diff --git a/.env.example b/.env.example deleted file mode 100644 index c0660ea..0000000 --- a/.env.example +++ /dev/null @@ -1,65 +0,0 @@ -APP_NAME=Laravel -APP_ENV=local -APP_KEY= -APP_DEBUG=true -APP_URL=http://localhost - -APP_LOCALE=en -APP_FALLBACK_LOCALE=en -APP_FAKER_LOCALE=en_US - -APP_MAINTENANCE_DRIVER=file -# APP_MAINTENANCE_STORE=database - -# PHP_CLI_SERVER_WORKERS=4 - -BCRYPT_ROUNDS=12 - -LOG_CHANNEL=stack -LOG_STACK=single -LOG_DEPRECATIONS_CHANNEL=null -LOG_LEVEL=debug - -DB_CONNECTION=sqlite -# DB_HOST=127.0.0.1 -# DB_PORT=3306 -# DB_DATABASE=laravel -# DB_USERNAME=root -# DB_PASSWORD= - -SESSION_DRIVER=database -SESSION_LIFETIME=120 -SESSION_ENCRYPT=false -SESSION_PATH=/ -SESSION_DOMAIN=null - -BROADCAST_CONNECTION=log -FILESYSTEM_DISK=local -QUEUE_CONNECTION=database - -CACHE_STORE=database -# CACHE_PREFIX= - -MEMCACHED_HOST=127.0.0.1 - -REDIS_CLIENT=phpredis -REDIS_HOST=127.0.0.1 -REDIS_PASSWORD=null -REDIS_PORT=6379 - -MAIL_MAILER=log -MAIL_SCHEME=null -MAIL_HOST=127.0.0.1 -MAIL_PORT=2525 -MAIL_USERNAME=null -MAIL_PASSWORD=null -MAIL_FROM_ADDRESS="hello@example.com" -MAIL_FROM_NAME="${APP_NAME}" - -AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= -AWS_DEFAULT_REGION=us-east-1 -AWS_BUCKET= -AWS_USE_PATH_STYLE_ENDPOINT=false - -VITE_APP_NAME="${APP_NAME}" diff --git a/README.md b/README.md index f24f872..862040c 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,13 @@ A modular monolith framework for Laravel with event-driven architecture, lazy mo ## Documentation -📚 **[Read the full documentation →](https://host-uk.github.io/core-php/)** +📚 **[Read the full documentation →](https://core.help/)** -- [Getting Started](https://host-uk.github.io/core-php/guide/getting-started) -- [Installation Guide](https://host-uk.github.io/core-php/guide/installation) -- [Architecture Overview](https://host-uk.github.io/core-php/architecture/lifecycle-events) -- [API Reference](https://host-uk.github.io/core-php/packages/api) -- [Security Guide](https://host-uk.github.io/core-php/security/overview) +- [Getting Started](https://core.help/guide/getting-started) +- [Installation Guide](https://core.help/guide/installation) +- [Architecture Overview](https://core.help/architecture/lifecycle-events) +- [API Reference](https://core.help/packages/api) +- [Security Guide](https://core.help/security/overview) ## Features diff --git a/TODO.md b/TODO.md index b1602a1..81ffb47 100644 --- a/TODO.md +++ b/TODO.md @@ -8,7 +8,8 @@ No pending tasks. For completed features and implementation details, see each package's changelog: -- `packages/core-php/changelog/` -- `packages/core-admin/changelog/` -- `packages/core-api/changelog/` -- `packages/core-mcp/changelog/` +- `changelog/` (this repo) +- [core-admin changelog](https://github.com/host-uk/core-admin) +- [core-api changelog](https://github.com/host-uk/core-api) +- [core-mcp changelog](https://github.com/host-uk/core-mcp) +- [core-tenant changelog](https://github.com/host-uk/core-tenant) diff --git a/app/Website/Demo/Boot.php b/app/Website/Demo/Boot.php deleted file mode 100644 index 78fe142..0000000 --- a/app/Website/Demo/Boot.php +++ /dev/null @@ -1,96 +0,0 @@ - - */ - public static array $domains = [ - '/^core\.(test|localhost)$/', - ]; - - /** - * Events this module listens to for lazy loading. - * - * @var array - */ - public static array $listens = [ - DomainResolving::class => 'onDomainResolving', - WebRoutesRegistering::class => 'onWebRoutes', - ]; - - /** - * Handle domain resolution - register if we match. - */ - public function onDomainResolving(DomainResolving $event): void - { - foreach (static::$domains as $pattern) { - if ($event->matches($pattern)) { - $event->register(static::class); - - return; - } - } - } - - public function register(): void - { - // - } - - /** - * Get domains for this website. - * - * @return array - */ - protected function domains(): array - { - return app(DomainResolver::class)->domainsFor(self::class); - } - - /** - * Register public web routes. - */ - public function onWebRoutes(WebRoutesRegistering $event): void - { - $event->views('demo', __DIR__.'/View/Blade'); - - // Register routes for all configured domains - $domains = $this->domains(); - - if (empty($domains)) { - // No domain mapping - register globally (for demo/dev) - $event->routes(fn () => Route::middleware('web') - ->group(__DIR__.'/Routes/web.php')); - } else { - foreach ($domains as $domain) { - $event->routes(fn () => Route::middleware('web') - ->domain($domain) - ->group(__DIR__.'/Routes/web.php')); - } - } - - // Livewire components - names must match Livewire's auto-discovery from namespace - $event->livewire('website.demo.view.modal.landing', View\Modal\Landing::class); - $event->livewire('website.demo.view.modal.login', View\Modal\Login::class); - $event->livewire('website.demo.view.modal.install', View\Modal\Install::class); - } -} diff --git a/app/Website/Demo/Middleware/EnsureInstalled.php b/app/Website/Demo/Middleware/EnsureInstalled.php deleted file mode 100644 index 05710de..0000000 --- a/app/Website/Demo/Middleware/EnsureInstalled.php +++ /dev/null @@ -1,70 +0,0 @@ -shouldSkip($request)) { - return $next($request); - } - - // Check if app needs installation - if ($this->needsInstallation()) { - return redirect()->route('install'); - } - - return $next($request); - } - - protected function shouldSkip(Request $request): bool - { - foreach ($this->except as $pattern) { - if ($request->is($pattern)) { - return true; - } - } - - return false; - } - - protected function needsInstallation(): bool - { - try { - // Check if users table exists and has at least one user - if (! Schema::hasTable('users')) { - return true; - } - - // Check if any users exist - return \DB::table('users')->count() === 0; - } catch (\Exception $e) { - // Database connection failed - needs installation - return true; - } - } -} diff --git a/app/Website/Demo/Routes/web.php b/app/Website/Demo/Routes/web.php deleted file mode 100644 index 3dd128b..0000000 --- a/app/Website/Demo/Routes/web.php +++ /dev/null @@ -1,38 +0,0 @@ -name('install'); - -// Routes that require installation -Route::middleware(EnsureInstalled::class)->group(function () { - Route::get('/', Landing::class)->name('home'); - - // Authentication routes - Route::get('/login', Login::class) - ->middleware('guest') - ->name('login'); - - Route::match(['get', 'post'], '/logout', function () { - Auth::logout(); - - request()->session()->invalidate(); - request()->session()->regenerateToken(); - - return redirect('/'); - })->middleware('auth')->name('logout'); -}); diff --git a/app/Website/Demo/View/Blade/layouts/app.blade.php b/app/Website/Demo/View/Blade/layouts/app.blade.php deleted file mode 100644 index c583a41..0000000 --- a/app/Website/Demo/View/Blade/layouts/app.blade.php +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - {{ $title ?? config('app.name', 'Core PHP') }} - - @vite(['resources/css/app.css', 'resources/js/app.js']) - @fluxAppearance - @livewireStyles - - - -
-
-
-
- -
- {{ $slot }} -
- - @fluxScripts - @livewireScripts - - diff --git a/app/Website/Demo/View/Blade/web/install.blade.php b/app/Website/Demo/View/Blade/web/install.blade.php deleted file mode 100644 index 04500e6..0000000 --- a/app/Website/Demo/View/Blade/web/install.blade.php +++ /dev/null @@ -1,246 +0,0 @@ -
-
- {{-- Header --}} -
-

Install {{ config('app.name', 'Core PHP') }}

-

Let's get your application set up

-
- - {{-- Progress Steps --}} -
- @foreach ([1 => 'Requirements', 2 => 'Admin User', 3 => 'Complete'] as $num => $label) -
-
-
$step >= $num, - 'bg-zinc-700 text-zinc-400' => $step < $num, - ])> - @if ($step > $num) - - - - @else - {{ $num }} - @endif -
- {{ $label }} -
- @if ($num < 3) -
$step > $num, - 'bg-zinc-700' => $step <= $num, - ])>
- @endif -
- @endforeach -
- - {{-- Error/Success Messages --}} - @if ($error) -
- {{ $error }} -
- @endif - - @if ($success) -
- {{ $success }} -
- @endif - - {{-- Step 1: Requirements --}} - @if ($step === 1) -
-

System Requirements

- -
- @foreach ($checks as $key => $check) -
-
- @if ($check['passed']) -
- - - -
- @else -
- - - -
- @endif -
-
{{ $check['label'] }}
-
{{ $check['description'] }}
-
-
- - {{ $check['value'] }} - -
- @endforeach -
- - @if (!$checks['migrations']['passed']) - - @endif - -
- -
-
- @endif - - {{-- Step 2: Create Admin User --}} - @if ($step === 2) -
-

Create Admin Account

- -
- - - @error('name') -

{{ $message }}

- @enderror -
- -
- - - @error('email') -

{{ $message }}

- @enderror -
- -
- - - @error('password') -

{{ $message }}

- @enderror -
- -
- - -
- - - -
- - -
-
- @endif - - {{-- Step 3: Complete --}} - @if ($step === 3) -
-
- - - -
- -

Installation Complete!

-

- {{ config('app.name', 'Core PHP') }} is ready to use. -

- -
-

Your credentials:

-
-
- Email: - {{ $email }} -
- @if ($createDemo) -
- Demo account: -
-
- Email: - demo@example.com -
-
- Password: - password -
- @endif -
-
- - -
- @endif -
-
diff --git a/app/Website/Demo/View/Blade/web/landing.blade.php b/app/Website/Demo/View/Blade/web/landing.blade.php deleted file mode 100644 index 3316731..0000000 --- a/app/Website/Demo/View/Blade/web/landing.blade.php +++ /dev/null @@ -1,56 +0,0 @@ -
- {{-- Hero Section --}} -
-

- {{ config('app.name', 'Core PHP') }} -

-

- A modular monolith framework for Laravel. - Build SaaS applications with event-driven architecture. -

- -
- - {{-- Features Grid --}} -
-
-
- - - -
-

Modular Architecture

-

Event-driven modules that load lazily. Only what you need, when you need it.

-
- -
-
- - - -
-

Multi-Website

-

Domain-scoped website modules. Each site isolated, all in one codebase.

-
- -
-
- - - -
-

Flux UI Ready

-

Built for Livewire 4 and Flux UI. Modern, composable components.

-
-
-
diff --git a/app/Website/Demo/View/Blade/web/login.blade.php b/app/Website/Demo/View/Blade/web/login.blade.php deleted file mode 100644 index a681b78..0000000 --- a/app/Website/Demo/View/Blade/web/login.blade.php +++ /dev/null @@ -1,43 +0,0 @@ -
-
- {{-- Header --}} -
- Sign in to {{ config('app.name', 'Core PHP') }} - Enter your credentials to continue -
- - {{-- Login Form --}} -
- {{-- Email --}} - - - {{-- Password --}} - - - {{-- Remember Me --}} - - - {{-- Submit --}} - - Sign in - - - - {{-- Back to home --}} - - ← Back to home - -
-
diff --git a/app/Website/Demo/View/Modal/Install.php b/app/Website/Demo/View/Modal/Install.php deleted file mode 100644 index db56011..0000000 --- a/app/Website/Demo/View/Modal/Install.php +++ /dev/null @@ -1,216 +0,0 @@ -runChecks(); - - // If already installed, redirect - if ($this->isInstalled()) { - $this->redirect('/', navigate: true); - } - } - - public function runChecks(): void - { - $this->checks = [ - 'php' => [ - 'label' => 'PHP Version', - 'description' => 'PHP 8.2 or higher required', - 'passed' => version_compare(PHP_VERSION, '8.2.0', '>='), - 'value' => PHP_VERSION, - ], - 'database' => [ - 'label' => 'Database Connection', - 'description' => 'MySQL/MariaDB/SQLite connection', - 'passed' => $this->checkDatabase(), - 'value' => $this->getDatabaseInfo(), - ], - 'migrations' => [ - 'label' => 'Database Tables', - 'description' => 'Core tables created', - 'passed' => $this->checkMigrations(), - 'value' => $this->checkMigrations() ? 'Ready' : 'Pending', - ], - 'storage' => [ - 'label' => 'Storage Writable', - 'description' => 'storage/ directory is writable', - 'passed' => is_writable(storage_path()), - 'value' => is_writable(storage_path()) ? 'Writable' : 'Not writable', - ], - 'cache' => [ - 'label' => 'Cache Writable', - 'description' => 'bootstrap/cache/ is writable', - 'passed' => is_writable(base_path('bootstrap/cache')), - 'value' => is_writable(base_path('bootstrap/cache')) ? 'Writable' : 'Not writable', - ], - ]; - } - - protected function checkDatabase(): bool - { - try { - DB::connection()->getPdo(); - - return true; - } catch (\Exception $e) { - return false; - } - } - - protected function getDatabaseInfo(): string - { - try { - $driver = config('database.default'); - $database = config("database.connections.{$driver}.database"); - - return ucfirst($driver).': '.$database; - } catch (\Exception $e) { - return 'Not configured'; - } - } - - protected function checkMigrations(): bool - { - try { - return Schema::hasTable('users'); - } catch (\Exception $e) { - return false; - } - } - - protected function isInstalled(): bool - { - try { - return Schema::hasTable('users') && DB::table('users')->count() > 0; - } catch (\Exception $e) { - return false; - } - } - - public function runMigrations(): void - { - $this->error = null; - - try { - Artisan::call('migrate', ['--force' => true]); - $this->runChecks(); - $this->success = 'Migrations completed successfully!'; - } catch (\Exception $e) { - $this->error = 'Migration failed: '.$e->getMessage(); - } - } - - public function nextStep(): void - { - $this->error = null; - $this->success = null; - - if ($this->step === 1) { - // Validate all checks pass - $allPassed = collect($this->checks)->every(fn ($check) => $check['passed']); - - if (! $allPassed) { - $this->error = 'Please resolve all requirements before continuing.'; - - return; - } - } - - $this->step++; - } - - public function previousStep(): void - { - $this->error = null; - $this->success = null; - $this->step = max(1, $this->step - 1); - } - - public function createUser(): void - { - $this->error = null; - - $this->validate([ - 'name' => ['required', 'string', 'max:255'], - 'email' => ['required', 'email', 'max:255'], - 'password' => ['required', 'string', 'min:8', 'confirmed'], - ]); - - try { - // Create admin user - $user = User::create([ - 'name' => $this->name, - 'email' => $this->email, - 'password' => Hash::make($this->password), - 'email_verified_at' => now(), - ]); - - // Create demo user if requested - if ($this->createDemo) { - User::create([ - 'name' => 'Demo User', - 'email' => 'demo@example.com', - 'password' => Hash::make('password'), - 'email_verified_at' => now(), - ]); - } - - // Log in as the new admin - Auth::login($user); - - $this->step = 3; - } catch (\Exception $e) { - $this->error = 'Failed to create user: '.$e->getMessage(); - } - } - - public function finish(): void - { - $this->redirect('/hub', navigate: true); - } - - #[Layout('demo::layouts.app', ['title' => 'Install'])] - public function render() - { - return view('demo::web.install'); - } -} diff --git a/app/Website/Demo/View/Modal/Landing.php b/app/Website/Demo/View/Modal/Landing.php deleted file mode 100644 index bfef088..0000000 --- a/app/Website/Demo/View/Modal/Landing.php +++ /dev/null @@ -1,23 +0,0 @@ -layout('demo::layouts.app', [ - 'title' => config('app.name', 'Core PHP'), - ]); - } -} diff --git a/app/Website/Demo/View/Modal/Login.php b/app/Website/Demo/View/Modal/Login.php deleted file mode 100644 index 48df4c0..0000000 --- a/app/Website/Demo/View/Modal/Login.php +++ /dev/null @@ -1,66 +0,0 @@ -validate([ - 'email' => ['required', 'email'], - 'password' => ['required', 'string'], - ]); - - $limiter = app(LoginRateLimiter::class); - - if ($limiter->tooManyAttempts(request())) { - throw ValidationException::withMessages([ - 'email' => __('auth.throttle', [ - 'seconds' => $limiter->availableIn(request()), - ]), - ]); - } - - if (! Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) { - $limiter->increment(request()); - - throw ValidationException::withMessages([ - 'email' => __('auth.failed'), - ]); - } - - $limiter->clear(request()); - - session()->regenerate(); - - $this->redirect('/hub', navigate: true); - } - - #[Layout('demo::layouts.app', ['title' => 'Sign In'])] - public function render() - { - return view('demo::web.login'); - } -} diff --git a/artisan b/artisan deleted file mode 100755 index a4f0ee0..0000000 --- a/artisan +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env php -handleCommand(new ArgvInput); - -exit($status); diff --git a/bootstrap/app.php b/bootstrap/app.php deleted file mode 100644 index d654276..0000000 --- a/bootstrap/app.php +++ /dev/null @@ -1,19 +0,0 @@ -withRouting( - web: __DIR__.'/../routes/web.php', - api: __DIR__.'/../routes/api.php', - commands: __DIR__.'/../routes/console.php', - health: '/up', - ) - ->withMiddleware(function (Middleware $middleware) { - // - }) - ->withExceptions(function (Exceptions $exceptions) { - // - })->create(); diff --git a/bootstrap/cache/.gitkeep b/bootstrap/cache/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/core-php/changelog/2026/jan/code-review.md b/changelog/2026/jan/code-review.md similarity index 100% rename from packages/core-php/changelog/2026/jan/code-review.md rename to changelog/2026/jan/code-review.md diff --git a/packages/core-php/changelog/2026/jan/features.md b/changelog/2026/jan/features.md similarity index 100% rename from packages/core-php/changelog/2026/jan/features.md rename to changelog/2026/jan/features.md diff --git a/composer.json b/composer.json index 97dcaed..490c764 100644 --- a/composer.json +++ b/composer.json @@ -1,108 +1,62 @@ { - "name": "host-uk/core-app", - "type": "project", - "description": "Core PHP Framework - Demo Application", - "keywords": ["laravel", "modular", "monolith", "framework"], + "name": "host-uk/core", + "description": "Modular monolith framework for Laravel - event-driven architecture with lazy module loading", + "keywords": ["laravel", "modular", "monolith", "framework", "events", "modules"], "license": "EUPL-1.2", + "authors": [ + { + "name": "Host UK", + "email": "support@host.uk.com" + } + ], "require": { "php": "^8.2", - "host-uk/core": "@dev", - "host-uk/core-admin": "@dev", - "host-uk/core-api": "@dev", - "host-uk/core-mcp": "@dev", - "laravel/framework": "^12.0", - "laravel/pennant": "^1.18", - "laravel/tinker": "^2.10.1", - "livewire/flux": "^2.0", - "livewire/flux-pro": "^2.10", - "livewire/livewire": "^3.0" + "laravel/framework": "^11.0|^12.0", + "laravel/pennant": "^1.0", + "livewire/livewire": "^3.0|^4.0" }, "require-dev": { "fakerphp/faker": "^1.23", - "laravel/pail": "^1.2.2", "laravel/pint": "^1.18", - "laravel/sail": "^1.41", "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.6", - "orchestra/testbench": "*", - "phpunit/phpunit": "^11.5.3", + "orchestra/testbench": "^9.0|^10.0", + "phpunit/phpunit": "^11.5", "spatie/laravel-activitylog": "^4.8" }, + "suggest": { + "spatie/laravel-activitylog": "Required for activity logging features (^4.0)" + }, "autoload": { "psr-4": { - "App\\": "app/", - "Website\\": "app/Website/", - "Database\\Factories\\": "database/factories/", - "Database\\Seeders\\": "database/seeders/" - } + "Core\\": "src/Core/", + "Core\\Website\\": "src/Website/", + "Core\\Mod\\": "src/Mod/", + "Core\\Plug\\": "src/Plug/" + }, + "files": [ + "src/Core/Media/Thumbnail/helpers.php" + ] }, "autoload-dev": { "psr-4": { - "Tests\\": "tests/", - "Core\\Tests\\": "packages/core-php/tests/", - "Core\\Mod\\Mcp\\Tests\\": "packages/core-mcp/tests/", - "Core\\Mod\\Tenant\\Tests\\": "packages/core-php/src/Mod/Tenant/Tests/", - "Mod\\": "packages/core-php/tests/Fixtures/Mod/", - "Plug\\": "packages/core-php/tests/Fixtures/Plug/", - "Website\\": "packages/core-php/tests/Fixtures/Website/" + "Core\\Tests\\": "tests/", + "Mod\\": "tests/Fixtures/Mod/", + "Plug\\": "tests/Fixtures/Plug/", + "Website\\": "tests/Fixtures/Website/" } }, - "repositories": [ - { - "name": "flux-pro", - "type": "composer", - "url": "https://composer.fluxui.dev" - }, - { - "type": "path", - "url": "packages/core-php", - "options": { - "symlink": true - } - }, - { - "type": "path", - "url": "packages/core-admin", - "options": { - "symlink": true - } - }, - { - "type": "path", - "url": "packages/core-api", - "options": { - "symlink": true - } - }, - { - "type": "path", - "url": "packages/core-mcp", - "options": { - "symlink": true - } - } - ], "scripts": { - "post-autoload-dump": [ - "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", - "@php artisan package:discover --ansi" - ], - "post-update-cmd": [ - "@php artisan vendor:publish --tag=laravel-assets --ansi --force" - ], - "post-root-package-install": [ - "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" - ], - "post-create-project-cmd": [ - "@php artisan key:generate --ansi", - "@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"", - "@php artisan migrate --graceful --ansi" - ], - "test": "vendor/bin/phpunit" + "test": "vendor/bin/phpunit", + "pint": "vendor/bin/pint" }, "extra": { "laravel": { - "dont-discover": [] + "providers": [ + "Core\\LifecycleEventProvider", + "Core\\Lang\\LangServiceProvider", + "Core\\Bouncer\\Gate\\Boot" + ] } }, "config": { diff --git a/config/app.php b/config/app.php deleted file mode 100644 index e13ef11..0000000 --- a/config/app.php +++ /dev/null @@ -1,126 +0,0 @@ - env('APP_NAME', 'Laravel'), - - /* - |-------------------------------------------------------------------------- - | Application Environment - |-------------------------------------------------------------------------- - | - | This value determines the "environment" your application is currently - | running in. This may determine how you prefer to configure various - | services the application utilizes. Set this in your ".env" file. - | - */ - - 'env' => env('APP_ENV', 'production'), - - /* - |-------------------------------------------------------------------------- - | Application Debug Mode - |-------------------------------------------------------------------------- - | - | When your application is in debug mode, detailed error messages with - | stack traces will be shown on every error that occurs within your - | application. If disabled, a simple generic error page is shown. - | - */ - - 'debug' => (bool) env('APP_DEBUG', false), - - /* - |-------------------------------------------------------------------------- - | Application URL - |-------------------------------------------------------------------------- - | - | This URL is used by the console to properly generate URLs when using - | the Artisan command line tool. You should set this to the root of - | the application so that it's available within Artisan commands. - | - */ - - 'url' => env('APP_URL', 'http://localhost'), - - /* - |-------------------------------------------------------------------------- - | Application Timezone - |-------------------------------------------------------------------------- - | - | Here you may specify the default timezone for your application, which - | will be used by the PHP date and date-time functions. The timezone - | is set to "UTC" by default as it is suitable for most use cases. - | - */ - - 'timezone' => 'UTC', - - /* - |-------------------------------------------------------------------------- - | Application Locale Configuration - |-------------------------------------------------------------------------- - | - | The application locale determines the default locale that will be used - | by Laravel's translation / localization methods. This option can be - | set to any locale for which you plan to have translation strings. - | - */ - - 'locale' => env('APP_LOCALE', 'en_GB'), - - 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en_GB'), - - 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), - - /* - |-------------------------------------------------------------------------- - | Encryption Key - |-------------------------------------------------------------------------- - | - | This key is utilized by Laravel's encryption services and should be set - | to a random, 32 character string to ensure that all encrypted values - | are secure. You should do this prior to deploying the application. - | - */ - - 'cipher' => 'AES-256-CBC', - - 'key' => env('APP_KEY'), - - 'previous_keys' => [ - ...array_filter( - explode(',', (string) env('APP_PREVIOUS_KEYS', '')) - ), - ], - - /* - |-------------------------------------------------------------------------- - | Maintenance Mode Driver - |-------------------------------------------------------------------------- - | - | These configuration options determine the driver used to determine and - | manage Laravel's "maintenance mode" status. The "cache" driver will - | allow maintenance mode to be controlled across multiple machines. - | - | Supported drivers: "file", "cache" - | - */ - - 'maintenance' => [ - 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), - 'store' => env('APP_MAINTENANCE_STORE', 'database'), - ], - -]; diff --git a/config/auth.php b/config/auth.php deleted file mode 100644 index 7d466b8..0000000 --- a/config/auth.php +++ /dev/null @@ -1,115 +0,0 @@ - [ - 'guard' => env('AUTH_GUARD', 'web'), - 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), - ], - - /* - |-------------------------------------------------------------------------- - | Authentication Guards - |-------------------------------------------------------------------------- - | - | Next, you may define every authentication guard for your application. - | Of course, a great default configuration has been defined for you - | which utilizes session storage plus the Eloquent user provider. - | - | All authentication guards have a user provider, which defines how the - | users are actually retrieved out of your database or other storage - | system used by the application. Typically, Eloquent is utilized. - | - | Supported: "session" - | - */ - - 'guards' => [ - 'web' => [ - 'driver' => 'session', - 'provider' => 'users', - ], - ], - - /* - |-------------------------------------------------------------------------- - | User Providers - |-------------------------------------------------------------------------- - | - | All authentication guards have a user provider, which defines how the - | users are actually retrieved out of your database or other storage - | system used by the application. Typically, Eloquent is utilized. - | - | If you have multiple user tables or models you may configure multiple - | providers to represent the model / table. These providers may then - | be assigned to any extra authentication guards you have defined. - | - | Supported: "database", "eloquent" - | - */ - - 'providers' => [ - 'users' => [ - 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', Core\Mod\Tenant\Models\User::class), - ], - - // 'users' => [ - // 'driver' => 'database', - // 'table' => 'users', - // ], - ], - - /* - |-------------------------------------------------------------------------- - | Resetting Passwords - |-------------------------------------------------------------------------- - | - | These configuration options specify the behavior of Laravel's password - | reset functionality, including the table utilized for token storage - | and the user provider that is invoked to actually retrieve users. - | - | The expiry time is the number of minutes that each reset token will be - | considered valid. This security feature keeps tokens short-lived so - | they have less time to be guessed. You may change this as needed. - | - | The throttle setting is the number of seconds a user must wait before - | generating more password reset tokens. This prevents the user from - | quickly generating a very large amount of password reset tokens. - | - */ - - 'passwords' => [ - 'users' => [ - 'provider' => 'users', - 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), - 'expire' => 60, - 'throttle' => 60, - ], - ], - - /* - |-------------------------------------------------------------------------- - | Password Confirmation Timeout - |-------------------------------------------------------------------------- - | - | Here you may define the number of seconds before a password confirmation - | window expires and users are asked to re-enter their password via the - | confirmation screen. By default, the timeout lasts for three hours. - | - */ - - 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), - -]; diff --git a/config/cache.php b/config/cache.php deleted file mode 100644 index b32aead..0000000 --- a/config/cache.php +++ /dev/null @@ -1,117 +0,0 @@ - env('CACHE_STORE', 'database'), - - /* - |-------------------------------------------------------------------------- - | Cache Stores - |-------------------------------------------------------------------------- - | - | Here you may define all of the cache "stores" for your application as - | well as their drivers. You may even define multiple stores for the - | same cache driver to group types of items stored in your caches. - | - | Supported drivers: "array", "database", "file", "memcached", - | "redis", "dynamodb", "octane", - | "failover", "null" - | - */ - - 'stores' => [ - - 'array' => [ - 'driver' => 'array', - 'serialize' => false, - ], - - 'database' => [ - 'driver' => 'database', - 'connection' => env('DB_CACHE_CONNECTION'), - 'table' => env('DB_CACHE_TABLE', 'cache'), - 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), - 'lock_table' => env('DB_CACHE_LOCK_TABLE'), - ], - - 'file' => [ - 'driver' => 'file', - 'path' => storage_path('framework/cache/data'), - 'lock_path' => storage_path('framework/cache/data'), - ], - - 'memcached' => [ - 'driver' => 'memcached', - 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), - 'sasl' => [ - env('MEMCACHED_USERNAME'), - env('MEMCACHED_PASSWORD'), - ], - 'options' => [ - // Memcached::OPT_CONNECT_TIMEOUT => 2000, - ], - 'servers' => [ - [ - 'host' => env('MEMCACHED_HOST', '127.0.0.1'), - 'port' => env('MEMCACHED_PORT', 11211), - 'weight' => 100, - ], - ], - ], - - 'redis' => [ - 'driver' => 'redis', - 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), - 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), - ], - - 'dynamodb' => [ - 'driver' => 'dynamodb', - 'key' => env('AWS_ACCESS_KEY_ID'), - 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), - 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), - 'endpoint' => env('DYNAMODB_ENDPOINT'), - ], - - 'octane' => [ - 'driver' => 'octane', - ], - - 'failover' => [ - 'driver' => 'failover', - 'stores' => [ - 'database', - 'array', - ], - ], - - ], - - /* - |-------------------------------------------------------------------------- - | Cache Key Prefix - |-------------------------------------------------------------------------- - | - | When utilizing the APC, database, memcached, Redis, and DynamoDB cache - | stores, there might be other applications using the same cache. For - | that reason, you may prefix every cache key to avoid collisions. - | - */ - - 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), - -]; diff --git a/config/core.php b/config/core.php index aa9141e..bf5f195 100644 --- a/config/core.php +++ b/config/core.php @@ -43,10 +43,8 @@ return [ */ 'module_paths' => [ - // Application modules (user-created) - app_path('Core'), - app_path('Mod'), - app_path('Website'), + // app_path('Core'), + // app_path('Mod'), ], /* @@ -96,4 +94,362 @@ return [ 'default_style' => 'solid', ], + /* + |-------------------------------------------------------------------------- + | Search Configuration + |-------------------------------------------------------------------------- + | + | Configure the unified search feature including searchable API endpoints. + | Add your application's API endpoints here to include them in search results. + | + */ + + 'search' => [ + 'api_endpoints' => [ + // Example endpoints - override in your application's config + // ['method' => 'GET', 'path' => '/api/v1/users', 'description' => 'List users'], + // ['method' => 'POST', 'path' => '/api/v1/users', 'description' => 'Create user'], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Email Shield Configuration + |-------------------------------------------------------------------------- + | + | Configure the Email Shield validation and statistics module. + | Statistics track daily email validation counts for monitoring and + | analysis. Old records are automatically pruned based on retention period. + | + | Schedule the prune command in your app/Console/Kernel.php: + | $schedule->command('email-shield:prune')->daily(); + | + */ + + 'email_shield' => [ + // Number of days to retain email shield statistics records. + // Records older than this will be deleted by the prune command. + // Set to 0 to disable automatic pruning. + 'retention_days' => env('CORE_EMAIL_SHIELD_RETENTION_DAYS', 90), + ], + + /* + |-------------------------------------------------------------------------- + | Admin Menu Configuration + |-------------------------------------------------------------------------- + | + | Configure the admin menu caching behaviour. Menu items are cached per + | user/workspace combination to improve performance on repeated requests. + | + */ + + 'admin_menu' => [ + // Whether to enable caching for static menu items. + // Set to false during development for instant menu updates. + 'cache_enabled' => env('CORE_ADMIN_MENU_CACHE', true), + + // Cache TTL in seconds (default: 5 minutes). + // Lower values mean more frequent cache misses but fresher menus. + 'cache_ttl' => env('CORE_ADMIN_MENU_CACHE_TTL', 300), + ], + + /* + |-------------------------------------------------------------------------- + | Storage Resilience Configuration + |-------------------------------------------------------------------------- + | + | Configure how the application handles Redis failures. When Redis becomes + | unavailable, the system can either silently fall back to database storage + | or throw an exception. + | + */ + + 'storage' => [ + // Whether to silently fall back to database when Redis fails. + // Set to false to throw exceptions on Redis failure. + 'silent_fallback' => env('CORE_STORAGE_SILENT_FALLBACK', true), + + // Log level for fallback events: 'debug', 'info', 'notice', 'warning', 'error', 'critical' + 'fallback_log_level' => env('CORE_STORAGE_FALLBACK_LOG_LEVEL', 'warning'), + + // Whether to dispatch RedisFallbackActivated events for monitoring/alerting + 'dispatch_fallback_events' => env('CORE_STORAGE_DISPATCH_EVENTS', true), + + /* + |---------------------------------------------------------------------- + | Circuit Breaker Configuration + |---------------------------------------------------------------------- + | + | The circuit breaker prevents cascading failures when Redis becomes + | unavailable. When failures exceed the threshold, the circuit opens + | and requests go directly to the fallback, avoiding repeated + | connection attempts that slow down the application. + | + */ + + 'circuit_breaker' => [ + // Enable/disable the circuit breaker + 'enabled' => env('CORE_STORAGE_CIRCUIT_BREAKER_ENABLED', true), + + // Number of failures before opening the circuit + 'failure_threshold' => env('CORE_STORAGE_CIRCUIT_BREAKER_FAILURES', 5), + + // Seconds to wait before attempting recovery (half-open state) + 'recovery_timeout' => env('CORE_STORAGE_CIRCUIT_BREAKER_RECOVERY', 30), + + // Number of successful operations to close the circuit + 'success_threshold' => env('CORE_STORAGE_CIRCUIT_BREAKER_SUCCESSES', 2), + + // Cache driver for storing circuit breaker state (use non-Redis driver) + 'state_driver' => env('CORE_STORAGE_CIRCUIT_BREAKER_DRIVER', 'file'), + ], + + /* + |---------------------------------------------------------------------- + | Storage Metrics Configuration + |---------------------------------------------------------------------- + | + | Storage metrics collect information about cache operations including + | hit/miss rates, latencies, and fallback activations. Use these + | metrics for monitoring cache health and performance tuning. + | + */ + + 'metrics' => [ + // Enable/disable metrics collection + 'enabled' => env('CORE_STORAGE_METRICS_ENABLED', true), + + // Maximum latency samples to keep per driver (for percentile calculations) + 'max_samples' => env('CORE_STORAGE_METRICS_MAX_SAMPLES', 1000), + + // Whether to log metrics events + 'log_enabled' => env('CORE_STORAGE_METRICS_LOG', true), + ], + ], + + /* + |-------------------------------------------------------------------------- + | Service Configuration + |-------------------------------------------------------------------------- + | + | Configure service discovery and dependency resolution. Services are + | discovered by scanning module paths for classes implementing + | ServiceDefinition. + | + */ + + 'services' => [ + // Whether to cache service discovery results + 'cache_discovery' => env('CORE_SERVICES_CACHE_DISCOVERY', true), + ], + + /* + |-------------------------------------------------------------------------- + | Language & Translation Configuration + |-------------------------------------------------------------------------- + | + | Configure translation fallback chains and missing key validation. + | The fallback chain allows regional locales to fall back to their base + | locale before using the application's fallback locale. + | + | Example chain: en_GB -> en -> fallback_locale (from config/app.php) + | + */ + + 'lang' => [ + // Enable locale chain fallback (e.g., en_GB -> en -> fallback) + // When true, regional locales like 'en_GB' will first try 'en' before + // falling back to the application's fallback_locale. + 'fallback_chain' => env('CORE_LANG_FALLBACK_CHAIN', true), + + // Warn about missing translation keys in development environments. + // Set to true to always enable, false to always disable, or leave + // null to auto-enable in local/development/testing environments. + 'validate_keys' => env('CORE_LANG_VALIDATE_KEYS'), + + // Log missing translation keys when validation is enabled. + 'log_missing_keys' => env('CORE_LANG_LOG_MISSING_KEYS', true), + + // Log level for missing translation key warnings. + // Options: 'debug', 'info', 'notice', 'warning', 'error', 'critical' + 'missing_key_log_level' => env('CORE_LANG_MISSING_KEY_LOG_LEVEL', 'debug'), + + // Enable ICU message format support. + // Requires the PHP intl extension for full functionality. + // When disabled, ICU patterns will use basic placeholder replacement. + 'icu_enabled' => env('CORE_LANG_ICU_ENABLED', true), + ], + + /* + |-------------------------------------------------------------------------- + | Bouncer Action Gate Configuration + |-------------------------------------------------------------------------- + | + | Configure the action whitelisting system. Philosophy: "If it wasn't + | trained, it doesn't exist." Every controller action must be explicitly + | permitted. Unknown actions are blocked (production) or prompt for + | approval (training mode). + | + */ + + 'bouncer' => [ + // Enable training mode to allow approving new actions interactively. + // In production, this should be false to enforce strict whitelisting. + // In development/staging, enable to train the system with valid actions. + 'training_mode' => env('CORE_BOUNCER_TRAINING_MODE', false), + + // Whether to enable the action gate middleware. + // Set to false to completely disable action whitelisting. + 'enabled' => env('CORE_BOUNCER_ENABLED', true), + + // Guards that should have action gating applied. + // Actions on routes using these middleware groups will be checked. + 'guarded_middleware' => ['web', 'admin', 'api', 'client'], + + // Routes matching these patterns will bypass the action gate. + // Use for login pages, public assets, health checks, etc. + 'bypass_patterns' => [ + 'login', + 'logout', + 'register', + 'password/*', + 'sanctum/*', + 'livewire/*', + '_debugbar/*', + 'horizon/*', + 'telescope/*', + ], + + // Number of days to retain action request logs. + // Set to 0 to disable automatic pruning. + 'log_retention_days' => env('CORE_BOUNCER_LOG_RETENTION', 30), + + // Whether to log allowed requests (can generate many records). + // Recommended: false in production, true during training. + 'log_allowed_requests' => env('CORE_BOUNCER_LOG_ALLOWED', false), + + /* + |---------------------------------------------------------------------- + | Honeypot Configuration + |---------------------------------------------------------------------- + | + | Configure the honeypot system that traps bots ignoring robots.txt. + | Paths listed in robots.txt as disallowed are monitored; any request + | indicates a bot that doesn't respect robots.txt. + | + */ + + 'honeypot' => [ + // Whether to auto-block IPs that hit critical honeypot paths. + // When enabled, IPs hitting paths like /admin or /.env are blocked. + // Set to false to require manual review of all honeypot hits. + 'auto_block_critical' => env('CORE_BOUNCER_HONEYPOT_AUTO_BLOCK', true), + + // Rate limiting for honeypot logging to prevent DoS via log flooding. + // Maximum number of log entries per IP within the time window. + 'rate_limit_max' => env('CORE_BOUNCER_HONEYPOT_RATE_LIMIT_MAX', 10), + + // Rate limit time window in seconds (default: 60 = 1 minute). + 'rate_limit_window' => env('CORE_BOUNCER_HONEYPOT_RATE_LIMIT_WINDOW', 60), + + // Severity levels for honeypot paths. + // 'critical' - Active probing (admin panels, config files). + // 'warning' - General robots.txt violation. + 'severity_levels' => [ + 'critical' => env('CORE_BOUNCER_HONEYPOT_SEVERITY_CRITICAL', 'critical'), + 'warning' => env('CORE_BOUNCER_HONEYPOT_SEVERITY_WARNING', 'warning'), + ], + + // Paths that indicate critical/malicious probing. + // Requests to these paths result in 'critical' severity. + // Supports prefix matching (e.g., 'admin' matches '/admin', '/admin/login'). + 'critical_paths' => [ + 'admin', + 'wp-admin', + 'wp-login.php', + 'administrator', + 'phpmyadmin', + '.env', + '.git', + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Workspace Cache Configuration + |-------------------------------------------------------------------------- + | + | Configure workspace-scoped caching for multi-tenant resources. + | Models using the BelongsToWorkspace trait can cache their collections + | with automatic invalidation when records are created, updated, or deleted. + | + | The cache system supports both tagged cache stores (Redis, Memcached) + | and non-tagged stores (file, database, array). Tagged stores provide + | more efficient cache invalidation. + | + */ + + 'workspace_cache' => [ + // Whether to enable workspace-scoped caching. + // Set to false to completely disable caching (all queries hit the database). + 'enabled' => env('CORE_WORKSPACE_CACHE_ENABLED', true), + + // Default TTL in seconds for cached workspace queries. + // Individual queries can override this with their own TTL. + 'ttl' => env('CORE_WORKSPACE_CACHE_TTL', 300), + + // Cache key prefix to avoid collisions with other cache keys. + // Change this if you need to separate cache data between deployments. + 'prefix' => env('CORE_WORKSPACE_CACHE_PREFIX', 'workspace_cache'), + + // Whether to use cache tags if available. + // Tags provide more efficient cache invalidation (flush by workspace or model). + // Only works with tag-supporting stores (Redis, Memcached). + // Set to false to always use key-based cache management. + 'use_tags' => env('CORE_WORKSPACE_CACHE_USE_TAGS', true), + ], + + /* + |-------------------------------------------------------------------------- + | Activity Logging Configuration + |-------------------------------------------------------------------------- + | + | Configure activity logging for audit trails across modules. + | Uses spatie/laravel-activitylog under the hood with workspace-aware + | enhancements for multi-tenant environments. + | + | Models can use the Core\Activity\Concerns\LogsActivity trait to + | automatically log create, update, and delete operations. + | + */ + + 'activity' => [ + // Whether to enable activity logging globally. + // Set to false to completely disable activity logging. + 'enabled' => env('CORE_ACTIVITY_ENABLED', true), + + // The log name to use for activities. + // Different log names can be used to separate activities by context. + 'log_name' => env('CORE_ACTIVITY_LOG_NAME', 'default'), + + // Whether to include workspace_id in activity properties. + // Enable this in multi-tenant applications to scope activities per workspace. + 'include_workspace' => env('CORE_ACTIVITY_INCLUDE_WORKSPACE', true), + + // Default events to log when using the LogsActivity trait. + // Models can override this with the $activityLogEvents property. + 'default_events' => ['created', 'updated', 'deleted'], + + // Number of days to retain activity logs. + // Use the activity:prune command to clean up old logs. + // Set to 0 to disable automatic pruning. + 'retention_days' => env('CORE_ACTIVITY_RETENTION_DAYS', 90), + + // Custom Activity model class (optional). + // Set this to use a custom Activity model with additional scopes. + // Default: Core\Activity\Models\Activity::class + 'activity_model' => env('CORE_ACTIVITY_MODEL', \Core\Activity\Models\Activity::class), + ], + ]; diff --git a/config/database.php b/config/database.php deleted file mode 100644 index 033b966..0000000 --- a/config/database.php +++ /dev/null @@ -1,220 +0,0 @@ - env('DB_CONNECTION', 'sqlite'), - - /* - |-------------------------------------------------------------------------- - | Database Connections - |-------------------------------------------------------------------------- - | - | Below are all of the database connections defined for your application. - | An example configuration is provided for each database system which - | is supported by Laravel. You're free to add / remove connections. - | - */ - - 'connections' => [ - - 'sqlite' => [ - 'driver' => 'sqlite', - 'url' => env('DB_URL'), - 'database' => env('DB_DATABASE', database_path('database.sqlite')), - 'prefix' => '', - 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), - 'busy_timeout' => null, - 'journal_mode' => null, - 'synchronous' => null, - 'transaction_mode' => 'DEFERRED', - ], - - 'mysql' => [ - 'driver' => 'mysql', - 'url' => env('DB_URL'), - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '3306'), - 'database' => env('DB_DATABASE', 'laravel'), - 'username' => env('DB_USERNAME', 'root'), - 'password' => env('DB_PASSWORD', ''), - 'unix_socket' => env('DB_SOCKET', ''), - 'charset' => env('DB_CHARSET', 'utf8mb4'), - 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), - 'prefix' => '', - 'prefix_indexes' => true, - 'strict' => true, - 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), - ]) : [], - ], - - 'mariadb' => [ - 'driver' => 'mariadb', - 'url' => env('DB_URL'), - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '3306'), - 'database' => env('DB_DATABASE', 'laravel'), - 'username' => env('DB_USERNAME', 'root'), - 'password' => env('DB_PASSWORD', ''), - 'unix_socket' => env('DB_SOCKET', ''), - 'charset' => env('DB_CHARSET', 'utf8mb4'), - 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), - 'prefix' => '', - 'prefix_indexes' => true, - 'strict' => true, - 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), - ]) : [], - ], - - 'pgsql' => [ - 'driver' => 'pgsql', - 'url' => env('DB_URL'), - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '5432'), - 'database' => env('DB_DATABASE', 'laravel'), - 'username' => env('DB_USERNAME', 'root'), - 'password' => env('DB_PASSWORD', ''), - 'charset' => env('DB_CHARSET', 'utf8'), - 'prefix' => '', - 'prefix_indexes' => true, - 'search_path' => 'public', - 'sslmode' => env('DB_SSLMODE', 'prefer'), - ], - - 'sqlsrv' => [ - 'driver' => 'sqlsrv', - 'url' => env('DB_URL'), - 'host' => env('DB_HOST', 'localhost'), - 'port' => env('DB_PORT', '1433'), - 'database' => env('DB_DATABASE', 'laravel'), - 'username' => env('DB_USERNAME', 'root'), - 'password' => env('DB_PASSWORD', ''), - 'charset' => env('DB_CHARSET', 'utf8'), - 'prefix' => '', - 'prefix_indexes' => true, - // 'encrypt' => env('DB_ENCRYPT', 'yes'), - // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), - ], - - /* - |-------------------------------------------------------------------------- - | MCP Read-Only Connection - |-------------------------------------------------------------------------- - | - | This connection is used by the MCP QueryDatabase tool. It should be - | configured with a database user that has SELECT-only permissions. - | - | For MySQL, create a read-only user: - | CREATE USER 'mcp_readonly'@'localhost' IDENTIFIED BY 'password'; - | GRANT SELECT ON your_database.* TO 'mcp_readonly'@'localhost'; - | FLUSH PRIVILEGES; - | - | If MCP_DB_CONNECTION is not set, this falls back to the default connection. - | In production, always configure a dedicated read-only user. - | - */ - 'mcp_readonly' => [ - 'driver' => env('MCP_DB_DRIVER', env('DB_CONNECTION', 'mysql')), - 'url' => env('MCP_DB_URL'), - 'host' => env('MCP_DB_HOST', env('DB_HOST', '127.0.0.1')), - 'port' => env('MCP_DB_PORT', env('DB_PORT', '3306')), - 'database' => env('MCP_DB_DATABASE', env('DB_DATABASE', 'laravel')), - 'username' => env('MCP_DB_USERNAME', env('DB_USERNAME', 'root')), - 'password' => env('MCP_DB_PASSWORD', env('DB_PASSWORD', '')), - 'unix_socket' => env('MCP_DB_SOCKET', env('DB_SOCKET', '')), - 'charset' => env('DB_CHARSET', 'utf8mb4'), - 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), - 'prefix' => '', - 'prefix_indexes' => true, - 'strict' => true, - 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), - ]) : [], - ], - - ], - - /* - |-------------------------------------------------------------------------- - | Migration Repository Table - |-------------------------------------------------------------------------- - | - | This table keeps track of all the migrations that have already run for - | your application. Using this information, we can determine which of - | the migrations on disk haven't actually been run on the database. - | - */ - - 'migrations' => [ - 'table' => 'migrations', - 'update_date_on_publish' => true, - ], - - /* - |-------------------------------------------------------------------------- - | Redis Databases - |-------------------------------------------------------------------------- - | - | Redis is an open source, fast, and advanced key-value store that also - | provides a richer body of commands than a typical key-value system - | such as Memcached. You may define your connection settings here. - | - */ - - 'redis' => [ - - 'client' => env('REDIS_CLIENT', 'phpredis'), - - 'options' => [ - 'cluster' => env('REDIS_CLUSTER', 'redis'), - 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), - 'persistent' => env('REDIS_PERSISTENT', false), - ], - - 'default' => [ - 'url' => env('REDIS_URL'), - 'host' => env('REDIS_HOST', '127.0.0.1'), - 'username' => env('REDIS_USERNAME'), - 'password' => env('REDIS_PASSWORD'), - 'port' => env('REDIS_PORT', '6379'), - 'database' => env('REDIS_DB', '0'), - 'max_retries' => env('REDIS_MAX_RETRIES', 3), - 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), - 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), - 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), - ], - - 'cache' => [ - 'url' => env('REDIS_URL'), - 'host' => env('REDIS_HOST', '127.0.0.1'), - 'username' => env('REDIS_USERNAME'), - 'password' => env('REDIS_PASSWORD'), - 'port' => env('REDIS_PORT', '6379'), - 'database' => env('REDIS_CACHE_DB', '1'), - 'max_retries' => env('REDIS_MAX_RETRIES', 3), - 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), - 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), - 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), - ], - - ], - -]; diff --git a/config/filesystems.php b/config/filesystems.php deleted file mode 100644 index 37d8fca..0000000 --- a/config/filesystems.php +++ /dev/null @@ -1,80 +0,0 @@ - env('FILESYSTEM_DISK', 'local'), - - /* - |-------------------------------------------------------------------------- - | Filesystem Disks - |-------------------------------------------------------------------------- - | - | Below you may configure as many filesystem disks as necessary, and you - | may even configure multiple disks for the same driver. Examples for - | most supported storage drivers are configured here for reference. - | - | Supported drivers: "local", "ftp", "sftp", "s3" - | - */ - - 'disks' => [ - - 'local' => [ - 'driver' => 'local', - 'root' => storage_path('app/private'), - 'serve' => true, - 'throw' => false, - 'report' => false, - ], - - 'public' => [ - 'driver' => 'local', - 'root' => storage_path('app/public'), - 'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage', - 'visibility' => 'public', - 'throw' => false, - 'report' => false, - ], - - 's3' => [ - 'driver' => 's3', - 'key' => env('AWS_ACCESS_KEY_ID'), - 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'region' => env('AWS_DEFAULT_REGION'), - 'bucket' => env('AWS_BUCKET'), - 'url' => env('AWS_URL'), - 'endpoint' => env('AWS_ENDPOINT'), - 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), - 'throw' => false, - 'report' => false, - ], - - ], - - /* - |-------------------------------------------------------------------------- - | Symbolic Links - |-------------------------------------------------------------------------- - | - | Here you may configure the symbolic links that will be created when the - | `storage:link` Artisan command is executed. The array keys should be - | the locations of the links and the values should be their targets. - | - */ - - 'links' => [ - public_path('storage') => storage_path('app/public'), - ], - -]; diff --git a/config/logging.php b/config/logging.php deleted file mode 100644 index 9e998a4..0000000 --- a/config/logging.php +++ /dev/null @@ -1,132 +0,0 @@ - env('LOG_CHANNEL', 'stack'), - - /* - |-------------------------------------------------------------------------- - | Deprecations Log Channel - |-------------------------------------------------------------------------- - | - | This option controls the log channel that should be used to log warnings - | regarding deprecated PHP and library features. This allows you to get - | your application ready for upcoming major versions of dependencies. - | - */ - - 'deprecations' => [ - 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), - 'trace' => env('LOG_DEPRECATIONS_TRACE', false), - ], - - /* - |-------------------------------------------------------------------------- - | Log Channels - |-------------------------------------------------------------------------- - | - | Here you may configure the log channels for your application. Laravel - | utilizes the Monolog PHP logging library, which includes a variety - | of powerful log handlers and formatters that you're free to use. - | - | Available drivers: "single", "daily", "slack", "syslog", - | "errorlog", "monolog", "custom", "stack" - | - */ - - 'channels' => [ - - 'stack' => [ - 'driver' => 'stack', - 'channels' => explode(',', (string) env('LOG_STACK', 'single')), - 'ignore_exceptions' => false, - ], - - 'single' => [ - 'driver' => 'single', - 'path' => storage_path('logs/laravel.log'), - 'level' => env('LOG_LEVEL', 'debug'), - 'replace_placeholders' => true, - ], - - 'daily' => [ - 'driver' => 'daily', - 'path' => storage_path('logs/laravel.log'), - 'level' => env('LOG_LEVEL', 'debug'), - 'days' => env('LOG_DAILY_DAYS', 14), - 'replace_placeholders' => true, - ], - - 'slack' => [ - 'driver' => 'slack', - 'url' => env('LOG_SLACK_WEBHOOK_URL'), - 'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'), - 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), - 'level' => env('LOG_LEVEL', 'critical'), - 'replace_placeholders' => true, - ], - - 'papertrail' => [ - 'driver' => 'monolog', - 'level' => env('LOG_LEVEL', 'debug'), - 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), - 'handler_with' => [ - 'host' => env('PAPERTRAIL_URL'), - 'port' => env('PAPERTRAIL_PORT'), - 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), - ], - 'processors' => [PsrLogMessageProcessor::class], - ], - - 'stderr' => [ - 'driver' => 'monolog', - 'level' => env('LOG_LEVEL', 'debug'), - 'handler' => StreamHandler::class, - 'handler_with' => [ - 'stream' => 'php://stderr', - ], - 'formatter' => env('LOG_STDERR_FORMATTER'), - 'processors' => [PsrLogMessageProcessor::class], - ], - - 'syslog' => [ - 'driver' => 'syslog', - 'level' => env('LOG_LEVEL', 'debug'), - 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), - 'replace_placeholders' => true, - ], - - 'errorlog' => [ - 'driver' => 'errorlog', - 'level' => env('LOG_LEVEL', 'debug'), - 'replace_placeholders' => true, - ], - - 'null' => [ - 'driver' => 'monolog', - 'handler' => NullHandler::class, - ], - - 'emergency' => [ - 'path' => storage_path('logs/laravel.log'), - ], - - ], - -]; diff --git a/config/mail.php b/config/mail.php deleted file mode 100644 index 522b284..0000000 --- a/config/mail.php +++ /dev/null @@ -1,118 +0,0 @@ - env('MAIL_MAILER', 'log'), - - /* - |-------------------------------------------------------------------------- - | Mailer Configurations - |-------------------------------------------------------------------------- - | - | Here you may configure all of the mailers used by your application plus - | their respective settings. Several examples have been configured for - | you and you are free to add your own as your application requires. - | - | Laravel supports a variety of mail "transport" drivers that can be used - | when delivering an email. You may specify which one you're using for - | your mailers below. You may also add additional mailers if needed. - | - | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", - | "postmark", "resend", "log", "array", - | "failover", "roundrobin" - | - */ - - 'mailers' => [ - - 'smtp' => [ - 'transport' => 'smtp', - 'scheme' => env('MAIL_SCHEME'), - 'url' => env('MAIL_URL'), - 'host' => env('MAIL_HOST', '127.0.0.1'), - 'port' => env('MAIL_PORT', 2525), - 'username' => env('MAIL_USERNAME'), - 'password' => env('MAIL_PASSWORD'), - 'timeout' => null, - 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), - ], - - 'ses' => [ - 'transport' => 'ses', - ], - - 'postmark' => [ - 'transport' => 'postmark', - // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), - // 'client' => [ - // 'timeout' => 5, - // ], - ], - - 'resend' => [ - 'transport' => 'resend', - ], - - 'sendmail' => [ - 'transport' => 'sendmail', - 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), - ], - - 'log' => [ - 'transport' => 'log', - 'channel' => env('MAIL_LOG_CHANNEL'), - ], - - 'array' => [ - 'transport' => 'array', - ], - - 'failover' => [ - 'transport' => 'failover', - 'mailers' => [ - 'smtp', - 'log', - ], - 'retry_after' => 60, - ], - - 'roundrobin' => [ - 'transport' => 'roundrobin', - 'mailers' => [ - 'ses', - 'postmark', - ], - 'retry_after' => 60, - ], - - ], - - /* - |-------------------------------------------------------------------------- - | Global "From" Address - |-------------------------------------------------------------------------- - | - | You may wish for all emails sent by your application to be sent from - | the same address. Here you may specify a name and address that is - | used globally for all emails that are sent by your application. - | - */ - - 'from' => [ - 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), - 'name' => env('MAIL_FROM_NAME', 'Example'), - ], - -]; diff --git a/config/mcp.php b/config/mcp.php deleted file mode 100644 index e271434..0000000 --- a/config/mcp.php +++ /dev/null @@ -1,160 +0,0 @@ - [ - /* - |-------------------------------------------------------------------------- - | Read-Only Connection - |-------------------------------------------------------------------------- - | - | The database connection to use for MCP query execution. This should - | be configured with a read-only database user for defence in depth. - | - | Set to null to use the default connection (not recommended for production). - | - */ - 'connection' => env('MCP_DB_CONNECTION', 'mcp_readonly'), - - /* - |-------------------------------------------------------------------------- - | Query Whitelist - |-------------------------------------------------------------------------- - | - | Enable or disable whitelist-based query validation. When enabled, - | queries must match at least one pattern in the whitelist to execute. - | - */ - 'use_whitelist' => env('MCP_DB_USE_WHITELIST', true), - - /* - |-------------------------------------------------------------------------- - | Custom Whitelist Patterns - |-------------------------------------------------------------------------- - | - | Additional regex patterns to allow. The default whitelist allows basic - | SELECT queries. Add patterns here for application-specific queries. - | - | Example: - | '/^\s*SELECT\s+.*\s+FROM\s+`?users`?\s+WHERE\s+id\s*=\s*\d+;?\s*$/i' - | - */ - 'whitelist_patterns' => [ - // Add custom patterns here - ], - - /* - |-------------------------------------------------------------------------- - | Blocked Tables - |-------------------------------------------------------------------------- - | - | Tables that cannot be queried even with valid SELECT queries. - | Use this to protect sensitive tables from MCP access. - | - */ - 'blocked_tables' => [ - 'users', - 'password_reset_tokens', - 'sessions', - 'personal_access_tokens', - 'failed_jobs', - ], - - /* - |-------------------------------------------------------------------------- - | Row Limit - |-------------------------------------------------------------------------- - | - | Maximum number of rows that can be returned from a query. - | This prevents accidentally returning huge result sets. - | - */ - 'max_rows' => env('MCP_DB_MAX_ROWS', 1000), - ], - - /* - |-------------------------------------------------------------------------- - | Tool Usage Analytics - |-------------------------------------------------------------------------- - | - | Configuration for MCP tool usage analytics and metrics tracking. - | - */ - - 'analytics' => [ - /* - |-------------------------------------------------------------------------- - | Enable Analytics - |-------------------------------------------------------------------------- - | - | Enable or disable tool usage analytics. When disabled, no metrics - | will be recorded for tool executions. - | - */ - 'enabled' => env('MCP_ANALYTICS_ENABLED', true), - - /* - |-------------------------------------------------------------------------- - | Data Retention - |-------------------------------------------------------------------------- - | - | Number of days to retain analytics data before pruning. - | Use the mcp:prune-metrics command to clean up old data. - | - */ - 'retention_days' => env('MCP_ANALYTICS_RETENTION_DAYS', 90), - - /* - |-------------------------------------------------------------------------- - | Batch Size - |-------------------------------------------------------------------------- - | - | Number of metrics to accumulate before flushing to the database. - | Higher values improve write performance but may lose data on crashes. - | - */ - 'batch_size' => env('MCP_ANALYTICS_BATCH_SIZE', 100), - ], - - /* - |-------------------------------------------------------------------------- - | Log Retention - |-------------------------------------------------------------------------- - | - | Configuration for MCP log retention and cleanup. - | - */ - - 'log_retention' => [ - /* - |-------------------------------------------------------------------------- - | Detailed Logs Retention - |-------------------------------------------------------------------------- - | - | Number of days to retain detailed tool call logs. - | - */ - 'days' => env('MCP_LOG_RETENTION_DAYS', 90), - - /* - |-------------------------------------------------------------------------- - | Statistics Retention - |-------------------------------------------------------------------------- - | - | Number of days to retain aggregated statistics. - | Should typically be longer than detailed logs. - | - */ - 'stats_days' => env('MCP_LOG_RETENTION_STATS_DAYS', 365), - ], - -]; diff --git a/config/queue.php b/config/queue.php deleted file mode 100644 index 79c2c0a..0000000 --- a/config/queue.php +++ /dev/null @@ -1,129 +0,0 @@ - env('QUEUE_CONNECTION', 'database'), - - /* - |-------------------------------------------------------------------------- - | Queue Connections - |-------------------------------------------------------------------------- - | - | Here you may configure the connection options for every queue backend - | used by your application. An example configuration is provided for - | each backend supported by Laravel. You're also free to add more. - | - | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", - | "deferred", "background", "failover", "null" - | - */ - - 'connections' => [ - - 'sync' => [ - 'driver' => 'sync', - ], - - 'database' => [ - 'driver' => 'database', - 'connection' => env('DB_QUEUE_CONNECTION'), - 'table' => env('DB_QUEUE_TABLE', 'jobs'), - 'queue' => env('DB_QUEUE', 'default'), - 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), - 'after_commit' => false, - ], - - 'beanstalkd' => [ - 'driver' => 'beanstalkd', - 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), - 'queue' => env('BEANSTALKD_QUEUE', 'default'), - 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), - 'block_for' => 0, - 'after_commit' => false, - ], - - 'sqs' => [ - 'driver' => 'sqs', - 'key' => env('AWS_ACCESS_KEY_ID'), - 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), - 'queue' => env('SQS_QUEUE', 'default'), - 'suffix' => env('SQS_SUFFIX'), - 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), - 'after_commit' => false, - ], - - 'redis' => [ - 'driver' => 'redis', - 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), - 'queue' => env('REDIS_QUEUE', 'default'), - 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), - 'block_for' => null, - 'after_commit' => false, - ], - - 'deferred' => [ - 'driver' => 'deferred', - ], - - 'background' => [ - 'driver' => 'background', - ], - - 'failover' => [ - 'driver' => 'failover', - 'connections' => [ - 'database', - 'deferred', - ], - ], - - ], - - /* - |-------------------------------------------------------------------------- - | Job Batching - |-------------------------------------------------------------------------- - | - | The following options configure the database and table that store job - | batching information. These options can be updated to any database - | connection and table which has been defined by your application. - | - */ - - 'batching' => [ - 'database' => env('DB_CONNECTION', 'sqlite'), - 'table' => 'job_batches', - ], - - /* - |-------------------------------------------------------------------------- - | Failed Queue Jobs - |-------------------------------------------------------------------------- - | - | These options configure the behavior of failed queue job logging so you - | can control how and where failed jobs are stored. Laravel ships with - | support for storing failed jobs in a simple file or in a database. - | - | Supported drivers: "database-uuids", "dynamodb", "file", "null" - | - */ - - 'failed' => [ - 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), - 'database' => env('DB_CONNECTION', 'sqlite'), - 'table' => 'failed_jobs', - ], - -]; diff --git a/config/services.php b/config/services.php deleted file mode 100644 index 6a90eb8..0000000 --- a/config/services.php +++ /dev/null @@ -1,38 +0,0 @@ - [ - 'key' => env('POSTMARK_API_KEY'), - ], - - 'resend' => [ - 'key' => env('RESEND_API_KEY'), - ], - - 'ses' => [ - 'key' => env('AWS_ACCESS_KEY_ID'), - 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), - ], - - 'slack' => [ - 'notifications' => [ - 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), - 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), - ], - ], - -]; diff --git a/config/session.php b/config/session.php deleted file mode 100644 index 5b541b7..0000000 --- a/config/session.php +++ /dev/null @@ -1,217 +0,0 @@ - env('SESSION_DRIVER', 'database'), - - /* - |-------------------------------------------------------------------------- - | Session Lifetime - |-------------------------------------------------------------------------- - | - | Here you may specify the number of minutes that you wish the session - | to be allowed to remain idle before it expires. If you want them - | to expire immediately when the browser is closed then you may - | indicate that via the expire_on_close configuration option. - | - */ - - 'lifetime' => (int) env('SESSION_LIFETIME', 120), - - 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), - - /* - |-------------------------------------------------------------------------- - | Session Encryption - |-------------------------------------------------------------------------- - | - | This option allows you to easily specify that all of your session data - | should be encrypted before it's stored. All encryption is performed - | automatically by Laravel and you may use the session like normal. - | - */ - - 'encrypt' => env('SESSION_ENCRYPT', false), - - /* - |-------------------------------------------------------------------------- - | Session File Location - |-------------------------------------------------------------------------- - | - | When utilizing the "file" session driver, the session files are placed - | on disk. The default storage location is defined here; however, you - | are free to provide another location where they should be stored. - | - */ - - 'files' => storage_path('framework/sessions'), - - /* - |-------------------------------------------------------------------------- - | Session Database Connection - |-------------------------------------------------------------------------- - | - | When using the "database" or "redis" session drivers, you may specify a - | connection that should be used to manage these sessions. This should - | correspond to a connection in your database configuration options. - | - */ - - 'connection' => env('SESSION_CONNECTION'), - - /* - |-------------------------------------------------------------------------- - | Session Database Table - |-------------------------------------------------------------------------- - | - | When using the "database" session driver, you may specify the table to - | be used to store sessions. Of course, a sensible default is defined - | for you; however, you're welcome to change this to another table. - | - */ - - 'table' => env('SESSION_TABLE', 'sessions'), - - /* - |-------------------------------------------------------------------------- - | Session Cache Store - |-------------------------------------------------------------------------- - | - | When using one of the framework's cache driven session backends, you may - | define the cache store which should be used to store the session data - | between requests. This must match one of your defined cache stores. - | - | Affects: "dynamodb", "memcached", "redis" - | - */ - - 'store' => env('SESSION_STORE'), - - /* - |-------------------------------------------------------------------------- - | Session Sweeping Lottery - |-------------------------------------------------------------------------- - | - | Some session drivers must manually sweep their storage location to get - | rid of old sessions from storage. Here are the chances that it will - | happen on a given request. By default, the odds are 2 out of 100. - | - */ - - 'lottery' => [2, 100], - - /* - |-------------------------------------------------------------------------- - | Session Cookie Name - |-------------------------------------------------------------------------- - | - | Here you may change the name of the session cookie that is created by - | the framework. Typically, you should not need to change this value - | since doing so does not grant a meaningful security improvement. - | - */ - - 'cookie' => env( - 'SESSION_COOKIE', - Str::slug((string) env('APP_NAME', 'laravel')).'-session' - ), - - /* - |-------------------------------------------------------------------------- - | Session Cookie Path - |-------------------------------------------------------------------------- - | - | The session cookie path determines the path for which the cookie will - | be regarded as available. Typically, this will be the root path of - | your application, but you're free to change this when necessary. - | - */ - - 'path' => env('SESSION_PATH', '/'), - - /* - |-------------------------------------------------------------------------- - | Session Cookie Domain - |-------------------------------------------------------------------------- - | - | This value determines the domain and subdomains the session cookie is - | available to. By default, the cookie will be available to the root - | domain without subdomains. Typically, this shouldn't be changed. - | - */ - - 'domain' => env('SESSION_DOMAIN'), - - /* - |-------------------------------------------------------------------------- - | HTTPS Only Cookies - |-------------------------------------------------------------------------- - | - | By setting this option to true, session cookies will only be sent back - | to the server if the browser has a HTTPS connection. This will keep - | the cookie from being sent to you when it can't be done securely. - | - */ - - 'secure' => env('SESSION_SECURE_COOKIE'), - - /* - |-------------------------------------------------------------------------- - | HTTP Access Only - |-------------------------------------------------------------------------- - | - | Setting this value to true will prevent JavaScript from accessing the - | value of the cookie and the cookie will only be accessible through - | the HTTP protocol. It's unlikely you should disable this option. - | - */ - - 'http_only' => env('SESSION_HTTP_ONLY', true), - - /* - |-------------------------------------------------------------------------- - | Same-Site Cookies - |-------------------------------------------------------------------------- - | - | This option determines how your cookies behave when cross-site requests - | take place, and can be used to mitigate CSRF attacks. By default, we - | will set this value to "lax" to permit secure cross-site requests. - | - | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value - | - | Supported: "lax", "strict", "none", null - | - */ - - 'same_site' => env('SESSION_SAME_SITE', 'lax'), - - /* - |-------------------------------------------------------------------------- - | Partitioned Cookies - |-------------------------------------------------------------------------- - | - | Setting this value to true will tie the cookie to the top-level site for - | a cross-site context. Partitioned cookies are accepted by the browser - | when flagged "secure" and the Same-Site attribute is set to "none". - | - */ - - 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), - -]; diff --git a/database/.gitignore b/database/.gitignore deleted file mode 100644 index 9b19b93..0000000 --- a/database/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.sqlite* diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php deleted file mode 100644 index 584104c..0000000 --- a/database/factories/UserFactory.php +++ /dev/null @@ -1,44 +0,0 @@ - - */ -class UserFactory extends Factory -{ - /** - * The current password being used by the factory. - */ - protected static ?string $password; - - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - 'name' => fake()->name(), - 'email' => fake()->unique()->safeEmail(), - 'email_verified_at' => now(), - 'password' => static::$password ??= Hash::make('password'), - 'remember_token' => Str::random(10), - ]; - } - - /** - * Indicate that the model's email address should be unverified. - */ - public function unverified(): static - { - return $this->state(fn (array $attributes) => [ - 'email_verified_at' => null, - ]); - } -} diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php deleted file mode 100644 index 05fb5d9..0000000 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ /dev/null @@ -1,49 +0,0 @@ -id(); - $table->string('name'); - $table->string('email')->unique(); - $table->timestamp('email_verified_at')->nullable(); - $table->string('password'); - $table->rememberToken(); - $table->timestamps(); - }); - - Schema::create('password_reset_tokens', function (Blueprint $table) { - $table->string('email')->primary(); - $table->string('token'); - $table->timestamp('created_at')->nullable(); - }); - - Schema::create('sessions', function (Blueprint $table) { - $table->string('id')->primary(); - $table->foreignId('user_id')->nullable()->index(); - $table->string('ip_address', 45)->nullable(); - $table->text('user_agent')->nullable(); - $table->longText('payload'); - $table->integer('last_activity')->index(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('users'); - Schema::dropIfExists('password_reset_tokens'); - Schema::dropIfExists('sessions'); - } -}; diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php deleted file mode 100644 index ed758bd..0000000 --- a/database/migrations/0001_01_01_000001_create_cache_table.php +++ /dev/null @@ -1,35 +0,0 @@ -string('key')->primary(); - $table->mediumText('value'); - $table->integer('expiration')->index(); - }); - - Schema::create('cache_locks', function (Blueprint $table) { - $table->string('key')->primary(); - $table->string('owner'); - $table->integer('expiration')->index(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('cache'); - Schema::dropIfExists('cache_locks'); - } -}; diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php deleted file mode 100644 index 425e705..0000000 --- a/database/migrations/0001_01_01_000002_create_jobs_table.php +++ /dev/null @@ -1,57 +0,0 @@ -id(); - $table->string('queue')->index(); - $table->longText('payload'); - $table->unsignedTinyInteger('attempts'); - $table->unsignedInteger('reserved_at')->nullable(); - $table->unsignedInteger('available_at'); - $table->unsignedInteger('created_at'); - }); - - Schema::create('job_batches', function (Blueprint $table) { - $table->string('id')->primary(); - $table->string('name'); - $table->integer('total_jobs'); - $table->integer('pending_jobs'); - $table->integer('failed_jobs'); - $table->longText('failed_job_ids'); - $table->mediumText('options')->nullable(); - $table->integer('cancelled_at')->nullable(); - $table->integer('created_at'); - $table->integer('finished_at')->nullable(); - }); - - Schema::create('failed_jobs', function (Blueprint $table) { - $table->id(); - $table->string('uuid')->unique(); - $table->text('connection'); - $table->text('queue'); - $table->longText('payload'); - $table->longText('exception'); - $table->timestamp('failed_at')->useCurrent(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('jobs'); - Schema::dropIfExists('job_batches'); - Schema::dropIfExists('failed_jobs'); - } -}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php deleted file mode 100644 index 6b901f8..0000000 --- a/database/seeders/DatabaseSeeder.php +++ /dev/null @@ -1,25 +0,0 @@ -create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); - } -} diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index 11a8bf6..e0241ca 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -3,7 +3,7 @@ import { defineConfig } from 'vitepress' export default defineConfig({ title: 'Core PHP Framework', description: 'Modular monolith framework for Laravel', - base: '/core-php/', + base: '/', ignoreDeadLinks: [ // Ignore localhost links diff --git a/docs/changelog.md b/docs/changelog.md index 4d9304c..b8c714d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -206,6 +206,6 @@ Special thanks to the open-source community! --- For more information, visit: -- [Documentation](https://host-uk.github.io/core-php/) +- [Documentation](https://core.help/) - [GitHub Repository](https://github.com/host-uk/core-php) - [Issue Tracker](https://github.com/host-uk/core-php/issues) diff --git a/docs/contributing.md b/docs/contributing.md index d7f9824..3e0a63c 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -461,6 +461,6 @@ By contributing, you agree that your contributions will be licensed under the EU - Open a [Discussion](https://github.com/host-uk/core-php/discussions) - Join our [Discord](https://discord.gg/host-uk) -- Read the [Documentation](https://host-uk.github.io/core-php/) +- Read the [Documentation](https://core.help/) Thank you for contributing! 🎉 diff --git a/docs/public/CNAME b/docs/public/CNAME new file mode 100644 index 0000000..008ea5a --- /dev/null +++ b/docs/public/CNAME @@ -0,0 +1 @@ +core.help diff --git a/packages/core-admin/README.md b/packages/core-admin/README.md deleted file mode 100644 index 01ebaa8..0000000 --- a/packages/core-admin/README.md +++ /dev/null @@ -1,113 +0,0 @@ -# Core Admin Package - -Admin panel components, Livewire modals, and service management interface for the Core PHP Framework. - -## Installation - -```bash -composer require host-uk/core-admin -``` - -## Features - -### Admin Menu System -Declarative menu registration with automatic permission checking: - -```php -use Core\Front\Admin\Contracts\AdminMenuProvider; - -class MyModuleMenu implements AdminMenuProvider -{ - public function registerMenu(AdminMenuRegistry $registry): void - { - $registry->addItem('products', [ - 'label' => 'Products', - 'icon' => 'cube', - 'route' => 'admin.products.index', - 'permission' => 'products.view', - ]); - } -} -``` - -### Livewire Modals -Full-page Livewire components for admin interfaces: - -```php -use Livewire\Component; -use Livewire\Attributes\Title; - -#[Title('Product Manager')] -class ProductManager extends Component -{ - public function render(): View - { - return view('admin.products.manager') - ->layout('hub::admin.layouts.app'); - } -} -``` - -### Form Components -Reusable form components with authorization: - -- `` - Text inputs with validation -- `` - Dropdowns -- `` - Checkboxes -- `` - Toggle switches -- `` - Text areas -- `` - Buttons with loading states - -```blade - -``` - -### Global Search -Extensible search provider system: - -```php -use Core\Admin\Search\Contracts\SearchProvider; - -class ProductSearchProvider implements SearchProvider -{ - public function search(string $query): array - { - return Product::where('name', 'like', "%{$query}%") - ->take(5) - ->get() - ->map(fn($p) => new SearchResult( - title: $p->name, - url: route('admin.products.edit', $p), - icon: 'cube' - )) - ->toArray(); - } -} -``` - -### Service Management Interface -Unified dashboard for viewing workspace services and statistics. - -## Configuration - -The package auto-discovers admin menu providers and search providers from your modules. - -## Requirements - -- PHP 8.2+ -- Laravel 11+ or 12+ -- Livewire 3.0+ -- Flux UI 2.0+ - -## Changelog - -See [changelog/2026/jan/features.md](changelog/2026/jan/features.md) for recent changes. - -## License - -EUPL-1.2 - See [LICENSE](../../LICENSE) for details. diff --git a/packages/core-admin/TODO.md b/packages/core-admin/TODO.md deleted file mode 100644 index ef70508..0000000 --- a/packages/core-admin/TODO.md +++ /dev/null @@ -1,227 +0,0 @@ -# Core-Admin TODO - -## Testing & Quality Assurance - -### High Priority - -- [ ] **Test Coverage: Search System** - Test global search functionality - - [ ] Test SearchProviderRegistry with multiple providers - - [ ] Test AdminPageSearchProvider query matching - - [ ] Test SearchResult highlighting - - [ ] Test search analytics tracking - - [ ] Test workspace-scoped search results - - **Estimated effort:** 3-4 hours - -- [ ] **Test Coverage: Form Components** - Test authorization props - - [ ] Test Button component with :can/:cannot props - - [ ] Test Input component with authorization - - [ ] Test Select/Checkbox/Toggle with permissions - - [ ] Test workspace context in form components - - **Estimated effort:** 2-3 hours - -- [ ] **Test Coverage: Livewire Modals** - Test modal system - - [ ] Test modal opening/closing - - [ ] Test file uploads in modals - - [ ] Test validation in modals - - [ ] Test nested modals - - [ ] Test modal events and lifecycle - - **Estimated effort:** 3-4 hours - -### Medium Priority - -- [ ] **Test Coverage: Admin Menu System** - Test menu building - - [ ] Test AdminMenuRegistry with multiple providers - - [ ] Test MenuItemBuilder with badges - - [ ] Test menu authorization (can/canAny) - - [ ] Test menu active state detection - - [ ] Test IconValidator - - **Estimated effort:** 2-3 hours - -- [ ] **Test Coverage: HLCRF Components** - Test layout system - - [ ] Test HierarchicalLayoutBuilder parsing - - [ ] Test nested layout rendering - - [ ] Test self-documenting IDs (H-0, C-R-2, etc.) - - [ ] Test responsive breakpoints - - **Estimated effort:** 4-5 hours - -### Low Priority - -- [ ] **Test Coverage: Teapot/Honeypot** - Test anti-spam - - [ ] Test TeapotController honeypot detection - - [ ] Test HoneypotHit recording - - [ ] Test automatic IP blocking - - [ ] Test hit pruning - - **Estimated effort:** 2-3 hours - -## Features & Enhancements - -### High Priority - -- [ ] **Feature: Data Tables Component** - Reusable admin tables - - [ ] Create sortable table component - - [ ] Add bulk action support - - [ ] Implement column filtering - - [ ] Add export to CSV/Excel - - [ ] Test with large datasets (1000+ rows) - - **Estimated effort:** 6-8 hours - - **Files:** `src/Admin/Tables/` - -- [ ] **Feature: Dashboard Widgets** - Composable dashboard - - [ ] Create widget system with layouts - - [ ] Add drag-and-drop widget arrangement - - [ ] Implement widget state persistence - - [ ] Create common widgets (stats, charts, lists) - - [ ] Test widget refresh and real-time updates - - **Estimated effort:** 8-10 hours - - **Files:** `src/Admin/Dashboard/` - -- [ ] **Feature: Notification Center** - In-app notifications - - [ ] Create notification inbox component - - [ ] Add real-time notification delivery - - [ ] Implement notification preferences - - [ ] Add notification grouping - - [ ] Test with high notification volume - - **Estimated effort:** 6-8 hours - - **Files:** `src/Admin/Notifications/` - -### Medium Priority - -- [ ] **Enhancement: Form Builder** - Dynamic form generation - - [ ] Create form builder UI - - [ ] Support custom field types - - [ ] Add conditional field visibility - - [ ] Implement form templates - - [ ] Test complex multi-step forms - - **Estimated effort:** 8-10 hours - - **Files:** `src/Forms/Builder/` - -- [ ] **Enhancement: Activity Feed Component** - Visual activity log - - [ ] Create activity feed Livewire component - - [ ] Add filtering by event type/user/date - - [ ] Implement infinite scroll - - [ ] Add export functionality - - [ ] Test with large activity logs - - **Estimated effort:** 4-5 hours - - **Files:** `src/Activity/Components/` - -- [ ] **Enhancement: File Manager** - Media browser - - [ ] Create file browser component - - [ ] Add upload with drag-and-drop - - [ ] Implement folder organization - - [ ] Add image preview and editing - - [ ] Test with S3/CDN integration - - **Estimated effort:** 10-12 hours - - **Files:** `src/Media/Manager/` - -### Low Priority - -- [ ] **Enhancement: Theme Customizer** - Visual theme editor - - [ ] Create color picker for brand colors - - [ ] Add font selection - - [ ] Implement logo upload - - [ ] Add CSS custom property generation - - [ ] Test theme persistence per workspace - - **Estimated effort:** 6-8 hours - - **Files:** `src/Theming/` - -- [ ] **Enhancement: Keyboard Shortcuts** - Power user features - - [ ] Implement global shortcut system - - [ ] Add command palette (Cmd+K) - - [ ] Create shortcut configuration UI - - [ ] Add accessibility support - - **Estimated effort:** 4-5 hours - - **Files:** `src/Shortcuts/` - -## Security & Authorization - -- [ ] **Audit: Admin Route Security** - Verify all admin routes protected - - [ ] Audit all admin controllers for authorization - - [ ] Ensure #[Action] attributes on sensitive operations - - [ ] Verify middleware chains - - [ ] Test unauthorized access attempts - - **Estimated effort:** 3-4 hours - -- [ ] **Enhancement: Action Audit Log** - Track admin actions - - [ ] Log all admin operations - - [ ] Track who/what/when for compliance - - [ ] Add audit log viewer - - [ ] Implement tamper-proof logging - - **Estimated effort:** 4-5 hours - - **Files:** `src/Audit/` - -## Documentation - -- [x] **Guide: Creating Admin Panels** - Step-by-step guide - - [x] Document menu registration - - [x] Show modal creation examples - - [x] Explain authorization integration - - [x] Add complete example module - - **Completed:** January 2026 - - **File:** `docs/packages/admin/creating-admin-panels.md` - -- [x] **Guide: HLCRF Deep Dive** - Advanced layout patterns - - [x] Document all layout combinations - - [x] Show responsive design patterns - - [x] Explain ID system in detail - - [x] Add complex real-world examples - - **Completed:** January 2026 - - **File:** `docs/packages/admin/hlcrf-deep-dive.md` - -- [x] **API Reference: Components** - Component prop documentation - - [x] Document all form component props - - [x] Add prop validation rules - - [x] Show authorization prop examples - - [x] Include accessibility notes - - **Completed:** January 2026 - - **File:** `docs/packages/admin/components-reference.md` - -## Code Quality - -- [ ] **Refactor: Extract Modal Manager** - Separate concerns - - [ ] Extract modal state management - - [ ] Create dedicated ModalManager service - - [ ] Add modal queue support - - [ ] Test modal lifecycle - - **Estimated effort:** 3-4 hours - -- [ ] **Refactor: Standardize Component Props** - Consistent API - - [ ] Audit all component props - - [ ] Standardize naming (can/cannot/canAny) - - [ ] Add prop validation - - [ ] Update documentation - - **Estimated effort:** 2-3 hours - -- [ ] **PHPStan: Fix Level 5 Errors** - Improve type safety - - [ ] Fix property type declarations - - [ ] Add missing return types - - [ ] Fix array shape types - - **Estimated effort:** 2-3 hours - -## Performance - -- [ ] **Optimization: Search Indexing** - Faster admin search - - [ ] Profile search performance - - [ ] Add search result caching - - [ ] Implement debounced search - - [ ] Optimize query building - - **Estimated effort:** 2-3 hours - -- [ ] **Optimization: Menu Rendering** - Reduce menu overhead - - [ ] Cache menu structure - - [ ] Lazy load menu icons - - [ ] Optimize authorization checks - - **Estimated effort:** 1-2 hours - ---- - -## Completed (January 2026) - -- [x] **Forms: Authorization Props** - Added :can/:cannot/:canAny to all form components -- [x] **Search: Provider System** - Global search with multiple providers -- [x] **Search: Analytics** - Track search queries and results -- [x] **Documentation** - Complete admin package documentation -- [x] **Guide: Creating Admin Panels** - Menu registration, modals, authorization, example module -- [x] **Guide: HLCRF Deep Dive** - Layout combinations, ID system, responsive patterns -- [x] **API Reference: Components** - Form component props with authorization examples - -*See `changelog/2026/jan/` for completed features.* diff --git a/packages/core-admin/changelog/2026/jan/features.md b/packages/core-admin/changelog/2026/jan/features.md deleted file mode 100644 index 96025c9..0000000 --- a/packages/core-admin/changelog/2026/jan/features.md +++ /dev/null @@ -1,70 +0,0 @@ -# Core-Admin - January 2026 - -## Features Implemented - -### Form Authorization Components - -Authorization-aware form components that automatically disable/hide based on permissions. - -**Files:** -- `src/Forms/Concerns/HasAuthorizationProps.php` - Authorization trait -- `src/Forms/View/Components/` - Input, Textarea, Select, Checkbox, Button, Toggle, FormGroup -- `resources/views/components/forms/` - Blade templates - -**Components:** -- `` - Text input with label, helper, error -- `` - Textarea with auto-resize -- `` - Dropdown with grouped options -- `` - Checkbox with description -- `` - Button with variants, loading state -- `` - Toggle with instant save -- `` - Wrapper for spacing - -**Usage:** -```blade - - - - Delete - -``` - ---- - -### Global Search (⌘K) - -Unified search across resources with keyboard navigation. - -**Files:** -- `src/Search/Contracts/SearchProvider.php` - Provider interface -- `src/Search/SearchProviderRegistry.php` - Registry with fuzzy matching -- `src/Search/SearchResult.php` - Result DTO -- `src/Search/Providers/AdminPageSearchProvider.php` - Built-in provider -- `src/Website/Hub/View/Modal/Admin/GlobalSearch.php` - Livewire component - -**Features:** -- ⌘K / Ctrl+K keyboard shortcut -- Arrow key navigation, Enter to select -- Fuzzy matching support -- Recent searches -- Grouped results by provider - -**Usage:** -```php -// Register custom provider -app(SearchProviderRegistry::class)->register(new MySearchProvider()); -``` - ---- - -## Design Decisions - -### Soketi (Real-time WebSocket) - -Excluded per project decision. Self-hosted Soketi integration not required at this time. diff --git a/packages/core-admin/composer.json b/packages/core-admin/composer.json deleted file mode 100644 index 7a2be27..0000000 --- a/packages/core-admin/composer.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "host-uk/core-admin", - "description": "Admin panel module for Core PHP framework", - "keywords": ["laravel", "admin", "panel", "dashboard"], - "license": "EUPL-1.2", - "require": { - "php": "^8.2", - "host-uk/core": "@dev" - }, - "autoload": { - "psr-4": { - "Core\\Admin\\": "src/", - "Website\\Hub\\": "src/Website/Hub/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Core\\Admin\\Boot" - ] - } - }, - "minimum-stability": "stable", - "prefer-stable": true -} diff --git a/packages/core-admin/resources/views/components/forms/button.blade.php b/packages/core-admin/resources/views/components/forms/button.blade.php deleted file mode 100644 index 67fe9fa..0000000 --- a/packages/core-admin/resources/views/components/forms/button.blade.php +++ /dev/null @@ -1,82 +0,0 @@ -{{-- - Button Component - - A button with authorization support, variants, loading states, and icons. - - Props: - - type: string - Button type (button, submit, reset) - - variant: string - Button style: primary, secondary, danger, ghost - - size: string - Button size: sm, md, lg - - icon: string|null - Icon name (left position) - - iconRight: string|null - Icon name (right position) - - loading: bool - Show loading state - - loadingText: string|null - Text to show during loading - - disabled: bool - Whether button is disabled - - canGate: string|null - Gate/ability to check - - canResource: mixed|null - Resource to check against - - canHide: bool - Hide instead of disable when unauthorized - - Usage: - - Save Changes - - - - Delete - - - {{-- With loading state --}} - - Save - Saving... - ---}} - -@if(!$hidden) - -@endif diff --git a/packages/core-admin/resources/views/components/forms/checkbox.blade.php b/packages/core-admin/resources/views/components/forms/checkbox.blade.php deleted file mode 100644 index bfb6036..0000000 --- a/packages/core-admin/resources/views/components/forms/checkbox.blade.php +++ /dev/null @@ -1,88 +0,0 @@ -{{-- - Checkbox Component - - A checkbox with authorization support, label positioning, and description. - - Props: - - id: string (required) - Checkbox element ID - - label: string|null - Label text - - description: string|null - Description text below label - - error: string|null - Error message - - labelPosition: string - Label position: 'left' or 'right' (default: 'right') - - disabled: bool - Whether checkbox is disabled - - canGate: string|null - Gate/ability to check - - canResource: mixed|null - Resource to check against - - canHide: bool - Hide instead of disable when unauthorized - - Usage: - - - {{-- Label on left --}} - ---}} - -@if(!$hidden) -
only(['class', 'x-show', 'x-if', 'x-cloak'])->merge(['class' => 'space-y-1']) }}> -
$labelPosition === 'left', - ])> - {{-- Checkbox --}} -
- except(['class', 'x-show', 'x-if', 'x-cloak'])->class([ - 'h-4 w-4 rounded transition-colors duration-200', - 'border-gray-300 dark:border-gray-600', - 'text-violet-600 dark:text-violet-500', - 'focus:ring-2 focus:ring-violet-500/20 focus:ring-offset-0', - 'bg-white dark:bg-gray-800', - // Disabled state - 'bg-gray-100 dark:bg-gray-900 cursor-not-allowed' => $disabled, - ]) }} - /> -
- - {{-- Label and description --}} - @if($label || $description) -
- @if($label) - - @endif - - @if($description) -

{{ $description }}

- @endif -
- @endif -
- - {{-- Error message --}} - @if($error) -

{{ $error }}

- @elseif($errors->has($id)) -

{{ $errors->first($id) }}

- @endif -
-@endif diff --git a/packages/core-admin/resources/views/components/forms/form-group.blade.php b/packages/core-admin/resources/views/components/forms/form-group.blade.php deleted file mode 100644 index fea442b..0000000 --- a/packages/core-admin/resources/views/components/forms/form-group.blade.php +++ /dev/null @@ -1,50 +0,0 @@ -{{-- - Form Group Component - - A wrapper component for consistent form field spacing and error display. - - Props: - - label: string|null - Label text - - for: string|null - ID of the form element (for label) - - error: string|null - Error bag key to check - - helper: string|null - Helper text - - required: bool - Show required indicator - - Usage: - - - - - {{-- Without label --}} - - - ---}} - -
merge(['class' => 'space-y-1']) }}> - {{-- Label --}} - @if($label) - - @endif - - {{-- Content slot --}} - {{ $slot }} - - {{-- Helper text --}} - @if($helper && !$hasError()) -

{{ $helper }}

- @endif - - {{-- Error message --}} - @if($hasError()) -

{{ $errorMessage }}

- @endif -
diff --git a/packages/core-admin/resources/views/components/forms/input.blade.php b/packages/core-admin/resources/views/components/forms/input.blade.php deleted file mode 100644 index b3c9804..0000000 --- a/packages/core-admin/resources/views/components/forms/input.blade.php +++ /dev/null @@ -1,77 +0,0 @@ -{{-- - Input Component - - A text input with authorization support, labels, helper text, and error display. - - Props: - - id: string (required) - Input element ID - - label: string|null - Label text - - helper: string|null - Helper text below input - - error: string|null - Error message (auto-resolved from validation bag if not provided) - - type: string - Input type (text, email, password, etc.) - - placeholder: string|null - Placeholder text - - disabled: bool - Whether input is disabled - - required: bool - Whether input is required - - canGate: string|null - Gate/ability to check - - canResource: mixed|null - Resource to check against - - canHide: bool - Hide instead of disable when unauthorized - - Usage: - ---}} - -@if(!$hidden) -
only(['class', 'x-show', 'x-if', 'x-cloak'])->merge(['class' => 'space-y-1']) }}> - {{-- Label --}} - @if($label) - - @endif - - {{-- Input --}} - except(['class', 'x-show', 'x-if', 'x-cloak'])->class([ - 'block w-full rounded-lg border px-3 py-2 text-sm transition-colors duration-200', - 'bg-white dark:bg-gray-800', - 'text-gray-900 dark:text-gray-100', - 'placeholder-gray-400 dark:placeholder-gray-500', - 'focus:outline-none focus:ring-2 focus:ring-offset-0', - // Normal state - 'border-gray-300 dark:border-gray-600 focus:border-violet-500 focus:ring-violet-500/20' => !$error, - // Error state - 'border-red-500 dark:border-red-500 focus:border-red-500 focus:ring-red-500/20' => $error, - // Disabled state - 'bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400 cursor-not-allowed' => $disabled, - ]) }} - /> - - {{-- Helper text --}} - @if($helper && !$error) -

{{ $helper }}

- @endif - - {{-- Error message --}} - @if($error) -

{{ $error }}

- @elseif($errors->has($id)) -

{{ $errors->first($id) }}

- @endif -
-@endif diff --git a/packages/core-admin/resources/views/components/forms/select.blade.php b/packages/core-admin/resources/views/components/forms/select.blade.php deleted file mode 100644 index a0b741d..0000000 --- a/packages/core-admin/resources/views/components/forms/select.blade.php +++ /dev/null @@ -1,108 +0,0 @@ -{{-- - Select Component - - A dropdown select with authorization support, options, and error display. - - Props: - - id: string (required) - Select element ID - - options: array - Options as value => label or grouped options - - label: string|null - Label text - - helper: string|null - Helper text below select - - error: string|null - Error message - - placeholder: string|null - Placeholder option text - - multiple: bool - Allow multiple selection - - disabled: bool - Whether select is disabled - - required: bool - Whether select is required - - canGate: string|null - Gate/ability to check - - canResource: mixed|null - Resource to check against - - canHide: bool - Hide instead of disable when unauthorized - - Usage: - - - {{-- With grouped options --}} - ---}} - -@if(!$hidden) -
only(['class', 'x-show', 'x-if', 'x-cloak'])->merge(['class' => 'space-y-1']) }}> - {{-- Label --}} - @if($label) - - @endif - - {{-- Select --}} - - - {{-- Helper text --}} - @if($helper && !$error) -

{{ $helper }}

- @endif - - {{-- Error message --}} - @if($error) -

{{ $error }}

- @elseif($errors->has($id)) -

{{ $errors->first($id) }}

- @endif -
-@endif diff --git a/packages/core-admin/resources/views/components/forms/textarea.blade.php b/packages/core-admin/resources/views/components/forms/textarea.blade.php deleted file mode 100644 index 0549fea..0000000 --- a/packages/core-admin/resources/views/components/forms/textarea.blade.php +++ /dev/null @@ -1,87 +0,0 @@ -{{-- - Textarea Component - - A textarea with authorization support, auto-resize, labels, and error display. - - Props: - - id: string (required) - Textarea element ID - - label: string|null - Label text - - helper: string|null - Helper text below textarea - - error: string|null - Error message - - placeholder: string|null - Placeholder text - - rows: int - Number of visible rows (default: 3) - - autoResize: bool - Enable auto-resize via Alpine.js - - disabled: bool - Whether textarea is disabled - - required: bool - Whether textarea is required - - canGate: string|null - Gate/ability to check - - canResource: mixed|null - Resource to check against - - canHide: bool - Hide instead of disable when unauthorized - - Usage: - ---}} - -@if(!$hidden) -
only(['class', 'x-show', 'x-if', 'x-cloak'])->merge(['class' => 'space-y-1']) }}> - {{-- Label --}} - @if($label) - - @endif - - {{-- Textarea --}} - - - {{-- Helper text --}} - @if($helper && !$error) -

{{ $helper }}

- @endif - - {{-- Error message --}} - @if($error) -

{{ $error }}

- @elseif($errors->has($id)) -

{{ $errors->first($id) }}

- @endif -
-@endif diff --git a/packages/core-admin/resources/views/components/forms/toggle.blade.php b/packages/core-admin/resources/views/components/forms/toggle.blade.php deleted file mode 100644 index ed843a8..0000000 --- a/packages/core-admin/resources/views/components/forms/toggle.blade.php +++ /dev/null @@ -1,104 +0,0 @@ -{{-- - Toggle Component - - A toggle switch with authorization support and instant save capability. - - Props: - - id: string (required) - Toggle element ID - - label: string|null - Label text - - description: string|null - Description text - - error: string|null - Error message - - size: string - Toggle size: sm, md, lg - - instantSave: bool - Enable instant save on change - - instantSaveMethod: string|null - Livewire method to call on change - - disabled: bool - Whether toggle is disabled - - canGate: string|null - Gate/ability to check - - canResource: mixed|null - Resource to check against - - canHide: bool - Hide instead of disable when unauthorized - - Usage: - - - {{-- With instant save --}} - ---}} - -@if(!$hidden) -
only(['class', 'x-show', 'x-if', 'x-cloak'])->merge(['class' => 'space-y-1']) }}> -
- {{-- Label and description --}} - @if($label || $description) -
- @if($label) - - @endif - - @if($description) -

{{ $description }}

- @endif -
- @endif - - {{-- Toggle switch --}} - -
- - {{-- Error message --}} - @if($error) -

{{ $error }}

- @elseif($errors->has($id)) -

{{ $errors->first($id) }}

- @endif -
-@endif diff --git a/packages/core-admin/src/Boot.php b/packages/core-admin/src/Boot.php deleted file mode 100644 index 53b1ea2..0000000 --- a/packages/core-admin/src/Boot.php +++ /dev/null @@ -1,88 +0,0 @@ -addPaths([ - __DIR__.'/Website', - ]); - - // Register the search provider registry as a singleton - $this->app->singleton(SearchProviderRegistry::class); - } - - public function boot(): void - { - // Load Hub translations - $this->loadTranslationsFrom(__DIR__.'/Mod/Hub/Lang', 'hub'); - - // Register form components - $this->registerFormComponents(); - - // Register the default search providers - $this->registerSearchProviders(); - } - - /** - * Register form components with authorization support. - * - * Components are registered with the 'core-forms' prefix: - * - - * - - * - - * - - * - - * - - * - - */ - protected function registerFormComponents(): void - { - // Register views namespace for form component templates - $this->loadViewsFrom(dirname(__DIR__).'/resources/views', 'core-forms'); - - // Register class-backed form components - Blade::component('core-forms.input', Input::class); - Blade::component('core-forms.textarea', Textarea::class); - Blade::component('core-forms.select', Select::class); - Blade::component('core-forms.checkbox', Checkbox::class); - Blade::component('core-forms.button', Button::class); - Blade::component('core-forms.toggle', Toggle::class); - Blade::component('core-forms.form-group', FormGroup::class); - } - - /** - * Register the default search providers. - */ - protected function registerSearchProviders(): void - { - $registry = $this->app->make(SearchProviderRegistry::class); - - // Register the built-in admin page search provider - $registry->register($this->app->make(AdminPageSearchProvider::class)); - } -} diff --git a/packages/core-admin/src/Forms/Concerns/HasAuthorizationProps.php b/packages/core-admin/src/Forms/Concerns/HasAuthorizationProps.php deleted file mode 100644 index dff78c2..0000000 --- a/packages/core-admin/src/Forms/Concerns/HasAuthorizationProps.php +++ /dev/null @@ -1,101 +0,0 @@ - - * Delete - * ``` - */ -trait HasAuthorizationProps -{ - /** - * The gate/ability to check (e.g., 'update', 'delete'). - */ - public ?string $canGate = null; - - /** - * The resource/model to check the gate against. - */ - public mixed $canResource = null; - - /** - * Whether to hide the component (instead of disabling) when unauthorized. - */ - public bool $canHide = false; - - /** - * Resolve whether the component should be disabled based on authorization. - * - * If `canGate` and `canResource` are both provided and the user lacks - * the required permission, the component will be disabled. - * - * @param bool $explicitlyDisabled Whether the component was explicitly disabled via props - */ - protected function resolveDisabledState(bool $explicitlyDisabled = false): bool - { - // Already explicitly disabled - no need to check authorization - if ($explicitlyDisabled) { - return true; - } - - // No authorization check configured - if (! $this->canGate || $this->canResource === null) { - return false; - } - - // Check if user can perform the action - return ! $this->userCan(); - } - - /** - * Resolve whether the component should be hidden based on authorization. - * - * Only hides if `canHide` is true and the user lacks permission. - */ - protected function resolveHiddenState(): bool - { - // Not configured to hide on unauthorized - if (! $this->canHide) { - return false; - } - - // No authorization check configured - if (! $this->canGate || $this->canResource === null) { - return false; - } - - // Hide if user cannot perform the action - return ! $this->userCan(); - } - - /** - * Check if the current user can perform the gate action on the resource. - */ - protected function userCan(): bool - { - $user = auth()->user(); - - if (! $user) { - return false; - } - - return $user->can($this->canGate, $this->canResource); - } -} diff --git a/packages/core-admin/src/Forms/View/Components/Button.php b/packages/core-admin/src/Forms/View/Components/Button.php deleted file mode 100644 index a11ff8f..0000000 --- a/packages/core-admin/src/Forms/View/Components/Button.php +++ /dev/null @@ -1,135 +0,0 @@ - - * Save Changes - * - * - * - * Delete - * - * ``` - */ -class Button extends Component -{ - use HasAuthorizationProps; - - public string $type; - - public string $variant; - - public string $size; - - public ?string $icon; - - public ?string $iconRight; - - public bool $loading; - - public ?string $loadingText; - - public bool $disabled; - - public bool $hidden; - - public string $variantClasses; - - public string $sizeClasses; - - public function __construct( - string $type = 'button', - string $variant = 'primary', - string $size = 'md', - ?string $icon = null, - ?string $iconRight = null, - bool $loading = false, - ?string $loadingText = null, - bool $disabled = false, - // Authorization props - ?string $canGate = null, - mixed $canResource = null, - bool $canHide = false, - ) { - $this->type = $type; - $this->variant = $variant; - $this->size = $size; - $this->icon = $icon; - $this->iconRight = $iconRight; - $this->loading = $loading; - $this->loadingText = $loadingText; - - // Authorization setup - $this->canGate = $canGate; - $this->canResource = $canResource; - $this->canHide = $canHide; - - // Resolve states based on authorization - $this->disabled = $this->resolveDisabledState($disabled); - $this->hidden = $this->resolveHiddenState(); - - // Resolve variant and size classes - $this->variantClasses = $this->resolveVariantClasses(); - $this->sizeClasses = $this->resolveSizeClasses(); - } - - protected function resolveVariantClasses(): string - { - return match ($this->variant) { - 'primary' => 'bg-violet-600 hover:bg-violet-700 text-white focus:ring-violet-500 disabled:bg-violet-400', - 'secondary' => 'bg-gray-100 hover:bg-gray-200 text-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-gray-200 focus:ring-gray-500 disabled:bg-gray-100 disabled:dark:bg-gray-800', - 'danger' => 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500 disabled:bg-red-400', - 'ghost' => 'bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 focus:ring-gray-500', - default => 'bg-violet-600 hover:bg-violet-700 text-white focus:ring-violet-500 disabled:bg-violet-400', - }; - } - - protected function resolveSizeClasses(): string - { - return match ($this->size) { - 'sm' => 'px-3 py-1.5 text-sm', - 'lg' => 'px-6 py-3 text-base', - default => 'px-4 py-2 text-sm', - }; - } - - public function render() - { - return view('core-forms::components.forms.button'); - } -} diff --git a/packages/core-admin/src/Forms/View/Components/Checkbox.php b/packages/core-admin/src/Forms/View/Components/Checkbox.php deleted file mode 100644 index a9d8181..0000000 --- a/packages/core-admin/src/Forms/View/Components/Checkbox.php +++ /dev/null @@ -1,89 +0,0 @@ - - * ``` - */ -class Checkbox extends Component -{ - use HasAuthorizationProps; - - public string $id; - - public ?string $label; - - public ?string $description; - - public ?string $error; - - public string $labelPosition; - - public bool $disabled; - - public bool $hidden; - - public function __construct( - string $id, - ?string $label = null, - ?string $description = null, - ?string $error = null, - string $labelPosition = 'right', - bool $disabled = false, - // Authorization props - ?string $canGate = null, - mixed $canResource = null, - bool $canHide = false, - ) { - $this->id = $id; - $this->label = $label; - $this->description = $description; - $this->error = $error; - $this->labelPosition = $labelPosition; - - // Authorization setup - $this->canGate = $canGate; - $this->canResource = $canResource; - $this->canHide = $canHide; - - // Resolve states based on authorization - $this->disabled = $this->resolveDisabledState($disabled); - $this->hidden = $this->resolveHiddenState(); - } - - public function render() - { - return view('core-forms::components.forms.checkbox'); - } -} diff --git a/packages/core-admin/src/Forms/View/Components/FormGroup.php b/packages/core-admin/src/Forms/View/Components/FormGroup.php deleted file mode 100644 index 9e47675..0000000 --- a/packages/core-admin/src/Forms/View/Components/FormGroup.php +++ /dev/null @@ -1,88 +0,0 @@ - - * - *
- * ``` - */ -class FormGroup extends Component -{ - public ?string $label; - - public ?string $for; - - public ?string $error; - - public ?string $helper; - - public bool $required; - - public string $errorMessage; - - public function __construct( - ?string $label = null, - ?string $for = null, - ?string $error = null, - ?string $helper = null, - bool $required = false, - ) { - $this->label = $label; - $this->for = $for; - $this->error = $error; - $this->helper = $helper; - $this->required = $required; - - // Resolve error message from validation bag - $this->errorMessage = $this->resolveError(); - } - - protected function resolveError(): string - { - if (! $this->error) { - return ''; - } - - $errors = session('errors'); - - if (! $errors) { - return ''; - } - - return $errors->first($this->error) ?? ''; - } - - public function hasError(): bool - { - return ! empty($this->errorMessage); - } - - public function render() - { - return view('core-forms::components.forms.form-group'); - } -} diff --git a/packages/core-admin/src/Forms/View/Components/Input.php b/packages/core-admin/src/Forms/View/Components/Input.php deleted file mode 100644 index e9e3a45..0000000 --- a/packages/core-admin/src/Forms/View/Components/Input.php +++ /dev/null @@ -1,99 +0,0 @@ - - * ``` - */ -class Input extends Component -{ - use HasAuthorizationProps; - - public string $id; - - public ?string $label; - - public ?string $helper; - - public ?string $error; - - public string $type; - - public ?string $placeholder; - - public bool $disabled; - - public bool $hidden; - - public bool $required; - - public function __construct( - string $id, - ?string $label = null, - ?string $helper = null, - ?string $error = null, - string $type = 'text', - ?string $placeholder = null, - bool $disabled = false, - bool $required = false, - // Authorization props - ?string $canGate = null, - mixed $canResource = null, - bool $canHide = false, - ) { - $this->id = $id; - $this->label = $label; - $this->helper = $helper; - $this->error = $error; - $this->type = $type; - $this->placeholder = $placeholder; - $this->required = $required; - - // Authorization setup - $this->canGate = $canGate; - $this->canResource = $canResource; - $this->canHide = $canHide; - - // Resolve states based on authorization - $this->disabled = $this->resolveDisabledState($disabled); - $this->hidden = $this->resolveHiddenState(); - } - - public function render() - { - return view('core-forms::components.forms.input'); - } -} diff --git a/packages/core-admin/src/Forms/View/Components/Select.php b/packages/core-admin/src/Forms/View/Components/Select.php deleted file mode 100644 index 4dba7eb..0000000 --- a/packages/core-admin/src/Forms/View/Components/Select.php +++ /dev/null @@ -1,146 +0,0 @@ - label or flat array) - * - Placeholder option - * - Multiple selection support - * - Label with automatic `for` attribute - * - Helper text support - * - Error display from validation - * - Dark mode support - * - * Usage: - * ```blade - * - * ``` - */ -class Select extends Component -{ - use HasAuthorizationProps; - - public string $id; - - public ?string $label; - - public ?string $helper; - - public ?string $error; - - public ?string $placeholder; - - public array $options; - - public array $normalizedOptions; - - public bool $multiple; - - public bool $disabled; - - public bool $hidden; - - public bool $required; - - public function __construct( - string $id, - array $options = [], - ?string $label = null, - ?string $helper = null, - ?string $error = null, - ?string $placeholder = null, - bool $multiple = false, - bool $disabled = false, - bool $required = false, - // Authorization props - ?string $canGate = null, - mixed $canResource = null, - bool $canHide = false, - ) { - $this->id = $id; - $this->label = $label; - $this->helper = $helper; - $this->error = $error; - $this->placeholder = $placeholder; - $this->options = $options; - $this->multiple = $multiple; - $this->required = $required; - - // Normalize options to value => label format - $this->normalizedOptions = $this->normalizeOptions($options); - - // Authorization setup - $this->canGate = $canGate; - $this->canResource = $canResource; - $this->canHide = $canHide; - - // Resolve states based on authorization - $this->disabled = $this->resolveDisabledState($disabled); - $this->hidden = $this->resolveHiddenState(); - } - - /** - * Normalize options to ensure consistent value => label format. - */ - protected function normalizeOptions(array $options): array - { - $normalized = []; - - foreach ($options as $key => $value) { - // Handle grouped options (optgroup) - if (is_array($value) && ! isset($value['label'])) { - $normalized[$key] = $this->normalizeOptions($value); - - continue; - } - - // Handle array format: ['label' => 'Display', 'value' => 'actual'] - if (is_array($value) && isset($value['label'])) { - $normalized[$value['value'] ?? $key] = $value['label']; - - continue; - } - - // Handle flat array: ['option1', 'option2'] - if (is_int($key)) { - $normalized[$value] = $value; - - continue; - } - - // Handle associative array: ['value' => 'Label'] - $normalized[$key] = $value; - } - - return $normalized; - } - - public function render() - { - return view('core-forms::components.forms.select'); - } -} diff --git a/packages/core-admin/src/Forms/View/Components/Textarea.php b/packages/core-admin/src/Forms/View/Components/Textarea.php deleted file mode 100644 index b4eb6df..0000000 --- a/packages/core-admin/src/Forms/View/Components/Textarea.php +++ /dev/null @@ -1,104 +0,0 @@ - - * ``` - */ -class Textarea extends Component -{ - use HasAuthorizationProps; - - public string $id; - - public ?string $label; - - public ?string $helper; - - public ?string $error; - - public ?string $placeholder; - - public int $rows; - - public bool $autoResize; - - public bool $disabled; - - public bool $hidden; - - public bool $required; - - public function __construct( - string $id, - ?string $label = null, - ?string $helper = null, - ?string $error = null, - ?string $placeholder = null, - int $rows = 3, - bool $autoResize = false, - bool $disabled = false, - bool $required = false, - // Authorization props - ?string $canGate = null, - mixed $canResource = null, - bool $canHide = false, - ) { - $this->id = $id; - $this->label = $label; - $this->helper = $helper; - $this->error = $error; - $this->placeholder = $placeholder; - $this->rows = $rows; - $this->autoResize = $autoResize; - $this->required = $required; - - // Authorization setup - $this->canGate = $canGate; - $this->canResource = $canResource; - $this->canHide = $canHide; - - // Resolve states based on authorization - $this->disabled = $this->resolveDisabledState($disabled); - $this->hidden = $this->resolveHiddenState(); - } - - public function render() - { - return view('core-forms::components.forms.textarea'); - } -} diff --git a/packages/core-admin/src/Forms/View/Components/Toggle.php b/packages/core-admin/src/Forms/View/Components/Toggle.php deleted file mode 100644 index 4530d30..0000000 --- a/packages/core-admin/src/Forms/View/Components/Toggle.php +++ /dev/null @@ -1,127 +0,0 @@ - - * ``` - */ -class Toggle extends Component -{ - use HasAuthorizationProps; - - public string $id; - - public ?string $label; - - public ?string $description; - - public ?string $error; - - public string $size; - - public bool $instantSave; - - public ?string $instantSaveMethod; - - public bool $disabled; - - public bool $hidden; - - public string $trackClasses; - - public string $thumbClasses; - - public function __construct( - string $id, - ?string $label = null, - ?string $description = null, - ?string $error = null, - string $size = 'md', - bool $instantSave = false, - ?string $instantSaveMethod = null, - bool $disabled = false, - // Authorization props - ?string $canGate = null, - mixed $canResource = null, - bool $canHide = false, - ) { - $this->id = $id; - $this->label = $label; - $this->description = $description; - $this->error = $error; - $this->size = $size; - $this->instantSave = $instantSave; - $this->instantSaveMethod = $instantSaveMethod; - - // Authorization setup - $this->canGate = $canGate; - $this->canResource = $canResource; - $this->canHide = $canHide; - - // Resolve states based on authorization - $this->disabled = $this->resolveDisabledState($disabled); - $this->hidden = $this->resolveHiddenState(); - - // Resolve size classes - [$this->trackClasses, $this->thumbClasses] = $this->resolveSizeClasses(); - } - - protected function resolveSizeClasses(): array - { - return match ($this->size) { - 'sm' => ['w-8 h-4', 'w-3 h-3'], - 'lg' => ['w-14 h-7', 'w-6 h-6'], - default => ['w-11 h-6', 'w-5 h-5'], - }; - } - - /** - * Get the wire:change directive for instant save. - */ - public function wireChange(): ?string - { - if (! $this->instantSave) { - return null; - } - - // Default to 'save' method if not specified - return $this->instantSaveMethod ?? 'save'; - } - - public function render() - { - return view('core-forms::components.forms.toggle'); - } -} diff --git a/packages/core-admin/src/Mod/Hub/Boot.php b/packages/core-admin/src/Mod/Hub/Boot.php deleted file mode 100644 index 5f09f3c..0000000 --- a/packages/core-admin/src/Mod/Hub/Boot.php +++ /dev/null @@ -1,268 +0,0 @@ - - */ - public static array $listens = [ - AdminPanelBooting::class => 'onAdminPanel', - ]; - - public function boot(): void - { - $this->loadMigrationsFrom(__DIR__.'/Migrations'); - $this->loadTranslationsFrom(__DIR__.'/Lang', 'hub'); - - app(AdminMenuRegistry::class)->register($this); - } - - /** - * Admin menu items for Hub (platform base items). - */ - public function adminMenuItems(): array - { - return [ - // Dashboard - [ - 'group' => 'dashboard', - 'priority' => 0, - 'item' => fn () => [ - 'label' => 'Dashboard', - 'href' => route('hub.dashboard'), - 'icon' => 'gauge', - 'color' => 'indigo', - 'active' => request()->routeIs('hub.dashboard'), - ], - ], - // Workspaces - Overview - [ - 'group' => 'workspaces', - 'priority' => 10, - 'item' => fn () => [ - 'label' => 'Overview', - 'href' => route('hub.sites'), - 'icon' => 'layer-group', - 'color' => 'blue', - 'active' => request()->routeIs('hub.sites') || request()->routeIs('hub.sites.settings'), - ], - ], - // Workspaces - Content - [ - 'group' => 'workspaces', - 'priority' => 20, - 'item' => fn () => [ - 'label' => 'Content', - 'href' => route('hub.content-manager', ['workspace' => app(WorkspaceService::class)->currentSlug()]), - 'icon' => 'file-lines', - 'color' => 'emerald', - 'active' => request()->routeIs('hub.content-manager') || request()->routeIs('hub.content-editor*'), - ], - ], - // Workspaces - Configuration - [ - 'group' => 'workspaces', - 'priority' => 30, - 'item' => fn () => [ - 'label' => 'Configuration', - 'href' => '/hub/config', - 'icon' => 'sliders', - 'color' => 'slate', - 'active' => request()->is('hub/config*'), - ], - ], - // Account - Profile - [ - 'group' => 'settings', - 'priority' => 10, - 'item' => fn () => [ - 'label' => 'Profile', - 'href' => route('hub.account'), - 'icon' => 'user', - 'color' => 'sky', - 'active' => request()->routeIs('hub.account') && ! request()->routeIs('hub.account.*'), - ], - ], - // Account - Settings - [ - 'group' => 'settings', - 'priority' => 20, - 'item' => fn () => [ - 'label' => 'Settings', - 'href' => route('hub.account.settings'), - 'icon' => 'gear', - 'color' => 'zinc', - 'active' => request()->routeIs('hub.account.settings*'), - ], - ], - // Account - Usage (consolidated: usage overview, boosts, AI services) - [ - 'group' => 'settings', - 'priority' => 30, - 'item' => fn () => [ - 'label' => 'Usage', - 'href' => route('hub.account.usage'), - 'icon' => 'chart-pie', - 'color' => 'amber', - 'active' => request()->routeIs('hub.account.usage'), - ], - ], - // Admin - Platform - [ - 'group' => 'admin', - 'priority' => 10, - 'admin' => true, - 'item' => fn () => [ - 'label' => 'Platform', - 'href' => route('hub.platform'), - 'icon' => 'crown', - 'color' => 'amber', - 'active' => request()->routeIs('hub.platform*'), - ], - ], - // Admin - Entitlements - [ - 'group' => 'admin', - 'priority' => 11, - 'admin' => true, - 'item' => fn () => [ - 'label' => 'Entitlements', - 'href' => route('hub.entitlements'), - 'icon' => 'key', - 'color' => 'violet', - 'active' => request()->routeIs('hub.entitlements*'), - ], - ], - // Admin - Services - [ - 'group' => 'admin', - 'priority' => 13, - 'admin' => true, - 'item' => fn () => [ - 'label' => 'Services', - 'href' => route('hub.admin.services'), - 'icon' => 'cubes', - 'color' => 'indigo', - 'active' => request()->routeIs('hub.admin.services'), - ], - ], - // Admin - Infrastructure - [ - 'group' => 'admin', - 'priority' => 60, - 'admin' => true, - 'item' => fn () => [ - 'label' => 'Infrastructure', - 'icon' => 'server', - 'color' => 'slate', - 'active' => request()->routeIs('hub.console*') || request()->routeIs('hub.databases*') || request()->routeIs('hub.deployments*') || request()->routeIs('hub.honeypot'), - 'children' => [ - ['label' => 'Console', 'icon' => 'terminal', 'href' => route('hub.console'), 'active' => request()->routeIs('hub.console*')], - ['label' => 'Databases', 'icon' => 'database', 'href' => route('hub.databases'), 'active' => request()->routeIs('hub.databases*')], - ['label' => 'Deployments', 'icon' => 'rocket', 'href' => route('hub.deployments'), 'active' => request()->routeIs('hub.deployments*')], - ['label' => 'Honeypot', 'icon' => 'bug', 'href' => route('hub.honeypot'), 'active' => request()->routeIs('hub.honeypot')], - ], - ], - ], - // Admin - Config - [ - 'group' => 'admin', - 'priority' => 85, - 'admin' => true, - 'item' => fn () => [ - 'label' => 'Config', - 'href' => route('admin.config'), - 'icon' => 'sliders', - 'color' => 'zinc', - 'active' => request()->routeIs('admin.config'), - ], - ], - // Admin - Workspaces - [ - 'group' => 'admin', - 'priority' => 15, - 'admin' => true, - 'item' => fn () => [ - 'label' => 'Workspaces', - 'href' => route('hub.admin.workspaces'), - 'icon' => 'layer-group', - 'color' => 'blue', - 'active' => request()->routeIs('hub.admin.workspaces'), - ], - ], - ]; - } - - public function register(): void - { - // - } - - // ------------------------------------------------------------------------- - // Event-driven handlers - // ------------------------------------------------------------------------- - - public function onAdminPanel(AdminPanelBooting $event): void - { - $event->views($this->moduleName, __DIR__.'/View/Blade'); - - if (file_exists(__DIR__.'/Routes/admin.php')) { - $event->routes(fn () => require __DIR__.'/Routes/admin.php'); - } - - // Core admin components - $event->livewire('hub.admin.dashboard', View\Modal\Admin\Dashboard::class); - $event->livewire('hub.admin.content', View\Modal\Admin\Content::class); - $event->livewire('hub.admin.content-manager', View\Modal\Admin\ContentManager::class); - $event->livewire('hub.admin.content-editor', View\Modal\Admin\ContentEditor::class); - $event->livewire('hub.admin.sites', View\Modal\Admin\Sites::class); - $event->livewire('hub.admin.console', View\Modal\Admin\Console::class); - $event->livewire('hub.admin.databases', View\Modal\Admin\Databases::class); - $event->livewire('hub.admin.profile', View\Modal\Admin\Profile::class); - $event->livewire('hub.admin.settings', View\Modal\Admin\Settings::class); - $event->livewire('hub.admin.account-usage', View\Modal\Admin\AccountUsage::class); - $event->livewire('hub.admin.site-settings', View\Modal\Admin\SiteSettings::class); - $event->livewire('hub.admin.deployments', View\Modal\Admin\Deployments::class); - $event->livewire('hub.admin.platform', View\Modal\Admin\Platform::class); - $event->livewire('hub.admin.platform-user', View\Modal\Admin\PlatformUser::class); - $event->livewire('hub.admin.prompt-manager', View\Modal\Admin\PromptManager::class); - $event->livewire('hub.admin.waitlist-manager', View\Modal\Admin\WaitlistManager::class); - $event->livewire('hub.admin.workspace-switcher', View\Modal\Admin\WorkspaceSwitcher::class); - $event->livewire('hub.admin.wp-connector-settings', View\Modal\Admin\WpConnectorSettings::class); - $event->livewire('hub.admin.services-admin', View\Modal\Admin\ServicesAdmin::class); - $event->livewire('hub.admin.service-manager', View\Modal\Admin\ServiceManager::class); - - // Entitlement - $event->livewire('hub.admin.entitlement.dashboard', View\Modal\Admin\Entitlement\Dashboard::class); - $event->livewire('hub.admin.entitlement.feature-manager', View\Modal\Admin\Entitlement\FeatureManager::class); - $event->livewire('hub.admin.entitlement.package-manager', View\Modal\Admin\Entitlement\PackageManager::class); - - // Global UI components - $event->livewire('hub.admin.global-search', View\Modal\Admin\GlobalSearch::class); - $event->livewire('hub.admin.activity-log', View\Modal\Admin\ActivityLog::class); - - // Security - $event->livewire('hub.admin.honeypot', View\Modal\Admin\Honeypot::class); - - // Workspace management (Tenant module) - $event->livewire('tenant.admin.workspace-manager', \Core\Mod\Tenant\View\Modal\Admin\WorkspaceManager::class); - $event->livewire('tenant.admin.workspace-details', \Core\Mod\Tenant\View\Modal\Admin\WorkspaceDetails::class); - } -} diff --git a/packages/core-admin/src/Mod/Hub/Controllers/TeapotController.php b/packages/core-admin/src/Mod/Hub/Controllers/TeapotController.php deleted file mode 100644 index d50113b..0000000 --- a/packages/core-admin/src/Mod/Hub/Controllers/TeapotController.php +++ /dev/null @@ -1,158 +0,0 @@ -userAgent(); - $botName = HoneypotHit::detectBot($userAgent); - $path = $request->path(); - $severity = HoneypotHit::severityForPath($path); - $ip = $request->ip(); - - // Rate limit honeypot logging to prevent DoS via log flooding. - // Each IP gets limited to N log entries per time window. - $rateLimitKey = 'honeypot:log:'.$ip; - $maxAttempts = (int) config('core.bouncer.honeypot.rate_limit_max', 10); - $decaySeconds = (int) config('core.bouncer.honeypot.rate_limit_window', 60); - - if (! RateLimiter::tooManyAttempts($rateLimitKey, $maxAttempts)) { - RateLimiter::hit($rateLimitKey, $decaySeconds); - - // Optional services - use app() since route skips web middleware - $geoIp = app(DetectLocation::class); - - HoneypotHit::create([ - 'ip_address' => $ip, - 'user_agent' => substr($userAgent ?? '', 0, 1000), - 'referer' => substr($request->header('Referer', ''), 0, 2000), - 'path' => $path, - 'method' => $request->method(), - 'headers' => $this->sanitizeHeaders($request->headers->all()), - 'country' => $geoIp?->getCountryCode($ip), - 'city' => $geoIp?->getCity($ip), - 'is_bot' => $botName !== null, - 'bot_name' => $botName, - 'severity' => $severity, - ]); - } - - // Auto-block critical hits (active probing) if enabled in config. - // Skip localhost in dev to avoid blocking yourself. - $autoBlockEnabled = config('core.bouncer.honeypot.auto_block_critical', true); - $isLocalhost = in_array($ip, ['127.0.0.1', '::1'], true); - $isCritical = $severity === HoneypotHit::getSeverityCritical(); - - if ($autoBlockEnabled && $isCritical && ! $isLocalhost) { - app(BlocklistService::class)->block($ip, 'honeypot_critical'); - } - - // Return the 418 I'm a teapot response - return response($this->teapotBody(), 418, [ - 'Content-Type' => 'text/html; charset=utf-8', - 'X-Powered-By' => 'Earl Grey', - 'X-Severity' => $severity, - ]); - } - - /** - * Remove sensitive headers before storing. - */ - protected function sanitizeHeaders(array $headers): array - { - $sensitive = ['cookie', 'authorization', 'x-csrf-token', 'x-xsrf-token']; - - foreach ($sensitive as $key) { - unset($headers[$key]); - } - - return $headers; - } - - /** - * The teapot response body. - */ - protected function teapotBody(): string - { - return <<<'HTML' - - - - - - 418 I'm a Teapot - - - -
🫖
-

418 I'm a Teapot

-

The server refuses to brew coffee because it is, permanently, a teapot.

-

- RFC 2324 · - RFC 7168 -

- - -HTML; - } -} diff --git a/packages/core-admin/src/Mod/Hub/Database/Seeders/ServiceSeeder.php b/packages/core-admin/src/Mod/Hub/Database/Seeders/ServiceSeeder.php deleted file mode 100644 index 07554be..0000000 --- a/packages/core-admin/src/Mod/Hub/Database/Seeders/ServiceSeeder.php +++ /dev/null @@ -1,110 +0,0 @@ -> - */ - protected array $services = [ - \Service\Hub\Boot::class, // Internal service - \Service\Bio\Boot::class, - \Service\Social\Boot::class, - \Service\Analytics\Boot::class, - \Service\Trust\Boot::class, - \Service\Notify\Boot::class, - \Service\Support\Boot::class, - \Service\Commerce\Boot::class, - \Service\Agentic\Boot::class, - ]; - - public function run(): void - { - if (! Schema::hasTable('platform_services')) { - $this->command?->warn('platform_services table does not exist. Run migrations first.'); - - return; - } - - $seeded = 0; - $updated = 0; - - foreach ($this->services as $serviceClass) { - if (! class_exists($serviceClass)) { - $this->command?->warn("Service class not found: {$serviceClass}"); - - continue; - } - - if (! method_exists($serviceClass, 'definition')) { - $this->command?->warn("Service {$serviceClass} does not have definition()"); - - continue; - } - - $definition = $serviceClass::definition(); - - if (! $definition) { - continue; - } - - $existing = Service::where('code', $definition['code'])->first(); - - if ($existing) { - // Sync core fields from definition (code is source of truth) - $existing->update([ - 'module' => $definition['module'], - 'name' => $definition['name'], - 'tagline' => $definition['tagline'] ?? null, - 'description' => $definition['description'] ?? null, - 'icon' => $definition['icon'] ?? null, - 'color' => $definition['color'] ?? null, - 'entitlement_code' => $definition['entitlement_code'] ?? null, - 'sort_order' => $definition['sort_order'] ?? 50, - // Domain routing - only set if not already configured (admin can override) - 'marketing_domain' => $existing->marketing_domain ?? ($definition['marketing_domain'] ?? null), - 'website_class' => $existing->website_class ?? ($definition['website_class'] ?? null), - ]); - $updated++; - } else { - Service::create([ - 'code' => $definition['code'], - 'module' => $definition['module'], - 'name' => $definition['name'], - 'tagline' => $definition['tagline'] ?? null, - 'description' => $definition['description'] ?? null, - 'icon' => $definition['icon'] ?? null, - 'color' => $definition['color'] ?? null, - 'marketing_domain' => $definition['marketing_domain'] ?? null, - 'website_class' => $definition['website_class'] ?? null, - 'entitlement_code' => $definition['entitlement_code'] ?? null, - 'sort_order' => $definition['sort_order'] ?? 50, - 'is_enabled' => true, - 'is_public' => true, - 'is_featured' => false, - ]); - $seeded++; - } - } - - $this->command?->info("Services seeded: {$seeded} created, {$updated} updated."); - } -} diff --git a/packages/core-admin/src/Mod/Hub/Lang/en_GB/hub.php b/packages/core-admin/src/Mod/Hub/Lang/en_GB/hub.php deleted file mode 100644 index fd3278c..0000000 --- a/packages/core-admin/src/Mod/Hub/Lang/en_GB/hub.php +++ /dev/null @@ -1,1034 +0,0 @@ - [ - 'title' => 'Dashboard', - 'subtitle' => 'Your creator toolkit at a glance', - 'greeting' => 'Hello :name', - 'greeting_subtitle' => 'What would you like to work on today?', - 'your_workspaces' => 'Your Workspaces', - 'manage_all' => 'Manage All', - 'enabled_services' => 'Enabled services', - 'no_services' => 'No services enabled yet', - 'add_services' => 'Add Services', - 'manage_workspace' => 'Manage', - 'service_count' => 'service|services', - 'renews_on' => 'Renews :date', - 'manage_billing' => 'Manage Billing', - 'no_workspaces_title' => 'No workspaces yet', - 'no_workspaces_description' => 'Create your first workspace to get started with Host UK services.', - 'create_workspace' => 'Create Workspace', - 'learn_more' => 'Learn More', - ], - - 'actions' => [ - 'edit_content' => 'Edit Content', - ], - - 'sections' => [ - 'recent_activity' => 'Recent Activity', - 'quick_actions' => 'Quick Actions', - ], - - 'quick_actions' => [ - 'edit_content' => [ - 'title' => 'Edit Content', - 'subtitle' => 'Manage WordPress content', - ], - 'manage_workspaces' => [ - 'title' => 'Manage Workspaces', - 'subtitle' => 'View and configure workspaces', - ], - 'server_console' => [ - 'title' => 'Server Console', - 'subtitle' => 'Access server terminal', - ], - 'view_analytics' => [ - 'title' => 'View Analytics', - 'subtitle' => 'Traffic and performance', - ], - 'profile' => [ - 'title' => 'Profile', - 'subtitle' => 'Manage your account', - ], - ], - - // Console page - 'console' => [ - 'title' => 'Server Console', - 'subtitle' => 'Secure terminal access to your hosted applications', - 'labels' => [ - 'select_server' => 'Select Server', - 'terminal' => 'Terminal', - 'enter_command' => 'Enter command...', - 'connecting' => 'Connecting to :name...', - 'establishing_connection' => 'Establishing secure connection via Coolify API...', - 'connected' => 'Connected successfully.', - 'select_server_prompt' => 'Select a server from the list to open a terminal session', - 'terminal_disabled' => 'Terminal functionality will be enabled once Coolify API integration is complete', - ], - 'coolify' => [ - 'title' => 'Coolify Integration', - 'description' => 'This console will connect to your Coolify instance for secure terminal access to containers.', - ], - ], - - // AI Services page - 'ai_services' => [ - 'title' => 'AI Services', - 'subtitle' => 'Configure AI providers for content generation in Host Social.', - 'labels' => [ - 'api_key' => 'API Key', - 'secret_key' => 'Secret Key', - 'model' => 'Model', - 'active' => 'Active', - 'save' => 'Save', - 'saving' => 'Saving...', - ], - 'providers' => [ - 'claude' => [ - 'name' => 'Claude', - 'title' => 'Claude (Anthropic)', - 'api_key_link' => 'Generate an API key from Anthropic Console', - ], - 'gemini' => [ - 'name' => 'Gemini', - 'title' => 'Gemini (Google)', - 'api_key_link' => 'Generate an API key from Google AI Studio', - ], - 'openai' => [ - 'name' => 'OpenAI', - 'title' => 'OpenAI', - 'api_key_link' => 'Generate an API key from OpenAI Platform', - ], - ], - ], - - // Prompts page - 'prompts' => [ - 'title' => 'Prompt Manager', - 'subtitle' => 'Manage AI prompts for content generation', - 'labels' => [ - 'new_prompt' => 'New Prompt', - 'search_prompts' => 'Search prompts...', - 'all_categories' => 'All categories', - 'all_models' => 'All models', - 'empty' => 'No prompts found. Create your first prompt to get started.', - ], - 'editor' => [ - 'edit_title' => 'Edit Prompt', - 'new_title' => 'New Prompt', - 'name' => 'Name', - 'name_placeholder' => 'help-article-generator', - 'category' => 'Category', - 'description' => 'Description', - 'description_placeholder' => 'What does this prompt do?', - 'model' => 'Model', - 'temperature' => 'Temperature', - 'max_tokens' => 'Max Tokens', - 'system_prompt' => 'System Prompt', - 'user_template' => 'User Template', - 'user_template_hint' => 'Use @{{variable}} for template variables', - 'template_variables' => 'Template Variables', - 'add_variable' => 'Add Variable', - 'variable_name' => 'variable_name', - 'variable_description' => 'Description', - 'variable_default' => 'Default value', - 'no_variables' => 'No variables defined', - 'active' => 'Active', - 'active_description' => 'Enable this prompt for use in content generation', - 'version_history' => 'Version History', - 'cancel' => 'Cancel', - 'update_prompt' => 'Update Prompt', - 'create_prompt' => 'Create Prompt', - ], - 'categories' => [ - 'content' => 'Content', - 'seo' => 'SEO', - 'refinement' => 'Refinement', - 'translation' => 'Translation', - 'analysis' => 'Analysis', - ], - 'models' => [ - 'claude' => 'Claude (Anthropic)', - 'gemini' => 'Gemini (Google)', - ], - 'versions' => [ - 'title' => 'Version History', - 'version' => 'Version :number', - 'by' => 'by :name', - 'restore' => 'Restore', - 'no_history' => 'No version history available', - ], - ], - - // Services admin page translations - 'services' => [ - // Tab labels for each service - 'tabs' => [ - 'dashboard' => 'Dashboard', - 'pages' => 'Pages', - 'projects' => 'Projects', - 'websites' => 'Websites', - 'goals' => 'Goals', - 'subscribers' => 'Subscribers', - 'campaigns' => 'Campaigns', - 'notifications' => 'Widgets', - 'accounts' => 'Accounts', - 'posts' => 'Posts', - 'inbox' => 'Inbox', - 'settings' => 'Settings', - 'orders' => 'Orders', - 'subscriptions' => 'Subscriptions', - 'coupons' => 'Coupons', - ], - - // Table column headers - 'columns' => [ - 'namespace' => 'Namespace', - 'type' => 'Type', - 'status' => 'Status', - 'clicks' => 'Clicks', - 'project' => 'Project', - 'pages' => 'Pages', - 'created' => 'Created', - 'website' => 'Mod', - 'name' => 'Name', - 'host' => 'Host', - 'pageviews_mtd' => 'Pageviews (MTD)', - 'subscribers' => 'Subscribers', - 'endpoint' => 'Endpoint', - 'subscribed' => 'Subscribed', - 'campaign' => 'Campaign', - 'stats' => 'Stats', - 'widgets' => 'Widgets', - 'widget' => 'Widget', - 'impressions' => 'Impressions', - 'conversions' => 'Conversions', - 'performance' => 'Performance', - ], - - // Status labels - 'status' => [ - 'active' => 'Active', - 'disabled' => 'Disabled', - 'inactive' => 'Inactive', - 'sent' => 'Sent', - 'sending' => 'Sending', - 'scheduled' => 'Scheduled', - 'draft' => 'Draft', - 'failed' => 'Failed', - ], - - // Action buttons and links - 'actions' => [ - 'manage_biohost' => 'Manage Bio', - 'manage_analytics' => 'Manage Analytics', - 'manage_notifyhost' => 'Manage Notify', - 'manage_trusthost' => 'Manage Trust', - 'manage_supporthost' => 'Manage Support', - 'manage_commerce' => 'Manage Commerce', - 'create_page' => 'Create Page', - 'manage_projects' => 'Manage Projects', - 'add_website' => 'Add Mod', - 'view_all' => 'View All', - 'create_campaign' => 'Create Campaign', - 'create_goal' => 'Create Goal', - ], - - // Section headings - 'headings' => [ - 'your_bio_pages' => 'Your Bio Pages', - 'all_pages' => 'All Pages', - 'projects' => 'Projects', - 'websites_by_pageviews' => 'Websites by Pageviews', - 'all_websites' => 'All Websites', - 'goals_coming_soon' => 'Goals management coming soon', - 'websites_by_subscribers' => 'Websites by Subscribers', - 'recent_subscribers' => 'Recent Subscribers', - 'campaigns' => 'Campaigns', - 'all_campaigns' => 'All Campaigns', - 'widgets_by_impressions' => 'Widgets by Impressions', - 'top_pages' => 'Top Pages', - 'pageviews_trend' => 'Pageviews Trend', - 'traffic_sources' => 'Traffic Sources', - 'devices' => 'Devices', - ], - - // Empty state messages - 'empty' => [ - 'bio_pages' => 'No bio pages found. Create your first one!', - 'pages' => 'No pages found', - 'projects' => 'No projects found', - 'websites' => 'No websites found', - 'subscribers' => 'No subscribers found', - 'campaigns' => 'No campaigns found', - 'widgets' => 'No widgets found', - 'tickets' => 'No tickets found', - 'orders' => 'No orders found', - 'subscriptions' => 'No subscriptions found', - 'coupons' => 'No coupons found', - 'page_data' => 'No page data yet', - 'no_websites_title' => 'No websites tracked', - 'no_websites_description' => 'Add a website to start tracking pageviews and visitor analytics.', - 'no_goals_title' => 'No goals defined', - 'no_goals_description' => 'Create conversion goals to track important actions on your websites.', - 'no_traffic_data' => 'No traffic data yet', - 'no_device_data' => 'No device data yet', - 'no_subscribers_title' => 'No subscribers yet', - 'no_campaigns_title' => 'No campaigns yet', - ], - - // Miscellaneous - 'misc' => [ - 'na' => 'N/A', - 'sent_count' => ':count sent', - ], - - // Summary bar metrics - 'summary' => [ - 'pageviews' => 'Pageviews', - 'visitors' => 'Visitors', - 'bounce_rate' => 'Bounce Rate', - 'avg_duration' => 'Avg. Duration', - ], - - // Date range options - 'date_range' => [ - '7d' => 'Last 7 days', - '30d' => 'Last 30 days', - '90d' => 'Last 90 days', - 'all' => 'All time', - ], - - // Analytics acquisition channels - 'analytics' => [ - 'channels' => [ - 'direct' => 'Direct', - 'search' => 'Search', - 'social' => 'Social', - 'referral' => 'Referral', - ], - 'devices' => [ - 'desktop' => 'Desktop', - 'mobile' => 'Mobile', - 'tablet' => 'Tablet', - ], - ], - - // Service names (for tabs and titles) - 'names' => [ - 'bio' => 'Bio', - 'social' => 'Social', - 'analytics' => 'Analytics', - 'notify' => 'Notify', - 'trust' => 'Trust', - 'support' => 'Support', - 'commerce' => 'Commerce', - ], - - // Support service contextual metrics - 'support' => [ - 'inbox_health' => 'Inbox Health', - 'open_tickets' => 'Open Tickets', - 'avg_response_time' => 'Avg Response Time', - 'oldest' => 'Oldest', - 'todays_activity' => "Today's Activity", - 'new_today' => 'New Conversations', - 'resolved_today' => 'Resolved Today', - 'messages_sent' => 'Messages Sent', - 'performance' => 'Performance (This Month)', - 'first_response' => 'First Response Time', - 'resolution_time' => 'Resolution Time', - 'na' => 'N/A', - 'recent_conversations' => 'Recent Conversations', - 'view_inbox' => 'View Inbox', - 'empty_inbox' => 'No conversations yet', - 'empty_inbox_description' => 'Messages will appear here when customers reach out.', - 'unknown' => 'Unknown', - 'open_full_inbox' => 'Open full inbox', - 'open_settings' => 'Open settings', - ], - - // Stat card labels - Bio - 'stats' => [ - 'bio' => [ - 'total_pages' => 'Total Pages', - 'active_pages' => 'Active Pages', - 'total_clicks' => 'Total Clicks', - 'projects' => 'Projects', - ], - 'social' => [ - 'total_accounts' => 'Total Accounts', - 'active_accounts' => 'Active Accounts', - 'scheduled_posts' => 'Scheduled Posts', - 'published_posts' => 'Published Posts', - ], - 'analytics' => [ - 'total_websites' => 'Total Websites', - 'active_websites' => 'Active Websites', - 'pageviews_today' => 'Pageviews Today', - 'sessions_today' => 'Sessions Today', - ], - 'notify' => [ - 'websites' => 'Websites', - 'active_subscribers' => 'Active Subscribers', - 'active_campaigns' => 'Active Campaigns', - 'messages_today' => 'Messages Today', - ], - 'trust' => [ - 'total_campaigns' => 'Total Campaigns', - 'active_campaigns' => 'Active Campaigns', - 'total_widgets' => 'Total Widgets', - 'total_impressions' => 'Total Impressions', - ], - ], - - // Trust module specific metrics - 'trust' => [ - 'metrics' => [ - 'impressions' => 'Impressions', - 'clicks' => 'Clicks', - 'conversions' => 'Conversions', - 'ctr' => 'CTR', - 'cvr' => 'CVR', - ], - 'support' => [ - 'open_tickets' => 'Open Tickets', - 'unread_messages' => 'Unread Messages', - 'avg_response_time' => 'Avg Response Time', - 'resolved_today' => 'Resolved Today', - ], - 'commerce' => [ - 'total_orders' => 'Total Orders', - 'pending_orders' => 'Pending Orders', - 'active_subscriptions' => 'Active Subscriptions', - 'revenue_mtd' => 'Revenue (MTD)', - ], - ], - ], - - // Workspace Settings page - 'workspace_settings' => [ - 'title' => 'Workspace Settings', - 'subtitle' => 'Configure your workspace deployment and environment', - 'under_construction' => 'Under Construction', - 'coming_soon_message' => 'Workspace settings management is currently being built. This page will allow you to configure deployment settings, environment variables, SSL certificates, and more.', - ], - - // Global Search - 'search' => [ - 'button' => 'Search...', - 'placeholder' => 'Search pages, workspaces, settings...', - 'no_results' => 'No results found for ":query"', - 'navigate' => 'to navigate', - 'select' => 'to select', - 'close' => 'to close', - 'start_typing' => 'Start typing to search...', - 'tips' => 'Search pages, settings, and more', - 'recent' => 'Recent', - 'clear_recent' => 'Clear', - 'remove' => 'Remove', - ], - - // Workspace Switcher - 'workspace_switcher' => [ - 'title' => 'Switch Workspace', - ], - - // Workspaces page - 'workspaces' => [ - 'title' => 'Workspaces', - 'subtitle' => 'Manage your workspaces', - 'add' => 'Add Workspace', - 'empty' => 'No workspaces found.', - 'active' => 'Active', - 'activate' => 'Activate', - 'activated' => 'Workspace activated', - ], - - /* - |-------------------------------------------------------------------------- - | Usage Dashboard - |-------------------------------------------------------------------------- - */ - 'usage' => [ - 'title' => 'Usage & Limits', - 'subtitle' => 'Monitor your workspace usage and available features', - - 'packages' => [ - 'title' => 'Active Packages', - 'subtitle' => 'Your current subscription packages', - 'empty' => 'No active packages', - 'empty_hint' => 'Contact support to activate your subscription', - 'renews' => 'Renews :time', - ], - - 'badges' => [ - 'base' => 'Base', - 'addon' => 'Addon', - 'active' => 'Active', - 'not_included' => 'Not included', - 'unlimited' => 'Unlimited', - 'enabled' => 'Enabled', - ], - - 'categories' => [ - 'general' => 'General', - ], - - 'warnings' => [ - 'approaching_limit' => 'Approaching limit - :remaining remaining', - ], - - 'empty' => [ - 'title' => 'No usage data available', - 'hint' => 'Usage will appear here once you start using features', - ], - - 'active_boosts' => [ - 'title' => 'Active Boosts', - 'subtitle' => 'One-time top-ups for additional capacity', - 'remaining' => 'remaining', - ], - - 'duration' => [ - 'cycle_bound' => 'Expires at cycle end', - 'expires' => 'Expires :time', - 'permanent' => 'Permanent', - ], - - 'cta' => [ - 'title' => 'Need more capacity?', - 'subtitle' => 'Upgrade your package or add boosts to increase your limits', - 'add_boosts' => 'Add Boosts', - 'view_plans' => 'View Plans', - ], - ], - - /* - |-------------------------------------------------------------------------- - | Boost Purchase - |-------------------------------------------------------------------------- - */ - 'boosts' => [ - 'title' => 'Purchase Boost', - 'subtitle' => 'Add one-time top-ups to increase your limits', - - 'types' => [ - 'unlimited' => 'Unlimited', - 'enable' => 'Enable', - ], - - 'duration' => [ - 'cycle_bound' => 'Expires at cycle end', - 'limited' => 'Limited duration', - 'permanent' => 'Permanent', - ], - - 'actions' => [ - 'purchase' => 'Purchase', - 'back' => 'Back to Usage', - ], - - 'empty' => [ - 'title' => 'No boosts available', - 'hint' => 'Boost options will appear here when configured', - ], - - 'info' => [ - 'title' => 'About Boosts', - 'cycle_bound' => 'Expires at the end of your billing cycle, unused capacity does not roll over', - 'duration_based' => 'Valid for a specific time period from purchase', - 'permanent' => 'One-time purchase that never expires', - ], - - 'labels' => [ - 'cycle_bound' => 'Cycle-bound:', - 'duration_based' => 'Duration-based:', - 'permanent' => 'Permanent:', - ], - ], - - /* - |-------------------------------------------------------------------------- - | Settings Page - |-------------------------------------------------------------------------- - */ - 'settings' => [ - 'title' => 'Account Settings', - 'subtitle' => 'Manage your account settings and preferences', - - 'sections' => [ - 'profile' => [ - 'title' => 'Profile Information', - 'description' => 'Update your account\'s profile information and email address.', - ], - 'preferences' => [ - 'title' => 'Preferences', - 'description' => 'Configure your language, timezone, and display preferences.', - ], - 'two_factor' => [ - 'title' => 'Two-Factor Authentication', - 'description' => 'Add additional security to your account using two-factor authentication.', - ], - 'password' => [ - 'title' => 'Update Password', - 'description' => 'Ensure your account is using a long, random password to stay secure.', - ], - 'delete_account' => [ - 'title' => 'Delete Account', - 'description' => 'Permanently delete your account and all of its data.', - ], - ], - - 'fields' => [ - 'name' => 'Name', - 'name_placeholder' => 'Your name', - 'email' => 'Email', - 'email_placeholder' => 'your@email.com', - 'language' => 'Language', - 'timezone' => 'Timezone', - 'time_format' => 'Time Format', - 'time_format_12' => '12-hour (AM/PM)', - 'time_format_24' => '24-hour', - 'week_starts_on' => 'Week Starts On', - 'week_sunday' => 'Sunday', - 'week_monday' => 'Monday', - 'current_password' => 'Current Password', - 'new_password' => 'New Password', - 'confirm_password' => 'Confirm Password', - 'verification_code' => 'Verification Code', - 'verification_code_placeholder' => 'Enter 6-digit code', - 'delete_reason' => 'Reason for leaving (optional)', - 'delete_reason_placeholder' => 'Help us improve by sharing why you\'re leaving...', - ], - - 'actions' => [ - 'save_profile' => 'Save Profile', - 'save_preferences' => 'Save Preferences', - 'update_password' => 'Update Password', - 'enable' => 'Enable', - 'disable' => 'Disable', - 'confirm' => 'Confirm', - 'cancel' => 'Cancel', - 'view_recovery_codes' => 'View Recovery Codes', - 'regenerate_codes' => 'Regenerate Codes', - 'delete_account' => 'Delete Account', - 'request_deletion' => 'Request Account Deletion', - 'cancel_deletion' => 'Cancel Deletion', - ], - - 'two_factor' => [ - 'not_enabled' => 'Two-factor authentication is not enabled.', - 'not_enabled_description' => 'When two factor authentication is enabled, you will be prompted for a secure, random token during authentication.', - 'setup_instructions' => 'Scan the QR code below with your authenticator app (Google Authenticator, Authy, etc.), or enter the secret key manually.', - 'secret_key' => 'Secret Key:', - 'enabled' => 'Two-factor authentication is enabled.', - 'recovery_codes_warning' => 'Store these recovery codes securely. They can be used to access your account if you lose your 2FA device.', - ], - - 'delete' => [ - 'warning_title' => 'Warning: This action is irreversible', - 'warning_delay' => 'Your account will be deleted in 7 days', - 'warning_workspaces' => 'All workspaces you own will be permanently removed', - 'warning_content' => 'All content, media, and settings will be erased', - 'warning_email' => 'You\'ll receive an email with options to delete immediately or cancel', - 'scheduled_title' => 'Account Deletion Scheduled', - 'scheduled_description' => 'Your account will be automatically deleted on :date (in :days days).', - 'scheduled_email_note' => 'Check your email for a link to delete immediately or cancel this request.', - 'initial_description' => 'Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.', - ], - - 'messages' => [ - 'profile_updated' => 'Profile updated successfully.', - 'preferences_updated' => 'Preferences saved.', - 'password_updated' => 'Password changed successfully.', - 'two_factor_upgrading' => 'Two-factor authentication is currently being upgraded. Please try again later.', - 'deletion_scheduled' => 'Account deletion scheduled. Check your email for options.', - 'deletion_cancelled' => 'Account deletion has been cancelled.', - ], - - 'nav' => [ - 'profile' => 'Profile', - 'preferences' => 'Preferences', - 'security' => 'Security', - 'password' => 'Password', - 'danger_zone' => 'Danger Zone', - ], - ], - - /* - |-------------------------------------------------------------------------- - | Profile Page - |-------------------------------------------------------------------------- - */ - 'profile' => [ - 'member_since' => 'Member since :date', - - 'sections' => [ - 'quotas' => 'Usage & Quotas', - 'services' => 'Services', - 'activity' => 'Recent Activity', - 'quick_actions' => 'Quick Actions', - ], - - 'quotas' => [ - 'unlimited' => 'Unlimited', - 'need_more' => 'Need more?', - 'need_more_description' => 'Upgrade to unlock higher limits and more features.', - ], - - 'activity' => [ - 'no_activity' => 'No recent activity', - ], - - 'actions' => [ - 'settings' => 'Settings', - 'upgrade' => 'Upgrade', - 'edit_profile' => 'Edit Profile', - 'change_password' => 'Change Password', - 'export_data' => 'Export Data', - ], - ], - - /* - |-------------------------------------------------------------------------- - | Content Manager (content-manager.blade.php) - |-------------------------------------------------------------------------- - */ - 'content_manager' => [ - 'title' => 'Content Manager', - 'subtitle' => 'Local content management with WordPress sync', - 'actions' => [ - 'new_content' => 'New Content', - 'sync_all' => 'Sync All', - 'purge_cdn' => 'Purge CDN', - ], - 'tabs' => [ - 'dashboard' => 'Dashboard', - 'kanban' => 'Kanban', - 'calendar' => 'Calendar', - 'list' => 'List', - 'webhooks' => 'Webhooks', - ], - 'command' => [ - 'placeholder' => 'Search content or run commands...', - 'sync_all' => 'Sync all content', - 'purge_cache' => 'Purge CDN cache', - 'open_wordpress' => 'Open WordPress', - 'no_results' => 'No results found', - ], - 'preview' => [ - 'sync_label' => 'Sync', - 'author' => 'Author', - 'excerpt' => 'Excerpt', - 'content_clean_html' => 'Content (Clean HTML)', - 'taxonomies' => 'Taxonomies', - 'structured_content' => 'Structured Content (JSON)', - 'created' => 'Created', - 'modified' => 'Modified', - 'last_synced' => 'Last Synced', - 'never' => 'Never', - 'wordpress_id' => 'WordPress ID', - ], - // Dashboard tab - 'dashboard' => [ - 'total_content' => 'Total Content', - 'posts' => 'Posts', - 'published' => 'Published', - 'drafts' => 'Drafts', - 'synced' => 'Synced', - 'failed' => 'Failed', - 'content_created' => 'Content created (last 30 days)', - 'tooltip_posts' => 'Posts', - 'content_by_type' => 'Content by type', - 'pages' => 'Pages', - 'sync_status' => 'Sync status', - 'pending' => 'Pending', - 'stale' => 'Stale', - 'taxonomies' => 'Taxonomies', - 'categories' => 'Categories', - 'tags' => 'Tags', - 'webhooks_today' => 'Webhooks today', - 'received' => 'Received', - ], - // Kanban tab - 'kanban' => [ - 'no_items' => 'No items', - ], - // Calendar tab - 'calendar' => [ - 'content_schedule' => 'Content schedule', - 'legend' => [ - 'published' => 'Published', - 'draft' => 'Draft', - 'scheduled' => 'Scheduled', - ], - 'days' => [ - 'sun' => 'Sun', - 'mon' => 'Mon', - 'tue' => 'Tue', - 'wed' => 'Wed', - 'thu' => 'Thu', - 'fri' => 'Fri', - 'sat' => 'Sat', - ], - 'more' => '+:count more', - ], - // List tab - 'list' => [ - 'search_placeholder' => 'Search content...', - 'filters' => [ - 'all_types' => 'All Types', - 'posts' => 'Posts', - 'pages' => 'Pages', - 'all_status' => 'All Status', - 'published' => 'Published', - 'draft' => 'Draft', - 'pending' => 'Pending', - 'scheduled' => 'Scheduled', - 'private' => 'Private', - 'all_sync' => 'All Sync Status', - 'synced' => 'Synced', - 'stale' => 'Stale', - 'failed' => 'Failed', - 'all_sources' => 'All Sources', - 'native' => 'Native', - 'host_uk' => 'Host UK', - 'satellite' => 'Satellite', - 'wordpress_legacy' => 'WordPress (Legacy)', - 'all_categories' => 'All Categories', - 'clear' => 'Clear', - 'clear_filters' => 'Clear filters', - ], - 'columns' => [ - 'title' => 'Title', - 'type' => 'Type', - 'status' => 'Status', - 'sync' => 'Sync', - 'categories' => 'Categories', - 'created' => 'Created', - 'last_synced' => 'Last Synced', - ], - 'never' => 'Never', - 'no_content' => 'No content found', - 'edit' => 'Edit', - 'preview' => 'Preview', - ], - // Webhooks tab - 'webhooks' => [ - 'today' => 'Today', - 'completed' => 'Completed', - 'pending' => 'Pending', - 'failed' => 'Failed', - 'columns' => [ - 'id' => 'ID', - 'event' => 'Event', - 'content' => 'Content', - 'status' => 'Status', - 'source_ip' => 'Source IP', - 'received' => 'Received', - 'processed' => 'Processed', - ], - 'actions' => [ - 'retry' => 'Retry', - 'view_payload' => 'View Payload', - ], - 'error' => 'Error', - 'no_logs' => 'No webhook logs found', - 'no_logs_description' => 'Webhooks from WordPress will appear here', - 'endpoint' => [ - 'title' => 'Webhook Endpoint', - 'description' => 'Configure your WordPress plugin to send webhooks to this endpoint with the :header header containing the HMAC-SHA256 signature.', - ], - 'payload_modal' => [ - 'title' => 'Webhook Payload', - ], - ], - ], - - /* - |-------------------------------------------------------------------------- - | Content (content.blade.php) - |-------------------------------------------------------------------------- - */ - 'content' => [ - 'title' => 'Content', - 'subtitle' => 'Manage your WordPress content', - 'new_post' => 'New Post', - 'new_page' => 'New Page', - 'tabs' => [ - 'posts' => 'Posts', - 'pages' => 'Pages', - 'media' => 'Media', - ], - 'filters' => [ - 'all_status' => 'All Status', - 'published' => 'Published', - 'draft' => 'Draft', - 'pending' => 'Pending', - 'private' => 'Private', - 'sort' => 'Sort', - 'date' => 'Date', - 'title' => 'Title', - 'status' => 'Status', - ], - 'columns' => [ - 'id' => 'ID', - 'title' => 'Title', - 'status' => 'Status', - 'date' => 'Date', - 'modified' => 'Modified', - ], - 'untitled' => 'Untitled', - 'no_media' => 'No media found', - 'no_posts' => 'No posts found', - 'no_pages' => 'No pages found', - 'actions' => [ - 'edit' => 'Edit', - 'view' => 'View', - 'duplicate' => 'Duplicate', - 'delete' => 'Delete', - 'delete_confirm' => 'Are you sure you want to delete this?', - ], - 'editor' => [ - 'new' => 'New', - 'edit' => 'Edit', - 'title_label' => 'Title', - 'title_placeholder' => 'Enter title...', - 'status_label' => 'Status', - 'status' => [ - 'draft' => 'Draft', - 'publish' => 'Published', - 'pending' => 'Pending Review', - 'private' => 'Private', - ], - 'excerpt_label' => 'Excerpt', - 'excerpt_placeholder' => 'Brief summary...', - 'content_label' => 'Content', - 'content_placeholder' => 'Write your content here... (HTML supported)', - 'cancel' => 'Cancel', - 'create' => 'Create', - 'update' => 'Update', - ], - ], - - /* - |-------------------------------------------------------------------------- - | Content Editor (content-editor.blade.php) - |-------------------------------------------------------------------------- - */ - 'content_editor' => [ - 'title' => [ - 'edit' => 'Edit Content', - 'new' => 'New Content', - ], - 'save_status' => [ - 'last_saved' => 'Last saved :time', - 'not_saved' => 'Not saved', - 'unsaved_changes' => 'Unsaved changes', - 'revisions' => ':count revision|:count revisions', - ], - 'actions' => [ - 'ai_assist' => 'AI Assist', - 'save_draft' => 'Save Draft', - 'schedule' => 'Schedule', - 'publish' => 'Publish', - ], - 'status' => [ - 'draft' => 'Draft', - 'pending' => 'Pending', - 'publish' => 'Published', - 'future' => 'Scheduled', - 'private' => 'Private', - ], - 'fields' => [ - 'title_placeholder' => 'Enter title...', - 'url_slug' => 'URL Slug', - 'type' => 'Type', - 'type_page' => 'Page', - 'type_post' => 'Post', - 'excerpt' => 'Excerpt', - 'excerpt_description' => 'Brief summary for search results and previews', - 'content' => 'Content', - 'content_placeholder' => 'Start writing your content...', - ], - 'sidebar' => [ - 'settings' => 'Settings', - 'seo' => 'SEO', - 'media' => 'Media', - 'history' => 'History', - ], - 'scheduling' => [ - 'title' => 'Scheduling', - 'schedule_later' => 'Schedule for later', - 'schedule_description' => 'Publish at a specific date and time', - 'publish_date' => 'Publish date', - ], - 'categories' => [ - 'title' => 'Categories', - 'none' => 'No categories yet', - ], - 'tags' => [ - 'title' => 'Tags', - 'add_placeholder' => 'Add tag...', - ], - 'seo' => [ - 'title' => 'Search Engine Optimisation', - 'meta_title' => 'Meta title', - 'meta_title_description' => 'Recommended: 50-60 characters', - 'meta_title_placeholder' => 'Page title', - 'characters' => ':count/:max characters', - 'meta_description' => 'Meta description', - 'meta_description_description' => 'Recommended: 150-160 characters', - 'meta_description_placeholder' => 'Brief description for search results...', - 'focus_keywords' => 'Focus keywords', - 'focus_keywords_placeholder' => 'keyword1, keyword2, keyword3', - 'preview_title' => 'Search preview', - 'preview_description_fallback' => 'Page description will appear here...', - ], - 'media' => [ - 'featured_image' => 'Featured Image', - 'drag_drop' => 'Drag and drop an image, or', - 'browse' => 'browse', - 'upload' => 'Upload', - 'select_from_library' => 'Or select from library', - ], - 'revisions' => [ - 'title' => 'Revision History', - 'no_revisions' => 'No revisions yet. Save your content to create the first revision.', - 'save_first' => 'Save your content first to start tracking revisions.', - 'restore' => 'Restore', - 'words' => ':count words', - 'change_types' => [ - 'publish' => 'Publish', - 'edit' => 'Edit', - 'restore' => 'Restore', - 'schedule' => 'Schedule', - ], - ], - 'ai' => [ - 'command_placeholder' => 'Search AI commands or type a prompt...', - 'quick_actions' => 'Quick Actions', - 'result_title' => 'AI Result', - 'discard' => 'Discard', - 'insert' => 'Insert', - 'replace_content' => 'Replace Content', - 'run' => 'Run', - 'processing' => 'Processing...', - 'thinking' => 'AI is thinking...', - 'cancel' => 'Cancel', - 'footer_close' => 'Press :key to close', - 'footer_powered' => 'Powered by Claude and Gemini', - ], - ], -]; diff --git a/packages/core-admin/src/Mod/Hub/Migrations/2026_01_11_000001_create_honeypot_hits_table.php b/packages/core-admin/src/Mod/Hub/Migrations/2026_01_11_000001_create_honeypot_hits_table.php deleted file mode 100644 index 75b5bce..0000000 --- a/packages/core-admin/src/Mod/Hub/Migrations/2026_01_11_000001_create_honeypot_hits_table.php +++ /dev/null @@ -1,35 +0,0 @@ -id(); - $table->string('ip_address', 45); - $table->string('user_agent', 1000)->nullable(); - $table->string('referer', 2000)->nullable(); - $table->string('path', 255); - $table->string('method', 10); - $table->json('headers')->nullable(); - $table->string('country', 2)->nullable(); - $table->string('city', 100)->nullable(); - $table->boolean('is_bot')->default(false); - $table->string('bot_name', 100)->nullable(); - $table->timestamps(); - - $table->index('ip_address'); - $table->index('created_at'); - $table->index('is_bot'); - }); - } - - public function down(): void - { - Schema::dropIfExists('honeypot_hits'); - } -}; diff --git a/packages/core-admin/src/Mod/Hub/Migrations/2026_01_20_000001_create_platform_services_table.php b/packages/core-admin/src/Mod/Hub/Migrations/2026_01_20_000001_create_platform_services_table.php deleted file mode 100644 index b2a9146..0000000 --- a/packages/core-admin/src/Mod/Hub/Migrations/2026_01_20_000001_create_platform_services_table.php +++ /dev/null @@ -1,49 +0,0 @@ -id(); - $table->string('code', 50)->unique(); // 'bio', 'social' - matches module's service key - $table->string('module', 50); // 'WebPage', 'Social' - source module name - $table->string('name', 100); // 'Bio' - display name - $table->string('tagline', 200)->nullable(); // 'Link-in-bio pages' - short marketing tagline - $table->text('description')->nullable(); // Marketing description - $table->string('icon', 50)->nullable(); // Font Awesome icon name - $table->string('color', 20)->nullable(); // Tailwind color name - $table->string('marketing_domain', 100)->nullable(); // 'lthn.test', 'social.host.test' - $table->string('marketing_url', 255)->nullable(); // Full marketing page URL override - $table->string('docs_url', 255)->nullable(); // Documentation URL - $table->boolean('is_enabled')->default(true); // Global enable/disable - $table->boolean('is_public')->default(true); // Show in public service catalogue - $table->boolean('is_featured')->default(false); // Feature in marketing - $table->string('entitlement_code', 50)->nullable(); // 'core.srv.bio' - links to entitlement system - $table->integer('sort_order')->default(50); - $table->json('metadata')->nullable(); // Extensible for future needs - $table->timestamps(); - - $table->index('is_enabled'); - $table->index('is_public'); - $table->index('sort_order'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('platform_services'); - } -}; diff --git a/packages/core-admin/src/Mod/Hub/Migrations/2026_01_20_000002_add_website_class_to_platform_services.php b/packages/core-admin/src/Mod/Hub/Migrations/2026_01_20_000002_add_website_class_to_platform_services.php deleted file mode 100644 index c55aabe..0000000 --- a/packages/core-admin/src/Mod/Hub/Migrations/2026_01_20_000002_add_website_class_to_platform_services.php +++ /dev/null @@ -1,35 +0,0 @@ -string('website_class', 150)->nullable()->after('marketing_domain'); - - $table->index('marketing_domain'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('platform_services', function (Blueprint $table) { - $table->dropIndex(['marketing_domain']); - $table->dropColumn('website_class'); - }); - } -}; diff --git a/packages/core-admin/src/Mod/Hub/Models/HoneypotHit.php b/packages/core-admin/src/Mod/Hub/Models/HoneypotHit.php deleted file mode 100644 index 5373e89..0000000 --- a/packages/core-admin/src/Mod/Hub/Models/HoneypotHit.php +++ /dev/null @@ -1,206 +0,0 @@ - 'array', - 'is_bot' => 'boolean', - ]; - - /** - * Severity levels for honeypot hits. - * - * These can be overridden via config('core.bouncer.honeypot.severity_levels'). - */ - public const SEVERITY_WARNING = 'warning'; // Ignored robots.txt (/teapot) - public const SEVERITY_CRITICAL = 'critical'; // Active probing (/admin) - - /** - * Default critical paths (used when config is not available). - */ - protected static array $defaultCriticalPaths = [ - 'admin', - 'wp-admin', - 'wp-login.php', - 'administrator', - 'phpmyadmin', - '.env', - '.git', - ]; - - /** - * Get the severity level string for 'critical'. - */ - public static function getSeverityCritical(): string - { - return config('core.bouncer.honeypot.severity_levels.critical', self::SEVERITY_CRITICAL); - } - - /** - * Get the severity level string for 'warning'. - */ - public static function getSeverityWarning(): string - { - return config('core.bouncer.honeypot.severity_levels.warning', self::SEVERITY_WARNING); - } - - /** - * Get the list of critical paths. - */ - public static function getCriticalPaths(): array - { - return config('core.bouncer.honeypot.critical_paths', self::$defaultCriticalPaths); - } - - /** - * Determine severity based on path. - * - * Uses configurable critical paths from config('core.bouncer.honeypot.critical_paths'). - */ - public static function severityForPath(string $path): string - { - $criticalPaths = self::getCriticalPaths(); - - $path = ltrim($path, '/'); - - foreach ($criticalPaths as $critical) { - if (str_starts_with($path, $critical)) { - return self::getSeverityCritical(); - } - } - - return self::getSeverityWarning(); - } - - /** - * Known bad bot patterns. - */ - protected static array $botPatterns = [ - 'AhrefsBot' => 'Ahrefs', - 'SemrushBot' => 'Semrush', - 'MJ12bot' => 'Majestic', - 'DotBot' => 'Moz', - 'BLEXBot' => 'BLEXBot', - 'PetalBot' => 'Petal', - 'YandexBot' => 'Yandex', - 'bingbot' => 'Bing', - 'Googlebot' => 'Google', - 'Bytespider' => 'ByteDance', - 'GPTBot' => 'OpenAI', - 'CCBot' => 'Common Crawl', - 'ClaudeBot' => 'Anthropic', - 'anthropic-ai' => 'Anthropic', - 'DataForSeoBot' => 'DataForSEO', - 'serpstatbot' => 'Serpstat', - 'curl/' => 'cURL', - 'python-requests' => 'Python', - 'Go-http-client' => 'Go', - 'wget' => 'Wget', - 'scrapy' => 'Scrapy', - 'HeadlessChrome' => 'HeadlessChrome', - 'PhantomJS' => 'PhantomJS', - ]; - - /** - * Detect if the user agent is a known bot. - */ - public static function detectBot(?string $userAgent): ?string - { - if (empty($userAgent)) { - return 'Unknown (no UA)'; - } - - foreach (self::$botPatterns as $pattern => $name) { - if (stripos($userAgent, $pattern) !== false) { - return $name; - } - } - - return null; - } - - /** - * Scope for recent hits. - */ - public function scopeRecent($query, int $hours = 24) - { - return $query->where('created_at', '>=', now()->subHours($hours)); - } - - /** - * Scope for a specific IP. - */ - public function scopeFromIp($query, string $ip) - { - return $query->where('ip_address', $ip); - } - - /** - * Scope for bots only. - */ - public function scopeBots($query) - { - return $query->where('is_bot', true); - } - - /** - * Scope for critical severity (blocklist candidates). - */ - public function scopeCritical($query) - { - return $query->where('severity', self::SEVERITY_CRITICAL); - } - - /** - * Scope for warning severity. - */ - public function scopeWarning($query) - { - return $query->where('severity', self::SEVERITY_WARNING); - } - - /** - * Get stats for the dashboard. - */ - public static function getStats(): array - { - return [ - 'total' => self::count(), - 'today' => self::whereDate('created_at', today())->count(), - 'this_week' => self::where('created_at', '>=', now()->subWeek())->count(), - 'unique_ips' => self::distinct('ip_address')->count('ip_address'), - 'bots' => self::where('is_bot', true)->count(), - 'top_ips' => self::selectRaw('ip_address, COUNT(*) as hits') - ->groupBy('ip_address') - ->orderByDesc('hits') - ->limit(10) - ->get(), - 'top_bots' => self::selectRaw('bot_name, COUNT(*) as hits') - ->whereNotNull('bot_name') - ->groupBy('bot_name') - ->orderByDesc('hits') - ->limit(10) - ->get(), - ]; - } -} diff --git a/packages/core-admin/src/Mod/Hub/Models/Service.php b/packages/core-admin/src/Mod/Hub/Models/Service.php deleted file mode 100644 index edf4884..0000000 --- a/packages/core-admin/src/Mod/Hub/Models/Service.php +++ /dev/null @@ -1,149 +0,0 @@ - 'boolean', - 'is_public' => 'boolean', - 'is_featured' => 'boolean', - 'metadata' => 'array', - 'sort_order' => 'integer', - ]; - - /** - * Scope: only enabled services. - */ - public function scopeEnabled(Builder $query): Builder - { - return $query->where('is_enabled', true); - } - - /** - * Scope: only public services (visible in catalogue). - */ - public function scopePublic(Builder $query): Builder - { - return $query->where('is_public', true); - } - - /** - * Scope: only featured services. - */ - public function scopeFeatured(Builder $query): Builder - { - return $query->where('is_featured', true); - } - - /** - * Scope: order by sort_order, then name. - */ - public function scopeOrdered(Builder $query): Builder - { - return $query->orderBy('sort_order')->orderBy('name'); - } - - /** - * Scope: services with a marketing domain configured. - */ - public function scopeWithMarketingDomain(Builder $query): Builder - { - return $query->whereNotNull('marketing_domain') - ->whereNotNull('website_class'); - } - - /** - * Find a service by its code. - */ - public static function findByCode(string $code): ?self - { - return self::where('code', $code)->first(); - } - - /** - * Get domain → website_class mappings for enabled services. - * - * Used by DomainResolver for routing marketing domains. - * - * @return array domain => website_class - */ - public static function getDomainMappings(): array - { - return self::enabled() - ->withMarketingDomain() - ->pluck('website_class', 'marketing_domain') - ->toArray(); - } - - /** - * Get the marketing URL, falling back to marketing_domain if no override set. - */ - public function getMarketingUrlAttribute(?string $value): ?string - { - if ($value) { - return $value; - } - - if ($this->marketing_domain) { - $scheme = app()->environment('local') ? 'http' : 'https'; - - return "{$scheme}://{$this->marketing_domain}"; - } - - return null; - } - - /** - * Check if a specific metadata key exists. - */ - public function hasMeta(string $key): bool - { - return isset($this->metadata[$key]); - } - - /** - * Get a specific metadata value. - */ - public function getMeta(string $key, mixed $default = null): mixed - { - return $this->metadata[$key] ?? $default; - } - - /** - * Set a metadata value. - */ - public function setMeta(string $key, mixed $value): void - { - $metadata = $this->metadata ?? []; - $metadata[$key] = $value; - $this->metadata = $metadata; - } -} diff --git a/packages/core-admin/src/Mod/Hub/Tests/Feature/HubRoutesTest.php b/packages/core-admin/src/Mod/Hub/Tests/Feature/HubRoutesTest.php deleted file mode 100644 index 0c5ea59..0000000 --- a/packages/core-admin/src/Mod/Hub/Tests/Feature/HubRoutesTest.php +++ /dev/null @@ -1,255 +0,0 @@ -user = User::factory()->create([ - 'account_type' => 'hades', - ]); -}); - -describe('Hub Routes (Guest)', function () { - it('redirects guests from hub home to login', function () { - $this->get('/hub') - ->assertRedirect(); - }); - - it('redirects guests from hub dashboard to login', function () { - $this->get('/hub/dashboard') - ->assertRedirect(); - }); - - it('redirects guests from SocialHost to login', function () { - $this->get('/hub/social') - ->assertRedirect(); - }); - - it('redirects guests from profile to login', function () { - $this->get('/hub/profile') - ->assertRedirect(); - }); - - it('redirects guests from settings to login', function () { - $this->get('/hub/settings') - ->assertRedirect(); - }); - - it('redirects guests from billing to login', function () { - $this->get('/hub/billing') - ->assertRedirect(); - }); - - it('redirects guests from analytics to login', function () { - $this->get('/hub/analytics') - ->assertRedirect(); - }); - - it('redirects guests from bio to login', function () { - $this->get('/hub/bio') - ->assertRedirect(); - }); - - it('redirects guests from notify to login', function () { - $this->get('/hub/notify') - ->assertRedirect(); - }); - - it('redirects guests from trust to login', function () { - $this->get('/hub/trust') - ->assertRedirect(); - }); -}); - -describe('Hub Home (Authenticated)', function () { - it('renders hub home with welcome banner', function () { - $this->actingAs($this->user) - ->get('/hub') - ->assertOk() - ->assertSee('Dashboard') - ->assertSee('Your creator toolkit at a glance'); - }); - - it('displays service cards on hub home', function () { - $this->actingAs($this->user) - ->get('/hub') - ->assertOk() - ->assertSee('BioHost') - ->assertSee('SocialHost'); - }); -}); - -describe('Hub Profile (Authenticated)', function () { - it('renders profile page with user information', function () { - $this->actingAs($this->user) - ->get('/hub/profile') - ->assertOk() - ->assertSee($this->user->name) - ->assertSee($this->user->email); - }); - - it('displays tier badge on profile', function () { - $this->actingAs($this->user) - ->get('/hub/profile') - ->assertOk() - ->assertSee('Settings'); - }); -}); - -describe('Hub Settings (Authenticated)', function () { - it('renders settings page with profile form', function () { - $this->actingAs($this->user) - ->get('/hub/settings') - ->assertOk() - ->assertSee('Account Settings') - ->assertSee('Profile Information'); - }); - - it('displays save button on settings', function () { - $this->actingAs($this->user) - ->get('/hub/settings') - ->assertOk() - ->assertSee('Save Profile'); - }); -}); - -describe('Billing Dashboard (Authenticated)', function () { - it('renders billing dashboard with current plan', function () { - $this->actingAs($this->user) - ->get('/hub/billing') - ->assertOk() - ->assertSee('Billing') - ->assertSee('Current Plan'); - }); - - it('displays plan upgrade option', function () { - $this->actingAs($this->user) - ->get('/hub/billing') - ->assertOk() - ->assertSee('Upgrade'); - }); -}); - -describe('SocialHost Dashboard (Authenticated)', function () { - it('renders social dashboard with analytics heading', function () { - $this->actingAs($this->user) - ->get('/hub/social') - ->assertOk() - ->assertSee('Dashboard') - ->assertSee('social accounts'); - }); - - it('displays period selector on social dashboard', function () { - $this->actingAs($this->user) - ->get('/hub/social') - ->assertOk() - ->assertSee('7 days') - ->assertSee('30 days'); - }); -}); - -describe('AnalyticsHost Index (Authenticated)', function () { - it('renders analytics index with page header', function () { - $this->actingAs($this->user) - ->get('/hub/analytics') - ->assertOk() - ->assertSee('Analytics') - ->assertSee('Privacy-focused'); - }); - - it('displays add website button on analytics', function () { - $this->actingAs($this->user) - ->get('/hub/analytics') - ->assertOk() - ->assertSee('Add Mod'); - }); -}); - -describe('BioHost Index (Authenticated)', function () { - it('renders bio index with page header', function () { - $this->actingAs($this->user) - ->get('/hub/bio') - ->assertOk() - ->assertSee('Bio'); - }); - - it('displays new bio page button', function () { - $this->actingAs($this->user) - ->get('/hub/bio') - ->assertOk() - ->assertSee('New'); - }); -}); - -describe('NotifyHost Index (Authenticated)', function () { - it('renders notify index with page header', function () { - $this->actingAs($this->user) - ->get('/hub/notify') - ->assertOk() - ->assertSee('Notify'); - }); - - it('displays add website button on notify', function () { - $this->actingAs($this->user) - ->get('/hub/notify') - ->assertOk() - ->assertSee('Add'); - }); -}); - -describe('TrustHost Index (Authenticated)', function () { - it('renders trust index with page header', function () { - $this->actingAs($this->user) - ->get('/hub/trust') - ->assertOk() - ->assertSee('Trust'); - }); - - it('displays add campaign button on trust', function () { - $this->actingAs($this->user) - ->get('/hub/trust') - ->assertOk() - ->assertSee('Add'); - }); -}); - -describe('Dev API Routes (Hades only)', function () { - it('allows Hades users to access dev logs API', function () { - $this->actingAs($this->user) - ->getJson('/hub/api/dev/logs') - ->assertOk() - ->assertJsonIsArray(); - }); - - it('allows Hades users to access dev routes API', function () { - $this->actingAs($this->user) - ->getJson('/hub/api/dev/routes') - ->assertOk() - ->assertJsonIsArray(); - }); - - it('allows Hades users to access dev session API', function () { - $this->actingAs($this->user) - ->getJson('/hub/api/dev/session') - ->assertOk() - ->assertJsonStructure(['id', 'ip', 'user_agent']); - }); - - it('denies non-Hades users access to dev APIs', function () { - $regularUser = User::factory()->create([ - 'account_type' => 'apollo', - ]); - - $this->actingAs($regularUser) - ->getJson('/hub/api/dev/logs') - ->assertForbidden(); - }); -}); diff --git a/packages/core-admin/src/Mod/Hub/Tests/Feature/WorkspaceSwitcherTest.php b/packages/core-admin/src/Mod/Hub/Tests/Feature/WorkspaceSwitcherTest.php deleted file mode 100644 index 8c75be6..0000000 --- a/packages/core-admin/src/Mod/Hub/Tests/Feature/WorkspaceSwitcherTest.php +++ /dev/null @@ -1,198 +0,0 @@ -user = User::factory()->create(); - - $this->workspaceA = Workspace::factory()->create([ - 'name' => 'Workspace A', - 'slug' => 'workspace-a', - ]); - - $this->workspaceB = Workspace::factory()->create([ - 'name' => 'Workspace B', - 'slug' => 'workspace-b', - ]); - - // Attach user to both workspaces - $this->user->hostWorkspaces()->attach($this->workspaceA, ['role' => 'owner', 'is_default' => true]); - $this->user->hostWorkspaces()->attach($this->workspaceB, ['role' => 'editor']); - } - - public function test_component_loads_with_user_workspaces(): void - { - $this->actingAs($this->user); - - Livewire::test(WorkspaceSwitcher::class) - ->assertSet('workspaces', function ($workspaces) { - return count($workspaces) === 2 - && isset($workspaces['workspace-a']) - && isset($workspaces['workspace-b']); - }) - ->assertSet('current.slug', 'workspace-a'); // Default workspace - } - - public function test_current_workspace_is_set_from_session(): void - { - $this->actingAs($this->user); - - // Set workspace B in session - session(['workspace' => 'workspace-b']); - - Livewire::test(WorkspaceSwitcher::class) - ->assertSet('current.slug', 'workspace-b'); - } - - public function test_switch_workspace_updates_session(): void - { - $this->actingAs($this->user); - - // Initialize - currentModel() sets session to default workspace - $service = app(WorkspaceService::class); - $model = $service->currentModel(); - $this->assertEquals('workspace-a', $model->slug); - $this->assertEquals('workspace-a', session('workspace')); - - Livewire::test(WorkspaceSwitcher::class) - ->call('switchWorkspace', 'workspace-b'); - - // Check session was updated - $this->assertEquals('workspace-b', session('workspace')); - } - - public function test_switch_workspace_dispatches_event(): void - { - $this->actingAs($this->user); - - Livewire::test(WorkspaceSwitcher::class) - ->call('switchWorkspace', 'workspace-b') - ->assertDispatched('workspace-changed', workspace: 'workspace-b'); - } - - public function test_switch_workspace_redirects(): void - { - $this->actingAs($this->user); - - Livewire::test(WorkspaceSwitcher::class) - ->call('switchWorkspace', 'workspace-b') - ->assertRedirect(); - } - - public function test_cannot_switch_to_workspace_user_does_not_belong_to(): void - { - $this->actingAs($this->user); - - $otherWorkspace = Workspace::factory()->create(['slug' => 'other-workspace']); - - Livewire::test(WorkspaceSwitcher::class) - ->call('switchWorkspace', 'other-workspace'); - - // Session should NOT be changed to the other workspace - $this->assertNotEquals('other-workspace', session('workspace')); - } - - public function test_workspace_service_set_current_returns_false_for_invalid_workspace(): void - { - $this->actingAs($this->user); - - $service = app(WorkspaceService::class); - - $this->assertFalse($service->setCurrent('nonexistent-workspace')); - $this->assertTrue($service->setCurrent('workspace-b')); - } - - public function test_switched_workspace_persists_across_component_instances(): void - { - $this->actingAs($this->user); - - // Initialize session with default workspace - app(WorkspaceService::class)->currentModel(); - - // Switch workspace - Livewire::test(WorkspaceSwitcher::class) - ->call('switchWorkspace', 'workspace-b'); - - // Create a NEW component instance - it should see the switched workspace - // Note: We need to manually set the session since Livewire tests are isolated - session(['workspace' => 'workspace-b']); - - Livewire::test(WorkspaceSwitcher::class) - ->assertSet('current.slug', 'workspace-b') - ->assertSet('current.name', 'Workspace B'); - } - - public function test_switch_workspace_closes_dropdown(): void - { - $this->actingAs($this->user); - - Livewire::test(WorkspaceSwitcher::class) - ->set('open', true) - ->call('switchWorkspace', 'workspace-b') - ->assertSet('open', false); - } - - public function test_component_renders_all_workspaces_in_dropdown(): void - { - $this->actingAs($this->user); - - Livewire::test(WorkspaceSwitcher::class) - ->assertSee('Workspace A') - ->assertSee('Workspace B') - ->assertSee('Switch Workspace'); - } - - public function test_switch_workspace_redirects_to_captured_url(): void - { - $this->actingAs($this->user); - - // Set a specific returnUrl and verify redirect uses it - Livewire::test(WorkspaceSwitcher::class) - ->set('returnUrl', 'https://example.com/test-page') - ->call('switchWorkspace', 'workspace-b') - ->assertRedirect('https://example.com/test-page'); - } - - public function test_return_url_is_captured_on_mount(): void - { - $this->actingAs($this->user); - - // Just verify returnUrl is set (not empty) - Livewire::test(WorkspaceSwitcher::class) - ->assertSet('returnUrl', fn ($url) => ! empty($url)); - } - - public function test_switch_workspace_falls_back_to_dashboard_if_no_return_url(): void - { - $this->actingAs($this->user); - - // If returnUrl is empty, should redirect to dashboard - Livewire::test(WorkspaceSwitcher::class) - ->set('returnUrl', '') - ->call('switchWorkspace', 'workspace-b') - ->assertRedirect(route('hub.dashboard')); - } -} diff --git a/packages/core-admin/src/Mod/Hub/Tests/UseCase/DashboardBasic.php b/packages/core-admin/src/Mod/Hub/Tests/UseCase/DashboardBasic.php deleted file mode 100644 index d7021f3..0000000 --- a/packages/core-admin/src/Mod/Hub/Tests/UseCase/DashboardBasic.php +++ /dev/null @@ -1,53 +0,0 @@ -user = User::factory()->create([ - 'email' => 'test@example.com', - 'password' => bcrypt('password'), - ]); - - $this->workspace = Workspace::factory()->create(); - $this->workspace->users()->attach($this->user->id, [ - 'role' => 'owner', - 'is_default' => true, - ]); - }); - - it('can login and view the dashboard with all sections', function () { - // Login - $page = visit('/login'); - - $page->fill('email', 'test@example.com') - ->fill('password', 'password') - ->click(__('pages::pages.login.submit')) - ->assertPathContains('/hub'); - - // Verify dashboard title and subtitle (from translations) - $page->assertSee(__('hub::hub.dashboard.title')) - ->assertSee(__('hub::hub.dashboard.subtitle')); - - // Verify action button - $page->assertSee(__('hub::hub.dashboard.actions.edit_content')); - - // Check activity section - $page->assertSee(__('hub::hub.dashboard.sections.recent_activity')); - - // Check quick actions section - $page->assertSee(__('hub::hub.quick_actions.manage_workspaces.title')) - ->assertSee(__('hub::hub.quick_actions.profile.title')); - }); -}); diff --git a/packages/core-admin/src/Search/Concerns/HasSearchProvider.php b/packages/core-admin/src/Search/Concerns/HasSearchProvider.php deleted file mode 100644 index 77db755..0000000 --- a/packages/core-admin/src/Search/Concerns/HasSearchProvider.php +++ /dev/null @@ -1,49 +0,0 @@ - 'unique-identifier', - * 'title' => 'Result Title', - * 'subtitle' => 'Optional description', - * 'url' => '/path/to/resource', - * 'icon' => 'optional-override-icon', - * 'meta' => ['optional' => 'metadata'], - * ] - * ``` - * - * ## Registration - * - * Providers are typically registered via `SearchProviderRegistry::register()` - * during the AdminPanelBooting event or in a service provider's boot method. - * - * - * @see SearchProviderRegistry For provider registration and discovery - * @see SearchResult For the result data structure - */ -interface SearchProvider -{ - /** - * Get the search type identifier. - * - * This is used for grouping results in the UI and for filtering. - * Examples: 'pages', 'users', 'posts', 'products', 'settings'. - */ - public function searchType(): string; - - /** - * Get the display label for this search type. - * - * This is shown as the group header in the search results. - * Should be a human-readable, translatable string. - */ - public function searchLabel(): string; - - /** - * Get the icon name for this search type. - * - * Used to display an icon next to search results from this provider. - * Should be a valid Heroicon or FontAwesome icon name. - */ - public function searchIcon(): string; - - /** - * Execute a search query. - * - * Searches the provider's data source for matches against the query. - * Should implement fuzzy matching where appropriate for better UX. - * - * @param string $query The search query string - * @param int $limit Maximum number of results to return (default: 5) - * @return Collection Collection of search results - */ - public function search(string $query, int $limit = 5): Collection; - - /** - * Get the URL for a search result. - * - * Generates the navigation URL for a given search result. - * This allows providers to implement custom URL generation logic. - * - * @param mixed $result The search result (model or array) - * @return string The URL to navigate to - */ - public function getUrl(mixed $result): string; - - /** - * Get the priority for ordering in search results. - * - * Lower numbers appear first. Default should be 50. - * Use lower numbers (10-40) for important/frequently accessed resources. - * Use higher numbers (60-100) for less important resources. - */ - public function searchPriority(): int; - - /** - * Check if this provider should be active for the current context. - * - * Override this to implement permission checks or context-based filtering. - * For example, only show certain searches to admin users. - * - * @param object|null $user The authenticated user - * @param object|null $workspace The current workspace context - */ - public function isAvailable(?object $user, ?object $workspace): bool; -} diff --git a/packages/core-admin/src/Search/Providers/AdminPageSearchProvider.php b/packages/core-admin/src/Search/Providers/AdminPageSearchProvider.php deleted file mode 100644 index 8d92e86..0000000 --- a/packages/core-admin/src/Search/Providers/AdminPageSearchProvider.php +++ /dev/null @@ -1,216 +0,0 @@ - - */ - protected array $pages = [ - [ - 'id' => 'dashboard', - 'title' => 'Dashboard', - 'subtitle' => 'Overview and quick actions', - 'url' => '/hub', - 'icon' => 'house', - ], - [ - 'id' => 'workspaces', - 'title' => 'Workspaces', - 'subtitle' => 'Manage your workspaces', - 'url' => '/hub/sites', - 'icon' => 'folders', - ], - [ - 'id' => 'profile', - 'title' => 'Profile', - 'subtitle' => 'Your account profile', - 'url' => '/hub/account', - 'icon' => 'user', - ], - [ - 'id' => 'settings', - 'title' => 'Settings', - 'subtitle' => 'Account settings and preferences', - 'url' => '/hub/account/settings', - 'icon' => 'gear', - ], - [ - 'id' => 'usage', - 'title' => 'Usage & Limits', - 'subtitle' => 'Monitor your usage and quotas', - 'url' => '/hub/account/usage', - 'icon' => 'chart-pie', - ], - [ - 'id' => 'ai-services', - 'title' => 'AI Services', - 'subtitle' => 'Configure AI providers', - 'url' => '/hub/ai-services', - 'icon' => 'sparkles', - ], - [ - 'id' => 'prompts', - 'title' => 'Prompt Manager', - 'subtitle' => 'Manage AI prompts', - 'url' => '/hub/prompts', - 'icon' => 'command', - ], - [ - 'id' => 'content-manager', - 'title' => 'Content Manager', - 'subtitle' => 'Manage WordPress content', - 'url' => '/hub/content-manager', - 'icon' => 'newspaper', - ], - [ - 'id' => 'deployments', - 'title' => 'Deployments', - 'subtitle' => 'View deployment history', - 'url' => '/hub/deployments', - 'icon' => 'rocket', - ], - [ - 'id' => 'databases', - 'title' => 'Databases', - 'subtitle' => 'Database management', - 'url' => '/hub/databases', - 'icon' => 'database', - ], - [ - 'id' => 'console', - 'title' => 'Server Console', - 'subtitle' => 'Terminal access', - 'url' => '/hub/console', - 'icon' => 'terminal', - ], - [ - 'id' => 'analytics', - 'title' => 'Analytics', - 'subtitle' => 'Traffic and performance', - 'url' => '/hub/analytics', - 'icon' => 'chart-line', - ], - [ - 'id' => 'activity', - 'title' => 'Activity Log', - 'subtitle' => 'Recent account activity', - 'url' => '/hub/activity', - 'icon' => 'clock-rotate-left', - ], - ]; - - protected SearchProviderRegistry $registry; - - public function __construct(SearchProviderRegistry $registry) - { - $this->registry = $registry; - } - - /** - * Get the search type identifier. - */ - public function searchType(): string - { - return 'pages'; - } - - /** - * Get the display label for this search type. - */ - public function searchLabel(): string - { - return __('Pages'); - } - - /** - * Get the icon name for this search type. - */ - public function searchIcon(): string - { - return 'rectangle-stack'; - } - - /** - * Get the priority for ordering in search results. - */ - public function searchPriority(): int - { - return 10; // Show pages first - } - - /** - * Execute a search query. - * - * @param string $query The search query string - * @param int $limit Maximum number of results to return - */ - public function search(string $query, int $limit = 5): Collection - { - return collect($this->pages) - ->filter(function ($page) use ($query) { - // Match against title and subtitle - return $this->registry->fuzzyMatch($query, $page['title']) - || $this->registry->fuzzyMatch($query, $page['subtitle']); - }) - ->sortByDesc(function ($page) use ($query) { - // Sort by relevance to title - return $this->registry->relevanceScore($query, $page['title']); - }) - ->take($limit) - ->map(function ($page) { - return new SearchResult( - id: $page['id'], - title: $page['title'], - url: $page['url'], - type: $this->searchType(), - icon: $page['icon'], - subtitle: $page['subtitle'], - ); - }) - ->values(); - } - - /** - * Get the URL for a search result. - * - * @param mixed $result The search result - */ - public function getUrl(mixed $result): string - { - if ($result instanceof SearchResult) { - return $result->url; - } - - return $result['url'] ?? '#'; - } -} diff --git a/packages/core-admin/src/Search/SearchProviderRegistry.php b/packages/core-admin/src/Search/SearchProviderRegistry.php deleted file mode 100644 index c5fa718..0000000 --- a/packages/core-admin/src/Search/SearchProviderRegistry.php +++ /dev/null @@ -1,305 +0,0 @@ -getAllItems(); - * return $results->filter(function ($item) use ($query) { - * return app(SearchProviderRegistry::class) - * ->fuzzyMatch($query, $item->title); - * })->take($limit); - * } - * ``` - */ -class SearchProviderRegistry -{ - /** - * Registered search providers. - * - * @var array - */ - protected array $providers = []; - - /** - * Register a search provider. - */ - public function register(SearchProvider $provider): void - { - $this->providers[] = $provider; - } - - /** - * Register multiple search providers. - * - * @param array $providers - */ - public function registerMany(array $providers): void - { - foreach ($providers as $provider) { - $this->register($provider); - } - } - - /** - * Get all registered providers. - * - * @return array - */ - public function providers(): array - { - return $this->providers; - } - - /** - * Get available providers for a given context. - * - * @param object|null $user The authenticated user - * @param object|null $workspace The current workspace context - * @return Collection - */ - public function availableProviders(?object $user, ?object $workspace): Collection - { - return collect($this->providers) - ->filter(fn (SearchProvider $provider) => $provider->isAvailable($user, $workspace)) - ->sortBy(fn (SearchProvider $provider) => $provider->searchPriority()); - } - - /** - * Search across all available providers. - * - * Returns results grouped by search type, sorted by provider priority. - * - * @param string $query The search query - * @param object|null $user The authenticated user - * @param object|null $workspace The current workspace context - * @param int $limitPerProvider Maximum results per provider - * @return array - */ - public function search( - string $query, - ?object $user, - ?object $workspace, - int $limitPerProvider = 5 - ): array { - $grouped = []; - - foreach ($this->availableProviders($user, $workspace) as $provider) { - $type = $provider->searchType(); - $results = $provider->search($query, $limitPerProvider); - - // Convert results to array format with type/icon - $formattedResults = $results->map(function ($result) use ($provider) { - if ($result instanceof SearchResult) { - return $result->withTypeAndIcon( - $provider->searchType(), - $provider->searchIcon() - )->toArray(); - } - - // Handle array results - if (is_array($result)) { - $searchResult = SearchResult::fromArray($result); - - return $searchResult->withTypeAndIcon( - $provider->searchType(), - $provider->searchIcon() - )->toArray(); - } - - // Handle model objects with getUrl - return [ - 'id' => (string) ($result->id ?? uniqid()), - 'title' => (string) ($result->title ?? $result->name ?? ''), - 'subtitle' => (string) ($result->subtitle ?? $result->description ?? ''), - 'url' => $provider->getUrl($result), - 'type' => $provider->searchType(), - 'icon' => $provider->searchIcon(), - 'meta' => [], - ]; - })->toArray(); - - if (! empty($formattedResults)) { - $grouped[$type] = [ - 'label' => $provider->searchLabel(), - 'icon' => $provider->searchIcon(), - 'results' => $formattedResults, - ]; - } - } - - return $grouped; - } - - /** - * Flatten search results into a single array for keyboard navigation. - * - * @param array $grouped Grouped search results - */ - public function flattenResults(array $grouped): array - { - $flat = []; - - foreach ($grouped as $type => $group) { - foreach ($group['results'] as $result) { - $flat[] = $result; - } - } - - return $flat; - } - - /** - * Check if a query fuzzy-matches a target string. - * - * Supports: - * - Case-insensitive partial matching - * - Word-start matching (e.g., "ps" matches "Post Settings") - * - Abbreviation matching (e.g., "gs" matches "Global Search") - * - * @param string $query The search query - * @param string $target The target string to match against - */ - public function fuzzyMatch(string $query, string $target): bool - { - $query = Str::lower(trim($query)); - $target = Str::lower(trim($target)); - - // Empty query matches nothing - if ($query === '') { - return false; - } - - // Direct substring match (most common case) - if (Str::contains($target, $query)) { - return true; - } - - // Word-start matching: each character matches start of consecutive words - // e.g., "ps" matches "Post Settings", "gs" matches "Global Search" - $words = preg_split('/\s+/', $target); - $queryChars = str_split($query); - $wordIndex = 0; - $charIndex = 0; - - while ($charIndex < count($queryChars) && $wordIndex < count($words)) { - $char = $queryChars[$charIndex]; - $word = $words[$wordIndex]; - - if (Str::startsWith($word, $char)) { - $charIndex++; - } - $wordIndex++; - } - - if ($charIndex === count($queryChars)) { - return true; - } - - // Abbreviation matching: all query chars appear in order - // e.g., "gsr" matches "Global Search Results" - $targetIndex = 0; - foreach ($queryChars as $char) { - $foundAt = strpos($target, $char, $targetIndex); - if ($foundAt === false) { - return false; - } - $targetIndex = $foundAt + 1; - } - - return true; - } - - /** - * Calculate a relevance score for sorting results. - * - * Higher scores indicate better matches. - * - * @param string $query The search query - * @param string $target The target string - * @return int Score from 0-100 - */ - public function relevanceScore(string $query, string $target): int - { - $query = Str::lower(trim($query)); - $target = Str::lower(trim($target)); - - if ($query === '' || $target === '') { - return 0; - } - - // Exact match - if ($target === $query) { - return 100; - } - - // Starts with query - if (Str::startsWith($target, $query)) { - return 90; - } - - // Contains query as whole word - if (preg_match('/\b'.preg_quote($query, '/').'\b/', $target)) { - return 80; - } - - // Contains query - if (Str::contains($target, $query)) { - return 70; - } - - // Word-start matching - $words = preg_split('/\s+/', $target); - $queryChars = str_split($query); - $matched = 0; - $wordIndex = 0; - - foreach ($queryChars as $char) { - while ($wordIndex < count($words)) { - if (Str::startsWith($words[$wordIndex], $char)) { - $matched++; - $wordIndex++; - break; - } - $wordIndex++; - } - } - - if ($matched === count($queryChars)) { - return 60; - } - - // Fuzzy match - if ($this->fuzzyMatch($query, $target)) { - return 40; - } - - return 0; - } -} diff --git a/packages/core-admin/src/Search/SearchResult.php b/packages/core-admin/src/Search/SearchResult.php deleted file mode 100644 index 7035317..0000000 --- a/packages/core-admin/src/Search/SearchResult.php +++ /dev/null @@ -1,104 +0,0 @@ -id, - title: $this->title, - url: $this->url, - type: $type, - icon: $this->icon !== 'document' ? $this->icon : $icon, - subtitle: $this->subtitle, - meta: $this->meta, - ); - } - - /** - * Convert the result to an array. - */ - public function toArray(): array - { - return [ - 'id' => $this->id, - 'title' => $this->title, - 'subtitle' => $this->subtitle, - 'url' => $this->url, - 'type' => $this->type, - 'icon' => $this->icon, - 'meta' => $this->meta, - ]; - } - - /** - * Specify data which should be serialized to JSON. - */ - public function jsonSerialize(): array - { - return $this->toArray(); - } -} diff --git a/packages/core-admin/src/Search/Tests/SearchProviderRegistryTest.php b/packages/core-admin/src/Search/Tests/SearchProviderRegistryTest.php deleted file mode 100644 index 55d7663..0000000 --- a/packages/core-admin/src/Search/Tests/SearchProviderRegistryTest.php +++ /dev/null @@ -1,237 +0,0 @@ -registry = new SearchProviderRegistry; - } - - public function test_can_register_provider(): void - { - $provider = $this->createMockProvider('test', 'Test', 'document'); - - $this->registry->register($provider); - - $this->assertCount(1, $this->registry->providers()); - } - - public function test_can_register_many_providers(): void - { - $providers = [ - $this->createMockProvider('pages', 'Pages', 'document'), - $this->createMockProvider('users', 'Users', 'user'), - ]; - - $this->registry->registerMany($providers); - - $this->assertCount(2, $this->registry->providers()); - } - - public function test_fuzzy_match_direct_substring(): void - { - $this->assertTrue($this->registry->fuzzyMatch('dash', 'Dashboard')); - $this->assertTrue($this->registry->fuzzyMatch('board', 'Dashboard')); - $this->assertTrue($this->registry->fuzzyMatch('settings', 'Account Settings')); - } - - public function test_fuzzy_match_case_insensitive(): void - { - $this->assertTrue($this->registry->fuzzyMatch('DASH', 'dashboard')); - $this->assertTrue($this->registry->fuzzyMatch('Dashboard', 'DASHBOARD')); - } - - public function test_fuzzy_match_word_start(): void - { - // "gs" should match "Global Search" (G + S) - $this->assertTrue($this->registry->fuzzyMatch('gs', 'Global Search')); - - // "ps" should match "Post Settings" - $this->assertTrue($this->registry->fuzzyMatch('ps', 'Post Settings')); - - // "ul" should match "Usage Limits" - $this->assertTrue($this->registry->fuzzyMatch('ul', 'Usage Limits')); - } - - public function test_fuzzy_match_abbreviation(): void - { - // Characters appear in order - $this->assertTrue($this->registry->fuzzyMatch('dbd', 'dashboard')); - $this->assertTrue($this->registry->fuzzyMatch('gsr', 'global search results')); - } - - public function test_fuzzy_match_empty_query_returns_false(): void - { - $this->assertFalse($this->registry->fuzzyMatch('', 'Dashboard')); - $this->assertFalse($this->registry->fuzzyMatch(' ', 'Dashboard')); - } - - public function test_fuzzy_match_no_match(): void - { - $this->assertFalse($this->registry->fuzzyMatch('xyz', 'Dashboard')); - $this->assertFalse($this->registry->fuzzyMatch('zzz', 'Settings')); - } - - public function test_relevance_score_exact_match(): void - { - $score = $this->registry->relevanceScore('dashboard', 'dashboard'); - $this->assertEquals(100, $score); - } - - public function test_relevance_score_starts_with(): void - { - $score = $this->registry->relevanceScore('dash', 'dashboard'); - $this->assertEquals(90, $score); - } - - public function test_relevance_score_contains(): void - { - $score = $this->registry->relevanceScore('board', 'dashboard'); - $this->assertEquals(70, $score); - } - - public function test_relevance_score_word_start(): void - { - $score = $this->registry->relevanceScore('gs', 'global search'); - $this->assertEquals(60, $score); - } - - public function test_relevance_score_no_match(): void - { - $score = $this->registry->relevanceScore('xyz', 'dashboard'); - $this->assertEquals(0, $score); - } - - public function test_search_returns_grouped_results(): void - { - $provider = $this->createMockProvider('pages', 'Pages', 'document', [ - new SearchResult('1', 'Dashboard', '/hub', 'pages', 'house', 'Overview'), - new SearchResult('2', 'Settings', '/hub/settings', 'pages', 'gear', 'Preferences'), - ]); - - $this->registry->register($provider); - - $results = $this->registry->search('dash', null, null); - - $this->assertArrayHasKey('pages', $results); - $this->assertEquals('Pages', $results['pages']['label']); - $this->assertEquals('document', $results['pages']['icon']); - $this->assertCount(2, $results['pages']['results']); - } - - public function test_search_respects_provider_availability(): void - { - $availableProvider = $this->createMockProvider('pages', 'Pages', 'document', [], true); - $unavailableProvider = $this->createMockProvider('admin', 'Admin', 'shield', [], false); - - $this->registry->register($availableProvider); - $this->registry->register($unavailableProvider); - - $available = $this->registry->availableProviders(null, null); - - $this->assertCount(1, $available); - } - - public function test_flatten_results(): void - { - $grouped = [ - 'pages' => [ - 'label' => 'Pages', - 'icon' => 'document', - 'results' => [ - ['id' => '1', 'title' => 'Dashboard'], - ['id' => '2', 'title' => 'Settings'], - ], - ], - 'users' => [ - 'label' => 'Users', - 'icon' => 'user', - 'results' => [ - ['id' => '3', 'title' => 'Admin'], - ], - ], - ]; - - $flat = $this->registry->flattenResults($grouped); - - $this->assertCount(3, $flat); - $this->assertEquals('Dashboard', $flat[0]['title']); - $this->assertEquals('Settings', $flat[1]['title']); - $this->assertEquals('Admin', $flat[2]['title']); - } - - /** - * Create a mock search provider. - */ - protected function createMockProvider( - string $type, - string $label, - string $icon, - array $results = [], - bool $available = true - ): SearchProvider { - return new class($type, $label, $icon, $results, $available) implements SearchProvider - { - use HasSearchProvider; - - public function __construct( - protected string $type, - protected string $label, - protected string $icon, - protected array $results, - protected bool $available - ) {} - - public function searchType(): string - { - return $this->type; - } - - public function searchLabel(): string - { - return $this->label; - } - - public function searchIcon(): string - { - return $this->icon; - } - - public function search(string $query, int $limit = 5): Collection - { - return collect($this->results)->take($limit); - } - - public function getUrl(mixed $result): string - { - return $result['url'] ?? '#'; - } - - public function isAvailable(?object $user, ?object $workspace): bool - { - return $this->available; - } - }; - } -} diff --git a/packages/core-admin/src/Search/Tests/SearchResultTest.php b/packages/core-admin/src/Search/Tests/SearchResultTest.php deleted file mode 100644 index 8a085ae..0000000 --- a/packages/core-admin/src/Search/Tests/SearchResultTest.php +++ /dev/null @@ -1,165 +0,0 @@ - 'value'], - ); - - $this->assertEquals('123', $result->id); - $this->assertEquals('Dashboard', $result->title); - $this->assertEquals('/hub', $result->url); - $this->assertEquals('pages', $result->type); - $this->assertEquals('house', $result->icon); - $this->assertEquals('Overview and quick actions', $result->subtitle); - $this->assertEquals(['key' => 'value'], $result->meta); - } - - public function test_can_create_from_array(): void - { - $data = [ - 'id' => '456', - 'title' => 'Settings', - 'url' => '/hub/settings', - 'type' => 'pages', - 'icon' => 'gear', - 'subtitle' => 'Account settings', - 'meta' => ['order' => 2], - ]; - - $result = SearchResult::fromArray($data); - - $this->assertEquals('456', $result->id); - $this->assertEquals('Settings', $result->title); - $this->assertEquals('/hub/settings', $result->url); - $this->assertEquals('pages', $result->type); - $this->assertEquals('gear', $result->icon); - $this->assertEquals('Account settings', $result->subtitle); - $this->assertEquals(['order' => 2], $result->meta); - } - - public function test_from_array_with_missing_fields(): void - { - $data = [ - 'title' => 'Minimal', - ]; - - $result = SearchResult::fromArray($data); - - $this->assertNotEmpty($result->id); // Should generate an ID - $this->assertEquals('Minimal', $result->title); - $this->assertEquals('#', $result->url); - $this->assertEquals('unknown', $result->type); - $this->assertEquals('document', $result->icon); - $this->assertNull($result->subtitle); - $this->assertEquals([], $result->meta); - } - - public function test_to_array(): void - { - $result = new SearchResult( - id: '789', - title: 'Test', - url: '/test', - type: 'test', - icon: 'test-icon', - subtitle: 'Test subtitle', - meta: ['foo' => 'bar'], - ); - - $array = $result->toArray(); - - $this->assertEquals([ - 'id' => '789', - 'title' => 'Test', - 'subtitle' => 'Test subtitle', - 'url' => '/test', - 'type' => 'test', - 'icon' => 'test-icon', - 'meta' => ['foo' => 'bar'], - ], $array); - } - - public function test_json_serialize(): void - { - $result = new SearchResult( - id: '1', - title: 'JSON Test', - url: '/json', - type: 'json', - icon: 'code', - ); - - $json = json_encode($result); - $decoded = json_decode($json, true); - - $this->assertEquals('1', $decoded['id']); - $this->assertEquals('JSON Test', $decoded['title']); - $this->assertEquals('/json', $decoded['url']); - } - - public function test_with_type_and_icon(): void - { - $original = new SearchResult( - id: '1', - title: 'Test', - url: '/test', - type: 'old-type', - icon: 'document', // Default icon - ); - - $modified = $original->withTypeAndIcon('new-type', 'new-icon'); - - // Original should be unchanged (immutable) - $this->assertEquals('old-type', $original->type); - $this->assertEquals('document', $original->icon); - - // Modified should have new values - $this->assertEquals('new-type', $modified->type); - $this->assertEquals('new-icon', $modified->icon); - - // Other properties should be preserved - $this->assertEquals('1', $modified->id); - $this->assertEquals('Test', $modified->title); - $this->assertEquals('/test', $modified->url); - } - - public function test_with_type_and_icon_preserves_custom_icon(): void - { - $original = new SearchResult( - id: '1', - title: 'Test', - url: '/test', - type: 'old-type', - icon: 'custom-icon', // Not the default - ); - - $modified = $original->withTypeAndIcon('new-type', 'fallback-icon'); - - // Should keep the custom icon, not use the fallback - $this->assertEquals('custom-icon', $modified->icon); - $this->assertEquals('new-type', $modified->type); - } -} diff --git a/packages/core-admin/src/Website/Hub/Boot.php b/packages/core-admin/src/Website/Hub/Boot.php deleted file mode 100644 index 0b74e4c..0000000 --- a/packages/core-admin/src/Website/Hub/Boot.php +++ /dev/null @@ -1,195 +0,0 @@ - - */ - public static array $domains = [ - '/^core\.(test|localhost)$/', - '/^hub\.core\.(test|localhost)$/', - ]; - - /** - * Events this module listens to for lazy loading. - * - * @var array - */ - public static array $listens = [ - DomainResolving::class => 'onDomainResolving', - AdminPanelBooting::class => 'onAdminPanel', - ]; - - /** - * Handle domain resolution - register if we match. - */ - public function onDomainResolving(DomainResolving $event): void - { - foreach (static::$domains as $pattern) { - if ($event->matches($pattern)) { - $event->register(static::class); - - return; - } - } - } - - public function register(): void - { - // - } - - /** - * Get domains for this website. - * - * @return array - */ - protected function domains(): array - { - return app(DomainResolver::class)->domainsFor(self::class); - } - - /** - * Register admin panel routes and components. - */ - public function onAdminPanel(AdminPanelBooting $event): void - { - $event->views('hub', __DIR__.'/View/Blade'); - - // Load translations (path should point to Lang folder, Laravel adds locale subdirectory) - $event->translations('hub', dirname(__DIR__, 2).'/Mod/Hub/Lang'); - - // Register Livewire components - $event->livewire('hub.admin.workspace-switcher', \Website\Hub\View\Modal\Admin\WorkspaceSwitcher::class); - $event->livewire('hub.admin.global-search', \Website\Hub\View\Modal\Admin\GlobalSearch::class); - - // Register menu provider - app(AdminMenuRegistry::class)->register($this); - - // Register routes for configured domains - foreach ($this->domains() as $domain) { - $event->routes(fn () => Route::prefix('hub') - ->name('hub.') - ->domain($domain) - ->group(__DIR__.'/Routes/admin.php')); - } - } - - /** - * Provide admin menu items. - */ - public function adminMenuItems(): array - { - return [ - // Dashboard - standalone group - [ - 'group' => 'dashboard', - 'priority' => 10, - 'item' => fn () => [ - 'label' => __('hub::hub.dashboard.title'), - 'icon' => 'house', - 'href' => route('hub.dashboard'), - 'active' => request()->routeIs('hub.dashboard'), - ], - ], - - // Workspaces - [ - 'group' => 'workspaces', - 'priority' => 10, - 'item' => fn () => [ - 'label' => __('hub::hub.workspaces.title'), - 'icon' => 'folders', - 'href' => route('hub.sites'), - 'active' => request()->routeIs('hub.sites*'), - ], - ], - - // Account - Profile - [ - 'group' => 'settings', - 'priority' => 10, - 'item' => fn () => [ - 'label' => __('hub::hub.quick_actions.profile.title'), - 'icon' => 'user', - 'href' => route('hub.account'), - 'active' => request()->routeIs('hub.account') && ! request()->routeIs('hub.account.*'), - ], - ], - - // Account - Settings - [ - 'group' => 'settings', - 'priority' => 20, - 'item' => fn () => [ - 'label' => __('hub::hub.settings.title'), - 'icon' => 'gear', - 'href' => route('hub.account.settings'), - 'active' => request()->routeIs('hub.account.settings'), - ], - ], - - // Account - Usage - [ - 'group' => 'settings', - 'priority' => 30, - 'item' => fn () => [ - 'label' => __('hub::hub.usage.title'), - 'icon' => 'chart-pie', - 'href' => route('hub.account.usage'), - 'active' => request()->routeIs('hub.account.usage'), - ], - ], - - // Admin - Platform (Hades only) - [ - 'group' => 'admin', - 'priority' => 10, - 'admin' => true, - 'item' => fn () => [ - 'label' => 'Platform', - 'icon' => 'server', - 'href' => route('hub.platform'), - 'active' => request()->routeIs('hub.platform*'), - ], - ], - - // Admin - Services (Hades only) - [ - 'group' => 'admin', - 'priority' => 20, - 'admin' => true, - 'item' => fn () => [ - 'label' => 'Services', - 'icon' => 'puzzle-piece', - 'href' => route('hub.admin.services'), - 'active' => request()->routeIs('hub.admin.services'), - ], - ], - ]; - } -} diff --git a/packages/core-admin/src/Website/Hub/Routes/admin.php b/packages/core-admin/src/Website/Hub/Routes/admin.php deleted file mode 100644 index 5b615bd..0000000 --- a/packages/core-admin/src/Website/Hub/Routes/admin.php +++ /dev/null @@ -1,74 +0,0 @@ -name('dashboard'); -Route::redirect('/dashboard', '/hub')->name('dashboard.redirect'); -Route::get('/content/{workspace}/{type}', \Website\Hub\View\Modal\Admin\Content::class)->name('content') - ->where('type', 'posts|pages|media'); -Route::get('/content-manager/{workspace}/{view?}', \Website\Hub\View\Modal\Admin\ContentManager::class)->name('content-manager') - ->where('view', 'dashboard|kanban|calendar|list|webhooks'); -Route::get('/content-editor/{workspace}/new/{contentType?}', \Website\Hub\View\Modal\Admin\ContentEditor::class)->name('content-editor.create'); -Route::get('/content-editor/{workspace}/{id}', \Website\Hub\View\Modal\Admin\ContentEditor::class)->name('content-editor.edit') - ->where('id', '[0-9]+'); -// /hub/workspaces redirects to current workspace settings (workspace switcher handles selection) -Route::get('/workspaces', \Website\Hub\View\Modal\Admin\Sites::class)->name('sites'); -Route::redirect('/sites', '/hub/workspaces'); -Route::get('/console', \Website\Hub\View\Modal\Admin\Console::class)->name('console'); -Route::get('/databases', \Website\Hub\View\Modal\Admin\Databases::class)->name('databases'); -// Account section -Route::get('/account', \Website\Hub\View\Modal\Admin\Profile::class)->name('account'); -Route::get('/account/settings', \Website\Hub\View\Modal\Admin\Settings::class)->name('account.settings'); -Route::get('/account/usage', \Website\Hub\View\Modal\Admin\AccountUsage::class)->name('account.usage'); -Route::redirect('/profile', '/hub/account'); -Route::redirect('/settings', '/hub/account/settings'); -Route::redirect('/usage', '/hub/account/usage'); -Route::redirect('/boosts', '/hub/account/usage?tab=boosts'); -Route::redirect('/ai-services', '/hub/account/usage?tab=ai'); -// Route::get('/config/{path?}', \Core\Config\View\Modal\Admin\WorkspaceConfig::class) -// ->where('path', '.*') -// ->name('workspace.config'); -// Route::redirect('/workspace/config', '/hub/config'); -Route::get('/workspaces/{workspace}/{tab?}', \Website\Hub\View\Modal\Admin\SiteSettings::class) - ->where('tab', 'services|general|deployment|environment|ssl|backups|danger') - ->name('sites.settings'); -Route::get('/deployments', \Website\Hub\View\Modal\Admin\Deployments::class)->name('deployments'); -Route::get('/platform', \Website\Hub\View\Modal\Admin\Platform::class)->name('platform'); -Route::get('/platform/user/{id}', \Website\Hub\View\Modal\Admin\PlatformUser::class)->name('platform.user') - ->where('id', '[0-9]+'); -Route::get('/prompts', \Website\Hub\View\Modal\Admin\PromptManager::class)->name('prompts'); - -// Entitlement management (admin only) -Route::get('/entitlements', \Website\Hub\View\Modal\Admin\Entitlement\Dashboard::class)->name('entitlements'); -Route::get('/entitlements/packages', \Website\Hub\View\Modal\Admin\Entitlement\PackageManager::class)->name('entitlements.packages'); -Route::get('/entitlements/features', \Website\Hub\View\Modal\Admin\Entitlement\FeatureManager::class)->name('entitlements.features'); - -// Waitlist management (admin only - Hades tier) -Route::get('/admin/waitlist', \Website\Hub\View\Modal\Admin\WaitlistManager::class)->name('admin.waitlist'); - -// Workspace management (admin only - Hades tier) -// Route::get('/admin/workspaces', \Core\Mod\Tenant\View\Modal\Admin\WorkspaceManager::class)->name('admin.workspaces'); -// Route::get('/admin/workspaces/{id}', \Core\Mod\Tenant\View\Modal\Admin\WorkspaceDetails::class)->name('admin.workspaces.details') -// ->where('id', '[0-9]+'); - -// Service management (admin only - Hades tier) -Route::get('/admin/services', \Website\Hub\View\Modal\Admin\ServiceManager::class)->name('admin.services'); - -// Services - workspace admin for Bio, Social, Analytics, Notify, Trust, Support, Commerce -Route::get('/services/{service?}/{tab?}', \Website\Hub\View\Modal\Admin\ServicesAdmin::class) - ->where('service', 'bio|social|analytics|notify|trust|support|commerce') - ->where('tab', 'dashboard|pages|channels|projects|accounts|posts|websites|goals|subscribers|campaigns|notifications|inbox|settings|orders|subscriptions|coupons') - ->name('services'); - -// Security - Honeypot monitoring -Route::get('/honeypot', \Website\Hub\View\Modal\Admin\Honeypot::class)->name('honeypot'); diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/account-usage.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/account-usage.blade.php deleted file mode 100644 index 5aafc3a..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/account-usage.blade.php +++ /dev/null @@ -1,691 +0,0 @@ -
- - - {{-- Card with sidebar --}} -
-
- - {{-- Sidebar navigation --}} -
- {{-- Usage group --}} -
- -
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
-
- - {{-- Integrations group --}} -
- -
    -
  • - -
  • -
-
-
- - {{-- Content panel --}} -
- {{-- Overview Section --}} - @if($activeSection === 'overview') -
-
-

Usage Overview

-

Monitor your current usage and limits.

-
- - {{-- Active Packages --}} -
-

Active Packages

- @if(empty($activePackages)) -
- -

No active packages

-
- @else -
- @foreach($activePackages as $workspacePackage) -
- @if($workspacePackage['package']['icon'] ?? null) -
- -
- @endif -
-

{{ $workspacePackage['package']['name'] ?? 'Unknown' }}

-
- @if($workspacePackage['package']['is_base_package'] ?? false) - Base - @else - Addon - @endif - Active -
-
-
- @endforeach -
- @endif -
- - {{-- Usage by Category - Accordion --}} - @if(!empty($usageSummary)) - - @foreach($usageSummary as $category => $features) - @php - $categoryIcon = match($category) { - 'social' => 'share-nodes', - 'bio', 'biolink' => 'link', - 'analytics' => 'chart-line', - 'notify' => 'bell', - 'trust' => 'shield-check', - 'support' => 'headset', - 'ai' => 'microchip', - 'mcp', 'api' => 'plug', - 'host', 'service' => 'server', - default => 'cubes', - }; - $categoryColor = match($category) { - 'social' => 'pink', - 'bio', 'biolink' => 'emerald', - 'analytics' => 'blue', - 'notify' => 'amber', - 'trust' => 'green', - 'support' => 'violet', - 'ai' => 'purple', - 'mcp', 'api' => 'indigo', - 'host', 'service' => 'sky', - default => 'gray', - }; - $allowedCount = collect($features)->where('allowed', true)->count(); - $totalCount = count($features); - @endphp - - -
-
- - - - {{ $category ?? 'General' }} -
- - {{ $allowedCount }}/{{ $totalCount }} - -
-
- -
- @foreach($features as $feature) -
- {{ $feature['name'] }} - @if(!$feature['allowed']) - Not included - @elseif($feature['unlimited']) - Unlimited - @elseif($feature['type'] === 'limit' && isset($feature['limit'])) - @php - $percentage = min($feature['percentage'] ?? 0, 100); - $badgeColor = match(true) { - $percentage >= 90 => 'red', - $percentage >= 75 => 'amber', - default => 'green', - }; - @endphp - {{ $feature['used'] }}/{{ $feature['limit'] }} - @elseif($feature['type'] === 'boolean') - Active - @endif -
- @endforeach -
-
-
- @endforeach -
- @else -
- -

No usage data available

-
- @endif - - {{-- Active Boosts --}} - @if(!empty($activeBoosts)) -
-

Active Boosts

-
- @foreach($activeBoosts as $boost) -
-
- {{ $boost['feature_code'] }} -
- @switch($boost['boost_type']) - @case('add_limit') - +{{ number_format($boost['limit_value']) }} - @break - @case('unlimited') - Unlimited - @break - @case('enable') - Enabled - @break - @endswitch -
-
- @if($boost['boost_type'] === 'add_limit' && $boost['limit_value']) -
- {{ number_format($boost['remaining_limit'] ?? $boost['limit_value']) }} - remaining -
- @endif -
- @endforeach -
-
- @endif -
- @endif - - {{-- Workspaces Section --}} - @if($activeSection === 'workspaces') -
-
-

Workspaces

-

View all your workspaces and their subscription details.

-
- - @php $workspaces = $this->userWorkspaces; @endphp - - @if(count($workspaces) > 0) - {{-- Cost Summary --}} - @php - $totalMonthly = collect($workspaces)->sum('price'); - $activeCount = collect($workspaces)->where('status', 'active')->count(); - @endphp -
-
-
-
- -
-
-

£{{ number_format($totalMonthly, 2) }}

-

Monthly total

-
-
-
-
-
-
- -
-
-

{{ count($workspaces) }}

-

Total workspaces

-
-
-
-
-
-
- -
-
-

{{ $activeCount }}

-

Active subscriptions

-
-
-
-
- - {{-- Workspace List --}} -
- @foreach($workspaces as $ws) -
-
-
-
- {{ strtoupper(substr($ws['workspace']->name, 0, 2)) }} -
-
-

{{ $ws['workspace']->name }}

-
- - {{ ucfirst($ws['status']) }} - - {{ $ws['plan'] }} -
-
-
-
-
- @if($ws['price'] > 0) -

£{{ number_format($ws['price'], 2) }}/mo

- @else -

Free

- @endif - @if($ws['renewsAt']) -

- Renews {{ $ws['renewsAt']->format('j M Y') }} -

- @endif -
-
- - - -
-
-
- @if($ws['serviceCount'] > 0) -
-

Active Services

-
- @foreach($ws['services'] as $service) - - - {{ $service['label'] }} - - @endforeach -
-
- @endif -
- @endforeach -
- @else -
- -

No workspaces found

-

Create a workspace to get started.

-
- @endif -
- @endif - - {{-- Entitlements Section --}} - @if($activeSection === 'entitlements') -
-
-

Entitlements

-

View all available features and your current access levels.

-
- - @forelse($this->allFeatures as $category => $features) -
-

- @php - $categoryIcon = match($category) { - 'social' => 'share-nodes', - 'bio' => 'link', - 'analytics' => 'chart-line', - 'notify' => 'bell', - 'trust' => 'shield-check', - 'support' => 'headset', - 'ai' => 'microchip', - 'mcp' => 'plug', - default => 'cubes', - }; - $categoryColor = match($category) { - 'social' => 'pink', - 'bio' => 'emerald', - 'analytics' => 'blue', - 'notify' => 'amber', - 'trust' => 'green', - 'support' => 'violet', - 'ai' => 'purple', - 'mcp' => 'indigo', - default => 'gray', - }; - @endphp - - - - {{ $category ?? 'General' }} -

-
- - - - - - - - - - - @foreach($features as $feature) - @php - $workspace = auth()->user()?->defaultHostWorkspace(); - $check = $workspace ? app(\Core\Mod\Tenant\Services\EntitlementService::class)->can($workspace, $feature['code']) : null; - $allowed = $check?->isAllowed() ?? false; - $limit = $check?->effectiveLimit ?? null; - $unlimited = $check?->isUnlimited ?? false; - @endphp - - - - - - - @endforeach - -
FeatureCodeTypeYour Access
- {{ $feature['name'] }} - @if($feature['description'] ?? null) -

{{ Str::limit($feature['description'], 50) }}

- @endif -
- {{ $feature['code'] }} - - - {{ ucfirst($feature['type']) }} - - - @if(!$allowed) - Not included - @elseif($unlimited) - Unlimited - @elseif($feature['type'] === 'boolean') - Enabled - @elseif($limit !== null) - {{ number_format($limit) }} - @else - Enabled - @endif -
-
-
- @empty -
- -

No features defined

-
- @endforelse - - {{-- Upgrade prompt --}} - @if(!auth()->user()?->isHades()) -
-
-
-

Need more access?

-

Upgrade your plan to unlock additional features and higher limits.

-
- - View Plans - -
-
- @endif -
- @endif - - {{-- Boosts Section --}} - @if($activeSection === 'boosts') -
-
-

Purchase Boosts

-

Add extra capacity to your account.

-
- - @if(count($boostOptions) > 0) -
- @foreach($boostOptions as $boost) -
-
-
-

{{ $boost['feature_name'] }}

-

{{ $boost['description'] }}

-
- @switch($boost['boost_type']) - @case('add_limit') - +{{ number_format($boost['limit_value']) }} - @break - @case('unlimited') - Unlimited - @break - @case('enable') - Enable - @break - @endswitch -
-
-
- @switch($boost['duration_type']) - @case('cycle_bound') - Billing cycle - @break - @case('duration') - Limited time - @break - @case('permanent') - Permanent - @break - @endswitch -
- - Purchase - -
-
- @endforeach -
- @else -
- -

No boosts available

-

Check back later for available boosts.

-
- @endif - - {{-- Info box --}} -
-

- About Boosts -

-
    -
  • Billing cycle: Resets with your billing period
  • -
  • Limited time: Expires after a set duration
  • -
  • Permanent: Never expires
  • -
-
-
- @endif - - {{-- AI Services Section --}} - @if($activeSection === 'ai') -
-
-

AI Services

-

Configure your AI provider API keys.

-
- - {{-- AI Provider Tabs --}} -
- -
- - {{-- Claude Panel --}} - @if($activeAiTab === 'claude') -
- - API Key - - - Get your API key from Anthropic - - - - - - Model - - @foreach($this->claudeModelsComputed as $value => $label) - {{ $label }} - @endforeach - - - - - -
- Save Claude Settings -
- - @endif - - {{-- Gemini Panel --}} - @if($activeAiTab === 'gemini') -
- - API Key - - - Get your API key from Google AI Studio - - - - - - Model - - @foreach($this->geminiModelsComputed as $value => $label) - {{ $label }} - @endforeach - - - - - -
- Save Gemini Settings -
- - @endif - - {{-- OpenAI Panel --}} - @if($activeAiTab === 'openai') -
- - Secret Key - - - Get your API key from OpenAI - - - - - - -
- Save OpenAI Settings -
- - @endif -
- @endif -
- -
-
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/activity-log.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/activity-log.blade.php deleted file mode 100644 index 8126a68..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/activity-log.blade.php +++ /dev/null @@ -1,19 +0,0 @@ - - - - @if(count($this->logNames) > 0) - - @endif - @if(count($this->events) > 0) - - @endif - - - - - diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/ai-services.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/ai-services.blade.php deleted file mode 100644 index 9908080..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/ai-services.blade.php +++ /dev/null @@ -1,316 +0,0 @@ -
- -
-
-

{{ __('hub::hub.ai_services.title') }}

-

{{ __('hub::hub.ai_services.subtitle') }}

-
-
- - - @if($savedMessage) -
-
- - {{ $savedMessage }} -
-
- @endif - - -
- -
- - - @if($activeTab === 'claude') -
-
- - - -
-

{{ __('hub::hub.ai_services.providers.claude.title') }}

-

- - {{ __('hub::hub.ai_services.providers.claude.api_key_link') }} - -

-
-
- -
- -
- - - @error('claudeApiKey') -

{{ $message }}

- @enderror -
- - -
- - - @error('claudeModel') -

{{ $message }}

- @enderror -
- - -
- - -
- - -
-
- @endif - - - @if($activeTab === 'gemini') -
-
- - - - - - - - - - -
-

{{ __('hub::hub.ai_services.providers.gemini.title') }}

-

- - {{ __('hub::hub.ai_services.providers.gemini.api_key_link') }} - -

-
-
- -
- -
- - - @error('geminiApiKey') -

{{ $message }}

- @enderror -
- - -
- - - @error('geminiModel') -

{{ $message }}

- @enderror -
- - -
- - -
- - -
-
- @endif - - - @if($activeTab === 'openai') -
-
- - - -
-

{{ __('hub::hub.ai_services.providers.openai.title') }}

-

- - {{ __('hub::hub.ai_services.providers.openai.api_key_link') }} - -

-
-
- -
- -
- - - @error('openaiSecretKey') -

{{ $message }}

- @enderror -
- - -
- - -
- - -
-
- @endif -
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/analytics.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/analytics.blade.php deleted file mode 100644 index 3be8f2b..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/analytics.blade.php +++ /dev/null @@ -1,62 +0,0 @@ -
- -
-
-

Analytics

-

Privacy-first insights across all your sites

-
-
-
- Last 30 days -
-
-
- - -
-
-
- -
-
-

Coming Soon

-

- Analytics integration is on the roadmap. This dashboard will display real-time visitor data, page views, traffic sources, and conversion metrics—all without cookies. -

-
-
-
- - -
- @foreach($metrics as $metric) -
-
- - {{ $metric['label'] }} -
-
{{ $metric['value'] }}
-
- @endforeach -
- - -
- @foreach($chartData as $key => $chart) -
-
-

{{ $chart['title'] }}

-

{{ $chart['description'] }}

-
-
-
-
- - Chart placeholder -
-
-
-
- @endforeach -
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/boost-purchase.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/boost-purchase.blade.php deleted file mode 100644 index e6fb1ea..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/boost-purchase.blade.php +++ /dev/null @@ -1,90 +0,0 @@ -
- -
-

{{ __('hub::hub.boosts.title') }}

-

{{ __('hub::hub.boosts.subtitle') }}

-
- -
- @if(count($boostOptions) > 0) -
- @foreach($boostOptions as $boost) -
-
-
-

- {{ $boost['feature_name'] }} -

-

- {{ $boost['description'] }} -

-
- @switch($boost['boost_type']) - @case('add_limit') - +{{ number_format($boost['limit_value']) }} - @break - @case('unlimited') - {{ __('hub::hub.boosts.types.unlimited') }} - @break - @case('enable') - {{ __('hub::hub.boosts.types.enable') }} - @break - @endswitch -
- -
-
- @switch($boost['duration_type']) - @case('cycle_bound') - - {{ __('hub::hub.boosts.duration.cycle_bound') }} - @break - @case('duration') - - {{ __('hub::hub.boosts.duration.limited') }} - @break - @case('permanent') - - {{ __('hub::hub.boosts.duration.permanent') }} - @break - @endswitch -
- - {{ __('hub::hub.boosts.actions.purchase') }} - -
-
- @endforeach -
- @else -
-
- -

{{ __('hub::hub.boosts.empty.title') }}

-

{{ __('hub::hub.boosts.empty.hint') }}

-
-
- @endif - - -
-

- - {{ __('hub::hub.boosts.info.title') }} -

-
    -
  • {{ __('hub::hub.boosts.labels.cycle_bound') }} {{ __('hub::hub.boosts.info.cycle_bound') }}
  • -
  • {{ __('hub::hub.boosts.labels.duration_based') }} {{ __('hub::hub.boosts.info.duration_based') }}
  • -
  • {{ __('hub::hub.boosts.labels.permanent') }} {{ __('hub::hub.boosts.info.permanent') }}
  • -
-
- - -
- - - {{ __('hub::hub.boosts.actions.back') }} - -
-
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/components/developer-bar.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/components/developer-bar.blade.php deleted file mode 100644 index 2542254..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/components/developer-bar.blade.php +++ /dev/null @@ -1,505 +0,0 @@ -@php - $user = auth()->user(); - $showDevBar = $user && method_exists($user, 'isHades') && $user->isHades(); - - // Performance metrics - $queryCount = count(DB::getQueryLog()); - $startTime = defined('LARAVEL_START') ? LARAVEL_START : microtime(true); - $loadTime = number_format((microtime(true) - $startTime) * 1000, 2); - $memoryUsage = number_format(memory_get_peak_usage(true) / 1024 / 1024, 1); - - // Check available dev tools - $hasHorizon = class_exists(\Laravel\Horizon\Horizon::class); - $hasPulse = class_exists(\Laravel\Pulse\Pulse::class); - $hasTelescope = class_exists(\Laravel\Telescope\Telescope::class) && config('telescope.enabled', false); -@endphp - -@if($showDevBar) -
- -
- -
-
-

Recent Logs

- -
-
- - - -
-
- - -
-
-

Routes

- -
-
- - -
-
- - -
-

Session & Request

-
-
-
Session ID
-
-
-
-
User Agent
-
-
-
-
IP Address
-
-
-
-
PHP Version
-
{{ PHP_VERSION }}
-
-
-
Laravel Version
-
{{ app()->version() }}
-
-
-
Environment
-
{{ app()->environment() }}
-
-
-
- - -
-

Cache Management

-
- - - - - -
-

- - These actions run artisan cache commands on the server. -

-
- - -
- -

Classic

-
- - - - - -
- - -

Sharp

-
- - - - - -
- - -

Specialty

-
- - - - - - - - - - - - - - -
- - -

Size

-
- - - - - - - -
- -

- - Current: - + -

-
-
- - -
-
- -
-
- - {{ app()->environment() }} - - | - - Hades - -
- -
- - -
- - - - - - - - - - - | - - - @if($hasHorizon) - - - - @endif - - @if($hasPulse) - - - - @endif - - @if($hasTelescope) - - - - @endif -
- - -
- - - -
-
-
-
- - - -@endif \ No newline at end of file diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/components/header.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/components/header.blade.php deleted file mode 100644 index f71d30a..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/components/header.blade.php +++ /dev/null @@ -1,183 +0,0 @@ -
-
-
- - -
- - - - - - - -
- - -
- - - - - -
- - -
- - - - - -
- - - @php - $user = auth()->user(); - $userName = $user?->name ?? 'Guest'; - $userEmail = $user?->email ?? ''; - $userTier = ($user && method_exists($user, 'getTier')) ? ($user->getTier()?->label() ?? 'Free') : 'Free'; - $userInitials = collect(explode(' ', $userName))->map(fn($n) => strtoupper(substr($n, 0, 1)))->take(2)->join(''); - @endphp -
- -
-
-
{{ $userName }}
-
{{ $userEmail }}
-
- -
-
- -
- -
-
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/components/sidebar.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/components/sidebar.blade.php deleted file mode 100644 index 73d7db6..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/components/sidebar.blade.php +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/console.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/console.blade.php deleted file mode 100644 index efb0237..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/console.blade.php +++ /dev/null @@ -1,132 +0,0 @@ -
- -
-
-

{{ __('hub::hub.console.title') }}

-

{{ __('hub::hub.console.subtitle') }}

-
-
- -
- -
-
-
-

{{ __('hub::hub.console.labels.select_server') }}

-
-
-
    - @foreach($servers as $server) -
  • - -
  • - @endforeach -
-
-
- - -
-
-
- -
-
-

{{ __('hub::hub.console.coolify.title') }}

-

{{ __('hub::hub.console.coolify.description') }}

-
-
-
-
- - -
-
- -
-
-
-
-
-
- @if($selectedServer) - @php $selectedServerData = collect($servers)->firstWhere('id', $selectedServer); @endphp - {{ $selectedServerData['name'] ?? __('hub::hub.console.labels.terminal') }} - @else - {{ __('hub::hub.console.labels.terminal') }} - @endif -
- - -
-
- - -
- @if($selectedServer) -
-
{{ __('hub::hub.console.labels.connecting', ['name' => $selectedServerData['name'] ?? 'server']) }}
-
{{ __('hub::hub.console.labels.establishing_connection') }}
-
{{ __('hub::hub.console.labels.connected') }}
-
- root@{{ strtolower(str_replace(' ', '-', $selectedServerData['name'] ?? 'server')) }}:~$ - _ -
-
- @else -
- -

{{ __('hub::hub.console.labels.select_server_prompt') }}

-
- @endif -
- - - @if($selectedServer) -
-
- $ - - -
-

- - {{ __('hub::hub.console.labels.terminal_disabled') }} -

-
- @endif -
-
-
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/content-editor.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/content-editor.blade.php deleted file mode 100644 index 7f6d9c8..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/content-editor.blade.php +++ /dev/null @@ -1,654 +0,0 @@ -
- {{-- Header --}} -
-
-
- - - -
-

- {{ $contentId ? __('hub::hub.content_editor.title.edit') : __('hub::hub.content_editor.title.new') }} -

-

- @if($lastSaved) - {{ __('hub::hub.content_editor.save_status.last_saved', ['time' => $lastSaved]) }} - @else - {{ __('hub::hub.content_editor.save_status.not_saved') }} - @endif - @if($isDirty) - • {{ __('hub::hub.content_editor.save_status.unsaved_changes') }} - @endif - @if($revisionCount > 0) - • {{ trans_choice('hub::hub.content_editor.save_status.revisions', $revisionCount, ['count' => $revisionCount]) }} - @endif -

-
-
- -
- {{-- AI Command Button --}} - - {{ __('hub::hub.content_editor.actions.ai_assist') }} - - - {{-- Status --}} - - {{ __('hub::hub.content_editor.status.draft') }} - {{ __('hub::hub.content_editor.status.pending') }} - {{ __('hub::hub.content_editor.status.publish') }} - {{ __('hub::hub.content_editor.status.future') }} - {{ __('hub::hub.content_editor.status.private') }} - - - {{-- Save --}} - - {{ __('hub::hub.content_editor.actions.save_draft') }} - - - {{-- Schedule/Publish --}} - @if($isScheduled) - - {{ __('hub::hub.content_editor.actions.schedule') }} - - @else - - {{ __('hub::hub.content_editor.actions.publish') }} - - @endif -
-
-
- - {{-- Main Content Area --}} -
- {{-- Editor Panel --}} -
-
-
- {{-- Title --}} -
- -
- - {{-- Slug & Type Row --}} -
-
- -
-
- - {{ __('hub::hub.content_editor.fields.type_page') }} - {{ __('hub::hub.content_editor.fields.type_post') }} - -
-
- - {{-- Excerpt --}} -
- -
- - {{-- Main Editor (AC7 - Rich Text) --}} -
- -
- -
-
-
-
-
- - {{-- Sidebar --}} -
- {{-- Sidebar Tabs --}} -
-
- - - - -
-
- -
- {{-- Settings Panel --}} -
- {{-- Scheduling (AC11) --}} -
-

{{ __('hub::hub.content_editor.scheduling.title') }}

- - - - @if($isScheduled) - - @endif -
- -
- - {{-- Categories (AC9) --}} -
-

{{ __('hub::hub.content_editor.categories.title') }}

- - @if(count($this->categories) > 0) -
- @foreach($this->categories as $category) - - @endforeach -
- @else -

{{ __('hub::hub.content_editor.categories.none') }}

- @endif -
- -
- - {{-- Tags (AC9) --}} -
-

{{ __('hub::hub.content_editor.tags.title') }}

- - {{-- Selected Tags --}} - @if(count($selectedTags) > 0) -
- @foreach($this->tags as $tag) - @if(in_array($tag['id'], $selectedTags)) - - {{ $tag['name'] }} - - @endif - @endforeach -
- @endif - - {{-- Add New Tag --}} -
- - -
- - {{-- Existing Tags to Select --}} - @if(count($this->tags) > 0) -
- @foreach($this->tags as $tag) - @if(!in_array($tag['id'], $selectedTags)) - - @endif - @endforeach -
- @endif -
-
- - {{-- SEO Panel (AC10) --}} -
-

{{ __('hub::hub.content_editor.seo.title') }}

- - -
- {{ __('hub::hub.content_editor.seo.characters', ['count' => strlen($seoTitle), 'max' => 70]) }} -
- - -
- {{ __('hub::hub.content_editor.seo.characters', ['count' => strlen($seoDescription), 'max' => 160]) }} -
- - - - {{-- SEO Preview --}} -
-

{{ __('hub::hub.content_editor.seo.preview_title') }}

-
- {{ $seoTitle ?: $title ?: __('hub::hub.content_editor.seo.meta_title_placeholder') }} -
-
- example.com/{{ $slug ?: 'page-url' }} -
-
- {{ $seoDescription ?: $excerpt ?: __('hub::hub.content_editor.seo.preview_description_fallback') }} -
-
-
- - {{-- Media Panel (AC8) --}} -
-

{{ __('hub::hub.content_editor.media.featured_image') }}

- - {{-- Current Featured Image --}} - @if($this->featuredMedia) -
- {{ $this->featuredMedia->alt_text }} - -
- @else - {{-- Upload Zone --}} -
- -

- {{ __('hub::hub.content_editor.media.drag_drop') }} -

- -
- - @if($featuredImageUpload) -
- - {{ $featuredImageUpload->getClientOriginalName() }} - - - {{ __('hub::hub.content_editor.media.upload') }} - -
- @endif - @endif - - {{-- Media Library --}} - @if(count($this->mediaLibrary) > 0) -
-

{{ __('hub::hub.content_editor.media.select_from_library') }}

-
- @foreach($this->mediaLibrary as $media) - - @endforeach -
-
- @endif -
- - {{-- Revisions Panel (AC12) --}} -
-

{{ __('hub::hub.content_editor.revisions.title') }}

- - @if($contentId) - @if(count($revisions) > 0) -
- @foreach($revisions as $revision) -
-
- - {{ ucfirst($revision['change_type']) }} - - - #{{ $revision['revision_number'] }} - -
-

- {{ $revision['title'] }} -

-
- - {{ \Carbon\Carbon::parse($revision['created_at'])->diffForHumans() }} - - - {{ __('hub::hub.content_editor.revisions.restore') }} - -
- @if($revision['word_count']) -

- {{ number_format($revision['word_count']) }} words -

- @endif -
- @endforeach -
- @else -

{{ __('hub::hub.content_editor.revisions.no_revisions') }}

- @endif - @else -

{{ __('hub::hub.content_editor.revisions.save_first') }}

- @endif -
-
-
-
- - {{-- AI Command Palette Modal --}} - -
- {{-- Search Input --}} -
- - - -
- - {{-- Quick Actions --}} - @if(empty($commandSearch) && !$selectedPromptId) -
-

- {{ __('hub::hub.content_editor.ai.quick_actions') }} -

-
- @foreach($this->quickActions as $action) - - @endforeach -
-
- @endif - - {{-- Prompt List --}} - @if(!$selectedPromptId) -
- @foreach($this->prompts as $category => $categoryPrompts) -
-

- {{ ucfirst($category) }} -

- @foreach($categoryPrompts as $prompt) - -
-
{{ $prompt['name'] }}
-
{{ $prompt['description'] }}
-
- - {{ $prompt['model'] }} - -
- @endforeach -
- @endforeach -
- @endif - - {{-- Prompt Variables Form --}} - @if($selectedPromptId) - @php $selectedPrompt = \App\Models\Prompt::find($selectedPromptId); @endphp -
-
- -
-

- {{ $selectedPrompt->name }} -

-

{{ $selectedPrompt->description }}

-
-
- - @if($selectedPrompt->variables) - @foreach($selectedPrompt->variables as $name => $config) - @if($name !== 'content') -
- @if(($config['type'] ?? 'string') === 'string') - - @elseif(($config['type'] ?? 'string') === 'boolean') - - @endif -
- @endif - @endforeach - @endif - -
- - {{ __('hub::hub.content_editor.ai.cancel') }} - - - {{ __('hub::hub.content_editor.ai.run') }} - {{ __('hub::hub.content_editor.ai.processing') }} - -
-
- @endif - - {{-- AI Result --}} - @if($aiResult) -
-

- {{ __('hub::hub.content_editor.ai.result_title') }} -

-
-
- {!! nl2br(e($aiResult)) !!} -
-
-
- - {{ __('hub::hub.content_editor.ai.discard') }} - - - {{ __('hub::hub.content_editor.ai.insert') }} - - - {{ __('hub::hub.content_editor.ai.replace_content') }} - -
-
- @endif - - {{-- Processing Indicator --}} - @if($aiProcessing) -
-
- - - - - {{ __('hub::hub.content_editor.ai.thinking') }} -
-
- @endif - - {{-- Footer --}} -
-
- {!! __('hub::hub.content_editor.ai.footer_close', ['key' => 'Esc']) !!} - {{ __('hub::hub.content_editor.ai.footer_powered') }} -
-
-
-
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/content-manager.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/content-manager.blade.php deleted file mode 100644 index 11302d9..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/content-manager.blade.php +++ /dev/null @@ -1,161 +0,0 @@ -
- -
-
-
- {{ __('hub::hub.content_manager.title') }} - @if($currentWorkspace) - - {{ $currentWorkspace->name }} - - @endif -
- {{ __('hub::hub.content_manager.subtitle') }} -
- - -
- @if($syncMessage) - - {{ $syncMessage }} - - @endif - - {{ __('hub::hub.content_manager.actions.new_content') }} - - - {{ __('hub::hub.content_manager.actions.sync_all') }} - - - {{ __('hub::hub.content_manager.actions.purge_cdn') }} - -
-
- - - - - - @if($view === 'dashboard') - @include('hub::admin.content-manager.dashboard') - @elseif($view === 'kanban') - @include('hub::admin.content-manager.kanban') - @elseif($view === 'calendar') - @include('hub::admin.content-manager.calendar') - @elseif($view === 'list') - @include('hub::admin.content-manager.list') - @elseif($view === 'webhooks') - @include('hub::admin.content-manager.webhooks') - @endif - - - - - - - @if($this->selectedItem) - -
- {{ $this->selectedItem->title }} -
- - -
- -
- - - - {{ __('hub::hub.content_manager.preview.sync_label') }}: {{ ucfirst($this->selectedItem->sync_status) }} - -
- - - @if($this->selectedItem->author) -
- @if($this->selectedItem->author->avatar_url) - - @else - {{ substr($this->selectedItem->author->name, 0, 1) }} - @endif -
- {{ $this->selectedItem->author->name }} - {{ __('hub::hub.content_manager.preview.author') }} -
-
- @endif - - - @if($this->selectedItem->excerpt) -
- {{ __('hub::hub.content_manager.preview.excerpt') }} - {{ $this->selectedItem->excerpt }} -
- @endif - - -
- {{ __('hub::hub.content_manager.preview.content_clean_html') }} -
- {!! $this->selectedItem->content_html_clean ?: $this->selectedItem->content_html_original !!} -
-
- - - @if($this->selectedItem->categories->isNotEmpty() || $this->selectedItem->tags->isNotEmpty()) -
- {{ __('hub::hub.content_manager.preview.taxonomies') }} -
- @foreach($this->selectedItem->categories as $category) - {{ $category->name }} - @endforeach - @foreach($this->selectedItem->tags as $tag) - #{{ $tag->name }} - @endforeach -
-
- @endif - - - @if($this->selectedItem->content_json) -
- {{ __('hub::hub.content_manager.preview.structured_content') }} -
-
{{ json_encode($this->selectedItem->content_json, JSON_PRETTY_PRINT) }}
-
-
- @endif - - - - -
-
- {{ __('hub::hub.content_manager.preview.created') }}: - {{ $this->selectedItem->wp_created_at?->format('M j, Y H:i') ?? '-' }} -
-
- {{ __('hub::hub.content_manager.preview.modified') }}: - {{ $this->selectedItem->wp_modified_at?->format('M j, Y H:i') ?? '-' }} -
-
- {{ __('hub::hub.content_manager.preview.last_synced') }}: - {{ $this->selectedItem->synced_at?->diffForHumans() ?? __('hub::hub.content_manager.preview.never') }} -
-
- {{ __('hub::hub.content_manager.preview.wordpress_id') }}: - #{{ $this->selectedItem->wp_id }} -
-
-
- @endif -
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/content-manager/calendar.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/content-manager/calendar.blade.php deleted file mode 100644 index a88fc7e..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/content-manager/calendar.blade.php +++ /dev/null @@ -1,100 +0,0 @@ - - - @php - $now = now(); - $startOfMonth = $now->copy()->startOfMonth(); - $endOfMonth = $now->copy()->endOfMonth(); - $startDay = $startOfMonth->dayOfWeek; - $daysInMonth = $now->daysInMonth; - - // Group events by date - $eventsByDate = collect($this->calendarEvents)->groupBy('date'); - @endphp - - -
-
- {{ $now->format('F Y') }} - {{ __('hub::hub.content_manager.calendar.content_schedule') }} -
-
-
-
- {{ __('hub::hub.content_manager.calendar.legend.published') }} -
-
-
- {{ __('hub::hub.content_manager.calendar.legend.draft') }} -
-
-
- {{ __('hub::hub.content_manager.calendar.legend.scheduled') }} -
-
-
- - - -
- @foreach([ - __('hub::hub.content_manager.calendar.days.sun'), - __('hub::hub.content_manager.calendar.days.mon'), - __('hub::hub.content_manager.calendar.days.tue'), - __('hub::hub.content_manager.calendar.days.wed'), - __('hub::hub.content_manager.calendar.days.thu'), - __('hub::hub.content_manager.calendar.days.fri'), - __('hub::hub.content_manager.calendar.days.sat') - ] as $day) -
- {{ $day }} -
- @endforeach -
- - -
- {{-- Empty cells for days before start of month --}} - @for($i = 0; $i < $startDay; $i++) -
- @endfor - - {{-- Days of the month --}} - @for($day = 1; $day <= $daysInMonth; $day++) - @php - $dateStr = $now->copy()->setDay($day)->format('Y-m-d'); - $dayEvents = $eventsByDate->get($dateStr, collect()); - $isToday = $now->copy()->setDay($day)->isToday(); - @endphp -
-
- {{ $day }} -
-
- @foreach($dayEvents->take(3) as $event) - - @endforeach - @if($dayEvents->count() > 3) -
- {{ __('hub::hub.content_manager.calendar.more', ['count' => $dayEvents->count() - 3]) }} -
- @endif -
-
- @endfor - - {{-- Empty cells for days after end of month --}} - @php - $remainingCells = 7 - (($startDay + $daysInMonth) % 7); - if ($remainingCells == 7) $remainingCells = 0; - @endphp - @for($i = 0; $i < $remainingCells; $i++) -
- @endfor -
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/content-manager/dashboard.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/content-manager/dashboard.blade.php deleted file mode 100644 index 6083edf..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/content-manager/dashboard.blade.php +++ /dev/null @@ -1,240 +0,0 @@ - -
- -
-
- -
-
- {{ $this->stats['total'] }} - {{ __('hub::hub.content_manager.dashboard.total_content') }} -
-
-
- - -
-
- -
-
- {{ $this->stats['posts'] }} - {{ __('hub::hub.content_manager.dashboard.posts') }} -
-
-
- - -
-
- -
-
- {{ $this->stats['published'] }} - {{ __('hub::hub.content_manager.dashboard.published') }} -
-
-
- - -
-
- -
-
- {{ $this->stats['drafts'] }} - {{ __('hub::hub.content_manager.dashboard.drafts') }} -
-
-
- - -
-
- -
-
- {{ $this->stats['synced'] }} - {{ __('hub::hub.content_manager.dashboard.synced') }} -
-
-
- - -
-
- -
-
- {{ $this->stats['failed'] }} - {{ __('hub::hub.content_manager.dashboard.failed') }} -
-
-
-
- - -
- - -
- {{ __('hub::hub.content_manager.dashboard.content_created') }} -
- -
- - - - - - - - - - - - - - - - - -
-
- - - -
- {{ __('hub::hub.content_manager.dashboard.content_by_type') }} -
- -
-
- @php - $total = $this->stats['posts'] + $this->stats['pages']; - $postsPercent = $total > 0 ? round(($this->stats['posts'] / $total) * 100) : 0; - $pagesPercent = $total > 0 ? round(($this->stats['pages'] / $total) * 100) : 0; - @endphp - -
-
- {{ __('hub::hub.content_manager.dashboard.posts') }} - {{ $this->stats['posts'] }} ({{ $postsPercent }}%) -
-
-
-
-
- -
-
- {{ __('hub::hub.content_manager.dashboard.pages') }} - {{ $this->stats['pages'] }} ({{ $pagesPercent }}%) -
-
-
-
-
-
- - - -
-
- {{ $this->stats['categories'] }} - {{ __('hub::hub.content_manager.dashboard.categories') }} -
-
- {{ $this->stats['tags'] }} - {{ __('hub::hub.content_manager.dashboard.tags') }} -
-
-
-
-
- - -
- -
- {{ __('hub::hub.content_manager.dashboard.sync_status') }} -
- -
-
-
-
- {{ __('hub::hub.content_manager.dashboard.synced') }} -
- {{ $this->stats['synced'] }} -
-
-
-
- {{ __('hub::hub.content_manager.dashboard.pending') }} -
- {{ $this->stats['pending'] }} -
-
-
-
- {{ __('hub::hub.content_manager.dashboard.stale') }} -
- {{ $this->stats['stale'] }} -
-
-
-
- {{ __('hub::hub.content_manager.dashboard.failed') }} -
- {{ $this->stats['failed'] }} -
-
-
- - -
- {{ __('hub::hub.content_manager.dashboard.taxonomies') }} -
- -
-
-
- - {{ __('hub::hub.content_manager.dashboard.categories') }} -
- {{ $this->stats['categories'] }} -
-
-
- - {{ __('hub::hub.content_manager.dashboard.tags') }} -
- {{ $this->stats['tags'] }} -
-
-
- - -
- {{ __('hub::hub.content_manager.dashboard.webhooks_today') }} -
- -
-
-
- - {{ __('hub::hub.content_manager.dashboard.received') }} -
- {{ $this->stats['webhooks_today'] }} -
-
-
- - {{ __('hub::hub.content_manager.dashboard.failed') }} -
- {{ $this->stats['webhooks_failed'] }} -
-
-
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/content-manager/kanban.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/content-manager/kanban.blade.php deleted file mode 100644 index 9a9c9e0..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/content-manager/kanban.blade.php +++ /dev/null @@ -1,58 +0,0 @@ - - - @foreach($this->kanbanColumns as $column) - - - - @if($column['status'] === 'draft') - - @endif - - - - - @forelse($column['items'] as $item) - - - - - - - {{ $item->title }} - - @if($item->excerpt) - - {{ Str::limit($item->excerpt, 80) }} - - @endif - - - @if($item->categories && $item->categories->isNotEmpty()) - @foreach($item->categories->take(2) as $category) - {{ $category->name }} - @endforeach - @if($item->categories->count() > 2) - +{{ $item->categories->count() - 2 }} - @endif - @endif -
- - {{ $item->wp_created_at?->format('M j') ?? '-' }} - -
-
- @empty -
- - {{ __('hub::hub.content_manager.kanban.no_items') }} -
- @endforelse -
-
- @endforeach -
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/content-manager/list.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/content-manager/list.blade.php deleted file mode 100644 index 9429d05..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/content-manager/list.blade.php +++ /dev/null @@ -1,176 +0,0 @@ - - -
-
- - - - - - {{ __('hub::hub.content_manager.list.filters.all_types') }} - {{ __('hub::hub.content_manager.list.filters.posts') }} - {{ __('hub::hub.content_manager.list.filters.pages') }} - - - - - {{ __('hub::hub.content_manager.list.filters.all_status') }} - {{ __('hub::hub.content_manager.list.filters.published') }} - {{ __('hub::hub.content_manager.list.filters.draft') }} - {{ __('hub::hub.content_manager.list.filters.pending') }} - {{ __('hub::hub.content_manager.list.filters.scheduled') }} - {{ __('hub::hub.content_manager.list.filters.private') }} - - - - - {{ __('hub::hub.content_manager.list.filters.all_sync') }} - {{ __('hub::hub.content_manager.list.filters.synced') }} - {{ __('hub::hub.content_manager.list.filters.pending') }} - {{ __('hub::hub.content_manager.list.filters.stale') }} - {{ __('hub::hub.content_manager.list.filters.failed') }} - - - - - {{ __('hub::hub.content_manager.list.filters.all_sources') }} - {{ __('hub::hub.content_manager.list.filters.native') }} - {{ __('hub::hub.content_manager.list.filters.host_uk') }} - {{ __('hub::hub.content_manager.list.filters.satellite') }} - @if(config('services.content.wordpress_enabled')) - {{ __('hub::hub.content_manager.list.filters.wordpress_legacy') }} - @endif - - - - @if(count($this->categories) > 0) - - {{ __('hub::hub.content_manager.list.filters.all_categories') }} - @foreach($this->categories as $slug => $name) - {{ $name }} - @endforeach - - @endif - - - @if($search || $type || $status || $syncStatus || $category || $contentType) - - {{ __('hub::hub.content_manager.list.filters.clear') }} - - @endif -
-
-
- - - - - - - {{ __('hub::hub.content_manager.list.columns.title') }} - - - - - - - - - - - - @forelse($this->content as $item) - - -
- - {{ $item->slug }} -
-
- - - - - - - - - - - - - - -
- @if($item->usesFluxEditor()) - - @endif - -
-
-
- @empty - - -
- - {{ __('hub::hub.content_manager.list.no_content') }} - @if($search || $type || $status || $syncStatus || $category) - - {{ __('hub::hub.content_manager.list.filters.clear_filters') }} - - @endif -
-
-
- @endforelse -
-
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/content-manager/webhooks.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/content-manager/webhooks.blade.php deleted file mode 100644 index 052e9cf..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/content-manager/webhooks.blade.php +++ /dev/null @@ -1,165 +0,0 @@ - -
- -
-
- -
-
- {{ $this->stats['webhooks_today'] }} - {{ __('hub::hub.content_manager.webhooks.today') }} -
-
-
- - -
-
- -
-
- {{ $this->webhookLogs->where('status', 'completed')->count() }} - {{ __('hub::hub.content_manager.webhooks.completed') }} -
-
-
- - -
-
- -
-
- {{ $this->webhookLogs->where('status', 'pending')->count() }} - {{ __('hub::hub.content_manager.webhooks.pending') }} -
-
-
- - -
-
- -
-
- {{ $this->stats['webhooks_failed'] }} - {{ __('hub::hub.content_manager.webhooks.failed') }} -
-
-
-
- - - - - - {{ __('hub::hub.content_manager.webhooks.columns.id') }} - {{ __('hub::hub.content_manager.webhooks.columns.event') }} - - {{ __('hub::hub.content_manager.webhooks.columns.status') }} - - - - - - - - @forelse($this->webhookLogs as $log) - - - #{{ $log->id }} - - - - {{ $log->event_type }} - - - - - - - - - - - - - - - - - - - - @if($log->status === 'failed') - - {{ __('hub::hub.content_manager.webhooks.actions.retry') }} - - @endif - - - {{ __('hub::hub.content_manager.webhooks.actions.view_payload') }} - - - @if($log->error_message) - -
- {{ __('hub::hub.content_manager.webhooks.error') }}: {{ Str::limit($log->error_message, 80) }} -
- @endif -
-
-
-
- @empty - - -
- - {{ __('hub::hub.content_manager.webhooks.no_logs') }} - - {{ __('hub::hub.content_manager.webhooks.no_logs_description') }} - -
-
-
- @endforelse -
-
-
- - - - {{ __('hub::hub.content_manager.webhooks.endpoint.title') }} -
- POST {{ url('/api/v1/webhook/content') }} -
- - {{ __('hub::hub.content_manager.webhooks.endpoint.description', ['header' => 'X-WP-Signature']) }} - -
- - -
- -
- {{ __('hub::hub.content_manager.webhooks.payload_modal.title') }} -
- -
-

-        
-
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/content.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/content.blade.php deleted file mode 100644 index 6c67741..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/content.blade.php +++ /dev/null @@ -1,298 +0,0 @@ -
- -
-
-
-

{{ __('hub::hub.content.title') }}

- - - {{ $currentWorkspace['name'] ?? 'Hestia Main' }} - -
-

{{ __('hub::hub.content.subtitle') }}

-
- @if($tab !== 'media') -
- -
- @endif -
- - - - - -
- @foreach ($this->stats as $stat) -
-
{{ $stat['title'] }}
-
{{ $stat['value'] }}
-
- - {{ $stat['trend'] }} -
-
- @endforeach -
- - -
-
-
- - - - - -
- - -
- - -
-
-
- - - @if($tab === 'media' && $view === 'grid') - -
- @forelse($this->rows as $item) -
- @if(($item['media_type'] ?? 'image') === 'image') - {{ $item['title']['rendered'] ?? '' }} - @else -
- -
- @endif -
- {{ $item['title']['rendered'] ?? __('hub::hub.content.untitled') }} -
-
- @empty -
- -

{{ __('hub::hub.content.no_media') }}

-
- @endforelse -
- @else - -
-
- - - - - - - - - - - - - - @forelse ($this->rows as $row) - - - - - - - - - - @empty - - - - @endforelse - -
- - {{ __('hub::hub.content.columns.title') }}
- - -
- @if($tab === 'media') -
- @if(($row['media_type'] ?? 'image') === 'image') - - @else -
- -
- @endif -
- @endif -
-
{{ $row['title']['rendered'] ?? __('hub::hub.content.untitled') }}
- @if($tab !== 'media' && !empty($row['excerpt']['rendered'])) -
{{ Str::limit(strip_tags($row['excerpt']['rendered']), 50) }}
- @endif -
-
-
-
- -
- @if($tab !== 'media') - - @endif - - -
- -
-
-
-
- -

{{ $tab === 'posts' ? __('hub::hub.content.no_posts') : ($tab === 'pages' ? __('hub::hub.content.no_pages') : __('hub::hub.content.no_media')) }}

-
-
-
-
- @endif - - - @if($this->paginator->hasPages()) -
- {{ $this->paginator->links() }} -
- @endif - - - @if($showEditor) - - @endif -
\ No newline at end of file diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/dashboard.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/dashboard.blade.php deleted file mode 100644 index 66d2440..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/dashboard.blade.php +++ /dev/null @@ -1,96 +0,0 @@ -
- {{-- Welcome Header --}} -
-

Welcome to {{ config('app.name', 'Core PHP') }}

-

Your application is ready to use.

-
- - {{-- Quick Stats --}} -
-
-
-
- - - -
-
-

Users

-

{{ \Core\Mod\Tenant\Models\User::count() }}

-
-
-
- -
-
-
- - - -
-
-

Status

-

Active

-
-
-
- -
-
-
- - - -
-
-

Laravel

-

{{ app()->version() }}

-
-
-
-
- - {{-- Quick Actions --}} - - - {{-- User Info --}} -
-

Logged in as

-
-
- {{ substr(auth()->user()->name ?? 'U', 0, 1) }} -
-
-

{{ auth()->user()->name ?? 'User' }}

-

{{ auth()->user()->email ?? '' }}

-
-
-
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/databases.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/databases.blade.php deleted file mode 100644 index 45be416..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/databases.blade.php +++ /dev/null @@ -1,233 +0,0 @@ -
- - Databases & Integrations - -
- - {{-- Internal WordPress (hestia.host.uk.com) --}} - -
-
-
- -
-
- Host UK WordPress - Internal content management system -
-
- - {{ ucfirst($internalWpHealth['status'] ?? 'Unknown') }} - -
- -
- {{-- API Status --}} -
- REST API -
- @if($internalWpHealth['api_available'] ?? false) -
- Available - @else -
- Unavailable - @endif -
-
- - {{-- Post Count --}} -
- Posts - - {{ number_format($internalWpHealth['post_count'] ?? 0) }} - -
- - {{-- Page Count --}} -
- Pages - - {{ number_format($internalWpHealth['page_count'] ?? 0) }} - -
-
- -
- {{ $internalWpHealth['url'] ?? 'Not configured' }} - Last checked: {{ isset($internalWpHealth['last_check']) ? \Carbon\Carbon::parse($internalWpHealth['last_check'])->diffForHumans() : 'Never' }} -
- -
- - Refresh - - - Manage Content - -
-
- - {{-- External WordPress Connector --}} - -
-
- -
-
- WordPress Connector - Connect your self-hosted WordPress site to sync content -
-
- -
- {{-- Enable Toggle --}} - - - @if($wpConnectorEnabled) - {{-- WordPress URL --}} - - - {{-- Webhook Configuration --}} -
- Plugin Configuration - - Install the Host Hub Connector plugin on your WordPress site and enter these settings: - - - {{-- Webhook URL --}} -
- Webhook URL -
- - -
-
- - {{-- Webhook Secret --}} -
- Webhook Secret -
- - - -
- - Keep this secret safe. It's used to verify webhooks are from your WordPress site. - -
-
- - {{-- Connection Status --}} -
-
- @if($this->isWpConnectorVerified) -
-
- Connected - @if($this->wpConnectorLastSync) - Last sync: {{ $this->wpConnectorLastSync }} - @endif -
- @else -
-
- Not verified - Test the connection to verify -
- @endif -
- - - Test Connection - -
- - @if($testResult) - - {{ $testResult }} - - @endif - - {{-- Plugin Download --}} -
-
- -
- WordPress Plugin - - Download and install the Host Hub Connector plugin on your WordPress site to enable content syncing. - - - Download Plugin - -
-
-
- @endif -
- -
- - Save Settings - -
-
- - {{-- Future Integrations Placeholder --}} - -
-
- -
- More Integrations Coming Soon - - Connect additional databases and external systems - -
-
- -
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/deployments.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/deployments.blade.php deleted file mode 100644 index 3ce3458..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/deployments.blade.php +++ /dev/null @@ -1,160 +0,0 @@ -
- Deployments & System Status - Monitor system health and recent deployments - - {{-- Current Deployment Info --}} - -
-
-
- -
-
- Current Deployment - Branch: {{ $this->gitInfo['branch'] }} -
-
-
- - Refresh - -
-
- -
-
- Commit - {{ $this->gitInfo['commit'] }} -
-
- Message - {{ \Illuminate\Support\Str::limit($this->gitInfo['message'], 30) }} -
-
- Author - {{ $this->gitInfo['author'] }} -
-
- Deployed - {{ $this->gitInfo['date'] ?? 'Unknown' }} -
-
-
- - {{-- Stats Grid --}} -
- @foreach($this->stats as $stat) - -
-
- -
-
- {{ $stat['label'] }} - {{ $stat['value'] }} -
-
-
- @endforeach -
- -
- {{-- Service Health --}} - - Service Health - -
- @foreach($this->services as $service) -
-
- -
- {{ $service['name'] }} - @if(isset($service['details'])) - - @if(isset($service['details']['version'])) - v{{ $service['details']['version'] }} - @endif - @if(isset($service['details']['memory'])) - · {{ $service['details']['memory'] }} - @endif - @if(isset($service['details']['pending'])) - · {{ $service['details']['pending'] }} pending - @endif - @if(isset($service['details']['used_percent'])) - · {{ $service['details']['used_percent'] }} used - @endif - - @endif - @if(isset($service['error'])) - {{ $service['error'] }} - @endif -
-
-
- @if($service['status'] === 'healthy') - - Healthy - @elseif($service['status'] === 'warning') - - Warning - @elseif($service['status'] === 'unknown') - - Unknown - @else - - Unhealthy - @endif -
-
- @endforeach -
- -
- - Clear Application Cache - -
-
- - {{-- Recent Commits --}} - - Recent Commits - - @if(count($this->recentCommits) > 0) -
- @foreach($this->recentCommits as $commit) -
- {{ $commit['hash'] }} -
- {{ $commit['message'] }} - {{ $commit['author'] }} · {{ $commit['date'] }} -
-
- @endforeach -
- @else -
- - No commit history available - Git may not be available in this environment -
- @endif -
-
- - {{-- Future Coolify Integration Notice --}} - -
-
- -
-
- Coming Soon: Deployment Management - - Full deployment management with Coolify integration is planned. You'll be able to trigger deployments, view build logs, rollback to previous versions, and monitor deployment health. - -
-
-
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/dev/cache.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/dev/cache.blade.php deleted file mode 100644 index 225131e..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/dev/cache.blade.php +++ /dev/null @@ -1,148 +0,0 @@ -
- {{-- Page header --}} -
-
-

Cache Management

-

Clear application caches and optimise performance

-
-
- - {{-- Cache actions grid --}} -
- {{-- Application Cache --}} -
-
-
- -
-
-

Application Cache

-

Redis/file cache data

-
-
- - Clear Cache - Clearing... - -
- - {{-- Config Cache --}} -
-
-
- -
-
-

Configuration Cache

-

Compiled config files

-
-
- - Clear Config - Clearing... - -
- - {{-- View Cache --}} -
-
-
- -
-
-

View Cache

-

Compiled Blade templates

-
-
- - Clear Views - Clearing... - -
- - {{-- Route Cache --}} -
-
-
- -
-
-

Route Cache

-

Compiled route files

-
-
- - Clear Routes - Clearing... - -
- - {{-- Clear All --}} -
-
-
- -
-
-

Clear All

-

All caches at once

-
-
- - Clear All Caches - Clearing... - -
- - {{-- Optimise --}} -
-
-
- -
-
-

Optimise

-

Rebuild all caches

-
-
- - Optimise App - Optimising... - -
-
- - {{-- Last action output --}} - @if($lastOutput) -
-
-

Last Action: {{ $lastAction }}

-
-
{{ $lastOutput }}
-
- @endif -
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/dev/logs.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/dev/logs.blade.php deleted file mode 100644 index c9f8222..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/dev/logs.blade.php +++ /dev/null @@ -1,112 +0,0 @@ -
- {{-- Page header --}} -
-
-

Application Logs

-

View recent Laravel log entries

-
-
- - -
-
- - {{-- Level filter --}} -
- - - - - -
- - {{-- Logs table --}} -
- @if(count($logs) === 0) -
- -

No log entries found

-
- @else -
- - - - - - - - - - @foreach($logs as $log) - - - - - - @endforeach - -
TimeLevelMessage
- {{ $log['time'] }} - - @php - $levelClass = match($log['level']) { - 'error', 'critical', 'alert', 'emergency' => 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', - 'warning' => 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', - 'info', 'notice' => 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', - default => 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400', - }; - @endphp - - {{ strtoupper($log['level']) }} - - - {{ Str::limit($log['message'], 300) }} -
-
- @endif -
- - {{-- Show count --}} -
- Showing {{ count($logs) }} of last {{ $limit }} log entries -
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/dev/routes.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/dev/routes.blade.php deleted file mode 100644 index 95ff9d2..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/dev/routes.blade.php +++ /dev/null @@ -1,111 +0,0 @@ -
- {{-- Page header --}} -
-
-

Application Routes

-

Browse all registered routes ({{ count($routes) }} total)

-
-
- - {{-- Search and filter --}} -
-
- -
-
- - - - - -
-
- - {{-- Routes table --}} -
- @php $filteredRoutes = $this->filteredRoutes; @endphp - @if(count($filteredRoutes) === 0) -
- -

No routes match your search

-
- @else -
- - - - - - - - - - - @foreach($filteredRoutes as $route) - - - - - - - @endforeach - -
MethodURINameAction
- @php - $methodClass = match($route['method']) { - 'GET' => 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', - 'POST' => 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', - 'PUT', 'PATCH' => 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', - 'DELETE' => 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', - default => 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400', - }; - @endphp - - {{ $route['method'] }} - - - {{ $route['uri'] }} - - {{ $route['name'] ?? '-' }} - - {{ Str::limit($route['action'], 60) }} -
-
- @endif -
- - {{-- Show count --}} -
- Showing {{ count($filteredRoutes) }} of {{ count($routes) }} routes -
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/entitlement/dashboard.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/entitlement/dashboard.blade.php deleted file mode 100644 index 2ac53c0..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/entitlement/dashboard.blade.php +++ /dev/null @@ -1,452 +0,0 @@ -
- {{-- Header --}} -
-
-
-
- -
-
-

Entitlements

-

Manage what workspaces can access and how much they can use

-
-
- - - Hades Only - -
-
- - {{-- Flash messages --}} - @if(session('success')) -
-
- - {{ session('success') }} -
-
- @endif - - @if(session('error')) -
-
- - {{ session('error') }} -
-
- @endif - - {{-- Tabs --}} -
- -
- - {{-- Tab Content --}} -
- {{-- Overview Tab --}} - @if($tab === 'overview') -
- {{-- Explanation --}} -
-

How Entitlements Work

-
-

- The entitlement system controls what workspaces can access and how much they can use. Think of it as a flexible permissions and quota system. -

- -
- {{-- Features --}} -
-
-
- -
-

Features

-
-

- The atomic building blocks. Each feature is something you can check: "Can they do X?" or "How many X can they have?" -

-
-
- boolean - On/off access (e.g., core.srv.bio) -
-
- limit - Quota (e.g., bio.pages = 10) -
-
-
- - {{-- Packages --}} -
-
-
- -
-

Packages

-
-

- Bundles of features sold as products. A "Pro" package might include 50 bio pages, social access, and analytics. -

-
-
- base - One per workspace (plans) -
-
- addon - Stackable extras -
-
-
- - {{-- Boosts --}} -
-
-
- -
-

Boosts

-
-

- One-off grants for specific features. Admin can give a workspace +100 pages or enable a feature temporarily. -

-
-
- permanent - Forever (or until revoked) -
-
- expiring - Time-limited -
-
-
-
- -
-
The Flow
-
- Features - - bundled into - - Packages - - assigned to - - Workspaces -
-

- Boosts bypass packages to grant features directly to workspaces (for support, promotions, etc.) -

-
-
-
- - {{-- Stats Grid --}} -
-
-
-
- -
-
-
{{ $this->stats['packages']['total'] }}
-
Packages
-
-
-
- {{ $this->stats['packages']['active'] }} active - {{ $this->stats['packages']['public'] }} public -
-
- -
-
-
- -
-
-
{{ $this->stats['features']['total'] }}
-
Features
-
-
-
- {{ $this->stats['features']['boolean'] }} boolean - {{ $this->stats['features']['limit'] }} limits -
-
- -
-
-
- -
-
-
{{ $this->stats['assignments']['workspace_packages'] }}
-
Active Assignments
-
-
-
- Workspaces with packages -
-
- -
-
-
- -
-
-
{{ $this->stats['assignments']['active_boosts'] }}
-
Active Boosts
-
-
-
- Direct feature grants -
-
-
- - {{-- Categories --}} -
-

Feature Categories

-
- @forelse($this->stats['categories'] as $category) - - {{ $category }} - - @empty - No categories defined - @endforelse -
-
-
- @endif - - {{-- Packages Tab --}} - @if($tab === 'packages') -
-
-
-

Packages

-

Bundles of features assigned to workspaces

-
- - New Package - -
- -
- -
-
- @endif - - {{-- Features Tab --}} - @if($tab === 'features') -
-
-
-

Features

-

Individual capabilities that can be checked and tracked

-
- - New Feature - -
- -
- -
-
- @endif -
- - {{-- Package Modal --}} - -
-
-
- -
- {{ $editingPackageId ? 'Edit Package' : 'Create Package' }} -
- -
-
- - -
- - - -
- - - -
- -
- - -
- -
- - -
- -
- Cancel - - {{ $editingPackageId ? 'Update' : 'Create' }} - -
- -
-
- - {{-- Feature Modal --}} - -
-
-
- -
- {{ $editingFeatureId ? 'Edit Feature' : 'Create Feature' }} -
- -
-
- - -
- - - -
- - -
- -
- - Boolean (on/off) - Limit (quota) - Unlimited - - - - Never - Monthly - Rolling Window - -
- - @if($featureResetType === 'rolling') - - @endif - - @if($featureType === 'limit') - - None - @foreach($this->parentFeatures as $parent) - {{ $parent->name }} ({{ $parent->code }}) - @endforeach - - @endif - - - -
- Cancel - - {{ $editingFeatureId ? 'Update' : 'Create' }} - -
- -
-
- - {{-- Features Assignment Modal --}} - -
-
-
- -
- Assign Features to Package -
- -
- @foreach($this->allFeatures as $category => $categoryFeatures) -
-

{{ $category ?: 'General' }}

-
- @foreach($categoryFeatures as $feature) -
- -
-
{{ $feature->name }}
- {{ $feature->code }} -
- @if($feature->type === 'limit') - - @elseif($feature->type === 'unlimited') - Unlimited - @else - Boolean - @endif -
- @endforeach -
-
- @endforeach - -
- Cancel - Save Features -
-
-
-
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/entitlement/feature-manager.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/entitlement/feature-manager.blade.php deleted file mode 100644 index ad5c7ef..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/entitlement/feature-manager.blade.php +++ /dev/null @@ -1,77 +0,0 @@ - - - New Feature - - - - - - - {{-- Create/Edit Feature Modal --}} - - - {{ $editingId ? 'Edit Feature' : 'Create Feature' }} - - -
-
- - -
- - - -
- - - @foreach ($this->categories as $cat) - - @endforeach - - - - -
- -
- - - - - - - - - - - -
- - @if ($reset_type === 'rolling') - - @endif - - - - @foreach ($this->parentFeatures as $parent) - - @endforeach - - - - -
- Cancel - - {{ $editingId ? 'Update' : 'Create' }} - -
- -
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/entitlement/package-manager.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/entitlement/package-manager.blade.php deleted file mode 100644 index 69a31c0..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/entitlement/package-manager.blade.php +++ /dev/null @@ -1,101 +0,0 @@ - - - New Package - - - - - - - {{-- Create/Edit Package Modal --}} - - - {{ $editingId ? 'Edit Package' : 'Create Package' }} - - -
-
- - -
- - - -
- - - -
- - - -
- - -
- -
- - -
- -
- Cancel - - {{ $editingId ? 'Update' : 'Create' }} - -
- -
- - {{-- Features Assignment Modal --}} - - Assign Features - -
- @foreach ($this->features as $category => $categoryFeatures) -
- {{ $category }} -
- @foreach ($categoryFeatures as $feature) -
- -
-
{{ $feature->name }}
- {{ $feature->code }} -
- @if ($feature->type === 'limit') - - @elseif ($feature->type === 'unlimited') - Unlimited - @else - Boolean - @endif -
- @endforeach -
-
- @endforeach - -
- Cancel - Save Features -
-
-
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/global-search.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/global-search.blade.php deleted file mode 100644 index d6200f2..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/global-search.blade.php +++ /dev/null @@ -1,211 +0,0 @@ -{{-- -Global search component with Command+K keyboard shortcut. - -Include in your layout: - - -Features: -- Command+K / Ctrl+K to open -- Arrow key navigation (up/down) -- Enter to select -- Escape to close -- Recent searches -- Grouped results by provider type ---}} - -
- {{-- Search modal --}} - -
- {{-- Search input --}} -
- - - @if($query) - - @endif -
- - {{-- Results --}} - @if(strlen($query) >= 2) -
- @php $currentIndex = 0; @endphp - - @forelse($this->results as $type => $group) - @if(count($group['results']) > 0) - {{-- Category header --}} -
- - - {{ $group['label'] }} - -
- - {{-- Results list --}} - @foreach($group['results'] as $item) - - @php $currentIndex++; @endphp - @endforeach - @endif - @empty - {{-- No results --}} -
- -

- {{ __('hub::hub.search.no_results', ['query' => $query]) }} -

-
- @endforelse - - @if(!$this->hasResults && strlen($query) >= 2) -
- -

- {{ __('hub::hub.search.no_results', ['query' => $query]) }} -

-
- @endif -
- - {{-- Footer with keyboard hints --}} -
-
- - - - {{ __('hub::hub.search.navigate') }} - - - - {{ __('hub::hub.search.select') }} - - - esc - {{ __('hub::hub.search.close') }} - -
-
- - @elseif($this->showRecentSearches) - {{-- Recent searches --}} -
-
- - {{ __('hub::hub.search.recent') }} - - -
-
- @foreach($recentSearches as $index => $recent) -
- - -
- @endforeach -
-
- - @else - {{-- Initial state --}} -
- -

- {{ __('hub::hub.search.start_typing') }} -

-

- {{ __('hub::hub.search.tips') }} -

-
- @endif -
-
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/honeypot.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/honeypot.blade.php deleted file mode 100644 index a68ceaf..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/honeypot.blade.php +++ /dev/null @@ -1,180 +0,0 @@ -
- {{-- Header --}} -
-
-

Honeypot Monitor

-

- Track requests to disallowed paths. These may indicate malicious crawlers. -

-
-
- - - Purge 30d+ - -
-
- - {{-- Flash Message --}} - @if (session()->has('message')) - - {{ session('message') }} - - @endif - - {{-- Stats Grid --}} -
-
-
{{ number_format($stats['total']) }}
-
Total Hits
-
-
-
{{ number_format($stats['today']) }}
-
Today
-
-
-
{{ number_format($stats['this_week']) }}
-
This Week
-
-
-
{{ number_format($stats['unique_ips']) }}
-
Unique IPs
-
-
-
{{ number_format($stats['bots']) }}
-
Known Bots
-
-
- - {{-- Top Offenders --}} -
- {{-- Top IPs --}} -
-
-

Top IPs

-
-
- @forelse($stats['top_ips'] as $row) -
- {{ $row->ip_address }} - {{ $row->hits }} hits -
- @empty -
No data yet
- @endforelse -
-
- - {{-- Top Bots --}} -
-
-

Top Bots

-
-
- @forelse($stats['top_bots'] as $row) -
- {{ $row->bot_name }} - {{ $row->hits }} hits -
- @empty -
No bots detected yet
- @endforelse -
-
-
- - {{-- Filters --}} -
-
- -
- - - - - -
- - {{-- Hits Table --}} -
-
- - - - - - - - - - - - - @forelse($hits as $hit) - - - - - - - - - @empty - - - - @endforelse - -
- Time - @if($sortField === 'created_at') - - @endif - - IP Address - - Path - - Bot - - User Agent -
- {{ $hit->created_at->diffForHumans() }} - - {{ $hit->ip_address }} - @if($hit->country) - {{ $hit->country }} - @endif - - {{ $hit->path }} - - @if($hit->is_bot) - - {{ $hit->bot_name ?? 'Bot' }} - - @else - - - @endif - - {{ Str::limit($hit->user_agent, 60) }} - - - Block - -
- No honeypot hits recorded yet. Good news - no one's ignoring your robots.txt! -
-
- - {{-- Pagination --}} - @if($hits->hasPages()) -
- {{ $hits->links() }} -
- @endif -
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/layouts/app.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/layouts/app.blade.php deleted file mode 100644 index e8cded6..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/layouts/app.blade.php +++ /dev/null @@ -1,126 +0,0 @@ -@php - $darkMode = request()->cookie('dark-mode') === 'true'; -@endphp - - - - - - - - {{ $title ?? 'Admin' }} - {{ config('app.name', 'Host Hub') }} - - {{-- Critical CSS: Prevents white flash during page load/navigation --}} - - - - - - @include('layouts::partials.fonts') - - - @if(file_exists(public_path('vendor/fontawesome/css/all.min.css'))) - - @else - - @endif - - - @vite(['resources/css/admin.css', 'resources/js/app.js']) - - - @fluxAppearance - - - - - -
- - @include('hub::admin.components.sidebar') - - -
- - @include('hub::admin.components.header') - -
- {{ $slot }} -
- -
- -
- - -@persist('toast') - -@endpersist - - -@persist('global-search') - -@endpersist - - -@include('hub::admin.components.developer-bar') - - -@fluxScripts - -@stack('scripts') - - - - diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/platform-user.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/platform-user.blade.php deleted file mode 100644 index d572c7c..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/platform-user.blade.php +++ /dev/null @@ -1,706 +0,0 @@ -
- {{-- Header --}} -
-
- - - Platform Users - - / - {{ $user->name }} -
- -
-
- @php - $tierColor = match($user->tier?->value ?? 'free') { - 'hades' => 'violet', - 'apollo' => 'blue', - default => 'gray', - }; - @endphp -
- -
-
-

{{ $user->name }}

-
- {{ $user->email }} - - {{ ucfirst($user->tier?->value ?? 'free') }} - - @if($user->email_verified_at) - - - Verified - - @else - - - Unverified - - @endif -
-
-
- - - Hades Only - -
-
- - {{-- Action message --}} - @if($actionMessage) -
-
- - {{ $actionMessage }} -
-
- @endif - - {{-- Pending deletion warning --}} - @if($pendingDeletion) -
-
-
-
- -
-
-
Account deletion scheduled
-
- This account is scheduled for deletion on {{ $pendingDeletion->expires_at->format('j F Y') }}. - @if($pendingDeletion->reason) - Reason: {{ $pendingDeletion->reason }} - @endif -
-
-
- - Cancel deletion - -
-
- @endif - - {{-- Tabs --}} -
- -
- - {{-- Tab Content --}} -
- {{-- Overview Tab --}} - @if($activeTab === 'overview') -
- {{-- Main content --}} -
- {{-- Account Information --}} -
-

Account Information

-
-
-
User ID
-
{{ $user->id }}
-
-
-
Created
-
{{ $user->created_at?->format('d M Y, H:i') }}
-
-
-
Last Updated
-
{{ $user->updated_at?->format('d M Y, H:i') }}
-
-
-
Email Verified
-
- {{ $user->email_verified_at ? $user->email_verified_at->format('d M Y, H:i') : 'Not verified' }} -
-
- @if($user->tier_expires_at) -
-
Tier Expires
-
{{ $user->tier_expires_at->format('d M Y') }}
-
- @endif -
-
- - {{-- Tier Management --}} -
-

Tier Management

-
-
- - @foreach($tiers as $tier) - {{ ucfirst($tier->value) }} - @endforeach - -
- Save Tier -
-
- - {{-- Email Verification --}} -
-

Email Verification

-
-
- - Save -
- - - Resend verification - -
-
-
- - {{-- Sidebar --}} -
- {{-- Quick Stats --}} -
-

Quick Stats

-
-
- Workspaces - {{ $dataCounts['workspaces'] }} -
-
- Deletion Requests - {{ $dataCounts['deletion_requests'] }} -
-
-
- - {{-- Account Details --}} -
-

Details

-
-
-
Tier
-
{{ $user->tier?->value ?? 'free' }}
-
-
-
Status
-
- @if($user->email_verified_at) - Active - @else - Pending Verification - @endif -
-
-
-
-
-
- @endif - - {{-- Workspaces Tab --}} - @if($activeTab === 'workspaces') -
- {{-- Workspace List --}} -
-
-

Workspaces ({{ $this->workspaces->count() }})

-
- - @if($this->workspaces->isEmpty()) -
-
- -
-

No workspaces

-

This user hasn't created any workspaces yet.

-
- @else -
- @foreach($this->workspaces as $workspace) -
-
-
-
- -
-
-
{{ $workspace->name }}
-
{{ $workspace->slug }}
-
-
- - - Add Package - -
- - @if($workspace->workspacePackages->isEmpty()) -
No packages provisioned
- @else -
- @foreach($workspace->workspacePackages as $wp) -
-
-
- -
-
-
{{ $wp->package->name }}
-
{{ $wp->package->code }}
-
-
-
- @if($wp->package->is_base_package) - Base - @endif - - {{ ucfirst($wp->status ?? 'active') }} - - -
-
- @endforeach -
- @endif -
- @endforeach -
- @endif -
-
- @endif - - {{-- Entitlements Tab --}} - @if($activeTab === 'entitlements') -
- @if($this->workspaces->isEmpty()) -
-
- -
-

No workspaces

-

This user has no workspaces to manage entitlements for.

-
- @else - @foreach($this->workspaceEntitlements as $wsId => $data) - @php $workspace = $data['workspace']; $stats = $data['stats']; $boosts = $data['boosts']; $summary = $data['summary']; @endphp -
- {{-- Workspace Header --}} -
-
-
- -
-
-

{{ $workspace->name }}

-
{{ $workspace->slug }}
-
-
- - - Add Entitlement - -
- - {{-- Quick Stats --}} -
-
-
-
{{ $stats['total'] }}
-
Total
-
-
-
{{ $stats['allowed'] }}
-
Allowed
-
-
-
{{ $stats['denied'] }}
-
Denied
-
-
-
{{ $stats['boosts'] }}
-
Boosts
-
-
-
- - {{-- Active Boosts --}} - @if($boosts->count() > 0) -
-

- - Active Boosts -

-
- @foreach($boosts as $boost) -
-
-
- -
-
-
{{ $boost->feature_code }}
-
- {{ str_replace('_', ' ', $boost->boost_type) }} - @if($boost->limit_value) - · +{{ number_format($boost->limit_value) }} - @endif - @if($boost->expires_at) - · Expires {{ $boost->expires_at->format('d M Y') }} - @else - · Permanent - @endif -
-
-
- -
- @endforeach -
-
- @endif - - {{-- Allowed Entitlements Summary --}} -
-

Allowed Features

- @php - $allowedFeatures = $summary->flatten(1)->where('allowed', true); - @endphp - @if($allowedFeatures->isEmpty()) -

No features enabled

- @else -
- @foreach($allowedFeatures as $entitlement) -
- - {{ $entitlement['name'] }} - @if(!$entitlement['unlimited'] && $entitlement['limit']) - ({{ number_format($entitlement['used'] ?? 0) }}/{{ number_format($entitlement['limit']) }}) - @endif -
- @endforeach -
- @endif -
-
- @endforeach - @endif -
- @endif - - {{-- Data & Privacy Tab --}} - @if($activeTab === 'data') -
- {{-- Main content --}} -
- {{-- Stored Data Preview --}} -
-
-
-

Stored Data

-

GDPR Article 15 - Right of access

-
- - - Export JSON - -
-
-
{{ json_encode($userData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}
-
-
-
- - {{-- Sidebar --}} -
- {{-- GDPR Info --}} -
-

GDPR Compliance

-
-
-
- -
-
-
Article 20
-
Data portability
-
-
-
-
- -
-
-
Article 15
-
Right of access
-
-
-
-
- -
-
-
Article 17
-
Right to erasure
-
-
-
-
-
-
- @endif - - {{-- Danger Zone Tab --}} - @if($activeTab === 'danger') -
- {{-- Scheduled Deletion --}} -
-
-
-
- -
-
-

Schedule Deletion

-

GDPR Article 17 - Right to erasure

-
-
-
-
-

- Schedule account deletion with a 7-day grace period. The user will be notified and can cancel during this time. -

- - - Schedule Deletion - -
-
- - {{-- Immediate Deletion --}} -
-
-
-
- -
-
-

Immediate Deletion

-

Permanently delete account and all data

-
-
-
-
-

- Permanently delete this account and all associated data immediately. This action cannot be undone. -

- - - Delete Immediately - -
-
- - {{-- Anonymisation --}} -
-
-
-
- -
-
-

Anonymise Account

-

Replace PII with anonymous data

-
-
-
-
-

- Replace all personally identifiable information with anonymous data while keeping the account structure intact. This is an alternative to full deletion. -

- - - Anonymise User - -
-
-
- @endif -
- - {{-- Delete confirmation modal --}} - -
-
-
- -
- - {{ $immediateDelete ? 'Delete account immediately' : 'Schedule account deletion' }} - -
- -

- @if($immediateDelete) - This will permanently delete {{ $user->email }} and all associated data immediately. This action cannot be undone. - @else - This will schedule {{ $user->email }} for deletion in 7 days. The user can cancel during this period. - @endif -

- - - -
- Cancel - - {{ $immediateDelete ? 'Delete permanently' : 'Schedule deletion' }} - -
-
-
- - {{-- Package provisioning modal --}} - -
-
-
- -
- Provision Package -
- - @if($selectedWorkspaceId) - @php - $selectedWorkspace = $this->workspaces->firstWhere('id', $selectedWorkspaceId); - @endphp -
-
Workspace
-
{{ $selectedWorkspace?->name ?? 'Unknown' }}
-
- @endif - - - Choose a package... - @foreach($this->availablePackages as $package) - - {{ $package->name }} - @if($package->is_base_package) (Base) @endif - @if(!$package->is_public) (Internal) @endif - - @endforeach - - -
-

- The package will be assigned immediately with no expiry date. You can modify or remove it later. -

-
- -
- Cancel - - Provision Package - -
-
-
- - {{-- Entitlement provisioning modal --}} - -
-
-
- -
- Add Entitlement -
- - @if($entitlementWorkspaceId) - @php - $entitlementWorkspace = $this->workspaces->firstWhere('id', $entitlementWorkspaceId); - @endphp -
-
Workspace
-
{{ $entitlementWorkspace?->name ?? 'Unknown' }}
-
- @endif - - - @foreach($this->allFeatures->groupBy('category') as $category => $features) - ── {{ ucfirst($category ?: 'General') }} ── - @foreach($features as $feature) - - {{ $feature->name }} ({{ $feature->code }}) - - @endforeach - @endforeach - - - - Enable (Toggle on) - Add Limit (Extra quota) - Unlimited - - - @if($entitlementType === 'add_limit') - - @endif - - - Permanent - Expires on date - - - @if($entitlementDuration === 'duration') - - @endif - -
-

- This will create a boost that grants the selected feature directly to this workspace, independent of packages. -

-
- -
- Cancel - - Add Entitlement - -
-
-
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/platform.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/platform.blade.php deleted file mode 100644 index f4387fc..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/platform.blade.php +++ /dev/null @@ -1,278 +0,0 @@ -
- -
-
-

Platform Admin

-

Manage users, tiers, and platform operations

-
-
- - - Hades Only - -
-
- - - @if($actionMessage) -
-
- - {{ $actionMessage }} -
-
- @endif - - -
-
-
{{ number_format($stats['total_users']) }}
-
Total Users
-
-
-
{{ number_format($stats['verified_users']) }}
-
Verified
-
-
-
{{ number_format($stats['hades_users']) }}
-
Hades
-
-
-
{{ number_format($stats['apollo_users']) }}
-
Apollo
-
-
-
{{ number_format($stats['free_users']) }}
-
Free
-
-
-
{{ number_format($stats['users_today']) }}
-
Today
-
-
-
{{ number_format($stats['users_this_week']) }}
-
This Week
-
-
- -
- -
-
-
-
-

User Management

-
- - - - - All Tiers - @foreach($tiers as $tier) - {{ ucfirst($tier->value) }} - @endforeach - - - - All Status - Verified - Unverified - -
-
-
-
- - - - - - - - - - - - - @forelse($users as $user) - - - - - - - - - @empty - - - - @endforelse - -
-
- Name - @if($sortField === 'name') - - @endif -
-
-
- Email - @if($sortField === 'email') - - @endif -
-
TierVerified -
- Joined - @if($sortField === 'created_at') - - @endif -
-
Actions
-
-
- {{ substr($user->name, 0, 2) }} -
- {{ $user->name }} -
-
- {{ $user->email }} - - @php - $tierColor = match($user->tier?->value ?? 'free') { - 'hades' => 'violet', - 'apollo' => 'blue', - default => 'gray', - }; - @endphp - - {{ ucfirst($user->tier?->value ?? 'free') }} - - - @if($user->email_verified_at) - - - Verified - - @else - - - Pending - - @endif - - {{ $user->created_at->format('d M Y') }} - -
- @if(!$user->email_verified_at) - - @endif - - - -
-
- No users found matching your criteria. -
-
- @if($users->hasPages()) -
- {{ $users->links() }} -
- @endif -
-
- - -
- -
-
-

System Info

-
-
- @foreach($systemInfo as $label => $value) -
- {{ str_replace('_', ' ', ucwords($label, '_')) }} - {{ $value }} -
- @endforeach -
-
- - -
-
-

DevOps Tools

-
-
- - - -
-
- - - -
-
- -
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/profile.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/profile.blade.php deleted file mode 100644 index b3b7126..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/profile.blade.php +++ /dev/null @@ -1,175 +0,0 @@ -
- -
- -
- -
- -
-
- {{ $userInitials }} -
-
-
-

{{ $userName }}

- - {{ $userTier }} - -
-

{{ $userEmail }}

- @if($memberSince) -

{{ __('hub::hub.profile.member_since', ['date' => $memberSince]) }}

- @endif -
- -
-
-
- -
- -
- -
-
-

- {{ __('hub::hub.profile.sections.quotas') }} -

-
-
-
- @foreach($quotas as $key => $quota) -
-
- {{ $quota['label'] }} - - @if($quota['limit']) - {{ $quota['used'] }} / {{ $quota['limit'] }} - @else - {{ $quota['used'] }} ({{ __('hub::hub.profile.quotas.unlimited') }}) - @endif - -
- @if($quota['limit']) - @php - $percentage = min(100, ($quota['used'] / $quota['limit']) * 100); - $barColor = $percentage > 90 ? 'bg-red-500' : ($percentage > 70 ? 'bg-amber-500' : 'bg-violet-500'); - @endphp -
-
-
- @else -
- @endif -
- @endforeach -
- - @if($userTier !== 'Hades') -
-
-
-

{{ __('hub::hub.profile.quotas.need_more') }}

-

{{ __('hub::hub.profile.quotas.need_more_description') }}

-
- - {{ __('hub::hub.profile.actions.upgrade') }} - -
-
- @endif -
-
- - -
-
-

- {{ __('hub::hub.profile.sections.services') }} -

-
-
-
- @foreach($serviceStats as $service) -
-
- -
-
-
- {{ $service['name'] }} - @if($service['status'] === 'active') - - @else - - @endif -
-

{{ $service['stat'] }}

-
-
- @endforeach -
-
-
-
- - -
- -
-
-

- {{ __('hub::hub.profile.sections.activity') }} -

-
-
- @if(count($recentActivity) > 0) -
- @foreach($recentActivity as $activity) -
-
- -
-
-

{{ $activity['message'] }}

-

{{ $activity['time'] }}

-
-
- @endforeach -
- @else -

{{ __('hub::hub.profile.activity.no_activity') }}

- @endif -
-
- - - -
-
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/prompt-manager.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/prompt-manager.blade.php deleted file mode 100644 index 3dcacee..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/prompt-manager.blade.php +++ /dev/null @@ -1,242 +0,0 @@ - - - {{ __('hub::hub.prompts.labels.new_prompt') }} - - - - - - - - - - - - {{-- Editor Modal --}} - -
- - {{ $editingPromptId ? __('hub::hub.prompts.editor.edit_title') : __('hub::hub.prompts.editor.new_title') }} - - -
- {{-- Basic Info --}} -
- - - - {{ __('hub::hub.prompts.categories.content') }} - {{ __('hub::hub.prompts.categories.seo') }} - {{ __('hub::hub.prompts.categories.refinement') }} - {{ __('hub::hub.prompts.categories.translation') }} - {{ __('hub::hub.prompts.categories.analysis') }} - -
- - - - {{-- Model Settings --}} -
- - {{ __('hub::hub.prompts.models.claude') }} - {{ __('hub::hub.prompts.models.gemini') }} - - - - - -
- - {{-- System Prompt with Monaco --}} -
- {{ __('hub::hub.prompts.editor.system_prompt') }} -
-
-
-
- - {{-- User Template with Monaco --}} -
- {{ __('hub::hub.prompts.editor.user_template') }} - {{ __('hub::hub.prompts.editor.user_template_hint') }} -
-
-
-
- - {{-- Variables --}} -
-
- {{ __('hub::hub.prompts.editor.template_variables') }} - - {{ __('hub::hub.prompts.editor.add_variable') }} - -
- - @if(count($variables) > 0) -
- @foreach($variables as $index => $var) -
- - - - -
- @endforeach -
- @else - {{ __('hub::hub.prompts.editor.no_variables') }} - @endif -
- - {{-- Active Toggle --}} - - - {{-- Actions --}} -
- @if($editingPromptId) - - {{ __('hub::hub.prompts.editor.version_history') }} - - @else -
- @endif - -
- - {{ __('hub::hub.prompts.editor.cancel') }} - - - {{ $editingPromptId ? __('hub::hub.prompts.editor.update_prompt') : __('hub::hub.prompts.editor.create_prompt') }} - -
-
- -
-
- - {{-- Version History Modal --}} - - {{ __('hub::hub.prompts.versions.title') }} - - @if($this->promptVersions->isNotEmpty()) -
- @foreach($this->promptVersions as $version) -
-
- {{ __('hub::hub.prompts.versions.version', ['number' => $version->version]) }} - - {{ $version->created_at->format('M j, Y H:i') }} - @if($version->creator) - {{ __('hub::hub.prompts.versions.by', ['name' => $version->creator->name]) }} - @endif - -
- - {{ __('hub::hub.prompts.versions.restore') }} - -
- @endforeach -
- @else - {{ __('hub::hub.prompts.versions.no_history') }} - @endif -
-
- -@push('scripts') - -@endpush diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/service-manager.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/service-manager.blade.php deleted file mode 100644 index a516ca5..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/service-manager.blade.php +++ /dev/null @@ -1,79 +0,0 @@ - - - - Sync from Modules - - - - - - - - {{-- Edit Service Modal --}} - - Edit Service - -
- {{-- Read-only section --}} -
-
Module Information (read-only)
-
-
-
Code
- {{ $code }} -
-
-
Module
- {{ $module }} -
-
-
Entitlement
- {{ $entitlement_code ?: '-' }} -
-
-
- - {{-- Editable fields --}} -
- - -
- - - -
- - - -
- -
-
Marketing Configuration
-
- - -
- -
- -
-
Visibility
-
- - - -
-
- -
- Cancel - Update Service -
- -
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/services-admin.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/services-admin.blade.php deleted file mode 100644 index 4a57c57..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/services-admin.blade.php +++ /dev/null @@ -1,1900 +0,0 @@ -@php - // Icon name to Font Awesome class mapping - $iconMap = [ - 'link' => 'fa-solid fa-link', - 'share-nodes' => 'fa-solid fa-share-nodes', - 'chart-line' => 'fa-solid fa-chart-line', - 'chart-simple' => 'fa-solid fa-chart-simple', - 'bell' => 'fa-solid fa-bell', - 'shield-check' => 'fa-solid fa-shield-check', - 'badge-check' => 'fa-solid fa-badge-check', - 'file' => 'fa-solid fa-file', - 'check-circle' => 'fa-solid fa-check-circle', - 'cursor-arrow-rays' => 'fa-solid fa-arrow-pointer', - 'folder' => 'fa-solid fa-folder', - 'globe' => 'fa-solid fa-globe', - 'eye' => 'fa-solid fa-eye', - 'users' => 'fa-solid fa-users', - 'bullhorn' => 'fa-solid fa-bullhorn', - 'paper-plane' => 'fa-solid fa-paper-plane', - 'megaphone' => 'fa-solid fa-bullhorn', - 'palette' => 'fa-solid fa-palette', - 'hand-raised' => 'fa-solid fa-hand', - 'x-mark' => 'fa-solid fa-xmark', - 'circle-stack' => 'fa-solid fa-layer-group', - 'plus' => 'fa-solid fa-plus', - 'calendar' => 'fa-solid fa-calendar', - 'headset' => 'fa-solid fa-headset', - 'shopping-cart' => 'fa-solid fa-shopping-cart', - 'inbox' => 'fa-solid fa-inbox', - 'gear' => 'fa-solid fa-gear', - 'receipt' => 'fa-solid fa-receipt', - 'rotate' => 'fa-solid fa-rotate', - 'ticket' => 'fa-solid fa-ticket', - 'gauge' => 'fa-solid fa-gauge', - 'pen-to-square' => 'fa-solid fa-pen-to-square', - 'bullseye' => 'fa-solid fa-bullseye', - 'chart-bar' => 'fa-solid fa-chart-bar', - 'globe-alt' => 'fa-solid fa-globe', - 'flag' => 'fa-solid fa-flag', - 'copy' => 'fa-solid fa-copy', - 'swatchbook' => 'fa-solid fa-swatchbook', - 'image' => 'fa-solid fa-image', - ]; - $faIcon = fn($name) => $iconMap[$name] ?? 'fa-solid fa-circle'; -@endphp - -
- {{-- Service Tabs (from each module's Boot.php via AdminMenuRegistry) --}} - - - {{-- Content Panel --}} -
- {{-- BIO SERVICE --}} - @if ($service === 'bio') - @if ($tab === 'dashboard') -
- @foreach ($this->bioStatCards as $card) -
- {{-- Coloured left border accent --}} -
$card['color'] === 'violet', - 'bg-green-500' => $card['color'] === 'green', - 'bg-blue-500' => $card['color'] === 'blue', - 'bg-orange-500' => $card['color'] === 'orange', - ])>
- -
-
-
- {{-- Label first (smaller, secondary) --}} -

{{ $card['label'] }}

- - {{-- Value (larger, bolder, primary) --}} -

{{ $card['value'] }}

-
- - {{-- Icon with background circle --}} -
$card['color'] === 'violet', - 'bg-green-100 dark:bg-green-900/30' => $card['color'] === 'green', - 'bg-blue-100 dark:bg-blue-900/30' => $card['color'] === 'blue', - 'bg-orange-100 dark:bg-orange-900/30' => $card['color'] === 'orange', - ])> - $card['color'] === 'violet', - 'text-green-600 dark:text-green-400' => $card['color'] === 'green', - 'text-blue-600 dark:text-blue-400' => $card['color'] === 'blue', - 'text-orange-600 dark:text-orange-400' => $card['color'] === 'orange', - ])> -
-
-
-
- @endforeach -
- - {{-- Top Pages Table --}} -
-
-

{{ __('hub::hub.services.headings.your_bio_pages') }}

- -
-
- - - {{ __('hub::hub.services.columns.namespace') }} - {{ __('hub::hub.services.columns.type') }} - {{ __('hub::hub.services.columns.status') }} - {{ __('hub::hub.services.columns.clicks') }} - - - - @forelse ($this->bioPages->take(10) as $page) - - - - {{ $page->url }} - - - - {{ ucfirst($page->type) }} - - - - {{ $page->is_enabled ? __('hub::hub.services.status.active') : __('hub::hub.services.status.disabled') }} - - - {{ number_format($page->clicks) }} - - @empty - - - {{ __('hub::hub.services.empty.bio_pages') }} - - - @endforelse - - -
-
- @elseif ($tab === 'pages') -
-
-

{{ __('hub::hub.services.headings.all_pages') }}

- - {{ __('hub::hub.services.actions.create_page') }} - -
-
- - - {{ __('hub::hub.services.columns.namespace') }} - {{ __('hub::hub.services.columns.type') }} - {{ __('hub::hub.services.columns.project') }} - {{ __('hub::hub.services.columns.status') }} - {{ __('hub::hub.services.columns.clicks') }} - - - - @forelse ($this->bioPages as $page) - - - - {{ $page->url }} - - - - {{ ucfirst($page->type) }} - - {{ $page->project?->name ?? '-' }} - - - {{ $page->is_enabled ? __('hub::hub.services.status.active') : __('hub::hub.services.status.disabled') }} - - - {{ number_format($page->clicks) }} - - @empty - - - {{ __('hub::hub.services.empty.pages') }} - - - @endforelse - - -
-
- @elseif ($tab === 'projects') -
-
-

{{ __('hub::hub.services.headings.projects') }}

- - {{ __('hub::hub.services.actions.manage_projects') }} - -
- - - {{ __('hub::hub.services.columns.project') }} - {{ __('hub::hub.services.columns.pages') }} - {{ __('hub::hub.services.columns.created') }} - - - - @forelse ($this->bioProjects as $project) - - {{ $project->name }} - {{ $project->biolinks_count }} - {{ $project->created_at->format('d M Y') }} - - @empty - - - {{ __('hub::hub.services.empty.projects') }} - - - @endforelse - - -
- @endif - @endif - - {{-- SOCIAL SERVICE --}} - @if ($service === 'social') - @if ($tab === 'dashboard') -
- @foreach ($this->socialStatCards as $card) -
- {{-- Coloured left border accent --}} -
$card['color'] === 'violet', - 'bg-green-500' => $card['color'] === 'green', - 'bg-blue-500' => $card['color'] === 'blue', - 'bg-orange-500' => $card['color'] === 'orange', - ])>
- -
-
-
- {{-- Label first (smaller, secondary) --}} -

{{ $card['label'] }}

- - {{-- Value (larger, bolder, primary) --}} -

{{ $card['value'] }}

-
- - {{-- Icon with background circle --}} -
$card['color'] === 'violet', - 'bg-green-100 dark:bg-green-900/30' => $card['color'] === 'green', - 'bg-blue-100 dark:bg-blue-900/30' => $card['color'] === 'blue', - 'bg-orange-100 dark:bg-orange-900/30' => $card['color'] === 'orange', - ])> - $card['color'] === 'violet', - 'text-green-600 dark:text-green-400' => $card['color'] === 'green', - 'text-blue-600 dark:text-blue-400' => $card['color'] === 'blue', - 'text-orange-600 dark:text-orange-400' => $card['color'] === 'orange', - ])> -
-
-
-
- @endforeach -
- - {{-- Connected Accounts --}} -
-
-

Connected Accounts

- - - Manage Accounts - -
- - - Account - Provider - {{ __('hub::hub.services.columns.status') }} - - - - @forelse ($this->socialAccounts->take(10) as $account) - - -
- @if ($account->image_url) - {{ $account->name }} - @else -
- -
- @endif -
- {{ $account->name }} - @if ($account->username) -

@{{ $account->username }}

- @endif -
-
-
- - {{ ucfirst($account->provider) }} - - - - {{ $account->status === 'active' ? __('hub::hub.services.status.active') : ucfirst($account->status) }} - - -
- @empty - - - No accounts connected yet. Connect your social media accounts to start scheduling posts. - - - @endforelse -
-
-
- @elseif ($tab === 'accounts') -
-
-

All Accounts

- - - Connect Account - -
- - - Account - Provider - {{ __('hub::hub.services.columns.status') }} - Last Synced - - - - @forelse ($this->socialAccounts as $account) - - -
- @if ($account->image_url) - {{ $account->name }} - @else -
- -
- @endif -
- {{ $account->name }} - @if ($account->username) -

@{{ $account->username }}

- @endif -
-
-
- - {{ ucfirst($account->provider) }} - - - - {{ $account->status === 'active' ? __('hub::hub.services.status.active') : ucfirst($account->status) }} - - - {{ $account->last_synced_at?->diffForHumans() ?? 'Never' }} -
- @empty - - - No accounts found - - - @endforelse -
-
-
- @elseif ($tab === 'posts') -
-
-

Recent Posts

- - - Create Post - -
- - - Post - Accounts - {{ __('hub::hub.services.columns.status') }} - {{ __('hub::hub.services.columns.created') }} - - - - @forelse ($this->socialPosts as $post) - - - {{ Str::limit($post->content['body'] ?? $post->content['caption'] ?? 'No content', 100) }} - - -
- @foreach ($post->accounts->take(3) as $account) - @if ($account->image_url) - {{ $account->name }} - @else -
- -
- @endif - @endforeach - @if ($post->accounts->count() > 3) - - +{{ $post->accounts->count() - 3 }} - - @endif -
-
- - - {{ $post->status->label() }} - - - {{ $post->created_at->diffForHumans() }} -
- @empty - - No posts found - - @endforelse -
-
-
- @endif - @endif - - {{-- ANALYTICS SERVICE --}} - @if ($service === 'analytics') - @if ($tab === 'pages' && $this->isViewingPageDetails) - {{-- Page Details View --}} -
- {{-- Header with back button --}} -
- -
-

{{ $this->pageDetailsPath }}

-

{{ $this->pageDetailsWebsite?->name }} · {{ $this->pageDetailsWebsite?->host }}

-
- - - - - -
- - {{-- Primary Stats --}} - @php $pageStats = $this->pageDetailsStats; @endphp -
-
-
Views
-
{{ number_format($pageStats['views']) }}
-
-
-
Visitors
-
{{ number_format($pageStats['visitors']) }}
-
-
-
Bounce Rate
-
{{ $pageStats['bounce_rate'] }}%
-
-
-
Views/Visitor
-
{{ $pageStats['views_per_visitor'] }}
-
-
- - {{-- Secondary Stats --}} -
-
-
Entries
-
{{ number_format($pageStats['entries']) }}
-
Sessions started here
-
-
-
Exits
-
{{ number_format($pageStats['exits']) }}
-
Sessions ended here
-
-
-
Exit Rate
-
{{ $pageStats['exit_rate'] }}%
-
Of views that left
-
-
-
Avg. Duration
-
{{ $this->formatDuration($pageStats['avg_duration']) }}
-
Time on page
-
-
- - {{-- Page Traffic Chart --}} - @if(! empty($this->pageDetailsChartData)) -
-

Page Traffic

- - - - - - - - - - - - - - - - - - - - - - -
- @endif - - {{-- Breakdowns Row --}} -
- {{-- Referrers --}} -
-

Referrers

- @if(count($this->pageDetailsReferrers) > 0) -
- @foreach($this->pageDetailsReferrers as $ref) -
- {{ $ref['referrer_host'] }} - {{ number_format($ref['sessions']) }} -
- @endforeach -
- @else -

No referrer data

- @endif -
- - {{-- Devices --}} -
-

Devices

- @if(count($this->pageDetailsDevices) > 0) -
- @foreach($this->pageDetailsDevices as $device => $count) -
-
- - {{ $device ?? 'Unknown' }} -
- {{ number_format($count) }} -
- @endforeach -
- @else -

No data

- @endif -
- - {{-- Browsers --}} -
-

Browsers

- @if(count($this->pageDetailsBrowsers) > 0) -
- @foreach($this->pageDetailsBrowsers as $browser => $count) -
- {{ $browser ?? 'Unknown' }} - {{ number_format($count) }} -
- @endforeach -
- @else -

No data

- @endif -
-
-
- @elseif ($tab === 'pages') - {{-- Top Pages Table --}} -
-
-

{{ __('hub::hub.services.headings.top_pages') }}

- - - - - - -
-
- @if($this->analyticsTopPages->isNotEmpty()) - @php $primaryWebsite = $this->analyticsWebsites->first(); @endphp - - - Page - Views - Visitors - Bounce - - - @foreach($this->analyticsTopPages as $page) - - - @if($primaryWebsite) - - @else - {{ $page->path }} - @endif - - {{ number_format($page->views) }} - {{ number_format($page->visitors) }} - - @if($page->bounce_rate !== null) - {{ $page->bounce_rate }}% - @else - — - @endif - - - @endforeach - - - @else -
-
- -
- No page data yet - - {{ __('hub::hub.services.empty.page_data') }} - -
- @endif -
-
- @elseif ($tab === 'dashboard') - @php - $summaryMetrics = $this->analyticsSummaryMetrics; - @endphp - - {{-- Stats Card + Chart Row --}} -
- {{-- Combined Stats Card --}} -
-
-

Overview

- - - - - - -
- - {{-- Primary metrics --}} -
-
-
- - Pageviews -
-
{{ number_format($summaryMetrics['total_pageviews']) }}
-
-
-
- - Visitors -
-
{{ number_format($summaryMetrics['unique_visitors']) }}
-
-
- - {{-- Secondary metrics --}} -
-
-
- - Bounce Rate -
-
{{ $summaryMetrics['bounce_rate'] }}%
-
-
-
- - Avg. Duration -
-
{{ $this->formatDuration($summaryMetrics['avg_session_duration']) }}
-
-
- - {{-- Mod stats --}} -
-
-
-
- -
-
-
{{ $this->analyticsStats['total_websites'] }}
-
Websites
-
-
-
-
- -
-
-
{{ $this->analyticsStats['active_websites'] }}
-
Active
-
-
-
-
-
- - {{-- Pageviews Chart --}} - @if(! empty($this->analyticsChartData)) -
-
-

{{ __('hub::hub.services.headings.pageviews_trend') }}

-
- - - - - - - - - - - - - - - - - - - - - - - -
- @endif -
- - {{-- Acquisition Channels and Device Breakdown --}} -
- {{-- Acquisition Channels --}} -
-

{{ __('hub::hub.services.headings.traffic_sources') }}

- - @if(! empty($this->analyticsAcquisitionChannels)) -
- @foreach($this->analyticsAcquisitionChannels as $channel) -
-
- {{ $channel['name'] }} - {{ $channel['percentage'] }}% - {{ number_format($channel['count']) }} -
- @endforeach -
- @else -
- -

{{ __('hub::hub.services.empty.no_traffic_data') }}

-
- @endif -
- - {{-- Device Breakdown --}} -
-

{{ __('hub::hub.services.headings.devices') }}

- - @if(! empty($this->analyticsDeviceBreakdown)) -
- @foreach($this->analyticsDeviceBreakdown as $device) -
- -
{{ $device['percentage'] }}%
-
{{ $device['name'] }}
-
- @endforeach -
- @else -
- -

{{ __('hub::hub.services.empty.no_device_data') }}

-
- @endif -
-
- @elseif ($tab === 'channels') - {{-- Channels - All analytics sources grouped by type --}} -
- {{-- Header --}} -
-
- Channels - All your analytics sources: websites, bio pages, social, and more -
- - - - - - -
- - @if($this->analyticsChannels->isNotEmpty()) - {{-- Channel list grouped by type --}} - @foreach($this->analyticsChannelsByType as $typeKey => $group) - @php $maxPageviews = $group['channels']->max('pageviews_count') ?: 1; @endphp -
-
-
- -
-

{{ $group['label'] }}

- {{ $group['channels']->count() }} -
-
- @foreach($group['channels'] as $channel) -
-
-
-
- {{ $channel->name }} - - - {{ $channel->is_enabled ? 'Active' : 'Disabled' }} - -
- {{ number_format($channel->pageviews_count) }} -
-
-
-
-
-
- @endforeach -
-
- @endforeach - - {{-- Selected channel detail view (inline) --}} - @if($this->selectedWebsiteId) - @php $site = $this->selectedWebsite; @endphp - @if($site) -
- {{-- Header --}} -
-
-
- -
-
-

{{ $site->name }}

-

{{ $site->host }}

-
-
-
- {{ $site->channel_type?->label() ?? 'Mod' }} - -
-
- -
- {{-- Stats cards --}} -
-
-
Visitors
-
{{ number_format($site->visitors_count) }}
-
-
-
Sessions
-
{{ number_format($site->sessions_count) }}
-
-
-
Pageviews
-
{{ number_format($site->pageviews_count) }}
-
-
-
Bounce Rate
-
{{ $site->bounce_rate }}%
-
-
-
Avg. Duration
-
{{ $this->formatDuration($site->avg_duration) }}
-
-
- - {{-- Chart --}} - @if(! empty($this->selectedWebsiteChartData)) -
-

Traffic Overview

- - - - - - - - - - - - - - - - - - -
- @endif - - {{-- Top pages --}} - @if(count($this->selectedWebsiteTopPages) > 0) -
-

Top Pages

-
- - - - - - - - - - @foreach($this->selectedWebsiteTopPages as $page) - - - - - - @endforeach - -
PageViewsVisitors
- {{ $page['path'] }} - {{ number_format($page['views']) }}{{ number_format($page['visitors']) }}
-
-
- @endif -
-
- @endif - @endif - @else - {{-- No channels yet --}} -
-
-
- -
- No channels yet - - Channels are created automatically when you add websites, bio pages, or connect social accounts. - -
-
- @endif -
- @elseif ($tab === 'goals') - {{-- Goals Header --}} -
-
- {{ __('hub::hub.services.tabs.goals') }} - {{ __('hub::hub.services.empty.no_goals_description') }} -
- - {{ __('hub::hub.services.actions.create_goal') }} - -
- - @if($this->analyticsGoals->isNotEmpty()) - {{-- Goals Grid --}} -
- @foreach($this->analyticsGoals as $goal) - @php - $typeInfo = $this->analyticsGoalTypes[$goal->type] ?? ['label' => ucfirst($goal->type), 'color' => 'zinc', 'icon' => 'flag']; - @endphp -
-
-
- {{ $goal->name }} -
- {{ $typeInfo['label'] }} - @if($goal->website) - {{ $goal->website->name }} - @endif -
-
- - - - Edit - - {{ $goal->is_enabled ? 'Disable' : 'Enable' }} - - - -
- - {{-- Goal Configuration --}} -
- @switch($goal->type) - @case('pageview') -
- - {{ $goal->path ?? '/' }} -
- @break - @case('event') -
- - {{ $goal->key ?? 'custom_event' }} -
- @break - @case('duration') -
- - {{ $goal->threshold ?? 0 }}s minimum -
- @break - @case('pages_per_session') -
- - {{ $goal->threshold ?? 0 }} pages minimum -
- @break - @endswitch -
- - {{-- Stats Row --}} -
-
-
- {{ __('hub::hub.services.columns.conversions') }} - {{ number_format($goal->conversions_count ?? 0) }} -
-
- - {{ $goal->is_enabled ? __('hub::hub.services.status.active') : __('hub::hub.services.status.disabled') }} - -
-
- @endforeach -
- @else - {{-- Empty State --}} -
-
-
- -
- {{ __('hub::hub.services.empty.no_goals_title') }} - - {{ __('hub::hub.services.empty.no_goals_description') }} - - - {{ __('hub::hub.services.actions.create_goal') }} - -
-
- @endif - @elseif ($tab === 'settings') - @php $primaryWebsite = $this->analyticsWebsites->first(); @endphp - @if($primaryWebsite) -
- {{-- General Settings --}} -
-

General Settings

-
-
-
- -
-
- -
-
- -
- - - - -

- @if($analyticsSettingsTrackingType === 'lightweight') - Privacy-first: anonymised IPs, no cookies, no personal data. - @else - Full tracking: session replay, scroll depth. Requires consent. - @endif -

-
- -
- - -
- -
- -

Visits from these IPs won't be tracked

-
- -
- Save Settings -
-
-
- - {{-- Tracking Code --}} -
-

Tracking Code

-

Add this to your website's <head>:

-
-
<script defer data-key="{{ $primaryWebsite->pixel_key }}" src="{{ asset('js/analytics.js') }}"></script>
-
- -
-

Pixel Key:

-
- {{ $primaryWebsite->pixel_key }} - - - -
-
-
-
- @else -
-
-
- -
-

No website configured

-

Add a website to configure analytics settings.

- - Add Website - -
-
- @endif - @endif - @endif - - {{-- NOTIFY SERVICE --}} - @if ($service === 'notify') - @if ($tab === 'dashboard') -
- @foreach ($this->notifyStatCards as $card) -
- {{-- Coloured left border accent --}} -
$card['color'] === 'indigo' || $card['color'] === 'purple', - 'bg-blue-500' => $card['color'] === 'blue', - 'bg-orange-500' => $card['color'] === 'orange', - 'bg-green-500' => $card['color'] === 'green', - ])>
- -
-
-
- {{-- Label first (smaller, secondary) --}} -

{{ $card['label'] }}

- - {{-- Value (larger, bolder, primary) --}} -

{{ $card['value'] }}

-
- - {{-- Icon with background circle --}} -
$card['color'] === 'indigo' || $card['color'] === 'purple', - 'bg-blue-100 dark:bg-blue-900/30' => $card['color'] === 'blue', - 'bg-orange-100 dark:bg-orange-900/30' => $card['color'] === 'orange', - 'bg-green-100 dark:bg-green-900/30' => $card['color'] === 'green', - ])> - $card['color'] === 'indigo' || $card['color'] === 'purple', - 'text-blue-600 dark:text-blue-400' => $card['color'] === 'blue', - 'text-orange-600 dark:text-orange-400' => $card['color'] === 'orange', - 'text-green-600 dark:text-green-400' => $card['color'] === 'green', - ])> -
-
-
-
- @endforeach -
- - {{-- Websites by Subscribers --}} -
-
-

{{ __('hub::hub.services.headings.websites_by_subscribers') }}

- - - {{ __('hub::hub.services.actions.manage_notifyhost') }} - -
- - - {{ __('hub::hub.services.columns.website') }} - {{ __('hub::hub.services.columns.host') }} - {{ __('hub::hub.services.columns.subscribers') }} - - - - @forelse ($this->notifyWebsites as $website) - - {{ $website->name }} - {{ $website->host }} - - {{ number_format($website->subscribers_count) }} - - - @empty - - -
-
- -
- {{ __('hub::hub.services.empty.no_websites_title') }} - {{ __('hub::hub.services.empty.websites') }} -
-
-
- @endforelse -
-
-
- @elseif ($tab === 'subscribers') -
-
-

{{ __('hub::hub.services.headings.recent_subscribers') }}

- - - {{ __('hub::hub.services.actions.view_all') }} - -
- - - {{ __('hub::hub.services.columns.endpoint') }} - {{ __('hub::hub.services.columns.website') }} - {{ __('hub::hub.services.columns.status') }} - {{ __('hub::hub.services.columns.subscribed') }} - - - - @forelse ($this->notifySubscribers as $sub) - - {{ Str::limit($sub->endpoint, 50) }} - {{ $sub->website?->name ?? __('hub::hub.services.misc.na') }} - - - {{ $sub->is_subscribed ? __('hub::hub.services.status.active') : __('hub::hub.services.status.inactive') }} - - - {{ $sub->subscribed_at?->diffForHumans() ?? __('hub::hub.services.misc.na') }} - - @empty - - -
-
- -
- {{ __('hub::hub.services.empty.no_subscribers_title') }} - {{ __('hub::hub.services.empty.subscribers') }} -
-
-
- @endforelse -
-
-
- @elseif ($tab === 'campaigns') -
-
-

{{ __('hub::hub.services.headings.campaigns') }}

- - - {{ __('hub::hub.services.actions.create_campaign') }} - -
- - - {{ __('hub::hub.services.columns.campaign') }} - {{ __('hub::hub.services.columns.website') }} - {{ __('hub::hub.services.columns.status') }} - {{ __('hub::hub.services.columns.stats') }} - - - - @forelse ($this->notifyCampaigns as $campaign) - - {{ $campaign->name }} - {{ $campaign->website?->name ?? __('hub::hub.services.misc.na') }} - - - {{ __('hub::hub.services.status.' . $campaign->status) }} - - - - @if ($campaign->status === 'sent') -
- {{ number_format($campaign->delivery_rate ?? 0, 1) }}% - {{ number_format($campaign->click_through_rate ?? 0, 1) }}% -
- @else - - - @endif -
-
- @empty - - -
-
- -
- {{ __('hub::hub.services.empty.no_campaigns_title') }} - {{ __('hub::hub.services.empty.campaigns') }} -
-
-
- @endforelse -
-
-
- @endif - @endif - - {{-- TRUST SERVICE --}} - @if ($service === 'trust') - @if ($tab === 'dashboard') - {{-- Aggregated Campaign Metrics Summary --}} -
-
-
{{ number_format($this->trustAggregatedMetrics['impressions']) }}
-
{{ __('hub::hub.services.trust.metrics.impressions') }}
-
-
-
{{ number_format($this->trustAggregatedMetrics['clicks']) }}
-
{{ __('hub::hub.services.trust.metrics.clicks') }}
-
-
-
{{ number_format($this->trustAggregatedMetrics['conversions']) }}
-
{{ __('hub::hub.services.trust.metrics.conversions') }}
-
-
-
{{ $this->trustAggregatedMetrics['ctr'] }}%
-
{{ __('hub::hub.services.trust.metrics.ctr') }}
-
-
-
{{ $this->trustAggregatedMetrics['cvr'] }}%
-
{{ __('hub::hub.services.trust.metrics.cvr') }}
-
-
- -
- @foreach ($this->trustStatCards as $card) -
- {{-- Coloured left border accent --}} -
$card['color'] === 'blue', - 'bg-green-500' => $card['color'] === 'green', - 'bg-purple-500' => $card['color'] === 'purple', - 'bg-orange-500' => $card['color'] === 'orange', - ])>
- -
-
-
- {{-- Label first (smaller, secondary) --}} -

{{ $card['label'] }}

- - {{-- Value (larger, bolder, primary) --}} -

{{ $card['value'] }}

-
- - {{-- Icon with background circle --}} -
$card['color'] === 'blue', - 'bg-green-100 dark:bg-green-900/30' => $card['color'] === 'green', - 'bg-purple-100 dark:bg-purple-900/30' => $card['color'] === 'purple', - 'bg-orange-100 dark:bg-orange-900/30' => $card['color'] === 'orange', - ])> - $card['color'] === 'blue', - 'text-green-600 dark:text-green-400' => $card['color'] === 'green', - 'text-purple-600 dark:text-purple-400' => $card['color'] === 'purple', - 'text-orange-600 dark:text-orange-400' => $card['color'] === 'orange', - ])> -
-
-
-
- @endforeach -
- - {{-- Campaigns Summary --}} -
-
-

{{ __('hub::hub.services.headings.campaigns') }}

- - - {{ __('hub::hub.services.actions.manage_trusthost') }} - -
- - - {{ __('hub::hub.services.columns.campaign') }} - {{ __('hub::hub.services.columns.widgets') }} - {{ __('hub::hub.services.columns.performance') }} - {{ __('hub::hub.services.columns.status') }} - - - - @forelse ($this->trustCampaigns->take(5) as $campaign) - @php - // Calculate CVR for performance colour - $impressions = $campaign->notifications->sum('impressions'); - $conversions = $campaign->notifications->sum('conversions'); - $cvr = $impressions > 0 ? ($conversions / $impressions) * 100 : 0; - $perfClass = match(true) { - $cvr >= 5 => 'border-l-4 border-l-green-500', - $cvr >= 1 => 'border-l-4 border-l-yellow-500', - $impressions > 0 => 'border-l-4 border-l-red-500', - default => '', - }; - $perfBadgeColor = match(true) { - $cvr >= 5 => 'green', - $cvr >= 1 => 'yellow', - default => 'zinc', - }; - @endphp - - -
- - {{ $campaign->name }} -
-
- {{ $campaign->notifications_count }} - - @if($impressions > 0) - {{ number_format($cvr, 1) }}% CVR - @else - - - @endif - - - - {{ $campaign->is_enabled ? __('hub::hub.services.status.active') : __('hub::hub.services.status.disabled') }} - - -
- @empty - - {{ __('hub::hub.services.empty.campaigns') }} - - @endforelse -
-
-
- @elseif ($tab === 'campaigns') -
-
-

{{ __('hub::hub.services.headings.all_campaigns') }}

- - - {{ __('hub::hub.services.actions.create_campaign') }} - -
- - - {{ __('hub::hub.services.columns.campaign') }} - {{ __('hub::hub.services.columns.widgets') }} - {{ __('hub::hub.services.columns.status') }} - - - - @forelse ($this->trustCampaigns as $campaign) - - -
- - {{ $campaign->name }} -
-
- {{ $campaign->notifications_count }} - - - {{ $campaign->is_enabled ? __('hub::hub.services.status.active') : __('hub::hub.services.status.disabled') }} - - -
- @empty - - {{ __('hub::hub.services.empty.campaigns') }} - - @endforelse -
-
-
- @elseif ($tab === 'notifications') -
-
-

{{ __('hub::hub.services.headings.widgets_by_impressions') }}

- - - {{ __('hub::hub.services.actions.view_all') }} - -
- - - {{ __('hub::hub.services.columns.widget') }} - {{ __('hub::hub.services.columns.campaign') }} - {{ __('hub::hub.services.columns.impressions') }} - {{ __('hub::hub.services.columns.clicks') }} - {{ __('hub::hub.services.columns.conversions') }} - - - - @forelse ($this->trustNotifications as $notification) - - {{ $notification->name }} - {{ $notification->campaign?->name ?? __('hub::hub.services.misc.na') }} - {{ number_format($notification->impressions) }} - {{ number_format($notification->clicks) }} - {{ number_format($notification->conversions) }} - - @empty - - {{ __('hub::hub.services.empty.widgets') }} - - @endforelse - - -
- @endif - @endif - - {{-- SUPPORT SERVICE --}} - @if ($service === 'support') - @if ($tab === 'dashboard') - {{-- Inbox Health Section --}} -
-

{{ __('hub::hub.services.support.inbox_health') }}

-
- @foreach($this->supportInboxHealthCards as $card) -
-
$card['color'] === 'blue', - 'bg-green-500' => $card['color'] === 'green', - ])>
-
-
-
-
$card['color'] === 'blue', - 'text-green-600 dark:text-green-400' => $card['color'] === 'green', - ])>{{ $card['value'] }}
-
{{ $card['label'] }}
-
-
$card['color'] === 'blue', - 'bg-green-100 dark:bg-green-900/30' => $card['color'] === 'green', - ])> - $card['color'] === 'blue', - 'text-green-600 dark:text-green-400' => $card['color'] === 'green', - ])> -
-
- @if(isset($card['oldest']) && $card['oldest']) -
- {{ __('hub::hub.services.support.oldest') }}: {{ $card['oldest']->created_at->diffForHumans() }} -
- @endif -
-
- @endforeach -
-
- - {{-- Today's Activity Section --}} -
-

{{ __('hub::hub.services.support.todays_activity') }}

-
- @foreach($this->supportActivityCards as $card) -
-
$card['color'] === 'violet', - 'bg-green-500' => $card['color'] === 'green', - 'bg-blue-500' => $card['color'] === 'blue', - ])>
-
-
-
-
$card['color'] === 'violet', - 'text-green-600 dark:text-green-400' => $card['color'] === 'green', - 'text-blue-600 dark:text-blue-400' => $card['color'] === 'blue', - ])>{{ $card['value'] }}
-
{{ $card['label'] }}
-
-
$card['color'] === 'violet', - 'bg-green-100 dark:bg-green-900/30' => $card['color'] === 'green', - 'bg-blue-100 dark:bg-blue-900/30' => $card['color'] === 'blue', - ])> - $card['color'] === 'violet', - 'text-green-600 dark:text-green-400' => $card['color'] === 'green', - 'text-blue-600 dark:text-blue-400' => $card['color'] === 'blue', - ])> -
-
-
-
- @endforeach -
-
- - {{-- Performance Section --}} -
-

{{ __('hub::hub.services.support.performance') }}

-
- @foreach($this->supportPerformanceCards as $card) -
-
$card['color'] === 'amber', - 'bg-teal-500' => $card['color'] === 'teal', - ])>
-
-
-
-
$card['color'] === 'amber', - 'text-teal-600 dark:text-teal-400' => $card['color'] === 'teal', - ])>{{ $card['value'] }}
-
{{ $card['label'] }}
-
-
$card['color'] === 'amber', - 'bg-teal-100 dark:bg-teal-900/30' => $card['color'] === 'teal', - ])> - $card['color'] === 'amber', - 'text-teal-600 dark:text-teal-400' => $card['color'] === 'teal', - ])> -
-
-
-
- @endforeach -
-
- - {{-- Recent Conversations --}} -
-
-

{{ __('hub::hub.services.support.recent_conversations') }}

- - - {{ __('hub::hub.services.support.view_inbox') }} - -
- @if($this->supportRecentConversations->isEmpty()) -
-
- -
-

{{ __('hub::hub.services.support.empty_inbox') }}

-

{{ __('hub::hub.services.support.empty_inbox_description') }}

-
- @else -
    - @foreach($this->supportRecentConversations as $conversation) -
  • -
    -
    -
    $conversation->status === 'active', - 'bg-yellow-100 dark:bg-yellow-900/30' => $conversation->status === 'pending', - 'bg-zinc-100 dark:bg-zinc-900/30' => $conversation->status === 'closed', - 'bg-red-100 dark:bg-red-900/30' => $conversation->status === 'spam', - ])> - $conversation->status === 'active', - 'text-yellow-600 dark:text-yellow-400' => $conversation->status === 'pending', - 'text-zinc-600 dark:text-zinc-400' => $conversation->status === 'closed', - 'text-red-600 dark:text-red-400' => $conversation->status === 'spam', - ])> -
    -
    -
    -
    - - {{ $conversation->customer?->name ?? $conversation->customer?->email ?? __('hub::hub.services.support.unknown') }} - - - {{ ucfirst($conversation->status) }} - -
    -

    {{ $conversation->subject }}

    - @if($conversation->latestThread) -

    - {{ Str::limit(strip_tags($conversation->latestThread->body ?? ''), 60) }} -

    - @endif -
    - #{{ $conversation->number }} - {{ $conversation->mailbox?->name ?? __('hub::hub.services.support.na') }} - {{ $conversation->created_at->diffForHumans() }} -
    -
    -
    -
  • - @endforeach -
- @endif -
- @elseif ($tab === 'inbox') - - @elseif ($tab === 'settings') - - @endif - @endif - - {{-- COMMERCE SERVICE --}} - @if ($service === 'commerce') - @if ($tab === 'dashboard') -
-
- -

Commerce Dashboard

-

Manage orders, subscriptions, and coupons.

- - - Go to Dashboard - -
-
- @elseif ($tab === 'orders') -
-
-

- Open orders → -

-
-
- @elseif ($tab === 'subscriptions') - - @elseif ($tab === 'coupons') -
- -
- @endif - @endif -
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/settings.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/settings.blade.php deleted file mode 100644 index aeff5bc..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/settings.blade.php +++ /dev/null @@ -1,390 +0,0 @@ -
- - - {{-- Settings card with sidebar --}} -
-
- - {{-- Sidebar navigation --}} -
- {{-- Account settings group --}} -
- -
    -
  • - -
  • -
  • - -
  • -
-
- - {{-- Security settings group --}} -
- -
    - @if($isTwoFactorEnabled) -
  • - -
  • - @endif -
  • - -
  • -
-
- - {{-- Danger zone --}} - @if($isDeleteAccountEnabled) -
- -
    -
  • - -
  • -
-
- @endif -
- - {{-- Content panel --}} -
- {{-- Profile Section --}} - @if($activeSection === 'profile') -
- - {{ __('hub::hub.settings.sections.profile.title') }} - {{ __('hub::hub.settings.sections.profile.description') }} - -
- - - -
- -
- - {{ __('hub::hub.settings.actions.save_profile') }} - -
-
-
- @endif - - {{-- Preferences Section --}} - @if($activeSection === 'preferences') -
-

{{ __('hub::hub.settings.sections.preferences.title') }}

-

{{ __('hub::hub.settings.sections.preferences.description') }}

- -
-
- - {{ __('hub::hub.settings.fields.language') }} - - @foreach($locales as $loc) - {{ $loc['long'] }} - @endforeach - - - - - - {{ __('hub::hub.settings.fields.timezone') }} - - @foreach($timezones as $group => $zones) - - @foreach($zones as $zone => $label) - {{ $label }} - @endforeach - - @endforeach - - - - - - {{ __('hub::hub.settings.fields.time_format') }} - - {{ __('hub::hub.settings.fields.time_format_12') }} - {{ __('hub::hub.settings.fields.time_format_24') }} - - - - - - {{ __('hub::hub.settings.fields.week_starts_on') }} - - {{ __('hub::hub.settings.fields.week_sunday') }} - {{ __('hub::hub.settings.fields.week_monday') }} - - - -
- -
- - {{ __('hub::hub.settings.actions.save_preferences') }} - -
-
-
- @endif - - {{-- Two-Factor Authentication Section --}} - @if($activeSection === 'two_factor' && $isTwoFactorEnabled) -
-

{{ __('hub::hub.settings.sections.two_factor.title') }}

-

{{ __('hub::hub.settings.sections.two_factor.description') }}

- - @if(!$userHasTwoFactorEnabled && !$showTwoFactorSetup) -
-
-

{{ __('hub::hub.settings.two_factor.not_enabled') }}

-

{{ __('hub::hub.settings.two_factor.not_enabled_description') }}

-
- - {{ __('hub::hub.settings.actions.enable') }} - -
- @endif - - @if($showTwoFactorSetup) -
-
-

- {{ __('hub::hub.settings.two_factor.setup_instructions') }} -

-
-
- {!! $twoFactorQrCode !!} -
-
-

{{ __('hub::hub.settings.two_factor.secret_key') }}

- {{ $twoFactorSecretKey }} -
-
-
- - - {{ __('hub::hub.settings.fields.verification_code') }} - - - - -
- - {{ __('hub::hub.settings.actions.confirm') }} - - - {{ __('hub::hub.settings.actions.cancel') }} - -
-
- @endif - - @if($userHasTwoFactorEnabled && !$showTwoFactorSetup) -
-
- - {{ __('hub::hub.settings.two_factor.enabled') }} -
- - @if($showRecoveryCodes && count($recoveryCodes) > 0) -
-

- {{ __('hub::hub.settings.two_factor.recovery_codes_warning') }} -

-
- @foreach($recoveryCodes as $code) - {{ $code }} - @endforeach -
-
- @endif - -
- - {{ __('hub::hub.settings.actions.view_recovery_codes') }} - - - {{ __('hub::hub.settings.actions.regenerate_codes') }} - - - {{ __('hub::hub.settings.actions.disable') }} - -
-
- @endif -
- @endif - - {{-- Password Section --}} - @if($activeSection === 'password') -
-

{{ __('hub::hub.settings.sections.password.title') }}

-

{{ __('hub::hub.settings.sections.password.description') }}

- -
- - {{ __('hub::hub.settings.fields.current_password') }} - - - - - - {{ __('hub::hub.settings.fields.new_password') }} - - - - - - {{ __('hub::hub.settings.fields.confirm_password') }} - - - - -
- - {{ __('hub::hub.settings.actions.update_password') }} - -
-
-
- @endif - - {{-- Delete Account Section --}} - @if($activeSection === 'delete' && $isDeleteAccountEnabled) -
-

{{ __('hub::hub.settings.sections.delete_account.title') }}

-

{{ __('hub::hub.settings.sections.delete_account.description') }}

- - @if($pendingDeletion) - {{-- Pending Deletion State --}} -
-
- -
-

{{ __('hub::hub.settings.delete.scheduled_title') }}

-

- {{ __('hub::hub.settings.delete.scheduled_description', ['date' => $pendingDeletion->expires_at->format('F j, Y \a\t g:i A'), 'days' => $pendingDeletion->daysRemaining()]) }} -

-

- {{ __('hub::hub.settings.delete.scheduled_email_note') }} -

-
-
-
- - {{ __('hub::hub.settings.actions.cancel_deletion') }} - - @elseif($showDeleteConfirmation) - {{-- Confirmation Form --}} -
-
-

- {{ __('hub::hub.settings.delete.warning_title') }} -

-
    -
  • {{ __('hub::hub.settings.delete.warning_delay') }}
  • -
  • {{ __('hub::hub.settings.delete.warning_workspaces') }}
  • -
  • {{ __('hub::hub.settings.delete.warning_content') }}
  • -
  • {{ __('hub::hub.settings.delete.warning_email') }}
  • -
-
- - - {{ __('hub::hub.settings.fields.delete_reason') }} - - - -
- - {{ __('hub::hub.settings.actions.request_deletion') }} - - - {{ __('hub::hub.settings.actions.cancel') }} - -
-
- @else - {{-- Initial State --}} -

- {{ __('hub::hub.settings.delete.initial_description') }} -

- - {{ __('hub::hub.settings.actions.delete_account') }} - - @endif -
- @endif -
- -
-
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/site-settings.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/site-settings.blade.php deleted file mode 100644 index 460cbb1..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/site-settings.blade.php +++ /dev/null @@ -1,253 +0,0 @@ -@php - // Map service colors to actual Tailwind classes (dynamic classes don't work with Tailwind purge) - $colorClasses = [ - 'violet' => [ - 'bg' => 'bg-violet-500/20', - 'icon' => 'text-violet-500', - 'link' => 'text-violet-500 hover:text-violet-600', - ], - 'blue' => [ - 'bg' => 'bg-blue-500/20', - 'icon' => 'text-blue-500', - 'link' => 'text-blue-500 hover:text-blue-600', - ], - 'cyan' => [ - 'bg' => 'bg-cyan-500/20', - 'icon' => 'text-cyan-500', - 'link' => 'text-cyan-500 hover:text-cyan-600', - ], - 'orange' => [ - 'bg' => 'bg-orange-500/20', - 'icon' => 'text-orange-500', - 'link' => 'text-orange-500 hover:text-orange-600', - ], - 'yellow' => [ - 'bg' => 'bg-yellow-500/20', - 'icon' => 'text-yellow-500', - 'link' => 'text-yellow-500 hover:text-yellow-600', - ], - 'teal' => [ - 'bg' => 'bg-teal-500/20', - 'icon' => 'text-teal-500', - 'link' => 'text-teal-500 hover:text-teal-600', - ], - ]; -@endphp - -
- -
-
-
- Site Settings - @if($this->workspace) - - {{ $this->workspace->name }} - - @endif -
- Configure your site services and settings -
- -
- - New Workspace - -
-
- - @if (session()->has('success')) -
- {{ session('success') }} -
- @endif - - @if (session()->has('error')) -
- {{ session('error') }} -
- @endif - - @if(!$this->workspace) -
-
- -
-

No Workspace Selected

-

Please select a workspace using the switcher in the header.

-
-
-
- @else - - - - - @if($tab === 'services') -
-

Enable services for this site

- - Get More Services - -
- -
- @foreach($this->serviceCards as $service) - @php $colors = $colorClasses[$service['color']] ?? $colorClasses['violet']; @endphp -
- {{-- Card Header --}} -
-
-
-
- -
-
-

{{ $service['name'] }}

-

{{ $service['description'] }}

-
-
- @unless($service['entitled']) - - Add - - @endunless -
-
- - {{-- Features List --}} -
-
    - @foreach($service['features'] as $feature) -
  • - - {{ $feature }} -
  • - @endforeach -
-
- - {{-- Card Footer --}} -
-
- @if($service['entitled']) - Active - - Manage - - @else - Not active - Locked - @endif -
-
-
- @endforeach -
- @elseif($tab === 'general') -
-
-

General Settings

-
-
-
- Site name - {{ $this->workspace->name }} -
-
- Domain - {{ $this->workspace->domain ?? 'Not configured' }} -
-
- Description - {{ $this->workspace->description ?? 'No description' }} -
-
- Status - @if($this->workspace->is_active) - Active - @else - Inactive - @endif -
-
-
- @elseif($tab === 'deployment') -
-
- -
-

Coming Soon

-

- Deployment settings will allow you to configure Git repository, branches, build commands, and deploy hooks. -

-
-
-
- @elseif($tab === 'environment') -
-
- -
-

Coming Soon

-

- Environment settings will allow you to configure environment variables, secrets, and runtime versions. -

-
-
-
- @elseif($tab === 'ssl') -
-
- -
-

Coming Soon

-

- SSL & Security settings will allow you to manage SSL certificates, force HTTPS, and HTTP/2 configuration. -

-
-
-
- @elseif($tab === 'backups') -
-
- -
-

Coming Soon

-

- Backup settings will allow you to configure backup frequency, retention periods, and restore points. -

-
-
-
- @elseif($tab === 'danger') -
-
- -
-

Danger Zone

-

- These actions are destructive and cannot be undone. -

-
-
-
-

Transfer Ownership

-

Transfer this site to another user

-
- Transfer -
-
-
-

Delete Site

-

Permanently delete this site and all its data

-
- Delete -
-
-
-
-
- @endif - @endif -
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/sites.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/sites.blade.php deleted file mode 100644 index 306c3f8..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/sites.blade.php +++ /dev/null @@ -1,72 +0,0 @@ - - - {{ __('hub::hub.workspaces.add') }} - - - @if($this->workspaces->isEmpty()) -
- -

{{ __('hub::hub.workspaces.empty') }}

-
- @else -
- @foreach($this->workspaces as $workspace) - @php - $isCurrent = $workspace->slug === $this->currentWorkspaceSlug; - $colorMap = [ - 'violet' => 'bg-violet-100 dark:bg-violet-500/20 text-violet-500', - 'blue' => 'bg-blue-100 dark:bg-blue-500/20 text-blue-500', - 'green' => 'bg-green-100 dark:bg-green-500/20 text-green-500', - 'orange' => 'bg-orange-100 dark:bg-orange-500/20 text-orange-500', - 'red' => 'bg-red-100 dark:bg-red-500/20 text-red-500', - 'cyan' => 'bg-cyan-100 dark:bg-cyan-500/20 text-cyan-500', - 'gray' => 'bg-gray-100 dark:bg-gray-500/20 text-gray-500', - ]; - $color = $workspace->color ?? 'violet'; - $iconClasses = $colorMap[$color] ?? $colorMap['violet']; - @endphp -
-
-
-
-
- -
-
-

{{ $workspace->name }}

-

{{ $workspace->domain ?? $workspace->slug }}

-
-
- @if($isCurrent) - - {{ __('hub::hub.workspaces.active') }} - - @else - - {{ __('hub::hub.workspaces.activate') }} - - @endif -
- - @if($workspace->description) -

{{ $workspace->description }}

- @endif -
- -
-
- @if($workspace->domain) - - Visit - - @endif -
- - Settings - -
-
- @endforeach -
- @endif -
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/usage-dashboard.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/usage-dashboard.blade.php deleted file mode 100644 index d735104..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/usage-dashboard.blade.php +++ /dev/null @@ -1,209 +0,0 @@ -
- -
-

{{ __('hub::hub.usage.title') }}

-

{{ __('hub::hub.usage.subtitle') }}

-
- -
- -
-
-

{{ __('hub::hub.usage.packages.title') }}

-

{{ __('hub::hub.usage.packages.subtitle') }}

-
-
- @if($activePackages->isEmpty()) -
- -

{{ __('hub::hub.usage.packages.empty') }}

-

{{ __('hub::hub.usage.packages.empty_hint') }}

-
- @else -
- @foreach($activePackages as $workspacePackage) -
- @if($workspacePackage->package->icon) -
- -
- @endif -
-

- {{ $workspacePackage->package->name }} -

- @if($workspacePackage->package->description) -

- {{ $workspacePackage->package->description }} -

- @endif -
- @if($workspacePackage->package->is_base_package) - {{ __('hub::hub.usage.badges.base') }} - @else - {{ __('hub::hub.usage.badges.addon') }} - @endif - {{ __('hub::hub.usage.badges.active') }} - @if($workspacePackage->expires_at) - - {{ __('hub::hub.usage.packages.renews', ['time' => $workspacePackage->expires_at->diffForHumans()]) }} - - @endif -
-
-
- @endforeach -
- @endif -
-
- - - @forelse($usageSummary as $category => $features) -
-
-

{{ $category ?? __('hub::hub.usage.categories.general') }}

-
-
- @foreach($features as $feature) -
-
-
- - {{ $feature['name'] }} - - @if(!$feature['allowed']) - {{ __('hub::hub.usage.badges.not_included') }} - @elseif($feature['unlimited']) - {{ __('hub::hub.usage.badges.unlimited') }} - @elseif($feature['type'] === 'boolean') - {{ __('hub::hub.usage.badges.enabled') }} - @endif -
- - @if($feature['allowed'] && !$feature['unlimited'] && $feature['type'] === 'limit') - - {{ number_format($feature['used']) }} / {{ number_format($feature['limit']) }} - - @endif -
- - @if($feature['allowed'] && !$feature['unlimited'] && $feature['type'] === 'limit') -
- @php - $percentage = min($feature['percentage'] ?? 0, 100); - $colorClass = match(true) { - $percentage >= 90 => 'bg-red-500', - $percentage >= 75 => 'bg-amber-500', - default => 'bg-green-500', - }; - @endphp -
-
- @if($feature['near_limit']) -

- - {{ __('hub::hub.usage.warnings.approaching_limit', ['remaining' => $feature['remaining']]) }} -

- @endif - @endif -
- @endforeach -
-
- @empty -
-
- -

{{ __('hub::hub.usage.empty.title') }}

-

{{ __('hub::hub.usage.empty.hint') }}

-
-
- @endforelse - - - @if($activeBoosts->isNotEmpty()) -
-
-

{{ __('hub::hub.usage.active_boosts.title') }}

-

{{ __('hub::hub.usage.active_boosts.subtitle') }}

-
-
-
- @foreach($activeBoosts as $boost) -
-
- - {{ $boost->feature_code }} - -
- @switch($boost->boost_type) - @case('add_limit') - - +{{ number_format($boost->limit_value) }} - - @break - @case('unlimited') - {{ __('hub::hub.usage.badges.unlimited') }} - @break - @case('enable') - {{ __('hub::hub.usage.badges.enabled') }} - @break - @endswitch - - @switch($boost->duration_type) - @case('cycle_bound') - {{ __('hub::hub.usage.duration.cycle_bound') }} - @break - @case('duration') - @if($boost->expires_at) - - {{ __('hub::hub.usage.duration.expires', ['time' => $boost->expires_at->diffForHumans()]) }} - - @endif - @break - @case('permanent') - {{ __('hub::hub.usage.duration.permanent') }} - @break - @endswitch -
-
- @if($boost->boost_type === 'add_limit' && $boost->limit_value) -
- - {{ number_format($boost->getRemainingLimit()) }} - - {{ __('hub::hub.usage.active_boosts.remaining') }} -
- @endif -
- @endforeach -
-
-
- @endif - - -
-

- {{ __('hub::hub.usage.cta.title') }} -

-

- {{ __('hub::hub.usage.cta.subtitle') }} -

-
- - - {{ __('hub::hub.usage.cta.add_boosts') }} - - - - {{ __('hub::hub.usage.cta.view_plans') }} - -
-
-
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/waitlist-manager.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/waitlist-manager.blade.php deleted file mode 100644 index 57ba181..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/waitlist-manager.blade.php +++ /dev/null @@ -1,40 +0,0 @@ - - - Export CSV - @if (count($selected) > 0) - - Invite Selected ({{ count($selected) }}) - - @endif - - - - - {{-- Stats Cards --}} - - - - - - - - - - - -
- -
-
- - -
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/workspace-switcher.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/workspace-switcher.blade.php deleted file mode 100644 index d71efd0..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/workspace-switcher.blade.php +++ /dev/null @@ -1,58 +0,0 @@ -
- - - - -
-
-

{{ __('hub::hub.workspace_switcher.title') }}

-
-
- @foreach($workspaces as $slug => $workspace) - - @endforeach -
-
-
- - {{ $current['domain'] }} -
-
-
-
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/wp-connector-settings.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/wp-connector-settings.blade.php deleted file mode 100644 index 9848f66..0000000 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/wp-connector-settings.blade.php +++ /dev/null @@ -1,150 +0,0 @@ -
- - -
- -
- WordPress Connector - Connect your self-hosted WordPress site to sync content -
-
- -
- - - - @if($enabled) - - - - -
- Plugin Configuration - - Install the Host Hub Connector plugin on your WordPress site and enter these settings: - - - -
- Webhook URL -
- - -
-
- - -
- Webhook Secret -
- - - -
- - Keep this secret safe. It's used to verify webhooks are from your WordPress site. - -
-
- - -
-
- @if($this->isVerified) -
-
- Connected - @if($this->lastSync) - Last sync: {{ $this->lastSync }} - @endif -
- @else -
-
- Not verified - Test the connection to verify -
- @endif -
- - - Test Connection - -
- - @if($testResult) - - {{ $testResult }} - - @endif - - -
-
- -
- WordPress Plugin - - Download and install the Host Hub Connector plugin on your WordPress site to enable content syncing. - - - Download Plugin - -
-
-
- @endif -
- -
- - Save Settings - -
-
-
diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/AIServices.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/AIServices.php deleted file mode 100644 index ddd404a..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/AIServices.php +++ /dev/null @@ -1,179 +0,0 @@ - 'Claude Sonnet 4 (Recommended)', - 'claude-opus-4-20250514' => 'Claude Opus 4', - 'claude-3-5-sonnet-20241022' => 'Claude 3.5 Sonnet', - 'claude-3-5-haiku-20241022' => 'Claude 3.5 Haiku (Fast)', - ]; - - protected array $geminiModels = [ - 'gemini-2.0-flash' => 'Gemini 2.0 Flash (Recommended)', - 'gemini-2.0-flash-lite' => 'Gemini 2.0 Flash Lite (Fast)', - 'gemini-1.5-pro' => 'Gemini 1.5 Pro', - 'gemini-1.5-flash' => 'Gemini 1.5 Flash', - ]; - - protected ServiceManager $serviceManager; - - public function boot(ServiceManager $serviceManager): void - { - $this->serviceManager = $serviceManager; - } - - public function mount(): void - { - $this->loadServices(); - } - - protected function loadServices(): void - { - // Load Claude - try { - $claude = $this->serviceManager->get('claude'); - $this->claudeApiKey = $claude['configuration']['api_key'] ?? ''; - $this->claudeModel = $claude['configuration']['model'] ?? 'claude-sonnet-4-20250514'; - $this->claudeActive = $claude['active'] ?? false; - } catch (\Exception $e) { - // Service not configured yet - } - - // Load Gemini - try { - $gemini = $this->serviceManager->get('gemini'); - $this->geminiApiKey = $gemini['configuration']['api_key'] ?? ''; - $this->geminiModel = $gemini['configuration']['model'] ?? 'gemini-2.0-flash'; - $this->geminiActive = $gemini['active'] ?? false; - } catch (\Exception $e) { - // Service not configured yet - } - - // Load OpenAI - try { - $openai = $this->serviceManager->get('openai'); - $this->openaiSecretKey = $openai['configuration']['secret_key'] ?? ''; - $this->openaiActive = $openai['active'] ?? false; - } catch (\Exception $e) { - // Service not configured yet - } - } - - public function saveClaude(): void - { - $this->validate([ - 'claudeApiKey' => 'required_if:claudeActive,true', - 'claudeModel' => 'required|in:'.implode(',', array_keys($this->claudeModels)), - ], [ - 'claudeApiKey.required_if' => 'API key is required when the service is active.', - ]); - - (new UpdateOrCreateService)( - name: 'claude', - configuration: [ - 'api_key' => $this->claudeApiKey, - 'model' => $this->claudeModel, - ], - active: $this->claudeActive - ); - - // Clear the cache so changes take effect - $this->serviceManager->forget('claude'); - - $this->savedMessage = 'Claude settings saved.'; - $this->dispatch('service-saved'); - } - - public function saveGemini(): void - { - $this->validate([ - 'geminiApiKey' => 'required_if:geminiActive,true', - 'geminiModel' => 'required|in:'.implode(',', array_keys($this->geminiModels)), - ], [ - 'geminiApiKey.required_if' => 'API key is required when the service is active.', - ]); - - (new UpdateOrCreateService)( - name: 'gemini', - configuration: [ - 'api_key' => $this->geminiApiKey, - 'model' => $this->geminiModel, - ], - active: $this->geminiActive - ); - - $this->serviceManager->forget('gemini'); - - $this->savedMessage = 'Gemini settings saved.'; - $this->dispatch('service-saved'); - } - - public function saveOpenAI(): void - { - $this->validate([ - 'openaiSecretKey' => 'required_if:openaiActive,true', - ], [ - 'openaiSecretKey.required_if' => 'API key is required when the service is active.', - ]); - - (new UpdateOrCreateService)( - name: 'openai', - configuration: [ - 'secret_key' => $this->openaiSecretKey, - ], - active: $this->openaiActive - ); - - $this->serviceManager->forget('openai'); - - $this->savedMessage = 'OpenAI settings saved.'; - $this->dispatch('service-saved'); - } - - public function getClaudeModelsProperty(): array - { - return $this->claudeModels; - } - - public function getGeminiModelsProperty(): array - { - return $this->geminiModels; - } - - public function render() - { - return view('hub::admin.ai-services') - ->layout('hub::admin.layouts.app', ['title' => 'AI Services']); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/AccountUsage.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/AccountUsage.php deleted file mode 100644 index f1b17ed..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/AccountUsage.php +++ /dev/null @@ -1,339 +0,0 @@ - 'Claude Sonnet 4 (Recommended)', - 'claude-opus-4-20250514' => 'Claude Opus 4', - 'claude-3-5-sonnet-20241022' => 'Claude 3.5 Sonnet', - 'claude-3-5-haiku-20241022' => 'Claude 3.5 Haiku (Fast)', - ]; - - protected array $geminiModels = [ - 'gemini-2.0-flash' => 'Gemini 2.0 Flash (Recommended)', - 'gemini-2.0-flash-lite' => 'Gemini 2.0 Flash Lite (Fast)', - 'gemini-1.5-pro' => 'Gemini 1.5 Pro', - 'gemini-1.5-flash' => 'Gemini 1.5 Flash', - ]; - - protected ServiceManager $serviceManager; - - protected EntitlementService $entitlementService; - - public function boot(ServiceManager $serviceManager, EntitlementService $entitlementService): void - { - $this->serviceManager = $serviceManager; - $this->entitlementService = $entitlementService; - } - - public function mount(): void - { - $this->loadDataForTab($this->activeSection); - } - - /** - * Load data when tab changes. - */ - public function updatedActiveSection(string $tab): void - { - $this->loadDataForTab($tab); - } - - /** - * Load only the data needed for the active tab. - */ - protected function loadDataForTab(string $tab): void - { - match ($tab) { - 'overview' => $this->loadUsageData(), - 'boosts' => $this->loadBoostOptions(), - 'ai' => $this->loadAiServices(), - default => null, - }; - } - - protected function loadUsageData(): void - { - if ($this->usageSummary !== null) { - return; // Already loaded - } - - $workspace = Auth::user()?->defaultHostWorkspace(); - - if (! $workspace) { - $this->usageSummary = []; - $this->activePackages = []; - $this->activeBoosts = []; - - return; - } - - $this->usageSummary = $this->entitlementService->getUsageSummary($workspace)->toArray(); - $this->activePackages = $this->entitlementService->getActivePackages($workspace)->toArray(); - $this->activeBoosts = $this->entitlementService->getActiveBoosts($workspace)->toArray(); - } - - protected function loadBoostOptions(): void - { - if ($this->boostOptions !== null) { - return; // Already loaded - } - - $addonMapping = config('services.blesta.addon_mapping', []); - - $this->boostOptions = collect($addonMapping)->map(function ($config, $blestaId) { - $feature = Feature::where('code', $config['feature_code'])->first(); - - return [ - 'blesta_id' => $blestaId, - 'feature_code' => $config['feature_code'], - 'feature_name' => $feature?->name ?? $config['feature_code'], - 'boost_type' => $config['boost_type'], - 'limit_value' => $config['limit_value'] ?? null, - 'duration_type' => $config['duration_type'], - 'description' => $this->getBoostDescription($config), - ]; - })->values()->toArray(); - } - - protected function getBoostDescription(array $config): string - { - $type = $config['boost_type']; - $value = $config['limit_value'] ?? null; - $duration = $config['duration_type']; - - $description = match ($type) { - 'add_limit' => "+{$value} additional", - 'unlimited' => 'Unlimited access', - 'enable' => 'Feature enabled', - default => 'Boost', - }; - - $durationText = match ($duration) { - 'cycle_bound' => 'until billing cycle ends', - 'duration' => 'for limited time', - 'permanent' => 'permanently', - default => '', - }; - - return trim("{$description} {$durationText}"); - } - - protected function loadAiServices(): void - { - if ($this->aiServicesLoaded) { - return; // Already loaded - } - - try { - $claude = $this->serviceManager->get('claude'); - $this->claudeApiKey = $claude['configuration']['api_key'] ?? ''; - $this->claudeModel = $claude['configuration']['model'] ?? 'claude-sonnet-4-20250514'; - $this->claudeActive = $claude['active'] ?? false; - } catch (\Exception) { - } - - try { - $gemini = $this->serviceManager->get('gemini'); - $this->geminiApiKey = $gemini['configuration']['api_key'] ?? ''; - $this->geminiModel = $gemini['configuration']['model'] ?? 'gemini-2.0-flash'; - $this->geminiActive = $gemini['active'] ?? false; - } catch (\Exception) { - } - - try { - $openai = $this->serviceManager->get('openai'); - $this->openaiSecretKey = $openai['configuration']['secret_key'] ?? ''; - $this->openaiActive = $openai['active'] ?? false; - } catch (\Exception) { - } - - $this->aiServicesLoaded = true; - } - - public function purchaseBoost(string $blestaId): void - { - $blestaUrl = config('services.blesta.url', 'https://billing.host.uk.com'); - $this->redirect("{$blestaUrl}/order/addon/{$blestaId}"); - } - - public function saveClaude(): void - { - $this->validate([ - 'claudeApiKey' => 'required_if:claudeActive,true', - 'claudeModel' => 'required|in:'.implode(',', array_keys($this->claudeModels)), - ], [ - 'claudeApiKey.required_if' => 'API key is required when the service is active.', - ]); - - (new UpdateOrCreateService)( - name: 'claude', - configuration: [ - 'api_key' => $this->claudeApiKey, - 'model' => $this->claudeModel, - ], - active: $this->claudeActive - ); - - $this->serviceManager->forget('claude'); - Flux::toast(text: 'Claude settings saved.', variant: 'success'); - } - - public function saveGemini(): void - { - $this->validate([ - 'geminiApiKey' => 'required_if:geminiActive,true', - 'geminiModel' => 'required|in:'.implode(',', array_keys($this->geminiModels)), - ], [ - 'geminiApiKey.required_if' => 'API key is required when the service is active.', - ]); - - (new UpdateOrCreateService)( - name: 'gemini', - configuration: [ - 'api_key' => $this->geminiApiKey, - 'model' => $this->geminiModel, - ], - active: $this->geminiActive - ); - - $this->serviceManager->forget('gemini'); - Flux::toast(text: 'Gemini settings saved.', variant: 'success'); - } - - public function saveOpenAI(): void - { - $this->validate([ - 'openaiSecretKey' => 'required_if:openaiActive,true', - ], [ - 'openaiSecretKey.required_if' => 'API key is required when the service is active.', - ]); - - (new UpdateOrCreateService)( - name: 'openai', - configuration: [ - 'secret_key' => $this->openaiSecretKey, - ], - active: $this->openaiActive - ); - - $this->serviceManager->forget('openai'); - Flux::toast(text: 'OpenAI settings saved.', variant: 'success'); - } - - #[Computed] - public function claudeModelsComputed(): array - { - return $this->claudeModels; - } - - #[Computed] - public function geminiModelsComputed(): array - { - return $this->geminiModels; - } - - /** - * Get all features grouped by category for entitlements display. - */ - #[Computed] - public function allFeatures(): array - { - return Feature::orderBy('category') - ->orderBy('name') - ->get() - ->groupBy('category') - ->toArray(); - } - - /** - * Get all user workspaces with subscription and cost information. - */ - #[Computed] - public function userWorkspaces(): array - { - $user = Auth::user(); - if (! $user) { - return []; - } - - $registry = app(AdminMenuRegistry::class); - $isHades = $user->isHades(); - - return $user->workspaces() - ->orderBy('name') - ->get() - ->map(function (Workspace $workspace) use ($registry, $isHades) { - $subscription = $workspace->activeSubscription(); - $services = $registry->getAllServiceItems($workspace, $isHades); - - return [ - 'workspace' => $workspace, - 'subscription' => $subscription, - 'plan' => $subscription?->workspacePackage?->package?->name ?? 'Free', - 'status' => $subscription?->status ?? 'inactive', - 'renewsAt' => $subscription?->current_period_end, - 'price' => $subscription?->workspacePackage?->package?->price ?? 0, - 'currency' => $subscription?->workspacePackage?->package?->currency ?? 'GBP', - 'services' => $services, - 'serviceCount' => count($services), - ]; - }) - ->toArray(); - } - - public function render() - { - return view('hub::admin.account-usage') - ->layout('hub::admin.layouts.app', ['title' => 'Usage & Billing']); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/ActivityLog.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/ActivityLog.php deleted file mode 100644 index bb90ef7..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/ActivityLog.php +++ /dev/null @@ -1,181 +0,0 @@ -distinct() - ->pluck('log_name') - ->filter() - ->values() - ->toArray(); - } - - /** - * Get available events for filtering. - */ - #[Computed] - public function events(): array - { - return Activity::query() - ->distinct() - ->pluck('event') - ->filter() - ->values() - ->toArray(); - } - - /** - * Get paginated activity records. - */ - #[Computed] - public function activities(): LengthAwarePaginator - { - $user = auth()->user(); - $workspace = $user?->defaultHostWorkspace(); - - $query = Activity::query() - ->with(['causer', 'subject']) - ->latest(); - - // Filter by workspace members if workspace exists - if ($workspace) { - $memberIds = $workspace->users->pluck('id'); - $query->whereIn('causer_id', $memberIds); - } - - // Filter by log name - if ($this->logName) { - $query->where('log_name', $this->logName); - } - - // Filter by event - if ($this->event) { - $query->where('event', $this->event); - } - - // Search in description - if ($this->search) { - $query->where('description', 'like', "%{$this->search}%"); - } - - return $query->paginate(20); - } - - /** - * Clear all filters. - */ - public function clearFilters(): void - { - $this->search = ''; - $this->logName = ''; - $this->event = ''; - $this->resetPage(); - } - - #[Computed] - public function logNameOptions(): array - { - $options = ['' => 'All logs']; - foreach ($this->logNames as $name) { - $options[$name] = Str::title($name); - } - - return $options; - } - - #[Computed] - public function eventOptions(): array - { - $options = ['' => 'All events']; - foreach ($this->events as $eventName) { - $options[$eventName] = Str::title($eventName); - } - - return $options; - } - - #[Computed] - public function activityItems(): array - { - return $this->activities->map(function ($activity) { - $item = [ - 'description' => $activity->description, - 'event' => $activity->event ?? 'activity', - 'timestamp' => $activity->created_at, - ]; - - // Actor - if ($activity->causer) { - $item['actor'] = [ - 'name' => $activity->causer->name ?? 'User', - 'initials' => substr($activity->causer->name ?? 'U', 0, 1), - ]; - } - - // Subject - if ($activity->subject) { - $item['subject'] = [ - 'type' => class_basename($activity->subject_type), - 'name' => $activity->subject->name - ?? $activity->subject->title - ?? $activity->subject->url - ?? (string) $activity->subject_id, - ]; - } - - // Changes diff - if ($activity->properties->has('old') && $activity->properties->has('new')) { - $item['changes'] = [ - 'old' => $activity->properties['old'], - 'new' => $activity->properties['new'], - ]; - } - - return $item; - })->all(); - } - - public function render() - { - return view('hub::admin.activity-log') - ->layout('hub::admin.layouts.app', ['title' => 'Activity Log']); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Analytics.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/Analytics.php deleted file mode 100644 index ec7e96b..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Analytics.php +++ /dev/null @@ -1,69 +0,0 @@ -metrics = [ - [ - 'label' => 'Total Visitors', - 'value' => '—', - 'change' => null, - 'icon' => 'users', - ], - [ - 'label' => 'Page Views', - 'value' => '—', - 'change' => null, - 'icon' => 'eye', - ], - [ - 'label' => 'Bounce Rate', - 'value' => '—', - 'change' => null, - 'icon' => 'arrow-right-from-bracket', - ], - [ - 'label' => 'Avg. Session', - 'value' => '—', - 'change' => null, - 'icon' => 'clock', - ], - ]; - - // Placeholder chart sections - $this->chartData = [ - 'visitors' => [ - 'title' => 'Visitors Over Time', - 'description' => 'Daily unique visitors across all sites', - ], - 'pages' => [ - 'title' => 'Top Pages', - 'description' => 'Most visited pages this period', - ], - 'sources' => [ - 'title' => 'Traffic Sources', - 'description' => 'Where your visitors come from', - ], - 'devices' => [ - 'title' => 'Devices', - 'description' => 'Device breakdown of your audience', - ], - ]; - } - - public function render() - { - return view('hub::admin.analytics') - ->layout('hub::admin.layouts.app', ['title' => 'Analytics']); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/BoostPurchase.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/BoostPurchase.php deleted file mode 100644 index 0f7943b..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/BoostPurchase.php +++ /dev/null @@ -1,77 +0,0 @@ -check()) { - abort(403, 'Authentication required.'); - } - - // Get boost options from config - $addonMapping = config('services.blesta.addon_mapping', []); - - $this->boostOptions = collect($addonMapping)->map(function ($config, $blestaId) { - $feature = Feature::where('code', $config['feature_code'])->first(); - - return [ - 'blesta_id' => $blestaId, - 'feature_code' => $config['feature_code'], - 'feature_name' => $feature?->name ?? $config['feature_code'], - 'boost_type' => $config['boost_type'], - 'limit_value' => $config['limit_value'] ?? null, - 'duration_type' => $config['duration_type'], - 'description' => $this->getBoostDescription($config), - ]; - })->values()->toArray(); - } - - protected function getBoostDescription(array $config): string - { - $type = $config['boost_type']; - $value = $config['limit_value'] ?? null; - $duration = $config['duration_type']; - - $description = match ($type) { - 'add_limit' => "+{$value} additional", - 'unlimited' => 'Unlimited access', - 'enable' => 'Feature enabled', - default => 'Boost', - }; - - $durationText = match ($duration) { - 'cycle_bound' => 'until billing cycle ends', - 'duration' => 'for limited time', - 'permanent' => 'permanently', - default => '', - }; - - return trim("{$description} {$durationText}"); - } - - public function purchaseBoost(string $blestaId): void - { - // Redirect to Blesta for purchase - // TODO: Implement when Blesta is configured - $blestaUrl = config('services.blesta.url', 'https://billing.host.uk.com'); - - $this->redirect("{$blestaUrl}/order/addon/{$blestaId}"); - } - - public function render() - { - return view('hub::admin.boost-purchase') - ->layout('hub::admin.layouts.app', ['title' => 'Purchase Boost']); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Console.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/Console.php deleted file mode 100644 index 156bc1e..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Console.php +++ /dev/null @@ -1,53 +0,0 @@ -servers = [ - [ - 'id' => 1, - 'name' => 'Bio (Production)', - 'type' => 'WordPress', - 'status' => 'online', - ], - [ - 'id' => 2, - 'name' => 'Social (Production)', - 'type' => 'Laravel', - 'status' => 'online', - ], - [ - 'id' => 3, - 'name' => 'Analytics (Production)', - 'type' => 'Node.js', - 'status' => 'online', - ], - [ - 'id' => 4, - 'name' => 'Host Hub (Development)', - 'type' => 'Laravel', - 'status' => 'online', - ], - ]; - } - - public function selectServer(int $serverId): void - { - $this->selectedServer = $serverId; - } - - public function render() - { - return view('hub::admin.console') - ->layout('hub::admin.layouts.app', ['title' => 'Console']); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Content.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/Content.php deleted file mode 100644 index fa709b6..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Content.php +++ /dev/null @@ -1,295 +0,0 @@ -workspaceService = $workspaceService; - } - - public function mount(string $workspace = 'main', string $type = 'posts'): void - { - $this->tab = $type; - - // Set workspace from URL - $this->workspaceService->setCurrent($workspace); - $this->currentWorkspace = $this->workspaceService->current(); - - $this->loadContent(); - } - - #[On('workspace-changed')] - public function handleWorkspaceChange(string $workspace): void - { - $this->currentWorkspace = $this->workspaceService->current(); - $this->resetPage(); - $this->loadContent(); - } - - #[Computed] - public function stats(): array - { - $published = collect($this->items)->where('status', 'publish')->count(); - $drafts = collect($this->items)->where('status', 'draft')->count(); - - return [ - [ - 'title' => 'Total '.ucfirst($this->tab), - 'value' => (string) $this->total, - 'trend' => '+12%', - 'trendUp' => true, - 'icon' => $this->tab === 'posts' ? 'newspaper' : ($this->tab === 'pages' ? 'file-lines' : 'images'), - ], - [ - 'title' => 'Published', - 'value' => (string) $published, - 'trend' => '+8%', - 'trendUp' => true, - 'icon' => 'check-circle', - ], - [ - 'title' => 'Drafts', - 'value' => (string) $drafts, - 'trend' => '-3%', - 'trendUp' => false, - 'icon' => 'pencil', - ], - [ - 'title' => 'This Week', - 'value' => (string) collect($this->items)->filter(fn ($i) => \Carbon\Carbon::parse($i['date'] ?? $i['modified'] ?? now())->isCurrentWeek())->count(), - 'trend' => '+24%', - 'trendUp' => true, - 'icon' => 'calendar', - ], - ]; - } - - #[Computed] - public function paginator(): LengthAwarePaginator - { - $page = $this->getPage(); - - return new LengthAwarePaginator( - items: array_slice($this->items, ($page - 1) * $this->perPage, $this->perPage), - total: $this->total, - perPage: $this->perPage, - currentPage: $page, - options: ['path' => request()->url()] - ); - } - - #[Computed] - public function rows(): array - { - return $this->paginator()->items(); - } - - public function loadContent(): void - { - // Load demo data - native content system to be implemented - $this->loadDemoData(); - - // Apply sorting - $this->applySorting(); - } - - protected function applySorting(): void - { - $items = collect($this->items); - - $items = match ($this->sort) { - 'title' => $items->sortBy(fn ($i) => $i['title']['rendered'] ?? '', SORT_REGULAR, $this->dir === 'desc'), - 'status' => $items->sortBy('status', SORT_REGULAR, $this->dir === 'desc'), - 'modified' => $items->sortBy('modified', SORT_REGULAR, $this->dir === 'desc'), - default => $items->sortBy('date', SORT_REGULAR, $this->dir === 'desc'), - }; - - $this->items = $items->values()->all(); - } - - protected function loadDemoData(): void - { - $workspaceName = $this->currentWorkspace['name'] ?? 'Host UK'; - $workspaceSlug = $this->currentWorkspace['slug'] ?? 'main'; - - if ($this->tab === 'posts') { - $this->items = []; - for ($i = 1; $i <= 25; $i++) { - $this->items[] = [ - 'id' => $i, - 'title' => ['rendered' => "{$workspaceName} Post #{$i}"], - 'content' => ['rendered' => "

Content for post {$i} in {$workspaceName}.

"], - 'status' => $i % 3 === 0 ? 'draft' : 'publish', - 'date' => now()->subDays($i)->toIso8601String(), - 'modified' => now()->subDays($i - 1)->toIso8601String(), - 'excerpt' => ['rendered' => "Excerpt for post {$i}"], - ]; - } - $this->total = 25; - } elseif ($this->tab === 'pages') { - $pageNames = ['Home', 'About', 'Services', 'Contact', 'Privacy', 'Terms', 'FAQ', 'Blog', 'Portfolio', 'Team']; - $this->items = []; - foreach ($pageNames as $i => $name) { - $this->items[] = [ - 'id' => $i + 10, - 'title' => ['rendered' => $name], - 'content' => ['rendered' => "

{$workspaceName} {$name} page content.

"], - 'status' => 'publish', - 'date' => now()->subMonths($i)->toIso8601String(), - 'modified' => now()->subDays($i)->toIso8601String(), - 'excerpt' => ['rendered' => ''], - ]; - } - $this->total = count($pageNames); - } else { - $this->items = []; - for ($i = 1; $i <= 12; $i++) { - $this->items[] = [ - 'id' => 100 + $i, - 'title' => ['rendered' => "{$workspaceSlug}-image-{$i}.jpg"], - 'media_type' => 'image', - 'source_url' => '/images/placeholder.jpg', - 'date' => now()->subDays($i)->toIso8601String(), - ]; - } - $this->total = 12; - } - } - - public function setSort(string $sort): void - { - if ($this->sort === $sort) { - $this->dir = $this->dir === 'asc' ? 'desc' : 'asc'; - } else { - $this->sort = $sort; - $this->dir = 'desc'; - } - $this->loadContent(); - } - - public function setStatus(string $status): void - { - $this->status = $status; - $this->resetPage(); - $this->loadContent(); - } - - public function setView(string $view): void - { - $this->view = $view; - } - - public function createNew(): void - { - $this->isCreating = true; - $this->editingId = null; - $this->editTitle = ''; - $this->editContent = ''; - $this->editStatus = 'draft'; - $this->editExcerpt = ''; - $this->showEditor = true; - } - - public function edit(int $id): void - { - $this->isCreating = false; - $this->editingId = $id; - - $item = collect($this->items)->firstWhere('id', $id); - if ($item) { - $this->editTitle = $item['title']['rendered'] ?? ''; - $this->editContent = $item['content']['rendered'] ?? ''; - $this->editStatus = $item['status'] ?? 'draft'; - $this->editExcerpt = $item['excerpt']['rendered'] ?? ''; - } - - $this->showEditor = true; - } - - public function save(): void - { - // Native content save - to be implemented - // For now, just close editor and dispatch event - - $this->closeEditor(); - $this->dispatch('content-saved'); - } - - public function delete(int $id): void - { - // Native content delete - to be implemented - // For demo, just remove from items - $this->items = array_values(array_filter($this->items, fn ($p) => $p['id'] !== $id)); - $this->total = count($this->items); - } - - public function closeEditor(): void - { - $this->showEditor = false; - $this->editingId = null; - $this->isCreating = false; - } - - public function render() - { - return view('hub::admin.content') - ->layout('hub::admin.layouts.app', ['title' => 'Content']); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/ContentEditor.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/ContentEditor.php deleted file mode 100644 index 01af915..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/ContentEditor.php +++ /dev/null @@ -1,843 +0,0 @@ - 'required|string|max:255', - 'slug' => 'required|string|max:255', - 'excerpt' => 'nullable|string|max:500', - 'content' => 'required|string', - 'type' => 'required|in:page,post', - 'status' => 'required|in:draft,publish,pending,future,private', - 'contentType' => 'required|in:native,hostuk,satellite,wordpress', - 'publishAt' => 'nullable|date', - 'seoTitle' => 'nullable|string|max:70', - 'seoDescription' => 'nullable|string|max:160', - 'seoKeywords' => 'nullable|string|max:255', - 'featuredImageUpload' => 'nullable|image|max:5120', // 5MB max - ]; - - public function boot(AgenticManager $ai, EntitlementService $entitlements): void - { - $this->ai = $ai; - $this->entitlements = $entitlements; - } - - public function mount(): void - { - $workspace = request()->route('workspace', 'main'); - $id = request()->route('id'); - $contentType = request()->route('contentType', 'native'); - - $workspaceModel = Workspace::where('slug', $workspace)->first(); - $this->workspaceId = $workspaceModel?->id; - $this->contentType = $contentType === 'hostuk' ? 'native' : $contentType; - - if ($id) { - $this->loadContent((int) $id); - } - } - - /** - * Load existing content for editing. - */ - public function loadContent(int $id): void - { - $item = ContentItem::with(['taxonomies', 'revisions'])->findOrFail($id); - - $this->contentId = $item->id; - $this->workspaceId = $item->workspace_id; - $this->contentType = $item->content_type instanceof ContentType - ? $item->content_type->value - : ($item->content_type ?? 'native'); - $this->type = $item->type; - $this->status = $item->status; - $this->title = $item->title; - $this->slug = $item->slug; - $this->excerpt = $item->excerpt ?? ''; - $this->content = $item->content_html ?? $item->content_markdown ?? ''; - $this->lastSaved = $item->updated_at?->diffForHumans(); - $this->revisionCount = $item->revision_count ?? 0; - - // Scheduling - $this->publishAt = $item->publish_at?->format('Y-m-d\TH:i'); - $this->isScheduled = $item->status === 'future' && $item->publish_at !== null; - - // SEO - $seoMeta = $item->seo_meta ?? []; - $this->seoTitle = $seoMeta['title'] ?? ''; - $this->seoDescription = $seoMeta['description'] ?? ''; - $this->seoKeywords = $seoMeta['keywords'] ?? ''; - $this->ogImage = $seoMeta['og_image'] ?? null; - - // Taxonomies - $this->selectedCategories = $item->categories->pluck('id')->toArray(); - $this->selectedTags = $item->tags->pluck('id')->toArray(); - - // Media - $this->featuredMediaId = $item->featured_media_id; - } - - /** - * Get available categories for this workspace. - */ - #[Computed] - public function categories(): array - { - if (! $this->workspaceId) { - return []; - } - - return ContentTaxonomy::where('workspace_id', $this->workspaceId) - ->where('type', 'category') - ->orderBy('name') - ->get() - ->toArray(); - } - - /** - * Get available tags for this workspace. - */ - #[Computed] - public function tags(): array - { - if (! $this->workspaceId) { - return []; - } - - return ContentTaxonomy::where('workspace_id', $this->workspaceId) - ->where('type', 'tag') - ->orderBy('name') - ->get() - ->toArray(); - } - - /** - * Get available media for this workspace. - */ - #[Computed] - public function mediaLibrary(): array - { - if (! $this->workspaceId) { - return []; - } - - return ContentMedia::where('workspace_id', $this->workspaceId) - ->images() - ->orderByDesc('created_at') - ->take(20) - ->get() - ->toArray(); - } - - /** - * Get the featured media object. - */ - #[Computed] - public function featuredMedia(): ?ContentMedia - { - if (! $this->featuredMediaId) { - return null; - } - - return ContentMedia::find($this->featuredMediaId); - } - - /** - * Generate slug from title. - */ - public function updatedTitle(string $value): void - { - if (empty($this->slug) || $this->slug === Str::slug($this->title)) { - $this->slug = Str::slug($value); - } - $this->isDirty = true; - } - - /** - * Mark as dirty when content changes. - */ - public function updatedContent(): void - { - $this->isDirty = true; - } - - /** - * Handle scheduling toggle. - */ - public function updatedIsScheduled(bool $value): void - { - if ($value) { - $this->status = 'future'; - if (empty($this->publishAt)) { - // Default to tomorrow at 9am - $this->publishAt = now()->addDay()->setTime(9, 0)->format('Y-m-d\TH:i'); - } - } else { - if ($this->status === 'future') { - $this->status = 'draft'; - } - $this->publishAt = null; - } - $this->isDirty = true; - } - - /** - * Add a new tag. - */ - public function addTag(): void - { - if (empty($this->newTag) || ! $this->workspaceId) { - return; - } - - $slug = Str::slug($this->newTag); - - // Check if tag exists - $existing = ContentTaxonomy::where('workspace_id', $this->workspaceId) - ->where('type', 'tag') - ->where('slug', $slug) - ->first(); - - if ($existing) { - if (! in_array($existing->id, $this->selectedTags)) { - $this->selectedTags[] = $existing->id; - } - } else { - // Create new tag - $tag = ContentTaxonomy::create([ - 'workspace_id' => $this->workspaceId, - 'type' => 'tag', - 'name' => $this->newTag, - 'slug' => $slug, - ]); - $this->selectedTags[] = $tag->id; - } - - $this->newTag = ''; - $this->isDirty = true; - } - - /** - * Remove a tag. - */ - public function removeTag(int $tagId): void - { - $this->selectedTags = array_values(array_filter( - $this->selectedTags, - fn ($id) => $id !== $tagId - )); - $this->isDirty = true; - } - - /** - * Toggle a category. - */ - public function toggleCategory(int $categoryId): void - { - if (in_array($categoryId, $this->selectedCategories)) { - $this->selectedCategories = array_values(array_filter( - $this->selectedCategories, - fn ($id) => $id !== $categoryId - )); - } else { - $this->selectedCategories[] = $categoryId; - } - $this->isDirty = true; - } - - /** - * Set featured image from media library. - */ - public function setFeaturedMedia(int $mediaId): void - { - $this->featuredMediaId = $mediaId; - $this->isDirty = true; - } - - /** - * Remove featured image. - */ - public function removeFeaturedMedia(): void - { - $this->featuredMediaId = null; - $this->isDirty = true; - } - - /** - * Upload featured image. - */ - public function uploadFeaturedImage(): void - { - $this->validate([ - 'featuredImageUpload' => 'required|image|max:5120', - ]); - - if (! $this->workspaceId) { - $this->dispatch('notify', message: 'No workspace selected', type: 'error'); - - return; - } - - // Store the file - $path = $this->featuredImageUpload->store('content-media', 'public'); - - // Create media record - $media = ContentMedia::create([ - 'workspace_id' => $this->workspaceId, - 'type' => 'image', - 'title' => pathinfo($this->featuredImageUpload->getClientOriginalName(), PATHINFO_FILENAME), - 'source_url' => asset('storage/'.$path), - 'alt_text' => $this->title, - 'mime_type' => $this->featuredImageUpload->getMimeType(), - ]); - - $this->featuredMediaId = $media->id; - $this->featuredImageUpload = null; - $this->isDirty = true; - - $this->dispatch('notify', message: 'Image uploaded', type: 'success'); - } - - /** - * Load revision history. - */ - public function loadRevisions(): void - { - if (! $this->contentId) { - $this->revisions = []; - - return; - } - - $this->revisions = ContentRevision::forContentItem($this->contentId) - ->withoutAutosaves() - ->latestFirst() - ->with('user') - ->take(20) - ->get() - ->toArray(); - - $this->showRevisions = true; - $this->activeSidebar = 'revisions'; - } - - /** - * Restore a revision. - */ - public function restoreRevision(int $revisionId): void - { - $revision = ContentRevision::findOrFail($revisionId); - - if ($revision->content_item_id !== $this->contentId) { - $this->dispatch('notify', message: 'Invalid revision', type: 'error'); - - return; - } - - // Load revision data into form - $this->title = $revision->title; - $this->excerpt = $revision->excerpt ?? ''; - $this->content = $revision->content_html ?? $revision->content_markdown ?? ''; - - // Restore SEO if available - if ($revision->seo_meta) { - $this->seoTitle = $revision->seo_meta['title'] ?? ''; - $this->seoDescription = $revision->seo_meta['description'] ?? ''; - $this->seoKeywords = $revision->seo_meta['keywords'] ?? ''; - } - - $this->isDirty = true; - $this->showRevisions = false; - - $this->dispatch('notify', message: "Restored revision #{$revision->revision_number}", type: 'success'); - } - - /** - * Save the content. - */ - public function save(string $changeType = ContentRevision::CHANGE_EDIT): void - { - $this->validate(); - - // Build SEO meta - $seoMeta = [ - 'title' => $this->seoTitle, - 'description' => $this->seoDescription, - 'keywords' => $this->seoKeywords, - 'og_image' => $this->ogImage, - ]; - - $data = [ - 'workspace_id' => $this->workspaceId, - 'content_type' => $this->contentType, - 'type' => $this->type, - 'status' => $this->status, - 'title' => $this->title, - 'slug' => $this->slug, - 'excerpt' => $this->excerpt, - 'content_html' => $this->content, - 'content_markdown' => $this->content, - 'seo_meta' => $seoMeta, - 'featured_media_id' => $this->featuredMediaId, - 'publish_at' => $this->isScheduled && $this->publishAt ? $this->publishAt : null, - 'last_edited_by' => auth()->id(), - 'sync_status' => 'synced', - 'synced_at' => now(), - ]; - - $isNew = ! $this->contentId; - - if ($this->contentId) { - $item = ContentItem::findOrFail($this->contentId); - $item->update($data); - } else { - $item = ContentItem::create($data); - $this->contentId = $item->id; - } - - // Sync taxonomies - $taxonomyIds = array_merge($this->selectedCategories, $this->selectedTags); - $item->taxonomies()->sync($taxonomyIds); - - // Create revision (except for autosaves on new content) - if (! $isNew || $changeType !== ContentRevision::CHANGE_AUTOSAVE) { - $item->createRevision(auth()->user(), $changeType); - $this->revisionCount = $item->fresh()->revision_count ?? 0; - } - - $this->isDirty = false; - $this->lastSaved = 'just now'; - - $this->dispatch('content-saved', id: $item->id); - $this->dispatch('notify', message: 'Content saved successfully', type: 'success'); - } - - /** - * Autosave the content (called periodically). - */ - public function autosave(): void - { - if (! $this->isDirty || empty($this->title) || empty($this->content)) { - return; - } - - $this->save(ContentRevision::CHANGE_AUTOSAVE); - } - - /** - * Publish the content. - */ - public function publish(): void - { - $this->status = 'publish'; - $this->isScheduled = false; - $this->publishAt = null; - $this->save(ContentRevision::CHANGE_PUBLISH); - } - - /** - * Schedule the content. - */ - public function schedule(): void - { - if (empty($this->publishAt)) { - $this->dispatch('notify', message: 'Please set a publish date', type: 'error'); - - return; - } - - $this->status = 'future'; - $this->isScheduled = true; - $this->save(ContentRevision::CHANGE_SCHEDULE); - } - - /** - * Get available prompts for AI command palette. - */ - #[Computed] - public function prompts(): array - { - $query = Prompt::active(); - - if ($this->commandSearch) { - $query->where(function ($q) { - $q->where('name', 'like', "%{$this->commandSearch}%") - ->orWhere('description', 'like', "%{$this->commandSearch}%") - ->orWhere('category', 'like', "%{$this->commandSearch}%"); - }); - } - - return $query->orderBy('category')->orderBy('name')->get()->groupBy('category')->toArray(); - } - - /** - * Get quick AI actions. - */ - #[Computed] - public function quickActions(): array - { - return [ - [ - 'name' => 'Improve writing', - 'description' => 'Enhance clarity and flow', - 'icon' => 'sparkles', - 'prompt' => 'content-refiner', - 'variables' => ['instruction' => 'Improve clarity, flow, and readability while maintaining the original meaning.'], - ], - [ - 'name' => 'Fix grammar', - 'description' => 'Correct spelling and grammar', - 'icon' => 'check-circle', - 'prompt' => 'content-refiner', - 'variables' => ['instruction' => 'Fix any spelling, grammar, or punctuation errors using UK English conventions.'], - ], - [ - 'name' => 'Make shorter', - 'description' => 'Condense the content', - 'icon' => 'arrows-pointing-in', - 'prompt' => 'content-refiner', - 'variables' => ['instruction' => 'Make this content more concise without losing important information.'], - ], - [ - 'name' => 'Make longer', - 'description' => 'Expand with more detail', - 'icon' => 'arrows-pointing-out', - 'prompt' => 'content-refiner', - 'variables' => ['instruction' => 'Expand this content with more detail, examples, and explanation.'], - ], - [ - 'name' => 'Generate SEO', - 'description' => 'Create meta title and description', - 'icon' => 'magnifying-glass', - 'prompt' => 'seo-title-optimizer', - 'variables' => [], - ], - ]; - } - - /** - * Open the AI command palette. - */ - public function openCommand(): void - { - $this->showCommand = true; - $this->commandSearch = ''; - $this->selectedPromptId = null; - $this->promptVariables = []; - } - - /** - * Close the AI command palette. - */ - public function closeCommand(): void - { - $this->showCommand = false; - $this->aiResult = null; - } - - /** - * Select a prompt from the command palette. - */ - public function selectPrompt(int $promptId): void - { - $this->selectedPromptId = $promptId; - - $prompt = Prompt::find($promptId); - if ($prompt && ! empty($prompt->variables)) { - foreach ($prompt->variables as $name => $config) { - $this->promptVariables[$name] = $config['default'] ?? ''; - } - } - } - - /** - * Execute a quick action. - */ - public function executeQuickAction(string $promptName, array $variables = []): void - { - $prompt = Prompt::where('name', $promptName)->first(); - - if (! $prompt) { - $this->dispatch('notify', message: 'Prompt not found', type: 'error'); - - return; - } - - $variables['content'] = $this->content; - $this->runAiPrompt($prompt, $variables); - } - - /** - * Execute the selected prompt. - */ - public function executePrompt(): void - { - if (! $this->selectedPromptId) { - return; - } - - $prompt = Prompt::find($this->selectedPromptId); - if (! $prompt) { - return; - } - - $variables = $this->promptVariables; - $variables['content'] = $this->content; - $variables['title'] = $this->title; - $variables['excerpt'] = $this->excerpt; - - $this->runAiPrompt($prompt, $variables); - } - - /** - * Run an AI prompt and display results. - */ - protected function runAiPrompt(Prompt $prompt, array $variables): void - { - $this->aiProcessing = true; - $this->aiResult = null; - - try { - $workspace = $this->workspaceId ? Workspace::find($this->workspaceId) : null; - - if ($workspace) { - $result = $this->entitlements->can($workspace, 'ai.credits'); - if ($result->isDenied()) { - $this->dispatch('notify', message: $result->message, type: 'error'); - $this->aiProcessing = false; - - return; - } - } - - $provider = $this->ai->provider($prompt->model); - $userPrompt = $this->interpolateVariables($prompt->user_template, $variables); - - $response = $provider->generate( - $prompt->system_prompt, - $userPrompt, - $prompt->model_config ?? [] - ); - - $this->aiResult = $response->content; - - if ($workspace) { - $this->entitlements->recordUsage( - $workspace, - 'ai.credits', - quantity: 1, - user: auth()->user(), - metadata: [ - 'prompt_id' => $prompt->id, - 'model' => $response->model, - 'tokens_input' => $response->inputTokens, - 'tokens_output' => $response->outputTokens, - 'estimated_cost' => $response->estimateCost(), - ] - ); - } - - } catch (\Exception $e) { - $this->dispatch('notify', message: 'AI request failed: '.$e->getMessage(), type: 'error'); - } - - $this->aiProcessing = false; - } - - /** - * Apply AI result to content. - */ - public function applyAiResult(): void - { - if ($this->aiResult) { - $this->content = $this->aiResult; - $this->isDirty = true; - $this->closeCommand(); - $this->dispatch('notify', message: 'AI suggestions applied', type: 'success'); - } - } - - /** - * Insert AI result at cursor (append for now). - */ - public function insertAiResult(): void - { - if ($this->aiResult) { - $this->content .= "\n\n".$this->aiResult; - $this->isDirty = true; - $this->closeCommand(); - $this->dispatch('notify', message: 'AI content inserted', type: 'success'); - } - } - - /** - * Interpolate template variables. - */ - protected function interpolateVariables(string $template, array $variables): string - { - foreach ($variables as $key => $value) { - if (is_array($value)) { - $value = implode(', ', $value); - } - $template = str_replace('{{'.$key.'}}', (string) $value, $template); - } - - $template = preg_replace_callback( - '/\{\{#if\s+(\w+)\}\}(.*?)\{\{\/if\}\}/s', - function ($matches) use ($variables) { - $key = $matches[1]; - $content = $matches[2]; - - return ! empty($variables[$key]) ? $content : ''; - }, - $template - ); - - $template = preg_replace_callback( - '/\{\{#each\s+(\w+)\}\}(.*?)\{\{\/each\}\}/s', - function ($matches) use ($variables) { - $key = $matches[1]; - $content = $matches[2]; - if (empty($variables[$key]) || ! is_array($variables[$key])) { - return ''; - } - $result = ''; - foreach ($variables[$key] as $item) { - $result .= str_replace('{{this}}', $item, $content); - } - - return $result; - }, - $template - ); - - return $template; - } - - /** - * Handle keyboard shortcut to open command. - */ - #[On('open-ai-command')] - public function handleOpenCommand(): void - { - $this->openCommand(); - } - - public function render() - { - return view('hub::admin.content-editor') - ->layout('hub::admin.layouts.app', [ - 'title' => $this->contentId ? 'Edit Content' : 'New Content', - ]); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/ContentManager.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/ContentManager.php deleted file mode 100644 index df9fa98..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/ContentManager.php +++ /dev/null @@ -1,520 +0,0 @@ -workspaceService = $workspaceService; - $this->cdn = $cdn; - } - - public function mount(string $workspace = 'main', string $view = 'dashboard'): void - { - $this->workspaceSlug = $workspace; - $this->view = $view; - - $this->currentWorkspace = Workspace::where('slug', $workspace)->first(); - - if (! $this->currentWorkspace) { - session()->flash('error', 'Workspace not found'); - } - - // Update session so sidebar links stay on this workspace - $this->workspaceService->setCurrent($workspace); - } - - #[On('workspace-changed')] - public function handleWorkspaceChange(string $workspace): void - { - $this->workspaceSlug = $workspace; - $this->currentWorkspace = Workspace::where('slug', $workspace)->first(); - $this->resetPage(); - } - - /** - * Available tabs for navigation. - */ - #[Computed] - public function tabs(): array - { - return [ - 'dashboard' => [ - 'label' => __('hub::hub.content_manager.tabs.dashboard'), - 'icon' => 'chart-pie', - 'href' => route('hub.content-manager', ['workspace' => $this->workspaceSlug, 'view' => 'dashboard']), - ], - 'kanban' => [ - 'label' => __('hub::hub.content_manager.tabs.kanban'), - 'icon' => 'view-columns', - 'href' => route('hub.content-manager', ['workspace' => $this->workspaceSlug, 'view' => 'kanban']), - ], - 'calendar' => [ - 'label' => __('hub::hub.content_manager.tabs.calendar'), - 'icon' => 'calendar', - 'href' => route('hub.content-manager', ['workspace' => $this->workspaceSlug, 'view' => 'calendar']), - ], - 'list' => [ - 'label' => __('hub::hub.content_manager.tabs.list'), - 'icon' => 'list-bullet', - 'href' => route('hub.content-manager', ['workspace' => $this->workspaceSlug, 'view' => 'list']), - ], - 'webhooks' => [ - 'label' => __('hub::hub.content_manager.tabs.webhooks'), - 'icon' => 'bolt', - 'href' => route('hub.content-manager', ['workspace' => $this->workspaceSlug, 'view' => 'webhooks']), - ], - ]; - } - - /** - * Get content statistics for dashboard. - */ - #[Computed] - public function stats(): array - { - if (! $this->currentWorkspace) { - return $this->emptyStats(); - } - - $id = $this->currentWorkspace->id; - - return [ - 'total' => ContentItem::forWorkspace($id)->count(), - 'posts' => ContentItem::forWorkspace($id)->posts()->count(), - 'pages' => ContentItem::forWorkspace($id)->pages()->count(), - 'published' => ContentItem::forWorkspace($id)->published()->count(), - 'drafts' => ContentItem::forWorkspace($id)->where('status', 'draft')->count(), - 'synced' => ContentItem::forWorkspace($id)->where('sync_status', 'synced')->count(), - 'pending' => ContentItem::forWorkspace($id)->where('sync_status', 'pending')->count(), - 'failed' => ContentItem::forWorkspace($id)->where('sync_status', 'failed')->count(), - 'stale' => ContentItem::forWorkspace($id)->where('sync_status', 'stale')->count(), - 'categories' => ContentTaxonomy::forWorkspace($id)->categories()->count(), - 'tags' => ContentTaxonomy::forWorkspace($id)->tags()->count(), - 'webhooks_today' => ContentWebhookLog::forWorkspace($id) - ->whereDate('created_at', today()) - ->count(), - 'webhooks_failed' => ContentWebhookLog::forWorkspace($id)->failed()->count(), - // Content by source type - 'wordpress' => ContentItem::forWorkspace($id)->wordpress()->count(), - 'hostuk' => ContentItem::forWorkspace($id)->hostuk()->count(), - 'satellite' => ContentItem::forWorkspace($id)->satellite()->count(), - ]; - } - - /** - * Get chart data for content over time (Flux chart format). - */ - #[Computed] - public function chartData(): array - { - if (! $this->currentWorkspace) { - return []; - } - - $days = 30; - $data = []; - - for ($i = $days - 1; $i >= 0; $i--) { - $date = now()->subDays($i); - $data[] = [ - 'date' => $date->toDateString(), - 'count' => ContentItem::forWorkspace($this->currentWorkspace->id) - ->whereDate('created_at', $date) - ->count(), - ]; - } - - return $data; - } - - /** - * Get content by type for donut chart. - */ - #[Computed] - public function contentByType(): array - { - if (! $this->currentWorkspace) { - return []; - } - - return [ - ['label' => 'Posts', 'value' => ContentItem::forWorkspace($this->currentWorkspace->id)->posts()->count()], - ['label' => 'Pages', 'value' => ContentItem::forWorkspace($this->currentWorkspace->id)->pages()->count()], - ]; - } - - /** - * Get content grouped by status for Kanban board. - */ - #[Computed] - public function kanbanColumns(): array - { - if (! $this->currentWorkspace) { - return []; - } - - $id = $this->currentWorkspace->id; - - return [ - [ - 'name' => 'Draft', - 'status' => 'draft', - 'color' => 'gray', - 'items' => ContentItem::forWorkspace($id) - ->where('status', 'draft') - ->orderBy('wp_modified_at', 'desc') - ->take(20) - ->get(), - ], - [ - 'name' => 'Pending Review', - 'status' => 'pending', - 'color' => 'yellow', - 'items' => ContentItem::forWorkspace($id) - ->where('status', 'pending') - ->orderBy('wp_modified_at', 'desc') - ->take(20) - ->get(), - ], - [ - 'name' => 'Scheduled', - 'status' => 'future', - 'color' => 'blue', - 'items' => ContentItem::forWorkspace($id) - ->where('status', 'future') - ->orderBy('wp_created_at', 'asc') - ->take(20) - ->get(), - ], - [ - 'name' => 'Published', - 'status' => 'publish', - 'color' => 'green', - 'items' => ContentItem::forWorkspace($id) - ->published() - ->orderBy('wp_created_at', 'desc') - ->take(20) - ->get(), - ], - ]; - } - - /** - * Get scheduled content for calendar view. - */ - #[Computed] - public function calendarEvents(): array - { - if (! $this->currentWorkspace) { - return []; - } - - return ContentItem::forWorkspace($this->currentWorkspace->id) - ->whereNotNull('wp_created_at') - ->orderBy('wp_created_at', 'desc') - ->take(100) - ->get() - ->map(fn ($item) => [ - 'id' => $item->id, - 'title' => $item->title, - 'date' => $item->wp_created_at?->format('Y-m-d'), - 'type' => $item->type, - 'status' => $item->status, - 'color' => $item->status_color, - ]) - ->toArray(); - } - - /** - * Get paginated content for list view. - */ - #[Computed] - public function content() - { - if (! $this->currentWorkspace) { - // Return empty paginator instead of collection for Flux table compatibility - return ContentItem::query()->whereRaw('1=0')->paginate($this->perPage); - } - - $query = ContentItem::forWorkspace($this->currentWorkspace->id) - ->with(['author', 'categories', 'tags']); - - // Apply filters - if ($this->search) { - $query->where(function ($q) { - $q->where('title', 'like', "%{$this->search}%") - ->orWhere('slug', 'like', "%{$this->search}%") - ->orWhere('excerpt', 'like', "%{$this->search}%"); - }); - } - - if ($this->type) { - $query->where('type', $this->type); - } - - if ($this->status) { - $query->where('status', $this->status); - } - - if ($this->syncStatus) { - $query->where('sync_status', $this->syncStatus); - } - - if ($this->category) { - $query->whereHas('categories', function ($q) { - $q->where('slug', $this->category); - }); - } - - if ($this->contentType) { - $query->where('content_type', $this->contentType); - } - - // Apply sorting - $query->orderBy($this->sort, $this->dir); - - return $query->paginate($this->perPage); - } - - /** - * Get categories for filter dropdown. - */ - #[Computed] - public function categories(): array - { - if (! $this->currentWorkspace) { - return []; - } - - return ContentTaxonomy::forWorkspace($this->currentWorkspace->id) - ->categories() - ->orderBy('name') - ->pluck('name', 'slug') - ->toArray(); - } - - /** - * Get recent webhook logs. - */ - #[Computed] - public function webhookLogs() - { - if (! $this->currentWorkspace) { - // Return empty paginator instead of collection for Flux table compatibility - return ContentWebhookLog::query()->whereRaw('1=0')->paginate($this->perPage); - } - - return ContentWebhookLog::forWorkspace($this->currentWorkspace->id) - ->orderBy('created_at', 'desc') - ->paginate($this->perPage); - } - - /** - * Get the selected item for preview. - */ - #[Computed] - public function selectedItem(): ?ContentItem - { - if (! $this->selectedItemId) { - return null; - } - - return ContentItem::with(['author', 'categories', 'tags', 'featuredMedia']) - ->find($this->selectedItemId); - } - - /** - * Trigger full sync for workspace. - * - * Note: WordPress sync removed - native content system. - */ - public function syncAll(): void - { - if (! $this->currentWorkspace) { - return; - } - - $this->syncMessage = 'Native content system - external sync not required'; - } - - /** - * Purge CDN cache for workspace. - */ - public function purgeCache(): void - { - if (! $this->currentWorkspace) { - return; - } - - $success = $this->cdn->purgeWorkspace($this->currentWorkspace->slug); - - if ($success) { - $this->syncMessage = 'CDN cache purged successfully'; - } else { - $this->syncMessage = 'Failed to purge CDN cache'; - } - } - - /** - * Select an item for preview. - */ - public function selectItem(int $id): void - { - $this->selectedItemId = $id; - $this->dispatch('modal-show', name: 'content-preview'); - } - - /** - * Close the preview panel. - */ - public function closePreview(): void - { - $this->selectedItemId = null; - $this->dispatch('modal-close', name: 'content-preview'); - } - - /** - * Set the sort column. - */ - public function setSort(string $column): void - { - if ($this->sort === $column) { - $this->dir = $this->dir === 'asc' ? 'desc' : 'asc'; - } else { - $this->sort = $column; - $this->dir = 'desc'; - } - } - - /** - * Clear all filters. - */ - public function clearFilters(): void - { - $this->search = ''; - $this->type = ''; - $this->status = ''; - $this->syncStatus = ''; - $this->category = ''; - $this->contentType = ''; - $this->resetPage(); - } - - /** - * Retry a failed webhook. - * - * Note: WordPress webhooks removed - native content system. - */ - public function retryWebhook(int $logId): void - { - $log = ContentWebhookLog::find($logId); - if ($log && $log->status === 'failed') { - $log->update(['status' => 'pending', 'error_message' => null]); - $this->syncMessage = 'Webhook marked for retry'; - } - } - - protected function emptyStats(): array - { - return [ - 'total' => 0, - 'posts' => 0, - 'pages' => 0, - 'published' => 0, - 'drafts' => 0, - 'synced' => 0, - 'pending' => 0, - 'failed' => 0, - 'stale' => 0, - 'categories' => 0, - 'tags' => 0, - 'webhooks_today' => 0, - 'webhooks_failed' => 0, - 'wordpress' => 0, - 'hostuk' => 0, - 'satellite' => 0, - ]; - } - - public function render() - { - return view('hub::admin.content-manager') - ->layout('hub::admin.layouts.app', [ - 'title' => 'Content Manager', - 'workspace' => $this->currentWorkspace, - ]); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Dashboard.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/Dashboard.php deleted file mode 100644 index f71aa4e..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Dashboard.php +++ /dev/null @@ -1,22 +0,0 @@ -layout('hub::admin.layouts.app', ['title' => 'Dashboard']); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Databases.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/Databases.php deleted file mode 100644 index d1e4012..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Databases.php +++ /dev/null @@ -1,219 +0,0 @@ -user()?->isHades()) { - abort(403, 'Hades access required'); - } - $slug = $workspaceService->currentSlug(); - $this->workspace = Workspace::where('slug', $slug)->first(); - - if ($this->workspace) { - $this->wpConnectorEnabled = $this->workspace->wp_connector_enabled ?? false; - $this->wpConnectorUrl = $this->workspace->wp_connector_url ?? ''; - } - - $this->loadInternalWordPressHealth(); - } - - #[Computed] - public function webhookUrl(): string - { - return $this->workspace?->wp_connector_webhook_url ?? ''; - } - - #[Computed] - public function webhookSecret(): string - { - return $this->workspace?->wp_connector_secret ?? ''; - } - - #[Computed] - public function isWpConnectorVerified(): bool - { - return $this->workspace?->wp_connector_verified_at !== null; - } - - #[Computed] - public function wpConnectorLastSync(): ?string - { - return $this->workspace?->wp_connector_last_sync?->diffForHumans(); - } - - public function loadInternalWordPressHealth(): void - { - $this->loadingHealth = true; - - // Cache health check for 5 minutes - $this->internalWpHealth = Cache::remember('internal_wp_health', 300, function () { - $health = [ - 'status' => 'unknown', - 'url' => config('services.wordpress.url', 'https://hestia.host.uk.com'), - 'api_available' => false, - 'version' => null, - 'post_count' => null, - 'page_count' => null, - 'last_check' => now()->toIso8601String(), - ]; - - try { - $response = Http::timeout(5)->get($health['url'].'/wp-json/wp/v2'); - - if ($response->successful()) { - $health['api_available'] = true; - $health['status'] = 'healthy'; - - // Get post count - $postsResponse = Http::timeout(5)->head($health['url'].'/wp-json/wp/v2/posts'); - if ($postsResponse->successful()) { - $health['post_count'] = (int) $postsResponse->header('X-WP-Total', 0); - } - - // Get page count - $pagesResponse = Http::timeout(5)->head($health['url'].'/wp-json/wp/v2/pages'); - if ($pagesResponse->successful()) { - $health['page_count'] = (int) $pagesResponse->header('X-WP-Total', 0); - } - } else { - $health['status'] = 'degraded'; - } - } catch (\Exception $e) { - $health['status'] = 'offline'; - $health['error'] = $e->getMessage(); - } - - return $health; - }); - - $this->loadingHealth = false; - } - - public function refreshInternalHealth(): void - { - Cache::forget('internal_wp_health'); - $this->loadInternalWordPressHealth(); - Flux::toast('Health check refreshed'); - } - - public function saveWpConnector(): void - { - if (! $this->workspace) { - Flux::toast('No workspace selected', variant: 'danger'); - - return; - } - - $this->validate([ - 'wpConnectorUrl' => 'nullable|url', - ]); - - if ($this->wpConnectorEnabled && empty($this->wpConnectorUrl)) { - Flux::toast('WordPress URL is required when connector is enabled', variant: 'danger'); - - return; - } - - if ($this->wpConnectorEnabled) { - $this->workspace->enableWpConnector($this->wpConnectorUrl); - Flux::toast('WordPress connector enabled'); - } else { - $this->workspace->disableWpConnector(); - Flux::toast('WordPress connector disabled'); - } - - $this->workspace->refresh(); - } - - public function regenerateSecret(): void - { - if (! $this->workspace) { - return; - } - - $this->workspace->generateWpConnectorSecret(); - $this->workspace->refresh(); - - Flux::toast('Webhook secret regenerated. Update the secret in your WordPress plugin.'); - } - - public function testWpConnection(): void - { - $this->testingConnection = true; - $this->testResult = null; - - if (empty($this->workspace?->wp_connector_url)) { - $this->testResult = 'WordPress URL is not configured'; - $this->testSuccess = false; - $this->testingConnection = false; - - return; - } - - try { - $response = Http::timeout(10)->get( - $this->workspace->wp_connector_url.'/wp-json/wp/v2' - ); - - if ($response->successful()) { - $this->testResult = 'Connected to WordPress REST API'; - $this->testSuccess = true; - $this->workspace->markWpConnectorVerified(); - } else { - $this->testResult = 'WordPress returned HTTP '.$response->status(); - $this->testSuccess = false; - } - } catch (\Exception $e) { - $this->testResult = 'Connection failed: '.$e->getMessage(); - $this->testSuccess = false; - } - - $this->testingConnection = false; - $this->workspace->refresh(); - } - - public function copyToClipboard(string $value): void - { - $this->dispatch('copy-to-clipboard', text: $value); - Flux::toast('Copied to clipboard'); - } - - public function render(): View - { - return view('hub::admin.databases'); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Deployments.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/Deployments.php deleted file mode 100644 index 659c29e..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Deployments.php +++ /dev/null @@ -1,274 +0,0 @@ -checkHadesAccess(); - } - - #[Computed] - public function services(): array - { - return Cache::remember('admin.deployments.services', 60, function () { - return [ - $this->checkDatabase(), - $this->checkRedis(), - $this->checkQueue(), - $this->checkStorage(), - ]; - }); - } - - #[Computed] - public function gitInfo(): array - { - return Cache::remember('admin.deployments.git', 300, function () { - $info = [ - 'branch' => 'unknown', - 'commit' => 'unknown', - 'message' => 'unknown', - 'author' => 'unknown', - 'date' => null, - ]; - - try { - // Get current branch - $branchResult = Process::path(base_path())->run('git rev-parse --abbrev-ref HEAD'); - if ($branchResult->successful()) { - $info['branch'] = trim($branchResult->output()); - } - - // Get latest commit info - $commitResult = Process::path(base_path())->run('git log -1 --format="%H|%s|%an|%ai"'); - if ($commitResult->successful()) { - $parts = explode('|', trim($commitResult->output())); - if (count($parts) >= 4) { - $info['commit'] = substr($parts[0], 0, 8); - $info['message'] = $parts[1]; - $info['author'] = $parts[2]; - $info['date'] = \Carbon\Carbon::parse($parts[3])->diffForHumans(); - } - } - } catch (\Exception $e) { - // Git not available or not a git repo - } - - return $info; - }); - } - - #[Computed] - public function recentCommits(): array - { - return Cache::remember('admin.deployments.commits', 300, function () { - $commits = []; - - try { - $result = Process::path(base_path())->run('git log -10 --format="%H|%s|%an|%ai"'); - if ($result->successful()) { - foreach (explode("\n", trim($result->output())) as $line) { - $parts = explode('|', $line); - if (count($parts) >= 4) { - $commits[] = [ - 'hash' => substr($parts[0], 0, 8), - 'message' => \Illuminate\Support\Str::limit($parts[1], 60), - 'author' => $parts[2], - 'date' => \Carbon\Carbon::parse($parts[3])->diffForHumans(), - ]; - } - } - } - } catch (\Exception $e) { - // Git not available - } - - return $commits; - }); - } - - #[Computed] - public function stats(): array - { - return [ - [ - 'label' => 'Database', - 'value' => $this->checkDatabase()['status'] === 'healthy' ? 'Online' : 'Offline', - 'icon' => 'circle-stack', - 'color' => $this->checkDatabase()['status'] === 'healthy' ? 'green' : 'red', - ], - [ - 'label' => 'Redis', - 'value' => $this->checkRedis()['status'] === 'healthy' ? 'Online' : 'Offline', - 'icon' => 'bolt', - 'color' => $this->checkRedis()['status'] === 'healthy' ? 'green' : 'red', - ], - [ - 'label' => 'Queue', - 'value' => $this->checkQueue()['status'] === 'healthy' ? 'Active' : 'Inactive', - 'icon' => 'queue-list', - 'color' => $this->checkQueue()['status'] === 'healthy' ? 'green' : 'amber', - ], - [ - 'label' => 'Storage', - 'value' => $this->checkStorage()['details']['free'] ?? 'N/A', - 'icon' => 'server', - 'color' => 'blue', - ], - ]; - } - - public function refresh(): void - { - $this->refreshing = true; - - Cache::forget('admin.deployments.services'); - Cache::forget('admin.deployments.git'); - Cache::forget('admin.deployments.commits'); - - // Force recompute - unset($this->services); - unset($this->gitInfo); - unset($this->recentCommits); - unset($this->stats); - - $this->refreshing = false; - $this->dispatch('notify', message: 'System status refreshed'); - } - - public function clearCache(): void - { - Cache::flush(); - $this->dispatch('notify', message: 'Application cache cleared'); - } - - private function checkDatabase(): array - { - try { - DB::connection()->getPdo(); - $version = DB::selectOne('SELECT VERSION() as version'); - - return [ - 'name' => 'Database (MariaDB)', - 'status' => 'healthy', - 'icon' => 'circle-stack', - 'details' => [ - 'version' => $version->version ?? 'Unknown', - 'connection' => config('database.default'), - 'database' => config('database.connections.'.config('database.default').'.database'), - ], - ]; - } catch (\Exception $e) { - return [ - 'name' => 'Database (MariaDB)', - 'status' => 'unhealthy', - 'icon' => 'circle-stack', - 'error' => $e->getMessage(), - ]; - } - } - - private function checkRedis(): array - { - try { - $redis = Redis::connection(); - $info = $redis->info(); - - return [ - 'name' => 'Redis', - 'status' => 'healthy', - 'icon' => 'bolt', - 'details' => [ - 'version' => $info['redis_version'] ?? 'Unknown', - 'memory' => $info['used_memory_human'] ?? 'Unknown', - 'clients' => $info['connected_clients'] ?? 0, - 'uptime' => isset($info['uptime_in_days']) ? $info['uptime_in_days'].' days' : 'Unknown', - ], - ]; - } catch (\Exception $e) { - return [ - 'name' => 'Redis', - 'status' => 'unhealthy', - 'icon' => 'bolt', - 'error' => $e->getMessage(), - ]; - } - } - - private function checkQueue(): array - { - try { - $pendingJobs = DB::table('jobs')->count(); - $failedJobs = DB::table('failed_jobs')->count(); - - return [ - 'name' => 'Queue Workers', - 'status' => 'healthy', - 'icon' => 'queue-list', - 'details' => [ - 'driver' => config('queue.default'), - 'pending' => $pendingJobs, - 'failed' => $failedJobs, - ], - ]; - } catch (\Exception $e) { - return [ - 'name' => 'Queue Workers', - 'status' => 'unknown', - 'icon' => 'queue-list', - 'error' => 'Could not check queue status', - ]; - } - } - - private function checkStorage(): array - { - $storagePath = storage_path(); - $freeBytes = disk_free_space($storagePath); - $totalBytes = disk_total_space($storagePath); - - $freeGb = $freeBytes ? round($freeBytes / 1024 / 1024 / 1024, 1) : 0; - $totalGb = $totalBytes ? round($totalBytes / 1024 / 1024 / 1024, 1) : 0; - $usedPercent = $totalBytes ? round((($totalBytes - $freeBytes) / $totalBytes) * 100) : 0; - - return [ - 'name' => 'Storage', - 'status' => $usedPercent < 90 ? 'healthy' : 'warning', - 'icon' => 'server', - 'details' => [ - 'free' => $freeGb.' GB', - 'total' => $totalGb.' GB', - 'used_percent' => $usedPercent.'%', - ], - ]; - } - - private function checkHadesAccess(): void - { - if (! auth()->user()?->isHades()) { - abort(403, 'Hades access required'); - } - } - - public function render(): View - { - return view('hub::admin.deployments') - ->layout('hub::admin.layouts.app', ['title' => 'Deployments & System Status']); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Entitlement/Dashboard.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/Entitlement/Dashboard.php deleted file mode 100644 index 324ea8e..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Entitlement/Dashboard.php +++ /dev/null @@ -1,534 +0,0 @@ -user()?->isHades()) { - abort(403, 'Hades tier required for entitlement management.'); - } - - if ($tab && in_array($tab, ['overview', 'packages', 'features'])) { - $this->tab = $tab; - } - } - - public function setTab(string $tab): void - { - if (in_array($tab, ['overview', 'packages', 'features'])) { - $this->tab = $tab; - $this->resetPage(); - } - } - - // ───────────────────────────────────────────────────────────── - // Overview Stats - // ───────────────────────────────────────────────────────────── - - #[Computed] - public function stats(): array - { - return [ - 'packages' => [ - 'total' => Package::count(), - 'active' => Package::where('is_active', true)->count(), - 'public' => Package::where('is_public', true)->count(), - 'base' => Package::where('is_base_package', true)->count(), - ], - 'features' => [ - 'total' => Feature::count(), - 'active' => Feature::where('is_active', true)->count(), - 'boolean' => Feature::where('type', 'boolean')->count(), - 'limit' => Feature::where('type', 'limit')->count(), - ], - 'assignments' => [ - 'workspace_packages' => WorkspacePackage::where('status', 'active')->count(), - 'active_boosts' => Boost::where('status', 'active')->count(), - ], - 'categories' => Feature::whereNotNull('category') - ->distinct() - ->pluck('category') - ->toArray(), - ]; - } - - // ───────────────────────────────────────────────────────────── - // Packages - // ───────────────────────────────────────────────────────────── - - #[Computed] - public function packages() - { - return Package::withCount('features') - ->orderBy('sort_order') - ->orderBy('name') - ->paginate(15); - } - - public function openCreatePackage(): void - { - $this->resetPackageForm(); - $this->showPackageModal = true; - } - - public function openEditPackage(int $id): void - { - $package = Package::findOrFail($id); - - $this->editingPackageId = $id; - $this->packageCode = $package->code; - $this->packageName = $package->name; - $this->packageDescription = $package->description ?? ''; - $this->packageIcon = $package->icon ?? 'box'; - $this->packageColor = $package->color ?? 'blue'; - $this->packageSortOrder = $package->sort_order; - $this->packageIsStackable = $package->is_stackable; - $this->packageIsBasePackage = $package->is_base_package; - $this->packageIsActive = $package->is_active; - $this->packageIsPublic = $package->is_public; - - $this->showPackageModal = true; - } - - public function savePackage(): void - { - $this->validate([ - 'packageCode' => ['required', 'string', 'max:50', $this->editingPackageId - ? 'unique:entitlement_packages,code,'.$this->editingPackageId - : 'unique:entitlement_packages,code'], - 'packageName' => ['required', 'string', 'max:100'], - 'packageDescription' => ['nullable', 'string', 'max:500'], - ]); - - $data = [ - 'code' => $this->packageCode, - 'name' => $this->packageName, - 'description' => $this->packageDescription ?: null, - 'icon' => $this->packageIcon ?: null, - 'color' => $this->packageColor ?: null, - 'sort_order' => $this->packageSortOrder, - 'is_stackable' => $this->packageIsStackable, - 'is_base_package' => $this->packageIsBasePackage, - 'is_active' => $this->packageIsActive, - 'is_public' => $this->packageIsPublic, - ]; - - if ($this->editingPackageId) { - Package::findOrFail($this->editingPackageId)->update($data); - session()->flash('success', 'Package updated.'); - } else { - Package::create($data); - session()->flash('success', 'Package created.'); - } - - $this->closePackageModal(); - unset($this->packages); - unset($this->stats); - } - - public function deletePackage(int $id): void - { - $package = Package::findOrFail($id); - - if ($package->workspacePackages()->exists()) { - session()->flash('error', 'Cannot delete package with active assignments.'); - - return; - } - - $package->delete(); - session()->flash('success', 'Package deleted.'); - unset($this->packages); - unset($this->stats); - } - - public function openAssignFeatures(int $id): void - { - $this->editingPackageId = $id; - $package = Package::with('features')->findOrFail($id); - - $this->selectedFeatures = []; - foreach ($package->features as $feature) { - $this->selectedFeatures[$feature->id] = [ - 'enabled' => true, - 'limit' => $feature->pivot->limit_value, - ]; - } - - $this->showFeaturesModal = true; - } - - public function toggleFeature(int $featureId): void - { - if (isset($this->selectedFeatures[$featureId])) { - $this->selectedFeatures[$featureId]['enabled'] = ! $this->selectedFeatures[$featureId]['enabled']; - } else { - $this->selectedFeatures[$featureId] = [ - 'enabled' => true, - 'limit' => null, - ]; - } - } - - public function saveFeatures(): void - { - $package = Package::findOrFail($this->editingPackageId); - - $syncData = []; - foreach ($this->selectedFeatures as $featureId => $config) { - if (! empty($config['enabled'])) { - $syncData[$featureId] = [ - 'limit_value' => isset($config['limit']) && $config['limit'] !== '' - ? (int) $config['limit'] - : null, - ]; - } - } - - $package->features()->sync($syncData); - - session()->flash('success', 'Package features updated.'); - $this->showFeaturesModal = false; - unset($this->packages); - } - - public function closePackageModal(): void - { - $this->showPackageModal = false; - $this->resetPackageForm(); - } - - protected function resetPackageForm(): void - { - $this->editingPackageId = null; - $this->packageCode = ''; - $this->packageName = ''; - $this->packageDescription = ''; - $this->packageIcon = 'box'; - $this->packageColor = 'blue'; - $this->packageSortOrder = 0; - $this->packageIsStackable = true; - $this->packageIsBasePackage = false; - $this->packageIsActive = true; - $this->packageIsPublic = true; - } - - // ───────────────────────────────────────────────────────────── - // Features - // ───────────────────────────────────────────────────────────── - - #[Computed] - public function features() - { - return Feature::with('parent') - ->orderBy('category') - ->orderBy('sort_order') - ->paginate(20); - } - - #[Computed] - public function allFeatures() - { - return Feature::active() - ->orderBy('category') - ->orderBy('sort_order') - ->get() - ->groupBy('category'); - } - - #[Computed] - public function parentFeatures() - { - return Feature::root() - ->where('type', 'limit') - ->get(); - } - - #[Computed] - public function featureCategories() - { - return Feature::whereNotNull('category') - ->distinct() - ->pluck('category'); - } - - public function openCreateFeature(): void - { - $this->resetFeatureForm(); - $this->showFeatureModal = true; - } - - public function openEditFeature(int $id): void - { - $feature = Feature::findOrFail($id); - - $this->editingFeatureId = $id; - $this->featureCode = $feature->code; - $this->featureName = $feature->name; - $this->featureDescription = $feature->description ?? ''; - $this->featureCategory = $feature->category ?? ''; - $this->featureType = $feature->type; - $this->featureResetType = $feature->reset_type; - $this->featureRollingDays = $feature->rolling_window_days; - $this->featureParentId = $feature->parent_feature_id; - $this->featureSortOrder = $feature->sort_order; - $this->featureIsActive = $feature->is_active; - - $this->showFeatureModal = true; - } - - public function saveFeature(): void - { - $this->validate([ - 'featureCode' => ['required', 'string', 'max:100', $this->editingFeatureId - ? 'unique:entitlement_features,code,'.$this->editingFeatureId - : 'unique:entitlement_features,code'], - 'featureName' => ['required', 'string', 'max:100'], - 'featureDescription' => ['nullable', 'string', 'max:500'], - 'featureCategory' => ['nullable', 'string', 'max:50'], - 'featureType' => ['required', 'in:boolean,limit,unlimited'], - 'featureResetType' => ['required', 'in:none,monthly,rolling'], - ]); - - $data = [ - 'code' => $this->featureCode, - 'name' => $this->featureName, - 'description' => $this->featureDescription ?: null, - 'category' => $this->featureCategory ?: null, - 'type' => $this->featureType, - 'reset_type' => $this->featureResetType, - 'rolling_window_days' => $this->featureResetType === 'rolling' ? $this->featureRollingDays : null, - 'parent_feature_id' => $this->featureParentId ?: null, - 'sort_order' => $this->featureSortOrder, - 'is_active' => $this->featureIsActive, - ]; - - if ($this->editingFeatureId) { - Feature::findOrFail($this->editingFeatureId)->update($data); - session()->flash('success', 'Feature updated.'); - } else { - Feature::create($data); - session()->flash('success', 'Feature created.'); - } - - $this->closeFeatureModal(); - unset($this->features); - unset($this->allFeatures); - unset($this->stats); - } - - public function deleteFeature(int $id): void - { - $feature = Feature::findOrFail($id); - - if ($feature->packages()->exists()) { - session()->flash('error', 'Cannot delete feature assigned to packages.'); - - return; - } - - if ($feature->children()->exists()) { - session()->flash('error', 'Cannot delete feature with children.'); - - return; - } - - $feature->delete(); - session()->flash('success', 'Feature deleted.'); - unset($this->features); - unset($this->allFeatures); - unset($this->stats); - } - - public function closeFeatureModal(): void - { - $this->showFeatureModal = false; - $this->resetFeatureForm(); - } - - protected function resetFeatureForm(): void - { - $this->editingFeatureId = null; - $this->featureCode = ''; - $this->featureName = ''; - $this->featureDescription = ''; - $this->featureCategory = ''; - $this->featureType = 'boolean'; - $this->featureResetType = 'none'; - $this->featureRollingDays = null; - $this->featureParentId = null; - $this->featureSortOrder = 0; - $this->featureIsActive = true; - } - - // ───────────────────────────────────────────────────────────── - // Table Helpers - // ───────────────────────────────────────────────────────────── - - #[Computed] - public function packageTableRows(): array - { - return $this->packages->map(function ($p) { - $lines = [['bold' => $p->name]]; - if ($p->description) { - $lines[] = ['muted' => Str::limit($p->description, 50)]; - } - - $typeBadge = match (true) { - $p->is_base_package => ['badge' => 'Base', 'color' => 'purple'], - $p->is_stackable => ['badge' => 'Addon', 'color' => 'blue'], - default => ['badge' => 'Standard', 'color' => 'gray'], - }; - - $statusLines = []; - $statusLines[] = ['badge' => $p->is_active ? 'Active' : 'Inactive', 'color' => $p->is_active ? 'green' : 'gray']; - if ($p->is_public) { - $statusLines[] = ['badge' => 'Public', 'color' => 'sky']; - } - - return [ - [ - 'icon' => $p->icon ?? 'box', - 'iconColor' => $p->color ?? 'gray', - 'lines' => $lines, - ], - ['mono' => $p->code], - ['badge' => $p->features_count.' features', 'color' => 'gray'], - $typeBadge, - ['lines' => $statusLines], - [ - 'actions' => [ - ['icon' => 'puzzle-piece', 'click' => "openAssignFeatures({$p->id})", 'title' => 'Assign features'], - ['icon' => 'pencil', 'click' => "openEditPackage({$p->id})", 'title' => 'Edit'], - ['icon' => 'trash', 'click' => "deletePackage({$p->id})", 'confirm' => 'Delete this package?', 'title' => 'Delete', 'class' => 'text-red-600'], - ], - ], - ]; - })->all(); - } - - #[Computed] - public function featureTableRows(): array - { - $typeColors = [ - 'boolean' => 'gray', - 'limit' => 'blue', - 'unlimited' => 'purple', - ]; - - return $this->features->map(function ($f) use ($typeColors) { - $lines = [['bold' => $f->name]]; - if ($f->description) { - $lines[] = ['muted' => Str::limit($f->description, 40)]; - } - if ($f->parent) { - $lines[] = ['muted' => 'Pool: '.$f->parent->name]; - } - - $resetCell = match ($f->reset_type) { - 'none' => ['muted' => 'Never'], - 'monthly' => ['badge' => 'Monthly', 'color' => 'green'], - 'rolling' => ['badge' => $f->rolling_window_days.'d', 'color' => 'amber'], - default => ['muted' => '-'], - }; - - return [ - ['lines' => $lines], - ['mono' => $f->code], - $f->category ? ['badge' => $f->category, 'color' => 'gray'] : ['muted' => '-'], - ['badge' => ucfirst($f->type), 'color' => $typeColors[$f->type] ?? 'gray'], - $resetCell, - ['badge' => $f->is_active ? 'Active' : 'Inactive', 'color' => $f->is_active ? 'green' : 'gray'], - [ - 'actions' => [ - ['icon' => 'pencil', 'click' => "openEditFeature({$f->id})", 'title' => 'Edit'], - ['icon' => 'trash', 'click' => "deleteFeature({$f->id})", 'confirm' => 'Delete this feature?', 'title' => 'Delete', 'class' => 'text-red-600'], - ], - ], - ]; - })->all(); - } - - public function render() - { - return view('hub::admin.entitlement.dashboard'); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Entitlement/FeatureManager.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/Entitlement/FeatureManager.php deleted file mode 100644 index a94e87a..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Entitlement/FeatureManager.php +++ /dev/null @@ -1,259 +0,0 @@ -user()?->isHades()) { - abort(403, 'Hades tier required for feature management.'); - } - } - - public ?int $editingId = null; - - // Form fields - public string $code = ''; - - public string $name = ''; - - public string $description = ''; - - public string $category = ''; - - public string $type = 'boolean'; - - public string $reset_type = 'none'; - - public ?int $rolling_window_days = null; - - public ?int $parent_feature_id = null; - - public int $sort_order = 0; - - public bool $is_active = true; - - protected function rules(): array - { - $uniqueRule = $this->editingId - ? 'unique:entitlement_features,code,'.$this->editingId - : 'unique:entitlement_features,code'; - - return [ - 'code' => ['required', 'string', 'max:100', $uniqueRule], - 'name' => ['required', 'string', 'max:100'], - 'description' => ['nullable', 'string', 'max:500'], - 'category' => ['nullable', 'string', 'max:50'], - 'type' => ['required', 'in:boolean,limit,unlimited'], - 'reset_type' => ['required', 'in:none,monthly,rolling'], - 'rolling_window_days' => ['nullable', 'integer', 'min:1', 'max:365'], - 'parent_feature_id' => ['nullable', 'exists:entitlement_features,id'], - 'sort_order' => ['integer'], - 'is_active' => ['boolean'], - ]; - } - - public function openCreate(): void - { - $this->resetForm(); - $this->showModal = true; - } - - public function openEdit(int $id): void - { - $feature = Feature::findOrFail($id); - - $this->editingId = $id; - $this->code = $feature->code; - $this->name = $feature->name; - $this->description = $feature->description ?? ''; - $this->category = $feature->category ?? ''; - $this->type = $feature->type; - $this->reset_type = $feature->reset_type; - $this->rolling_window_days = $feature->rolling_window_days; - $this->parent_feature_id = $feature->parent_feature_id; - $this->sort_order = $feature->sort_order; - $this->is_active = $feature->is_active; - - $this->showModal = true; - } - - public function save(): void - { - $this->validate(); - - $data = [ - 'code' => $this->code, - 'name' => $this->name, - 'description' => $this->description ?: null, - 'category' => $this->category ?: null, - 'type' => $this->type, - 'reset_type' => $this->reset_type, - 'rolling_window_days' => $this->reset_type === 'rolling' ? $this->rolling_window_days : null, - 'parent_feature_id' => $this->parent_feature_id ?: null, - 'sort_order' => $this->sort_order, - 'is_active' => $this->is_active, - ]; - - if ($this->editingId) { - Feature::findOrFail($this->editingId)->update($data); - session()->flash('message', 'Feature updated successfully.'); - } else { - Feature::create($data); - session()->flash('message', 'Feature created successfully.'); - } - - $this->closeModal(); - } - - public function delete(int $id): void - { - $feature = Feature::findOrFail($id); - - // Check if feature is used in any packages - if ($feature->packages()->exists()) { - session()->flash('error', 'Cannot delete feature that is assigned to packages.'); - - return; - } - - // Check if feature has children - if ($feature->children()->exists()) { - session()->flash('error', 'Cannot delete feature that has child features.'); - - return; - } - - $feature->delete(); - session()->flash('message', 'Feature deleted successfully.'); - } - - public function closeModal(): void - { - $this->showModal = false; - $this->resetForm(); - } - - protected function resetForm(): void - { - $this->editingId = null; - $this->code = ''; - $this->name = ''; - $this->description = ''; - $this->category = ''; - $this->type = 'boolean'; - $this->reset_type = 'none'; - $this->rolling_window_days = null; - $this->parent_feature_id = null; - $this->sort_order = 0; - $this->is_active = true; - } - - #[Computed] - public function features() - { - return Feature::with('parent') - ->orderBy('category') - ->orderBy('sort_order') - ->paginate(30); - } - - #[Computed] - public function categories() - { - return Feature::whereNotNull('category') - ->distinct() - ->pluck('category'); - } - - #[Computed] - public function parentFeatures() - { - return Feature::root() - ->where('type', 'limit') - ->get(); - } - - #[Computed] - public function tableColumns(): array - { - return [ - 'Feature', - 'Code', - 'Category', - ['label' => 'Type', 'align' => 'center'], - ['label' => 'Reset', 'align' => 'center'], - ['label' => 'Status', 'align' => 'center'], - ['label' => 'Actions', 'align' => 'center'], - ]; - } - - #[Computed] - public function tableRows(): array - { - $typeColors = [ - 'boolean' => 'gray', - 'limit' => 'blue', - 'unlimited' => 'purple', - ]; - - return $this->features->map(function ($f) use ($typeColors) { - // Feature name with description and parent - $featureLines = [['bold' => $f->name]]; - if ($f->description) { - $featureLines[] = ['muted' => \Illuminate\Support\Str::limit($f->description, 40)]; - } - if ($f->parent) { - $featureLines[] = ['muted' => 'Parent: '.$f->parent->name]; - } - - // Reset type display - $resetCell = match ($f->reset_type) { - 'none' => ['muted' => 'Never'], - 'monthly' => ['badge' => 'Monthly', 'color' => 'green'], - 'rolling' => ['badge' => $f->rolling_window_days.'d Rolling', 'color' => 'amber'], - default => ['muted' => '-'], - }; - - return [ - ['lines' => $featureLines], - ['mono' => $f->code], - $f->category ? ['badge' => $f->category, 'color' => 'gray'] : ['muted' => '-'], - ['badge' => ucfirst($f->type), 'color' => $typeColors[$f->type] ?? 'gray'], - $resetCell, - ['badge' => $f->is_active ? 'Active' : 'Inactive', 'color' => $f->is_active ? 'green' : 'gray'], - [ - 'actions' => [ - ['icon' => 'pencil', 'click' => "openEdit({$f->id})", 'title' => 'Edit'], - ['icon' => 'trash', 'click' => "delete({$f->id})", 'confirm' => 'Are you sure you want to delete this feature?', 'title' => 'Delete', 'class' => 'text-red-600'], - ], - ], - ]; - })->all(); - } - - public function render() - { - return view('hub::admin.entitlement.feature-manager') - ->layout('hub::admin.layouts.app', ['title' => 'Features']); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Entitlement/PackageManager.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/Entitlement/PackageManager.php deleted file mode 100644 index 60bd27a..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Entitlement/PackageManager.php +++ /dev/null @@ -1,306 +0,0 @@ -user()?->isHades()) { - abort(403, 'Hades tier required for package management.'); - } - } - - public bool $showFeaturesModal = false; - - public ?int $editingId = null; - - // Form fields - public string $code = ''; - - public string $name = ''; - - public string $description = ''; - - public string $icon = 'package'; - - public string $color = 'blue'; - - public int $sort_order = 0; - - public bool $is_stackable = true; - - public bool $is_base_package = false; - - public bool $is_active = true; - - public bool $is_public = true; - - public string $blesta_package_id = ''; - - // Features assignment - public array $selectedFeatures = []; - - protected function rules(): array - { - $uniqueRule = $this->editingId - ? 'unique:entitlement_packages,code,'.$this->editingId - : 'unique:entitlement_packages,code'; - - return [ - 'code' => ['required', 'string', 'max:50', $uniqueRule], - 'name' => ['required', 'string', 'max:100'], - 'description' => ['nullable', 'string', 'max:500'], - 'icon' => ['nullable', 'string', 'max:50'], - 'color' => ['nullable', 'string', 'max:20'], - 'sort_order' => ['integer'], - 'is_stackable' => ['boolean'], - 'is_base_package' => ['boolean'], - 'is_active' => ['boolean'], - 'is_public' => ['boolean'], - 'blesta_package_id' => ['nullable', 'string', 'max:100'], - ]; - } - - public function openCreate(): void - { - $this->resetForm(); - $this->showModal = true; - } - - public function openEdit(int $id): void - { - $package = Package::findOrFail($id); - - $this->editingId = $id; - $this->code = $package->code; - $this->name = $package->name; - $this->description = $package->description ?? ''; - $this->icon = $package->icon ?? 'package'; - $this->color = $package->color ?? 'blue'; - $this->sort_order = $package->sort_order; - $this->is_stackable = $package->is_stackable; - $this->is_base_package = $package->is_base_package; - $this->is_active = $package->is_active; - $this->is_public = $package->is_public; - $this->blesta_package_id = $package->blesta_package_id ?? ''; - - $this->showModal = true; - } - - public function save(): void - { - $this->validate(); - - $data = [ - 'code' => $this->code, - 'name' => $this->name, - 'description' => $this->description ?: null, - 'icon' => $this->icon ?: null, - 'color' => $this->color ?: null, - 'sort_order' => $this->sort_order, - 'is_stackable' => $this->is_stackable, - 'is_base_package' => $this->is_base_package, - 'is_active' => $this->is_active, - 'is_public' => $this->is_public, - 'blesta_package_id' => $this->blesta_package_id ?: null, - ]; - - if ($this->editingId) { - Package::findOrFail($this->editingId)->update($data); - session()->flash('message', 'Package updated successfully.'); - } else { - Package::create($data); - session()->flash('message', 'Package created successfully.'); - } - - $this->closeModal(); - } - - public function openFeatures(int $id): void - { - $this->editingId = $id; - $package = Package::with('features')->findOrFail($id); - - // Build selectedFeatures array with limit values - $this->selectedFeatures = []; - foreach ($package->features as $feature) { - $this->selectedFeatures[$feature->id] = [ - 'enabled' => true, - 'limit' => $feature->pivot->limit_value, - ]; - } - - $this->showFeaturesModal = true; - } - - public function saveFeatures(): void - { - $package = Package::findOrFail($this->editingId); - - $syncData = []; - foreach ($this->selectedFeatures as $featureId => $config) { - if (! empty($config['enabled'])) { - $syncData[$featureId] = [ - 'limit_value' => isset($config['limit']) && $config['limit'] !== '' - ? (int) $config['limit'] - : null, - ]; - } - } - - $package->features()->sync($syncData); - - session()->flash('message', 'Package features updated successfully.'); - $this->showFeaturesModal = false; - } - - public function toggleFeature(int $featureId): void - { - if (isset($this->selectedFeatures[$featureId])) { - $this->selectedFeatures[$featureId]['enabled'] = ! $this->selectedFeatures[$featureId]['enabled']; - } else { - $this->selectedFeatures[$featureId] = [ - 'enabled' => true, - 'limit' => null, - ]; - } - } - - public function delete(int $id): void - { - $package = Package::findOrFail($id); - - // Check if any workspaces use this package - if ($package->workspacePackages()->exists()) { - session()->flash('error', 'Cannot delete package with active assignments.'); - - return; - } - - $package->delete(); - session()->flash('message', 'Package deleted successfully.'); - } - - public function closeModal(): void - { - $this->showModal = false; - $this->showFeaturesModal = false; - $this->resetForm(); - } - - protected function resetForm(): void - { - $this->editingId = null; - $this->code = ''; - $this->name = ''; - $this->description = ''; - $this->icon = 'package'; - $this->color = 'blue'; - $this->sort_order = 0; - $this->is_stackable = true; - $this->is_base_package = false; - $this->is_active = true; - $this->is_public = true; - $this->blesta_package_id = ''; - $this->selectedFeatures = []; - } - - #[Computed] - public function packages() - { - return Package::withCount('features') - ->orderBy('sort_order') - ->orderBy('name') - ->paginate(20); - } - - #[Computed] - public function features() - { - return Feature::active() - ->orderBy('category') - ->orderBy('sort_order') - ->get() - ->groupBy('category'); - } - - #[Computed] - public function tableColumns(): array - { - return [ - 'Package', - 'Code', - 'Features', - ['label' => 'Type', 'align' => 'center'], - ['label' => 'Status', 'align' => 'center'], - ['label' => 'Actions', 'align' => 'center'], - ]; - } - - #[Computed] - public function tableRows(): array - { - return $this->packages->map(function ($p) { - // Package name with icon and description - $packageLines = [['bold' => $p->name]]; - if ($p->description) { - $packageLines[] = ['muted' => \Illuminate\Support\Str::limit($p->description, 50)]; - } - - // Type badge - $typeBadge = match (true) { - $p->is_base_package => ['badge' => 'Base', 'color' => 'purple'], - $p->is_stackable => ['badge' => 'Addon', 'color' => 'blue'], - default => ['badge' => 'Standard', 'color' => 'gray'], - }; - - // Status badges (multiple) - $statusLines = []; - $statusLines[] = ['badge' => $p->is_active ? 'Active' : 'Inactive', 'color' => $p->is_active ? 'green' : 'gray']; - if ($p->is_public) { - $statusLines[] = ['badge' => 'Public', 'color' => 'sky']; - } - - return [ - ['lines' => $packageLines], - ['mono' => $p->code], - ['badge' => $p->features_count.' features', 'color' => 'gray'], - $typeBadge, - ['lines' => $statusLines], - [ - 'actions' => [ - ['icon' => 'puzzle-piece', 'click' => "openFeatures({$p->id})", 'title' => 'Assign features'], - ['icon' => 'pencil', 'click' => "openEdit({$p->id})", 'title' => 'Edit'], - ['icon' => 'trash', 'click' => "delete({$p->id})", 'confirm' => 'Are you sure you want to delete this package?', 'title' => 'Delete', 'class' => 'text-red-600'], - ], - ], - ]; - })->all(); - } - - public function render() - { - return view('hub::admin.entitlement.package-manager') - ->layout('hub::admin.layouts.app', ['title' => 'Packages']); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/GlobalSearch.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/GlobalSearch.php deleted file mode 100644 index b418cab..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/GlobalSearch.php +++ /dev/null @@ -1,257 +0,0 @@ -registry = $registry; - } - - /** - * Mount the component. - */ - public function mount(): void - { - $this->recentSearches = session('global_search.recent', []); - } - - /** - * Open the search modal. - */ - #[On('open-global-search')] - public function openSearch(): void - { - $this->open = true; - $this->query = ''; - $this->selectedIndex = 0; - } - - /** - * Close the search modal. - */ - public function closeSearch(): void - { - $this->open = false; - $this->query = ''; - $this->selectedIndex = 0; - } - - /** - * Handle query changes - reset selection index. - */ - public function updatedQuery(): void - { - $this->selectedIndex = 0; - } - - /** - * Navigate up in results. - */ - public function navigateUp(): void - { - if ($this->selectedIndex > 0) { - $this->selectedIndex--; - } - } - - /** - * Navigate down in results. - */ - public function navigateDown(): void - { - $allResults = $this->flatResults; - if ($this->selectedIndex < count($allResults) - 1) { - $this->selectedIndex++; - } - } - - /** - * Select the current result. - */ - public function selectCurrent(): void - { - $allResults = $this->flatResults; - if (isset($allResults[$this->selectedIndex])) { - $result = $allResults[$this->selectedIndex]; - $this->navigateTo($result); - } - } - - /** - * Navigate to a specific result. - */ - public function navigateTo(array $result): void - { - // Add to recent searches - $this->addToRecentSearches($result); - - $this->closeSearch(); - - $this->dispatch('navigate-to-url', url: $result['url']); - } - - /** - * Navigate to a recent search item. - */ - public function navigateToRecent(int $index): void - { - if (isset($this->recentSearches[$index])) { - $result = $this->recentSearches[$index]; - $this->closeSearch(); - $this->dispatch('navigate-to-url', url: $result['url']); - } - } - - /** - * Clear all recent searches. - */ - public function clearRecentSearches(): void - { - $this->recentSearches = []; - session()->forget('global_search.recent'); - } - - /** - * Remove a single recent search. - */ - public function removeRecentSearch(int $index): void - { - if (isset($this->recentSearches[$index])) { - array_splice($this->recentSearches, $index, 1); - session(['global_search.recent' => $this->recentSearches]); - } - } - - /** - * Add a result to recent searches. - */ - protected function addToRecentSearches(array $result): void - { - // Remove if already exists (to move to top) - $this->recentSearches = array_values(array_filter( - $this->recentSearches, - fn ($item) => $item['id'] !== $result['id'] || $item['type'] !== $result['type'] - )); - - // Add to the beginning - array_unshift($this->recentSearches, [ - 'id' => $result['id'], - 'title' => $result['title'], - 'subtitle' => $result['subtitle'] ?? null, - 'url' => $result['url'], - 'type' => $result['type'], - 'icon' => $result['icon'], - ]); - - // Limit the number of recent searches - $this->recentSearches = array_slice($this->recentSearches, 0, $this->maxRecentSearches); - - // Save to session - session(['global_search.recent' => $this->recentSearches]); - } - - /** - * Get search results grouped by type. - */ - #[Computed] - public function results(): array - { - if (strlen($this->query) < 2) { - return []; - } - - $user = auth()->user(); - $workspace = $user?->defaultHostWorkspace(); - - return $this->registry->search($this->query, $user, $workspace); - } - - /** - * Get flattened results for keyboard navigation. - */ - #[Computed] - public function flatResults(): array - { - return $this->registry->flattenResults($this->results); - } - - /** - * Check if there are any results. - */ - #[Computed] - public function hasResults(): bool - { - return ! empty($this->flatResults); - } - - /** - * Check if we should show recent searches. - */ - #[Computed] - public function showRecentSearches(): bool - { - return strlen($this->query) < 2 && ! empty($this->recentSearches); - } - - public function render() - { - return view('hub::admin.global-search'); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Honeypot.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/Honeypot.php deleted file mode 100644 index 1616587..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Honeypot.php +++ /dev/null @@ -1,84 +0,0 @@ - ['except' => ''], - 'botFilter' => ['except' => ''], - ]; - - public function mount(): void - { - if (! auth()->user()?->isHades()) { - abort(403, 'Hades tier required.'); - } - } - - public function updatingSearch(): void - { - $this->resetPage(); - } - - public function sortBy(string $field): void - { - if ($this->sortField === $field) { - $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; - } else { - $this->sortField = $field; - $this->sortDirection = 'asc'; - } - } - - public function deleteOld(int $days = 30): void - { - HoneypotHit::where('created_at', '<', now()->subDays($days))->delete(); - session()->flash('message', "Deleted hits older than {$days} days."); - } - - public function blockIp(string $ip): void - { - // This could integrate with a firewall or rate limiter - // For now, just show a message - session()->flash('message', "IP {$ip} flagged for review. Add to firewall manually."); - } - - public function render() - { - $hits = HoneypotHit::query() - ->when($this->search, function ($query) { - $query->where(function ($q) { - $q->where('ip_address', 'like', "%{$this->search}%") - ->orWhere('user_agent', 'like', "%{$this->search}%") - ->orWhere('bot_name', 'like', "%{$this->search}%"); - }); - }) - ->when($this->botFilter !== '', function ($query) { - $query->where('is_bot', $this->botFilter === '1'); - }) - ->orderBy($this->sortField, $this->sortDirection) - ->paginate(50); - - return view('hub::admin.honeypot', [ - 'hits' => $hits, - 'stats' => HoneypotHit::getStats(), - ])->layout('hub::admin.layouts.app', ['title' => 'Honeypot Monitor']); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Platform.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/Platform.php deleted file mode 100644 index d44ff9f..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Platform.php +++ /dev/null @@ -1,162 +0,0 @@ - ['except' => ''], - 'tierFilter' => ['except' => ''], - 'verifiedFilter' => ['except' => ''], - ]; - - public function mount(): void - { - // Ensure only Hades users can access - if (! auth()->user()?->isHades()) { - abort(403, 'Hades tier required for platform administration.'); - } - } - - public function updatingSearch(): void - { - $this->resetPage(); - } - - public function sortBy(string $field): void - { - if ($this->sortField === $field) { - $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; - } else { - $this->sortField = $field; - $this->sortDirection = 'asc'; - } - } - - public function verifyEmail(int $userId): void - { - $user = User::find($userId); - if ($user && ! $user->email_verified_at) { - $user->markEmailAsVerified(); - $this->actionMessage = "Email verified for {$user->email}."; - $this->actionType = 'success'; - } - } - - public function clearCache(): void - { - Cache::flush(); - Artisan::call('config:clear'); - Artisan::call('view:clear'); - Artisan::call('route:clear'); - - $this->actionMessage = 'All caches cleared successfully.'; - $this->actionType = 'success'; - } - - public function clearOpcache(): void - { - if (function_exists('opcache_reset')) { - opcache_reset(); - $this->actionMessage = 'OPcache cleared successfully.'; - $this->actionType = 'success'; - } else { - $this->actionMessage = 'OPcache is not available.'; - $this->actionType = 'warning'; - } - } - - public function restartQueue(): void - { - Artisan::call('queue:restart'); - $this->actionMessage = 'Queue workers will restart after their current job completes.'; - $this->actionType = 'success'; - } - - public function getPlatformStats(): array - { - return [ - 'total_users' => User::count(), - 'verified_users' => User::whereNotNull('email_verified_at')->count(), - 'hades_users' => User::where('tier', 'hades')->count(), - 'apollo_users' => User::where('tier', 'apollo')->count(), - 'free_users' => User::where('tier', 'free')->orWhereNull('tier')->count(), - 'users_today' => User::whereDate('created_at', today())->count(), - 'users_this_week' => User::where('created_at', '>=', now()->subWeek())->count(), - ]; - } - - public function getSystemInfo(): array - { - return [ - 'php_version' => PHP_VERSION, - 'laravel_version' => app()->version(), - 'environment' => app()->environment(), - 'debug_mode' => config('app.debug') ? 'Enabled' : 'Disabled', - 'cache_driver' => config('cache.default'), - 'session_driver' => config('session.driver'), - 'queue_driver' => config('queue.default'), - 'db_connection' => config('database.default'), - ]; - } - - public function render() - { - $users = User::query() - ->when($this->search, function ($query) { - $query->where(function ($q) { - $q->where('name', 'like', "%{$this->search}%") - ->orWhere('email', 'like', "%{$this->search}%"); - }); - }) - ->when($this->tierFilter, function ($query) { - if ($this->tierFilter === 'free') { - $query->where(function ($q) { - $q->where('tier', 'free')->orWhereNull('tier'); - }); - } else { - $query->where('tier', $this->tierFilter); - } - }) - ->when($this->verifiedFilter !== '', function ($query) { - if ($this->verifiedFilter === '1') { - $query->whereNotNull('email_verified_at'); - } else { - $query->whereNull('email_verified_at'); - } - }) - ->orderBy($this->sortField, $this->sortDirection) - ->paginate(20); - - return view('hub::admin.platform', [ - 'users' => $users, - 'stats' => $this->getPlatformStats(), - 'systemInfo' => $this->getSystemInfo(), - 'tiers' => UserTier::cases(), - ])->layout('hub::admin.layouts.app', ['title' => 'Platform Admin']); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/PlatformUser.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/PlatformUser.php deleted file mode 100644 index 94587f0..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/PlatformUser.php +++ /dev/null @@ -1,697 +0,0 @@ -user()?->isHades()) { - abort(403, 'Hades tier required for platform administration.'); - } - - $this->user = User::findOrFail($id); - $this->editingTier = $this->user->tier?->value ?? 'free'; - $this->editingVerified = $this->user->email_verified_at !== null; - } - - public function setTab(string $tab): void - { - if (in_array($tab, ['overview', 'workspaces', 'entitlements', 'data', 'danger'])) { - $this->activeTab = $tab; - } - } - - public function saveTier(): void - { - $this->user->tier = UserTier::from($this->editingTier); - $this->user->save(); - - $this->actionMessage = "Tier updated to {$this->editingTier}."; - $this->actionType = 'success'; - } - - public function saveVerification(): void - { - if ($this->editingVerified && ! $this->user->email_verified_at) { - $this->user->email_verified_at = now(); - } elseif (! $this->editingVerified) { - $this->user->email_verified_at = null; - } - - $this->user->save(); - - $this->actionMessage = $this->editingVerified - ? 'Email marked as verified.' - : 'Email verification removed.'; - $this->actionType = 'success'; - } - - public function resendVerification(): void - { - if ($this->user->email_verified_at) { - $this->actionMessage = 'User email is already verified.'; - $this->actionType = 'warning'; - - return; - } - - $this->user->sendEmailVerificationNotification(); - - $this->actionMessage = 'Verification email sent.'; - $this->actionType = 'success'; - } - - /** - * Export all user data as JSON (GDPR Article 20 - Right to data portability). - */ - public function exportUserData() - { - $data = $this->collectUserData(); - - $filename = "user-data-{$this->user->id}-".now()->format('Y-m-d-His').'.json'; - - Log::info('GDPR data export performed by admin', [ - 'admin_id' => auth()->id(), - 'target_user_id' => $this->user->id, - 'target_email' => $this->user->email, - ]); - - return response()->streamDownload(function () use ($data) { - echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - }, $filename, [ - 'Content-Type' => 'application/json', - ]); - } - - /** - * Collect all user data for export or display. - */ - public function collectUserData(): array - { - $this->user->load([ - 'hostWorkspaces', - ]); - - return [ - 'export_info' => [ - 'exported_at' => now()->toIso8601String(), - 'exported_by' => 'Platform Administrator', - 'reason' => 'GDPR Article 15 - Right of access / Article 20 - Right to data portability', - ], - 'account' => [ - 'id' => $this->user->id, - 'name' => $this->user->name, - 'email' => $this->user->email, - 'tier' => $this->user->tier?->value ?? 'free', - 'tier_expires_at' => $this->user->tier_expires_at?->toIso8601String(), - 'email_verified_at' => $this->user->email_verified_at?->toIso8601String(), - 'created_at' => $this->user->created_at?->toIso8601String(), - 'updated_at' => $this->user->updated_at?->toIso8601String(), - ], - 'workspaces' => $this->user->hostWorkspaces->map(fn ($ws) => [ - 'id' => $ws->id, - 'name' => $ws->name, - 'slug' => $ws->slug, - 'role' => $ws->pivot->role ?? null, - 'is_default' => $ws->pivot->is_default ?? false, - 'joined_at' => $ws->pivot->created_at?->toIso8601String(), - ])->toArray(), - 'cached_stats' => $this->user->cached_stats, - 'deletion_requests' => AccountDeletionRequest::where('user_id', $this->user->id) - ->get() - ->map(fn ($req) => [ - 'id' => $req->id, - 'reason' => $req->reason, - 'status' => $this->getDeletionStatus($req), - 'created_at' => $req->created_at?->toIso8601String(), - 'expires_at' => $req->expires_at?->toIso8601String(), - 'confirmed_at' => $req->confirmed_at?->toIso8601String(), - 'completed_at' => $req->completed_at?->toIso8601String(), - 'cancelled_at' => $req->cancelled_at?->toIso8601String(), - ])->toArray(), - ]; - } - - protected function getDeletionStatus(AccountDeletionRequest $req): string - { - if ($req->completed_at) { - return 'completed'; - } - if ($req->cancelled_at) { - return 'cancelled'; - } - if ($req->expires_at->isPast()) { - return 'expired_pending'; - } - - return 'pending'; - } - - /** - * Get pending deletion request for user. - */ - public function getPendingDeletionProperty(): ?AccountDeletionRequest - { - return AccountDeletionRequest::where('user_id', $this->user->id) - ->whereNull('completed_at') - ->whereNull('cancelled_at') - ->first(); - } - - /** - * Show delete confirmation dialog. - */ - public function confirmDelete(bool $immediate = false): void - { - $this->immediateDelete = $immediate; - $this->showDeleteConfirm = true; - $this->deleteReason = ''; - } - - /** - * Cancel delete confirmation. - */ - public function cancelDelete(): void - { - $this->showDeleteConfirm = false; - $this->immediateDelete = false; - $this->deleteReason = ''; - } - - /** - * Schedule account deletion (GDPR Article 17 - Right to erasure). - */ - public function scheduleDelete(): void - { - if ($this->user->isHades() && $this->user->id === auth()->id()) { - $this->actionMessage = 'You cannot delete your own Hades account from here.'; - $this->actionType = 'error'; - $this->showDeleteConfirm = false; - - return; - } - - $request = AccountDeletionRequest::createForUser($this->user, $this->deleteReason ?: 'Admin initiated - GDPR request'); - - Log::warning('GDPR deletion scheduled by admin', [ - 'admin_id' => auth()->id(), - 'target_user_id' => $this->user->id, - 'target_email' => $this->user->email, - 'immediate' => $this->immediateDelete, - 'reason' => $this->deleteReason, - ]); - - if ($this->immediateDelete) { - $this->executeImmediateDelete($request); - } else { - $this->actionMessage = 'Account deletion scheduled. Will be deleted in 7 days unless cancelled.'; - $this->actionType = 'warning'; - } - - $this->showDeleteConfirm = false; - } - - /** - * Execute immediate deletion. - */ - protected function executeImmediateDelete(AccountDeletionRequest $request): void - { - try { - $email = $this->user->email; - - DB::transaction(function () use ($request) { - $request->confirm(); - $request->complete(); - - // Delete all workspaces owned by the user - if (method_exists($this->user, 'hostWorkspaces')) { - $this->user->hostWorkspaces()->detach(); - } - - // Hard delete user account - $this->user->forceDelete(); - }); - - Log::warning('GDPR immediate deletion executed by admin', [ - 'admin_id' => auth()->id(), - 'deleted_user_email' => $email, - ]); - - session()->flash('platform_message', "User {$email} has been permanently deleted."); - session()->flash('platform_message_type', 'success'); - - $this->redirect(route('hub.platform'), navigate: true); - } catch (\Exception $e) { - Log::error('Failed to execute immediate deletion', [ - 'user_id' => $this->user->id, - 'error' => $e->getMessage(), - ]); - - $this->actionMessage = 'Failed to delete account: '.$e->getMessage(); - $this->actionType = 'error'; - } - } - - /** - * Cancel pending deletion request. - */ - public function cancelPendingDeletion(): void - { - $pending = $this->pendingDeletion; - - if (! $pending) { - $this->actionMessage = 'No pending deletion request found.'; - $this->actionType = 'warning'; - - return; - } - - $pending->cancel(); - - Log::info('GDPR deletion cancelled by admin', [ - 'admin_id' => auth()->id(), - 'target_user_id' => $this->user->id, - 'deletion_request_id' => $pending->id, - ]); - - $this->actionMessage = 'Deletion request cancelled.'; - $this->actionType = 'success'; - } - - /** - * Anonymize user data (alternative to deletion - GDPR compliant). - */ - public function anonymizeUser(): void - { - if ($this->user->isHades() && $this->user->id === auth()->id()) { - $this->actionMessage = 'You cannot anonymize your own account.'; - $this->actionType = 'error'; - - return; - } - - $originalEmail = $this->user->email; - $anonymizedId = 'anon_'.$this->user->id.'_'.now()->timestamp; - - DB::transaction(function () use ($anonymizedId) { - $this->user->update([ - 'name' => 'Anonymized User', - 'email' => $anonymizedId.'@anonymized.local', - 'password' => bcrypt(str()->random(64)), - 'tier' => UserTier::FREE, - 'email_verified_at' => null, - 'cached_stats' => null, - ]); - - // Remove from all workspaces - if (method_exists($this->user, 'hostWorkspaces')) { - $this->user->hostWorkspaces()->detach(); - } - - // Cancel any pending deletions - AccountDeletionRequest::where('user_id', $this->user->id) - ->whereNull('completed_at') - ->whereNull('cancelled_at') - ->update(['cancelled_at' => now()]); - }); - - Log::warning('User anonymized by admin (GDPR)', [ - 'admin_id' => auth()->id(), - 'target_user_id' => $this->user->id, - 'original_email' => $originalEmail, - ]); - - $this->user->refresh(); - $this->editingTier = $this->user->tier?->value ?? 'free'; - $this->editingVerified = false; - - $this->actionMessage = 'User data has been anonymized.'; - $this->actionType = 'success'; - } - - /** - * Get all related data counts for display. - */ - public function getDataCountsProperty(): array - { - return [ - 'workspaces' => $this->user->hostWorkspaces()->count(), - 'deletion_requests' => AccountDeletionRequest::where('user_id', $this->user->id)->count(), - ]; - } - - // ───────────────────────────────────────────────────────────── - // Workspace & Entitlement Management - // ───────────────────────────────────────────────────────────── - - /** - * Get user's workspaces with their packages. - */ - #[Computed] - public function workspaces() - { - return $this->user->hostWorkspaces() - ->with(['workspacePackages' => function ($query) { - $query->active()->with('package'); - }]) - ->get(); - } - - /** - * Get all available packages for provisioning. - */ - #[Computed] - public function availablePackages() - { - return Package::active()->ordered()->get(); - } - - /** - * Open the package provisioning modal. - */ - public function openPackageModal(int $workspaceId): void - { - $this->selectedWorkspaceId = $workspaceId; - $this->selectedPackageCode = ''; - $this->showPackageModal = true; - } - - /** - * Close the package provisioning modal. - */ - public function closePackageModal(): void - { - $this->showPackageModal = false; - $this->selectedWorkspaceId = null; - $this->selectedPackageCode = ''; - } - - /** - * Provision a package to the selected workspace. - */ - public function provisionPackage(): void - { - if (! $this->selectedWorkspaceId || ! $this->selectedPackageCode) { - $this->actionMessage = 'Please select a workspace and package.'; - $this->actionType = 'warning'; - - return; - } - - $workspace = Workspace::findOrFail($this->selectedWorkspaceId); - $package = Package::where('code', $this->selectedPackageCode)->firstOrFail(); - - $entitlements = app(EntitlementService::class); - $entitlements->provisionPackage($workspace, $this->selectedPackageCode, [ - 'source' => 'admin', - ]); - - Log::info('Package provisioned by admin', [ - 'admin_id' => auth()->id(), - 'user_id' => $this->user->id, - 'workspace_id' => $workspace->id, - 'package_code' => $this->selectedPackageCode, - ]); - - $this->actionMessage = "Package '{$package->name}' provisioned to workspace '{$workspace->name}'."; - $this->actionType = 'success'; - - $this->closePackageModal(); - unset($this->workspaces); // Clear computed cache - } - - /** - * Revoke a package from a workspace. - */ - public function revokePackage(int $workspaceId, string $packageCode): void - { - $workspace = Workspace::findOrFail($workspaceId); - - // Verify this belongs to one of the user's workspaces - if (! $this->user->hostWorkspaces->contains($workspace)) { - $this->actionMessage = 'This workspace does not belong to this user.'; - $this->actionType = 'error'; - - return; - } - - $package = Package::where('code', $packageCode)->first(); - $packageName = $package?->name ?? $packageCode; - $workspaceName = $workspace->name; - - $entitlements = app(EntitlementService::class); - $entitlements->revokePackage($workspace, $packageCode, 'admin'); - - Log::info('Package revoked by admin', [ - 'admin_id' => auth()->id(), - 'user_id' => $this->user->id, - 'workspace_id' => $workspace->id, - 'package_code' => $packageCode, - ]); - - $this->actionMessage = "Package '{$packageName}' revoked from workspace '{$workspaceName}'."; - $this->actionType = 'success'; - - unset($this->workspaces); // Clear computed cache - } - - // ───────────────────────────────────────────────────────────── - // Entitlement Management - // ───────────────────────────────────────────────────────────── - - /** - * Get all available features for autocomplete. - */ - #[Computed] - public function allFeatures() - { - return Feature::active() - ->orderBy('category') - ->orderBy('sort_order') - ->get(); - } - - /** - * Get resolved entitlements for each workspace. - */ - #[Computed] - public function workspaceEntitlements(): array - { - $entitlements = app(EntitlementService::class); - $result = []; - - foreach ($this->workspaces as $workspace) { - $summary = $entitlements->getUsageSummary($workspace); - $boosts = $entitlements->getActiveBoosts($workspace); - - $result[$workspace->id] = [ - 'workspace' => $workspace, - 'summary' => $summary, - 'boosts' => $boosts, - 'stats' => [ - 'total' => $summary->flatten(1)->count(), - 'allowed' => $summary->flatten(1)->where('allowed', true)->count(), - 'denied' => $summary->flatten(1)->where('allowed', false)->count(), - 'boosts' => $boosts->count(), - ], - ]; - } - - return $result; - } - - /** - * Open the entitlement provisioning modal. - */ - public function openEntitlementModal(int $workspaceId): void - { - $this->entitlementWorkspaceId = $workspaceId; - $this->entitlementFeatureCode = ''; - $this->entitlementType = 'enable'; - $this->entitlementLimit = null; - $this->entitlementDuration = 'permanent'; - $this->entitlementExpiresAt = null; - $this->showEntitlementModal = true; - } - - /** - * Close the entitlement provisioning modal. - */ - public function closeEntitlementModal(): void - { - $this->showEntitlementModal = false; - $this->entitlementWorkspaceId = null; - $this->entitlementFeatureCode = ''; - } - - /** - * Provision an entitlement (boost) to the selected workspace. - */ - public function provisionEntitlement(): void - { - if (! $this->entitlementWorkspaceId || ! $this->entitlementFeatureCode) { - $this->actionMessage = 'Please select a workspace and feature.'; - $this->actionType = 'warning'; - - return; - } - - $workspace = Workspace::findOrFail($this->entitlementWorkspaceId); - $feature = Feature::where('code', $this->entitlementFeatureCode)->first(); - - if (! $feature) { - $this->actionMessage = 'Feature not found.'; - $this->actionType = 'error'; - - return; - } - - // Verify this belongs to one of the user's workspaces - if (! $this->user->hostWorkspaces->contains($workspace)) { - $this->actionMessage = 'This workspace does not belong to this user.'; - $this->actionType = 'error'; - - return; - } - - $options = [ - 'source' => 'admin', - 'boost_type' => match ($this->entitlementType) { - 'enable' => Boost::BOOST_TYPE_ENABLE, - 'add_limit' => Boost::BOOST_TYPE_ADD_LIMIT, - 'unlimited' => Boost::BOOST_TYPE_UNLIMITED, - default => Boost::BOOST_TYPE_ENABLE, - }, - 'duration_type' => $this->entitlementDuration === 'permanent' - ? Boost::DURATION_PERMANENT - : Boost::DURATION_DURATION, - ]; - - if ($this->entitlementType === 'add_limit' && $this->entitlementLimit) { - $options['limit_value'] = $this->entitlementLimit; - } - - if ($this->entitlementDuration === 'duration' && $this->entitlementExpiresAt) { - $options['expires_at'] = $this->entitlementExpiresAt; - } - - $entitlements = app(EntitlementService::class); - $entitlements->provisionBoost($workspace, $this->entitlementFeatureCode, $options); - - Log::info('Entitlement provisioned by admin', [ - 'admin_id' => auth()->id(), - 'user_id' => $this->user->id, - 'workspace_id' => $workspace->id, - 'feature_code' => $this->entitlementFeatureCode, - 'type' => $this->entitlementType, - ]); - - $this->actionMessage = "Entitlement '{$feature->name}' added to workspace '{$workspace->name}'."; - $this->actionType = 'success'; - - $this->closeEntitlementModal(); - unset($this->workspaceEntitlements); - } - - /** - * Remove a boost from a workspace. - */ - public function removeBoost(int $boostId): void - { - $boost = Boost::findOrFail($boostId); - - // Verify this belongs to one of the user's workspaces - $workspace = $boost->workspace; - if (! $this->user->hostWorkspaces->contains($workspace)) { - $this->actionMessage = 'This boost does not belong to this user.'; - $this->actionType = 'error'; - - return; - } - - $featureCode = $boost->feature_code; - $workspaceName = $workspace->name; - - $boost->update(['status' => Boost::STATUS_CANCELLED]); - - Log::info('Boost removed by admin', [ - 'admin_id' => auth()->id(), - 'user_id' => $this->user->id, - 'workspace_id' => $workspace->id, - 'boost_id' => $boostId, - 'feature_code' => $featureCode, - ]); - - $this->actionMessage = "Boost for '{$featureCode}' removed from workspace '{$workspaceName}'."; - $this->actionType = 'success'; - - unset($this->workspaceEntitlements); - } - - public function render() - { - return view('hub::admin.platform-user', [ - 'tiers' => UserTier::cases(), - 'userData' => $this->collectUserData(), - 'dataCounts' => $this->dataCounts, - 'pendingDeletion' => $this->pendingDeletion, - ])->layout('hub::admin.layouts.app', ['title' => 'User: '.$this->user->name]); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Profile.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/Profile.php deleted file mode 100644 index eba7aa4..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Profile.php +++ /dev/null @@ -1,128 +0,0 @@ -userName = $user->name ?? 'User'; - $this->userEmail = $user->email ?? ''; - $this->userInitials = collect(explode(' ', $this->userName)) - ->map(fn ($n) => strtoupper(substr($n, 0, 1))) - ->take(2) - ->join(''); - - // Get tier info - $tier = $appUser?->getTier() ?? UserTier::FREE; - $this->userTier = $tier->label(); - $this->tierColor = match ($tier) { - UserTier::HADES => 'from-red-500 to-orange-500', - UserTier::APOLLO => 'from-violet-500 to-purple-500', - default => 'from-gray-500 to-gray-600', - }; - - $this->memberSince = $user->created_at?->format('F Y'); - - // Use cached stats if available, otherwise defaults - // Stats are computed by background job, not on page load - $cached = $appUser?->cached_stats; - - $this->quotas = $cached['quotas'] ?? $this->getDefaultQuotas($tier); - $this->serviceStats = $cached['services'] ?? $this->getDefaultServiceStats(); - $this->recentActivity = $cached['activity'] ?? []; - } - - protected function getDefaultQuotas(UserTier $tier): array - { - return match ($tier) { - UserTier::HADES => [ - 'workspaces' => ['used' => 0, 'limit' => null, 'label' => 'Workspaces'], - 'social_accounts' => ['used' => 0, 'limit' => null, 'label' => 'Social Accounts'], - 'scheduled_posts' => ['used' => 0, 'limit' => null, 'label' => 'Scheduled Posts'], - 'storage' => ['used' => 0, 'limit' => null, 'label' => 'Storage (GB)'], - ], - UserTier::APOLLO => [ - 'workspaces' => ['used' => 0, 'limit' => 5, 'label' => 'Workspaces'], - 'social_accounts' => ['used' => 0, 'limit' => 25, 'label' => 'Social Accounts'], - 'scheduled_posts' => ['used' => 0, 'limit' => 500, 'label' => 'Scheduled Posts'], - 'storage' => ['used' => 0, 'limit' => 10, 'label' => 'Storage (GB)'], - ], - default => [ - 'workspaces' => ['used' => 0, 'limit' => 1, 'label' => 'Workspaces'], - 'social_accounts' => ['used' => 0, 'limit' => 5, 'label' => 'Social Accounts'], - 'scheduled_posts' => ['used' => 0, 'limit' => 50, 'label' => 'Scheduled Posts'], - 'storage' => ['used' => 0, 'limit' => 1, 'label' => 'Storage (GB)'], - ], - }; - } - - protected function getDefaultServiceStats(): array - { - return [ - [ - 'name' => 'Social', - 'icon' => 'fa-share-nodes', - 'color' => 'bg-blue-500', - 'status' => 'inactive', - 'stat' => 'Not configured', - ], - [ - 'name' => 'Bio', - 'icon' => 'fa-id-card', - 'color' => 'bg-violet-500', - 'status' => 'inactive', - 'stat' => 'Not configured', - ], - [ - 'name' => 'Analytics', - 'icon' => 'fa-chart-line', - 'color' => 'bg-green-500', - 'status' => 'inactive', - 'stat' => 'Not configured', - ], - [ - 'name' => 'Trust', - 'icon' => 'fa-shield-check', - 'color' => 'bg-amber-500', - 'status' => 'inactive', - 'stat' => 'Not configured', - ], - ]; - } - - public function render() - { - return view('hub::admin.profile') - ->layout('hub::admin.layouts.app', ['title' => 'Profile']); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/PromptManager.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/PromptManager.php deleted file mode 100644 index 714c18c..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/PromptManager.php +++ /dev/null @@ -1,335 +0,0 @@ -modelConfig = [ - 'temperature' => 1.0, - 'max_tokens' => 4096, - ]; - } - - #[Computed] - public function prompts() - { - return Prompt::query() - ->when($this->search, fn ($q) => $q->where('name', 'like', "%{$this->search}%") - ->orWhere('description', 'like', "%{$this->search}%")) - ->when($this->category, fn ($q) => $q->where('category', $this->category)) - ->when($this->model, fn ($q) => $q->where('model', $this->model)) - ->orderBy('category') - ->orderBy('name') - ->paginate(20); - } - - #[Computed] - public function categories(): array - { - return Prompt::distinct()->pluck('category')->toArray(); - } - - #[Computed] - public function models(): array - { - return ['claude', 'gemini']; - } - - #[Computed] - public function categoryOptions(): array - { - return collect($this->categories) - ->mapWithKeys(fn ($cat) => [$cat => ucfirst($cat)]) - ->all(); - } - - #[Computed] - public function modelOptions(): array - { - return [ - 'claude' => 'Claude', - 'gemini' => 'Gemini', - ]; - } - - #[Computed] - public function tableColumns(): array - { - return [ - 'Name', - 'Category', - 'Model', - ['label' => 'Status', 'align' => 'center'], - 'Updated', - ['label' => 'Actions', 'align' => 'center'], - ]; - } - - #[Computed] - public function tableRows(): array - { - $modelColors = [ - 'claude' => 'orange', - 'gemini' => 'blue', - ]; - - return $this->prompts->map(function ($p) use ($modelColors) { - $actions = [ - ['icon' => 'pencil', 'click' => "edit({$p->id})", 'title' => 'Edit'], - ['icon' => 'document-duplicate', 'click' => "duplicate({$p->id})", 'title' => 'Duplicate'], - ['icon' => $p->is_active ? 'pause' : 'play', 'click' => "toggleActive({$p->id})", 'title' => $p->is_active ? 'Deactivate' : 'Activate'], - ['icon' => 'trash', 'click' => "delete({$p->id})", 'confirm' => 'Are you sure you want to delete this prompt?', 'title' => 'Delete', 'class' => 'text-red-600'], - ]; - - return [ - [ - 'lines' => array_filter([ - ['bold' => $p->name], - $p->description ? ['muted' => \Illuminate\Support\Str::limit($p->description, 60)] : null, - ]), - ], - ['badge' => ucfirst($p->category), 'color' => 'violet'], - ['badge' => ucfirst($p->model), 'color' => $modelColors[$p->model] ?? 'gray'], - ['badge' => $p->is_active ? 'Active' : 'Inactive', 'color' => $p->is_active ? 'green' : 'gray'], - ['muted' => $p->updated_at->diffForHumans()], - ['actions' => $actions], - ]; - })->all(); - } - - #[Computed] - public function editingPrompt(): ?Prompt - { - return $this->editingPromptId - ? Prompt::find($this->editingPromptId) - : null; - } - - #[Computed] - public function promptVersions() - { - if (! $this->editingPromptId) { - return collect(); - } - - return PromptVersion::where('prompt_id', $this->editingPromptId) - ->with('creator') - ->orderByDesc('version') - ->limit(20) - ->get(); - } - - public function create(): void - { - $this->resetForm(); - $this->editingPromptId = null; - $this->showEditor = true; - } - - public function edit(int $id): void - { - $prompt = Prompt::findOrFail($id); - - $this->editingPromptId = $id; - $this->name = $prompt->name; - $this->promptCategory = $prompt->category; - $this->description = $prompt->description ?? ''; - $this->systemPrompt = $prompt->system_prompt; - $this->userTemplate = $prompt->user_template; - $this->variables = $prompt->variables ?? []; - $this->promptModel = $prompt->model; - $this->modelConfig = $prompt->model_config ?? ['temperature' => 1.0, 'max_tokens' => 4096]; - $this->isActive = $prompt->is_active; - - $this->showEditor = true; - } - - public function save(): void - { - $validated = $this->validate([ - 'name' => 'required|string|max:255', - 'promptCategory' => 'required|string|max:50', - 'description' => 'nullable|string', - 'systemPrompt' => 'required|string', - 'userTemplate' => 'required|string', - 'variables' => 'array', - 'promptModel' => 'required|in:claude,gemini', - 'modelConfig' => 'array', - 'isActive' => 'boolean', - ]); - - $data = [ - 'name' => $this->name, - 'category' => $this->promptCategory, - 'description' => $this->description ?: null, - 'system_prompt' => $this->systemPrompt, - 'user_template' => $this->userTemplate, - 'variables' => $this->variables ?: null, - 'model' => $this->promptModel, - 'model_config' => $this->modelConfig ?: null, - 'is_active' => $this->isActive, - ]; - - if ($this->editingPromptId) { - $prompt = Prompt::findOrFail($this->editingPromptId); - - // Create version before updating - $prompt->createVersion(Auth::id()); - - $prompt->update($data); - - Flux::toast('Prompt updated successfully'); - } else { - Prompt::create($data); - - Flux::toast('Prompt created successfully'); - } - - $this->showEditor = false; - $this->resetForm(); - } - - public function delete(int $id): void - { - $prompt = Prompt::findOrFail($id); - $prompt->delete(); - - Flux::toast('Prompt deleted'); - } - - public function duplicate(int $id): void - { - $original = Prompt::findOrFail($id); - - $copy = $original->replicate(); - $copy->name = $original->name.' (copy)'; - $copy->save(); - - Flux::toast('Prompt duplicated'); - } - - public function toggleActive(int $id): void - { - $prompt = Prompt::findOrFail($id); - $prompt->update(['is_active' => ! $prompt->is_active]); - - Flux::toast($prompt->is_active ? 'Prompt activated' : 'Prompt deactivated'); - } - - public function restoreVersion(int $versionId): void - { - $version = PromptVersion::findOrFail($versionId); - $version->restore(); - - // Reload the form with restored data - $this->edit($version->prompt_id); - - Flux::toast("Restored to version {$version->version}"); - } - - public function addVariable(): void - { - $this->variables[] = [ - 'name' => '', - 'description' => '', - 'required' => true, - 'default' => '', - ]; - } - - public function removeVariable(int $index): void - { - unset($this->variables[$index]); - $this->variables = array_values($this->variables); - } - - public function closeEditor(): void - { - $this->showEditor = false; - $this->resetForm(); - } - - private function resetForm(): void - { - $this->name = ''; - $this->promptCategory = 'content'; - $this->description = ''; - $this->systemPrompt = ''; - $this->userTemplate = ''; - $this->variables = []; - $this->promptModel = 'claude'; - $this->modelConfig = ['temperature' => 1.0, 'max_tokens' => 4096]; - $this->isActive = true; - $this->editingPromptId = null; - $this->testOutput = ''; - } - - public function render(): View - { - return view('hub::admin.prompt-manager'); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/ServiceManager.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/ServiceManager.php deleted file mode 100644 index 08e24e4..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/ServiceManager.php +++ /dev/null @@ -1,244 +0,0 @@ -user()?->isHades()) { - abort(403, 'Hades tier required for service management.'); - } - } - - protected function rules(): array - { - return [ - 'name' => ['required', 'string', 'max:100'], - 'tagline' => ['nullable', 'string', 'max:200'], - 'description' => ['nullable', 'string', 'max:2000'], - 'icon' => ['nullable', 'string', 'max:50'], - 'color' => ['nullable', 'string', 'max:20'], - 'marketing_domain' => ['nullable', 'string', 'max:100'], - 'marketing_url' => ['nullable', 'url', 'max:255'], - 'docs_url' => ['nullable', 'url', 'max:255'], - 'is_enabled' => ['boolean'], - 'is_public' => ['boolean'], - 'is_featured' => ['boolean'], - 'sort_order' => ['integer', 'min:0', 'max:999'], - ]; - } - - public function openEdit(int $id): void - { - $service = Service::findOrFail($id); - - $this->editingId = $id; - - // Read-only fields - $this->code = $service->code; - $this->module = $service->module; - $this->entitlement_code = $service->entitlement_code ?? ''; - - // Editable fields - $this->name = $service->name; - $this->tagline = $service->tagline ?? ''; - $this->description = $service->description ?? ''; - $this->icon = $service->icon ?? ''; - $this->color = $service->color ?? ''; - $this->marketing_domain = $service->marketing_domain ?? ''; - $this->marketing_url = $service->getRawOriginal('marketing_url') ?? ''; - $this->docs_url = $service->docs_url ?? ''; - $this->is_enabled = $service->is_enabled; - $this->is_public = $service->is_public; - $this->is_featured = $service->is_featured; - $this->sort_order = $service->sort_order; - - $this->showModal = true; - } - - public function save(): void - { - $this->validate(); - - $service = Service::findOrFail($this->editingId); - - $service->update([ - 'name' => $this->name, - 'tagline' => $this->tagline ?: null, - 'description' => $this->description ?: null, - 'icon' => $this->icon ?: null, - 'color' => $this->color ?: null, - 'marketing_domain' => $this->marketing_domain ?: null, - 'marketing_url' => $this->marketing_url ?: null, - 'docs_url' => $this->docs_url ?: null, - 'is_enabled' => $this->is_enabled, - 'is_public' => $this->is_public, - 'is_featured' => $this->is_featured, - 'sort_order' => $this->sort_order, - ]); - - session()->flash('message', 'Service updated successfully.'); - $this->closeModal(); - } - - public function toggleEnabled(int $id): void - { - $service = Service::findOrFail($id); - $service->update(['is_enabled' => ! $service->is_enabled]); - - $status = $service->is_enabled ? 'enabled' : 'disabled'; - session()->flash('message', "{$service->name} has been {$status}."); - } - - public function syncFromModules(): void - { - $seeder = new ServiceSeeder; - $seeder->run(); - - session()->flash('message', 'Services synced from modules successfully.'); - } - - public function closeModal(): void - { - $this->showModal = false; - $this->resetForm(); - } - - protected function resetForm(): void - { - $this->editingId = null; - $this->code = ''; - $this->module = ''; - $this->entitlement_code = ''; - $this->name = ''; - $this->tagline = ''; - $this->description = ''; - $this->icon = ''; - $this->color = ''; - $this->marketing_domain = ''; - $this->marketing_url = ''; - $this->docs_url = ''; - $this->is_enabled = true; - $this->is_public = true; - $this->is_featured = false; - $this->sort_order = 50; - } - - #[Computed] - public function services() - { - return Service::ordered()->get(); - } - - #[Computed] - public function tableColumns(): array - { - return [ - 'Service', - 'Code', - 'Domain', - ['label' => 'Entitlement', 'align' => 'center'], - ['label' => 'Status', 'align' => 'center'], - ['label' => 'Actions', 'align' => 'center'], - ]; - } - - #[Computed] - public function tableRows(): array - { - return $this->services->map(function ($s) { - // Service name with icon and tagline - $serviceLines = [['bold' => $s->name]]; - if ($s->tagline) { - $serviceLines[] = ['muted' => \Illuminate\Support\Str::limit($s->tagline, 40)]; - } - - // Status badges - $statusLines = []; - $statusLines[] = ['badge' => $s->is_enabled ? 'Enabled' : 'Disabled', 'color' => $s->is_enabled ? 'green' : 'red']; - if ($s->is_public) { - $statusLines[] = ['badge' => 'Public', 'color' => 'sky']; - } - if ($s->is_featured) { - $statusLines[] = ['badge' => 'Featured', 'color' => 'amber']; - } - - return [ - [ - 'icon' => $s->icon, - 'iconColor' => $s->color, - 'lines' => $serviceLines, - ], - ['mono' => $s->code], - $s->marketing_domain - ? ['link' => 'Open in Tab', 'href' => 'http://'.$s->marketing_domain, 'target' => '_blank'] - : ['muted' => 'Not set'], - $s->entitlement_code ? ['mono' => $s->entitlement_code] : ['muted' => '-'], - ['lines' => $statusLines], - [ - 'actions' => [ - ['icon' => $s->is_enabled ? 'toggle-on' : 'toggle-off', 'click' => "toggleEnabled({$s->id})", 'title' => $s->is_enabled ? 'Disable' : 'Enable', 'class' => $s->is_enabled ? 'text-green-600' : 'text-gray-400'], - ['icon' => 'pencil', 'click' => "openEdit({$s->id})", 'title' => 'Edit'], - ], - ], - ]; - })->all(); - } - - public function render() - { - return view('hub::admin.service-manager') - ->layout('hub::admin.layouts.app', ['title' => 'Services']); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/ServicesAdmin.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/ServicesAdmin.php deleted file mode 100644 index 244457e..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/ServicesAdmin.php +++ /dev/null @@ -1,1973 +0,0 @@ -workspaceService = $workspaceService; - } - - public function mount(?string $service = null, ?string $tab = null): void - { - if ($service && in_array($service, $this->availableServices())) { - $this->service = $service; - } - - if ($tab) { - $this->tab = $tab; - } - - if ($this->service === 'analytics') { - // Load analytics settings if mounted directly on settings tab - if ($this->tab === 'settings') { - $this->loadAnalyticsSettings(); - } - - // Set selected channel for channels tab - if ($this->tab === 'channels') { - $this->selectedWebsiteId = $this->analyticsChannels->first()?->id; - } - } - } - - /** - * Get the current workspace from the workspace switcher. - */ - #[Computed] - public function workspace(): ?Workspace - { - return $this->workspaceService->currentModel(); - } - - #[On('workspace-changed')] - public function refreshWorkspace(): void - { - unset($this->workspace); - unset($this->services); - unset($this->bioStats, $this->bioStatCards, $this->bioPages, $this->bioProjects); - unset($this->socialStats, $this->socialStatCards, $this->socialAccounts, $this->socialPosts); - unset($this->analyticsStats, $this->analyticsStatCards, $this->analyticsWebsites); - unset($this->notifyStats, $this->notifyStatCards, $this->notifyWebsites); - unset($this->trustStats, $this->trustStatCards, $this->trustCampaigns); - unset($this->supportStats); - } - - /** - * Get all service items from the registry. - * This is the single source of truth - services are defined in each module's Boot.php. - */ - #[Computed] - public function services(): array - { - $registry = app(AdminMenuRegistry::class); - - return $registry->getAllServiceItems( - $this->workspace, - auth()->user()?->isHades() ?? false - ); - } - - /** - * Get the current service's menu item. - */ - #[Computed] - public function currentServiceItem(): ?array - { - return $this->services[$this->service] ?? null; - } - - /** - * Get the current service's marketing URL from the database. - */ - #[Computed] - public function serviceMarketingUrl(): ?string - { - $service = Service::where('code', $this->service)->first(); - - return $service?->marketing_url; - } - - /** - * Get children (tabs) for the current service. - */ - #[Computed] - public function serviceTabs(): array - { - return $this->currentServiceItem['children'] ?? []; - } - - /** - * Get available service keys for validation. - */ - public function availableServices(): array - { - return array_keys($this->services); - } - - public function switchService(string $service): void - { - if (in_array($service, $this->availableServices())) { - $this->service = $service; - $this->tab = 'dashboard'; - } - } - - public function switchTab(string $tab): void - { - $this->tab = $tab; - - if ($this->service === 'analytics') { - // Load analytics settings when entering settings tab - if ($tab === 'settings') { - $this->loadAnalyticsSettings(); - } - - // Set selected channel for channels tab - if ($tab === 'channels') { - $this->selectedWebsiteId = $this->analyticsChannels->first()?->id; - } - } - } - - /** - * Load analytics settings from the primary website. - */ - public function loadAnalyticsSettings(): void - { - $website = $this->analyticsWebsites->first(); - - if ($website) { - $this->analyticsSettingsName = $website->name ?? ''; - $this->analyticsSettingsHost = $website->host ?? ''; - $this->analyticsSettingsTrackingType = $website->tracking_type ?? 'lightweight'; - $this->analyticsSettingsEnabled = (bool) $website->is_enabled; - $this->analyticsSettingsPublicStats = (bool) $website->public_stats_enabled; - $this->analyticsSettingsExcludedIps = $website->excluded_ips ?? ''; - } - } - - /** - * Save analytics settings for the primary website. - */ - public function saveAnalyticsSettings(): void - { - $website = $this->analyticsWebsites->first(); - - if (! $website) { - return; - } - - $website->update([ - 'name' => $this->analyticsSettingsName, - 'host' => $this->analyticsSettingsHost, - 'tracking_type' => $this->analyticsSettingsTrackingType, - 'is_enabled' => $this->analyticsSettingsEnabled, - 'public_stats_enabled' => $this->analyticsSettingsPublicStats, - 'excluded_ips' => $this->analyticsSettingsExcludedIps, - ]); - - // Clear computed cache - unset($this->analyticsWebsites); - - $this->dispatch('notify', message: 'Settings saved successfully'); - } - - /** - * Regenerate the analytics pixel key for the primary website. - */ - public function regenerateAnalyticsPixelKey(): void - { - $website = $this->analyticsWebsites->first(); - - if (! $website) { - return; - } - - $website->update([ - 'pixel_key' => \Illuminate\Support\Str::random(32), - ]); - - // Clear computed cache - unset($this->analyticsWebsites); - - $this->dispatch('notify', message: 'Pixel key regenerated. Update your website tracking code.'); - } - - /** - * Show page details within the services panel. - */ - public function showPageDetails(int $websiteId, string $path): void - { - $this->pageDetailsWebsiteId = $websiteId; - $this->pageDetailsPath = '/'.ltrim($path, '/'); - $this->tab = 'pages'; - } - - /** - * Close page details and return to pages list. - */ - public function closePageDetails(): void - { - $this->pageDetailsWebsiteId = null; - $this->pageDetailsPath = null; - } - - /** - * Select a website to view its dashboard. - */ - public function selectWebsite(int $websiteId): void - { - $this->selectedWebsiteId = $websiteId; - } - - /** - * Close website dashboard and return to list. - */ - public function closeWebsiteDashboard(): void - { - $this->selectedWebsiteId = null; - } - - /** - * Check if we're viewing a website dashboard. - */ - #[Computed] - public function isViewingWebsiteDashboard(): bool - { - return $this->selectedWebsiteId !== null; - } - - /** - * Get the selected website. - */ - #[Computed] - public function selectedWebsite(): ?AnalyticsWebsite - { - if (! $this->selectedWebsiteId) { - return null; - } - - return $this->analyticsWebsites->firstWhere('id', $this->selectedWebsiteId); - } - - /** - * Get chart data for the selected website. - */ - #[Computed] - public function selectedWebsiteChartData(): array - { - if (! $this->selectedWebsiteId) { - return []; - } - - $days = match ($this->analyticsDateRange) { - '7d' => 7, - '30d' => 30, - '90d' => 90, - 'all' => 365, - default => 30, - }; - - $startDate = now()->subDays($days - 1)->startOfDay(); - - $sessions = AnalyticsSession::where('website_id', $this->selectedWebsiteId) - ->where('started_at', '>=', $startDate) - ->selectRaw('DATE(started_at) as date, COUNT(DISTINCT visitor_id) as visitors, COUNT(*) as sessions') - ->groupBy('date') - ->orderBy('date') - ->get() - ->keyBy('date'); - - $data = []; - for ($i = 0; $i < $days; $i++) { - $date = $startDate->copy()->addDays($i); - $dateStr = $date->format('Y-m-d'); - $row = $sessions->get($dateStr); - $data[] = [ - 'date' => $date->format('M j'), - 'visitors' => $row?->visitors ?? 0, - 'sessions' => $row?->sessions ?? 0, - ]; - } - - return $data; - } - - /** - * Get top pages for the selected website. - */ - #[Computed] - public function selectedWebsiteTopPages(): array - { - if (! $this->selectedWebsiteId) { - return []; - } - - $days = match ($this->analyticsDateRange) { - '7d' => 7, - '30d' => 30, - '90d' => 90, - 'all' => 365, - default => 30, - }; - - $startDate = now()->subDays($days)->startOfDay(); - - return AnalyticsEvent::where('website_id', $this->selectedWebsiteId) - ->where('type', 'pageview') - ->where('created_at', '>=', $startDate) - ->selectRaw('path, COUNT(*) as views, COUNT(DISTINCT visitor_id) as visitors') - ->groupBy('path') - ->orderByDesc('views') - ->limit(10) - ->get() - ->toArray(); - } - - /** - * Get top referrers for the selected website. - */ - #[Computed] - public function selectedWebsiteReferrers(): array - { - if (! $this->selectedWebsiteId) { - return []; - } - - $days = match ($this->analyticsDateRange) { - '7d' => 7, - '30d' => 30, - '90d' => 90, - 'all' => 365, - default => 30, - }; - - $startDate = now()->subDays($days)->startOfDay(); - - return AnalyticsSession::where('website_id', $this->selectedWebsiteId) - ->whereNotNull('referrer_host') - ->where('referrer_host', '!=', '') - ->where('started_at', '>=', $startDate) - ->selectRaw('referrer_host, COUNT(*) as sessions') - ->groupBy('referrer_host') - ->orderByDesc('sessions') - ->limit(10) - ->get() - ->toArray(); - } - - /** - * Get device breakdown for the selected website. - */ - #[Computed] - public function selectedWebsiteDevices(): array - { - if (! $this->selectedWebsiteId) { - return []; - } - - $days = match ($this->analyticsDateRange) { - '7d' => 7, - '30d' => 30, - '90d' => 90, - 'all' => 365, - default => 30, - }; - - $startDate = now()->subDays($days)->startOfDay(); - - return AnalyticsVisitor::where('website_id', $this->selectedWebsiteId) - ->where('last_seen_at', '>=', $startDate) - ->selectRaw('device_type, COUNT(*) as count') - ->groupBy('device_type') - ->orderByDesc('count') - ->get() - ->pluck('count', 'device_type') - ->toArray(); - } - - /** - * Get browser breakdown for the selected website. - */ - #[Computed] - public function selectedWebsiteBrowsers(): array - { - if (! $this->selectedWebsiteId) { - return []; - } - - $days = match ($this->analyticsDateRange) { - '7d' => 7, - '30d' => 30, - '90d' => 90, - 'all' => 365, - default => 30, - }; - - $startDate = now()->subDays($days)->startOfDay(); - - return AnalyticsVisitor::where('website_id', $this->selectedWebsiteId) - ->where('last_seen_at', '>=', $startDate) - ->selectRaw('browser_name, COUNT(*) as count') - ->groupBy('browser_name') - ->orderByDesc('count') - ->limit(5) - ->get() - ->pluck('count', 'browser_name') - ->toArray(); - } - - /** - * Get country breakdown for the selected website. - */ - #[Computed] - public function selectedWebsiteCountries(): array - { - if (! $this->selectedWebsiteId) { - return []; - } - - $days = match ($this->analyticsDateRange) { - '7d' => 7, - '30d' => 30, - '90d' => 90, - 'all' => 365, - default => 30, - }; - - $startDate = now()->subDays($days)->startOfDay(); - - return AnalyticsVisitor::where('website_id', $this->selectedWebsiteId) - ->whereNotNull('country_code') - ->where('last_seen_at', '>=', $startDate) - ->selectRaw('country_code, COUNT(*) as count') - ->groupBy('country_code') - ->orderByDesc('count') - ->limit(10) - ->get() - ->pluck('count', 'country_code') - ->toArray(); - } - - /** - * Check if we're viewing page details. - */ - #[Computed] - public function isViewingPageDetails(): bool - { - return $this->pageDetailsWebsiteId !== null && $this->pageDetailsPath !== null; - } - - /** - * Get the website for page details. - */ - #[Computed] - public function pageDetailsWebsite(): ?AnalyticsWebsite - { - if (! $this->pageDetailsWebsiteId) { - return null; - } - - return AnalyticsWebsite::find($this->pageDetailsWebsiteId); - } - - /** - * Get stats for the page details view. - */ - #[Computed] - public function pageDetailsStats(): array - { - if (! $this->isViewingPageDetails) { - return []; - } - - $days = match ($this->analyticsDateRange) { - '7d' => 7, - '30d' => 30, - '90d' => 90, - default => 30, - }; - - $start = now()->subDays($days)->startOfDay(); - $end = now()->endOfDay(); - - $views = AnalyticsEvent::where('website_id', $this->pageDetailsWebsiteId) - ->where('type', 'pageview') - ->where('path', $this->pageDetailsPath) - ->whereBetween('created_at', [$start, $end]) - ->count(); - - $visitors = AnalyticsEvent::where('website_id', $this->pageDetailsWebsiteId) - ->where('type', 'pageview') - ->where('path', $this->pageDetailsPath) - ->whereBetween('created_at', [$start, $end]) - ->distinct('visitor_id') - ->count('visitor_id'); - - // Entry stats (sessions that started on this page) - $entries = AnalyticsSession::where('website_id', $this->pageDetailsWebsiteId) - ->where('landing_page', $this->pageDetailsPath) - ->whereBetween('started_at', [$start, $end]) - ->count(); - - $bounces = AnalyticsSession::where('website_id', $this->pageDetailsWebsiteId) - ->where('landing_page', $this->pageDetailsPath) - ->where('is_bounce', true) - ->whereBetween('started_at', [$start, $end]) - ->count(); - - $bounceRate = $entries > 0 ? round(($bounces / $entries) * 100, 1) : 0; - - // Exit stats - $exits = AnalyticsSession::where('website_id', $this->pageDetailsWebsiteId) - ->where('exit_page', $this->pageDetailsPath) - ->whereBetween('started_at', [$start, $end]) - ->count(); - - $exitRate = $views > 0 ? round(($exits / $views) * 100, 1) : 0; - - // Average time on page - $avgDuration = AnalyticsSession::where('website_id', $this->pageDetailsWebsiteId) - ->where('landing_page', $this->pageDetailsPath) - ->where('is_bounce', false) - ->whereBetween('started_at', [$start, $end]) - ->avg('duration') ?? 0; - - return [ - 'views' => $views, - 'visitors' => $visitors, - 'entries' => $entries, - 'bounce_rate' => $bounceRate, - 'exits' => $exits, - 'exit_rate' => $exitRate, - 'avg_duration' => (int) $avgDuration, - 'views_per_visitor' => $visitors > 0 ? round($views / $visitors, 1) : 0, - ]; - } - - /** - * Get chart data for page details. - */ - #[Computed] - public function pageDetailsChartData(): array - { - if (! $this->isViewingPageDetails) { - return []; - } - - $days = match ($this->analyticsDateRange) { - '7d' => 7, - '30d' => 30, - '90d' => 90, - default => 30, - }; - - $startDate = now()->subDays($days - 1)->startOfDay(); - - $events = AnalyticsEvent::where('website_id', $this->pageDetailsWebsiteId) - ->where('type', 'pageview') - ->where('path', $this->pageDetailsPath) - ->where('created_at', '>=', $startDate) - ->selectRaw('DATE(created_at) as date, COUNT(*) as views, COUNT(DISTINCT visitor_id) as visitors') - ->groupBy('date') - ->orderBy('date') - ->pluck('views', 'date') - ->toArray(); - - $data = []; - for ($i = 0; $i < $days; $i++) { - $date = $startDate->copy()->addDays($i)->format('Y-m-d'); - $data[] = [ - 'date' => $startDate->copy()->addDays($i)->format('M j'), - 'views' => $events[$date] ?? 0, - ]; - } - - return $data; - } - - /** - * Get referrers for page details. - */ - #[Computed] - public function pageDetailsReferrers(): array - { - if (! $this->isViewingPageDetails) { - return []; - } - - $days = match ($this->analyticsDateRange) { - '7d' => 7, - '30d' => 30, - '90d' => 90, - default => 30, - }; - - $start = now()->subDays($days)->startOfDay(); - - return AnalyticsSession::where('website_id', $this->pageDetailsWebsiteId) - ->where('landing_page', $this->pageDetailsPath) - ->whereNotNull('referrer_host') - ->where('referrer_host', '!=', '') - ->where('started_at', '>=', $start) - ->selectRaw('referrer_host, COUNT(*) as sessions') - ->groupBy('referrer_host') - ->orderByDesc('sessions') - ->limit(10) - ->get() - ->toArray(); - } - - /** - * Get device breakdown for page details. - */ - #[Computed] - public function pageDetailsDevices(): array - { - if (! $this->isViewingPageDetails) { - return []; - } - - $days = match ($this->analyticsDateRange) { - '7d' => 7, - '30d' => 30, - '90d' => 90, - default => 30, - }; - - $start = now()->subDays($days)->startOfDay(); - - $visitorIds = AnalyticsEvent::where('website_id', $this->pageDetailsWebsiteId) - ->where('type', 'pageview') - ->where('path', $this->pageDetailsPath) - ->where('created_at', '>=', $start) - ->pluck('visitor_id') - ->unique(); - - return AnalyticsVisitor::whereIn('id', $visitorIds) - ->selectRaw('device_type, COUNT(*) as count') - ->groupBy('device_type') - ->orderByDesc('count') - ->get() - ->pluck('count', 'device_type') - ->toArray(); - } - - /** - * Get browser breakdown for page details. - */ - #[Computed] - public function pageDetailsBrowsers(): array - { - if (! $this->isViewingPageDetails) { - return []; - } - - $days = match ($this->analyticsDateRange) { - '7d' => 7, - '30d' => 30, - '90d' => 90, - default => 30, - }; - - $start = now()->subDays($days)->startOfDay(); - - $visitorIds = AnalyticsEvent::where('website_id', $this->pageDetailsWebsiteId) - ->where('type', 'pageview') - ->where('path', $this->pageDetailsPath) - ->where('created_at', '>=', $start) - ->pluck('visitor_id') - ->unique(); - - return AnalyticsVisitor::whereIn('id', $visitorIds) - ->selectRaw('browser_name, COUNT(*) as count') - ->groupBy('browser_name') - ->orderByDesc('count') - ->limit(5) - ->get() - ->pluck('count', 'browser_name') - ->toArray(); - } - - // ======================================== - // BIO STATS (workspace-scoped) - // ======================================== - - // TODO: Bio service admin moved to Host UK app (Mod\Bio) - // These computed properties are stubbed until the admin panel is refactored - - #[Computed] - public function bioStats(): array - { - return ['total_pages' => 0, 'active_pages' => 0, 'total_clicks' => 0, 'total_projects' => 0, 'biolinks' => 0, 'shortlinks' => 0]; - } - - #[Computed] - public function bioStatCards(): array - { - return []; - } - - #[Computed] - public function bioPages(): \Illuminate\Support\Collection - { - return collect(); - } - - #[Computed] - public function bioProjects(): \Illuminate\Support\Collection - { - return collect(); - } - - #[Computed] - public function bioThemes(): array - { - return []; - } - - // ======================================== - // SOCIAL STATS (workspace-scoped) - // ======================================== - - #[Computed] - public function socialStats(): array - { - $workspaceId = $this->workspace?->id; - - if (! $workspaceId) { - return ['total_accounts' => 0, 'active_accounts' => 0, 'total_posts' => 0, 'scheduled_posts' => 0, 'published_posts' => 0, 'failed_posts' => 0]; - } - - return [ - 'total_accounts' => SocialAccount::where('workspace_id', $workspaceId)->count(), - 'active_accounts' => SocialAccount::where('workspace_id', $workspaceId)->where('status', 'active')->count(), - 'total_posts' => SocialPost::where('workspace_id', $workspaceId)->count(), - 'scheduled_posts' => SocialPost::where('workspace_id', $workspaceId)->where('status', PostStatus::SCHEDULED)->count(), - 'published_posts' => SocialPost::where('workspace_id', $workspaceId)->where('status', PostStatus::PUBLISHED)->count(), - 'failed_posts' => SocialPost::where('workspace_id', $workspaceId)->where('status', PostStatus::FAILED)->count(), - ]; - } - - #[Computed] - public function socialStatCards(): array - { - return [ - ['value' => number_format($this->socialStats['total_accounts']), 'label' => __('hub::hub.services.stats.social.total_accounts'), 'icon' => 'users', 'color' => 'violet'], - ['value' => number_format($this->socialStats['active_accounts']), 'label' => __('hub::hub.services.stats.social.active_accounts'), 'icon' => 'check-circle', 'color' => 'green'], - ['value' => number_format($this->socialStats['scheduled_posts']), 'label' => __('hub::hub.services.stats.social.scheduled_posts'), 'icon' => 'calendar', 'color' => 'blue'], - ['value' => number_format($this->socialStats['published_posts']), 'label' => __('hub::hub.services.stats.social.published_posts'), 'icon' => 'paper-plane', 'color' => 'orange'], - ]; - } - - #[Computed] - public function socialAccounts(): \Illuminate\Support\Collection - { - $workspaceId = $this->workspace?->id; - - if (! $workspaceId) { - return collect(); - } - - return SocialAccount::where('workspace_id', $workspaceId) - ->orderBy('name') - ->get(); - } - - #[Computed] - public function socialPosts(): \Illuminate\Support\Collection - { - $workspaceId = $this->workspace?->id; - - if (! $workspaceId) { - return collect(); - } - - return SocialPost::with(['accounts', 'user']) - ->where('workspace_id', $workspaceId) - ->latest() - ->take(50) - ->get(); - } - - // ======================================== - // ANALYTICS STATS (workspace-scoped) - // ======================================== - - #[Computed] - public function analyticsStats(): array - { - $workspaceId = $this->workspace?->id; - - if (! $workspaceId) { - return ['total_websites' => 0, 'active_websites' => 0, 'pageviews_today' => 0, 'pageviews_week' => 0, 'pageviews_month' => 0, 'sessions_today' => 0]; - } - - $today = now()->startOfDay(); - $weekStart = now()->startOfWeek(); - $monthStart = now()->startOfMonth(); - - $websiteIds = AnalyticsWebsite::where('workspace_id', $workspaceId)->pluck('id'); - - return [ - 'total_websites' => AnalyticsWebsite::where('workspace_id', $workspaceId)->count(), - 'active_websites' => AnalyticsWebsite::where('workspace_id', $workspaceId)->enabled()->count(), - 'pageviews_today' => AnalyticsEvent::whereIn('website_id', $websiteIds)->pageviews()->where('created_at', '>=', $today)->count(), - 'pageviews_week' => AnalyticsEvent::whereIn('website_id', $websiteIds)->pageviews()->where('created_at', '>=', $weekStart)->count(), - 'pageviews_month' => AnalyticsEvent::whereIn('website_id', $websiteIds)->pageviews()->where('created_at', '>=', $monthStart)->count(), - 'sessions_today' => AnalyticsSession::whereIn('website_id', $websiteIds)->where('started_at', '>=', $today)->count(), - ]; - } - - #[Computed] - public function analyticsStatCards(): array - { - return [ - ['value' => number_format($this->analyticsStats['total_websites']), 'label' => __('hub::hub.services.stats.analytics.total_websites'), 'icon' => 'globe', 'color' => 'violet'], - ['value' => number_format($this->analyticsStats['active_websites']), 'label' => __('hub::hub.services.stats.analytics.active_websites'), 'icon' => 'check-circle', 'color' => 'green'], - ['value' => number_format($this->analyticsStats['pageviews_today']), 'label' => __('hub::hub.services.stats.analytics.pageviews_today'), 'icon' => 'eye', 'color' => 'blue'], - ['value' => number_format($this->analyticsStats['sessions_today']), 'label' => __('hub::hub.services.stats.analytics.sessions_today'), 'icon' => 'users', 'color' => 'orange'], - ]; - } - - #[Computed] - public function analyticsWebsites(): \Illuminate\Support\Collection - { - $workspaceId = $this->workspace?->id; - - if (! $workspaceId) { - return collect(); - } - - $days = match ($this->analyticsDateRange) { - '7d' => 7, - '30d' => 30, - '90d' => 90, - 'all' => 365, - default => 30, - }; - - $startDate = now()->subDays($days)->startOfDay(); - - return AnalyticsWebsite::where('workspace_id', $workspaceId) - ->withCount([ - 'events as pageviews_count' => fn ($q) => $q->pageviews()->where('created_at', '>=', $startDate), - 'sessions as sessions_count' => fn ($q) => $q->where('started_at', '>=', $startDate), - 'sessions as bounced_sessions_count' => fn ($q) => $q->where('started_at', '>=', $startDate)->where('is_bounce', true), - ]) - ->withSum(['sessions as total_duration' => fn ($q) => $q->where('started_at', '>=', $startDate)->whereNotNull('duration')], 'duration') - ->orderByDesc('pageviews_count') - ->get() - ->map(function ($website) use ($startDate) { - // Calculate derived metrics - $website->visitors_count = AnalyticsSession::where('website_id', $website->id) - ->where('started_at', '>=', $startDate) - ->distinct('visitor_id') - ->count('visitor_id'); - - $website->bounce_rate = $website->sessions_count > 0 - ? round(($website->bounced_sessions_count / $website->sessions_count) * 100, 1) - : 0; - - $website->avg_duration = $website->sessions_count > 0 - ? (int) round($website->total_duration / $website->sessions_count) - : 0; - - return $website; - }); - } - - /** - * Get all analytics channels for the workspace, grouped by type. - */ - #[Computed] - public function analyticsChannels(): \Illuminate\Support\Collection - { - return $this->analyticsWebsites; - } - - /** - * Get analytics channels grouped by channel type. - */ - #[Computed] - public function analyticsChannelsByType(): array - { - $channels = $this->analyticsChannels; - - $grouped = []; - foreach (ChannelType::cases() as $type) { - $typeChannels = $channels->filter(fn ($c) => ($c->channel_type?->value ?? 'website') === $type->value); - if ($typeChannels->isNotEmpty()) { - $grouped[$type->value] = [ - 'type' => $type, - 'label' => $type->label(), - 'icon' => $type->icon(), - 'color' => $type->color(), - 'channels' => $typeChannels, - ]; - } - } - - return $grouped; - } - - #[Computed] - public function analyticsChartData(): array - { - $workspaceId = $this->workspace?->id; - - if (! $workspaceId) { - return []; - } - - $days = match ($this->analyticsDateRange) { - '7d' => 7, - '30d' => 30, - '90d' => 90, - default => 30, - }; - - $websiteIds = AnalyticsWebsite::where('workspace_id', $workspaceId)->pluck('id'); - - if ($websiteIds->isEmpty()) { - return []; - } - - $startDate = now()->subDays($days - 1)->startOfDay(); - - // Get daily pageview counts - $pageviews = AnalyticsEvent::whereIn('website_id', $websiteIds) - ->pageviews() - ->where('created_at', '>=', $startDate) - ->selectRaw('DATE(created_at) as date, COUNT(*) as count') - ->groupBy('date') - ->orderBy('date') - ->pluck('count', 'date') - ->toArray(); - - // Build chart data with all dates - $data = []; - for ($i = 0; $i < $days; $i++) { - $date = $startDate->copy()->addDays($i)->format('Y-m-d'); - $data[] = [ - 'date' => $startDate->copy()->addDays($i)->format('M j'), - 'pageviews' => $pageviews[$date] ?? 0, - ]; - } - - return $data; - } - - #[Computed] - public function analyticsTopPages(): \Illuminate\Support\Collection - { - $workspaceId = $this->workspace?->id; - - if (! $workspaceId) { - return collect(); - } - - $days = match ($this->analyticsDateRange) { - '7d' => 7, - '30d' => 30, - '90d' => 90, - 'all' => null, - default => 30, - }; - - $websiteIds = AnalyticsWebsite::where('workspace_id', $workspaceId)->pluck('id'); - - if ($websiteIds->isEmpty()) { - return collect(); - } - - // Get pageview stats - $query = AnalyticsEvent::whereIn('website_id', $websiteIds) - ->pageviews() - ->selectRaw('path, COUNT(*) as views, COUNT(DISTINCT visitor_id) as visitors') - ->groupBy('path') - ->orderByDesc('views') - ->limit(10); - - if ($days !== null) { - $query->where('created_at', '>=', now()->subDays($days)->startOfDay()); - } - - $pages = $query->get(); - - // Get bounce rates by landing page - $bounceQuery = AnalyticsSession::whereIn('website_id', $websiteIds) - ->whereNotNull('landing_page') - ->selectRaw('landing_page, COUNT(*) as entries, SUM(CASE WHEN is_bounce = 1 THEN 1 ELSE 0 END) as bounces'); - - if ($days !== null) { - $bounceQuery->where('started_at', '>=', now()->subDays($days)->startOfDay()); - } - - $bounceRates = $bounceQuery->groupBy('landing_page')->get()->keyBy('landing_page'); - - // Merge bounce rate into pages - return $pages->map(function ($page) use ($bounceRates) { - $bounceData = $bounceRates->get($page->path); - $page->entries = $bounceData?->entries ?? 0; - $page->bounces = $bounceData?->bounces ?? 0; - $page->bounce_rate = $page->entries > 0 - ? round(($page->bounces / $page->entries) * 100, 1) - : null; - - return $page; - }); - } - - /** - * Get analytics summary metrics for the inline summary bar. - * Returns total pageviews, unique visitors, bounce rate, and avg session duration - * based on the selected date range. - */ - #[Computed] - public function analyticsSummaryMetrics(): array - { - $workspaceId = $this->workspace?->id; - - if (! $workspaceId) { - return [ - 'total_pageviews' => 0, - 'unique_visitors' => 0, - 'bounce_rate' => 0, - 'avg_session_duration' => 0, - ]; - } - - $days = match ($this->analyticsDateRange) { - '7d' => 7, - '30d' => 30, - '90d' => 90, - 'all' => null, - default => 30, - }; - - $websiteIds = AnalyticsWebsite::where('workspace_id', $workspaceId)->pluck('id'); - - if ($websiteIds->isEmpty()) { - return [ - 'total_pageviews' => 0, - 'unique_visitors' => 0, - 'bounce_rate' => 0, - 'avg_session_duration' => 0, - ]; - } - - $query = AnalyticsEvent::whereIn('website_id', $websiteIds)->pageviews(); - $sessionQuery = AnalyticsSession::whereIn('website_id', $websiteIds); - - if ($days !== null) { - $startDate = now()->subDays($days)->startOfDay(); - $query->where('created_at', '>=', $startDate); - $sessionQuery->where('started_at', '>=', $startDate); - } - - $totalPageviews = $query->count(); - - // Unique visitors (distinct visitor_ids from sessions) - $uniqueVisitors = (clone $sessionQuery)->distinct('visitor_id')->count('visitor_id'); - - // Bounce rate: sessions with only 1 pageview / total sessions - $totalSessions = (clone $sessionQuery)->count(); - $bouncedSessions = (clone $sessionQuery)->where('pageviews', 1)->count(); - $bounceRate = $totalSessions > 0 ? round(($bouncedSessions / $totalSessions) * 100, 1) : 0; - - // Average session duration in seconds - $avgDuration = (clone $sessionQuery)->whereNotNull('ended_at')->avg(\DB::raw('TIMESTAMPDIFF(SECOND, started_at, ended_at)')) ?? 0; - - return [ - 'total_pageviews' => $totalPageviews, - 'unique_visitors' => $uniqueVisitors, - 'bounce_rate' => $bounceRate, - 'avg_session_duration' => (int) round($avgDuration), - ]; - } - - /** - * Format seconds into a human-readable duration (e.g., "2m 30s"). - */ - public function formatDuration(int $seconds): string - { - if ($seconds < 60) { - return $seconds.'s'; - } - - $minutes = floor($seconds / 60); - $remainingSeconds = $seconds % 60; - - if ($minutes < 60) { - return $remainingSeconds > 0 ? "{$minutes}m {$remainingSeconds}s" : "{$minutes}m"; - } - - $hours = floor($minutes / 60); - $remainingMinutes = $minutes % 60; - - return $remainingMinutes > 0 ? "{$hours}h {$remainingMinutes}m" : "{$hours}h"; - } - - #[Computed] - public function analyticsAcquisitionChannels(): array - { - $workspaceId = $this->workspace?->id; - - if (! $workspaceId) { - return []; - } - - $days = match ($this->analyticsDateRange) { - '7d' => 7, - '30d' => 30, - '90d' => 90, - 'all' => 365, - default => 30, - }; - - $websiteIds = AnalyticsWebsite::where('workspace_id', $workspaceId)->pluck('id'); - - if ($websiteIds->isEmpty()) { - return []; - } - - $startDate = now()->subDays($days)->startOfDay(); - - // Get sessions grouped by referrer type - $sessions = AnalyticsSession::whereIn('website_id', $websiteIds) - ->where('started_at', '>=', $startDate) - ->get(['referrer_host', 'utm_source', 'utm_medium']); - - $total = $sessions->count(); - - if ($total === 0) { - return []; - } - - // Categorise traffic sources - $channels = [ - 'direct' => 0, - 'search' => 0, - 'social' => 0, - 'referral' => 0, - ]; - - $searchEngines = ['google', 'bing', 'yahoo', 'duckduckgo', 'baidu', 'yandex']; - $socialNetworks = ['facebook', 'twitter', 'instagram', 'linkedin', 'youtube', 'tiktok', 'pinterest', 'reddit']; - - foreach ($sessions as $session) { - $host = strtolower($session->referrer_host ?? ''); - $source = strtolower($session->utm_source ?? ''); - $medium = strtolower($session->utm_medium ?? ''); - - // Direct traffic (no referrer) - if (empty($host) && empty($source)) { - $channels['direct']++; - - continue; - } - - // Check UTM medium first - if (in_array($medium, ['cpc', 'ppc', 'organic', 'search'])) { - $channels['search']++; - - continue; - } - if (in_array($medium, ['social', 'social-media'])) { - $channels['social']++; - - continue; - } - - // Check referrer host for search engines - foreach ($searchEngines as $engine) { - if (str_contains($host, $engine) || str_contains($source, $engine)) { - $channels['search']++; - - continue 2; - } - } - - // Check referrer host for social networks - foreach ($socialNetworks as $network) { - if (str_contains($host, $network) || str_contains($source, $network)) { - $channels['social']++; - - continue 2; - } - } - - // Everything else is referral - $channels['referral']++; - } - - $colours = [ - 'direct' => '#8b5cf6', - 'search' => '#06b6d4', - 'social' => '#f59e0b', - 'referral' => '#10b981', - ]; - - $labels = [ - 'direct' => __('hub::hub.services.analytics.channels.direct'), - 'search' => __('hub::hub.services.analytics.channels.search'), - 'social' => __('hub::hub.services.analytics.channels.social'), - 'referral' => __('hub::hub.services.analytics.channels.referral'), - ]; - - return collect($channels) - ->filter(fn ($count) => $count > 0) - ->map(fn ($count, $key) => [ - 'name' => $labels[$key] ?? ucfirst($key), - 'count' => $count, - 'percentage' => round(($count / $total) * 100, 1), - 'color' => $colours[$key] ?? '#6b7280', - ]) - ->sortByDesc('count') - ->values() - ->toArray(); - } - - #[Computed] - public function analyticsDeviceBreakdown(): array - { - $workspaceId = $this->workspace?->id; - - if (! $workspaceId) { - return []; - } - - $days = match ($this->analyticsDateRange) { - '7d' => 7, - '30d' => 30, - '90d' => 90, - 'all' => 365, - default => 30, - }; - - $websiteIds = AnalyticsWebsite::where('workspace_id', $workspaceId)->pluck('id'); - - if ($websiteIds->isEmpty()) { - return []; - } - - $startDate = now()->subDays($days)->startOfDay(); - - // Get visitors by device type - $devices = AnalyticsVisitor::whereIn('website_id', $websiteIds) - ->where('last_seen_at', '>=', $startDate) - ->selectRaw('device_type, COUNT(*) as count') - ->groupBy('device_type') - ->pluck('count', 'device_type') - ->toArray(); - - $total = array_sum($devices); - - if ($total === 0) { - return []; - } - - $icons = [ - 'desktop' => 'computer-desktop', - 'mobile' => 'device-phone-mobile', - 'tablet' => 'device-tablet', - ]; - - $labels = [ - 'desktop' => __('hub::hub.services.analytics.devices.desktop'), - 'mobile' => __('hub::hub.services.analytics.devices.mobile'), - 'tablet' => __('hub::hub.services.analytics.devices.tablet'), - ]; - - // Ensure all device types are represented - $deviceTypes = ['desktop', 'mobile', 'tablet']; - $result = []; - - foreach ($deviceTypes as $type) { - $count = $devices[$type] ?? 0; - if ($count > 0 || $total > 0) { - $result[] = [ - 'name' => $labels[$type] ?? ucfirst($type), - 'icon' => $icons[$type] ?? 'question-mark-circle', - 'count' => $count, - 'percentage' => $total > 0 ? round(($count / $total) * 100, 0) : 0, - ]; - } - } - - return $result; - } - - #[Computed] - public function analyticsGoals(): \Illuminate\Support\Collection - { - $workspaceId = $this->workspace?->id; - - if (! $workspaceId) { - return collect(); - } - - $websiteIds = AnalyticsWebsite::where('workspace_id', $workspaceId)->pluck('id'); - - if ($websiteIds->isEmpty()) { - return collect(); - } - - return AnalyticsGoal::with('website') - ->whereIn('website_id', $websiteIds) - ->withCount([ - 'conversions as conversions_count' => fn ($q) => $q->where('created_at', '>=', now()->startOfMonth()), - ]) - ->orderBy('name') - ->get(); - } - - #[Computed] - public function analyticsGoalTypes(): array - { - return [ - 'pageview' => ['label' => 'Page Visit', 'color' => 'blue', 'icon' => 'document-text'], - 'event' => ['label' => 'Custom Event', 'color' => 'purple', 'icon' => 'bolt'], - 'duration' => ['label' => 'Time on Page', 'color' => 'orange', 'icon' => 'clock'], - 'pages_per_session' => ['label' => 'Pages Per Session', 'color' => 'green', 'icon' => 'document-duplicate'], - ]; - } - - // ======================================== - // NOTIFY STATS (workspace-scoped) - // ======================================== - - #[Computed] - public function notifyStats(): array - { - $workspaceId = $this->workspace?->id; - - if (! $workspaceId) { - return ['total_websites' => 0, 'total_subscribers' => 0, 'active_subscribers' => 0, 'active_campaigns' => 0, 'messages_today' => 0]; - } - - $websiteIds = PushWebsite::where('workspace_id', $workspaceId)->pluck('id'); - - return [ - 'total_websites' => PushWebsite::where('workspace_id', $workspaceId)->count(), - 'total_subscribers' => PushSubscriber::whereIn('website_id', $websiteIds)->count(), - 'active_subscribers' => PushSubscriber::whereIn('website_id', $websiteIds)->where('is_subscribed', true)->count(), - 'active_campaigns' => PushCampaign::whereIn('website_id', $websiteIds)->whereIn('status', [PushCampaign::STATUS_SCHEDULED, PushCampaign::STATUS_SENDING])->count(), - 'messages_today' => PushCampaignLog::whereIn('campaign_id', PushCampaign::whereIn('website_id', $websiteIds)->pluck('id'))->whereDate('sent_at', today())->count(), - ]; - } - - #[Computed] - public function notifyStatCards(): array - { - return [ - ['value' => number_format($this->notifyStats['total_websites']), 'label' => __('hub::hub.services.stats.notify.websites'), 'icon' => 'globe', 'color' => 'purple'], - ['value' => number_format($this->notifyStats['active_subscribers']), 'label' => __('hub::hub.services.stats.notify.active_subscribers'), 'icon' => 'users', 'color' => 'blue'], - ['value' => number_format($this->notifyStats['active_campaigns']), 'label' => __('hub::hub.services.stats.notify.active_campaigns'), 'icon' => 'bullhorn', 'color' => 'orange'], - ['value' => number_format($this->notifyStats['messages_today']), 'label' => __('hub::hub.services.stats.notify.messages_today'), 'icon' => 'paper-plane', 'color' => 'green'], - ]; - } - - #[Computed] - public function notifyWebsites(): \Illuminate\Support\Collection - { - $workspaceId = $this->workspace?->id; - - if (! $workspaceId) { - return collect(); - } - - return PushWebsite::where('workspace_id', $workspaceId) - ->withCount(['subscribers' => fn ($q) => $q->where('is_subscribed', true)]) - ->orderByDesc('subscribers_count') - ->get(); - } - - #[Computed] - public function notifySubscribers(): \Illuminate\Support\Collection - { - $workspaceId = $this->workspace?->id; - - if (! $workspaceId) { - return collect(); - } - - $websiteIds = PushWebsite::where('workspace_id', $workspaceId)->pluck('id'); - - return PushSubscriber::with('website') - ->whereIn('website_id', $websiteIds) - ->latest('subscribed_at') - ->take(100) - ->get(); - } - - #[Computed] - public function notifyCampaigns(): \Illuminate\Support\Collection - { - $workspaceId = $this->workspace?->id; - - if (! $workspaceId) { - return collect(); - } - - $websiteIds = PushWebsite::where('workspace_id', $workspaceId)->pluck('id'); - - return PushCampaign::with(['website', 'user']) - ->whereIn('website_id', $websiteIds) - ->latest() - ->get(); - } - - // ======================================== - // TRUST STATS (workspace-scoped) - // ======================================== - - #[Computed] - public function trustStats(): array - { - $workspaceId = $this->workspace?->id; - - if (! $workspaceId) { - return ['total_campaigns' => 0, 'active_campaigns' => 0, 'total_notifications' => 0, 'total_impressions' => 0, 'total_clicks' => 0, 'total_conversions' => 0]; - } - - $campaignIds = TrustCampaign::where('workspace_id', $workspaceId)->pluck('id'); - - return [ - 'total_campaigns' => TrustCampaign::where('workspace_id', $workspaceId)->count(), - 'active_campaigns' => TrustCampaign::where('workspace_id', $workspaceId)->where('is_enabled', true)->count(), - 'total_notifications' => TrustNotification::whereIn('campaign_id', $campaignIds)->count(), - 'total_impressions' => TrustNotification::whereIn('campaign_id', $campaignIds)->sum('impressions'), - 'total_clicks' => TrustNotification::whereIn('campaign_id', $campaignIds)->sum('clicks'), - 'total_conversions' => TrustNotification::whereIn('campaign_id', $campaignIds)->sum('conversions'), - ]; - } - - #[Computed] - public function trustStatCards(): array - { - return [ - ['value' => number_format($this->trustStats['total_campaigns']), 'label' => __('hub::hub.services.stats.trust.total_campaigns'), 'icon' => 'megaphone', 'color' => 'blue'], - ['value' => number_format($this->trustStats['active_campaigns']), 'label' => __('hub::hub.services.stats.trust.active_campaigns'), 'icon' => 'check-circle', 'color' => 'green'], - ['value' => number_format($this->trustStats['total_notifications']), 'label' => __('hub::hub.services.stats.trust.total_widgets'), 'icon' => 'bell', 'color' => 'purple'], - ['value' => number_format($this->trustStats['total_impressions']), 'label' => __('hub::hub.services.stats.trust.total_impressions'), 'icon' => 'eye', 'color' => 'orange'], - ]; - } - - /** - * Get aggregated Trust metrics for summary display. - */ - #[Computed] - public function trustAggregatedMetrics(): array - { - $stats = $this->trustStats; - - $ctr = $stats['total_impressions'] > 0 ? round(($stats['total_clicks'] / $stats['total_impressions']) * 100, 2) : 0; - $cvr = $stats['total_impressions'] > 0 ? round(($stats['total_conversions'] / $stats['total_impressions']) * 100, 2) : 0; - - return [ - 'impressions' => $stats['total_impressions'], - 'clicks' => $stats['total_clicks'], - 'conversions' => $stats['total_conversions'], - 'ctr' => $ctr, - 'cvr' => $cvr, - ]; - } - - #[Computed] - public function trustCampaigns(): \Illuminate\Support\Collection - { - $workspaceId = $this->workspace?->id; - - if (! $workspaceId) { - return collect(); - } - - return TrustCampaign::where('workspace_id', $workspaceId) - ->withCount('notifications') - ->orderBy('name') - ->get(); - } - - #[Computed] - public function trustNotifications(): \Illuminate\Support\Collection - { - $workspaceId = $this->workspace?->id; - - if (! $workspaceId) { - return collect(); - } - - $campaignIds = TrustCampaign::where('workspace_id', $workspaceId)->pluck('id'); - - return TrustNotification::with('campaign') - ->whereIn('campaign_id', $campaignIds) - ->orderByDesc('impressions') - ->get(); - } - - // ======================================== - // SUPPORT STATS (workspace-scoped) - // ======================================== - - #[Computed] - public function supportStats(): array - { - $workspaceId = $this->workspace?->id; - - if (! $workspaceId) { - return [ - 'open_tickets' => 0, - 'new_today' => 0, - 'resolved_today' => 0, - 'total_mailboxes' => 0, - ]; - } - - $today = now()->startOfDay(); - $mailboxIds = Mailbox::where('workspace_id', $workspaceId)->pluck('id'); - - return [ - 'open_tickets' => Conversation::whereIn('mailbox_id', $mailboxIds) - ->whereIn('status', ['active', 'pending']) - ->count(), - 'new_today' => Conversation::whereIn('mailbox_id', $mailboxIds) - ->where('created_at', '>=', $today) - ->count(), - 'resolved_today' => Conversation::whereIn('mailbox_id', $mailboxIds) - ->where('status', 'closed') - ->where('closed_at', '>=', $today) - ->count(), - 'total_mailboxes' => Mailbox::where('workspace_id', $workspaceId)->count(), - ]; - } - - /** - * Inbox health for support dashboard - open tickets and oldest unresponded. - */ - #[Computed] - public function supportInboxHealth(): array - { - $workspaceId = $this->workspace?->id; - - if (! $workspaceId) { - return [ - 'open_tickets' => 0, - 'oldest_unresponded' => null, - 'avg_response_time' => null, - ]; - } - - $mailboxIds = Mailbox::where('workspace_id', $workspaceId)->pluck('id'); - - $openTickets = Conversation::whereIn('mailbox_id', $mailboxIds) - ->whereIn('status', ['active', 'pending']) - ->count(); - - // Find oldest unresponded conversation - $oldestUnresponded = Conversation::query() - ->whereIn('mailbox_id', $mailboxIds) - ->whereIn('status', ['active', 'pending']) - ->whereDoesntHave('threads', function ($query) { - $query->where('type', 'message'); - }) - ->orderBy('created_at') - ->first(); - - // Calculate average response time - $avgResponseTime = $this->calculateSupportAvgResponseTime($mailboxIds); - - return [ - 'open_tickets' => $openTickets, - 'oldest_unresponded' => $oldestUnresponded, - 'avg_response_time' => $avgResponseTime, - ]; - } - - /** - * Today's activity for support dashboard. - */ - #[Computed] - public function supportTodaysActivity(): array - { - $workspaceId = $this->workspace?->id; - - if (! $workspaceId) { - return [ - 'new_conversations' => 0, - 'resolved_today' => 0, - 'messages_sent' => 0, - ]; - } - - $today = now()->startOfDay(); - $mailboxIds = Mailbox::where('workspace_id', $workspaceId)->pluck('id'); - $conversationIds = Conversation::whereIn('mailbox_id', $mailboxIds)->pluck('id'); - - return [ - 'new_conversations' => Conversation::whereIn('mailbox_id', $mailboxIds) - ->where('created_at', '>=', $today) - ->count(), - 'resolved_today' => Conversation::whereIn('mailbox_id', $mailboxIds) - ->where('status', 'closed') - ->where('closed_at', '>=', $today) - ->count(), - 'messages_sent' => Thread::whereIn('conversation_id', $conversationIds) - ->where('created_at', '>=', $today) - ->where('type', 'message') - ->count(), - ]; - } - - /** - * Performance metrics for support dashboard. - */ - #[Computed] - public function supportPerformance(): array - { - $workspaceId = $this->workspace?->id; - - if (! $workspaceId) { - return [ - 'first_response_time' => null, - 'resolution_time' => null, - ]; - } - - $mailboxIds = Mailbox::where('workspace_id', $workspaceId)->pluck('id'); - - return [ - 'first_response_time' => $this->calculateSupportFirstResponseTime($mailboxIds), - 'resolution_time' => $this->calculateSupportResolutionTime($mailboxIds), - ]; - } - - /** - * Inbox health cards for support service. - */ - #[Computed] - public function supportInboxHealthCards(): array - { - $health = $this->supportInboxHealth; - - return [ - [ - 'value' => number_format($health['open_tickets']), - 'label' => __('hub::hub.services.support.open_tickets'), - 'icon' => 'inbox', - 'color' => 'blue', - 'oldest' => $health['oldest_unresponded'], - ], - [ - 'value' => $health['avg_response_time'] ?? __('hub::hub.services.support.na'), - 'label' => __('hub::hub.services.support.avg_response_time'), - 'icon' => 'clock', - 'color' => 'green', - ], - ]; - } - - /** - * Activity cards for support service. - */ - #[Computed] - public function supportActivityCards(): array - { - $activity = $this->supportTodaysActivity; - - return [ - [ - 'value' => number_format($activity['new_conversations']), - 'label' => __('hub::hub.services.support.new_today'), - 'icon' => 'plus-circle', - 'color' => 'violet', - ], - [ - 'value' => number_format($activity['resolved_today']), - 'label' => __('hub::hub.services.support.resolved_today'), - 'icon' => 'check-circle', - 'color' => 'green', - ], - [ - 'value' => number_format($activity['messages_sent']), - 'label' => __('hub::hub.services.support.messages_sent'), - 'icon' => 'paper-airplane', - 'color' => 'blue', - ], - ]; - } - - /** - * Performance cards for support service. - */ - #[Computed] - public function supportPerformanceCards(): array - { - $performance = $this->supportPerformance; - - return [ - [ - 'value' => $performance['first_response_time'] ?? __('hub::hub.services.support.na'), - 'label' => __('hub::hub.services.support.first_response'), - 'icon' => 'bolt', - 'color' => 'amber', - ], - [ - 'value' => $performance['resolution_time'] ?? __('hub::hub.services.support.na'), - 'label' => __('hub::hub.services.support.resolution_time'), - 'icon' => 'flag', - 'color' => 'teal', - ], - ]; - } - - /** - * Recent conversations for support service. - */ - #[Computed] - public function supportRecentConversations(): \Illuminate\Support\Collection - { - $workspaceId = $this->workspace?->id; - - if (! $workspaceId) { - return collect(); - } - - $mailboxIds = Mailbox::where('workspace_id', $workspaceId)->pluck('id'); - - return Conversation::with(['mailbox', 'customer', 'latestThread']) - ->whereIn('mailbox_id', $mailboxIds) - ->latest() - ->take(5) - ->get(); - } - - /** - * Calculate average response time for support conversations. - */ - private function calculateSupportAvgResponseTime(\Illuminate\Support\Collection $mailboxIds): ?string - { - $monthStart = now()->startOfMonth(); - - $conversations = Conversation::query() - ->whereIn('mailbox_id', $mailboxIds) - ->where('created_at', '>=', $monthStart) - ->whereHas('threads', function ($query) { - $query->where('type', 'message'); - }) - ->with(['threads' => function ($query) { - $query->orderBy('created_at'); - }]) - ->get(); - - if ($conversations->isEmpty()) { - return null; - } - - $totalSeconds = 0; - $count = 0; - - foreach ($conversations as $conversation) { - $customerThread = $conversation->threads->firstWhere('type', 'customer'); - $agentThread = $conversation->threads->firstWhere('type', 'message'); - - if ($customerThread && $agentThread && $agentThread->created_at > $customerThread->created_at) { - $totalSeconds += $agentThread->created_at->diffInSeconds($customerThread->created_at); - $count++; - } - } - - if ($count === 0) { - return null; - } - - return $this->formatSupportDuration((int) ($totalSeconds / $count)); - } - - /** - * Calculate first response time for support conversations. - */ - private function calculateSupportFirstResponseTime(\Illuminate\Support\Collection $mailboxIds): ?string - { - $monthStart = now()->startOfMonth(); - - $conversations = Conversation::query() - ->whereIn('mailbox_id', $mailboxIds) - ->where('created_at', '>=', $monthStart) - ->whereHas('threads', function ($query) { - $query->where('type', 'message'); - }) - ->get(); - - if ($conversations->isEmpty()) { - return null; - } - - $totalSeconds = 0; - $count = 0; - - foreach ($conversations as $conversation) { - $firstAgentReply = Thread::where('conversation_id', $conversation->id) - ->where('type', 'message') - ->orderBy('created_at') - ->first(); - - if ($firstAgentReply) { - $totalSeconds += $firstAgentReply->created_at->diffInSeconds($conversation->created_at); - $count++; - } - } - - if ($count === 0) { - return null; - } - - return $this->formatSupportDuration((int) ($totalSeconds / $count)); - } - - /** - * Calculate resolution time for support conversations. - */ - private function calculateSupportResolutionTime(\Illuminate\Support\Collection $mailboxIds): ?string - { - $monthStart = now()->startOfMonth(); - - $conversations = Conversation::query() - ->whereIn('mailbox_id', $mailboxIds) - ->where('status', 'closed') - ->where('closed_at', '>=', $monthStart) - ->whereNotNull('closed_at') - ->get(); - - if ($conversations->isEmpty()) { - return null; - } - - $totalSeconds = 0; - $count = 0; - - foreach ($conversations as $conversation) { - $totalSeconds += $conversation->closed_at->diffInSeconds($conversation->created_at); - $count++; - } - - if ($count === 0) { - return null; - } - - return $this->formatSupportDuration((int) ($totalSeconds / $count)); - } - - /** - * Format seconds into human-readable duration for support metrics. - */ - private function formatSupportDuration(int $seconds): string - { - if ($seconds < 60) { - return $seconds.'s'; - } - - if ($seconds < 3600) { - $minutes = (int) ($seconds / 60); - - return $minutes.'m'; - } - - if ($seconds < 86400) { - $hours = (int) ($seconds / 3600); - $minutes = (int) (($seconds % 3600) / 60); - - return $minutes > 0 ? "{$hours}h {$minutes}m" : "{$hours}h"; - } - - $days = (int) ($seconds / 86400); - $hours = (int) (($seconds % 86400) / 3600); - - return $hours > 0 ? "{$days}d {$hours}h" : "{$days}d"; - } - - /** - * Get status color for support conversations. - */ - public function supportStatusColor(string $status): string - { - return match ($status) { - 'active' => 'green', - 'pending' => 'yellow', - 'closed' => 'zinc', - 'spam' => 'red', - default => 'zinc', - }; - } - - public function render(): View - { - return view('hub::admin.services-admin') - ->layout('hub::admin.layouts.app', ['title' => $this->currentServiceItem['label'] ?? 'Services']); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Settings.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/Settings.php deleted file mode 100644 index 95fde1d..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Settings.php +++ /dev/null @@ -1,247 +0,0 @@ -name = $user->name ?? ''; - $this->email = $user->email ?? ''; - - // Load preferences from user settings - $this->locale = $this->getUserSetting('locale', config('app.locale', 'en_GB')); - $this->timezone = $this->getUserSetting('timezone', config('app.timezone', 'Europe/London')); - $this->time_format = (int) $this->getUserSetting('time_format', 12); - $this->week_starts_on = (int) $this->getUserSetting('week_starts_on', 1); - - // Feature flags - 2FA disabled until native implementation - $this->isTwoFactorEnabled = config('social.features.two_factor_auth', false); - $this->userHasTwoFactorEnabled = method_exists($user, 'hasTwoFactorAuthEnabled') - ? $user->hasTwoFactorAuthEnabled() - : false; - - // Check for pending deletion request - $this->pendingDeletion = AccountDeletionRequest::where('user_id', $user->id) - ->whereNull('completed_at') - ->whereNull('cancelled_at') - ->first(); - - // Data for selects (cached for performance) - $this->locales = UserStatsService::getLocaleList(); - $this->timezones = UserStatsService::getTimezoneList(); - } - - protected function getUserSetting(string $name, mixed $default = null): mixed - { - $setting = Setting::where('user_id', Auth::id()) - ->where('name', $name) - ->first(); - - return $setting?->payload ?? $default; - } - - public function updateProfile(): void - { - $this->validate([ - 'name' => ['required', 'string', 'max:255'], - 'email' => ['required', 'email', 'max:255', 'unique:'.(new User)->getTable().',email,'.Auth::id()], - ]); - - $user = User::findOrFail(Auth::id()); - $user->update([ - 'name' => $this->name, - 'email' => $this->email, - ]); - - $this->dispatch('profile-updated'); - Flux::toast(text: __('hub::hub.settings.messages.profile_updated'), variant: 'success'); - } - - public function updatePreferences(): void - { - $this->validate([ - 'locale' => ['required', 'string'], - 'timezone' => ['required', 'timezone'], - 'time_format' => ['required', 'in:12,24'], - 'week_starts_on' => ['required', 'in:0,1'], - ]); - - $preferences = [ - 'locale' => $this->locale, - 'timezone' => $this->timezone, - 'time_format' => (int) $this->time_format, - 'week_starts_on' => (int) $this->week_starts_on, - ]; - - foreach ($preferences as $name => $payload) { - Setting::updateOrCreate( - ['name' => $name, 'user_id' => Auth::id()], - ['payload' => $payload] - ); - } - - $this->dispatch('preferences-updated'); - Flux::toast(text: __('hub::hub.settings.messages.preferences_updated'), variant: 'success'); - } - - public function updatePassword(): void - { - $this->validate([ - 'current_password' => ['required', 'current_password'], - 'new_password' => ['required', 'confirmed', Password::defaults()], - ]); - - $user = User::findOrFail(Auth::id()); - $user->update([ - 'password' => Hash::make($this->new_password), - ]); - - $this->current_password = ''; - $this->new_password = ''; - $this->new_password_confirmation = ''; - - $this->dispatch('password-updated'); - Flux::toast(text: __('hub::hub.settings.messages.password_updated'), variant: 'success'); - } - - public function enableTwoFactor(): void - { - // TODO: Implement native 2FA - currently disabled - Flux::toast(text: __('hub::hub.settings.messages.two_factor_upgrading'), variant: 'warning'); - } - - public function confirmTwoFactor(): void - { - // TODO: Implement native 2FA - currently disabled - Flux::toast(text: __('hub::hub.settings.messages.two_factor_upgrading'), variant: 'warning'); - } - - public function showRecoveryCodesModal(): void - { - // TODO: Implement native 2FA - currently disabled - Flux::toast(text: __('hub::hub.settings.messages.two_factor_upgrading'), variant: 'warning'); - } - - public function regenerateRecoveryCodes(): void - { - // TODO: Implement native 2FA - currently disabled - Flux::toast(text: __('hub::hub.settings.messages.two_factor_upgrading'), variant: 'warning'); - } - - public function disableTwoFactor(): void - { - // TODO: Implement native 2FA - currently disabled - Flux::toast(text: __('hub::hub.settings.messages.two_factor_upgrading'), variant: 'warning'); - } - - public function requestAccountDeletion(): void - { - // Get the base user model for the app - $user = \Core\Mod\Tenant\Models\User::findOrFail(Auth::id()); - - // Create the deletion request - $deletionRequest = AccountDeletionRequest::createForUser($user, $this->deleteReason ?: null); - - // Send confirmation email - Mail::to($user->email)->send(new AccountDeletionRequested($deletionRequest)); - - $this->pendingDeletion = $deletionRequest; - $this->showDeleteConfirmation = false; - $this->deleteReason = ''; - - Flux::toast(text: __('hub::hub.settings.messages.deletion_scheduled'), variant: 'warning'); - } - - public function cancelAccountDeletion(): void - { - if ($this->pendingDeletion) { - $this->pendingDeletion->cancel(); - $this->pendingDeletion = null; - } - - Flux::toast(text: __('hub::hub.settings.messages.deletion_cancelled'), variant: 'success'); - } - - public function render() - { - return view('hub::admin.settings') - ->layout('hub::admin.layouts.app', ['title' => 'Settings']); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/SiteSettings.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/SiteSettings.php deleted file mode 100644 index 4502db2..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/SiteSettings.php +++ /dev/null @@ -1,297 +0,0 @@ -entitlements = $entitlements; - } - - public function mount(string $workspace, ?string $tab = null): void - { - $this->workspaceSlug = $workspace; - - if ($tab && in_array($tab, ['services', 'general', 'deployment', 'environment', 'ssl', 'backups', 'danger'])) { - $this->tab = $tab; - } - } - - /** - * Get the current workspace by slug. - */ - #[Computed] - public function workspace(): ?Workspace - { - $user = auth()->user(); - - if (! $user) { - return null; - } - - return $user->workspaces() - ->where('slug', $this->workspaceSlug) - ->first(); - } - - /** - * Available tabs for navigation. - */ - #[Computed] - public function tabs(): array - { - return [ - 'services' => [ - 'label' => 'Services', - 'icon' => 'puzzle-piece', - 'href' => route('hub.sites.settings', ['workspace' => $this->workspaceSlug, 'tab' => 'services']), - ], - 'general' => [ - 'label' => 'General', - 'icon' => 'gear', - 'href' => route('hub.sites.settings', ['workspace' => $this->workspaceSlug, 'tab' => 'general']), - ], - 'deployment' => [ - 'label' => 'Deployment', - 'icon' => 'rocket', - 'href' => route('hub.sites.settings', ['workspace' => $this->workspaceSlug, 'tab' => 'deployment']), - ], - 'environment' => [ - 'label' => 'Environment', - 'icon' => 'key', - 'href' => route('hub.sites.settings', ['workspace' => $this->workspaceSlug, 'tab' => 'environment']), - ], - 'ssl' => [ - 'label' => 'SSL & Security', - 'icon' => 'shield-check', - 'href' => route('hub.sites.settings', ['workspace' => $this->workspaceSlug, 'tab' => 'ssl']), - ], - 'backups' => [ - 'label' => 'Backups', - 'icon' => 'cloud-arrow-up', - 'href' => route('hub.sites.settings', ['workspace' => $this->workspaceSlug, 'tab' => 'backups']), - ], - 'danger' => [ - 'label' => 'Danger Zone', - 'icon' => 'triangle-exclamation', - 'href' => route('hub.sites.settings', ['workspace' => $this->workspaceSlug, 'tab' => 'danger']), - ], - ]; - } - - /** - * Service definitions with entitlement checks. - */ - #[Computed] - public function serviceCards(): array - { - $workspace = $this->workspace; - - $services = [ - [ - 'name' => 'Bio', - 'description' => 'Bio pages, short links & QR codes', - 'icon' => 'link', - 'color' => 'violet', - 'slug' => 'bio', - 'feature' => 'core.srv.bio', - 'adminRoute' => route('hub.services', ['service' => 'bio']), - 'features' => [ - 'Unlimited bio pages', - 'Custom domains', - 'Link analytics', - 'QR code generation', - ], - ], - [ - 'name' => 'Social', - 'description' => 'Social media scheduling & management', - 'icon' => 'share-nodes', - 'color' => 'blue', - 'slug' => 'social', - 'feature' => 'core.srv.social', - 'adminRoute' => route('hub.services', ['service' => 'social']), - 'features' => [ - 'Multi-platform posting', - 'Content calendar', - 'Team approvals', - 'Analytics & insights', - ], - ], - [ - 'name' => 'Analytics', - 'description' => 'Privacy-focused website analytics', - 'icon' => 'chart-line', - 'color' => 'cyan', - 'slug' => 'analytics', - 'feature' => 'core.srv.analytics', - 'adminRoute' => route('hub.services', ['service' => 'analytics']), - 'features' => [ - 'Real-time visitors', - 'Goal tracking', - 'Heatmaps', - 'Session replays', - ], - ], - [ - 'name' => 'Trust', - 'description' => 'Social proof & conversion widgets', - 'icon' => 'shield-check', - 'color' => 'orange', - 'slug' => 'trust', - 'feature' => 'core.srv.trust', - 'adminRoute' => route('hub.services', ['service' => 'trust']), - 'features' => [ - 'Purchase notifications', - 'Review widgets', - 'Visitor counts', - 'Custom campaigns', - ], - ], - [ - 'name' => 'Notify', - 'description' => 'Push notifications & campaigns', - 'icon' => 'bell', - 'color' => 'yellow', - 'slug' => 'notify', - 'feature' => 'core.srv.notify', - 'adminRoute' => route('hub.services', ['service' => 'notify']), - 'features' => [ - 'Browser push notifications', - 'Subscriber management', - 'Campaign scheduling', - 'Delivery analytics', - ], - ], - [ - 'name' => 'Support', - 'description' => 'Help desk & live chat', - 'icon' => 'headset', - 'color' => 'teal', - 'slug' => 'support', - 'feature' => 'core.srv.support', - 'adminRoute' => route('hub.support.inbox'), - 'features' => [ - 'Email ticketing', - 'Live chat widget', - 'Knowledge base', - 'Team collaboration', - ], - ], - ]; - - // Add entitlement status to each service - return collect($services)->map(function ($service) use ($workspace) { - $service['entitled'] = $workspace - ? $this->entitlements->can($workspace, $service['feature'])->isAllowed() - : false; - - return $service; - })->all(); - } - - /** - * Add a service to the workspace by provisioning its package. - */ - public function addService(string $featureCode): void - { - $workspace = $this->workspace; - - if (! $workspace) { - session()->flash('error', 'No workspace found.'); - - return; - } - - // Get service definition to get the name - $serviceCard = collect($this->serviceCards)->firstWhere('feature', $featureCode); - - if (! $serviceCard) { - session()->flash('error', 'Service not found.'); - - return; - } - - // Find or create the feature - $feature = Feature::firstOrCreate( - ['code' => $featureCode], - [ - 'name' => $serviceCard['name'].' Access', - 'description' => "Access to {$serviceCard['name']}", - 'category' => 'service', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'is_active' => true, - 'sort_order' => 1, - ] - ); - - // Find or create a package for this specific service - $packageCode = str_replace('.', '-', $featureCode).'-access'; - $package = Package::firstOrCreate( - ['code' => $packageCode], - [ - 'name' => $feature->name, - 'description' => "Access to {$feature->name}", - 'is_stackable' => true, - 'is_base_package' => false, - 'is_active' => true, - 'is_public' => false, - 'sort_order' => 99, - ] - ); - - // Attach feature to package if not already - if (! $package->features()->where('feature_id', $feature->id)->exists()) { - $package->features()->attach($feature->id, ['limit_value' => null]); - } - - // Provision the package to the workspace - $this->entitlements->provisionPackage($workspace, $packageCode, [ - 'source' => 'user', - 'metadata' => ['added_via' => 'site_settings_page'], - ]); - - // Clear caches - Cache::flush(); - - session()->flash('success', "{$feature->name} has been added to your site."); - } - - /** - * Switch to a different tab. - */ - public function switchTab(string $tab): void - { - if (array_key_exists($tab, $this->tabs)) { - $this->tab = $tab; - } - } - - public function render(): View - { - return view('hub::admin.site-settings'); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Sites.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/Sites.php deleted file mode 100644 index e6f2a5f..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/Sites.php +++ /dev/null @@ -1,282 +0,0 @@ -workspaceService = $workspaceService; - $this->entitlements = $entitlements; - } - - #[Computed] - public function workspace(): ?Workspace - { - return $this->workspaceService->currentModel(); - } - - #[Computed] - public function workspaceSlug(): string - { - return $this->workspace?->slug ?? ''; - } - - #[On('workspace-changed')] - public function refreshWorkspace(): void - { - unset($this->workspace); - unset($this->workspaceSlug); - unset($this->serviceCards); - unset($this->tabs); - } - - #[Computed] - public function tabs(): array - { - return [ - 'services' => [ - 'label' => 'Services', - 'icon' => 'puzzle-piece', - 'href' => route('hub.sites').'?tab=services', - ], - 'general' => [ - 'label' => 'General', - 'icon' => 'gear', - 'href' => route('hub.sites').'?tab=general', - ], - 'deployment' => [ - 'label' => 'Deployment', - 'icon' => 'rocket', - 'href' => route('hub.sites').'?tab=deployment', - ], - 'environment' => [ - 'label' => 'Environment', - 'icon' => 'key', - 'href' => route('hub.sites').'?tab=environment', - ], - 'ssl' => [ - 'label' => 'SSL & Security', - 'icon' => 'shield-check', - 'href' => route('hub.sites').'?tab=ssl', - ], - 'backups' => [ - 'label' => 'Backups', - 'icon' => 'cloud-arrow-up', - 'href' => route('hub.sites').'?tab=backups', - ], - 'danger' => [ - 'label' => 'Danger Zone', - 'icon' => 'triangle-exclamation', - 'href' => route('hub.sites').'?tab=danger', - ], - ]; - } - - #[Computed] - public function serviceCards(): array - { - $workspace = $this->workspace; - - $services = [ - [ - 'name' => 'Bio', - 'description' => 'Bio pages, short links & QR codes', - 'icon' => 'link', - 'color' => 'violet', - 'slug' => 'bio', - 'feature' => 'core.srv.bio', - 'adminRoute' => route('hub.services', ['service' => 'bio']), - 'features' => [ - 'Unlimited bio pages', - 'Custom domains', - 'Link analytics', - 'QR code generation', - ], - ], - [ - 'name' => 'Social', - 'description' => 'Social media scheduling & management', - 'icon' => 'share-nodes', - 'color' => 'blue', - 'slug' => 'social', - 'feature' => 'core.srv.social', - 'adminRoute' => route('hub.services', ['service' => 'social']), - 'features' => [ - 'Multi-platform posting', - 'Content calendar', - 'Team approvals', - 'Analytics & insights', - ], - ], - [ - 'name' => 'Analytics', - 'description' => 'Privacy-focused website analytics', - 'icon' => 'chart-line', - 'color' => 'cyan', - 'slug' => 'analytics', - 'feature' => 'core.srv.analytics', - 'adminRoute' => route('hub.services', ['service' => 'analytics']), - 'features' => [ - 'Real-time visitors', - 'Goal tracking', - 'Heatmaps', - 'Session replays', - ], - ], - [ - 'name' => 'Trust', - 'description' => 'Social proof & conversion widgets', - 'icon' => 'shield-check', - 'color' => 'orange', - 'slug' => 'trust', - 'feature' => 'core.srv.trust', - 'adminRoute' => route('hub.services', ['service' => 'trust']), - 'features' => [ - 'Purchase notifications', - 'Review widgets', - 'Visitor counts', - 'Custom campaigns', - ], - ], - [ - 'name' => 'Notify', - 'description' => 'Push notifications & campaigns', - 'icon' => 'bell', - 'color' => 'yellow', - 'slug' => 'notify', - 'feature' => 'core.srv.notify', - 'adminRoute' => route('hub.services', ['service' => 'notify']), - 'features' => [ - 'Browser push notifications', - 'Subscriber management', - 'Campaign scheduling', - 'Delivery analytics', - ], - ], - [ - 'name' => 'Support', - 'description' => 'Help desk & live chat', - 'icon' => 'headset', - 'color' => 'teal', - 'slug' => 'support', - 'feature' => 'core.srv.support', - 'adminRoute' => route('hub.support.inbox'), - 'features' => [ - 'Email ticketing', - 'Live chat widget', - 'Knowledge base', - 'Team collaboration', - ], - ], - ]; - - return collect($services)->map(function ($service) use ($workspace) { - $service['entitled'] = $workspace - ? $this->entitlements->can($workspace, $service['feature'])->isAllowed() - : false; - - return $service; - })->all(); - } - - public function addService(string $featureCode): void - { - $workspace = $this->workspace; - - if (! $workspace) { - session()->flash('error', 'No workspace found.'); - - return; - } - - $serviceCard = collect($this->serviceCards)->firstWhere('feature', $featureCode); - - if (! $serviceCard) { - session()->flash('error', 'Service not found.'); - - return; - } - - $feature = Feature::firstOrCreate( - ['code' => $featureCode], - [ - 'name' => $serviceCard['name'].' Access', - 'description' => "Access to {$serviceCard['name']}", - 'category' => 'service', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'is_active' => true, - 'sort_order' => 1, - ] - ); - - $packageCode = str_replace('.', '-', $featureCode).'-access'; - $package = Package::firstOrCreate( - ['code' => $packageCode], - [ - 'name' => $feature->name, - 'description' => "Access to {$feature->name}", - 'is_stackable' => true, - 'is_base_package' => false, - 'is_active' => true, - 'is_public' => false, - 'sort_order' => 99, - ] - ); - - if (! $package->features()->where('feature_id', $feature->id)->exists()) { - $package->features()->attach($feature->id, ['limit_value' => null]); - } - - $this->entitlements->provisionPackage($workspace, $packageCode, [ - 'source' => 'user', - 'metadata' => ['added_via' => 'site_settings_page'], - ]); - - Cache::flush(); - - session()->flash('success', "{$feature->name} has been added to your site."); - } - - public function switchTab(string $tab): void - { - if (array_key_exists($tab, $this->tabs)) { - $this->tab = $tab; - } - } - - public function render(): View - { - return view('hub::admin.site-settings'); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/UsageDashboard.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/UsageDashboard.php deleted file mode 100644 index 169cccb..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/UsageDashboard.php +++ /dev/null @@ -1,41 +0,0 @@ -defaultHostWorkspace(); - - if (! $workspace) { - $this->usageSummary = collect(); - $this->activePackages = collect(); - $this->activeBoosts = collect(); - - return; - } - - $this->usageSummary = $entitlementService->getUsageSummary($workspace); - $this->activePackages = $entitlementService->getActivePackages($workspace); - $this->activeBoosts = $entitlementService->getActiveBoosts($workspace); - } - - public function render() - { - return view('hub::admin.usage-dashboard') - ->layout('hub::admin.layouts.app', ['title' => 'Usage']); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/WaitlistManager.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/WaitlistManager.php deleted file mode 100644 index 1d68f98..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/WaitlistManager.php +++ /dev/null @@ -1,330 +0,0 @@ -user()?->isHades()) { - abort(403, 'Hades tier required for waitlist management.'); - } - - $this->refreshStats(); - } - - public function updatingSearch(): void - { - $this->resetPage(); - } - - public function updatedSelectAll(bool $value): void - { - if ($value) { - $this->selected = $this->getFilteredQuery()->pluck('id')->toArray(); - } else { - $this->selected = []; - } - } - - /** - * Send invite to a single entry. - */ - public function sendInvite(int $id): void - { - $entry = WaitlistEntry::findOrFail($id); - - if ($entry->isInvited()) { - session()->flash('error', 'This person has already been invited.'); - - return; - } - - $entry->generateInviteCode(); - $entry->notify(new WaitlistInviteNotification($entry)); - - session()->flash('message', "Invite sent to {$entry->email}"); - $this->refreshStats(); - } - - /** - * Send invites to selected entries. - */ - public function sendBulkInvites(): void - { - $entries = WaitlistEntry::whereIn('id', $this->selected) - ->whereNull('invited_at') - ->get(); - - if ($entries->isEmpty()) { - session()->flash('error', 'No pending entries selected.'); - - return; - } - - $count = 0; - foreach ($entries as $entry) { - $entry->generateInviteCode(); - $entry->notify(new WaitlistInviteNotification($entry)); - $count++; - } - - $this->selected = []; - $this->selectAll = false; - - session()->flash('message', "Sent {$count} invite(s) successfully."); - $this->refreshStats(); - } - - /** - * Resend invite to an already-invited entry. - */ - public function resendInvite(int $id): void - { - $entry = WaitlistEntry::findOrFail($id); - - if (! $entry->isInvited()) { - session()->flash('error', 'This person has not been invited yet.'); - - return; - } - - if ($entry->hasConverted()) { - session()->flash('error', 'This person has already registered.'); - - return; - } - - $entry->notify(new WaitlistInviteNotification($entry)); - - session()->flash('message', "Invite resent to {$entry->email}"); - } - - /** - * Delete a waitlist entry. - */ - public function delete(int $id): void - { - $entry = WaitlistEntry::findOrFail($id); - - if ($entry->hasConverted()) { - session()->flash('error', 'Cannot delete entries that have converted to users.'); - - return; - } - - $entry->delete(); - - session()->flash('message', 'Entry deleted.'); - $this->refreshStats(); - } - - /** - * Add manual note to entry. - */ - public function addNote(int $id, string $note): void - { - $entry = WaitlistEntry::findOrFail($id); - $entry->update(['notes' => $note]); - - session()->flash('message', 'Note saved.'); - } - - /** - * Export waitlist as CSV. - */ - public function export() - { - $entries = $this->getFilteredQuery()->get(); - - $csv = "Email,Name,Interest,Source,Status,Signed Up,Invited,Registered\n"; - - foreach ($entries as $entry) { - $status = $entry->hasConverted() ? 'Converted' : ($entry->isInvited() ? 'Invited' : 'Pending'); - $csv .= sprintf( - "%s,%s,%s,%s,%s,%s,%s,%s\n", - $entry->email, - $entry->name ?? '', - $entry->interest ?? '', - $entry->source ?? '', - $status, - $entry->created_at->format('Y-m-d'), - $entry->invited_at?->format('Y-m-d') ?? '', - $entry->registered_at?->format('Y-m-d') ?? '' - ); - } - - return response()->streamDownload(function () use ($csv) { - echo $csv; - }, 'waitlist-export-'.now()->format('Y-m-d').'.csv', [ - 'Content-Type' => 'text/csv', - ]); - } - - protected function refreshStats(): void - { - $this->totalCount = WaitlistEntry::count(); - $this->pendingCount = WaitlistEntry::pending()->count(); - $this->invitedCount = WaitlistEntry::invited()->count(); - $this->convertedCount = WaitlistEntry::converted()->count(); - } - - protected function getFilteredQuery() - { - return WaitlistEntry::query() - ->when($this->search, function ($query) { - $query->where(function ($q) { - $q->where('email', 'like', "%{$this->search}%") - ->orWhere('name', 'like', "%{$this->search}%"); - }); - }) - ->when($this->statusFilter === 'pending', fn ($q) => $q->pending()) - ->when($this->statusFilter === 'invited', fn ($q) => $q->invited()) - ->when($this->statusFilter === 'converted', fn ($q) => $q->converted()) - ->when($this->interestFilter, fn ($q) => $q->where('interest', $this->interestFilter)) - ->latest(); - } - - #[Computed] - public function entries() - { - return $this->getFilteredQuery()->paginate(25); - } - - #[Computed] - public function interests(): array - { - return WaitlistEntry::select('interest') - ->whereNotNull('interest') - ->distinct() - ->pluck('interest') - ->mapWithKeys(fn ($i) => [$i => ucfirst($i)]) - ->all(); - } - - #[Computed] - public function statusOptions(): array - { - return [ - 'pending' => 'Pending invite', - 'invited' => 'Invited (not registered)', - 'converted' => 'Converted to user', - ]; - } - - #[Computed] - public function tableColumns(): array - { - return [ - ['label' => '', 'width' => 'w-12'], - 'Email', - 'Name', - 'Interest', - 'Source', - ['label' => 'Status', 'align' => 'center'], - 'Signed up', - ['label' => 'Actions', 'align' => 'center'], - ]; - } - - #[Computed] - public function tableRows(): array - { - return $this->entries->map(function ($e) { - // Status badge - if ($e->hasConverted()) { - $statusBadge = ['badge' => 'Converted', 'color' => 'green']; - $statusExtra = $e->user ? ['muted' => $e->registered_at->diffForHumans()] : null; - } elseif ($e->isInvited()) { - $statusBadge = ['badge' => 'Invited', 'color' => 'blue']; - $statusExtra = ['muted' => $e->invited_at->diffForHumans()]; - } else { - $statusBadge = ['badge' => 'Pending', 'color' => 'amber']; - $statusExtra = null; - } - - // Actions - $actions = []; - if ($e->hasConverted()) { - if ($e->user) { - $actions[] = ['icon' => 'user', 'href' => route('admin.platform.user', $e->user_id), 'title' => 'View user']; - } - } elseif ($e->isInvited()) { - $actions[] = ['icon' => 'arrow-path', 'click' => "resendInvite({$e->id})", 'title' => 'Resend invite']; - } else { - $actions[] = ['icon' => 'paper-airplane', 'click' => "sendInvite({$e->id})", 'title' => 'Send invite', 'variant' => 'primary']; - } - if (! $e->hasConverted()) { - $actions[] = ['icon' => 'trash', 'click' => "delete({$e->id})", 'confirm' => 'Are you sure you want to delete this waitlist entry?', 'title' => 'Delete', 'class' => 'text-red-600']; - } - - // Checkbox cell (custom HTML) - $checkboxCell = ! $e->hasConverted() - ? ['html' => ''] - : ''; - - return [ - $checkboxCell, - [ - 'lines' => array_filter([ - ['bold' => $e->email], - $e->invite_code ? ['mono' => $e->invite_code] : null, - ]), - ], - $e->name ?? ['muted' => '-'], - $e->interest ? ['badge' => ucfirst($e->interest), 'color' => 'purple'] : ['muted' => '-'], - ['muted' => $e->source ?? 'direct'], - $statusExtra ? ['lines' => [$statusBadge, $statusExtra]] : $statusBadge, - [ - 'lines' => [ - ['bold' => $e->created_at->format('d M Y')], - ['muted' => $e->created_at->diffForHumans()], - ], - ], - ['actions' => $actions], - ]; - })->all(); - } - - public function render() - { - return view('hub::admin.waitlist-manager') - ->layout('hub::admin.layouts.app', ['title' => 'Waitlist']); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/WorkspaceSwitcher.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/WorkspaceSwitcher.php deleted file mode 100644 index d5d2d53..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/WorkspaceSwitcher.php +++ /dev/null @@ -1,75 +0,0 @@ -url() returns /livewire/update during updates. - */ - public string $returnUrl = ''; - - protected WorkspaceService $workspaceService; - - public function boot(WorkspaceService $workspaceService): void - { - $this->workspaceService = $workspaceService; - } - - public function mount(): void - { - $this->workspaces = $this->workspaceService->all(); - $this->current = $this->workspaceService->current(); - - // Capture the current URL on mount (initial page load) - // This is the page URL, not the Livewire endpoint - $this->returnUrl = url()->current(); - } - - /** - * Refresh workspace data when a workspace is activated elsewhere. - */ - #[On('workspace-activated')] - public function refreshWorkspaces(): void - { - $this->workspaces = $this->workspaceService->all(); - $this->current = $this->workspaceService->current(); - } - - public function switchWorkspace(string $slug): void - { - $result = $this->workspaceService->setCurrent($slug); - - if (! $result) { - // User doesn't have access to this workspace - return; - } - - $this->current = $this->workspaceService->current(); - $this->open = false; - - // Dispatch event to refresh any workspace-aware components - $this->dispatch('workspace-changed', workspace: $slug); - - // Redirect to the page we were on (captured during mount) - $this->redirect($this->returnUrl ?: route('hub.dashboard')); - } - - public function render() - { - return view('hub::admin.workspace-switcher'); - } -} diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/WpConnectorSettings.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/WpConnectorSettings.php deleted file mode 100644 index a1fb540..0000000 --- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/WpConnectorSettings.php +++ /dev/null @@ -1,136 +0,0 @@ -workspace = $workspace; - $this->enabled = $workspace->wp_connector_enabled; - $this->wordpressUrl = $workspace->wp_connector_url ?? ''; - } - - #[Computed] - public function webhookUrl(): string - { - return $this->workspace->wp_connector_webhook_url; - } - - #[Computed] - public function webhookSecret(): string - { - return $this->workspace->wp_connector_secret ?? ''; - } - - #[Computed] - public function isVerified(): bool - { - return $this->workspace->wp_connector_verified_at !== null; - } - - #[Computed] - public function lastSync(): ?string - { - return $this->workspace->wp_connector_last_sync?->diffForHumans(); - } - - public function save(): void - { - $this->validate([ - 'wordpressUrl' => 'nullable|url', - ]); - - if ($this->enabled && empty($this->wordpressUrl)) { - Flux::toast('WordPress URL is required when connector is enabled', variant: 'danger'); - - return; - } - - if ($this->enabled) { - $this->workspace->enableWpConnector($this->wordpressUrl); - Flux::toast('WordPress connector enabled'); - } else { - $this->workspace->disableWpConnector(); - Flux::toast('WordPress connector disabled'); - } - - $this->workspace->refresh(); - } - - public function regenerateSecret(): void - { - $this->workspace->generateWpConnectorSecret(); - $this->workspace->refresh(); - - Flux::toast('Webhook secret regenerated. Update the secret in your WordPress plugin.'); - } - - public function testConnection(): void - { - $this->testing = true; - $this->testResult = null; - - if (empty($this->workspace->wp_connector_url)) { - $this->testResult = 'WordPress URL is not configured'; - $this->testSuccess = false; - $this->testing = false; - - return; - } - - try { - // Try to reach the WordPress REST API - $response = Http::timeout(10)->get( - $this->workspace->wp_connector_url.'/wp-json/wp/v2' - ); - - if ($response->successful()) { - $this->testResult = 'Connected to WordPress REST API'; - $this->testSuccess = true; - $this->workspace->markWpConnectorVerified(); - } else { - $this->testResult = 'WordPress returned HTTP '.$response->status(); - $this->testSuccess = false; - } - } catch (\Exception $e) { - $this->testResult = 'Connection failed: '.$e->getMessage(); - $this->testSuccess = false; - } - - $this->testing = false; - $this->workspace->refresh(); - } - - public function copyToClipboard(string $value): void - { - $this->dispatch('copy-to-clipboard', text: $value); - Flux::toast('Copied to clipboard'); - } - - public function render(): View - { - return view('hub::admin.wp-connector-settings'); - } -} diff --git a/packages/core-api/README.md b/packages/core-api/README.md deleted file mode 100644 index 4fc8ca1..0000000 --- a/packages/core-api/README.md +++ /dev/null @@ -1,155 +0,0 @@ -# Core API Package - -REST API infrastructure with OpenAPI documentation, rate limiting, webhook signing, and secure API key management. - -## Installation - -```bash -composer require host-uk/core-api -``` - -## Features - -### OpenAPI/Swagger Documentation -Auto-generated API documentation with multiple UI options: - -```php -use Core\Mod\Api\Documentation\Attributes\{ApiTag, ApiResponse}; - -#[ApiTag('Products')] -#[ApiResponse(200, ProductResource::class)] -class ProductController extends Controller -{ - public function index() - { - return ProductResource::collection(Product::paginate()); - } -} -``` - -**Access documentation:** -- `GET /api/docs` - Scalar UI (default) -- `GET /api/docs/swagger` - Swagger UI -- `GET /api/docs/redoc` - ReDoc -- `GET /api/docs/openapi.json` - OpenAPI spec - -### Secure API Keys -Bcrypt hashing with backward compatibility: - -```php -use Core\Mod\Api\Models\ApiKey; - -$key = ApiKey::create([ - 'name' => 'Production API', - 'workspace_id' => $workspace->id, - 'scopes' => ['read', 'write'], -]); - -// Returns the plain key (shown only once) -$plainKey = $key->getPlainKey(); -``` - -**Features:** -- Bcrypt hashing for new keys -- Legacy SHA-256 support -- Key rotation with grace periods -- Scope-based permissions - -### Rate Limiting -Granular rate limiting per endpoint: - -```php -use Core\Mod\Api\RateLimit\RateLimit; - -#[RateLimit(limit: 100, window: 60, burst: 1.2)] -class ProductController extends Controller -{ - // Limited to 100 requests per 60 seconds - // With 20% burst allowance -} -``` - -**Features:** -- Per-endpoint limits -- Workspace isolation -- Tier-based limits -- Standard headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` - -### Webhook Signing -HMAC-SHA256 signatures for outbound webhooks: - -```php -use Core\Mod\Api\Models\WebhookEndpoint; - -$endpoint = WebhookEndpoint::create([ - 'url' => 'https://example.com/webhooks', - 'events' => ['order.created', 'order.updated'], - 'secret' => WebhookEndpoint::generateSecret(), -]); -``` - -**Verification:** -```php -$signature = hash_hmac('sha256', $timestamp . '.' . $payload, $secret); -hash_equals($signature, $request->header('X-Webhook-Signature')); -``` - -### Scope Enforcement -Fine-grained API permissions: - -```php -use Core\Mod\Api\Middleware\EnforceApiScope; - -Route::middleware(['api', EnforceApiScope::class.':write']) - ->post('/products', [ProductController::class, 'store']); -``` - -## Configuration - -```php -// config/api.php (after php artisan vendor:publish --tag=api-config) - -return [ - 'rate_limits' => [ - 'default' => 60, - 'tiers' => [ - 'free' => 100, - 'pro' => 1000, - 'enterprise' => 10000, - ], - ], - 'docs' => [ - 'enabled' => env('API_DOCS_ENABLED', true), - 'require_auth' => env('API_DOCS_REQUIRE_AUTH', false), - ], -]; -``` - -## API Guides - -The package includes comprehensive guides: - -- **Authentication** - API key creation and usage -- **Quick Start** - Getting started in 5 minutes -- **Rate Limiting** - Understanding limits and tiers -- **Webhooks** - Setting up and verifying webhooks -- **Errors** - Error codes and handling - -Access at: `/api/guides` - -## Requirements - -- PHP 8.2+ -- Laravel 11+ or 12+ - -## Changelog - -See [changelog/2026/jan/features.md](changelog/2026/jan/features.md) for recent changes. - -## Security - -See [changelog/2026/jan/security.md](changelog/2026/jan/security.md) for security updates. - -## License - -EUPL-1.2 - See [LICENSE](../../LICENSE) for details. diff --git a/packages/core-api/TODO.md b/packages/core-api/TODO.md deleted file mode 100644 index fa18354..0000000 --- a/packages/core-api/TODO.md +++ /dev/null @@ -1,246 +0,0 @@ -# Core-API TODO - -## Testing & Quality Assurance - -### High Priority - -- [ ] **Test Coverage: API Key Security** - Test bcrypt hashing and rotation - - [ ] Test API key creation with bcrypt hashing - - [ ] Test API key authentication - - [ ] Test key rotation with grace period - - [ ] Test key revocation - - [ ] Test scoped key access - - **Estimated effort:** 3-4 hours - -- [ ] **Test Coverage: Webhook System** - Test delivery and signatures - - [ ] Test webhook endpoint registration - - [ ] Test HMAC-SHA256 signature generation - - [ ] Test signature verification - - [ ] Test webhook delivery retry logic - - [ ] Test exponential backoff - - [ ] Test delivery status tracking - - **Estimated effort:** 4-5 hours - -- [ ] **Test Coverage: Rate Limiting** - Test tier-based limits - - [ ] Test per-tier rate limits - - [ ] Test rate limit headers - - [ ] Test quota exceeded responses - - [ ] Test workspace-scoped limits - - [ ] Test burst allowance - - **Estimated effort:** 3-4 hours - -- [ ] **Test Coverage: Scope Enforcement** - Test permission system - - [ ] Test EnforceApiScope middleware - - [ ] Test wildcard scopes (posts:*, *:read) - - [ ] Test scope inheritance - - [ ] Test scope validation errors - - **Estimated effort:** 3-4 hours - -### Medium Priority - -- [ ] **Test Coverage: OpenAPI Documentation** - Test spec generation - - [ ] Test OpenApiBuilder with controller scanning - - [ ] Test #[ApiParameter] attribute parsing - - [ ] Test #[ApiResponse] rendering - - [ ] Test #[ApiSecurity] requirements - - [ ] Test #[ApiHidden] filtering - - [ ] Test extension system - - **Estimated effort:** 4-5 hours - -- [ ] **Test Coverage: Usage Alerts** - Test quota monitoring - - [ ] Test CheckApiUsageAlerts command - - [ ] Test HighApiUsageNotification delivery - - [ ] Test usage alert thresholds - - [ ] Test alert history tracking - - **Estimated effort:** 2-3 hours - -### Low Priority - -- [ ] **Test Coverage: Webhook Payload Validation** - Test request validation - - [ ] Test payload size limits - - [ ] Test content-type validation - - [ ] Test malformed JSON handling - - **Estimated effort:** 2-3 hours - -## Features & Enhancements - -### High Priority - -- [ ] **Feature: API Versioning** - Support multiple API versions - - [ ] Implement version routing (v1, v2) - - [ ] Add version deprecation warnings - - [ ] Support version-specific transformers - - [ ] Document migration between versions - - [ ] Test backward compatibility - - **Estimated effort:** 6-8 hours - - **Files:** `src/Mod/Api/Versioning/` - -- [ ] **Feature: GraphQL API** - Alternative to REST - - [ ] Implement GraphQL schema generation - - [ ] Add query resolver system - - [ ] Support mutations - - [ ] Add introspection - - [ ] Test complex nested queries - - **Estimated effort:** 12-16 hours - - **Files:** `src/Mod/Api/GraphQL/` - -- [ ] **Feature: Batch Operations** - Bulk API requests - - [ ] Support batched requests - - [ ] Implement atomic batch transactions - - [ ] Add batch size limits - - [ ] Test error handling in batches - - **Estimated effort:** 4-6 hours - - **Files:** `src/Mod/Api/Batch/` - -### Medium Priority - -- [ ] **Enhancement: Webhook Transformers** - Custom payload formatting - - [ ] Create transformer interface - - [ ] Support per-endpoint transformers - - [ ] Add JSON-LD format support - - [ ] Test with complex data structures - - **Estimated effort:** 3-4 hours - - **Files:** `src/Mod/Api/Webhooks/Transformers/` - -- [ ] **Enhancement: API Analytics** - Detailed usage metrics - - [ ] Track API calls per endpoint - - [ ] Monitor response times - - [ ] Track error rates - - [ ] Create admin dashboard - - [ ] Add export to CSV - - **Estimated effort:** 5-6 hours - - **Files:** `src/Mod/Api/Analytics/` - -- [ ] **Enhancement: Request Throttling Strategies** - Advanced rate limiting - - [ ] Implement sliding window algorithm - - [ ] Add burst allowance - - [ ] Support custom throttle strategies - - [ ] Add per-endpoint rate limits - - **Estimated effort:** 4-5 hours - - **Files:** `src/Mod/Api/RateLimit/Strategies/` - -### Low Priority - -- [ ] **Enhancement: API Client SDK Generator** - Auto-generate SDKs - - [ ] Generate PHP SDK from OpenAPI - - [ ] Generate JavaScript SDK - - [ ] Generate Python SDK - - [ ] Add usage examples - - **Estimated effort:** 8-10 hours - - **Files:** `src/Mod/Api/Sdk/` - -- [ ] **Enhancement: Webhook Retry Dashboard** - Visual delivery monitoring - - [ ] Create delivery status dashboard - - [ ] Add manual retry button - - [ ] Show delivery timeline - - [ ] Export delivery logs - - **Estimated effort:** 3-4 hours - - **Files:** `src/Website/Api/Components/` - -## Security - -### High Priority - -- [ ] **Security: API Key IP Whitelisting** - Restrict key usage - - [ ] Add allowed_ips column to api_keys - - [ ] Validate request IP against whitelist - - [ ] Test with IPv4 and IPv6 - - [ ] Add CIDR notation support - - **Estimated effort:** 3-4 hours - -- [ ] **Security: Request Signing** - Prevent replay attacks - - [ ] Implement timestamp validation - - [ ] Add nonce tracking - - [ ] Support custom signing algorithms - - [ ] Test with clock skew - - **Estimated effort:** 4-5 hours - -### Medium Priority - -- [ ] **Security: Webhook Mutual TLS** - Secure webhook delivery - - [ ] Add client certificate support - - [ ] Implement certificate validation - - [ ] Test with self-signed certs - - **Estimated effort:** 4-5 hours - -- [ ] **Audit: API Permission Model** - Review scope granularity - - [ ] Audit all API scopes - - [ ] Ensure least-privilege defaults - - [ ] Document scope requirements - - [ ] Test scope escalation attempts - - **Estimated effort:** 3-4 hours - -## Documentation - -- [x] **Guide: Building REST APIs** - Complete tutorial - - [x] Document resource creation - - [x] Show pagination best practices - - [x] Explain filtering and sorting - - [x] Add authentication examples - - **Completed:** January 2026 - - **File:** `docs/packages/api/building-rest-apis.md` - -- [x] **Guide: Webhook Integration** - For API consumers - - [x] Document signature verification - - [x] Show retry handling - - [x] Explain event types - - [x] Add code examples (PHP, JS, Python) - - **Completed:** January 2026 - - **File:** `docs/packages/api/webhook-integration.md` - -- [x] **API Reference: All Endpoints** - Complete OpenAPI spec - - [x] Document all request parameters - - [x] Add response examples - - [x] Show error responses - - [x] Include authentication notes - - **Completed:** January 2026 - - **File:** `docs/packages/api/endpoints-reference.md` - -## Code Quality - -- [ ] **Refactor: Extract Rate Limiter** - Reusable rate limiting - - [ ] Create standalone RateLimiter service - - [ ] Support multiple backends (Redis, DB, memory) - - [ ] Add configurable strategies - - [ ] Test with high concurrency - - **Estimated effort:** 3-4 hours - -- [ ] **Refactor: Webhook Queue Priority** - Prioritize critical webhooks - - [ ] Add priority field to webhooks - - [ ] Implement priority queue - - [ ] Test delivery order - - **Estimated effort:** 2-3 hours - -- [ ] **PHPStan: Fix Level 5 Errors** - Improve type safety - - [ ] Fix array shape types in resources - - [ ] Add missing return types - - [ ] Fix property type declarations - - **Estimated effort:** 2-3 hours - -## Performance - -- [ ] **Optimization: Response Caching** - Cache GET requests - - [ ] Implement HTTP cache headers - - [ ] Add ETag support - - [ ] Support cache invalidation - - [ ] Test with CDN - - **Estimated effort:** 3-4 hours - -- [ ] **Optimization: Database Query Reduction** - Eager load relationships - - [ ] Audit N+1 queries in resources - - [ ] Add eager loading - - [ ] Benchmark before/after - - **Estimated effort:** 2-3 hours - ---- - -## Completed (January 2026) - -- [x] **API Key Hashing** - Bcrypt hashing for all API keys -- [x] **Webhook Signatures** - HMAC-SHA256 signature verification -- [x] **Scope System** - Fine-grained API permissions -- [x] **Rate Limiting** - Tier-based rate limits with usage alerts -- [x] **OpenAPI Documentation** - Auto-generated API docs with Swagger/Scalar/ReDoc -- [x] **Documentation** - Complete API package documentation - -*See `changelog/2026/jan/` for completed features.* diff --git a/packages/core-api/changelog/2026/jan/features.md b/packages/core-api/changelog/2026/jan/features.md deleted file mode 100644 index dca84c4..0000000 --- a/packages/core-api/changelog/2026/jan/features.md +++ /dev/null @@ -1,122 +0,0 @@ -# Core-API - January 2026 - -## Features Implemented - -### Webhook Signing (Outbound) - -HMAC-SHA256 signatures with timestamp for replay attack protection. - -**Files:** -- `Services/WebhookSignature.php` - Sign/verify service -- `Models/WebhookEndpoint.php` - Signature methods -- `Models/WebhookDelivery.php` - Headers in payload - -**Headers:** -| Header | Description | -|--------|-------------| -| `X-Webhook-Signature` | HMAC-SHA256 (64 hex chars) | -| `X-Webhook-Timestamp` | Unix timestamp | -| `X-Webhook-Event` | Event type | -| `X-Webhook-Id` | Unique delivery ID | - -**Verification:** -```php -$signature = hash_hmac('sha256', $timestamp . '.' . $payload, $secret); -hash_equals($signature, $headerSignature); -``` - ---- - -### API Key Security - -Secure bcrypt hashing with backward compatibility for legacy SHA-256 keys. - -**Files:** -- `Models/ApiKey.php` - Secure hashing, rotation, grace periods -- `Migrations/2026_01_27_*` - Added hash_algorithm column - -**Features:** -- New keys use `Hash::make()` (bcrypt) -- Legacy keys continue working -- Key rotation with grace periods -- Scopes: `legacyHash()`, `secureHash()`, `inGracePeriod()` - ---- - -### Rate Limiting - -Granular rate limiting with sliding window algorithm. - -**Files:** -- `RateLimit/RateLimitService.php` - Sliding window service -- `RateLimit/RateLimitResult.php` - Result DTO -- `RateLimit/RateLimit.php` - PHP 8 attribute -- `Middleware/RateLimitApi.php` - Enhanced middleware -- `Exceptions/RateLimitExceededException.php` - -**Features:** -- Per-endpoint limits via `#[RateLimit]` attribute or config -- Per-workspace isolation -- Tier-based limits (free/starter/pro/agency/enterprise) -- Burst allowance (e.g., 20% over limit) -- Headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` - -**Usage:** -```php -#[RateLimit(limit: 100, window: 60, burst: 1.2)] -public function index() { ... } -``` - ---- - -### OpenAPI/Swagger Documentation - -Auto-generated API documentation with multiple UI options. - -**Files:** -- `Documentation/OpenApiBuilder.php` - Spec generator -- `Documentation/DocumentationController.php` - Routes -- `Documentation/Attributes/` - ApiTag, ApiResponse, ApiSecurity, ApiParameter, ApiHidden -- `Documentation/Extensions/` - WorkspaceHeader, RateLimit, ApiKeyAuth -- `Documentation/Views/` - Swagger, Scalar, ReDoc - -**Routes:** -| Route | Description | -|-------|-------------| -| `GET /api/docs` | Default UI (Scalar) | -| `GET /api/docs/swagger` | Swagger UI | -| `GET /api/docs/scalar` | Scalar API Reference | -| `GET /api/docs/redoc` | ReDoc | -| `GET /api/docs/openapi.json` | OpenAPI spec (JSON) | -| `GET /api/docs/openapi.yaml` | OpenAPI spec (YAML) | - -**Usage:** -```php -#[ApiTag('Users')] -#[ApiResponse(200, UserResource::class)] -#[ApiParameter('page', 'query', 'integer')] -public function index() { ... } -``` - -**Config:** `API_DOCS_ENABLED`, `API_DOCS_TITLE`, `API_DOCS_REQUIRE_AUTH` - ---- - -### Documentation Genericization - -Removed vendor-specific branding from API documentation. - -**Files:** -- `Website/Api/View/Blade/guides/authentication.blade.php` -- `Website/Api/View/Blade/guides/errors.blade.php` -- `Website/Api/View/Blade/guides/index.blade.php` -- `Website/Api/View/Blade/guides/qrcodes.blade.php` -- `Website/Api/View/Blade/guides/quickstart.blade.php` - -**Changes:** -- Replaced "Host UK API" with generic "API" -- Removed specific domain references (lt.hn) -- Replaced sign-up URLs with generic account requirements -- Made example URLs vendor-neutral - -**Impact:** Framework documentation is now vendor-agnostic and suitable for open-source distribution. diff --git a/packages/core-api/composer.json b/packages/core-api/composer.json deleted file mode 100644 index ca9ab32..0000000 --- a/packages/core-api/composer.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "host-uk/core-api", - "description": "REST API module for Core PHP framework", - "keywords": ["laravel", "api", "rest", "json"], - "license": "EUPL-1.2", - "require": { - "php": "^8.2", - "host-uk/core": "@dev", - "symfony/yaml": "^7.0" - }, - "autoload": { - "psr-4": { - "Core\\Mod\\Api\\": "src/Mod/Api/", - "Core\\Website\\Api\\": "src/Website/Api/" - } - }, - "extra": { - "laravel": { - "providers": [] - } - }, - "minimum-stability": "stable", - "prefer-stable": true -} diff --git a/packages/core-api/src/Mod/Api/Boot.php b/packages/core-api/src/Mod/Api/Boot.php deleted file mode 100644 index e02e0b6..0000000 --- a/packages/core-api/src/Mod/Api/Boot.php +++ /dev/null @@ -1,98 +0,0 @@ - - */ - public static array $listens = [ - ApiRoutesRegistering::class => 'onApiRoutes', - ConsoleBooting::class => 'onConsole', - ]; - - /** - * Register any application services. - */ - public function register(): void - { - $this->mergeConfigFrom( - __DIR__.'/config.php', - $this->moduleName - ); - - // Register RateLimitService as a singleton - $this->app->singleton(RateLimitService::class, function ($app) { - return new RateLimitService($app->make(CacheRepository::class)); - }); - - // Register API Documentation provider - $this->app->register(DocumentationServiceProvider::class); - } - - /** - * Bootstrap any application services. - */ - public function boot(): void - { - $this->loadMigrationsFrom(__DIR__.'/Migrations'); - } - - // ------------------------------------------------------------------------- - // Event-driven handlers - // ------------------------------------------------------------------------- - - public function onApiRoutes(ApiRoutesRegistering $event): void - { - // Middleware aliases registered via event - $event->middleware('api.auth', Middleware\AuthenticateApiKey::class); - $event->middleware('api.scope', Middleware\CheckApiScope::class); - $event->middleware('api.scope.enforce', Middleware\EnforceApiScope::class); - $event->middleware('api.rate', Middleware\RateLimitApi::class); - $event->middleware('auth.api', Middleware\AuthenticateApiKey::class); - - // Core API routes (SEO, Pixel, Entitlements, MCP) - if (file_exists(__DIR__.'/Routes/api.php')) { - $event->routes(fn () => Route::middleware('api')->group(__DIR__.'/Routes/api.php')); - } - } - - public function onConsole(ConsoleBooting $event): void - { - // Register middleware aliases for CLI context (artisan route:list etc) - $event->middleware('api.auth', Middleware\AuthenticateApiKey::class); - $event->middleware('api.scope', Middleware\CheckApiScope::class); - $event->middleware('api.scope.enforce', Middleware\EnforceApiScope::class); - $event->middleware('api.rate', Middleware\RateLimitApi::class); - $event->middleware('auth.api', Middleware\AuthenticateApiKey::class); - - // Register console commands - $event->command(Console\Commands\CleanupExpiredGracePeriods::class); - $event->command(Console\Commands\CheckApiUsageAlerts::class); - } -} diff --git a/packages/core-api/src/Mod/Api/Concerns/HasApiResponses.php b/packages/core-api/src/Mod/Api/Concerns/HasApiResponses.php deleted file mode 100644 index 1db3bf3..0000000 --- a/packages/core-api/src/Mod/Api/Concerns/HasApiResponses.php +++ /dev/null @@ -1,92 +0,0 @@ -json([ - 'error' => 'no_workspace', - 'message' => 'No workspace found. Please select a workspace first.', - ], 404); - } - - /** - * Return a resource not found response. - */ - protected function notFoundResponse(string $resource = 'Resource'): JsonResponse - { - return response()->json([ - 'error' => 'not_found', - 'message' => "{$resource} not found.", - ], 404); - } - - /** - * Return a feature limit reached response. - */ - protected function limitReachedResponse(string $feature, ?string $message = null): JsonResponse - { - return response()->json([ - 'error' => 'feature_limit_reached', - 'message' => $message ?? 'You have reached your limit for this feature.', - 'feature' => $feature, - 'upgrade_url' => route('hub.usage'), - ], 403); - } - - /** - * Return an access denied response. - */ - protected function accessDeniedResponse(string $message = 'Access denied.'): JsonResponse - { - return response()->json([ - 'error' => 'access_denied', - 'message' => $message, - ], 403); - } - - /** - * Return a success response with message. - */ - protected function successResponse(string $message, array $data = []): JsonResponse - { - return response()->json(array_merge([ - 'message' => $message, - ], $data)); - } - - /** - * Return a created response. - */ - protected function createdResponse(mixed $resource, string $message = 'Created successfully.'): JsonResponse - { - return response()->json([ - 'message' => $message, - 'data' => $resource, - ], 201); - } - - /** - * Return a validation error response. - */ - protected function validationErrorResponse(array $errors): JsonResponse - { - return response()->json([ - 'error' => 'validation_failed', - 'message' => 'The given data was invalid.', - 'errors' => $errors, - ], 422); - } -} diff --git a/packages/core-api/src/Mod/Api/Concerns/HasApiTokens.php b/packages/core-api/src/Mod/Api/Concerns/HasApiTokens.php deleted file mode 100644 index fb02590..0000000 --- a/packages/core-api/src/Mod/Api/Concerns/HasApiTokens.php +++ /dev/null @@ -1,76 +0,0 @@ - - */ - public function tokens(): HasMany - { - return $this->hasMany(UserToken::class); - } - - /** - * Create a new personal access token for the user. - * - * @param string $name Human-readable name for the token - * @param \DateTimeInterface|null $expiresAt Optional expiration date - * @return array{token: string, model: UserToken} Plain-text token and model instance - */ - public function createToken(string $name, ?\DateTimeInterface $expiresAt = null): array - { - // Generate a random 40-character token - $plainTextToken = Str::random(40); - - // Hash it for storage - $hashedToken = hash('sha256', $plainTextToken); - - // Create the token record - $token = $this->tokens()->create([ - 'name' => $name, - 'token' => $hashedToken, - 'expires_at' => $expiresAt, - ]); - - return [ - 'token' => $plainTextToken, - 'model' => $token, - ]; - } - - /** - * Revoke all tokens for this user. - * - * @return int Number of tokens deleted - */ - public function revokeAllTokens(): int - { - return $this->tokens()->delete(); - } - - /** - * Revoke a specific token by its ID. - * - * @return bool True if the token was deleted - */ - public function revokeToken(int $tokenId): bool - { - return (bool) $this->tokens()->where('id', $tokenId)->delete(); - } -} diff --git a/packages/core-api/src/Mod/Api/Concerns/ResolvesWorkspace.php b/packages/core-api/src/Mod/Api/Concerns/ResolvesWorkspace.php deleted file mode 100644 index 957958b..0000000 --- a/packages/core-api/src/Mod/Api/Concerns/ResolvesWorkspace.php +++ /dev/null @@ -1,84 +0,0 @@ -attributes->get('workspace'); - if ($workspace instanceof Workspace) { - return $workspace; - } - - // Check for explicit workspace_id - $workspaceId = $request->attributes->get('workspace_id') - ?? $request->input('workspace_id') - ?? $request->header('X-Workspace-Id'); - - if ($workspaceId) { - return $this->findWorkspaceForUser($request, (int) $workspaceId); - } - - // Fall back to user's default workspace - $user = $request->user(); - if ($user instanceof User) { - return $user->defaultHostWorkspace(); - } - - return null; - } - - /** - * Find a workspace by ID that the user has access to. - */ - protected function findWorkspaceForUser(Request $request, int $workspaceId): ?Workspace - { - $user = $request->user(); - - if (! $user instanceof User) { - return null; - } - - return $user->workspaces() - ->where('workspaces.id', $workspaceId) - ->first(); - } - - /** - * Get the authentication type. - */ - protected function getAuthType(Request $request): string - { - return $request->attributes->get('auth_type', 'session'); - } - - /** - * Check if authenticated via API key. - */ - protected function isApiKeyAuth(Request $request): bool - { - return $this->getAuthType($request) === 'api_key'; - } -} diff --git a/packages/core-api/src/Mod/Api/Console/Commands/CheckApiUsageAlerts.php b/packages/core-api/src/Mod/Api/Console/Commands/CheckApiUsageAlerts.php deleted file mode 100644 index 6163605..0000000 --- a/packages/core-api/src/Mod/Api/Console/Commands/CheckApiUsageAlerts.php +++ /dev/null @@ -1,291 +0,0 @@ -info('API usage alerts are disabled.'); - - return Command::SUCCESS; - } - - // Load thresholds from config (sorted by severity, critical first) - $this->thresholds = config('api.alerts.thresholds', [ - 'critical' => 95, - 'warning' => 80, - ]); - arsort($this->thresholds); - - $this->cooldownHours = config('api.alerts.cooldown_hours', self::DEFAULT_COOLDOWN_HOURS); - - $dryRun = $this->option('dry-run'); - $specificWorkspace = $this->option('workspace'); - - if ($dryRun) { - $this->warn('DRY RUN MODE - No notifications will be sent'); - $this->newLine(); - } - - // Get workspaces with active API keys - $query = Workspace::whereHas('apiKeys', function ($q) { - $q->active(); - }); - - if ($specificWorkspace) { - $query->where('id', $specificWorkspace); - } - - $workspaces = $query->get(); - - if ($workspaces->isEmpty()) { - $this->info('No workspaces with active API keys found.'); - - return Command::SUCCESS; - } - - $alertsSent = 0; - $alertsSkipped = 0; - - foreach ($workspaces as $workspace) { - $result = $this->checkWorkspaceUsage($workspace, $rateLimitService, $dryRun); - $alertsSent += $result['sent']; - $alertsSkipped += $result['skipped']; - } - - $this->newLine(); - $this->info("Alerts sent: {$alertsSent}"); - $this->info("Alerts skipped (cooldown): {$alertsSkipped}"); - - return Command::SUCCESS; - } - - /** - * Check usage for a workspace and send alerts if needed. - * - * @return array{sent: int, skipped: int} - */ - protected function checkWorkspaceUsage( - Workspace $workspace, - RateLimitService $rateLimitService, - bool $dryRun - ): array { - $sent = 0; - $skipped = 0; - - // Get rate limit config for this workspace's tier - $tier = $this->getWorkspaceTier($workspace); - $limitConfig = $this->getTierLimitConfig($tier); - - if (! $limitConfig) { - return ['sent' => 0, 'skipped' => 0]; - } - - // Check usage for each active API key - $apiKeys = $workspace->apiKeys()->active()->get(); - - foreach ($apiKeys as $apiKey) { - $key = $rateLimitService->buildApiKeyKey($apiKey->id); - $attempts = $rateLimitService->attempts($key, $limitConfig['window']); - $limit = (int) floor($limitConfig['limit'] * ($limitConfig['burst'] ?? 1.0)); - - if ($limit === 0) { - continue; - } - - $percentage = ($attempts / $limit) * 100; - - // Check thresholds (critical first, then warning) - foreach ($this->thresholds as $level => $threshold) { - if ($percentage >= $threshold) { - $cacheKey = $this->getCacheKey($workspace->id, $apiKey->id, $level); - - if (Cache::has($cacheKey)) { - $this->line(" [SKIP] {$workspace->name} - Key {$apiKey->prefix}: {$level} (cooldown)"); - $skipped++; - - break; // Don't check lower thresholds - } - - $this->line(" [ALERT] {$workspace->name} - Key {$apiKey->prefix}: {$level} ({$percentage}%)"); - - if (! $dryRun) { - $this->sendAlert($workspace, $apiKey, $level, $attempts, $limit, $limitConfig); - Cache::put($cacheKey, true, now()->addHours($this->cooldownHours)); - } - - $sent++; - - break; // Only send one alert per key (highest severity) - } - } - } - - return ['sent' => $sent, 'skipped' => $skipped]; - } - - /** - * Send alert notification to workspace owner. - */ - protected function sendAlert( - Workspace $workspace, - ApiKey $apiKey, - string $level, - int $currentUsage, - int $limit, - array $limitConfig - ): void { - $owner = $workspace->owner(); - - if (! $owner) { - $this->warn(" No owner found for workspace {$workspace->name}"); - - return; - } - - $period = $this->formatPeriod($limitConfig['window']); - - $owner->notify(new HighApiUsageNotification( - workspace: $workspace, - level: $level, - currentUsage: $currentUsage, - limit: $limit, - period: $period, - )); - } - - /** - * Get workspace tier for rate limiting. - */ - protected function getWorkspaceTier(Workspace $workspace): string - { - // Check for active package - $package = $workspace->workspacePackages() - ->active() - ->with('package') - ->first(); - - return $package?->package?->slug ?? 'free'; - } - - /** - * Get rate limit config for a tier. - * - * @return array{limit: int, window: int, burst: float}|null - */ - protected function getTierLimitConfig(string $tier): ?array - { - $config = config("api.rate_limits.tiers.{$tier}"); - - if (! $config) { - $config = config('api.rate_limits.tiers.free'); - } - - if (! $config) { - $config = config('api.rate_limits.authenticated'); - } - - if (! $config) { - return null; - } - - return [ - 'limit' => $config['limit'] ?? $config['requests'] ?? 60, - 'window' => $config['window'] ?? (($config['per_minutes'] ?? 1) * 60), - 'burst' => $config['burst'] ?? 1.0, - ]; - } - - /** - * Format window period for display. - */ - protected function formatPeriod(int $seconds): string - { - if ($seconds < 60) { - return "{$seconds} seconds"; - } - - $minutes = $seconds / 60; - - if ($minutes === 1.0) { - return 'minute'; - } - - if ($minutes < 60) { - return "{$minutes} minutes"; - } - - $hours = $minutes / 60; - - if ($hours === 1.0) { - return 'hour'; - } - - return "{$hours} hours"; - } - - /** - * Get cache key for notification cooldown. - */ - protected function getCacheKey(int $workspaceId, int $apiKeyId, string $level): string - { - return self::CACHE_PREFIX."{$workspaceId}:{$apiKeyId}:{$level}"; - } -} diff --git a/packages/core-api/src/Mod/Api/Console/Commands/CleanupExpiredGracePeriods.php b/packages/core-api/src/Mod/Api/Console/Commands/CleanupExpiredGracePeriods.php deleted file mode 100644 index 2cf5f26..0000000 --- a/packages/core-api/src/Mod/Api/Console/Commands/CleanupExpiredGracePeriods.php +++ /dev/null @@ -1,67 +0,0 @@ -option('dry-run'); - - if ($dryRun) { - $this->warn('DRY RUN MODE - No keys will be revoked'); - $this->newLine(); - - // Count keys that would be cleaned up - $count = \Mod\Api\Models\ApiKey::gracePeriodExpired() - ->whereNull('deleted_at') - ->count(); - - if ($count === 0) { - $this->info('No API keys with expired grace periods found.'); - } else { - $this->info("Would revoke {$count} API key(s) with expired grace periods."); - } - - return Command::SUCCESS; - } - - $this->info('Cleaning up API keys with expired grace periods...'); - - $count = $service->cleanupExpiredGracePeriods(); - - if ($count === 0) { - $this->info('No API keys with expired grace periods found.'); - } else { - $this->info("Revoked {$count} API key(s) with expired grace periods."); - } - - return Command::SUCCESS; - } -} diff --git a/packages/core-api/src/Mod/Api/Controllers/McpApiController.php b/packages/core-api/src/Mod/Api/Controllers/McpApiController.php deleted file mode 100644 index b980e51..0000000 --- a/packages/core-api/src/Mod/Api/Controllers/McpApiController.php +++ /dev/null @@ -1,625 +0,0 @@ -loadRegistry(); - - $servers = collect($registry['servers'] ?? []) - ->map(fn ($ref) => $this->loadServerSummary($ref['id'])) - ->filter() - ->values(); - - return response()->json([ - 'servers' => $servers, - 'count' => $servers->count(), - ]); - } - - /** - * Get server details with tools and resources. - * - * GET /api/v1/mcp/servers/{id} - */ - public function server(Request $request, string $id): JsonResponse - { - $server = $this->loadServerFull($id); - - if (! $server) { - return response()->json(['error' => 'Server not found'], 404); - } - - return response()->json($server); - } - - /** - * List tools for a specific server. - * - * GET /api/v1/mcp/servers/{id}/tools - * - * Query params: - * - include_versions: bool - include version info for each tool - */ - public function tools(Request $request, string $id): JsonResponse - { - $server = $this->loadServerFull($id); - - if (! $server) { - return response()->json(['error' => 'Server not found'], 404); - } - - $tools = $server['tools'] ?? []; - $includeVersions = $request->boolean('include_versions', false); - - // Optionally enrich tools with version information - if ($includeVersions) { - $versionService = app(ToolVersionService::class); - $tools = collect($tools)->map(function ($tool) use ($id, $versionService) { - $toolName = $tool['name'] ?? ''; - $latestVersion = $versionService->getLatestVersion($id, $toolName); - - $tool['versioning'] = [ - 'latest_version' => $latestVersion?->version ?? ToolVersionService::DEFAULT_VERSION, - 'is_versioned' => $latestVersion !== null, - 'deprecated' => $latestVersion?->is_deprecated ?? false, - ]; - - // If version exists, use its schema (may differ from YAML) - if ($latestVersion?->input_schema) { - $tool['inputSchema'] = $latestVersion->input_schema; - } - - return $tool; - })->all(); - } - - return response()->json([ - 'server' => $id, - 'tools' => $tools, - 'count' => count($tools), - ]); - } - - /** - * Execute a tool on an MCP server. - * - * POST /api/v1/mcp/tools/call - * - * Request body: - * - server: string (required) - * - tool: string (required) - * - arguments: array (optional) - * - version: string (optional) - semver version to use, defaults to latest - */ - public function callTool(Request $request): JsonResponse - { - $validated = $request->validate([ - 'server' => 'required|string|max:64', - 'tool' => 'required|string|max:128', - 'arguments' => 'nullable|array', - 'version' => 'nullable|string|max:32', - ]); - - $server = $this->loadServerFull($validated['server']); - if (! $server) { - return response()->json(['error' => 'Server not found'], 404); - } - - // Verify tool exists in server definition - $toolDef = collect($server['tools'] ?? [])->firstWhere('name', $validated['tool']); - if (! $toolDef) { - return response()->json(['error' => 'Tool not found'], 404); - } - - // Version resolution - $versionService = app(ToolVersionService::class); - $versionResult = $versionService->resolveVersion( - $validated['server'], - $validated['tool'], - $validated['version'] ?? null - ); - - // If version was requested but is sunset, block the call - if ($versionResult['error']) { - $error = $versionResult['error']; - - // Sunset versions return 410 Gone - $status = ($error['code'] ?? '') === 'TOOL_VERSION_SUNSET' ? 410 : 400; - - return response()->json([ - 'success' => false, - 'error' => $error['message'] ?? 'Version error', - 'error_code' => $error['code'] ?? 'VERSION_ERROR', - 'server' => $validated['server'], - 'tool' => $validated['tool'], - 'requested_version' => $validated['version'] ?? null, - 'latest_version' => $error['latest_version'] ?? null, - 'migration_notes' => $error['migration_notes'] ?? null, - ], $status); - } - - /** @var McpToolVersion|null $toolVersion */ - $toolVersion = $versionResult['version']; - $deprecationWarning = $versionResult['warning']; - - // Use versioned schema if available for validation - $schemaForValidation = $toolVersion?->input_schema ?? $toolDef['inputSchema'] ?? null; - if ($schemaForValidation) { - $validationErrors = $this->validateToolArguments( - ['inputSchema' => $schemaForValidation], - $validated['arguments'] ?? [] - ); - - if (! empty($validationErrors)) { - return response()->json([ - 'success' => false, - 'error' => 'Validation failed', - 'error_code' => 'VALIDATION_ERROR', - 'validation_errors' => $validationErrors, - 'server' => $validated['server'], - 'tool' => $validated['tool'], - 'version' => $toolVersion?->version ?? 'unversioned', - ], 422); - } - } - - // Get API key for logging - $apiKey = $request->attributes->get('api_key'); - $workspace = $apiKey?->workspace; - - $startTime = microtime(true); - - try { - // Execute the tool via artisan command - $result = $this->executeToolViaArtisan( - $validated['server'], - $validated['tool'], - $validated['arguments'] ?? [] - ); - - $durationMs = (int) ((microtime(true) - $startTime) * 1000); - - // Log the call - $this->logToolCall($apiKey, $validated, $result, $durationMs, true); - - // Dispatch webhooks - $this->dispatchWebhook($apiKey, $validated, true, $durationMs); - - $response = [ - 'success' => true, - 'server' => $validated['server'], - 'tool' => $validated['tool'], - 'version' => $toolVersion?->version ?? ToolVersionService::DEFAULT_VERSION, - 'result' => $result, - 'duration_ms' => $durationMs, - ]; - - // Include deprecation warning if applicable - if ($deprecationWarning) { - $response['_warnings'] = [$deprecationWarning]; - } - - // Log full request for debugging/replay - $this->logApiRequest($request, $validated, 200, $response, $durationMs, $apiKey); - - // Build response with deprecation headers if needed - $jsonResponse = response()->json($response); - - if ($deprecationWarning) { - $jsonResponse->header('X-MCP-Deprecation-Warning', $deprecationWarning['message'] ?? 'Version deprecated'); - if (isset($deprecationWarning['sunset_at'])) { - $jsonResponse->header('X-MCP-Sunset-At', $deprecationWarning['sunset_at']); - } - if (isset($deprecationWarning['latest_version'])) { - $jsonResponse->header('X-MCP-Latest-Version', $deprecationWarning['latest_version']); - } - } - - return $jsonResponse; - } catch (\Throwable $e) { - $durationMs = (int) ((microtime(true) - $startTime) * 1000); - - $this->logToolCall($apiKey, $validated, null, $durationMs, false, $e->getMessage()); - - // Dispatch webhooks (even on failure) - $this->dispatchWebhook($apiKey, $validated, false, $durationMs, $e->getMessage()); - - $response = [ - 'success' => false, - 'error' => $e->getMessage(), - 'server' => $validated['server'], - 'tool' => $validated['tool'], - 'version' => $toolVersion?->version ?? ToolVersionService::DEFAULT_VERSION, - ]; - - // Log full request for debugging/replay - $this->logApiRequest($request, $validated, 500, $response, $durationMs, $apiKey, $e->getMessage()); - - return response()->json($response, 500); - } - } - - /** - * Validate tool arguments against a JSON schema. - * - * @return array Validation error messages - */ - protected function validateToolArguments(array $toolDef, array $arguments): array - { - $inputSchema = $toolDef['inputSchema'] ?? null; - - if (! $inputSchema || ! is_array($inputSchema)) { - return []; - } - - $errors = []; - $properties = $inputSchema['properties'] ?? []; - $required = $inputSchema['required'] ?? []; - - // Check required properties - foreach ($required as $requiredProp) { - if (! array_key_exists($requiredProp, $arguments)) { - $errors[] = "Missing required argument: {$requiredProp}"; - } - } - - // Type validation for provided arguments - foreach ($arguments as $key => $value) { - if (! isset($properties[$key])) { - if (($inputSchema['additionalProperties'] ?? true) === false) { - $errors[] = "Unknown argument: {$key}"; - } - - continue; - } - - $propSchema = $properties[$key]; - $expectedType = $propSchema['type'] ?? null; - - if ($expectedType && ! $this->validateType($value, $expectedType)) { - $errors[] = "Argument '{$key}' must be of type {$expectedType}"; - } - - // Validate enum values - if (isset($propSchema['enum']) && ! in_array($value, $propSchema['enum'], true)) { - $allowedValues = implode(', ', $propSchema['enum']); - $errors[] = "Argument '{$key}' must be one of: {$allowedValues}"; - } - } - - return $errors; - } - - /** - * Validate a value against a JSON Schema type. - */ - protected function validateType(mixed $value, string $type): bool - { - return match ($type) { - 'string' => is_string($value), - 'integer' => is_int($value) || (is_numeric($value) && floor((float) $value) == $value), - 'number' => is_numeric($value), - 'boolean' => is_bool($value), - 'array' => is_array($value) && array_is_list($value), - 'object' => is_array($value) && ! array_is_list($value), - 'null' => is_null($value), - default => true, - }; - } - - /** - * Get version history for a specific tool. - * - * GET /api/v1/mcp/servers/{server}/tools/{tool}/versions - */ - public function toolVersions(Request $request, string $server, string $tool): JsonResponse - { - $serverConfig = $this->loadServerFull($server); - if (! $serverConfig) { - return response()->json(['error' => 'Server not found'], 404); - } - - // Verify tool exists in server definition - $toolDef = collect($serverConfig['tools'] ?? [])->firstWhere('name', $tool); - if (! $toolDef) { - return response()->json(['error' => 'Tool not found'], 404); - } - - $versionService = app(ToolVersionService::class); - $versions = $versionService->getVersionHistory($server, $tool); - - return response()->json([ - 'server' => $server, - 'tool' => $tool, - 'versions' => $versions->map(fn (McpToolVersion $v) => $v->toApiArray())->values(), - 'count' => $versions->count(), - ]); - } - - /** - * Get a specific version of a tool. - * - * GET /api/v1/mcp/servers/{server}/tools/{tool}/versions/{version} - */ - public function toolVersion(Request $request, string $server, string $tool, string $version): JsonResponse - { - $versionService = app(ToolVersionService::class); - $toolVersion = $versionService->getToolAtVersion($server, $tool, $version); - - if (! $toolVersion) { - return response()->json(['error' => 'Version not found'], 404); - } - - $response = response()->json($toolVersion->toApiArray()); - - // Add deprecation headers if applicable - if ($deprecationWarning = $toolVersion->getDeprecationWarning()) { - $response->header('X-MCP-Deprecation-Warning', $deprecationWarning['message'] ?? 'Version deprecated'); - if (isset($deprecationWarning['sunset_at'])) { - $response->header('X-MCP-Sunset-At', $deprecationWarning['sunset_at']); - } - } - - return $response; - } - - /** - * Read a resource from an MCP server. - * - * GET /api/v1/mcp/resources/{uri} - */ - public function resource(Request $request, string $uri): JsonResponse - { - // Parse URI format: server://resource/path - if (! preg_match('/^([a-z0-9-]+):\/\/(.+)$/', $uri, $matches)) { - return response()->json(['error' => 'Invalid resource URI format'], 400); - } - - $serverId = $matches[1]; - $resourcePath = $matches[2]; - - $server = $this->loadServerFull($serverId); - if (! $server) { - return response()->json(['error' => 'Server not found'], 404); - } - - try { - $result = $this->readResourceViaArtisan($serverId, $resourcePath); - - return response()->json([ - 'uri' => $uri, - 'content' => $result, - ]); - } catch (\Throwable $e) { - return response()->json([ - 'error' => $e->getMessage(), - 'uri' => $uri, - ], 500); - } - } - - /** - * Execute tool via artisan MCP server command. - */ - protected function executeToolViaArtisan(string $server, string $tool, array $arguments): mixed - { - $commandMap = [ - 'hosthub-agent' => 'mcp:agent-server', - 'socialhost' => 'mcp:socialhost-server', - 'biohost' => 'mcp:biohost-server', - 'commerce' => 'mcp:commerce-server', - 'supporthost' => 'mcp:support-server', - 'upstream' => 'mcp:upstream-server', - ]; - - $command = $commandMap[$server] ?? null; - if (! $command) { - throw new \RuntimeException("Unknown server: {$server}"); - } - - // Build MCP request - $mcpRequest = [ - 'jsonrpc' => '2.0', - 'id' => uniqid(), - 'method' => 'tools/call', - 'params' => [ - 'name' => $tool, - 'arguments' => $arguments, - ], - ]; - - // Execute via process - $process = proc_open( - ['php', 'artisan', $command], - [ - 0 => ['pipe', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - ], - $pipes, - base_path() - ); - - if (! is_resource($process)) { - throw new \RuntimeException('Failed to start MCP server process'); - } - - fwrite($pipes[0], json_encode($mcpRequest)."\n"); - fclose($pipes[0]); - - $output = stream_get_contents($pipes[1]); - fclose($pipes[1]); - fclose($pipes[2]); - - proc_close($process); - - $response = json_decode($output, true); - - if (isset($response['error'])) { - throw new \RuntimeException($response['error']['message'] ?? 'Tool execution failed'); - } - - return $response['result'] ?? null; - } - - /** - * Read resource via artisan MCP server command. - */ - protected function readResourceViaArtisan(string $server, string $path): mixed - { - // Similar to executeToolViaArtisan but with resources/read method - // Simplified for now - can expand later - return ['path' => $path, 'content' => 'Resource reading not yet implemented']; - } - - /** - * Log full API request for debugging and replay. - */ - protected function logApiRequest( - Request $request, - array $validated, - int $status, - array $response, - int $durationMs, - ?ApiKey $apiKey, - ?string $error = null - ): void { - try { - McpApiRequest::log( - method: $request->method(), - path: '/tools/call', - requestBody: $validated, - responseStatus: $status, - responseBody: $response, - durationMs: $durationMs, - workspaceId: $apiKey?->workspace_id, - apiKeyId: $apiKey?->id, - serverId: $validated['server'], - toolName: $validated['tool'], - errorMessage: $error, - ipAddress: $request->ip(), - headers: $request->headers->all() - ); - } catch (\Throwable $e) { - // Don't let logging failures affect API response - report($e); - } - } - - /** - * Dispatch webhook for tool execution. - */ - protected function dispatchWebhook( - ?ApiKey $apiKey, - array $request, - bool $success, - int $durationMs, - ?string $error = null - ): void { - if (! $apiKey?->workspace_id) { - return; - } - - try { - $dispatcher = new McpWebhookDispatcher; - $dispatcher->dispatchToolExecuted( - workspaceId: $apiKey->workspace_id, - serverId: $request['server'], - toolName: $request['tool'], - arguments: $request['arguments'] ?? [], - success: $success, - durationMs: $durationMs, - errorMessage: $error - ); - } catch (\Throwable $e) { - // Don't let webhook failures affect API response - report($e); - } - } - - /** - * Log tool call for analytics. - */ - protected function logToolCall( - ?ApiKey $apiKey, - array $request, - mixed $result, - int $durationMs, - bool $success, - ?string $error = null - ): void { - McpToolCall::log( - serverId: $request['server'], - toolName: $request['tool'], - params: $request['arguments'] ?? [], - success: $success, - durationMs: $durationMs, - errorMessage: $error, - workspaceId: $apiKey?->workspace_id - ); - } - - // Registry loading methods (shared with McpRegistryController) - - protected function loadRegistry(): array - { - return Cache::remember('mcp:registry', 600, function () { - $path = resource_path('mcp/registry.yaml'); - - return file_exists($path) ? Yaml::parseFile($path) : ['servers' => []]; - }); - } - - protected function loadServerFull(string $id): ?array - { - return Cache::remember("mcp:server:{$id}", 600, function () use ($id) { - $path = resource_path("mcp/servers/{$id}.yaml"); - - return file_exists($path) ? Yaml::parseFile($path) : null; - }); - } - - protected function loadServerSummary(string $id): ?array - { - $server = $this->loadServerFull($id); - if (! $server) { - return null; - } - - return [ - 'id' => $server['id'], - 'name' => $server['name'], - 'tagline' => $server['tagline'] ?? '', - 'status' => $server['status'] ?? 'available', - 'tool_count' => count($server['tools'] ?? []), - 'resource_count' => count($server['resources'] ?? []), - ]; - } -} diff --git a/packages/core-api/src/Mod/Api/Database/Factories/ApiKeyFactory.php b/packages/core-api/src/Mod/Api/Database/Factories/ApiKeyFactory.php deleted file mode 100644 index 36b6898..0000000 --- a/packages/core-api/src/Mod/Api/Database/Factories/ApiKeyFactory.php +++ /dev/null @@ -1,253 +0,0 @@ - - */ -class ApiKeyFactory extends Factory -{ - /** - * The name of the factory's corresponding model. - * - * @var class-string - */ - protected $model = ApiKey::class; - - /** - * Store the plain key for testing. - */ - private ?string $plainKey = null; - - /** - * Define the model's default state. - * - * Creates keys with secure bcrypt hashing by default. - * - * @return array - */ - public function definition(): array - { - $plainKey = Str::random(48); - $prefix = 'hk_'.Str::random(8); - $this->plainKey = "{$prefix}_{$plainKey}"; - - return [ - 'workspace_id' => Workspace::factory(), - 'user_id' => User::factory(), - 'name' => fake()->words(2, true).' API Key', - 'key' => Hash::make($plainKey), - 'hash_algorithm' => ApiKey::HASH_BCRYPT, - 'prefix' => $prefix, - 'scopes' => [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE], - 'server_scopes' => null, - 'last_used_at' => null, - 'expires_at' => null, - 'grace_period_ends_at' => null, - 'rotated_from_id' => null, - ]; - } - - /** - * Get the plain key after creation. - * Must be called immediately after create() to get the plain key. - */ - public function getPlainKey(): ?string - { - return $this->plainKey; - } - - /** - * Create a key with specific known credentials for testing. - * - * This method uses ApiKey::generate() which creates secure bcrypt keys. - * - * @return array{api_key: ApiKey, plain_key: string} - */ - public static function createWithPlainKey( - ?Workspace $workspace = null, - ?User $user = null, - array $scopes = [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE], - ?\DateTimeInterface $expiresAt = null - ): array { - $workspace ??= Workspace::factory()->create(); - $user ??= User::factory()->create(); - - return ApiKey::generate( - $workspace->id, - $user->id, - fake()->words(2, true).' API Key', - $scopes, - $expiresAt - ); - } - - /** - * Create a key with legacy SHA-256 hashing for migration testing. - * - * @return array{api_key: ApiKey, plain_key: string} - */ - public static function createLegacyKey( - ?Workspace $workspace = null, - ?User $user = null, - array $scopes = [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE], - ?\DateTimeInterface $expiresAt = null - ): array { - $workspace ??= Workspace::factory()->create(); - $user ??= User::factory()->create(); - - $plainKey = Str::random(48); - $prefix = 'hk_'.Str::random(8); - - $apiKey = ApiKey::create([ - 'workspace_id' => $workspace->id, - 'user_id' => $user->id, - 'name' => fake()->words(2, true).' API Key', - 'key' => hash('sha256', $plainKey), - 'hash_algorithm' => ApiKey::HASH_SHA256, - 'prefix' => $prefix, - 'scopes' => $scopes, - 'expires_at' => $expiresAt, - ]); - - return [ - 'api_key' => $apiKey, - 'plain_key' => "{$prefix}_{$plainKey}", - ]; - } - - /** - * Create key with legacy SHA-256 hashing (for migration testing). - */ - public function legacyHash(): static - { - return $this->state(function (array $attributes) { - // Extract the plain key from the stored state - $parts = explode('_', $this->plainKey ?? '', 3); - $plainKey = $parts[2] ?? Str::random(48); - - return [ - 'key' => hash('sha256', $plainKey), - 'hash_algorithm' => ApiKey::HASH_SHA256, - ]; - }); - } - - /** - * Indicate that the key has been used recently. - */ - public function used(): static - { - return $this->state(fn (array $attributes) => [ - 'last_used_at' => now()->subMinutes(fake()->numberBetween(1, 60)), - ]); - } - - /** - * Indicate that the key expires in the future. - * - * @param int $days Number of days until expiration - */ - public function expiresIn(int $days = 30): static - { - return $this->state(fn (array $attributes) => [ - 'expires_at' => now()->addDays($days), - ]); - } - - /** - * Indicate that the key has expired. - */ - public function expired(): static - { - return $this->state(fn (array $attributes) => [ - 'expires_at' => now()->subDays(1), - ]); - } - - /** - * Set specific scopes. - * - * @param array $scopes - */ - public function withScopes(array $scopes): static - { - return $this->state(fn (array $attributes) => [ - 'scopes' => $scopes, - ]); - } - - /** - * Set read-only scope. - */ - public function readOnly(): static - { - return $this->withScopes([ApiKey::SCOPE_READ]); - } - - /** - * Set all scopes (read, write, delete). - */ - public function fullAccess(): static - { - return $this->withScopes(ApiKey::ALL_SCOPES); - } - - /** - * Set specific server scopes. - * - * @param array|null $servers - */ - public function withServerScopes(?array $servers): static - { - return $this->state(fn (array $attributes) => [ - 'server_scopes' => $servers, - ]); - } - - /** - * Create a revoked (soft-deleted) key. - */ - public function revoked(): static - { - return $this->state(fn (array $attributes) => [ - 'deleted_at' => now()->subDay(), - ]); - } - - /** - * Create a key in a rotation grace period. - * - * @param int $hoursRemaining Hours until grace period ends - */ - public function inGracePeriod(int $hoursRemaining = 12): static - { - return $this->state(fn (array $attributes) => [ - 'grace_period_ends_at' => now()->addHours($hoursRemaining), - ]); - } - - /** - * Create a key with an expired grace period. - */ - public function gracePeriodExpired(): static - { - return $this->state(fn (array $attributes) => [ - 'grace_period_ends_at' => now()->subHours(1), - ]); - } -} diff --git a/packages/core-api/src/Mod/Api/Documentation/Attributes/ApiHidden.php b/packages/core-api/src/Mod/Api/Documentation/Attributes/ApiHidden.php deleted file mode 100644 index 4ae0858..0000000 --- a/packages/core-api/src/Mod/Api/Documentation/Attributes/ApiHidden.php +++ /dev/null @@ -1,41 +0,0 @@ - $this->type, - ]; - - if ($this->format !== null) { - $schema['format'] = $this->format; - } - - if ($this->enum !== null) { - $schema['enum'] = $this->enum; - } - - if ($this->default !== null) { - $schema['default'] = $this->default; - } - - if ($this->example !== null) { - $schema['example'] = $this->example; - } - - return $schema; - } - - /** - * Convert to full OpenAPI parameter object. - */ - public function toOpenApi(): array - { - $param = [ - 'name' => $this->name, - 'in' => $this->in, - 'required' => $this->required || $this->in === 'path', - 'schema' => $this->toSchema(), - ]; - - if ($this->description !== null) { - $param['description'] = $this->description; - } - - return $param; - } -} diff --git a/packages/core-api/src/Mod/Api/Documentation/Attributes/ApiResponse.php b/packages/core-api/src/Mod/Api/Documentation/Attributes/ApiResponse.php deleted file mode 100644 index 2b5092a..0000000 --- a/packages/core-api/src/Mod/Api/Documentation/Attributes/ApiResponse.php +++ /dev/null @@ -1,80 +0,0 @@ - $headers Additional response headers to document - */ - public function __construct( - public int $status, - public ?string $resource = null, - public ?string $description = null, - public bool $paginated = false, - public array $headers = [], - ) {} - - /** - * Get the description or generate from status code. - */ - public function getDescription(): string - { - if ($this->description !== null) { - return $this->description; - } - - return match ($this->status) { - 200 => 'Successful response', - 201 => 'Resource created', - 202 => 'Request accepted', - 204 => 'No content', - 301 => 'Moved permanently', - 302 => 'Found (redirect)', - 304 => 'Not modified', - 400 => 'Bad request', - 401 => 'Unauthorized', - 403 => 'Forbidden', - 404 => 'Not found', - 405 => 'Method not allowed', - 409 => 'Conflict', - 422 => 'Validation error', - 429 => 'Too many requests', - 500 => 'Internal server error', - 502 => 'Bad gateway', - 503 => 'Service unavailable', - default => 'Response', - }; - } -} diff --git a/packages/core-api/src/Mod/Api/Documentation/Attributes/ApiSecurity.php b/packages/core-api/src/Mod/Api/Documentation/Attributes/ApiSecurity.php deleted file mode 100644 index 97fcf01..0000000 --- a/packages/core-api/src/Mod/Api/Documentation/Attributes/ApiSecurity.php +++ /dev/null @@ -1,51 +0,0 @@ - $scopes Required OAuth2 scopes (if applicable) - */ - public function __construct( - public ?string $scheme, - public array $scopes = [], - ) {} - - /** - * Check if this marks the endpoint as public. - */ - public function isPublic(): bool - { - return $this->scheme === null; - } -} diff --git a/packages/core-api/src/Mod/Api/Documentation/Attributes/ApiTag.php b/packages/core-api/src/Mod/Api/Documentation/Attributes/ApiTag.php deleted file mode 100644 index 239d3c5..0000000 --- a/packages/core-api/src/Mod/Api/Documentation/Attributes/ApiTag.php +++ /dev/null @@ -1,38 +0,0 @@ - $this->swagger($request), - 'redoc' => $this->redoc($request), - default => $this->scalar($request), - }; - } - - /** - * Show Swagger UI. - */ - public function swagger(Request $request): View - { - $config = config('api-docs.ui.swagger', []); - - return view('api-docs::swagger', [ - 'specUrl' => route('api.docs.openapi.json'), - 'config' => $config, - ]); - } - - /** - * Show Scalar API Reference. - */ - public function scalar(Request $request): View - { - $config = config('api-docs.ui.scalar', []); - - return view('api-docs::scalar', [ - 'specUrl' => route('api.docs.openapi.json'), - 'config' => $config, - ]); - } - - /** - * Show ReDoc documentation. - */ - public function redoc(Request $request): View - { - return view('api-docs::redoc', [ - 'specUrl' => route('api.docs.openapi.json'), - ]); - } - - /** - * Get OpenAPI specification as JSON. - */ - public function openApiJson(Request $request): JsonResponse - { - $spec = $this->builder->build(); - - return response()->json($spec) - ->header('Cache-Control', $this->getCacheControl()); - } - - /** - * Get OpenAPI specification as YAML. - */ - public function openApiYaml(Request $request): Response - { - $spec = $this->builder->build(); - - // Convert to YAML - $yaml = Yaml::dump($spec, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); - - return response($yaml) - ->header('Content-Type', 'application/x-yaml') - ->header('Cache-Control', $this->getCacheControl()); - } - - /** - * Clear the documentation cache. - */ - public function clearCache(Request $request): JsonResponse - { - $this->builder->clearCache(); - - return response()->json([ - 'message' => 'Documentation cache cleared successfully.', - ]); - } - - /** - * Get cache control header value. - */ - protected function getCacheControl(): string - { - if (app()->environment('local', 'testing')) { - return 'no-cache, no-store, must-revalidate'; - } - - $ttl = config('api-docs.cache.ttl', 3600); - - return "public, max-age={$ttl}"; - } -} diff --git a/packages/core-api/src/Mod/Api/Documentation/DocumentationServiceProvider.php b/packages/core-api/src/Mod/Api/Documentation/DocumentationServiceProvider.php deleted file mode 100644 index 12b8f2b..0000000 --- a/packages/core-api/src/Mod/Api/Documentation/DocumentationServiceProvider.php +++ /dev/null @@ -1,87 +0,0 @@ -mergeConfigFrom( - __DIR__.'/config.php', - 'api-docs' - ); - - // Register OpenApiBuilder as singleton - $this->app->singleton(OpenApiBuilder::class, function ($app) { - return new OpenApiBuilder; - }); - } - - /** - * Bootstrap any application services. - */ - public function boot(): void - { - // Skip route registration during console commands (except route:list) - if ($this->shouldRegisterRoutes()) { - $this->registerRoutes(); - } - - // Register views - $this->loadViewsFrom(__DIR__.'/Views', 'api-docs'); - - // Publish configuration - if ($this->app->runningInConsole()) { - $this->publishes([ - __DIR__.'/config.php' => config_path('api-docs.php'), - ], 'api-docs-config'); - - $this->publishes([ - __DIR__.'/Views' => resource_path('views/vendor/api-docs'), - ], 'api-docs-views'); - } - } - - /** - * Check if routes should be registered. - */ - protected function shouldRegisterRoutes(): bool - { - // Always register if not in console - if (! $this->app->runningInConsole()) { - return true; - } - - // Register for artisan route:list command - $command = $_SERVER['argv'][1] ?? null; - - return $command === 'route:list' || $command === 'route:cache'; - } - - /** - * Register documentation routes. - */ - protected function registerRoutes(): void - { - $path = config('api-docs.path', '/api/docs'); - - Route::middleware(['web', ProtectDocumentation::class]) - ->prefix($path) - ->group(__DIR__.'/Routes/docs.php'); - } -} diff --git a/packages/core-api/src/Mod/Api/Documentation/Examples/CommonExamples.php b/packages/core-api/src/Mod/Api/Documentation/Examples/CommonExamples.php deleted file mode 100644 index f53ab02..0000000 --- a/packages/core-api/src/Mod/Api/Documentation/Examples/CommonExamples.php +++ /dev/null @@ -1,278 +0,0 @@ - [ - 'name' => 'page', - 'in' => 'query', - 'description' => 'Page number for pagination', - 'required' => false, - 'schema' => [ - 'type' => 'integer', - 'minimum' => 1, - 'default' => 1, - 'example' => 1, - ], - ], - 'per_page' => [ - 'name' => 'per_page', - 'in' => 'query', - 'description' => 'Number of items per page', - 'required' => false, - 'schema' => [ - 'type' => 'integer', - 'minimum' => 1, - 'maximum' => 100, - 'default' => 25, - 'example' => 25, - ], - ], - ]; - } - - /** - * Get example for sorting parameters. - */ - public static function sortingParams(): array - { - return [ - 'sort' => [ - 'name' => 'sort', - 'in' => 'query', - 'description' => 'Field to sort by (prefix with - for descending)', - 'required' => false, - 'schema' => [ - 'type' => 'string', - 'example' => '-created_at', - ], - ], - ]; - } - - /** - * Get example for filtering parameters. - */ - public static function filteringParams(): array - { - return [ - 'filter' => [ - 'name' => 'filter', - 'in' => 'query', - 'description' => 'Filter parameters in the format filter[field]=value', - 'required' => false, - 'style' => 'deepObject', - 'explode' => true, - 'schema' => [ - 'type' => 'object', - 'additionalProperties' => [ - 'type' => 'string', - ], - ], - 'example' => [ - 'status' => 'active', - 'created_at[gte]' => '2024-01-01', - ], - ], - ]; - } - - /** - * Get example paginated response. - */ - public static function paginatedResponse(string $dataExample = '[]'): array - { - return [ - 'data' => json_decode($dataExample, true) ?? [], - 'links' => [ - 'first' => 'https://api.example.com/resource?page=1', - 'last' => 'https://api.example.com/resource?page=10', - 'prev' => null, - 'next' => 'https://api.example.com/resource?page=2', - ], - 'meta' => [ - 'current_page' => 1, - 'from' => 1, - 'last_page' => 10, - 'per_page' => 25, - 'to' => 25, - 'total' => 250, - ], - ]; - } - - /** - * Get example error response. - */ - public static function errorResponse(int $status, string $message, ?array $errors = null): array - { - $response = ['message' => $message]; - - if ($errors !== null) { - $response['errors'] = $errors; - } - - return $response; - } - - /** - * Get example validation error response. - */ - public static function validationErrorResponse(): array - { - return [ - 'message' => 'The given data was invalid.', - 'errors' => [ - 'email' => [ - 'The email field is required.', - ], - 'name' => [ - 'The name field must be at least 2 characters.', - ], - ], - ]; - } - - /** - * Get example rate limit headers. - */ - public static function rateLimitHeaders(int $limit = 1000, int $remaining = 999): array - { - return [ - 'X-RateLimit-Limit' => (string) $limit, - 'X-RateLimit-Remaining' => (string) $remaining, - 'X-RateLimit-Reset' => (string) (time() + 60), - ]; - } - - /** - * Get example authentication headers. - */ - public static function authHeaders(string $type = 'api_key'): array - { - return match ($type) { - 'api_key' => [ - 'X-API-Key' => 'hk_1234567890abcdefghijklmnop', - ], - 'bearer' => [ - 'Authorization' => 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', - ], - default => [], - }; - } - - /** - * Get example workspace header. - */ - public static function workspaceHeader(): array - { - return [ - 'X-Workspace-ID' => '550e8400-e29b-41d4-a716-446655440000', - ]; - } - - /** - * Get example CURL request. - */ - public static function curlExample( - string $method, - string $endpoint, - ?array $body = null, - array $headers = [] - ): string { - $curl = "curl -X {$method} \\\n"; - $curl .= " 'https://api.example.com{$endpoint}' \\\n"; - - foreach ($headers as $name => $value) { - $curl .= " -H '{$name}: {$value}' \\\n"; - } - - if ($body !== null) { - $curl .= " -H 'Content-Type: application/json' \\\n"; - $curl .= " -d '".json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."'"; - } - - return rtrim($curl, " \\\n"); - } - - /** - * Get example JavaScript fetch request. - */ - public static function fetchExample( - string $method, - string $endpoint, - ?array $body = null, - array $headers = [] - ): string { - $allHeaders = array_merge([ - 'Content-Type' => 'application/json', - ], $headers); - - $options = [ - 'method' => strtoupper($method), - 'headers' => $allHeaders, - ]; - - if ($body !== null) { - $options['body'] = 'JSON.stringify('.json_encode($body, JSON_PRETTY_PRINT).')'; - } - - $code = "const response = await fetch('https://api.example.com{$endpoint}', {\n"; - $code .= " method: '{$options['method']}',\n"; - $code .= ' headers: '.json_encode($allHeaders, JSON_PRETTY_PRINT).",\n"; - - if ($body !== null) { - $code .= ' body: JSON.stringify('.json_encode($body, JSON_PRETTY_PRINT)."),\n"; - } - - $code .= "});\n\n"; - $code .= 'const data = await response.json();'; - - return $code; - } - - /** - * Get example PHP request. - */ - public static function phpExample( - string $method, - string $endpoint, - ?array $body = null, - array $headers = [] - ): string { - $code = "request('{$method}', 'https://api.example.com{$endpoint}', [\n"; - - if (! empty($headers)) { - $code .= " 'headers' => [\n"; - foreach ($headers as $name => $value) { - $code .= " '{$name}' => '{$value}',\n"; - } - $code .= " ],\n"; - } - - if ($body !== null) { - $code .= " 'json' => ".var_export($body, true).",\n"; - } - - $code .= "]);\n\n"; - $code .= '$data = json_decode($response->getBody(), true);'; - - return $code; - } -} diff --git a/packages/core-api/src/Mod/Api/Documentation/Extension.php b/packages/core-api/src/Mod/Api/Documentation/Extension.php deleted file mode 100644 index 31e7360..0000000 --- a/packages/core-api/src/Mod/Api/Documentation/Extension.php +++ /dev/null @@ -1,40 +0,0 @@ -buildApiKeyDescription($apiKeyConfig); - } - - // Add authentication guide to info.description - $authGuide = $this->buildAuthenticationGuide($config); - if (! empty($authGuide)) { - $spec['info']['description'] = ($spec['info']['description'] ?? '')."\n\n".$authGuide; - } - - // Add example schemas for authentication-related responses - $spec['components']['schemas']['UnauthorizedError'] = [ - 'type' => 'object', - 'properties' => [ - 'message' => [ - 'type' => 'string', - 'example' => 'Unauthenticated.', - ], - ], - ]; - - $spec['components']['schemas']['ForbiddenError'] = [ - 'type' => 'object', - 'properties' => [ - 'message' => [ - 'type' => 'string', - 'example' => 'This action is unauthorized.', - ], - ], - ]; - - // Add common auth error responses to components - $spec['components']['responses']['Unauthorized'] = [ - 'description' => 'Authentication required or invalid credentials', - 'content' => [ - 'application/json' => [ - 'schema' => [ - '$ref' => '#/components/schemas/UnauthorizedError', - ], - 'examples' => [ - 'missing_key' => [ - 'summary' => 'Missing API Key', - 'value' => ['message' => 'API key is required.'], - ], - 'invalid_key' => [ - 'summary' => 'Invalid API Key', - 'value' => ['message' => 'Invalid API key.'], - ], - 'expired_key' => [ - 'summary' => 'Expired API Key', - 'value' => ['message' => 'API key has expired.'], - ], - ], - ], - ], - ]; - - $spec['components']['responses']['Forbidden'] = [ - 'description' => 'Insufficient permissions for this action', - 'content' => [ - 'application/json' => [ - 'schema' => [ - '$ref' => '#/components/schemas/ForbiddenError', - ], - 'examples' => [ - 'insufficient_scope' => [ - 'summary' => 'Missing Required Scope', - 'value' => ['message' => 'API key lacks required scope: write'], - ], - 'workspace_access' => [ - 'summary' => 'Workspace Access Denied', - 'value' => ['message' => 'API key does not have access to this workspace.'], - ], - ], - ], - ], - ]; - - return $spec; - } - - /** - * Extend an individual operation. - */ - public function extendOperation(array $operation, Route $route, string $method, array $config): array - { - // Add 401/403 responses to authenticated endpoints - if (! empty($operation['security'])) { - $hasApiKeyAuth = false; - foreach ($operation['security'] as $security) { - if (isset($security['apiKeyAuth'])) { - $hasApiKeyAuth = true; - break; - } - } - - if ($hasApiKeyAuth) { - // Add 401 response if not present - if (! isset($operation['responses']['401'])) { - $operation['responses']['401'] = [ - '$ref' => '#/components/responses/Unauthorized', - ]; - } - - // Add 403 response if not present - if (! isset($operation['responses']['403'])) { - $operation['responses']['403'] = [ - '$ref' => '#/components/responses/Forbidden', - ]; - } - } - } - - return $operation; - } - - /** - * Build detailed API key description. - */ - protected function buildApiKeyDescription(array $config): string - { - $headerName = $config['name'] ?? 'X-API-Key'; - $baseDescription = $config['description'] ?? 'API key for authentication.'; - - return << 'Maximum number of requests allowed per window', - 'X-RateLimit-Remaining' => 'Number of requests remaining in the current window', - 'X-RateLimit-Reset' => 'Unix timestamp when the rate limit window resets', - ]; - - $spec['components']['headers'] = $spec['components']['headers'] ?? []; - - foreach ($headers as $name => $description) { - $headerKey = str_replace(['-', ' '], '', strtolower($name)); - $spec['components']['headers'][$headerKey] = [ - 'description' => $description, - 'schema' => [ - 'type' => 'integer', - ], - ]; - } - - // Add 429 response schema to components - $spec['components']['responses']['RateLimitExceeded'] = [ - 'description' => 'Rate limit exceeded', - 'headers' => [ - 'X-RateLimit-Limit' => [ - '$ref' => '#/components/headers/xratelimitlimit', - ], - 'X-RateLimit-Remaining' => [ - '$ref' => '#/components/headers/xratelimitremaining', - ], - 'X-RateLimit-Reset' => [ - '$ref' => '#/components/headers/xratelimitreset', - ], - 'Retry-After' => [ - 'description' => 'Seconds to wait before retrying', - 'schema' => ['type' => 'integer'], - ], - ], - 'content' => [ - 'application/json' => [ - 'schema' => [ - 'type' => 'object', - 'properties' => [ - 'message' => [ - 'type' => 'string', - 'example' => 'Too Many Requests', - ], - 'retry_after' => [ - 'type' => 'integer', - 'description' => 'Seconds until rate limit resets', - 'example' => 30, - ], - ], - ], - ], - ], - ]; - - return $spec; - } - - /** - * Extend an individual operation. - */ - public function extendOperation(array $operation, Route $route, string $method, array $config): array - { - $rateLimitConfig = $config['rate_limits'] ?? []; - - if (! ($rateLimitConfig['enabled'] ?? true)) { - return $operation; - } - - // Check if route has rate limiting middleware - if (! $this->hasRateLimiting($route)) { - return $operation; - } - - // Add rate limit headers to successful responses - foreach ($operation['responses'] as $status => &$response) { - if ((int) $status >= 200 && (int) $status < 300) { - $response['headers'] = $response['headers'] ?? []; - $response['headers']['X-RateLimit-Limit'] = [ - '$ref' => '#/components/headers/xratelimitlimit', - ]; - $response['headers']['X-RateLimit-Remaining'] = [ - '$ref' => '#/components/headers/xratelimitremaining', - ]; - $response['headers']['X-RateLimit-Reset'] = [ - '$ref' => '#/components/headers/xratelimitreset', - ]; - } - } - - // Add 429 response - $operation['responses']['429'] = [ - '$ref' => '#/components/responses/RateLimitExceeded', - ]; - - // Extract rate limit from attribute and add to description - $rateLimit = $this->extractRateLimit($route); - if ($rateLimit !== null) { - $limitInfo = sprintf( - '**Rate Limit:** %d requests per %d seconds', - $rateLimit['limit'], - $rateLimit['window'] - ); - - if ($rateLimit['burst'] > 1.0) { - $limitInfo .= sprintf(' (%.0f%% burst allowed)', ($rateLimit['burst'] - 1) * 100); - } - - $operation['description'] = isset($operation['description']) - ? $operation['description']."\n\n".$limitInfo - : $limitInfo; - } - - return $operation; - } - - /** - * Check if route has rate limiting. - */ - protected function hasRateLimiting(Route $route): bool - { - $middleware = $route->middleware(); - - foreach ($middleware as $m) { - if (str_contains($m, 'throttle') || - str_contains($m, 'rate') || - str_contains($m, 'api.rate') || - str_contains($m, 'RateLimit')) { - return true; - } - } - - // Also check for RateLimit attribute on controller - $controller = $route->getController(); - if ($controller !== null) { - $reflection = new ReflectionClass($controller); - if (! empty($reflection->getAttributes(RateLimit::class))) { - return true; - } - - $action = $route->getActionMethod(); - if ($reflection->hasMethod($action)) { - $method = $reflection->getMethod($action); - if (! empty($method->getAttributes(RateLimit::class))) { - return true; - } - } - } - - return false; - } - - /** - * Extract rate limit configuration from route. - */ - protected function extractRateLimit(Route $route): ?array - { - $controller = $route->getController(); - - if ($controller === null) { - return null; - } - - $reflection = new ReflectionClass($controller); - $action = $route->getActionMethod(); - - // Check method first - if ($reflection->hasMethod($action)) { - $method = $reflection->getMethod($action); - $attrs = $method->getAttributes(RateLimit::class); - if (! empty($attrs)) { - $rateLimit = $attrs[0]->newInstance(); - - return [ - 'limit' => $rateLimit->limit, - 'window' => $rateLimit->window, - 'burst' => $rateLimit->burst, - ]; - } - } - - // Check class - $attrs = $reflection->getAttributes(RateLimit::class); - if (! empty($attrs)) { - $rateLimit = $attrs[0]->newInstance(); - - return [ - 'limit' => $rateLimit->limit, - 'window' => $rateLimit->window, - 'burst' => $rateLimit->burst, - ]; - } - - return null; - } -} diff --git a/packages/core-api/src/Mod/Api/Documentation/Extensions/WorkspaceHeaderExtension.php b/packages/core-api/src/Mod/Api/Documentation/Extensions/WorkspaceHeaderExtension.php deleted file mode 100644 index 0679048..0000000 --- a/packages/core-api/src/Mod/Api/Documentation/Extensions/WorkspaceHeaderExtension.php +++ /dev/null @@ -1,111 +0,0 @@ - $workspaceConfig['header_name'] ?? 'X-Workspace-ID', - 'in' => 'header', - 'required' => $workspaceConfig['required'] ?? false, - 'description' => $workspaceConfig['description'] ?? 'Workspace identifier for multi-tenant operations', - 'schema' => [ - 'type' => 'string', - 'format' => 'uuid', - 'example' => '550e8400-e29b-41d4-a716-446655440000', - ], - ]; - } - - return $spec; - } - - /** - * Extend an individual operation. - */ - public function extendOperation(array $operation, Route $route, string $method, array $config): array - { - // Check if route requires workspace context - if (! $this->requiresWorkspace($route)) { - return $operation; - } - - $workspaceConfig = $config['workspace'] ?? []; - $headerName = $workspaceConfig['header_name'] ?? 'X-Workspace-ID'; - - // Add workspace header parameter reference - $operation['parameters'] = $operation['parameters'] ?? []; - - // Check if already added - foreach ($operation['parameters'] as $param) { - if (isset($param['name']) && $param['name'] === $headerName) { - return $operation; - } - } - - // Add as reference to component - $operation['parameters'][] = [ - '$ref' => '#/components/parameters/workspaceId', - ]; - - return $operation; - } - - /** - * Check if route requires workspace context. - */ - protected function requiresWorkspace(Route $route): bool - { - $middleware = $route->middleware(); - - // Check for workspace-related middleware - foreach ($middleware as $m) { - if (str_contains($m, 'workspace') || - str_contains($m, 'api.auth') || - str_contains($m, 'auth.api')) { - return true; - } - } - - // Check route name patterns that typically need workspace - $name = $route->getName() ?? ''; - $workspaceRoutes = [ - 'api.key.', - 'api.bio.', - 'api.blocks.', - 'api.shortlinks.', - 'api.qr.', - 'api.workspaces.', - 'api.webhooks.', - 'api.content.', - ]; - - foreach ($workspaceRoutes as $pattern) { - if (str_starts_with($name, $pattern)) { - return true; - } - } - - return false; - } -} diff --git a/packages/core-api/src/Mod/Api/Documentation/Middleware/ProtectDocumentation.php b/packages/core-api/src/Mod/Api/Documentation/Middleware/ProtectDocumentation.php deleted file mode 100644 index 4752c81..0000000 --- a/packages/core-api/src/Mod/Api/Documentation/Middleware/ProtectDocumentation.php +++ /dev/null @@ -1,76 +0,0 @@ -environment(), $publicEnvironments, true)) { - return $next($request); - } - - // Check IP whitelist - $ipWhitelist = $config['ip_whitelist'] ?? []; - if (! empty($ipWhitelist)) { - $clientIp = $request->ip(); - if (! in_array($clientIp, $ipWhitelist, true)) { - abort(403, 'Access denied.'); - } - - return $next($request); - } - - // Check if authentication is required - if ($config['require_auth'] ?? false) { - if (! $request->user()) { - return redirect()->route('login'); - } - - // Check allowed roles - $allowedRoles = $config['allowed_roles'] ?? []; - if (! empty($allowedRoles)) { - $user = $request->user(); - - // Check if user has any of the allowed roles - $hasRole = false; - foreach ($allowedRoles as $role) { - if (method_exists($user, 'hasRole') && $user->hasRole($role)) { - $hasRole = true; - break; - } - } - - if (! $hasRole) { - abort(403, 'Insufficient permissions to view documentation.'); - } - } - } - - return $next($request); - } -} diff --git a/packages/core-api/src/Mod/Api/Documentation/ModuleDiscovery.php b/packages/core-api/src/Mod/Api/Documentation/ModuleDiscovery.php deleted file mode 100644 index 9bb3681..0000000 --- a/packages/core-api/src/Mod/Api/Documentation/ModuleDiscovery.php +++ /dev/null @@ -1,209 +0,0 @@ - - */ - protected array $modules = []; - - /** - * Discover all API modules and their routes. - * - * @return array - */ - public function discover(): array - { - $this->modules = []; - - foreach (Route::getRoutes() as $route) { - if (! $this->isApiRoute($route)) { - continue; - } - - $module = $this->identifyModule($route); - $this->addRouteToModule($module, $route); - } - - ksort($this->modules); - - return $this->modules; - } - - /** - * Get modules grouped by tag. - * - * @return array - */ - public function getModulesByTag(): array - { - $byTag = []; - - foreach ($this->discover() as $module => $data) { - $tag = $data['tag'] ?? $module; - $byTag[$tag] = $byTag[$tag] ?? [ - 'name' => $tag, - 'description' => $data['description'] ?? null, - 'routes' => [], - ]; - - $byTag[$tag]['routes'] = array_merge( - $byTag[$tag]['routes'], - $data['routes'] - ); - } - - return $byTag; - } - - /** - * Get a summary of discovered modules. - */ - public function getSummary(): array - { - $modules = $this->discover(); - - return array_map(function ($data) { - return [ - 'tag' => $data['tag'], - 'description' => $data['description'], - 'route_count' => count($data['routes']), - 'endpoints' => array_map(function ($route) { - return [ - 'method' => $route['method'], - 'uri' => $route['uri'], - 'name' => $route['name'], - ]; - }, $data['routes']), - ]; - }, $modules); - } - - /** - * Check if route is an API route. - */ - protected function isApiRoute($route): bool - { - $uri = $route->uri(); - - return str_starts_with($uri, 'api/') || $uri === 'api'; - } - - /** - * Identify which module a route belongs to. - */ - protected function identifyModule($route): string - { - $controller = $route->getController(); - - if ($controller !== null) { - // Check for ApiTag attribute - $reflection = new ReflectionClass($controller); - $tagAttrs = $reflection->getAttributes(ApiTag::class); - - if (! empty($tagAttrs)) { - return $tagAttrs[0]->newInstance()->name; - } - - // Infer from namespace - $namespace = $reflection->getNamespaceName(); - - // Extract module name from namespace patterns - if (preg_match('/(?:Mod|Module|Http\\\\Controllers)\\\\([^\\\\]+)/', $namespace, $matches)) { - return $matches[1]; - } - } - - // Infer from route URI - return $this->inferModuleFromUri($route->uri()); - } - - /** - * Infer module name from URI. - */ - protected function inferModuleFromUri(string $uri): string - { - // Remove api/ prefix - $path = preg_replace('#^api/#', '', $uri); - - // Get first segment - $parts = explode('/', $path); - $segment = $parts[0] ?? 'general'; - - // Map common segments to module names - $mapping = [ - 'bio' => 'Bio', - 'blocks' => 'Bio', - 'shortlinks' => 'Bio', - 'qr' => 'Bio', - 'commerce' => 'Commerce', - 'provisioning' => 'Commerce', - 'workspaces' => 'Tenant', - 'analytics' => 'Analytics', - 'social' => 'Social', - 'notify' => 'Notifications', - 'support' => 'Support', - 'pixel' => 'Pixel', - 'seo' => 'SEO', - 'mcp' => 'MCP', - 'content' => 'Content', - 'trust' => 'Trust', - 'webhooks' => 'Webhooks', - 'entitlements' => 'Entitlements', - ]; - - return $mapping[$segment] ?? ucfirst($segment); - } - - /** - * Add a route to a module. - */ - protected function addRouteToModule(string $module, $route): void - { - if (! isset($this->modules[$module])) { - $this->modules[$module] = [ - 'tag' => $module, - 'description' => $this->getModuleDescription($module), - 'routes' => [], - ]; - } - - $methods = array_filter($route->methods(), fn ($m) => $m !== 'HEAD'); - - foreach ($methods as $method) { - $this->modules[$module]['routes'][] = [ - 'method' => strtoupper($method), - 'uri' => '/'.$route->uri(), - 'name' => $route->getName(), - 'action' => $route->getActionMethod(), - 'middleware' => $route->middleware(), - ]; - } - } - - /** - * Get module description from config. - */ - protected function getModuleDescription(string $module): ?string - { - $tags = config('api-docs.tags', []); - - return $tags[$module]['description'] ?? null; - } -} diff --git a/packages/core-api/src/Mod/Api/Documentation/OpenApiBuilder.php b/packages/core-api/src/Mod/Api/Documentation/OpenApiBuilder.php deleted file mode 100644 index e02764c..0000000 --- a/packages/core-api/src/Mod/Api/Documentation/OpenApiBuilder.php +++ /dev/null @@ -1,819 +0,0 @@ - - */ - protected array $extensions = []; - - /** - * Discovered tags from modules. - * - * @var array - */ - protected array $discoveredTags = []; - - /** - * Create a new builder instance. - */ - public function __construct() - { - $this->registerDefaultExtensions(); - } - - /** - * Register default extensions. - */ - protected function registerDefaultExtensions(): void - { - $this->extensions = [ - new WorkspaceHeaderExtension, - new RateLimitExtension, - new ApiKeyAuthExtension, - ]; - } - - /** - * Add a custom extension. - */ - public function addExtension(Extension $extension): static - { - $this->extensions[] = $extension; - - return $this; - } - - /** - * Generate the complete OpenAPI specification. - */ - public function build(): array - { - $config = config('api-docs', []); - - if ($this->shouldCache($config)) { - $cacheKey = $config['cache']['key'] ?? 'api-docs:openapi'; - $cacheTtl = $config['cache']['ttl'] ?? 3600; - - return Cache::remember($cacheKey, $cacheTtl, fn () => $this->buildSpec($config)); - } - - return $this->buildSpec($config); - } - - /** - * Clear the cached specification. - */ - public function clearCache(): void - { - $cacheKey = config('api-docs.cache.key', 'api-docs:openapi'); - Cache::forget($cacheKey); - } - - /** - * Check if caching should be enabled. - */ - protected function shouldCache(array $config): bool - { - if (! ($config['cache']['enabled'] ?? true)) { - return false; - } - - $disabledEnvs = $config['cache']['disabled_environments'] ?? ['local', 'testing']; - - return ! in_array(app()->environment(), $disabledEnvs, true); - } - - /** - * Build the full OpenAPI specification. - */ - protected function buildSpec(array $config): array - { - $spec = [ - 'openapi' => '3.1.0', - 'info' => $this->buildInfo($config), - 'servers' => $this->buildServers($config), - 'tags' => [], - 'paths' => [], - 'components' => $this->buildComponents($config), - ]; - - // Build paths and collect tags - $spec['paths'] = $this->buildPaths($config); - $spec['tags'] = $this->buildTags($config); - - // Apply extensions to spec - foreach ($this->extensions as $extension) { - $spec = $extension->extend($spec, $config); - } - - return $spec; - } - - /** - * Build API info section. - */ - protected function buildInfo(array $config): array - { - $info = $config['info'] ?? []; - - $result = [ - 'title' => $info['title'] ?? config('app.name', 'API').' API', - 'version' => $info['version'] ?? config('api.version', '1.0.0'), - ]; - - if (! empty($info['description'])) { - $result['description'] = $info['description']; - } - - if (! empty($info['contact'])) { - $contact = array_filter($info['contact']); - if (! empty($contact)) { - $result['contact'] = $contact; - } - } - - if (! empty($info['license']['name'])) { - $result['license'] = array_filter($info['license']); - } - - return $result; - } - - /** - * Build servers section. - */ - protected function buildServers(array $config): array - { - $servers = $config['servers'] ?? []; - - if (empty($servers)) { - return [ - [ - 'url' => config('app.url', 'http://localhost'), - 'description' => 'Current Environment', - ], - ]; - } - - return array_map(fn ($server) => array_filter($server), $servers); - } - - /** - * Build tags section from discovered modules and config. - */ - protected function buildTags(array $config): array - { - $configTags = $config['tags'] ?? []; - $tags = []; - - // Add discovered tags first - foreach ($this->discoveredTags as $name => $data) { - $tags[$name] = [ - 'name' => $name, - 'description' => $data['description'] ?? null, - ]; - } - - // Merge with configured tags (config takes precedence) - foreach ($configTags as $key => $tagConfig) { - $tagName = $tagConfig['name'] ?? $key; - $tags[$tagName] = [ - 'name' => $tagName, - 'description' => $tagConfig['description'] ?? null, - ]; - } - - // Clean up null descriptions and sort - $result = []; - foreach ($tags as $tag) { - $result[] = array_filter($tag); - } - - usort($result, fn ($a, $b) => strcasecmp($a['name'], $b['name'])); - - return $result; - } - - /** - * Build paths section from routes. - */ - protected function buildPaths(array $config): array - { - $paths = []; - $includePatterns = $config['routes']['include'] ?? ['api/*']; - $excludePatterns = $config['routes']['exclude'] ?? []; - - foreach (RouteFacade::getRoutes() as $route) { - /** @var Route $route */ - if (! $this->shouldIncludeRoute($route, $includePatterns, $excludePatterns)) { - continue; - } - - $path = $this->normalizePath($route->uri()); - $methods = array_filter($route->methods(), fn ($m) => $m !== 'HEAD'); - - foreach ($methods as $method) { - $method = strtolower($method); - $operation = $this->buildOperation($route, $method, $config); - - if ($operation !== null) { - $paths[$path][$method] = $operation; - } - } - } - - ksort($paths); - - return $paths; - } - - /** - * Check if a route should be included in documentation. - */ - protected function shouldIncludeRoute(Route $route, array $include, array $exclude): bool - { - $uri = $route->uri(); - - // Check exclusions first - foreach ($exclude as $pattern) { - if (fnmatch($pattern, $uri)) { - return false; - } - } - - // Check inclusions - foreach ($include as $pattern) { - if (fnmatch($pattern, $uri)) { - return true; - } - } - - return false; - } - - /** - * Normalize route path to OpenAPI format. - */ - protected function normalizePath(string $uri): string - { - // Prepend slash if missing - $path = '/'.ltrim($uri, '/'); - - // Convert Laravel parameters to OpenAPI format: {param?} -> {param} - $path = preg_replace('/\{([^}?]+)\?\}/', '{$1}', $path); - - return $path === '/' ? '/' : rtrim($path, '/'); - } - - /** - * Build operation for a specific route and method. - */ - protected function buildOperation(Route $route, string $method, array $config): ?array - { - $controller = $route->getController(); - $action = $route->getActionMethod(); - - // Check for ApiHidden attribute - if ($this->isHidden($controller, $action)) { - return null; - } - - $operation = [ - 'summary' => $this->buildSummary($route, $method), - 'operationId' => $this->buildOperationId($route, $method), - 'tags' => $this->buildOperationTags($route, $controller, $action), - 'responses' => $this->buildResponses($controller, $action), - ]; - - // Add description from PHPDoc if available - $description = $this->extractDescription($controller, $action); - if ($description) { - $operation['description'] = $description; - } - - // Add parameters - $parameters = $this->buildParameters($route, $controller, $action, $config); - if (! empty($parameters)) { - $operation['parameters'] = $parameters; - } - - // Add request body for POST/PUT/PATCH - if (in_array($method, ['post', 'put', 'patch'])) { - $operation['requestBody'] = $this->buildRequestBody($controller, $action); - } - - // Add security requirements - $security = $this->buildSecurity($route, $controller, $action); - if ($security !== null) { - $operation['security'] = $security; - } - - // Apply extensions to operation - foreach ($this->extensions as $extension) { - $operation = $extension->extendOperation($operation, $route, $method, $config); - } - - return $operation; - } - - /** - * Check if controller/method is hidden from docs. - */ - protected function isHidden(?object $controller, string $action): bool - { - if ($controller === null) { - return false; - } - - $reflection = new ReflectionClass($controller); - - // Check class-level attribute - $classAttrs = $reflection->getAttributes(ApiHidden::class); - if (! empty($classAttrs)) { - return true; - } - - // Check method-level attribute - if ($reflection->hasMethod($action)) { - $method = $reflection->getMethod($action); - $methodAttrs = $method->getAttributes(ApiHidden::class); - if (! empty($methodAttrs)) { - return true; - } - } - - return false; - } - - /** - * Build operation summary. - */ - protected function buildSummary(Route $route, string $method): string - { - $name = $route->getName(); - - if ($name) { - // Convert route name to human-readable summary - $parts = explode('.', $name); - $action = array_pop($parts); - - return Str::title(str_replace(['-', '_'], ' ', $action)); - } - - // Generate from URI and method - $uri = Str::afterLast($route->uri(), '/'); - - return Str::title($method.' '.str_replace(['-', '_'], ' ', $uri)); - } - - /** - * Build operation ID from route name. - */ - protected function buildOperationId(Route $route, string $method): string - { - $name = $route->getName(); - - if ($name) { - return Str::camel(str_replace(['.', '-'], '_', $name)); - } - - return Str::camel($method.'_'.str_replace(['/', '-', '.'], '_', $route->uri())); - } - - /** - * Build tags for an operation. - */ - protected function buildOperationTags(Route $route, ?object $controller, string $action): array - { - // Check for ApiTag attribute - if ($controller !== null) { - $tagAttr = $this->getAttribute($controller, $action, ApiTag::class); - if ($tagAttr !== null) { - $tag = $tagAttr->newInstance(); - $this->discoveredTags[$tag->name] = ['description' => $tag->description]; - - return [$tag->name]; - } - } - - // Infer tag from route - return [$this->inferTag($route)]; - } - - /** - * Infer tag from route. - */ - protected function inferTag(Route $route): string - { - $uri = $route->uri(); - $name = $route->getName() ?? ''; - - // Common tag mappings by route prefix - $tagMap = [ - 'api/bio' => 'Bio Links', - 'api/blocks' => 'Bio Links', - 'api/shortlinks' => 'Bio Links', - 'api/qr' => 'Bio Links', - 'api/commerce' => 'Commerce', - 'api/provisioning' => 'Commerce', - 'api/workspaces' => 'Workspaces', - 'api/analytics' => 'Analytics', - 'api/social' => 'Social', - 'api/notify' => 'Notifications', - 'api/support' => 'Support', - 'api/pixel' => 'Pixel', - 'api/seo' => 'SEO', - 'api/mcp' => 'MCP', - 'api/content' => 'Content', - 'api/trust' => 'Trust', - 'api/webhooks' => 'Webhooks', - 'api/entitlements' => 'Entitlements', - ]; - - foreach ($tagMap as $prefix => $tag) { - if (str_starts_with($uri, $prefix)) { - $this->discoveredTags[$tag] = $this->discoveredTags[$tag] ?? []; - - return $tag; - } - } - - $this->discoveredTags['General'] = $this->discoveredTags['General'] ?? []; - - return 'General'; - } - - /** - * Extract description from PHPDoc. - */ - protected function extractDescription(?object $controller, string $action): ?string - { - if ($controller === null) { - return null; - } - - $reflection = new ReflectionClass($controller); - if (! $reflection->hasMethod($action)) { - return null; - } - - $method = $reflection->getMethod($action); - $doc = $method->getDocComment(); - - if (! $doc) { - return null; - } - - // Extract description from PHPDoc (first paragraph before @tags) - preg_match('/\/\*\*\s*\n\s*\*\s*(.+?)(?:\n\s*\*\s*\n|\n\s*\*\s*@)/s', $doc, $matches); - - if (! empty($matches[1])) { - $description = preg_replace('/\n\s*\*\s*/', ' ', $matches[1]); - - return trim($description); - } - - return null; - } - - /** - * Build parameters for operation. - */ - protected function buildParameters(Route $route, ?object $controller, string $action, array $config): array - { - $parameters = []; - - // Add path parameters - preg_match_all('/\{([^}?]+)\??}/', $route->uri(), $matches); - foreach ($matches[1] as $param) { - $parameters[] = [ - 'name' => $param, - 'in' => 'path', - 'required' => true, - 'schema' => ['type' => 'string'], - ]; - } - - // Add parameters from ApiParameter attributes - if ($controller !== null) { - $reflection = new ReflectionClass($controller); - if ($reflection->hasMethod($action)) { - $method = $reflection->getMethod($action); - $paramAttrs = $method->getAttributes(ApiParameter::class, ReflectionAttribute::IS_INSTANCEOF); - - foreach ($paramAttrs as $attr) { - $param = $attr->newInstance(); - $parameters[] = $param->toOpenApi(); - } - } - } - - return $parameters; - } - - /** - * Build responses section. - */ - protected function buildResponses(?object $controller, string $action): array - { - $responses = []; - - // Get ApiResponse attributes - if ($controller !== null) { - $reflection = new ReflectionClass($controller); - if ($reflection->hasMethod($action)) { - $method = $reflection->getMethod($action); - $responseAttrs = $method->getAttributes(ApiResponse::class, ReflectionAttribute::IS_INSTANCEOF); - - foreach ($responseAttrs as $attr) { - $response = $attr->newInstance(); - $responses[(string) $response->status] = $this->buildResponseSchema($response); - } - } - } - - // Default 200 response if none specified - if (empty($responses)) { - $responses['200'] = ['description' => 'Successful response']; - } - - return $responses; - } - - /** - * Build response schema from ApiResponse attribute. - */ - protected function buildResponseSchema(ApiResponse $response): array - { - $result = [ - 'description' => $response->getDescription(), - ]; - - if ($response->resource !== null && class_exists($response->resource)) { - $schema = $this->extractResourceSchema($response->resource); - - if ($response->paginated) { - $schema = $this->wrapPaginatedSchema($schema); - } - - $result['content'] = [ - 'application/json' => [ - 'schema' => $schema, - ], - ]; - } - - if (! empty($response->headers)) { - $result['headers'] = []; - foreach ($response->headers as $header => $description) { - $result['headers'][$header] = [ - 'description' => $description, - 'schema' => ['type' => 'string'], - ]; - } - } - - return $result; - } - - /** - * Extract schema from JsonResource class. - */ - protected function extractResourceSchema(string $resourceClass): array - { - if (! is_subclass_of($resourceClass, JsonResource::class)) { - return ['type' => 'object']; - } - - // For now, return a generic object schema - // A more sophisticated implementation would analyze the resource's toArray method - return [ - 'type' => 'object', - 'additionalProperties' => true, - ]; - } - - /** - * Wrap schema in pagination structure. - */ - protected function wrapPaginatedSchema(array $itemSchema): array - { - return [ - 'type' => 'object', - 'properties' => [ - 'data' => [ - 'type' => 'array', - 'items' => $itemSchema, - ], - 'links' => [ - 'type' => 'object', - 'properties' => [ - 'first' => ['type' => 'string', 'format' => 'uri'], - 'last' => ['type' => 'string', 'format' => 'uri'], - 'prev' => ['type' => 'string', 'format' => 'uri', 'nullable' => true], - 'next' => ['type' => 'string', 'format' => 'uri', 'nullable' => true], - ], - ], - 'meta' => [ - 'type' => 'object', - 'properties' => [ - 'current_page' => ['type' => 'integer'], - 'from' => ['type' => 'integer', 'nullable' => true], - 'last_page' => ['type' => 'integer'], - 'per_page' => ['type' => 'integer'], - 'to' => ['type' => 'integer', 'nullable' => true], - 'total' => ['type' => 'integer'], - ], - ], - ], - ]; - } - - /** - * Build request body schema. - */ - protected function buildRequestBody(?object $controller, string $action): array - { - return [ - 'required' => true, - 'content' => [ - 'application/json' => [ - 'schema' => ['type' => 'object'], - ], - ], - ]; - } - - /** - * Build security requirements. - */ - protected function buildSecurity(Route $route, ?object $controller, string $action): ?array - { - // Check for ApiSecurity attribute - if ($controller !== null) { - $securityAttr = $this->getAttribute($controller, $action, ApiSecurity::class); - if ($securityAttr !== null) { - $security = $securityAttr->newInstance(); - if ($security->isPublic()) { - return []; // Empty array means no auth required - } - - return [[$security->scheme => $security->scopes]]; - } - } - - // Infer from route middleware - $middleware = $route->middleware(); - - if (in_array('auth:sanctum', $middleware) || in_array('auth', $middleware)) { - return [['bearerAuth' => []]]; - } - - if (in_array('api.auth', $middleware) || in_array('auth.api', $middleware)) { - return [['apiKeyAuth' => []]]; - } - - foreach ($middleware as $m) { - if (str_contains($m, 'ApiKeyAuth') || str_contains($m, 'AuthenticateApiKey')) { - return [['apiKeyAuth' => []]]; - } - } - - return null; - } - - /** - * Build components section. - */ - protected function buildComponents(array $config): array - { - $components = [ - 'securitySchemes' => [], - 'schemas' => $this->buildCommonSchemas(), - ]; - - // Add API Key security scheme - $apiKeyConfig = $config['auth']['api_key'] ?? []; - if ($apiKeyConfig['enabled'] ?? true) { - $components['securitySchemes']['apiKeyAuth'] = [ - 'type' => 'apiKey', - 'in' => $apiKeyConfig['in'] ?? 'header', - 'name' => $apiKeyConfig['name'] ?? 'X-API-Key', - 'description' => $apiKeyConfig['description'] ?? 'API key for authentication', - ]; - } - - // Add Bearer token security scheme - $bearerConfig = $config['auth']['bearer'] ?? []; - if ($bearerConfig['enabled'] ?? true) { - $components['securitySchemes']['bearerAuth'] = [ - 'type' => 'http', - 'scheme' => $bearerConfig['scheme'] ?? 'bearer', - 'bearerFormat' => $bearerConfig['format'] ?? 'JWT', - 'description' => $bearerConfig['description'] ?? 'Bearer token authentication', - ]; - } - - // Add OAuth2 security scheme - $oauth2Config = $config['auth']['oauth2'] ?? []; - if ($oauth2Config['enabled'] ?? false) { - $components['securitySchemes']['oauth2'] = [ - 'type' => 'oauth2', - 'flows' => $oauth2Config['flows'] ?? [], - ]; - } - - return $components; - } - - /** - * Build common reusable schemas. - */ - protected function buildCommonSchemas(): array - { - return [ - 'Error' => [ - 'type' => 'object', - 'required' => ['message'], - 'properties' => [ - 'message' => ['type' => 'string', 'description' => 'Error message'], - 'errors' => [ - 'type' => 'object', - 'description' => 'Validation errors (field => messages)', - 'additionalProperties' => [ - 'type' => 'array', - 'items' => ['type' => 'string'], - ], - ], - ], - ], - 'Pagination' => [ - 'type' => 'object', - 'properties' => [ - 'current_page' => ['type' => 'integer'], - 'from' => ['type' => 'integer', 'nullable' => true], - 'last_page' => ['type' => 'integer'], - 'per_page' => ['type' => 'integer'], - 'to' => ['type' => 'integer', 'nullable' => true], - 'total' => ['type' => 'integer'], - ], - ], - ]; - } - - /** - * Get attribute from controller class or method. - * - * @template T - * - * @param class-string $attributeClass - * @return ReflectionAttribute|null - */ - protected function getAttribute(object $controller, string $action, string $attributeClass): ?ReflectionAttribute - { - $reflection = new ReflectionClass($controller); - - // Check method first (method takes precedence) - if ($reflection->hasMethod($action)) { - $method = $reflection->getMethod($action); - $attrs = $method->getAttributes($attributeClass); - if (! empty($attrs)) { - return $attrs[0]; - } - } - - // Fall back to class - $attrs = $reflection->getAttributes($attributeClass); - - return $attrs[0] ?? null; - } -} diff --git a/packages/core-api/src/Mod/Api/Documentation/Routes/docs.php b/packages/core-api/src/Mod/Api/Documentation/Routes/docs.php deleted file mode 100644 index 03ae6ad..0000000 --- a/packages/core-api/src/Mod/Api/Documentation/Routes/docs.php +++ /dev/null @@ -1,36 +0,0 @@ -name('api.docs'); -Route::get('/swagger', [DocumentationController::class, 'swagger'])->name('api.docs.swagger'); -Route::get('/scalar', [DocumentationController::class, 'scalar'])->name('api.docs.scalar'); -Route::get('/redoc', [DocumentationController::class, 'redoc'])->name('api.docs.redoc'); - -// OpenAPI specification routes -Route::get('/openapi.json', [DocumentationController::class, 'openApiJson']) - ->name('api.docs.openapi.json') - ->middleware('throttle:60,1'); - -Route::get('/openapi.yaml', [DocumentationController::class, 'openApiYaml']) - ->name('api.docs.openapi.yaml') - ->middleware('throttle:60,1'); - -// Cache management (admin only) -Route::post('/cache/clear', [DocumentationController::class, 'clearCache']) - ->name('api.docs.cache.clear') - ->middleware('auth'); diff --git a/packages/core-api/src/Mod/Api/Documentation/Views/redoc.blade.php b/packages/core-api/src/Mod/Api/Documentation/Views/redoc.blade.php deleted file mode 100644 index d1fd68e..0000000 --- a/packages/core-api/src/Mod/Api/Documentation/Views/redoc.blade.php +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - {{ config('api-docs.info.title', 'API Documentation') }} - ReDoc - - - - - - - - - - diff --git a/packages/core-api/src/Mod/Api/Documentation/Views/scalar.blade.php b/packages/core-api/src/Mod/Api/Documentation/Views/scalar.blade.php deleted file mode 100644 index 85ac8c8..0000000 --- a/packages/core-api/src/Mod/Api/Documentation/Views/scalar.blade.php +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - {{ config('api-docs.info.title', 'API Documentation') }} - - - - - - - diff --git a/packages/core-api/src/Mod/Api/Documentation/Views/swagger.blade.php b/packages/core-api/src/Mod/Api/Documentation/Views/swagger.blade.php deleted file mode 100644 index 2515ddd..0000000 --- a/packages/core-api/src/Mod/Api/Documentation/Views/swagger.blade.php +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - {{ config('api-docs.info.title', 'API Documentation') }} - Swagger UI - - - - -
- - - - - - diff --git a/packages/core-api/src/Mod/Api/Documentation/config.php b/packages/core-api/src/Mod/Api/Documentation/config.php deleted file mode 100644 index 0c43186..0000000 --- a/packages/core-api/src/Mod/Api/Documentation/config.php +++ /dev/null @@ -1,319 +0,0 @@ - env('API_DOCS_ENABLED', true), - - /* - |-------------------------------------------------------------------------- - | Documentation Path - |-------------------------------------------------------------------------- - | - | The URL path where API documentation is served. - | - */ - - 'path' => '/api/docs', - - /* - |-------------------------------------------------------------------------- - | API Information - |-------------------------------------------------------------------------- - | - | Basic information about your API displayed in the documentation. - | - */ - - 'info' => [ - 'title' => env('API_DOCS_TITLE', 'API Documentation'), - 'description' => env('API_DOCS_DESCRIPTION', 'REST API for programmatic access to services.'), - 'version' => env('API_DOCS_VERSION', '1.0.0'), - 'contact' => [ - 'name' => env('API_DOCS_CONTACT_NAME'), - 'email' => env('API_DOCS_CONTACT_EMAIL'), - 'url' => env('API_DOCS_CONTACT_URL'), - ], - 'license' => [ - 'name' => env('API_DOCS_LICENSE_NAME', 'Proprietary'), - 'url' => env('API_DOCS_LICENSE_URL'), - ], - ], - - /* - |-------------------------------------------------------------------------- - | Servers - |-------------------------------------------------------------------------- - | - | List of API servers displayed in the documentation. - | - */ - - 'servers' => [ - [ - 'url' => env('APP_URL', 'http://localhost'), - 'description' => 'Current Environment', - ], - ], - - /* - |-------------------------------------------------------------------------- - | Authentication Schemes - |-------------------------------------------------------------------------- - | - | Configure how authentication is documented in OpenAPI. - | - */ - - 'auth' => [ - // API Key authentication via header - 'api_key' => [ - 'enabled' => true, - 'name' => 'X-API-Key', - 'in' => 'header', - 'description' => 'API key for authentication. Create keys in your workspace settings.', - ], - - // Bearer token authentication - 'bearer' => [ - 'enabled' => true, - 'scheme' => 'bearer', - 'format' => 'JWT', - 'description' => 'Bearer token authentication for user sessions.', - ], - - // OAuth2 (if applicable) - 'oauth2' => [ - 'enabled' => false, - 'flows' => [ - 'authorizationCode' => [ - 'authorizationUrl' => '/oauth/authorize', - 'tokenUrl' => '/oauth/token', - 'refreshUrl' => '/oauth/token', - 'scopes' => [ - 'read' => 'Read access to resources', - 'write' => 'Write access to resources', - 'delete' => 'Delete access to resources', - ], - ], - ], - ], - ], - - /* - |-------------------------------------------------------------------------- - | Workspace Header - |-------------------------------------------------------------------------- - | - | Configure the workspace header documentation. - | - */ - - 'workspace' => [ - 'header_name' => 'X-Workspace-ID', - 'required' => false, - 'description' => 'Optional workspace identifier for multi-tenant operations. If not provided, the default workspace associated with the API key will be used.', - ], - - /* - |-------------------------------------------------------------------------- - | Rate Limiting Documentation - |-------------------------------------------------------------------------- - | - | Configure how rate limits are documented in responses. - | - */ - - 'rate_limits' => [ - 'enabled' => true, - 'headers' => [ - 'X-RateLimit-Limit' => 'Maximum number of requests allowed per window', - 'X-RateLimit-Remaining' => 'Number of requests remaining in the current window', - 'X-RateLimit-Reset' => 'Unix timestamp when the rate limit window resets', - 'Retry-After' => 'Seconds to wait before retrying (only on 429 responses)', - ], - ], - - /* - |-------------------------------------------------------------------------- - | Module Tags - |-------------------------------------------------------------------------- - | - | Map module namespaces to documentation tags for grouping endpoints. - | - */ - - 'tags' => [ - // Module namespace => Tag configuration - 'Bio' => [ - 'name' => 'Bio Links', - 'description' => 'Bio link pages, blocks, and customization', - ], - 'Commerce' => [ - 'name' => 'Commerce', - 'description' => 'Billing, subscriptions, orders, and invoices', - ], - 'Analytics' => [ - 'name' => 'Analytics', - 'description' => 'Website and link analytics tracking', - ], - 'Social' => [ - 'name' => 'Social', - 'description' => 'Social media management and scheduling', - ], - 'Notify' => [ - 'name' => 'Notifications', - 'description' => 'Push notifications and alerts', - ], - 'Support' => [ - 'name' => 'Support', - 'description' => 'Helpdesk and customer support', - ], - 'Tenant' => [ - 'name' => 'Workspaces', - 'description' => 'Workspace and team management', - ], - 'Pixel' => [ - 'name' => 'Pixel', - 'description' => 'Unified tracking pixel endpoints', - ], - 'SEO' => [ - 'name' => 'SEO', - 'description' => 'SEO analysis and reporting', - ], - 'MCP' => [ - 'name' => 'MCP', - 'description' => 'Model Context Protocol HTTP bridge', - ], - 'Content' => [ - 'name' => 'Content', - 'description' => 'AI content generation', - ], - 'Trust' => [ - 'name' => 'Trust', - 'description' => 'Social proof and testimonials', - ], - 'Webhooks' => [ - 'name' => 'Webhooks', - 'description' => 'Webhook endpoints and management', - ], - ], - - /* - |-------------------------------------------------------------------------- - | Route Filtering - |-------------------------------------------------------------------------- - | - | Configure which routes are included in the documentation. - | - */ - - 'routes' => [ - // Only include routes matching these patterns - 'include' => [ - 'api/*', - ], - - // Exclude routes matching these patterns - 'exclude' => [ - 'api/sanctum/*', - 'api/telescope/*', - 'api/horizon/*', - ], - - // Hide internal/admin routes from public docs - 'hide_internal' => true, - ], - - /* - |-------------------------------------------------------------------------- - | Documentation UI - |-------------------------------------------------------------------------- - | - | Configure the documentation UI appearance. - | - */ - - 'ui' => [ - // Default UI renderer: 'swagger', 'scalar', 'redoc', 'stoplight' - 'default' => 'scalar', - - // Swagger UI specific options - 'swagger' => [ - 'doc_expansion' => 'none', // 'list', 'full', 'none' - 'filter' => true, - 'show_extensions' => true, - 'show_common_extensions' => true, - ], - - // Scalar specific options - 'scalar' => [ - 'theme' => 'default', // 'default', 'alternate', 'moon', 'purple', 'solarized' - 'show_sidebar' => true, - 'hide_download_button' => false, - 'hide_models' => false, - ], - ], - - /* - |-------------------------------------------------------------------------- - | Access Control - |-------------------------------------------------------------------------- - | - | Configure who can access the documentation. - | - */ - - 'access' => [ - // Require authentication to view docs - 'require_auth' => env('API_DOCS_REQUIRE_AUTH', false), - - // Only allow these roles to view docs (empty = all authenticated users) - 'allowed_roles' => [], - - // Allow unauthenticated access in these environments - 'public_environments' => ['local', 'testing', 'staging'], - - // IP whitelist for production (empty = no restriction) - 'ip_whitelist' => [], - ], - - /* - |-------------------------------------------------------------------------- - | Caching - |-------------------------------------------------------------------------- - | - | Configure documentation caching. - | - */ - - 'cache' => [ - // Enable caching of generated OpenAPI spec - 'enabled' => env('API_DOCS_CACHE_ENABLED', true), - - // Cache key prefix - 'key' => 'api-docs:openapi', - - // Cache duration in seconds (1 hour default) - 'ttl' => env('API_DOCS_CACHE_TTL', 3600), - - // Disable cache in these environments - 'disabled_environments' => ['local', 'testing'], - ], - -]; diff --git a/packages/core-api/src/Mod/Api/Exceptions/RateLimitExceededException.php b/packages/core-api/src/Mod/Api/Exceptions/RateLimitExceededException.php deleted file mode 100644 index 62436b1..0000000 --- a/packages/core-api/src/Mod/Api/Exceptions/RateLimitExceededException.php +++ /dev/null @@ -1,56 +0,0 @@ -rateLimitResult; - } - - /** - * Render the exception as a JSON response. - */ - public function render(): JsonResponse - { - return response()->json([ - 'error' => 'rate_limit_exceeded', - 'message' => $this->getMessage(), - 'retry_after' => $this->rateLimitResult->retryAfter, - 'limit' => $this->rateLimitResult->limit, - 'resets_at' => $this->rateLimitResult->resetsAt->toIso8601String(), - ], 429, $this->rateLimitResult->headers()); - } - - /** - * Get headers for the response. - * - * @return array - */ - public function getHeaders(): array - { - return array_map(fn ($value) => (string) $value, $this->rateLimitResult->headers()); - } -} diff --git a/packages/core-api/src/Mod/Api/Guards/AccessTokenGuard.php b/packages/core-api/src/Mod/Api/Guards/AccessTokenGuard.php deleted file mode 100644 index cd098b4..0000000 --- a/packages/core-api/src/Mod/Api/Guards/AccessTokenGuard.php +++ /dev/null @@ -1,98 +0,0 @@ -group(function () { - * // Protected API routes - * }); - */ -class AccessTokenGuard -{ - /** - * The authentication factory instance. - */ - protected Factory $auth; - - /** - * Create a new guard instance. - */ - public function __construct(Factory $auth) - { - $this->auth = $auth; - } - - /** - * Handle the authentication for the incoming request. - * - * This method is called by Laravel's authentication system when using - * the guard. It attempts to authenticate the request using the Bearer - * token and returns the authenticated user if successful. - * - * @return User|null The authenticated user or null if authentication fails - */ - public function __invoke(Request $request): ?User - { - $token = $this->getTokenFromRequest($request); - - if (! $token) { - return null; - } - - $accessToken = UserToken::findToken($token); - - if (! $this->isValidAccessToken($accessToken)) { - return null; - } - - // Update last used timestamp - $accessToken->recordUsage(); - - return $accessToken->user; - } - - /** - * Extract the Bearer token from the request. - * - * Looks for the token in the Authorization header in the format: - * Authorization: Bearer {token} - * - * @return string|null The extracted token or null if not found - */ - protected function getTokenFromRequest(Request $request): ?string - { - $token = $request->bearerToken(); - - return ! empty($token) ? $token : null; - } - - /** - * Validate the access token. - * - * Checks if the token exists and hasn't expired. - * - * @return bool True if the token is valid, false otherwise - */ - protected function isValidAccessToken(?UserToken $accessToken): bool - { - if (! $accessToken) { - return false; - } - - return $accessToken->isValid(); - } -} diff --git a/packages/core-api/src/Mod/Api/Jobs/DeliverWebhookJob.php b/packages/core-api/src/Mod/Api/Jobs/DeliverWebhookJob.php deleted file mode 100644 index ba7612d..0000000 --- a/packages/core-api/src/Mod/Api/Jobs/DeliverWebhookJob.php +++ /dev/null @@ -1,182 +0,0 @@ -queue = config('api.webhooks.queue', 'default'); - - $connection = config('api.webhooks.queue_connection'); - if ($connection) { - $this->connection = $connection; - } - } - - /** - * Execute the job. - */ - public function handle(): void - { - // Don't deliver if endpoint is disabled - $endpoint = $this->delivery->endpoint; - if (! $endpoint || ! $endpoint->shouldReceive($this->delivery->event_type)) { - Log::info('Webhook delivery skipped - endpoint inactive or does not receive this event', [ - 'delivery_id' => $this->delivery->id, - 'event_type' => $this->delivery->event_type, - ]); - - return; - } - - // Get delivery payload with signature headers - $deliveryPayload = $this->delivery->getDeliveryPayload(); - $timeout = config('api.webhooks.timeout', 30); - - Log::info('Attempting webhook delivery', [ - 'delivery_id' => $this->delivery->id, - 'endpoint_url' => $endpoint->url, - 'event_type' => $this->delivery->event_type, - 'attempt' => $this->delivery->attempt, - ]); - - try { - $response = Http::timeout($timeout) - ->withHeaders($deliveryPayload['headers']) - ->withBody($deliveryPayload['body'], 'application/json') - ->post($endpoint->url); - - $statusCode = $response->status(); - $responseBody = $response->body(); - - // Success is any 2xx status code - if ($response->successful()) { - $this->delivery->markSuccess($statusCode, $responseBody); - - Log::info('Webhook delivered successfully', [ - 'delivery_id' => $this->delivery->id, - 'status_code' => $statusCode, - ]); - - return; - } - - // Non-2xx response - mark as failed and potentially retry - $this->handleFailure($statusCode, $responseBody); - - } catch (\Illuminate\Http\Client\ConnectionException $e) { - // Connection timeout or refused - $this->handleFailure(0, 'Connection failed: '.$e->getMessage()); - - } catch (\Throwable $e) { - // Unexpected error - $this->handleFailure(0, 'Unexpected error: '.$e->getMessage()); - - Log::error('Webhook delivery unexpected error', [ - 'delivery_id' => $this->delivery->id, - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - } - } - - /** - * Handle a failed delivery attempt. - */ - protected function handleFailure(int $statusCode, ?string $responseBody): void - { - Log::warning('Webhook delivery failed', [ - 'delivery_id' => $this->delivery->id, - 'attempt' => $this->delivery->attempt, - 'status_code' => $statusCode, - 'can_retry' => $this->delivery->canRetry(), - ]); - - // Mark as failed (this also schedules retry if attempts remain) - $this->delivery->markFailed($statusCode, $responseBody); - - // If we can retry, dispatch a new job with the appropriate delay - if ($this->delivery->canRetry() && $this->delivery->next_retry_at) { - $delay = $this->delivery->next_retry_at->diffInSeconds(now()); - - Log::info('Scheduling webhook retry', [ - 'delivery_id' => $this->delivery->id, - 'next_attempt' => $this->delivery->attempt, - 'delay_seconds' => $delay, - 'next_retry_at' => $this->delivery->next_retry_at->toIso8601String(), - ]); - - // Dispatch retry with calculated delay - self::dispatch($this->delivery->fresh())->delay($delay); - } - } - - /** - * Handle a job failure. - */ - public function failed(\Throwable $exception): void - { - Log::error('Webhook delivery job failed completely', [ - 'delivery_id' => $this->delivery->id, - 'error' => $exception->getMessage(), - ]); - } - - /** - * Get the tags for the job. - * - * @return array - */ - public function tags(): array - { - return [ - 'webhook', - 'webhook:'.$this->delivery->webhook_endpoint_id, - 'event:'.$this->delivery->event_type, - ]; - } -} diff --git a/packages/core-api/src/Mod/Api/Middleware/AuthenticateApiKey.php b/packages/core-api/src/Mod/Api/Middleware/AuthenticateApiKey.php deleted file mode 100644 index ab6e101..0000000 --- a/packages/core-api/src/Mod/Api/Middleware/AuthenticateApiKey.php +++ /dev/null @@ -1,125 +0,0 @@ -withMiddleware(function (Middleware $middleware) { - * $middleware->alias([ - * 'auth.api' => \App\Http\Middleware\Api\AuthenticateApiKey::class, - * ]); - * }) - */ -class AuthenticateApiKey -{ - public function handle(Request $request, Closure $next, ?string $scope = null): Response - { - $token = $request->bearerToken(); - - if (! $token) { - return $this->unauthorized('API key required. Use Authorization: Bearer '); - } - - // Check if it's an API key (prefixed with hk_) - if (str_starts_with($token, 'hk_')) { - return $this->authenticateApiKey($request, $next, $token, $scope); - } - - // Fall back to Sanctum for OAuth tokens - return $this->authenticateSanctum($request, $next, $scope); - } - - /** - * Authenticate using an API key. - */ - protected function authenticateApiKey( - Request $request, - Closure $next, - string $token, - ?string $scope - ): Response { - $apiKey = ApiKey::findByPlainKey($token); - - if (! $apiKey) { - return $this->unauthorized('Invalid API key'); - } - - if ($apiKey->isExpired()) { - return $this->unauthorized('API key has expired'); - } - - // Check scope if required - if ($scope !== null && ! $apiKey->hasScope($scope)) { - return $this->forbidden("API key missing required scope: {$scope}"); - } - - // Record usage (non-blocking) - $apiKey->recordUsage(); - - // Set request context - $request->setUserResolver(fn () => $apiKey->user); - $request->attributes->set('api_key', $apiKey); - $request->attributes->set('workspace', $apiKey->workspace); - $request->attributes->set('workspace_id', $apiKey->workspace_id); - $request->attributes->set('auth_type', 'api_key'); - - return $next($request); - } - - /** - * Fall back to Sanctum authentication for OAuth tokens. - */ - protected function authenticateSanctum( - Request $request, - Closure $next, - ?string $scope - ): Response { - // For API requests, use token authentication - if (! $request->user()) { - // Try to authenticate via Sanctum token - $guard = auth('sanctum'); - if (! $guard->check()) { - return $this->unauthorized('Invalid authentication token'); - } - - $request->setUserResolver(fn () => $guard->user()); - } - - $request->attributes->set('auth_type', 'sanctum'); - - return $next($request); - } - - /** - * Return 401 Unauthorized response. - */ - protected function unauthorized(string $message): Response - { - return response()->json([ - 'error' => 'unauthorized', - 'message' => $message, - ], 401); - } - - /** - * Return 403 Forbidden response. - */ - protected function forbidden(string $message): Response - { - return response()->json([ - 'error' => 'forbidden', - 'message' => $message, - ], 403); - } -} diff --git a/packages/core-api/src/Mod/Api/Middleware/CheckApiScope.php b/packages/core-api/src/Mod/Api/Middleware/CheckApiScope.php deleted file mode 100644 index 826b979..0000000 --- a/packages/core-api/src/Mod/Api/Middleware/CheckApiScope.php +++ /dev/null @@ -1,52 +0,0 @@ -post('/resource', ...); - * Route::middleware(['auth.api', 'api.scope:read,write'])->put('/resource', ...); - * - * Register in bootstrap/app.php: - * ->withMiddleware(function (Middleware $middleware) { - * $middleware->alias([ - * 'api.scope' => \App\Http\Middleware\Api\CheckApiScope::class, - * ]); - * }) - */ -class CheckApiScope -{ - public function handle(Request $request, Closure $next, string ...$scopes): Response - { - $apiKey = $request->attributes->get('api_key'); - - // If not authenticated via API key, allow through - // (Sanctum auth handles its own scopes) - if (! $apiKey instanceof ApiKey) { - return $next($request); - } - - // Check all required scopes - foreach ($scopes as $scope) { - if (! $apiKey->hasScope($scope)) { - return response()->json([ - 'error' => 'forbidden', - 'message' => "API key missing required scope: {$scope}", - 'required_scopes' => $scopes, - 'key_scopes' => $apiKey->scopes, - ], 403); - } - } - - return $next($request); - } -} diff --git a/packages/core-api/src/Mod/Api/Middleware/EnforceApiScope.php b/packages/core-api/src/Mod/Api/Middleware/EnforceApiScope.php deleted file mode 100644 index 2a91f42..0000000 --- a/packages/core-api/src/Mod/Api/Middleware/EnforceApiScope.php +++ /dev/null @@ -1,65 +0,0 @@ - read - * - POST, PUT, PATCH -> write - * - DELETE -> delete - * - * Usage: Add to routes alongside api.auth middleware. - * Route::middleware(['api.auth', 'api.scope.enforce'])->group(...) - * - * For routes that need to override the auto-detection, use CheckApiScope: - * Route::middleware(['api.auth', 'api.scope:read'])->post('/readonly-action', ...) - */ -class EnforceApiScope -{ - /** - * HTTP method to required scope mapping. - */ - protected const METHOD_SCOPES = [ - 'GET' => ApiKey::SCOPE_READ, - 'HEAD' => ApiKey::SCOPE_READ, - 'OPTIONS' => ApiKey::SCOPE_READ, - 'POST' => ApiKey::SCOPE_WRITE, - 'PUT' => ApiKey::SCOPE_WRITE, - 'PATCH' => ApiKey::SCOPE_WRITE, - 'DELETE' => ApiKey::SCOPE_DELETE, - ]; - - public function handle(Request $request, Closure $next): Response - { - $apiKey = $request->attributes->get('api_key'); - - // If not authenticated via API key, allow through - // Session auth and Sanctum handle their own permissions - if (! $apiKey instanceof ApiKey) { - return $next($request); - } - - $method = strtoupper($request->method()); - $requiredScope = self::METHOD_SCOPES[$method] ?? ApiKey::SCOPE_READ; - - if (! $apiKey->hasScope($requiredScope)) { - return response()->json([ - 'error' => 'forbidden', - 'message' => "API key missing required scope: {$requiredScope}", - 'detail' => "{$method} requests require '{$requiredScope}' scope", - 'key_scopes' => $apiKey->scopes, - ], 403); - } - - return $next($request); - } -} diff --git a/packages/core-api/src/Mod/Api/Middleware/PublicApiCors.php b/packages/core-api/src/Mod/Api/Middleware/PublicApiCors.php deleted file mode 100644 index da299df..0000000 --- a/packages/core-api/src/Mod/Api/Middleware/PublicApiCors.php +++ /dev/null @@ -1,64 +0,0 @@ -isMethod('OPTIONS')) { - return $this->buildPreflightResponse($request); - } - - $response = $next($request); - - return $this->addCorsHeaders($response, $request); - } - - /** - * Build preflight response for OPTIONS requests. - */ - protected function buildPreflightResponse(Request $request): Response - { - $response = response('', 204); - - return $this->addCorsHeaders($response, $request); - } - - /** - * Add CORS headers to response. - */ - protected function addCorsHeaders(Response $response, Request $request): Response - { - $origin = $request->header('Origin', '*'); - - // Allow any origin for public widget/pixel endpoints - $response->headers->set('Access-Control-Allow-Origin', $origin); - $response->headers->set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - $response->headers->set('Access-Control-Allow-Headers', 'Content-Type, Accept, X-Requested-With'); - $response->headers->set('Access-Control-Expose-Headers', 'X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After'); - $response->headers->set('Access-Control-Max-Age', '3600'); - - // Vary on Origin for proper caching - $response->headers->set('Vary', 'Origin'); - - return $response; - } -} diff --git a/packages/core-api/src/Mod/Api/Middleware/RateLimitApi.php b/packages/core-api/src/Mod/Api/Middleware/RateLimitApi.php deleted file mode 100644 index 772bb5d..0000000 --- a/packages/core-api/src/Mod/Api/Middleware/RateLimitApi.php +++ /dev/null @@ -1,352 +0,0 @@ -withMiddleware(function (Middleware $middleware) { - * $middleware->alias([ - * 'api.rate' => \Core\Mod\Api\Middleware\RateLimitApi::class, - * ]); - * }) - */ -class RateLimitApi -{ - public function __construct( - protected RateLimitService $rateLimitService, - ) {} - - public function handle(Request $request, Closure $next): Response - { - // Check if rate limiting is enabled - if (! config('api.rate_limits.enabled', true)) { - return $next($request); - } - - $rateLimitConfig = $this->resolveRateLimitConfig($request); - $key = $this->resolveRateLimitKey($request, $rateLimitConfig); - - // Perform rate limit check and hit - $result = $this->rateLimitService->hit( - key: $key, - limit: $rateLimitConfig['limit'], - window: $rateLimitConfig['window'], - burst: $rateLimitConfig['burst'], - ); - - if (! $result->allowed) { - throw new RateLimitExceededException($result); - } - - $response = $next($request); - - return $this->addRateLimitHeaders($response, $result); - } - - /** - * Resolve the rate limit configuration for the request. - * - * @return array{limit: int, window: int, burst: float, key: string|null} - */ - protected function resolveRateLimitConfig(Request $request): array - { - $defaults = config('api.rate_limits.default', [ - 'limit' => 60, - 'window' => 60, - 'burst' => 1.0, - ]); - - // 1. Check for #[RateLimit] attribute on controller/method - $attributeConfig = $this->getAttributeRateLimit($request); - if ($attributeConfig !== null) { - return array_merge($defaults, $attributeConfig); - } - - // 2. Check for per-endpoint config - $endpointConfig = $this->getEndpointRateLimit($request); - if ($endpointConfig !== null) { - return array_merge($defaults, $endpointConfig); - } - - // 3. Check for tier-based limits - $tierConfig = $this->getTierRateLimit($request); - if ($tierConfig !== null) { - return array_merge($defaults, $tierConfig); - } - - // 4. Use authenticated limits if authenticated - if ($this->isAuthenticated($request)) { - $authenticated = config('api.rate_limits.authenticated', $defaults); - - return [ - 'limit' => $authenticated['requests'] ?? $authenticated['limit'] ?? $defaults['limit'], - 'window' => ($authenticated['per_minutes'] ?? 1) * 60, - 'burst' => $authenticated['burst'] ?? $defaults['burst'] ?? 1.0, - 'key' => null, - ]; - } - - // 5. Use default limits - return [ - 'limit' => $defaults['requests'] ?? $defaults['limit'] ?? 60, - 'window' => ($defaults['per_minutes'] ?? 1) * 60, - 'burst' => $defaults['burst'] ?? 1.0, - 'key' => null, - ]; - } - - /** - * Get rate limit from #[RateLimit] attribute. - * - * @return array{limit: int, window: int, burst: float, key: string|null}|null - */ - protected function getAttributeRateLimit(Request $request): ?array - { - $route = $request->route(); - if (! $route) { - return null; - } - - $controller = $route->getController(); - $method = $route->getActionMethod(); - - if (! $controller || ! $method) { - return null; - } - - try { - // Check method-level attribute first - $reflection = new ReflectionMethod($controller, $method); - $attributes = $reflection->getAttributes(RateLimit::class); - - if (! empty($attributes)) { - /** @var RateLimit $rateLimit */ - $rateLimit = $attributes[0]->newInstance(); - - return [ - 'limit' => $rateLimit->limit, - 'window' => $rateLimit->window, - 'burst' => $rateLimit->burst, - 'key' => $rateLimit->key, - ]; - } - - // Check class-level attribute - $classReflection = new ReflectionClass($controller); - $classAttributes = $classReflection->getAttributes(RateLimit::class); - - if (! empty($classAttributes)) { - /** @var RateLimit $rateLimit */ - $rateLimit = $classAttributes[0]->newInstance(); - - return [ - 'limit' => $rateLimit->limit, - 'window' => $rateLimit->window, - 'burst' => $rateLimit->burst, - 'key' => $rateLimit->key, - ]; - } - } catch (\ReflectionException) { - // Controller or method doesn't exist - } - - return null; - } - - /** - * Get rate limit from per-endpoint config. - * - * @return array{limit: int, window: int, burst: float, key: string|null}|null - */ - protected function getEndpointRateLimit(Request $request): ?array - { - $route = $request->route(); - if (! $route) { - return null; - } - - $routeName = $route->getName(); - if (! $routeName) { - return null; - } - - // Try exact match first (e.g., "api.users.index") - $config = config("api.rate_limits.endpoints.{$routeName}"); - - // Try with dots replaced (e.g., "users.index" for route "api.users.index") - if (! $config) { - $shortName = preg_replace('/^api\./', '', $routeName); - $config = config("api.rate_limits.endpoints.{$shortName}"); - } - - if (! $config) { - return null; - } - - return [ - 'limit' => $config['limit'] ?? $config['requests'] ?? 60, - 'window' => $config['window'] ?? (($config['per_minutes'] ?? 1) * 60), - 'burst' => $config['burst'] ?? 1.0, - 'key' => $config['key'] ?? null, - ]; - } - - /** - * Get tier-based rate limit from workspace subscription. - * - * @return array{limit: int, window: int, burst: float, key: string|null}|null - */ - protected function getTierRateLimit(Request $request): ?array - { - $workspace = $request->attributes->get('workspace'); - if (! $workspace) { - return null; - } - - $tier = $this->getWorkspaceTier($workspace); - $tierConfig = config("api.rate_limits.tiers.{$tier}"); - - if (! $tierConfig) { - // Fall back to by_tier for backwards compatibility - $tierConfig = config("api.rate_limits.by_tier.{$tier}"); - } - - if (! $tierConfig) { - return null; - } - - return [ - 'limit' => $tierConfig['limit'] ?? $tierConfig['requests'] ?? 60, - 'window' => $tierConfig['window'] ?? (($tierConfig['per_minutes'] ?? 1) * 60), - 'burst' => $tierConfig['burst'] ?? 1.0, - 'key' => null, - ]; - } - - /** - * Resolve the rate limit key for the request. - * - * @param array{limit: int, window: int, burst: float, key: string|null} $config - */ - protected function resolveRateLimitKey(Request $request, array $config): string - { - $parts = []; - - // Use custom key suffix if provided - $suffix = $config['key']; - - // Add endpoint to key if per_workspace is enabled and we have a route - $perWorkspace = config('api.rate_limits.per_workspace', true); - $route = $request->route(); - - // Build identifier based on auth context - $apiKey = $request->attributes->get('api_key'); - $workspace = $request->attributes->get('workspace'); - - if ($apiKey instanceof ApiKey) { - $parts[] = "api_key:{$apiKey->id}"; - - // Include workspace if per_workspace is enabled - if ($perWorkspace && $workspace) { - $parts[] = "ws:{$workspace->id}"; - } - } elseif ($request->user()) { - $parts[] = "user:{$request->user()->id}"; - - if ($perWorkspace && $workspace) { - $parts[] = "ws:{$workspace->id}"; - } - } else { - $parts[] = "ip:{$request->ip()}"; - } - - // Add route name for per-endpoint isolation - if ($route && $route->getName()) { - $parts[] = "route:{$route->getName()}"; - } - - // Add custom suffix if provided - if ($suffix) { - $parts[] = $suffix; - } - - return implode(':', $parts); - } - - /** - * Get workspace tier for rate limiting. - */ - protected function getWorkspaceTier(mixed $workspace): string - { - // Check if workspace has an active package/subscription - if (method_exists($workspace, 'activePackages')) { - $package = $workspace->activePackages()->first(); - - return $package?->slug ?? 'free'; - } - - // Check for a tier attribute - if (property_exists($workspace, 'tier')) { - return $workspace->tier ?? 'free'; - } - - // Check for a plan attribute - if (property_exists($workspace, 'plan')) { - return $workspace->plan ?? 'free'; - } - - return 'free'; - } - - /** - * Check if the request is authenticated. - */ - protected function isAuthenticated(Request $request): bool - { - return $request->attributes->get('api_key') !== null - || $request->user() !== null; - } - - /** - * Add rate limit headers to response. - */ - protected function addRateLimitHeaders(Response $response, RateLimitResult $result): Response - { - foreach ($result->headers() as $header => $value) { - $response->headers->set($header, (string) $value); - } - - return $response; - } -} diff --git a/packages/core-api/src/Mod/Api/Middleware/TrackApiUsage.php b/packages/core-api/src/Mod/Api/Middleware/TrackApiUsage.php deleted file mode 100644 index d836106..0000000 --- a/packages/core-api/src/Mod/Api/Middleware/TrackApiUsage.php +++ /dev/null @@ -1,81 +0,0 @@ -attributes->get('api_key'); - - if ($apiKey instanceof ApiKey) { - $this->recordUsage($request, $response, $apiKey, $responseTimeMs); - } - - return $response; - } - - /** - * Record the API usage. - */ - protected function recordUsage( - Request $request, - Response $response, - ApiKey $apiKey, - int $responseTimeMs - ): void { - try { - $this->usageService->record( - apiKeyId: $apiKey->id, - workspaceId: $apiKey->workspace_id, - endpoint: $request->path(), - method: $request->method(), - statusCode: $response->getStatusCode(), - responseTimeMs: $responseTimeMs, - requestSize: strlen($request->getContent()), - responseSize: strlen($response->getContent()), - ipAddress: $request->ip(), - userAgent: $request->userAgent() - ); - } catch (\Throwable $e) { - // Don't let analytics failures affect the API response - Log::warning('Failed to record API usage', [ - 'error' => $e->getMessage(), - 'api_key_id' => $apiKey->id, - 'endpoint' => $request->path(), - ]); - } - } -} diff --git a/packages/core-api/src/Mod/Api/Migrations/2026_01_07_002358_create_api_keys_table.php b/packages/core-api/src/Mod/Api/Migrations/2026_01_07_002358_create_api_keys_table.php deleted file mode 100644 index eb3547a..0000000 --- a/packages/core-api/src/Mod/Api/Migrations/2026_01_07_002358_create_api_keys_table.php +++ /dev/null @@ -1,41 +0,0 @@ -id(); - $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); - $table->foreignId('user_id')->constrained()->cascadeOnDelete(); - $table->string('name'); - $table->string('key', 64)->comment('SHA256 hash of the key'); - $table->string('prefix', 16)->comment('Key prefix for identification (hk_xxxxxxxx)'); - $table->json('scopes')->default('["read","write"]'); - $table->json('server_scopes')->nullable()->comment('Per-server access: null=all, ["commerce","biohost"]=specific'); - $table->timestamp('last_used_at')->nullable(); - $table->timestamp('expires_at')->nullable(); - $table->softDeletes(); - $table->timestamps(); - - // Index for key lookup - $table->index(['prefix', 'key']); - $table->index('workspace_id'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('api_keys'); - } -}; diff --git a/packages/core-api/src/Mod/Api/Migrations/2026_01_07_002400_create_webhook_endpoints_table.php b/packages/core-api/src/Mod/Api/Migrations/2026_01_07_002400_create_webhook_endpoints_table.php deleted file mode 100644 index eebe7b3..0000000 --- a/packages/core-api/src/Mod/Api/Migrations/2026_01_07_002400_create_webhook_endpoints_table.php +++ /dev/null @@ -1,40 +0,0 @@ -id(); - $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); - $table->string('url'); - $table->string('secret', 64)->comment('HMAC signing secret'); - $table->json('events')->comment('Event types to receive, or ["*"] for all'); - $table->boolean('active')->default(true); - $table->string('description')->nullable(); - $table->timestamp('last_triggered_at')->nullable(); - $table->unsignedInteger('failure_count')->default(0); - $table->timestamp('disabled_at')->nullable()->comment('Auto-disabled after 10 consecutive failures'); - $table->timestamps(); - $table->softDeletes(); - - $table->index(['workspace_id', 'active']); - $table->index(['active', 'disabled_at']); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('webhook_endpoints'); - } -}; diff --git a/packages/core-api/src/Mod/Api/Migrations/2026_01_07_002401_create_webhook_deliveries_table.php b/packages/core-api/src/Mod/Api/Migrations/2026_01_07_002401_create_webhook_deliveries_table.php deleted file mode 100644 index 96faf41..0000000 --- a/packages/core-api/src/Mod/Api/Migrations/2026_01_07_002401_create_webhook_deliveries_table.php +++ /dev/null @@ -1,40 +0,0 @@ -id(); - $table->foreignId('webhook_endpoint_id')->constrained('webhook_endpoints')->cascadeOnDelete(); - $table->string('event_id', 32)->comment('Unique event identifier (evt_xxx)'); - $table->string('event_type', 64)->index(); - $table->json('payload'); - $table->unsignedSmallInteger('response_code')->nullable(); - $table->text('response_body')->nullable(); - $table->unsignedTinyInteger('attempt')->default(1); - $table->string('status', 16)->default('pending')->comment('pending, success, failed, retrying'); - $table->timestamp('delivered_at')->nullable(); - $table->timestamp('next_retry_at')->nullable()->index(); - $table->timestamps(); - - $table->index(['webhook_endpoint_id', 'status']); - $table->index(['status', 'next_retry_at']); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('webhook_deliveries'); - } -}; diff --git a/packages/core-api/src/Mod/Api/Migrations/2026_01_27_000000_add_secure_hashing_to_api_keys_table.php b/packages/core-api/src/Mod/Api/Migrations/2026_01_27_000000_add_secure_hashing_to_api_keys_table.php deleted file mode 100644 index 4883ffc..0000000 --- a/packages/core-api/src/Mod/Api/Migrations/2026_01_27_000000_add_secure_hashing_to_api_keys_table.php +++ /dev/null @@ -1,46 +0,0 @@ -string('hash_algorithm', 16)->default('sha256')->after('key'); - - // Grace period for key rotation - old key remains valid until this time - $table->timestamp('grace_period_ends_at')->nullable()->after('expires_at'); - - // Track key rotation lineage - $table->foreignId('rotated_from_id') - ->nullable() - ->after('grace_period_ends_at') - ->constrained('api_keys') - ->nullOnDelete(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('api_keys', function (Blueprint $table) { - $table->dropForeign(['rotated_from_id']); - $table->dropColumn(['hash_algorithm', 'grace_period_ends_at', 'rotated_from_id']); - }); - } -}; diff --git a/packages/core-api/src/Mod/Api/Models/ApiKey.php b/packages/core-api/src/Mod/Api/Models/ApiKey.php deleted file mode 100644 index 61587a7..0000000 --- a/packages/core-api/src/Mod/Api/Models/ApiKey.php +++ /dev/null @@ -1,412 +0,0 @@ - 'array', - 'server_scopes' => 'array', - 'last_used_at' => 'datetime', - 'expires_at' => 'datetime', - 'grace_period_ends_at' => 'datetime', - ]; - - protected $hidden = [ - 'key', // Never expose the hashed key - ]; - - /** - * Generate a new API key for a workspace. - * - * Returns both the ApiKey model and the plain key (only available once). - * New keys use bcrypt for secure hashing with salt. - * - * @return array{api_key: ApiKey, plain_key: string} - */ - public static function generate( - int $workspaceId, - int $userId, - string $name, - array $scopes = [self::SCOPE_READ, self::SCOPE_WRITE], - ?\DateTimeInterface $expiresAt = null - ): array { - $plainKey = Str::random(48); - $prefix = 'hk_'.Str::random(8); - - $apiKey = static::create([ - 'workspace_id' => $workspaceId, - 'user_id' => $userId, - 'name' => $name, - 'key' => Hash::make($plainKey), - 'hash_algorithm' => self::HASH_BCRYPT, - 'prefix' => $prefix, - 'scopes' => $scopes, - 'expires_at' => $expiresAt, - ]); - - // Return plain key only once - never stored - return [ - 'api_key' => $apiKey, - 'plain_key' => "{$prefix}_{$plainKey}", - ]; - } - - /** - * Find an API key by its plain text value. - * - * Supports both legacy SHA-256 keys and new bcrypt keys. - * For bcrypt keys, we must load all candidates by prefix and verify each. - */ - public static function findByPlainKey(string $plainKey): ?static - { - // Expected format: hk_xxxxxxxx_xxxxx... - if (! str_starts_with($plainKey, 'hk_')) { - return null; - } - - $parts = explode('_', $plainKey, 3); - if (count($parts) !== 3) { - return null; - } - - $prefix = $parts[0].'_'.$parts[1]; // hk_xxxxxxxx - $key = $parts[2]; - - // Find potential matches by prefix - $candidates = static::where('prefix', $prefix) - ->whereNull('deleted_at') - ->where(function ($query) { - $query->whereNull('expires_at') - ->orWhere('expires_at', '>', now()); - }) - ->where(function ($query) { - // Exclude keys past their grace period - $query->whereNull('grace_period_ends_at') - ->orWhere('grace_period_ends_at', '>', now()); - }) - ->get(); - - foreach ($candidates as $candidate) { - if ($candidate->verifyKey($key)) { - return $candidate; - } - } - - return null; - } - - /** - * Verify if the provided key matches this API key's stored hash. - * - * Handles both legacy SHA-256 and secure bcrypt algorithms. - */ - public function verifyKey(string $plainKey): bool - { - if ($this->hash_algorithm === self::HASH_BCRYPT) { - return Hash::check($plainKey, $this->key); - } - - // Legacy SHA-256 verification (for backward compatibility) - return hash_equals($this->key, hash('sha256', $plainKey)); - } - - /** - * Check if this key uses legacy (insecure) SHA-256 hashing. - * - * Keys using SHA-256 should be rotated to use bcrypt. - */ - public function usesLegacyHash(): bool - { - return $this->hash_algorithm === self::HASH_SHA256 - || $this->hash_algorithm === null; - } - - /** - * Rotate this API key, creating a new secure key. - * - * The old key remains valid during the grace period to allow - * seamless migration of integrations. - * - * @param int $gracePeriodHours Hours the old key remains valid - * @return array{api_key: ApiKey, plain_key: string, old_key: ApiKey} - */ - public function rotate(int $gracePeriodHours = self::DEFAULT_GRACE_PERIOD_HOURS): array - { - // Create new key with same settings - $result = static::generate( - $this->workspace_id, - $this->user_id, - $this->name, - $this->scopes ?? [self::SCOPE_READ, self::SCOPE_WRITE], - $this->expires_at - ); - - // Copy server scopes to new key - $result['api_key']->update([ - 'server_scopes' => $this->server_scopes, - 'rotated_from_id' => $this->id, - ]); - - // Set grace period on old key - $this->update([ - 'grace_period_ends_at' => now()->addHours($gracePeriodHours), - ]); - - return [ - 'api_key' => $result['api_key'], - 'plain_key' => $result['plain_key'], - 'old_key' => $this, - ]; - } - - /** - * Check if this key is currently in a rotation grace period. - */ - public function isInGracePeriod(): bool - { - return $this->grace_period_ends_at !== null - && $this->grace_period_ends_at->isFuture(); - } - - /** - * Check if the grace period has expired (key should be revoked). - */ - public function isGracePeriodExpired(): bool - { - return $this->grace_period_ends_at !== null - && $this->grace_period_ends_at->isPast(); - } - - /** - * End the grace period early and revoke this key. - */ - public function endGracePeriod(): void - { - $this->update(['grace_period_ends_at' => now()]); - $this->revoke(); - } - - /** - * Record API key usage. - */ - public function recordUsage(): void - { - $this->update(['last_used_at' => now()]); - } - - /** - * Check if key has a specific scope. - */ - public function hasScope(string $scope): bool - { - return in_array($scope, $this->scopes ?? [], true); - } - - /** - * Check if key has all specified scopes. - */ - public function hasScopes(array $scopes): bool - { - foreach ($scopes as $scope) { - if (! $this->hasScope($scope)) { - return false; - } - } - - return true; - } - - /** - * Check if key is expired. - */ - public function isExpired(): bool - { - return $this->expires_at !== null && $this->expires_at->isPast(); - } - - /** - * Check if key has access to a specific MCP server. - */ - public function hasServerAccess(string $serverId): bool - { - // Null means all servers - if ($this->server_scopes === null) { - return true; - } - - return in_array($serverId, $this->server_scopes, true); - } - - /** - * Get list of allowed servers (null = all). - */ - public function getAllowedServers(): ?array - { - return $this->server_scopes; - } - - /** - * Revoke this API key. - */ - public function revoke(): void - { - $this->delete(); - } - - /** - * Get the masked key for display. - * Shows prefix and last 4 characters. - */ - public function getMaskedKeyAttribute(): string - { - return "{$this->prefix}_****"; - } - - // Relationships - public function workspace(): BelongsTo - { - return $this->belongsTo(Workspace::class, 'workspace_id'); - } - - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } - - /** - * Get the key this one was rotated from. - */ - public function rotatedFrom(): BelongsTo - { - return $this->belongsTo(static::class, 'rotated_from_id'); - } - - // Query Scopes - public function scopeForWorkspace($query, int $workspaceId) - { - return $query->where('workspace_id', $workspaceId); - } - - public function scopeActive($query) - { - return $query->whereNull('deleted_at') - ->where(function ($q) { - $q->whereNull('expires_at') - ->orWhere('expires_at', '>', now()); - }) - ->where(function ($q) { - $q->whereNull('grace_period_ends_at') - ->orWhere('grace_period_ends_at', '>', now()); - }); - } - - public function scopeExpired($query) - { - return $query->whereNotNull('expires_at') - ->where('expires_at', '<=', now()); - } - - /** - * Keys currently in a rotation grace period. - */ - public function scopeInGracePeriod($query) - { - return $query->whereNotNull('grace_period_ends_at') - ->where('grace_period_ends_at', '>', now()); - } - - /** - * Keys with expired grace periods (should be cleaned up). - */ - public function scopeGracePeriodExpired($query) - { - return $query->whereNotNull('grace_period_ends_at') - ->where('grace_period_ends_at', '<=', now()); - } - - /** - * Keys using legacy SHA-256 hashing (should be rotated). - */ - public function scopeLegacyHash($query) - { - return $query->where(function ($q) { - $q->where('hash_algorithm', self::HASH_SHA256) - ->orWhereNull('hash_algorithm'); - }); - } - - /** - * Keys using secure bcrypt hashing. - */ - public function scopeSecureHash($query) - { - return $query->where('hash_algorithm', self::HASH_BCRYPT); - } -} diff --git a/packages/core-api/src/Mod/Api/Models/ApiUsage.php b/packages/core-api/src/Mod/Api/Models/ApiUsage.php deleted file mode 100644 index 3bae241..0000000 --- a/packages/core-api/src/Mod/Api/Models/ApiUsage.php +++ /dev/null @@ -1,135 +0,0 @@ - 'datetime', - ]; - - /** - * Create a usage entry from request/response data. - */ - public static function record( - int $apiKeyId, - int $workspaceId, - string $endpoint, - string $method, - int $statusCode, - int $responseTimeMs, - ?int $requestSize = null, - ?int $responseSize = null, - ?string $ipAddress = null, - ?string $userAgent = null - ): static { - return static::create([ - 'api_key_id' => $apiKeyId, - 'workspace_id' => $workspaceId, - 'endpoint' => $endpoint, - 'method' => strtoupper($method), - 'status_code' => $statusCode, - 'response_time_ms' => $responseTimeMs, - 'request_size' => $requestSize, - 'response_size' => $responseSize, - 'ip_address' => $ipAddress, - 'user_agent' => $userAgent ? substr($userAgent, 0, 500) : null, - 'created_at' => now(), - ]); - } - - /** - * Check if this was a successful request (2xx status). - */ - public function isSuccess(): bool - { - return $this->status_code >= 200 && $this->status_code < 300; - } - - /** - * Check if this was a client error (4xx status). - */ - public function isClientError(): bool - { - return $this->status_code >= 400 && $this->status_code < 500; - } - - /** - * Check if this was a server error (5xx status). - */ - public function isServerError(): bool - { - return $this->status_code >= 500; - } - - // Relationships - public function apiKey(): BelongsTo - { - return $this->belongsTo(ApiKey::class); - } - - public function workspace(): BelongsTo - { - return $this->belongsTo(Workspace::class); - } - - // Scopes - public function scopeForKey($query, int $apiKeyId) - { - return $query->where('api_key_id', $apiKeyId); - } - - public function scopeForWorkspace($query, int $workspaceId) - { - return $query->where('workspace_id', $workspaceId); - } - - public function scopeForEndpoint($query, string $endpoint) - { - return $query->where('endpoint', $endpoint); - } - - public function scopeSuccessful($query) - { - return $query->whereBetween('status_code', [200, 299]); - } - - public function scopeErrors($query) - { - return $query->where('status_code', '>=', 400); - } - - public function scopeBetween($query, $startDate, $endDate) - { - return $query->whereBetween('created_at', [$startDate, $endDate]); - } -} diff --git a/packages/core-api/src/Mod/Api/Models/ApiUsageDaily.php b/packages/core-api/src/Mod/Api/Models/ApiUsageDaily.php deleted file mode 100644 index 9dd15cb..0000000 --- a/packages/core-api/src/Mod/Api/Models/ApiUsageDaily.php +++ /dev/null @@ -1,172 +0,0 @@ - 'date', - ]; - - /** - * Update or create daily stats from a usage record. - * - * Uses Laravel's upsert() for database portability while maintaining - * atomic operations. For increment operations, we use a two-step approach: - * first upsert the base record, then atomically update counters. - */ - public static function recordFromUsage(ApiUsage $usage): static - { - $isSuccess = $usage->isSuccess(); - $isError = $usage->status_code >= 400; - $date = $usage->created_at->toDateString(); - $now = now(); - - // Unique key for this daily aggregation - $uniqueKey = [ - 'api_key_id' => $usage->api_key_id, - 'workspace_id' => $usage->workspace_id, - 'date' => $date, - 'endpoint' => $usage->endpoint, - 'method' => $usage->method, - ]; - - // First, ensure the record exists with upsert (database-portable) - static::upsert( - [ - ...$uniqueKey, - 'request_count' => 0, - 'success_count' => 0, - 'error_count' => 0, - 'total_response_time_ms' => 0, - 'total_request_size' => 0, - 'total_response_size' => 0, - 'min_response_time_ms' => null, - 'max_response_time_ms' => null, - 'created_at' => $now, - 'updated_at' => $now, - ], - ['api_key_id', 'workspace_id', 'date', 'endpoint', 'method'], - ['updated_at'] // Only touch updated_at if record exists - ); - - // Then atomically increment counters using query builder - $query = static::where($uniqueKey); - - // Build raw update for atomic increments - $query->update([ - 'request_count' => DB::raw('request_count + 1'), - 'success_count' => DB::raw('success_count + '.($isSuccess ? 1 : 0)), - 'error_count' => DB::raw('error_count + '.($isError ? 1 : 0)), - 'total_response_time_ms' => DB::raw('total_response_time_ms + '.(int) $usage->response_time_ms), - 'total_request_size' => DB::raw('total_request_size + '.(int) ($usage->request_size ?? 0)), - 'total_response_size' => DB::raw('total_response_size + '.(int) ($usage->response_size ?? 0)), - 'updated_at' => $now, - ]); - - // Update min/max response times (these need conditional logic) - $responseTimeMs = (int) $usage->response_time_ms; - static::where($uniqueKey) - ->where(function ($q) use ($responseTimeMs) { - $q->whereNull('min_response_time_ms') - ->orWhere('min_response_time_ms', '>', $responseTimeMs); - }) - ->update(['min_response_time_ms' => $responseTimeMs]); - - static::where($uniqueKey) - ->where(function ($q) use ($responseTimeMs) { - $q->whereNull('max_response_time_ms') - ->orWhere('max_response_time_ms', '<', $responseTimeMs); - }) - ->update(['max_response_time_ms' => $responseTimeMs]); - - // Retrieve the record for return - return static::where($uniqueKey)->first(); - } - - /** - * Calculate average response time. - */ - public function getAverageResponseTimeMsAttribute(): float - { - if ($this->request_count === 0) { - return 0; - } - - return round($this->total_response_time_ms / $this->request_count, 2); - } - - /** - * Calculate success rate percentage. - */ - public function getSuccessRateAttribute(): float - { - if ($this->request_count === 0) { - return 100; - } - - return round(($this->success_count / $this->request_count) * 100, 2); - } - - // Relationships - public function apiKey(): BelongsTo - { - return $this->belongsTo(ApiKey::class); - } - - public function workspace(): BelongsTo - { - return $this->belongsTo(Workspace::class); - } - - // Scopes - public function scopeForKey($query, int $apiKeyId) - { - return $query->where('api_key_id', $apiKeyId); - } - - public function scopeForWorkspace($query, int $workspaceId) - { - return $query->where('workspace_id', $workspaceId); - } - - public function scopeForEndpoint($query, string $endpoint) - { - return $query->where('endpoint', $endpoint); - } - - public function scopeBetween($query, $startDate, $endDate) - { - return $query->whereBetween('date', [$startDate, $endDate]); - } -} diff --git a/packages/core-api/src/Mod/Api/Models/WebhookDelivery.php b/packages/core-api/src/Mod/Api/Models/WebhookDelivery.php deleted file mode 100644 index 637b6c2..0000000 --- a/packages/core-api/src/Mod/Api/Models/WebhookDelivery.php +++ /dev/null @@ -1,209 +0,0 @@ - 1, // 1 minute - 2 => 5, // 5 minutes - 3 => 30, // 30 minutes - 4 => 120, // 2 hours - 5 => 1440, // 24 hours - ]; - - protected $fillable = [ - 'webhook_endpoint_id', - 'event_id', - 'event_type', - 'payload', - 'response_code', - 'response_body', - 'attempt', - 'status', - 'delivered_at', - 'next_retry_at', - ]; - - protected $casts = [ - 'payload' => 'array', - 'delivered_at' => 'datetime', - 'next_retry_at' => 'datetime', - ]; - - /** - * Create a new delivery for an event. - */ - public static function createForEvent( - WebhookEndpoint $endpoint, - string $eventType, - array $data, - ?int $workspaceId = null - ): static { - return static::create([ - 'webhook_endpoint_id' => $endpoint->id, - 'event_id' => 'evt_'.Str::random(24), - 'event_type' => $eventType, - 'payload' => [ - 'id' => 'evt_'.Str::random(24), - 'type' => $eventType, - 'created_at' => now()->toIso8601String(), - 'data' => $data, - 'workspace_id' => $workspaceId, - ], - 'status' => self::STATUS_PENDING, - 'attempt' => 1, - ]); - } - - /** - * Mark as successfully delivered. - */ - public function markSuccess(int $responseCode, ?string $responseBody = null): void - { - $this->update([ - 'status' => self::STATUS_SUCCESS, - 'response_code' => $responseCode, - 'response_body' => $responseBody ? Str::limit($responseBody, 10000) : null, - 'delivered_at' => now(), - 'next_retry_at' => null, - ]); - - $this->endpoint->recordSuccess(); - } - - /** - * Mark as failed and schedule retry if attempts remain. - */ - public function markFailed(int $responseCode, ?string $responseBody = null): void - { - $this->endpoint->recordFailure(); - - if ($this->attempt >= self::MAX_RETRIES) { - $this->update([ - 'status' => self::STATUS_FAILED, - 'response_code' => $responseCode, - 'response_body' => $responseBody ? Str::limit($responseBody, 10000) : null, - ]); - - return; - } - - // Schedule retry - $nextAttempt = $this->attempt + 1; - $delayMinutes = self::RETRY_DELAYS[$nextAttempt] ?? 1440; - - $this->update([ - 'status' => self::STATUS_RETRYING, - 'response_code' => $responseCode, - 'response_body' => $responseBody ? Str::limit($responseBody, 10000) : null, - 'attempt' => $nextAttempt, - 'next_retry_at' => now()->addMinutes($delayMinutes), - ]); - } - - /** - * Check if delivery can be retried. - */ - public function canRetry(): bool - { - return $this->attempt < self::MAX_RETRIES - && $this->status !== self::STATUS_SUCCESS; - } - - /** - * Get formatted payload with signature headers. - * - * Includes all required headers for webhook verification: - * - X-Webhook-Signature: HMAC-SHA256 signature of timestamp.payload - * - X-Webhook-Timestamp: Unix timestamp (for replay protection) - * - X-Webhook-Event: The event type (e.g., 'bio.created') - * - X-Webhook-Id: Unique delivery ID for idempotency - * - * ## Verification Instructions (for recipients) - * - * 1. Get the signature and timestamp from headers - * 2. Compute: HMAC-SHA256(timestamp + "." + rawBody, yourSecret) - * 3. Compare with X-Webhook-Signature using timing-safe comparison - * 4. Verify timestamp is within 5 minutes of current time - * - * @param int|null $timestamp Unix timestamp (defaults to current time) - * @return array{headers: array, body: string} - */ - public function getDeliveryPayload(?int $timestamp = null): array - { - $timestamp ??= time(); - $jsonPayload = json_encode($this->payload); - - return [ - 'headers' => [ - 'Content-Type' => 'application/json', - 'X-Webhook-Id' => $this->event_id, - 'X-Webhook-Event' => $this->event_type, - 'X-Webhook-Timestamp' => (string) $timestamp, - 'X-Webhook-Signature' => $this->endpoint->generateSignature($jsonPayload, $timestamp), - ], - 'body' => $jsonPayload, - ]; - } - - // Relationships - public function endpoint(): BelongsTo - { - return $this->belongsTo(WebhookEndpoint::class, 'webhook_endpoint_id'); - } - - // Scopes - public function scopePending($query) - { - return $query->where('status', self::STATUS_PENDING); - } - - public function scopeRetrying($query) - { - return $query->where('status', self::STATUS_RETRYING) - ->where('next_retry_at', '<=', now()); - } - - public function scopeNeedsDelivery($query) - { - return $query->where(function ($q) { - $q->where('status', self::STATUS_PENDING) - ->orWhere(function ($q2) { - $q2->where('status', self::STATUS_RETRYING) - ->where('next_retry_at', '<=', now()); - }); - }); - } -} diff --git a/packages/core-api/src/Mod/Api/Models/WebhookEndpoint.php b/packages/core-api/src/Mod/Api/Models/WebhookEndpoint.php deleted file mode 100644 index 6c4ebad..0000000 --- a/packages/core-api/src/Mod/Api/Models/WebhookEndpoint.php +++ /dev/null @@ -1,266 +0,0 @@ - 'array', - 'active' => 'boolean', - 'last_triggered_at' => 'datetime', - 'disabled_at' => 'datetime', - ]; - - protected $hidden = [ - 'secret', - ]; - - /** - * Create a new webhook endpoint with auto-generated secret. - */ - public static function createForWorkspace( - int $workspaceId, - string $url, - array $events, - ?string $description = null - ): static { - $signatureService = app(WebhookSignature::class); - - return static::create([ - 'workspace_id' => $workspaceId, - 'url' => $url, - 'secret' => $signatureService->generateSecret(), - 'events' => $events, - 'description' => $description, - 'active' => true, - ]); - } - - /** - * Generate signature for payload with timestamp. - * - * The signature includes the timestamp to prevent replay attacks. - * Format: HMAC-SHA256(timestamp + "." + payload, secret) - * - * @param string $payload The JSON-encoded webhook payload - * @param int $timestamp Unix timestamp of the request - * @return string The hex-encoded HMAC-SHA256 signature - */ - public function generateSignature(string $payload, int $timestamp): string - { - $signatureService = app(WebhookSignature::class); - - return $signatureService->sign($payload, $this->secret, $timestamp); - } - - /** - * Verify a signature from an incoming request (for testing endpoints). - * - * @param string $payload The raw request body - * @param string $signature The signature from the header - * @param int $timestamp The timestamp from the header - * @param int $tolerance Maximum age in seconds (default: 300) - * @return bool True if the signature is valid - */ - public function verifySignature( - string $payload, - string $signature, - int $timestamp, - int $tolerance = WebhookSignature::DEFAULT_TOLERANCE - ): bool { - $signatureService = app(WebhookSignature::class); - - return $signatureService->verify($payload, $signature, $this->secret, $timestamp, $tolerance); - } - - /** - * Check if endpoint should receive an event. - */ - public function shouldReceive(string $eventType): bool - { - if (! $this->active) { - return false; - } - - if ($this->disabled_at !== null) { - return false; - } - - return in_array($eventType, $this->events, true) - || in_array('*', $this->events, true); - } - - /** - * Record successful delivery. - */ - public function recordSuccess(): void - { - $this->update([ - 'last_triggered_at' => now(), - 'failure_count' => 0, - ]); - } - - /** - * Record failed delivery. - * Auto-disables after 10 consecutive failures. - */ - public function recordFailure(): void - { - $failureCount = $this->failure_count + 1; - - $updates = [ - 'failure_count' => $failureCount, - 'last_triggered_at' => now(), - ]; - - // Auto-disable after 10 consecutive failures - if ($failureCount >= 10) { - $updates['disabled_at'] = now(); - $updates['active'] = false; - } - - $this->update($updates); - } - - /** - * Re-enable a disabled endpoint. - */ - public function enable(): void - { - $this->update([ - 'active' => true, - 'disabled_at' => null, - 'failure_count' => 0, - ]); - } - - /** - * Rotate the webhook secret. - * - * Generates a new cryptographically secure secret. The old secret - * immediately becomes invalid - recipients must update their configuration. - * - * @return string The new secret (only returned once, store securely) - */ - public function rotateSecret(): string - { - $signatureService = app(WebhookSignature::class); - $newSecret = $signatureService->generateSecret(); - $this->update(['secret' => $newSecret]); - - return $newSecret; - } - - // Relationships - public function workspace(): BelongsTo - { - return $this->belongsTo(Workspace::class, 'workspace_id'); - } - - public function deliveries(): HasMany - { - return $this->hasMany(WebhookDelivery::class); - } - - // Scopes - public function scopeActive($query) - { - return $query->where('active', true) - ->whereNull('disabled_at'); - } - - public function scopeForWorkspace($query, int $workspaceId) - { - return $query->where('workspace_id', $workspaceId); - } - - public function scopeForEvent($query, string $eventType) - { - return $query->where(function ($q) use ($eventType) { - $q->whereJsonContains('events', $eventType) - ->orWhereJsonContains('events', '*'); - }); - } -} diff --git a/packages/core-api/src/Mod/Api/Notifications/HighApiUsageNotification.php b/packages/core-api/src/Mod/Api/Notifications/HighApiUsageNotification.php deleted file mode 100644 index ae8c44a..0000000 --- a/packages/core-api/src/Mod/Api/Notifications/HighApiUsageNotification.php +++ /dev/null @@ -1,111 +0,0 @@ - - */ - public function via(object $notifiable): array - { - return ['mail']; - } - - /** - * Get the mail representation of the notification. - */ - public function toMail(object $notifiable): MailMessage - { - $percentage = round(($this->currentUsage / $this->limit) * 100, 1); - - $subject = match ($this->level) { - 'critical' => "API Usage Critical - {$percentage}% of limit reached", - default => "API Usage Warning - {$percentage}% of limit reached", - }; - - $message = (new MailMessage) - ->subject($subject) - ->greeting($this->getGreeting()) - ->line($this->getMainMessage()) - ->line("**Workspace:** {$this->workspace->name}") - ->line("**Current usage:** {$this->currentUsage} requests") - ->line("**Rate limit:** {$this->limit} requests per {$this->period}") - ->line("**Usage:** {$percentage}%"); - - if ($this->level === 'critical') { - $message->line('If you exceed your rate limit, API requests will be temporarily blocked until the limit resets.'); - } - - $message->action('View API Usage', url('/developer/api')) - ->line('Consider upgrading your plan if you regularly approach these limits.'); - - return $message; - } - - /** - * Get the greeting based on level. - */ - protected function getGreeting(): string - { - return match ($this->level) { - 'critical' => 'Warning: API Usage Critical', - default => 'Notice: API Usage High', - }; - } - - /** - * Get the main message based on level. - */ - protected function getMainMessage(): string - { - return match ($this->level) { - 'critical' => 'Your API usage has reached a critical level and is approaching the rate limit.', - default => 'Your API usage is high and approaching the rate limit threshold.', - }; - } - - /** - * Get the array representation of the notification. - * - * @return array - */ - public function toArray(object $notifiable): array - { - return [ - 'level' => $this->level, - 'workspace_id' => $this->workspace->id, - 'workspace_name' => $this->workspace->name, - 'current_usage' => $this->currentUsage, - 'limit' => $this->limit, - 'period' => $this->period, - ]; - } -} diff --git a/packages/core-api/src/Mod/Api/RateLimit/RateLimit.php b/packages/core-api/src/Mod/Api/RateLimit/RateLimit.php deleted file mode 100644 index b49e099..0000000 --- a/packages/core-api/src/Mod/Api/RateLimit/RateLimit.php +++ /dev/null @@ -1,42 +0,0 @@ - - */ - public function headers(): array - { - $headers = [ - 'X-RateLimit-Limit' => $this->limit, - 'X-RateLimit-Remaining' => $this->remaining, - 'X-RateLimit-Reset' => $this->resetsAt->timestamp, - ]; - - if (! $this->allowed) { - $headers['Retry-After'] = $this->retryAfter; - } - - return $headers; - } -} diff --git a/packages/core-api/src/Mod/Api/RateLimit/RateLimitService.php b/packages/core-api/src/Mod/Api/RateLimit/RateLimitService.php deleted file mode 100644 index c85aebd..0000000 --- a/packages/core-api/src/Mod/Api/RateLimit/RateLimitService.php +++ /dev/null @@ -1,247 +0,0 @@ -getCacheKey($key); - $effectiveLimit = (int) floor($limit * $burst); - $now = Carbon::now(); - $windowStart = $now->timestamp - $window; - - // Get current window data - $hits = $this->getWindowHits($cacheKey, $windowStart); - $currentCount = count($hits); - $remaining = max(0, $effectiveLimit - $currentCount); - - // Calculate reset time - $resetsAt = $this->calculateResetTime($hits, $window, $effectiveLimit); - - if ($currentCount >= $effectiveLimit) { - // Find oldest hit to determine retry after - $oldestHit = min($hits); - $retryAfter = max(1, ($oldestHit + $window) - $now->timestamp); - - return RateLimitResult::denied($limit, $retryAfter, $resetsAt); - } - - return RateLimitResult::allowed($limit, $remaining, $resetsAt); - } - - /** - * Record a hit and check if the request is allowed. - * - * @param string $key Unique identifier for the rate limit bucket - * @param int $limit Maximum requests allowed - * @param int $window Time window in seconds - * @param float $burst Burst multiplier (e.g., 1.2 for 20% burst allowance) - */ - public function hit(string $key, int $limit, int $window, float $burst = 1.0): RateLimitResult - { - $cacheKey = $this->getCacheKey($key); - $effectiveLimit = (int) floor($limit * $burst); - $now = Carbon::now(); - $windowStart = $now->timestamp - $window; - - // Get current window data and clean up old entries - $hits = $this->getWindowHits($cacheKey, $windowStart); - $currentCount = count($hits); - - // Calculate reset time - $resetsAt = $this->calculateResetTime($hits, $window, $effectiveLimit); - - if ($currentCount >= $effectiveLimit) { - // Find oldest hit to determine retry after - $oldestHit = min($hits); - $retryAfter = max(1, ($oldestHit + $window) - $now->timestamp); - - return RateLimitResult::denied($limit, $retryAfter, $resetsAt); - } - - // Record the hit - $hits[] = $now->timestamp; - $this->storeWindowHits($cacheKey, $hits, $window); - - $remaining = max(0, $effectiveLimit - count($hits)); - - return RateLimitResult::allowed($limit, $remaining, $resetsAt); - } - - /** - * Get remaining attempts for a key. - * - * @param string $key Unique identifier for the rate limit bucket - * @param int $limit Maximum requests allowed (needed to calculate remaining) - * @param int $window Time window in seconds - * @param float $burst Burst multiplier - */ - public function remaining(string $key, int $limit, int $window, float $burst = 1.0): int - { - $cacheKey = $this->getCacheKey($key); - $effectiveLimit = (int) floor($limit * $burst); - $windowStart = Carbon::now()->timestamp - $window; - - $hits = $this->getWindowHits($cacheKey, $windowStart); - - return max(0, $effectiveLimit - count($hits)); - } - - /** - * Reset (clear) a rate limit bucket. - */ - public function reset(string $key): void - { - $cacheKey = $this->getCacheKey($key); - $this->cache->forget($cacheKey); - } - - /** - * Get the current hit count for a key. - */ - public function attempts(string $key, int $window): int - { - $cacheKey = $this->getCacheKey($key); - $windowStart = Carbon::now()->timestamp - $window; - - return count($this->getWindowHits($cacheKey, $windowStart)); - } - - /** - * Build a rate limit key for an endpoint. - */ - public function buildEndpointKey(string $identifier, string $endpoint): string - { - return "endpoint:{$identifier}:{$endpoint}"; - } - - /** - * Build a rate limit key for a workspace. - */ - public function buildWorkspaceKey(int $workspaceId, ?string $suffix = null): string - { - $key = "workspace:{$workspaceId}"; - - if ($suffix !== null) { - $key .= ":{$suffix}"; - } - - return $key; - } - - /** - * Build a rate limit key for an API key. - */ - public function buildApiKeyKey(int|string $apiKeyId, ?string $suffix = null): string - { - $key = "api_key:{$apiKeyId}"; - - if ($suffix !== null) { - $key .= ":{$suffix}"; - } - - return $key; - } - - /** - * Build a rate limit key for an IP address. - */ - public function buildIpKey(string $ip, ?string $suffix = null): string - { - $key = "ip:{$ip}"; - - if ($suffix !== null) { - $key .= ":{$suffix}"; - } - - return $key; - } - - /** - * Get hits within the sliding window. - * - * @return array Array of timestamps - */ - protected function getWindowHits(string $cacheKey, int $windowStart): array - { - /** @var array $hits */ - $hits = $this->cache->get($cacheKey, []); - - // Filter to only include hits within the window - return array_values(array_filter($hits, fn (int $timestamp) => $timestamp >= $windowStart)); - } - - /** - * Store hits in cache. - * - * @param array $hits Array of timestamps - */ - protected function storeWindowHits(string $cacheKey, array $hits, int $window): void - { - // Add buffer to TTL to handle clock drift - $ttl = $window + 60; - $this->cache->put($cacheKey, $hits, $ttl); - } - - /** - * Calculate when the rate limit resets. - * - * @param array $hits Array of timestamps - */ - protected function calculateResetTime(array $hits, int $window, int $limit): Carbon - { - if (empty($hits)) { - return Carbon::now()->addSeconds($window); - } - - // If under limit, reset is at the end of the window - if (count($hits) < $limit) { - return Carbon::now()->addSeconds($window); - } - - // If at or over limit, reset when the oldest hit expires - $oldestHit = min($hits); - - return Carbon::createFromTimestamp($oldestHit + $window); - } - - /** - * Generate the cache key. - */ - protected function getCacheKey(string $key): string - { - return self::CACHE_PREFIX.$key; - } -} diff --git a/packages/core-api/src/Mod/Api/Resources/ApiKeyResource.php b/packages/core-api/src/Mod/Api/Resources/ApiKeyResource.php deleted file mode 100644 index 51c4f6f..0000000 --- a/packages/core-api/src/Mod/Api/Resources/ApiKeyResource.php +++ /dev/null @@ -1,59 +0,0 @@ -plainKey = $plainKey; - - return $instance; - } - - public function toArray(Request $request): array - { - return [ - 'id' => $this->id, - 'name' => $this->name, - 'prefix' => $this->prefix, - 'scopes' => $this->scopes, - 'last_used_at' => $this->last_used_at?->toIso8601String(), - 'expires_at' => $this->expires_at?->toIso8601String(), - 'created_at' => $this->created_at->toIso8601String(), - - // Only included on creation - 'key' => $this->when($this->plainKey !== null, $this->plainKey), - - // Masked display key - 'display_key' => $this->masked_key, - ]; - } -} diff --git a/packages/core-api/src/Mod/Api/Resources/ErrorResource.php b/packages/core-api/src/Mod/Api/Resources/ErrorResource.php deleted file mode 100644 index a32bc45..0000000 --- a/packages/core-api/src/Mod/Api/Resources/ErrorResource.php +++ /dev/null @@ -1,93 +0,0 @@ - ['The name field is required.'], - * ])->response()->setStatusCode(422); - */ -class ErrorResource extends JsonResource -{ - protected string $errorCode; - - protected string $message; - - protected ?array $details; - - public function __construct(string $errorCode, string $message, ?array $details = null) - { - $this->errorCode = $errorCode; - $this->message = $message; - $this->details = $details; - - parent::__construct(null); - } - - public static function make(...$args): static - { - return new static(...$args); - } - - /** - * Common error factory methods. - */ - public static function unauthorized(string $message = 'Unauthorized'): static - { - return new static('unauthorized', $message); - } - - public static function forbidden(string $message = 'Forbidden'): static - { - return new static('forbidden', $message); - } - - public static function notFound(string $message = 'Resource not found'): static - { - return new static('not_found', $message); - } - - public static function validation(array $errors): static - { - return new static('validation_error', 'The given data was invalid.', $errors); - } - - public static function rateLimited(int $retryAfter): static - { - return new static('rate_limit_exceeded', 'Too many requests. Please slow down.', [ - 'retry_after' => $retryAfter, - ]); - } - - public static function entitlementExceeded(string $feature): static - { - return new static('entitlement_exceeded', "Plan limit reached for: {$feature}"); - } - - public static function serverError(string $message = 'An unexpected error occurred'): static - { - return new static('internal_error', $message); - } - - public function toArray(Request $request): array - { - $response = [ - 'error' => $this->errorCode, - 'message' => $this->message, - ]; - - if ($this->details !== null) { - $response['details'] = $this->details; - } - - return $response; - } -} diff --git a/packages/core-api/src/Mod/Api/Resources/PaginatedCollection.php b/packages/core-api/src/Mod/Api/Resources/PaginatedCollection.php deleted file mode 100644 index 5d878db..0000000 --- a/packages/core-api/src/Mod/Api/Resources/PaginatedCollection.php +++ /dev/null @@ -1,49 +0,0 @@ -resourceClass = $resourceClass; - parent::__construct($resource); - } - - public function toArray(Request $request): array - { - return [ - 'data' => $this->resourceClass::collection($this->collection), - 'meta' => [ - 'current_page' => $this->currentPage(), - 'from' => $this->firstItem(), - 'last_page' => $this->lastPage(), - 'per_page' => $this->perPage(), - 'to' => $this->lastItem(), - 'total' => $this->total(), - ], - 'links' => [ - 'first' => $this->url(1), - 'last' => $this->url($this->lastPage()), - 'prev' => $this->previousPageUrl(), - 'next' => $this->nextPageUrl(), - ], - ]; - } -} diff --git a/packages/core-api/src/Mod/Api/Resources/WebhookEndpointResource.php b/packages/core-api/src/Mod/Api/Resources/WebhookEndpointResource.php deleted file mode 100644 index a5e3840..0000000 --- a/packages/core-api/src/Mod/Api/Resources/WebhookEndpointResource.php +++ /dev/null @@ -1,67 +0,0 @@ -includeSecret = true; - - return $instance; - } - - public function toArray(Request $request): array - { - return [ - 'id' => $this->id, - 'url' => $this->url, - 'events' => $this->events, - 'active' => $this->active, - 'description' => $this->description, - 'last_triggered_at' => $this->last_triggered_at?->toIso8601String(), - 'failure_count' => $this->failure_count, - 'disabled_at' => $this->disabled_at?->toIso8601String(), - 'created_at' => $this->created_at->toIso8601String(), - 'updated_at' => $this->updated_at->toIso8601String(), - - // Only on creation - 'secret' => $this->when($this->includeSecret, $this->secret), - - // Links - 'links' => [ - 'self' => route('api.v1.webhooks.show', $this->id, false), - 'deliveries' => route('api.v1.webhooks.deliveries', $this->id, false), - ], - ]; - } -} diff --git a/packages/core-api/src/Mod/Api/Resources/WorkspaceResource.php b/packages/core-api/src/Mod/Api/Resources/WorkspaceResource.php deleted file mode 100644 index 7df357e..0000000 --- a/packages/core-api/src/Mod/Api/Resources/WorkspaceResource.php +++ /dev/null @@ -1,68 +0,0 @@ - - */ - public function toArray(Request $request): array - { - return [ - 'id' => $this->id, - 'name' => $this->name, - 'slug' => $this->slug, - 'icon' => $this->icon, - 'color' => $this->color, - 'description' => $this->description, - 'type' => $this->type, - 'is_active' => $this->is_active, - - // Stats - 'users_count' => $this->whenCounted('users'), - 'bio_pages_count' => $this->whenCounted('bioPages'), - - // Role (when available via pivot) - 'role' => $this->whenPivotLoaded('user_workspace', fn () => $this->pivot->role), - 'is_default' => $this->whenPivotLoaded('user_workspace', fn () => $this->pivot->is_default), - - // Settings (public only) - 'settings' => $this->when($this->settings, fn () => $this->getPublicSettings()), - - // Timestamps - 'created_at' => $this->created_at?->toIso8601String(), - 'updated_at' => $this->updated_at?->toIso8601String(), - ]; - } - - /** - * Get public settings (filter sensitive data). - */ - protected function getPublicSettings(): array - { - $settings = $this->settings ?? []; - - // Remove sensitive keys - unset( - $settings['wp_connector_secret'], - $settings['api_secrets'] - ); - - return $settings; - } -} diff --git a/packages/core-api/src/Mod/Api/Routes/api.php b/packages/core-api/src/Mod/Api/Routes/api.php deleted file mode 100644 index 2190728..0000000 --- a/packages/core-api/src/Mod/Api/Routes/api.php +++ /dev/null @@ -1,103 +0,0 @@ -prefix('seo')->group(function () { - Route::post('/report', [SeoReportController::class, 'receive']) - ->name('api.seo.report'); - - Route::get('/issues/{workspace}', [SeoReportController::class, 'issues']) - ->name('api.seo.issues'); - - Route::post('/task/generate', [SeoReportController::class, 'generateTask']) - ->name('api.seo.generate-task'); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Unified Pixel API (public - high rate limit for tracking) -// ───────────────────────────────────────────────────────────────────────────── - -Route::middleware('throttle:300,1')->prefix('pixel')->group(function () { - Route::get('/config', [UnifiedPixelController::class, 'config']) - ->name('api.pixel.config'); - Route::post('/track', [UnifiedPixelController::class, 'track']) - ->name('api.pixel.track'); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Entitlements API (authenticated) -// ───────────────────────────────────────────────────────────────────────────── - -Route::middleware('auth')->prefix('entitlements')->group(function () { - // Check feature access (for external apps) - Route::get('/check', [EntitlementApiController::class, 'check']) - ->name('api.entitlements.check'); - - // Record usage (for external apps) - Route::post('/usage', [EntitlementApiController::class, 'recordUsage']) - ->name('api.entitlements.usage'); - - // Get usage summary for current user's workspace - Route::get('/summary', [EntitlementApiController::class, 'mySummary']) - ->name('api.entitlements.summary'); - - // Get usage summary for a specific workspace (admin) - Route::get('/summary/{workspace}', [EntitlementApiController::class, 'summary']) - ->name('api.entitlements.summary.workspace'); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// MCP HTTP Bridge (API key auth) -// ───────────────────────────────────────────────────────────────────────────── - -Route::middleware(['throttle:120,1', McpApiKeyAuth::class, 'api.scope.enforce']) - ->prefix('mcp') - ->name('api.mcp.') - ->group(function () { - // Scope enforcement: GET=read, POST=write - // Server discovery (read) - Route::get('/servers', [McpApiController::class, 'servers']) - ->name('servers'); - Route::get('/servers/{id}', [McpApiController::class, 'server']) - ->name('servers.show'); - Route::get('/servers/{id}/tools', [McpApiController::class, 'tools']) - ->name('servers.tools'); - - // Tool version history (read) - Route::get('/servers/{server}/tools/{tool}/versions', [McpApiController::class, 'toolVersions']) - ->name('tools.versions'); - - // Specific tool version (read) - Route::get('/servers/{server}/tools/{tool}/versions/{version}', [McpApiController::class, 'toolVersion']) - ->name('tools.version'); - - // Tool execution (write) - Route::post('/tools/call', [McpApiController::class, 'callTool']) - ->name('tools.call'); - - // Resource access (read) - Route::get('/resources/{uri}', [McpApiController::class, 'resource']) - ->where('uri', '.*') - ->name('resources.show'); - }); diff --git a/packages/core-api/src/Mod/Api/Services/ApiKeyService.php b/packages/core-api/src/Mod/Api/Services/ApiKeyService.php deleted file mode 100644 index 2175826..0000000 --- a/packages/core-api/src/Mod/Api/Services/ApiKeyService.php +++ /dev/null @@ -1,217 +0,0 @@ -active()->count(); - - if ($currentCount >= $maxKeys) { - throw new \RuntimeException( - "Workspace has reached the maximum number of API keys ({$maxKeys})" - ); - } - - $result = ApiKey::generate($workspaceId, $userId, $name, $scopes, $expiresAt); - - // Set server scopes if provided - if ($serverScopes !== null) { - $result['api_key']->update(['server_scopes' => $serverScopes]); - } - - Log::info('API key created', [ - 'key_id' => $result['api_key']->id, - 'workspace_id' => $workspaceId, - 'user_id' => $userId, - 'name' => $name, - ]); - - return $result; - } - - /** - * Rotate an existing API key. - * - * Creates a new key with the same settings, keeping the old key - * valid for a grace period to allow migration. - * - * @param int $gracePeriodHours Hours the old key remains valid (default: 24) - * @return array{api_key: ApiKey, plain_key: string, old_key: ApiKey} - */ - public function rotate(ApiKey $apiKey, int $gracePeriodHours = ApiKey::DEFAULT_GRACE_PERIOD_HOURS): array - { - // Don't rotate keys that are already being rotated out - if ($apiKey->isInGracePeriod()) { - throw new \RuntimeException( - 'This key is already being rotated. Wait for the grace period to end or end it manually.' - ); - } - - // Don't rotate revoked keys - if ($apiKey->trashed()) { - throw new \RuntimeException('Cannot rotate a revoked key.'); - } - - $result = $apiKey->rotate($gracePeriodHours); - - Log::info('API key rotated', [ - 'old_key_id' => $apiKey->id, - 'new_key_id' => $result['api_key']->id, - 'workspace_id' => $apiKey->workspace_id, - 'grace_period_hours' => $gracePeriodHours, - 'grace_period_ends_at' => $apiKey->fresh()->grace_period_ends_at?->toIso8601String(), - ]); - - return $result; - } - - /** - * Revoke an API key immediately. - */ - public function revoke(ApiKey $apiKey): void - { - $apiKey->revoke(); - - Log::info('API key revoked', [ - 'key_id' => $apiKey->id, - 'workspace_id' => $apiKey->workspace_id, - ]); - } - - /** - * End the grace period for a rotating key and revoke it. - */ - public function endGracePeriod(ApiKey $apiKey): void - { - if (! $apiKey->isInGracePeriod()) { - throw new \RuntimeException('This key is not in a grace period.'); - } - - $apiKey->endGracePeriod(); - - Log::info('API key grace period ended', [ - 'key_id' => $apiKey->id, - 'workspace_id' => $apiKey->workspace_id, - ]); - } - - /** - * Clean up keys with expired grace periods. - * - * This should be called by a scheduled command to revoke - * old keys after their grace period has ended. - * - * @return int Number of keys cleaned up - */ - public function cleanupExpiredGracePeriods(): int - { - $keys = ApiKey::gracePeriodExpired() - ->whereNull('deleted_at') - ->get(); - - $count = 0; - - foreach ($keys as $key) { - $key->revoke(); - $count++; - - Log::info('Cleaned up API key after grace period', [ - 'key_id' => $key->id, - 'workspace_id' => $key->workspace_id, - ]); - } - - return $count; - } - - /** - * Update API key scopes. - */ - public function updateScopes(ApiKey $apiKey, array $scopes): void - { - // Validate scopes - $validScopes = array_intersect($scopes, ApiKey::ALL_SCOPES); - - if (empty($validScopes)) { - throw new \InvalidArgumentException('At least one valid scope must be provided.'); - } - - $apiKey->update(['scopes' => array_values($validScopes)]); - - Log::info('API key scopes updated', [ - 'key_id' => $apiKey->id, - 'scopes' => $validScopes, - ]); - } - - /** - * Update API key server scopes. - */ - public function updateServerScopes(ApiKey $apiKey, ?array $serverScopes): void - { - $apiKey->update(['server_scopes' => $serverScopes]); - - Log::info('API key server scopes updated', [ - 'key_id' => $apiKey->id, - 'server_scopes' => $serverScopes, - ]); - } - - /** - * Rename an API key. - */ - public function rename(ApiKey $apiKey, string $name): void - { - $apiKey->update(['name' => $name]); - - Log::info('API key renamed', [ - 'key_id' => $apiKey->id, - 'name' => $name, - ]); - } - - /** - * Get statistics for a workspace's API keys. - */ - public function getStats(int $workspaceId): array - { - $keys = ApiKey::forWorkspace($workspaceId); - - return [ - 'total' => (clone $keys)->count(), - 'active' => (clone $keys)->active()->count(), - 'expired' => (clone $keys)->expired()->count(), - 'in_grace_period' => (clone $keys)->inGracePeriod()->count(), - 'revoked' => ApiKey::withTrashed() - ->forWorkspace($workspaceId) - ->whereNotNull('deleted_at') - ->count(), - ]; - } -} diff --git a/packages/core-api/src/Mod/Api/Services/ApiSnippetService.php b/packages/core-api/src/Mod/Api/Services/ApiSnippetService.php deleted file mode 100644 index 6a89a02..0000000 --- a/packages/core-api/src/Mod/Api/Services/ApiSnippetService.php +++ /dev/null @@ -1,427 +0,0 @@ - 'cURL', - 'php' => 'PHP', - 'javascript' => 'JavaScript', - 'python' => 'Python', - 'ruby' => 'Ruby', - 'go' => 'Go', - 'java' => 'Java', - 'csharp' => 'C#', - 'swift' => 'Swift', - 'kotlin' => 'Kotlin', - 'rust' => 'Rust', - ]; - - /** - * Generate snippets for all supported languages. - */ - public function generateAll( - string $method, - string $endpoint, - array $headers = [], - ?array $body = null, - string $baseUrl = 'https://api.host.uk.com' - ): array { - $snippets = []; - - foreach (array_keys(self::LANGUAGES) as $language) { - $snippets[$language] = $this->generate($language, $method, $endpoint, $headers, $body, $baseUrl); - } - - return $snippets; - } - - /** - * Generate a snippet for a specific language. - */ - public function generate( - string $language, - string $method, - string $endpoint, - array $headers = [], - ?array $body = null, - string $baseUrl = 'https://api.host.uk.com' - ): string { - $url = rtrim($baseUrl, '/').'/'.ltrim($endpoint, '/'); - - // Add default headers - $headers = array_merge([ - 'Authorization' => 'Bearer YOUR_API_KEY', - 'Content-Type' => 'application/json', - 'Accept' => 'application/json', - ], $headers); - - return match ($language) { - 'curl' => $this->generateCurl($method, $url, $headers, $body), - 'php' => $this->generatePhp($method, $url, $headers, $body), - 'javascript' => $this->generateJavaScript($method, $url, $headers, $body), - 'python' => $this->generatePython($method, $url, $headers, $body), - 'ruby' => $this->generateRuby($method, $url, $headers, $body), - 'go' => $this->generateGo($method, $url, $headers, $body), - 'java' => $this->generateJava($method, $url, $headers, $body), - 'csharp' => $this->generateCSharp($method, $url, $headers, $body), - 'swift' => $this->generateSwift($method, $url, $headers, $body), - 'kotlin' => $this->generateKotlin($method, $url, $headers, $body), - 'rust' => $this->generateRust($method, $url, $headers, $body), - default => "# Language '{$language}' not supported", - }; - } - - protected function generateCurl(string $method, string $url, array $headers, ?array $body): string - { - $lines = ["curl -X {$method} '{$url}' \\"]; - - foreach ($headers as $key => $value) { - $lines[] = " -H '{$key}: {$value}' \\"; - } - - if ($body) { - $json = json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $lines[] = " -d '{$json}'"; - } else { - // Remove trailing backslash from last header - $lastIndex = count($lines) - 1; - $lines[$lastIndex] = rtrim($lines[$lastIndex], ' \\'); - } - - return implode("\n", $lines); - } - - protected function generatePhp(string $method, string $url, array $headers, ?array $body): string - { - $headerStr = ''; - foreach ($headers as $key => $value) { - $headerStr .= " '{$key}' => '{$value}',\n"; - } - - $bodyStr = $body ? json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) : 'null'; - - return <<phpMethod($method)}('{$url}', [ - 'headers' => [ -{$headerStr} ], - 'json' => {$bodyStr}, -]); - -\$data = \$response->json(); -PHP; - } - - protected function generateJavaScript(string $method, string $url, array $headers, ?array $body): string - { - $headerJson = json_encode($headers, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $bodyJson = $body ? json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) : 'null'; - - return << $value) { - $headerLines[] = " \"{$key}\": \"{$value}\""; - } - $headerStr = implode(",\n", $headerLines); - - $bodyStr = $body ? json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) : 'None'; - - return <<pythonMethod($method)}( - "{$url}", - headers={ -{$headerStr} - }, - json={$bodyStr} -) - -data = response.json() -PYTHON; - } - - protected function generateRuby(string $method, string $url, array $headers, ?array $body): string - { - $headerLines = []; - foreach ($headers as $key => $value) { - $headerLines[] = " \"{$key}\" => \"{$value}\""; - } - $headerStr = implode(",\n", $headerLines); - - $bodyStr = $body ? json_encode($body, JSON_UNESCAPED_SLASHES) : 'nil'; - - return <<rubyMethod($method)}( - "{$url}", - headers: { -{$headerStr} - }, - body: {$bodyStr} -) - -data = JSON.parse(response.body) -RUBY; - } - - protected function generateGo(string $method, string $url, array $headers, ?array $body): string - { - $bodySetup = $body - ? 'jsonData, _ := json.Marshal(map[string]interface{}{'.$this->goMapEntries($body)."})\\n\\treq, _ := http.NewRequest(\"{$method}\", \"{$url}\", bytes.NewBuffer(jsonData))" - : "req, _ := http.NewRequest(\"{$method}\", \"{$url}\", nil)"; - - $headerLines = []; - foreach ($headers as $key => $value) { - $headerLines[] = "\treq.Header.Set(\"{$key}\", \"{$value}\")"; - } - $headerStr = implode("\n", $headerLines); - - return << $value) { - $headerLines[] = " .header(\"{$key}\", \"{$value}\")"; - } - $headerStr = implode("\n", $headerLines); - - $bodyStr = $body ? json_encode($body, JSON_UNESCAPED_SLASHES) : '""'; - - return << response = client.send(request, HttpResponse.BodyHandlers.ofString()); -String body = response.body(); -JAVA; - } - - protected function generateCSharp(string $method, string $url, array $headers, ?array $body): string - { - $headerLines = []; - foreach ($headers as $key => $value) { - if ($key === 'Content-Type') { - continue; - } - $headerLines[] = "client.DefaultRequestHeaders.Add(\"{$key}\", \"{$value}\");"; - } - $headerStr = implode("\n", $headerLines); - - $bodyStr = $body ? json_encode($body, JSON_UNESCAPED_SLASHES) : '""'; - - return <<csharpMethod($method)}Async("{$url}", content); - -var body = await response.Content.ReadAsStringAsync(); -CSHARP; - } - - protected function generateSwift(string $method, string $url, array $headers, ?array $body): string - { - $headerLines = []; - foreach ($headers as $key => $value) { - $headerLines[] = "request.setValue(\"{$value}\", forHTTPHeaderField: \"{$key}\")"; - } - $headerStr = implode("\n", $headerLines); - - $bodyStr = $body ? json_encode($body, JSON_UNESCAPED_SLASHES) : 'nil'; - - return << $value) { - $headerLines[] = " .addHeader(\"{$key}\", \"{$value}\")"; - } - $headerStr = implode("\n", $headerLines); - - $bodyStr = $body ? json_encode($body, JSON_UNESCAPED_SLASHES) : '""'; - - return << $value) { - $headerLines[] = " .header(\"{$key}\", \"{$value}\")"; - } - $headerStr = implode("\n", $headerLines); - - $bodyStr = $body ? json_encode($body, JSON_UNESCAPED_SLASHES) : '""'; - - return <<rustMethod($method)}("{$url}") -{$headerStr} - .body("{$bodyStr}") - .send()?; - -let json: serde_json::Value = response.json()?; -RUST; - } - - // Helper methods for language-specific syntax - protected function phpMethod(string $method): string - { - return strtolower($method); - } - - protected function pythonMethod(string $method): string - { - return strtolower($method); - } - - protected function rubyMethod(string $method): string - { - return strtolower($method); - } - - protected function csharpMethod(string $method): string - { - return match (strtoupper($method)) { - 'GET' => 'Get', - 'POST' => 'Post', - 'PUT' => 'Put', - 'PATCH' => 'Patch', - 'DELETE' => 'Delete', - default => 'Send', - }; - } - - protected function rustMethod(string $method): string - { - return strtolower($method); - } - - protected function goMapEntries(array $data): string - { - $entries = []; - foreach ($data as $key => $value) { - $val = is_string($value) ? "\"{$value}\"" : json_encode($value); - $entries[] = "\"{$key}\": {$val}"; - } - - return implode(', ', $entries); - } - - /** - * Get language metadata for UI display. - */ - public static function getLanguages(): array - { - return collect(self::LANGUAGES)->map(fn ($name, $code) => [ - 'code' => $code, - 'name' => $name, - 'icon' => self::getLanguageIcon($code), - ])->values()->all(); - } - - /** - * Get icon class for a language. - */ - public static function getLanguageIcon(string $code): string - { - return match ($code) { - 'curl' => 'terminal', - 'php' => 'code-bracket', - 'javascript' => 'code-bracket-square', - 'python' => 'code-bracket', - 'ruby' => 'sparkles', - 'go' => 'cube', - 'java' => 'fire', - 'csharp' => 'window', - 'swift' => 'bolt', - 'kotlin' => 'beaker', - 'rust' => 'cog', - default => 'code-bracket', - }; - } -} diff --git a/packages/core-api/src/Mod/Api/Services/ApiUsageService.php b/packages/core-api/src/Mod/Api/Services/ApiUsageService.php deleted file mode 100644 index 204f444..0000000 --- a/packages/core-api/src/Mod/Api/Services/ApiUsageService.php +++ /dev/null @@ -1,361 +0,0 @@ -normaliseEndpoint($endpoint); - - // Record individual usage - $usage = ApiUsage::record( - $apiKeyId, - $workspaceId, - $normalisedEndpoint, - $method, - $statusCode, - $responseTimeMs, - $requestSize, - $responseSize, - $ipAddress, - $userAgent - ); - - // Update daily aggregation - ApiUsageDaily::recordFromUsage($usage); - - return $usage; - } - - /** - * Get usage summary for a workspace. - */ - public function getWorkspaceSummary( - int $workspaceId, - ?Carbon $startDate = null, - ?Carbon $endDate = null - ): array { - $startDate = $startDate ?? now()->subDays(30); - $endDate = $endDate ?? now(); - - $query = ApiUsageDaily::forWorkspace($workspaceId) - ->between($startDate, $endDate); - - $totals = (clone $query)->selectRaw(' - SUM(request_count) as total_requests, - SUM(success_count) as total_success, - SUM(error_count) as total_errors, - SUM(total_response_time_ms) as total_response_time, - MIN(min_response_time_ms) as min_response_time, - MAX(max_response_time_ms) as max_response_time, - SUM(total_request_size) as total_request_size, - SUM(total_response_size) as total_response_size - ')->first(); - - $totalRequests = (int) ($totals->total_requests ?? 0); - $totalSuccess = (int) ($totals->total_success ?? 0); - - return [ - 'period' => [ - 'start' => $startDate->toIso8601String(), - 'end' => $endDate->toIso8601String(), - ], - 'totals' => [ - 'requests' => $totalRequests, - 'success' => $totalSuccess, - 'errors' => (int) ($totals->total_errors ?? 0), - 'success_rate' => $totalRequests > 0 - ? round(($totalSuccess / $totalRequests) * 100, 2) - : 100, - ], - 'response_time' => [ - 'average_ms' => $totalRequests > 0 - ? round((int) $totals->total_response_time / $totalRequests, 2) - : 0, - 'min_ms' => (int) ($totals->min_response_time ?? 0), - 'max_ms' => (int) ($totals->max_response_time ?? 0), - ], - 'data_transfer' => [ - 'request_bytes' => (int) ($totals->total_request_size ?? 0), - 'response_bytes' => (int) ($totals->total_response_size ?? 0), - ], - ]; - } - - /** - * Get usage summary for a specific API key. - */ - public function getKeySummary( - int $apiKeyId, - ?Carbon $startDate = null, - ?Carbon $endDate = null - ): array { - $startDate = $startDate ?? now()->subDays(30); - $endDate = $endDate ?? now(); - - $query = ApiUsageDaily::forKey($apiKeyId) - ->between($startDate, $endDate); - - $totals = (clone $query)->selectRaw(' - SUM(request_count) as total_requests, - SUM(success_count) as total_success, - SUM(error_count) as total_errors, - SUM(total_response_time_ms) as total_response_time, - MIN(min_response_time_ms) as min_response_time, - MAX(max_response_time_ms) as max_response_time - ')->first(); - - $totalRequests = (int) ($totals->total_requests ?? 0); - $totalSuccess = (int) ($totals->total_success ?? 0); - - return [ - 'period' => [ - 'start' => $startDate->toIso8601String(), - 'end' => $endDate->toIso8601String(), - ], - 'totals' => [ - 'requests' => $totalRequests, - 'success' => $totalSuccess, - 'errors' => (int) ($totals->total_errors ?? 0), - 'success_rate' => $totalRequests > 0 - ? round(($totalSuccess / $totalRequests) * 100, 2) - : 100, - ], - 'response_time' => [ - 'average_ms' => $totalRequests > 0 - ? round((int) $totals->total_response_time / $totalRequests, 2) - : 0, - 'min_ms' => (int) ($totals->min_response_time ?? 0), - 'max_ms' => (int) ($totals->max_response_time ?? 0), - ], - ]; - } - - /** - * Get daily usage chart data. - */ - public function getDailyChart( - int $workspaceId, - ?Carbon $startDate = null, - ?Carbon $endDate = null - ): array { - $startDate = $startDate ?? now()->subDays(30); - $endDate = $endDate ?? now(); - - $data = ApiUsageDaily::forWorkspace($workspaceId) - ->between($startDate, $endDate) - ->selectRaw(' - date, - SUM(request_count) as requests, - SUM(success_count) as success, - SUM(error_count) as errors, - SUM(total_response_time_ms) / NULLIF(SUM(request_count), 0) as avg_response_time - ') - ->groupBy('date') - ->orderBy('date') - ->get(); - - return $data->map(fn ($row) => [ - 'date' => $row->date->toDateString(), - 'requests' => (int) $row->requests, - 'success' => (int) $row->success, - 'errors' => (int) $row->errors, - 'avg_response_time_ms' => round((float) ($row->avg_response_time ?? 0), 2), - ])->all(); - } - - /** - * Get top endpoints by request count. - */ - public function getTopEndpoints( - int $workspaceId, - int $limit = 10, - ?Carbon $startDate = null, - ?Carbon $endDate = null - ): array { - $startDate = $startDate ?? now()->subDays(30); - $endDate = $endDate ?? now(); - - return ApiUsageDaily::forWorkspace($workspaceId) - ->between($startDate, $endDate) - ->selectRaw(' - endpoint, - method, - SUM(request_count) as requests, - SUM(success_count) as success, - SUM(error_count) as errors, - SUM(total_response_time_ms) / NULLIF(SUM(request_count), 0) as avg_response_time - ') - ->groupBy('endpoint', 'method') - ->orderByDesc('requests') - ->limit($limit) - ->get() - ->map(fn ($row) => [ - 'endpoint' => $row->endpoint, - 'method' => $row->method, - 'requests' => (int) $row->requests, - 'success' => (int) $row->success, - 'errors' => (int) $row->errors, - 'success_rate' => $row->requests > 0 - ? round(($row->success / $row->requests) * 100, 2) - : 100, - 'avg_response_time_ms' => round((float) ($row->avg_response_time ?? 0), 2), - ]) - ->all(); - } - - /** - * Get error breakdown by status code. - */ - public function getErrorBreakdown( - int $workspaceId, - ?Carbon $startDate = null, - ?Carbon $endDate = null - ): array { - $startDate = $startDate ?? now()->subDays(30); - $endDate = $endDate ?? now(); - - return ApiUsage::forWorkspace($workspaceId) - ->between($startDate, $endDate) - ->where('status_code', '>=', 400) - ->selectRaw('status_code, COUNT(*) as count') - ->groupBy('status_code') - ->orderByDesc('count') - ->get() - ->map(fn ($row) => [ - 'status_code' => $row->status_code, - 'count' => (int) $row->count, - 'description' => $this->getStatusCodeDescription($row->status_code), - ]) - ->all(); - } - - /** - * Get API key usage comparison. - */ - public function getKeyComparison( - int $workspaceId, - ?Carbon $startDate = null, - ?Carbon $endDate = null - ): array { - $startDate = $startDate ?? now()->subDays(30); - $endDate = $endDate ?? now(); - - $aggregated = ApiUsageDaily::forWorkspace($workspaceId) - ->between($startDate, $endDate) - ->selectRaw(' - api_key_id, - SUM(request_count) as requests, - SUM(success_count) as success, - SUM(error_count) as errors, - SUM(total_response_time_ms) / NULLIF(SUM(request_count), 0) as avg_response_time - ') - ->groupBy('api_key_id') - ->orderByDesc('requests') - ->get(); - - // Fetch API keys separately to avoid broken eager loading with aggregation - $apiKeyIds = $aggregated->pluck('api_key_id')->filter()->unique()->all(); - $apiKeys = \Mod\Api\Models\ApiKey::whereIn('id', $apiKeyIds) - ->select('id', 'name', 'prefix') - ->get() - ->keyBy('id'); - - return $aggregated->map(fn ($row) => [ - 'api_key_id' => $row->api_key_id, - 'api_key_name' => $apiKeys->get($row->api_key_id)?->name ?? 'Unknown', - 'api_key_prefix' => $apiKeys->get($row->api_key_id)?->prefix ?? 'N/A', - 'requests' => (int) $row->requests, - 'success' => (int) $row->success, - 'errors' => (int) $row->errors, - 'success_rate' => $row->requests > 0 - ? round(($row->success / $row->requests) * 100, 2) - : 100, - 'avg_response_time_ms' => round((float) ($row->avg_response_time ?? 0), 2), - ])->all(); - } - - /** - * Normalise endpoint path for aggregation. - * - * Replaces dynamic IDs with placeholders for consistent grouping. - */ - protected function normaliseEndpoint(string $endpoint): string - { - // Remove query string - $path = parse_url($endpoint, PHP_URL_PATH) ?? $endpoint; - - // Replace numeric IDs with {id} placeholder - $normalised = preg_replace('/\/\d+/', '/{id}', $path); - - // Replace UUIDs with {uuid} placeholder - $normalised = preg_replace( - '/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i', - '/{uuid}', - $normalised - ); - - return $normalised ?? $path; - } - - /** - * Get human-readable status code description. - */ - protected function getStatusCodeDescription(int $statusCode): string - { - return match ($statusCode) { - 400 => 'Bad Request', - 401 => 'Unauthorised', - 403 => 'Forbidden', - 404 => 'Not Found', - 405 => 'Method Not Allowed', - 422 => 'Validation Failed', - 429 => 'Rate Limit Exceeded', - 500 => 'Internal Server Error', - 502 => 'Bad Gateway', - 503 => 'Service Unavailable', - 504 => 'Gateway Timeout', - default => 'Error', - }; - } - - /** - * Prune old detailed usage records. - * - * Keeps aggregated daily data but removes detailed logs older than retention period. - * - * @return int Number of records deleted - */ - public function pruneOldRecords(int $retentionDays = 30): int - { - $cutoff = now()->subDays($retentionDays); - - return ApiUsage::where('created_at', '<', $cutoff)->delete(); - } -} diff --git a/packages/core-api/src/Mod/Api/Services/WebhookService.php b/packages/core-api/src/Mod/Api/Services/WebhookService.php deleted file mode 100644 index 4a77d5c..0000000 --- a/packages/core-api/src/Mod/Api/Services/WebhookService.php +++ /dev/null @@ -1,192 +0,0 @@ - The created delivery records - */ - public function dispatch(int $workspaceId, string $eventType, array $data): array - { - // Find all active endpoints for this workspace that subscribe to this event - $endpoints = WebhookEndpoint::query() - ->forWorkspace($workspaceId) - ->active() - ->forEvent($eventType) - ->get(); - - if ($endpoints->isEmpty()) { - Log::debug('No webhook endpoints found for event', [ - 'workspace_id' => $workspaceId, - 'event_type' => $eventType, - ]); - - return []; - } - - $deliveries = []; - - // Wrap all deliveries in a transaction to ensure atomicity - DB::transaction(function () use ($endpoints, $eventType, $data, $workspaceId, &$deliveries) { - foreach ($endpoints as $endpoint) { - // Create delivery record - $delivery = WebhookDelivery::createForEvent( - $endpoint, - $eventType, - $data, - $workspaceId - ); - - $deliveries[] = $delivery; - - // Queue the delivery job after the transaction commits - DeliverWebhookJob::dispatch($delivery)->afterCommit(); - - Log::info('Webhook delivery queued', [ - 'delivery_id' => $delivery->id, - 'endpoint_id' => $endpoint->id, - 'event_type' => $eventType, - ]); - } - }); - - return $deliveries; - } - - /** - * Retry a specific failed delivery. - * - * @return bool True if retry was queued, false if not eligible - */ - public function retry(WebhookDelivery $delivery): bool - { - if (! $delivery->canRetry()) { - return false; - } - - DB::transaction(function () use ($delivery) { - // Reset status for manual retry but preserve attempt history - $delivery->update([ - 'status' => WebhookDelivery::STATUS_PENDING, - 'next_retry_at' => null, - ]); - - DeliverWebhookJob::dispatch($delivery)->afterCommit(); - - Log::info('Manual webhook retry queued', [ - 'delivery_id' => $delivery->id, - 'attempt' => $delivery->attempt, - ]); - }); - - return true; - } - - /** - * Process all pending and retryable deliveries. - * - * This method is typically called by a scheduled command. - * - * @return int Number of deliveries queued - */ - public function processQueue(): int - { - $count = 0; - - // Process deliveries one at a time with row locking to prevent race conditions - $deliveryIds = WebhookDelivery::query() - ->needsDelivery() - ->limit(100) - ->pluck('id'); - - foreach ($deliveryIds as $deliveryId) { - DB::transaction(function () use ($deliveryId, &$count) { - // Lock the row for update to prevent concurrent processing - $delivery = WebhookDelivery::query() - ->with('endpoint') - ->where('id', $deliveryId) - ->lockForUpdate() - ->first(); - - if (! $delivery) { - return; - } - - // Skip if already being processed (status changed since initial query) - if (! in_array($delivery->status, [WebhookDelivery::STATUS_PENDING, WebhookDelivery::STATUS_RETRYING])) { - return; - } - - // Handle inactive endpoints by cancelling the delivery - if (! $delivery->endpoint?->shouldReceive($delivery->event_type)) { - $delivery->update(['status' => WebhookDelivery::STATUS_CANCELLED]); - - return; - } - - // Mark as queued to prevent duplicate processing - $delivery->update(['status' => WebhookDelivery::STATUS_QUEUED]); - - DeliverWebhookJob::dispatch($delivery)->afterCommit(); - $count++; - }); - } - - if ($count > 0) { - Log::info('Processed webhook queue', ['count' => $count]); - } - - return $count; - } - - /** - * Get delivery statistics for a workspace. - */ - public function getStats(int $workspaceId): array - { - $endpointIds = WebhookEndpoint::query() - ->forWorkspace($workspaceId) - ->pluck('id'); - - if ($endpointIds->isEmpty()) { - return [ - 'total' => 0, - 'pending' => 0, - 'success' => 0, - 'failed' => 0, - 'retrying' => 0, - ]; - } - - $deliveries = WebhookDelivery::query() - ->whereIn('webhook_endpoint_id', $endpointIds); - - return [ - 'total' => (clone $deliveries)->count(), - 'pending' => (clone $deliveries)->where('status', WebhookDelivery::STATUS_PENDING)->count(), - 'success' => (clone $deliveries)->where('status', WebhookDelivery::STATUS_SUCCESS)->count(), - 'failed' => (clone $deliveries)->where('status', WebhookDelivery::STATUS_FAILED)->count(), - 'retrying' => (clone $deliveries)->where('status', WebhookDelivery::STATUS_RETRYING)->count(), - ]; - } -} diff --git a/packages/core-api/src/Mod/Api/Services/WebhookSignature.php b/packages/core-api/src/Mod/Api/Services/WebhookSignature.php deleted file mode 100644 index 400f032..0000000 --- a/packages/core-api/src/Mod/Api/Services/WebhookSignature.php +++ /dev/null @@ -1,206 +0,0 @@ -header('X-Webhook-Signature'); - * $timestamp = $request->header('X-Webhook-Timestamp'); - * $payload = $request->getContent(); - * - * // Compute expected signature - * $expectedSignature = hash_hmac('sha256', $timestamp . '.' . $payload, $webhookSecret); - * - * // Verify signature using timing-safe comparison - * if (!hash_equals($expectedSignature, $signature)) { - * abort(401, 'Invalid webhook signature'); - * } - * - * // Verify timestamp is within tolerance (e.g., 5 minutes) - * $tolerance = 300; // seconds - * if (abs(time() - (int)$timestamp) > $tolerance) { - * abort(401, 'Webhook timestamp too old'); - * } - * ``` - */ -class WebhookSignature -{ - /** - * Default secret length in bytes (64 characters when hex-encoded). - */ - private const SECRET_LENGTH = 32; - - /** - * Default tolerance for timestamp verification in seconds. - * 5 minutes allows for reasonable clock skew and network delays. - */ - public const DEFAULT_TOLERANCE = 300; - - /** - * The hashing algorithm used for HMAC. - */ - private const ALGORITHM = 'sha256'; - - /** - * Generate a cryptographically secure webhook signing secret. - * - * The secret is a 64-character random string suitable for HMAC-SHA256 signing. - * This should be stored securely and shared with the webhook recipient out-of-band. - * - * @return string A 64-character random string - */ - public function generateSecret(): string - { - return Str::random(64); - } - - /** - * Sign a webhook payload with the given secret and timestamp. - * - * The signature format is: - * HMAC-SHA256(timestamp + "." + payload, secret) - * - * This format ensures the timestamp cannot be changed without invalidating - * the signature, providing replay attack protection. - * - * @param string $payload The JSON-encoded webhook payload - * @param string $secret The endpoint's signing secret - * @param int $timestamp Unix timestamp of when the webhook was sent - * @return string The HMAC-SHA256 signature (hex-encoded, 64 characters) - */ - public function sign(string $payload, string $secret, int $timestamp): string - { - $signedPayload = $this->buildSignedPayload($timestamp, $payload); - - return hash_hmac(self::ALGORITHM, $signedPayload, $secret); - } - - /** - * Verify a webhook signature. - * - * Performs a timing-safe comparison to prevent timing attacks, and optionally - * validates that the timestamp is within the specified tolerance. - * - * @param string $payload The raw request body (JSON string) - * @param string $signature The signature from X-Webhook-Signature header - * @param string $secret The webhook endpoint's secret - * @param int $timestamp The timestamp from X-Webhook-Timestamp header - * @param int $tolerance Maximum age of the timestamp in seconds (default: 300) - * @return bool True if the signature is valid and timestamp is within tolerance - */ - public function verify( - string $payload, - string $signature, - string $secret, - int $timestamp, - int $tolerance = self::DEFAULT_TOLERANCE - ): bool { - // Check timestamp is within tolerance - if (! $this->isTimestampValid($timestamp, $tolerance)) { - return false; - } - - // Compute expected signature - $expectedSignature = $this->sign($payload, $secret, $timestamp); - - // Use timing-safe comparison to prevent timing attacks - return hash_equals($expectedSignature, $signature); - } - - /** - * Verify signature without timestamp validation. - * - * Use this method when you need to verify the signature but handle - * timestamp validation separately (e.g., for testing or special cases). - * - * @param string $payload The raw request body - * @param string $signature The signature from the header - * @param string $secret The webhook secret - * @param int $timestamp The timestamp from the header - * @return bool True if the signature is valid - */ - public function verifySignatureOnly( - string $payload, - string $signature, - string $secret, - int $timestamp - ): bool { - $expectedSignature = $this->sign($payload, $secret, $timestamp); - - return hash_equals($expectedSignature, $signature); - } - - /** - * Check if a timestamp is within the allowed tolerance. - * - * @param int $timestamp The Unix timestamp to check - * @param int $tolerance Maximum age in seconds - * @return bool True if the timestamp is within tolerance - */ - public function isTimestampValid(int $timestamp, int $tolerance = self::DEFAULT_TOLERANCE): bool - { - $now = time(); - - return abs($now - $timestamp) <= $tolerance; - } - - /** - * Build the signed payload string. - * - * Format: "{timestamp}.{payload}" - * - * @param int $timestamp Unix timestamp - * @param string $payload The JSON payload - * @return string The combined string to be signed - */ - private function buildSignedPayload(int $timestamp, string $payload): string - { - return $timestamp.'.'.$payload; - } - - /** - * Get the headers to include with a webhook request. - * - * Returns an array of headers ready to be used with HTTP client: - * - X-Webhook-Signature: The HMAC signature - * - X-Webhook-Timestamp: Unix timestamp - * - * @param string $payload The JSON-encoded payload - * @param string $secret The signing secret - * @param int|null $timestamp Unix timestamp (defaults to current time) - * @return array Headers array - */ - public function getHeaders(string $payload, string $secret, ?int $timestamp = null): array - { - $timestamp ??= time(); - - return [ - 'X-Webhook-Signature' => $this->sign($payload, $secret, $timestamp), - 'X-Webhook-Timestamp' => $timestamp, - ]; - } -} diff --git a/packages/core-api/src/Mod/Api/Tests/Feature/ApiKeyRotationTest.php b/packages/core-api/src/Mod/Api/Tests/Feature/ApiKeyRotationTest.php deleted file mode 100644 index 86c2f5c..0000000 --- a/packages/core-api/src/Mod/Api/Tests/Feature/ApiKeyRotationTest.php +++ /dev/null @@ -1,232 +0,0 @@ -user = User::factory()->create(); - $this->workspace = Workspace::factory()->create(); - $this->workspace->users()->attach($this->user->id, [ - 'role' => 'owner', - 'is_default' => true, - ]); - $this->service = app(ApiKeyService::class); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// API Key Rotation -// ───────────────────────────────────────────────────────────────────────────── - -describe('API Key Rotation', function () { - it('rotates a key creating new key with same settings', function () { - $original = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Original Key', - [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE] - ); - - $result = $this->service->rotate($original['api_key']); - - expect($result)->toHaveKeys(['api_key', 'plain_key', 'old_key']); - expect($result['api_key']->name)->toBe('Original Key'); - expect($result['api_key']->scopes)->toBe([ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]); - expect($result['api_key']->workspace_id)->toBe($this->workspace->id); - expect($result['api_key']->rotated_from_id)->toBe($original['api_key']->id); - }); - - it('sets grace period on old key during rotation', function () { - $original = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Grace Period Key' - ); - - $result = $this->service->rotate($original['api_key'], 24); - - $oldKey = $result['old_key']->fresh(); - expect($oldKey->grace_period_ends_at)->not->toBeNull(); - expect($oldKey->isInGracePeriod())->toBeTrue(); - }); - - it('old key remains valid during grace period', function () { - $original = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Still Valid Key' - ); - - $this->service->rotate($original['api_key'], 24); - - // Old key should still be findable - $foundKey = ApiKey::findByPlainKey($original['plain_key']); - expect($foundKey)->not->toBeNull(); - expect($foundKey->id)->toBe($original['api_key']->id); - }); - - it('old key becomes invalid after grace period expires', function () { - $original = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Expired Grace Key' - ); - - $original['api_key']->update([ - 'grace_period_ends_at' => now()->subHour(), - ]); - - $foundKey = ApiKey::findByPlainKey($original['plain_key']); - expect($foundKey)->toBeNull(); - }); - - it('prevents rotating key already in grace period', function () { - $original = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Already Rotating Key' - ); - - $this->service->rotate($original['api_key']); - - expect(fn () => $this->service->rotate($original['api_key']->fresh())) - ->toThrow(\RuntimeException::class); - }); - - it('can end grace period early', function () { - $original = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Early End Key' - ); - - $this->service->rotate($original['api_key'], 24); - $this->service->endGracePeriod($original['api_key']->fresh()); - - expect($original['api_key']->fresh()->trashed())->toBeTrue(); - }); - - it('preserves server scopes during rotation', function () { - $original = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Server Scoped Key' - ); - $original['api_key']->update(['server_scopes' => ['commerce', 'biohost']]); - - $result = $this->service->rotate($original['api_key']->fresh()); - - expect($result['api_key']->server_scopes)->toBe(['commerce', 'biohost']); - }); - - it('cleans up keys with expired grace periods', function () { - // Create keys with expired grace periods - $key1 = ApiKey::generate($this->workspace->id, $this->user->id, 'Expired 1'); - $key1['api_key']->update(['grace_period_ends_at' => now()->subDay()]); - - $key2 = ApiKey::generate($this->workspace->id, $this->user->id, 'Expired 2'); - $key2['api_key']->update(['grace_period_ends_at' => now()->subHour()]); - - // Create key still in grace period - $key3 = ApiKey::generate($this->workspace->id, $this->user->id, 'Still Active'); - $key3['api_key']->update(['grace_period_ends_at' => now()->addDay()]); - - $cleaned = $this->service->cleanupExpiredGracePeriods(); - - expect($cleaned)->toBe(2); - expect($key1['api_key']->fresh()->trashed())->toBeTrue(); - expect($key2['api_key']->fresh()->trashed())->toBeTrue(); - expect($key3['api_key']->fresh()->trashed())->toBeFalse(); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// API Key Scopes via Service -// ───────────────────────────────────────────────────────────────────────────── - -describe('API Key Service Scopes', function () { - it('updates key scopes', function () { - $result = $this->service->create( - $this->workspace->id, - $this->user->id, - 'Scoped Key' - ); - - $this->service->updateScopes($result['api_key'], [ApiKey::SCOPE_READ]); - - expect($result['api_key']->fresh()->scopes)->toBe([ApiKey::SCOPE_READ]); - }); - - it('requires at least one valid scope', function () { - $result = $this->service->create( - $this->workspace->id, - $this->user->id, - 'Invalid Scopes Key' - ); - - expect(fn () => $this->service->updateScopes($result['api_key'], ['invalid'])) - ->toThrow(\InvalidArgumentException::class); - }); - - it('updates server scopes', function () { - $result = $this->service->create( - $this->workspace->id, - $this->user->id, - 'Server Scoped Key' - ); - - $this->service->updateServerScopes($result['api_key'], ['commerce']); - - expect($result['api_key']->fresh()->server_scopes)->toBe(['commerce']); - }); - - it('clears server scopes with null', function () { - $result = $this->service->create( - $this->workspace->id, - $this->user->id, - 'Clear Server Scopes Key', - serverScopes: ['commerce'] - ); - - $this->service->updateServerScopes($result['api_key'], null); - - expect($result['api_key']->fresh()->server_scopes)->toBeNull(); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// API Key Service Limits -// ───────────────────────────────────────────────────────────────────────────── - -describe('API Key Service Limits', function () { - it('enforces max keys per workspace limit', function () { - config(['api.keys.max_per_workspace' => 2]); - - $this->service->create($this->workspace->id, $this->user->id, 'Key 1'); - $this->service->create($this->workspace->id, $this->user->id, 'Key 2'); - - expect(fn () => $this->service->create($this->workspace->id, $this->user->id, 'Key 3')) - ->toThrow(\RuntimeException::class); - }); - - it('returns workspace key statistics', function () { - $key1 = $this->service->create($this->workspace->id, $this->user->id, 'Active Key'); - $key2 = $this->service->create($this->workspace->id, $this->user->id, 'Expired Key'); - $key2['api_key']->update(['expires_at' => now()->subDay()]); - - $key3 = $this->service->create($this->workspace->id, $this->user->id, 'Rotating Key'); - $this->service->rotate($key3['api_key']); - - $stats = $this->service->getStats($this->workspace->id); - - expect($stats)->toHaveKeys(['total', 'active', 'expired', 'in_grace_period', 'revoked']); - expect($stats['total'])->toBe(4); // 3 original + 1 rotated - expect($stats['expired'])->toBe(1); - expect($stats['in_grace_period'])->toBe(1); - }); -}); diff --git a/packages/core-api/src/Mod/Api/Tests/Feature/ApiKeySecurityTest.php b/packages/core-api/src/Mod/Api/Tests/Feature/ApiKeySecurityTest.php deleted file mode 100644 index d9f0545..0000000 --- a/packages/core-api/src/Mod/Api/Tests/Feature/ApiKeySecurityTest.php +++ /dev/null @@ -1,381 +0,0 @@ -user = User::factory()->create(); - $this->workspace = Workspace::factory()->create(); - $this->workspace->users()->attach($this->user->id, [ - 'role' => 'owner', - 'is_default' => true, - ]); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Secure Hashing (bcrypt) -// ───────────────────────────────────────────────────────────────────────────── - -describe('Secure Hashing', function () { - it('uses bcrypt for new API keys', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Secure Key' - ); - - expect($result['api_key']->hash_algorithm)->toBe(ApiKey::HASH_BCRYPT); - expect($result['api_key']->key)->toStartWith('$2y$'); - }); - - it('verifies bcrypt hashed keys correctly', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Verifiable Key' - ); - - $parts = explode('_', $result['plain_key'], 3); - $keyPart = $parts[2]; - - expect($result['api_key']->verifyKey($keyPart))->toBeTrue(); - expect($result['api_key']->verifyKey('wrong-key'))->toBeFalse(); - }); - - it('finds bcrypt keys by plain key', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Findable Bcrypt Key' - ); - - $found = ApiKey::findByPlainKey($result['plain_key']); - - expect($found)->not->toBeNull(); - expect($found->id)->toBe($result['api_key']->id); - }); - - it('bcrypt keys are not vulnerable to timing attacks', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Timing Safe Key' - ); - - $parts = explode('_', $result['plain_key'], 3); - $keyPart = $parts[2]; - - // bcrypt verification should take similar time for valid and invalid keys - // (this is a property test, not a precise timing test) - expect($result['api_key']->verifyKey($keyPart))->toBeTrue(); - expect($result['api_key']->verifyKey('x'.$keyPart))->toBeFalse(); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Legacy SHA-256 Backward Compatibility -// ───────────────────────────────────────────────────────────────────────────── - -describe('Legacy SHA-256 Compatibility', function () { - it('identifies legacy hash keys', function () { - $result = ApiKeyFactory::createLegacyKey( - $this->workspace, - $this->user - ); - - expect($result['api_key']->hash_algorithm)->toBe(ApiKey::HASH_SHA256); - expect($result['api_key']->usesLegacyHash())->toBeTrue(); - }); - - it('verifies legacy SHA-256 keys correctly', function () { - $result = ApiKeyFactory::createLegacyKey( - $this->workspace, - $this->user - ); - - $parts = explode('_', $result['plain_key'], 3); - $keyPart = $parts[2]; - - expect($result['api_key']->verifyKey($keyPart))->toBeTrue(); - expect($result['api_key']->verifyKey('wrong-key'))->toBeFalse(); - }); - - it('finds legacy SHA-256 keys by plain key', function () { - $result = ApiKeyFactory::createLegacyKey( - $this->workspace, - $this->user - ); - - $found = ApiKey::findByPlainKey($result['plain_key']); - - expect($found)->not->toBeNull(); - expect($found->id)->toBe($result['api_key']->id); - }); - - it('treats null hash_algorithm as legacy', function () { - // Create a key without hash_algorithm (simulating pre-migration key) - $plainKey = Str::random(48); - $prefix = 'hk_'.Str::random(8); - - $apiKey = ApiKey::create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - 'name' => 'Pre-migration Key', - 'key' => hash('sha256', $plainKey), - 'hash_algorithm' => null, // Simulate pre-migration - 'prefix' => $prefix, - 'scopes' => [ApiKey::SCOPE_READ], - ]); - - expect($apiKey->usesLegacyHash())->toBeTrue(); - - // Should still be findable - $found = ApiKey::findByPlainKey("{$prefix}_{$plainKey}"); - expect($found)->not->toBeNull(); - expect($found->id)->toBe($apiKey->id); - }); - - it('can query for legacy hash keys', function () { - // Create a bcrypt key - ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Secure Key' - ); - - // Create a legacy key - ApiKeyFactory::createLegacyKey( - $this->workspace, - $this->user - ); - - $legacyKeys = ApiKey::legacyHash()->get(); - $secureKeys = ApiKey::secureHash()->get(); - - expect($legacyKeys)->toHaveCount(1); - expect($secureKeys)->toHaveCount(1); - expect($legacyKeys->first()->name)->toContain('API Key'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Key Rotation for Security Migration -// ───────────────────────────────────────────────────────────────────────────── - -describe('Security Migration via Rotation', function () { - it('rotates legacy key to secure bcrypt key', function () { - $legacy = ApiKeyFactory::createLegacyKey( - $this->workspace, - $this->user - ); - - expect($legacy['api_key']->usesLegacyHash())->toBeTrue(); - - $rotated = $legacy['api_key']->rotate(); - - expect($rotated['api_key']->hash_algorithm)->toBe(ApiKey::HASH_BCRYPT); - expect($rotated['api_key']->usesLegacyHash())->toBeFalse(); - expect($rotated['api_key']->key)->toStartWith('$2y$'); - }); - - it('preserves settings when rotating legacy key', function () { - $legacy = ApiKeyFactory::createLegacyKey( - $this->workspace, - $this->user, - [ApiKey::SCOPE_READ, ApiKey::SCOPE_DELETE] - ); - - $legacy['api_key']->update(['server_scopes' => ['commerce', 'biohost']]); - - $rotated = $legacy['api_key']->fresh()->rotate(); - - expect($rotated['api_key']->scopes)->toBe([ApiKey::SCOPE_READ, ApiKey::SCOPE_DELETE]); - expect($rotated['api_key']->server_scopes)->toBe(['commerce', 'biohost']); - expect($rotated['api_key']->workspace_id)->toBe($this->workspace->id); - }); - - it('legacy key remains valid during grace period after rotation', function () { - $legacy = ApiKeyFactory::createLegacyKey( - $this->workspace, - $this->user - ); - - $legacy['api_key']->rotate(24); // 24 hour grace period - - // Old key should still work - $found = ApiKey::findByPlainKey($legacy['plain_key']); - expect($found)->not->toBeNull(); - expect($found->isInGracePeriod())->toBeTrue(); - }); - - it('tracks rotation lineage', function () { - $original = ApiKeyFactory::createLegacyKey( - $this->workspace, - $this->user - ); - - $rotated = $original['api_key']->rotate(); - - expect($rotated['api_key']->rotated_from_id)->toBe($original['api_key']->id); - expect($rotated['api_key']->rotatedFrom->id)->toBe($original['api_key']->id); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Grace Period Handling -// ───────────────────────────────────────────────────────────────────────────── - -describe('Grace Period', function () { - it('sets grace period on rotation', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'To Be Rotated' - ); - - $result['api_key']->rotate(48); - - $oldKey = $result['api_key']->fresh(); - expect($oldKey->grace_period_ends_at)->not->toBeNull(); - expect($oldKey->isInGracePeriod())->toBeTrue(); - expect($oldKey->grace_period_ends_at->diffInHours(now()))->toBeLessThanOrEqual(48); - }); - - it('key becomes invalid after grace period expires', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Expiring Grace Key' - ); - - $result['api_key']->update([ - 'grace_period_ends_at' => now()->subHour(), - ]); - - $found = ApiKey::findByPlainKey($result['plain_key']); - expect($found)->toBeNull(); - }); - - it('can end grace period early', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Early End Key' - ); - - $result['api_key']->rotate(24); - - $oldKey = $result['api_key']->fresh(); - expect($oldKey->isInGracePeriod())->toBeTrue(); - - $oldKey->endGracePeriod(); - - expect($oldKey->fresh()->trashed())->toBeTrue(); - }); - - it('scopes keys in grace period correctly', function () { - // Key in grace period - $key1 = ApiKey::generate($this->workspace->id, $this->user->id, 'In Grace'); - $key1['api_key']->update(['grace_period_ends_at' => now()->addHours(12)]); - - // Key with expired grace period - $key2 = ApiKey::generate($this->workspace->id, $this->user->id, 'Expired Grace'); - $key2['api_key']->update(['grace_period_ends_at' => now()->subHours(1)]); - - // Normal key - ApiKey::generate($this->workspace->id, $this->user->id, 'Normal Key'); - - expect(ApiKey::inGracePeriod()->count())->toBe(1); - expect(ApiKey::gracePeriodExpired()->count())->toBe(1); - expect(ApiKey::active()->count())->toBe(2); // Normal + In Grace - }); - - it('detects grace period expired status', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Status Check Key' - ); - - // Not in grace period - expect($result['api_key']->isInGracePeriod())->toBeFalse(); - expect($result['api_key']->isGracePeriodExpired())->toBeFalse(); - - // In grace period - $result['api_key']->update(['grace_period_ends_at' => now()->addHour()]); - expect($result['api_key']->fresh()->isInGracePeriod())->toBeTrue(); - expect($result['api_key']->fresh()->isGracePeriodExpired())->toBeFalse(); - - // Grace period expired - $result['api_key']->update(['grace_period_ends_at' => now()->subHour()]); - expect($result['api_key']->fresh()->isInGracePeriod())->toBeFalse(); - expect($result['api_key']->fresh()->isGracePeriodExpired())->toBeTrue(); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Hash Algorithm Constants -// ───────────────────────────────────────────────────────────────────────────── - -describe('Hash Algorithm Constants', function () { - it('defines correct hash algorithm constants', function () { - expect(ApiKey::HASH_SHA256)->toBe('sha256'); - expect(ApiKey::HASH_BCRYPT)->toBe('bcrypt'); - }); - - it('defines default grace period constant', function () { - expect(ApiKey::DEFAULT_GRACE_PERIOD_HOURS)->toBe(24); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Factory Legacy Support -// ───────────────────────────────────────────────────────────────────────────── - -describe('Factory Legacy Support', function () { - it('creates legacy keys via static helper', function () { - $result = ApiKeyFactory::createLegacyKey( - $this->workspace, - $this->user - ); - - expect($result['api_key']->hash_algorithm)->toBe(ApiKey::HASH_SHA256); - expect($result['api_key']->key)->not->toStartWith('$2y$'); - - // Should be a 64-char hex string (SHA-256) - expect(strlen($result['api_key']->key))->toBe(64); - }); - - it('creates keys in grace period via factory', function () { - $key = ApiKey::factory() - ->for($this->workspace) - ->for($this->user) - ->inGracePeriod(6) - ->create(); - - expect($key->isInGracePeriod())->toBeTrue(); - expect($key->grace_period_ends_at->diffInHours(now()))->toBeLessThanOrEqual(6); - }); - - it('creates keys with expired grace period via factory', function () { - $key = ApiKey::factory() - ->for($this->workspace) - ->for($this->user) - ->gracePeriodExpired() - ->create(); - - expect($key->isGracePeriodExpired())->toBeTrue(); - expect($key->isInGracePeriod())->toBeFalse(); - }); -}); diff --git a/packages/core-api/src/Mod/Api/Tests/Feature/ApiKeyTest.php b/packages/core-api/src/Mod/Api/Tests/Feature/ApiKeyTest.php deleted file mode 100644 index 109811c..0000000 --- a/packages/core-api/src/Mod/Api/Tests/Feature/ApiKeyTest.php +++ /dev/null @@ -1,617 +0,0 @@ -user = User::factory()->create(); - $this->workspace = Workspace::factory()->create(); - $this->workspace->users()->attach($this->user->id, [ - 'role' => 'owner', - 'is_default' => true, - ]); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// API Key Creation -// ───────────────────────────────────────────────────────────────────────────── - -describe('API Key Creation', function () { - it('generates a new API key with correct format', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Test API Key' - ); - - expect($result)->toHaveKeys(['api_key', 'plain_key']); - expect($result['api_key'])->toBeInstanceOf(ApiKey::class); - expect($result['plain_key'])->toStartWith('hk_'); - - // Plain key format: hk_xxxxxxxx_xxxx... - $parts = explode('_', $result['plain_key']); - expect($parts)->toHaveCount(3); - expect($parts[0])->toBe('hk'); - expect(strlen($parts[1]))->toBe(8); - expect(strlen($parts[2]))->toBe(48); - }); - - it('creates key with default read and write scopes', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Default Scopes Key' - ); - - expect($result['api_key']->scopes)->toBe([ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]); - }); - - it('creates key with custom scopes', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Full Access Key', - [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE, ApiKey::SCOPE_DELETE] - ); - - expect($result['api_key']->scopes)->toBe(ApiKey::ALL_SCOPES); - }); - - it('creates key with expiry date', function () { - $expiresAt = now()->addDays(30); - - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Expiring Key', - [ApiKey::SCOPE_READ], - $expiresAt - ); - - expect($result['api_key']->expires_at)->not->toBeNull(); - expect($result['api_key']->expires_at->timestamp)->toBe($expiresAt->timestamp); - }); - - it('stores key as bcrypt hashed value', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Hashed Key' - ); - - // Extract the key part from plain key - $parts = explode('_', $result['plain_key'], 3); - $keyPart = $parts[2]; - - // The stored key should be a bcrypt hash (starts with $2y$) - expect($result['api_key']->key)->toStartWith('$2y$'); - expect($result['api_key']->hash_algorithm)->toBe(ApiKey::HASH_BCRYPT); - - // Verify the key matches using Hash::check - expect(\Illuminate\Support\Facades\Hash::check($keyPart, $result['api_key']->key))->toBeTrue(); - }); - - it('sets hash_algorithm to bcrypt for new keys', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Bcrypt Key' - ); - - expect($result['api_key']->hash_algorithm)->toBe(ApiKey::HASH_BCRYPT); - expect($result['api_key']->usesLegacyHash())->toBeFalse(); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// API Key Authentication -// ───────────────────────────────────────────────────────────────────────────── - -describe('API Key Authentication', function () { - it('finds key by valid plain key', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Findable Key' - ); - - $foundKey = ApiKey::findByPlainKey($result['plain_key']); - - expect($foundKey)->not->toBeNull(); - expect($foundKey->id)->toBe($result['api_key']->id); - }); - - it('returns null for invalid key format', function () { - expect(ApiKey::findByPlainKey('invalid-key'))->toBeNull(); - expect(ApiKey::findByPlainKey('hk_only_two_parts'))->toBeNull(); - expect(ApiKey::findByPlainKey(''))->toBeNull(); - }); - - it('returns null for non-existent key', function () { - $result = ApiKey::findByPlainKey('hk_nonexist_'.str_repeat('x', 48)); - - expect($result)->toBeNull(); - }); - - it('returns null for expired key', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Expired Key', - [ApiKey::SCOPE_READ], - now()->subDay() // Already expired - ); - - $foundKey = ApiKey::findByPlainKey($result['plain_key']); - - expect($foundKey)->toBeNull(); - }); - - it('returns null for revoked (soft-deleted) key', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Revoked Key' - ); - - $result['api_key']->revoke(); - - $foundKey = ApiKey::findByPlainKey($result['plain_key']); - - expect($foundKey)->toBeNull(); - }); - - it('records usage on authentication', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Tracking Key' - ); - - expect($result['api_key']->last_used_at)->toBeNull(); - - $result['api_key']->recordUsage(); - - expect($result['api_key']->fresh()->last_used_at)->not->toBeNull(); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Scope Checking -// ───────────────────────────────────────────────────────────────────────────── - -describe('Scope Checking', function () { - it('checks for single scope', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Scoped Key', - [ApiKey::SCOPE_READ] - ); - - $key = $result['api_key']; - - expect($key->hasScope(ApiKey::SCOPE_READ))->toBeTrue(); - expect($key->hasScope(ApiKey::SCOPE_WRITE))->toBeFalse(); - expect($key->hasScope(ApiKey::SCOPE_DELETE))->toBeFalse(); - }); - - it('checks for multiple scopes', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Multi-Scoped Key', - [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE] - ); - - $key = $result['api_key']; - - expect($key->hasScopes([ApiKey::SCOPE_READ]))->toBeTrue(); - expect($key->hasScopes([ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]))->toBeTrue(); - expect($key->hasScopes([ApiKey::SCOPE_READ, ApiKey::SCOPE_DELETE]))->toBeFalse(); - }); - - it('returns available scope constants', function () { - expect(ApiKey::SCOPE_READ)->toBe('read'); - expect(ApiKey::SCOPE_WRITE)->toBe('write'); - expect(ApiKey::SCOPE_DELETE)->toBe('delete'); - expect(ApiKey::ALL_SCOPES)->toBe(['read', 'write', 'delete']); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Expiry Handling -// ───────────────────────────────────────────────────────────────────────────── - -describe('Expiry Handling', function () { - it('detects expired key', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Past Expiry Key', - [ApiKey::SCOPE_READ], - now()->subDay() - ); - - expect($result['api_key']->isExpired())->toBeTrue(); - }); - - it('detects non-expired key', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Future Expiry Key', - [ApiKey::SCOPE_READ], - now()->addDay() - ); - - expect($result['api_key']->isExpired())->toBeFalse(); - }); - - it('keys without expiry are never expired', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'No Expiry Key' - ); - - expect($result['api_key']->expires_at)->toBeNull(); - expect($result['api_key']->isExpired())->toBeFalse(); - }); - - it('scopes expired keys correctly', function () { - // Create expired key - ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Expired Key 1', - [ApiKey::SCOPE_READ], - now()->subDays(2) - ); - - // Create active key - ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Active Key', - [ApiKey::SCOPE_READ], - now()->addDays(30) - ); - - // Create no-expiry key - ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'No Expiry Key' - ); - - $expired = ApiKey::expired()->count(); - $active = ApiKey::active()->count(); - - expect($expired)->toBe(1); - expect($active)->toBe(2); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Server Scopes (MCP Access) -// ───────────────────────────────────────────────────────────────────────────── - -describe('Server Scopes', function () { - it('allows all servers when server_scopes is null', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'All Servers Key' - ); - - $key = $result['api_key']; - - expect($key->server_scopes)->toBeNull(); - expect($key->hasServerAccess('commerce'))->toBeTrue(); - expect($key->hasServerAccess('biohost'))->toBeTrue(); - expect($key->hasServerAccess('anything'))->toBeTrue(); - }); - - it('restricts to specific servers when server_scopes is set', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Limited Servers Key' - ); - - $key = $result['api_key']; - $key->update(['server_scopes' => ['commerce', 'biohost']]); - - expect($key->hasServerAccess('commerce'))->toBeTrue(); - expect($key->hasServerAccess('biohost'))->toBeTrue(); - expect($key->hasServerAccess('analytics'))->toBeFalse(); - }); - - it('returns allowed servers list', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Specific Servers Key' - ); - - $key = $result['api_key']; - $key->update(['server_scopes' => ['commerce']]); - - expect($key->getAllowedServers())->toBe(['commerce']); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Key Revocation -// ───────────────────────────────────────────────────────────────────────────── - -describe('Key Revocation', function () { - it('revokes key via soft delete', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'To Be Revoked' - ); - - $key = $result['api_key']; - $keyId = $key->id; - - $key->revoke(); - - // Should be soft deleted - expect(ApiKey::find($keyId))->toBeNull(); - expect(ApiKey::withTrashed()->find($keyId))->not->toBeNull(); - }); - - it('revoked keys are excluded from workspace scope', function () { - // Create active key - ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Active Key' - ); - - // Create and revoke a key - $revokedResult = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Revoked Key' - ); - $revokedResult['api_key']->revoke(); - - $keys = ApiKey::forWorkspace($this->workspace->id)->get(); - - expect($keys)->toHaveCount(1); - expect($keys->first()->name)->toBe('Active Key'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Masked Key Display -// ───────────────────────────────────────────────────────────────────────────── - -describe('Masked Key Display', function () { - it('provides masked key for display', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Masked Key' - ); - - $key = $result['api_key']; - $maskedKey = $key->masked_key; - - expect($maskedKey)->toStartWith($key->prefix); - expect($maskedKey)->toEndWith('_****'); - expect($maskedKey)->toBe("{$key->prefix}_****"); - }); - - it('hides raw key in JSON serialization', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Hidden Key' - ); - - $json = $result['api_key']->toArray(); - - expect($json)->not->toHaveKey('key'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Relationships -// ───────────────────────────────────────────────────────────────────────────── - -describe('Relationships', function () { - it('belongs to workspace', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Workspace Key' - ); - - expect($result['api_key']->workspace->id)->toBe($this->workspace->id); - }); - - it('belongs to user', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'User Key' - ); - - expect($result['api_key']->user->id)->toBe($this->user->id); - }); - - it('is deleted when workspace is deleted', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Cascade Key' - ); - - $keyId = $result['api_key']->id; - - $this->workspace->delete(); - - expect(ApiKey::withTrashed()->find($keyId))->toBeNull(); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Factory Tests -// ───────────────────────────────────────────────────────────────────────────── - -describe('Factory', function () { - it('creates key via factory', function () { - $key = ApiKey::factory() - ->for($this->workspace) - ->for($this->user) - ->create(); - - expect($key)->toBeInstanceOf(ApiKey::class); - expect($key->workspace_id)->toBe($this->workspace->id); - expect($key->user_id)->toBe($this->user->id); - }); - - it('creates read-only key via factory', function () { - $key = ApiKey::factory() - ->for($this->workspace) - ->for($this->user) - ->readOnly() - ->create(); - - expect($key->scopes)->toBe([ApiKey::SCOPE_READ]); - }); - - it('creates full access key via factory', function () { - $key = ApiKey::factory() - ->for($this->workspace) - ->for($this->user) - ->fullAccess() - ->create(); - - expect($key->scopes)->toBe(ApiKey::ALL_SCOPES); - }); - - it('creates expired key via factory', function () { - $key = ApiKey::factory() - ->for($this->workspace) - ->for($this->user) - ->expired() - ->create(); - - expect($key->isExpired())->toBeTrue(); - }); - - it('creates key with known credentials via helper', function () { - $result = ApiKeyFactory::createWithPlainKey( - $this->workspace, - $this->user, - [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE] - ); - - expect($result)->toHaveKeys(['api_key', 'plain_key']); - - // Verify the plain key works for lookup - $foundKey = ApiKey::findByPlainKey($result['plain_key']); - expect($foundKey)->not->toBeNull(); - expect($foundKey->id)->toBe($result['api_key']->id); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Rate Limiting (Integration) -// ───────────────────────────────────────────────────────────────────────────── - -describe('Rate Limiting Configuration', function () { - it('has default rate limits configured', function () { - $default = config('api.rate_limits.default'); - - expect($default)->toHaveKeys(['requests', 'per_minutes']); - expect($default['requests'])->toBeInt(); - expect($default['per_minutes'])->toBeInt(); - }); - - it('has authenticated rate limits configured', function () { - $authenticated = config('api.rate_limits.authenticated'); - - expect($authenticated)->toHaveKeys(['requests', 'per_minutes']); - expect($authenticated['requests'])->toBeGreaterThan(config('api.rate_limits.default.requests')); - }); - - it('has tier-based rate limits configured', function () { - $tiers = ['starter', 'pro', 'agency', 'enterprise']; - - foreach ($tiers as $tier) { - $limits = config("api.rate_limits.by_tier.{$tier}"); - expect($limits)->toHaveKeys(['requests', 'per_minutes']); - } - }); - - it('tier limits increase with tier level', function () { - $starter = config('api.rate_limits.by_tier.starter.requests'); - $pro = config('api.rate_limits.by_tier.pro.requests'); - $agency = config('api.rate_limits.by_tier.agency.requests'); - $enterprise = config('api.rate_limits.by_tier.enterprise.requests'); - - expect($pro)->toBeGreaterThan($starter); - expect($agency)->toBeGreaterThan($pro); - expect($enterprise)->toBeGreaterThan($agency); - }); - - it('has route-level rate limit names configured', function () { - $routeLimits = config('api.rate_limits.routes'); - - expect($routeLimits)->toBeArray(); - expect($routeLimits)->toHaveKeys(['mcp', 'pixel']); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// HTTP Authentication Tests -// ───────────────────────────────────────────────────────────────────────────── - -describe('HTTP Authentication', function () { - it('requires authorization header', function () { - $response = $this->getJson('/api/mcp/servers'); - - expect($response->status())->toBe(401); - expect($response->json('error'))->toBe('unauthorized'); - }); - - it('rejects invalid API key', function () { - $response = $this->getJson('/api/mcp/servers', [ - 'Authorization' => 'Bearer hk_invalid_'.str_repeat('x', 48), - ]); - - expect($response->status())->toBe(401); - }); - - it('rejects expired API key via HTTP', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Expired HTTP Key', - [ApiKey::SCOPE_READ], - now()->subDay() - ); - - $response = $this->getJson('/api/mcp/servers', [ - 'Authorization' => "Bearer {$result['plain_key']}", - ]); - - expect($response->status())->toBe(401); - }); -}); diff --git a/packages/core-api/src/Mod/Api/Tests/Feature/ApiScopeEnforcementTest.php b/packages/core-api/src/Mod/Api/Tests/Feature/ApiScopeEnforcementTest.php deleted file mode 100644 index ec6f630..0000000 --- a/packages/core-api/src/Mod/Api/Tests/Feature/ApiScopeEnforcementTest.php +++ /dev/null @@ -1,232 +0,0 @@ -user = User::factory()->create(); - $this->workspace = Workspace::factory()->create(); - $this->workspace->users()->attach($this->user->id, [ - 'role' => 'owner', - 'is_default' => true, - ]); - - // Register test routes with scope enforcement - Route::middleware(['api', 'api.auth', 'api.scope.enforce']) - ->prefix('test-scope') - ->group(function () { - Route::get('/read', fn () => response()->json(['status' => 'ok'])); - Route::post('/write', fn () => response()->json(['status' => 'ok'])); - Route::put('/update', fn () => response()->json(['status' => 'ok'])); - Route::patch('/patch', fn () => response()->json(['status' => 'ok'])); - Route::delete('/delete', fn () => response()->json(['status' => 'ok'])); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Read Scope Enforcement -// ───────────────────────────────────────────────────────────────────────────── - -describe('Read Scope Enforcement', function () { - it('allows GET request with read scope', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Read Only Key', - [ApiKey::SCOPE_READ] - ); - - $response = $this->getJson('/api/test-scope/read', [ - 'Authorization' => "Bearer {$result['plain_key']}", - ]); - - expect($response->status())->toBe(200); - expect($response->json('status'))->toBe('ok'); - }); - - it('denies POST request with read-only scope', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Read Only Key', - [ApiKey::SCOPE_READ] - ); - - $response = $this->postJson('/api/test-scope/write', [], [ - 'Authorization' => "Bearer {$result['plain_key']}", - ]); - - expect($response->status())->toBe(403); - expect($response->json('error'))->toBe('forbidden'); - expect($response->json('message'))->toContain('write'); - }); - - it('denies DELETE request with read-only scope', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Read Only Key', - [ApiKey::SCOPE_READ] - ); - - $response = $this->deleteJson('/api/test-scope/delete', [], [ - 'Authorization' => "Bearer {$result['plain_key']}", - ]); - - expect($response->status())->toBe(403); - expect($response->json('error'))->toBe('forbidden'); - expect($response->json('message'))->toContain('delete'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Write Scope Enforcement -// ───────────────────────────────────────────────────────────────────────────── - -describe('Write Scope Enforcement', function () { - it('allows POST request with write scope', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Read/Write Key', - [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE] - ); - - $response = $this->postJson('/api/test-scope/write', [], [ - 'Authorization' => "Bearer {$result['plain_key']}", - ]); - - expect($response->status())->toBe(200); - }); - - it('allows PUT request with write scope', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Read/Write Key', - [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE] - ); - - $response = $this->putJson('/api/test-scope/update', [], [ - 'Authorization' => "Bearer {$result['plain_key']}", - ]); - - expect($response->status())->toBe(200); - }); - - it('allows PATCH request with write scope', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Read/Write Key', - [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE] - ); - - $response = $this->patchJson('/api/test-scope/patch', [], [ - 'Authorization' => "Bearer {$result['plain_key']}", - ]); - - expect($response->status())->toBe(200); - }); - - it('denies DELETE request without delete scope', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Read/Write Key', - [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE] - ); - - $response = $this->deleteJson('/api/test-scope/delete', [], [ - 'Authorization' => "Bearer {$result['plain_key']}", - ]); - - expect($response->status())->toBe(403); - expect($response->json('message'))->toContain('delete'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Delete Scope Enforcement -// ───────────────────────────────────────────────────────────────────────────── - -describe('Delete Scope Enforcement', function () { - it('allows DELETE request with delete scope', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Full Access Key', - [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE, ApiKey::SCOPE_DELETE] - ); - - $response = $this->deleteJson('/api/test-scope/delete', [], [ - 'Authorization' => "Bearer {$result['plain_key']}", - ]); - - expect($response->status())->toBe(200); - }); - - it('includes key scopes in error response', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Read Only Key', - [ApiKey::SCOPE_READ] - ); - - $response = $this->deleteJson('/api/test-scope/delete', [], [ - 'Authorization' => "Bearer {$result['plain_key']}", - ]); - - expect($response->status())->toBe(403); - expect($response->json('key_scopes'))->toBe([ApiKey::SCOPE_READ]); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Full Access Keys -// ───────────────────────────────────────────────────────────────────────────── - -describe('Full Access Keys', function () { - it('allows all operations with full access', function () { - $result = ApiKey::generate( - $this->workspace->id, - $this->user->id, - 'Full Access Key', - ApiKey::ALL_SCOPES - ); - - $headers = ['Authorization' => "Bearer {$result['plain_key']}"]; - - expect($this->getJson('/api/test-scope/read', $headers)->status())->toBe(200); - expect($this->postJson('/api/test-scope/write', [], $headers)->status())->toBe(200); - expect($this->putJson('/api/test-scope/update', [], $headers)->status())->toBe(200); - expect($this->patchJson('/api/test-scope/patch', [], $headers)->status())->toBe(200); - expect($this->deleteJson('/api/test-scope/delete', [], $headers)->status())->toBe(200); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Non-API Key Auth (Session) -// ───────────────────────────────────────────────────────────────────────────── - -describe('Non-API Key Auth', function () { - it('passes through for session authenticated users', function () { - // For session auth, the middleware should allow through - // as scope enforcement only applies to API key auth - $this->actingAs($this->user); - - // The api.auth middleware will require API key, so this tests - // that if somehow session auth is used, scope middleware allows it - // In practice, routes use either 'auth' OR 'api.auth', not both - }); -}); diff --git a/packages/core-api/src/Mod/Api/Tests/Feature/ApiUsageTest.php b/packages/core-api/src/Mod/Api/Tests/Feature/ApiUsageTest.php deleted file mode 100644 index 20c3f0d..0000000 --- a/packages/core-api/src/Mod/Api/Tests/Feature/ApiUsageTest.php +++ /dev/null @@ -1,362 +0,0 @@ -user = User::factory()->create(); - $this->workspace = Workspace::factory()->create(); - $this->workspace->users()->attach($this->user->id, [ - 'role' => 'owner', - 'is_default' => true, - ]); - - $result = ApiKey::generate($this->workspace->id, $this->user->id, 'Test Key'); - $this->apiKey = $result['api_key']; - - $this->service = app(ApiUsageService::class); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Recording Usage -// ───────────────────────────────────────────────────────────────────────────── - -describe('Recording API Usage', function () { - it('records individual usage entries', function () { - $usage = $this->service->record( - apiKeyId: $this->apiKey->id, - workspaceId: $this->workspace->id, - endpoint: '/api/v1/workspaces', - method: 'GET', - statusCode: 200, - responseTimeMs: 150, - requestSize: 0, - responseSize: 1024 - ); - - expect($usage)->toBeInstanceOf(ApiUsage::class); - expect($usage->api_key_id)->toBe($this->apiKey->id); - expect($usage->endpoint)->toBe('/api/v1/workspaces'); - expect($usage->method)->toBe('GET'); - expect($usage->status_code)->toBe(200); - expect($usage->response_time_ms)->toBe(150); - }); - - it('normalises endpoint paths with IDs', function () { - $usage = $this->service->record( - apiKeyId: $this->apiKey->id, - workspaceId: $this->workspace->id, - endpoint: '/api/v1/workspaces/123/users/456', - method: 'GET', - statusCode: 200, - responseTimeMs: 100 - ); - - expect($usage->endpoint)->toBe('/api/v1/workspaces/{id}/users/{id}'); - }); - - it('normalises endpoint paths with UUIDs', function () { - $usage = $this->service->record( - apiKeyId: $this->apiKey->id, - workspaceId: $this->workspace->id, - endpoint: '/api/v1/resources/550e8400-e29b-41d4-a716-446655440000', - method: 'GET', - statusCode: 200, - responseTimeMs: 100 - ); - - expect($usage->endpoint)->toBe('/api/v1/resources/{uuid}'); - }); - - it('updates daily aggregation on record', function () { - $this->service->record( - apiKeyId: $this->apiKey->id, - workspaceId: $this->workspace->id, - endpoint: '/api/v1/test', - method: 'GET', - statusCode: 200, - responseTimeMs: 100 - ); - - $daily = ApiUsageDaily::forKey($this->apiKey->id) - ->where('date', now()->toDateString()) - ->first(); - - expect($daily)->not->toBeNull(); - expect($daily->request_count)->toBe(1); - expect($daily->success_count)->toBe(1); - }); - - it('increments daily counts correctly', function () { - // Record multiple requests - for ($i = 0; $i < 5; $i++) { - $this->service->record( - apiKeyId: $this->apiKey->id, - workspaceId: $this->workspace->id, - endpoint: '/api/v1/test', - method: 'GET', - statusCode: 200, - responseTimeMs: 100 + ($i * 10) - ); - } - - // Record some errors - for ($i = 0; $i < 2; $i++) { - $this->service->record( - apiKeyId: $this->apiKey->id, - workspaceId: $this->workspace->id, - endpoint: '/api/v1/test', - method: 'GET', - statusCode: 500, - responseTimeMs: 50 - ); - } - - $daily = ApiUsageDaily::forKey($this->apiKey->id) - ->where('date', now()->toDateString()) - ->first(); - - expect($daily->request_count)->toBe(7); - expect($daily->success_count)->toBe(5); - expect($daily->error_count)->toBe(2); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Usage Summaries -// ───────────────────────────────────────────────────────────────────────────── - -describe('Usage Summaries', function () { - beforeEach(function () { - // Create some usage data - for ($i = 0; $i < 10; $i++) { - $this->service->record( - apiKeyId: $this->apiKey->id, - workspaceId: $this->workspace->id, - endpoint: '/api/v1/workspaces', - method: 'GET', - statusCode: 200, - responseTimeMs: 100 + $i - ); - } - - for ($i = 0; $i < 3; $i++) { - $this->service->record( - apiKeyId: $this->apiKey->id, - workspaceId: $this->workspace->id, - endpoint: '/api/v1/workspaces', - method: 'POST', - statusCode: 422, - responseTimeMs: 50 - ); - } - }); - - it('returns workspace summary', function () { - $summary = $this->service->getWorkspaceSummary($this->workspace->id); - - expect($summary)->toHaveKeys(['period', 'totals', 'response_time', 'data_transfer']); - expect($summary['totals']['requests'])->toBe(13); - expect($summary['totals']['success'])->toBe(10); - expect($summary['totals']['errors'])->toBe(3); - }); - - it('returns key summary', function () { - $summary = $this->service->getKeySummary($this->apiKey->id); - - expect($summary['totals']['requests'])->toBe(13); - expect($summary['totals']['success_rate'])->toBeGreaterThan(70); - }); - - it('calculates average response time', function () { - $summary = $this->service->getWorkspaceSummary($this->workspace->id); - - // (100+101+102+...+109 + 50*3) / 13 - expect($summary['response_time']['average_ms'])->toBeGreaterThan(0); - }); - - it('filters by date range', function () { - // Create usage for 2 days ago with correct timestamp upfront - $oldDate = now()->subDays(2); - $usage = ApiUsage::create([ - 'api_key_id' => $this->apiKey->id, - 'workspace_id' => $this->workspace->id, - 'endpoint' => '/api/v1/old', - 'method' => 'GET', - 'status_code' => 200, - 'response_time_ms' => 100, - 'created_at' => $oldDate, - 'updated_at' => $oldDate, - ]); - - // Also create a backdated daily aggregate for consistency - ApiUsageDaily::updateOrCreate( - [ - 'api_key_id' => $this->apiKey->id, - 'date' => $oldDate->toDateString(), - ], - [ - 'request_count' => 1, - 'success_count' => 1, - 'error_count' => 0, - 'total_response_time_ms' => 100, - 'total_request_size' => 0, - 'total_response_size' => 0, - ] - ); - - // Summary for last 24 hours should not include old data - $summary = $this->service->getWorkspaceSummary( - $this->workspace->id, - now()->subDay(), - now() - ); - - expect($summary['totals']['requests'])->toBe(13); // Only today's requests - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Charts and Reports -// ───────────────────────────────────────────────────────────────────────────── - -describe('Charts and Reports', function () { - beforeEach(function () { - // Create usage spread across days - for ($day = 0; $day < 7; $day++) { - $date = now()->subDays($day); - $requests = 10 - $day; - - for ($i = 0; $i < $requests; $i++) { - $usage = ApiUsage::record( - $this->apiKey->id, - $this->workspace->id, - '/api/v1/test', - 'GET', - 200, - 100 - ); - $usage->update(['created_at' => $date]); - - ApiUsageDaily::recordFromUsage($usage); - } - } - }); - - it('returns daily chart data', function () { - $chart = $this->service->getDailyChart($this->workspace->id); - - expect($chart)->toBeArray(); - expect(count($chart))->toBeGreaterThan(0); - expect($chart[0])->toHaveKeys(['date', 'requests', 'success', 'errors', 'avg_response_time_ms']); - }); - - it('returns top endpoints', function () { - // Add some variety - $this->service->record( - $this->apiKey->id, - $this->workspace->id, - '/api/v1/popular', - 'GET', - 200, - 100 - ); - - $endpoints = $this->service->getTopEndpoints($this->workspace->id, 5); - - expect($endpoints)->toBeArray(); - expect($endpoints[0])->toHaveKeys(['endpoint', 'method', 'requests', 'success_rate', 'avg_response_time_ms']); - }); - - it('returns error breakdown', function () { - // Add some errors - $this->service->record($this->apiKey->id, $this->workspace->id, '/api/v1/test', 'GET', 401, 50); - $this->service->record($this->apiKey->id, $this->workspace->id, '/api/v1/test', 'GET', 404, 50); - $this->service->record($this->apiKey->id, $this->workspace->id, '/api/v1/test', 'GET', 500, 50); - - $errors = $this->service->getErrorBreakdown($this->workspace->id); - - expect($errors)->toBeArray(); - expect(count($errors))->toBe(3); - expect($errors[0])->toHaveKeys(['status_code', 'count', 'description']); - }); - - it('returns key comparison', function () { - // Create another key with usage - $key2 = ApiKey::generate($this->workspace->id, $this->user->id, 'Second Key'); - $this->service->record($key2['api_key']->id, $this->workspace->id, '/api/v1/test', 'GET', 200, 100); - - $comparison = $this->service->getKeyComparison($this->workspace->id); - - expect($comparison)->toBeArray(); - expect(count($comparison))->toBe(2); - expect($comparison[0])->toHaveKeys(['api_key_id', 'api_key_name', 'requests', 'success_rate']); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Data Retention -// ───────────────────────────────────────────────────────────────────────────── - -describe('Data Retention', function () { - it('prunes old detailed records', function () { - // Create old records - for ($i = 0; $i < 5; $i++) { - $usage = ApiUsage::record( - $this->apiKey->id, - $this->workspace->id, - '/api/v1/old', - 'GET', - 200, - 100 - ); - $usage->update(['created_at' => now()->subDays(60)]); - } - - // Create recent records - for ($i = 0; $i < 3; $i++) { - ApiUsage::record( - $this->apiKey->id, - $this->workspace->id, - '/api/v1/recent', - 'GET', - 200, - 100 - ); - } - - $deleted = $this->service->pruneOldRecords(30); - - expect($deleted)->toBe(5); - expect(ApiUsage::count())->toBe(3); - }); - - it('keeps daily aggregates when pruning detailed records', function () { - // Create and aggregate old record - $usage = ApiUsage::record( - $this->apiKey->id, - $this->workspace->id, - '/api/v1/old', - 'GET', - 200, - 100 - ); - $usage->update(['created_at' => now()->subDays(60)]); - ApiUsageDaily::recordFromUsage($usage); - - $dailyCountBefore = ApiUsageDaily::count(); - - $this->service->pruneOldRecords(30); - - // Daily aggregates should remain - expect(ApiUsageDaily::count())->toBe($dailyCountBefore); - }); -}); diff --git a/packages/core-api/src/Mod/Api/Tests/Feature/OpenApiDocumentationTest.php b/packages/core-api/src/Mod/Api/Tests/Feature/OpenApiDocumentationTest.php deleted file mode 100644 index b8f31d9..0000000 --- a/packages/core-api/src/Mod/Api/Tests/Feature/OpenApiDocumentationTest.php +++ /dev/null @@ -1,120 +0,0 @@ -assertInstanceOf(OpenApiBuilder::class, $builder); - } - - public function test_extensions_implement_interface(): void - { - $this->assertInstanceOf(Extension::class, new WorkspaceHeaderExtension); - $this->assertInstanceOf(Extension::class, new RateLimitExtension); - $this->assertInstanceOf(Extension::class, new ApiKeyAuthExtension); - } - - public function test_api_tag_attribute(): void - { - $tag = new ApiTag('Users', 'User management'); - - $this->assertEquals('Users', $tag->name); - $this->assertEquals('User management', $tag->description); - } - - public function test_api_response_attribute(): void - { - $response = new ApiResponse(200, null, 'Success'); - - $this->assertEquals(200, $response->status); - $this->assertEquals('Success', $response->getDescription()); - $this->assertFalse($response->paginated); - } - - public function test_api_response_generates_description_from_status(): void - { - $response = new ApiResponse(404); - - $this->assertEquals('Not found', $response->getDescription()); - } - - public function test_api_security_attribute(): void - { - $security = new ApiSecurity('apiKey', ['read', 'write']); - - $this->assertEquals('apiKey', $security->scheme); - $this->assertEquals(['read', 'write'], $security->scopes); - $this->assertFalse($security->isPublic()); - } - - public function test_api_security_public(): void - { - $security = new ApiSecurity(null); - - $this->assertTrue($security->isPublic()); - } - - public function test_api_parameter_attribute(): void - { - $param = new ApiParameter( - name: 'page', - in: 'query', - type: 'integer', - description: 'Page number', - required: false, - example: 1 - ); - - $this->assertEquals('page', $param->name); - $this->assertEquals('query', $param->in); - $this->assertEquals('integer', $param->type); - $this->assertEquals(1, $param->example); - } - - public function test_api_parameter_to_openapi(): void - { - $param = new ApiParameter( - name: 'page', - in: 'query', - type: 'integer', - description: 'Page number', - required: false, - example: 1 - ); - - $openApi = $param->toOpenApi(); - - $this->assertEquals('page', $openApi['name']); - $this->assertEquals('query', $openApi['in']); - $this->assertFalse($openApi['required']); - $this->assertEquals('integer', $openApi['schema']['type']); - } - - public function test_api_hidden_attribute(): void - { - $hidden = new ApiHidden('Internal only'); - - $this->assertEquals('Internal only', $hidden->reason); - } -} diff --git a/packages/core-api/src/Mod/Api/Tests/Feature/RateLimitTest.php b/packages/core-api/src/Mod/Api/Tests/Feature/RateLimitTest.php deleted file mode 100644 index b6a3300..0000000 --- a/packages/core-api/src/Mod/Api/Tests/Feature/RateLimitTest.php +++ /dev/null @@ -1,532 +0,0 @@ -rateLimitService = new RateLimitService($this->app->make(CacheRepository::class)); - } - - protected function tearDown(): void - { - Carbon::setTestNow(); - parent::tearDown(); - } - - // ───────────────────────────────────────────────────────────────────────── - // RateLimitResult DTO Tests - // ───────────────────────────────────────────────────────────────────────── - - public function test_rate_limit_result_creates_allowed_result(): void - { - $resetsAt = Carbon::now()->addMinute(); - $result = RateLimitResult::allowed(100, 99, $resetsAt); - - $this->assertTrue($result->allowed); - $this->assertSame(100, $result->limit); - $this->assertSame(99, $result->remaining); - $this->assertSame(0, $result->retryAfter); - $this->assertSame($resetsAt->timestamp, $result->resetsAt->timestamp); - } - - public function test_rate_limit_result_creates_denied_result(): void - { - $resetsAt = Carbon::now()->addMinute(); - $result = RateLimitResult::denied(100, 30, $resetsAt); - - $this->assertFalse($result->allowed); - $this->assertSame(100, $result->limit); - $this->assertSame(0, $result->remaining); - $this->assertSame(30, $result->retryAfter); - $this->assertSame($resetsAt->timestamp, $result->resetsAt->timestamp); - } - - public function test_rate_limit_result_generates_correct_headers_for_allowed(): void - { - $resetsAt = Carbon::now()->addMinute(); - $result = RateLimitResult::allowed(100, 99, $resetsAt); - - $headers = $result->headers(); - - $this->assertArrayHasKey('X-RateLimit-Limit', $headers); - $this->assertArrayHasKey('X-RateLimit-Remaining', $headers); - $this->assertArrayHasKey('X-RateLimit-Reset', $headers); - $this->assertSame(100, $headers['X-RateLimit-Limit']); - $this->assertSame(99, $headers['X-RateLimit-Remaining']); - $this->assertSame($resetsAt->timestamp, $headers['X-RateLimit-Reset']); - $this->assertArrayNotHasKey('Retry-After', $headers); - } - - public function test_rate_limit_result_generates_correct_headers_for_denied(): void - { - $resetsAt = Carbon::now()->addMinute(); - $result = RateLimitResult::denied(100, 30, $resetsAt); - - $headers = $result->headers(); - - $this->assertArrayHasKey('X-RateLimit-Limit', $headers); - $this->assertArrayHasKey('X-RateLimit-Remaining', $headers); - $this->assertArrayHasKey('X-RateLimit-Reset', $headers); - $this->assertArrayHasKey('Retry-After', $headers); - $this->assertSame(100, $headers['X-RateLimit-Limit']); - $this->assertSame(0, $headers['X-RateLimit-Remaining']); - $this->assertSame(30, $headers['Retry-After']); - } - - // ───────────────────────────────────────────────────────────────────────── - // RateLimitService - Basic Rate Limiting Tests - // ───────────────────────────────────────────────────────────────────────── - - public function test_service_allows_requests_under_the_limit(): void - { - $result = $this->rateLimitService->hit('test-key', 10, 60); - - $this->assertTrue($result->allowed); - $this->assertSame(9, $result->remaining); - $this->assertSame(10, $result->limit); - } - - public function test_service_tracks_requests_correctly(): void - { - // Make 5 requests - for ($i = 0; $i < 5; $i++) { - $result = $this->rateLimitService->hit('test-key', 10, 60); - } - - $this->assertTrue($result->allowed); - $this->assertSame(5, $result->remaining); - } - - public function test_service_blocks_requests_when_limit_exceeded(): void - { - // Make 10 requests (at limit) - for ($i = 0; $i < 10; $i++) { - $this->rateLimitService->hit('test-key', 10, 60); - } - - // 11th request should be blocked - $result = $this->rateLimitService->hit('test-key', 10, 60); - - $this->assertFalse($result->allowed); - $this->assertSame(0, $result->remaining); - $this->assertGreaterThan(0, $result->retryAfter); - } - - public function test_check_method_does_not_increment_counter(): void - { - // Hit once - $this->rateLimitService->hit('test-key', 10, 60); - - // Check multiple times (should not count) - $this->rateLimitService->check('test-key', 10, 60); - $this->rateLimitService->check('test-key', 10, 60); - $this->rateLimitService->check('test-key', 10, 60); - - // Verify only 1 hit was recorded - $this->assertSame(9, $this->rateLimitService->remaining('test-key', 10, 60)); - } - - public function test_service_resets_correctly(): void - { - // Make some requests - for ($i = 0; $i < 5; $i++) { - $this->rateLimitService->hit('test-key', 10, 60); - } - - $this->assertSame(5, $this->rateLimitService->remaining('test-key', 10, 60)); - - // Reset - $this->rateLimitService->reset('test-key'); - - $this->assertSame(10, $this->rateLimitService->remaining('test-key', 10, 60)); - } - - public function test_service_returns_correct_attempts_count(): void - { - $this->assertSame(0, $this->rateLimitService->attempts('test-key', 60)); - - $this->rateLimitService->hit('test-key', 10, 60); - $this->rateLimitService->hit('test-key', 10, 60); - $this->rateLimitService->hit('test-key', 10, 60); - - $this->assertSame(3, $this->rateLimitService->attempts('test-key', 60)); - } - - // ───────────────────────────────────────────────────────────────────────── - // RateLimitService - Sliding Window Algorithm Tests - // ───────────────────────────────────────────────────────────────────────── - - public function test_sliding_window_expires_old_requests(): void - { - // Make 5 requests now - for ($i = 0; $i < 5; $i++) { - $this->rateLimitService->hit('test-key', 10, 60); - } - - $this->assertSame(5, $this->rateLimitService->remaining('test-key', 10, 60)); - - // Move time forward 61 seconds (past the window) - Carbon::setTestNow(Carbon::now()->addSeconds(61)); - - // Old requests should have expired - $this->assertSame(10, $this->rateLimitService->remaining('test-key', 10, 60)); - } - - public function test_sliding_window_maintains_requests_within_window(): void - { - // Make 5 requests now - for ($i = 0; $i < 5; $i++) { - $this->rateLimitService->hit('test-key', 10, 60); - } - - // Move time forward 30 seconds (still within window) - Carbon::setTestNow(Carbon::now()->addSeconds(30)); - - // Requests should still count - $this->assertSame(5, $this->rateLimitService->remaining('test-key', 10, 60)); - - // Make 3 more requests - for ($i = 0; $i < 3; $i++) { - $this->rateLimitService->hit('test-key', 10, 60); - } - - $this->assertSame(2, $this->rateLimitService->remaining('test-key', 10, 60)); - } - - // ───────────────────────────────────────────────────────────────────────── - // RateLimitService - Burst Allowance Tests - // ───────────────────────────────────────────────────────────────────────── - - public function test_burst_allows_when_configured(): void - { - // With 20% burst, limit of 10 becomes effective limit of 12 - for ($i = 0; $i < 12; $i++) { - $result = $this->rateLimitService->hit('test-key', 10, 60, 1.2); - $this->assertTrue($result->allowed); - } - - // 13th request should be blocked - $result = $this->rateLimitService->hit('test-key', 10, 60, 1.2); - $this->assertFalse($result->allowed); - } - - public function test_burst_reports_base_limit_not_burst_limit(): void - { - $result = $this->rateLimitService->hit('test-key', 10, 60, 1.5); - - // Limit shown should be the base limit (10), not the burst limit (15) - $this->assertSame(10, $result->limit); - } - - public function test_burst_calculates_remaining_based_on_burst_limit(): void - { - // With 50% burst, limit of 10 becomes effective limit of 15 - $result = $this->rateLimitService->hit('test-key', 10, 60, 1.5); - - // After 1 hit, remaining should be 14 (15 - 1) - $this->assertSame(14, $result->remaining); - } - - public function test_burst_works_without_burst(): void - { - for ($i = 0; $i < 10; $i++) { - $result = $this->rateLimitService->hit('test-key', 10, 60, 1.0); - $this->assertTrue($result->allowed); - } - - $result = $this->rateLimitService->hit('test-key', 10, 60, 1.0); - $this->assertFalse($result->allowed); - } - - // ───────────────────────────────────────────────────────────────────────── - // RateLimitService - Key Builders Tests - // ───────────────────────────────────────────────────────────────────────── - - public function test_builds_endpoint_keys_correctly(): void - { - $key = $this->rateLimitService->buildEndpointKey('api_key:123', 'users.index'); - $this->assertSame('endpoint:api_key:123:users.index', $key); - } - - public function test_builds_workspace_keys_correctly(): void - { - $key = $this->rateLimitService->buildWorkspaceKey(456); - $this->assertSame('workspace:456', $key); - - $keyWithSuffix = $this->rateLimitService->buildWorkspaceKey(456, 'users.index'); - $this->assertSame('workspace:456:users.index', $keyWithSuffix); - } - - public function test_builds_api_key_keys_correctly(): void - { - $key = $this->rateLimitService->buildApiKeyKey(789); - $this->assertSame('api_key:789', $key); - - $keyWithSuffix = $this->rateLimitService->buildApiKeyKey(789, 'users.index'); - $this->assertSame('api_key:789:users.index', $keyWithSuffix); - } - - public function test_builds_ip_keys_correctly(): void - { - $key = $this->rateLimitService->buildIpKey('192.168.1.1'); - $this->assertSame('ip:192.168.1.1', $key); - - $keyWithSuffix = $this->rateLimitService->buildIpKey('192.168.1.1', 'users.index'); - $this->assertSame('ip:192.168.1.1:users.index', $keyWithSuffix); - } - - // ───────────────────────────────────────────────────────────────────────── - // RateLimit Attribute Tests - // ───────────────────────────────────────────────────────────────────────── - - public function test_attribute_instantiates_with_required_parameters(): void - { - $attribute = new RateLimit(limit: 100); - - $this->assertSame(100, $attribute->limit); - $this->assertSame(60, $attribute->window); // default - $this->assertSame(1.0, $attribute->burst); // default - $this->assertNull($attribute->key); // default - } - - public function test_attribute_instantiates_with_all_parameters(): void - { - $attribute = new RateLimit( - limit: 200, - window: 120, - burst: 1.5, - key: 'custom-key' - ); - - $this->assertSame(200, $attribute->limit); - $this->assertSame(120, $attribute->window); - $this->assertSame(1.5, $attribute->burst); - $this->assertSame('custom-key', $attribute->key); - } - - // ───────────────────────────────────────────────────────────────────────── - // RateLimitExceededException Tests - // ───────────────────────────────────────────────────────────────────────── - - public function test_exception_creates_with_rate_limit_result(): void - { - $resetsAt = Carbon::now()->addMinute(); - $result = RateLimitResult::denied(100, 30, $resetsAt); - $exception = new RateLimitExceededException($result); - - $this->assertSame(429, $exception->getStatusCode()); - $this->assertSame($result, $exception->getRateLimitResult()); - } - - public function test_exception_renders_as_json_response(): void - { - $resetsAt = Carbon::now()->addMinute(); - $result = RateLimitResult::denied(100, 30, $resetsAt); - $exception = new RateLimitExceededException($result); - - $response = $exception->render(); - - $this->assertSame(429, $response->getStatusCode()); - - $content = json_decode($response->getContent(), true); - $this->assertSame('rate_limit_exceeded', $content['error']); - $this->assertSame(30, $content['retry_after']); - $this->assertSame(100, $content['limit']); - } - - public function test_exception_includes_rate_limit_headers_in_response(): void - { - $resetsAt = Carbon::now()->addMinute(); - $result = RateLimitResult::denied(100, 30, $resetsAt); - $exception = new RateLimitExceededException($result); - - $response = $exception->render(); - - $this->assertSame('100', $response->headers->get('X-RateLimit-Limit')); - $this->assertSame('0', $response->headers->get('X-RateLimit-Remaining')); - $this->assertSame('30', $response->headers->get('Retry-After')); - } - - public function test_exception_allows_custom_message(): void - { - $resetsAt = Carbon::now()->addMinute(); - $result = RateLimitResult::denied(100, 30, $resetsAt); - $exception = new RateLimitExceededException($result, 'Custom rate limit message'); - - $response = $exception->render(); - $content = json_decode($response->getContent(), true); - - $this->assertSame('Custom rate limit message', $content['message']); - } - - // ───────────────────────────────────────────────────────────────────────── - // Per-Workspace Rate Limiting Tests - // ───────────────────────────────────────────────────────────────────────── - - public function test_isolates_rate_limits_by_workspace(): void - { - // Create two different workspace keys - $key1 = $this->rateLimitService->buildWorkspaceKey(1, 'endpoint'); - $key2 = $this->rateLimitService->buildWorkspaceKey(2, 'endpoint'); - - // Hit rate limit for workspace 1 - for ($i = 0; $i < 10; $i++) { - $this->rateLimitService->hit($key1, 10, 60); - } - - // Workspace 1 should be blocked - $result1 = $this->rateLimitService->hit($key1, 10, 60); - $this->assertFalse($result1->allowed); - - // Workspace 2 should still be allowed - $result2 = $this->rateLimitService->hit($key2, 10, 60); - $this->assertTrue($result2->allowed); - } - - // ───────────────────────────────────────────────────────────────────────── - // Rate Limit Configuration Tests - // ───────────────────────────────────────────────────────────────────────── - - public function test_config_has_enabled_flag(): void - { - Config::set('api.rate_limits.enabled', true); - $this->assertTrue(config('api.rate_limits.enabled')); - } - - public function test_config_has_default_limits(): void - { - Config::set('api.rate_limits.default', [ - 'limit' => 60, - 'window' => 60, - 'burst' => 1.0, - ]); - - $default = config('api.rate_limits.default'); - - $this->assertArrayHasKey('limit', $default); - $this->assertArrayHasKey('window', $default); - $this->assertArrayHasKey('burst', $default); - } - - public function test_config_has_authenticated_limits(): void - { - Config::set('api.rate_limits.authenticated', [ - 'limit' => 1000, - 'window' => 60, - 'burst' => 1.2, - ]); - - $authenticated = config('api.rate_limits.authenticated'); - - $this->assertArrayHasKey('limit', $authenticated); - $this->assertSame(1000, $authenticated['limit']); - } - - public function test_config_has_per_workspace_flag(): void - { - Config::set('api.rate_limits.per_workspace', true); - $this->assertTrue(config('api.rate_limits.per_workspace')); - } - - public function test_config_has_endpoints_configuration(): void - { - Config::set('api.rate_limits.endpoints', []); - $this->assertIsArray(config('api.rate_limits.endpoints')); - } - - public function test_config_has_tier_based_limits(): void - { - Config::set('api.rate_limits.tiers', [ - 'free' => ['limit' => 60, 'window' => 60, 'burst' => 1.0], - 'starter' => ['limit' => 1000, 'window' => 60, 'burst' => 1.2], - 'pro' => ['limit' => 5000, 'window' => 60, 'burst' => 1.3], - 'agency' => ['limit' => 20000, 'window' => 60, 'burst' => 1.5], - 'enterprise' => ['limit' => 100000, 'window' => 60, 'burst' => 2.0], - ]); - - $tiers = config('api.rate_limits.tiers'); - - $this->assertArrayHasKey('free', $tiers); - $this->assertArrayHasKey('starter', $tiers); - $this->assertArrayHasKey('pro', $tiers); - $this->assertArrayHasKey('agency', $tiers); - $this->assertArrayHasKey('enterprise', $tiers); - - foreach ($tiers as $tier => $tierConfig) { - $this->assertArrayHasKey('limit', $tierConfig); - $this->assertArrayHasKey('window', $tierConfig); - $this->assertArrayHasKey('burst', $tierConfig); - } - } - - public function test_tier_limits_increase_with_tier_level(): void - { - Config::set('api.rate_limits.tiers', [ - 'free' => ['limit' => 60, 'window' => 60, 'burst' => 1.0], - 'starter' => ['limit' => 1000, 'window' => 60, 'burst' => 1.2], - 'pro' => ['limit' => 5000, 'window' => 60, 'burst' => 1.3], - 'agency' => ['limit' => 20000, 'window' => 60, 'burst' => 1.5], - 'enterprise' => ['limit' => 100000, 'window' => 60, 'burst' => 2.0], - ]); - - $tiers = config('api.rate_limits.tiers'); - - $this->assertGreaterThan($tiers['free']['limit'], $tiers['starter']['limit']); - $this->assertGreaterThan($tiers['starter']['limit'], $tiers['pro']['limit']); - $this->assertGreaterThan($tiers['pro']['limit'], $tiers['agency']['limit']); - $this->assertGreaterThan($tiers['agency']['limit'], $tiers['enterprise']['limit']); - } - - public function test_higher_tiers_have_higher_burst_allowance(): void - { - Config::set('api.rate_limits.tiers', [ - 'free' => ['limit' => 60, 'window' => 60, 'burst' => 1.0], - 'pro' => ['limit' => 5000, 'window' => 60, 'burst' => 1.3], - 'agency' => ['limit' => 20000, 'window' => 60, 'burst' => 1.5], - 'enterprise' => ['limit' => 100000, 'window' => 60, 'burst' => 2.0], - ]); - - $tiers = config('api.rate_limits.tiers'); - - $this->assertGreaterThanOrEqual($tiers['pro']['burst'], $tiers['agency']['burst']); - $this->assertGreaterThanOrEqual($tiers['agency']['burst'], $tiers['enterprise']['burst']); - } -} diff --git a/packages/core-api/src/Mod/Api/Tests/Feature/WebhookDeliveryTest.php b/packages/core-api/src/Mod/Api/Tests/Feature/WebhookDeliveryTest.php deleted file mode 100644 index 3ee6c02..0000000 --- a/packages/core-api/src/Mod/Api/Tests/Feature/WebhookDeliveryTest.php +++ /dev/null @@ -1,770 +0,0 @@ -workspace = Workspace::factory()->create(); - $this->service = app(WebhookService::class); - $this->signatureService = app(WebhookSignature::class); -}); - -// ----------------------------------------------------------------------------- -// Webhook Signature Service -// ----------------------------------------------------------------------------- - -describe('Webhook Signature Service', function () { - it('generates a 64-character secret', function () { - $secret = $this->signatureService->generateSecret(); - - expect($secret)->toBeString(); - expect(strlen($secret))->toBe(64); - }); - - it('generates unique secrets', function () { - $secrets = []; - for ($i = 0; $i < 100; $i++) { - $secrets[] = $this->signatureService->generateSecret(); - } - - expect(array_unique($secrets))->toHaveCount(100); - }); - - it('signs payload with timestamp', function () { - $payload = '{"event":"test"}'; - $secret = 'test_secret_key'; - $timestamp = 1704067200; // Fixed timestamp for testing - - $signature = $this->signatureService->sign($payload, $secret, $timestamp); - - // Verify it's a 64-character hex string (SHA256) - expect($signature)->toBeString(); - expect(strlen($signature))->toBe(64); - expect(ctype_xdigit($signature))->toBeTrue(); - - // Verify signature is deterministic - $signature2 = $this->signatureService->sign($payload, $secret, $timestamp); - expect($signature)->toBe($signature2); - }); - - it('produces different signatures for different payloads', function () { - $secret = 'test_secret_key'; - $timestamp = 1704067200; - - $sig1 = $this->signatureService->sign('{"a":1}', $secret, $timestamp); - $sig2 = $this->signatureService->sign('{"a":2}', $secret, $timestamp); - - expect($sig1)->not->toBe($sig2); - }); - - it('produces different signatures for different timestamps', function () { - $payload = '{"event":"test"}'; - $secret = 'test_secret_key'; - - $sig1 = $this->signatureService->sign($payload, $secret, 1704067200); - $sig2 = $this->signatureService->sign($payload, $secret, 1704067201); - - expect($sig1)->not->toBe($sig2); - }); - - it('produces different signatures for different secrets', function () { - $payload = '{"event":"test"}'; - $timestamp = 1704067200; - - $sig1 = $this->signatureService->sign($payload, 'secret1', $timestamp); - $sig2 = $this->signatureService->sign($payload, 'secret2', $timestamp); - - expect($sig1)->not->toBe($sig2); - }); - - it('verifies valid signature', function () { - $payload = '{"event":"test","data":{"id":123}}'; - $secret = 'webhook_secret_abc123'; - $timestamp = time(); - - $signature = $this->signatureService->sign($payload, $secret, $timestamp); - - $isValid = $this->signatureService->verify( - $payload, - $signature, - $secret, - $timestamp - ); - - expect($isValid)->toBeTrue(); - }); - - it('rejects invalid signature', function () { - $payload = '{"event":"test"}'; - $secret = 'webhook_secret_abc123'; - $timestamp = time(); - - $isValid = $this->signatureService->verify( - $payload, - 'invalid_signature_abc123', - $secret, - $timestamp - ); - - expect($isValid)->toBeFalse(); - }); - - it('rejects tampered payload', function () { - $secret = 'webhook_secret_abc123'; - $timestamp = time(); - - // Sign original payload - $signature = $this->signatureService->sign('{"event":"test"}', $secret, $timestamp); - - // Verify with tampered payload - $isValid = $this->signatureService->verify( - '{"event":"test","hacked":true}', - $signature, - $secret, - $timestamp - ); - - expect($isValid)->toBeFalse(); - }); - - it('rejects tampered timestamp', function () { - $payload = '{"event":"test"}'; - $secret = 'webhook_secret_abc123'; - $originalTimestamp = time(); - - // Sign with original timestamp - $signature = $this->signatureService->sign($payload, $secret, $originalTimestamp); - - // Verify with different timestamp (simulating replay attack) - $isValid = $this->signatureService->verifySignatureOnly( - $payload, - $signature, - $secret, - $originalTimestamp + 1 - ); - - expect($isValid)->toBeFalse(); - }); - - it('rejects expired timestamp', function () { - $payload = '{"event":"test"}'; - $secret = 'webhook_secret_abc123'; - $oldTimestamp = time() - 600; // 10 minutes ago - - $signature = $this->signatureService->sign($payload, $secret, $oldTimestamp); - - // Default tolerance is 5 minutes - $isValid = $this->signatureService->verify( - $payload, - $signature, - $secret, - $oldTimestamp - ); - - expect($isValid)->toBeFalse(); - }); - - it('accepts timestamp within tolerance', function () { - $payload = '{"event":"test"}'; - $secret = 'webhook_secret_abc123'; - $recentTimestamp = time() - 60; // 1 minute ago - - $signature = $this->signatureService->sign($payload, $secret, $recentTimestamp); - - $isValid = $this->signatureService->verify( - $payload, - $signature, - $secret, - $recentTimestamp - ); - - expect($isValid)->toBeTrue(); - }); - - it('allows custom tolerance', function () { - $payload = '{"event":"test"}'; - $secret = 'webhook_secret_abc123'; - $oldTimestamp = time() - 600; // 10 minutes ago - - $signature = $this->signatureService->sign($payload, $secret, $oldTimestamp); - - // Verify with 15-minute tolerance - $isValid = $this->signatureService->verify( - $payload, - $signature, - $secret, - $oldTimestamp, - tolerance: 900 - ); - - expect($isValid)->toBeTrue(); - }); - - it('checks timestamp validity correctly', function () { - $now = time(); - - // Within tolerance - expect($this->signatureService->isTimestampValid($now))->toBeTrue(); - expect($this->signatureService->isTimestampValid($now - 60))->toBeTrue(); - expect($this->signatureService->isTimestampValid($now - 299))->toBeTrue(); - - // Outside tolerance - expect($this->signatureService->isTimestampValid($now - 301))->toBeFalse(); - expect($this->signatureService->isTimestampValid($now - 600))->toBeFalse(); - - // Future timestamp within tolerance - expect($this->signatureService->isTimestampValid($now + 60))->toBeTrue(); - - // Future timestamp outside tolerance - expect($this->signatureService->isTimestampValid($now + 400))->toBeFalse(); - }); - - it('returns correct headers', function () { - $payload = '{"event":"test"}'; - $secret = 'webhook_secret_abc123'; - $timestamp = 1704067200; - - $headers = $this->signatureService->getHeaders($payload, $secret, $timestamp); - - expect($headers)->toHaveKey('X-Webhook-Signature'); - expect($headers)->toHaveKey('X-Webhook-Timestamp'); - expect($headers['X-Webhook-Timestamp'])->toBe($timestamp); - expect($headers['X-Webhook-Signature'])->toBe( - $this->signatureService->sign($payload, $secret, $timestamp) - ); - }); -}); - -// ----------------------------------------------------------------------------- -// Webhook Endpoint Signing -// ----------------------------------------------------------------------------- - -describe('Webhook Endpoint Signing', function () { - it('generates signature for payload with timestamp', function () { - $endpoint = WebhookEndpoint::createForWorkspace( - $this->workspace->id, - 'https://example.com/webhook', - ['bio.created'] - ); - - $payload = '{"event":"test"}'; - $timestamp = time(); - - $signature = $endpoint->generateSignature($payload, $timestamp); - - expect($signature)->toBeString(); - expect(strlen($signature))->toBe(64); - }); - - it('verifies valid signature', function () { - $endpoint = WebhookEndpoint::createForWorkspace( - $this->workspace->id, - 'https://example.com/webhook', - ['bio.created'] - ); - - $payload = '{"event":"test","data":{"id":123}}'; - $timestamp = time(); - - $signature = $endpoint->generateSignature($payload, $timestamp); - - $isValid = $endpoint->verifySignature($payload, $signature, $timestamp); - - expect($isValid)->toBeTrue(); - }); - - it('rejects invalid signature', function () { - $endpoint = WebhookEndpoint::createForWorkspace( - $this->workspace->id, - 'https://example.com/webhook', - ['bio.created'] - ); - - $isValid = $endpoint->verifySignature( - '{"event":"test"}', - 'invalid_signature', - time() - ); - - expect($isValid)->toBeFalse(); - }); - - it('rotates secret and invalidates old signatures', function () { - $endpoint = WebhookEndpoint::createForWorkspace( - $this->workspace->id, - 'https://example.com/webhook', - ['bio.created'] - ); - - $payload = '{"event":"test"}'; - $timestamp = time(); - - // Sign with original secret - $originalSignature = $endpoint->generateSignature($payload, $timestamp); - - // Rotate secret - $newSecret = $endpoint->rotateSecret(); - $endpoint->refresh(); - - // Old signature should be invalid - $isValid = $endpoint->verifySignature($payload, $originalSignature, $timestamp); - expect($isValid)->toBeFalse(); - - // New signature should be valid - $newSignature = $endpoint->generateSignature($payload, $timestamp); - $isValid = $endpoint->verifySignature($payload, $newSignature, $timestamp); - expect($isValid)->toBeTrue(); - - // New secret should be 64 characters - expect(strlen($newSecret))->toBe(64); - }); -}); - -// ----------------------------------------------------------------------------- -// Webhook Service -// ----------------------------------------------------------------------------- - -describe('Webhook Service', function () { - it('dispatches event to subscribed endpoints', function () { - $endpoint = WebhookEndpoint::createForWorkspace( - $this->workspace->id, - 'https://example.com/webhook', - ['bio.created'] - ); - - $deliveries = $this->service->dispatch( - $this->workspace->id, - 'bio.created', - ['bio_id' => 123, 'name' => 'Test Bio'] - ); - - expect($deliveries)->toHaveCount(1); - expect($deliveries[0]->event_type)->toBe('bio.created'); - expect($deliveries[0]->webhook_endpoint_id)->toBe($endpoint->id); - expect($deliveries[0]->status)->toBe(WebhookDelivery::STATUS_PENDING); - }); - - it('does not dispatch to endpoints not subscribed to event', function () { - WebhookEndpoint::createForWorkspace( - $this->workspace->id, - 'https://example.com/webhook', - ['bio.updated'] // Different event - ); - - $deliveries = $this->service->dispatch( - $this->workspace->id, - 'bio.created', - ['bio_id' => 123] - ); - - expect($deliveries)->toBeEmpty(); - }); - - it('dispatches to wildcard subscribed endpoints', function () { - $endpoint = WebhookEndpoint::createForWorkspace( - $this->workspace->id, - 'https://example.com/webhook', - ['*'] // Subscribe to all events - ); - - $deliveries = $this->service->dispatch( - $this->workspace->id, - 'any.event.type', - ['data' => 'test'] - ); - - expect($deliveries)->toHaveCount(1); - }); - - it('does not dispatch to inactive endpoints', function () { - $endpoint = WebhookEndpoint::createForWorkspace( - $this->workspace->id, - 'https://example.com/webhook', - ['bio.created'] - ); - $endpoint->update(['active' => false]); - - $deliveries = $this->service->dispatch( - $this->workspace->id, - 'bio.created', - ['bio_id' => 123] - ); - - expect($deliveries)->toBeEmpty(); - }); - - it('does not dispatch to disabled endpoints', function () { - $endpoint = WebhookEndpoint::createForWorkspace( - $this->workspace->id, - 'https://example.com/webhook', - ['bio.created'] - ); - $endpoint->update(['disabled_at' => now()]); - - $deliveries = $this->service->dispatch( - $this->workspace->id, - 'bio.created', - ['bio_id' => 123] - ); - - expect($deliveries)->toBeEmpty(); - }); - - it('returns webhook stats for workspace', function () { - $endpoint = WebhookEndpoint::createForWorkspace( - $this->workspace->id, - 'https://example.com/webhook', - ['bio.created'] - ); - - // Create some deliveries - WebhookDelivery::createForEvent($endpoint, 'bio.created', ['id' => 1]); - $delivery2 = WebhookDelivery::createForEvent($endpoint, 'bio.created', ['id' => 2]); - $delivery2->markSuccess(200); - $delivery3 = WebhookDelivery::createForEvent($endpoint, 'bio.created', ['id' => 3]); - $delivery3->markFailed(500, 'Server Error'); - - $stats = $this->service->getStats($this->workspace->id); - - expect($stats['total'])->toBe(3); - expect($stats['pending'])->toBe(1); - expect($stats['success'])->toBe(1); - expect($stats['retrying'])->toBe(1); // Failed with retries remaining - }); -}); - -// ----------------------------------------------------------------------------- -// Webhook Delivery Job -// ----------------------------------------------------------------------------- - -describe('Webhook Delivery Job', function () { - it('marks delivery as success on 2xx response', function () { - Http::fake([ - 'example.com/*' => Http::response(['received' => true], 200), - ]); - - $endpoint = WebhookEndpoint::createForWorkspace( - $this->workspace->id, - 'https://example.com/webhook', - ['bio.created'] - ); - - $delivery = WebhookDelivery::createForEvent( - $endpoint, - 'bio.created', - ['bio_id' => 123] - ); - - $job = new DeliverWebhookJob($delivery); - $job->handle(); - - $delivery->refresh(); - expect($delivery->status)->toBe(WebhookDelivery::STATUS_SUCCESS); - expect($delivery->response_code)->toBe(200); - expect($delivery->delivered_at)->not->toBeNull(); - }); - - it('marks delivery as retrying on 5xx response', function () { - Http::fake([ - 'example.com/*' => Http::response('Server Error', 500), - ]); - - $endpoint = WebhookEndpoint::createForWorkspace( - $this->workspace->id, - 'https://example.com/webhook', - ['bio.created'] - ); - - $delivery = WebhookDelivery::createForEvent( - $endpoint, - 'bio.created', - ['bio_id' => 123] - ); - - $job = new DeliverWebhookJob($delivery); - $job->handle(); - - $delivery->refresh(); - expect($delivery->status)->toBe(WebhookDelivery::STATUS_RETRYING); - expect($delivery->response_code)->toBe(500); - expect($delivery->attempt)->toBe(2); - expect($delivery->next_retry_at)->not->toBeNull(); - }); - - it('marks delivery as failed after max retries', function () { - Http::fake([ - 'example.com/*' => Http::response('Server Error', 500), - ]); - - $endpoint = WebhookEndpoint::createForWorkspace( - $this->workspace->id, - 'https://example.com/webhook', - ['bio.created'] - ); - - $delivery = WebhookDelivery::createForEvent( - $endpoint, - 'bio.created', - ['bio_id' => 123] - ); - $delivery->update(['attempt' => WebhookDelivery::MAX_RETRIES]); - - $job = new DeliverWebhookJob($delivery); - $job->handle(); - - $delivery->refresh(); - expect($delivery->status)->toBe(WebhookDelivery::STATUS_FAILED); - }); - - it('includes correct signature and timestamp headers', function () { - Http::fake(function ($request) { - // Verify all required headers exist - expect($request->hasHeader('X-Webhook-Signature'))->toBeTrue(); - expect($request->hasHeader('X-Webhook-Timestamp'))->toBeTrue(); - expect($request->hasHeader('X-Webhook-Event'))->toBeTrue(); - expect($request->hasHeader('X-Webhook-Id'))->toBeTrue(); - - // Verify timestamp is a valid Unix timestamp - $timestamp = $request->header('X-Webhook-Timestamp')[0]; - expect(is_numeric($timestamp))->toBeTrue(); - expect((int) $timestamp)->toBeGreaterThan(0); - - // Verify signature is a 64-character hex string - $signature = $request->header('X-Webhook-Signature')[0]; - expect(strlen($signature))->toBe(64); - expect(ctype_xdigit($signature))->toBeTrue(); - - return Http::response(['ok' => true], 200); - }); - - $endpoint = WebhookEndpoint::createForWorkspace( - $this->workspace->id, - 'https://example.com/webhook', - ['bio.created'] - ); - - $delivery = WebhookDelivery::createForEvent( - $endpoint, - 'bio.created', - ['bio_id' => 123] - ); - - $job = new DeliverWebhookJob($delivery); - $job->handle(); - - Http::assertSent(function ($request) { - return $request->url() === 'https://example.com/webhook'; - }); - }); - - it('sends verifiable signature', function () { - $capturedRequest = null; - - Http::fake(function ($request) use (&$capturedRequest) { - $capturedRequest = $request; - - return Http::response(['ok' => true], 200); - }); - - $endpoint = WebhookEndpoint::createForWorkspace( - $this->workspace->id, - 'https://example.com/webhook', - ['bio.created'] - ); - - $delivery = WebhookDelivery::createForEvent( - $endpoint, - 'bio.created', - ['bio_id' => 123] - ); - - $job = new DeliverWebhookJob($delivery); - $job->handle(); - - // Verify the signature can be verified by a recipient - $body = $capturedRequest->body(); - $signature = $capturedRequest->header('X-Webhook-Signature')[0]; - $timestamp = (int) $capturedRequest->header('X-Webhook-Timestamp')[0]; - - $isValid = $endpoint->verifySignature($body, $signature, $timestamp); - expect($isValid)->toBeTrue(); - }); - - it('skips delivery if endpoint becomes inactive', function () { - $endpoint = WebhookEndpoint::createForWorkspace( - $this->workspace->id, - 'https://example.com/webhook', - ['bio.created'] - ); - - $delivery = WebhookDelivery::createForEvent( - $endpoint, - 'bio.created', - ['bio_id' => 123] - ); - - // Deactivate endpoint after delivery created - $endpoint->update(['active' => false]); - - $job = new DeliverWebhookJob($delivery); - $job->handle(); - - // Should not have made any HTTP requests - Http::assertNothingSent(); - - // Delivery should remain pending (skipped) - $delivery->refresh(); - expect($delivery->status)->toBe(WebhookDelivery::STATUS_PENDING); - }); -}); - -// ----------------------------------------------------------------------------- -// Webhook Endpoint Auto-Disable -// ----------------------------------------------------------------------------- - -describe('Webhook Endpoint Auto-Disable', function () { - it('disables endpoint after consecutive failures', function () { - $endpoint = WebhookEndpoint::createForWorkspace( - $this->workspace->id, - 'https://example.com/webhook', - ['bio.created'] - ); - - // Simulate 10 consecutive failures - for ($i = 0; $i < 10; $i++) { - $endpoint->recordFailure(); - } - - $endpoint->refresh(); - expect($endpoint->active)->toBeFalse(); - expect($endpoint->disabled_at)->not->toBeNull(); - expect($endpoint->failure_count)->toBe(10); - }); - - it('resets failure count on success', function () { - $endpoint = WebhookEndpoint::createForWorkspace( - $this->workspace->id, - 'https://example.com/webhook', - ['bio.created'] - ); - - // Record some failures - $endpoint->recordFailure(); - $endpoint->recordFailure(); - $endpoint->recordFailure(); - expect($endpoint->fresh()->failure_count)->toBe(3); - - // Record success - $endpoint->recordSuccess(); - - $endpoint->refresh(); - expect($endpoint->failure_count)->toBe(0); - }); - - it('can be re-enabled after being disabled', function () { - $endpoint = WebhookEndpoint::createForWorkspace( - $this->workspace->id, - 'https://example.com/webhook', - ['bio.created'] - ); - - // Disable it - $endpoint->update([ - 'active' => false, - 'disabled_at' => now(), - 'failure_count' => 10, - ]); - - // Re-enable - $endpoint->enable(); - - $endpoint->refresh(); - expect($endpoint->active)->toBeTrue(); - expect($endpoint->disabled_at)->toBeNull(); - expect($endpoint->failure_count)->toBe(0); - }); -}); - -// ----------------------------------------------------------------------------- -// Delivery Payload Headers -// ----------------------------------------------------------------------------- - -describe('Delivery Payload Headers', function () { - it('includes all required headers', function () { - $endpoint = WebhookEndpoint::createForWorkspace( - $this->workspace->id, - 'https://example.com/webhook', - ['bio.created'] - ); - - $delivery = WebhookDelivery::createForEvent( - $endpoint, - 'bio.created', - ['bio_id' => 123] - ); - - $payload = $delivery->getDeliveryPayload(); - - expect($payload)->toHaveKey('headers'); - expect($payload)->toHaveKey('body'); - expect($payload['headers'])->toHaveKey('Content-Type'); - expect($payload['headers'])->toHaveKey('X-Webhook-Id'); - expect($payload['headers'])->toHaveKey('X-Webhook-Event'); - expect($payload['headers'])->toHaveKey('X-Webhook-Timestamp'); - expect($payload['headers'])->toHaveKey('X-Webhook-Signature'); - }); - - it('uses provided timestamp', function () { - $endpoint = WebhookEndpoint::createForWorkspace( - $this->workspace->id, - 'https://example.com/webhook', - ['bio.created'] - ); - - $delivery = WebhookDelivery::createForEvent( - $endpoint, - 'bio.created', - ['bio_id' => 123] - ); - - $fixedTimestamp = 1704067200; - $payload = $delivery->getDeliveryPayload($fixedTimestamp); - - expect($payload['headers']['X-Webhook-Timestamp'])->toBe((string) $fixedTimestamp); - }); - - it('generates valid signature in payload', function () { - $endpoint = WebhookEndpoint::createForWorkspace( - $this->workspace->id, - 'https://example.com/webhook', - ['bio.created'] - ); - - $delivery = WebhookDelivery::createForEvent( - $endpoint, - 'bio.created', - ['bio_id' => 123] - ); - - $payload = $delivery->getDeliveryPayload(); - - $timestamp = (int) $payload['headers']['X-Webhook-Timestamp']; - $signature = $payload['headers']['X-Webhook-Signature']; - $body = $payload['body']; - - // Verify the signature is valid - $isValid = $endpoint->verifySignature($body, $signature, $timestamp); - expect($isValid)->toBeTrue(); - }); -}); diff --git a/packages/core-api/src/Mod/Api/config.php b/packages/core-api/src/Mod/Api/config.php deleted file mode 100644 index 701ee76..0000000 --- a/packages/core-api/src/Mod/Api/config.php +++ /dev/null @@ -1,237 +0,0 @@ - env('API_VERSION', '1'), - - /* - |-------------------------------------------------------------------------- - | Rate Limiting - |-------------------------------------------------------------------------- - | - | Configure rate limits for API requests. - | - | Features: - | - Per-endpoint limits via 'endpoints' config or #[RateLimit] attribute - | - Per-workspace limits (when 'per_workspace' is true) - | - Tier-based limits based on workspace subscription - | - Burst allowance for temporary traffic spikes - | - Sliding window algorithm for smoother rate limiting - | - | Priority (highest to lowest): - | 1. Method-level #[RateLimit] attribute - | 2. Class-level #[RateLimit] attribute - | 3. Per-endpoint config (endpoints.{route_name}) - | 4. Tier-based limits (tiers.{tier}) - | 5. Authenticated limits - | 6. Default limits - | - */ - - 'rate_limits' => [ - // Enable/disable rate limiting globally - 'enabled' => env('API_RATE_LIMITING_ENABLED', true), - - // Unauthenticated requests (by IP) - 'default' => [ - 'limit' => 60, - 'window' => 60, // seconds - 'burst' => 1.0, // no burst allowance for unauthenticated - // Legacy support - 'requests' => 60, - 'per_minutes' => 1, - ], - - // Authenticated requests (by user/key) - 'authenticated' => [ - 'limit' => 1000, - 'window' => 60, // seconds - 'burst' => 1.2, // 20% burst allowance - // Legacy support - 'requests' => 1000, - 'per_minutes' => 1, - ], - - // Enable per-workspace rate limiting (isolates limits by workspace) - 'per_workspace' => true, - - // Per-endpoint rate limits (route names) - // Example: 'users.index' => ['limit' => 100, 'window' => 60] - 'endpoints' => [ - // High-volume endpoints may need higher limits - // 'links.index' => ['limit' => 500, 'window' => 60], - // 'qrcodes.index' => ['limit' => 500, 'window' => 60], - - // Sensitive endpoints may need lower limits - // 'auth.login' => ['limit' => 10, 'window' => 60], - // 'keys.create' => ['limit' => 20, 'window' => 60], - ], - - // Tier-based limits (based on workspace subscription/plan) - 'tiers' => [ - 'free' => [ - 'limit' => 60, - 'window' => 60, // seconds - 'burst' => 1.0, - ], - 'starter' => [ - 'limit' => 1000, - 'window' => 60, - 'burst' => 1.2, - ], - 'pro' => [ - 'limit' => 5000, - 'window' => 60, - 'burst' => 1.3, - ], - 'agency' => [ - 'limit' => 20000, - 'window' => 60, - 'burst' => 1.5, - ], - 'enterprise' => [ - 'limit' => 100000, - 'window' => 60, - 'burst' => 2.0, - ], - ], - - // Legacy: Tier-based limits (deprecated, use 'tiers' instead) - 'by_tier' => [ - 'starter' => [ - 'requests' => 1000, - 'per_minutes' => 1, - ], - 'pro' => [ - 'requests' => 5000, - 'per_minutes' => 1, - ], - 'agency' => [ - 'requests' => 20000, - 'per_minutes' => 1, - ], - 'enterprise' => [ - 'requests' => 100000, - 'per_minutes' => 1, - ], - ], - - // Route-specific rate limiters (for named routes) - 'routes' => [ - 'mcp' => 'authenticated', - 'pixel' => 'default', - ], - ], - - /* - |-------------------------------------------------------------------------- - | Usage Alerts - |-------------------------------------------------------------------------- - | - | Configure notifications when API usage approaches limits. - | - | Thresholds define percentages of rate limit that trigger alerts: - | - warning: First alert level (default: 80%) - | - critical: Urgent alert level (default: 95%) - | - | Cooldown prevents duplicate notifications for the same level. - | - */ - - 'alerts' => [ - // Enable/disable usage alerting - 'enabled' => env('API_USAGE_ALERTS_ENABLED', true), - - // Alert thresholds (percentage of rate limit) - 'thresholds' => [ - 'warning' => 80, - 'critical' => 95, - ], - - // Hours between notifications of the same level - 'cooldown_hours' => 6, - ], - - /* - |-------------------------------------------------------------------------- - | API Key Settings - |-------------------------------------------------------------------------- - | - | Configuration for API key generation and validation. - | - */ - - 'keys' => [ - // Prefix for all API keys - 'prefix' => 'hk_', - - // Default scopes for new API keys - 'default_scopes' => ['read', 'write'], - - // Maximum API keys per workspace - 'max_per_workspace' => 10, - - // Auto-expire keys after this many days (null = never) - 'default_expiry_days' => null, - ], - - /* - |-------------------------------------------------------------------------- - | Webhooks - |-------------------------------------------------------------------------- - | - | Webhook delivery settings. - | - */ - - 'webhooks' => [ - // Maximum webhook endpoints per workspace - 'max_per_workspace' => 5, - - // Timeout for webhook delivery in seconds - 'timeout' => 30, - - // Max retries for failed deliveries - 'max_retries' => 5, - - // Disable endpoint after this many consecutive failures - 'disable_after_failures' => 10, - - // Events that are high-volume and opt-in only - 'high_volume_events' => [ - 'link.clicked', - 'qrcode.scanned', - ], - ], - - /* - |-------------------------------------------------------------------------- - | Pagination - |-------------------------------------------------------------------------- - | - | Default pagination settings for API responses. - | - */ - - 'pagination' => [ - 'default_per_page' => 25, - 'max_per_page' => 100, - ], - -]; diff --git a/packages/core-api/src/Website/Api/Boot.php b/packages/core-api/src/Website/Api/Boot.php deleted file mode 100644 index 9baa06c..0000000 --- a/packages/core-api/src/Website/Api/Boot.php +++ /dev/null @@ -1,35 +0,0 @@ -registerViews(); - $this->registerRoutes(); - } - - protected function registerViews(): void - { - View::addNamespace('api', __DIR__.'/View/Blade'); - } - - protected function registerRoutes(): void - { - // Skip domain binding during console commands (no request available) - if ($this->app->runningInConsole()) { - return; - } - - Route::middleware('web') - ->domain(request()->getHost()) - ->group(__DIR__.'/Routes/web.php'); - } -} diff --git a/packages/core-api/src/Website/Api/Controllers/DocsController.php b/packages/core-api/src/Website/Api/Controllers/DocsController.php deleted file mode 100644 index 05de0f4..0000000 --- a/packages/core-api/src/Website/Api/Controllers/DocsController.php +++ /dev/null @@ -1,72 +0,0 @@ -json($generator->generate()); - } -} diff --git a/packages/core-api/src/Website/Api/Routes/web.php b/packages/core-api/src/Website/Api/Routes/web.php deleted file mode 100644 index b90954b..0000000 --- a/packages/core-api/src/Website/Api/Routes/web.php +++ /dev/null @@ -1,34 +0,0 @@ -name('api.docs'); - -// Guides -Route::get('/guides', [DocsController::class, 'guides'])->name('api.guides'); -Route::get('/guides/quickstart', [DocsController::class, 'quickstart'])->name('api.guides.quickstart'); -Route::get('/guides/authentication', [DocsController::class, 'authentication'])->name('api.guides.authentication'); -Route::get('/guides/qrcodes', [DocsController::class, 'qrcodes'])->name('api.guides.qrcodes'); -Route::get('/guides/webhooks', [DocsController::class, 'webhooks'])->name('api.guides.webhooks'); -Route::get('/guides/errors', [DocsController::class, 'errors'])->name('api.guides.errors'); - -// API Reference -Route::get('/reference', [DocsController::class, 'reference'])->name('api.reference'); - -// Swagger UI -Route::get('/swagger', [DocsController::class, 'swagger'])->name('api.swagger'); - -// Scalar (modern API reference with sidebar) -Route::get('/scalar', [DocsController::class, 'scalar'])->name('api.scalar'); - -// ReDoc (three-panel API reference) -Route::get('/redoc', [DocsController::class, 'redoc'])->name('api.redoc'); - -// OpenAPI spec (rate limited - expensive to generate) -Route::get('/openapi.json', [DocsController::class, 'openapi']) - ->middleware('throttle:60,1') - ->name('api.openapi.json'); diff --git a/packages/core-api/src/Website/Api/Services/OpenApiGenerator.php b/packages/core-api/src/Website/Api/Services/OpenApiGenerator.php deleted file mode 100644 index 93d74ca..0000000 --- a/packages/core-api/src/Website/Api/Services/OpenApiGenerator.php +++ /dev/null @@ -1,348 +0,0 @@ -isProduction() ? 3600 : 0; - } - - /** - * Generate OpenAPI 3.0 specification from Laravel routes. - */ - public function generate(): array - { - $duration = $this->getCacheDuration(); - - if ($duration === 0) { - return $this->buildSpec(); - } - - return Cache::remember('openapi:spec', $duration, fn () => $this->buildSpec()); - } - - /** - * Clear the cached OpenAPI spec. - */ - public function clearCache(): void - { - Cache::forget('openapi:spec'); - } - - /** - * Build the full OpenAPI specification. - */ - protected function buildSpec(): array - { - return [ - 'openapi' => '3.0.0', - 'info' => $this->buildInfo(), - 'servers' => $this->buildServers(), - 'tags' => $this->buildTags(), - 'paths' => $this->buildPaths(), - 'components' => $this->buildComponents(), - ]; - } - - protected function buildInfo(): array - { - return [ - 'title' => config('app.name').' API', - 'description' => 'Unified API for Host UK services including commerce, analytics, push notifications, support, and MCP.', - 'version' => config('api.version', '1.0.0'), - 'contact' => [ - 'name' => config('app.name').' Support', - 'url' => config('app.url').'/contact', - 'email' => config('mail.from.address', 'support@host.uk.com'), - ], - ]; - } - - protected function buildServers(): array - { - return [ - [ - 'url' => config('app.url').'/api', - 'description' => 'Production API', - ], - ]; - } - - protected function buildTags(): array - { - return [ - ['name' => 'Analytics', 'description' => 'Website analytics and tracking'], - ['name' => 'Bio', 'description' => 'Bio link pages, blocks, and QR codes'], - ['name' => 'Chat Widget', 'description' => 'Public chat widget API'], - ['name' => 'Commerce', 'description' => 'Billing, orders, invoices, subscriptions, and provisioning'], - ['name' => 'Content', 'description' => 'AI content generation and briefs'], - ['name' => 'Entitlements', 'description' => 'Feature entitlements and usage'], - ['name' => 'MCP', 'description' => 'Model Context Protocol HTTP bridge'], - ['name' => 'Notify', 'description' => 'Push notification management'], - ['name' => 'Pixel', 'description' => 'Unified pixel tracking'], - ['name' => 'SEO', 'description' => 'SEO report and analysis endpoints'], - ['name' => 'Social', 'description' => 'Social media management'], - ['name' => 'Support', 'description' => 'Helpdesk API'], - ['name' => 'Tenant', 'description' => 'Workspaces and multi-tenancy'], - ['name' => 'Trees', 'description' => 'Trees for Agents statistics'], - ['name' => 'Trust', 'description' => 'Social proof widgets'], - ['name' => 'Webhooks', 'description' => 'Incoming webhook endpoints for external services'], - ]; - } - - protected function buildPaths(): array - { - $paths = []; - - foreach (RouteFacade::getRoutes() as $route) { - /** @var Route $route */ - if (! $this->isApiRoute($route)) { - continue; - } - - $path = $this->normalisePath($route->uri()); - $methods = array_filter($route->methods(), fn ($m) => $m !== 'HEAD'); - - foreach ($methods as $method) { - $method = strtolower($method); - $paths[$path][$method] = $this->buildOperation($route, $method); - } - } - - ksort($paths); - - return $paths; - } - - protected function isApiRoute(Route $route): bool - { - $uri = $route->uri(); - - // Must start with 'api/' or be exactly 'api' - if (! str_starts_with($uri, 'api/') && $uri !== 'api') { - return false; - } - - // Skip sanctum routes - if (str_contains($uri, 'sanctum')) { - return false; - } - - return true; - } - - protected function normalisePath(string $uri): string - { - // Remove 'api' prefix, keep leading slash - $path = '/'.ltrim(Str::after($uri, 'api/'), '/'); - - // Convert Laravel route parameters to OpenAPI format - $path = preg_replace('/\{([^}]+)\}/', '{$1}', $path); - - return $path === '/' ? '/' : rtrim($path, '/'); - } - - protected function buildOperation(Route $route, string $method): array - { - $name = $route->getName() ?? ''; - $tag = $this->inferTag($route); - - $operation = [ - 'tags' => [$tag], - 'summary' => $this->generateSummary($route, $method), - 'operationId' => $name ?: Str::camel($method.'_'.str_replace('/', '_', $route->uri())), - 'responses' => [ - '200' => ['description' => 'Successful response'], - ], - ]; - - // Add parameters for path variables - $parameters = $this->buildParameters($route); - if (! empty($parameters)) { - $operation['parameters'] = $parameters; - } - - // Add request body for POST/PUT/PATCH - if (in_array($method, ['post', 'put', 'patch'])) { - $operation['requestBody'] = [ - 'required' => true, - 'content' => [ - 'application/json' => [ - 'schema' => ['type' => 'object'], - ], - ], - ]; - } - - // Add security based on middleware - $security = $this->inferSecurity($route); - if (! empty($security)) { - $operation['security'] = $security; - } - - return $operation; - } - - protected function inferTag(Route $route): string - { - $uri = $route->uri(); - $name = $route->getName() ?? ''; - - // Match by route name prefix - $tagMap = [ - 'api.webhook' => 'Webhooks', - 'api.trees' => 'Trees', - 'api.seo' => 'SEO', - 'api.pixel' => 'Pixel', - 'api.commerce' => 'Commerce', - 'api.entitlements' => 'Entitlements', - 'api.support.chat' => 'Chat Widget', - 'api.support' => 'Support', - 'api.mcp' => 'MCP', - 'api.social' => 'Social', - 'api.notify' => 'Notify', - 'api.bio' => 'Bio', - 'api.blocks' => 'Bio', - 'api.shortlinks' => 'Bio', - 'api.qr' => 'Bio', - 'api.workspaces' => 'Tenant', - 'api.key.workspaces' => 'Tenant', - 'api.key.bio' => 'Bio', - 'api.key.blocks' => 'Bio', - 'api.key.shortlinks' => 'Bio', - 'api.key.qr' => 'Bio', - 'api.content' => 'Content', - 'api.key.content' => 'Content', - 'api.trust' => 'Trust', - ]; - - foreach ($tagMap as $prefix => $tag) { - if (str_starts_with($name, $prefix)) { - return $tag; - } - } - - // Match by URI prefix (check start of path after 'api/') - $path = preg_replace('#^api/#', '', $uri); - $uriTagMap = [ - 'webhooks' => 'Webhooks', - 'trees' => 'Trees', - 'pixel' => 'Pixel', - 'provisioning' => 'Commerce', - 'commerce' => 'Commerce', - 'entitlements' => 'Entitlements', - 'support/chat' => 'Chat Widget', - 'support' => 'Support', - 'mcp' => 'MCP', - 'bio' => 'Bio', - 'shortlinks' => 'Bio', - 'qr' => 'Bio', - 'blocks' => 'Bio', - 'workspaces' => 'Tenant', - 'analytics' => 'Analytics', - 'social' => 'Social', - 'trust' => 'Trust', - 'notify' => 'Notify', - 'content' => 'Content', - ]; - - foreach ($uriTagMap as $prefix => $tag) { - if (str_starts_with($path, $prefix)) { - return $tag; - } - } - - return 'General'; - } - - protected function generateSummary(Route $route, string $method): string - { - $name = $route->getName(); - - if ($name) { - // Convert route name to human-readable summary - $parts = explode('.', $name); - $action = array_pop($parts); - - return Str::title(str_replace(['-', '_'], ' ', $action)); - } - - // Generate from URI and method - $uri = Str::afterLast($route->uri(), '/'); - - return Str::title($method.' '.str_replace(['-', '_'], ' ', $uri)); - } - - protected function buildParameters(Route $route): array - { - $parameters = []; - preg_match_all('/\{([^}]+)\}/', $route->uri(), $matches); - - foreach ($matches[1] as $param) { - $optional = str_ends_with($param, '?'); - $paramName = rtrim($param, '?'); - - $parameters[] = [ - 'name' => $paramName, - 'in' => 'path', - 'required' => ! $optional, - 'schema' => ['type' => 'string'], - ]; - } - - return $parameters; - } - - protected function inferSecurity(Route $route): array - { - $middleware = $route->middleware(); - - if (in_array('auth', $middleware) || in_array('auth:sanctum', $middleware)) { - return [['bearerAuth' => []]]; - } - - if (in_array('commerce.api', $middleware)) { - return [['apiKeyAuth' => []]]; - } - - foreach ($middleware as $m) { - if (str_contains($m, 'McpApiKeyAuth')) { - return [['apiKeyAuth' => []]]; - } - } - - return []; - } - - protected function buildComponents(): array - { - return [ - 'securitySchemes' => [ - 'bearerAuth' => [ - 'type' => 'http', - 'scheme' => 'bearer', - 'bearerFormat' => 'JWT', - 'description' => 'Sanctum authentication token', - ], - 'apiKeyAuth' => [ - 'type' => 'apiKey', - 'in' => 'header', - 'name' => 'X-API-Key', - 'description' => 'API key for service-to-service authentication', - ], - ], - ]; - } -} diff --git a/packages/core-api/src/Website/Api/View/Blade/docs.blade.php b/packages/core-api/src/Website/Api/View/Blade/docs.blade.php deleted file mode 100644 index 5f702a7..0000000 --- a/packages/core-api/src/Website/Api/View/Blade/docs.blade.php +++ /dev/null @@ -1,111 +0,0 @@ - - - - - - Host UK API Documentation - - - - -
-

- - - - Host UK API -

- -
- -
- - - - - - diff --git a/packages/core-api/src/Website/Api/View/Blade/guides/authentication.blade.php b/packages/core-api/src/Website/Api/View/Blade/guides/authentication.blade.php deleted file mode 100644 index 5c27993..0000000 --- a/packages/core-api/src/Website/Api/View/Blade/guides/authentication.blade.php +++ /dev/null @@ -1,187 +0,0 @@ -@extends('api::layouts.docs') - -@section('title', 'Authentication') - -@section('content') -
- - {{-- Sidebar --}} - - - {{-- Main content --}} -
-
- - {{-- Breadcrumb --}} - - -

Authentication

-

- Learn how to authenticate your API requests using API keys. -

- - {{-- Overview --}} -
-

Overview

-

- The API uses API keys for authentication. Each API key is scoped to a specific workspace and has configurable permissions. -

-

- API keys are prefixed with hk_ to make them easily identifiable. -

-
- - {{-- API Keys --}} -
-

API Keys

-

- To create an API key: -

-
    -
  1. Log in to your account
  2. -
  3. Navigate to Settings → API Keys
  4. -
  5. Click Create API Key
  6. -
  7. Enter a descriptive name (e.g., "Production", "Development")
  8. -
  9. Select the required scopes
  10. -
  11. Copy the generated key immediately
  12. -
- -
-
- - - -

- Important: API keys are only shown once when created. Store them securely as they cannot be retrieved later. -

-
-
-
- - {{-- Using Keys --}} -
-

Using API Keys

-

- Include your API key in the Authorization header as a Bearer token: -

- -
-
- HTTP Header -
-
Authorization: Bearer hk_your_api_key_here
-
- -

- Example request with cURL: -

- -
-
- cURL -
-
curl --request GET \
-  --url 'https://api.host.uk.com/api/v1/bio' \
-  --header 'Authorization: Bearer hk_your_api_key'
-
-
- - {{-- Scopes --}} -
-

Scopes

-

- API keys can have different scopes to limit their permissions: -

- -
- - - - - - - - - - - - - - - - - - - - - -
ScopeDescription
readRead access to resources (GET requests)
writeCreate and update resources (POST, PUT requests)
deleteDelete resources (DELETE requests)
-
-
- - {{-- Security --}} -
-

Security Best Practices

-
    -
  • Never commit API keys to version control
  • -
  • Use environment variables to store keys
  • -
  • Rotate keys periodically
  • -
  • Use the minimum required scopes
  • -
  • Revoke unused keys immediately
  • -
  • Never expose keys in client-side code
  • -
-
- - {{-- Next steps --}} - - -
-
- -
-@endsection diff --git a/packages/core-api/src/Website/Api/View/Blade/guides/errors.blade.php b/packages/core-api/src/Website/Api/View/Blade/guides/errors.blade.php deleted file mode 100644 index 2bb9770..0000000 --- a/packages/core-api/src/Website/Api/View/Blade/guides/errors.blade.php +++ /dev/null @@ -1,211 +0,0 @@ -@extends('api::layouts.docs') - -@section('title', 'Error Handling') - -@section('content') -
- - {{-- Sidebar --}} - - - {{-- Main content --}} -
-
- - {{-- Breadcrumb --}} - - -

Error Handling

-

- Understand API error codes and how to handle them gracefully. -

- - {{-- Overview --}} -
-

Overview

-

- The API uses conventional HTTP response codes to indicate success or failure. Codes in the 2xx range indicate success, 4xx indicate client errors, and 5xx indicate server errors. -

-
- - {{-- HTTP Codes --}} -
-

HTTP Status Codes

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
CodeMeaning
200Success - Request completed successfully
201Created - Resource was created successfully
400Bad Request - Invalid request parameters
401Unauthorised - Invalid or missing API key
403Forbidden - Insufficient permissions
404Not Found - Resource doesn't exist
422Unprocessable - Validation failed
429Too Many Requests - Rate limit exceeded
500Server Error - Something went wrong on our end
-
-
- - {{-- Error Format --}} -
-

Error Format

-

- Error responses include a JSON body with details: -

- -
-
{
-  "message": "The given data was invalid.",
-  "errors": {
-    "url": [
-      "The url has already been taken."
-    ]
-  }
-}
-
-
- - {{-- Common Errors --}} -
-

Common Errors

- -
-
-

Invalid API Key

-

- Returned when the API key is missing, malformed, or revoked. -

- 401 Unauthorised -
- -
-

Resource Not Found

-

- The requested resource (biolink, workspace, etc.) doesn't exist or you don't have access. -

- 404 Not Found -
- -
-

Validation Failed

-

- Request data failed validation. Check the errors object for specific fields. -

- 422 Unprocessable Entity -
-
-
- - {{-- Rate Limiting --}} -
-

Rate Limiting

-

- API requests are rate limited to ensure fair usage. Rate limit headers are included in all responses: -

- -
-
X-RateLimit-Limit: 60
-X-RateLimit-Remaining: 58
-X-RateLimit-Reset: 1705320000
-
- -

- When rate limited, you'll receive a 429 response. Wait until the reset timestamp before retrying. -

- -
-

- Tip: Implement exponential backoff in your retry logic. Start with a 1-second delay and double it with each retry, up to a maximum of 32 seconds. -

-
-
- - {{-- Next steps --}} - - -
-
- -
-@endsection diff --git a/packages/core-api/src/Website/Api/View/Blade/guides/index.blade.php b/packages/core-api/src/Website/Api/View/Blade/guides/index.blade.php deleted file mode 100644 index ef77a68..0000000 --- a/packages/core-api/src/Website/Api/View/Blade/guides/index.blade.php +++ /dev/null @@ -1,88 +0,0 @@ -@extends('api::layouts.docs') - -@section('title', 'Guides') - -@section('content') - -@endsection diff --git a/packages/core-api/src/Website/Api/View/Blade/guides/qrcodes.blade.php b/packages/core-api/src/Website/Api/View/Blade/guides/qrcodes.blade.php deleted file mode 100644 index 6d08861..0000000 --- a/packages/core-api/src/Website/Api/View/Blade/guides/qrcodes.blade.php +++ /dev/null @@ -1,202 +0,0 @@ -@extends('api::layouts.docs') - -@section('title', 'QR Code Generation') - -@section('content') -
- - {{-- Sidebar --}} - - - {{-- Main content --}} -
-
- - {{-- Breadcrumb --}} - - -

QR Code Generation

-

- Generate customisable QR codes for your biolinks or any URL. -

- - {{-- Overview --}} -
-

Overview

-

- The API provides two ways to generate QR codes: -

-
    -
  • Biolink QR codes - Generate QR codes for your existing biolinks
  • -
  • Custom URL QR codes - Generate QR codes for any URL
  • -
-
- - {{-- Biolink QR --}} - - - {{-- Custom QR --}} -
-

Custom URL QR Codes

-

- Generate a QR code for any URL: -

- -
-
- cURL -
-
curl --request POST \
-  --url 'https://api.host.uk.com/api/v1/qr/generate' \
-  --header 'Authorization: Bearer YOUR_API_KEY' \
-  --header 'Content-Type: application/json' \
-  --data '{
-    "url": "https://example.com",
-    "format": "svg",
-    "size": 300
-  }'
-
-
- - {{-- Options --}} -
-

Customisation Options

-

- Available customisation parameters: -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterTypeDescription
formatstringOutput format: svg or png
sizeintegerSize in pixels (100-2000)
colorstringForeground colour (hex)
backgroundstringBackground colour (hex)
-
-
- - {{-- Download --}} -
-

Download Formats

-

- Download QR codes directly as image files: -

- -
-
- cURL -
-
curl --request GET \
-  --url 'https://api.host.uk.com/api/v1/bio/1/qr/download?format=png&size=500' \
-  --header 'Authorization: Bearer YOUR_API_KEY' \
-  --output qrcode.png
-
- -

- The response is binary image data with appropriate Content-Type header. -

-
- - {{-- Next steps --}} - - -
-
- -
-@endsection diff --git a/packages/core-api/src/Website/Api/View/Blade/guides/quickstart.blade.php b/packages/core-api/src/Website/Api/View/Blade/guides/quickstart.blade.php deleted file mode 100644 index c71acdf..0000000 --- a/packages/core-api/src/Website/Api/View/Blade/guides/quickstart.blade.php +++ /dev/null @@ -1,193 +0,0 @@ -@extends('api::layouts.docs') - -@section('title', 'Quick Start') - -@section('content') -
- - {{-- Sidebar --}} - - - {{-- Main content --}} -
-
- - {{-- Breadcrumb --}} - - -

Quick Start

-

- Get up and running with the API in under 5 minutes. -

- - {{-- Prerequisites --}} -
-

Prerequisites

-

- Before you begin, you'll need: -

-
    -
  • An account with API access
  • -
  • A workspace (created automatically on signup)
  • -
  • cURL or any HTTP client
  • -
-
- - {{-- Create API Key --}} -
-

Create an API Key

-

- Navigate to your workspace settings and create a new API key: -

-
    -
  1. Go to Settings → API Keys
  2. -
  3. Click Create API Key
  4. -
  5. Give it a name (e.g., "Development")
  6. -
  7. Select the scopes you need (read, write, delete)
  8. -
  9. Copy the key - it won't be shown again!
  10. -
- - {{-- Note box --}} -
-
- - - -

- Important: Store your API key securely. Never commit it to version control or expose it in client-side code. -

-
-
-
- - {{-- First Request --}} -
-

Make Your First Request

-

- Let's verify your API key by listing your workspaces: -

- -
-
- cURL -
-
curl --request GET \
-  --url 'https://api.host.uk.com/api/v1/workspaces/current' \
-  --header 'Authorization: Bearer YOUR_API_KEY'
-
- -

- You should receive a response like: -

- -
-
- Response -
-
{
-  "data": {
-    "id": 1,
-    "name": "My Workspace",
-    "slug": "my-workspace-abc123",
-    "is_active": true
-  }
-}
-
-
- - {{-- Create Biolink --}} - - - {{-- Next Steps --}} -
-

Next Steps

-

- Now that you've made your first API calls, explore more: -

- - -
- -
-
- -
-@endsection diff --git a/packages/core-api/src/Website/Api/View/Blade/guides/webhooks.blade.php b/packages/core-api/src/Website/Api/View/Blade/guides/webhooks.blade.php deleted file mode 100644 index 31323fa..0000000 --- a/packages/core-api/src/Website/Api/View/Blade/guides/webhooks.blade.php +++ /dev/null @@ -1,586 +0,0 @@ -@extends('api::layouts.docs') - -@section('title', 'Webhooks') - -@section('content') -
- - {{-- Sidebar --}} - - - {{-- Main content --}} -
-
- - {{-- Breadcrumb --}} - - -

Webhooks

-

- Receive real-time notifications for events in your workspace with cryptographically signed payloads. -

- - {{-- Overview --}} -
-

Overview

-

- Webhooks allow your application to receive real-time HTTP callbacks when events occur in your workspace. Instead of polling the API, webhooks push data to your server as events happen. -

-

- All webhook requests are cryptographically signed using HMAC-SHA256, allowing you to verify that requests genuinely came from our platform and haven't been tampered with. -

-
-
- - - -

- Security: Always verify webhook signatures before processing. Never trust unverified webhook requests. -

-
-
-
- - {{-- Setup --}} -
-

Setup

-

- To configure webhooks: -

-
    -
  1. Go to Settings → Webhooks in your workspace
  2. -
  3. Click Add Webhook
  4. -
  5. Enter your endpoint URL (must be HTTPS in production)
  6. -
  7. Select the events you want to receive
  8. -
  9. Save and securely store your webhook secret
  10. -
-
-
- - - -

- Your webhook secret is only shown once when you create the endpoint. Store it securely - you'll need it to verify incoming webhooks. -

-
-
-
- - {{-- Events --}} -
-

Event Types

-

- Available webhook events: -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
EventDescription
bio.createdA new biolink was created
bio.updatedA biolink was updated
bio.deletedA biolink was deleted
link.createdA new link was created
link.clickedA link was clicked (high volume)
qrcode.createdA QR code was generated
qrcode.scannedA QR code was scanned (high volume)
*Subscribe to all events (wildcard)
-
-
- - {{-- Payload --}} -
-

Payload Format

-

- Webhook payloads are sent as JSON with a consistent structure: -

- -
-
{
-  "id": "evt_abc123xyz456",
-  "type": "bio.created",
-  "created_at": "2024-01-15T10:30:00Z",
-  "workspace_id": 1,
-  "data": {
-    "id": 123,
-    "url": "mypage",
-    "type": "biolink"
-  }
-}
-
-
- - {{-- Headers --}} -
-

Request Headers

-

- Every webhook request includes the following headers: -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
HeaderDescription
X-Webhook-SignatureHMAC-SHA256 signature for verification
X-Webhook-TimestampUnix timestamp when the webhook was sent
X-Webhook-EventThe event type (e.g., bio.created)
X-Webhook-IdUnique delivery ID for idempotency
Content-TypeAlways application/json
-
-
- - {{-- Verification --}} -
-

Signature Verification

-

- To verify a webhook signature, compute the HMAC-SHA256 of the timestamp concatenated with the raw request body using your webhook secret. The signature includes the timestamp to prevent replay attacks. -

- -

Verification Algorithm

-
    -
  1. Extract X-Webhook-Signature and X-Webhook-Timestamp headers
  2. -
  3. Concatenate: timestamp + "." + raw_request_body
  4. -
  5. Compute: HMAC-SHA256(concatenated_string, your_webhook_secret)
  6. -
  7. Compare using timing-safe comparison (prevents timing attacks)
  8. -
  9. Verify timestamp is within 5 minutes of current time (prevents replay attacks)
  10. -
- - {{-- PHP Example --}} -

PHP

-
-
- webhook-handler.php -
-
<?php
-
-// Get request data
-$payload = file_get_contents('php://input');
-$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
-$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
-$secret = getenv('WEBHOOK_SECRET');
-
-// Verify timestamp (5 minute tolerance)
-$tolerance = 300;
-if (abs(time() - (int)$timestamp) > $tolerance) {
-    http_response_code(401);
-    die('Webhook timestamp expired');
-}
-
-// Compute expected signature
-$signedPayload = $timestamp . '.' . $payload;
-$expectedSignature = hash_hmac('sha256', $signedPayload, $secret);
-
-// Verify signature (timing-safe comparison)
-if (!hash_equals($expectedSignature, $signature)) {
-    http_response_code(401);
-    die('Invalid webhook signature');
-}
-
-// Signature valid - process the webhook
-$event = json_decode($payload, true);
-processWebhook($event);
-
- - {{-- Node.js Example --}} -

Node.js

-
-
- webhook-handler.js -
-
const crypto = require('crypto');
-const express = require('express');
-
-const app = express();
-app.use(express.raw({ type: 'application/json' }));
-
-const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
-const TOLERANCE = 300; // 5 minutes
-
-app.post('/webhook', (req, res) => {
-    const signature = req.headers['x-webhook-signature'];
-    const timestamp = req.headers['x-webhook-timestamp'];
-    const payload = req.body;
-
-    // Verify timestamp
-    const now = Math.floor(Date.now() / 1000);
-    if (Math.abs(now - parseInt(timestamp)) > TOLERANCE) {
-        return res.status(401).send('Webhook timestamp expired');
-    }
-
-    // Compute expected signature
-    const signedPayload = `${timestamp}.${payload}`;
-    const expectedSignature = crypto
-        .createHmac('sha256', WEBHOOK_SECRET)
-        .update(signedPayload)
-        .digest('hex');
-
-    // Verify signature (timing-safe comparison)
-    if (!crypto.timingSafeEqual(
-        Buffer.from(expectedSignature),
-        Buffer.from(signature)
-    )) {
-        return res.status(401).send('Invalid webhook signature');
-    }
-
-    // Signature valid - process the webhook
-    const event = JSON.parse(payload);
-    processWebhook(event);
-    res.status(200).send('OK');
-});
-
- - {{-- Python Example --}} -

Python

-
-
- webhook_handler.py -
-
import hmac
-import hashlib
-import time
-import os
-from flask import Flask, request, abort
-
-app = Flask(__name__)
-WEBHOOK_SECRET = os.environ['WEBHOOK_SECRET']
-TOLERANCE = 300  # 5 minutes
-
-@app.route('/webhook', methods=['POST'])
-def webhook():
-    signature = request.headers.get('X-Webhook-Signature', '')
-    timestamp = request.headers.get('X-Webhook-Timestamp', '')
-    payload = request.get_data(as_text=True)
-
-    # Verify timestamp
-    if abs(time.time() - int(timestamp)) > TOLERANCE:
-        abort(401, 'Webhook timestamp expired')
-
-    # Compute expected signature
-    signed_payload = f'{timestamp}.{payload}'
-    expected_signature = hmac.new(
-        WEBHOOK_SECRET.encode(),
-        signed_payload.encode(),
-        hashlib.sha256
-    ).hexdigest()
-
-    # Verify signature (timing-safe comparison)
-    if not hmac.compare_digest(expected_signature, signature):
-        abort(401, 'Invalid webhook signature')
-
-    # Signature valid - process the webhook
-    event = request.get_json()
-    process_webhook(event)
-    return 'OK', 200
-
- - {{-- Ruby Example --}} -

Ruby

-
-
- webhook_handler.rb -
-
require 'sinatra'
-require 'openssl'
-require 'json'
-
-WEBHOOK_SECRET = ENV['WEBHOOK_SECRET']
-TOLERANCE = 300  # 5 minutes
-
-post '/webhook' do
-  signature = request.env['HTTP_X_WEBHOOK_SIGNATURE'] || ''
-  timestamp = request.env['HTTP_X_WEBHOOK_TIMESTAMP'] || ''
-  payload = request.body.read
-
-  # Verify timestamp
-  if (Time.now.to_i - timestamp.to_i).abs > TOLERANCE
-    halt 401, 'Webhook timestamp expired'
-  end
-
-  # Compute expected signature
-  signed_payload = "#{timestamp}.#{payload}"
-  expected_signature = OpenSSL::HMAC.hexdigest(
-    'sha256',
-    WEBHOOK_SECRET,
-    signed_payload
-  )
-
-  # Verify signature (timing-safe comparison)
-  unless Rack::Utils.secure_compare(expected_signature, signature)
-    halt 401, 'Invalid webhook signature'
-  end
-
-  # Signature valid - process the webhook
-  event = JSON.parse(payload)
-  process_webhook(event)
-  200
-end
-
- - {{-- Go Example --}} -

Go

-
-
- webhook_handler.go -
-
package main
-
-import (
-    "crypto/hmac"
-    "crypto/sha256"
-    "crypto/subtle"
-    "encoding/hex"
-    "io"
-    "math"
-    "net/http"
-    "os"
-    "strconv"
-    "time"
-)
-
-const tolerance = 300 // 5 minutes
-
-func webhookHandler(w http.ResponseWriter, r *http.Request) {
-    signature := r.Header.Get("X-Webhook-Signature")
-    timestamp := r.Header.Get("X-Webhook-Timestamp")
-    secret := os.Getenv("WEBHOOK_SECRET")
-
-    payload, _ := io.ReadAll(r.Body)
-
-    // Verify timestamp
-    ts, _ := strconv.ParseInt(timestamp, 10, 64)
-    if math.Abs(float64(time.Now().Unix()-ts)) > tolerance {
-        http.Error(w, "Webhook timestamp expired", 401)
-        return
-    }
-
-    // Compute expected signature
-    signedPayload := timestamp + "." + string(payload)
-    mac := hmac.New(sha256.New, []byte(secret))
-    mac.Write([]byte(signedPayload))
-    expectedSignature := hex.EncodeToString(mac.Sum(nil))
-
-    // Verify signature (timing-safe comparison)
-    if subtle.ConstantTimeCompare(
-        []byte(expectedSignature),
-        []byte(signature),
-    ) != 1 {
-        http.Error(w, "Invalid webhook signature", 401)
-        return
-    }
-
-    // Signature valid - process the webhook
-    processWebhook(payload)
-    w.WriteHeader(http.StatusOK)
-}
-
-
- - {{-- Retry Policy --}} -
-

Retry Policy

-

- If your endpoint returns a non-2xx status code or times out, we'll retry with exponential backoff: -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
AttemptDelay
1 (initial)Immediate
21 minute
35 minutes
430 minutes
5 (final)2 hours
-
- -

- After 5 failed attempts, the delivery is marked as failed. If your endpoint fails 10 consecutive deliveries, it will be automatically disabled. You can re-enable it from your webhook settings. -

-
- - {{-- Best Practices --}} -
-

Best Practices

-
    -
  • - - - - Always verify signatures - Never process webhooks without verification -
  • -
  • - - - - Respond quickly - Return 200 within 30 seconds to avoid timeouts -
  • -
  • - - - - Process asynchronously - Queue webhook processing for long-running tasks -
  • -
  • - - - - Handle duplicates - Use X-Webhook-Id for idempotency -
  • -
  • - - - - Use HTTPS - Always use HTTPS endpoints in production -
  • -
  • - - - - Rotate secrets regularly - Rotate your webhook secret periodically -
  • -
-
- - {{-- Next steps --}} - - -
-
- -
-@endsection diff --git a/packages/core-api/src/Website/Api/View/Blade/index.blade.php b/packages/core-api/src/Website/Api/View/Blade/index.blade.php deleted file mode 100644 index b9d82fd..0000000 --- a/packages/core-api/src/Website/Api/View/Blade/index.blade.php +++ /dev/null @@ -1,136 +0,0 @@ -@extends('api::layouts.docs') - -@section('title', 'API Documentation') -@section('description', 'Build powerful integrations with the Host UK API. Access biolinks, workspaces, QR codes, and more.') - -@section('content') -
- - {{-- Hero --}} -
-
- Developer Documentation -
-

Build with the Host UK API

-

- Integrate biolinks, workspaces, QR codes, and analytics into your applications. - Full REST API with comprehensive documentation and SDK support. -

- -
- - {{-- Features grid --}} -
- - {{-- Authentication --}} -
-
- - - -
-

Authentication

-

- Secure API key authentication with scoped permissions. Generate keys from your workspace settings. -

- - Learn more → - -
- - {{-- Biolinks --}} -
-
- - - -
-

Biolinks

-

- Create, update, and manage biolink pages with blocks, themes, and analytics programmatically. -

- - Learn more → - -
- - {{-- QR Codes --}} -
-
- - - -
-

QR Codes

-

- Generate customisable QR codes with colours, logos, and multiple formats for any URL. -

- - Learn more → - -
- -
- - {{-- Quick start code example --}} -
-

Quick Start

-
-
- cURL - -
-
curl --request GET \
-  --url 'https://api.host.uk.com/api/v1/bio' \
-  --header 'Authorization: Bearer hk_your_api_key'
-
- - -
- - {{-- API endpoints preview --}} -
-

API Endpoints

-
- @foreach([ - ['method' => 'GET', 'path' => '/api/v1/workspaces', 'desc' => 'List all workspaces'], - ['method' => 'GET', 'path' => '/api/v1/bio', 'desc' => 'List all biolinks'], - ['method' => 'POST', 'path' => '/api/v1/bio', 'desc' => 'Create a biolink'], - ['method' => 'GET', 'path' => '/api/v1/bio/{id}/qr', 'desc' => 'Generate QR code'], - ['method' => 'GET', 'path' => '/api/v1/shortlinks', 'desc' => 'List short links'], - ['method' => 'POST', 'path' => '/api/v1/qr/generate', 'desc' => 'Generate QR for any URL'], - ] as $endpoint) - - - {{ $endpoint['method'] }} - -
- {{ $endpoint['path'] }} - {{ $endpoint['desc'] }} -
-
- @endforeach -
- - -
- -
-@endsection diff --git a/packages/core-api/src/Website/Api/View/Blade/layouts/docs.blade.php b/packages/core-api/src/Website/Api/View/Blade/layouts/docs.blade.php deleted file mode 100644 index e4b10c9..0000000 --- a/packages/core-api/src/Website/Api/View/Blade/layouts/docs.blade.php +++ /dev/null @@ -1,166 +0,0 @@ - - - - - @yield('title', 'API Documentation') - Host UK - - - - - - - - - @include('layouts::partials.fonts') - - - - - - @vite(['resources/css/app.css', 'resources/js/app.js']) - - - - - - @stack('head') - - - -
- - {{-- Site header --}} -
- -
-
- - {{-- Site branding --}} -
-
- {{-- Logo --}} - - - - - Host UK API - - - {{-- Search --}} -
- - - {{-- Search modal placeholder --}} - -
-
-
- - {{-- Desktop nav --}} - - -
-
-
- - {{-- Page content --}} -
- @yield('content') -
- - {{-- Site footer --}} - - -
- - @stack('scripts') - - diff --git a/packages/core-api/src/Website/Api/View/Blade/partials/endpoint.blade.php b/packages/core-api/src/Website/Api/View/Blade/partials/endpoint.blade.php deleted file mode 100644 index b8ca518..0000000 --- a/packages/core-api/src/Website/Api/View/Blade/partials/endpoint.blade.php +++ /dev/null @@ -1,37 +0,0 @@ -@props(['method', 'path', 'description', 'body' => null, 'response']) - -
- {{-- Header --}} -
- - {{ $method }} - - {{ $path }} -
- - {{-- Body --}} -
-

{{ $description }}

- - @if($body) -
-

Request Body

-
-
{{ $body }}
-
-
- @endif - -
-

Response

-
-
{{ $response }}
-
-
-
-
diff --git a/packages/core-api/src/Website/Api/View/Blade/redoc.blade.php b/packages/core-api/src/Website/Api/View/Blade/redoc.blade.php deleted file mode 100644 index a9c98a2..0000000 --- a/packages/core-api/src/Website/Api/View/Blade/redoc.blade.php +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - API Reference - Host UK - - - - - - - -
- - - - diff --git a/packages/core-api/src/Website/Api/View/Blade/reference.blade.php b/packages/core-api/src/Website/Api/View/Blade/reference.blade.php deleted file mode 100644 index 22360e4..0000000 --- a/packages/core-api/src/Website/Api/View/Blade/reference.blade.php +++ /dev/null @@ -1,261 +0,0 @@ -@extends('api::layouts.docs') - -@section('title', 'API Reference') - -@section('content') -
- - {{-- Sidebar --}} - - - {{-- Main content --}} -
-
- -

API Reference

-

- Complete reference for all Host UK API endpoints. -

-

- Base URL: https://api.host.uk.com/api/v1 -

- - {{-- Workspaces --}} -
-

Workspaces

-

- Workspaces are containers for your biolinks, short links, and other resources. -

- - @include('api::partials.endpoint', [ - 'method' => 'GET', - 'path' => '/workspaces', - 'description' => 'List all workspaces you have access to.', - 'response' => '{"data": [{"id": 1, "name": "My Workspace", "slug": "my-workspace"}]}' - ]) - - @include('api::partials.endpoint', [ - 'method' => 'GET', - 'path' => '/workspaces/current', - 'description' => 'Get the current workspace (from API key context).', - 'response' => '{"data": {"id": 1, "name": "My Workspace", "slug": "my-workspace"}}' - ]) - - @include('api::partials.endpoint', [ - 'method' => 'GET', - 'path' => '/workspaces/{id}', - 'description' => 'Get a specific workspace by ID.', - 'response' => '{"data": {"id": 1, "name": "My Workspace", "slug": "my-workspace"}}' - ]) -
- - {{-- Biolinks --}} - - - {{-- Blocks --}} -
-

Blocks

-

- Blocks are content elements within a biolink page. -

- - @include('api::partials.endpoint', [ - 'method' => 'GET', - 'path' => '/bio/{bioId}/blocks', - 'description' => 'List all blocks for a biolink.', - 'response' => '{"data": [{"id": 1, "type": "link", "data": {"title": "My Link"}}]}' - ]) - - @include('api::partials.endpoint', [ - 'method' => 'POST', - 'path' => '/bio/{bioId}/blocks', - 'description' => 'Add a new block to a biolink.', - 'body' => '{"type": "link", "data": {"title": "My Link", "url": "https://example.com"}}', - 'response' => '{"data": {"id": 1, "type": "link", "data": {"title": "My Link"}}}' - ]) - - @include('api::partials.endpoint', [ - 'method' => 'PUT', - 'path' => '/bio/{bioId}/blocks/{id}', - 'description' => 'Update a block.', - 'body' => '{"data": {"title": "Updated Link"}}', - 'response' => '{"data": {"id": 1, "type": "link", "data": {"title": "Updated Link"}}}' - ]) - - @include('api::partials.endpoint', [ - 'method' => 'DELETE', - 'path' => '/bio/{bioId}/blocks/{id}', - 'description' => 'Delete a block.', - 'response' => '{"message": "Deleted successfully"}' - ]) -
- - {{-- Short Links --}} - - - {{-- QR Codes --}} -
-

QR Codes

-

- Generate customisable QR codes for biolinks or any URL. -

- - @include('api::partials.endpoint', [ - 'method' => 'GET', - 'path' => '/bio/{id}/qr', - 'description' => 'Get QR code data for a biolink.', - 'response' => '{"data": {"svg": "...", "url": "https://lt.hn/mypage"}}' - ]) - - @include('api::partials.endpoint', [ - 'method' => 'GET', - 'path' => '/bio/{id}/qr/download', - 'description' => 'Download QR code as PNG/SVG. Query params: format (png|svg), size (100-2000).', - 'response' => 'Binary image data' - ]) - - @include('api::partials.endpoint', [ - 'method' => 'POST', - 'path' => '/qr/generate', - 'description' => 'Generate QR code for any URL.', - 'body' => '{"url": "https://example.com", "format": "svg", "size": 300}', - 'response' => '{"data": {"svg": "..."}}' - ]) - - @include('api::partials.endpoint', [ - 'method' => 'GET', - 'path' => '/qr/options', - 'description' => 'Get available QR code customisation options.', - 'response' => '{"data": {"formats": ["png", "svg"], "sizes": {"min": 100, "max": 2000}}}' - ]) -
- - {{-- Analytics --}} -
-

Analytics

-

- View analytics data for your biolinks. -

- - @include('api::partials.endpoint', [ - 'method' => 'GET', - 'path' => '/bio/{id}/analytics', - 'description' => 'Get analytics for a biolink. Query params: period (7d|30d|90d).', - 'response' => '{"data": {"views": 1234, "clicks": 567, "unique_visitors": 890}}' - ]) -
- - {{-- CTA --}} -
-

Try it out

-

Test endpoints interactively with Swagger UI.

- - Open Swagger UI - -
- -
-
- -
-@endsection diff --git a/packages/core-api/src/Website/Api/View/Blade/scalar.blade.php b/packages/core-api/src/Website/Api/View/Blade/scalar.blade.php deleted file mode 100644 index f996834..0000000 --- a/packages/core-api/src/Website/Api/View/Blade/scalar.blade.php +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - API Reference - Host UK - - - - - - -
- - -
- - diff --git a/packages/core-api/src/Website/Api/View/Blade/swagger.blade.php b/packages/core-api/src/Website/Api/View/Blade/swagger.blade.php deleted file mode 100644 index 89424af..0000000 --- a/packages/core-api/src/Website/Api/View/Blade/swagger.blade.php +++ /dev/null @@ -1,58 +0,0 @@ -@extends('api::layouts.docs') - -@section('title', 'Swagger UI') - -@push('head') - - -@endpush - -@section('content') -
-
-

Swagger UI

-

- Interactive API explorer. Try out endpoints directly from your browser. -

-
- -
-
-@endsection - -@push('scripts') - - - -@endpush diff --git a/packages/core-mcp/README.md b/packages/core-mcp/README.md deleted file mode 100644 index 8234704..0000000 --- a/packages/core-mcp/README.md +++ /dev/null @@ -1,203 +0,0 @@ -# Core MCP Package - -Model Context Protocol (MCP) tools and analytics for AI-powered automation and integrations. - -## Installation - -```bash -composer require host-uk/core-mcp -``` - -## Features - -### MCP Tool Registry -Extensible tool system for AI integrations: - -```php -use Core\Mcp\Tools\BaseTool; - -class GetProductsTool extends BaseTool -{ - public function name(): string - { - return 'get_products'; - } - - public function description(): string - { - return 'Retrieve a list of products from the workspace'; - } - - public function schema(JsonSchema $schema): array - { - return [ - 'limit' => $schema->integer('Maximum number of products to return'), - ]; - } - - public function handle(Request $request): Response - { - $products = Product::take($request->input('limit', 10))->get(); - return Response::text(json_encode($products)); - } -} -``` - -### Workspace Context Security -Prevents cross-tenant data leakage: - -```php -use Core\Mcp\Tools\Concerns\RequiresWorkspaceContext; - -class MyTool extends BaseTool -{ - use RequiresWorkspaceContext; - - // Automatically validates workspace context - // Throws exception if context is missing -} -``` - -### SQL Query Validation -Multi-layer protection for database queries: - -```php -use Core\Mcp\Services\SqlQueryValidator; - -$validator = new SqlQueryValidator(); -$validator->validate($query); // Throws if unsafe - -// Features: -// - Blocked keywords (INSERT, UPDATE, DELETE, DROP) -// - Pattern detection (stacked queries, hex encoding) -// - Whitelist matching -// - Comment stripping -``` - -### Tool Analytics -Track tool usage and performance: - -```php -use Core\Mcp\Services\ToolAnalyticsService; - -$analytics = app(ToolAnalyticsService::class); - -$stats = $analytics->getToolStats('get_products'); -// Returns: calls, avg_duration, error_rate, etc. -``` - -**Admin dashboard:** `/admin/mcp/analytics` - -### Tool Dependencies -Declare tool dependencies and validate at runtime: - -```php -use Core\Mcp\Dependencies\{HasDependencies, ToolDependency}; - -class AdvancedTool extends BaseTool implements HasDependencies -{ - public function dependencies(): array - { - return [ - new ToolDependency('get_products', DependencyType::REQUIRED), - new ToolDependency('send_email', DependencyType::OPTIONAL), - ]; - } -} -``` - -### MCP Playground -Interactive UI for testing tools: - -**Route:** `/admin/mcp/playground` - -**Features:** -- Tool browser with search -- Dynamic form generation -- JSON response viewer -- Conversation history -- Example pre-fill - -### Query EXPLAIN Analysis -Performance insights for database queries: - -```json -{ - "query": "SELECT * FROM users WHERE email = ?", - "explain": true -} -``` - -**Returns:** -- Raw EXPLAIN output -- Performance warnings -- Index usage analysis -- Optimization recommendations - -### Usage Quotas -Workspace-level rate limiting: - -```php -use Core\Mcp\Services\McpQuotaService; - -$quota = app(McpQuotaService::class); - -// Check if workspace can execute tool -if (!$quota->canExecute($workspace, 'expensive_tool')) { - throw new QuotaExceededException(); -} - -// Record execution -$quota->recordExecution($workspace, 'expensive_tool'); -``` - -## Configuration - -```php -// config/mcp.php - -return [ - 'database' => [ - 'connection' => 'readonly', // Dedicated read-only connection - 'use_whitelist' => true, - 'blocked_tables' => ['users', 'api_keys'], - ], - 'analytics' => [ - 'enabled' => true, - 'retention_days' => 90, - ], - 'quota' => [ - 'enabled' => true, - 'default_limit' => 1000, // Per workspace per day - ], -]; -``` - -## Security - -### Query Security (Defense in Depth) -1. **Read-only database user** (infrastructure) -2. **Blocked keywords** (application) -3. **Pattern validation** (application) -4. **Whitelist matching** (application) -5. **Table access controls** (application) - -### Workspace Isolation -- Context MUST come from authentication -- Cross-tenant access prevented by design -- Tools throw exceptions without context - -See [changelog/2026/jan/security.md](changelog/2026/jan/security.md) for security updates. - -## Requirements - -- PHP 8.2+ -- Laravel 11+ or 12+ - -## Changelog - -See [changelog/2026/jan/features.md](changelog/2026/jan/features.md) for recent changes. - -## License - -EUPL-1.2 - See [LICENSE](../../LICENSE) for details. diff --git a/packages/core-mcp/TODO.md b/packages/core-mcp/TODO.md deleted file mode 100644 index 62992bf..0000000 --- a/packages/core-mcp/TODO.md +++ /dev/null @@ -1,305 +0,0 @@ -# Core-MCP TODO - -## Testing & Quality Assurance - -### High Priority - -- [ ] **Test Coverage: SQL Query Validator** - Test injection prevention - - [ ] Test all forbidden SQL keywords (DROP, INSERT, UPDATE, DELETE, etc.) - - [ ] Test SQL injection attempts (UNION, boolean blinds, etc.) - - [ ] Test parameterized query validation - - [ ] Test subquery restrictions - - [ ] Test multi-statement detection - - **Estimated effort:** 4-5 hours - -- [ ] **Test Coverage: Workspace Context** - Test isolation and validation - - [ ] Test WorkspaceContext resolution from headers - - [ ] Test automatic workspace scoping in queries - - [ ] Test MissingWorkspaceContextException - - [ ] Test workspace boundary enforcement - - [ ] Test cross-workspace query prevention - - **Estimated effort:** 3-4 hours - -- [ ] **Test Coverage: Tool Analytics** - Test metrics tracking - - [ ] Test ToolAnalyticsService recording - - [ ] Test ToolStats DTO calculations - - [ ] Test performance percentiles (P95, P99) - - [ ] Test error rate calculations - - [ ] Test daily trend aggregation - - **Estimated effort:** 3-4 hours - -- [ ] **Test Coverage: Quota System** - Test limits and enforcement - - [ ] Test McpQuotaService tier limits - - [ ] Test quota exceeded detection - - [ ] Test quota reset timing - - [ ] Test workspace-scoped quotas - - [ ] Test custom quota overrides - - **Estimated effort:** 3-4 hours - -### Medium Priority - -- [ ] **Test Coverage: Tool Dependencies** - Test dependency validation - - [ ] Test ToolDependencyService resolution - - [ ] Test MissingDependencyException - - [ ] Test circular dependency detection - - [ ] Test version compatibility checking - - **Estimated effort:** 2-3 hours - -- [ ] **Test Coverage: Query Database Tool** - Test complete workflow - - [ ] Test SELECT query execution - - [ ] Test EXPLAIN plan analysis - - [ ] Test connection validation - - [ ] Test result formatting - - [ ] Test error handling - - **Estimated effort:** 3-4 hours - -### Low Priority - -- [ ] **Test Coverage: Tool Registry** - Test tool registration - - [ ] Test AgentToolRegistry with multiple tools - - [ ] Test tool discovery - - [ ] Test tool metadata - - **Estimated effort:** 2-3 hours - -## Security (Critical) - -### High Priority - Security Fixes Needed - -- [x] **COMPLETED: Database Connection Fallback** - Throw exception instead of fallback - - [x] Fixed to throw ForbiddenConnectionException - - [x] No silent fallback to default connection - - [x] Prevents accidental production data exposure - - **Completed:** January 2026 - -- [x] **COMPLETED: SQL Validator Regex Strengthening** - Stricter WHERE clause validation - - [x] Replaced permissive `.+` with restrictive character classes - - [x] Added explicit structure validation - - [x] Better detection of injection attempts - - **Completed:** January 2026 - -### Medium Priority - Additional Security - -- [ ] **Security: Query Result Size Limits** - Prevent data exfiltration - - [ ] Add max_rows configuration per tier - - [ ] Enforce result set limits - - [ ] Return truncation warnings - - [ ] Test with large result sets - - **Estimated effort:** 2-3 hours - -- [ ] **Security: Query Timeout Enforcement** - Prevent resource exhaustion - - [ ] Add per-query timeout configuration - - [ ] Kill long-running queries - - [ ] Log slow query attempts - - [ ] Test with expensive queries - - **Estimated effort:** 2-3 hours - -- [ ] **Security: Audit Logging** - Complete query audit trail - - [ ] Log all query attempts (success and failure) - - [ ] Include user, workspace, query, and bindings - - [ ] Add tamper-proof logging - - [ ] Implement log retention policy - - **Estimated effort:** 3-4 hours - -## Features & Enhancements - -### High Priority - -- [x] **COMPLETED: EXPLAIN Plan Analysis** - Query optimization insights - - [x] Added `explain` parameter to QueryDatabase tool - - [x] Returns human-readable performance analysis - - [x] Shows index usage and optimization opportunities - - **Completed:** January 2026 - -- [ ] **Feature: Query Templates** - Reusable parameterized queries - - [ ] Create query template system - - [ ] Support named parameters - - [ ] Add template validation - - [ ] Store templates per workspace - - [ ] Test with complex queries - - **Estimated effort:** 5-6 hours - - **Files:** `src/Mod/Mcp/Templates/` - -- [ ] **Feature: Schema Exploration Tools** - Database metadata access - - [ ] Add ListTables tool - - [ ] Add DescribeTable tool - - [ ] Add ListIndexes tool - - [ ] Respect information_schema restrictions - - [ ] Test with multiple database types - - **Estimated effort:** 4-5 hours - - **Files:** `src/Mod/Mcp/Tools/Schema/` - -### Medium Priority - -- [ ] **Enhancement: Query Result Caching** - Cache frequent queries - - [ ] Implement result caching with TTL - - [ ] Add cache key generation - - [ ] Support cache invalidation - - [ ] Test cache hit rates - - **Estimated effort:** 3-4 hours - -- [ ] **Enhancement: Query History** - Track agent queries - - [ ] Store query history per workspace - - [ ] Add query rerun capability - - [ ] Create history browser UI - - [ ] Add favorite queries - - **Estimated effort:** 4-5 hours - - **Files:** `src/Mod/Mcp/History/` - -- [ ] **Enhancement: Advanced Analytics** - Deeper insights - - [ ] Add query complexity scoring - - [ ] Track table access patterns - - [ ] Identify slow query patterns - - [ ] Create optimization recommendations - - **Estimated effort:** 5-6 hours - - **Files:** `src/Mod/Mcp/Analytics/` - -### Low Priority - -- [ ] **Enhancement: Multi-Database Support** - Query multiple databases - - [ ] Support cross-database queries - - [ ] Add database selection parameter - - [ ] Test with MySQL, PostgreSQL, SQLite - - **Estimated effort:** 4-5 hours - -- [ ] **Enhancement: Query Builder UI** - Visual query construction - - [ ] Create Livewire query builder component - - [ ] Add table/column selection - - [ ] Support WHERE clause builder - - [ ] Generate safe SQL - - **Estimated effort:** 8-10 hours - - **Files:** `src/Mod/Mcp/QueryBuilder/` - -## Tool Development - -### High Priority - -- [ ] **Tool: Create/Update Records** - Controlled data modification - - [ ] Create InsertRecord tool with strict validation - - [ ] Create UpdateRecord tool with WHERE requirements - - [ ] Implement record-level permissions - - [ ] Require explicit confirmation for modifications - - [ ] Test with workspace scoping - - **Estimated effort:** 6-8 hours - - **Files:** `src/Mod/Mcp/Tools/Modify/` - - **Note:** Requires careful security review - -- [ ] **Tool: Export Data** - Export query results - - [ ] Add ExportResults tool - - [ ] Support CSV, JSON, Excel formats - - [ ] Add row limits per tier - - [ ] Implement streaming for large exports - - **Estimated effort:** 4-5 hours - - **Files:** `src/Mod/Mcp/Tools/Export/` - -### Medium Priority - -- [ ] **Tool: Analyze Performance** - Database health insights - - [ ] Add TableStats tool (row count, size, etc.) - - [ ] Add SlowQueries tool - - [ ] Add IndexUsage tool - - [ ] Create performance dashboard - - **Estimated effort:** 5-6 hours - - **Files:** `src/Mod/Mcp/Tools/Performance/` - -- [ ] **Tool: Data Validation** - Validate data quality - - [ ] Add ValidateData tool - - [ ] Check for NULL values, duplicates - - [ ] Validate foreign key integrity - - [ ] Generate data quality report - - **Estimated effort:** 4-5 hours - - **Files:** `src/Mod/Mcp/Tools/Validation/` - -## Documentation - -- [x] **Guide: Creating MCP Tools** - Comprehensive tutorial - - [x] Document tool interface - - [x] Show parameter validation - - [x] Explain workspace context - - [x] Add dependency examples - - [x] Include security best practices - - **Completed:** January 2026 - - **File:** `docs/packages/mcp/creating-mcp-tools.md` - -- [x] **Guide: SQL Security** - Safe query patterns - - [x] Document allowed SQL patterns - - [x] Show parameterized query examples - - [x] Explain validation rules - - [x] List forbidden operations - - **Completed:** January 2026 - - **File:** `docs/packages/mcp/sql-security.md` - -- [x] **API Reference: All MCP Tools** - Complete tool catalog - - [x] Document each tool's parameters - - [x] Add usage examples - - [x] Show response formats - - [x] Include error cases - - **Completed:** January 2026 - - **File:** `docs/packages/mcp/tools-reference.md` - -## Code Quality - -- [ ] **Refactor: Extract SQL Parser** - Better query validation - - [ ] Create proper SQL parser - - [ ] Replace regex with AST parsing - - [ ] Support dialect-specific syntax - - [ ] Add comprehensive tests - - **Estimated effort:** 8-10 hours - -- [ ] **Refactor: Standardize Tool Responses** - Consistent API - - [ ] Create ToolResult DTO - - [ ] Standardize error responses - - [ ] Add response metadata - - [ ] Update all tools - - **Estimated effort:** 3-4 hours - -- [ ] **PHPStan: Fix Level 5 Errors** - Improve type safety - - [ ] Fix property type declarations - - [ ] Add missing return types - - [ ] Fix array shape types - - **Estimated effort:** 2-3 hours - -## Performance - -- [ ] **Optimization: Query Result Streaming** - Handle large results - - [ ] Implement cursor-based result streaming - - [ ] Add chunked response delivery - - [ ] Test with millions of rows - - **Estimated effort:** 3-4 hours - -- [ ] **Optimization: Connection Pooling** - Reuse database connections - - [ ] Implement connection pool - - [ ] Add connection health checks - - [ ] Test connection lifecycle - - **Estimated effort:** 3-4 hours - -## Infrastructure - -- [ ] **Monitoring: Alert on Suspicious Queries** - Security monitoring - - [ ] Detect unusual query patterns - - [ ] Alert on potential injection attempts - - [ ] Track query anomalies - - [ ] Create security dashboard - - **Estimated effort:** 4-5 hours - -- [ ] **CI/CD: Add Security Regression Tests** - Prevent vulnerabilities - - [ ] Test SQL injection prevention - - [ ] Test workspace isolation - - [ ] Test quota enforcement - - [ ] Fail CI on security issues - - **Estimated effort:** 3-4 hours - ---- - -## Completed (January 2026) - -- [x] **Security: Database Connection Validation** - Throws exception for invalid connections -- [x] **Security: SQL Validator Strengthening** - Stricter WHERE clause patterns -- [x] **Feature: EXPLAIN Plan Analysis** - Query optimization insights -- [x] **Tool Analytics System** - Complete usage tracking and metrics -- [x] **Quota System** - Tier-based limits with enforcement -- [x] **Workspace Context** - Automatic query scoping and validation -- [x] **Documentation: Creating MCP Tools Guide** - Complete tutorial with workspace context, dependencies, security -- [x] **Documentation: SQL Security Guide** - Allowed patterns, forbidden operations, injection prevention -- [x] **Documentation: MCP Tools API Reference** - All tools with parameters, examples, error handling - -*See `changelog/2026/jan/` for completed features and security fixes.* diff --git a/packages/core-mcp/changelog/2026/jan/features.md b/packages/core-mcp/changelog/2026/jan/features.md deleted file mode 100644 index d99e2bb..0000000 --- a/packages/core-mcp/changelog/2026/jan/features.md +++ /dev/null @@ -1,121 +0,0 @@ -# Core-MCP - January 2026 - -## Features Implemented - -### Workspace Context Security - -Prevents cross-tenant data leakage by requiring authenticated workspace context. - -**Files:** -- `Exceptions/MissingWorkspaceContextException.php` -- `Context/WorkspaceContext.php` - Value object -- `Tools/Concerns/RequiresWorkspaceContext.php` - Tool trait -- `Middleware/ValidateWorkspaceContext.php` - -**Security Guarantees:** -- Workspace context MUST come from authentication -- Cross-tenant access prevented by design -- Tools throw exceptions when called without context - ---- - -### Query Security - -Defence in depth for SQL injection prevention. - -**Files:** -- `Exceptions/ForbiddenQueryException.php` -- `Services/SqlQueryValidator.php` - Multi-layer validation - -**Features:** -- Blocked keywords: INSERT, UPDATE, DELETE, DROP, UNION -- Pattern detection: stacked queries, hex encoding, SLEEP/BENCHMARK -- Comment stripping to prevent obfuscation -- Query whitelist matching -- Read-only database connection support - -**Config:** `mcp.database.connection`, `mcp.database.use_whitelist`, `mcp.database.blocked_tables` - ---- - -### MCP Playground UI - -Interactive interface for testing MCP tools. - -**Files:** -- `Services/ToolRegistry.php` - Tool discovery and schemas -- `View/Modal/Admin/McpPlayground.php` - Livewire component -- `View/Blade/admin/mcp-playground.blade.php` - -**Features:** -- Tool browser with search and category filtering -- Dynamic form builder from JSON schemas -- JSON response viewer with syntax highlighting -- Conversation history (last 50 executions) -- Example input pre-fill -- API key validation - -**Route:** `GET /admin/mcp/playground` - ---- - -### Tool Usage Analytics - -Usage tracking and dashboard for MCP tools. - -**Files:** -- `Migrations/2026_01_26_*` - mcp_tool_metrics, mcp_tool_combinations -- `Models/ToolMetric.php` -- `DTO/ToolStats.php` -- `Services/ToolAnalyticsService.php` -- `Events/ToolExecuted.php` -- `Listeners/RecordToolExecution.php` -- `View/Modal/Admin/ToolAnalyticsDashboard.php` -- `View/Modal/Admin/ToolAnalyticsDetail.php` -- `Console/Commands/PruneMetricsCommand.php` - -**Features:** -- Per-tool call counts with daily granularity -- Average, min, max response times -- Error rates with threshold highlighting -- Tool combination tracking -- Admin dashboard with sortable tables -- Date range filtering - -**Routes:** -- `GET /admin/mcp/analytics` - Dashboard -- `GET /admin/mcp/analytics/tool/{name}` - Tool detail - -**Config:** `mcp.analytics.enabled`, `mcp.analytics.retention_days` - ---- - -### EXPLAIN Query Analysis - -Query optimization insights with automated performance analysis. - -**Files:** -- `Tools/QueryDatabase.php` - Added `explain` parameter -- Enhanced with human-readable performance interpretation - -**Features:** -- Optional EXPLAIN execution before query runs -- Detects full table scans -- Identifies missing indexes -- Warns about filesort and temporary tables -- Shows row count estimates -- Includes MySQL warnings when available - -**Usage:** -```json -{ - "query": "SELECT * FROM users WHERE email = 'test@example.com'", - "explain": true -} -``` - -**Response includes:** -- Raw EXPLAIN output -- Performance warnings (full scans, high row counts) -- Index usage analysis -- Optimization recommendations diff --git a/packages/core-mcp/changelog/2026/jan/security.md b/packages/core-mcp/changelog/2026/jan/security.md deleted file mode 100644 index 8399cdb..0000000 --- a/packages/core-mcp/changelog/2026/jan/security.md +++ /dev/null @@ -1,52 +0,0 @@ -# Core-MCP - January 2026 - Security Fixes - -## Critical - -### Database Connection Validation - -Fixed fallback behavior that could bypass read-only connection configuration. - -**Issue:** QueryDatabase tool would silently fall back to default database connection if configured MCP connection was invalid. - -**Fix:** Now throws `RuntimeException` with clear error message when configured connection doesn't exist. - -**Files:** -- `Tools/QueryDatabase.php` - Added connection validation - -**Impact:** Prevents accidental queries against production read-write connections. - ---- - -## High Priority - -### SQL Query Validator Strengthening - -Restricted WHERE clause patterns to prevent SQL injection vectors. - -**Issue:** Whitelist regex patterns used `.+` which was too permissive for WHERE clause validation. - -**Fix:** Replaced with strict character class restrictions: -- Only allows: alphanumeric, spaces, backticks, operators, quotes, parentheses -- Explicitly supports AND/OR logical operators -- Blocks function calls and subqueries -- Prevents nested SELECT statements - -**Files:** -- `Services/SqlQueryValidator.php` - Updated DEFAULT_WHITELIST patterns - -**Before:** -```php -'/^\s*SELECT\s+.*\s+FROM\s+`?\w+`?(\s+WHERE\s+.+)?/i' -``` - -**After:** -```php -'/^\s*SELECT\s+.*\s+FROM\s+`?\w+`?(\s+WHERE\s+[\w\s`.,!=<>\'"%()]+(\s+(AND|OR)\s+[\w\s`.,!=<>\'"%()]+)*)?/i' -``` - -**Defense in depth:** -- Read-only database user (infrastructure) -- Blocked keywords (application) -- Pattern validation (application) -- Whitelist matching (application) -- Table access controls (application) diff --git a/packages/core-mcp/composer.json b/packages/core-mcp/composer.json deleted file mode 100644 index d085cc1..0000000 --- a/packages/core-mcp/composer.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "host-uk/core-mcp", - "description": "MCP (Model Context Protocol) tools module for Core PHP framework", - "keywords": ["laravel", "mcp", "ai", "tools", "claude"], - "license": "EUPL-1.2", - "require": { - "php": "^8.2", - "host-uk/core": "@dev" - }, - "autoload": { - "psr-4": { - "Core\\Mod\\Mcp\\": "src/Mod/Mcp/", - "Core\\Website\\Mcp\\": "src/Website/Mcp/" - } - }, - "autoload-dev": { - "psr-4": { - "Core\\Mod\\Mcp\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [] - } - }, - "minimum-stability": "stable", - "prefer-stable": true -} diff --git a/packages/core-mcp/src/Mod/Mcp/Boot.php b/packages/core-mcp/src/Mod/Mcp/Boot.php deleted file mode 100644 index afc6a89..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Boot.php +++ /dev/null @@ -1,98 +0,0 @@ - - */ - public static array $listens = [ - AdminPanelBooting::class => 'onAdminPanel', - ConsoleBooting::class => 'onConsole', - McpToolsRegistering::class => 'onMcpTools', - ]; - - /** - * Register any application services. - */ - public function register(): void - { - $this->app->singleton(ToolRegistry::class); - $this->app->singleton(ToolAnalyticsService::class); - $this->app->singleton(McpQuotaService::class); - $this->app->singleton(ToolDependencyService::class); - $this->app->singleton(AuditLogService::class); - $this->app->singleton(ToolVersionService::class); - } - - /** - * Bootstrap any application services. - */ - public function boot(): void - { - $this->loadMigrationsFrom(__DIR__.'/Migrations'); - - // Register event listener for tool execution analytics - Event::listen(ToolExecuted::class, RecordToolExecution::class); - } - - // ------------------------------------------------------------------------- - // Event-driven handlers - // ------------------------------------------------------------------------- - - public function onAdminPanel(AdminPanelBooting $event): void - { - $event->views($this->moduleName, __DIR__.'/View/Blade'); - - if (file_exists(__DIR__.'/Routes/admin.php')) { - $event->routes(fn () => require __DIR__.'/Routes/admin.php'); - } - - $event->livewire('mcp.admin.api-key-manager', View\Modal\Admin\ApiKeyManager::class); - $event->livewire('mcp.admin.playground', View\Modal\Admin\Playground::class); - $event->livewire('mcp.admin.mcp-playground', View\Modal\Admin\McpPlayground::class); - $event->livewire('mcp.admin.request-log', View\Modal\Admin\RequestLog::class); - $event->livewire('mcp.admin.tool-analytics-dashboard', View\Modal\Admin\ToolAnalyticsDashboard::class); - $event->livewire('mcp.admin.tool-analytics-detail', View\Modal\Admin\ToolAnalyticsDetail::class); - $event->livewire('mcp.admin.quota-usage', View\Modal\Admin\QuotaUsage::class); - $event->livewire('mcp.admin.audit-log-viewer', View\Modal\Admin\AuditLogViewer::class); - $event->livewire('mcp.admin.tool-version-manager', View\Modal\Admin\ToolVersionManager::class); - } - - public function onConsole(ConsoleBooting $event): void - { - $event->command(Console\Commands\McpAgentServerCommand::class); - $event->command(Console\Commands\PruneMetricsCommand::class); - $event->command(Console\Commands\VerifyAuditLogCommand::class); - } - - public function onMcpTools(McpToolsRegistering $event): void - { - // MCP tool handlers will be registered here once extracted - // from the monolithic McpAgentServerCommand - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Console/Commands/CleanupToolCallLogsCommand.php b/packages/core-mcp/src/Mod/Mcp/Console/Commands/CleanupToolCallLogsCommand.php deleted file mode 100644 index 42923eb..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Console/Commands/CleanupToolCallLogsCommand.php +++ /dev/null @@ -1,111 +0,0 @@ -option('dry-run'); - $logRetentionDays = (int) ($this->option('days') ?? config('mcp.log_retention.days', 90)); - $statsRetentionDays = (int) ($this->option('stats-days') ?? config('mcp.log_retention.stats_days', 365)); - - $this->info('MCP Log Cleanup'.($dryRun ? ' (DRY RUN)' : '')); - $this->line(''); - $this->line("Detailed logs retention: {$logRetentionDays} days"); - $this->line("Statistics retention: {$statsRetentionDays} days"); - $this->line(''); - - $logsCutoff = now()->subDays($logRetentionDays); - $statsCutoff = now()->subDays($statsRetentionDays); - - // Clean up tool call logs - $toolCallsCount = McpToolCall::where('created_at', '<', $logsCutoff)->count(); - if ($toolCallsCount > 0) { - if ($dryRun) { - $this->line("Would delete {$toolCallsCount} tool call log(s) older than {$logsCutoff->toDateString()}"); - } else { - // Delete in chunks to avoid memory issues and lock contention - $deleted = $this->deleteInChunks(McpToolCall::class, 'created_at', $logsCutoff); - $this->info("Deleted {$deleted} tool call log(s)"); - } - } else { - $this->line('No tool call logs to clean up'); - } - - // Clean up API request logs - $apiRequestsCount = McpApiRequest::where('created_at', '<', $logsCutoff)->count(); - if ($apiRequestsCount > 0) { - if ($dryRun) { - $this->line("Would delete {$apiRequestsCount} API request log(s) older than {$logsCutoff->toDateString()}"); - } else { - $deleted = $this->deleteInChunks(McpApiRequest::class, 'created_at', $logsCutoff); - $this->info("Deleted {$deleted} API request log(s)"); - } - } else { - $this->line('No API request logs to clean up'); - } - - // Clean up aggregated statistics (longer retention) - $statsCount = McpToolCallStat::where('date', '<', $statsCutoff->toDateString())->count(); - if ($statsCount > 0) { - if ($dryRun) { - $this->line("Would delete {$statsCount} tool call stat(s) older than {$statsCutoff->toDateString()}"); - } else { - $deleted = McpToolCallStat::where('date', '<', $statsCutoff->toDateString())->delete(); - $this->info("Deleted {$deleted} tool call stat(s)"); - } - } else { - $this->line('No tool call stats to clean up'); - } - - $this->line(''); - $this->info('Cleanup complete.'); - - return self::SUCCESS; - } - - /** - * Delete records in chunks to avoid memory issues. - */ - protected function deleteInChunks(string $model, string $column, \DateTimeInterface $cutoff, int $chunkSize = 1000): int - { - $totalDeleted = 0; - - do { - $deleted = $model::where($column, '<', $cutoff) - ->limit($chunkSize) - ->delete(); - - $totalDeleted += $deleted; - - // Small pause to reduce database pressure - if ($deleted > 0) { - usleep(10000); // 10ms - } - } while ($deleted > 0); - - return $totalDeleted; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Console/Commands/McpAgentServerCommand.php b/packages/core-mcp/src/Mod/Mcp/Console/Commands/McpAgentServerCommand.php deleted file mode 100644 index 411752e..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Console/Commands/McpAgentServerCommand.php +++ /dev/null @@ -1,2064 +0,0 @@ -registerTools(); - $this->registerResources(); - - // Run MCP server loop - while (($line = fgets(STDIN)) !== false) { - $line = trim($line); - if (empty($line)) { - continue; - } - - try { - $request = json_decode($line, true, 512, JSON_THROW_ON_ERROR); - $response = $this->handleRequest($request); - - if ($response !== null) { - $this->sendResponse($response); - } - } catch (Throwable $e) { - Log::error('MCP Agent Server error', [ - 'error' => $e->getMessage(), - 'line' => $line, - ]); - - $this->sendResponse([ - 'jsonrpc' => '2.0', - 'id' => null, - 'error' => [ - 'code' => -32700, - 'message' => 'Parse error: '.$e->getMessage(), - ], - ]); - } - } - - return 0; - } - - protected function registerTools(): void - { - // Plan management tools - $this->tools['plan_list'] = [ - 'description' => 'List all work plans with their current status and progress', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'status' => [ - 'type' => 'string', - 'description' => 'Filter by status (draft, active, paused, completed, archived)', - 'enum' => ['draft', 'active', 'paused', 'completed', 'archived'], - ], - 'include_archived' => [ - 'type' => 'boolean', - 'description' => 'Include archived plans (default: false)', - ], - ], - ], - 'handler' => 'toolPlanList', - ]; - - $this->tools['plan_create'] = [ - 'description' => 'Create a new work plan with phases and tasks', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'title' => [ - 'type' => 'string', - 'description' => 'Plan title', - ], - 'slug' => [ - 'type' => 'string', - 'description' => 'URL-friendly identifier (auto-generated if not provided)', - ], - 'description' => [ - 'type' => 'string', - 'description' => 'Plan description', - ], - 'context' => [ - 'type' => 'object', - 'description' => 'Additional context (related files, dependencies, etc.)', - ], - 'phases' => [ - 'type' => 'array', - 'description' => 'Array of phase definitions with name, description, and tasks', - 'items' => [ - 'type' => 'object', - 'properties' => [ - 'name' => ['type' => 'string'], - 'description' => ['type' => 'string'], - 'tasks' => [ - 'type' => 'array', - 'items' => ['type' => 'string'], - ], - ], - ], - ], - ], - 'required' => ['title'], - ], - 'handler' => 'toolPlanCreate', - ]; - - $this->tools['plan_get'] = [ - 'description' => 'Get detailed information about a specific plan', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'slug' => [ - 'type' => 'string', - 'description' => 'Plan slug identifier', - ], - 'format' => [ - 'type' => 'string', - 'description' => 'Output format: json or markdown', - 'enum' => ['json', 'markdown'], - ], - ], - 'required' => ['slug'], - ], - 'handler' => 'toolPlanGet', - ]; - - $this->tools['plan_update_status'] = [ - 'description' => 'Update the status of a plan', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'slug' => [ - 'type' => 'string', - 'description' => 'Plan slug identifier', - ], - 'status' => [ - 'type' => 'string', - 'description' => 'New status', - 'enum' => ['draft', 'active', 'paused', 'completed'], - ], - ], - 'required' => ['slug', 'status'], - ], - 'handler' => 'toolPlanUpdateStatus', - ]; - - $this->tools['plan_archive'] = [ - 'description' => 'Archive a completed or abandoned plan', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'slug' => [ - 'type' => 'string', - 'description' => 'Plan slug identifier', - ], - 'reason' => [ - 'type' => 'string', - 'description' => 'Reason for archiving', - ], - ], - 'required' => ['slug'], - ], - 'handler' => 'toolPlanArchive', - ]; - - // Phase tools - $this->tools['phase_get'] = [ - 'description' => 'Get details of a specific phase within a plan', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'plan_slug' => [ - 'type' => 'string', - 'description' => 'Plan slug identifier', - ], - 'phase' => [ - 'type' => 'string', - 'description' => 'Phase identifier (number or name)', - ], - ], - 'required' => ['plan_slug', 'phase'], - ], - 'handler' => 'toolPhaseGet', - ]; - - $this->tools['phase_update_status'] = [ - 'description' => 'Update the status of a phase', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'plan_slug' => [ - 'type' => 'string', - 'description' => 'Plan slug identifier', - ], - 'phase' => [ - 'type' => 'string', - 'description' => 'Phase identifier (number or name)', - ], - 'status' => [ - 'type' => 'string', - 'description' => 'New status', - 'enum' => ['pending', 'in_progress', 'completed', 'blocked', 'skipped'], - ], - 'notes' => [ - 'type' => 'string', - 'description' => 'Optional notes about the status change', - ], - ], - 'required' => ['plan_slug', 'phase', 'status'], - ], - 'handler' => 'toolPhaseUpdateStatus', - ]; - - $this->tools['phase_add_checkpoint'] = [ - 'description' => 'Add a checkpoint note to a phase', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'plan_slug' => [ - 'type' => 'string', - 'description' => 'Plan slug identifier', - ], - 'phase' => [ - 'type' => 'string', - 'description' => 'Phase identifier (number or name)', - ], - 'note' => [ - 'type' => 'string', - 'description' => 'Checkpoint note', - ], - 'context' => [ - 'type' => 'object', - 'description' => 'Additional context data', - ], - ], - 'required' => ['plan_slug', 'phase', 'note'], - ], - 'handler' => 'toolPhaseAddCheckpoint', - ]; - - // Task tools - $this->tools['task_toggle'] = [ - 'description' => 'Toggle a task completion status', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'plan_slug' => [ - 'type' => 'string', - 'description' => 'Plan slug identifier', - ], - 'phase' => [ - 'type' => 'string', - 'description' => 'Phase identifier (number or name)', - ], - 'task_index' => [ - 'type' => 'integer', - 'description' => 'Task index (0-based)', - ], - ], - 'required' => ['plan_slug', 'phase', 'task_index'], - ], - 'handler' => 'toolTaskToggle', - ]; - - $this->tools['task_update'] = [ - 'description' => 'Update task details (status, notes)', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'plan_slug' => [ - 'type' => 'string', - 'description' => 'Plan slug identifier', - ], - 'phase' => [ - 'type' => 'string', - 'description' => 'Phase identifier (number or name)', - ], - 'task_index' => [ - 'type' => 'integer', - 'description' => 'Task index (0-based)', - ], - 'status' => [ - 'type' => 'string', - 'description' => 'New status', - 'enum' => ['pending', 'in_progress', 'completed', 'blocked', 'skipped'], - ], - 'notes' => [ - 'type' => 'string', - 'description' => 'Task notes', - ], - ], - 'required' => ['plan_slug', 'phase', 'task_index'], - ], - 'handler' => 'toolTaskUpdate', - ]; - - // Session tools (for multi-agent handoff) - $this->tools['session_start'] = [ - 'description' => 'Start a new agent session for a plan', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'plan_slug' => [ - 'type' => 'string', - 'description' => 'Plan slug identifier', - ], - 'agent_type' => [ - 'type' => 'string', - 'description' => 'Type of agent (e.g., opus, sonnet, haiku)', - ], - 'context' => [ - 'type' => 'object', - 'description' => 'Initial session context', - ], - ], - 'required' => ['agent_type'], - ], - 'handler' => 'toolSessionStart', - ]; - - $this->tools['session_log'] = [ - 'description' => 'Log an entry in the current session', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'message' => [ - 'type' => 'string', - 'description' => 'Log message', - ], - 'type' => [ - 'type' => 'string', - 'description' => 'Log type', - 'enum' => ['info', 'progress', 'decision', 'error', 'checkpoint'], - ], - 'data' => [ - 'type' => 'object', - 'description' => 'Additional data to log', - ], - ], - 'required' => ['message'], - ], - 'handler' => 'toolSessionLog', - ]; - - $this->tools['session_artifact'] = [ - 'description' => 'Record an artifact created/modified during the session', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'path' => [ - 'type' => 'string', - 'description' => 'File or resource path', - ], - 'action' => [ - 'type' => 'string', - 'description' => 'Action performed', - 'enum' => ['created', 'modified', 'deleted', 'reviewed'], - ], - 'description' => [ - 'type' => 'string', - 'description' => 'Description of changes', - ], - ], - 'required' => ['path', 'action'], - ], - 'handler' => 'toolSessionArtifact', - ]; - - $this->tools['session_handoff'] = [ - 'description' => 'Prepare session for handoff to another agent', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'summary' => [ - 'type' => 'string', - 'description' => 'Summary of work done', - ], - 'next_steps' => [ - 'type' => 'array', - 'description' => 'Recommended next steps', - 'items' => ['type' => 'string'], - ], - 'blockers' => [ - 'type' => 'array', - 'description' => 'Any blockers encountered', - 'items' => ['type' => 'string'], - ], - 'context_for_next' => [ - 'type' => 'object', - 'description' => 'Context to pass to next agent', - ], - ], - 'required' => ['summary'], - ], - 'handler' => 'toolSessionHandoff', - ]; - - $this->tools['session_end'] = [ - 'description' => 'End the current session', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'status' => [ - 'type' => 'string', - 'description' => 'Final session status', - 'enum' => ['completed', 'handed_off', 'paused', 'failed'], - ], - 'summary' => [ - 'type' => 'string', - 'description' => 'Final summary', - ], - ], - 'required' => ['status'], - ], - 'handler' => 'toolSessionEnd', - ]; - - // State tools (persistent workspace state) - $this->tools['state_get'] = [ - 'description' => 'Get a workspace state value', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'plan_slug' => [ - 'type' => 'string', - 'description' => 'Plan slug identifier', - ], - 'key' => [ - 'type' => 'string', - 'description' => 'State key', - ], - ], - 'required' => ['plan_slug', 'key'], - ], - 'handler' => 'toolStateGet', - ]; - - $this->tools['state_set'] = [ - 'description' => 'Set a workspace state value', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'plan_slug' => [ - 'type' => 'string', - 'description' => 'Plan slug identifier', - ], - 'key' => [ - 'type' => 'string', - 'description' => 'State key', - ], - 'value' => [ - 'type' => ['string', 'number', 'boolean', 'object', 'array'], - 'description' => 'State value', - ], - 'category' => [ - 'type' => 'string', - 'description' => 'State category for organisation', - ], - ], - 'required' => ['plan_slug', 'key', 'value'], - ], - 'handler' => 'toolStateSet', - ]; - - $this->tools['state_list'] = [ - 'description' => 'List all state values for a plan', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'plan_slug' => [ - 'type' => 'string', - 'description' => 'Plan slug identifier', - ], - 'category' => [ - 'type' => 'string', - 'description' => 'Filter by category', - ], - ], - 'required' => ['plan_slug'], - ], - 'handler' => 'toolStateList', - ]; - - // Template tools - $this->tools['template_list'] = [ - 'description' => 'List available plan templates', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'category' => [ - 'type' => 'string', - 'description' => 'Filter by category', - ], - ], - ], - 'handler' => 'toolTemplateList', - ]; - - $this->tools['template_preview'] = [ - 'description' => 'Preview a template with variables', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'template' => [ - 'type' => 'string', - 'description' => 'Template name/slug', - ], - 'variables' => [ - 'type' => 'object', - 'description' => 'Variable values for the template', - ], - ], - 'required' => ['template'], - ], - 'handler' => 'toolTemplatePreview', - ]; - - $this->tools['template_create_plan'] = [ - 'description' => 'Create a new plan from a template', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'template' => [ - 'type' => 'string', - 'description' => 'Template name/slug', - ], - 'variables' => [ - 'type' => 'object', - 'description' => 'Variable values for the template', - ], - 'slug' => [ - 'type' => 'string', - 'description' => 'Custom slug for the plan', - ], - ], - 'required' => ['template', 'variables'], - ], - 'handler' => 'toolTemplateCreatePlan', - ]; - - // Content generation tools - $this->tools['content_status'] = [ - 'description' => 'Get content generation pipeline status (AI provider availability, brief counts)', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [], - ], - 'handler' => 'toolContentStatus', - ]; - - $this->tools['content_brief_create'] = [ - 'description' => 'Create a content brief for AI generation', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'title' => [ - 'type' => 'string', - 'description' => 'Content title', - ], - 'content_type' => [ - 'type' => 'string', - 'description' => 'Type of content', - 'enum' => ['help_article', 'blog_post', 'landing_page', 'social_post'], - ], - 'service' => [ - 'type' => 'string', - 'description' => 'Service context (e.g., BioHost, QRHost)', - ], - 'keywords' => [ - 'type' => 'array', - 'description' => 'SEO keywords to include', - 'items' => ['type' => 'string'], - ], - 'target_word_count' => [ - 'type' => 'integer', - 'description' => 'Target word count (default: 800)', - ], - 'description' => [ - 'type' => 'string', - 'description' => 'Brief description of what to write about', - ], - 'difficulty' => [ - 'type' => 'string', - 'description' => 'Target audience level', - 'enum' => ['beginner', 'intermediate', 'advanced'], - ], - 'plan_slug' => [ - 'type' => 'string', - 'description' => 'Link to an existing plan', - ], - ], - 'required' => ['title', 'content_type'], - ], - 'handler' => 'toolContentBriefCreate', - ]; - - $this->tools['content_brief_list'] = [ - 'description' => 'List content briefs with optional status filter', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'status' => [ - 'type' => 'string', - 'description' => 'Filter by status', - 'enum' => ['pending', 'queued', 'generating', 'review', 'published', 'failed'], - ], - 'limit' => [ - 'type' => 'integer', - 'description' => 'Maximum results (default: 20)', - ], - ], - ], - 'handler' => 'toolContentBriefList', - ]; - - $this->tools['content_brief_get'] = [ - 'description' => 'Get details of a specific content brief including generated content', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'id' => [ - 'type' => 'integer', - 'description' => 'Brief ID', - ], - ], - 'required' => ['id'], - ], - 'handler' => 'toolContentBriefGet', - ]; - - $this->tools['content_generate'] = [ - 'description' => 'Generate content for a brief using AI pipeline (Gemini draft → Claude refine)', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'brief_id' => [ - 'type' => 'integer', - 'description' => 'Brief ID to generate content for', - ], - 'mode' => [ - 'type' => 'string', - 'description' => 'Generation mode', - 'enum' => ['draft', 'refine', 'full'], - ], - 'sync' => [ - 'type' => 'boolean', - 'description' => 'Run synchronously (wait for result) vs queue for async processing', - ], - ], - 'required' => ['brief_id'], - ], - 'handler' => 'toolContentGenerate', - ]; - - $this->tools['content_batch_generate'] = [ - 'description' => 'Queue multiple briefs for batch content generation', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'limit' => [ - 'type' => 'integer', - 'description' => 'Maximum briefs to process (default: 5)', - ], - 'mode' => [ - 'type' => 'string', - 'description' => 'Generation mode', - 'enum' => ['draft', 'refine', 'full'], - ], - ], - ], - 'handler' => 'toolContentBatchGenerate', - ]; - - $this->tools['content_from_plan'] = [ - 'description' => 'Create content briefs from plan tasks and queue for generation', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'plan_slug' => [ - 'type' => 'string', - 'description' => 'Plan slug to generate content from', - ], - 'content_type' => [ - 'type' => 'string', - 'description' => 'Type of content to generate', - 'enum' => ['help_article', 'blog_post', 'landing_page', 'social_post'], - ], - 'service' => [ - 'type' => 'string', - 'description' => 'Service context', - ], - 'limit' => [ - 'type' => 'integer', - 'description' => 'Maximum briefs to create (default: 5)', - ], - 'target_word_count' => [ - 'type' => 'integer', - 'description' => 'Target word count per article', - ], - ], - 'required' => ['plan_slug'], - ], - 'handler' => 'toolContentFromPlan', - ]; - - $this->tools['content_usage_stats'] = [ - 'description' => 'Get AI usage statistics (tokens, costs) for content generation', - 'inputSchema' => [ - 'type' => 'object', - 'properties' => [ - 'period' => [ - 'type' => 'string', - 'description' => 'Time period for stats', - 'enum' => ['day', 'week', 'month', 'year'], - ], - ], - ], - 'handler' => 'toolContentUsageStats', - ]; - } - - protected function registerResources(): void - { - $this->resources['plans://all'] = [ - 'name' => 'All Plans Overview', - 'description' => 'Overview of all work plans and their status', - 'mimeType' => 'text/markdown', - 'handler' => 'resourceAllPlans', - ]; - - // Dynamic plan resources are handled in getResourcesList - } - - protected function handleRequest(array $request): ?array - { - $method = $request['method'] ?? ''; - $id = $request['id'] ?? null; - $params = $request['params'] ?? []; - - return match ($method) { - 'initialize' => $this->handleInitialize($id, $params), - 'tools/list' => $this->handleToolsList($id), - 'tools/call' => $this->handleToolsCall($id, $params), - 'resources/list' => $this->handleResourcesList($id), - 'resources/read' => $this->handleResourcesRead($id, $params), - 'notifications/initialized' => null, - default => $this->errorResponse($id, -32601, "Method not found: {$method}"), - }; - } - - protected function handleInitialize(mixed $id, array $params): array - { - return [ - 'jsonrpc' => '2.0', - 'id' => $id, - 'result' => [ - 'protocolVersion' => '2024-11-05', - 'capabilities' => [ - 'tools' => ['listChanged' => true], - 'resources' => ['subscribe' => false, 'listChanged' => true], - ], - 'serverInfo' => [ - 'name' => 'hosthub-agent', - 'version' => '1.0.0', - ], - ], - ]; - } - - protected function handleToolsList(mixed $id): array - { - $tools = []; - - foreach ($this->tools as $name => $tool) { - $tools[] = [ - 'name' => $name, - 'description' => $tool['description'], - 'inputSchema' => $tool['inputSchema'], - ]; - } - - return [ - 'jsonrpc' => '2.0', - 'id' => $id, - 'result' => ['tools' => $tools], - ]; - } - - protected function handleToolsCall(mixed $id, array $params): array - { - $toolName = $params['name'] ?? ''; - $args = $params['arguments'] ?? []; - $startTime = microtime(true); - - if (! isset($this->tools[$toolName])) { - return $this->errorResponse($id, -32602, "Unknown tool: {$toolName}"); - } - - try { - $handler = $this->tools[$toolName]['handler']; - $result = $this->$handler($args); - - // Log tool call - $this->logToolCall($toolName, $args, $result, $startTime, true); - - return [ - 'jsonrpc' => '2.0', - 'id' => $id, - 'result' => [ - 'content' => [ - [ - 'type' => 'text', - 'text' => json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), - ], - ], - ], - ]; - } catch (Throwable $e) { - $this->logToolCall($toolName, $args, ['error' => $e->getMessage()], $startTime, false); - - Log::error('MCP tool error', [ - 'tool' => $toolName, - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - - return $this->errorResponse($id, -32603, $e->getMessage()); - } - } - - protected function handleResourcesList(mixed $id): array - { - $resources = []; - - // Static resources - foreach ($this->resources as $uri => $resource) { - $resources[] = [ - 'uri' => $uri, - 'name' => $resource['name'], - 'description' => $resource['description'], - 'mimeType' => $resource['mimeType'], - ]; - } - - // Dynamic plan resources - $plans = AgentPlan::notArchived()->get(); - foreach ($plans as $plan) { - $resources[] = [ - 'uri' => "plans://{$plan->slug}", - 'name' => $plan->title, - 'description' => "Work plan: {$plan->title}", - 'mimeType' => 'text/markdown', - ]; - } - - return [ - 'jsonrpc' => '2.0', - 'id' => $id, - 'result' => ['resources' => $resources], - ]; - } - - protected function handleResourcesRead(mixed $id, array $params): array - { - $uri = $params['uri'] ?? ''; - - // Handle static resources - if (isset($this->resources[$uri])) { - $handler = $this->resources[$uri]['handler']; - $content = $this->$handler(); - - return [ - 'jsonrpc' => '2.0', - 'id' => $id, - 'result' => [ - 'contents' => [ - [ - 'uri' => $uri, - 'mimeType' => $this->resources[$uri]['mimeType'], - 'text' => $content, - ], - ], - ], - ]; - } - - // Handle dynamic plan resources - if (str_starts_with($uri, 'plans://')) { - $path = substr($uri, 9); // Remove 'plans://' - $parts = explode('/', $path); - $slug = $parts[0]; - - // plans://{slug}/phases/{order} - if (count($parts) === 3 && $parts[1] === 'phases') { - $content = $this->resourcePhaseChecklist($slug, (int) $parts[2]); - } - // plans://{slug}/state/{key} - elseif (count($parts) === 3 && $parts[1] === 'state') { - $content = $this->resourceStateValue($slug, $parts[2]); - } - // plans://{slug} - else { - $content = $this->resourcePlanDocument($slug); - } - - return [ - 'jsonrpc' => '2.0', - 'id' => $id, - 'result' => [ - 'contents' => [ - [ - 'uri' => $uri, - 'mimeType' => 'text/markdown', - 'text' => $content, - ], - ], - ], - ]; - } - - // Handle session resources - if (str_starts_with($uri, 'sessions://')) { - $path = substr($uri, 11); - $parts = explode('/', $path); - - if (count($parts) === 2 && $parts[1] === 'context') { - $content = $this->resourceSessionContext($parts[0]); - - return [ - 'jsonrpc' => '2.0', - 'id' => $id, - 'result' => [ - 'contents' => [ - [ - 'uri' => $uri, - 'mimeType' => 'text/markdown', - 'text' => $content, - ], - ], - ], - ]; - } - } - - return $this->errorResponse($id, -32602, "Resource not found: {$uri}"); - } - - protected function sendResponse(array $response): void - { - echo json_encode($response, JSON_UNESCAPED_SLASHES)."\n"; - flush(); - } - - protected function logToolCall(string $tool, array $args, array $result, float $startTime, bool $success): void - { - $duration = (int) ((microtime(true) - $startTime) * 1000); - - // Use the log() method which updates daily stats automatically - McpToolCall::log( - serverId: 'hosthub-agent', - toolName: $tool, - params: $args, - success: $success, - durationMs: $duration, - errorMessage: $success ? null : ($result['error'] ?? null), - errorCode: $success ? null : ($result['code'] ?? null), - resultSummary: $success ? $result : null, - sessionId: $this->currentSessionId, - ); - } - - // ===== TOOL IMPLEMENTATIONS ===== - - protected function toolPlanList(array $args): array - { - $query = AgentPlan::with('agentPhases') - ->orderBy('updated_at', 'desc'); - - if (! ($args['include_archived'] ?? false)) { - $query->notArchived(); - } - - if (! empty($args['status'])) { - $query->where('status', $args['status']); - } - - $plans = $query->get(); - - return [ - 'plans' => $plans->map(fn ($plan) => [ - 'slug' => $plan->slug, - 'title' => $plan->title, - 'status' => $plan->status, - 'progress' => $plan->getProgress(), - 'updated_at' => $plan->updated_at->toIso8601String(), - ])->all(), - 'total' => $plans->count(), - ]; - } - - protected function toolPlanCreate(array $args): array - { - $slug = $args['slug'] ?? Str::slug($args['title']).'-'.Str::random(6); - - if (AgentPlan::where('slug', $slug)->exists()) { - return ['error' => "Plan with slug '{$slug}' already exists"]; - } - - $plan = AgentPlan::create([ - 'slug' => $slug, - 'title' => $args['title'], - 'description' => $args['description'] ?? null, - 'status' => 'draft', - 'context' => $args['context'] ?? [], - ]); - - // Create phases if provided - if (! empty($args['phases'])) { - foreach ($args['phases'] as $order => $phaseData) { - $tasks = collect($phaseData['tasks'] ?? [])->map(fn ($task) => [ - 'name' => $task, - 'status' => 'pending', - ])->all(); - - AgentPhase::create([ - 'agent_plan_id' => $plan->id, - 'name' => $phaseData['name'], - 'description' => $phaseData['description'] ?? null, - 'order' => $order + 1, - 'status' => 'pending', - 'tasks' => $tasks, - ]); - } - } - - $plan->load('agentPhases'); - - return [ - 'success' => true, - 'plan' => [ - 'slug' => $plan->slug, - 'title' => $plan->title, - 'status' => $plan->status, - 'phases' => $plan->agentPhases->count(), - ], - ]; - } - - protected function toolPlanGet(array $args): array - { - $plan = AgentPlan::with('agentPhases') - ->where('slug', $args['slug']) - ->first(); - - if (! $plan) { - return ['error' => "Plan not found: {$args['slug']}"]; - } - - $format = $args['format'] ?? 'json'; - - if ($format === 'markdown') { - return ['markdown' => $plan->toMarkdown()]; - } - - return [ - 'plan' => [ - 'slug' => $plan->slug, - 'title' => $plan->title, - 'description' => $plan->description, - 'status' => $plan->status, - 'context' => $plan->context, - 'progress' => $plan->getProgress(), - 'phases' => $plan->agentPhases->map(fn ($phase) => [ - 'order' => $phase->order, - 'name' => $phase->name, - 'description' => $phase->description, - 'status' => $phase->status, - 'tasks' => $phase->tasks, - 'checkpoints' => $phase->checkpoints, - ])->all(), - 'created_at' => $plan->created_at->toIso8601String(), - 'updated_at' => $plan->updated_at->toIso8601String(), - ], - ]; - } - - protected function toolPlanUpdateStatus(array $args): array - { - $plan = AgentPlan::where('slug', $args['slug'])->first(); - - if (! $plan) { - return ['error' => "Plan not found: {$args['slug']}"]; - } - - $plan->update(['status' => $args['status']]); - - return [ - 'success' => true, - 'plan' => [ - 'slug' => $plan->slug, - 'status' => $plan->fresh()->status, - ], - ]; - } - - protected function toolPlanArchive(array $args): array - { - $plan = AgentPlan::where('slug', $args['slug'])->first(); - - if (! $plan) { - return ['error' => "Plan not found: {$args['slug']}"]; - } - - $plan->archive($args['reason'] ?? null); - - return [ - 'success' => true, - 'plan' => [ - 'slug' => $plan->slug, - 'status' => 'archived', - 'archived_at' => $plan->archived_at->toIso8601String(), - ], - ]; - } - - protected function toolPhaseGet(array $args): array - { - $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); - - if (! $plan) { - return ['error' => "Plan not found: {$args['plan_slug']}"]; - } - - $phase = $this->findPhase($plan, $args['phase']); - - if (! $phase) { - return ['error' => "Phase not found: {$args['phase']}"]; - } - - return [ - 'phase' => [ - 'order' => $phase->order, - 'name' => $phase->name, - 'description' => $phase->description, - 'status' => $phase->status, - 'tasks' => $phase->tasks, - 'checkpoints' => $phase->checkpoints, - 'dependencies' => $phase->dependencies, - ], - ]; - } - - protected function toolPhaseUpdateStatus(array $args): array - { - $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); - - if (! $plan) { - return ['error' => "Plan not found: {$args['plan_slug']}"]; - } - - $phase = $this->findPhase($plan, $args['phase']); - - if (! $phase) { - return ['error' => "Phase not found: {$args['phase']}"]; - } - - $updateData = ['status' => $args['status']]; - - if (! empty($args['notes'])) { - $phase->addCheckpoint($args['notes'], ['status_change' => $args['status']]); - } - - $phase->update($updateData); - - return [ - 'success' => true, - 'phase' => [ - 'order' => $phase->order, - 'name' => $phase->name, - 'status' => $phase->fresh()->status, - ], - ]; - } - - protected function toolPhaseAddCheckpoint(array $args): array - { - $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); - - if (! $plan) { - return ['error' => "Plan not found: {$args['plan_slug']}"]; - } - - $phase = $this->findPhase($plan, $args['phase']); - - if (! $phase) { - return ['error' => "Phase not found: {$args['phase']}"]; - } - - $phase->addCheckpoint($args['note'], $args['context'] ?? []); - - return [ - 'success' => true, - 'checkpoints' => $phase->fresh()->checkpoints, - ]; - } - - protected function toolTaskToggle(array $args): array - { - $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); - - if (! $plan) { - return ['error' => "Plan not found: {$args['plan_slug']}"]; - } - - $phase = $this->findPhase($plan, $args['phase']); - - if (! $phase) { - return ['error' => "Phase not found: {$args['phase']}"]; - } - - $tasks = $phase->tasks ?? []; - $index = $args['task_index']; - - if (! isset($tasks[$index])) { - return ['error' => "Task not found at index: {$index}"]; - } - - $currentStatus = is_string($tasks[$index]) - ? 'pending' - : ($tasks[$index]['status'] ?? 'pending'); - - $newStatus = $currentStatus === 'completed' ? 'pending' : 'completed'; - - if (is_string($tasks[$index])) { - $tasks[$index] = [ - 'name' => $tasks[$index], - 'status' => $newStatus, - ]; - } else { - $tasks[$index]['status'] = $newStatus; - } - - $phase->update(['tasks' => $tasks]); - - return [ - 'success' => true, - 'task' => $tasks[$index], - 'plan_progress' => $plan->fresh()->getProgress(), - ]; - } - - protected function toolTaskUpdate(array $args): array - { - $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); - - if (! $plan) { - return ['error' => "Plan not found: {$args['plan_slug']}"]; - } - - $phase = $this->findPhase($plan, $args['phase']); - - if (! $phase) { - return ['error' => "Phase not found: {$args['phase']}"]; - } - - $tasks = $phase->tasks ?? []; - $index = $args['task_index']; - - if (! isset($tasks[$index])) { - return ['error' => "Task not found at index: {$index}"]; - } - - if (is_string($tasks[$index])) { - $tasks[$index] = ['name' => $tasks[$index], 'status' => 'pending']; - } - - if (isset($args['status'])) { - $tasks[$index]['status'] = $args['status']; - } - - if (isset($args['notes'])) { - $tasks[$index]['notes'] = $args['notes']; - } - - $phase->update(['tasks' => $tasks]); - - return [ - 'success' => true, - 'task' => $tasks[$index], - ]; - } - - protected function toolSessionStart(array $args): array - { - $plan = null; - if (! empty($args['plan_slug'])) { - $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); - } - - $sessionId = 'ses_'.Str::random(12); - $this->currentSessionId = $sessionId; - - $session = AgentSession::create([ - 'session_id' => $sessionId, - 'agent_plan_id' => $plan?->id, - 'agent_type' => $args['agent_type'], - 'status' => 'active', - 'started_at' => now(), - 'context_summary' => $args['context'] ?? [], - ]); - - return [ - 'success' => true, - 'session' => [ - 'session_id' => $session->session_id, - 'agent_type' => $session->agent_type, - 'plan' => $plan?->slug, - 'status' => $session->status, - ], - ]; - } - - protected function toolSessionLog(array $args): array - { - if (! $this->currentSessionId) { - return ['error' => 'No active session. Call session_start first.']; - } - - $session = AgentSession::where('session_id', $this->currentSessionId)->first(); - - if (! $session) { - return ['error' => 'Session not found']; - } - - $session->addWorkLogEntry( - $args['message'], - $args['type'] ?? 'info', - $args['data'] ?? [] - ); - - return ['success' => true, 'logged' => $args['message']]; - } - - protected function toolSessionArtifact(array $args): array - { - if (! $this->currentSessionId) { - return ['error' => 'No active session. Call session_start first.']; - } - - $session = AgentSession::where('session_id', $this->currentSessionId)->first(); - - if (! $session) { - return ['error' => 'Session not found']; - } - - $session->addArtifact( - $args['path'], - $args['action'], - $args['description'] ?? null - ); - - return ['success' => true, 'artifact' => $args['path']]; - } - - protected function toolSessionHandoff(array $args): array - { - if (! $this->currentSessionId) { - return ['error' => 'No active session. Call session_start first.']; - } - - $session = AgentSession::where('session_id', $this->currentSessionId)->first(); - - if (! $session) { - return ['error' => 'Session not found']; - } - - $session->prepareHandoff( - $args['summary'], - $args['next_steps'] ?? [], - $args['blockers'] ?? [], - $args['context_for_next'] ?? [] - ); - - return [ - 'success' => true, - 'handoff_context' => $session->getHandoffContext(), - ]; - } - - protected function toolSessionEnd(array $args): array - { - if (! $this->currentSessionId) { - return ['error' => 'No active session']; - } - - $session = AgentSession::where('session_id', $this->currentSessionId)->first(); - - if (! $session) { - return ['error' => 'Session not found']; - } - - $session->end($args['status'], $args['summary'] ?? null); - $this->currentSessionId = null; - - return [ - 'success' => true, - 'session' => [ - 'session_id' => $session->session_id, - 'status' => $session->status, - 'duration' => $session->getDurationFormatted(), - ], - ]; - } - - protected function toolStateGet(array $args): array - { - $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); - - if (! $plan) { - return ['error' => "Plan not found: {$args['plan_slug']}"]; - } - - $state = $plan->states()->where('key', $args['key'])->first(); - - if (! $state) { - return ['error' => "State not found: {$args['key']}"]; - } - - return [ - 'key' => $state->key, - 'value' => $state->value, - 'category' => $state->category, - 'updated_at' => $state->updated_at->toIso8601String(), - ]; - } - - protected function toolStateSet(array $args): array - { - $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); - - if (! $plan) { - return ['error' => "Plan not found: {$args['plan_slug']}"]; - } - - $state = AgentWorkspaceState::updateOrCreate( - [ - 'agent_plan_id' => $plan->id, - 'key' => $args['key'], - ], - [ - 'value' => $args['value'], - 'category' => $args['category'] ?? 'general', - ] - ); - - return [ - 'success' => true, - 'state' => [ - 'key' => $state->key, - 'value' => $state->value, - 'category' => $state->category, - ], - ]; - } - - protected function toolStateList(array $args): array - { - $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); - - if (! $plan) { - return ['error' => "Plan not found: {$args['plan_slug']}"]; - } - - $query = $plan->states(); - - if (! empty($args['category'])) { - $query->where('category', $args['category']); - } - - $states = $query->get(); - - return [ - 'states' => $states->map(fn ($state) => [ - 'key' => $state->key, - 'value' => $state->value, - 'category' => $state->category, - ])->all(), - 'total' => $states->count(), - ]; - } - - protected function toolTemplateList(array $args): array - { - $templateService = app(PlanTemplateService::class); - $templates = $templateService->listTemplates(); - - if (! empty($args['category'])) { - $templates = array_filter($templates, fn ($t) => ($t['category'] ?? '') === $args['category']); - } - - return [ - 'templates' => array_values($templates), - 'total' => count($templates), - ]; - } - - protected function toolTemplatePreview(array $args): array - { - $templateService = app(PlanTemplateService::class); - $templateSlug = $args['template']; - $variables = $args['variables'] ?? []; - - $preview = $templateService->previewTemplate($templateSlug, $variables); - - if (! $preview) { - return ['error' => "Template not found: {$templateSlug}"]; - } - - return [ - 'template' => $templateSlug, - 'preview' => $preview, - ]; - } - - protected function toolTemplateCreatePlan(array $args): array - { - $templateService = app(PlanTemplateService::class); - $templateSlug = $args['template']; - $variables = $args['variables'] ?? []; - - $options = []; - - if (! empty($args['slug'])) { - $options['slug'] = $args['slug']; - } - - $plan = $templateService->createPlan($templateSlug, $variables, $options); - - if (! $plan) { - return ['error' => 'Failed to create plan from template']; - } - - return [ - 'success' => true, - 'plan' => [ - 'slug' => $plan->slug, - 'title' => $plan->title, - 'status' => $plan->status, - 'phases' => $plan->agentPhases->count(), - 'total_tasks' => $plan->getProgress()['total'], - ], - 'commands' => [ - 'view' => "php artisan plan:show {$plan->slug}", - 'activate' => "php artisan plan:status {$plan->slug} --set=active", - ], - ]; - } - - // ===== CONTENT GENERATION TOOL IMPLEMENTATIONS ===== - - protected function toolContentStatus(array $args): array - { - $gateway = app(AIGatewayService::class); - - return [ - 'providers' => [ - 'gemini' => $gateway->isGeminiAvailable(), - 'claude' => $gateway->isClaudeAvailable(), - ], - 'pipeline_available' => $gateway->isAvailable(), - 'briefs' => [ - 'pending' => ContentBrief::pending()->count(), - 'queued' => ContentBrief::where('status', ContentBrief::STATUS_QUEUED)->count(), - 'generating' => ContentBrief::where('status', ContentBrief::STATUS_GENERATING)->count(), - 'review' => ContentBrief::needsReview()->count(), - 'published' => ContentBrief::where('status', ContentBrief::STATUS_PUBLISHED)->count(), - 'failed' => ContentBrief::where('status', ContentBrief::STATUS_FAILED)->count(), - ], - ]; - } - - protected function toolContentBriefCreate(array $args): array - { - $plan = null; - if (! empty($args['plan_slug'])) { - $plan = AgentPlan::where('slug', $args['plan_slug'])->first(); - } - - $brief = ContentBrief::create([ - 'title' => $args['title'], - 'slug' => Str::slug($args['title']).'-'.Str::random(6), - 'content_type' => $args['content_type'], - 'service' => $args['service'] ?? null, - 'description' => $args['description'] ?? null, - 'keywords' => $args['keywords'] ?? null, - 'target_word_count' => $args['target_word_count'] ?? 800, - 'difficulty' => $args['difficulty'] ?? null, - 'status' => ContentBrief::STATUS_PENDING, - 'metadata' => $plan ? [ - 'plan_id' => $plan->id, - 'plan_slug' => $plan->slug, - ] : null, - ]); - - return [ - 'success' => true, - 'brief' => [ - 'id' => $brief->id, - 'title' => $brief->title, - 'slug' => $brief->slug, - 'status' => $brief->status, - 'content_type' => $brief->content_type, - ], - ]; - } - - protected function toolContentBriefList(array $args): array - { - $query = ContentBrief::query()->orderBy('created_at', 'desc'); - - if (! empty($args['status'])) { - $query->where('status', $args['status']); - } - - $limit = $args['limit'] ?? 20; - $briefs = $query->limit($limit)->get(); - - return [ - 'briefs' => $briefs->map(fn ($brief) => [ - 'id' => $brief->id, - 'title' => $brief->title, - 'status' => $brief->status, - 'content_type' => $brief->content_type, - 'service' => $brief->service, - 'created_at' => $brief->created_at->toIso8601String(), - ])->all(), - 'total' => $briefs->count(), - ]; - } - - protected function toolContentBriefGet(array $args): array - { - $brief = ContentBrief::find($args['id']); - - if (! $brief) { - return ['error' => "Brief not found: {$args['id']}"]; - } - - return [ - 'brief' => [ - 'id' => $brief->id, - 'title' => $brief->title, - 'slug' => $brief->slug, - 'status' => $brief->status, - 'content_type' => $brief->content_type, - 'service' => $brief->service, - 'description' => $brief->description, - 'keywords' => $brief->keywords, - 'target_word_count' => $brief->target_word_count, - 'difficulty' => $brief->difficulty, - 'draft_output' => $brief->draft_output, - 'refined_output' => $brief->refined_output, - 'final_content' => $brief->final_content, - 'best_content' => $brief->best_content, - 'error_message' => $brief->error_message, - 'generation_log' => $brief->generation_log, - 'total_cost' => $brief->total_cost, - 'created_at' => $brief->created_at->toIso8601String(), - 'generated_at' => $brief->generated_at?->toIso8601String(), - 'refined_at' => $brief->refined_at?->toIso8601String(), - ], - ]; - } - - protected function toolContentGenerate(array $args): array - { - $brief = ContentBrief::find($args['brief_id']); - - if (! $brief) { - return ['error' => "Brief not found: {$args['brief_id']}"]; - } - - $gateway = app(AIGatewayService::class); - - if (! $gateway->isAvailable()) { - return ['error' => 'AI providers not configured. Set GOOGLE_AI_API_KEY and ANTHROPIC_API_KEY.']; - } - - $mode = $args['mode'] ?? 'full'; - $sync = $args['sync'] ?? false; - - if ($sync) { - try { - if ($mode === 'full') { - $result = $gateway->generateAndRefine($brief); - - return [ - 'success' => true, - 'brief_id' => $brief->id, - 'status' => $brief->fresh()->status, - 'draft' => [ - 'model' => $result['draft']->model, - 'tokens' => $result['draft']->totalTokens(), - 'cost' => $result['draft']->estimateCost(), - ], - 'refined' => [ - 'model' => $result['refined']->model, - 'tokens' => $result['refined']->totalTokens(), - 'cost' => $result['refined']->estimateCost(), - ], - ]; - } elseif ($mode === 'draft') { - $response = $gateway->generateDraft($brief); - $brief->markDraftComplete($response->content); - - return [ - 'success' => true, - 'brief_id' => $brief->id, - 'status' => $brief->fresh()->status, - 'draft' => [ - 'model' => $response->model, - 'tokens' => $response->totalTokens(), - 'cost' => $response->estimateCost(), - ], - ]; - } elseif ($mode === 'refine') { - if (! $brief->isGenerated()) { - return ['error' => 'No draft to refine. Generate draft first.']; - } - $response = $gateway->refineDraft($brief, $brief->draft_output); - $brief->markRefined($response->content); - - return [ - 'success' => true, - 'brief_id' => $brief->id, - 'status' => $brief->fresh()->status, - 'refined' => [ - 'model' => $response->model, - 'tokens' => $response->totalTokens(), - 'cost' => $response->estimateCost(), - ], - ]; - } - } catch (\Exception $e) { - $brief->markFailed($e->getMessage()); - - return ['error' => $e->getMessage()]; - } - } - - // Async - queue for processing - $brief->markQueued(); - GenerateContentJob::dispatch($brief, $mode); - - return [ - 'success' => true, - 'queued' => true, - 'brief_id' => $brief->id, - 'mode' => $mode, - 'message' => 'Brief queued for generation', - ]; - } - - protected function toolContentBatchGenerate(array $args): array - { - $limit = $args['limit'] ?? 5; - $mode = $args['mode'] ?? 'full'; - - $briefs = ContentBrief::readyToProcess()->limit($limit)->get(); - - if ($briefs->isEmpty()) { - return ['message' => 'No briefs ready for processing', 'queued' => 0]; - } - - foreach ($briefs as $brief) { - GenerateContentJob::dispatch($brief, $mode); - } - - return [ - 'success' => true, - 'queued' => $briefs->count(), - 'mode' => $mode, - 'brief_ids' => $briefs->pluck('id')->all(), - ]; - } - - protected function toolContentFromPlan(array $args): array - { - $plan = AgentPlan::with('agentPhases') - ->where('slug', $args['plan_slug']) - ->first(); - - if (! $plan) { - return ['error' => "Plan not found: {$args['plan_slug']}"]; - } - - $limit = $args['limit'] ?? 5; - $contentType = $args['content_type'] ?? 'help_article'; - $service = $args['service'] ?? ($plan->metadata['service'] ?? null); - $wordCount = $args['target_word_count'] ?? 800; - - $phases = $plan->agentPhases() - ->whereIn('status', ['pending', 'in_progress']) - ->get(); - - if ($phases->isEmpty()) { - return ['message' => 'No pending phases in plan', 'created' => 0]; - } - - $briefsCreated = []; - - foreach ($phases as $phase) { - $tasks = $phase->getTasks(); - - foreach ($tasks as $index => $task) { - if (count($briefsCreated) >= $limit) { - break 2; - } - - $taskName = is_string($task) ? $task : ($task['name'] ?? ''); - $taskStatus = is_array($task) ? ($task['status'] ?? 'pending') : 'pending'; - - if ($taskStatus === 'completed') { - continue; - } - - $brief = ContentBrief::create([ - 'title' => $taskName, - 'slug' => Str::slug($taskName).'-'.time(), - 'content_type' => $contentType, - 'service' => $service, - 'target_word_count' => $wordCount, - 'status' => ContentBrief::STATUS_QUEUED, - 'metadata' => [ - 'plan_id' => $plan->id, - 'plan_slug' => $plan->slug, - 'phase_id' => $phase->id, - 'phase_order' => $phase->order, - 'task_index' => $index, - ], - ]); - - GenerateContentJob::dispatch($brief, 'full'); - $briefsCreated[] = [ - 'id' => $brief->id, - 'title' => $brief->title, - ]; - } - } - - return [ - 'success' => true, - 'plan' => $plan->slug, - 'created' => count($briefsCreated), - 'briefs' => $briefsCreated, - ]; - } - - protected function toolContentUsageStats(array $args): array - { - $period = $args['period'] ?? 'month'; - $stats = AIUsage::statsForWorkspace(null, $period); - - return [ - 'period' => $period, - 'total_requests' => $stats['total_requests'], - 'total_input_tokens' => $stats['total_input_tokens'], - 'total_output_tokens' => $stats['total_output_tokens'], - 'total_cost' => number_format($stats['total_cost'], 4), - 'by_provider' => $stats['by_provider'], - 'by_purpose' => $stats['by_purpose'], - ]; - } - - // ===== RESOURCE IMPLEMENTATIONS ===== - - protected function resourceAllPlans(): string - { - $plans = AgentPlan::with('agentPhases')->notArchived()->orderBy('updated_at', 'desc')->get(); - - $md = "# Work Plans\n\n"; - $md .= '**Total:** '.$plans->count()." plan(s)\n\n"; - - foreach ($plans->groupBy('status') as $status => $group) { - $md .= '## '.ucfirst($status).' ('.$group->count().")\n\n"; - - foreach ($group as $plan) { - $progress = $plan->getProgress(); - $md .= "- **[{$plan->slug}]** {$plan->title} - {$progress['percentage']}%\n"; - } - $md .= "\n"; - } - - return $md; - } - - protected function resourcePlanDocument(string $slug): string - { - $plan = AgentPlan::with('agentPhases')->where('slug', $slug)->first(); - - if (! $plan) { - return "Plan not found: {$slug}"; - } - - return $plan->toMarkdown(); - } - - protected function resourcePhaseChecklist(string $slug, int $phaseOrder): string - { - $plan = AgentPlan::where('slug', $slug)->first(); - - if (! $plan) { - return "Plan not found: {$slug}"; - } - - $phase = $plan->agentPhases()->where('order', $phaseOrder)->first(); - - if (! $phase) { - return "Phase not found: {$phaseOrder}"; - } - - $md = "# Phase {$phase->order}: {$phase->name}\n\n"; - $md .= "**Status:** {$phase->getStatusIcon()} {$phase->status}\n\n"; - - if ($phase->description) { - $md .= "{$phase->description}\n\n"; - } - - $md .= "## Tasks\n\n"; - - foreach ($phase->tasks ?? [] as $task) { - $status = is_string($task) ? 'pending' : ($task['status'] ?? 'pending'); - $name = is_string($task) ? $task : ($task['name'] ?? 'Unknown'); - $icon = $status === 'completed' ? '✅' : '⬜'; - $md .= "- {$icon} {$name}\n"; - } - - return $md; - } - - protected function resourceStateValue(string $slug, string $key): string - { - $plan = AgentPlan::where('slug', $slug)->first(); - - if (! $plan) { - return "Plan not found: {$slug}"; - } - - $state = $plan->states()->where('key', $key)->first(); - - if (! $state) { - return "State key not found: {$key}"; - } - - return $state->getFormattedValue(); - } - - protected function resourceSessionContext(string $sessionId): string - { - $session = AgentSession::where('session_id', $sessionId)->first(); - - if (! $session) { - return "Session not found: {$sessionId}"; - } - - $context = $session->getHandoffContext(); - - $md = "# Session: {$session->session_id}\n\n"; - $md .= "**Agent:** {$session->agent_type}\n"; - $md .= "**Status:** {$session->status}\n"; - $md .= "**Duration:** {$session->getDurationFormatted()}\n\n"; - - if ($session->plan) { - $md .= "## Plan\n\n"; - $md .= "**{$session->plan->title}** ({$session->plan->slug})\n\n"; - } - - if (! empty($context['context_summary'])) { - $md .= "## Context Summary\n\n"; - $md .= json_encode($context['context_summary'], JSON_PRETTY_PRINT)."\n\n"; - } - - if (! empty($context['handoff_notes'])) { - $md .= "## Handoff Notes\n\n"; - $md .= json_encode($context['handoff_notes'], JSON_PRETTY_PRINT)."\n\n"; - } - - if (! empty($context['artifacts'])) { - $md .= "## Artifacts\n\n"; - foreach ($context['artifacts'] as $artifact) { - $md .= "- {$artifact['action']}: {$artifact['path']}\n"; - } - $md .= "\n"; - } - - return $md; - } - - // ===== HELPERS ===== - - protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase - { - if (is_numeric($identifier)) { - return $plan->agentPhases()->where('order', (int) $identifier)->first(); - } - - return $plan->agentPhases() - ->where(function ($query) use ($identifier) { - $query->where('name', $identifier) - ->orWhere('order', $identifier); - }) - ->first(); - } - - protected function errorResponse(mixed $id, int $code, string $message): array - { - return [ - 'jsonrpc' => '2.0', - 'id' => $id, - 'error' => [ - 'code' => $code, - 'message' => $message, - ], - ]; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Console/Commands/McpMonitorCommand.php b/packages/core-mcp/src/Mod/Mcp/Console/Commands/McpMonitorCommand.php deleted file mode 100644 index d3869b8..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Console/Commands/McpMonitorCommand.php +++ /dev/null @@ -1,199 +0,0 @@ -argument('action'); - - return match ($action) { - 'status' => $this->showStatus($monitoring), - 'alerts' => $this->checkAlerts($monitoring), - 'export' => $this->exportMetrics($monitoring), - 'report' => $this->showReport($monitoring), - 'prometheus' => $this->showPrometheus($monitoring), - default => $this->showHelp(), - }; - } - - protected function showStatus(McpMonitoringService $monitoring): int - { - $health = $monitoring->getHealthStatus(); - - if ($this->option('json')) { - $this->line(json_encode($health, JSON_PRETTY_PRINT)); - - return 0; - } - - $statusColor = match ($health['status']) { - 'healthy' => 'green', - 'degraded' => 'yellow', - 'critical' => 'red', - default => 'white', - }; - - $this->newLine(); - $this->line("MCP Health Status: ".strtoupper($health['status']).''); - $this->newLine(); - - $this->table( - ['Metric', 'Value'], - [ - ['Total Calls (24h)', number_format($health['metrics']['total_calls'])], - ['Success Rate', $health['metrics']['success_rate'].'%'], - ['Error Rate', $health['metrics']['error_rate'].'%'], - ['Avg Duration', $health['metrics']['avg_duration_ms'].'ms'], - ] - ); - - if (count($health['issues']) > 0) { - $this->newLine(); - $this->warn('Issues Detected:'); - - foreach ($health['issues'] as $issue) { - $icon = $issue['severity'] === 'critical' ? '!!' : '!'; - $this->line(" [{$icon}] {$issue['message']}"); - } - } - - $this->newLine(); - $this->line('Checked at: '.$health['checked_at'].''); - - return $health['status'] === 'critical' ? 1 : 0; - } - - protected function checkAlerts(McpMonitoringService $monitoring): int - { - $alerts = $monitoring->checkAlerts(); - - if ($this->option('json')) { - $this->line(json_encode($alerts, JSON_PRETTY_PRINT)); - - return count($alerts) > 0 ? 1 : 0; - } - - if (count($alerts) === 0) { - $this->info('No alerts detected.'); - - return 0; - } - - $this->warn(count($alerts).' alert(s) detected:'); - $this->newLine(); - - foreach ($alerts as $alert) { - $severityColor = $alert['severity'] === 'critical' ? 'red' : 'yellow'; - $this->line("[{$alert['severity']}] {$alert['message']}"); - } - - return 1; - } - - protected function exportMetrics(McpMonitoringService $monitoring): int - { - $monitoring->exportMetrics(); - $this->info('Metrics exported to monitoring channel.'); - - return 0; - } - - protected function showReport(McpMonitoringService $monitoring): int - { - $days = (int) $this->option('days'); - $report = $monitoring->getSummaryReport($days); - - if ($this->option('json')) { - $this->line(json_encode($report, JSON_PRETTY_PRINT)); - - return 0; - } - - $this->newLine(); - $this->line("MCP Summary Report ({$days} days)"); - $this->line("Period: {$report['period']['from']} to {$report['period']['to']}"); - $this->newLine(); - - // Overview - $this->line('Overview:'); - $this->table( - ['Metric', 'Value'], - [ - ['Total Calls', number_format($report['overview']['total_calls'])], - ['Success Rate', $report['overview']['success_rate'].'%'], - ['Avg Duration', $report['overview']['avg_duration_ms'].'ms'], - ['Unique Tools', $report['overview']['unique_tools']], - ['Unique Servers', $report['overview']['unique_servers']], - ] - ); - - // Top tools - if (count($report['top_tools']) > 0) { - $this->newLine(); - $this->line('Top Tools:'); - - $toolRows = []; - foreach ($report['top_tools'] as $tool) { - $toolRows[] = [ - $tool->tool_name, - number_format($tool->total_calls), - $tool->success_rate.'%', - round($tool->avg_duration ?? 0).'ms', - ]; - } - - $this->table(['Tool', 'Calls', 'Success Rate', 'Avg Duration'], $toolRows); - } - - // Anomalies - if (count($report['anomalies']) > 0) { - $this->newLine(); - $this->warn('Anomalies Detected:'); - - foreach ($report['anomalies'] as $anomaly) { - $this->line(" - [{$anomaly['tool']}] {$anomaly['message']}"); - } - } - - $this->newLine(); - $this->line('Generated: '.$report['generated_at'].''); - - return 0; - } - - protected function showPrometheus(McpMonitoringService $monitoring): int - { - $metrics = $monitoring->getPrometheusMetrics(); - $this->line($metrics); - - return 0; - } - - protected function showHelp(): int - { - $this->error('Unknown action. Available actions: status, alerts, export, report, prometheus'); - - return 1; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Console/Commands/PruneMetricsCommand.php b/packages/core-mcp/src/Mod/Mcp/Console/Commands/PruneMetricsCommand.php deleted file mode 100644 index 0c088f7..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Console/Commands/PruneMetricsCommand.php +++ /dev/null @@ -1,97 +0,0 @@ -option('dry-run'); - $retentionDays = (int) ($this->option('days') ?? config('mcp.analytics.retention_days', 90)); - - $this->info('MCP Metrics Pruning'.($dryRun ? ' (DRY RUN)' : '')); - $this->line(''); - $this->line("Retention period: {$retentionDays} days"); - $this->line(''); - - $cutoffDate = now()->subDays($retentionDays)->toDateString(); - - // Prune tool metrics - $metricsCount = ToolMetric::where('date', '<', $cutoffDate)->count(); - - if ($metricsCount > 0) { - if ($dryRun) { - $this->line("Would delete {$metricsCount} tool metric record(s) older than {$cutoffDate}"); - } else { - $deleted = $this->deleteInChunks(ToolMetric::class, 'date', $cutoffDate); - $this->info("Deleted {$deleted} tool metric record(s)"); - } - } else { - $this->line('No tool metrics to prune'); - } - - // Prune tool combinations - $combinationsCount = DB::table('mcp_tool_combinations') - ->where('date', '<', $cutoffDate) - ->count(); - - if ($combinationsCount > 0) { - if ($dryRun) { - $this->line("Would delete {$combinationsCount} tool combination record(s) older than {$cutoffDate}"); - } else { - $deleted = DB::table('mcp_tool_combinations') - ->where('date', '<', $cutoffDate) - ->delete(); - $this->info("Deleted {$deleted} tool combination record(s)"); - } - } else { - $this->line('No tool combinations to prune'); - } - - $this->line(''); - $this->info('Pruning complete.'); - - return self::SUCCESS; - } - - /** - * Delete records in chunks to avoid memory issues. - */ - protected function deleteInChunks(string $model, string $column, string $cutoff, int $chunkSize = 1000): int - { - $totalDeleted = 0; - - do { - $deleted = $model::where($column, '<', $cutoff) - ->limit($chunkSize) - ->delete(); - - $totalDeleted += $deleted; - - // Small pause to reduce database pressure - if ($deleted > 0) { - usleep(10000); // 10ms - } - } while ($deleted > 0); - - return $totalDeleted; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Console/Commands/VerifyAuditLogCommand.php b/packages/core-mcp/src/Mod/Mcp/Console/Commands/VerifyAuditLogCommand.php deleted file mode 100644 index c6f67ef..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Console/Commands/VerifyAuditLogCommand.php +++ /dev/null @@ -1,104 +0,0 @@ -option('from') ? (int) $this->option('from') : null; - $toId = $this->option('to') ? (int) $this->option('to') : null; - $jsonOutput = $this->option('json'); - - if (! $jsonOutput) { - $this->info('Verifying MCP audit log integrity...'); - $this->newLine(); - } - - $result = $auditLogService->verifyChain($fromId, $toId); - - if ($jsonOutput) { - $this->line(json_encode($result, JSON_PRETTY_PRINT)); - - return $result['valid'] ? self::SUCCESS : self::FAILURE; - } - - // Display results - $this->displayResults($result); - - return $result['valid'] ? self::SUCCESS : self::FAILURE; - } - - /** - * Display verification results. - */ - protected function displayResults(array $result): void - { - // Summary table - $this->table( - ['Metric', 'Value'], - [ - ['Total Entries', number_format($result['total'])], - ['Verified', number_format($result['verified'])], - ['Status', $result['valid'] ? 'VALID' : 'INVALID'], - ['Issues Found', count($result['issues'])], - ] - ); - - if ($result['valid']) { - $this->newLine(); - $this->info('Audit log integrity verified successfully.'); - $this->info('The hash chain is intact and no tampering has been detected.'); - - return; - } - - // Display issues - $this->newLine(); - $this->error('Integrity issues detected!'); - $this->newLine(); - - foreach ($result['issues'] as $issue) { - $this->warn("Entry #{$issue['id']}: {$issue['type']}"); - $this->line(" {$issue['message']}"); - - if (isset($issue['expected'])) { - $this->line(" Expected: {$issue['expected']}"); - } - - if (isset($issue['actual'])) { - $this->line(" Actual: {$issue['actual']}"); - } - - $this->newLine(); - } - - $this->error('The audit log may have been tampered with. Please investigate immediately.'); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Context/WorkspaceContext.php b/packages/core-mcp/src/Mod/Mcp/Context/WorkspaceContext.php deleted file mode 100644 index 1ce6876..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Context/WorkspaceContext.php +++ /dev/null @@ -1,112 +0,0 @@ -id, - workspace: $workspace, - ); - } - - /** - * Create context from a workspace ID (lazy loads workspace when needed). - */ - public static function fromId(int $workspaceId): self - { - return new self(workspaceId: $workspaceId); - } - - /** - * Create context from request attributes. - * - * @throws MissingWorkspaceContextException If no workspace context is available - */ - public static function fromRequest(mixed $request, string $toolName = 'unknown'): self - { - // Try to get workspace from request attributes (set by middleware) - $workspace = $request->attributes->get('mcp_workspace') - ?? $request->attributes->get('workspace'); - - if ($workspace instanceof Workspace) { - return self::fromWorkspace($workspace); - } - - // Try to get API key's workspace - $apiKey = $request->attributes->get('api_key'); - if ($apiKey?->workspace_id) { - return new self( - workspaceId: $apiKey->workspace_id, - workspace: $apiKey->workspace, - ); - } - - // Try authenticated user's default workspace - $user = $request->user(); - if ($user && method_exists($user, 'defaultHostWorkspace')) { - $workspace = $user->defaultHostWorkspace(); - if ($workspace) { - return self::fromWorkspace($workspace); - } - } - - throw new MissingWorkspaceContextException($toolName); - } - - /** - * Get the workspace model, loading it if necessary. - */ - public function getWorkspace(): Workspace - { - if ($this->workspace) { - return $this->workspace; - } - - return Workspace::findOrFail($this->workspaceId); - } - - /** - * Check if this context has a specific workspace ID. - */ - public function hasWorkspaceId(int $workspaceId): bool - { - return $this->workspaceId === $workspaceId; - } - - /** - * Validate that a resource belongs to this workspace. - * - * @throws \RuntimeException If the resource doesn't belong to this workspace - */ - public function validateOwnership(int $resourceWorkspaceId, string $resourceType = 'resource'): void - { - if ($resourceWorkspaceId !== $this->workspaceId) { - throw new \RuntimeException( - "Access denied: {$resourceType} does not belong to the authenticated workspace." - ); - } - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Controllers/McpApiController.php b/packages/core-mcp/src/Mod/Mcp/Controllers/McpApiController.php deleted file mode 100644 index 19ff660..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Controllers/McpApiController.php +++ /dev/null @@ -1,492 +0,0 @@ -loadRegistry(); - - $servers = collect($registry['servers'] ?? []) - ->map(fn ($ref) => $this->loadServerSummary($ref['id'])) - ->filter() - ->values(); - - return response()->json([ - 'servers' => $servers, - 'count' => $servers->count(), - ]); - } - - /** - * Get server details with tools and resources. - * - * GET /api/v1/mcp/servers/{id} - */ - public function server(Request $request, string $id): JsonResponse - { - $server = $this->loadServerFull($id); - - if (! $server) { - return response()->json(['error' => 'Server not found'], 404); - } - - return response()->json($server); - } - - /** - * List tools for a specific server. - * - * GET /api/v1/mcp/servers/{id}/tools - */ - public function tools(Request $request, string $id): JsonResponse - { - $server = $this->loadServerFull($id); - - if (! $server) { - return response()->json(['error' => 'Server not found'], 404); - } - - return response()->json([ - 'server' => $id, - 'tools' => $server['tools'] ?? [], - 'count' => count($server['tools'] ?? []), - ]); - } - - /** - * Execute a tool on an MCP server. - * - * POST /api/v1/mcp/tools/call - */ - public function callTool(Request $request): JsonResponse - { - $validated = $request->validate([ - 'server' => 'required|string|max:64', - 'tool' => 'required|string|max:128', - 'arguments' => 'nullable|array', - ]); - - $server = $this->loadServerFull($validated['server']); - if (! $server) { - return response()->json(['error' => 'Server not found'], 404); - } - - // Verify tool exists - $toolDef = collect($server['tools'] ?? [])->firstWhere('name', $validated['tool']); - if (! $toolDef) { - return response()->json(['error' => 'Tool not found'], 404); - } - - // Validate arguments against tool's input schema - $validationErrors = $this->validateToolArguments($toolDef, $validated['arguments'] ?? []); - if (! empty($validationErrors)) { - return response()->json([ - 'error' => 'validation_failed', - 'message' => 'Tool arguments do not match input schema', - 'validation_errors' => $validationErrors, - ], 422); - } - - // Get API key for logging - $apiKey = $request->attributes->get('api_key'); - $workspace = $apiKey?->workspace; - - $startTime = microtime(true); - - try { - // Execute the tool via artisan command - $result = $this->executeToolViaArtisan( - $validated['server'], - $validated['tool'], - $validated['arguments'] ?? [] - ); - - $durationMs = (int) ((microtime(true) - $startTime) * 1000); - - // Log the call - $this->logToolCall($apiKey, $validated, $result, $durationMs, true); - - // Record quota usage - $this->recordQuotaUsage($workspace); - - // Dispatch webhooks - $this->dispatchWebhook($apiKey, $validated, true, $durationMs); - - $response = [ - 'success' => true, - 'server' => $validated['server'], - 'tool' => $validated['tool'], - 'result' => $result, - 'duration_ms' => $durationMs, - ]; - - // Log full request for debugging/replay - $this->logApiRequest($request, $validated, 200, $response, $durationMs, $apiKey); - - return response()->json($response); - } catch (\Throwable $e) { - $durationMs = (int) ((microtime(true) - $startTime) * 1000); - - $this->logToolCall($apiKey, $validated, null, $durationMs, false, $e->getMessage()); - - // Dispatch webhooks (even on failure) - $this->dispatchWebhook($apiKey, $validated, false, $durationMs, $e->getMessage()); - - $response = [ - 'success' => false, - 'error' => $e->getMessage(), - 'server' => $validated['server'], - 'tool' => $validated['tool'], - ]; - - // Log full request for debugging/replay - $this->logApiRequest($request, $validated, 500, $response, $durationMs, $apiKey, $e->getMessage()); - - return response()->json($response, 500); - } - } - - /** - * Read a resource from an MCP server. - * - * GET /api/v1/mcp/resources/{uri} - * - * NOTE: Resource reading is not yet implemented. Returns 501 Not Implemented. - */ - public function resource(Request $request, string $uri): JsonResponse - { - // Parse URI format: server://resource/path - if (! preg_match('/^([a-z0-9-]+):\/\/(.+)$/', $uri, $matches)) { - return response()->json(['error' => 'Invalid resource URI format'], 400); - } - - $serverId = $matches[1]; - - $server = $this->loadServerFull($serverId); - if (! $server) { - return response()->json(['error' => 'Server not found'], 404); - } - - // Resource reading not yet implemented - return response()->json([ - 'error' => 'not_implemented', - 'message' => 'MCP resource reading is not yet implemented. Use tool calls instead.', - 'uri' => $uri, - ], 501); - } - - /** - * Execute tool via artisan MCP server command. - */ - protected function executeToolViaArtisan(string $server, string $tool, array $arguments): mixed - { - $commandMap = config('api.mcp.server_commands', []); - - $command = $commandMap[$server] ?? null; - if (! $command) { - throw new \RuntimeException("Unknown server: {$server}"); - } - - // Build MCP request - $mcpRequest = [ - 'jsonrpc' => '2.0', - 'id' => uniqid(), - 'method' => 'tools/call', - 'params' => [ - 'name' => $tool, - 'arguments' => $arguments, - ], - ]; - - // Execute via process - $process = proc_open( - ['php', 'artisan', $command], - [ - 0 => ['pipe', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - ], - $pipes, - base_path() - ); - - if (! is_resource($process)) { - throw new \RuntimeException('Failed to start MCP server process'); - } - - fwrite($pipes[0], json_encode($mcpRequest)."\n"); - fclose($pipes[0]); - - $output = stream_get_contents($pipes[1]); - fclose($pipes[1]); - fclose($pipes[2]); - - proc_close($process); - - $response = json_decode($output, true); - - if (isset($response['error'])) { - throw new \RuntimeException($response['error']['message'] ?? 'Tool execution failed'); - } - - return $response['result'] ?? null; - } - - /** - * Log full API request for debugging and replay. - */ - protected function logApiRequest( - Request $request, - array $validated, - int $status, - array $response, - int $durationMs, - ?ApiKey $apiKey, - ?string $error = null - ): void { - try { - McpApiRequest::log( - method: $request->method(), - path: '/tools/call', - requestBody: $validated, - responseStatus: $status, - responseBody: $response, - durationMs: $durationMs, - workspaceId: $apiKey?->workspace_id, - apiKeyId: $apiKey?->id, - serverId: $validated['server'], - toolName: $validated['tool'], - errorMessage: $error, - ipAddress: $request->ip(), - headers: $request->headers->all() - ); - } catch (\Throwable $e) { - // Don't let logging failures affect API response - report($e); - } - } - - /** - * Dispatch webhook for tool execution. - */ - protected function dispatchWebhook( - ?ApiKey $apiKey, - array $request, - bool $success, - int $durationMs, - ?string $error = null - ): void { - if (! $apiKey?->workspace_id) { - return; - } - - try { - $dispatcher = new McpWebhookDispatcher; - $dispatcher->dispatchToolExecuted( - workspaceId: $apiKey->workspace_id, - serverId: $request['server'], - toolName: $request['tool'], - arguments: $request['arguments'] ?? [], - success: $success, - durationMs: $durationMs, - errorMessage: $error - ); - } catch (\Throwable $e) { - // Don't let webhook failures affect API response - report($e); - } - } - - /** - * Log tool call for analytics. - */ - protected function logToolCall( - ?ApiKey $apiKey, - array $request, - mixed $result, - int $durationMs, - bool $success, - ?string $error = null - ): void { - McpToolCall::log( - serverId: $request['server'], - toolName: $request['tool'], - params: $request['arguments'] ?? [], - success: $success, - durationMs: $durationMs, - errorMessage: $error, - workspaceId: $apiKey?->workspace_id - ); - } - - /** - * Validate tool arguments against the tool's input schema. - * - * @return array Validation errors (empty if valid) - */ - protected function validateToolArguments(array $toolDef, array $arguments): array - { - $inputSchema = $toolDef['inputSchema'] ?? null; - - // No schema = no validation - if (! $inputSchema || ! is_array($inputSchema)) { - return []; - } - - $errors = []; - $properties = $inputSchema['properties'] ?? []; - $required = $inputSchema['required'] ?? []; - - // Check required properties - foreach ($required as $requiredProp) { - if (! array_key_exists($requiredProp, $arguments)) { - $errors[] = "Missing required argument: {$requiredProp}"; - } - } - - // Type validation for provided arguments - foreach ($arguments as $key => $value) { - // Check if argument is defined in schema - if (! isset($properties[$key])) { - // Allow extra properties unless additionalProperties is false - if (isset($inputSchema['additionalProperties']) && $inputSchema['additionalProperties'] === false) { - $errors[] = "Unknown argument: {$key}"; - } - - continue; - } - - $propSchema = $properties[$key]; - $expectedType = $propSchema['type'] ?? null; - - if ($expectedType && ! $this->validateType($value, $expectedType)) { - $errors[] = "Argument '{$key}' must be of type {$expectedType}"; - } - - // Validate enum values - if (isset($propSchema['enum']) && ! in_array($value, $propSchema['enum'], true)) { - $allowedValues = implode(', ', $propSchema['enum']); - $errors[] = "Argument '{$key}' must be one of: {$allowedValues}"; - } - - // Validate string constraints - if ($expectedType === 'string' && is_string($value)) { - if (isset($propSchema['minLength']) && strlen($value) < $propSchema['minLength']) { - $errors[] = "Argument '{$key}' must be at least {$propSchema['minLength']} characters"; - } - if (isset($propSchema['maxLength']) && strlen($value) > $propSchema['maxLength']) { - $errors[] = "Argument '{$key}' must be at most {$propSchema['maxLength']} characters"; - } - } - - // Validate numeric constraints - if (in_array($expectedType, ['integer', 'number']) && is_numeric($value)) { - if (isset($propSchema['minimum']) && $value < $propSchema['minimum']) { - $errors[] = "Argument '{$key}' must be at least {$propSchema['minimum']}"; - } - if (isset($propSchema['maximum']) && $value > $propSchema['maximum']) { - $errors[] = "Argument '{$key}' must be at most {$propSchema['maximum']}"; - } - } - } - - return $errors; - } - - /** - * Validate a value against a JSON Schema type. - */ - protected function validateType(mixed $value, string $type): bool - { - return match ($type) { - 'string' => is_string($value), - 'integer' => is_int($value) || (is_numeric($value) && floor((float) $value) == $value), - 'number' => is_numeric($value), - 'boolean' => is_bool($value), - 'array' => is_array($value) && array_is_list($value), - 'object' => is_array($value) && ! array_is_list($value), - 'null' => is_null($value), - default => true, // Unknown types pass validation - }; - } - - // Registry loading methods (shared with McpRegistryController) - - protected function loadRegistry(): array - { - return Cache::remember('mcp:registry', 600, function () { - $path = resource_path('mcp/registry.yaml'); - - return file_exists($path) ? Yaml::parseFile($path) : ['servers' => []]; - }); - } - - protected function loadServerFull(string $id): ?array - { - return Cache::remember("mcp:server:{$id}", 600, function () use ($id) { - $path = resource_path("mcp/servers/{$id}.yaml"); - - return file_exists($path) ? Yaml::parseFile($path) : null; - }); - } - - protected function loadServerSummary(string $id): ?array - { - $server = $this->loadServerFull($id); - if (! $server) { - return null; - } - - return [ - 'id' => $server['id'], - 'name' => $server['name'], - 'tagline' => $server['tagline'] ?? '', - 'status' => $server['status'] ?? 'available', - 'tool_count' => count($server['tools'] ?? []), - 'resource_count' => count($server['resources'] ?? []), - ]; - } - - /** - * Record quota usage for successful tool calls. - */ - protected function recordQuotaUsage($workspace): void - { - if (! $workspace) { - return; - } - - try { - $quotaService = app(McpQuotaService::class); - $quotaService->recordUsage($workspace, toolCalls: 1); - } catch (\Throwable $e) { - // Don't let quota recording failures affect API response - report($e); - } - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/DTO/ToolStats.php b/packages/core-mcp/src/Mod/Mcp/DTO/ToolStats.php deleted file mode 100644 index 01f8e50..0000000 --- a/packages/core-mcp/src/Mod/Mcp/DTO/ToolStats.php +++ /dev/null @@ -1,95 +0,0 @@ - $this->toolName, - 'total_calls' => $this->totalCalls, - 'error_count' => $this->errorCount, - 'error_rate' => $this->errorRate, - 'avg_duration_ms' => $this->avgDurationMs, - 'min_duration_ms' => $this->minDurationMs, - 'max_duration_ms' => $this->maxDurationMs, - ]; - } - - /** - * Get success rate as percentage. - */ - public function getSuccessRate(): float - { - return 100.0 - $this->errorRate; - } - - /** - * Get average duration formatted for display. - */ - public function getAvgDurationForHumans(): string - { - if ($this->avgDurationMs === 0.0) { - return '-'; - } - - if ($this->avgDurationMs < 1000) { - return round($this->avgDurationMs).'ms'; - } - - return round($this->avgDurationMs / 1000, 2).'s'; - } - - /** - * Check if the tool has a high error rate (above threshold). - */ - public function hasHighErrorRate(float $threshold = 10.0): bool - { - return $this->errorRate > $threshold; - } - - /** - * Check if the tool has slow response times (above threshold in ms). - */ - public function isSlowResponding(int $thresholdMs = 5000): bool - { - return $this->avgDurationMs > $thresholdMs; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Database/Seeders/SensitiveToolSeeder.php b/packages/core-mcp/src/Mod/Mcp/Database/Seeders/SensitiveToolSeeder.php deleted file mode 100644 index 4ce6f8e..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Database/Seeders/SensitiveToolSeeder.php +++ /dev/null @@ -1,130 +0,0 @@ - 'query_database', - 'reason' => 'Direct database access - may expose sensitive data', - 'redact_fields' => ['password', 'email', 'phone', 'address', 'ssn'], - 'require_explicit_consent' => false, - ], - - // User management - [ - 'tool_name' => 'create_user', - 'reason' => 'User account creation - security sensitive', - 'redact_fields' => ['password', 'secret'], - 'require_explicit_consent' => true, - ], - [ - 'tool_name' => 'update_user', - 'reason' => 'User account modification - security sensitive', - 'redact_fields' => ['password', 'secret', 'email'], - 'require_explicit_consent' => true, - ], - [ - 'tool_name' => 'delete_user', - 'reason' => 'User account deletion - irreversible operation', - 'redact_fields' => [], - 'require_explicit_consent' => true, - ], - - // API key management - [ - 'tool_name' => 'create_api_key', - 'reason' => 'API key creation - security credential', - 'redact_fields' => ['key', 'secret', 'token'], - 'require_explicit_consent' => true, - ], - [ - 'tool_name' => 'revoke_api_key', - 'reason' => 'API key revocation - access control', - 'redact_fields' => [], - 'require_explicit_consent' => true, - ], - - // Billing and financial - [ - 'tool_name' => 'upgrade_plan', - 'reason' => 'Plan upgrade - financial impact', - 'redact_fields' => ['card_number', 'cvv', 'payment_method'], - 'require_explicit_consent' => true, - ], - [ - 'tool_name' => 'create_coupon', - 'reason' => 'Coupon creation - financial impact', - 'redact_fields' => [], - 'require_explicit_consent' => false, - ], - [ - 'tool_name' => 'process_refund', - 'reason' => 'Refund processing - financial transaction', - 'redact_fields' => ['card_number', 'bank_account'], - 'require_explicit_consent' => true, - ], - - // Content operations - [ - 'tool_name' => 'delete_content', - 'reason' => 'Content deletion - irreversible data loss', - 'redact_fields' => [], - 'require_explicit_consent' => true, - ], - [ - 'tool_name' => 'publish_content', - 'reason' => 'Public content publishing - visibility impact', - 'redact_fields' => [], - 'require_explicit_consent' => false, - ], - - // System configuration - [ - 'tool_name' => 'update_config', - 'reason' => 'System configuration change - affects application behaviour', - 'redact_fields' => ['api_key', 'secret', 'password'], - 'require_explicit_consent' => true, - ], - - // Webhook management - [ - 'tool_name' => 'create_webhook', - 'reason' => 'External webhook creation - data exfiltration risk', - 'redact_fields' => ['secret', 'token'], - 'require_explicit_consent' => true, - ], - ]; - - foreach ($sensitiveTools as $tool) { - McpSensitiveTool::updateOrCreate( - ['tool_name' => $tool['tool_name']], - [ - 'reason' => $tool['reason'], - 'redact_fields' => $tool['redact_fields'], - 'require_explicit_consent' => $tool['require_explicit_consent'], - ] - ); - } - - $this->command->info('Registered '.count($sensitiveTools).' sensitive tool definitions.'); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Dependencies/DependencyType.php b/packages/core-mcp/src/Mod/Mcp/Dependencies/DependencyType.php deleted file mode 100644 index 78cc407..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Dependencies/DependencyType.php +++ /dev/null @@ -1,57 +0,0 @@ - 'Tool must be called first', - self::SESSION_STATE => 'Session state required', - self::CONTEXT_EXISTS => 'Context value required', - self::ENTITY_EXISTS => 'Entity must exist', - self::CUSTOM => 'Custom condition', - }; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Dependencies/HasDependencies.php b/packages/core-mcp/src/Mod/Mcp/Dependencies/HasDependencies.php deleted file mode 100644 index 692fcfc..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Dependencies/HasDependencies.php +++ /dev/null @@ -1,21 +0,0 @@ - - */ - public function dependencies(): array; -} diff --git a/packages/core-mcp/src/Mod/Mcp/Dependencies/ToolDependency.php b/packages/core-mcp/src/Mod/Mcp/Dependencies/ToolDependency.php deleted file mode 100644 index 69ff64d..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Dependencies/ToolDependency.php +++ /dev/null @@ -1,134 +0,0 @@ -type, - key: $this->key, - description: $this->description, - optional: true, - metadata: $this->metadata, - ); - } - - /** - * Convert to array representation. - */ - public function toArray(): array - { - return [ - 'type' => $this->type->value, - 'key' => $this->key, - 'description' => $this->description, - 'optional' => $this->optional, - 'metadata' => $this->metadata, - ]; - } - - /** - * Create from array representation. - */ - public static function fromArray(array $data): self - { - return new self( - type: DependencyType::from($data['type']), - key: $data['key'], - description: $data['description'] ?? null, - optional: $data['optional'] ?? false, - metadata: $data['metadata'] ?? [], - ); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Events/ToolExecuted.php b/packages/core-mcp/src/Mod/Mcp/Events/ToolExecuted.php deleted file mode 100644 index 5c3ce77..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Events/ToolExecuted.php +++ /dev/null @@ -1,114 +0,0 @@ -toolName; - } - - /** - * Get the duration in milliseconds. - */ - public function getDurationMs(): int - { - return $this->durationMs; - } - - /** - * Check if the execution was successful. - */ - public function wasSuccessful(): bool - { - return $this->success; - } - - /** - * Get the workspace ID. - */ - public function getWorkspaceId(): ?string - { - return $this->workspaceId; - } - - /** - * Get the session ID. - */ - public function getSessionId(): ?string - { - return $this->sessionId; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Exceptions/CircuitOpenException.php b/packages/core-mcp/src/Mod/Mcp/Exceptions/CircuitOpenException.php deleted file mode 100644 index 779b065..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Exceptions/CircuitOpenException.php +++ /dev/null @@ -1,27 +0,0 @@ - $missingDependencies List of unmet dependencies - * @param array $suggestedOrder Suggested tools to call first - */ - public function __construct( - public readonly string $toolName, - public readonly array $missingDependencies, - public readonly array $suggestedOrder = [], - ) { - $message = $this->buildMessage(); - parent::__construct($message); - } - - /** - * Build a user-friendly error message. - */ - protected function buildMessage(): string - { - $missing = array_map( - fn (ToolDependency $dep) => "- {$dep->description}", - $this->missingDependencies - ); - - $message = "Cannot execute '{$this->toolName}': prerequisites not met.\n\n"; - $message .= "Missing:\n".implode("\n", $missing); - - if (! empty($this->suggestedOrder)) { - $message .= "\n\nSuggested order:\n"; - foreach ($this->suggestedOrder as $i => $tool) { - $message .= sprintf(" %d. %s\n", $i + 1, $tool); - } - } - - return $message; - } - - /** - * Get a structured error response for API output. - */ - public function toApiResponse(): array - { - return [ - 'error' => 'dependency_not_met', - 'message' => "Cannot execute '{$this->toolName}': prerequisites not met", - 'tool' => $this->toolName, - 'missing_dependencies' => array_map( - fn (ToolDependency $dep) => $dep->toArray(), - $this->missingDependencies - ), - 'suggested_order' => $this->suggestedOrder, - 'help' => $this->getHelpText(), - ]; - } - - /** - * Get help text explaining how to resolve the issue. - */ - protected function getHelpText(): string - { - if (empty($this->suggestedOrder)) { - return 'Ensure all required dependencies are satisfied before calling this tool.'; - } - - return sprintf( - 'Call these tools in order before attempting %s: %s', - $this->toolName, - implode(' -> ', $this->suggestedOrder) - ); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Exceptions/MissingWorkspaceContextException.php b/packages/core-mcp/src/Mod/Mcp/Exceptions/MissingWorkspaceContextException.php deleted file mode 100644 index 0ff33ed..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Exceptions/MissingWorkspaceContextException.php +++ /dev/null @@ -1,45 +0,0 @@ - [ - 'title' => 'API Keys', - 'description' => 'Create API keys to authenticate HTTP requests to MCP servers.', - 'empty' => [ - 'title' => 'No API Keys Yet', - 'description' => 'Create an API key to start making authenticated requests to MCP servers over HTTP.', - ], - 'actions' => [ - 'create' => 'Create Key', - 'create_first' => 'Create Your First Key', - 'revoke' => 'Revoke', - ], - 'table' => [ - 'name' => 'Name', - 'key' => 'Key', - 'scopes' => 'Scopes', - 'last_used' => 'Last Used', - 'expires' => 'Expires', - 'actions' => 'Actions', - ], - 'status' => [ - 'expired' => 'Expired', - 'never' => 'Never', - ], - 'confirm_revoke' => 'Are you sure you want to revoke this API key? This cannot be undone.', - - // Authentication section - 'auth' => [ - 'title' => 'Authentication', - 'description' => 'Include your API key in HTTP requests using one of these methods:', - 'header_recommended' => 'Authorization Header (recommended)', - 'header_api_key' => 'X-API-Key Header', - ], - - // Example section - 'example' => [ - 'title' => 'Example Request', - 'description' => 'Call an MCP tool via HTTP POST:', - ], - - // Create modal - 'create_modal' => [ - 'title' => 'Create API Key', - 'name_label' => 'Key Name', - 'name_placeholder' => 'e.g., Production Server, Claude Agent', - 'permissions_label' => 'Permissions', - 'permission_read' => 'Read - Query tools and resources', - 'permission_write' => 'Write - Create and update data', - 'permission_delete' => 'Delete - Remove data', - 'expiry_label' => 'Expiration', - 'expiry_never' => 'Never expires', - 'expiry_30' => '30 days', - 'expiry_90' => '90 days', - 'expiry_1year' => '1 year', - 'cancel' => 'Cancel', - 'create' => 'Create Key', - ], - - // New key modal - 'new_key_modal' => [ - 'title' => 'API Key Created', - 'warning' => 'Copy this key now.', - 'warning_detail' => "You won't be able to see it again.", - 'done' => 'Done', - ], - ], - - // Request Log - 'logs' => [ - 'title' => 'Request Log', - 'description' => 'View API requests and generate curl commands to replay them.', - 'filters' => [ - 'server' => 'Server', - 'status' => 'Status', - 'all_servers' => 'All servers', - 'all' => 'All', - 'success' => 'Success', - 'failed' => 'Failed', - ], - 'empty' => 'No requests found.', - 'detail' => [ - 'title' => 'Request Detail', - 'status' => 'Status', - 'request' => 'Request', - 'response' => 'Response', - 'error' => 'Error', - 'replay_command' => 'Replay Command', - 'copy' => 'Copy', - 'copied' => 'Copied', - 'metadata' => [ - 'request_id' => 'Request ID', - 'duration' => 'Duration', - 'ip' => 'IP', - 'time' => 'Time', - ], - ], - 'empty_detail' => 'Select a request to view details and generate replay commands.', - 'status_ok' => 'OK', - 'status_error' => 'Error', - ], - - // Playground - 'playground' => [ - 'title' => 'Playground', - 'description' => 'Test MCP tools interactively and execute requests live.', - - // Authentication section - 'auth' => [ - 'title' => 'Authentication', - 'api_key_label' => 'API Key', - 'api_key_placeholder' => 'hk_xxxxxxxx_xxxxxxxxxxxx...', - 'api_key_description' => 'Paste your API key to execute requests live', - 'validate' => 'Validate Key', - 'status' => [ - 'valid' => 'Valid', - 'invalid' => 'Invalid key', - 'expired' => 'Expired', - 'empty' => 'Enter a key to validate', - ], - 'key_info' => [ - 'name' => 'Name', - 'workspace' => 'Workspace', - 'scopes' => 'Scopes', - 'last_used' => 'Last used', - ], - 'sign_in_prompt' => 'Sign in', - 'sign_in_description' => 'to create API keys, or paste an existing key above.', - ], - - // Tool selection section - 'tools' => [ - 'title' => 'Select Tool', - 'server_label' => 'Server', - 'server_placeholder' => 'Choose a server...', - 'tool_label' => 'Tool', - 'tool_placeholder' => 'Choose a tool...', - 'arguments' => 'Arguments', - 'no_arguments' => 'This tool has no arguments.', - 'execute' => 'Execute Request', - 'generate' => 'Generate Request', - 'executing' => 'Executing...', - ], - - // Response section - 'response' => [ - 'title' => 'Response', - 'copy' => 'Copy', - 'copied' => 'Copied', - 'empty' => 'Select a server and tool to get started.', - ], - - // API Reference section - 'reference' => [ - 'title' => 'API Reference', - 'endpoint' => 'Endpoint', - 'method' => 'Method', - 'auth' => 'Auth', - 'content_type' => 'Content-Type', - 'manage_keys' => 'Manage API Keys', - ], - ], - - // Common - 'common' => [ - 'na' => 'N/A', - ], -]; diff --git a/packages/core-mcp/src/Mod/Mcp/Listeners/RecordToolExecution.php b/packages/core-mcp/src/Mod/Mcp/Listeners/RecordToolExecution.php deleted file mode 100644 index 25e09b9..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Listeners/RecordToolExecution.php +++ /dev/null @@ -1,164 +0,0 @@ -getToolName($event); - $durationMs = $this->getDuration($event); - $success = $this->wasSuccessful($event); - $workspaceId = $this->getWorkspaceId($event); - $sessionId = $this->getSessionId($event); - - if ($toolName === null || $durationMs === null) { - return; - } - - $this->analyticsService->recordExecution( - tool: $toolName, - durationMs: $durationMs, - success: $success, - workspaceId: $workspaceId, - sessionId: $sessionId - ); - } - - /** - * Extract tool name from the event. - */ - protected function getToolName(object $event): ?string - { - // Support multiple event structures - if (property_exists($event, 'toolName')) { - return $event->toolName; - } - - if (property_exists($event, 'tool_name')) { - return $event->tool_name; - } - - if (property_exists($event, 'tool')) { - return is_string($event->tool) ? $event->tool : $event->tool->getName(); - } - - if (method_exists($event, 'getToolName')) { - return $event->getToolName(); - } - - return null; - } - - /** - * Extract duration from the event. - */ - protected function getDuration(object $event): ?int - { - if (property_exists($event, 'durationMs')) { - return (int) $event->durationMs; - } - - if (property_exists($event, 'duration_ms')) { - return (int) $event->duration_ms; - } - - if (property_exists($event, 'duration')) { - return (int) $event->duration; - } - - if (method_exists($event, 'getDurationMs')) { - return $event->getDurationMs(); - } - - return null; - } - - /** - * Determine if the execution was successful. - */ - protected function wasSuccessful(object $event): bool - { - if (property_exists($event, 'success')) { - return (bool) $event->success; - } - - if (property_exists($event, 'error')) { - return $event->error === null; - } - - if (property_exists($event, 'exception')) { - return $event->exception === null; - } - - if (method_exists($event, 'wasSuccessful')) { - return $event->wasSuccessful(); - } - - return true; // Assume success if no indicator - } - - /** - * Extract workspace ID from the event. - */ - protected function getWorkspaceId(object $event): ?string - { - if (property_exists($event, 'workspaceId')) { - return $event->workspaceId; - } - - if (property_exists($event, 'workspace_id')) { - return $event->workspace_id; - } - - if (method_exists($event, 'getWorkspaceId')) { - return $event->getWorkspaceId(); - } - - return null; - } - - /** - * Extract session ID from the event. - */ - protected function getSessionId(object $event): ?string - { - if (property_exists($event, 'sessionId')) { - return $event->sessionId; - } - - if (property_exists($event, 'session_id')) { - return $event->session_id; - } - - if (method_exists($event, 'getSessionId')) { - return $event->getSessionId(); - } - - return null; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Middleware/CheckMcpQuota.php b/packages/core-mcp/src/Mod/Mcp/Middleware/CheckMcpQuota.php deleted file mode 100644 index e370220..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Middleware/CheckMcpQuota.php +++ /dev/null @@ -1,89 +0,0 @@ -attributes->get('workspace'); - - // No workspace context = skip quota check (other middleware handles auth) - if (! $workspace) { - return $next($request); - } - - // Check quota - $quotaCheck = $this->quotaService->checkQuotaDetailed($workspace); - - if (! $quotaCheck['allowed']) { - return $this->quotaExceededResponse($quotaCheck, $workspace); - } - - // Process request - $response = $next($request); - - // Add quota headers to response - $this->addQuotaHeaders($response, $workspace); - - return $response; - } - - /** - * Build quota exceeded error response. - */ - protected function quotaExceededResponse(array $quotaCheck, $workspace): Response - { - $headers = $this->quotaService->getQuotaHeaders($workspace); - - $errorData = [ - 'error' => 'quota_exceeded', - 'message' => $quotaCheck['reason'] ?? 'Monthly quota exceeded', - 'quota' => [ - 'tool_calls' => [ - 'used' => $quotaCheck['tool_calls']['used'] ?? 0, - 'limit' => $quotaCheck['tool_calls']['limit'], - 'unlimited' => $quotaCheck['tool_calls']['unlimited'] ?? false, - ], - 'tokens' => [ - 'used' => $quotaCheck['tokens']['used'] ?? 0, - 'limit' => $quotaCheck['tokens']['limit'], - 'unlimited' => $quotaCheck['tokens']['unlimited'] ?? false, - ], - 'resets_at' => now()->endOfMonth()->toIso8601String(), - ], - 'upgrade_hint' => 'Upgrade your plan to increase MCP quota limits.', - ]; - - return response()->json($errorData, 429, $headers); - } - - /** - * Add quota headers to response. - */ - protected function addQuotaHeaders(Response $response, $workspace): void - { - $headers = $this->quotaService->getQuotaHeaders($workspace); - - foreach ($headers as $name => $value) { - $response->headers->set($name, $value); - } - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Middleware/McpApiKeyAuth.php b/packages/core-mcp/src/Mod/Mcp/Middleware/McpApiKeyAuth.php deleted file mode 100644 index 96150b3..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Middleware/McpApiKeyAuth.php +++ /dev/null @@ -1,85 +0,0 @@ -extractKey($request); - - if (! $key) { - return response()->json([ - 'error' => 'Missing API key', - 'hint' => 'Provide via Authorization: Bearer or X-API-Key header', - ], 401); - } - - $apiKey = ApiKey::findByPlainKey($key); - - if (! $apiKey) { - return response()->json([ - 'error' => 'Invalid API key', - ], 401); - } - - if ($apiKey->isExpired()) { - return response()->json([ - 'error' => 'API key has expired', - ], 401); - } - - // Check server-level access for tool calls - if ($request->is('*/tools/call') && $request->isMethod('POST')) { - $serverId = $request->input('server'); - if ($serverId && ! $apiKey->hasServerAccess($serverId)) { - return response()->json([ - 'error' => 'Access denied to server: '.$serverId, - 'allowed_servers' => $apiKey->getAllowedServers(), - ], 403); - } - } - - // Record usage - $apiKey->recordUsage(); - - // Attach to request for controller access - $request->attributes->set('api_key', $apiKey); - $request->attributes->set('workspace', $apiKey->workspace); - - return $next($request); - } - - protected function extractKey(Request $request): ?string - { - // Try Authorization: Bearer - $authHeader = $request->header('Authorization'); - if ($authHeader && str_starts_with($authHeader, 'Bearer ')) { - return substr($authHeader, 7); - } - - // Try X-API-Key - $apiKeyHeader = $request->header('X-API-Key'); - if ($apiKeyHeader) { - return $apiKeyHeader; - } - - return null; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Middleware/McpAuthenticate.php b/packages/core-mcp/src/Mod/Mcp/Middleware/McpAuthenticate.php deleted file mode 100644 index a4264af..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Middleware/McpAuthenticate.php +++ /dev/null @@ -1,102 +0,0 @@ -authenticateByApiKey($request); - - // Fall back to session auth - if (! $workspace && $request->user()) { - $user = $request->user(); - if (method_exists($user, 'defaultHostWorkspace')) { - $workspace = $user->defaultHostWorkspace(); - } - } - - // Store workspace for downstream use - if ($workspace) { - $request->attributes->set('mcp_workspace', $workspace); - - // Check MCP access entitlement - $result = $this->entitlementService->can($workspace, 'mcp.access'); - $request->attributes->set('mcp_entitlement', $result); - } - - // For 'required' level, must have workspace - if ($level === 'required' && ! $workspace) { - return $this->unauthenticatedResponse($request); - } - - return $next($request); - } - - /** - * Authenticate using API key from header or query. - */ - protected function authenticateByApiKey(Request $request): ?Workspace - { - $apiKey = $request->header('X-API-Key') - ?? $request->header('Authorization') - ?? $request->query('api_key'); - - if (! $apiKey) { - return null; - } - - // Strip 'Bearer ' prefix if present - if (str_starts_with($apiKey, 'Bearer ')) { - $apiKey = substr($apiKey, 7); - } - - // Look up workspace by API key - return Workspace::whereHas('apiKeys', function ($query) use ($apiKey) { - $query->where('key', hash('sha256', $apiKey)) - ->where(function ($q) { - $q->whereNull('expires_at') - ->orWhere('expires_at', '>', now()); - }); - })->first(); - } - - /** - * Return unauthenticated response. - */ - protected function unauthenticatedResponse(Request $request): Response - { - if ($request->expectsJson() || $request->is('api/*')) { - return response()->json([ - 'error' => 'unauthenticated', - 'message' => 'Authentication required. Provide an API key or sign in.', - ], 401); - } - - return redirect()->guest(route('login')); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Middleware/ValidateToolDependencies.php b/packages/core-mcp/src/Mod/Mcp/Middleware/ValidateToolDependencies.php deleted file mode 100644 index 8992a27..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Middleware/ValidateToolDependencies.php +++ /dev/null @@ -1,146 +0,0 @@ -isToolCallRequest($request)) { - return $next($request); - } - - $toolName = $this->extractToolName($request); - $sessionId = $this->extractSessionId($request); - $context = $this->extractContext($request); - $args = $this->extractArguments($request); - - if (! $toolName || ! $sessionId) { - return $next($request); - } - - try { - $this->dependencyService->validateDependencies($sessionId, $toolName, $context, $args); - } catch (MissingDependencyException $e) { - return $this->buildErrorResponse($e); - } - - // Record the tool call after successful execution - $response = $next($request); - - // Only record on success - if ($response instanceof JsonResponse && $this->isSuccessResponse($response)) { - $this->dependencyService->recordToolCall($sessionId, $toolName, $args); - } - - return $response; - } - - /** - * Check if this is a tool call request. - */ - protected function isToolCallRequest(Request $request): bool - { - return $request->is('*/tools/call') || $request->is('api/*/mcp/tools/call'); - } - - /** - * Extract the tool name from the request. - */ - protected function extractToolName(Request $request): ?string - { - return $request->input('tool') ?? $request->input('name'); - } - - /** - * Extract the session ID from the request. - */ - protected function extractSessionId(Request $request): ?string - { - // Try various locations where session ID might be - return $request->input('session_id') - ?? $request->input('arguments.session_id') - ?? $request->header('X-MCP-Session-ID') - ?? $request->attributes->get('session_id'); - } - - /** - * Extract context from the request. - */ - protected function extractContext(Request $request): array - { - $context = []; - - // Get API key context - $apiKey = $request->attributes->get('api_key'); - if ($apiKey) { - $context['workspace_id'] = $apiKey->workspace_id; - } - - // Get explicit context from request - $requestContext = $request->input('context', []); - if (is_array($requestContext)) { - $context = array_merge($context, $requestContext); - } - - // Get session ID - $sessionId = $this->extractSessionId($request); - if ($sessionId) { - $context['session_id'] = $sessionId; - } - - return $context; - } - - /** - * Extract tool arguments from the request. - */ - protected function extractArguments(Request $request): array - { - return $request->input('arguments', []) ?? []; - } - - /** - * Check if response indicates success. - */ - protected function isSuccessResponse(JsonResponse $response): bool - { - if ($response->getStatusCode() >= 400) { - return false; - } - - $data = $response->getData(true); - - return ($data['success'] ?? true) !== false; - } - - /** - * Build error response for missing dependencies. - */ - protected function buildErrorResponse(MissingDependencyException $e): JsonResponse - { - return response()->json($e->toApiResponse(), 422); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Middleware/ValidateWorkspaceContext.php b/packages/core-mcp/src/Mod/Mcp/Middleware/ValidateWorkspaceContext.php deleted file mode 100644 index 40d71a8..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Middleware/ValidateWorkspaceContext.php +++ /dev/null @@ -1,91 +0,0 @@ -attributes->get('mcp_workspace'); - - if ($workspace) { - // Create workspace context and store it - $context = WorkspaceContext::fromWorkspace($workspace); - $request->attributes->set('mcp_workspace_context', $context); - - return $next($request); - } - - // Try to get workspace from API key - $apiKey = $request->attributes->get('api_key'); - if ($apiKey?->workspace_id) { - $context = new WorkspaceContext( - workspaceId: $apiKey->workspace_id, - workspace: $apiKey->workspace, - ); - $request->attributes->set('mcp_workspace_context', $context); - - return $next($request); - } - - // Try authenticated user's default workspace - $user = $request->user(); - if ($user && method_exists($user, 'defaultHostWorkspace')) { - $workspace = $user->defaultHostWorkspace(); - if ($workspace) { - $context = WorkspaceContext::fromWorkspace($workspace); - $request->attributes->set('mcp_workspace_context', $context); - - return $next($request); - } - } - - // If mode is 'required', reject the request - if ($mode === 'required') { - return $this->missingContextResponse($request); - } - - // Mode is 'optional', continue without context - return $next($request); - } - - /** - * Return response for missing workspace context. - */ - protected function missingContextResponse(Request $request): Response - { - $exception = new MissingWorkspaceContextException('MCP API'); - - if ($request->expectsJson() || $request->is('api/*')) { - return response()->json([ - 'error' => $exception->getErrorType(), - 'message' => $exception->getMessage(), - ], $exception->getStatusCode()); - } - - return response($exception->getMessage(), $exception->getStatusCode()); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Migrations/2026_01_07_004936_create_mcp_api_requests_table.php b/packages/core-mcp/src/Mod/Mcp/Migrations/2026_01_07_004936_create_mcp_api_requests_table.php deleted file mode 100644 index 76cc9fa..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Migrations/2026_01_07_004936_create_mcp_api_requests_table.php +++ /dev/null @@ -1,40 +0,0 @@ -id(); - $table->string('request_id', 32)->unique(); - $table->foreignId('workspace_id')->nullable()->constrained('workspaces')->nullOnDelete(); - $table->foreignId('api_key_id')->nullable()->constrained('api_keys')->nullOnDelete(); - $table->string('method', 10); - $table->string('path', 255); - $table->json('headers')->nullable(); - $table->json('request_body')->nullable(); - $table->unsignedSmallInteger('response_status'); - $table->json('response_body')->nullable(); - $table->unsignedInteger('duration_ms')->default(0); - $table->string('server_id', 64)->nullable(); - $table->string('tool_name', 128)->nullable(); - $table->text('error_message')->nullable(); - $table->string('ip_address', 45)->nullable(); - $table->timestamps(); - - $table->index(['workspace_id', 'created_at']); - $table->index(['server_id', 'tool_name']); - $table->index('created_at'); - $table->index('response_status'); - }); - } - - public function down(): void - { - Schema::dropIfExists('mcp_api_requests'); - } -}; diff --git a/packages/core-mcp/src/Mod/Mcp/Migrations/2026_01_26_000001_create_mcp_tool_metrics_table.php b/packages/core-mcp/src/Mod/Mcp/Migrations/2026_01_26_000001_create_mcp_tool_metrics_table.php deleted file mode 100644 index d31a179..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Migrations/2026_01_26_000001_create_mcp_tool_metrics_table.php +++ /dev/null @@ -1,48 +0,0 @@ -id(); - $table->string('tool_name'); - $table->string('workspace_id')->nullable(); - $table->unsignedInteger('call_count')->default(0); - $table->unsignedInteger('error_count')->default(0); - $table->unsignedInteger('total_duration_ms')->default(0); - $table->unsignedInteger('min_duration_ms')->nullable(); - $table->unsignedInteger('max_duration_ms')->nullable(); - $table->date('date'); - $table->timestamps(); - - $table->unique(['tool_name', 'workspace_id', 'date']); - $table->index(['date', 'tool_name']); - $table->index('workspace_id'); - }); - - // Table for tracking tool combinations (tools used together in sessions) - Schema::create('mcp_tool_combinations', function (Blueprint $table) { - $table->id(); - $table->string('tool_a'); - $table->string('tool_b'); - $table->string('workspace_id')->nullable(); - $table->unsignedInteger('occurrence_count')->default(0); - $table->date('date'); - $table->timestamps(); - - $table->unique(['tool_a', 'tool_b', 'workspace_id', 'date']); - $table->index(['date', 'occurrence_count']); - }); - } - - public function down(): void - { - Schema::dropIfExists('mcp_tool_combinations'); - Schema::dropIfExists('mcp_tool_metrics'); - } -}; diff --git a/packages/core-mcp/src/Mod/Mcp/Migrations/2026_01_26_000002_create_mcp_usage_quotas_table.php b/packages/core-mcp/src/Mod/Mcp/Migrations/2026_01_26_000002_create_mcp_usage_quotas_table.php deleted file mode 100644 index f3f2180..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Migrations/2026_01_26_000002_create_mcp_usage_quotas_table.php +++ /dev/null @@ -1,29 +0,0 @@ -id(); - $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); - $table->string('month', 7); // YYYY-MM format - $table->unsignedBigInteger('tool_calls_count')->default(0); - $table->unsignedBigInteger('input_tokens')->default(0); - $table->unsignedBigInteger('output_tokens')->default(0); - $table->timestamps(); - - $table->unique(['workspace_id', 'month']); - $table->index('month'); - }); - } - - public function down(): void - { - Schema::dropIfExists('mcp_usage_quotas'); - } -}; diff --git a/packages/core-mcp/src/Mod/Mcp/Migrations/2026_01_26_000003_create_mcp_audit_logs_table.php b/packages/core-mcp/src/Mod/Mcp/Migrations/2026_01_26_000003_create_mcp_audit_logs_table.php deleted file mode 100644 index 0520748..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Migrations/2026_01_26_000003_create_mcp_audit_logs_table.php +++ /dev/null @@ -1,78 +0,0 @@ -id(); - - // Tool execution details - $table->string('server_id')->index(); - $table->string('tool_name')->index(); - $table->unsignedBigInteger('workspace_id')->nullable()->index(); - $table->string('session_id')->nullable()->index(); - - // Input/output (stored as JSON, may be redacted) - $table->json('input_params')->nullable(); - $table->json('output_summary')->nullable(); - $table->boolean('success')->default(true); - $table->unsignedInteger('duration_ms')->nullable(); - $table->string('error_code')->nullable(); - $table->text('error_message')->nullable(); - - // Actor information - $table->string('actor_type')->nullable(); // user, api_key, system - $table->unsignedBigInteger('actor_id')->nullable(); - $table->string('actor_ip', 45)->nullable(); // IPv4 or IPv6 - - // Sensitive tool flagging - $table->boolean('is_sensitive')->default(false)->index(); - $table->string('sensitivity_reason')->nullable(); - - // Hash chain for tamper detection - $table->string('previous_hash', 64)->nullable(); // SHA-256 of previous entry - $table->string('entry_hash', 64)->index(); // SHA-256 of this entry - - // Agent context - $table->string('agent_type')->nullable(); - $table->string('plan_slug')->nullable(); - - // Timestamps (immutable - no updated_at updates after creation) - $table->timestamp('created_at')->useCurrent(); - $table->timestamp('updated_at')->nullable(); - - // Foreign key constraint - $table->foreign('workspace_id') - ->references('id') - ->on('workspaces') - ->nullOnDelete(); - - // Composite indexes for common queries - $table->index(['workspace_id', 'created_at']); - $table->index(['tool_name', 'created_at']); - $table->index(['is_sensitive', 'created_at']); - $table->index(['actor_type', 'actor_id']); - }); - - // Table for tracking sensitive tool definitions - Schema::create('mcp_sensitive_tools', function (Blueprint $table) { - $table->id(); - $table->string('tool_name')->unique(); - $table->string('reason'); - $table->json('redact_fields')->nullable(); // Fields to redact in audit logs - $table->boolean('require_explicit_consent')->default(false); - $table->timestamps(); - }); - } - - public function down(): void - { - Schema::dropIfExists('mcp_sensitive_tools'); - Schema::dropIfExists('mcp_audit_logs'); - } -}; diff --git a/packages/core-mcp/src/Mod/Mcp/Migrations/2026_01_26_000004_create_mcp_tool_versions_table.php b/packages/core-mcp/src/Mod/Mcp/Migrations/2026_01_26_000004_create_mcp_tool_versions_table.php deleted file mode 100644 index 9248f62..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Migrations/2026_01_26_000004_create_mcp_tool_versions_table.php +++ /dev/null @@ -1,41 +0,0 @@ -id(); - $table->string('server_id', 64)->index(); - $table->string('tool_name', 128); - $table->string('version', 32); // semver: 1.0.0, 2.1.0-beta, etc. - $table->json('input_schema')->nullable(); - $table->json('output_schema')->nullable(); - $table->text('description')->nullable(); - $table->text('changelog')->nullable(); - $table->text('migration_notes')->nullable(); // guidance for upgrading from previous version - $table->boolean('is_latest')->default(false); - $table->timestamp('deprecated_at')->nullable(); - $table->timestamp('sunset_at')->nullable(); // after this date, version is blocked - $table->timestamps(); - - // Unique constraint: one version per tool per server - $table->unique(['server_id', 'tool_name', 'version'], 'mcp_tool_versions_unique'); - - // Index for finding latest versions - $table->index(['server_id', 'tool_name', 'is_latest'], 'mcp_tool_versions_latest'); - - // Index for finding deprecated/sunset versions - $table->index(['deprecated_at', 'sunset_at'], 'mcp_tool_versions_lifecycle'); - }); - } - - public function down(): void - { - Schema::dropIfExists('mcp_tool_versions'); - } -}; diff --git a/packages/core-mcp/src/Mod/Mcp/Models/McpApiRequest.php b/packages/core-mcp/src/Mod/Mcp/Models/McpApiRequest.php deleted file mode 100644 index bdacb4c..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Models/McpApiRequest.php +++ /dev/null @@ -1,176 +0,0 @@ - 'array', - 'request_body' => 'array', - 'response_body' => 'array', - 'duration_ms' => 'integer', - 'response_status' => 'integer', - ]; - - /** - * Log an API request. - */ - public static function log( - string $method, - string $path, - array $requestBody, - int $responseStatus, - ?array $responseBody = null, - int $durationMs = 0, - ?int $workspaceId = null, - ?int $apiKeyId = null, - ?string $serverId = null, - ?string $toolName = null, - ?string $errorMessage = null, - ?string $ipAddress = null, - array $headers = [] - ): self { - // Sanitise headers - remove sensitive info - $sanitisedHeaders = collect($headers) - ->except(['authorization', 'x-api-key', 'cookie']) - ->toArray(); - - return static::create([ - 'request_id' => 'req_'.Str::random(20), - 'workspace_id' => $workspaceId, - 'api_key_id' => $apiKeyId, - 'method' => $method, - 'path' => $path, - 'headers' => $sanitisedHeaders, - 'request_body' => $requestBody, - 'response_status' => $responseStatus, - 'response_body' => $responseBody, - 'duration_ms' => $durationMs, - 'server_id' => $serverId, - 'tool_name' => $toolName, - 'error_message' => $errorMessage, - 'ip_address' => $ipAddress, - ]); - } - - /** - * Generate curl command to replay this request. - */ - public function toCurl(string $apiKey = 'YOUR_API_KEY'): string - { - $url = config('app.url').'/api/v1/mcp'.$this->path; - - $curl = "curl -X {$this->method} \"{$url}\""; - $curl .= " \\\n -H \"Authorization: Bearer {$apiKey}\""; - $curl .= " \\\n -H \"Content-Type: application/json\""; - - if (! empty($this->request_body)) { - $curl .= " \\\n -d '".json_encode($this->request_body)."'"; - } - - return $curl; - } - - /** - * Get duration formatted for humans. - */ - public function getDurationForHumansAttribute(): string - { - if ($this->duration_ms < 1000) { - return $this->duration_ms.'ms'; - } - - return round($this->duration_ms / 1000, 2).'s'; - } - - /** - * Check if request was successful. - */ - public function isSuccessful(): bool - { - return $this->response_status >= 200 && $this->response_status < 300; - } - - // Relationships - public function workspace(): BelongsTo - { - return $this->belongsTo(Workspace::class); - } - - // Scopes - public function scopeForWorkspace(Builder $query, int $workspaceId): Builder - { - return $query->where('workspace_id', $workspaceId); - } - - public function scopeForServer(Builder $query, string $serverId): Builder - { - return $query->where('server_id', $serverId); - } - - public function scopeForTool(Builder $query, string $toolName): Builder - { - return $query->where('tool_name', $toolName); - } - - public function scopeFailed(Builder $query): Builder - { - return $query->where('response_status', '>=', 400); - } - - public function scopeSuccessful(Builder $query): Builder - { - return $query->whereBetween('response_status', [200, 299]); - } - - public function scopeRecent(Builder $query, int $hours = 24): Builder - { - return $query->where('created_at', '>=', now()->subHours($hours)); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Models/McpAuditLog.php b/packages/core-mcp/src/Mod/Mcp/Models/McpAuditLog.php deleted file mode 100644 index ecf3e7a..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Models/McpAuditLog.php +++ /dev/null @@ -1,383 +0,0 @@ - 'array', - 'output_summary' => 'array', - 'success' => 'boolean', - 'duration_ms' => 'integer', - 'actor_id' => 'integer', - 'is_sensitive' => 'boolean', - 'created_at' => 'datetime', - ]; - - /** - * Boot the model. - */ - protected static function boot(): void - { - parent::boot(); - - // Prevent updates to maintain immutability - static::updating(function (self $model) { - // Allow only specific fields to be updated (for soft operations) - $allowedChanges = ['updated_at']; - $changes = array_keys($model->getDirty()); - - foreach ($changes as $change) { - if (! in_array($change, $allowedChanges)) { - throw new \RuntimeException( - 'Audit log entries are immutable. Cannot modify: '.$change - ); - } - } - }); - - // Prevent deletion - static::deleting(function () { - throw new \RuntimeException( - 'Audit log entries cannot be deleted. They are immutable for compliance purposes.' - ); - }); - } - - // ------------------------------------------------------------------------- - // Relationships - // ------------------------------------------------------------------------- - - public function workspace(): BelongsTo - { - return $this->belongsTo(Workspace::class); - } - - // ------------------------------------------------------------------------- - // Scopes - // ------------------------------------------------------------------------- - - /** - * Filter by server. - */ - public function scopeForServer(Builder $query, string $serverId): Builder - { - return $query->where('server_id', $serverId); - } - - /** - * Filter by tool name. - */ - public function scopeForTool(Builder $query, string $toolName): Builder - { - return $query->where('tool_name', $toolName); - } - - /** - * Filter by session. - */ - public function scopeForSession(Builder $query, string $sessionId): Builder - { - return $query->where('session_id', $sessionId); - } - - /** - * Filter successful calls. - */ - public function scopeSuccessful(Builder $query): Builder - { - return $query->where('success', true); - } - - /** - * Filter failed calls. - */ - public function scopeFailed(Builder $query): Builder - { - return $query->where('success', false); - } - - /** - * Filter sensitive tool calls. - */ - public function scopeSensitive(Builder $query): Builder - { - return $query->where('is_sensitive', true); - } - - /** - * Filter by actor type. - */ - public function scopeByActorType(Builder $query, string $actorType): Builder - { - return $query->where('actor_type', $actorType); - } - - /** - * Filter by actor. - */ - public function scopeByActor(Builder $query, string $actorType, int $actorId): Builder - { - return $query->where('actor_type', $actorType) - ->where('actor_id', $actorId); - } - - /** - * Filter by date range. - */ - public function scopeInDateRange(Builder $query, string|\DateTimeInterface $start, string|\DateTimeInterface $end): Builder - { - return $query->whereBetween('created_at', [$start, $end]); - } - - /** - * Filter for today. - */ - public function scopeToday(Builder $query): Builder - { - return $query->whereDate('created_at', today()); - } - - /** - * Filter for last N days. - */ - public function scopeLastDays(Builder $query, int $days): Builder - { - return $query->where('created_at', '>=', now()->subDays($days)); - } - - // ------------------------------------------------------------------------- - // Hash Chain Methods - // ------------------------------------------------------------------------- - - /** - * Compute the hash for this entry. - * Uses SHA-256 to create a deterministic hash of the entry data. - */ - public function computeHash(): string - { - $data = [ - 'id' => $this->id, - 'server_id' => $this->server_id, - 'tool_name' => $this->tool_name, - 'workspace_id' => $this->workspace_id, - 'session_id' => $this->session_id, - 'input_params' => $this->input_params, - 'output_summary' => $this->output_summary, - 'success' => $this->success, - 'duration_ms' => $this->duration_ms, - 'error_code' => $this->error_code, - 'actor_type' => $this->actor_type, - 'actor_id' => $this->actor_id, - 'actor_ip' => $this->actor_ip, - 'is_sensitive' => $this->is_sensitive, - 'previous_hash' => $this->previous_hash, - 'created_at' => $this->created_at?->toIso8601String(), - ]; - - return hash('sha256', json_encode($data, JSON_THROW_ON_ERROR)); - } - - /** - * Verify this entry's hash is valid. - */ - public function verifyHash(): bool - { - return $this->entry_hash === $this->computeHash(); - } - - /** - * Verify the chain link to the previous entry. - */ - public function verifyChainLink(): bool - { - if ($this->previous_hash === null) { - // First entry in chain - check there's no earlier entry - return ! static::where('id', '<', $this->id)->exists(); - } - - $previous = static::where('id', '<', $this->id) - ->orderByDesc('id') - ->first(); - - if (! $previous) { - return false; // Previous entry missing - } - - return $this->previous_hash === $previous->entry_hash; - } - - // ------------------------------------------------------------------------- - // Helpers - // ------------------------------------------------------------------------- - - /** - * Get duration formatted for humans. - */ - public function getDurationForHumans(): string - { - if (! $this->duration_ms) { - return '-'; - } - - if ($this->duration_ms < 1000) { - return $this->duration_ms.'ms'; - } - - return round($this->duration_ms / 1000, 2).'s'; - } - - /** - * Get actor display name. - */ - public function getActorDisplay(): string - { - return match ($this->actor_type) { - self::ACTOR_USER => "User #{$this->actor_id}", - self::ACTOR_API_KEY => "API Key #{$this->actor_id}", - self::ACTOR_SYSTEM => 'System', - default => 'Unknown', - }; - } - - /** - * Check if this entry has integrity issues. - */ - public function hasIntegrityIssues(): bool - { - return ! $this->verifyHash() || ! $this->verifyChainLink(); - } - - /** - * Get integrity status. - */ - public function getIntegrityStatus(): array - { - $hashValid = $this->verifyHash(); - $chainValid = $this->verifyChainLink(); - - return [ - 'valid' => $hashValid && $chainValid, - 'hash_valid' => $hashValid, - 'chain_valid' => $chainValid, - 'issues' => array_filter([ - ! $hashValid ? 'Entry hash mismatch - data may have been tampered' : null, - ! $chainValid ? 'Chain link broken - previous entry missing or modified' : null, - ]), - ]; - } - - /** - * Convert to array for export. - */ - public function toExportArray(): array - { - return [ - 'id' => $this->id, - 'timestamp' => $this->created_at->toIso8601String(), - 'server_id' => $this->server_id, - 'tool_name' => $this->tool_name, - 'workspace_id' => $this->workspace_id, - 'session_id' => $this->session_id, - 'success' => $this->success, - 'duration_ms' => $this->duration_ms, - 'error_code' => $this->error_code, - 'actor_type' => $this->actor_type, - 'actor_id' => $this->actor_id, - 'actor_ip' => $this->actor_ip, - 'is_sensitive' => $this->is_sensitive, - 'sensitivity_reason' => $this->sensitivity_reason, - 'entry_hash' => $this->entry_hash, - 'previous_hash' => $this->previous_hash, - 'agent_type' => $this->agent_type, - 'plan_slug' => $this->plan_slug, - ]; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Models/McpSensitiveTool.php b/packages/core-mcp/src/Mod/Mcp/Models/McpSensitiveTool.php deleted file mode 100644 index 3a03bf1..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Models/McpSensitiveTool.php +++ /dev/null @@ -1,127 +0,0 @@ - 'array', - 'require_explicit_consent' => 'boolean', - ]; - - // ------------------------------------------------------------------------- - // Scopes - // ------------------------------------------------------------------------- - - /** - * Find by tool name. - */ - public function scopeForTool(Builder $query, string $toolName): Builder - { - return $query->where('tool_name', $toolName); - } - - /** - * Filter tools requiring explicit consent. - */ - public function scopeRequiringConsent(Builder $query): Builder - { - return $query->where('require_explicit_consent', true); - } - - // ------------------------------------------------------------------------- - // Static Methods - // ------------------------------------------------------------------------- - - /** - * Check if a tool is marked as sensitive. - */ - public static function isSensitive(string $toolName): bool - { - return static::where('tool_name', $toolName)->exists(); - } - - /** - * Get sensitivity info for a tool. - */ - public static function getSensitivityInfo(string $toolName): ?array - { - $tool = static::where('tool_name', $toolName)->first(); - - if (! $tool) { - return null; - } - - return [ - 'is_sensitive' => true, - 'reason' => $tool->reason, - 'redact_fields' => $tool->redact_fields ?? [], - 'require_explicit_consent' => $tool->require_explicit_consent, - ]; - } - - /** - * Register a sensitive tool. - */ - public static function register( - string $toolName, - string $reason, - array $redactFields = [], - bool $requireConsent = false - ): self { - return static::updateOrCreate( - ['tool_name' => $toolName], - [ - 'reason' => $reason, - 'redact_fields' => $redactFields, - 'require_explicit_consent' => $requireConsent, - ] - ); - } - - /** - * Unregister a sensitive tool. - */ - public static function unregister(string $toolName): bool - { - return static::where('tool_name', $toolName)->delete() > 0; - } - - /** - * Get all sensitive tool names. - */ - public static function getAllToolNames(): array - { - return static::pluck('tool_name')->toArray(); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Models/McpToolCall.php b/packages/core-mcp/src/Mod/Mcp/Models/McpToolCall.php deleted file mode 100644 index 9281d99..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Models/McpToolCall.php +++ /dev/null @@ -1,161 +0,0 @@ - 'array', - 'result_summary' => 'array', - 'success' => 'boolean', - 'duration_ms' => 'integer', - ]; - - // Relationships - public function workspace(): BelongsTo - { - return $this->belongsTo(Workspace::class); - } - - // Scopes - public function scopeForServer(Builder $query, string $serverId): Builder - { - return $query->where('server_id', $serverId); - } - - public function scopeForTool(Builder $query, string $toolName): Builder - { - return $query->where('tool_name', $toolName); - } - - public function scopeSuccessful(Builder $query): Builder - { - return $query->where('success', true); - } - - public function scopeFailed(Builder $query): Builder - { - return $query->where('success', false); - } - - public function scopeRecent(Builder $query, int $hours = 24): Builder - { - return $query->where('created_at', '>=', now()->subHours($hours)); - } - - public function scopeToday(Builder $query): Builder - { - return $query->whereDate('created_at', today()); - } - - public function scopeThisWeek(Builder $query): Builder - { - return $query->where('created_at', '>=', now()->startOfWeek()); - } - - /** - * Log a tool call and update daily stats. - */ - public static function log( - string $serverId, - string $toolName, - array $params = [], - bool $success = true, - ?int $durationMs = null, - ?string $errorMessage = null, - ?string $errorCode = null, - ?array $resultSummary = null, - ?string $sessionId = null, - ?string $agentType = null, - ?string $planSlug = null, - ?int $workspaceId = null - ): self { - $call = static::create([ - 'workspace_id' => $workspaceId, - 'server_id' => $serverId, - 'tool_name' => $toolName, - 'input_params' => $params, - 'success' => $success, - 'duration_ms' => $durationMs, - 'error_message' => $errorMessage, - 'error_code' => $errorCode, - 'result_summary' => $resultSummary, - 'session_id' => $sessionId, - 'agent_type' => $agentType, - 'plan_slug' => $planSlug, - ]); - - // Update daily stats - McpToolCallStat::incrementForCall($call); - - return $call; - } - - // Helpers - public function getDurationForHumans(): string - { - if (! $this->duration_ms) { - return '-'; - } - - if ($this->duration_ms < 1000) { - return $this->duration_ms.'ms'; - } - - return round($this->duration_ms / 1000, 2).'s'; - } - - public function getStatusBadge(): string - { - return $this->success - ? 'Success' - : 'Failed'; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Models/McpToolCallStat.php b/packages/core-mcp/src/Mod/Mcp/Models/McpToolCallStat.php deleted file mode 100644 index 0ed58b2..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Models/McpToolCallStat.php +++ /dev/null @@ -1,263 +0,0 @@ - 'date', - 'call_count' => 'integer', - 'success_count' => 'integer', - 'error_count' => 'integer', - 'total_duration_ms' => 'integer', - 'min_duration_ms' => 'integer', - 'max_duration_ms' => 'integer', - ]; - - // Relationships - public function workspace(): BelongsTo - { - return $this->belongsTo(Workspace::class); - } - - // Scopes - public function scopeForServer(Builder $query, string $serverId): Builder - { - return $query->where('server_id', $serverId); - } - - public function scopeForTool(Builder $query, string $toolName): Builder - { - return $query->where('tool_name', $toolName); - } - - public function scopeForDate(Builder $query, Carbon|string $date): Builder - { - $date = $date instanceof Carbon ? $date->toDateString() : $date; - - return $query->where('date', $date); - } - - public function scopeForDateRange(Builder $query, Carbon|string $start, Carbon|string $end): Builder - { - $start = $start instanceof Carbon ? $start->toDateString() : $start; - $end = $end instanceof Carbon ? $end->toDateString() : $end; - - return $query->whereBetween('date', [$start, $end]); - } - - public function scopeLast7Days(Builder $query): Builder - { - return $query->forDateRange(now()->subDays(6), now()); - } - - public function scopeLast30Days(Builder $query): Builder - { - return $query->forDateRange(now()->subDays(29), now()); - } - - /** - * Increment stats for a tool call. - */ - public static function incrementForCall(McpToolCall $call): void - { - $stat = static::firstOrCreate([ - 'date' => $call->created_at->toDateString(), - 'server_id' => $call->server_id, - 'tool_name' => $call->tool_name, - 'workspace_id' => $call->workspace_id, - ], [ - 'call_count' => 0, - 'success_count' => 0, - 'error_count' => 0, - 'total_duration_ms' => 0, - ]); - - $stat->call_count++; - - if ($call->success) { - $stat->success_count++; - } else { - $stat->error_count++; - } - - if ($call->duration_ms) { - $stat->total_duration_ms += $call->duration_ms; - - if ($stat->min_duration_ms === null || $call->duration_ms < $stat->min_duration_ms) { - $stat->min_duration_ms = $call->duration_ms; - } - - if ($stat->max_duration_ms === null || $call->duration_ms > $stat->max_duration_ms) { - $stat->max_duration_ms = $call->duration_ms; - } - } - - $stat->save(); - } - - // Computed attributes - public function getSuccessRateAttribute(): float - { - if ($this->call_count === 0) { - return 0; - } - - return round(($this->success_count / $this->call_count) * 100, 1); - } - - public function getAvgDurationMsAttribute(): ?float - { - if ($this->call_count === 0 || $this->total_duration_ms === 0) { - return null; - } - - return round($this->total_duration_ms / $this->call_count, 1); - } - - public function getAvgDurationForHumansAttribute(): string - { - $avg = $this->avg_duration_ms; - if ($avg === null) { - return '-'; - } - - if ($avg < 1000) { - return round($avg).'ms'; - } - - return round($avg / 1000, 2).'s'; - } - - /** - * Get top tools by call count. - */ - public static function getTopTools(int $days = 7, int $limit = 10, ?int $workspaceId = null): Collection - { - $query = static::query() - ->select('server_id', 'tool_name') - ->selectRaw('SUM(call_count) as total_calls') - ->selectRaw('SUM(success_count) as total_success') - ->selectRaw('SUM(error_count) as total_errors') - ->selectRaw('AVG(total_duration_ms / NULLIF(call_count, 0)) as avg_duration') - ->forDateRange(now()->subDays($days - 1), now()) - ->groupBy('server_id', 'tool_name') - ->orderByDesc('total_calls') - ->limit($limit); - - if ($workspaceId !== null) { - $query->where('workspace_id', $workspaceId); - } - - return $query->get() - ->map(function ($item) { - $item->success_rate = $item->total_calls > 0 - ? round(($item->total_success / $item->total_calls) * 100, 1) - : 0; - - return $item; - }); - } - - /** - * Get daily trend data. - */ - public static function getDailyTrend(int $days = 7, ?int $workspaceId = null): Collection - { - $query = static::query() - ->select('date') - ->selectRaw('SUM(call_count) as total_calls') - ->selectRaw('SUM(success_count) as total_success') - ->selectRaw('SUM(error_count) as total_errors') - ->forDateRange(now()->subDays($days - 1), now()) - ->groupBy('date') - ->orderBy('date'); - - if ($workspaceId !== null) { - $query->where('workspace_id', $workspaceId); - } - - return $query->get() - ->map(function ($item) { - $item->success_rate = $item->total_calls > 0 - ? round(($item->total_success / $item->total_calls) * 100, 1) - : 0; - - return $item; - }); - } - - /** - * Get server-level statistics. - */ - public static function getServerStats(int $days = 7, ?int $workspaceId = null): Collection - { - $query = static::query() - ->select('server_id') - ->selectRaw('SUM(call_count) as total_calls') - ->selectRaw('SUM(success_count) as total_success') - ->selectRaw('SUM(error_count) as total_errors') - ->selectRaw('COUNT(DISTINCT tool_name) as unique_tools') - ->forDateRange(now()->subDays($days - 1), now()) - ->groupBy('server_id') - ->orderByDesc('total_calls'); - - if ($workspaceId !== null) { - $query->where('workspace_id', $workspaceId); - } - - return $query->get() - ->map(function ($item) { - $item->success_rate = $item->total_calls > 0 - ? round(($item->total_success / $item->total_calls) * 100, 1) - : 0; - - return $item; - }); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Models/McpToolVersion.php b/packages/core-mcp/src/Mod/Mcp/Models/McpToolVersion.php deleted file mode 100644 index 3bff53a..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Models/McpToolVersion.php +++ /dev/null @@ -1,359 +0,0 @@ - 'array', - 'output_schema' => 'array', - 'is_latest' => 'boolean', - 'deprecated_at' => 'datetime', - 'sunset_at' => 'datetime', - ]; - - // ------------------------------------------------------------------------- - // Scopes - // ------------------------------------------------------------------------- - - /** - * Filter by server. - */ - public function scopeForServer(Builder $query, string $serverId): Builder - { - return $query->where('server_id', $serverId); - } - - /** - * Filter by tool name. - */ - public function scopeForTool(Builder $query, string $toolName): Builder - { - return $query->where('tool_name', $toolName); - } - - /** - * Filter by specific version. - */ - public function scopeForVersion(Builder $query, string $version): Builder - { - return $query->where('version', $version); - } - - /** - * Get only latest versions. - */ - public function scopeLatest(Builder $query): Builder - { - return $query->where('is_latest', true); - } - - /** - * Get deprecated versions. - */ - public function scopeDeprecated(Builder $query): Builder - { - return $query->whereNotNull('deprecated_at') - ->where('deprecated_at', '<=', now()); - } - - /** - * Get sunset versions (blocked). - */ - public function scopeSunset(Builder $query): Builder - { - return $query->whereNotNull('sunset_at') - ->where('sunset_at', '<=', now()); - } - - /** - * Get active versions (not sunset). - */ - public function scopeActive(Builder $query): Builder - { - return $query->where(function ($q) { - $q->whereNull('sunset_at') - ->orWhere('sunset_at', '>', now()); - }); - } - - /** - * Order by version (newest first using semver sort). - */ - public function scopeOrderByVersion(Builder $query, string $direction = 'desc'): Builder - { - // Basic version ordering - splits on dots and orders numerically - // For production use, consider a more robust semver sorting approach - return $query->orderByRaw( - "CAST(SUBSTRING_INDEX(version, '.', 1) AS UNSIGNED) {$direction}, ". - "CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(version, '.', 2), '.', -1) AS UNSIGNED) {$direction}, ". - "CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(version, '.', 3), '.', -1) AS UNSIGNED) {$direction}" - ); - } - - // ------------------------------------------------------------------------- - // Accessors - // ------------------------------------------------------------------------- - - /** - * Check if this version is deprecated. - */ - public function getIsDeprecatedAttribute(): bool - { - return $this->deprecated_at !== null && $this->deprecated_at->isPast(); - } - - /** - * Check if this version is sunset (blocked). - */ - public function getIsSunsetAttribute(): bool - { - return $this->sunset_at !== null && $this->sunset_at->isPast(); - } - - /** - * Get the lifecycle status of this version. - */ - public function getStatusAttribute(): string - { - if ($this->is_sunset) { - return 'sunset'; - } - - if ($this->is_deprecated) { - return 'deprecated'; - } - - if ($this->is_latest) { - return 'latest'; - } - - return 'active'; - } - - /** - * Get full tool identifier (server:tool). - */ - public function getFullNameAttribute(): string - { - return "{$this->server_id}:{$this->tool_name}"; - } - - /** - * Get full versioned identifier (server:tool@version). - */ - public function getVersionedNameAttribute(): string - { - return "{$this->server_id}:{$this->tool_name}@{$this->version}"; - } - - // ------------------------------------------------------------------------- - // Methods - // ------------------------------------------------------------------------- - - /** - * Get deprecation warning message if deprecated but not sunset. - */ - public function getDeprecationWarning(): ?array - { - if (! $this->is_deprecated || $this->is_sunset) { - return null; - } - - $warning = [ - 'code' => 'TOOL_VERSION_DEPRECATED', - 'message' => "Tool version {$this->version} is deprecated.", - 'current_version' => $this->version, - ]; - - // Find the latest version to suggest - $latest = static::forServer($this->server_id) - ->forTool($this->tool_name) - ->latest() - ->first(); - - if ($latest && $latest->version !== $this->version) { - $warning['latest_version'] = $latest->version; - $warning['message'] .= " Please upgrade to version {$latest->version}."; - } - - if ($this->sunset_at) { - $warning['sunset_at'] = $this->sunset_at->toIso8601String(); - $warning['message'] .= " This version will be blocked after {$this->sunset_at->format('Y-m-d')}."; - } - - if ($this->migration_notes) { - $warning['migration_notes'] = $this->migration_notes; - } - - return $warning; - } - - /** - * Get sunset error if this version is blocked. - */ - public function getSunsetError(): ?array - { - if (! $this->is_sunset) { - return null; - } - - $error = [ - 'code' => 'TOOL_VERSION_SUNSET', - 'message' => "Tool version {$this->version} is no longer available as of {$this->sunset_at->format('Y-m-d')}.", - 'sunset_version' => $this->version, - 'sunset_at' => $this->sunset_at->toIso8601String(), - ]; - - // Find the latest version to suggest - $latest = static::forServer($this->server_id) - ->forTool($this->tool_name) - ->latest() - ->first(); - - if ($latest && $latest->version !== $this->version) { - $error['latest_version'] = $latest->version; - $error['message'] .= " Please use version {$latest->version} instead."; - } - - if ($this->migration_notes) { - $error['migration_notes'] = $this->migration_notes; - } - - return $error; - } - - /** - * Compare schemas between this version and another. - * - * @return array{added: array, removed: array, changed: array} - */ - public function compareSchemaWith(self $other): array - { - $thisProps = $this->input_schema['properties'] ?? []; - $otherProps = $other->input_schema['properties'] ?? []; - - $added = array_diff_key($otherProps, $thisProps); - $removed = array_diff_key($thisProps, $otherProps); - - $changed = []; - foreach (array_intersect_key($thisProps, $otherProps) as $key => $thisProp) { - $otherProp = $otherProps[$key]; - if (json_encode($thisProp) !== json_encode($otherProp)) { - $changed[$key] = [ - 'from' => $thisProp, - 'to' => $otherProp, - ]; - } - } - - return [ - 'added' => array_keys($added), - 'removed' => array_keys($removed), - 'changed' => $changed, - ]; - } - - /** - * Mark this version as deprecated. - */ - public function deprecate(?Carbon $sunsetAt = null): self - { - $this->deprecated_at = now(); - - if ($sunsetAt) { - $this->sunset_at = $sunsetAt; - } - - $this->save(); - - return $this; - } - - /** - * Mark this version as the latest (and unmark others). - */ - public function markAsLatest(): self - { - // Unmark all other versions for this tool - static::forServer($this->server_id) - ->forTool($this->tool_name) - ->where('id', '!=', $this->id) - ->update(['is_latest' => false]); - - $this->is_latest = true; - $this->save(); - - return $this; - } - - /** - * Export version info for API responses. - */ - public function toApiArray(): array - { - return [ - 'server_id' => $this->server_id, - 'tool_name' => $this->tool_name, - 'version' => $this->version, - 'is_latest' => $this->is_latest, - 'status' => $this->status, - 'description' => $this->description, - 'input_schema' => $this->input_schema, - 'output_schema' => $this->output_schema, - 'deprecated_at' => $this->deprecated_at?->toIso8601String(), - 'sunset_at' => $this->sunset_at?->toIso8601String(), - 'migration_notes' => $this->migration_notes, - 'changelog' => $this->changelog, - 'created_at' => $this->created_at?->toIso8601String(), - ]; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Models/McpUsageQuota.php b/packages/core-mcp/src/Mod/Mcp/Models/McpUsageQuota.php deleted file mode 100644 index e58d18e..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Models/McpUsageQuota.php +++ /dev/null @@ -1,193 +0,0 @@ - 'integer', - 'input_tokens' => 'integer', - 'output_tokens' => 'integer', - ]; - - // ───────────────────────────────────────────────────────────────────────── - // Relationships - // ───────────────────────────────────────────────────────────────────────── - - public function workspace(): BelongsTo - { - return $this->belongsTo(Workspace::class); - } - - // ───────────────────────────────────────────────────────────────────────── - // Scopes - // ───────────────────────────────────────────────────────────────────────── - - public function scopeForMonth(Builder $query, string $month): Builder - { - return $query->where('month', $month); - } - - public function scopeCurrentMonth(Builder $query): Builder - { - return $query->where('month', now()->format('Y-m')); - } - - // ───────────────────────────────────────────────────────────────────────── - // Factory Methods - // ───────────────────────────────────────────────────────────────────────── - - /** - * Get or create usage quota record for a workspace and month. - */ - public static function getOrCreate(int $workspaceId, ?string $month = null): self - { - $month = $month ?? now()->format('Y-m'); - - return static::firstOrCreate( - [ - 'workspace_id' => $workspaceId, - 'month' => $month, - ], - [ - 'tool_calls_count' => 0, - 'input_tokens' => 0, - 'output_tokens' => 0, - ] - ); - } - - /** - * Get current month's quota for a workspace. - */ - public static function getCurrentForWorkspace(int $workspaceId): self - { - return static::getOrCreate($workspaceId); - } - - // ───────────────────────────────────────────────────────────────────────── - // Usage Recording - // ───────────────────────────────────────────────────────────────────────── - - /** - * Record usage (increments counters atomically). - */ - public function recordUsage(int $toolCalls = 1, int $inputTokens = 0, int $outputTokens = 0): self - { - $this->increment('tool_calls_count', $toolCalls); - - if ($inputTokens > 0) { - $this->increment('input_tokens', $inputTokens); - } - - if ($outputTokens > 0) { - $this->increment('output_tokens', $outputTokens); - } - - return $this->fresh(); - } - - /** - * Record usage for a workspace (static convenience method). - */ - public static function record( - int $workspaceId, - int $toolCalls = 1, - int $inputTokens = 0, - int $outputTokens = 0 - ): self { - $quota = static::getCurrentForWorkspace($workspaceId); - - return $quota->recordUsage($toolCalls, $inputTokens, $outputTokens); - } - - // ───────────────────────────────────────────────────────────────────────── - // Computed Attributes - // ───────────────────────────────────────────────────────────────────────── - - /** - * Get total tokens (input + output). - */ - public function getTotalTokensAttribute(): int - { - return $this->input_tokens + $this->output_tokens; - } - - /** - * Get formatted month (e.g., "January 2026"). - */ - public function getMonthLabelAttribute(): string - { - return \Carbon\Carbon::createFromFormat('Y-m', $this->month)->format('F Y'); - } - - // ───────────────────────────────────────────────────────────────────────── - // Helpers - // ───────────────────────────────────────────────────────────────────────── - - /** - * Reset usage counters (for billing cycle reset). - */ - public function reset(): self - { - $this->update([ - 'tool_calls_count' => 0, - 'input_tokens' => 0, - 'output_tokens' => 0, - ]); - - return $this; - } - - /** - * Convert to array for API responses. - */ - public function toArray(): array - { - return [ - 'workspace_id' => $this->workspace_id, - 'month' => $this->month, - 'month_label' => $this->month_label, - 'tool_calls_count' => $this->tool_calls_count, - 'input_tokens' => $this->input_tokens, - 'output_tokens' => $this->output_tokens, - 'total_tokens' => $this->total_tokens, - 'updated_at' => $this->updated_at?->toIso8601String(), - ]; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Models/ToolMetric.php b/packages/core-mcp/src/Mod/Mcp/Models/ToolMetric.php deleted file mode 100644 index 92bb7ac..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Models/ToolMetric.php +++ /dev/null @@ -1,278 +0,0 @@ - 'date', - 'call_count' => 'integer', - 'error_count' => 'integer', - 'total_duration_ms' => 'integer', - 'min_duration_ms' => 'integer', - 'max_duration_ms' => 'integer', - ]; - - // ------------------------------------------------------------------------- - // Scopes - // ------------------------------------------------------------------------- - - /** - * Filter metrics for a specific tool. - */ - public function scopeForTool(Builder $query, string $toolName): Builder - { - return $query->where('tool_name', $toolName); - } - - /** - * Filter metrics for a specific workspace. - */ - public function scopeForWorkspace(Builder $query, ?string $workspaceId): Builder - { - if ($workspaceId === null) { - return $query->whereNull('workspace_id'); - } - - return $query->where('workspace_id', $workspaceId); - } - - /** - * Filter metrics within a date range. - */ - public function scopeForDateRange(Builder $query, Carbon|string $start, Carbon|string $end): Builder - { - $start = $start instanceof Carbon ? $start->toDateString() : $start; - $end = $end instanceof Carbon ? $end->toDateString() : $end; - - return $query->whereBetween('date', [$start, $end]); - } - - /** - * Filter metrics for today. - */ - public function scopeToday(Builder $query): Builder - { - return $query->where('date', today()->toDateString()); - } - - /** - * Filter metrics for the last N days. - */ - public function scopeLastDays(Builder $query, int $days): Builder - { - return $query->forDateRange(now()->subDays($days - 1), now()); - } - - // ------------------------------------------------------------------------- - // Accessors - // ------------------------------------------------------------------------- - - /** - * Get the average duration in milliseconds. - */ - public function getAverageDurationAttribute(): float - { - if ($this->call_count === 0 || $this->total_duration_ms === 0) { - return 0.0; - } - - return round($this->total_duration_ms / $this->call_count, 2); - } - - /** - * Get the error rate as a percentage (0-100). - */ - public function getErrorRateAttribute(): float - { - if ($this->call_count === 0) { - return 0.0; - } - - return round(($this->error_count / $this->call_count) * 100, 2); - } - - /** - * Get average duration formatted for display. - */ - public function getAverageDurationForHumansAttribute(): string - { - $avg = $this->average_duration; - - if ($avg === 0.0) { - return '-'; - } - - if ($avg < 1000) { - return round($avg).'ms'; - } - - return round($avg / 1000, 2).'s'; - } - - // ------------------------------------------------------------------------- - // Methods - // ------------------------------------------------------------------------- - - /** - * Record a successful tool call. - */ - public static function recordCall( - string $toolName, - int $durationMs, - ?string $workspaceId = null, - ?Carbon $date = null - ): self { - $date = $date ?? now(); - - $metric = static::firstOrCreate([ - 'tool_name' => $toolName, - 'workspace_id' => $workspaceId, - 'date' => $date->toDateString(), - ], [ - 'call_count' => 0, - 'error_count' => 0, - 'total_duration_ms' => 0, - ]); - - $metric->call_count++; - $metric->total_duration_ms += $durationMs; - - if ($metric->min_duration_ms === null || $durationMs < $metric->min_duration_ms) { - $metric->min_duration_ms = $durationMs; - } - - if ($metric->max_duration_ms === null || $durationMs > $metric->max_duration_ms) { - $metric->max_duration_ms = $durationMs; - } - - $metric->save(); - - return $metric; - } - - /** - * Record a failed tool call. - */ - public static function recordError( - string $toolName, - int $durationMs, - ?string $workspaceId = null, - ?Carbon $date = null - ): self { - $date = $date ?? now(); - - $metric = static::firstOrCreate([ - 'tool_name' => $toolName, - 'workspace_id' => $workspaceId, - 'date' => $date->toDateString(), - ], [ - 'call_count' => 0, - 'error_count' => 0, - 'total_duration_ms' => 0, - ]); - - $metric->call_count++; - $metric->error_count++; - $metric->total_duration_ms += $durationMs; - - if ($metric->min_duration_ms === null || $durationMs < $metric->min_duration_ms) { - $metric->min_duration_ms = $durationMs; - } - - if ($metric->max_duration_ms === null || $durationMs > $metric->max_duration_ms) { - $metric->max_duration_ms = $durationMs; - } - - $metric->save(); - - return $metric; - } - - /** - * Get aggregated stats for a tool across all dates. - */ - public static function getAggregatedStats( - string $toolName, - ?Carbon $from = null, - ?Carbon $to = null, - ?string $workspaceId = null - ): array { - $query = static::forTool($toolName); - - if ($from && $to) { - $query->forDateRange($from, $to); - } - - if ($workspaceId !== null) { - $query->forWorkspace($workspaceId); - } - - $metrics = $query->get(); - - if ($metrics->isEmpty()) { - return [ - 'tool_name' => $toolName, - 'total_calls' => 0, - 'error_count' => 0, - 'error_rate' => 0.0, - 'avg_duration_ms' => 0.0, - 'min_duration_ms' => 0, - 'max_duration_ms' => 0, - ]; - } - - $totalCalls = $metrics->sum('call_count'); - $errorCount = $metrics->sum('error_count'); - $totalDuration = $metrics->sum('total_duration_ms'); - - return [ - 'tool_name' => $toolName, - 'total_calls' => $totalCalls, - 'error_count' => $errorCount, - 'error_rate' => $totalCalls > 0 ? round(($errorCount / $totalCalls) * 100, 2) : 0.0, - 'avg_duration_ms' => $totalCalls > 0 ? round($totalDuration / $totalCalls, 2) : 0.0, - 'min_duration_ms' => $metrics->min('min_duration_ms') ?? 0, - 'max_duration_ms' => $metrics->max('max_duration_ms') ?? 0, - ]; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Resources/AppConfig.php b/packages/core-mcp/src/Mod/Mcp/Resources/AppConfig.php deleted file mode 100644 index ba42cf7..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Resources/AppConfig.php +++ /dev/null @@ -1,24 +0,0 @@ - config('app.name'), - 'env' => config('app.env'), - 'debug' => config('app.debug'), - 'url' => config('app.url'), - ]; - - return Response::text(json_encode($config, JSON_PRETTY_PRINT)); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Resources/ContentResource.php b/packages/core-mcp/src/Mod/Mcp/Resources/ContentResource.php deleted file mode 100644 index f807f9f..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Resources/ContentResource.php +++ /dev/null @@ -1,170 +0,0 @@ -get('uri', ''); - - // Parse URI: content://{workspace}/{slug} - if (! str_starts_with($uri, 'content://')) { - return Response::text('Invalid URI format. Expected: content://{workspace}/{slug}'); - } - - $path = substr($uri, 10); // Remove 'content://' - $parts = explode('/', $path, 2); - - if (count($parts) < 2) { - return Response::text('Invalid URI format. Expected: content://{workspace}/{slug}'); - } - - [$workspaceSlug, $contentSlug] = $parts; - - // Resolve workspace - $workspace = Workspace::where('slug', $workspaceSlug) - ->orWhere('id', $workspaceSlug) - ->first(); - - if (! $workspace) { - return Response::text("Workspace not found: {$workspaceSlug}"); - } - - // Find content item - $item = ContentItem::forWorkspace($workspace->id) - ->native() - ->where('slug', $contentSlug) - ->first(); - - if (! $item) { - // Try by ID - if (is_numeric($contentSlug)) { - $item = ContentItem::forWorkspace($workspace->id) - ->native() - ->find($contentSlug); - } - } - - if (! $item) { - return Response::text("Content not found: {$contentSlug}"); - } - - // Load relationships - $item->load(['author', 'taxonomies']); - - // Return as markdown with frontmatter - $markdown = $this->contentToMarkdown($item, $workspace); - - return Response::text($markdown); - } - - /** - * Convert content item to markdown with frontmatter. - */ - protected function contentToMarkdown(ContentItem $item, Workspace $workspace): string - { - $md = "---\n"; - $md .= "title: \"{$item->title}\"\n"; - $md .= "slug: {$item->slug}\n"; - $md .= "workspace: {$workspace->slug}\n"; - $md .= "type: {$item->type}\n"; - $md .= "status: {$item->status}\n"; - - if ($item->author) { - $md .= "author: {$item->author->name}\n"; - } - - $categories = $item->categories->pluck('name')->all(); - if (! empty($categories)) { - $md .= 'categories: ['.implode(', ', $categories)."]\n"; - } - - $tags = $item->tags->pluck('name')->all(); - if (! empty($tags)) { - $md .= 'tags: ['.implode(', ', $tags)."]\n"; - } - - if ($item->publish_at) { - $md .= 'publish_at: '.$item->publish_at->toIso8601String()."\n"; - } - - $md .= 'created_at: '.$item->created_at->toIso8601String()."\n"; - $md .= 'updated_at: '.$item->updated_at->toIso8601String()."\n"; - - if ($item->seo_meta) { - if (isset($item->seo_meta['title'])) { - $md .= "seo_title: \"{$item->seo_meta['title']}\"\n"; - } - if (isset($item->seo_meta['description'])) { - $md .= "seo_description: \"{$item->seo_meta['description']}\"\n"; - } - } - - $md .= "---\n\n"; - - // Add excerpt if available - if ($item->excerpt) { - $md .= "> {$item->excerpt}\n\n"; - } - - // Prefer markdown content, fall back to stripping HTML (clean > original) - $content = $item->content_markdown - ?? strip_tags($item->content_html_clean ?? $item->content_html_original ?? ''); - $md .= $content; - - return $md; - } - - /** - * Get list of available content resources. - * - * This is called when MCP lists available resources. - */ - public static function list(): array - { - $resources = []; - - // Get all workspaces with content - $workspaces = Workspace::whereHas('contentItems', function ($q) { - $q->native()->where('status', 'publish'); - })->get(); - - foreach ($workspaces as $workspace) { - // Get published content for this workspace - $items = ContentItem::forWorkspace($workspace->id) - ->native() - ->published() - ->orderByDesc('updated_at') - ->limit(50) - ->get(['id', 'slug', 'title', 'type']); - - foreach ($items as $item) { - $resources[] = [ - 'uri' => "content://{$workspace->slug}/{$item->slug}", - 'name' => $item->title, - 'description' => ucfirst($item->type).": {$item->title}", - 'mimeType' => 'text/markdown', - ]; - } - } - - return $resources; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Resources/DatabaseSchema.php b/packages/core-mcp/src/Mod/Mcp/Resources/DatabaseSchema.php deleted file mode 100644 index 3055249..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Resources/DatabaseSchema.php +++ /dev/null @@ -1,27 +0,0 @@ -mapWithKeys(function ($table) { - $tableName = array_values((array) $table)[0]; - $columns = DB::select("DESCRIBE {$tableName}"); - - return [$tableName => $columns]; - }) - ->toArray(); - - return Response::text(json_encode($schema, JSON_PRETTY_PRINT)); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Routes/admin.php b/packages/core-mcp/src/Mod/Mcp/Routes/admin.php deleted file mode 100644 index 251c438..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Routes/admin.php +++ /dev/null @@ -1,70 +0,0 @@ -name('mcp.')->group(function () { - // Dashboard (workspace MCP usage overview) - Route::get('dashboard', Dashboard::class) - ->name('dashboard'); - - // API key management - Route::get('keys', ApiKeyManager::class) - ->name('keys'); - - // Enhanced MCP Playground with tool browser, history, and examples - Route::get('playground', McpPlayground::class) - ->name('playground'); - - // Legacy simple playground (API-key focused) - Route::get('playground/simple', Playground::class) - ->name('playground.simple'); - - // Request log for debugging - Route::get('logs', RequestLog::class) - ->name('logs'); - - // Analytics endpoints - Route::get('servers/{id}/analytics', [McpRegistryController::class, 'analytics']) - ->name('servers.analytics'); - - // Tool Usage Analytics Dashboard - Route::get('analytics', ToolAnalyticsDashboard::class) - ->name('analytics'); - - // Single tool analytics detail - Route::get('analytics/tool/{name}', ToolAnalyticsDetail::class) - ->name('analytics.tool'); - - // Audit log viewer (compliance and security) - Route::get('audit-log', AuditLogViewer::class) - ->name('audit-log'); - - // Tool version management (Hades only) - Route::get('versions', ToolVersionManager::class) - ->name('versions'); - - // Quota usage overview - Route::get('quotas', QuotaUsage::class) - ->name('quotas'); -}); diff --git a/packages/core-mcp/src/Mod/Mcp/Services/AgentSessionService.php b/packages/core-mcp/src/Mod/Mcp/Services/AgentSessionService.php deleted file mode 100644 index dac3aad..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Services/AgentSessionService.php +++ /dev/null @@ -1,336 +0,0 @@ -update(['workspace_id' => $workspaceId]); - } - - if (! empty($initialContext)) { - $session->updateContextSummary($initialContext); - } - - // Cache the active session ID for quick lookup - $this->cacheActiveSession($session); - - return $session; - } - - /** - * Get an active session by ID. - */ - public function get(string $sessionId): ?AgentSession - { - return AgentSession::where('session_id', $sessionId)->first(); - } - - /** - * Resume an existing session. - */ - public function resume(string $sessionId): ?AgentSession - { - $session = $this->get($sessionId); - - if (! $session) { - return null; - } - - // Only resume if paused or was handed off - if ($session->status === AgentSession::STATUS_PAUSED) { - $session->resume(); - } - - // Update activity timestamp - $session->touchActivity(); - - // Cache as active - $this->cacheActiveSession($session); - - return $session; - } - - /** - * Get active sessions for a workspace. - */ - public function getActiveSessions(?int $workspaceId = null): Collection - { - $query = AgentSession::active(); - - if ($workspaceId !== null) { - $query->where('workspace_id', $workspaceId); - } - - return $query->orderBy('last_active_at', 'desc')->get(); - } - - /** - * Get sessions for a specific plan. - */ - public function getSessionsForPlan(AgentPlan $plan): Collection - { - return AgentSession::forPlan($plan) - ->orderBy('created_at', 'desc') - ->get(); - } - - /** - * Get the most recent session for a plan. - */ - public function getLatestSessionForPlan(AgentPlan $plan): ?AgentSession - { - return AgentSession::forPlan($plan) - ->orderBy('created_at', 'desc') - ->first(); - } - - /** - * End a session. - */ - public function end(string $sessionId, string $status = AgentSession::STATUS_COMPLETED, ?string $summary = null): ?AgentSession - { - $session = $this->get($sessionId); - - if (! $session) { - return null; - } - - $session->end($status, $summary); - - // Remove from active cache - $this->clearCachedSession($session); - - return $session; - } - - /** - * Pause a session for later resumption. - */ - public function pause(string $sessionId): ?AgentSession - { - $session = $this->get($sessionId); - - if (! $session) { - return null; - } - - $session->pause(); - - return $session; - } - - /** - * Prepare a session for handoff to another agent. - */ - public function prepareHandoff( - string $sessionId, - string $summary, - array $nextSteps = [], - array $blockers = [], - array $contextForNext = [] - ): ?AgentSession { - $session = $this->get($sessionId); - - if (! $session) { - return null; - } - - $session->prepareHandoff($summary, $nextSteps, $blockers, $contextForNext); - - return $session; - } - - /** - * Get handoff context from a session. - */ - public function getHandoffContext(string $sessionId): ?array - { - $session = $this->get($sessionId); - - if (! $session) { - return null; - } - - return $session->getHandoffContext(); - } - - /** - * Create a follow-up session continuing from a previous one. - */ - public function continueFrom(string $previousSessionId, string $newAgentType): ?AgentSession - { - $previousSession = $this->get($previousSessionId); - - if (! $previousSession) { - return null; - } - - // Get the handoff context - $handoffContext = $previousSession->getHandoffContext(); - - // Create new session with context from previous - $newSession = $this->start( - $newAgentType, - $previousSession->plan, - $previousSession->workspace_id, - [ - 'continued_from' => $previousSessionId, - 'previous_agent' => $previousSession->agent_type, - 'handoff_notes' => $handoffContext['handoff_notes'] ?? null, - 'inherited_context' => $handoffContext['context_summary'] ?? null, - ] - ); - - // Mark previous session as handed off - $previousSession->end('handed_off', 'Handed off to '.$newAgentType); - - return $newSession; - } - - /** - * Store custom state in session cache for fast access. - */ - public function setState(string $sessionId, string $key, mixed $value, ?int $ttl = null): void - { - $cacheKey = self::CACHE_PREFIX.$sessionId.':'.$key; - Cache::put($cacheKey, $value, $ttl ?? $this->getCacheTtl()); - } - - /** - * Get custom state from session cache. - */ - public function getState(string $sessionId, string $key, mixed $default = null): mixed - { - $cacheKey = self::CACHE_PREFIX.$sessionId.':'.$key; - - return Cache::get($cacheKey, $default); - } - - /** - * Check if a session exists and is valid. - */ - public function exists(string $sessionId): bool - { - return AgentSession::where('session_id', $sessionId)->exists(); - } - - /** - * Check if a session is active. - */ - public function isActive(string $sessionId): bool - { - $session = $this->get($sessionId); - - return $session !== null && $session->isActive(); - } - - /** - * Get session statistics. - */ - public function getSessionStats(?int $workspaceId = null, int $days = 7): array - { - $query = AgentSession::where('created_at', '>=', now()->subDays($days)); - - if ($workspaceId !== null) { - $query->where('workspace_id', $workspaceId); - } - - $sessions = $query->get(); - - $byStatus = $sessions->groupBy('status')->map->count(); - $byAgent = $sessions->groupBy('agent_type')->map->count(); - - $completedSessions = $sessions->where('status', AgentSession::STATUS_COMPLETED); - $avgDuration = $completedSessions->avg(fn ($s) => $s->getDuration() ?? 0); - - return [ - 'total' => $sessions->count(), - 'active' => $sessions->where('status', AgentSession::STATUS_ACTIVE)->count(), - 'by_status' => $byStatus->toArray(), - 'by_agent_type' => $byAgent->toArray(), - 'avg_duration_minutes' => round($avgDuration, 1), - 'period_days' => $days, - ]; - } - - /** - * Clean up stale sessions (active but not touched in X hours). - */ - public function cleanupStaleSessions(int $hoursInactive = 24): int - { - $cutoff = now()->subHours($hoursInactive); - - $staleSessions = AgentSession::active() - ->where('last_active_at', '<', $cutoff) - ->get(); - - foreach ($staleSessions as $session) { - $session->fail('Session timed out due to inactivity'); - $this->clearCachedSession($session); - } - - return $staleSessions->count(); - } - - /** - * Cache the active session for quick lookup. - */ - protected function cacheActiveSession(AgentSession $session): void - { - $cacheKey = self::CACHE_PREFIX.'active:'.$session->session_id; - Cache::put($cacheKey, [ - 'session_id' => $session->session_id, - 'agent_type' => $session->agent_type, - 'plan_id' => $session->agent_plan_id, - 'workspace_id' => $session->workspace_id, - 'started_at' => $session->started_at?->toIso8601String(), - ], $this->getCacheTtl()); - } - - /** - * Clear cached session data. - */ - protected function clearCachedSession(AgentSession $session): void - { - $cacheKey = self::CACHE_PREFIX.'active:'.$session->session_id; - Cache::forget($cacheKey); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Services/AgentToolRegistry.php b/packages/core-mcp/src/Mod/Mcp/Services/AgentToolRegistry.php deleted file mode 100644 index e3718c9..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Services/AgentToolRegistry.php +++ /dev/null @@ -1,244 +0,0 @@ - - */ - protected array $tools = []; - - /** - * Register a tool. - * - * If the tool implements HasDependencies, its dependencies - * are automatically registered with the ToolDependencyService. - */ - public function register(AgentToolInterface $tool): self - { - $this->tools[$tool->name()] = $tool; - - // Auto-register dependencies if tool declares them - if ($tool instanceof HasDependencies && method_exists($tool, 'dependencies')) { - $dependencies = $tool->dependencies(); - if (! empty($dependencies)) { - app(ToolDependencyService::class)->register($tool->name(), $dependencies); - } - } - - return $this; - } - - /** - * Register multiple tools at once. - * - * @param array $tools - */ - public function registerMany(array $tools): self - { - foreach ($tools as $tool) { - $this->register($tool); - } - - return $this; - } - - /** - * Check if a tool is registered. - */ - public function has(string $name): bool - { - return isset($this->tools[$name]); - } - - /** - * Get a tool by name. - */ - public function get(string $name): ?AgentToolInterface - { - return $this->tools[$name] ?? null; - } - - /** - * Get all registered tools. - * - * @return Collection - */ - public function all(): Collection - { - return collect($this->tools); - } - - /** - * Get tools filtered by category. - * - * @return Collection - */ - public function byCategory(string $category): Collection - { - return $this->all()->filter( - fn (AgentToolInterface $tool) => $tool->category() === $category - ); - } - - /** - * Get tools accessible by an API key. - * - * @return Collection - */ - public function forApiKey(ApiKey $apiKey): Collection - { - return $this->all()->filter(function (AgentToolInterface $tool) use ($apiKey) { - // Check if API key has required scopes - foreach ($tool->requiredScopes() as $scope) { - if (! $apiKey->hasScope($scope)) { - return false; - } - } - - // Check if API key has tool-level permission - return $this->apiKeyCanAccessTool($apiKey, $tool->name()); - }); - } - - /** - * Check if an API key can access a specific tool. - */ - public function apiKeyCanAccessTool(ApiKey $apiKey, string $toolName): bool - { - $allowedTools = $apiKey->tool_scopes ?? null; - - // Null means all tools allowed - if ($allowedTools === null) { - return true; - } - - return in_array($toolName, $allowedTools, true); - } - - /** - * Execute a tool with permission and dependency checking. - * - * @param string $name Tool name - * @param array $args Tool arguments - * @param array $context Execution context - * @param ApiKey|null $apiKey Optional API key for permission checking - * @param bool $validateDependencies Whether to validate dependencies - * @return array Tool result - * - * @throws \InvalidArgumentException If tool not found - * @throws \RuntimeException If permission denied - * @throws \Core\Mod\Mcp\Exceptions\MissingDependencyException If dependencies not met - */ - public function execute( - string $name, - array $args, - array $context = [], - ?ApiKey $apiKey = null, - bool $validateDependencies = true - ): array { - $tool = $this->get($name); - - if (! $tool) { - throw new \InvalidArgumentException("Unknown tool: {$name}"); - } - - // Permission check if API key provided - if ($apiKey !== null) { - // Check scopes - foreach ($tool->requiredScopes() as $scope) { - if (! $apiKey->hasScope($scope)) { - throw new \RuntimeException( - "Permission denied: API key missing scope '{$scope}' for tool '{$name}'" - ); - } - } - - // Check tool-level permission - if (! $this->apiKeyCanAccessTool($apiKey, $name)) { - throw new \RuntimeException( - "Permission denied: API key does not have access to tool '{$name}'" - ); - } - } - - // Dependency check - if ($validateDependencies) { - $sessionId = $context['session_id'] ?? 'anonymous'; - $dependencyService = app(ToolDependencyService::class); - - $dependencyService->validateDependencies($sessionId, $name, $context, $args); - } - - $result = $tool->handle($args, $context); - - // Record successful tool call for dependency tracking - if ($validateDependencies && ($result['success'] ?? true) !== false) { - $sessionId = $context['session_id'] ?? 'anonymous'; - app(ToolDependencyService::class)->recordToolCall($sessionId, $name, $args); - } - - return $result; - } - - /** - * Get all tools as MCP tool definitions. - * - * @param ApiKey|null $apiKey Filter by API key permissions - */ - public function toMcpDefinitions(?ApiKey $apiKey = null): array - { - $tools = $apiKey !== null - ? $this->forApiKey($apiKey) - : $this->all(); - - return $tools->map(fn (AgentToolInterface $tool) => $tool->toMcpDefinition()) - ->values() - ->all(); - } - - /** - * Get tool categories with counts. - */ - public function categories(): Collection - { - return $this->all() - ->groupBy(fn (AgentToolInterface $tool) => $tool->category()) - ->map(fn ($tools) => $tools->count()); - } - - /** - * Get all tool names. - * - * @return array - */ - public function names(): array - { - return array_keys($this->tools); - } - - /** - * Get tool count. - */ - public function count(): int - { - return count($this->tools); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Services/AuditLogService.php b/packages/core-mcp/src/Mod/Mcp/Services/AuditLogService.php deleted file mode 100644 index ee2f0c6..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Services/AuditLogService.php +++ /dev/null @@ -1,480 +0,0 @@ -getSensitivityInfo($toolName); - $isSensitive = $sensitivityInfo !== null; - $sensitivityReason = $sensitivityInfo['reason'] ?? null; - $redactFields = $sensitivityInfo['redact_fields'] ?? []; - - // Redact sensitive fields from input - $redactedInput = $this->redactFields($inputParams, $redactFields); - - // Redact output if it contains sensitive data - $redactedOutput = $outputSummary ? $this->redactFields($outputSummary, $redactFields) : null; - - // Get the previous entry's hash for chain linking - $previousEntry = McpAuditLog::orderByDesc('id')->first(); - $previousHash = $previousEntry?->entry_hash; - - // Create the audit log entry - $auditLog = new McpAuditLog([ - 'server_id' => $serverId, - 'tool_name' => $toolName, - 'workspace_id' => $workspaceId, - 'session_id' => $sessionId, - 'input_params' => $redactedInput, - 'output_summary' => $redactedOutput, - 'success' => $success, - 'duration_ms' => $durationMs, - 'error_code' => $errorCode, - 'error_message' => $errorMessage, - 'actor_type' => $actorType, - 'actor_id' => $actorId, - 'actor_ip' => $actorIp, - 'is_sensitive' => $isSensitive, - 'sensitivity_reason' => $sensitivityReason, - 'previous_hash' => $previousHash, - 'agent_type' => $agentType, - 'plan_slug' => $planSlug, - ]); - - $auditLog->save(); - - // Compute and store the entry hash - $auditLog->entry_hash = $auditLog->computeHash(); - $auditLog->saveQuietly(); // Bypass updating event to allow hash update - - return $auditLog; - }); - } - - /** - * Verify the integrity of the entire audit log chain. - * - * @return array{valid: bool, total: int, verified: int, issues: array} - */ - public function verifyChain(?int $fromId = null, ?int $toId = null): array - { - $query = McpAuditLog::orderBy('id'); - - if ($fromId !== null) { - $query->where('id', '>=', $fromId); - } - - if ($toId !== null) { - $query->where('id', '<=', $toId); - } - - $issues = []; - $verified = 0; - $previousHash = null; - $isFirst = true; - - // If starting from a specific ID, get the previous entry's hash - if ($fromId !== null && $fromId > 1) { - $previousEntry = McpAuditLog::where('id', '<', $fromId) - ->orderByDesc('id') - ->first(); - $previousHash = $previousEntry?->entry_hash; - $isFirst = false; - } - - $total = $query->count(); - - // Process in chunks to avoid memory issues - $query->chunk(1000, function ($entries) use (&$issues, &$verified, &$previousHash, &$isFirst) { - foreach ($entries as $entry) { - // Verify hash - if (! $entry->verifyHash()) { - $issues[] = [ - 'id' => $entry->id, - 'type' => 'hash_mismatch', - 'message' => "Entry #{$entry->id}: Hash mismatch - data may have been tampered", - 'expected' => $entry->computeHash(), - 'actual' => $entry->entry_hash, - ]; - } - - // Verify chain link - if ($isFirst) { - if ($entry->previous_hash !== null) { - $issues[] = [ - 'id' => $entry->id, - 'type' => 'chain_break', - 'message' => "Entry #{$entry->id}: First entry should have null previous_hash", - ]; - } - $isFirst = false; - } else { - if ($entry->previous_hash !== $previousHash) { - $issues[] = [ - 'id' => $entry->id, - 'type' => 'chain_break', - 'message' => "Entry #{$entry->id}: Chain link broken", - 'expected' => $previousHash, - 'actual' => $entry->previous_hash, - ]; - } - } - - $previousHash = $entry->entry_hash; - $verified++; - } - }); - - return [ - 'valid' => empty($issues), - 'total' => $total, - 'verified' => $verified, - 'issues' => $issues, - ]; - } - - /** - * Get audit logs for export. - */ - public function export( - ?int $workspaceId = null, - ?Carbon $from = null, - ?Carbon $to = null, - ?string $toolName = null, - bool $sensitiveOnly = false - ): Collection { - $query = McpAuditLog::orderBy('id'); - - if ($workspaceId !== null) { - $query->where('workspace_id', $workspaceId); - } - - if ($from !== null) { - $query->where('created_at', '>=', $from); - } - - if ($to !== null) { - $query->where('created_at', '<=', $to); - } - - if ($toolName !== null) { - $query->where('tool_name', $toolName); - } - - if ($sensitiveOnly) { - $query->where('is_sensitive', true); - } - - return $query->get()->map(fn ($entry) => $entry->toExportArray()); - } - - /** - * Export to CSV format. - */ - public function exportToCsv( - ?int $workspaceId = null, - ?Carbon $from = null, - ?Carbon $to = null, - ?string $toolName = null, - bool $sensitiveOnly = false - ): string { - $data = $this->export($workspaceId, $from, $to, $toolName, $sensitiveOnly); - - if ($data->isEmpty()) { - return ''; - } - - $headers = array_keys($data->first()); - $output = fopen('php://temp', 'r+'); - - fputcsv($output, $headers); - - foreach ($data as $row) { - fputcsv($output, array_values($row)); - } - - rewind($output); - $csv = stream_get_contents($output); - fclose($output); - - return $csv; - } - - /** - * Export to JSON format. - */ - public function exportToJson( - ?int $workspaceId = null, - ?Carbon $from = null, - ?Carbon $to = null, - ?string $toolName = null, - bool $sensitiveOnly = false - ): string { - $data = $this->export($workspaceId, $from, $to, $toolName, $sensitiveOnly); - - // Include integrity verification in export - $verification = $this->verifyChain(); - - return json_encode([ - 'exported_at' => now()->toIso8601String(), - 'integrity' => [ - 'valid' => $verification['valid'], - 'total_entries' => $verification['total'], - 'verified' => $verification['verified'], - 'issues_count' => count($verification['issues']), - ], - 'filters' => [ - 'workspace_id' => $workspaceId, - 'from' => $from?->toIso8601String(), - 'to' => $to?->toIso8601String(), - 'tool_name' => $toolName, - 'sensitive_only' => $sensitiveOnly, - ], - 'entries' => $data->toArray(), - ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); - } - - /** - * Get statistics for the audit log. - */ - public function getStats(?int $workspaceId = null, ?int $days = 30): array - { - $query = McpAuditLog::query(); - - if ($workspaceId !== null) { - $query->where('workspace_id', $workspaceId); - } - - if ($days !== null) { - $query->where('created_at', '>=', now()->subDays($days)); - } - - $total = $query->count(); - $successful = (clone $query)->where('success', true)->count(); - $failed = (clone $query)->where('success', false)->count(); - $sensitive = (clone $query)->where('is_sensitive', true)->count(); - - $topTools = (clone $query) - ->select('tool_name', DB::raw('COUNT(*) as count')) - ->groupBy('tool_name') - ->orderByDesc('count') - ->limit(10) - ->pluck('count', 'tool_name') - ->toArray(); - - $dailyCounts = (clone $query) - ->select(DB::raw('DATE(created_at) as date'), DB::raw('COUNT(*) as count')) - ->groupBy('date') - ->orderBy('date') - ->limit($days ?? 30) - ->pluck('count', 'date') - ->toArray(); - - return [ - 'total' => $total, - 'successful' => $successful, - 'failed' => $failed, - 'success_rate' => $total > 0 ? round(($successful / $total) * 100, 2) : 0, - 'sensitive_calls' => $sensitive, - 'top_tools' => $topTools, - 'daily_counts' => $dailyCounts, - ]; - } - - /** - * Register a sensitive tool. - */ - public function registerSensitiveTool( - string $toolName, - string $reason, - array $redactFields = [], - bool $requireConsent = false - ): void { - McpSensitiveTool::register($toolName, $reason, $redactFields, $requireConsent); - $this->clearSensitiveToolsCache(); - } - - /** - * Unregister a sensitive tool. - */ - public function unregisterSensitiveTool(string $toolName): bool - { - $result = McpSensitiveTool::unregister($toolName); - $this->clearSensitiveToolsCache(); - - return $result; - } - - /** - * Get all registered sensitive tools. - */ - public function getSensitiveTools(): Collection - { - return McpSensitiveTool::all(); - } - - /** - * Check if a tool requires explicit consent. - */ - public function requiresConsent(string $toolName): bool - { - $info = $this->getSensitivityInfo($toolName); - - return $info !== null && ($info['require_explicit_consent'] ?? false); - } - - // ------------------------------------------------------------------------- - // Protected Methods - // ------------------------------------------------------------------------- - - /** - * Get sensitivity info for a tool (cached). - */ - protected function getSensitivityInfo(string $toolName): ?array - { - $sensitiveTools = Cache::remember( - self::SENSITIVE_TOOLS_CACHE_KEY, - self::SENSITIVE_TOOLS_CACHE_TTL, - fn () => McpSensitiveTool::all()->keyBy('tool_name')->toArray() - ); - - if (! isset($sensitiveTools[$toolName])) { - return null; - } - - $tool = $sensitiveTools[$toolName]; - - return [ - 'is_sensitive' => true, - 'reason' => $tool['reason'], - 'redact_fields' => $tool['redact_fields'] ?? [], - 'require_explicit_consent' => $tool['require_explicit_consent'] ?? false, - ]; - } - - /** - * Redact sensitive fields from data. - */ - protected function redactFields(array $data, array $additionalFields = []): array - { - $fieldsToRedact = array_merge($this->defaultRedactFields, $additionalFields); - - return $this->redactRecursive($data, $fieldsToRedact); - } - - /** - * Recursively redact fields in nested arrays. - */ - protected function redactRecursive(array $data, array $fieldsToRedact): array - { - foreach ($data as $key => $value) { - $keyLower = strtolower((string) $key); - - // Check if this key should be redacted - foreach ($fieldsToRedact as $field) { - if (str_contains($keyLower, strtolower($field))) { - $data[$key] = '[REDACTED]'; - - continue 2; - } - } - - // Recurse into nested arrays - if (is_array($value)) { - $data[$key] = $this->redactRecursive($value, $fieldsToRedact); - } - } - - return $data; - } - - /** - * Clear the sensitive tools cache. - */ - protected function clearSensitiveToolsCache(): void - { - Cache::forget(self::SENSITIVE_TOOLS_CACHE_KEY); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Services/CircuitBreaker.php b/packages/core-mcp/src/Mod/Mcp/Services/CircuitBreaker.php deleted file mode 100644 index 4b130df..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Services/CircuitBreaker.php +++ /dev/null @@ -1,442 +0,0 @@ -getState($service); - - // Fast fail when circuit is open - if ($state === self::STATE_OPEN) { - Log::debug("Circuit breaker open for {$service}, failing fast"); - - if ($fallback !== null) { - return $fallback(); - } - - throw new CircuitOpenException($service); - } - - // Handle half-open state with trial lock to prevent concurrent trial requests - $hasTrialLock = false; - if ($state === self::STATE_HALF_OPEN) { - $hasTrialLock = $this->acquireTrialLock($service); - - if (! $hasTrialLock) { - // Another request is already testing the service, fail fast - Log::debug("Circuit breaker half-open for {$service}, trial in progress, failing fast"); - - if ($fallback !== null) { - return $fallback(); - } - - throw new CircuitOpenException($service, "Service '{$service}' is being tested. Please try again shortly."); - } - } - - // Try the operation - try { - $result = $operation(); - - // Record success and release trial lock if held - $this->recordSuccess($service); - - if ($hasTrialLock) { - $this->releaseTrialLock($service); - } - - return $result; - } catch (Throwable $e) { - // Release trial lock if held - if ($hasTrialLock) { - $this->releaseTrialLock($service); - } - - // Record failure - $this->recordFailure($service, $e); - - // Check if we should trip the circuit - if ($this->shouldTrip($service)) { - $this->tripCircuit($service); - } - - // If fallback provided and this is a recoverable error, use it - if ($fallback !== null && $this->isRecoverableError($e)) { - Log::warning("Circuit breaker using fallback for {$service}", [ - 'error' => $e->getMessage(), - ]); - - return $fallback(); - } - - throw $e; - } - } - - /** - * Get the current state of a circuit. - */ - public function getState(string $service): string - { - $cacheKey = $this->getStateKey($service); - - $state = Cache::get($cacheKey); - - if ($state === null) { - return self::STATE_CLOSED; - } - - // Check if open circuit should transition to half-open - if ($state === self::STATE_OPEN) { - $openedAt = Cache::get($this->getOpenedAtKey($service)); - $resetTimeout = $this->getResetTimeout($service); - - if ($openedAt && (time() - $openedAt) >= $resetTimeout) { - $this->setState($service, self::STATE_HALF_OPEN); - - return self::STATE_HALF_OPEN; - } - } - - return $state; - } - - /** - * Get circuit statistics for monitoring. - */ - public function getStats(string $service): array - { - return [ - 'service' => $service, - 'state' => $this->getState($service), - 'failures' => (int) Cache::get($this->getFailureCountKey($service), 0), - 'successes' => (int) Cache::get($this->getSuccessCountKey($service), 0), - 'last_failure' => Cache::get($this->getLastFailureKey($service)), - 'opened_at' => Cache::get($this->getOpenedAtKey($service)), - 'threshold' => $this->getFailureThreshold($service), - 'reset_timeout' => $this->getResetTimeout($service), - ]; - } - - /** - * Manually reset a circuit to closed state. - */ - public function reset(string $service): void - { - $this->setState($service, self::STATE_CLOSED); - Cache::forget($this->getFailureCountKey($service)); - Cache::forget($this->getSuccessCountKey($service)); - Cache::forget($this->getLastFailureKey($service)); - Cache::forget($this->getOpenedAtKey($service)); - - Log::info("Circuit breaker manually reset for {$service}"); - } - - /** - * Check if a service is available (circuit not open). - */ - public function isAvailable(string $service): bool - { - return $this->getState($service) !== self::STATE_OPEN; - } - - /** - * Record a successful operation. - */ - protected function recordSuccess(string $service): void - { - $state = $this->getState($service); - - // Increment success counter with TTL - $this->atomicIncrement($this->getSuccessCountKey($service), self::COUNTER_TTL); - - // If half-open and we got a success, close the circuit - if ($state === self::STATE_HALF_OPEN) { - $this->closeCircuit($service); - } - - // Decay failures over time (successful calls reduce failure count) - $this->atomicDecrement($this->getFailureCountKey($service)); - } - - /** - * Record a failed operation. - */ - protected function recordFailure(string $service, Throwable $e): void - { - $failureKey = $this->getFailureCountKey($service); - $lastFailureKey = $this->getLastFailureKey($service); - $window = $this->getFailureWindow($service); - - // Atomic increment with TTL refresh using lock - $newCount = $this->atomicIncrement($failureKey, $window); - - // Record last failure details - Cache::put($lastFailureKey, [ - 'message' => $e->getMessage(), - 'class' => get_class($e), - 'time' => now()->toIso8601String(), - ], $window); - - Log::warning("Circuit breaker recorded failure for {$service}", [ - 'error' => $e->getMessage(), - 'failures' => $newCount, - ]); - } - - /** - * Check if the circuit should trip (open). - */ - protected function shouldTrip(string $service): bool - { - $failures = (int) Cache::get($this->getFailureCountKey($service), 0); - $threshold = $this->getFailureThreshold($service); - - return $failures >= $threshold; - } - - /** - * Trip the circuit to open state. - */ - protected function tripCircuit(string $service): void - { - $this->setState($service, self::STATE_OPEN); - Cache::put($this->getOpenedAtKey($service), time(), 86400); // 24h max - - Log::error("Circuit breaker tripped for {$service}", [ - 'failures' => Cache::get($this->getFailureCountKey($service)), - ]); - } - - /** - * Close the circuit after successful recovery. - */ - protected function closeCircuit(string $service): void - { - $this->setState($service, self::STATE_CLOSED); - Cache::forget($this->getFailureCountKey($service)); - Cache::forget($this->getOpenedAtKey($service)); - - Log::info("Circuit breaker closed for {$service} after successful recovery"); - } - - /** - * Set circuit state. - */ - protected function setState(string $service, string $state): void - { - Cache::put($this->getStateKey($service), $state, 86400); // 24h max - } - - /** - * Check if an exception is recoverable (should use fallback). - */ - protected function isRecoverableError(Throwable $e): bool - { - // Database connection errors, table not found, etc. - $recoverablePatterns = [ - 'SQLSTATE', - 'Connection refused', - 'Table .* doesn\'t exist', - 'Base table or view not found', - 'Connection timed out', - 'Too many connections', - ]; - - $message = $e->getMessage(); - - foreach ($recoverablePatterns as $pattern) { - if (preg_match('/'.$pattern.'/i', $message)) { - return true; - } - } - - return false; - } - - /** - * Get the failure threshold from config. - */ - protected function getFailureThreshold(string $service): int - { - return (int) config("mcp.circuit_breaker.{$service}.threshold", - config('mcp.circuit_breaker.default_threshold', 5) - ); - } - - /** - * Get the reset timeout (how long to wait before trying again). - */ - protected function getResetTimeout(string $service): int - { - return (int) config("mcp.circuit_breaker.{$service}.reset_timeout", - config('mcp.circuit_breaker.default_reset_timeout', 60) - ); - } - - /** - * Get the failure window (how long failures are counted). - */ - protected function getFailureWindow(string $service): int - { - return (int) config("mcp.circuit_breaker.{$service}.failure_window", - config('mcp.circuit_breaker.default_failure_window', 120) - ); - } - - /** - * Atomically increment a counter with TTL refresh. - * - * Uses a lock to ensure the increment and TTL refresh are atomic. - */ - protected function atomicIncrement(string $key, int $ttl): int - { - $lock = Cache::lock($key.':lock', 5); - - try { - $lock->block(3); - - $current = (int) Cache::get($key, 0); - $newValue = $current + 1; - Cache::put($key, $newValue, $ttl); - - return $newValue; - } finally { - $lock->release(); - } - } - - /** - * Atomically decrement a counter (only if positive). - * - * Note: We use COUNTER_TTL as a fallback since Laravel's Cache facade - * doesn't expose remaining TTL. The counter will refresh on activity. - */ - protected function atomicDecrement(string $key): int - { - $lock = Cache::lock($key.':lock', 5); - - try { - $lock->block(3); - - $current = (int) Cache::get($key, 0); - if ($current > 0) { - $newValue = $current - 1; - Cache::put($key, $newValue, self::COUNTER_TTL); - - return $newValue; - } - - return 0; - } finally { - $lock->release(); - } - } - - /** - * Acquire a trial lock for half-open state. - * - * Only one request can hold the trial lock at a time, preventing - * concurrent trial requests during half-open state. - */ - protected function acquireTrialLock(string $service): bool - { - $lockKey = $this->getTrialLockKey($service); - - // Try to acquire lock with a short TTL (auto-release if request hangs) - return Cache::add($lockKey, true, 30); - } - - /** - * Release the trial lock. - */ - protected function releaseTrialLock(string $service): void - { - Cache::forget($this->getTrialLockKey($service)); - } - - /** - * Get the trial lock cache key. - */ - protected function getTrialLockKey(string $service): string - { - return self::CACHE_PREFIX.$service.':trial_lock'; - } - - // Cache key helpers - protected function getStateKey(string $service): string - { - return self::CACHE_PREFIX.$service.':state'; - } - - protected function getFailureCountKey(string $service): string - { - return self::CACHE_PREFIX.$service.':failures'; - } - - protected function getSuccessCountKey(string $service): string - { - return self::CACHE_PREFIX.$service.':successes'; - } - - protected function getLastFailureKey(string $service): string - { - return self::CACHE_PREFIX.$service.':last_failure'; - } - - protected function getOpenedAtKey(string $service): string - { - return self::CACHE_PREFIX.$service.':opened_at'; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Services/DataRedactor.php b/packages/core-mcp/src/Mod/Mcp/Services/DataRedactor.php deleted file mode 100644 index 54c1e71..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Services/DataRedactor.php +++ /dev/null @@ -1,305 +0,0 @@ -redactArray($data, $maxDepth - 1); - } - - if (is_string($data)) { - return $this->redactString($data); - } - - return $data; - } - - /** - * Redact sensitive values from an array. - */ - protected function redactArray(array $data, int $maxDepth): array - { - $result = []; - - foreach ($data as $key => $value) { - $lowerKey = strtolower((string) $key); - - // Check for fully sensitive keys - if ($this->isSensitiveKey($lowerKey)) { - $result[$key] = self::REDACTED; - - continue; - } - - // Check for PII keys - partially redact - if ($this->isPiiKey($lowerKey) && is_string($value)) { - $result[$key] = $this->partialRedact($value); - - continue; - } - - // Recurse into nested arrays (with depth guard) - if (is_array($value)) { - if ($maxDepth <= 0) { - $result[$key] = '[MAX_DEPTH_EXCEEDED]'; - } else { - $result[$key] = $this->redactArray($value, $maxDepth - 1); - } - - continue; - } - - // Check string values for embedded sensitive patterns - if (is_string($value)) { - $result[$key] = $this->redactString($value); - - continue; - } - - $result[$key] = $value; - } - - return $result; - } - - /** - * Check if a key name indicates sensitive data. - */ - protected function isSensitiveKey(string $key): bool - { - foreach (self::SENSITIVE_KEYS as $sensitiveKey) { - if (str_contains($key, $sensitiveKey)) { - return true; - } - } - - return false; - } - - /** - * Check if a key name indicates PII. - */ - protected function isPiiKey(string $key): bool - { - foreach (self::PII_KEYS as $piiKey) { - if (str_contains($key, $piiKey)) { - return true; - } - } - - return false; - } - - /** - * Redact sensitive patterns from a string value. - */ - protected function redactString(string $value): string - { - // Redact bearer tokens - $value = preg_replace( - '/Bearer\s+[A-Za-z0-9\-_\.]+/i', - 'Bearer '.self::REDACTED, - $value - ) ?? $value; - - // Redact Basic auth - $value = preg_replace( - '/Basic\s+[A-Za-z0-9\+\/=]+/i', - 'Basic '.self::REDACTED, - $value - ) ?? $value; - - // Redact common API key patterns (key_xxx, sk_xxx, pk_xxx) - $value = preg_replace( - '/\b(sk|pk|key|api|token)_[a-zA-Z0-9]{16,}/i', - '$1_'.self::REDACTED, - $value - ) ?? $value; - - // Redact JWT tokens (xxx.xxx.xxx format with base64) - $value = preg_replace( - '/eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*/i', - self::REDACTED, - $value - ) ?? $value; - - // Redact UK National Insurance numbers - $value = preg_replace( - '/[A-Z]{2}\s?\d{2}\s?\d{2}\s?\d{2}\s?[A-Z]/i', - self::REDACTED, - $value - ) ?? $value; - - // Redact credit card numbers (basic pattern) - $value = preg_replace( - '/\b\d{4}[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}\b/', - self::REDACTED, - $value - ) ?? $value; - - return $value; - } - - /** - * Partially redact a value, showing first and last characters. - */ - protected function partialRedact(string $value): string - { - $length = strlen($value); - - if ($length <= 4) { - return self::REDACTED; - } - - if ($length <= 8) { - return substr($value, 0, 2).'***'.substr($value, -1); - } - - // For longer values, show more context - $showChars = min(3, (int) floor($length / 4)); - - return substr($value, 0, $showChars).'***'.substr($value, -$showChars); - } - - /** - * Create a summary of array data without sensitive information. - * - * Useful for result_summary where we want structure info without details. - */ - public function summarize(mixed $data, int $maxDepth = 3): mixed - { - if ($maxDepth <= 0) { - return '[...]'; - } - - if (is_array($data)) { - $result = []; - $count = count($data); - - // Limit array size in summary - $limit = 10; - $truncated = $count > $limit; - $items = array_slice($data, 0, $limit, true); - - foreach ($items as $key => $value) { - $lowerKey = strtolower((string) $key); - - // Fully redact sensitive keys - if ($this->isSensitiveKey($lowerKey)) { - $result[$key] = self::REDACTED; - - continue; - } - - // Partially redact PII keys - if ($this->isPiiKey($lowerKey) && is_string($value)) { - $result[$key] = $this->partialRedact($value); - - continue; - } - - // Recurse with reduced depth - $result[$key] = $this->summarize($value, $maxDepth - 1); - } - - if ($truncated) { - $result['_truncated'] = '... and '.($count - $limit).' more items'; - } - - return $result; - } - - if (is_string($data)) { - // Redact first, then truncate (prevents leaking sensitive patterns) - $redacted = $this->redactString($data); - if (strlen($redacted) > 100) { - return substr($redacted, 0, 97).'...'; - } - - return $redacted; - } - - return $data; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Services/McpHealthService.php b/packages/core-mcp/src/Mod/Mcp/Services/McpHealthService.php deleted file mode 100644 index 83fafe1..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Services/McpHealthService.php +++ /dev/null @@ -1,303 +0,0 @@ -loadServerConfig($serverId); - - if (! $server) { - $result = $this->buildResult(self::STATUS_UNKNOWN, 'Server not found'); - Cache::put($cacheKey, $result, $this->cacheTtl); - - return $result; - } - - $result = $this->pingServer($server); - Cache::put($cacheKey, $result, $this->cacheTtl); - - return $result; - } - - /** - * Check health of all registered MCP servers. - */ - public function checkAll(bool $forceRefresh = false): array - { - $servers = $this->getRegisteredServers(); - $results = []; - - foreach ($servers as $serverId) { - $results[$serverId] = $this->check($serverId, $forceRefresh); - } - - return $results; - } - - /** - * Get cached health status without triggering a check. - */ - public function getCachedStatus(string $serverId): ?array - { - return Cache::get("mcp:health:{$serverId}"); - } - - /** - * Clear cached health status for a server. - */ - public function clearCache(string $serverId): void - { - Cache::forget("mcp:health:{$serverId}"); - } - - /** - * Clear all cached health statuses. - */ - public function clearAllCache(): void - { - foreach ($this->getRegisteredServers() as $serverId) { - Cache::forget("mcp:health:{$serverId}"); - } - } - - /** - * Ping a server by sending a minimal MCP request. - */ - protected function pingServer(array $server): array - { - $connection = $server['connection'] ?? []; - $type = $connection['type'] ?? 'stdio'; - - // Only support stdio for now - if ($type !== 'stdio') { - return $this->buildResult( - self::STATUS_UNKNOWN, - "Connection type '{$type}' health check not supported" - ); - } - - $command = $connection['command'] ?? null; - $args = $connection['args'] ?? []; - $cwd = $this->resolveEnvVars($connection['cwd'] ?? getcwd()); - - if (! $command) { - return $this->buildResult(self::STATUS_OFFLINE, 'No command configured'); - } - - // Build the MCP initialize request - $initRequest = json_encode([ - 'jsonrpc' => '2.0', - 'method' => 'initialize', - 'params' => [ - 'protocolVersion' => '2024-11-05', - 'capabilities' => [], - 'clientInfo' => [ - 'name' => 'mcp-health-check', - 'version' => '1.0.0', - ], - ], - 'id' => 1, - ]); - - try { - $startTime = microtime(true); - - // Build full command - $fullCommand = array_merge([$command], $args); - $process = new Process($fullCommand, $cwd); - $process->setInput($initRequest); - $process->setTimeout($this->timeout); - - $process->run(); - - $duration = round((microtime(true) - $startTime) * 1000); - $output = $process->getOutput(); - - // Check for valid JSON-RPC response - if ($process->isSuccessful() && ! empty($output)) { - // Try to parse the response - $lines = explode("\n", trim($output)); - foreach ($lines as $line) { - $response = json_decode($line, true); - if ($response && isset($response['result'])) { - return $this->buildResult( - self::STATUS_ONLINE, - 'Server responding', - [ - 'response_time_ms' => $duration, - 'server_info' => $response['result']['serverInfo'] ?? null, - 'protocol_version' => $response['result']['protocolVersion'] ?? null, - ] - ); - } - } - } - - // Process ran but didn't return expected response - if ($process->isSuccessful()) { - return $this->buildResult( - self::STATUS_DEGRADED, - 'Server started but returned unexpected response', - [ - 'response_time_ms' => $duration, - 'output' => substr($output, 0, 500), - ] - ); - } - - // Process failed - return $this->buildResult( - self::STATUS_OFFLINE, - 'Server failed to start', - [ - 'exit_code' => $process->getExitCode(), - 'error' => substr($process->getErrorOutput(), 0, 500), - ] - ); - - } catch (\Exception $e) { - Log::warning("MCP health check failed for {$server['id']}", [ - 'error' => $e->getMessage(), - ]); - - return $this->buildResult( - self::STATUS_OFFLINE, - 'Health check failed: '.$e->getMessage() - ); - } - } - - /** - * Build a health check result array. - */ - protected function buildResult(string $status, string $message, array $extra = []): array - { - return array_merge([ - 'status' => $status, - 'message' => $message, - 'checked_at' => now()->toIso8601String(), - ], $extra); - } - - /** - * Get list of registered server IDs. - */ - protected function getRegisteredServers(): array - { - $registry = $this->loadRegistry(); - - return collect($registry['servers'] ?? []) - ->pluck('id') - ->all(); - } - - /** - * Load the main registry file. - */ - protected function loadRegistry(): array - { - $path = resource_path('mcp/registry.yaml'); - - if (! file_exists($path)) { - return ['servers' => []]; - } - - return Yaml::parseFile($path); - } - - /** - * Load a server's YAML config. - */ - protected function loadServerConfig(string $id): ?array - { - $path = resource_path("mcp/servers/{$id}.yaml"); - - if (! file_exists($path)) { - return null; - } - - return Yaml::parseFile($path); - } - - /** - * Resolve environment variables in a string. - */ - protected function resolveEnvVars(string $value): string - { - return preg_replace_callback('/\$\{([^}]+)\}/', function ($matches) { - $parts = explode(':-', $matches[1], 2); - $var = $parts[0]; - $default = $parts[1] ?? ''; - - return env($var, $default); - }, $value); - } - - /** - * Get status badge HTML. - */ - public function getStatusBadge(string $status): string - { - return match ($status) { - self::STATUS_ONLINE => 'Online', - self::STATUS_OFFLINE => 'Offline', - self::STATUS_DEGRADED => 'Degraded', - default => 'Unknown', - }; - } - - /** - * Get status colour class for Tailwind. - */ - public function getStatusColour(string $status): string - { - return match ($status) { - self::STATUS_ONLINE => 'green', - self::STATUS_OFFLINE => 'red', - self::STATUS_DEGRADED => 'yellow', - default => 'gray', - }; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Services/McpMetricsService.php b/packages/core-mcp/src/Mod/Mcp/Services/McpMetricsService.php deleted file mode 100644 index 7a0a23b..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Services/McpMetricsService.php +++ /dev/null @@ -1,267 +0,0 @@ -subDays($days - 1)->startOfDay(); - - $stats = McpToolCallStat::forDateRange($startDate, now())->get(); - - $totalCalls = $stats->sum('call_count'); - $successCalls = $stats->sum('success_count'); - $errorCalls = $stats->sum('error_count'); - - $successRate = $totalCalls > 0 - ? round(($successCalls / $totalCalls) * 100, 1) - : 0; - - $avgDuration = $totalCalls > 0 - ? round($stats->sum('total_duration_ms') / $totalCalls, 1) - : 0; - - // Compare to previous period - $previousStart = $startDate->copy()->subDays($days); - $previousStats = McpToolCallStat::forDateRange($previousStart, $startDate->copy()->subDay())->get(); - $previousCalls = $previousStats->sum('call_count'); - - $callsTrend = $previousCalls > 0 - ? round((($totalCalls - $previousCalls) / $previousCalls) * 100, 1) - : 0; - - return [ - 'total_calls' => $totalCalls, - 'success_calls' => $successCalls, - 'error_calls' => $errorCalls, - 'success_rate' => $successRate, - 'avg_duration_ms' => $avgDuration, - 'calls_trend_percent' => $callsTrend, - 'unique_tools' => $stats->pluck('tool_name')->unique()->count(), - 'unique_servers' => $stats->pluck('server_id')->unique()->count(), - 'period_days' => $days, - ]; - } - - /** - * Get daily call trend data for charting. - */ - public function getDailyTrend(int $days = 7): Collection - { - $trend = McpToolCallStat::getDailyTrend($days); - - // Fill in missing dates with zeros - $dates = collect(); - for ($i = $days - 1; $i >= 0; $i--) { - $date = now()->subDays($i)->toDateString(); - $existing = $trend->firstWhere('date', $date); - - $dates->push([ - 'date' => $date, - 'date_formatted' => Carbon::parse($date)->format('M j'), - 'total_calls' => $existing->total_calls ?? 0, - 'total_success' => $existing->total_success ?? 0, - 'total_errors' => $existing->total_errors ?? 0, - 'success_rate' => $existing->success_rate ?? 0, - ]); - } - - return $dates; - } - - /** - * Get top tools by call count. - */ - public function getTopTools(int $days = 7, int $limit = 10): Collection - { - return McpToolCallStat::getTopTools($days, $limit); - } - - /** - * Get server breakdown. - */ - public function getServerStats(int $days = 7): Collection - { - return McpToolCallStat::getServerStats($days); - } - - /** - * Get recent tool calls for activity feed. - */ - public function getRecentCalls(int $limit = 20): Collection - { - return McpToolCall::query() - ->orderByDesc('created_at') - ->limit($limit) - ->get() - ->map(function ($call) { - return [ - 'id' => $call->id, - 'server_id' => $call->server_id, - 'tool_name' => $call->tool_name, - 'success' => $call->success, - 'duration' => $call->getDurationForHumans(), - 'duration_ms' => $call->duration_ms, - 'error_message' => $call->error_message, - 'session_id' => $call->session_id, - 'plan_slug' => $call->plan_slug, - 'created_at' => $call->created_at->diffForHumans(), - 'created_at_full' => $call->created_at->toIso8601String(), - ]; - }); - } - - /** - * Get error breakdown. - */ - public function getErrorBreakdown(int $days = 7): Collection - { - return McpToolCall::query() - ->select('tool_name', 'error_code') - ->selectRaw('COUNT(*) as error_count') - ->where('success', false) - ->where('created_at', '>=', now()->subDays($days)) - ->groupBy('tool_name', 'error_code') - ->orderByDesc('error_count') - ->limit(20) - ->get(); - } - - /** - * Get tool performance metrics (p50, p95, p99). - */ - public function getToolPerformance(int $days = 7, int $limit = 10): Collection - { - // Get raw call data for percentile calculations - $calls = McpToolCall::query() - ->select('tool_name', 'duration_ms') - ->whereNotNull('duration_ms') - ->where('success', true) - ->where('created_at', '>=', now()->subDays($days)) - ->get() - ->groupBy('tool_name'); - - $performance = collect(); - - foreach ($calls as $toolName => $toolCalls) { - $durations = $toolCalls->pluck('duration_ms')->sort()->values(); - $count = $durations->count(); - - if ($count === 0) { - continue; - } - - $performance->push([ - 'tool_name' => $toolName, - 'call_count' => $count, - 'min_ms' => $durations->first(), - 'max_ms' => $durations->last(), - 'avg_ms' => round($durations->avg(), 1), - 'p50_ms' => $this->percentile($durations, 50), - 'p95_ms' => $this->percentile($durations, 95), - 'p99_ms' => $this->percentile($durations, 99), - ]); - } - - return $performance - ->sortByDesc('call_count') - ->take($limit) - ->values(); - } - - /** - * Get hourly distribution for the last 24 hours. - */ - public function getHourlyDistribution(): Collection - { - $hourly = McpToolCall::query() - ->selectRaw('HOUR(created_at) as hour') - ->selectRaw('COUNT(*) as call_count') - ->selectRaw('SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count') - ->where('created_at', '>=', now()->subHours(24)) - ->groupBy('hour') - ->orderBy('hour') - ->get() - ->keyBy('hour'); - - // Fill in missing hours - $result = collect(); - for ($i = 0; $i < 24; $i++) { - $hour = str_pad((string) $i, 2, '0', STR_PAD_LEFT); - $existing = $hourly->get($i); - - $result->push([ - 'hour' => $hour, - 'hour_formatted' => Carbon::createFromTime($i)->format('ga'), - 'call_count' => $existing->call_count ?? 0, - 'success_count' => $existing->success_count ?? 0, - ]); - } - - return $result; - } - - /** - * Get plan activity - which plans are using MCP tools. - */ - public function getPlanActivity(int $days = 7, int $limit = 10): Collection - { - return McpToolCall::query() - ->select('plan_slug') - ->selectRaw('COUNT(*) as call_count') - ->selectRaw('COUNT(DISTINCT tool_name) as unique_tools') - ->selectRaw('SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count') - ->whereNotNull('plan_slug') - ->where('created_at', '>=', now()->subDays($days)) - ->groupBy('plan_slug') - ->orderByDesc('call_count') - ->limit($limit) - ->get() - ->map(function ($item) { - $item->success_rate = $item->call_count > 0 - ? round(($item->success_count / $item->call_count) * 100, 1) - : 0; - - return $item; - }); - } - - /** - * Calculate percentile from a sorted collection. - */ - protected function percentile(Collection $sortedValues, int $percentile): float - { - $count = $sortedValues->count(); - if ($count === 0) { - return 0; - } - - $index = ($percentile / 100) * ($count - 1); - $lower = (int) floor($index); - $upper = (int) ceil($index); - - if ($lower === $upper) { - return $sortedValues[$lower]; - } - - $fraction = $index - $lower; - - return round($sortedValues[$lower] + ($sortedValues[$upper] - $sortedValues[$lower]) * $fraction, 1); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Services/McpQuotaService.php b/packages/core-mcp/src/Mod/Mcp/Services/McpQuotaService.php deleted file mode 100644 index d695983..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Services/McpQuotaService.php +++ /dev/null @@ -1,395 +0,0 @@ -id : $workspace; - - $quota = McpUsageQuota::record($workspaceId, $toolCalls, $inputTokens, $outputTokens); - - // Invalidate cached usage - $this->invalidateUsageCache($workspaceId); - - return $quota; - } - - // ───────────────────────────────────────────────────────────────────────── - // Quota Checking - // ───────────────────────────────────────────────────────────────────────── - - /** - * Check if workspace is within quota limits. - * - * Returns true if within limits (or unlimited), false if quota exceeded. - */ - public function checkQuota(Workspace|int $workspace): bool - { - $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; - $workspace = $workspace instanceof Workspace ? $workspace : Workspace::find($workspaceId); - - if (! $workspace) { - return false; - } - - // Check tool calls quota - $toolCallsResult = $this->entitlements->can($workspace, self::FEATURE_MONTHLY_TOOL_CALLS); - - if ($toolCallsResult->isDenied()) { - // Feature not in plan - deny access - return false; - } - - if (! $toolCallsResult->isUnlimited()) { - $usage = $this->getCurrentUsage($workspace); - $limit = $toolCallsResult->limit; - - if ($limit !== null && $usage['tool_calls_count'] >= $limit) { - return false; - } - } - - // Check tokens quota - $tokensResult = $this->entitlements->can($workspace, self::FEATURE_MONTHLY_TOKENS); - - if (! $tokensResult->isUnlimited() && $tokensResult->isAllowed()) { - $usage = $this->getCurrentUsage($workspace); - $limit = $tokensResult->limit; - - if ($limit !== null && $usage['total_tokens'] >= $limit) { - return false; - } - } - - return true; - } - - /** - * Get detailed quota check result with reasons. - * - * @return array{allowed: bool, reason: ?string, tool_calls: array, tokens: array} - */ - public function checkQuotaDetailed(Workspace|int $workspace): array - { - $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; - $workspace = $workspace instanceof Workspace ? $workspace : Workspace::find($workspaceId); - - if (! $workspace) { - return [ - 'allowed' => false, - 'reason' => 'Workspace not found', - 'tool_calls' => ['allowed' => false], - 'tokens' => ['allowed' => false], - ]; - } - - $usage = $this->getCurrentUsage($workspace); - - // Check tool calls - $toolCallsResult = $this->entitlements->can($workspace, self::FEATURE_MONTHLY_TOOL_CALLS); - $toolCallsAllowed = true; - $toolCallsReason = null; - - if ($toolCallsResult->isDenied()) { - $toolCallsAllowed = false; - $toolCallsReason = 'MCP tool calls not included in your plan'; - } elseif (! $toolCallsResult->isUnlimited()) { - $limit = $toolCallsResult->limit; - if ($limit !== null && $usage['tool_calls_count'] >= $limit) { - $toolCallsAllowed = false; - $toolCallsReason = "Monthly tool calls limit reached ({$usage['tool_calls_count']}/{$limit})"; - } - } - - // Check tokens - $tokensResult = $this->entitlements->can($workspace, self::FEATURE_MONTHLY_TOKENS); - $tokensAllowed = true; - $tokensReason = null; - - if ($tokensResult->isDenied()) { - // Tokens might not be tracked separately - this is OK - $tokensAllowed = true; - } elseif (! $tokensResult->isUnlimited() && $tokensResult->isAllowed()) { - $limit = $tokensResult->limit; - if ($limit !== null && $usage['total_tokens'] >= $limit) { - $tokensAllowed = false; - $tokensReason = "Monthly token limit reached ({$usage['total_tokens']}/{$limit})"; - } - } - - $allowed = $toolCallsAllowed && $tokensAllowed; - $reason = $toolCallsReason ?? $tokensReason; - - return [ - 'allowed' => $allowed, - 'reason' => $reason, - 'tool_calls' => [ - 'allowed' => $toolCallsAllowed, - 'reason' => $toolCallsReason, - 'used' => $usage['tool_calls_count'], - 'limit' => $toolCallsResult->isUnlimited() ? null : $toolCallsResult->limit, - 'unlimited' => $toolCallsResult->isUnlimited(), - ], - 'tokens' => [ - 'allowed' => $tokensAllowed, - 'reason' => $tokensReason, - 'used' => $usage['total_tokens'], - 'input_tokens' => $usage['input_tokens'], - 'output_tokens' => $usage['output_tokens'], - 'limit' => $tokensResult->isUnlimited() ? null : $tokensResult->limit, - 'unlimited' => $tokensResult->isUnlimited(), - ], - ]; - } - - // ───────────────────────────────────────────────────────────────────────── - // Usage Retrieval - // ───────────────────────────────────────────────────────────────────────── - - /** - * Get current month's usage for a workspace. - * - * @return array{tool_calls_count: int, input_tokens: int, output_tokens: int, total_tokens: int, month: string} - */ - public function getCurrentUsage(Workspace|int $workspace): array - { - $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; - - return Cache::remember( - $this->getUsageCacheKey($workspaceId), - 60, // 1 minute cache for current usage - function () use ($workspaceId) { - $quota = McpUsageQuota::getCurrentForWorkspace($workspaceId); - - return [ - 'tool_calls_count' => $quota->tool_calls_count, - 'input_tokens' => $quota->input_tokens, - 'output_tokens' => $quota->output_tokens, - 'total_tokens' => $quota->total_tokens, - 'month' => $quota->month, - ]; - } - ); - } - - /** - * Get remaining quota for a workspace. - * - * @return array{tool_calls: int|null, tokens: int|null, tool_calls_unlimited: bool, tokens_unlimited: bool} - */ - public function getRemainingQuota(Workspace|int $workspace): array - { - $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; - $workspace = $workspace instanceof Workspace ? $workspace : Workspace::find($workspaceId); - - if (! $workspace) { - return [ - 'tool_calls' => 0, - 'tokens' => 0, - 'tool_calls_unlimited' => false, - 'tokens_unlimited' => false, - ]; - } - - $usage = $this->getCurrentUsage($workspace); - - // Tool calls remaining - $toolCallsResult = $this->entitlements->can($workspace, self::FEATURE_MONTHLY_TOOL_CALLS); - $toolCallsRemaining = null; - $toolCallsUnlimited = $toolCallsResult->isUnlimited(); - - if ($toolCallsResult->isAllowed() && ! $toolCallsUnlimited && $toolCallsResult->limit !== null) { - $toolCallsRemaining = max(0, $toolCallsResult->limit - $usage['tool_calls_count']); - } - - // Tokens remaining - $tokensResult = $this->entitlements->can($workspace, self::FEATURE_MONTHLY_TOKENS); - $tokensRemaining = null; - $tokensUnlimited = $tokensResult->isUnlimited(); - - if ($tokensResult->isAllowed() && ! $tokensUnlimited && $tokensResult->limit !== null) { - $tokensRemaining = max(0, $tokensResult->limit - $usage['total_tokens']); - } - - return [ - 'tool_calls' => $toolCallsRemaining, - 'tokens' => $tokensRemaining, - 'tool_calls_unlimited' => $toolCallsUnlimited, - 'tokens_unlimited' => $tokensUnlimited, - ]; - } - - // ───────────────────────────────────────────────────────────────────────── - // Quota Management - // ───────────────────────────────────────────────────────────────────────── - - /** - * Reset monthly quota for a workspace (for billing cycle reset). - */ - public function resetMonthlyQuota(Workspace|int $workspace): McpUsageQuota - { - $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; - - $quota = McpUsageQuota::getCurrentForWorkspace($workspaceId); - $quota->reset(); - - $this->invalidateUsageCache($workspaceId); - - return $quota; - } - - /** - * Get usage history for a workspace (last N months). - * - * @return \Illuminate\Support\Collection - */ - public function getUsageHistory(Workspace|int $workspace, int $months = 12): \Illuminate\Support\Collection - { - $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; - - return McpUsageQuota::where('workspace_id', $workspaceId) - ->orderByDesc('month') - ->limit($months) - ->get(); - } - - /** - * Get quota limits from entitlements. - * - * @return array{tool_calls_limit: int|null, tokens_limit: int|null, tool_calls_unlimited: bool, tokens_unlimited: bool} - */ - public function getQuotaLimits(Workspace|int $workspace): array - { - $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; - $workspace = $workspace instanceof Workspace ? $workspace : Workspace::find($workspaceId); - - if (! $workspace) { - return [ - 'tool_calls_limit' => 0, - 'tokens_limit' => 0, - 'tool_calls_unlimited' => false, - 'tokens_unlimited' => false, - ]; - } - - $cacheKey = "mcp_quota_limits:{$workspaceId}"; - - return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($workspace) { - $toolCallsResult = $this->entitlements->can($workspace, self::FEATURE_MONTHLY_TOOL_CALLS); - $tokensResult = $this->entitlements->can($workspace, self::FEATURE_MONTHLY_TOKENS); - - return [ - 'tool_calls_limit' => $toolCallsResult->isUnlimited() ? null : $toolCallsResult->limit, - 'tokens_limit' => $tokensResult->isUnlimited() ? null : $tokensResult->limit, - 'tool_calls_unlimited' => $toolCallsResult->isUnlimited(), - 'tokens_unlimited' => $tokensResult->isUnlimited(), - ]; - }); - } - - // ───────────────────────────────────────────────────────────────────────── - // Response Headers - // ───────────────────────────────────────────────────────────────────────── - - /** - * Get quota info formatted for HTTP response headers. - * - * @return array - */ - public function getQuotaHeaders(Workspace|int $workspace): array - { - $usage = $this->getCurrentUsage($workspace); - $remaining = $this->getRemainingQuota($workspace); - $limits = $this->getQuotaLimits($workspace); - - $headers = [ - 'X-MCP-Quota-Tool-Calls-Used' => (string) $usage['tool_calls_count'], - 'X-MCP-Quota-Tokens-Used' => (string) $usage['total_tokens'], - ]; - - if ($limits['tool_calls_unlimited']) { - $headers['X-MCP-Quota-Tool-Calls-Limit'] = 'unlimited'; - $headers['X-MCP-Quota-Tool-Calls-Remaining'] = 'unlimited'; - } else { - $headers['X-MCP-Quota-Tool-Calls-Limit'] = (string) ($limits['tool_calls_limit'] ?? 0); - $headers['X-MCP-Quota-Tool-Calls-Remaining'] = (string) ($remaining['tool_calls'] ?? 0); - } - - if ($limits['tokens_unlimited']) { - $headers['X-MCP-Quota-Tokens-Limit'] = 'unlimited'; - $headers['X-MCP-Quota-Tokens-Remaining'] = 'unlimited'; - } else { - $headers['X-MCP-Quota-Tokens-Limit'] = (string) ($limits['tokens_limit'] ?? 0); - $headers['X-MCP-Quota-Tokens-Remaining'] = (string) ($remaining['tokens'] ?? 0); - } - - $headers['X-MCP-Quota-Reset'] = now()->endOfMonth()->toIso8601String(); - - return $headers; - } - - // ───────────────────────────────────────────────────────────────────────── - // Cache Management - // ───────────────────────────────────────────────────────────────────────── - - /** - * Invalidate usage cache for a workspace. - */ - public function invalidateUsageCache(int $workspaceId): void - { - Cache::forget($this->getUsageCacheKey($workspaceId)); - Cache::forget("mcp_quota_limits:{$workspaceId}"); - } - - /** - * Get cache key for workspace usage. - */ - protected function getUsageCacheKey(int $workspaceId): string - { - $month = now()->format('Y-m'); - - return "mcp_usage:{$workspaceId}:{$month}"; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Services/McpWebhookDispatcher.php b/packages/core-mcp/src/Mod/Mcp/Services/McpWebhookDispatcher.php deleted file mode 100644 index 91f0048..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Services/McpWebhookDispatcher.php +++ /dev/null @@ -1,128 +0,0 @@ -forWorkspace($workspaceId) - ->active() - ->forEvent($eventType) - ->get(); - - if ($endpoints->isEmpty()) { - return; - } - - $payload = [ - 'event' => $eventType, - 'timestamp' => now()->toIso8601String(), - 'data' => [ - 'server_id' => $serverId, - 'tool_name' => $toolName, - 'arguments' => $arguments, - 'success' => $success, - 'duration_ms' => $durationMs, - 'error' => $errorMessage, - ], - ]; - - foreach ($endpoints as $endpoint) { - $this->deliverWebhook($endpoint, $payload); - } - } - - /** - * Deliver a webhook to an endpoint. - */ - protected function deliverWebhook(WebhookEndpoint $endpoint, array $payload): void - { - $payloadJson = json_encode($payload); - $signature = $endpoint->generateSignature($payloadJson); - - $startTime = microtime(true); - - try { - $response = Http::timeout(10) - ->withHeaders([ - 'Content-Type' => 'application/json', - 'X-Webhook-Signature' => $signature, - 'X-Webhook-Event' => $payload['event'], - 'X-Webhook-Timestamp' => $payload['timestamp'], - ]) - ->withBody($payloadJson, 'application/json') - ->post($endpoint->url); - - $durationMs = (int) ((microtime(true) - $startTime) * 1000); - - // Record delivery - WebhookDelivery::create([ - 'webhook_endpoint_id' => $endpoint->id, - 'event_id' => 'evt_'.uniqid(), - 'event_type' => $payload['event'], - 'payload' => $payload, - 'response_code' => $response->status(), - 'response_body' => substr($response->body(), 0, 1000), - 'status' => $response->successful() ? 'success' : 'failed', - 'attempt' => 1, - 'delivered_at' => $response->successful() ? now() : null, - ]); - - if ($response->successful()) { - $endpoint->recordSuccess(); - } else { - $endpoint->recordFailure(); - Log::warning('MCP Webhook delivery failed', [ - 'endpoint_id' => $endpoint->id, - 'url' => $endpoint->url, - 'status' => $response->status(), - ]); - } - } catch (\Throwable $e) { - $durationMs = (int) ((microtime(true) - $startTime) * 1000); - - WebhookDelivery::create([ - 'webhook_endpoint_id' => $endpoint->id, - 'event_id' => 'evt_'.uniqid(), - 'event_type' => $payload['event'], - 'payload' => $payload, - 'response_code' => 0, - 'response_body' => $e->getMessage(), - 'status' => 'failed', - 'attempt' => 1, - ]); - - $endpoint->recordFailure(); - - Log::error('MCP Webhook delivery error', [ - 'endpoint_id' => $endpoint->id, - 'url' => $endpoint->url, - 'error' => $e->getMessage(), - ]); - } - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Services/OpenApiGenerator.php b/packages/core-mcp/src/Mod/Mcp/Services/OpenApiGenerator.php deleted file mode 100644 index 6872eb3..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Services/OpenApiGenerator.php +++ /dev/null @@ -1,409 +0,0 @@ -loadRegistry(); - $this->loadServers(); - - return [ - 'openapi' => '3.0.3', - 'info' => $this->buildInfo(), - 'servers' => $this->buildServers(), - 'tags' => $this->buildTags(), - 'paths' => $this->buildPaths(), - 'components' => $this->buildComponents(), - ]; - } - - public function toJson(): string - { - return json_encode($this->generate(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - } - - public function toYaml(): string - { - return Yaml::dump($this->generate(), 10, 2); - } - - protected function loadRegistry(): void - { - $path = resource_path('mcp/registry.yaml'); - $this->registry = file_exists($path) ? Yaml::parseFile($path) : ['servers' => []]; - } - - protected function loadServers(): void - { - foreach ($this->registry['servers'] ?? [] as $ref) { - $path = resource_path("mcp/servers/{$ref['id']}.yaml"); - if (file_exists($path)) { - $this->servers[$ref['id']] = Yaml::parseFile($path); - } - } - } - - protected function buildInfo(): array - { - return [ - 'title' => 'Host UK MCP API', - 'description' => 'HTTP API for interacting with Host UK MCP servers. Execute tools, read resources, and discover available capabilities.', - 'version' => '1.0.0', - 'contact' => [ - 'name' => 'Host UK Support', - 'url' => 'https://host.uk.com/contact', - 'email' => 'support@host.uk.com', - ], - 'license' => [ - 'name' => 'Proprietary', - 'url' => 'https://host.uk.com/terms', - ], - ]; - } - - protected function buildServers(): array - { - return [ - [ - 'url' => 'https://mcp.host.uk.com/api/v1/mcp', - 'description' => 'Production', - ], - [ - 'url' => 'https://mcp.test/api/v1/mcp', - 'description' => 'Local development', - ], - ]; - } - - protected function buildTags(): array - { - $tags = [ - [ - 'name' => 'Discovery', - 'description' => 'Server and tool discovery endpoints', - ], - [ - 'name' => 'Execution', - 'description' => 'Tool execution endpoints', - ], - ]; - - foreach ($this->servers as $id => $server) { - $tags[] = [ - 'name' => $server['name'] ?? $id, - 'description' => $server['tagline'] ?? $server['description'] ?? '', - ]; - } - - return $tags; - } - - protected function buildPaths(): array - { - $paths = []; - - // Discovery endpoints - $paths['/servers'] = [ - 'get' => [ - 'tags' => ['Discovery'], - 'summary' => 'List all MCP servers', - 'operationId' => 'listServers', - 'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]], - 'responses' => [ - '200' => [ - 'description' => 'List of available servers', - 'content' => [ - 'application/json' => [ - 'schema' => [ - '$ref' => '#/components/schemas/ServerList', - ], - ], - ], - ], - ], - ], - ]; - - $paths['/servers/{serverId}'] = [ - 'get' => [ - 'tags' => ['Discovery'], - 'summary' => 'Get server details', - 'operationId' => 'getServer', - 'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]], - 'parameters' => [ - [ - 'name' => 'serverId', - 'in' => 'path', - 'required' => true, - 'schema' => ['type' => 'string'], - 'description' => 'Server identifier', - ], - ], - 'responses' => [ - '200' => [ - 'description' => 'Server details with tools and resources', - 'content' => [ - 'application/json' => [ - 'schema' => [ - '$ref' => '#/components/schemas/Server', - ], - ], - ], - ], - '404' => ['description' => 'Server not found'], - ], - ], - ]; - - $paths['/servers/{serverId}/tools'] = [ - 'get' => [ - 'tags' => ['Discovery'], - 'summary' => 'List tools for a server', - 'operationId' => 'listServerTools', - 'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]], - 'parameters' => [ - [ - 'name' => 'serverId', - 'in' => 'path', - 'required' => true, - 'schema' => ['type' => 'string'], - ], - ], - 'responses' => [ - '200' => [ - 'description' => 'List of tools', - 'content' => [ - 'application/json' => [ - 'schema' => [ - '$ref' => '#/components/schemas/ToolList', - ], - ], - ], - ], - ], - ], - ]; - - // Execution endpoint - $paths['/tools/call'] = [ - 'post' => [ - 'tags' => ['Execution'], - 'summary' => 'Execute an MCP tool', - 'operationId' => 'callTool', - 'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]], - 'requestBody' => [ - 'required' => true, - 'content' => [ - 'application/json' => [ - 'schema' => [ - '$ref' => '#/components/schemas/ToolCallRequest', - ], - ], - ], - ], - 'responses' => [ - '200' => [ - 'description' => 'Tool executed successfully', - 'content' => [ - 'application/json' => [ - 'schema' => [ - '$ref' => '#/components/schemas/ToolCallResponse', - ], - ], - ], - ], - '400' => ['description' => 'Invalid request'], - '401' => ['description' => 'Unauthorized'], - '404' => ['description' => 'Server or tool not found'], - '500' => ['description' => 'Tool execution error'], - ], - ], - ]; - - // Resource endpoint - $paths['/resources/{uri}'] = [ - 'get' => [ - 'tags' => ['Execution'], - 'summary' => 'Read a resource', - 'operationId' => 'readResource', - 'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]], - 'parameters' => [ - [ - 'name' => 'uri', - 'in' => 'path', - 'required' => true, - 'schema' => ['type' => 'string'], - 'description' => 'Resource URI (server://path)', - ], - ], - 'responses' => [ - '200' => [ - 'description' => 'Resource content', - 'content' => [ - 'application/json' => [ - 'schema' => [ - '$ref' => '#/components/schemas/ResourceResponse', - ], - ], - ], - ], - ], - ], - ]; - - return $paths; - } - - protected function buildComponents(): array - { - return [ - 'securitySchemes' => [ - 'bearerAuth' => [ - 'type' => 'http', - 'scheme' => 'bearer', - 'description' => 'API key in Bearer format: hk_xxx_yyy', - ], - 'apiKeyAuth' => [ - 'type' => 'apiKey', - 'in' => 'header', - 'name' => 'X-API-Key', - 'description' => 'API key header', - ], - ], - 'schemas' => $this->buildSchemas(), - ]; - } - - protected function buildSchemas(): array - { - $schemas = [ - 'ServerList' => [ - 'type' => 'object', - 'properties' => [ - 'servers' => [ - 'type' => 'array', - 'items' => ['$ref' => '#/components/schemas/ServerSummary'], - ], - 'count' => ['type' => 'integer'], - ], - ], - 'ServerSummary' => [ - 'type' => 'object', - 'properties' => [ - 'id' => ['type' => 'string'], - 'name' => ['type' => 'string'], - 'tagline' => ['type' => 'string'], - 'status' => ['type' => 'string', 'enum' => ['available', 'beta', 'deprecated']], - 'tool_count' => ['type' => 'integer'], - 'resource_count' => ['type' => 'integer'], - ], - ], - 'Server' => [ - 'type' => 'object', - 'properties' => [ - 'id' => ['type' => 'string'], - 'name' => ['type' => 'string'], - 'tagline' => ['type' => 'string'], - 'description' => ['type' => 'string'], - 'tools' => [ - 'type' => 'array', - 'items' => ['$ref' => '#/components/schemas/Tool'], - ], - 'resources' => [ - 'type' => 'array', - 'items' => ['$ref' => '#/components/schemas/Resource'], - ], - ], - ], - 'Tool' => [ - 'type' => 'object', - 'properties' => [ - 'name' => ['type' => 'string'], - 'description' => ['type' => 'string'], - 'inputSchema' => [ - 'type' => 'object', - 'additionalProperties' => true, - ], - ], - ], - 'Resource' => [ - 'type' => 'object', - 'properties' => [ - 'uri' => ['type' => 'string'], - 'name' => ['type' => 'string'], - 'description' => ['type' => 'string'], - 'mimeType' => ['type' => 'string'], - ], - ], - 'ToolList' => [ - 'type' => 'object', - 'properties' => [ - 'server' => ['type' => 'string'], - 'tools' => [ - 'type' => 'array', - 'items' => ['$ref' => '#/components/schemas/Tool'], - ], - 'count' => ['type' => 'integer'], - ], - ], - 'ToolCallRequest' => [ - 'type' => 'object', - 'required' => ['server', 'tool'], - 'properties' => [ - 'server' => [ - 'type' => 'string', - 'description' => 'Server ID', - ], - 'tool' => [ - 'type' => 'string', - 'description' => 'Tool name', - ], - 'arguments' => [ - 'type' => 'object', - 'description' => 'Tool arguments', - 'additionalProperties' => true, - ], - ], - ], - 'ToolCallResponse' => [ - 'type' => 'object', - 'properties' => [ - 'success' => ['type' => 'boolean'], - 'server' => ['type' => 'string'], - 'tool' => ['type' => 'string'], - 'result' => [ - 'type' => 'object', - 'additionalProperties' => true, - ], - 'duration_ms' => ['type' => 'integer'], - 'error' => ['type' => 'string'], - ], - ], - 'ResourceResponse' => [ - 'type' => 'object', - 'properties' => [ - 'uri' => ['type' => 'string'], - 'content' => [ - 'type' => 'object', - 'additionalProperties' => true, - ], - ], - ], - ]; - - return $schemas; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Services/SqlQueryValidator.php b/packages/core-mcp/src/Mod/Mcp/Services/SqlQueryValidator.php deleted file mode 100644 index 134186a..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Services/SqlQueryValidator.php +++ /dev/null @@ -1,302 +0,0 @@ - value, etc. - * - Supports AND/OR logical operators - * - Allows LIKE, IN, BETWEEN, IS NULL/NOT NULL operators - * - No subqueries (no nested SELECT) - * - No function calls except common safe ones - */ - private const DEFAULT_WHITELIST = [ - // Simple SELECT from single table with optional WHERE - '/^\s*SELECT\s+[\w\s,.*`]+\s+FROM\s+`?\w+`?(\s+WHERE\s+[\w\s`.,!=<>\'"%()]+(\s+(AND|OR)\s+[\w\s`.,!=<>\'"%()]+)*)?(\s+ORDER\s+BY\s+[\w\s,`]+(\s+(ASC|DESC))?)?(\s+LIMIT\s+\d+(\s*,\s*\d+)?)?;?\s*$/i', - // COUNT queries - '/^\s*SELECT\s+COUNT\s*\(\s*\*?\s*\)\s+FROM\s+`?\w+`?(\s+WHERE\s+[\w\s`.,!=<>\'"%()]+(\s+(AND|OR)\s+[\w\s`.,!=<>\'"%()]+)*)?;?\s*$/i', - // SELECT with explicit column list - '/^\s*SELECT\s+`?\w+`?(\s*,\s*`?\w+`?)*\s+FROM\s+`?\w+`?(\s+WHERE\s+[\w\s`.,!=<>\'"%()]+(\s+(AND|OR)\s+[\w\s`.,!=<>\'"%()]+)*)?(\s+ORDER\s+BY\s+[\w\s,`]+)?(\s+LIMIT\s+\d+)?;?\s*$/i', - ]; - - private array $whitelist; - - private bool $useWhitelist; - - public function __construct( - ?array $whitelist = null, - bool $useWhitelist = true - ) { - $this->whitelist = $whitelist ?? self::DEFAULT_WHITELIST; - $this->useWhitelist = $useWhitelist; - } - - /** - * Validate a SQL query for safety. - * - * @throws ForbiddenQueryException If the query fails validation - */ - public function validate(string $query): void - { - // Check for dangerous patterns on the ORIGINAL query first - // This catches attempts to obfuscate keywords with comments - $this->checkDangerousPatterns($query); - - // Now normalise and continue validation - $query = $this->normaliseQuery($query); - - $this->checkBlockedKeywords($query); - $this->checkQueryStructure($query); - - if ($this->useWhitelist) { - $this->checkWhitelist($query); - } - } - - /** - * Check if a query is valid without throwing. - */ - public function isValid(string $query): bool - { - try { - $this->validate($query); - - return true; - } catch (ForbiddenQueryException) { - return false; - } - } - - /** - * Add a pattern to the whitelist. - */ - public function addWhitelistPattern(string $pattern): self - { - $this->whitelist[] = $pattern; - - return $this; - } - - /** - * Replace the entire whitelist. - */ - public function setWhitelist(array $patterns): self - { - $this->whitelist = $patterns; - - return $this; - } - - /** - * Enable or disable whitelist checking. - */ - public function setUseWhitelist(bool $use): self - { - $this->useWhitelist = $use; - - return $this; - } - - /** - * Normalise the query for consistent validation. - */ - private function normaliseQuery(string $query): string - { - // Remove SQL comments - $query = $this->stripComments($query); - - // Normalise whitespace - $query = preg_replace('/\s+/', ' ', $query); - - return trim($query); - } - - /** - * Strip SQL comments which could be used to bypass filters. - */ - private function stripComments(string $query): string - { - // Remove -- style comments - $query = preg_replace('/--.*$/m', '', $query); - - // Remove # style comments - $query = preg_replace('/#.*$/m', '', $query); - - // Remove /* */ style comments (including multi-line) - $query = preg_replace('/\/\*.*?\*\//s', '', $query); - - // Remove /*! MySQL-specific comments that execute code - $query = preg_replace('/\/\*!.*?\*\//s', '', $query); - - return $query; - } - - /** - * Check for blocked SQL keywords. - * - * @throws ForbiddenQueryException - */ - private function checkBlockedKeywords(string $query): void - { - $upperQuery = strtoupper($query); - - foreach (self::BLOCKED_KEYWORDS as $keyword) { - // Use word boundary check for most keywords - $pattern = '/\b'.preg_quote($keyword, '/').'\b/i'; - - if (preg_match($pattern, $query)) { - throw ForbiddenQueryException::disallowedKeyword($query, $keyword); - } - } - } - - /** - * Check for dangerous patterns that indicate injection. - * - * @throws ForbiddenQueryException - */ - private function checkDangerousPatterns(string $query): void - { - foreach (self::DANGEROUS_PATTERNS as $pattern) { - if (preg_match($pattern, $query)) { - throw ForbiddenQueryException::invalidStructure( - $query, - 'Query contains potentially malicious pattern' - ); - } - } - } - - /** - * Check basic query structure. - * - * @throws ForbiddenQueryException - */ - private function checkQueryStructure(string $query): void - { - // Must start with SELECT - if (! preg_match('/^\s*SELECT\b/i', $query)) { - throw ForbiddenQueryException::invalidStructure( - $query, - 'Query must begin with SELECT' - ); - } - - // Check for multiple statements (stacked queries) - // After stripping comments, there should be at most one semicolon at the end - $semicolonCount = substr_count($query, ';'); - if ($semicolonCount > 1) { - throw ForbiddenQueryException::invalidStructure( - $query, - 'Multiple statements detected' - ); - } - - if ($semicolonCount === 1 && ! preg_match('/;\s*$/', $query)) { - throw ForbiddenQueryException::invalidStructure( - $query, - 'Semicolon only allowed at end of query' - ); - } - } - - /** - * Check if query matches at least one whitelist pattern. - * - * @throws ForbiddenQueryException - */ - private function checkWhitelist(string $query): void - { - foreach ($this->whitelist as $pattern) { - if (preg_match($pattern, $query)) { - return; // Query matches a whitelisted pattern - } - } - - throw ForbiddenQueryException::notWhitelisted($query); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Services/ToolAnalyticsService.php b/packages/core-mcp/src/Mod/Mcp/Services/ToolAnalyticsService.php deleted file mode 100644 index daa67c1..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Services/ToolAnalyticsService.php +++ /dev/null @@ -1,386 +0,0 @@ - - */ - protected array $pendingMetrics = []; - - /** - * Track tools used in current session for combination tracking. - * - * @var array> - */ - protected array $sessionTools = []; - - /** - * Record a tool execution. - */ - public function recordExecution( - string $tool, - int $durationMs, - bool $success, - ?string $workspaceId = null, - ?string $sessionId = null - ): void { - if (! config('mcp.analytics.enabled', true)) { - return; - } - - $key = $this->getMetricKey($tool, $workspaceId); - - if (! isset($this->pendingMetrics[$key])) { - $this->pendingMetrics[$key] = [ - 'tool_name' => $tool, - 'workspace_id' => $workspaceId, - 'calls' => 0, - 'errors' => 0, - 'duration' => 0, - 'min' => null, - 'max' => null, - ]; - } - - $this->pendingMetrics[$key]['calls']++; - $this->pendingMetrics[$key]['duration'] += $durationMs; - - if (! $success) { - $this->pendingMetrics[$key]['errors']++; - } - - if ($this->pendingMetrics[$key]['min'] === null || $durationMs < $this->pendingMetrics[$key]['min']) { - $this->pendingMetrics[$key]['min'] = $durationMs; - } - - if ($this->pendingMetrics[$key]['max'] === null || $durationMs > $this->pendingMetrics[$key]['max']) { - $this->pendingMetrics[$key]['max'] = $durationMs; - } - - // Track tool combinations if session ID provided - if ($sessionId !== null) { - $this->trackToolInSession($sessionId, $tool, $workspaceId); - } - - // Flush if batch size reached - $batchSize = config('mcp.analytics.batch_size', 100); - if ($this->getTotalPendingCalls() >= $batchSize) { - $this->flush(); - } - } - - /** - * Get statistics for a specific tool. - */ - public function getToolStats(string $tool, ?Carbon $from = null, ?Carbon $to = null): ToolStats - { - $from = $from ?? now()->subDays(30); - $to = $to ?? now(); - - $stats = ToolMetric::getAggregatedStats($tool, $from, $to); - - return ToolStats::fromArray($stats); - } - - /** - * Get statistics for all tools. - */ - public function getAllToolStats(?Carbon $from = null, ?Carbon $to = null): Collection - { - $from = $from ?? now()->subDays(30); - $to = $to ?? now(); - - $results = ToolMetric::query() - ->select('tool_name') - ->selectRaw('SUM(call_count) as total_calls') - ->selectRaw('SUM(error_count) as error_count') - ->selectRaw('SUM(total_duration_ms) as total_duration') - ->selectRaw('MIN(min_duration_ms) as min_duration_ms') - ->selectRaw('MAX(max_duration_ms) as max_duration_ms') - ->forDateRange($from, $to) - ->groupBy('tool_name') - ->orderByDesc('total_calls') - ->get(); - - return $results->map(function ($row) { - $totalCalls = (int) $row->total_calls; - $errorCount = (int) $row->error_count; - $totalDuration = (int) $row->total_duration; - - return new ToolStats( - toolName: $row->tool_name, - totalCalls: $totalCalls, - errorCount: $errorCount, - errorRate: $totalCalls > 0 ? round(($errorCount / $totalCalls) * 100, 2) : 0.0, - avgDurationMs: $totalCalls > 0 ? round($totalDuration / $totalCalls, 2) : 0.0, - minDurationMs: (int) ($row->min_duration_ms ?? 0), - maxDurationMs: (int) ($row->max_duration_ms ?? 0), - ); - }); - } - - /** - * Get the most popular tools by call count. - */ - public function getPopularTools(int $limit = 10, ?Carbon $from = null, ?Carbon $to = null): Collection - { - return $this->getAllToolStats($from, $to) - ->sortByDesc(fn (ToolStats $stats) => $stats->totalCalls) - ->take($limit) - ->values(); - } - - /** - * Get tools with the highest error rates. - */ - public function getErrorProneTools(int $limit = 10, ?Carbon $from = null, ?Carbon $to = null): Collection - { - $minCalls = 10; // Require minimum calls to be considered - - return $this->getAllToolStats($from, $to) - ->filter(fn (ToolStats $stats) => $stats->totalCalls >= $minCalls) - ->sortByDesc(fn (ToolStats $stats) => $stats->errorRate) - ->take($limit) - ->values(); - } - - /** - * Get tool combinations - tools frequently used together. - */ - public function getToolCombinations(int $limit = 10, ?Carbon $from = null, ?Carbon $to = null): Collection - { - $from = $from ?? now()->subDays(30); - $to = $to ?? now(); - - return DB::table('mcp_tool_combinations') - ->select('tool_a', 'tool_b') - ->selectRaw('SUM(occurrence_count) as total_occurrences') - ->whereBetween('date', [$from->toDateString(), $to->toDateString()]) - ->groupBy('tool_a', 'tool_b') - ->orderByDesc('total_occurrences') - ->limit($limit) - ->get() - ->map(fn ($row) => [ - 'tool_a' => $row->tool_a, - 'tool_b' => $row->tool_b, - 'occurrences' => (int) $row->total_occurrences, - ]); - } - - /** - * Get usage trends for a specific tool. - */ - public function getUsageTrends(string $tool, int $days = 30): array - { - $startDate = now()->subDays($days - 1)->startOfDay(); - $endDate = now()->endOfDay(); - - $metrics = ToolMetric::forTool($tool) - ->forDateRange($startDate, $endDate) - ->orderBy('date') - ->get() - ->keyBy(fn ($m) => $m->date->toDateString()); - - $trends = []; - - for ($i = $days - 1; $i >= 0; $i--) { - $date = now()->subDays($i)->toDateString(); - $metric = $metrics->get($date); - - $trends[] = [ - 'date' => $date, - 'date_formatted' => Carbon::parse($date)->format('M j'), - 'calls' => $metric?->call_count ?? 0, - 'errors' => $metric?->error_count ?? 0, - 'avg_duration_ms' => $metric?->average_duration ?? 0, - 'error_rate' => $metric?->error_rate ?? 0, - ]; - } - - return $trends; - } - - /** - * Get workspace-specific statistics. - */ - public function getWorkspaceStats(string $workspaceId, ?Carbon $from = null, ?Carbon $to = null): array - { - $from = $from ?? now()->subDays(30); - $to = $to ?? now(); - - $results = ToolMetric::query() - ->forWorkspace($workspaceId) - ->forDateRange($from, $to) - ->get(); - - $totalCalls = $results->sum('call_count'); - $errorCount = $results->sum('error_count'); - $totalDuration = $results->sum('total_duration_ms'); - $uniqueTools = $results->pluck('tool_name')->unique()->count(); - - return [ - 'workspace_id' => $workspaceId, - 'total_calls' => $totalCalls, - 'error_count' => $errorCount, - 'error_rate' => $totalCalls > 0 ? round(($errorCount / $totalCalls) * 100, 2) : 0.0, - 'avg_duration_ms' => $totalCalls > 0 ? round($totalDuration / $totalCalls, 2) : 0.0, - 'unique_tools' => $uniqueTools, - ]; - } - - /** - * Flush pending metrics to the database. - */ - public function flush(): void - { - if (empty($this->pendingMetrics)) { - return; - } - - $date = now()->toDateString(); - - foreach ($this->pendingMetrics as $data) { - $metric = ToolMetric::firstOrCreate([ - 'tool_name' => $data['tool_name'], - 'workspace_id' => $data['workspace_id'], - 'date' => $date, - ], [ - 'call_count' => 0, - 'error_count' => 0, - 'total_duration_ms' => 0, - ]); - - $metric->call_count += $data['calls']; - $metric->error_count += $data['errors']; - $metric->total_duration_ms += $data['duration']; - - if ($data['min'] !== null) { - if ($metric->min_duration_ms === null || $data['min'] < $metric->min_duration_ms) { - $metric->min_duration_ms = $data['min']; - } - } - - if ($data['max'] !== null) { - if ($metric->max_duration_ms === null || $data['max'] > $metric->max_duration_ms) { - $metric->max_duration_ms = $data['max']; - } - } - - $metric->save(); - } - - // Flush session tool combinations - $this->flushToolCombinations(); - - $this->pendingMetrics = []; - } - - /** - * Track a tool being used in a session. - */ - protected function trackToolInSession(string $sessionId, string $tool, ?string $workspaceId): void - { - $key = $sessionId.':'.($workspaceId ?? 'global'); - - if (! isset($this->sessionTools[$key])) { - $this->sessionTools[$key] = [ - 'workspace_id' => $workspaceId, - 'tools' => [], - ]; - } - - if (! in_array($tool, $this->sessionTools[$key]['tools'], true)) { - $this->sessionTools[$key]['tools'][] = $tool; - } - } - - /** - * Flush tool combinations to the database. - */ - protected function flushToolCombinations(): void - { - $date = now()->toDateString(); - - foreach ($this->sessionTools as $sessionData) { - $tools = $sessionData['tools']; - $workspaceId = $sessionData['workspace_id']; - - // Generate all unique pairs - $count = count($tools); - for ($i = 0; $i < $count; $i++) { - for ($j = $i + 1; $j < $count; $j++) { - // Ensure consistent ordering (alphabetical) - $pair = [$tools[$i], $tools[$j]]; - sort($pair); - - DB::table('mcp_tool_combinations') - ->updateOrInsert( - [ - 'tool_a' => $pair[0], - 'tool_b' => $pair[1], - 'workspace_id' => $workspaceId, - 'date' => $date, - ], - [ - 'occurrence_count' => DB::raw('occurrence_count + 1'), - 'updated_at' => now(), - ] - ); - - // Handle insert case where occurrence_count wasn't set - DB::table('mcp_tool_combinations') - ->where('tool_a', $pair[0]) - ->where('tool_b', $pair[1]) - ->where('workspace_id', $workspaceId) - ->where('date', $date) - ->whereNull('created_at') - ->update([ - 'created_at' => now(), - 'occurrence_count' => 1, - ]); - } - } - } - - $this->sessionTools = []; - } - - /** - * Get the metric key for batching. - */ - protected function getMetricKey(string $tool, ?string $workspaceId): string - { - return $tool.':'.($workspaceId ?? 'global'); - } - - /** - * Get total pending calls across all batches. - */ - protected function getTotalPendingCalls(): int - { - $total = 0; - foreach ($this->pendingMetrics as $data) { - $total += $data['calls']; - } - - return $total; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Services/ToolDependencyService.php b/packages/core-mcp/src/Mod/Mcp/Services/ToolDependencyService.php deleted file mode 100644 index 3376f77..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Services/ToolDependencyService.php +++ /dev/null @@ -1,496 +0,0 @@ -> - */ - protected array $dependencies = []; - - /** - * Custom dependency validators. - * - * @var array - */ - protected array $customValidators = []; - - public function __construct() - { - $this->registerDefaultDependencies(); - } - - /** - * Register dependencies for a tool. - * - * @param string $toolName The tool name - * @param array $dependencies List of dependencies - */ - public function register(string $toolName, array $dependencies): self - { - $this->dependencies[$toolName] = $dependencies; - - return $this; - } - - /** - * Register a custom validator for CUSTOM dependency types. - * - * @param string $name The custom dependency name - * @param callable $validator Function(array $context, array $args): bool - */ - public function registerCustomValidator(string $name, callable $validator): self - { - $this->customValidators[$name] = $validator; - - return $this; - } - - /** - * Get dependencies for a tool. - * - * @return array - */ - public function getDependencies(string $toolName): array - { - return $this->dependencies[$toolName] ?? []; - } - - /** - * Check if all dependencies are met for a tool. - * - * @param string $sessionId The session identifier - * @param string $toolName The tool to check - * @param array $context The execution context - * @param array $args The tool arguments - * @return bool True if all dependencies are met - */ - public function checkDependencies(string $sessionId, string $toolName, array $context = [], array $args = []): bool - { - $missing = $this->getMissingDependencies($sessionId, $toolName, $context, $args); - - return empty($missing); - } - - /** - * Get list of missing dependencies for a tool. - * - * @param string $sessionId The session identifier - * @param string $toolName The tool to check - * @param array $context The execution context - * @param array $args The tool arguments - * @return array List of unmet dependencies - */ - public function getMissingDependencies(string $sessionId, string $toolName, array $context = [], array $args = []): array - { - $dependencies = $this->getDependencies($toolName); - - if (empty($dependencies)) { - return []; - } - - $calledTools = $this->getCalledTools($sessionId); - $missing = []; - - foreach ($dependencies as $dependency) { - if ($dependency->optional) { - continue; // Skip optional dependencies - } - - $isMet = $this->isDependencyMet($dependency, $calledTools, $context, $args); - - if (! $isMet) { - $missing[] = $dependency; - } - } - - return $missing; - } - - /** - * Validate dependencies and throw exception if not met. - * - * @param string $sessionId The session identifier - * @param string $toolName The tool to validate - * @param array $context The execution context - * @param array $args The tool arguments - * - * @throws MissingDependencyException If dependencies are not met - */ - public function validateDependencies(string $sessionId, string $toolName, array $context = [], array $args = []): void - { - $missing = $this->getMissingDependencies($sessionId, $toolName, $context, $args); - - if (! empty($missing)) { - $suggestedOrder = $this->getSuggestedToolOrder($toolName, $missing); - - throw new MissingDependencyException($toolName, $missing, $suggestedOrder); - } - } - - /** - * Record that a tool was called in a session. - * - * @param string $sessionId The session identifier - * @param string $toolName The tool that was called - * @param array $args The arguments used (for entity tracking) - */ - public function recordToolCall(string $sessionId, string $toolName, array $args = []): void - { - $key = self::SESSION_CACHE_PREFIX.$sessionId; - $history = Cache::get($key, []); - - $history[] = [ - 'tool' => $toolName, - 'args' => $args, - 'timestamp' => now()->toIso8601String(), - ]; - - Cache::put($key, $history, self::SESSION_CACHE_TTL); - } - - /** - * Get list of tools called in a session. - * - * @return array Tool names that have been called - */ - public function getCalledTools(string $sessionId): array - { - $key = self::SESSION_CACHE_PREFIX.$sessionId; - $history = Cache::get($key, []); - - return array_unique(array_column($history, 'tool')); - } - - /** - * Get full tool call history for a session. - * - * @return array - */ - public function getToolHistory(string $sessionId): array - { - $key = self::SESSION_CACHE_PREFIX.$sessionId; - - return Cache::get($key, []); - } - - /** - * Clear session tool history. - */ - public function clearSession(string $sessionId): void - { - Cache::forget(self::SESSION_CACHE_PREFIX.$sessionId); - } - - /** - * Get the full dependency graph for visualization. - * - * @return array - */ - public function getDependencyGraph(): array - { - $graph = []; - - // Build forward dependencies - foreach ($this->dependencies as $tool => $deps) { - $graph[$tool] = [ - 'dependencies' => array_map(fn (ToolDependency $d) => $d->toArray(), $deps), - 'dependents' => [], - ]; - } - - // Build reverse dependencies (who depends on whom) - foreach ($this->dependencies as $tool => $deps) { - foreach ($deps as $dep) { - if ($dep->type === DependencyType::TOOL_CALLED) { - if (! isset($graph[$dep->key])) { - $graph[$dep->key] = [ - 'dependencies' => [], - 'dependents' => [], - ]; - } - $graph[$dep->key]['dependents'][] = $tool; - } - } - } - - return $graph; - } - - /** - * Get all tools that depend on a specific tool. - * - * @return array Tool names that depend on the given tool - */ - public function getDependentTools(string $toolName): array - { - $dependents = []; - - foreach ($this->dependencies as $tool => $deps) { - foreach ($deps as $dep) { - if ($dep->type === DependencyType::TOOL_CALLED && $dep->key === $toolName) { - $dependents[] = $tool; - } - } - } - - return $dependents; - } - - /** - * Get all tools in dependency order (topological sort). - * - * @return array Tools sorted by dependency order - */ - public function getTopologicalOrder(): array - { - $visited = []; - $order = []; - $tools = array_keys($this->dependencies); - - foreach ($tools as $tool) { - $this->topologicalVisit($tool, $visited, $order); - } - - return $order; - } - - /** - * Check if a specific dependency is met. - */ - protected function isDependencyMet( - ToolDependency $dependency, - array $calledTools, - array $context, - array $args - ): bool { - return match ($dependency->type) { - DependencyType::TOOL_CALLED => in_array($dependency->key, $calledTools, true), - DependencyType::SESSION_STATE => isset($context[$dependency->key]) && $context[$dependency->key] !== null, - DependencyType::CONTEXT_EXISTS => array_key_exists($dependency->key, $context), - DependencyType::ENTITY_EXISTS => $this->checkEntityExists($dependency, $args, $context), - DependencyType::CUSTOM => $this->checkCustomDependency($dependency, $context, $args), - }; - } - - /** - * Check if an entity exists based on the dependency configuration. - */ - protected function checkEntityExists(ToolDependency $dependency, array $args, array $context): bool - { - $entityType = $dependency->key; - $argKey = $dependency->metadata['arg_key'] ?? null; - - if (! $argKey || ! isset($args[$argKey])) { - return false; - } - - // Check based on entity type - return match ($entityType) { - 'plan' => $this->planExists($args[$argKey]), - 'session' => $this->sessionExists($args[$argKey] ?? $context['session_id'] ?? null), - 'phase' => $this->phaseExists($args['plan_slug'] ?? null, $args[$argKey] ?? null), - default => true, // Unknown entity types pass by default - }; - } - - /** - * Check if a plan exists. - */ - protected function planExists(?string $slug): bool - { - if (! $slug) { - return false; - } - - // Use a simple database check - the model namespace may vary - return \DB::table('agent_plans')->where('slug', $slug)->exists(); - } - - /** - * Check if a session exists. - */ - protected function sessionExists(?string $sessionId): bool - { - if (! $sessionId) { - return false; - } - - return \DB::table('agent_sessions')->where('session_id', $sessionId)->exists(); - } - - /** - * Check if a phase exists. - */ - protected function phaseExists(?string $planSlug, ?string $phaseIdentifier): bool - { - if (! $planSlug || ! $phaseIdentifier) { - return false; - } - - $plan = \DB::table('agent_plans')->where('slug', $planSlug)->first(); - if (! $plan) { - return false; - } - - $query = \DB::table('agent_phases')->where('agent_plan_id', $plan->id); - - if (is_numeric($phaseIdentifier)) { - return $query->where('order', (int) $phaseIdentifier)->exists(); - } - - return $query->where('name', $phaseIdentifier)->exists(); - } - - /** - * Check a custom dependency using registered validator. - */ - protected function checkCustomDependency(ToolDependency $dependency, array $context, array $args): bool - { - $validator = $this->customValidators[$dependency->key] ?? null; - - if (! $validator) { - // No validator registered - pass by default with warning - return true; - } - - return call_user_func($validator, $context, $args); - } - - /** - * Get suggested tool order to satisfy dependencies. - * - * @param array $missing - * @return array - */ - protected function getSuggestedToolOrder(string $targetTool, array $missing): array - { - $order = []; - - foreach ($missing as $dep) { - if ($dep->type === DependencyType::TOOL_CALLED) { - // Recursively get dependencies of the required tool - $preDeps = $this->getDependencies($dep->key); - foreach ($preDeps as $preDep) { - if ($preDep->type === DependencyType::TOOL_CALLED && ! in_array($preDep->key, $order, true)) { - $order[] = $preDep->key; - } - } - - if (! in_array($dep->key, $order, true)) { - $order[] = $dep->key; - } - } - } - - $order[] = $targetTool; - - return $order; - } - - /** - * Helper for topological sort. - */ - protected function topologicalVisit(string $tool, array &$visited, array &$order): void - { - if (isset($visited[$tool])) { - return; - } - - $visited[$tool] = true; - - foreach ($this->getDependencies($tool) as $dep) { - if ($dep->type === DependencyType::TOOL_CALLED) { - $this->topologicalVisit($dep->key, $visited, $order); - } - } - - $order[] = $tool; - } - - /** - * Register default dependencies for known tools. - */ - protected function registerDefaultDependencies(): void - { - // Session tools - session_log/artifact/handoff require active session - $this->register('session_log', [ - ToolDependency::sessionState('session_id', 'Active session required. Call session_start first.'), - ]); - - $this->register('session_artifact', [ - ToolDependency::sessionState('session_id', 'Active session required. Call session_start first.'), - ]); - - $this->register('session_handoff', [ - ToolDependency::sessionState('session_id', 'Active session required. Call session_start first.'), - ]); - - $this->register('session_end', [ - ToolDependency::sessionState('session_id', 'Active session required. Call session_start first.'), - ]); - - // Plan tools - require workspace context - $this->register('plan_create', [ - ToolDependency::contextExists('workspace_id', 'Workspace context required'), - ]); - - // Task tools - require plan to exist - $this->register('task_update', [ - ToolDependency::entityExists('plan', 'Plan must exist', ['arg_key' => 'plan_slug']), - ]); - - $this->register('task_toggle', [ - ToolDependency::entityExists('plan', 'Plan must exist', ['arg_key' => 'plan_slug']), - ]); - - // Phase tools - require plan to exist - $this->register('phase_get', [ - ToolDependency::entityExists('plan', 'Plan must exist', ['arg_key' => 'plan_slug']), - ]); - - $this->register('phase_update_status', [ - ToolDependency::entityExists('plan', 'Plan must exist', ['arg_key' => 'plan_slug']), - ]); - - $this->register('phase_add_checkpoint', [ - ToolDependency::entityExists('plan', 'Plan must exist', ['arg_key' => 'plan_slug']), - ]); - - // Content tools - require brief to exist for generation - $this->register('content_generate', [ - ToolDependency::contextExists('workspace_id', 'Workspace context required'), - ]); - - $this->register('content_batch_generate', [ - ToolDependency::contextExists('workspace_id', 'Workspace context required'), - ]); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Services/ToolRateLimiter.php b/packages/core-mcp/src/Mod/Mcp/Services/ToolRateLimiter.php deleted file mode 100644 index 6178983..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Services/ToolRateLimiter.php +++ /dev/null @@ -1,144 +0,0 @@ - false, 'remaining' => PHP_INT_MAX, 'retry_after' => null]; - } - - $limit = $this->getLimitForTool($toolName); - $decaySeconds = config('mcp.rate_limiting.decay_seconds', 60); - $cacheKey = $this->getCacheKey($identifier, $toolName); - - $current = (int) Cache::get($cacheKey, 0); - - if ($current >= $limit) { - $ttl = Cache::ttl($cacheKey); - - return [ - 'limited' => true, - 'remaining' => 0, - 'retry_after' => $ttl > 0 ? $ttl : $decaySeconds, - ]; - } - - return [ - 'limited' => false, - 'remaining' => $limit - $current - 1, - 'retry_after' => null, - ]; - } - - /** - * Record a tool call against the rate limit. - * - * @param string $identifier Session ID, API key, or other unique identifier - * @param string $toolName The tool being called - */ - public function hit(string $identifier, string $toolName): void - { - if (! config('mcp.rate_limiting.enabled', true)) { - return; - } - - $decaySeconds = config('mcp.rate_limiting.decay_seconds', 60); - $cacheKey = $this->getCacheKey($identifier, $toolName); - - $current = (int) Cache::get($cacheKey, 0); - - if ($current === 0) { - // First call - set with expiration - Cache::put($cacheKey, 1, $decaySeconds); - } else { - // Increment without resetting TTL - Cache::increment($cacheKey); - } - } - - /** - * Clear rate limit for an identifier. - * - * @param string $identifier Session ID, API key, or other unique identifier - * @param string|null $toolName Specific tool, or null to clear all - */ - public function clear(string $identifier, ?string $toolName = null): void - { - if ($toolName !== null) { - Cache::forget($this->getCacheKey($identifier, $toolName)); - } else { - // Clear all tool rate limits for this identifier (requires knowing tools) - // For now, just clear the specific key pattern - Cache::forget($this->getCacheKey($identifier, '*')); - } - } - - /** - * Get the rate limit for a specific tool. - */ - protected function getLimitForTool(string $toolName): int - { - // Check for tool-specific limit - $perToolLimits = config('mcp.rate_limiting.per_tool', []); - - if (isset($perToolLimits[$toolName])) { - return (int) $perToolLimits[$toolName]; - } - - // Use default limit - return (int) config('mcp.rate_limiting.calls_per_minute', 60); - } - - /** - * Generate cache key for rate limiting. - */ - protected function getCacheKey(string $identifier, string $toolName): string - { - // Use general key for overall rate limiting - return self::CACHE_PREFIX.$identifier.':'.$toolName; - } - - /** - * Get rate limit status for reporting. - * - * @return array{limit: int, remaining: int, reset_at: string|null} - */ - public function getStatus(string $identifier, string $toolName): array - { - $limit = $this->getLimitForTool($toolName); - $cacheKey = $this->getCacheKey($identifier, $toolName); - $current = (int) Cache::get($cacheKey, 0); - $ttl = Cache::ttl($cacheKey); - - return [ - 'limit' => $limit, - 'remaining' => max(0, $limit - $current), - 'reset_at' => $ttl > 0 ? now()->addSeconds($ttl)->toIso8601String() : null, - ]; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Services/ToolRegistry.php b/packages/core-mcp/src/Mod/Mcp/Services/ToolRegistry.php deleted file mode 100644 index 5738007..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Services/ToolRegistry.php +++ /dev/null @@ -1,353 +0,0 @@ -> - */ - protected array $examples = [ - 'query_database' => [ - 'query' => 'SELECT id, name FROM users LIMIT 10', - ], - 'list_tables' => [], - 'list_routes' => [], - 'list_sites' => [], - 'get_stats' => [], - 'create_coupon' => [ - 'code' => 'SUMMER25', - 'discount_type' => 'percentage', - 'discount_value' => 25, - 'expires_at' => '2025-12-31', - ], - 'list_invoices' => [ - 'status' => 'paid', - 'limit' => 10, - ], - 'get_billing_status' => [], - 'upgrade_plan' => [ - 'plan_slug' => 'professional', - ], - ]; - - /** - * Get all available MCP servers. - * - * @return Collection - */ - public function getServers(): Collection - { - return Cache::remember('mcp:playground:servers', self::CACHE_TTL, function () { - $registry = $this->loadRegistry(); - - return collect($registry['servers'] ?? []) - ->map(fn ($ref) => $this->loadServerSummary($ref['id'])) - ->filter() - ->values(); - }); - } - - /** - * Get all tools for a specific server. - * - * @return Collection - */ - public function getToolsForServer(string $serverId, bool $includeVersionInfo = false): Collection - { - $cacheKey = $includeVersionInfo - ? "mcp:playground:tools:{$serverId}:versioned" - : "mcp:playground:tools:{$serverId}"; - - return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($serverId, $includeVersionInfo) { - $server = $this->loadServerFull($serverId); - - if (! $server) { - return collect(); - } - - return collect($server['tools'] ?? []) - ->map(function ($tool) use ($serverId, $includeVersionInfo) { - $name = $tool['name']; - $baseVersion = $tool['version'] ?? ToolVersionService::DEFAULT_VERSION; - - $result = [ - 'name' => $name, - 'description' => $tool['description'] ?? $tool['purpose'] ?? '', - 'category' => $this->extractCategory($tool), - 'inputSchema' => $tool['inputSchema'] ?? ['type' => 'object', 'properties' => $tool['parameters'] ?? []], - 'examples' => $this->examples[$name] ?? $this->generateExampleFromSchema($tool['inputSchema'] ?? []), - 'version' => $baseVersion, - ]; - - // Optionally enrich with database version info - if ($includeVersionInfo) { - $latestVersion = McpToolVersion::forServer($serverId) - ->forTool($name) - ->latest() - ->first(); - - if ($latestVersion) { - $result['version'] = $latestVersion->version; - $result['version_status'] = $latestVersion->status; - $result['is_deprecated'] = $latestVersion->is_deprecated; - $result['sunset_at'] = $latestVersion->sunset_at?->toIso8601String(); - - // Use versioned schema if available - if ($latestVersion->input_schema) { - $result['inputSchema'] = $latestVersion->input_schema; - } - } - } - - return $result; - }) - ->values(); - }); - } - - /** - * Get all tools grouped by category. - * - * @return Collection> - */ - public function getToolsByCategory(string $serverId): Collection - { - return $this->getToolsForServer($serverId) - ->groupBy('category') - ->sortKeys(); - } - - /** - * Search tools by name or description. - * - * @return Collection - */ - public function searchTools(string $serverId, string $query): Collection - { - $query = strtolower(trim($query)); - - if (empty($query)) { - return $this->getToolsForServer($serverId); - } - - return $this->getToolsForServer($serverId) - ->filter(function ($tool) use ($query) { - return str_contains(strtolower($tool['name']), $query) - || str_contains(strtolower($tool['description']), $query) - || str_contains(strtolower($tool['category']), $query); - }) - ->values(); - } - - /** - * Get a specific tool by name. - */ - public function getTool(string $serverId, string $toolName): ?array - { - return $this->getToolsForServer($serverId) - ->firstWhere('name', $toolName); - } - - /** - * Get example inputs for a tool. - */ - public function getExampleInputs(string $toolName): array - { - return $this->examples[$toolName] ?? []; - } - - /** - * Set custom example inputs for a tool. - */ - public function setExampleInputs(string $toolName, array $examples): void - { - $this->examples[$toolName] = $examples; - } - - /** - * Get all categories across all servers. - * - * @return Collection - */ - public function getAllCategories(): Collection - { - return $this->getServers() - ->flatMap(fn ($server) => $this->getToolsForServer($server['id'])) - ->groupBy('category') - ->map(fn ($tools) => $tools->count()) - ->sortKeys(); - } - - /** - * Get full server configuration. - */ - public function getServerFull(string $serverId): ?array - { - return $this->loadServerFull($serverId); - } - - /** - * Clear cached registry data. - */ - public function clearCache(): void - { - Cache::forget('mcp:playground:servers'); - - foreach ($this->getServers() as $server) { - Cache::forget("mcp:playground:tools:{$server['id']}"); - } - } - - /** - * Extract category from tool definition. - */ - protected function extractCategory(array $tool): string - { - // Check for explicit category - if (isset($tool['category'])) { - return ucfirst($tool['category']); - } - - // Infer from tool name - $name = $tool['name'] ?? ''; - - $categoryPatterns = [ - 'query' => ['query', 'search', 'find', 'get', 'list'], - 'commerce' => ['coupon', 'invoice', 'billing', 'plan', 'payment', 'subscription'], - 'content' => ['content', 'article', 'page', 'post', 'media'], - 'system' => ['table', 'route', 'stat', 'config', 'setting'], - 'user' => ['user', 'auth', 'session', 'permission'], - ]; - - foreach ($categoryPatterns as $category => $patterns) { - foreach ($patterns as $pattern) { - if (str_contains(strtolower($name), $pattern)) { - return ucfirst($category); - } - } - } - - return 'General'; - } - - /** - * Generate example inputs from JSON schema. - */ - protected function generateExampleFromSchema(array $schema): array - { - $properties = $schema['properties'] ?? []; - $examples = []; - - foreach ($properties as $name => $prop) { - $type = is_array($prop['type'] ?? 'string') ? ($prop['type'][0] ?? 'string') : ($prop['type'] ?? 'string'); - - // Use default if available - if (isset($prop['default'])) { - $examples[$name] = $prop['default']; - - continue; - } - - // Use example if available - if (isset($prop['example'])) { - $examples[$name] = $prop['example']; - - continue; - } - - // Use first enum value if available - if (isset($prop['enum']) && ! empty($prop['enum'])) { - $examples[$name] = $prop['enum'][0]; - - continue; - } - - // Generate based on type - $examples[$name] = match ($type) { - 'integer', 'number' => $prop['minimum'] ?? 0, - 'boolean' => false, - 'array' => [], - 'object' => new \stdClass, - default => '', // string - }; - } - - return $examples; - } - - /** - * Load the MCP registry file. - */ - protected function loadRegistry(): array - { - $path = resource_path('mcp/registry.yaml'); - - if (! file_exists($path)) { - return ['servers' => []]; - } - - return Yaml::parseFile($path); - } - - /** - * Load full server configuration. - */ - protected function loadServerFull(string $id): ?array - { - // Sanitise server ID to prevent path traversal - $id = basename($id, '.yaml'); - - if (! preg_match('/^[a-z0-9-]+$/', $id)) { - return null; - } - - $path = resource_path("mcp/servers/{$id}.yaml"); - - if (! file_exists($path)) { - return null; - } - - return Yaml::parseFile($path); - } - - /** - * Load server summary (id, name, tagline, tool count). - */ - protected function loadServerSummary(string $id): ?array - { - $server = $this->loadServerFull($id); - - if (! $server) { - return null; - } - - return [ - 'id' => $server['id'], - 'name' => $server['name'], - 'tagline' => $server['tagline'] ?? '', - 'tool_count' => count($server['tools'] ?? []), - ]; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Services/ToolVersionService.php b/packages/core-mcp/src/Mod/Mcp/Services/ToolVersionService.php deleted file mode 100644 index 83ee630..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Services/ToolVersionService.php +++ /dev/null @@ -1,478 +0,0 @@ -isValidSemver($version)) { - throw new \InvalidArgumentException("Invalid semver version: {$version}"); - } - - // Check if version already exists - $existing = McpToolVersion::forServer($serverId) - ->forTool($toolName) - ->forVersion($version) - ->first(); - - if ($existing) { - // Update existing version - $existing->update([ - 'input_schema' => $inputSchema ?? $existing->input_schema, - 'output_schema' => $outputSchema ?? $existing->output_schema, - 'description' => $description ?? $existing->description, - 'changelog' => $options['changelog'] ?? $existing->changelog, - 'migration_notes' => $options['migration_notes'] ?? $existing->migration_notes, - ]); - - if ($options['mark_latest'] ?? false) { - $existing->markAsLatest(); - } - - $this->clearCache($serverId, $toolName); - - return $existing->fresh(); - } - - // Create new version - $toolVersion = McpToolVersion::create([ - 'server_id' => $serverId, - 'tool_name' => $toolName, - 'version' => $version, - 'input_schema' => $inputSchema, - 'output_schema' => $outputSchema, - 'description' => $description, - 'changelog' => $options['changelog'] ?? null, - 'migration_notes' => $options['migration_notes'] ?? null, - 'is_latest' => false, - ]); - - // Mark as latest if requested or if it's the first version - $isFirst = McpToolVersion::forServer($serverId)->forTool($toolName)->count() === 1; - - if (($options['mark_latest'] ?? false) || $isFirst) { - $toolVersion->markAsLatest(); - } - - $this->clearCache($serverId, $toolName); - - Log::info('MCP tool version registered', [ - 'server_id' => $serverId, - 'tool_name' => $toolName, - 'version' => $version, - 'is_latest' => $toolVersion->is_latest, - ]); - - return $toolVersion; - } - - /** - * Get a tool at a specific version. - * - * Returns null if version doesn't exist. Use getLatestVersion() for fallback. - */ - public function getToolAtVersion(string $serverId, string $toolName, string $version): ?McpToolVersion - { - $cacheKey = self::CACHE_PREFIX."{$serverId}:{$toolName}:{$version}"; - - return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($serverId, $toolName, $version) { - return McpToolVersion::forServer($serverId) - ->forTool($toolName) - ->forVersion($version) - ->first(); - }); - } - - /** - * Get the latest version of a tool. - */ - public function getLatestVersion(string $serverId, string $toolName): ?McpToolVersion - { - $cacheKey = self::CACHE_PREFIX."{$serverId}:{$toolName}:latest"; - - return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($serverId, $toolName) { - // First try to find explicitly marked latest - $latest = McpToolVersion::forServer($serverId) - ->forTool($toolName) - ->latest() - ->first(); - - if ($latest) { - return $latest; - } - - // Fallback to newest version by semver - return McpToolVersion::forServer($serverId) - ->forTool($toolName) - ->active() - ->orderByVersion('desc') - ->first(); - }); - } - - /** - * Resolve a tool version, falling back to latest if not specified. - * - * @return array{version: McpToolVersion|null, warning: array|null, error: array|null} - */ - public function resolveVersion(string $serverId, string $toolName, ?string $requestedVersion = null): array - { - // If no version requested, use latest - if ($requestedVersion === null) { - $version = $this->getLatestVersion($serverId, $toolName); - - return [ - 'version' => $version, - 'warning' => null, - 'error' => $version === null ? [ - 'code' => 'TOOL_NOT_FOUND', - 'message' => "No versions found for tool {$serverId}:{$toolName}", - ] : null, - ]; - } - - // Look up specific version - $version = $this->getToolAtVersion($serverId, $toolName, $requestedVersion); - - if (! $version) { - return [ - 'version' => null, - 'warning' => null, - 'error' => [ - 'code' => 'VERSION_NOT_FOUND', - 'message' => "Version {$requestedVersion} not found for tool {$serverId}:{$toolName}", - ], - ]; - } - - // Check if sunset - if ($version->is_sunset) { - return [ - 'version' => null, - 'warning' => null, - 'error' => $version->getSunsetError(), - ]; - } - - // Check if deprecated (warning, not error) - $warning = $version->getDeprecationWarning(); - - return [ - 'version' => $version, - 'warning' => $warning, - 'error' => null, - ]; - } - - /** - * Check if a version is deprecated. - */ - public function isDeprecated(string $serverId, string $toolName, string $version): bool - { - $toolVersion = $this->getToolAtVersion($serverId, $toolName, $version); - - return $toolVersion?->is_deprecated ?? false; - } - - /** - * Check if a version is sunset (blocked). - */ - public function isSunset(string $serverId, string $toolName, string $version): bool - { - $toolVersion = $this->getToolAtVersion($serverId, $toolName, $version); - - return $toolVersion?->is_sunset ?? false; - } - - /** - * Compare two semver versions. - * - * @return int -1 if $a < $b, 0 if equal, 1 if $a > $b - */ - public function compareVersions(string $a, string $b): int - { - return version_compare( - $this->normalizeSemver($a), - $this->normalizeSemver($b) - ); - } - - /** - * Get version history for a tool. - * - * @return Collection - */ - public function getVersionHistory(string $serverId, string $toolName): Collection - { - return McpToolVersion::forServer($serverId) - ->forTool($toolName) - ->orderByVersion('desc') - ->get(); - } - - /** - * Attempt to migrate a tool call from an old version schema to a new one. - * - * This is a best-effort migration that: - * - Preserves arguments that exist in both schemas - * - Applies defaults for new required arguments where possible - * - Returns warnings for arguments that couldn't be migrated - * - * @return array{arguments: array, warnings: array, success: bool} - */ - public function migrateToolCall( - string $serverId, - string $toolName, - string $fromVersion, - string $toVersion, - array $arguments - ): array { - $fromTool = $this->getToolAtVersion($serverId, $toolName, $fromVersion); - $toTool = $this->getToolAtVersion($serverId, $toolName, $toVersion); - - if (! $fromTool || ! $toTool) { - return [ - 'arguments' => $arguments, - 'warnings' => ['Could not load version schemas for migration'], - 'success' => false, - ]; - } - - $toSchema = $toTool->input_schema ?? []; - $toProperties = $toSchema['properties'] ?? []; - $toRequired = $toSchema['required'] ?? []; - - $migratedArgs = []; - $warnings = []; - - // Copy over arguments that exist in the new schema - foreach ($arguments as $key => $value) { - if (isset($toProperties[$key])) { - $migratedArgs[$key] = $value; - } else { - $warnings[] = "Argument '{$key}' removed in version {$toVersion}"; - } - } - - // Check for new required arguments without defaults - foreach ($toRequired as $requiredKey) { - if (! isset($migratedArgs[$requiredKey])) { - // Try to apply default from schema - if (isset($toProperties[$requiredKey]['default'])) { - $migratedArgs[$requiredKey] = $toProperties[$requiredKey]['default']; - $warnings[] = "Applied default value for new required argument '{$requiredKey}'"; - } else { - $warnings[] = "Missing required argument '{$requiredKey}' added in version {$toVersion}"; - } - } - } - - return [ - 'arguments' => $migratedArgs, - 'warnings' => $warnings, - 'success' => empty(array_filter($warnings, fn ($w) => str_starts_with($w, 'Missing required'))), - ]; - } - - /** - * Deprecate a tool version with optional sunset date. - */ - public function deprecateVersion( - string $serverId, - string $toolName, - string $version, - ?Carbon $sunsetAt = null - ): ?McpToolVersion { - $toolVersion = McpToolVersion::forServer($serverId) - ->forTool($toolName) - ->forVersion($version) - ->first(); - - if (! $toolVersion) { - return null; - } - - $toolVersion->deprecate($sunsetAt); - $this->clearCache($serverId, $toolName); - - Log::info('MCP tool version deprecated', [ - 'server_id' => $serverId, - 'tool_name' => $toolName, - 'version' => $version, - 'sunset_at' => $sunsetAt?->toIso8601String(), - ]); - - return $toolVersion; - } - - /** - * Get all tools with version info for a server. - * - * @return Collection - */ - public function getToolsWithVersions(string $serverId): Collection - { - $versions = McpToolVersion::forServer($serverId) - ->orderByVersion('desc') - ->get(); - - return $versions->groupBy('tool_name') - ->map(function ($toolVersions, $toolName) { - return [ - 'tool_name' => $toolName, - 'latest' => $toolVersions->firstWhere('is_latest', true) ?? $toolVersions->first(), - 'versions' => $toolVersions, - 'version_count' => $toolVersions->count(), - 'has_deprecated' => $toolVersions->contains(fn ($v) => $v->is_deprecated), - 'has_sunset' => $toolVersions->contains(fn ($v) => $v->is_sunset), - ]; - }); - } - - /** - * Get all unique servers that have versioned tools. - */ - public function getServersWithVersions(): Collection - { - return McpToolVersion::select('server_id') - ->distinct() - ->orderBy('server_id') - ->pluck('server_id'); - } - - /** - * Sync tool versions from YAML server definitions. - * - * Call this during deployment to register/update versions from server configs. - * - * @param array $serverConfig Parsed YAML server configuration - * @param string $version Version to register (e.g., from deployment tag) - */ - public function syncFromServerConfig(array $serverConfig, string $version, bool $markLatest = true): int - { - $serverId = $serverConfig['id'] ?? null; - $tools = $serverConfig['tools'] ?? []; - - if (! $serverId || empty($tools)) { - return 0; - } - - $registered = 0; - - foreach ($tools as $tool) { - $toolName = $tool['name'] ?? null; - if (! $toolName) { - continue; - } - - $this->registerVersion( - serverId: $serverId, - toolName: $toolName, - version: $version, - inputSchema: $tool['inputSchema'] ?? null, - outputSchema: $tool['outputSchema'] ?? null, - description: $tool['description'] ?? $tool['purpose'] ?? null, - options: [ - 'mark_latest' => $markLatest, - ] - ); - - $registered++; - } - - return $registered; - } - - /** - * Get statistics about tool versions. - */ - public function getStats(): array - { - return [ - 'total_versions' => McpToolVersion::count(), - 'total_tools' => McpToolVersion::select('server_id', 'tool_name') - ->distinct() - ->count(), - 'deprecated_count' => McpToolVersion::deprecated()->count(), - 'sunset_count' => McpToolVersion::sunset()->count(), - 'servers' => $this->getServersWithVersions()->count(), - ]; - } - - // ------------------------------------------------------------------------- - // Protected Methods - // ------------------------------------------------------------------------- - - /** - * Validate semver format. - */ - protected function isValidSemver(string $version): bool - { - // Basic semver pattern: major.minor.patch with optional prerelease/build - $pattern = '/^(\d+)\.(\d+)\.(\d+)(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/'; - - return (bool) preg_match($pattern, $version); - } - - /** - * Normalize semver for comparison (removes prerelease/build metadata). - */ - protected function normalizeSemver(string $version): string - { - // Remove prerelease and build metadata for basic comparison - return preg_replace('/[-+].*$/', '', $version) ?? $version; - } - - /** - * Clear cache for a tool's versions. - */ - protected function clearCache(string $serverId, string $toolName): void - { - // Clear specific version caches would require tracking all versions - // For simplicity, we use a short TTL and let cache naturally expire - Cache::forget(self::CACHE_PREFIX."{$serverId}:{$toolName}:latest"); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Tests/Unit/McpQuotaServiceTest.php b/packages/core-mcp/src/Mod/Mcp/Tests/Unit/McpQuotaServiceTest.php deleted file mode 100644 index b8ef3fd..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Tests/Unit/McpQuotaServiceTest.php +++ /dev/null @@ -1,245 +0,0 @@ -entitlementsMock = Mockery::mock(EntitlementService::class); - $this->quotaService = new McpQuotaService($this->entitlementsMock); - - $this->workspace = Workspace::factory()->create(); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - - public function test_records_usage_for_workspace(): void - { - $quota = $this->quotaService->recordUsage($this->workspace, toolCalls: 5, inputTokens: 100, outputTokens: 50); - - $this->assertInstanceOf(McpUsageQuota::class, $quota); - $this->assertEquals(5, $quota->tool_calls_count); - $this->assertEquals(100, $quota->input_tokens); - $this->assertEquals(50, $quota->output_tokens); - $this->assertEquals(now()->format('Y-m'), $quota->month); - } - - public function test_increments_existing_usage(): void - { - // First call - $this->quotaService->recordUsage($this->workspace, toolCalls: 5, inputTokens: 100, outputTokens: 50); - - // Second call - $quota = $this->quotaService->recordUsage($this->workspace, toolCalls: 3, inputTokens: 200, outputTokens: 100); - - $this->assertEquals(8, $quota->tool_calls_count); - $this->assertEquals(300, $quota->input_tokens); - $this->assertEquals(150, $quota->output_tokens); - } - - public function test_check_quota_returns_true_when_unlimited(): void - { - $this->entitlementsMock - ->shouldReceive('can') - ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) - ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); - - $this->entitlementsMock - ->shouldReceive('can') - ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) - ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS)); - - $result = $this->quotaService->checkQuota($this->workspace); - - $this->assertTrue($result); - } - - public function test_check_quota_returns_false_when_denied(): void - { - $this->entitlementsMock - ->shouldReceive('can') - ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) - ->andReturn(EntitlementResult::denied('Not included in plan', featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); - - $result = $this->quotaService->checkQuota($this->workspace); - - $this->assertFalse($result); - } - - public function test_check_quota_returns_false_when_limit_exceeded(): void - { - // Set up existing usage that exceeds limit - McpUsageQuota::create([ - 'workspace_id' => $this->workspace->id, - 'month' => now()->format('Y-m'), - 'tool_calls_count' => 100, - 'input_tokens' => 0, - 'output_tokens' => 0, - ]); - - $this->entitlementsMock - ->shouldReceive('can') - ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) - ->andReturn(EntitlementResult::allowed(limit: 100, used: 100, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); - - $this->entitlementsMock - ->shouldReceive('can') - ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) - ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS)); - - $result = $this->quotaService->checkQuota($this->workspace); - - $this->assertFalse($result); - } - - public function test_check_quota_returns_true_when_within_limit(): void - { - McpUsageQuota::create([ - 'workspace_id' => $this->workspace->id, - 'month' => now()->format('Y-m'), - 'tool_calls_count' => 50, - 'input_tokens' => 0, - 'output_tokens' => 0, - ]); - - $this->entitlementsMock - ->shouldReceive('can') - ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) - ->andReturn(EntitlementResult::allowed(limit: 100, used: 50, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); - - $this->entitlementsMock - ->shouldReceive('can') - ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) - ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS)); - - $result = $this->quotaService->checkQuota($this->workspace); - - $this->assertTrue($result); - } - - public function test_get_remaining_quota_calculates_correctly(): void - { - McpUsageQuota::create([ - 'workspace_id' => $this->workspace->id, - 'month' => now()->format('Y-m'), - 'tool_calls_count' => 30, - 'input_tokens' => 500, - 'output_tokens' => 500, - ]); - - $this->entitlementsMock - ->shouldReceive('can') - ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) - ->andReturn(EntitlementResult::allowed(limit: 100, used: 30, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); - - $this->entitlementsMock - ->shouldReceive('can') - ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) - ->andReturn(EntitlementResult::allowed(limit: 5000, used: 1000, featureCode: McpQuotaService::FEATURE_MONTHLY_TOKENS)); - - $remaining = $this->quotaService->getRemainingQuota($this->workspace); - - $this->assertEquals(70, $remaining['tool_calls']); - $this->assertEquals(4000, $remaining['tokens']); - $this->assertFalse($remaining['tool_calls_unlimited']); - $this->assertFalse($remaining['tokens_unlimited']); - } - - public function test_get_quota_headers_returns_correct_format(): void - { - McpUsageQuota::create([ - 'workspace_id' => $this->workspace->id, - 'month' => now()->format('Y-m'), - 'tool_calls_count' => 25, - 'input_tokens' => 300, - 'output_tokens' => 200, - ]); - - $this->entitlementsMock - ->shouldReceive('can') - ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS) - ->andReturn(EntitlementResult::allowed(limit: 100, used: 25, featureCode: McpQuotaService::FEATURE_MONTHLY_TOOL_CALLS)); - - $this->entitlementsMock - ->shouldReceive('can') - ->with($this->workspace, McpQuotaService::FEATURE_MONTHLY_TOKENS) - ->andReturn(EntitlementResult::unlimited(McpQuotaService::FEATURE_MONTHLY_TOKENS)); - - $headers = $this->quotaService->getQuotaHeaders($this->workspace); - - $this->assertArrayHasKey('X-MCP-Quota-Tool-Calls-Used', $headers); - $this->assertArrayHasKey('X-MCP-Quota-Tool-Calls-Limit', $headers); - $this->assertArrayHasKey('X-MCP-Quota-Tool-Calls-Remaining', $headers); - $this->assertArrayHasKey('X-MCP-Quota-Tokens-Used', $headers); - $this->assertArrayHasKey('X-MCP-Quota-Tokens-Limit', $headers); - $this->assertArrayHasKey('X-MCP-Quota-Reset', $headers); - - $this->assertEquals('25', $headers['X-MCP-Quota-Tool-Calls-Used']); - $this->assertEquals('100', $headers['X-MCP-Quota-Tool-Calls-Limit']); - $this->assertEquals('unlimited', $headers['X-MCP-Quota-Tokens-Limit']); - } - - public function test_reset_monthly_quota_clears_usage(): void - { - McpUsageQuota::create([ - 'workspace_id' => $this->workspace->id, - 'month' => now()->format('Y-m'), - 'tool_calls_count' => 50, - 'input_tokens' => 1000, - 'output_tokens' => 500, - ]); - - $quota = $this->quotaService->resetMonthlyQuota($this->workspace); - - $this->assertEquals(0, $quota->tool_calls_count); - $this->assertEquals(0, $quota->input_tokens); - $this->assertEquals(0, $quota->output_tokens); - } - - public function test_get_usage_history_returns_ordered_records(): void - { - // Create usage for multiple months - foreach (['2026-01', '2025-12', '2025-11'] as $month) { - McpUsageQuota::create([ - 'workspace_id' => $this->workspace->id, - 'month' => $month, - 'tool_calls_count' => rand(10, 100), - 'input_tokens' => rand(100, 1000), - 'output_tokens' => rand(100, 1000), - ]); - } - - $history = $this->quotaService->getUsageHistory($this->workspace, 3); - - $this->assertCount(3, $history); - // Should be ordered by month descending - $this->assertEquals('2026-01', $history->first()->month); - $this->assertEquals('2025-11', $history->last()->month); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Tests/Unit/ToolDependencyServiceTest.php b/packages/core-mcp/src/Mod/Mcp/Tests/Unit/ToolDependencyServiceTest.php deleted file mode 100644 index 6eb0838..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Tests/Unit/ToolDependencyServiceTest.php +++ /dev/null @@ -1,480 +0,0 @@ -service = new ToolDependencyService; - Cache::flush(); - } - - public function test_can_register_dependencies(): void - { - $deps = [ - ToolDependency::toolCalled('plan_create'), - ToolDependency::contextExists('workspace_id'), - ]; - - $this->service->register('custom_tool', $deps); - - $registered = $this->service->getDependencies('custom_tool'); - - $this->assertCount(2, $registered); - $this->assertSame('plan_create', $registered[0]->key); - $this->assertSame(DependencyType::TOOL_CALLED, $registered[0]->type); - } - - public function test_returns_empty_for_unregistered_tool(): void - { - $deps = $this->service->getDependencies('nonexistent_tool'); - - $this->assertEmpty($deps); - } - - public function test_check_dependencies_passes_when_no_deps(): void - { - $result = $this->service->checkDependencies( - sessionId: 'test-session', - toolName: 'tool_without_deps', - context: [], - args: [], - ); - - $this->assertTrue($result); - } - - public function test_check_dependencies_fails_when_tool_not_called(): void - { - $this->service->register('dependent_tool', [ - ToolDependency::toolCalled('required_tool'), - ]); - - $result = $this->service->checkDependencies( - sessionId: 'test-session', - toolName: 'dependent_tool', - context: [], - args: [], - ); - - $this->assertFalse($result); - } - - public function test_check_dependencies_passes_after_tool_called(): void - { - $this->service->register('dependent_tool', [ - ToolDependency::toolCalled('required_tool'), - ]); - - // Record the required tool call - $this->service->recordToolCall('test-session', 'required_tool'); - - $result = $this->service->checkDependencies( - sessionId: 'test-session', - toolName: 'dependent_tool', - context: [], - args: [], - ); - - $this->assertTrue($result); - } - - public function test_check_context_exists_dependency(): void - { - $this->service->register('workspace_tool', [ - ToolDependency::contextExists('workspace_id'), - ]); - - // Without workspace_id - $result = $this->service->checkDependencies( - sessionId: 'test-session', - toolName: 'workspace_tool', - context: [], - args: [], - ); - $this->assertFalse($result); - - // With workspace_id - $result = $this->service->checkDependencies( - sessionId: 'test-session', - toolName: 'workspace_tool', - context: ['workspace_id' => 123], - args: [], - ); - $this->assertTrue($result); - } - - public function test_check_session_state_dependency(): void - { - $this->service->register('session_tool', [ - ToolDependency::sessionState('session_id'), - ]); - - // Without session_id - $result = $this->service->checkDependencies( - sessionId: 'test-session', - toolName: 'session_tool', - context: [], - args: [], - ); - $this->assertFalse($result); - - // With null session_id (should still fail) - $result = $this->service->checkDependencies( - sessionId: 'test-session', - toolName: 'session_tool', - context: ['session_id' => null], - args: [], - ); - $this->assertFalse($result); - - // With valid session_id - $result = $this->service->checkDependencies( - sessionId: 'test-session', - toolName: 'session_tool', - context: ['session_id' => 'ses_123'], - args: [], - ); - $this->assertTrue($result); - } - - public function test_get_missing_dependencies(): void - { - $this->service->register('multi_dep_tool', [ - ToolDependency::toolCalled('tool_a'), - ToolDependency::toolCalled('tool_b'), - ToolDependency::contextExists('workspace_id'), - ]); - - // Record one tool call - $this->service->recordToolCall('test-session', 'tool_a'); - - $missing = $this->service->getMissingDependencies( - sessionId: 'test-session', - toolName: 'multi_dep_tool', - context: [], - args: [], - ); - - $this->assertCount(2, $missing); - $this->assertSame('tool_b', $missing[0]->key); - $this->assertSame('workspace_id', $missing[1]->key); - } - - public function test_validate_dependencies_throws_exception(): void - { - $this->service->register('validated_tool', [ - ToolDependency::toolCalled('required_tool', 'You must call required_tool first'), - ]); - - $this->expectException(MissingDependencyException::class); - $this->expectExceptionMessage('Cannot execute \'validated_tool\''); - - $this->service->validateDependencies( - sessionId: 'test-session', - toolName: 'validated_tool', - context: [], - args: [], - ); - } - - public function test_validate_dependencies_passes_when_met(): void - { - $this->service->register('validated_tool', [ - ToolDependency::toolCalled('required_tool'), - ]); - - $this->service->recordToolCall('test-session', 'required_tool'); - - // Should not throw - $this->service->validateDependencies( - sessionId: 'test-session', - toolName: 'validated_tool', - context: [], - args: [], - ); - - $this->assertTrue(true); // No exception means pass - } - - public function test_optional_dependencies_are_skipped(): void - { - $this->service->register('soft_dep_tool', [ - ToolDependency::toolCalled('hard_req'), - ToolDependency::toolCalled('soft_req')->asOptional(), - ]); - - $this->service->recordToolCall('test-session', 'hard_req'); - - // Should pass even though soft_req not called - $result = $this->service->checkDependencies( - sessionId: 'test-session', - toolName: 'soft_dep_tool', - context: [], - args: [], - ); - - $this->assertTrue($result); - } - - public function test_record_and_get_tool_call_history(): void - { - $this->service->recordToolCall('test-session', 'tool_a', ['arg1' => 'value1']); - $this->service->recordToolCall('test-session', 'tool_b'); - $this->service->recordToolCall('test-session', 'tool_a', ['arg1' => 'value2']); - - $calledTools = $this->service->getCalledTools('test-session'); - - $this->assertCount(2, $calledTools); - $this->assertContains('tool_a', $calledTools); - $this->assertContains('tool_b', $calledTools); - - $history = $this->service->getToolHistory('test-session'); - - $this->assertCount(3, $history); - $this->assertSame('tool_a', $history[0]['tool']); - $this->assertSame(['arg1' => 'value1'], $history[0]['args']); - } - - public function test_clear_session(): void - { - $this->service->recordToolCall('test-session', 'tool_a'); - - $this->assertNotEmpty($this->service->getCalledTools('test-session')); - - $this->service->clearSession('test-session'); - - $this->assertEmpty($this->service->getCalledTools('test-session')); - } - - public function test_get_dependency_graph(): void - { - $this->service->register('tool_a', []); - $this->service->register('tool_b', [ - ToolDependency::toolCalled('tool_a'), - ]); - $this->service->register('tool_c', [ - ToolDependency::toolCalled('tool_b'), - ]); - - $graph = $this->service->getDependencyGraph(); - - $this->assertArrayHasKey('tool_a', $graph); - $this->assertArrayHasKey('tool_b', $graph); - $this->assertArrayHasKey('tool_c', $graph); - - // tool_b depends on tool_a - $this->assertContains('tool_b', $graph['tool_a']['dependents']); - - // tool_c depends on tool_b - $this->assertContains('tool_c', $graph['tool_b']['dependents']); - } - - public function test_get_dependent_tools(): void - { - $this->service->register('base_tool', []); - $this->service->register('dep_tool_1', [ - ToolDependency::toolCalled('base_tool'), - ]); - $this->service->register('dep_tool_2', [ - ToolDependency::toolCalled('base_tool'), - ]); - - $dependents = $this->service->getDependentTools('base_tool'); - - $this->assertCount(2, $dependents); - $this->assertContains('dep_tool_1', $dependents); - $this->assertContains('dep_tool_2', $dependents); - } - - public function test_get_topological_order(): void - { - $this->service->register('tool_a', []); - $this->service->register('tool_b', [ - ToolDependency::toolCalled('tool_a'), - ]); - $this->service->register('tool_c', [ - ToolDependency::toolCalled('tool_b'), - ]); - - $order = $this->service->getTopologicalOrder(); - - $indexA = array_search('tool_a', $order); - $indexB = array_search('tool_b', $order); - $indexC = array_search('tool_c', $order); - - $this->assertLessThan($indexB, $indexA); - $this->assertLessThan($indexC, $indexB); - } - - public function test_custom_validator(): void - { - $this->service->register('custom_validated_tool', [ - ToolDependency::custom('has_permission', 'User must have admin permission'), - ]); - - // Register custom validator that checks for admin role - $this->service->registerCustomValidator('has_permission', function ($context, $args) { - return ($context['role'] ?? null) === 'admin'; - }); - - // Without admin role - $result = $this->service->checkDependencies( - sessionId: 'test-session', - toolName: 'custom_validated_tool', - context: ['role' => 'user'], - args: [], - ); - $this->assertFalse($result); - - // With admin role - $result = $this->service->checkDependencies( - sessionId: 'test-session', - toolName: 'custom_validated_tool', - context: ['role' => 'admin'], - args: [], - ); - $this->assertTrue($result); - } - - public function test_suggested_tool_order(): void - { - $this->service->register('tool_a', []); - $this->service->register('tool_b', [ - ToolDependency::toolCalled('tool_a'), - ]); - $this->service->register('tool_c', [ - ToolDependency::toolCalled('tool_b'), - ]); - - try { - $this->service->validateDependencies( - sessionId: 'test-session', - toolName: 'tool_c', - context: [], - args: [], - ); - $this->fail('Should have thrown MissingDependencyException'); - } catch (MissingDependencyException $e) { - $this->assertContains('tool_a', $e->suggestedOrder); - $this->assertContains('tool_b', $e->suggestedOrder); - $this->assertContains('tool_c', $e->suggestedOrder); - - // Verify order - $indexA = array_search('tool_a', $e->suggestedOrder); - $indexB = array_search('tool_b', $e->suggestedOrder); - $this->assertLessThan($indexB, $indexA); - } - } - - public function test_session_isolation(): void - { - $this->service->register('isolated_tool', [ - ToolDependency::toolCalled('prereq'), - ]); - - // Record in session 1 - $this->service->recordToolCall('session-1', 'prereq'); - - // Session 1 should pass - $result1 = $this->service->checkDependencies( - sessionId: 'session-1', - toolName: 'isolated_tool', - context: [], - args: [], - ); - $this->assertTrue($result1); - - // Session 2 should fail (different session) - $result2 = $this->service->checkDependencies( - sessionId: 'session-2', - toolName: 'isolated_tool', - context: [], - args: [], - ); - $this->assertFalse($result2); - } - - public function test_missing_dependency_exception_api_response(): void - { - $missing = [ - ToolDependency::toolCalled('tool_a', 'Tool A must be called first'), - ToolDependency::contextExists('workspace_id', 'Workspace context required'), - ]; - - $exception = new MissingDependencyException( - toolName: 'target_tool', - missingDependencies: $missing, - suggestedOrder: ['tool_a', 'target_tool'], - ); - - $response = $exception->toApiResponse(); - - $this->assertSame('dependency_not_met', $response['error']); - $this->assertSame('target_tool', $response['tool']); - $this->assertCount(2, $response['missing_dependencies']); - $this->assertSame(['tool_a', 'target_tool'], $response['suggested_order']); - $this->assertArrayHasKey('help', $response); - } - - public function test_default_dependencies_registered(): void - { - // The service should have default dependencies registered - $sessionLogDeps = $this->service->getDependencies('session_log'); - - $this->assertNotEmpty($sessionLogDeps); - $this->assertSame(DependencyType::SESSION_STATE, $sessionLogDeps[0]->type); - $this->assertSame('session_id', $sessionLogDeps[0]->key); - } - - public function test_tool_dependency_factory_methods(): void - { - $toolCalled = ToolDependency::toolCalled('some_tool'); - $this->assertSame(DependencyType::TOOL_CALLED, $toolCalled->type); - $this->assertSame('some_tool', $toolCalled->key); - - $sessionState = ToolDependency::sessionState('session_key'); - $this->assertSame(DependencyType::SESSION_STATE, $sessionState->type); - - $contextExists = ToolDependency::contextExists('context_key'); - $this->assertSame(DependencyType::CONTEXT_EXISTS, $contextExists->type); - - $entityExists = ToolDependency::entityExists('plan', 'Plan must exist', ['arg_key' => 'plan_slug']); - $this->assertSame(DependencyType::ENTITY_EXISTS, $entityExists->type); - $this->assertSame('plan_slug', $entityExists->metadata['arg_key']); - - $custom = ToolDependency::custom('custom_check', 'Custom validation'); - $this->assertSame(DependencyType::CUSTOM, $custom->type); - } - - public function test_tool_dependency_to_and_from_array(): void - { - $original = ToolDependency::toolCalled('some_tool', 'Must call first') - ->asOptional(); - - $array = $original->toArray(); - - $this->assertSame('tool_called', $array['type']); - $this->assertSame('some_tool', $array['key']); - $this->assertTrue($array['optional']); - - $restored = ToolDependency::fromArray($array); - - $this->assertSame($original->type, $restored->type); - $this->assertSame($original->key, $restored->key); - $this->assertSame($original->optional, $restored->optional); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Tests/Unit/ToolVersionServiceTest.php b/packages/core-mcp/src/Mod/Mcp/Tests/Unit/ToolVersionServiceTest.php deleted file mode 100644 index caaedd1..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Tests/Unit/ToolVersionServiceTest.php +++ /dev/null @@ -1,441 +0,0 @@ -service = new ToolVersionService; - } - - public function test_can_register_new_version(): void - { - $version = $this->service->registerVersion( - serverId: 'test-server', - toolName: 'test_tool', - version: '1.0.0', - inputSchema: ['type' => 'object', 'properties' => ['query' => ['type' => 'string']]], - description: 'A test tool', - options: ['mark_latest' => true] - ); - - $this->assertSame('test-server', $version->server_id); - $this->assertSame('test_tool', $version->tool_name); - $this->assertSame('1.0.0', $version->version); - $this->assertTrue($version->is_latest); - } - - public function test_first_version_is_automatically_latest(): void - { - $version = $this->service->registerVersion( - serverId: 'test-server', - toolName: 'test_tool', - version: '1.0.0', - ); - - $this->assertTrue($version->is_latest); - } - - public function test_can_get_tool_at_specific_version(): void - { - $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); - $this->service->registerVersion('test-server', 'test_tool', '2.0.0'); - - $v1 = $this->service->getToolAtVersion('test-server', 'test_tool', '1.0.0'); - $v2 = $this->service->getToolAtVersion('test-server', 'test_tool', '2.0.0'); - - $this->assertSame('1.0.0', $v1->version); - $this->assertSame('2.0.0', $v2->version); - } - - public function test_get_latest_version(): void - { - $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); - $v2 = $this->service->registerVersion('test-server', 'test_tool', '2.0.0', options: ['mark_latest' => true]); - - $latest = $this->service->getLatestVersion('test-server', 'test_tool'); - - $this->assertSame('2.0.0', $latest->version); - $this->assertTrue($latest->is_latest); - } - - public function test_resolve_version_returns_latest_when_no_version_specified(): void - { - $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); - $this->service->registerVersion('test-server', 'test_tool', '2.0.0', options: ['mark_latest' => true]); - - $result = $this->service->resolveVersion('test-server', 'test_tool', null); - - $this->assertNotNull($result['version']); - $this->assertSame('2.0.0', $result['version']->version); - $this->assertNull($result['warning']); - $this->assertNull($result['error']); - } - - public function test_resolve_version_returns_specific_version(): void - { - $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); - $this->service->registerVersion('test-server', 'test_tool', '2.0.0', options: ['mark_latest' => true]); - - $result = $this->service->resolveVersion('test-server', 'test_tool', '1.0.0'); - - $this->assertNotNull($result['version']); - $this->assertSame('1.0.0', $result['version']->version); - } - - public function test_resolve_version_returns_error_for_nonexistent_version(): void - { - $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); - - $result = $this->service->resolveVersion('test-server', 'test_tool', '9.9.9'); - - $this->assertNull($result['version']); - $this->assertNotNull($result['error']); - $this->assertSame('VERSION_NOT_FOUND', $result['error']['code']); - } - - public function test_resolve_deprecated_version_returns_warning(): void - { - $version = $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); - $version->deprecate(Carbon::now()->addDays(30)); - - $this->service->registerVersion('test-server', 'test_tool', '2.0.0', options: ['mark_latest' => true]); - - $result = $this->service->resolveVersion('test-server', 'test_tool', '1.0.0'); - - $this->assertNotNull($result['version']); - $this->assertNotNull($result['warning']); - $this->assertSame('TOOL_VERSION_DEPRECATED', $result['warning']['code']); - } - - public function test_resolve_sunset_version_returns_error(): void - { - $version = $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); - $version->deprecated_at = Carbon::now()->subDays(60); - $version->sunset_at = Carbon::now()->subDays(30); - $version->save(); - - $this->service->registerVersion('test-server', 'test_tool', '2.0.0', options: ['mark_latest' => true]); - - $result = $this->service->resolveVersion('test-server', 'test_tool', '1.0.0'); - - $this->assertNull($result['version']); - $this->assertNotNull($result['error']); - $this->assertSame('TOOL_VERSION_SUNSET', $result['error']['code']); - } - - public function test_is_deprecated(): void - { - $version = $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); - $version->deprecate(); - - $this->assertTrue($this->service->isDeprecated('test-server', 'test_tool', '1.0.0')); - } - - public function test_is_sunset(): void - { - $version = $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); - $version->deprecated_at = Carbon::now()->subDays(60); - $version->sunset_at = Carbon::now()->subDays(30); - $version->save(); - - $this->assertTrue($this->service->isSunset('test-server', 'test_tool', '1.0.0')); - } - - public function test_compare_versions(): void - { - $this->assertSame(-1, $this->service->compareVersions('1.0.0', '2.0.0')); - $this->assertSame(0, $this->service->compareVersions('1.0.0', '1.0.0')); - $this->assertSame(1, $this->service->compareVersions('2.0.0', '1.0.0')); - $this->assertSame(-1, $this->service->compareVersions('1.0.0', '1.0.1')); - $this->assertSame(-1, $this->service->compareVersions('1.0.0', '1.1.0')); - } - - public function test_get_version_history(): void - { - $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); - $this->service->registerVersion('test-server', 'test_tool', '1.1.0'); - $this->service->registerVersion('test-server', 'test_tool', '2.0.0'); - - $history = $this->service->getVersionHistory('test-server', 'test_tool'); - - $this->assertCount(3, $history); - // Should be ordered by version desc - $this->assertSame('2.0.0', $history[0]->version); - $this->assertSame('1.1.0', $history[1]->version); - $this->assertSame('1.0.0', $history[2]->version); - } - - public function test_migrate_tool_call(): void - { - $this->service->registerVersion( - serverId: 'test-server', - toolName: 'test_tool', - version: '1.0.0', - inputSchema: [ - 'type' => 'object', - 'properties' => ['query' => ['type' => 'string']], - 'required' => ['query'], - ] - ); - - $this->service->registerVersion( - serverId: 'test-server', - toolName: 'test_tool', - version: '2.0.0', - inputSchema: [ - 'type' => 'object', - 'properties' => [ - 'query' => ['type' => 'string'], - 'limit' => ['type' => 'integer', 'default' => 10], - ], - 'required' => ['query', 'limit'], - ] - ); - - $result = $this->service->migrateToolCall( - serverId: 'test-server', - toolName: 'test_tool', - fromVersion: '1.0.0', - toVersion: '2.0.0', - arguments: ['query' => 'SELECT * FROM users'] - ); - - $this->assertTrue($result['success']); - $this->assertSame('SELECT * FROM users', $result['arguments']['query']); - $this->assertSame(10, $result['arguments']['limit']); // Default applied - } - - public function test_deprecate_version(): void - { - $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); - - $sunsetDate = Carbon::now()->addDays(30); - $deprecatedVersion = $this->service->deprecateVersion( - 'test-server', - 'test_tool', - '1.0.0', - $sunsetDate - ); - - $this->assertNotNull($deprecatedVersion->deprecated_at); - $this->assertSame($sunsetDate->toDateString(), $deprecatedVersion->sunset_at->toDateString()); - } - - public function test_get_tools_with_versions(): void - { - $this->service->registerVersion('test-server', 'tool_a', '1.0.0'); - $this->service->registerVersion('test-server', 'tool_a', '2.0.0', options: ['mark_latest' => true]); - $this->service->registerVersion('test-server', 'tool_b', '1.0.0'); - - $tools = $this->service->getToolsWithVersions('test-server'); - - $this->assertCount(2, $tools); - $this->assertArrayHasKey('tool_a', $tools); - $this->assertArrayHasKey('tool_b', $tools); - $this->assertSame(2, $tools['tool_a']['version_count']); - $this->assertSame(1, $tools['tool_b']['version_count']); - } - - public function test_get_servers_with_versions(): void - { - $this->service->registerVersion('server-a', 'tool', '1.0.0'); - $this->service->registerVersion('server-b', 'tool', '1.0.0'); - - $servers = $this->service->getServersWithVersions(); - - $this->assertCount(2, $servers); - $this->assertContains('server-a', $servers); - $this->assertContains('server-b', $servers); - } - - public function test_sync_from_server_config(): void - { - $config = [ - 'id' => 'test-server', - 'tools' => [ - [ - 'name' => 'tool_a', - 'description' => 'Tool A', - 'inputSchema' => ['type' => 'object'], - ], - [ - 'name' => 'tool_b', - 'purpose' => 'Tool B purpose', - ], - ], - ]; - - $registered = $this->service->syncFromServerConfig($config, '1.0.0'); - - $this->assertSame(2, $registered); - - $toolA = $this->service->getToolAtVersion('test-server', 'tool_a', '1.0.0'); - $toolB = $this->service->getToolAtVersion('test-server', 'tool_b', '1.0.0'); - - $this->assertNotNull($toolA); - $this->assertNotNull($toolB); - $this->assertSame('Tool A', $toolA->description); - $this->assertSame('Tool B purpose', $toolB->description); - } - - public function test_get_stats(): void - { - $this->service->registerVersion('server-a', 'tool_a', '1.0.0'); - $this->service->registerVersion('server-a', 'tool_a', '2.0.0'); - $this->service->registerVersion('server-b', 'tool_b', '1.0.0'); - - $stats = $this->service->getStats(); - - $this->assertSame(3, $stats['total_versions']); - $this->assertSame(2, $stats['total_tools']); - $this->assertSame(2, $stats['servers']); - } - - public function test_invalid_semver_throws_exception(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid semver version'); - - $this->service->registerVersion('test-server', 'test_tool', 'invalid'); - } - - public function test_valid_semver_formats(): void - { - // Basic versions - $v1 = $this->service->registerVersion('test-server', 'tool', '1.0.0'); - $this->assertSame('1.0.0', $v1->version); - - // Prerelease - $v2 = $this->service->registerVersion('test-server', 'tool', '2.0.0-beta'); - $this->assertSame('2.0.0-beta', $v2->version); - - // Prerelease with dots - $v3 = $this->service->registerVersion('test-server', 'tool', '2.0.0-alpha.1'); - $this->assertSame('2.0.0-alpha.1', $v3->version); - - // Build metadata - $v4 = $this->service->registerVersion('test-server', 'tool', '2.0.0+build.123'); - $this->assertSame('2.0.0+build.123', $v4->version); - } - - public function test_updating_existing_version(): void - { - $original = $this->service->registerVersion( - serverId: 'test-server', - toolName: 'test_tool', - version: '1.0.0', - description: 'Original description' - ); - - $updated = $this->service->registerVersion( - serverId: 'test-server', - toolName: 'test_tool', - version: '1.0.0', - description: 'Updated description' - ); - - $this->assertSame($original->id, $updated->id); - $this->assertSame('Updated description', $updated->description); - } - - public function test_model_compare_schema_with(): void - { - $v1 = $this->service->registerVersion( - serverId: 'test-server', - toolName: 'test_tool', - version: '1.0.0', - inputSchema: [ - 'type' => 'object', - 'properties' => [ - 'query' => ['type' => 'string'], - 'format' => ['type' => 'string'], - ], - ] - ); - - $v2 = $this->service->registerVersion( - serverId: 'test-server', - toolName: 'test_tool', - version: '2.0.0', - inputSchema: [ - 'type' => 'object', - 'properties' => [ - 'query' => ['type' => 'string', 'maxLength' => 1000], // Changed - 'limit' => ['type' => 'integer'], // Added - ], - ] - ); - - $diff = $v1->compareSchemaWith($v2); - - $this->assertContains('limit', $diff['added']); - $this->assertContains('format', $diff['removed']); - $this->assertArrayHasKey('query', $diff['changed']); - } - - public function test_model_mark_as_latest(): void - { - $v1 = $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); - $v2 = $this->service->registerVersion('test-server', 'test_tool', '2.0.0'); - - $v2->markAsLatest(); - - $this->assertFalse($v1->fresh()->is_latest); - $this->assertTrue($v2->fresh()->is_latest); - } - - public function test_model_status_attribute(): void - { - $version = $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); - - $this->assertSame('latest', $version->status); - - $version->is_latest = false; - $version->save(); - $this->assertSame('active', $version->fresh()->status); - - $version->deprecated_at = Carbon::now()->subDay(); - $version->save(); - $this->assertSame('deprecated', $version->fresh()->status); - - $version->sunset_at = Carbon::now()->subDay(); - $version->save(); - $this->assertSame('sunset', $version->fresh()->status); - } - - public function test_model_to_api_array(): void - { - $version = $this->service->registerVersion( - serverId: 'test-server', - toolName: 'test_tool', - version: '1.0.0', - inputSchema: ['type' => 'object'], - description: 'Test tool', - options: ['changelog' => 'Initial release'] - ); - - $array = $version->toApiArray(); - - $this->assertSame('test-server', $array['server_id']); - $this->assertSame('test_tool', $array['tool_name']); - $this->assertSame('1.0.0', $array['version']); - $this->assertTrue($array['is_latest']); - $this->assertSame('latest', $array['status']); - $this->assertSame('Test tool', $array['description']); - $this->assertSame('Initial release', $array['changelog']); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Tests/Unit/ValidateWorkspaceContextMiddlewareTest.php b/packages/core-mcp/src/Mod/Mcp/Tests/Unit/ValidateWorkspaceContextMiddlewareTest.php deleted file mode 100644 index 526c3ff..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Tests/Unit/ValidateWorkspaceContextMiddlewareTest.php +++ /dev/null @@ -1,110 +0,0 @@ -middleware = new ValidateWorkspaceContext; - $this->user = User::factory()->create(); - $this->workspace = Workspace::factory()->create(); - $this->workspace->users()->attach($this->user->id, [ - 'role' => 'owner', - 'is_default' => true, - ]); - }); - - it('sets workspace context when mcp_workspace attribute exists', function () { - $request = Request::create('/api/mcp/tools/call', 'POST'); - $request->attributes->set('mcp_workspace', $this->workspace); - - $contextSet = null; - $response = $this->middleware->handle($request, function ($request) use (&$contextSet) { - $contextSet = $request->attributes->get('mcp_workspace_context'); - - return response()->json(['success' => true]); - }); - - expect($contextSet)->toBeInstanceOf(WorkspaceContext::class); - expect($contextSet->workspaceId)->toBe($this->workspace->id); - expect($response->getStatusCode())->toBe(200); - }); - - it('rejects requests without workspace when mode is required', function () { - $request = Request::create('/api/mcp/tools/call', 'POST'); - $request->headers->set('Accept', 'application/json'); - - $response = $this->middleware->handle($request, function () { - return response()->json(['success' => true]); - }, 'required'); - - expect($response->getStatusCode())->toBe(403); - - $data = json_decode($response->getContent(), true); - expect($data['error'])->toBe('missing_workspace_context'); - }); - - it('allows requests without workspace when mode is optional', function () { - $request = Request::create('/api/mcp/tools/call', 'POST'); - - $response = $this->middleware->handle($request, function ($request) { - $context = $request->attributes->get('mcp_workspace_context'); - - return response()->json(['has_context' => $context !== null]); - }, 'optional'); - - expect($response->getStatusCode())->toBe(200); - - $data = json_decode($response->getContent(), true); - expect($data['has_context'])->toBeFalse(); - }); - - it('extracts workspace from authenticated user', function () { - $request = Request::create('/api/mcp/tools/call', 'POST'); - $request->setUserResolver(fn () => $this->user); - - $contextSet = null; - $response = $this->middleware->handle($request, function ($request) use (&$contextSet) { - $contextSet = $request->attributes->get('mcp_workspace_context'); - - return response()->json(['success' => true]); - }); - - expect($contextSet)->toBeInstanceOf(WorkspaceContext::class); - expect($contextSet->workspaceId)->toBe($this->workspace->id); - }); - - it('defaults to required mode', function () { - $request = Request::create('/api/mcp/tools/call', 'POST'); - $request->headers->set('Accept', 'application/json'); - - $response = $this->middleware->handle($request, function () { - return response()->json(['success' => true]); - }); - - expect($response->getStatusCode())->toBe(403); - }); - - it('returns HTML response for non-API requests', function () { - $request = Request::create('/mcp/tools', 'GET'); - // Not setting Accept: application/json - - $response = $this->middleware->handle($request, function () { - return response()->json(['success' => true]); - }, 'required'); - - expect($response->getStatusCode())->toBe(403); - expect($response->headers->get('Content-Type'))->not->toContain('application/json'); - }); -}); diff --git a/packages/core-mcp/src/Mod/Mcp/Tests/Unit/WorkspaceContextSecurityTest.php b/packages/core-mcp/src/Mod/Mcp/Tests/Unit/WorkspaceContextSecurityTest.php deleted file mode 100644 index b3c1e4f..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Tests/Unit/WorkspaceContextSecurityTest.php +++ /dev/null @@ -1,190 +0,0 @@ -tool)->toBe('ListInvoices'); - expect($exception->getMessage())->toContain('ListInvoices'); - expect($exception->getMessage())->toContain('workspace context'); - }); - - it('creates exception with custom message', function () { - $exception = new MissingWorkspaceContextException('TestTool', 'Custom error message'); - - expect($exception->getMessage())->toBe('Custom error message'); - expect($exception->tool)->toBe('TestTool'); - }); - - it('returns correct status code', function () { - $exception = new MissingWorkspaceContextException('TestTool'); - - expect($exception->getStatusCode())->toBe(403); - }); - - it('returns correct error type', function () { - $exception = new MissingWorkspaceContextException('TestTool'); - - expect($exception->getErrorType())->toBe('missing_workspace_context'); - }); -}); - -describe('WorkspaceContext', function () { - beforeEach(function () { - $this->workspace = Workspace::factory()->create([ - 'name' => 'Test Workspace', - 'slug' => 'test-workspace', - ]); - }); - - it('creates context from workspace model', function () { - $context = WorkspaceContext::fromWorkspace($this->workspace); - - expect($context->workspaceId)->toBe($this->workspace->id); - expect($context->workspace)->toBe($this->workspace); - }); - - it('creates context from workspace ID', function () { - $context = WorkspaceContext::fromId($this->workspace->id); - - expect($context->workspaceId)->toBe($this->workspace->id); - expect($context->workspace)->toBeNull(); - }); - - it('loads workspace when accessing from ID-only context', function () { - $context = WorkspaceContext::fromId($this->workspace->id); - - $loadedWorkspace = $context->getWorkspace(); - - expect($loadedWorkspace->id)->toBe($this->workspace->id); - expect($loadedWorkspace->name)->toBe('Test Workspace'); - }); - - it('validates ownership correctly', function () { - $context = WorkspaceContext::fromWorkspace($this->workspace); - - // Should not throw for matching workspace - $context->validateOwnership($this->workspace->id, 'invoice'); - - expect(true)->toBeTrue(); // If we get here, no exception was thrown - }); - - it('throws on ownership validation failure', function () { - $context = WorkspaceContext::fromWorkspace($this->workspace); - $differentWorkspaceId = $this->workspace->id + 999; - - expect(fn () => $context->validateOwnership($differentWorkspaceId, 'invoice')) - ->toThrow(\RuntimeException::class, 'invoice does not belong to the authenticated workspace'); - }); - - it('checks workspace ID correctly', function () { - $context = WorkspaceContext::fromWorkspace($this->workspace); - - expect($context->hasWorkspaceId($this->workspace->id))->toBeTrue(); - expect($context->hasWorkspaceId($this->workspace->id + 1))->toBeFalse(); - }); -}); - -describe('RequiresWorkspaceContext trait', function () { - beforeEach(function () { - $this->workspace = Workspace::factory()->create(); - $this->tool = new TestToolWithWorkspaceContext; - }); - - it('throws MissingWorkspaceContextException when no context set', function () { - expect(fn () => $this->tool->getWorkspaceId()) - ->toThrow(MissingWorkspaceContextException::class); - }); - - it('returns workspace ID when context is set', function () { - $this->tool->setWorkspace($this->workspace); - - expect($this->tool->getWorkspaceId())->toBe($this->workspace->id); - }); - - it('returns workspace when context is set', function () { - $this->tool->setWorkspace($this->workspace); - - $workspace = $this->tool->getWorkspace(); - - expect($workspace->id)->toBe($this->workspace->id); - }); - - it('allows setting context from workspace ID', function () { - $this->tool->setWorkspaceId($this->workspace->id); - - expect($this->tool->getWorkspaceId())->toBe($this->workspace->id); - }); - - it('allows setting context object directly', function () { - $context = WorkspaceContext::fromWorkspace($this->workspace); - $this->tool->setWorkspaceContext($context); - - expect($this->tool->getWorkspaceId())->toBe($this->workspace->id); - }); - - it('correctly reports whether context is available', function () { - expect($this->tool->hasWorkspaceContext())->toBeFalse(); - - $this->tool->setWorkspace($this->workspace); - - expect($this->tool->hasWorkspaceContext())->toBeTrue(); - }); - - it('validates resource ownership through context', function () { - $this->tool->setWorkspace($this->workspace); - $differentWorkspaceId = $this->workspace->id + 999; - - expect(fn () => $this->tool->validateResourceOwnership($differentWorkspaceId, 'subscription')) - ->toThrow(\RuntimeException::class, 'subscription does not belong'); - }); - - it('requires context with custom error message', function () { - expect(fn () => $this->tool->requireWorkspaceContext('listing invoices')) - ->toThrow(MissingWorkspaceContextException::class, 'listing invoices'); - }); -}); - -describe('Workspace-scoped tool security', function () { - beforeEach(function () { - $this->user = User::factory()->create(); - $this->workspace = Workspace::factory()->create(); - $this->workspace->users()->attach($this->user->id, [ - 'role' => 'owner', - 'is_default' => true, - ]); - - // Create another workspace to test isolation - $this->otherWorkspace = Workspace::factory()->create(); - }); - - it('prevents accessing another workspace data by setting context correctly', function () { - $context = WorkspaceContext::fromWorkspace($this->workspace); - - // Trying to validate ownership of data from another workspace should fail - expect(fn () => $context->validateOwnership($this->otherWorkspace->id, 'data')) - ->toThrow(\RuntimeException::class); - }); -}); diff --git a/packages/core-mcp/src/Mod/Mcp/Tests/UseCase/ApiKeyManagerBasic.php b/packages/core-mcp/src/Mod/Mcp/Tests/UseCase/ApiKeyManagerBasic.php deleted file mode 100644 index 270680c..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Tests/UseCase/ApiKeyManagerBasic.php +++ /dev/null @@ -1,71 +0,0 @@ -user = User::factory()->create([ - 'email' => 'test@example.com', - 'password' => bcrypt('password'), - ]); - - $this->workspace = Workspace::factory()->create(); - $this->workspace->users()->attach($this->user->id, [ - 'role' => 'owner', - 'is_default' => true, - ]); - }); - - it('can view the API key manager page with all sections', function () { - // Login and navigate to MCP keys page - $this->actingAs($this->user); - - $response = $this->get(route('mcp.keys')); - - $response->assertOk(); - - // Verify page title and description - $response->assertSee(__('mcp::mcp.keys.title')); - $response->assertSee(__('mcp::mcp.keys.description')); - - // Verify empty state when no keys exist - $response->assertSee(__('mcp::mcp.keys.empty.title')); - $response->assertSee(__('mcp::mcp.keys.empty.description')); - - // Verify action buttons - $response->assertSee(__('mcp::mcp.keys.actions.create')); - }); - - it('can view the playground page', function () { - $this->actingAs($this->user); - - $response = $this->get(route('mcp.playground')); - - $response->assertOk(); - - // Verify page title and description - $response->assertSee(__('mcp::mcp.playground.title')); - $response->assertSee(__('mcp::mcp.playground.description')); - }); - - it('can view the request log page', function () { - $this->actingAs($this->user); - - $response = $this->get(route('mcp.logs')); - - $response->assertOk(); - - // Verify page title and description - $response->assertSee(__('mcp::mcp.logs.title')); - $response->assertSee(__('mcp::mcp.logs.description')); - }); -}); diff --git a/packages/core-mcp/src/Mod/Mcp/Tools/Commerce/CreateCoupon.php b/packages/core-mcp/src/Mod/Mcp/Tools/Commerce/CreateCoupon.php deleted file mode 100644 index 18b53eb..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Tools/Commerce/CreateCoupon.php +++ /dev/null @@ -1,100 +0,0 @@ -input('code')); - $name = $request->input('name'); - $type = $request->input('type', 'percentage'); - $value = $request->input('value'); - $duration = $request->input('duration', 'once'); - $maxUses = $request->input('max_uses'); - $validUntil = $request->input('valid_until'); - - // Validate code format - if (! preg_match('/^[A-Z0-9_-]+$/', $code)) { - return Response::text(json_encode([ - 'error' => 'Invalid code format. Use only uppercase letters, numbers, hyphens, and underscores.', - ])); - } - - // Check for existing code - if (Coupon::where('code', $code)->exists()) { - return Response::text(json_encode([ - 'error' => 'A coupon with this code already exists.', - ])); - } - - // Validate type - if (! in_array($type, ['percentage', 'fixed_amount'])) { - return Response::text(json_encode([ - 'error' => 'Invalid type. Use percentage or fixed_amount.', - ])); - } - - // Validate value - if ($type === 'percentage' && ($value < 1 || $value > 100)) { - return Response::text(json_encode([ - 'error' => 'Percentage value must be between 1 and 100.', - ])); - } - - try { - $coupon = Coupon::create([ - 'code' => $code, - 'name' => $name, - 'type' => $type, - 'value' => $value, - 'duration' => $duration, - 'max_uses' => $maxUses, - 'max_uses_per_workspace' => 1, - 'valid_until' => $validUntil ? \Carbon\Carbon::parse($validUntil) : null, - 'is_active' => true, - 'applies_to' => 'all', - ]); - - return Response::text(json_encode([ - 'success' => true, - 'coupon' => [ - 'id' => $coupon->id, - 'code' => $coupon->code, - 'name' => $coupon->name, - 'type' => $coupon->type, - 'value' => (float) $coupon->value, - 'duration' => $coupon->duration, - 'max_uses' => $coupon->max_uses, - 'valid_until' => $coupon->valid_until?->toDateString(), - 'is_active' => $coupon->is_active, - ], - ], JSON_PRETTY_PRINT)); - } catch (\Exception $e) { - return Response::text(json_encode([ - 'error' => 'Failed to create coupon: '.$e->getMessage(), - ])); - } - } - - public function schema(JsonSchema $schema): array - { - return [ - 'code' => $schema->string('Unique coupon code (uppercase letters, numbers, hyphens, underscores)')->required(), - 'name' => $schema->string('Display name for the coupon')->required(), - 'type' => $schema->string('Discount type: percentage or fixed_amount (default: percentage)'), - 'value' => $schema->number('Discount value (percentage 1-100 or fixed amount)')->required(), - 'duration' => $schema->string('How long discount applies: once, repeating, or forever (default: once)'), - 'max_uses' => $schema->integer('Maximum total uses (null for unlimited)'), - 'valid_until' => $schema->string('Expiry date in YYYY-MM-DD format'), - ]; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Tools/Commerce/GetBillingStatus.php b/packages/core-mcp/src/Mod/Mcp/Tools/Commerce/GetBillingStatus.php deleted file mode 100644 index d30f037..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Tools/Commerce/GetBillingStatus.php +++ /dev/null @@ -1,77 +0,0 @@ -getWorkspace(); - $workspaceId = $workspace->id; - - // Get active subscription - $subscription = Subscription::with('workspacePackage.package') - ->where('workspace_id', $workspaceId) - ->whereIn('status', ['active', 'trialing', 'past_due']) - ->first(); - - // Get workspace packages - $packages = $workspace->workspacePackages() - ->with('package') - ->whereIn('status', ['active', 'trial']) - ->get(); - - $status = [ - 'workspace' => [ - 'id' => $workspace->id, - 'name' => $workspace->name, - ], - 'subscription' => $subscription ? [ - 'id' => $subscription->id, - 'status' => $subscription->status, - 'gateway' => $subscription->gateway, - 'billing_cycle' => $subscription->billing_cycle, - 'current_period_start' => $subscription->current_period_start?->toIso8601String(), - 'current_period_end' => $subscription->current_period_end?->toIso8601String(), - 'days_until_renewal' => $subscription->daysUntilRenewal(), - 'cancel_at_period_end' => $subscription->cancel_at_period_end, - 'on_trial' => $subscription->onTrial(), - 'trial_ends_at' => $subscription->trial_ends_at?->toIso8601String(), - ] : null, - 'packages' => $packages->map(fn ($wp) => [ - 'code' => $wp->package?->code, - 'name' => $wp->package?->name, - 'status' => $wp->status, - 'expires_at' => $wp->expires_at?->toIso8601String(), - ])->values()->all(), - ]; - - return Response::text(json_encode($status, JSON_PRETTY_PRINT)); - } - - public function schema(JsonSchema $schema): array - { - // No parameters needed - workspace comes from authentication context - return []; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Tools/Commerce/ListInvoices.php b/packages/core-mcp/src/Mod/Mcp/Tools/Commerce/ListInvoices.php deleted file mode 100644 index 3f4282c..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Tools/Commerce/ListInvoices.php +++ /dev/null @@ -1,76 +0,0 @@ -getWorkspaceId(); - - $status = $request->input('status'); // paid, pending, overdue, void - $limit = min($request->input('limit', 10), 50); - - $query = Invoice::with('order') - ->where('workspace_id', $workspaceId) - ->latest(); - - if ($status) { - $query->where('status', $status); - } - - $invoices = $query->limit($limit)->get(); - - $result = [ - 'workspace_id' => $workspaceId, - 'count' => $invoices->count(), - 'invoices' => $invoices->map(fn ($invoice) => [ - 'id' => $invoice->id, - 'invoice_number' => $invoice->invoice_number, - 'status' => $invoice->status, - 'subtotal' => (float) $invoice->subtotal, - 'discount_amount' => (float) $invoice->discount_amount, - 'tax_amount' => (float) $invoice->tax_amount, - 'total' => (float) $invoice->total, - 'amount_paid' => (float) $invoice->amount_paid, - 'amount_due' => (float) $invoice->amount_due, - 'currency' => $invoice->currency, - 'issue_date' => $invoice->issue_date?->toDateString(), - 'due_date' => $invoice->due_date?->toDateString(), - 'paid_at' => $invoice->paid_at?->toIso8601String(), - 'is_overdue' => $invoice->isOverdue(), - 'order_number' => $invoice->order?->order_number, - ])->all(), - ]; - - return Response::text(json_encode($result, JSON_PRETTY_PRINT)); - } - - public function schema(JsonSchema $schema): array - { - return [ - 'status' => $schema->string('Filter by status: paid, pending, overdue, void'), - 'limit' => $schema->integer('Maximum number of invoices to return (default 10, max 50)'), - ]; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Tools/Commerce/UpgradePlan.php b/packages/core-mcp/src/Mod/Mcp/Tools/Commerce/UpgradePlan.php deleted file mode 100644 index 44e57a9..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Tools/Commerce/UpgradePlan.php +++ /dev/null @@ -1,120 +0,0 @@ -getWorkspace(); - $workspaceId = $workspace->id; - - $newPackageCode = $request->input('package_code'); - $preview = $request->input('preview', true); - $immediate = $request->input('immediate', true); - - $newPackage = Package::where('code', $newPackageCode)->first(); - - if (! $newPackage) { - return Response::text(json_encode([ - 'error' => 'Package not found', - 'available_packages' => Package::where('is_active', true) - ->where('is_public', true) - ->pluck('code') - ->all(), - ])); - } - - // Get active subscription - $subscription = Subscription::with('workspacePackage.package') - ->where('workspace_id', $workspaceId) - ->whereIn('status', ['active', 'trialing']) - ->first(); - - if (! $subscription) { - return Response::text(json_encode([ - 'error' => 'No active subscription found for this workspace', - ])); - } - - $subscriptionService = app(SubscriptionService::class); - - try { - if ($preview) { - // Preview the proration - $proration = $subscriptionService->previewPlanChange($subscription, $newPackage); - - return Response::text(json_encode([ - 'preview' => true, - 'current_package' => $subscription->workspacePackage?->package?->code, - 'new_package' => $newPackage->code, - 'proration' => [ - 'is_upgrade' => $proration->isUpgrade(), - 'is_downgrade' => $proration->isDowngrade(), - 'current_plan_price' => $proration->currentPlanPrice, - 'new_plan_price' => $proration->newPlanPrice, - 'credit_amount' => $proration->creditAmount, - 'prorated_new_cost' => $proration->proratedNewPlanCost, - 'net_amount' => $proration->netAmount, - 'requires_payment' => $proration->requiresPayment(), - 'days_remaining' => $proration->daysRemaining, - 'currency' => $proration->currency, - ], - ], JSON_PRETTY_PRINT)); - } - - // Execute the plan change - $result = $subscriptionService->changePlan( - $subscription, - $newPackage, - prorate: true, - immediate: $immediate - ); - - return Response::text(json_encode([ - 'success' => true, - 'immediate' => $result['immediate'], - 'current_package' => $subscription->workspacePackage?->package?->code, - 'new_package' => $newPackage->code, - 'proration' => $result['proration']?->toArray(), - 'subscription_status' => $result['subscription']->status, - ], JSON_PRETTY_PRINT)); - - } catch (\Exception $e) { - return Response::text(json_encode([ - 'error' => $e->getMessage(), - ])); - } - } - - public function schema(JsonSchema $schema): array - { - return [ - 'package_code' => $schema->string('The code of the new package (e.g., agency, enterprise)')->required(), - 'preview' => $schema->boolean('If true, only preview the change without executing (default: true)'), - 'immediate' => $schema->boolean('If true, apply change immediately; if false, schedule for period end (default: true)'), - ]; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Tools/Concerns/RequiresWorkspaceContext.php b/packages/core-mcp/src/Mod/Mcp/Tools/Concerns/RequiresWorkspaceContext.php deleted file mode 100644 index 9b06991..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Tools/Concerns/RequiresWorkspaceContext.php +++ /dev/null @@ -1,135 +0,0 @@ -name - ? $this->name - : class_basename(static::class); - } - - /** - * Get the workspace context, throwing if not available. - * - * @throws MissingWorkspaceContextException - */ - protected function getWorkspaceContext(): WorkspaceContext - { - if ($this->workspaceContext) { - return $this->workspaceContext; - } - - throw new MissingWorkspaceContextException($this->getToolName()); - } - - /** - * Get the workspace ID from context. - * - * @throws MissingWorkspaceContextException - */ - protected function getWorkspaceId(): int - { - return $this->getWorkspaceContext()->workspaceId; - } - - /** - * Get the workspace model from context. - * - * @throws MissingWorkspaceContextException - */ - protected function getWorkspace(): Workspace - { - return $this->getWorkspaceContext()->getWorkspace(); - } - - /** - * Set the workspace context for this tool execution. - */ - public function setWorkspaceContext(WorkspaceContext $context): void - { - $this->workspaceContext = $context; - } - - /** - * Set workspace context from a workspace model. - */ - public function setWorkspace(Workspace $workspace): void - { - $this->workspaceContext = WorkspaceContext::fromWorkspace($workspace); - } - - /** - * Set workspace context from a workspace ID. - */ - public function setWorkspaceId(int $workspaceId): void - { - $this->workspaceContext = WorkspaceContext::fromId($workspaceId); - } - - /** - * Check if workspace context is available. - */ - protected function hasWorkspaceContext(): bool - { - return $this->workspaceContext !== null; - } - - /** - * Validate that a resource belongs to the current workspace. - * - * @throws \RuntimeException If the resource doesn't belong to this workspace - * @throws MissingWorkspaceContextException If no workspace context - */ - protected function validateResourceOwnership(int $resourceWorkspaceId, string $resourceType = 'resource'): void - { - $this->getWorkspaceContext()->validateOwnership($resourceWorkspaceId, $resourceType); - } - - /** - * Require workspace context, throwing with a custom message if not available. - * - * @throws MissingWorkspaceContextException - */ - protected function requireWorkspaceContext(string $operation = 'this operation'): WorkspaceContext - { - if (! $this->workspaceContext) { - throw new MissingWorkspaceContextException( - $this->getToolName(), - sprintf( - "Workspace context is required for %s in tool '%s'. Authenticate with an API key or user session.", - $operation, - $this->getToolName() - ) - ); - } - - return $this->workspaceContext; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Tools/Concerns/ValidatesDependencies.php b/packages/core-mcp/src/Mod/Mcp/Tools/Concerns/ValidatesDependencies.php deleted file mode 100644 index ad92c7e..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Tools/Concerns/ValidatesDependencies.php +++ /dev/null @@ -1,123 +0,0 @@ - - */ - public function dependencies(): array - { - return []; - } - - /** - * Validate that all dependencies are met. - * - * @param array $context The execution context - * @param array $args The tool arguments - * - * @throws MissingDependencyException If dependencies are not met - */ - protected function validateDependencies(array $context = [], array $args = []): void - { - $sessionId = $context['session_id'] ?? 'anonymous'; - - app(ToolDependencyService::class)->validateDependencies( - sessionId: $sessionId, - toolName: $this->name(), - context: $context, - args: $args, - ); - } - - /** - * Check if all dependencies are met without throwing. - * - * @param array $context The execution context - * @param array $args The tool arguments - */ - protected function dependenciesMet(array $context = [], array $args = []): bool - { - $sessionId = $context['session_id'] ?? 'anonymous'; - - return app(ToolDependencyService::class)->checkDependencies( - sessionId: $sessionId, - toolName: $this->name(), - context: $context, - args: $args, - ); - } - - /** - * Get list of unmet dependencies. - * - * @param array $context The execution context - * @param array $args The tool arguments - * @return array - */ - protected function getMissingDependencies(array $context = [], array $args = []): array - { - $sessionId = $context['session_id'] ?? 'anonymous'; - - return app(ToolDependencyService::class)->getMissingDependencies( - sessionId: $sessionId, - toolName: $this->name(), - context: $context, - args: $args, - ); - } - - /** - * Record this tool call for dependency tracking. - * - * @param array $context The execution context - * @param array $args The tool arguments - */ - protected function recordToolCall(array $context = [], array $args = []): void - { - $sessionId = $context['session_id'] ?? 'anonymous'; - - app(ToolDependencyService::class)->recordToolCall( - sessionId: $sessionId, - toolName: $this->name(), - args: $args, - ); - } - - /** - * Create a dependency error response. - */ - protected function dependencyError(MissingDependencyException $e): array - { - return [ - 'error' => 'dependency_not_met', - 'message' => $e->getMessage(), - 'missing' => array_map( - fn (ToolDependency $dep) => [ - 'type' => $dep->type->value, - 'key' => $dep->key, - 'description' => $dep->description, - ], - $e->missingDependencies - ), - 'suggested_order' => $e->suggestedOrder, - ]; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Tools/ContentTools.php b/packages/core-mcp/src/Mod/Mcp/Tools/ContentTools.php deleted file mode 100644 index fd5b69d..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Tools/ContentTools.php +++ /dev/null @@ -1,633 +0,0 @@ -get('action'); - $workspaceSlug = $request->get('workspace'); - - // Resolve workspace - $workspace = $this->resolveWorkspace($workspaceSlug); - if (! $workspace && in_array($action, ['list', 'read', 'create', 'update', 'delete'])) { - return Response::text(json_encode([ - 'error' => 'Workspace is required. Provide a workspace slug.', - ])); - } - - return match ($action) { - 'list' => $this->listContent($workspace, $request), - 'read' => $this->readContent($workspace, $request), - 'create' => $this->createContent($workspace, $request), - 'update' => $this->updateContent($workspace, $request), - 'delete' => $this->deleteContent($workspace, $request), - 'taxonomies' => $this->listTaxonomies($workspace, $request), - default => Response::text(json_encode([ - 'error' => 'Invalid action. Available: list, read, create, update, delete, taxonomies', - ])), - }; - } - - /** - * Resolve workspace from slug. - */ - protected function resolveWorkspace(?string $slug): ?Workspace - { - if (! $slug) { - return null; - } - - return Workspace::where('slug', $slug) - ->orWhere('id', $slug) - ->first(); - } - - /** - * Check entitlements for content operations. - */ - protected function checkEntitlement(Workspace $workspace, string $action): ?array - { - $entitlements = app(EntitlementService::class); - - // Check if workspace has content MCP access - $result = $entitlements->can($workspace, 'content.mcp_access'); - - if ($result->isDenied()) { - return ['error' => $result->reason ?? 'Content MCP access not available in your plan.']; - } - - // For create operations, check content limits - if ($action === 'create') { - $limitResult = $entitlements->can($workspace, 'content.items'); - if ($limitResult->isDenied()) { - return ['error' => $limitResult->reason ?? 'Content item limit reached.']; - } - } - - return null; - } - - /** - * List content items for a workspace. - */ - protected function listContent(Workspace $workspace, Request $request): Response - { - $query = ContentItem::forWorkspace($workspace->id) - ->native() - ->with(['author', 'taxonomies']); - - // Filter by type (post/page) - if ($type = $request->get('type')) { - $query->where('type', $type); - } - - // Filter by status - if ($status = $request->get('status')) { - if ($status === 'published') { - $query->published(); - } elseif ($status === 'scheduled') { - $query->scheduled(); - } else { - $query->where('status', $status); - } - } - - // Search - if ($search = $request->get('search')) { - $query->where(function ($q) use ($search) { - $q->where('title', 'like', "%{$search}%") - ->orWhere('content_html', 'like', "%{$search}%") - ->orWhere('excerpt', 'like', "%{$search}%"); - }); - } - - // Pagination - $limit = min($request->get('limit', 20), 100); - $offset = $request->get('offset', 0); - - $total = $query->count(); - $items = $query->orderByDesc('updated_at') - ->skip($offset) - ->take($limit) - ->get(); - - $result = [ - 'items' => $items->map(fn (ContentItem $item) => [ - 'id' => $item->id, - 'slug' => $item->slug, - 'title' => $item->title, - 'type' => $item->type, - 'status' => $item->status, - 'excerpt' => Str::limit($item->excerpt, 200), - 'author' => $item->author?->name, - 'categories' => $item->categories->pluck('name')->all(), - 'tags' => $item->tags->pluck('name')->all(), - 'word_count' => str_word_count(strip_tags($item->content_html ?? '')), - 'publish_at' => $item->publish_at?->toIso8601String(), - 'created_at' => $item->created_at->toIso8601String(), - 'updated_at' => $item->updated_at->toIso8601String(), - ]), - 'total' => $total, - 'limit' => $limit, - 'offset' => $offset, - ]; - - return Response::text(json_encode($result, JSON_PRETTY_PRINT)); - } - - /** - * Read full content of an item. - */ - protected function readContent(Workspace $workspace, Request $request): Response - { - $identifier = $request->get('identifier'); - - if (! $identifier) { - return Response::text(json_encode(['error' => 'identifier (slug or ID) is required'])); - } - - $query = ContentItem::forWorkspace($workspace->id)->native(); - - // Find by ID, slug, or wp_id - if (is_numeric($identifier)) { - $item = $query->where('id', $identifier) - ->orWhere('wp_id', $identifier) - ->first(); - } else { - $item = $query->where('slug', $identifier)->first(); - } - - if (! $item) { - return Response::text(json_encode(['error' => 'Content not found'])); - } - - // Load relationships - $item->load(['author', 'taxonomies', 'revisions' => fn ($q) => $q->latest()->limit(5)]); - - // Return as markdown with frontmatter for AI context - $format = $request->get('format', 'json'); - - if ($format === 'markdown') { - $markdown = $this->contentToMarkdown($item); - - return Response::text($markdown); - } - - $result = [ - 'id' => $item->id, - 'slug' => $item->slug, - 'title' => $item->title, - 'type' => $item->type, - 'status' => $item->status, - 'excerpt' => $item->excerpt, - 'content_html' => $item->content_html, - 'content_markdown' => $item->content_markdown, - 'author' => [ - 'id' => $item->author?->id, - 'name' => $item->author?->name, - ], - 'categories' => $item->categories->map(fn ($t) => [ - 'id' => $t->id, - 'slug' => $t->slug, - 'name' => $t->name, - ])->all(), - 'tags' => $item->tags->map(fn ($t) => [ - 'id' => $t->id, - 'slug' => $t->slug, - 'name' => $t->name, - ])->all(), - 'seo_meta' => $item->seo_meta, - 'publish_at' => $item->publish_at?->toIso8601String(), - 'revision_count' => $item->revision_count, - 'recent_revisions' => $item->revisions->map(fn ($r) => [ - 'id' => $r->id, - 'revision_number' => $r->revision_number, - 'change_type' => $r->change_type, - 'created_at' => $r->created_at->toIso8601String(), - ])->all(), - 'created_at' => $item->created_at->toIso8601String(), - 'updated_at' => $item->updated_at->toIso8601String(), - ]; - - return Response::text(json_encode($result, JSON_PRETTY_PRINT)); - } - - /** - * Create new content. - */ - protected function createContent(Workspace $workspace, Request $request): Response - { - // Check entitlements - $entitlementError = $this->checkEntitlement($workspace, 'create'); - if ($entitlementError) { - return Response::text(json_encode($entitlementError)); - } - - // Validate required fields - $title = $request->get('title'); - if (! $title) { - return Response::text(json_encode(['error' => 'title is required'])); - } - - $type = $request->get('type', 'post'); - if (! in_array($type, ['post', 'page'])) { - return Response::text(json_encode(['error' => 'type must be post or page'])); - } - - $status = $request->get('status', 'draft'); - if (! in_array($status, ['draft', 'publish', 'future', 'private'])) { - return Response::text(json_encode(['error' => 'status must be draft, publish, future, or private'])); - } - - // Generate slug - $slug = $request->get('slug') ?: Str::slug($title); - $baseSlug = $slug; - $counter = 1; - - // Ensure unique slug within workspace - while (ContentItem::forWorkspace($workspace->id)->where('slug', $slug)->exists()) { - $slug = $baseSlug.'-'.$counter++; - } - - // Parse markdown content if provided - $content = $request->get('content', ''); - $contentHtml = $request->get('content_html'); - $contentMarkdown = $request->get('content_markdown', $content); - - // Convert markdown to HTML if only markdown provided - if ($contentMarkdown && ! $contentHtml) { - $contentHtml = Str::markdown($contentMarkdown); - } - - // Handle scheduling - $publishAt = null; - if ($status === 'future') { - $publishAt = $request->get('publish_at'); - if (! $publishAt) { - return Response::text(json_encode(['error' => 'publish_at is required for scheduled content'])); - } - $publishAt = \Carbon\Carbon::parse($publishAt); - } - - // Create content item - $item = ContentItem::create([ - 'workspace_id' => $workspace->id, - 'content_type' => ContentType::NATIVE, - 'type' => $type, - 'status' => $status, - 'slug' => $slug, - 'title' => $title, - 'excerpt' => $request->get('excerpt'), - 'content_html' => $contentHtml, - 'content_markdown' => $contentMarkdown, - 'seo_meta' => $request->get('seo_meta'), - 'publish_at' => $publishAt, - 'last_edited_by' => Auth::id(), - ]); - - // Handle categories - if ($categories = $request->get('categories')) { - $categoryIds = $this->resolveOrCreateTaxonomies($workspace, $categories, 'category'); - $item->taxonomies()->attach($categoryIds); - } - - // Handle tags - if ($tags = $request->get('tags')) { - $tagIds = $this->resolveOrCreateTaxonomies($workspace, $tags, 'tag'); - $item->taxonomies()->attach($tagIds); - } - - // Create initial revision - $item->createRevision(Auth::user(), ContentRevision::CHANGE_EDIT, 'Created via MCP'); - - // Record usage - $entitlements = app(EntitlementService::class); - $entitlements->recordUsage($workspace, 'content.items', 1, Auth::user(), [ - 'source' => 'mcp', - 'content_id' => $item->id, - ]); - - return Response::text(json_encode([ - 'ok' => true, - 'item' => [ - 'id' => $item->id, - 'slug' => $item->slug, - 'title' => $item->title, - 'type' => $item->type, - 'status' => $item->status, - 'url' => $this->getContentUrl($workspace, $item), - ], - ], JSON_PRETTY_PRINT)); - } - - /** - * Update existing content. - */ - protected function updateContent(Workspace $workspace, Request $request): Response - { - $identifier = $request->get('identifier'); - - if (! $identifier) { - return Response::text(json_encode(['error' => 'identifier (slug or ID) is required'])); - } - - $query = ContentItem::forWorkspace($workspace->id)->native(); - - if (is_numeric($identifier)) { - $item = $query->find($identifier); - } else { - $item = $query->where('slug', $identifier)->first(); - } - - if (! $item) { - return Response::text(json_encode(['error' => 'Content not found'])); - } - - // Build update data - $updateData = []; - - if ($request->has('title')) { - $updateData['title'] = $request->get('title'); - } - - if ($request->has('excerpt')) { - $updateData['excerpt'] = $request->get('excerpt'); - } - - if ($request->has('content') || $request->has('content_markdown')) { - $contentMarkdown = $request->get('content_markdown') ?? $request->get('content'); - $updateData['content_markdown'] = $contentMarkdown; - $updateData['content_html'] = $request->get('content_html') ?? Str::markdown($contentMarkdown); - } - - if ($request->has('content_html') && ! $request->has('content_markdown')) { - $updateData['content_html'] = $request->get('content_html'); - } - - if ($request->has('status')) { - $status = $request->get('status'); - if (! in_array($status, ['draft', 'publish', 'future', 'private'])) { - return Response::text(json_encode(['error' => 'status must be draft, publish, future, or private'])); - } - $updateData['status'] = $status; - - if ($status === 'future' && $request->has('publish_at')) { - $updateData['publish_at'] = \Carbon\Carbon::parse($request->get('publish_at')); - } - } - - if ($request->has('seo_meta')) { - $updateData['seo_meta'] = $request->get('seo_meta'); - } - - if ($request->has('slug')) { - $newSlug = $request->get('slug'); - if ($newSlug !== $item->slug) { - // Check uniqueness - if (ContentItem::forWorkspace($workspace->id)->where('slug', $newSlug)->where('id', '!=', $item->id)->exists()) { - return Response::text(json_encode(['error' => 'Slug already exists'])); - } - $updateData['slug'] = $newSlug; - } - } - - $updateData['last_edited_by'] = Auth::id(); - - // Update item - $item->update($updateData); - - // Handle categories - if ($request->has('categories')) { - $categoryIds = $this->resolveOrCreateTaxonomies($workspace, $request->get('categories'), 'category'); - $item->categories()->sync($categoryIds); - } - - // Handle tags - if ($request->has('tags')) { - $tagIds = $this->resolveOrCreateTaxonomies($workspace, $request->get('tags'), 'tag'); - $item->tags()->sync($tagIds); - } - - // Create revision - $changeSummary = $request->get('change_summary', 'Updated via MCP'); - $item->createRevision(Auth::user(), ContentRevision::CHANGE_EDIT, $changeSummary); - - $item->refresh()->load(['author', 'taxonomies']); - - return Response::text(json_encode([ - 'ok' => true, - 'item' => [ - 'id' => $item->id, - 'slug' => $item->slug, - 'title' => $item->title, - 'type' => $item->type, - 'status' => $item->status, - 'revision_count' => $item->revision_count, - 'url' => $this->getContentUrl($workspace, $item), - ], - ], JSON_PRETTY_PRINT)); - } - - /** - * Delete content (soft delete). - */ - protected function deleteContent(Workspace $workspace, Request $request): Response - { - $identifier = $request->get('identifier'); - - if (! $identifier) { - return Response::text(json_encode(['error' => 'identifier (slug or ID) is required'])); - } - - $query = ContentItem::forWorkspace($workspace->id)->native(); - - if (is_numeric($identifier)) { - $item = $query->find($identifier); - } else { - $item = $query->where('slug', $identifier)->first(); - } - - if (! $item) { - return Response::text(json_encode(['error' => 'Content not found'])); - } - - // Create final revision before delete - $item->createRevision(Auth::user(), ContentRevision::CHANGE_EDIT, 'Deleted via MCP'); - - // Soft delete - $item->delete(); - - return Response::text(json_encode([ - 'ok' => true, - 'deleted' => [ - 'id' => $item->id, - 'slug' => $item->slug, - 'title' => $item->title, - ], - ], JSON_PRETTY_PRINT)); - } - - /** - * List taxonomies (categories and tags). - */ - protected function listTaxonomies(Workspace $workspace, Request $request): Response - { - $type = $request->get('type'); // category or tag - - $query = ContentTaxonomy::where('workspace_id', $workspace->id); - - if ($type) { - $query->where('type', $type); - } - - $taxonomies = $query->orderBy('name')->get(); - - $result = [ - 'taxonomies' => $taxonomies->map(fn ($t) => [ - 'id' => $t->id, - 'type' => $t->type, - 'slug' => $t->slug, - 'name' => $t->name, - 'description' => $t->description, - ])->all(), - 'total' => $taxonomies->count(), - ]; - - return Response::text(json_encode($result, JSON_PRETTY_PRINT)); - } - - /** - * Resolve or create taxonomies from slugs/names. - */ - protected function resolveOrCreateTaxonomies(Workspace $workspace, array $items, string $type): array - { - $ids = []; - - foreach ($items as $item) { - $taxonomy = ContentTaxonomy::where('workspace_id', $workspace->id) - ->where('type', $type) - ->where(function ($q) use ($item) { - $q->where('slug', $item) - ->orWhere('name', $item); - }) - ->first(); - - if (! $taxonomy) { - // Create new taxonomy - $taxonomy = ContentTaxonomy::create([ - 'workspace_id' => $workspace->id, - 'type' => $type, - 'slug' => Str::slug($item), - 'name' => $item, - ]); - } - - $ids[] = $taxonomy->id; - } - - return $ids; - } - - /** - * Convert content item to markdown with frontmatter. - */ - protected function contentToMarkdown(ContentItem $item): string - { - $frontmatter = [ - 'title' => $item->title, - 'slug' => $item->slug, - 'type' => $item->type, - 'status' => $item->status, - 'author' => $item->author?->name, - 'categories' => $item->categories->pluck('name')->all(), - 'tags' => $item->tags->pluck('name')->all(), - 'created_at' => $item->created_at->toIso8601String(), - 'updated_at' => $item->updated_at->toIso8601String(), - ]; - - if ($item->publish_at) { - $frontmatter['publish_at'] = $item->publish_at->toIso8601String(); - } - - if ($item->seo_meta) { - $frontmatter['seo'] = $item->seo_meta; - } - - $yaml = "---\n"; - foreach ($frontmatter as $key => $value) { - if (is_array($value)) { - $yaml .= "{$key}: ".json_encode($value)."\n"; - } else { - $yaml .= "{$key}: {$value}\n"; - } - } - $yaml .= "---\n\n"; - - // Prefer markdown content, fall back to stripping HTML - $content = $item->content_markdown ?? strip_tags($item->content_html ?? ''); - - return $yaml.$content; - } - - /** - * Get the public URL for content. - */ - protected function getContentUrl(Workspace $workspace, ContentItem $item): string - { - $domain = $workspace->domain ?? config('app.url'); - $path = $item->type === 'post' ? "/blog/{$item->slug}" : "/{$item->slug}"; - - return "https://{$domain}{$path}"; - } - - public function schema(JsonSchema $schema): array - { - return [ - 'action' => $schema->string('Action: list, read, create, update, delete, taxonomies'), - 'workspace' => $schema->string('Workspace slug (required for most actions)')->nullable(), - 'identifier' => $schema->string('Content slug or ID (for read, update, delete)')->nullable(), - 'type' => $schema->string('Content type: post or page (for list filter or create)')->nullable(), - 'status' => $schema->string('Content status: draft, publish, future, private')->nullable(), - 'search' => $schema->string('Search term for list action')->nullable(), - 'limit' => $schema->integer('Max items to return (default 20, max 100)')->nullable(), - 'offset' => $schema->integer('Offset for pagination')->nullable(), - 'format' => $schema->string('Output format: json or markdown (for read action)')->nullable(), - 'title' => $schema->string('Content title (for create/update)')->nullable(), - 'slug' => $schema->string('URL slug (for create/update)')->nullable(), - 'excerpt' => $schema->string('Content excerpt/summary')->nullable(), - 'content' => $schema->string('Content body as markdown (for create/update)')->nullable(), - 'content_html' => $schema->string('Content body as HTML (optional, auto-generated from markdown)')->nullable(), - 'content_markdown' => $schema->string('Content body as markdown (alias for content)')->nullable(), - 'categories' => $schema->array('Array of category slugs or names')->nullable(), - 'tags' => $schema->array('Array of tag strings')->nullable(), - 'seo_meta' => $schema->array('SEO metadata: {title, description, keywords}')->nullable(), - 'publish_at' => $schema->string('ISO datetime for scheduled publishing (status=future)')->nullable(), - 'change_summary' => $schema->string('Summary of changes for revision history (update action)')->nullable(), - ]; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Tools/GetStats.php b/packages/core-mcp/src/Mod/Mcp/Tools/GetStats.php deleted file mode 100644 index 16ee6e3..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Tools/GetStats.php +++ /dev/null @@ -1,30 +0,0 @@ - 6, - 'active_users' => 128, - 'page_views_30d' => 12500, - 'server_load' => '23%', - ]; - - return Response::text(json_encode($stats, JSON_PRETTY_PRINT)); - } - - public function schema(JsonSchema $schema): array - { - return []; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Tools/ListRoutes.php b/packages/core-mcp/src/Mod/Mcp/Tools/ListRoutes.php deleted file mode 100644 index bdb9230..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Tools/ListRoutes.php +++ /dev/null @@ -1,32 +0,0 @@ -getRoutes()) - ->map(fn ($route) => [ - 'uri' => $route->uri(), - 'methods' => $route->methods(), - 'name' => $route->getName(), - ]) - ->values() - ->toArray(); - - return Response::text(json_encode($routes, JSON_PRETTY_PRINT)); - } - - public function schema(JsonSchema $schema): array - { - return []; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Tools/ListSites.php b/packages/core-mcp/src/Mod/Mcp/Tools/ListSites.php deleted file mode 100644 index 7ce9a9f..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Tools/ListSites.php +++ /dev/null @@ -1,32 +0,0 @@ - 'BioHost', 'domain' => 'link.host.uk.com', 'type' => 'WordPress'], - ['name' => 'SocialHost', 'domain' => 'social.host.uk.com', 'type' => 'Laravel'], - ['name' => 'AnalyticsHost', 'domain' => 'analytics.host.uk.com', 'type' => 'Node.js'], - ['name' => 'TrustHost', 'domain' => 'trust.host.uk.com', 'type' => 'WordPress'], - ['name' => 'NotifyHost', 'domain' => 'notify.host.uk.com', 'type' => 'Go'], - ['name' => 'MailHost', 'domain' => 'hostmail.cc', 'type' => 'MailCow'], - ]; - - return Response::text(json_encode($sites, JSON_PRETTY_PRINT)); - } - - public function schema(JsonSchema $schema): array - { - return []; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Tools/ListTables.php b/packages/core-mcp/src/Mod/Mcp/Tools/ListTables.php deleted file mode 100644 index f6255ea..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Tools/ListTables.php +++ /dev/null @@ -1,28 +0,0 @@ -map(fn ($table) => array_values((array) $table)[0]) - ->toArray(); - - return Response::text(json_encode($tables, JSON_PRETTY_PRINT)); - } - - public function schema(JsonSchema $schema): array - { - return []; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Tools/QueryDatabase.php b/packages/core-mcp/src/Mod/Mcp/Tools/QueryDatabase.php deleted file mode 100644 index 164199e..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Tools/QueryDatabase.php +++ /dev/null @@ -1,281 +0,0 @@ -validator = $this->createValidator(); - } - - public function handle(Request $request): Response - { - $query = $request->input('query'); - $explain = $request->input('explain', false); - - if (empty($query)) { - return $this->errorResponse('Query is required'); - } - - // Validate the query - try { - $this->validator->validate($query); - } catch (ForbiddenQueryException $e) { - return $this->errorResponse($e->getMessage()); - } - - // Check for blocked tables - $blockedTable = $this->checkBlockedTables($query); - if ($blockedTable !== null) { - return $this->errorResponse( - sprintf("Access to table '%s' is not permitted", $blockedTable) - ); - } - - // Apply row limit if not present - $query = $this->applyRowLimit($query); - - try { - $connection = $this->getConnection(); - - // If explain is requested, run EXPLAIN first - if ($explain) { - return $this->handleExplain($connection, $query); - } - - $results = DB::connection($connection)->select($query); - - return Response::text(json_encode($results, JSON_PRETTY_PRINT)); - } catch (\Exception $e) { - // Log the actual error for debugging but return sanitised message - report($e); - - return $this->errorResponse('Query execution failed: '.$this->sanitiseErrorMessage($e->getMessage())); - } - } - - public function schema(JsonSchema $schema): array - { - return [ - 'query' => $schema->string('SQL SELECT query to execute. Only read-only SELECT queries are permitted.'), - 'explain' => $schema->boolean('If true, runs EXPLAIN on the query instead of executing it. Useful for query optimization and debugging.')->default(false), - ]; - } - - /** - * Create the SQL validator with configuration. - */ - private function createValidator(): SqlQueryValidator - { - $useWhitelist = Config::get('mcp.database.use_whitelist', true); - $customPatterns = Config::get('mcp.database.whitelist_patterns', []); - - $validator = new SqlQueryValidator(null, $useWhitelist); - - foreach ($customPatterns as $pattern) { - $validator->addWhitelistPattern($pattern); - } - - return $validator; - } - - /** - * Get the database connection to use. - * - * @throws \RuntimeException If the configured connection is invalid - */ - private function getConnection(): ?string - { - $connection = Config::get('mcp.database.connection'); - - // If configured connection doesn't exist, throw exception - if ($connection && ! Config::has("database.connections.{$connection}")) { - throw new \RuntimeException( - "Invalid MCP database connection '{$connection}' configured. ". - "Please ensure 'database.connections.{$connection}' exists in your database configuration." - ); - } - - return $connection; - } - - /** - * Check if the query references any blocked tables. - */ - private function checkBlockedTables(string $query): ?string - { - $blockedTables = Config::get('mcp.database.blocked_tables', []); - - foreach ($blockedTables as $table) { - // Check for table references in various formats - $patterns = [ - '/\bFROM\s+`?'.preg_quote($table, '/').'`?\b/i', - '/\bJOIN\s+`?'.preg_quote($table, '/').'`?\b/i', - '/\b'.preg_quote($table, '/').'\./i', // table.column format - ]; - - foreach ($patterns as $pattern) { - if (preg_match($pattern, $query)) { - return $table; - } - } - } - - return null; - } - - /** - * Apply row limit to query if not already present. - */ - private function applyRowLimit(string $query): string - { - $maxRows = Config::get('mcp.database.max_rows', 1000); - - // Check if LIMIT is already present - if (preg_match('/\bLIMIT\s+\d+/i', $query)) { - return $query; - } - - // Remove trailing semicolon if present - $query = rtrim(trim($query), ';'); - - return $query.' LIMIT '.$maxRows; - } - - /** - * Sanitise database error messages to avoid leaking sensitive information. - */ - private function sanitiseErrorMessage(string $message): string - { - // Remove specific database paths, credentials, etc. - $message = preg_replace('/\/[^\s]+/', '[path]', $message); - $message = preg_replace('/at \d+\.\d+\.\d+\.\d+/', 'at [ip]', $message); - - // Truncate long messages - if (strlen($message) > 200) { - $message = substr($message, 0, 200).'...'; - } - - return $message; - } - - /** - * Handle EXPLAIN query execution. - */ - private function handleExplain(?string $connection, string $query): Response - { - try { - // Run EXPLAIN on the query - $explainResults = DB::connection($connection)->select("EXPLAIN {$query}"); - - // Also try to get extended information if MySQL/MariaDB - $warnings = []; - try { - $warnings = DB::connection($connection)->select('SHOW WARNINGS'); - } catch (\Exception $e) { - // SHOW WARNINGS may not be available on all databases - } - - $response = [ - 'explain' => $explainResults, - 'query' => $query, - ]; - - if (! empty($warnings)) { - $response['warnings'] = $warnings; - } - - // Add helpful interpretation - $response['interpretation'] = $this->interpretExplain($explainResults); - - return Response::text(json_encode($response, JSON_PRETTY_PRINT)); - } catch (\Exception $e) { - report($e); - - return $this->errorResponse('EXPLAIN failed: '.$this->sanitiseErrorMessage($e->getMessage())); - } - } - - /** - * Provide human-readable interpretation of EXPLAIN results. - */ - private function interpretExplain(array $explainResults): array - { - $interpretation = []; - - foreach ($explainResults as $row) { - $rowAnalysis = []; - - // Convert stdClass to array for easier access - $rowArray = (array) $row; - - // Check for full table scan - if (isset($rowArray['type']) && $rowArray['type'] === 'ALL') { - $rowAnalysis[] = 'WARNING: Full table scan detected. Consider adding an index.'; - } - - // Check for filesort - if (isset($rowArray['Extra']) && str_contains($rowArray['Extra'], 'Using filesort')) { - $rowAnalysis[] = 'INFO: Using filesort. Query may benefit from an index on ORDER BY columns.'; - } - - // Check for temporary table - if (isset($rowArray['Extra']) && str_contains($rowArray['Extra'], 'Using temporary')) { - $rowAnalysis[] = 'INFO: Using temporary table. Consider optimizing the query.'; - } - - // Check rows examined - if (isset($rowArray['rows']) && $rowArray['rows'] > 10000) { - $rowAnalysis[] = sprintf('WARNING: High row count (%d rows). Query may be slow.', $rowArray['rows']); - } - - // Check if index is used - if (isset($rowArray['key']) && $rowArray['key'] !== null) { - $rowAnalysis[] = sprintf('GOOD: Using index: %s', $rowArray['key']); - } - - if (! empty($rowAnalysis)) { - $interpretation[] = [ - 'table' => $rowArray['table'] ?? 'unknown', - 'analysis' => $rowAnalysis, - ]; - } - } - - return $interpretation; - } - - /** - * Create an error response. - */ - private function errorResponse(string $message): Response - { - return Response::text(json_encode(['error' => $message])); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/analytics/dashboard.blade.php b/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/analytics/dashboard.blade.php deleted file mode 100644 index 10a44b0..0000000 --- a/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/analytics/dashboard.blade.php +++ /dev/null @@ -1,233 +0,0 @@ -
- -
-
- Tool Usage Analytics - Monitor MCP tool usage patterns, performance, and errors -
-
- - 7 Days - 14 Days - 30 Days - - Refresh -
-
- - -
- @include('mcp::admin.analytics.partials.stats-card', [ - 'label' => 'Total Calls', - 'value' => number_format($this->overview['total_calls']), - 'color' => 'default', - ]) - - @include('mcp::admin.analytics.partials.stats-card', [ - 'label' => 'Error Rate', - 'value' => $this->overview['error_rate'] . '%', - 'color' => $this->overview['error_rate'] > 10 ? 'red' : ($this->overview['error_rate'] > 5 ? 'yellow' : 'green'), - ]) - - @include('mcp::admin.analytics.partials.stats-card', [ - 'label' => 'Avg Response', - 'value' => $this->formatDuration($this->overview['avg_duration_ms']), - 'color' => $this->overview['avg_duration_ms'] > 5000 ? 'yellow' : 'default', - ]) - - @include('mcp::admin.analytics.partials.stats-card', [ - 'label' => 'Total Errors', - 'value' => number_format($this->overview['total_errors']), - 'color' => $this->overview['total_errors'] > 0 ? 'red' : 'default', - ]) - - @include('mcp::admin.analytics.partials.stats-card', [ - 'label' => 'Unique Tools', - 'value' => $this->overview['unique_tools'], - 'color' => 'default', - ]) -
- - -
- -
- - @if($tab === 'overview') -
- -
-
- Top 10 Most Used Tools -
-
- @if($this->popularTools->isEmpty()) -
No tool usage data available
- @else -
- @php $maxCalls = $this->popularTools->first()->totalCalls ?: 1; @endphp - @foreach($this->popularTools as $tool) -
-
- {{ $tool->toolName }} -
-
-
-
-
-
-
-
- {{ number_format($tool->totalCalls) }} -
-
- {{ $tool->errorRate }}% -
-
- @endforeach -
- @endif -
-
- - -
-
- Tools with Highest Error Rates -
-
- @if($this->errorProneTools->isEmpty()) -
All tools are healthy - no significant errors
- @else -
- @foreach($this->errorProneTools as $tool) -
-
- - {{ $tool->toolName }} - -
- {{ number_format($tool->errorCount) }} errors / {{ number_format($tool->totalCalls) }} calls -
-
- - {{ $tool->errorRate }}% errors - -
- @endforeach -
- @endif -
-
-
- @endif - - @if($tab === 'tools') - -
-
- All Tools - {{ $this->sortedTools->count() }} tools -
-
- @include('mcp::admin.analytics.partials.tool-table', ['tools' => $this->sortedTools]) -
-
- @endif - - @if($tab === 'errors') - -
-
- Error Analysis -
-
- @if($this->errorProneTools->isEmpty()) -
-
- All tools are healthy - no significant errors detected -
- @else -
- @foreach($this->errorProneTools as $tool) -
-
- - {{ $tool->toolName }} - - - {{ $tool->errorRate }}% Error Rate - -
-
-
- Total Calls: - {{ number_format($tool->totalCalls) }} -
-
- Errors: - {{ number_format($tool->errorCount) }} -
-
- Avg Duration: - {{ $this->formatDuration($tool->avgDurationMs) }} -
-
- Max Duration: - {{ $this->formatDuration($tool->maxDurationMs) }} -
-
-
- @endforeach -
- @endif -
-
- @endif - - @if($tab === 'combinations') - -
-
- Popular Tool Combinations - Tools frequently used together in the same session -
-
- @if($this->toolCombinations->isEmpty()) -
No tool combination data available yet
- @else -
- @foreach($this->toolCombinations as $combo) -
-
- {{ $combo['tool_a'] }} - + - {{ $combo['tool_b'] }} -
- - {{ number_format($combo['occurrences']) }} times - -
- @endforeach -
- @endif -
-
- @endif -
diff --git a/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/analytics/partials/stats-card.blade.php b/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/analytics/partials/stats-card.blade.php deleted file mode 100644 index c873cf3..0000000 --- a/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/analytics/partials/stats-card.blade.php +++ /dev/null @@ -1,32 +0,0 @@ -@props([ - 'label', - 'value', - 'color' => 'default', - 'subtext' => null, -]) - -@php - $colorClasses = match($color) { - 'red' => 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800', - 'yellow' => 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800', - 'green' => 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800', - 'blue' => 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800', - default => 'bg-white dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700', - }; - - $valueClasses = match($color) { - 'red' => 'text-red-600 dark:text-red-400', - 'yellow' => 'text-yellow-600 dark:text-yellow-400', - 'green' => 'text-green-600 dark:text-green-400', - 'blue' => 'text-blue-600 dark:text-blue-400', - default => '', - }; -@endphp - -
- {{ $label }} - {{ $value }} - @if($subtext) - {{ $subtext }} - @endif -
diff --git a/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/analytics/partials/tool-table.blade.php b/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/analytics/partials/tool-table.blade.php deleted file mode 100644 index a03c517..0000000 --- a/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/analytics/partials/tool-table.blade.php +++ /dev/null @@ -1,100 +0,0 @@ -@props(['tools']) - - - - - - - - - - - - - - - @forelse($tools as $tool) - - - - - - - - - - @empty - - - - @endforelse - -
-
- Tool Name - @if($sortColumn === 'toolName') - {{ $sortDirection === 'asc' ? '▲' : '▼' }} - @endif -
-
-
- Total Calls - @if($sortColumn === 'totalCalls') - {{ $sortDirection === 'asc' ? '▲' : '▼' }} - @endif -
-
-
- Errors - @if($sortColumn === 'errorCount') - {{ $sortDirection === 'asc' ? '▲' : '▼' }} - @endif -
-
-
- Error Rate - @if($sortColumn === 'errorRate') - {{ $sortDirection === 'asc' ? '▲' : '▼' }} - @endif -
-
-
- Avg Duration - @if($sortColumn === 'avgDurationMs') - {{ $sortDirection === 'asc' ? '▲' : '▼' }} - @endif -
-
- Min / Max - - Actions -
- - {{ $tool->toolName }} - - - {{ number_format($tool->totalCalls) }} - - {{ number_format($tool->errorCount) }} - - - {{ $tool->errorRate }}% - - - {{ $this->formatDuration($tool->avgDurationMs) }} - - {{ $this->formatDuration($tool->minDurationMs) }} / {{ $this->formatDuration($tool->maxDurationMs) }} - - - View Details - -
- No tool usage data available -
diff --git a/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/analytics/tool-detail.blade.php b/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/analytics/tool-detail.blade.php deleted file mode 100644 index 3166aaa..0000000 --- a/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/analytics/tool-detail.blade.php +++ /dev/null @@ -1,183 +0,0 @@ -
- -
-
- - {{ $toolName }} - Detailed usage analytics for this tool -
-
- - 7 Days - 14 Days - 30 Days - - Refresh -
-
- - -
-
- Total Calls - {{ number_format($this->stats->totalCalls) }} -
- -
- Error Rate - - {{ $this->stats->errorRate }}% - -
- -
- Total Errors - - {{ number_format($this->stats->errorCount) }} - -
- -
- Avg Duration - {{ $this->formatDuration($this->stats->avgDurationMs) }} -
- -
- Min Duration - {{ $this->formatDuration($this->stats->minDurationMs) }} -
- -
- Max Duration - {{ $this->formatDuration($this->stats->maxDurationMs) }} -
-
- - -
-
- Usage Trend -
-
- @if(empty($this->trends) || array_sum(array_column($this->trends, 'calls')) === 0) -
No usage data available for this period
- @else -
- @php - $maxCalls = max(array_column($this->trends, 'calls')) ?: 1; - @endphp - @foreach($this->trends as $day) -
- {{ $day['date_formatted'] }} -
-
- @php - $callsWidth = ($day['calls'] / $maxCalls) * 100; - $errorsWidth = $day['calls'] > 0 ? ($day['errors'] / $day['calls']) * $callsWidth : 0; - $successWidth = $callsWidth - $errorsWidth; - @endphp -
-
-
-
-
- {{ $day['calls'] }} -
-
- @if($day['calls'] > 0) - - {{ round($day['error_rate'], 1) }}% - - @else - - - @endif -
-
- @endforeach -
- -
-
-
- Successful -
-
-
- Errors -
-
- @endif -
-
- - -
-
- Response Time Distribution -
-
-
-
-
Fastest
-
{{ $this->formatDuration($this->stats->minDurationMs) }}
-
-
-
Average
-
{{ $this->formatDuration($this->stats->avgDurationMs) }}
-
-
-
Slowest
-
{{ $this->formatDuration($this->stats->maxDurationMs) }}
-
-
-
-
- - -
-
- Daily Breakdown -
-
- - - - - - - - - - - - @forelse($this->trends as $day) - @if($day['calls'] > 0) - - - - - - - - @endif - @empty - - - - @endforelse - -
DateCallsErrorsError RateAvg Duration
{{ $day['date'] }}{{ number_format($day['calls']) }}{{ number_format($day['errors']) }} - - {{ round($day['error_rate'], 1) }}% - - {{ $this->formatDuration($day['avg_duration_ms']) }}
- No data available for this period -
-
-
-
diff --git a/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/api-key-manager.blade.php b/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/api-key-manager.blade.php deleted file mode 100644 index 7226a73..0000000 --- a/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/api-key-manager.blade.php +++ /dev/null @@ -1,268 +0,0 @@ -
- - @if(session('message')) -
-

{{ session('message') }}

-
- @endif - - -
-
-

- {{ __('mcp::mcp.keys.title') }} -

-

- {{ __('mcp::mcp.keys.description') }} -

-
- - {{ __('mcp::mcp.keys.actions.create') }} - -
- - -
- @if($keys->isEmpty()) -
-
- -
-

{{ __('mcp::mcp.keys.empty.title') }}

-

- {{ __('mcp::mcp.keys.empty.description') }} -

- - {{ __('mcp::mcp.keys.actions.create_first') }} - -
- @else - - - - - - - - - - - - - @foreach($keys as $key) - - - - - - - - - @endforeach - -
- {{ __('mcp::mcp.keys.table.name') }} - - {{ __('mcp::mcp.keys.table.key') }} - - {{ __('mcp::mcp.keys.table.scopes') }} - - {{ __('mcp::mcp.keys.table.last_used') }} - - {{ __('mcp::mcp.keys.table.expires') }} - - {{ __('mcp::mcp.keys.table.actions') }} -
- {{ $key->name }} - - - {{ $key->prefix }}_**** - - -
- @foreach($key->scopes ?? [] as $scope) - - {{ $scope }} - - @endforeach -
-
- {{ $key->last_used_at?->diffForHumans() ?? __('mcp::mcp.keys.status.never') }} - - @if($key->expires_at) - @if($key->expires_at->isPast()) - {{ __('mcp::mcp.keys.status.expired') }} - @else - {{ $key->expires_at->diffForHumans() }} - @endif - @else - {{ __('mcp::mcp.keys.status.never') }} - @endif - - - {{ __('mcp::mcp.keys.actions.revoke') }} - -
- @endif -
- - -
- -
-

- - {{ __('mcp::mcp.keys.auth.title') }} -

-

- {{ __('mcp::mcp.keys.auth.description') }} -

-
-
-

{{ __('mcp::mcp.keys.auth.header_recommended') }}

-
Authorization: Bearer hk_abc123_****
-
-
-

{{ __('mcp::mcp.keys.auth.header_api_key') }}

-
X-API-Key: hk_abc123_****
-
-
-
- - -
-

- - {{ __('mcp::mcp.keys.example.title') }} -

-

- {{ __('mcp::mcp.keys.example.description') }} -

-
curl -X POST https://mcp.host.uk.com/api/v1/tools/call \
-  -H "Authorization: Bearer YOUR_API_KEY" \
-  -H "Content-Type: application/json" \
-  -d '{
-    "server": "commerce",
-    "tool": "product_list",
-    "arguments": {}
-  }'
-
-
- - - -
-

{{ __('mcp::mcp.keys.create_modal.title') }}

- -
- -
- {{ __('mcp::mcp.keys.create_modal.name_label') }} - - @error('newKeyName') -

{{ $message }}

- @enderror -
- - -
- {{ __('mcp::mcp.keys.create_modal.permissions_label') }} -
- - - -
-
- - -
- {{ __('mcp::mcp.keys.create_modal.expiry_label') }} - - - - - - -
-
- -
- {{ __('mcp::mcp.keys.create_modal.cancel') }} - {{ __('mcp::mcp.keys.create_modal.create') }} -
-
-
- - - -
-
-
- -
-

{{ __('mcp::mcp.keys.new_key_modal.title') }}

-
- -
-

- {{ __('mcp::mcp.keys.new_key_modal.warning') }} {{ __('mcp::mcp.keys.new_key_modal.warning_detail') }} -

-
- -
-
{{ $newPlainKey }}
- -
- -
- {{ __('mcp::mcp.keys.new_key_modal.done') }} -
-
-
-
diff --git a/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/audit-log-viewer.blade.php b/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/audit-log-viewer.blade.php deleted file mode 100644 index dbac118..0000000 --- a/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/audit-log-viewer.blade.php +++ /dev/null @@ -1,400 +0,0 @@ -{{-- -MCP Audit Log Viewer. - -Displays immutable audit trail for MCP tool executions. -Includes integrity verification and compliance export features. ---}} - -
- {{-- Header --}} -
-
- {{ __('MCP Audit Log') }} - Immutable audit trail for tool executions with hash chain integrity -
-
- - Verify Integrity - - - Export - -
-
- - {{-- Stats Cards --}} -
-
-
Total Entries
-
- {{ number_format($this->stats['total']) }} -
-
-
-
Success Rate
-
- {{ $this->stats['success_rate'] }}% -
-
-
-
Failed Calls
-
- {{ number_format($this->stats['failed']) }} -
-
-
-
Sensitive Calls
-
- {{ number_format($this->stats['sensitive_calls']) }} -
-
-
- - {{-- Filters --}} -
-
- -
- - All tools - @foreach ($this->tools as $toolName) - {{ $toolName }} - @endforeach - - - All workspaces - @foreach ($this->workspaces as $ws) - {{ $ws->name }} - @endforeach - - - All statuses - Success - Failed - - - All sensitivity - Sensitive only - Normal only - - - - @if($search || $tool || $workspace || $status || $sensitivity || $dateFrom || $dateTo) - Clear - @endif -
- - {{-- Audit Log Table --}} - - - ID - Time - Tool - Workspace - Status - Sensitivity - Actor - Duration - - - - - @forelse ($this->entries as $entry) - - - #{{ $entry->id }} - - - {{ $entry->created_at->format('M j, Y H:i:s') }} - - -
{{ $entry->tool_name }}
-
{{ $entry->server_id }}
-
- - @if($entry->workspace) - {{ $entry->workspace->name }} - @else - - - @endif - - - - {{ $entry->success ? 'Success' : 'Failed' }} - - - - @if($entry->is_sensitive) - - Sensitive - - @else - - - @endif - - - {{ $entry->getActorDisplay() }} - @if($entry->actor_ip) -
{{ $entry->actor_ip }}
- @endif -
- - {{ $entry->getDurationForHumans() }} - - - - View - - -
- @empty - - -
-
- -
- No audit entries found - Audit logs will appear here as tools are executed. -
-
-
- @endforelse -
-
- - @if($this->entries->hasPages()) -
- {{ $this->entries->links() }} -
- @endif - - {{-- Entry Detail Modal --}} - @if($this->selectedEntry) - -
-
- Audit Entry #{{ $this->selectedEntry->id }} - -
- - {{-- Integrity Status --}} - @php - $integrity = $this->selectedEntry->getIntegrityStatus(); - @endphp -
-
- - - {{ $integrity['valid'] ? 'Integrity Verified' : 'Integrity Issues Detected' }} - -
- @if(!$integrity['valid']) -
    - @foreach($integrity['issues'] as $issue) -
  • {{ $issue }}
  • - @endforeach -
- @endif -
- - {{-- Entry Details --}} -
-
-
Tool
-
{{ $this->selectedEntry->tool_name }}
-
-
-
Server
-
{{ $this->selectedEntry->server_id }}
-
-
-
Timestamp
-
{{ $this->selectedEntry->created_at->format('Y-m-d H:i:s.u') }}
-
-
-
Duration
-
{{ $this->selectedEntry->getDurationForHumans() }}
-
-
-
Status
-
- - {{ $this->selectedEntry->success ? 'Success' : 'Failed' }} - -
-
-
-
Actor
-
{{ $this->selectedEntry->getActorDisplay() }}
-
-
- - @if($this->selectedEntry->is_sensitive) -
-
- - Sensitive Tool -
-

- {{ $this->selectedEntry->sensitivity_reason ?? 'This tool is flagged as sensitive.' }} -

-
- @endif - - @if($this->selectedEntry->error_message) -
-
Error
-
- @if($this->selectedEntry->error_code) -
- {{ $this->selectedEntry->error_code }} -
- @endif -
- {{ $this->selectedEntry->error_message }} -
-
-
- @endif - - @if($this->selectedEntry->input_params) -
-
Input Parameters
-
{{ json_encode($this->selectedEntry->input_params, JSON_PRETTY_PRINT) }}
-
- @endif - - @if($this->selectedEntry->output_summary) -
-
Output Summary
-
{{ json_encode($this->selectedEntry->output_summary, JSON_PRETTY_PRINT) }}
-
- @endif - - {{-- Hash Chain Info --}} -
-
Hash Chain
-
-
- Entry Hash: - {{ $this->selectedEntry->entry_hash }} -
-
- Previous Hash: - {{ $this->selectedEntry->previous_hash ?? '(first entry)' }} -
-
-
-
-
- @endif - - {{-- Integrity Verification Modal --}} - @if($showIntegrityModal && $integrityStatus) - -
-
- Integrity Verification - -
- -
-
- -
-
- {{ $integrityStatus['valid'] ? 'Audit Log Verified' : 'Integrity Issues Detected' }} -
-
- {{ number_format($integrityStatus['verified']) }} of {{ number_format($integrityStatus['total']) }} entries verified -
-
-
-
- - @if(!$integrityStatus['valid'] && !empty($integrityStatus['issues'])) -
-
Issues Found:
-
- @foreach($integrityStatus['issues'] as $issue) -
-
- Entry #{{ $issue['id'] }}: {{ $issue['type'] }} -
-
- {{ $issue['message'] }} -
-
- @endforeach -
-
- @endif - -
- - Close - -
-
-
- @endif - - {{-- Export Modal --}} - @if($showExportModal) - -
-
- Export Audit Log - -
- -
-

- Export the audit log with current filters applied. The export includes integrity verification metadata. -

- -
- Export Format - - JSON (with integrity metadata) - CSV (data only) - -
- -
-
Current Filters:
-
    - @if($tool) -
  • Tool: {{ $tool }}
  • - @endif - @if($workspace) -
  • Workspace: {{ $this->workspaces->firstWhere('id', $workspace)?->name }}
  • - @endif - @if($dateFrom || $dateTo) -
  • Date: {{ $dateFrom ?: 'start' }} to {{ $dateTo ?: 'now' }}
  • - @endif - @if($sensitivity === 'sensitive') -
  • Sensitive only
  • - @endif - @if(!$tool && !$workspace && !$dateFrom && !$dateTo && !$sensitivity) -
  • All entries
  • - @endif -
-
-
- -
- - Cancel - - - Download - -
-
-
- @endif -
diff --git a/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/mcp-playground.blade.php b/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/mcp-playground.blade.php deleted file mode 100644 index d5f5191..0000000 --- a/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/mcp-playground.blade.php +++ /dev/null @@ -1,502 +0,0 @@ -
- {{-- Header --}} -
-
-
-

MCP Playground

-

- Interactive tool testing with documentation and examples -

-
-
- -
-
-
- - {{-- Error Display --}} - @if($error) -
-
- - - -

{{ $error }}

-
-
- @endif - -
- {{-- Left Sidebar: Tool Browser --}} -
-
- {{-- Server Selection --}} -
- - -
- - @if($selectedServer) - {{-- Search --}} -
-
- - - - -
-
- - {{-- Category Filter --}} - @if($categories->isNotEmpty()) -
- -
- - @foreach($categories as $category) - - @endforeach -
-
- @endif - - {{-- Tools List --}} -
- @forelse($toolsByCategory as $category => $categoryTools) -
-

{{ $category }}

-
- @foreach($categoryTools as $tool) - - @endforeach - @empty -
-

No tools found

-
- @endforelse -
- @else -
- - - -

Select a server to browse tools

-
- @endif -
-
- - {{-- Center: Tool Details & Input Form --}} -
- {{-- API Key Authentication --}} -
-

- - - - Authentication -

-
-
- - -

Paste your API key to execute requests live

-
-
- - @if($keyStatus === 'valid') - - - - - Valid - - @elseif($keyStatus === 'invalid') - - - - - Invalid key - - @elseif($keyStatus === 'expired') - - - - - Expired - - @endif -
- @if($keyInfo) -
-
-
- Name: - {{ $keyInfo['name'] }} -
-
- Workspace: - {{ $keyInfo['workspace'] }} -
-
-
- @endif -
-
- - {{-- Tool Form --}} - @if($currentTool) -
-
-
-
-

{{ $currentTool['name'] }}

-

{{ $currentTool['description'] }}

-
- - {{ $currentTool['category'] }} - -
-
- - @php - $properties = $currentTool['inputSchema']['properties'] ?? []; - $required = $currentTool['inputSchema']['required'] ?? []; - @endphp - - @if(count($properties) > 0) -
-
-

Parameters

- -
- - @foreach($properties as $name => $schema) - @php - $isRequired = in_array($name, $required) || ($schema['required'] ?? false); - $type = is_array($schema['type'] ?? 'string') ? ($schema['type'][0] ?? 'string') : ($schema['type'] ?? 'string'); - $description = $schema['description'] ?? ''; - @endphp - -
- - - @if(isset($schema['enum'])) - - @elseif($type === 'boolean') - - @elseif($type === 'integer' || $type === 'number') - - @elseif($type === 'array' || $type === 'object') - - @else - - @endif - - @if($description) -

{{ $description }}

- @endif -
- @endforeach -
- @else -

This tool has no parameters.

- @endif - -
- -
-
- @else -
- - - -

Select a tool

-

- Choose a tool from the sidebar to view its documentation and test it -

-
- @endif -
- - {{-- Right: Response Viewer --}} -
-
-
-

Response

- @if($executionTime > 0) - {{ $executionTime }}ms - @endif -
- -
- @if($lastResponse) -
- -
- - @if(isset($lastResponse['error'])) -
-

{{ $lastResponse['error'] }}

-
- @endif - -
-
{{ json_encode($lastResponse, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) }}
-
- - @if(isset($lastResponse['executed']) && !$lastResponse['executed']) -
-

- This is a preview. Add a valid API key to execute requests live. -

-
- @endif - @else -
- - - -

Response will appear here

-
- @endif -
- - {{-- API Reference --}} -
-

API Reference

-
-
- Endpoint - /api/v1/mcp/tools/call -
-
- Method - POST -
-
- Auth - Bearer token -
-
-
-
-
-
- - {{-- History Panel (Collapsible Bottom) --}} -
-
-
-

- - - - Conversation History -

- @if(count($conversationHistory) > 0) - - @endif -
- - @if(count($conversationHistory) > 0) -
- @foreach($conversationHistory as $index => $entry) -
-
-
-
- @if($entry['success'] ?? true) - - Success - - @else - - Failed - - @endif - {{ $entry['tool'] }} - on - {{ $entry['server'] }} -
-
- {{ \Carbon\Carbon::parse($entry['timestamp'])->diffForHumans() }} - @if(isset($entry['duration_ms'])) - {{ $entry['duration_ms'] }}ms - @endif -
-
-
- - -
-
-
- @endforeach -
- @else -
-

No history yet. Execute a tool to see it here.

-
- @endif -
-
-
diff --git a/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/playground.blade.php b/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/playground.blade.php deleted file mode 100644 index 1077ee5..0000000 --- a/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/playground.blade.php +++ /dev/null @@ -1,281 +0,0 @@ -
-
-

{{ __('mcp::mcp.playground.title') }}

-

- {{ __('mcp::mcp.playground.description') }} -

-
- - {{-- Error Display --}} - @if($error) -
-
- -

{{ $error }}

-
-
- @endif - -
- -
- -
-

{{ __('mcp::mcp.playground.auth.title') }}

- -
-
- -
- -
- - {{ __('mcp::mcp.playground.auth.validate') }} - - - @if($keyStatus === 'valid') - - - {{ __('mcp::mcp.playground.auth.status.valid') }} - - @elseif($keyStatus === 'invalid') - - - {{ __('mcp::mcp.playground.auth.status.invalid') }} - - @elseif($keyStatus === 'expired') - - - {{ __('mcp::mcp.playground.auth.status.expired') }} - - @elseif($keyStatus === 'empty') - - {{ __('mcp::mcp.playground.auth.status.empty') }} - - @endif -
- - @if($keyInfo) -
-
-
- {{ __('mcp::mcp.playground.auth.key_info.name') }}: - {{ $keyInfo['name'] }} -
-
- {{ __('mcp::mcp.playground.auth.key_info.workspace') }}: - {{ $keyInfo['workspace'] }} -
-
- {{ __('mcp::mcp.playground.auth.key_info.scopes') }}: - {{ implode(', ', $keyInfo['scopes'] ?? []) }} -
-
- {{ __('mcp::mcp.playground.auth.key_info.last_used') }}: - {{ $keyInfo['last_used'] }} -
-
-
- @elseif(!$isAuthenticated && !$apiKey) -
-

- {{ __('mcp::mcp.playground.auth.sign_in_prompt') }} - {{ __('mcp::mcp.playground.auth.sign_in_description') }} -

-
- @endif -
-
- - -
-

{{ __('mcp::mcp.playground.tools.title') }}

- -
- - @foreach($servers as $server) - {{ $server['name'] }} - @endforeach - - - @if($selectedServer && count($tools) > 0) - - @foreach($tools as $tool) - {{ $tool['name'] }} - @endforeach - - @endif -
-
- - - @if($toolSchema) -
-
-

{{ $toolSchema['name'] }}

-

{{ $toolSchema['description'] ?? $toolSchema['purpose'] ?? '' }}

-
- - @php - $params = $toolSchema['inputSchema']['properties'] ?? $toolSchema['parameters'] ?? []; - $required = $toolSchema['inputSchema']['required'] ?? []; - @endphp - - @if(count($params) > 0) -
-

{{ __('mcp::mcp.playground.tools.arguments') }}

- - @foreach($params as $name => $schema) -
- @php - $paramRequired = in_array($name, $required) || ($schema['required'] ?? false); - $paramType = is_array($schema['type'] ?? 'string') ? ($schema['type'][0] ?? 'string') : ($schema['type'] ?? 'string'); - @endphp - - @if(isset($schema['enum'])) - - @foreach($schema['enum'] as $option) - {{ $option }} - @endforeach - - @elseif($paramType === 'boolean') - - true - false - - @elseif($paramType === 'integer' || $paramType === 'number') - - @else - - @endif -
- @endforeach -
- @else -

{{ __('mcp::mcp.playground.tools.no_arguments') }}

- @endif - -
- - - @if($keyStatus === 'valid') - {{ __('mcp::mcp.playground.tools.execute') }} - @else - {{ __('mcp::mcp.playground.tools.generate') }} - @endif - - {{ __('mcp::mcp.playground.tools.executing') }} - -
-
- @endif -
- - -
-
-

{{ __('mcp::mcp.playground.response.title') }}

- - @if($response) -
-
- -
-
{{ $response }}
-
- @else -
- -

{{ __('mcp::mcp.playground.response.empty') }}

-
- @endif -
- - -
-

{{ __('mcp::mcp.playground.reference.title') }}

-
-
- {{ __('mcp::mcp.playground.reference.endpoint') }}: - {{ config('app.url') }}/api/v1/mcp/tools/call -
-
- {{ __('mcp::mcp.playground.reference.method') }}: - POST -
-
- {{ __('mcp::mcp.playground.reference.auth') }}: - @if($keyStatus === 'valid') - Bearer {{ Str::limit($apiKey, 20, '...') }} - @else - Bearer <your-api-key> - @endif -
-
- {{ __('mcp::mcp.playground.reference.content_type') }}: - application/json -
-
- - @if($isAuthenticated) -
- - {{ __('mcp::mcp.playground.reference.manage_keys') }} - -
- @endif -
-
-
-
- -@script - -@endscript diff --git a/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/quota-usage.blade.php b/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/quota-usage.blade.php deleted file mode 100644 index 90f27fe..0000000 --- a/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/quota-usage.blade.php +++ /dev/null @@ -1,186 +0,0 @@ -
- {{-- Header --}} -
-
-

MCP Usage Quota

-

- Current billing period resets {{ $this->resetDate }} -

-
- -
- - {{-- Current Usage Cards --}} -
- {{-- Tool Calls Card --}} -
-
-
-
- -
-
-

Tool Calls

-

Monthly usage

-
-
-
- - @if($quotaLimits['tool_calls_unlimited'] ?? false) -
- - {{ number_format($currentUsage['tool_calls_count'] ?? 0) }} - - Unlimited -
- @else -
-
- - {{ number_format($currentUsage['tool_calls_count'] ?? 0) }} - - - of {{ number_format($quotaLimits['tool_calls_limit'] ?? 0) }} - -
-
-
-
-

- {{ number_format($remaining['tool_calls'] ?? 0) }} remaining -

-
- @endif -
- - {{-- Tokens Card --}} -
-
-
-
- -
-
-

Tokens

-

Monthly consumption

-
-
-
- - @if($quotaLimits['tokens_unlimited'] ?? false) -
- - {{ number_format($currentUsage['total_tokens'] ?? 0) }} - - Unlimited -
-
-
- Input: - - {{ number_format($currentUsage['input_tokens'] ?? 0) }} - -
-
- Output: - - {{ number_format($currentUsage['output_tokens'] ?? 0) }} - -
-
- @else -
-
- - {{ number_format($currentUsage['total_tokens'] ?? 0) }} - - - of {{ number_format($quotaLimits['tokens_limit'] ?? 0) }} - -
-
-
-
-
-

- {{ number_format($remaining['tokens'] ?? 0) }} remaining -

-
- - In: {{ number_format($currentUsage['input_tokens'] ?? 0) }} - - - Out: {{ number_format($currentUsage['output_tokens'] ?? 0) }} - -
-
-
- @endif -
-
- - {{-- Usage History --}} - @if($usageHistory->count() > 0) -
-

Usage History

-
- - - - - - - - - - - - @foreach($usageHistory as $record) - - - - - - - - @endforeach - -
MonthTool CallsInput TokensOutput TokensTotal Tokens
- {{ $record->month_label }} - - {{ number_format($record->tool_calls_count) }} - - {{ number_format($record->input_tokens) }} - - {{ number_format($record->output_tokens) }} - - {{ number_format($record->total_tokens) }} -
-
-
- @endif - - {{-- Upgrade Prompt (shown when near limit) --}} - @if(($this->toolCallsPercentage >= 80 || $this->tokensPercentage >= 80) && !($quotaLimits['tool_calls_unlimited'] ?? false)) -
-
- -
-

Approaching usage limit

-

- You're nearing your monthly MCP quota. Consider upgrading your plan for higher limits. -

-
-
-
- @endif -
diff --git a/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/request-log.blade.php b/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/request-log.blade.php deleted file mode 100644 index 9086b55..0000000 --- a/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/request-log.blade.php +++ /dev/null @@ -1,153 +0,0 @@ -
-
-

{{ __('mcp::mcp.logs.title') }}

-

- {{ __('mcp::mcp.logs.description') }} -

-
- - -
-
-
- - -
-
- - -
-
-
- -
- -
-
- @forelse($requests as $request) - - @empty -
- {{ __('mcp::mcp.logs.empty') }} -
- @endforelse -
- - @if($requests->hasPages()) -
- {{ $requests->links() }} -
- @endif -
- - -
- @if($selectedRequest) -
-

{{ __('mcp::mcp.logs.detail.title') }}

- -
- -
- -
- - - {{ $selectedRequest->response_status }} - {{ $selectedRequest->isSuccessful() ? __('mcp::mcp.logs.status_ok') : __('mcp::mcp.logs.status_error') }} - -
- - -
- -
{{ json_encode($selectedRequest->request_body, JSON_PRETTY_PRINT) }}
-
- - -
- -
{{ json_encode($selectedRequest->response_body, JSON_PRETTY_PRINT) }}
-
- - @if($selectedRequest->error_message) -
- -
{{ $selectedRequest->error_message }}
-
- @endif - - -
- -
{{ $selectedRequest->toCurl() }}
-
- - -
-
{{ __('mcp::mcp.logs.detail.metadata.request_id') }}: {{ $selectedRequest->request_id }}
-
{{ __('mcp::mcp.logs.detail.metadata.duration') }}: {{ $selectedRequest->duration_for_humans }}
-
{{ __('mcp::mcp.logs.detail.metadata.ip') }}: {{ $selectedRequest->ip_address ?? __('mcp::mcp.common.na') }}
-
{{ __('mcp::mcp.logs.detail.metadata.time') }}: {{ $selectedRequest->created_at->format('Y-m-d H:i:s') }}
-
-
- @else -
- -

{{ __('mcp::mcp.logs.empty_detail') }}

-
- @endif -
-
-
diff --git a/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/tool-version-manager.blade.php b/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/tool-version-manager.blade.php deleted file mode 100644 index 5d6b424..0000000 --- a/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/tool-version-manager.blade.php +++ /dev/null @@ -1,537 +0,0 @@ -{{-- -MCP Tool Version Manager. - -Admin interface for managing tool version lifecycles, -viewing schema changes between versions, and setting deprecation schedules. ---}} - -
- {{-- Header --}} -
-
- {{ __('Tool Versions') }} - Manage MCP tool version lifecycles and backwards compatibility -
-
- - Register Version - -
-
- - {{-- Stats Cards --}} -
-
-
Total Versions
-
- {{ number_format($this->stats['total_versions']) }} -
-
-
-
Unique Tools
-
- {{ number_format($this->stats['total_tools']) }} -
-
-
-
Servers
-
- {{ number_format($this->stats['servers']) }} -
-
-
-
Deprecated
-
- {{ number_format($this->stats['deprecated_count']) }} -
-
-
-
Sunset
-
- {{ number_format($this->stats['sunset_count']) }} -
-
-
- - {{-- Filters --}} -
-
- -
- - All servers - @foreach ($this->servers as $serverId) - {{ $serverId }} - @endforeach - - - All statuses - Latest - Active (non-latest) - Deprecated - Sunset - - @if($search || $server || $status) - Clear - @endif -
- - {{-- Versions Table --}} - - - Tool - Server - Version - Status - Deprecated - Sunset - Created - - - - - @forelse ($this->versions as $version) - - -
{{ $version->tool_name }}
- @if($version->description) -
{{ $version->description }}
- @endif -
- - {{ $version->server_id }} - - - - {{ $version->version }} - - - - - {{ ucfirst($version->status) }} - - - - @if($version->deprecated_at) - {{ $version->deprecated_at->format('M j, Y') }} - @else - - - @endif - - - @if($version->sunset_at) - - {{ $version->sunset_at->format('M j, Y') }} - - @else - - - @endif - - - {{ $version->created_at->format('M j, Y') }} - - - - - - - View Details - - @if(!$version->is_latest && !$version->is_sunset) - - Mark as Latest - - @endif - @if(!$version->is_deprecated && !$version->is_sunset) - - Deprecate - - @endif - - - -
- @empty - - -
-
- -
- No tool versions found - Register tool versions to enable backwards compatibility. -
-
-
- @endforelse -
-
- - @if($this->versions->hasPages()) -
- {{ $this->versions->links() }} -
- @endif - - {{-- Version Detail Modal --}} - @if($showVersionDetail && $this->selectedVersion) - -
-
-
- {{ $this->selectedVersion->tool_name }} -
- - {{ $this->selectedVersion->version }} - - - {{ ucfirst($this->selectedVersion->status) }} - -
-
- -
- - {{-- Metadata --}} -
-
-
Server
-
{{ $this->selectedVersion->server_id }}
-
-
-
Created
-
{{ $this->selectedVersion->created_at->format('Y-m-d H:i:s') }}
-
- @if($this->selectedVersion->deprecated_at) -
-
Deprecated
-
- {{ $this->selectedVersion->deprecated_at->format('Y-m-d') }} -
-
- @endif - @if($this->selectedVersion->sunset_at) -
-
Sunset
-
- {{ $this->selectedVersion->sunset_at->format('Y-m-d') }} -
-
- @endif -
- - @if($this->selectedVersion->description) -
-
Description
-
{{ $this->selectedVersion->description }}
-
- @endif - - @if($this->selectedVersion->changelog) -
-
Changelog
-
- {!! nl2br(e($this->selectedVersion->changelog)) !!} -
-
- @endif - - @if($this->selectedVersion->migration_notes) -
-
- - Migration Notes -
-
- {!! nl2br(e($this->selectedVersion->migration_notes)) !!} -
-
- @endif - - {{-- Input Schema --}} - @if($this->selectedVersion->input_schema) -
-
Input Schema
-
{{ $this->formatSchema($this->selectedVersion->input_schema) }}
-
- @endif - - {{-- Output Schema --}} - @if($this->selectedVersion->output_schema) -
-
Output Schema
-
{{ $this->formatSchema($this->selectedVersion->output_schema) }}
-
- @endif - - {{-- Version History --}} - @if($this->versionHistory->count() > 1) -
-
Version History
-
- @foreach($this->versionHistory as $index => $historyVersion) -
-
- - {{ $historyVersion->version }} - - - {{ ucfirst($historyVersion->status) }} - - - {{ $historyVersion->created_at->format('M j, Y') }} - -
- @if($historyVersion->id !== $this->selectedVersion->id && $index < $this->versionHistory->count() - 1) - @php $nextVersion = $this->versionHistory[$index + 1] @endphp - - Compare - - @endif -
- @endforeach -
-
- @endif -
-
- @endif - - {{-- Compare Schemas Modal --}} - @if($showCompareModal && $this->schemaComparison) - -
-
- Schema Comparison - -
- -
-
- - {{ $this->schemaComparison['from']->version }} - -
- -
- - {{ $this->schemaComparison['to']->version }} - -
-
- - @php $changes = $this->schemaComparison['changes'] @endphp - - @if(empty($changes['added']) && empty($changes['removed']) && empty($changes['changed'])) -
-
- - No schema changes between versions -
-
- @else -
- @if(!empty($changes['added'])) -
-
- Added Properties ({{ count($changes['added']) }}) -
-
    - @foreach($changes['added'] as $prop) -
  • {{ $prop }}
  • - @endforeach -
-
- @endif - - @if(!empty($changes['removed'])) -
-
- Removed Properties ({{ count($changes['removed']) }}) -
-
    - @foreach($changes['removed'] as $prop) -
  • {{ $prop }}
  • - @endforeach -
-
- @endif - - @if(!empty($changes['changed'])) -
-
- Changed Properties ({{ count($changes['changed']) }}) -
-
- @foreach($changes['changed'] as $prop => $change) -
- {{ $prop }} -
-
-
Before:
-
{{ json_encode($change['from'], JSON_PRETTY_PRINT) }}
-
-
-
After:
-
{{ json_encode($change['to'], JSON_PRETTY_PRINT) }}
-
-
-
- @endforeach -
-
- @endif -
- @endif - -
- Close -
-
-
- @endif - - {{-- Deprecate Modal --}} - @if($showDeprecateModal) - @php $deprecateVersion = \Core\Mod\Mcp\Models\McpToolVersion::find($deprecateVersionId) @endphp - @if($deprecateVersion) - -
-
- Deprecate Version - -
- -
-
- - {{ $deprecateVersion->tool_name }} v{{ $deprecateVersion->version }} -
-

- Deprecated versions will show warnings to agents but remain usable until sunset. -

-
- -
- Sunset Date (optional) - - - After this date, the version will be blocked and return errors. - -
- -
- Cancel - - Deprecate Version - -
-
-
- @endif - @endif - - {{-- Register Version Modal --}} - @if($showRegisterModal) - -
-
- Register Tool Version - -
- -
-
-
- Server ID - - @error('registerServer') {{ $message }} @enderror -
-
- Tool Name - - @error('registerTool') {{ $message }} @enderror -
-
- -
-
- Version (semver) - - @error('registerVersion') {{ $message }} @enderror -
-
- -
-
- -
- Description - - @error('registerDescription') {{ $message }} @enderror -
- -
- Changelog - - @error('registerChangelog') {{ $message }} @enderror -
- -
- Migration Notes - - @error('registerMigrationNotes') {{ $message }} @enderror -
- -
- Input Schema (JSON) - - @error('registerInputSchema') {{ $message }} @enderror -
- -
- Cancel - Register Version -
-
-
-
- @endif -
diff --git a/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/ApiKeyManager.php b/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/ApiKeyManager.php deleted file mode 100644 index 3f98cd2..0000000 --- a/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/ApiKeyManager.php +++ /dev/null @@ -1,112 +0,0 @@ -workspace = $workspace; - } - - public function openCreateModal(): void - { - $this->showCreateModal = true; - $this->newKeyName = ''; - $this->newKeyScopes = ['read', 'write']; - $this->newKeyExpiry = 'never'; - } - - public function closeCreateModal(): void - { - $this->showCreateModal = false; - } - - public function createKey(): void - { - $this->validate([ - 'newKeyName' => 'required|string|max:100', - ]); - - $expiresAt = match ($this->newKeyExpiry) { - '30days' => now()->addDays(30), - '90days' => now()->addDays(90), - '1year' => now()->addYear(), - default => null, - }; - - $result = ApiKey::generate( - workspaceId: $this->workspace->id, - userId: auth()->id(), - name: $this->newKeyName, - scopes: $this->newKeyScopes, - expiresAt: $expiresAt, - ); - - $this->newPlainKey = $result['plain_key']; - $this->showCreateModal = false; - $this->showNewKeyModal = true; - - session()->flash('message', 'API key created successfully.'); - } - - public function closeNewKeyModal(): void - { - $this->newPlainKey = null; - $this->showNewKeyModal = false; - } - - public function revokeKey(int $keyId): void - { - $key = $this->workspace->apiKeys()->findOrFail($keyId); - $key->revoke(); - - session()->flash('message', 'API key revoked.'); - } - - public function toggleScope(string $scope): void - { - if (in_array($scope, $this->newKeyScopes)) { - $this->newKeyScopes = array_values(array_diff($this->newKeyScopes, [$scope])); - } else { - $this->newKeyScopes[] = $scope; - } - } - - public function render() - { - return view('mcp::admin.api-key-manager', [ - 'keys' => $this->workspace->apiKeys()->orderByDesc('created_at')->get(), - ]); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/AuditLogViewer.php b/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/AuditLogViewer.php deleted file mode 100644 index df98d14..0000000 --- a/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/AuditLogViewer.php +++ /dev/null @@ -1,249 +0,0 @@ -checkHadesAccess(); - } - - #[Computed] - public function entries(): LengthAwarePaginator - { - $query = McpAuditLog::query() - ->with('workspace') - ->orderByDesc('id'); - - if ($this->search) { - $query->where(function ($q) { - $q->where('tool_name', 'like', "%{$this->search}%") - ->orWhere('server_id', 'like', "%{$this->search}%") - ->orWhere('session_id', 'like', "%{$this->search}%") - ->orWhere('error_message', 'like', "%{$this->search}%"); - }); - } - - if ($this->tool) { - $query->where('tool_name', $this->tool); - } - - if ($this->workspace) { - $query->where('workspace_id', $this->workspace); - } - - if ($this->status === 'success') { - $query->where('success', true); - } elseif ($this->status === 'failed') { - $query->where('success', false); - } - - if ($this->sensitivity === 'sensitive') { - $query->where('is_sensitive', true); - } elseif ($this->sensitivity === 'normal') { - $query->where('is_sensitive', false); - } - - if ($this->dateFrom) { - $query->where('created_at', '>=', Carbon::parse($this->dateFrom)->startOfDay()); - } - - if ($this->dateTo) { - $query->where('created_at', '<=', Carbon::parse($this->dateTo)->endOfDay()); - } - - return $query->paginate($this->perPage); - } - - #[Computed] - public function workspaces(): Collection - { - return Workspace::orderBy('name')->get(['id', 'name']); - } - - #[Computed] - public function tools(): Collection - { - return McpAuditLog::query() - ->select('tool_name') - ->distinct() - ->orderBy('tool_name') - ->pluck('tool_name'); - } - - #[Computed] - public function selectedEntry(): ?McpAuditLog - { - if (! $this->selectedEntryId) { - return null; - } - - return McpAuditLog::with('workspace')->find($this->selectedEntryId); - } - - #[Computed] - public function stats(): array - { - return app(AuditLogService::class)->getStats( - workspaceId: $this->workspace ? (int) $this->workspace : null, - days: 30 - ); - } - - public function viewEntry(int $id): void - { - $this->selectedEntryId = $id; - } - - public function closeEntryDetail(): void - { - $this->selectedEntryId = null; - } - - public function verifyIntegrity(): void - { - $this->integrityStatus = app(AuditLogService::class)->verifyChain(); - $this->showIntegrityModal = true; - } - - public function closeIntegrityModal(): void - { - $this->showIntegrityModal = false; - $this->integrityStatus = null; - } - - public function openExportModal(): void - { - $this->showExportModal = true; - } - - public function closeExportModal(): void - { - $this->showExportModal = false; - } - - public function export(): StreamedResponse - { - $auditLogService = app(AuditLogService::class); - - $workspaceId = $this->workspace ? (int) $this->workspace : null; - $from = $this->dateFrom ? Carbon::parse($this->dateFrom) : null; - $to = $this->dateTo ? Carbon::parse($this->dateTo) : null; - $tool = $this->tool ?: null; - $sensitiveOnly = $this->sensitivity === 'sensitive'; - - if ($this->exportFormat === 'csv') { - $content = $auditLogService->exportToCsv($workspaceId, $from, $to, $tool, $sensitiveOnly); - $filename = 'mcp-audit-log-'.now()->format('Y-m-d-His').'.csv'; - $contentType = 'text/csv'; - } else { - $content = $auditLogService->exportToJson($workspaceId, $from, $to, $tool, $sensitiveOnly); - $filename = 'mcp-audit-log-'.now()->format('Y-m-d-His').'.json'; - $contentType = 'application/json'; - } - - return response()->streamDownload(function () use ($content) { - echo $content; - }, $filename, [ - 'Content-Type' => $contentType, - ]); - } - - public function clearFilters(): void - { - $this->search = ''; - $this->tool = ''; - $this->workspace = ''; - $this->status = ''; - $this->sensitivity = ''; - $this->dateFrom = ''; - $this->dateTo = ''; - $this->resetPage(); - } - - public function getStatusBadgeClass(bool $success): string - { - return $success - ? 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300' - : 'bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300'; - } - - public function getSensitivityBadgeClass(bool $isSensitive): string - { - return $isSensitive - ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300' - : 'bg-zinc-100 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300'; - } - - private function checkHadesAccess(): void - { - if (! auth()->user()?->isHades()) { - abort(403, 'Hades access required'); - } - } - - public function render() - { - return view('mcp::admin.audit-log-viewer'); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/McpPlayground.php b/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/McpPlayground.php deleted file mode 100644 index 3b4c983..0000000 --- a/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/McpPlayground.php +++ /dev/null @@ -1,539 +0,0 @@ -loadConversationHistory(); - - // Auto-select first server if available - $servers = $this->getServers(); - if ($servers->isNotEmpty()) { - $this->selectedServer = $servers->first()['id']; - } - } - - /** - * Handle server selection change. - */ - public function updatedSelectedServer(): void - { - $this->selectedTool = null; - $this->toolInput = []; - $this->lastResponse = null; - $this->error = null; - $this->searchQuery = ''; - $this->selectedCategory = ''; - } - - /** - * Handle tool selection change. - */ - public function updatedSelectedTool(): void - { - $this->toolInput = []; - $this->lastResponse = null; - $this->error = null; - - if ($this->selectedTool) { - $this->loadExampleInputs(); - } - } - - /** - * Handle API key change. - */ - public function updatedApiKey(): void - { - $this->keyStatus = null; - $this->keyInfo = null; - } - - /** - * Validate the API key. - */ - public function validateKey(): void - { - $this->keyStatus = null; - $this->keyInfo = null; - - if (empty($this->apiKey)) { - $this->keyStatus = 'empty'; - - return; - } - - $key = ApiKey::findByPlainKey($this->apiKey); - - if (! $key) { - $this->keyStatus = 'invalid'; - - return; - } - - if ($key->isExpired()) { - $this->keyStatus = 'expired'; - - return; - } - - $this->keyStatus = 'valid'; - $this->keyInfo = [ - 'name' => $key->name, - 'scopes' => $key->scopes ?? [], - 'workspace' => $key->workspace?->name ?? 'Unknown', - 'last_used' => $key->last_used_at?->diffForHumans() ?? 'Never', - ]; - } - - /** - * Select a tool by name. - */ - public function selectTool(string $toolName): void - { - $this->selectedTool = $toolName; - $this->updatedSelectedTool(); - } - - /** - * Load example inputs for the selected tool. - */ - public function loadExampleInputs(): void - { - if (! $this->selectedTool) { - return; - } - - $tool = $this->getRegistry()->getTool($this->selectedServer, $this->selectedTool); - - if (! $tool) { - return; - } - - // Load example inputs - $examples = $tool['examples'] ?? []; - - // Also populate from schema defaults if no examples - if (empty($examples) && isset($tool['inputSchema']['properties'])) { - foreach ($tool['inputSchema']['properties'] as $name => $schema) { - if (isset($schema['default'])) { - $examples[$name] = $schema['default']; - } - } - } - - $this->toolInput = $examples; - } - - /** - * Execute the selected tool. - */ - public function execute(): void - { - if (! $this->selectedServer || ! $this->selectedTool) { - $this->error = 'Please select a server and tool.'; - - return; - } - - // Rate limiting: 10 executions per minute - $rateLimitKey = 'mcp-playground:'.$this->getRateLimitKey(); - if (RateLimiter::tooManyAttempts($rateLimitKey, 10)) { - $this->error = 'Too many requests. Please wait before trying again.'; - - return; - } - RateLimiter::hit($rateLimitKey, 60); - - $this->isExecuting = true; - $this->lastResponse = null; - $this->error = null; - - try { - $startTime = microtime(true); - - // Filter empty values from input - $args = array_filter($this->toolInput, fn ($v) => $v !== '' && $v !== null); - - // Type conversion for arguments - $args = $this->convertArgumentTypes($args); - - // Execute the tool - if ($this->keyStatus === 'valid') { - $result = $this->executeViaApi($args); - } else { - $result = $this->generateRequestPreview($args); - } - - $this->executionTime = (int) round((microtime(true) - $startTime) * 1000); - $this->lastResponse = $result; - - // Add to conversation history - $this->addToHistory([ - 'server' => $this->selectedServer, - 'tool' => $this->selectedTool, - 'input' => $args, - 'output' => $result, - 'success' => ! isset($result['error']), - 'duration_ms' => $this->executionTime, - 'timestamp' => now()->toIso8601String(), - ]); - - } catch (\Throwable $e) { - $this->error = $e->getMessage(); - $this->lastResponse = ['error' => $e->getMessage()]; - } finally { - $this->isExecuting = false; - } - } - - /** - * Re-run a historical execution. - */ - public function rerunFromHistory(int $index): void - { - if (! isset($this->conversationHistory[$index])) { - return; - } - - $entry = $this->conversationHistory[$index]; - - $this->selectedServer = $entry['server']; - $this->selectedTool = $entry['tool']; - $this->toolInput = $entry['input'] ?? []; - - $this->execute(); - } - - /** - * View a historical execution result. - */ - public function viewFromHistory(int $index): void - { - if (! isset($this->conversationHistory[$index])) { - return; - } - - $entry = $this->conversationHistory[$index]; - - $this->selectedServer = $entry['server']; - $this->selectedTool = $entry['tool']; - $this->toolInput = $entry['input'] ?? []; - $this->lastResponse = $entry['output'] ?? null; - $this->executionTime = $entry['duration_ms'] ?? 0; - } - - /** - * Clear conversation history. - */ - public function clearHistory(): void - { - $this->conversationHistory = []; - Session::forget(self::HISTORY_SESSION_KEY); - } - - /** - * Get available servers. - */ - #[Computed] - public function getServers(): \Illuminate\Support\Collection - { - return $this->getRegistry()->getServers(); - } - - /** - * Get tools for the selected server. - */ - #[Computed] - public function getTools(): \Illuminate\Support\Collection - { - if (empty($this->selectedServer)) { - return collect(); - } - - $tools = $this->getRegistry()->getToolsForServer($this->selectedServer); - - // Apply search filter - if (! empty($this->searchQuery)) { - $query = strtolower($this->searchQuery); - $tools = $tools->filter(function ($tool) use ($query) { - return str_contains(strtolower($tool['name']), $query) - || str_contains(strtolower($tool['description']), $query); - }); - } - - // Apply category filter - if (! empty($this->selectedCategory)) { - $tools = $tools->filter(fn ($tool) => $tool['category'] === $this->selectedCategory); - } - - return $tools->values(); - } - - /** - * Get tools grouped by category. - */ - #[Computed] - public function getToolsByCategory(): \Illuminate\Support\Collection - { - return $this->getTools()->groupBy('category')->sortKeys(); - } - - /** - * Get available categories. - */ - #[Computed] - public function getCategories(): \Illuminate\Support\Collection - { - if (empty($this->selectedServer)) { - return collect(); - } - - return $this->getRegistry() - ->getToolsForServer($this->selectedServer) - ->pluck('category') - ->unique() - ->sort() - ->values(); - } - - /** - * Get the current tool schema. - */ - #[Computed] - public function getCurrentTool(): ?array - { - if (! $this->selectedTool) { - return null; - } - - return $this->getRegistry()->getTool($this->selectedServer, $this->selectedTool); - } - - /** - * Check if user is authenticated. - */ - public function isAuthenticated(): bool - { - return auth()->check(); - } - - public function render() - { - return view('mcp::admin.mcp-playground', [ - 'servers' => $this->getServers(), - 'tools' => $this->getTools(), - 'toolsByCategory' => $this->getToolsByCategory(), - 'categories' => $this->getCategories(), - 'currentTool' => $this->getCurrentTool(), - 'isAuthenticated' => $this->isAuthenticated(), - ]); - } - - /** - * Get the tool registry service. - */ - protected function getRegistry(): ToolRegistry - { - return app(ToolRegistry::class); - } - - /** - * Get rate limit key based on user or IP. - */ - protected function getRateLimitKey(): string - { - if (auth()->check()) { - return 'user:'.auth()->id(); - } - - return 'ip:'.request()->ip(); - } - - /** - * Convert argument types based on their values. - */ - protected function convertArgumentTypes(array $args): array - { - foreach ($args as $key => $value) { - if (is_numeric($value)) { - $args[$key] = str_contains((string) $value, '.') ? (float) $value : (int) $value; - } - if ($value === 'true') { - $args[$key] = true; - } - if ($value === 'false') { - $args[$key] = false; - } - } - - return $args; - } - - /** - * Execute tool via HTTP API. - */ - protected function executeViaApi(array $args): array - { - $payload = [ - 'server' => $this->selectedServer, - 'tool' => $this->selectedTool, - 'arguments' => $args, - ]; - - $response = Http::withToken($this->apiKey) - ->timeout(30) - ->post(config('app.url').'/api/v1/mcp/tools/call', $payload); - - return [ - 'status' => $response->status(), - 'response' => $response->json(), - 'executed' => true, - ]; - } - - /** - * Generate a request preview without executing. - */ - protected function generateRequestPreview(array $args): array - { - $payload = [ - 'server' => $this->selectedServer, - 'tool' => $this->selectedTool, - 'arguments' => $args, - ]; - - return [ - 'request' => $payload, - 'note' => 'Add a valid API key to execute this request live.', - 'curl' => sprintf( - "curl -X POST %s/api/v1/mcp/tools/call \\\n -H \"Authorization: Bearer YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '%s'", - config('app.url'), - json_encode($payload, JSON_UNESCAPED_SLASHES) - ), - 'executed' => false, - ]; - } - - /** - * Load conversation history from session. - */ - protected function loadConversationHistory(): void - { - $this->conversationHistory = Session::get(self::HISTORY_SESSION_KEY, []); - } - - /** - * Add an entry to conversation history. - */ - protected function addToHistory(array $entry): void - { - // Prepend new entry - array_unshift($this->conversationHistory, $entry); - - // Keep only last N entries - $this->conversationHistory = array_slice($this->conversationHistory, 0, self::MAX_HISTORY_ENTRIES); - - // Save to session - Session::put(self::HISTORY_SESSION_KEY, $this->conversationHistory); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/Playground.php b/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/Playground.php deleted file mode 100644 index ccea82e..0000000 --- a/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/Playground.php +++ /dev/null @@ -1,263 +0,0 @@ -loadServers(); - } - - public function loadServers(): void - { - try { - $registry = $this->loadRegistry(); - $this->servers = collect($registry['servers'] ?? []) - ->map(fn ($ref) => $this->loadServerSummary($ref['id'])) - ->filter() - ->values() - ->toArray(); - } catch (\Throwable $e) { - $this->error = 'Failed to load servers'; - $this->servers = []; - } - } - - public function updatedSelectedServer(): void - { - $this->error = null; - $this->selectedTool = ''; - $this->toolSchema = null; - $this->arguments = []; - $this->response = ''; - - if (! $this->selectedServer) { - $this->tools = []; - - return; - } - - try { - $server = $this->loadServerFull($this->selectedServer); - $this->tools = $server['tools'] ?? []; - } catch (\Throwable $e) { - $this->error = 'Failed to load server tools'; - $this->tools = []; - } - } - - public function updatedSelectedTool(): void - { - $this->error = null; - $this->arguments = []; - $this->response = ''; - - if (! $this->selectedTool) { - $this->toolSchema = null; - - return; - } - - try { - $this->toolSchema = collect($this->tools)->firstWhere('name', $this->selectedTool); - - // Pre-fill arguments with defaults - $params = $this->toolSchema['inputSchema']['properties'] ?? []; - foreach ($params as $name => $schema) { - $this->arguments[$name] = $schema['default'] ?? ''; - } - } catch (\Throwable $e) { - $this->error = 'Failed to load tool schema'; - $this->toolSchema = null; - } - } - - public function updatedApiKey(): void - { - // Clear key status when key changes - $this->keyStatus = null; - $this->keyInfo = null; - } - - public function validateKey(): void - { - $this->keyStatus = null; - $this->keyInfo = null; - - if (empty($this->apiKey)) { - $this->keyStatus = 'empty'; - - return; - } - - $key = ApiKey::findByPlainKey($this->apiKey); - - if (! $key) { - $this->keyStatus = 'invalid'; - - return; - } - - if ($key->isExpired()) { - $this->keyStatus = 'expired'; - - return; - } - - $this->keyStatus = 'valid'; - $this->keyInfo = [ - 'name' => $key->name, - 'scopes' => $key->scopes, - 'server_scopes' => $key->getAllowedServers(), - 'workspace' => $key->workspace?->name ?? 'Unknown', - 'last_used' => $key->last_used_at?->diffForHumans() ?? 'Never', - ]; - } - - public function isAuthenticated(): bool - { - return auth()->check(); - } - - public function execute(): void - { - if (! $this->selectedServer || ! $this->selectedTool) { - return; - } - - $this->loading = true; - $this->response = ''; - $this->error = null; - - try { - // Filter out empty arguments - $args = array_filter($this->arguments, fn ($v) => $v !== '' && $v !== null); - - // Convert numeric strings to numbers where appropriate - foreach ($args as $key => $value) { - if (is_numeric($value)) { - $args[$key] = str_contains($value, '.') ? (float) $value : (int) $value; - } - if ($value === 'true') { - $args[$key] = true; - } - if ($value === 'false') { - $args[$key] = false; - } - } - - $payload = [ - 'server' => $this->selectedServer, - 'tool' => $this->selectedTool, - 'arguments' => $args, - ]; - - // If we have an API key, make a real request - if (! empty($this->apiKey) && $this->keyStatus === 'valid') { - $response = Http::withToken($this->apiKey) - ->timeout(30) - ->post(config('app.url').'/api/v1/mcp/tools/call', $payload); - - $this->response = json_encode([ - 'status' => $response->status(), - 'response' => $response->json(), - ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - - return; - } - - // Otherwise, just show request format - $this->response = json_encode([ - 'request' => $payload, - 'note' => 'Add an API key above to execute this request live.', - 'curl' => sprintf( - "curl -X POST %s/api/v1/mcp/tools/call \\\n -H \"Authorization: Bearer YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '%s'", - config('app.url'), - json_encode($payload, JSON_UNESCAPED_SLASHES) - ), - ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - } catch (\Throwable $e) { - $this->response = json_encode([ - 'error' => $e->getMessage(), - ], JSON_PRETTY_PRINT); - } finally { - $this->loading = false; - } - } - - public function render() - { - $isAuthenticated = $this->isAuthenticated(); - $workspace = $isAuthenticated ? auth()->user()?->defaultHostWorkspace() : null; - - return view('mcp::admin.playground', [ - 'isAuthenticated' => $isAuthenticated, - 'workspace' => $workspace, - ]); - } - - protected function loadRegistry(): array - { - $path = resource_path('mcp/registry.yaml'); - - return file_exists($path) ? Yaml::parseFile($path) : ['servers' => []]; - } - - protected function loadServerFull(string $id): ?array - { - $path = resource_path("mcp/servers/{$id}.yaml"); - - return file_exists($path) ? Yaml::parseFile($path) : null; - } - - protected function loadServerSummary(string $id): ?array - { - $server = $this->loadServerFull($id); - if (! $server) { - return null; - } - - return [ - 'id' => $server['id'], - 'name' => $server['name'], - 'tagline' => $server['tagline'] ?? '', - ]; - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/QuotaUsage.php b/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/QuotaUsage.php deleted file mode 100644 index 889afd1..0000000 --- a/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/QuotaUsage.php +++ /dev/null @@ -1,93 +0,0 @@ -workspaceId = $workspaceId ?? auth()->user()?->defaultHostWorkspace()?->id; - $this->usageHistory = collect(); - $this->loadQuotaData(); - } - - public function loadQuotaData(): void - { - if (! $this->workspaceId) { - return; - } - - $quotaService = app(McpQuotaService::class); - $workspace = Workspace::find($this->workspaceId); - - if (! $workspace) { - return; - } - - $this->currentUsage = $quotaService->getCurrentUsage($workspace); - $this->quotaLimits = $quotaService->getQuotaLimits($workspace); - $this->remaining = $quotaService->getRemainingQuota($workspace); - $this->usageHistory = $quotaService->getUsageHistory($workspace, 6); - } - - public function getToolCallsPercentageProperty(): float - { - if ($this->quotaLimits['tool_calls_unlimited'] ?? false) { - return 0; - } - - $limit = $this->quotaLimits['tool_calls_limit'] ?? 0; - if ($limit === 0) { - return 0; - } - - return min(100, round(($this->currentUsage['tool_calls_count'] ?? 0) / $limit * 100, 1)); - } - - public function getTokensPercentageProperty(): float - { - if ($this->quotaLimits['tokens_unlimited'] ?? false) { - return 0; - } - - $limit = $this->quotaLimits['tokens_limit'] ?? 0; - if ($limit === 0) { - return 0; - } - - return min(100, round(($this->currentUsage['total_tokens'] ?? 0) / $limit * 100, 1)); - } - - public function getResetDateProperty(): string - { - return now()->endOfMonth()->format('j F Y'); - } - - public function render() - { - return view('mcp::admin.quota-usage'); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/RequestLog.php b/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/RequestLog.php deleted file mode 100644 index 1927c28..0000000 --- a/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/RequestLog.php +++ /dev/null @@ -1,86 +0,0 @@ -resetPage(); - } - - public function updatedStatusFilter(): void - { - $this->resetPage(); - } - - public function selectRequest(int $id): void - { - $this->selectedRequestId = $id; - $this->selectedRequest = McpApiRequest::find($id); - } - - public function closeDetail(): void - { - $this->selectedRequestId = null; - $this->selectedRequest = null; - } - - public function render() - { - $workspace = auth()->user()?->defaultHostWorkspace(); - - $query = McpApiRequest::query() - ->orderByDesc('created_at'); - - if ($workspace) { - $query->forWorkspace($workspace->id); - } - - if ($this->serverFilter) { - $query->forServer($this->serverFilter); - } - - if ($this->statusFilter === 'success') { - $query->successful(); - } elseif ($this->statusFilter === 'failed') { - $query->failed(); - } - - $requests = $query->paginate(20); - - // Get unique servers for filter dropdown - $servers = McpApiRequest::query() - ->when($workspace, fn ($q) => $q->forWorkspace($workspace->id)) - ->distinct() - ->pluck('server_id') - ->filter() - ->values(); - - return view('mcp::admin.request-log', [ - 'requests' => $requests, - 'servers' => $servers, - ]); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/ToolAnalyticsDashboard.php b/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/ToolAnalyticsDashboard.php deleted file mode 100644 index 4676eeb..0000000 --- a/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/ToolAnalyticsDashboard.php +++ /dev/null @@ -1,249 +0,0 @@ -analyticsService = $analyticsService; - } - - /** - * Set the number of days to display. - */ - public function setDays(int $days): void - { - $this->days = max(1, min(90, $days)); - } - - /** - * Set the active tab. - */ - public function setTab(string $tab): void - { - $this->tab = $tab; - } - - /** - * Set the sort column and direction. - */ - public function sort(string $column): void - { - if ($this->sortColumn === $column) { - $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; - } else { - $this->sortColumn = $column; - $this->sortDirection = 'desc'; - } - } - - /** - * Set the workspace filter. - */ - public function setWorkspace(?string $workspaceId): void - { - $this->workspaceId = $workspaceId; - } - - /** - * Get the date range. - */ - protected function getDateRange(): array - { - return [ - 'from' => now()->subDays($this->days - 1)->startOfDay(), - 'to' => now()->endOfDay(), - ]; - } - - /** - * Get overview statistics. - */ - public function getOverviewProperty(): array - { - $range = $this->getDateRange(); - $stats = $this->getAllToolsProperty(); - - $totalCalls = $stats->sum(fn (ToolStats $s) => $s->totalCalls); - $totalErrors = $stats->sum(fn (ToolStats $s) => $s->errorCount); - $avgDuration = $totalCalls > 0 - ? $stats->sum(fn (ToolStats $s) => $s->avgDurationMs * $s->totalCalls) / $totalCalls - : 0; - - return [ - 'total_calls' => $totalCalls, - 'total_errors' => $totalErrors, - 'error_rate' => $totalCalls > 0 ? round(($totalErrors / $totalCalls) * 100, 2) : 0, - 'avg_duration_ms' => round($avgDuration, 2), - 'unique_tools' => $stats->count(), - ]; - } - - /** - * Get all tool statistics. - */ - public function getAllToolsProperty(): Collection - { - $range = $this->getDateRange(); - - return app(ToolAnalyticsService::class)->getAllToolStats($range['from'], $range['to']); - } - - /** - * Get sorted tool statistics for the table. - */ - public function getSortedToolsProperty(): Collection - { - $tools = $this->getAllToolsProperty(); - - return $tools->sortBy( - fn (ToolStats $s) => match ($this->sortColumn) { - 'toolName' => $s->toolName, - 'totalCalls' => $s->totalCalls, - 'errorCount' => $s->errorCount, - 'errorRate' => $s->errorRate, - 'avgDurationMs' => $s->avgDurationMs, - default => $s->totalCalls, - }, - SORT_REGULAR, - $this->sortDirection === 'desc' - )->values(); - } - - /** - * Get the most popular tools. - */ - public function getPopularToolsProperty(): Collection - { - $range = $this->getDateRange(); - - return app(ToolAnalyticsService::class)->getPopularTools(10, $range['from'], $range['to']); - } - - /** - * Get tools with high error rates. - */ - public function getErrorProneToolsProperty(): Collection - { - $range = $this->getDateRange(); - - return app(ToolAnalyticsService::class)->getErrorProneTools(10, $range['from'], $range['to']); - } - - /** - * Get tool combinations. - */ - public function getToolCombinationsProperty(): Collection - { - $range = $this->getDateRange(); - - return app(ToolAnalyticsService::class)->getToolCombinations(10, $range['from'], $range['to']); - } - - /** - * Get daily trends for charting. - */ - public function getDailyTrendsProperty(): array - { - $range = $this->getDateRange(); - $allStats = $this->getAllToolsProperty(); - - // Aggregate daily data - $dailyData = []; - for ($i = $this->days - 1; $i >= 0; $i--) { - $date = now()->subDays($i); - $dailyData[] = [ - 'date' => $date->toDateString(), - 'date_formatted' => $date->format('M j'), - 'calls' => 0, // Would need per-day aggregation - 'errors' => 0, - ]; - } - - return $dailyData; - } - - /** - * Get chart data for the top tools bar chart. - */ - public function getTopToolsChartDataProperty(): array - { - $tools = $this->getPopularToolsProperty()->take(10); - - return [ - 'labels' => $tools->pluck('toolName')->toArray(), - 'data' => $tools->pluck('totalCalls')->toArray(), - 'colors' => $tools->map(fn (ToolStats $t) => $t->errorRate > 10 ? '#ef4444' : '#3b82f6')->toArray(), - ]; - } - - /** - * Format duration for display. - */ - public function formatDuration(float $ms): string - { - if ($ms === 0.0) { - return '-'; - } - - if ($ms < 1000) { - return round($ms).'ms'; - } - - return round($ms / 1000, 2).'s'; - } - - public function render() - { - return view('mcp::admin.analytics.dashboard'); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/ToolAnalyticsDetail.php b/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/ToolAnalyticsDetail.php deleted file mode 100644 index e58f207..0000000 --- a/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/ToolAnalyticsDetail.php +++ /dev/null @@ -1,109 +0,0 @@ -toolName = $name; - } - - public function boot(ToolAnalyticsService $analyticsService): void - { - $this->analyticsService = $analyticsService; - } - - /** - * Set the number of days to display. - */ - public function setDays(int $days): void - { - $this->days = max(1, min(90, $days)); - } - - /** - * Get the tool statistics. - */ - public function getStatsProperty(): ToolStats - { - $from = now()->subDays($this->days - 1)->startOfDay(); - $to = now()->endOfDay(); - - return app(ToolAnalyticsService::class)->getToolStats($this->toolName, $from, $to); - } - - /** - * Get usage trends for the tool. - */ - public function getTrendsProperty(): array - { - return app(ToolAnalyticsService::class)->getUsageTrends($this->toolName, $this->days); - } - - /** - * Get chart data for the usage trend line chart. - */ - public function getTrendChartDataProperty(): array - { - $trends = $this->getTrendsProperty(); - - return [ - 'labels' => array_column($trends, 'date_formatted'), - 'calls' => array_column($trends, 'calls'), - 'errors' => array_column($trends, 'errors'), - 'avgDuration' => array_column($trends, 'avg_duration_ms'), - ]; - } - - /** - * Format duration for display. - */ - public function formatDuration(float $ms): string - { - if ($ms === 0.0) { - return '-'; - } - - if ($ms < 1000) { - return round($ms).'ms'; - } - - return round($ms / 1000, 2).'s'; - } - - public function render() - { - return view('mcp::admin.analytics.tool-detail'); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/ToolVersionManager.php b/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/ToolVersionManager.php deleted file mode 100644 index bea8367..0000000 --- a/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/ToolVersionManager.php +++ /dev/null @@ -1,349 +0,0 @@ -checkHadesAccess(); - } - - #[Computed] - public function versions(): LengthAwarePaginator - { - $query = McpToolVersion::query() - ->orderByDesc('created_at'); - - if ($this->search) { - $query->where(function ($q) { - $q->where('tool_name', 'like', "%{$this->search}%") - ->orWhere('server_id', 'like', "%{$this->search}%") - ->orWhere('version', 'like', "%{$this->search}%") - ->orWhere('description', 'like', "%{$this->search}%"); - }); - } - - if ($this->server) { - $query->forServer($this->server); - } - - if ($this->status === 'latest') { - $query->latest(); - } elseif ($this->status === 'deprecated') { - $query->deprecated(); - } elseif ($this->status === 'sunset') { - $query->sunset(); - } elseif ($this->status === 'active') { - $query->active()->where('is_latest', false); - } - - return $query->paginate($this->perPage); - } - - #[Computed] - public function servers(): Collection - { - return app(ToolVersionService::class)->getServersWithVersions(); - } - - #[Computed] - public function stats(): array - { - return app(ToolVersionService::class)->getStats(); - } - - #[Computed] - public function selectedVersion(): ?McpToolVersion - { - if (! $this->selectedVersionId) { - return null; - } - - return McpToolVersion::find($this->selectedVersionId); - } - - #[Computed] - public function versionHistory(): Collection - { - if (! $this->selectedVersion) { - return collect(); - } - - return app(ToolVersionService::class)->getVersionHistory( - $this->selectedVersion->server_id, - $this->selectedVersion->tool_name - ); - } - - #[Computed] - public function schemaComparison(): ?array - { - if (! $this->compareFromId || ! $this->compareToId) { - return null; - } - - $from = McpToolVersion::find($this->compareFromId); - $to = McpToolVersion::find($this->compareToId); - - if (! $from || ! $to) { - return null; - } - - return [ - 'from' => $from, - 'to' => $to, - 'changes' => $from->compareSchemaWith($to), - ]; - } - - // ------------------------------------------------------------------------- - // Actions - // ------------------------------------------------------------------------- - - public function viewVersion(int $id): void - { - $this->selectedVersionId = $id; - $this->showVersionDetail = true; - } - - public function closeVersionDetail(): void - { - $this->showVersionDetail = false; - $this->selectedVersionId = null; - } - - public function openCompareModal(int $fromId, int $toId): void - { - $this->compareFromId = $fromId; - $this->compareToId = $toId; - $this->showCompareModal = true; - } - - public function closeCompareModal(): void - { - $this->showCompareModal = false; - $this->compareFromId = null; - $this->compareToId = null; - } - - public function openDeprecateModal(int $versionId): void - { - $this->deprecateVersionId = $versionId; - $this->deprecateSunsetDate = ''; - $this->showDeprecateModal = true; - } - - public function closeDeprecateModal(): void - { - $this->showDeprecateModal = false; - $this->deprecateVersionId = null; - $this->deprecateSunsetDate = ''; - } - - public function deprecateVersion(): void - { - $version = McpToolVersion::find($this->deprecateVersionId); - if (! $version) { - return; - } - - $sunsetAt = $this->deprecateSunsetDate - ? Carbon::parse($this->deprecateSunsetDate) - : null; - - app(ToolVersionService::class)->deprecateVersion( - $version->server_id, - $version->tool_name, - $version->version, - $sunsetAt - ); - - $this->closeDeprecateModal(); - $this->dispatch('version-deprecated'); - } - - public function markAsLatest(int $versionId): void - { - $version = McpToolVersion::find($versionId); - if (! $version) { - return; - } - - $version->markAsLatest(); - $this->dispatch('version-marked-latest'); - } - - public function openRegisterModal(): void - { - $this->resetRegisterForm(); - $this->showRegisterModal = true; - } - - public function closeRegisterModal(): void - { - $this->showRegisterModal = false; - $this->resetRegisterForm(); - } - - public function registerVersion(): void - { - $this->validate([ - 'registerServer' => 'required|string|max:64', - 'registerTool' => 'required|string|max:128', - 'registerVersion' => 'required|string|max:32|regex:/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?$/', - 'registerDescription' => 'nullable|string|max:1000', - 'registerChangelog' => 'nullable|string|max:5000', - 'registerMigrationNotes' => 'nullable|string|max:5000', - 'registerInputSchema' => 'nullable|string', - ]); - - $inputSchema = null; - if ($this->registerInputSchema) { - $inputSchema = json_decode($this->registerInputSchema, true); - if (json_last_error() !== JSON_ERROR_NONE) { - $this->addError('registerInputSchema', 'Invalid JSON'); - - return; - } - } - - app(ToolVersionService::class)->registerVersion( - serverId: $this->registerServer, - toolName: $this->registerTool, - version: $this->registerVersion, - inputSchema: $inputSchema, - description: $this->registerDescription ?: null, - options: [ - 'changelog' => $this->registerChangelog ?: null, - 'migration_notes' => $this->registerMigrationNotes ?: null, - 'mark_latest' => $this->registerMarkLatest, - ] - ); - - $this->closeRegisterModal(); - $this->dispatch('version-registered'); - } - - public function clearFilters(): void - { - $this->search = ''; - $this->server = ''; - $this->status = ''; - $this->resetPage(); - } - - // ------------------------------------------------------------------------- - // Helpers - // ------------------------------------------------------------------------- - - public function getStatusBadgeColor(string $status): string - { - return match ($status) { - 'latest' => 'green', - 'active' => 'zinc', - 'deprecated' => 'amber', - 'sunset' => 'red', - default => 'zinc', - }; - } - - public function formatSchema(array $schema): string - { - return json_encode($schema, JSON_PRETTY_PRINT); - } - - private function resetRegisterForm(): void - { - $this->registerServer = ''; - $this->registerTool = ''; - $this->registerVersion = ''; - $this->registerDescription = ''; - $this->registerChangelog = ''; - $this->registerMigrationNotes = ''; - $this->registerInputSchema = ''; - $this->registerMarkLatest = false; - } - - private function checkHadesAccess(): void - { - if (! auth()->user()?->isHades()) { - abort(403, 'Hades access required'); - } - } - - public function render() - { - return view('mcp::admin.tool-version-manager'); - } -} diff --git a/packages/core-mcp/src/Website/Mcp/Boot.php b/packages/core-mcp/src/Website/Mcp/Boot.php deleted file mode 100644 index ea4035a..0000000 --- a/packages/core-mcp/src/Website/Mcp/Boot.php +++ /dev/null @@ -1,48 +0,0 @@ -loadViewsFrom(__DIR__.'/View/Blade', 'mcp'); - - $this->registerLivewireComponents(); - $this->registerRoutes(); - } - - protected function registerLivewireComponents(): void - { - Livewire::component('mcp.dashboard', View\Modal\Dashboard::class); - Livewire::component('mcp.api-key-manager', View\Modal\ApiKeyManager::class); - Livewire::component('mcp.api-explorer', View\Modal\ApiExplorer::class); - Livewire::component('mcp.mcp-metrics', View\Modal\McpMetrics::class); - Livewire::component('mcp.mcp-playground', View\Modal\McpPlayground::class); - Livewire::component('mcp.playground', View\Modal\Playground::class); - Livewire::component('mcp.request-log', View\Modal\RequestLog::class); - Livewire::component('mcp.unified-search', View\Modal\UnifiedSearch::class); - } - - protected function registerRoutes(): void - { - Route::middleware('web')->group(__DIR__.'/Routes/web.php'); - } -} diff --git a/packages/core-mcp/src/Website/Mcp/Controllers/McpRegistryController.php b/packages/core-mcp/src/Website/Mcp/Controllers/McpRegistryController.php deleted file mode 100644 index f0ad09c..0000000 --- a/packages/core-mcp/src/Website/Mcp/Controllers/McpRegistryController.php +++ /dev/null @@ -1,482 +0,0 @@ -environment('production') ? 600 : 0; - } - - /** - * Discovery endpoint: /.well-known/mcp-servers.json - * - * Returns the registry of all available MCP servers. - * This is the entry point for agent discovery. - */ - public function registry(Request $request) - { - $registry = $this->loadRegistry(); - - // Build server summaries for discovery - $servers = collect($registry['servers'] ?? []) - ->map(fn ($ref) => $this->loadServerSummary($ref['id'])) - ->filter() - ->values() - ->all(); - - $data = [ - 'servers' => $servers, - 'registry_version' => $registry['registry_version'] ?? '1.0', - 'organization' => $registry['organization'] ?? 'Host UK', - ]; - - // Always return JSON for .well-known - return response()->json($data); - } - - /** - * Server list page: /servers - * - * Shows all available servers (HTML) or returns JSON array. - */ - public function index(Request $request) - { - $registry = $this->loadRegistry(); - - $servers = collect($registry['servers'] ?? []) - ->map(fn ($ref) => $this->loadServerFull($ref['id'])) - ->filter() - ->values(); - - // Include planned servers for display - $plannedServers = collect($registry['planned_servers'] ?? []); - - if ($this->wantsJson($request)) { - return response()->json([ - 'servers' => $servers, - 'planned' => $plannedServers, - ]); - } - - return view('mcp::web.index', [ - 'servers' => $servers, - 'plannedServers' => $plannedServers, - ]); - } - - /** - * Server detail: /servers/{id} or /servers/{id}.json - * - * Returns full server definition with all tools, resources, workflows. - */ - public function show(Request $request, string $id) - { - // Remove .json extension if present - $id = preg_replace('/\.json$/', '', $id); - - $server = $this->loadServerFull($id); - - if (! $server) { - if ($this->wantsJson($request)) { - return response()->json(['error' => 'Server not found'], 404); - } - abort(404, 'Server not found'); - } - - if ($this->wantsJson($request)) { - return response()->json($server); - } - - return view('mcp::web.show', ['server' => $server]); - } - - /** - * Landing page: / - * - * MCP portal landing page for humans. - */ - public function landing(Request $request) - { - $registry = $this->loadRegistry(); - - $servers = collect($registry['servers'] ?? []) - ->map(fn ($ref) => $this->loadServerSummary($ref['id'])) - ->filter() - ->values(); - - $plannedServers = collect($registry['planned_servers'] ?? []); - - return view('mcp::web.landing', [ - 'servers' => $servers, - 'plannedServers' => $plannedServers, - 'organization' => $registry['organization'] ?? 'Host UK', - ]); - } - - /** - * Connection config generator: /connect - * - * Shows how to add MCP servers to Claude Code etc. - */ - public function connect(Request $request) - { - $registry = $this->loadRegistry(); - - $servers = collect($registry['servers'] ?? []) - ->map(fn ($ref) => $this->loadServerFull($ref['id'])) - ->filter() - ->values(); - - return view('mcp::web.connect', [ - 'servers' => $servers, - 'templates' => $registry['connection_templates'] ?? [], - 'workspace' => $request->attributes->get('mcp_workspace'), - ]); - } - - /** - * Dashboard: /dashboard - * - * Shows MCP usage for the authenticated workspace. - */ - public function dashboard(Request $request) - { - $workspace = $request->attributes->get('mcp_workspace'); - $entitlement = $request->attributes->get('mcp_entitlement'); - - // Get tool call stats for this workspace - $stats = $this->getWorkspaceStats($workspace); - - return view('mcp::web.dashboard', [ - 'workspace' => $workspace, - 'entitlement' => $entitlement, - 'stats' => $stats, - ]); - } - - /** - * API Keys management: /keys - * - * Manage API keys for MCP access. - */ - public function keys(Request $request) - { - $workspace = $request->attributes->get('mcp_workspace'); - - return view('mcp::web.keys', [ - 'workspace' => $workspace, - 'keys' => $workspace->apiKeys ?? collect(), - ]); - } - - /** - * Get MCP usage stats for a workspace. - */ - protected function getWorkspaceStats($workspace): array - { - $since = now()->subDays(30); - - // Use aggregate queries instead of loading all records into memory - $baseQuery = McpToolCall::where('created_at', '>=', $since); - - if ($workspace) { - $baseQuery->where('workspace_id', $workspace->id); - } - - $totalCalls = (clone $baseQuery)->count(); - $successfulCalls = (clone $baseQuery)->where('success', true)->count(); - - $byServer = (clone $baseQuery) - ->selectRaw('server_id, COUNT(*) as count') - ->groupBy('server_id') - ->orderByDesc('count') - ->limit(5) - ->pluck('count', 'server_id') - ->all(); - - $byDay = (clone $baseQuery) - ->selectRaw('DATE(created_at) as date, COUNT(*) as count') - ->groupBy('date') - ->orderBy('date') - ->pluck('count', 'date') - ->all(); - - return [ - 'total_calls' => $totalCalls, - 'successful_calls' => $successfulCalls, - 'by_server' => $byServer, - 'by_day' => $byDay, - ]; - } - - /** - * Usage analytics endpoint: /servers/{id}/analytics - * - * Shows tool usage stats for a specific server. - */ - public function analytics(Request $request, string $id) - { - $server = $this->loadServerFull($id); - - if (! $server) { - if ($this->wantsJson($request)) { - return response()->json(['error' => 'Server not found'], 404); - } - abort(404, 'Server not found'); - } - - // Validate days parameter - bound to reasonable range - $days = min(max($request->integer('days', 7), 1), 90); - - // Get tool call stats for this server - $stats = $this->getServerAnalytics($id, $days); - - if ($this->wantsJson($request)) { - return response()->json([ - 'server_id' => $id, - 'period_days' => $days, - 'stats' => $stats, - ]); - } - - return view('mcp::web.analytics', [ - 'server' => $server, - 'stats' => $stats, - 'days' => $days, - ]); - } - - /** - * OpenAPI specification. - * - * GET /openapi.json or /openapi.yaml - */ - public function openapi(Request $request) - { - $generator = new OpenApiGenerator; - $format = $request->query('format', 'json'); - - if ($format === 'yaml' || str_ends_with($request->path(), '.yaml')) { - return response($generator->toYaml()) - ->header('Content-Type', 'application/x-yaml'); - } - - return response()->json($generator->generate()); - } - - /** - * Get analytics for a specific server. - */ - protected function getServerAnalytics(string $serverId, int $days = 7): array - { - $since = now()->subDays($days); - - $baseQuery = McpToolCall::forServer($serverId) - ->where('created_at', '>=', $since); - - // Get aggregate stats without loading all records into memory - $totalCalls = (clone $baseQuery)->count(); - $successfulCalls = (clone $baseQuery)->where('success', true)->count(); - $failedCalls = $totalCalls - $successfulCalls; - $avgDuration = (clone $baseQuery)->avg('duration_ms') ?? 0; - - // Tool breakdown with aggregates - $byTool = (clone $baseQuery) - ->selectRaw('tool_name, COUNT(*) as calls, SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count, AVG(duration_ms) as avg_duration') - ->groupBy('tool_name') - ->orderByDesc('calls') - ->limit(10) - ->get() - ->mapWithKeys(fn ($row) => [ - $row->tool_name => [ - 'calls' => (int) $row->calls, - 'success_rate' => $row->calls > 0 - ? round($row->success_count / $row->calls * 100, 1) - : 0, - 'avg_duration_ms' => round($row->avg_duration ?? 0), - ], - ]) - ->all(); - - // Daily breakdown - $byDay = (clone $baseQuery) - ->selectRaw('DATE(created_at) as date, COUNT(*) as count') - ->groupBy('date') - ->orderBy('date') - ->pluck('count', 'date') - ->all(); - - // Error breakdown - $errors = (clone $baseQuery) - ->where('success', false) - ->whereNotNull('error_code') - ->selectRaw('error_code, COUNT(*) as count') - ->groupBy('error_code') - ->orderByDesc('count') - ->limit(5) - ->pluck('count', 'error_code') - ->all(); - - return [ - 'total_calls' => $totalCalls, - 'successful_calls' => $successfulCalls, - 'failed_calls' => $failedCalls, - 'success_rate' => $totalCalls > 0 ? round($successfulCalls / $totalCalls * 100, 1) : 0, - 'avg_duration_ms' => round($avgDuration), - 'by_tool' => $byTool, - 'by_day' => $byDay, - 'errors' => $errors, - ]; - } - - /** - * Load the main registry file. - */ - protected function loadRegistry(): array - { - return Cache::remember('mcp:registry', $this->getCacheTtl(), function () { - $path = resource_path('mcp/registry.yaml'); - - if (! file_exists($path)) { - return ['servers' => [], 'planned_servers' => []]; - } - - return Yaml::parseFile($path); - }); - } - - /** - * Load a server's YAML file. - */ - protected function loadServerYaml(string $id): ?array - { - // Sanitise server ID to prevent path traversal attacks - $id = basename($id, '.yaml'); - - // Validate ID format (alphanumeric with hyphens only) - if (! preg_match('/^[a-z0-9-]+$/', $id)) { - return null; - } - - return Cache::remember("mcp:server:{$id}", $this->getCacheTtl(), function () use ($id) { - $path = resource_path("mcp/servers/{$id}.yaml"); - - if (! file_exists($path)) { - return null; - } - - return Yaml::parseFile($path); - }); - } - - /** - * Load server summary for registry discovery. - * - * Returns minimal info: id, name, description, use_when, connection type. - */ - protected function loadServerSummary(string $id): ?array - { - $server = $this->loadServerYaml($id); - - if (! $server) { - return null; - } - - return [ - 'id' => $server['id'], - 'name' => $server['name'], - 'description' => $server['description'] ?? $server['tagline'] ?? '', - 'tagline' => $server['tagline'] ?? '', - 'icon' => $server['icon'] ?? 'server', - 'status' => $server['status'] ?? 'available', - 'use_when' => $server['use_when'] ?? [], - 'connection' => [ - 'type' => $server['connection']['type'] ?? 'stdio', - ], - 'capabilities' => $this->extractCapabilities($server), - 'related_servers' => $server['related_servers'] ?? [], - ]; - } - - /** - * Load full server definition for detail view. - */ - protected function loadServerFull(string $id): ?array - { - $server = $this->loadServerYaml($id); - - if (! $server) { - return null; - } - - // Add computed fields - $server['tool_count'] = count($server['tools'] ?? []); - $server['resource_count'] = count($server['resources'] ?? []); - $server['workflow_count'] = count($server['workflows'] ?? []); - $server['capabilities'] = $this->extractCapabilities($server); - - return $server; - } - - /** - * Extract capability summary from server definition. - */ - protected function extractCapabilities(array $server): array - { - $caps = []; - - if (! empty($server['tools'])) { - $caps[] = 'tools'; - } - - if (! empty($server['resources'])) { - $caps[] = 'resources'; - } - - return $caps; - } - - /** - * Check if request wants JSON response. - */ - protected function wantsJson(Request $request): bool - { - // Explicit .json extension - if (str_ends_with($request->path(), '.json')) { - return true; - } - - // Accept header - if ($request->wantsJson()) { - return true; - } - - // Query param override - if ($request->query('format') === 'json') { - return true; - } - - return false; - } -} diff --git a/packages/core-mcp/src/Website/Mcp/Routes/web.php b/packages/core-mcp/src/Website/Mcp/Routes/web.php deleted file mode 100644 index c6c575d..0000000 --- a/packages/core-mcp/src/Website/Mcp/Routes/web.php +++ /dev/null @@ -1,48 +0,0 @@ -name('mcp.')->group(function () { - // Agent discovery endpoint (always JSON) - Route::get('.well-known/mcp-servers.json', [McpRegistryController::class, 'registry']) - ->name('registry'); - - // Landing page - Route::get('/', [McpRegistryController::class, 'landing']) - ->middleware(McpAuthenticate::class.':optional') - ->name('landing'); - - // Server list (HTML/JSON based on Accept header) - Route::get('servers', [McpRegistryController::class, 'index']) - ->middleware(McpAuthenticate::class.':optional') - ->name('servers.index'); - - // Server detail (supports .json extension) - Route::get('servers/{id}', [McpRegistryController::class, 'show']) - ->middleware(McpAuthenticate::class.':optional') - ->name('servers.show') - ->where('id', '[a-z0-9-]+(?:\.json)?'); - - // Connection config page - Route::get('connect', [McpRegistryController::class, 'connect']) - ->middleware(McpAuthenticate::class.':optional') - ->name('connect'); - - // OpenAPI spec - Route::get('openapi.json', [McpRegistryController::class, 'openapi'])->name('openapi.json'); - Route::get('openapi.yaml', [McpRegistryController::class, 'openapi'])->name('openapi.yaml'); -}); diff --git a/packages/core-mcp/src/Website/Mcp/View/Blade/web/analytics.blade.php b/packages/core-mcp/src/Website/Mcp/View/Blade/web/analytics.blade.php deleted file mode 100644 index c6afc41..0000000 --- a/packages/core-mcp/src/Website/Mcp/View/Blade/web/analytics.blade.php +++ /dev/null @@ -1,115 +0,0 @@ - - {{ $server['name'] }} Analytics - -
- - -

{{ $server['name'] }} Analytics

-

- Tool usage statistics for the last {{ $days }} days. -

-
- - -
-
-

Total Calls

-

{{ number_format($stats['total_calls']) }}

-
-
-

Success Rate

-

- {{ $stats['success_rate'] }}% -

-
-
-

Successful

-

{{ number_format($stats['successful_calls']) }}

-
-
-

Failed

-

{{ number_format($stats['failed_calls']) }}

-
-
- - - @if(!empty($stats['by_tool'])) -
-

Tool Usage

-
- @foreach($stats['by_tool'] as $tool => $data) -
-
- {{ $tool }} -
-
- {{ $data['calls'] }} calls - - {{ $data['success_rate'] }}% success - - {{ $data['avg_duration_ms'] }}ms avg -
-
- @endforeach -
-
- @endif - - - @if(!empty($stats['by_day'])) -
-

Daily Activity

-
- @foreach($stats['by_day'] as $date => $count) -
- {{ $date }} -
-
- @php - $maxCalls = max($stats['by_day']); - $width = $maxCalls > 0 ? ($count / $maxCalls) * 100 : 0; - @endphp -
-
-
- {{ $count }} -
- @endforeach -
-
- @endif - - - @if(!empty($stats['errors'])) -
-

Error Breakdown

-
- @foreach($stats['errors'] as $code => $count) -
- {{ $code ?: 'Unknown' }} - {{ $count }} occurrences -
- @endforeach -
-
- @endif - - -
- Time range: - - @foreach([7, 14, 30] as $range) - - {{ $range }} days - - @endforeach - -
-
diff --git a/packages/core-mcp/src/Website/Mcp/View/Blade/web/api-explorer.blade.php b/packages/core-mcp/src/Website/Mcp/View/Blade/web/api-explorer.blade.php deleted file mode 100644 index 17595d7..0000000 --- a/packages/core-mcp/src/Website/Mcp/View/Blade/web/api-explorer.blade.php +++ /dev/null @@ -1,219 +0,0 @@ -
-
- -
-

API Explorer

-

Interactive documentation with code snippets in 11 languages

-
- - -
-
- - - -
- -
- - -
-

Enter your API key to enable live testing. Keys are not stored.

-
-
-
- -
- -
-
-
-

Endpoints

-
-
- @foreach($endpoints as $index => $endpoint) - - @endforeach -
-
-
- - -
- -
-
-

Request

-
-
-
- - -
- - @if(in_array($method, ['POST', 'PUT', 'PATCH'])) -
-
- - -
- -
- @endif - - -
-
- - -
-
-
-

Code Snippet

- -
-
- - -
-
- @foreach($languages as $lang) - - @endforeach -
-
- - -
-
{{ $snippet }}
-
-
- - - @if($error) -
-
- - - -
-

Error

-

{{ $error }}

-
-
-
- @endif - - @if($response) -
-
-
-

Response

- - {{ $response['status'] }} - -
- {{ $responseTime }}ms -
-
-
{{ json_encode($response['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) }}
-
-
- @endif -
-
- - - -
- - @script - - @endscript -
diff --git a/packages/core-mcp/src/Website/Mcp/View/Blade/web/api-key-manager.blade.php b/packages/core-mcp/src/Website/Mcp/View/Blade/web/api-key-manager.blade.php deleted file mode 100644 index 340a4eb..0000000 --- a/packages/core-mcp/src/Website/Mcp/View/Blade/web/api-key-manager.blade.php +++ /dev/null @@ -1,268 +0,0 @@ -
- - @if(session('message')) -
-

{{ session('message') }}

-
- @endif - - -
-
-

- API Keys -

-

- Create API keys to authenticate HTTP requests to MCP servers. -

-
- - Create Key - -
- - -
- @if($keys->isEmpty()) -
-
- -
-

No API Keys Yet

-

- Create an API key to start making authenticated requests to MCP servers over HTTP. -

- - Create Your First Key - -
- @else - - - - - - - - - - - - - @foreach($keys as $key) - - - - - - - - - @endforeach - -
- Name - - Key - - Scopes - - Last Used - - Expires - - Actions -
- {{ $key->name }} - - - {{ $key->prefix }}_**** - - -
- @foreach($key->scopes ?? [] as $scope) - - {{ $scope }} - - @endforeach -
-
- {{ $key->last_used_at?->diffForHumans() ?? 'Never' }} - - @if($key->expires_at) - @if($key->expires_at->isPast()) - Expired - @else - {{ $key->expires_at->diffForHumans() }} - @endif - @else - Never - @endif - - - Revoke - -
- @endif -
- - -
- -
-

- - Authentication -

-

- Include your API key in HTTP requests using one of these methods: -

-
-
-

Authorization Header (recommended)

-
Authorization: Bearer hk_abc123_****
-
-
-

X-API-Key Header

-
X-API-Key: hk_abc123_****
-
-
-
- - -
-

- - Example Request -

-

- Call an MCP tool via HTTP POST: -

-
curl -X POST https://mcp.host.uk.com/api/v1/tools/call \
-  -H "Authorization: Bearer YOUR_API_KEY" \
-  -H "Content-Type: application/json" \
-  -d '{
-    "server": "commerce",
-    "tool": "product_list",
-    "arguments": {}
-  }'
-
-
- - - -
-

Create API Key

- -
- -
- Key Name - - @error('newKeyName') -

{{ $message }}

- @enderror -
- - -
- Permissions -
- - - -
-
- - -
- Expiration - - - - - - -
-
- -
- Cancel - Create Key -
-
-
- - - -
-
-
- -
-

API Key Created

-
- -
-

- Copy this key now. You won't be able to see it again. -

-
- -
-
{{ $newPlainKey }}
- -
- -
- Done -
-
-
-
diff --git a/packages/core-mcp/src/Website/Mcp/View/Blade/web/connect.blade.php b/packages/core-mcp/src/Website/Mcp/View/Blade/web/connect.blade.php deleted file mode 100644 index 426972d..0000000 --- a/packages/core-mcp/src/Website/Mcp/View/Blade/web/connect.blade.php +++ /dev/null @@ -1,217 +0,0 @@ - - Setup Guide - -
-
-

Setup Guide

-

- Connect to Host UK MCP servers via HTTP API or stdio. -

-
- - - - - -
-
-
- -
-
-

HTTP API

- - Recommended - -
-
- -

- Call MCP tools from any language or platform using standard HTTP requests. - Perfect for external integrations, webhooks, and remote agents. -

- -

1. Get your API key

-

- Sign in to your Host UK account to create an API key from the admin dashboard. -

- -

2. Call a tool

-
curl -X POST https://mcp.host.uk.com/api/v1/mcp/tools/call \
-  -H "Authorization: Bearer YOUR_API_KEY" \
-  -H "Content-Type: application/json" \
-  -d '{
-    "server": "commerce",
-    "tool": "product_list",
-    "arguments": { "category": "hosting" }
-  }'
- -

3. List available tools

-
curl https://mcp.host.uk.com/api/v1/mcp/servers \
-  -H "Authorization: Bearer YOUR_API_KEY"
- -
-
-

API Endpoints

- - View OpenAPI Spec → - -
-
-
- GET /api/v1/mcp/servers - List all servers -
-
- GET /api/v1/mcp/servers/{id} - Server details -
-
- GET /api/v1/mcp/servers/{id}/tools - List tools -
-
- POST /api/v1/mcp/tools/call - Execute a tool -
-
- GET /api/v1/mcp/resources/{uri} - Read a resource -
-
-
-
- - -
-
-
- -
-
-

Stdio (Local)

- - For local development - -
-
- -

- Direct stdio connection for Claude Code and other local AI agents. - Ideal for OSS framework users running their own Host Hub instance. -

- -
- - Show stdio configuration - - -
- -
-

Claude Code

-

- Add to ~/.claude/claude_code_config.json: -

-
{
-  "mcpServers": {
-@foreach($servers as $server)
-    "{{ $server['id'] }}": {
-      "command": "{{ $server['connection']['command'] ?? 'php' }}",
-      "args": {!! json_encode($server['connection']['args'] ?? ['artisan', 'mcp:agent-server']) !!},
-      "cwd": "{{ $server['connection']['cwd'] ?? '/path/to/host.uk.com' }}"
-    }{{ !$loop->last ? ',' : '' }}
-@endforeach
-  }
-}
-
- - -
-

Cursor

-

- Add to .cursor/mcp.json: -

-
{
-  "mcpServers": {
-@foreach($servers as $server)
-    "{{ $server['id'] }}": {
-      "command": "{{ $server['connection']['command'] ?? 'php' }}",
-      "args": {!! json_encode($server['connection']['args'] ?? ['artisan', 'mcp:agent-server']) !!}
-    }{{ !$loop->last ? ',' : '' }}
-@endforeach
-  }
-}
-
- - -
-

Docker

-
{
-  "mcpServers": {
-    "hosthub-agent": {
-      "command": "docker",
-      "args": ["exec", "-i", "hosthub-app", "php", "artisan", "mcp:agent-server"]
-    }
-  }
-}
-
-
-
-
- - -
-

Authentication

- -
-
-

Authorization Header (Recommended)

-
Authorization: Bearer hk_abc123_your_key_here
-
- -
-

X-API-Key Header

-
X-API-Key: hk_abc123_your_key_here
-
-
- -
-

Server-scoped keys

-

- API keys can be restricted to specific MCP servers. If you get a 403 error, - check your key's server scopes in your admin dashboard. -

-
-
- - -
-

Need help setting up?

-
- - Browse Servers - - - Contact Support - -
-
-
-
diff --git a/packages/core-mcp/src/Website/Mcp/View/Blade/web/dashboard.blade.php b/packages/core-mcp/src/Website/Mcp/View/Blade/web/dashboard.blade.php deleted file mode 100644 index f530059..0000000 --- a/packages/core-mcp/src/Website/Mcp/View/Blade/web/dashboard.blade.php +++ /dev/null @@ -1,283 +0,0 @@ -
- -
-
- Upstream Intelligence - Track vendor updates and manage porting tasks -
-
- Refresh -
-
- - -
-
- Vendors - {{ $this->stats['total_vendors'] }} -
-
- Pending - {{ $this->stats['pending_todos'] }} -
-
- Quick Wins - {{ $this->stats['quick_wins'] }} -
-
- Security - {{ $this->stats['security_updates'] }} -
-
- In Progress - {{ $this->stats['in_progress'] }} -
-
- This Week - {{ $this->stats['recent_releases'] }} -
-
- - -
-
- Tracked Vendors -
-
-
- @foreach($this->vendors as $vendor) -
-
- {{ $vendor->getSourceTypeIcon() }} - {{ $vendor->name }} -
-
-
{{ $vendor->vendor_name }} · {{ $vendor->getSourceTypeLabel() }}
-
Version: {{ $vendor->current_version ?? 'Not set' }}
-
- {{ $vendor->todos_count }} todos - {{ $vendor->releases_count }} releases -
-
-
- @endforeach -
-
-
- - -
-
- - All Vendors - @foreach($this->vendors as $vendor) - {{ $vendor->name }} - @endforeach - - - - All Status - Pending - In Progress - Ported - Skipped - - - - All Types - Feature - Bugfix - Security - UI - Block - API - - - - All Effort - Low (<1hr) - Medium (1-4hr) - High (4+hr) - - - -
-
- - -
-
- Porting Tasks - {{ $this->todos->total() }} total -
-
- - - Type - Title - Vendor - Priority - Effort - Status - Actions - - - @forelse($this->todos as $todo) - - - {{ $todo->getTypeIcon() }} - - -
-
{{ $todo->title }}
- @if($todo->description) -
{{ Str::limit($todo->description, 80) }}
- @endif -
-
- - {{ $todo->vendor->name }} - - - - {{ $todo->priority }}/10 - - - - - {{ $todo->getEffortLabel() }} - - - - - {{ str_replace('_', ' ', $todo->status) }} - - - - @if($todo->status === 'pending') - Start - @elseif($todo->status === 'in_progress') -
- Done - Skip -
- @endif -
-
- @empty - - - No todos found matching filters - - - @endforelse -
-
-
- @if($this->todos->hasPages()) -
- {{ $this->todos->links() }} -
- @endif -
- - -
- -
-
- Asset Library -
- @if($this->assetStats['updates_available'] > 0) - {{ $this->assetStats['updates_available'] }} updates - @endif - {{ $this->assetStats['total'] }} assets -
-
-
-
- @foreach($this->assets as $asset) -
-
- {{ $asset->getTypeIcon() }} -
-
{{ $asset->name }}
-
- @if($asset->package_name) - {{ $asset->package_name }} - @endif -
-
-
-
- {{ $asset->getLicenseIcon() }} - @if($asset->installed_version) - - {{ $asset->installed_version }} - @if($asset->hasUpdate()) - → {{ $asset->latest_version }} - @endif - - @else - Not installed - @endif -
-
- @endforeach -
-
-
- - -
-
- Pattern Library - {{ $this->assetStats['patterns'] }} patterns -
-
-
- @foreach($this->patterns as $pattern) -
-
- {{ $pattern->getCategoryIcon() }} - {{ $pattern->name }} -
-
{{ $pattern->description }}
-
- {{ $pattern->language }} - @if($pattern->is_vetted) - Vetted - @endif -
-
- @endforeach -
-
-
-
- - -
-
- Recent Activity -
-
-
- @forelse($this->recentLogs as $log) -
- {{ $log->getActionIcon() }} - {{ $log->created_at->diffForHumans() }} - {{ $log->getActionLabel() }} - · - {{ $log->vendor->name }} - @if($log->error_message) - Error - @endif -
- @empty -
No recent activity
- @endforelse -
-
-
-
diff --git a/packages/core-mcp/src/Website/Mcp/View/Blade/web/index.blade.php b/packages/core-mcp/src/Website/Mcp/View/Blade/web/index.blade.php deleted file mode 100644 index 9f7a8bc..0000000 --- a/packages/core-mcp/src/Website/Mcp/View/Blade/web/index.blade.php +++ /dev/null @@ -1,126 +0,0 @@ - - MCP Servers - -
-

MCP Servers

-

- All available MCP servers for AI agent integration. -

-
- - -
- @forelse($servers as $server) -
-
-
-
-
- @switch($server['id']) - @case('hosthub-agent') - - @break - @case('commerce') - - @break - @case('socialhost') - - @break - @case('biohost') - - @break - @case('supporthost') - - @break - @case('analyticshost') - - @break - @default - - @endswitch -
-
-

- {{ $server['name'] }} -

-

{{ $server['id'] }}

-
-
- - {{ ucfirst($server['status'] ?? 'available') }} - -
- -

- {{ $server['tagline'] ?? $server['description'] ?? '' }} -

- - -
- - - {{ $server['tool_count'] ?? 0 }} tools - - - - {{ $server['resource_count'] ?? 0 }} resources - - @if(($server['workflow_count'] ?? 0) > 0) - - - {{ $server['workflow_count'] }} workflows - - @endif -
-
- - -
- @empty -
- -

No MCP servers available.

-
- @endforelse -
- - - @if($plannedServers->isNotEmpty()) -
-

Planned Servers

-
- @foreach($plannedServers as $server) -
-
-
- @switch($server['id']) - @case('upstream') - - @break - @case('analyticshost') - - @break - @default - - @endswitch -
-

{{ $server['name'] }}

-
-

{{ $server['tagline'] ?? '' }}

-
- @endforeach -
-
- @endif -
diff --git a/packages/core-mcp/src/Website/Mcp/View/Blade/web/keys.blade.php b/packages/core-mcp/src/Website/Mcp/View/Blade/web/keys.blade.php deleted file mode 100644 index 78463fb..0000000 --- a/packages/core-mcp/src/Website/Mcp/View/Blade/web/keys.blade.php +++ /dev/null @@ -1,6 +0,0 @@ - - API Keys - Manage API keys for MCP server access. - - - diff --git a/packages/core-mcp/src/Website/Mcp/View/Blade/web/landing.blade.php b/packages/core-mcp/src/Website/Mcp/View/Blade/web/landing.blade.php deleted file mode 100644 index 18a79a5..0000000 --- a/packages/core-mcp/src/Website/Mcp/View/Blade/web/landing.blade.php +++ /dev/null @@ -1,205 +0,0 @@ - - MCP Portal - Connect AI agents to Host UK infrastructure. Machine-readable, agent-optimised, human-friendly. - - -
-

- Host UK MCP Ecosystem -

-

- Connect AI agents to Host UK infrastructure.
- Machine-readable • - Agent-optimised • - Human-friendly -

-
- - Browse Servers - - - Setup Guide - -
-
- - -
-

Developer Tools

- -
- - -
-

Available Servers

- -
- - - @if($plannedServers->isNotEmpty()) -
-

Coming Soon

-
- @foreach($plannedServers as $server) -
-
-
- @switch($server['id']) - @case('analyticshost') - - @break - @case('upstream') - - @break - @default - - @endswitch -
- - Planned - -
-

- {{ $server['name'] }} -

-

- {{ $server['tagline'] ?? '' }} -

-
- @endforeach -
-
- @endif - - -
-

Quick Start

-

- Call MCP tools via HTTP API with your API key: -

-
curl -X POST https://mcp.host.uk.com/api/v1/mcp/tools/call \
-  -H "Authorization: Bearer YOUR_API_KEY" \
-  -H "Content-Type: application/json" \
-  -d '{
-    "server": "commerce",
-    "tool": "product_list",
-    "arguments": {}
-  }'
-
- - Full Setup Guide - - - OpenAPI Spec - -
-
-
diff --git a/packages/core-mcp/src/Website/Mcp/View/Blade/web/mcp-metrics.blade.php b/packages/core-mcp/src/Website/Mcp/View/Blade/web/mcp-metrics.blade.php deleted file mode 100644 index fd550bc..0000000 --- a/packages/core-mcp/src/Website/Mcp/View/Blade/web/mcp-metrics.blade.php +++ /dev/null @@ -1,309 +0,0 @@ -
- -
-
- MCP Agent Metrics - Monitor tool usage, performance, and errors -
-
- - 7 Days - 14 Days - 30 Days - - Refresh -
-
- - -
-
- Total Calls - {{ number_format($this->overview['total_calls']) }} - @if($this->overview['calls_trend_percent'] != 0) - - {{ $this->overview['calls_trend_percent'] > 0 ? '+' : '' }}{{ $this->overview['calls_trend_percent'] }}% - - @endif -
-
- Success Rate - {{ $this->overview['success_rate'] }}% -
-
- Successful - {{ number_format($this->overview['success_calls']) }} -
-
- Errors - {{ number_format($this->overview['error_calls']) }} -
-
- Avg Duration - {{ $this->overview['avg_duration_ms'] < 1000 ? $this->overview['avg_duration_ms'] . 'ms' : round($this->overview['avg_duration_ms'] / 1000, 2) . 's' }} -
-
- Unique Tools - {{ $this->overview['unique_tools'] }} -
-
- - -
- -
- - @if($activeTab === 'overview') -
- -
-
- Daily Call Volume -
-
-
- @foreach($this->dailyTrend as $day) -
- {{ $day['date_formatted'] }} -
-
- @php - $maxCalls = collect($this->dailyTrend)->max('total_calls') ?: 1; - $successWidth = ($day['total_success'] / $maxCalls) * 100; - $errorWidth = ($day['total_errors'] / $maxCalls) * 100; - @endphp -
-
-
-
-
- {{ $day['total_calls'] }} -
-
- @endforeach -
-
-
- - -
-
- Top Tools -
-
-
- @forelse($this->topTools as $tool) -
-
- {{ $tool->tool_name }} - {{ $tool->server_id }} -
-
- - {{ $tool->success_rate }}% - - {{ number_format($tool->total_calls) }} -
-
- @empty -
No tool calls recorded yet
- @endforelse -
-
-
- - -
-
- Server Breakdown -
-
- @forelse($this->serverStats as $server) -
-
- {{ $server->server_id }} - {{ $server->unique_tools }} tools -
-
- {{ number_format($server->total_success) }} - {{ number_format($server->total_errors) }} - {{ number_format($server->total_calls) }} -
-
- @empty -
No servers active yet
- @endforelse -
-
- - -
-
- Plan Activity -
-
- @forelse($this->planActivity as $plan) -
-
- {{ $plan->plan_slug }} - {{ $plan->unique_tools }} tools -
-
- - {{ $plan->success_rate }}% - - {{ number_format($plan->call_count) }} -
-
- @empty -
No plan activity recorded
- @endforelse -
-
-
- @endif - - @if($activeTab === 'performance') -
- -
-
- Tool Performance (p50 / p95 / p99) -
-
- - - - - - - - - - - - - - - @forelse($this->toolPerformance as $tool) - - - - - - - - - - - @empty - - - - @endforelse - -
ToolCallsMinAvgp50p95p99Max
{{ $tool['tool_name'] }}{{ number_format($tool['call_count']) }}{{ $tool['min_ms'] }}ms{{ round($tool['avg_ms']) }}ms{{ round($tool['p50_ms']) }}ms{{ round($tool['p95_ms']) }}ms{{ round($tool['p99_ms']) }}ms{{ $tool['max_ms'] }}ms
No performance data recorded yet
-
-
- - -
-
- Hourly Distribution (Last 24 Hours) -
-
-
- @php $maxHourly = collect($this->hourlyDistribution)->max('call_count') ?: 1; @endphp - @foreach($this->hourlyDistribution as $hour) -
-
- {{ $hour['hour_formatted'] }} -
- @endforeach -
-
-
-
- @endif - - @if($activeTab === 'errors') -
-
- Error Breakdown -
-
- - - - - - - - - - @forelse($this->errorBreakdown as $error) - - - - - - @empty - - - - @endforelse - -
ToolError CodeCount
{{ $error->tool_name }} - - {{ $error->error_code ?? 'unknown' }} - - {{ number_format($error->error_count) }}
No errors recorded - all systems healthy
-
-
- @endif - - @if($activeTab === 'activity') -
-
- Recent Activity -
-
- @forelse($this->recentCalls as $call) -
-
- -
- {{ $call['tool_name'] }} - @if($call['plan_slug']) - @ {{ $call['plan_slug'] }} - @endif - @if(!$call['success'] && $call['error_message']) -
{{ Str::limit($call['error_message'], 80) }}
- @endif -
-
-
- {{ $call['duration'] }} - {{ $call['created_at'] }} -
-
- @empty -
No activity recorded yet
- @endforelse -
-
- @endif -
diff --git a/packages/core-mcp/src/Website/Mcp/View/Blade/web/mcp-playground.blade.php b/packages/core-mcp/src/Website/Mcp/View/Blade/web/mcp-playground.blade.php deleted file mode 100644 index df3ea74..0000000 --- a/packages/core-mcp/src/Website/Mcp/View/Blade/web/mcp-playground.blade.php +++ /dev/null @@ -1,180 +0,0 @@ -
-
- -
-

MCP Tool Playground

-

Test MCP tool calls with custom parameters

-
- -
- -
-
-

Request

- - -
- - -
- - -
- - - @if($selectedTool) - @php $currentTool = collect($tools)->firstWhere('name', $selectedTool); @endphp - @if($currentTool && !empty($currentTool['purpose'])) -

{{ $currentTool['purpose'] }}

- @endif - @endif -
- - -
-
- - -
- - @error('inputJson') -

{{ $message }}

- @enderror -
- - - -
-
- - -
-
-
-

Response

- @if($executionTime > 0) - {{ $executionTime }}ms - @endif -
- - @if($lastError) -
-
-
- - - -
-
-

Error

-

{{ $lastError }}

-
-
-
- @endif - -
-
@if($lastResult){{ json_encode($lastResult, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) }}@else// Response will appear here...@endif
-
-
-
-
- - - @if($selectedTool && !empty($tools)) - @php $currentTool = collect($tools)->firstWhere('name', $selectedTool); @endphp - @if($currentTool && !empty($currentTool['parameters'])) -
-
-

Parameter Reference

-
- - - - - - - - - - - @foreach($currentTool['parameters'] as $paramName => $paramDef) - - - - - - - @endforeach - -
NameTypeRequiredDescription
{{ $paramName }}{{ is_array($paramDef) ? ($paramDef['type'] ?? 'string') : 'string' }} - @if(is_array($paramDef) && ($paramDef['required'] ?? false)) - Required - @else - Optional - @endif - {{ is_array($paramDef) ? ($paramDef['description'] ?? '-') : $paramDef }}
-
-
-
- @endif - @endif - - - -
-
diff --git a/packages/core-mcp/src/Website/Mcp/View/Blade/web/playground.blade.php b/packages/core-mcp/src/Website/Mcp/View/Blade/web/playground.blade.php deleted file mode 100644 index 0554f9d..0000000 --- a/packages/core-mcp/src/Website/Mcp/View/Blade/web/playground.blade.php +++ /dev/null @@ -1,274 +0,0 @@ -
-
-

Playground

-

- Test MCP tools interactively and execute requests live. -

-
- - {{-- Error Display --}} - @if($error) -
-
- -

{{ $error }}

-
-
- @endif - -
- -
- -
-

Authentication

- -
-
- -
- -
- - Validate Key - - - @if($keyStatus === 'valid') - - - Valid - - @elseif($keyStatus === 'invalid') - - - Invalid key - - @elseif($keyStatus === 'expired') - - - Expired - - @elseif($keyStatus === 'empty') - - Enter a key to validate - - @endif -
- - @if($keyInfo) -
-
-
- Name: - {{ $keyInfo['name'] }} -
-
- Workspace: - {{ $keyInfo['workspace'] }} -
-
- Scopes: - {{ implode(', ', $keyInfo['scopes'] ?? []) }} -
-
- Last used: - {{ $keyInfo['last_used'] }} -
-
-
- @elseif(!$isAuthenticated && !$apiKey) -
-

- Sign in - to create API keys, or paste an existing key above. -

-
- @endif -
-
- - -
-

Select Tool

- -
- - @foreach($servers as $server) - {{ $server['name'] }} - @endforeach - - - @if($selectedServer && count($tools) > 0) - - @foreach($tools as $tool) - {{ $tool['name'] }} - @endforeach - - @endif -
-
- - - @if($toolSchema) -
-
-

{{ $toolSchema['name'] }}

-

{{ $toolSchema['description'] ?? $toolSchema['purpose'] ?? '' }}

-
- - @php - $params = $toolSchema['inputSchema']['properties'] ?? $toolSchema['parameters'] ?? []; - $required = $toolSchema['inputSchema']['required'] ?? []; - @endphp - - @if(count($params) > 0) -
-

Arguments

- - @foreach($params as $name => $schema) -
- @php - $paramRequired = in_array($name, $required) || ($schema['required'] ?? false); - $paramType = is_array($schema['type'] ?? 'string') ? ($schema['type'][0] ?? 'string') : ($schema['type'] ?? 'string'); - @endphp - - @if(isset($schema['enum'])) - - @foreach($schema['enum'] as $option) - {{ $option }} - @endforeach - - @elseif($paramType === 'boolean') - - true - false - - @elseif($paramType === 'integer' || $paramType === 'number') - - @else - - @endif -
- @endforeach -
- @else -

This tool has no arguments.

- @endif - -
- - - @if($keyStatus === 'valid') - Execute Request - @else - Generate Request - @endif - - Executing... - -
-
- @endif -
- - -
-
-

Response

- - @if($response) -
-
- -
-
{{ $response }}
-
- @else -
- -

Select a server and tool to get started.

-
- @endif -
- - -
-

API Reference

-
-
- Endpoint: - {{ config('app.url') }}/api/v1/mcp/tools/call -
-
- Method: - POST -
-
- Auth: - @if($keyStatus === 'valid') - Bearer {{ Str::limit($apiKey, 20, '...') }} - @else - Bearer <your-api-key> - @endif -
-
- Content-Type: - application/json -
-
- -
-
-
-
- -@script - -@endscript diff --git a/packages/core-mcp/src/Website/Mcp/View/Blade/web/request-log.blade.php b/packages/core-mcp/src/Website/Mcp/View/Blade/web/request-log.blade.php deleted file mode 100644 index fc6a27b..0000000 --- a/packages/core-mcp/src/Website/Mcp/View/Blade/web/request-log.blade.php +++ /dev/null @@ -1,153 +0,0 @@ -
-
-

Request Log

-

- View API requests and generate curl commands to replay them. -

-
- - -
-
-
- - -
-
- - -
-
-
- -
- -
-
- @forelse($requests as $request) - - @empty -
- No requests found. -
- @endforelse -
- - @if($requests->hasPages()) -
- {{ $requests->links() }} -
- @endif -
- - -
- @if($selectedRequest) -
-

Request Detail

- -
- -
- -
- - - {{ $selectedRequest->response_status }} - {{ $selectedRequest->isSuccessful() ? 'OK' : 'Error' }} - -
- - -
- -
{{ json_encode($selectedRequest->request_body, JSON_PRETTY_PRINT) }}
-
- - -
- -
{{ json_encode($selectedRequest->response_body, JSON_PRETTY_PRINT) }}
-
- - @if($selectedRequest->error_message) -
- -
{{ $selectedRequest->error_message }}
-
- @endif - - -
- -
{{ $selectedRequest->toCurl() }}
-
- - -
-
Request ID: {{ $selectedRequest->request_id }}
-
Duration: {{ $selectedRequest->duration_for_humans }}
-
IP: {{ $selectedRequest->ip_address ?? 'N/A' }}
-
Time: {{ $selectedRequest->created_at->format('Y-m-d H:i:s') }}
-
-
- @else -
- -

Select a request to view details and generate replay commands.

-
- @endif -
-
-
diff --git a/packages/core-mcp/src/Website/Mcp/View/Blade/web/show.blade.php b/packages/core-mcp/src/Website/Mcp/View/Blade/web/show.blade.php deleted file mode 100644 index 9c2b9e7..0000000 --- a/packages/core-mcp/src/Website/Mcp/View/Blade/web/show.blade.php +++ /dev/null @@ -1,227 +0,0 @@ - - {{ $server['name'] }} - {{ $server['tagline'] ?? $server['description'] ?? '' }} - - -
- - -
-
-
- @switch($server['id']) - @case('hosthub-agent') - - @break - @case('commerce') - - @break - @case('socialhost') - - @break - @case('biohost') - - @break - @case('supporthost') - - @break - @case('analyticshost') - - @break - @case('upstream') - - @break - @default - - @endswitch -
-
-

{{ $server['name'] }}

-

{{ $server['id'] }}

-
-
- - {{ ucfirst($server['status'] ?? 'available') }} - -
- -

- {{ $server['tagline'] ?? '' }} -

-
- - - @if(!empty($server['description'])) -
-

About

-
- {!! nl2br(e($server['description'])) !!} -
-
- @endif - - -
- @if(!empty($server['use_when'])) -
-

- - Use when -

-
    - @foreach($server['use_when'] as $item) -
  • • {{ $item }}
  • - @endforeach -
-
- @endif - - @if(!empty($server['dont_use_when'])) -
-

- - Don't use when -

-
    - @foreach($server['dont_use_when'] as $item) -
  • • {{ $item }}
  • - @endforeach -
-
- @endif -
- - - @if(!empty($server['connection'])) -
-

Connection

-
{
-  "{{ $server['id'] }}": {
-    "command": "{{ $server['connection']['command'] ?? 'php' }}",
-    "args": {!! json_encode($server['connection']['args'] ?? ['artisan', 'mcp:agent-server']) !!},
-    "cwd": "{{ $server['connection']['cwd'] ?? '/path/to/project' }}"
-  }
-}
-
- @endif - - - @if(!empty($server['tools'])) -
-

- Tools ({{ count($server['tools']) }}) -

-
- @foreach($server['tools'] as $tool) -
-
-

- {{ $tool['name'] }} -

-
-

- {{ $tool['purpose'] ?? '' }} -

- - @if(!empty($tool['example_prompts'])) -
-

Example prompts:

-
    - @foreach(array_slice($tool['example_prompts'], 0, 3) as $prompt) -
  • "{{ $prompt }}"
  • - @endforeach -
-
- @endif - - @if(!empty($tool['parameters'])) -
- - Parameters - -
- @foreach($tool['parameters'] as $name => $param) -
- {{ $name }} - @if(!empty($param['required'])) - * - @endif - - {{ is_array($param['type'] ?? '') ? implode('|', $param['type']) : ($param['type'] ?? 'string') }} - - @if(!empty($param['description'])) -

{{ $param['description'] }}

- @endif -
- @endforeach -
-
- @endif -
- @endforeach -
-
- @endif - - - @if(!empty($server['resources'])) -
-

- Resources ({{ count($server['resources']) }}) -

-
- @foreach($server['resources'] as $resource) -
- -
-

{{ $resource['uri'] }}

-

{{ $resource['purpose'] ?? $resource['name'] ?? '' }}

-
-
- @endforeach -
-
- @endif - - - @if(!empty($server['workflows'])) -
-

- Workflows ({{ count($server['workflows']) }}) -

-
- @foreach($server['workflows'] as $workflow) -
-

{{ $workflow['name'] }}

-

{{ $workflow['description'] ?? '' }}

- @if(!empty($workflow['steps'])) -
    - @foreach($workflow['steps'] as $index => $step) -
  1. - {{ $step['action'] }} - @if(!empty($step['note'])) - — {{ $step['note'] }} - @endif -
  2. - @endforeach -
- @endif -
- @endforeach -
-
- @endif - - - -
diff --git a/packages/core-mcp/src/Website/Mcp/View/Blade/web/unified-search.blade.php b/packages/core-mcp/src/Website/Mcp/View/Blade/web/unified-search.blade.php deleted file mode 100644 index db7d1b3..0000000 --- a/packages/core-mcp/src/Website/Mcp/View/Blade/web/unified-search.blade.php +++ /dev/null @@ -1,202 +0,0 @@ -
-
- -
-

Search

-

Find tools, endpoints, patterns, and more across the system

-
- - -
-
-
-
- - - -
- -
-
- - -
-
- Filter: - @foreach($this->types as $typeKey => $typeInfo) - - @endforeach - - @if(count($selectedTypes) > 0) - - @endif -
-
-
- - - @if(strlen($query) >= 2) - - - - @if($this->results->count() > 0) -
- Showing {{ $this->results->count() }} result{{ $this->results->count() !== 1 ? 's' : '' }} -
- @endif - @else - -
- - - -

Start searching

-

Type at least 2 characters to search across all system components.

-
- @foreach($this->types as $typeKey => $typeInfo) - - {{ $typeInfo['name'] }} - - @endforeach -
-
- @endif - - - -
-
diff --git a/packages/core-mcp/src/Website/Mcp/View/Modal/ApiExplorer.php b/packages/core-mcp/src/Website/Mcp/View/Modal/ApiExplorer.php deleted file mode 100644 index 1c7bd24..0000000 --- a/packages/core-mcp/src/Website/Mcp/View/Modal/ApiExplorer.php +++ /dev/null @@ -1,271 +0,0 @@ - 'List Workspaces', - 'method' => 'GET', - 'path' => '/api/v1/workspaces', - 'description' => 'Get all workspaces for the authenticated user', - 'body' => null, - ], - [ - 'name' => 'Create Workspace', - 'method' => 'POST', - 'path' => '/api/v1/workspaces', - 'description' => 'Create a new workspace', - 'body' => ['name' => 'My Workspace', 'description' => 'A new workspace'], - ], - [ - 'name' => 'Get Workspace', - 'method' => 'GET', - 'path' => '/api/v1/workspaces/{id}', - 'description' => 'Get a specific workspace by ID', - 'body' => null, - ], - [ - 'name' => 'Update Workspace', - 'method' => 'PATCH', - 'path' => '/api/v1/workspaces/{id}', - 'description' => 'Update workspace details', - 'body' => ['name' => 'Updated Workspace', 'settings' => ['timezone' => 'UTC']], - ], - [ - 'name' => 'List Namespaces', - 'method' => 'GET', - 'path' => '/api/v1/namespaces', - 'description' => 'Get all namespaces accessible to the user', - 'body' => null, - ], - [ - 'name' => 'Check Entitlement', - 'method' => 'POST', - 'path' => '/api/v1/namespaces/{id}/entitlements/check', - 'description' => 'Check if a namespace has access to a feature', - 'body' => ['feature' => 'storage', 'quantity' => 1073741824], - ], - [ - 'name' => 'List API Keys', - 'method' => 'GET', - 'path' => '/api/v1/api-keys', - 'description' => 'Get all API keys for the workspace', - 'body' => null, - ], - [ - 'name' => 'Create API Key', - 'method' => 'POST', - 'path' => '/api/v1/api-keys', - 'description' => 'Create a new API key', - 'body' => ['name' => 'Production Key', 'scopes' => ['read:all'], 'rate_limit_tier' => 'pro'], - ], - ]; - - protected ApiSnippetService $snippetService; - - public function boot(ApiSnippetService $snippetService): void - { - $this->snippetService = $snippetService; - } - - public function mount(): void - { - // Set base URL from config - $this->baseUrl = config('api.base_url', config('app.url')); - - // Pre-select first endpoint - if (! empty($this->endpoints)) { - $this->selectEndpoint(0); - } - } - - public function selectEndpoint(int $index): void - { - if (! isset($this->endpoints[$index])) { - return; - } - - $endpoint = $this->endpoints[$index]; - $this->selectedEndpoint = (string) $index; - $this->method = $endpoint['method']; - $this->path = $endpoint['path']; - $this->bodyJson = $endpoint['body'] - ? json_encode($endpoint['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) - : '{}'; - $this->response = null; - $this->error = null; - } - - public function getCodeSnippet(): string - { - $headers = [ - 'Authorization' => 'Bearer '.($this->apiKey ?: 'YOUR_API_KEY'), - 'Content-Type' => 'application/json', - 'Accept' => 'application/json', - ]; - - $body = null; - if (in_array($this->method, ['POST', 'PUT', 'PATCH']) && $this->bodyJson !== '{}') { - $body = json_decode($this->bodyJson, true); - } - - return $this->snippetService->generate( - $this->selectedLanguage, - $this->method, - $this->path, - $headers, - $body, - $this->baseUrl - ); - } - - public function getAllSnippets(): array - { - $headers = [ - 'Authorization' => 'Bearer '.($this->apiKey ?: 'YOUR_API_KEY'), - 'Content-Type' => 'application/json', - 'Accept' => 'application/json', - ]; - - $body = null; - if (in_array($this->method, ['POST', 'PUT', 'PATCH']) && $this->bodyJson !== '{}') { - $body = json_decode($this->bodyJson, true); - } - - return $this->snippetService->generateAll( - $this->method, - $this->path, - $headers, - $body, - $this->baseUrl - ); - } - - public function copyToClipboard(): void - { - $this->dispatch('copy-to-clipboard', code: $this->getCodeSnippet()); - } - - public function sendRequest(): void - { - if (empty($this->apiKey)) { - $this->error = 'Please enter your API key to send requests'; - - return; - } - - $this->isLoading = true; - $this->response = null; - $this->error = null; - - try { - $startTime = microtime(true); - - $url = rtrim($this->baseUrl, '/').'/'.ltrim($this->path, '/'); - - $options = [ - 'http' => [ - 'method' => $this->method, - 'header' => [ - "Authorization: Bearer {$this->apiKey}", - 'Content-Type: application/json', - 'Accept: application/json', - ], - 'timeout' => 30, - 'ignore_errors' => true, - ], - ]; - - if (in_array($this->method, ['POST', 'PUT', 'PATCH']) && $this->bodyJson !== '{}') { - $options['http']['content'] = $this->bodyJson; - } - - $context = stream_context_create($options); - $result = @file_get_contents($url, false, $context); - - $this->responseTime = (int) round((microtime(true) - $startTime) * 1000); - - if ($result === false) { - $this->error = 'Request failed - check your API key and endpoint'; - - return; - } - - // Parse response headers - $statusCode = 200; - if (isset($http_response_header[0])) { - preg_match('/HTTP\/\d+\.?\d* (\d+)/', $http_response_header[0], $matches); - $statusCode = (int) ($matches[1] ?? 200); - } - - $this->response = [ - 'status' => $statusCode, - 'body' => json_decode($result, true) ?? $result, - 'headers' => $http_response_header ?? [], - ]; - - } catch (\Exception $e) { - $this->error = $e->getMessage(); - } finally { - $this->isLoading = false; - } - } - - public function formatBody(): void - { - try { - $decoded = json_decode($this->bodyJson, true); - if (json_last_error() === JSON_ERROR_NONE) { - $this->bodyJson = json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - } - } catch (\Exception $e) { - // Ignore - } - } - - public function render() - { - return view('mcp::web.api-explorer', [ - 'languages' => ApiSnippetService::getLanguages(), - 'snippet' => $this->getCodeSnippet(), - ]); - } -} diff --git a/packages/core-mcp/src/Website/Mcp/View/Modal/ApiKeyManager.php b/packages/core-mcp/src/Website/Mcp/View/Modal/ApiKeyManager.php deleted file mode 100644 index a41114f..0000000 --- a/packages/core-mcp/src/Website/Mcp/View/Modal/ApiKeyManager.php +++ /dev/null @@ -1,110 +0,0 @@ -workspace = $workspace; - } - - public function openCreateModal(): void - { - $this->showCreateModal = true; - $this->newKeyName = ''; - $this->newKeyScopes = ['read', 'write']; - $this->newKeyExpiry = 'never'; - } - - public function closeCreateModal(): void - { - $this->showCreateModal = false; - } - - public function createKey(): void - { - $this->validate([ - 'newKeyName' => 'required|string|max:100', - ]); - - $expiresAt = match ($this->newKeyExpiry) { - '30days' => now()->addDays(30), - '90days' => now()->addDays(90), - '1year' => now()->addYear(), - default => null, - }; - - $result = ApiKey::generate( - workspaceId: $this->workspace->id, - userId: auth()->id(), - name: $this->newKeyName, - scopes: $this->newKeyScopes, - expiresAt: $expiresAt, - ); - - $this->newPlainKey = $result['plain_key']; - $this->showCreateModal = false; - $this->showNewKeyModal = true; - - session()->flash('message', 'API key created successfully.'); - } - - public function closeNewKeyModal(): void - { - $this->newPlainKey = null; - $this->showNewKeyModal = false; - } - - public function revokeKey(int $keyId): void - { - $key = $this->workspace->apiKeys()->findOrFail($keyId); - $key->revoke(); - - session()->flash('message', 'API key revoked.'); - } - - public function toggleScope(string $scope): void - { - if (in_array($scope, $this->newKeyScopes)) { - $this->newKeyScopes = array_values(array_diff($this->newKeyScopes, [$scope])); - } else { - $this->newKeyScopes[] = $scope; - } - } - - public function render() - { - return view('mcp::web.api-key-manager', [ - 'keys' => $this->workspace->apiKeys()->orderByDesc('created_at')->get(), - ]); - } -} diff --git a/packages/core-mcp/src/Website/Mcp/View/Modal/Dashboard.php b/packages/core-mcp/src/Website/Mcp/View/Modal/Dashboard.php deleted file mode 100644 index 1138fda..0000000 --- a/packages/core-mcp/src/Website/Mcp/View/Modal/Dashboard.php +++ /dev/null @@ -1,188 +0,0 @@ -resetPage(); - } - - public function updatingTypeFilter(): void - { - $this->resetPage(); - } - - public function updatingStatusFilter(): void - { - $this->resetPage(); - } - - public function getVendorsProperty() - { - try { - return Vendor::active()->withCount(['todos', 'releases'])->get(); - } catch (\Illuminate\Database\QueryException $e) { - return collect(); - } - } - - public function getStatsProperty(): array - { - try { - return [ - 'total_vendors' => Vendor::active()->count(), - 'pending_todos' => UpstreamTodo::pending()->count(), - 'quick_wins' => UpstreamTodo::quickWins()->count(), - 'security_updates' => UpstreamTodo::pending()->where('type', 'security')->count(), - 'recent_releases' => \Mod\Uptelligence\Models\VersionRelease::recent(7)->count(), - 'in_progress' => UpstreamTodo::inProgress()->count(), - ]; - } catch (\Illuminate\Database\QueryException $e) { - return [ - 'total_vendors' => 0, - 'pending_todos' => 0, - 'quick_wins' => 0, - 'security_updates' => 0, - 'recent_releases' => 0, - 'in_progress' => 0, - ]; - } - } - - public function getTodosProperty() - { - try { - $query = UpstreamTodo::with('vendor') - ->orderByDesc('priority') - ->orderBy('effort'); - - if ($this->vendorFilter) { - $query->where('vendor_id', $this->vendorFilter); - } - - if ($this->typeFilter) { - $query->where('type', $this->typeFilter); - } - - if ($this->statusFilter) { - $query->where('status', $this->statusFilter); - } - - if ($this->effortFilter) { - $query->where('effort', $this->effortFilter); - } - - if ($this->quickWinsOnly) { - $query->where('effort', 'low')->where('priority', '>=', 5); - } - - return $query->paginate(15); - } catch (\Illuminate\Database\QueryException $e) { - return new \Illuminate\Pagination\LengthAwarePaginator([], 0, 15); - } - } - - public function getRecentLogsProperty() - { - try { - return AnalysisLog::with('vendor') - ->latest() - ->limit(10) - ->get(); - } catch (\Illuminate\Database\QueryException $e) { - return collect(); - } - } - - public function getAssetsProperty() - { - try { - return Asset::active()->orderBy('type')->get(); - } catch (\Illuminate\Database\QueryException $e) { - return collect(); - } - } - - public function getPatternsProperty() - { - try { - return Pattern::active()->orderBy('category')->limit(6)->get(); - } catch (\Illuminate\Database\QueryException $e) { - return collect(); - } - } - - public function getAssetStatsProperty(): array - { - try { - return [ - 'total' => Asset::active()->count(), - 'updates_available' => Asset::active()->needsUpdate()->count(), - 'patterns' => Pattern::active()->count(), - ]; - } catch (\Illuminate\Database\QueryException $e) { - return [ - 'total' => 0, - 'updates_available' => 0, - 'patterns' => 0, - ]; - } - } - - public function markInProgress(int $todoId): void - { - $todo = UpstreamTodo::findOrFail($todoId); - $todo->markInProgress(); - } - - public function markPorted(int $todoId): void - { - $todo = UpstreamTodo::findOrFail($todoId); - $todo->markPorted(); - } - - public function markSkipped(int $todoId): void - { - $todo = UpstreamTodo::findOrFail($todoId); - $todo->markSkipped(); - } - - public function render() - { - return view('mcp::web.dashboard'); - } -} diff --git a/packages/core-mcp/src/Website/Mcp/View/Modal/McpMetrics.php b/packages/core-mcp/src/Website/Mcp/View/Modal/McpMetrics.php deleted file mode 100644 index 00d20d6..0000000 --- a/packages/core-mcp/src/Website/Mcp/View/Modal/McpMetrics.php +++ /dev/null @@ -1,90 +0,0 @@ -metricsService = $metricsService; - } - - public function setDays(int $days): void - { - // Bound days to a reasonable range (1-90) - $this->days = min(max($days, 1), 90); - } - - public function setTab(string $tab): void - { - $this->activeTab = $tab; - } - - public function getOverviewProperty(): array - { - return app(McpMetricsService::class)->getOverview($this->days); - } - - public function getDailyTrendProperty(): array - { - return app(McpMetricsService::class)->getDailyTrend($this->days); - } - - public function getTopToolsProperty(): array - { - return app(McpMetricsService::class)->getTopTools($this->days, 10); - } - - public function getServerStatsProperty(): array - { - return app(McpMetricsService::class)->getServerStats($this->days); - } - - public function getRecentCallsProperty(): array - { - return app(McpMetricsService::class)->getRecentCalls(20); - } - - public function getErrorBreakdownProperty(): array - { - return app(McpMetricsService::class)->getErrorBreakdown($this->days); - } - - public function getToolPerformanceProperty(): array - { - return app(McpMetricsService::class)->getToolPerformance($this->days, 10); - } - - public function getHourlyDistributionProperty(): array - { - return app(McpMetricsService::class)->getHourlyDistribution(); - } - - public function getPlanActivityProperty(): array - { - return app(McpMetricsService::class)->getPlanActivity($this->days, 10); - } - - public function render() - { - return view('mcp::web.mcp-metrics'); - } -} diff --git a/packages/core-mcp/src/Website/Mcp/View/Modal/McpPlayground.php b/packages/core-mcp/src/Website/Mcp/View/Modal/McpPlayground.php deleted file mode 100644 index e879e99..0000000 --- a/packages/core-mcp/src/Website/Mcp/View/Modal/McpPlayground.php +++ /dev/null @@ -1,358 +0,0 @@ - 'required|string', - 'selectedTool' => 'required|string', - 'inputJson' => 'required|json', - ]; - - public function mount(): void - { - $this->loadServers(); - - if (! empty($this->servers)) { - $this->selectedServer = $this->servers[0]['id']; - $this->loadTools(); - } - } - - public function updatedSelectedServer(): void - { - $this->loadTools(); - $this->selectedTool = ''; - $this->inputJson = '{}'; - $this->lastResult = null; - $this->lastError = null; - } - - public function updatedSelectedTool(): void - { - // Pre-fill example parameters based on tool definition - $this->prefillParameters(); - $this->lastResult = null; - $this->lastError = null; - } - - public function execute(): void - { - $this->validate(); - - // Rate limit: 10 executions per minute per user/IP - $rateLimitKey = 'mcp-playground:'.$this->getRateLimitKey(); - if (RateLimiter::tooManyAttempts($rateLimitKey, 10)) { - $this->lastError = 'Too many requests. Please wait before trying again.'; - - return; - } - RateLimiter::hit($rateLimitKey, 60); - - $this->isExecuting = true; - $this->lastResult = null; - $this->lastError = null; - - try { - $params = json_decode($this->inputJson, true); - if (json_last_error() !== JSON_ERROR_NONE) { - $this->lastError = 'Invalid JSON: '.json_last_error_msg(); - - return; - } - - $startTime = microtime(true); - $result = $this->callTool($this->selectedServer, $this->selectedTool, $params); - $this->executionTime = (int) round((microtime(true) - $startTime) * 1000); - - if (isset($result['error'])) { - $this->lastError = $result['error']; - $this->lastResult = $result; - } else { - $this->lastResult = $result; - } - - } catch (\Exception $e) { - $this->lastError = $e->getMessage(); - } finally { - $this->isExecuting = false; - } - } - - /** - * Get rate limit key based on user or IP. - */ - protected function getRateLimitKey(): string - { - if (auth()->check()) { - return 'user:'.auth()->id(); - } - - return 'ip:'.request()->ip(); - } - - public function formatJson(): void - { - try { - $decoded = json_decode($this->inputJson, true); - if (json_last_error() === JSON_ERROR_NONE) { - $this->inputJson = json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - } - } catch (\Exception $e) { - // Ignore formatting errors - } - } - - protected function loadServers(): void - { - $registry = $this->loadRegistry(); - - $this->servers = collect($registry['servers'] ?? []) - ->map(fn ($ref) => $this->loadServerYaml($ref['id'])) - ->filter() - ->map(fn ($server) => [ - 'id' => $server['id'], - 'name' => $server['name'], - 'tagline' => $server['tagline'] ?? '', - 'tool_count' => count($server['tools'] ?? []), - ]) - ->values() - ->all(); - } - - protected function loadTools(): void - { - if (empty($this->selectedServer)) { - $this->tools = []; - - return; - } - - $server = $this->loadServerYaml($this->selectedServer); - - $this->tools = collect($server['tools'] ?? []) - ->map(fn ($tool) => [ - 'name' => $tool['name'], - 'purpose' => $tool['purpose'] ?? '', - 'parameters' => $tool['parameters'] ?? [], - ]) - ->values() - ->all(); - } - - protected function prefillParameters(): void - { - if (empty($this->selectedTool)) { - $this->inputJson = '{}'; - - return; - } - - $tool = collect($this->tools)->firstWhere('name', $this->selectedTool); - - if (! $tool || empty($tool['parameters'])) { - $this->inputJson = '{}'; - - return; - } - - // Build example params from parameter definitions - $params = []; - foreach ($tool['parameters'] as $paramName => $paramDef) { - if (is_array($paramDef)) { - $type = $paramDef['type'] ?? 'string'; - $default = $paramDef['default'] ?? null; - $required = $paramDef['required'] ?? false; - - if ($default !== null) { - $params[$paramName] = $default; - } elseif ($required) { - // Add placeholder - $params[$paramName] = match ($type) { - 'boolean' => false, - 'integer', 'number' => 0, - 'array' => [], - default => '', - }; - } - } - } - - $this->inputJson = json_encode($params, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - } - - protected function callTool(string $serverId, string $toolName, array $params): array - { - $server = $this->loadServerYaml($serverId); - - if (! $server) { - return ['error' => 'Server not found']; - } - - $connection = $server['connection'] ?? []; - $type = $connection['type'] ?? 'stdio'; - - if ($type !== 'stdio') { - return ['error' => "Connection type '{$type}' not supported in playground"]; - } - - $command = $connection['command'] ?? null; - $args = $connection['args'] ?? []; - $cwd = $this->resolveEnvVars($connection['cwd'] ?? getcwd()); - - if (! $command) { - return ['error' => 'No command configured for this server']; - } - - // Build MCP tool call request - $request = json_encode([ - 'jsonrpc' => '2.0', - 'method' => 'tools/call', - 'params' => [ - 'name' => $toolName, - 'arguments' => $params, - ], - 'id' => 1, - ]); - - try { - $startTime = microtime(true); - - $fullCommand = array_merge([$command], $args); - $process = new Process($fullCommand, $cwd); - $process->setInput($request); - $process->setTimeout(30); - - $process->run(); - - $duration = (int) round((microtime(true) - $startTime) * 1000); - $output = $process->getOutput(); - - // Log the tool call - McpToolCall::log( - serverId: $serverId, - toolName: $toolName, - params: $params, - success: $process->isSuccessful(), - durationMs: $duration, - errorMessage: $process->isSuccessful() ? null : $process->getErrorOutput(), - ); - - if (! $process->isSuccessful()) { - return [ - 'error' => 'Process failed', - 'exit_code' => $process->getExitCode(), - 'stderr' => $process->getErrorOutput(), - ]; - } - - // Parse JSON-RPC response - $lines = explode("\n", trim($output)); - foreach ($lines as $line) { - $response = json_decode($line, true); - if ($response) { - if (isset($response['error'])) { - return [ - 'error' => $response['error']['message'] ?? 'Unknown error', - 'code' => $response['error']['code'] ?? null, - 'data' => $response['error']['data'] ?? null, - ]; - } - if (isset($response['result'])) { - return $response['result']; - } - } - } - - return [ - 'error' => 'No valid response received', - 'raw_output' => $output, - ]; - - } catch (\Exception $e) { - return ['error' => $e->getMessage()]; - } - } - - protected function loadRegistry(): array - { - return Cache::remember('mcp:registry', 0, function () { - $path = resource_path('mcp/registry.yaml'); - if (! file_exists($path)) { - return ['servers' => []]; - } - - return Yaml::parseFile($path); - }); - } - - protected function loadServerYaml(string $id): ?array - { - // Sanitise server ID to prevent path traversal attacks - $id = basename($id, '.yaml'); - - // Validate ID format (alphanumeric with hyphens only) - if (! preg_match('/^[a-z0-9-]+$/', $id)) { - return null; - } - - $path = resource_path("mcp/servers/{$id}.yaml"); - if (! file_exists($path)) { - return null; - } - - return Yaml::parseFile($path); - } - - protected function resolveEnvVars(string $value): string - { - return preg_replace_callback('/\$\{([^}]+)\}/', function ($matches) { - $parts = explode(':-', $matches[1], 2); - $var = $parts[0]; - $default = $parts[1] ?? ''; - - return env($var, $default); - }, $value); - } - - public function render() - { - return view('mcp::web.mcp-playground'); - } -} diff --git a/packages/core-mcp/src/Website/Mcp/View/Modal/Playground.php b/packages/core-mcp/src/Website/Mcp/View/Modal/Playground.php deleted file mode 100644 index cfccdd9..0000000 --- a/packages/core-mcp/src/Website/Mcp/View/Modal/Playground.php +++ /dev/null @@ -1,293 +0,0 @@ -loadServers(); - } - - public function loadServers(): void - { - try { - $registry = $this->loadRegistry(); - $this->servers = collect($registry['servers'] ?? []) - ->map(fn ($ref) => $this->loadServerSummary($ref['id'])) - ->filter() - ->values() - ->toArray(); - } catch (\Throwable $e) { - $this->error = 'Failed to load servers'; - $this->servers = []; - } - } - - public function updatedSelectedServer(): void - { - $this->error = null; - $this->selectedTool = ''; - $this->toolSchema = null; - $this->arguments = []; - $this->response = ''; - - if (! $this->selectedServer) { - $this->tools = []; - - return; - } - - try { - $server = $this->loadServerFull($this->selectedServer); - $this->tools = $server['tools'] ?? []; - } catch (\Throwable $e) { - $this->error = 'Failed to load server tools'; - $this->tools = []; - } - } - - public function updatedSelectedTool(): void - { - $this->error = null; - $this->arguments = []; - $this->response = ''; - - if (! $this->selectedTool) { - $this->toolSchema = null; - - return; - } - - try { - $this->toolSchema = collect($this->tools)->firstWhere('name', $this->selectedTool); - - // Pre-fill arguments with defaults - $params = $this->toolSchema['inputSchema']['properties'] ?? []; - foreach ($params as $name => $schema) { - $this->arguments[$name] = $schema['default'] ?? ''; - } - } catch (\Throwable $e) { - $this->error = 'Failed to load tool schema'; - $this->toolSchema = null; - } - } - - public function updatedApiKey(): void - { - // Clear key status when key changes - $this->keyStatus = null; - $this->keyInfo = null; - } - - public function validateKey(): void - { - $this->keyStatus = null; - $this->keyInfo = null; - - if (empty($this->apiKey)) { - $this->keyStatus = 'empty'; - - return; - } - - $key = ApiKey::findByPlainKey($this->apiKey); - - if (! $key) { - $this->keyStatus = 'invalid'; - - return; - } - - if ($key->isExpired()) { - $this->keyStatus = 'expired'; - - return; - } - - $this->keyStatus = 'valid'; - $this->keyInfo = [ - 'name' => $key->name, - 'scopes' => $key->scopes, - 'server_scopes' => $key->getAllowedServers(), - 'workspace' => $key->workspace?->name ?? 'Unknown', - 'last_used' => $key->last_used_at?->diffForHumans() ?? 'Never', - ]; - } - - public function isAuthenticated(): bool - { - return auth()->check(); - } - - public function execute(): void - { - if (! $this->selectedServer || ! $this->selectedTool) { - return; - } - - // Rate limit: 10 executions per minute per user/IP - $rateLimitKey = 'mcp-playground-api:'.$this->getRateLimitKey(); - if (RateLimiter::tooManyAttempts($rateLimitKey, 10)) { - $this->error = 'Too many requests. Please wait before trying again.'; - - return; - } - RateLimiter::hit($rateLimitKey, 60); - - $this->loading = true; - $this->response = ''; - $this->error = null; - - try { - // Filter out empty arguments - $args = array_filter($this->arguments, fn ($v) => $v !== '' && $v !== null); - - // Convert numeric strings to numbers where appropriate - foreach ($args as $key => $value) { - if (is_numeric($value)) { - $args[$key] = str_contains($value, '.') ? (float) $value : (int) $value; - } - if ($value === 'true') { - $args[$key] = true; - } - if ($value === 'false') { - $args[$key] = false; - } - } - - $payload = [ - 'server' => $this->selectedServer, - 'tool' => $this->selectedTool, - 'arguments' => $args, - ]; - - // If we have an API key, make a real request - if (! empty($this->apiKey) && $this->keyStatus === 'valid') { - $response = Http::withToken($this->apiKey) - ->timeout(30) - ->post(config('app.url').'/api/v1/mcp/tools/call', $payload); - - $this->response = json_encode([ - 'status' => $response->status(), - 'response' => $response->json(), - ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - - return; - } - - // Otherwise, just show request format - $this->response = json_encode([ - 'request' => $payload, - 'note' => 'Add an API key above to execute this request live.', - 'curl' => sprintf( - "curl -X POST %s/api/v1/mcp/tools/call \\\n -H \"Authorization: Bearer YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '%s'", - config('app.url'), - json_encode($payload, JSON_UNESCAPED_SLASHES) - ), - ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - } catch (\Throwable $e) { - $this->response = json_encode([ - 'error' => $e->getMessage(), - ], JSON_PRETTY_PRINT); - } finally { - $this->loading = false; - } - } - - public function render() - { - $isAuthenticated = $this->isAuthenticated(); - $workspace = $isAuthenticated ? auth()->user()?->defaultHostWorkspace() : null; - - return view('mcp::web.playground', [ - 'isAuthenticated' => $isAuthenticated, - 'workspace' => $workspace, - ]); - } - - protected function loadRegistry(): array - { - $path = resource_path('mcp/registry.yaml'); - - return file_exists($path) ? Yaml::parseFile($path) : ['servers' => []]; - } - - protected function loadServerFull(string $id): ?array - { - // Sanitise server ID to prevent path traversal attacks - $id = basename($id, '.yaml'); - - // Validate ID format (alphanumeric with hyphens only) - if (! preg_match('/^[a-z0-9-]+$/', $id)) { - return null; - } - - $path = resource_path("mcp/servers/{$id}.yaml"); - - return file_exists($path) ? Yaml::parseFile($path) : null; - } - - /** - * Get rate limit key based on user or IP. - */ - protected function getRateLimitKey(): string - { - if (auth()->check()) { - return 'user:'.auth()->id(); - } - - return 'ip:'.request()->ip(); - } - - protected function loadServerSummary(string $id): ?array - { - $server = $this->loadServerFull($id); - if (! $server) { - return null; - } - - return [ - 'id' => $server['id'], - 'name' => $server['name'], - 'tagline' => $server['tagline'] ?? '', - ]; - } -} diff --git a/packages/core-mcp/src/Website/Mcp/View/Modal/RequestLog.php b/packages/core-mcp/src/Website/Mcp/View/Modal/RequestLog.php deleted file mode 100644 index 0e81606..0000000 --- a/packages/core-mcp/src/Website/Mcp/View/Modal/RequestLog.php +++ /dev/null @@ -1,100 +0,0 @@ -resetPage(); - } - - public function updatedStatusFilter(): void - { - $this->resetPage(); - } - - public function selectRequest(int $id): void - { - $workspace = auth()->user()?->defaultHostWorkspace(); - - // Only allow selecting requests that belong to the user's workspace - $request = McpApiRequest::query() - ->when($workspace, fn ($q) => $q->forWorkspace($workspace->id)) - ->find($id); - - if (! $request) { - $this->selectedRequestId = null; - $this->selectedRequest = null; - - return; - } - - $this->selectedRequestId = $id; - $this->selectedRequest = $request; - } - - public function closeDetail(): void - { - $this->selectedRequestId = null; - $this->selectedRequest = null; - } - - public function render() - { - $workspace = auth()->user()?->defaultHostWorkspace(); - - $query = McpApiRequest::query() - ->orderByDesc('created_at'); - - if ($workspace) { - $query->forWorkspace($workspace->id); - } - - if ($this->serverFilter) { - $query->forServer($this->serverFilter); - } - - if ($this->statusFilter === 'success') { - $query->successful(); - } elseif ($this->statusFilter === 'failed') { - $query->failed(); - } - - $requests = $query->paginate(20); - - // Get unique servers for filter dropdown - $servers = McpApiRequest::query() - ->when($workspace, fn ($q) => $q->forWorkspace($workspace->id)) - ->distinct() - ->pluck('server_id') - ->filter() - ->values(); - - return view('mcp::web.request-log', [ - 'requests' => $requests, - 'servers' => $servers, - ]); - } -} diff --git a/packages/core-mcp/src/Website/Mcp/View/Modal/UnifiedSearch.php b/packages/core-mcp/src/Website/Mcp/View/Modal/UnifiedSearch.php deleted file mode 100644 index 03bf000..0000000 --- a/packages/core-mcp/src/Website/Mcp/View/Modal/UnifiedSearch.php +++ /dev/null @@ -1,82 +0,0 @@ -searchService = $searchService; - } - - public function updatedQuery(): void - { - // Debounce handled by wire:model.debounce - } - - public function toggleType(string $type): void - { - if (in_array($type, $this->selectedTypes)) { - $this->selectedTypes = array_values(array_diff($this->selectedTypes, [$type])); - } else { - $this->selectedTypes[] = $type; - } - } - - public function clearFilters(): void - { - $this->selectedTypes = []; - } - - public function getResultsProperty(): Collection - { - if (strlen($this->query) < 2) { - return collect(); - } - - return $this->searchService->search($this->query, $this->selectedTypes, $this->limit); - } - - public function getTypesProperty(): array - { - return UnifiedSearchService::getTypes(); - } - - public function getResultCountsByTypeProperty(): array - { - if (strlen($this->query) < 2) { - return []; - } - - $allResults = $this->searchService->search($this->query, [], 200); - - return $allResults->groupBy('type')->map->count()->toArray(); - } - - public function render() - { - return view('mcp::web.unified-search'); - } -} diff --git a/packages/core-mcp/tests/Feature/SqlQueryValidatorTest.php b/packages/core-mcp/tests/Feature/SqlQueryValidatorTest.php deleted file mode 100644 index 08d41f4..0000000 --- a/packages/core-mcp/tests/Feature/SqlQueryValidatorTest.php +++ /dev/null @@ -1,453 +0,0 @@ -validator = new SqlQueryValidator; - } - - // ========================================================================= - // Valid Queries - Should Pass - // ========================================================================= - - #[Test] - public function it_allows_simple_select_queries(): void - { - $query = 'SELECT * FROM posts'; - - $this->validator->validate($query); - $this->assertTrue($this->validator->isValid($query)); - } - - #[Test] - public function it_allows_select_with_where_clause(): void - { - $query = 'SELECT id, title FROM posts WHERE status = 1'; - - $this->validator->validate($query); - $this->assertTrue($this->validator->isValid($query)); - } - - #[Test] - public function it_allows_select_with_order_by(): void - { - $query = 'SELECT * FROM posts ORDER BY created_at DESC'; - - $this->validator->validate($query); - $this->assertTrue($this->validator->isValid($query)); - } - - #[Test] - public function it_allows_select_with_limit(): void - { - $query = 'SELECT * FROM posts LIMIT 10'; - - $this->validator->validate($query); - $this->assertTrue($this->validator->isValid($query)); - } - - #[Test] - public function it_allows_count_queries(): void - { - $query = 'SELECT COUNT(*) FROM posts'; - - $this->validator->validate($query); - $this->assertTrue($this->validator->isValid($query)); - } - - #[Test] - public function it_allows_queries_with_backtick_escaped_identifiers(): void - { - $query = 'SELECT `id`, `title` FROM `posts`'; - - $this->validator->validate($query); - $this->assertTrue($this->validator->isValid($query)); - } - - #[Test] - public function it_allows_queries_ending_with_semicolon(): void - { - $query = 'SELECT * FROM posts;'; - - $this->validator->validate($query); - $this->assertTrue($this->validator->isValid($query)); - } - - // ========================================================================= - // Blocked Keywords - Data Modification - // ========================================================================= - - #[Test] - #[DataProvider('blockedKeywordProvider')] - public function it_blocks_dangerous_keywords(string $query, string $keyword): void - { - $this->expectException(ForbiddenQueryException::class); - $this->expectExceptionMessageMatches('/Disallowed SQL keyword/i'); - - $this->validator->validate($query); - } - - public static function blockedKeywordProvider(): array - { - return [ - 'INSERT' => ['INSERT INTO posts (title) VALUES ("test")', 'INSERT'], - 'UPDATE' => ['UPDATE posts SET title = "hacked"', 'UPDATE'], - 'DELETE' => ['DELETE FROM posts WHERE id = 1', 'DELETE'], - 'DROP TABLE' => ['DROP TABLE posts', 'DROP'], - 'TRUNCATE' => ['TRUNCATE TABLE posts', 'TRUNCATE'], - 'ALTER' => ['ALTER TABLE posts ADD COLUMN hacked INT', 'ALTER'], - 'CREATE' => ['CREATE TABLE hacked (id INT)', 'CREATE'], - 'GRANT' => ['GRANT ALL ON *.* TO hacker', 'GRANT'], - 'REVOKE' => ['REVOKE ALL ON posts FROM user', 'REVOKE'], - ]; - } - - // ========================================================================= - // UNION Injection Attempts - // ========================================================================= - - #[Test] - public function it_blocks_union_based_injection(): void - { - $query = 'SELECT * FROM posts UNION SELECT * FROM users'; - - $this->expectException(ForbiddenQueryException::class); - $this->validator->validate($query); - } - - #[Test] - public function it_blocks_union_all_injection(): void - { - $query = 'SELECT * FROM posts UNION ALL SELECT * FROM users'; - - $this->expectException(ForbiddenQueryException::class); - $this->validator->validate($query); - } - - #[Test] - public function it_blocks_union_with_comments(): void - { - $query = 'SELECT * FROM posts /**/UNION/**/SELECT * FROM users'; - - $this->expectException(ForbiddenQueryException::class); - $this->validator->validate($query); - } - - #[Test] - public function it_blocks_union_with_newlines(): void - { - $query = "SELECT * FROM posts\nUNION\nSELECT * FROM users"; - - $this->expectException(ForbiddenQueryException::class); - $this->validator->validate($query); - } - - // ========================================================================= - // Stacked Query Attempts - // ========================================================================= - - #[Test] - public function it_blocks_stacked_queries(): void - { - $query = 'SELECT * FROM posts; DROP TABLE users;'; - - $this->expectException(ForbiddenQueryException::class); - $this->validator->validate($query); - } - - #[Test] - public function it_blocks_stacked_queries_with_spaces(): void - { - $query = 'SELECT * FROM posts ; DELETE FROM users'; - - $this->expectException(ForbiddenQueryException::class); - $this->validator->validate($query); - } - - #[Test] - public function it_blocks_comment_hidden_stacked_queries(): void - { - $query = 'SELECT * FROM posts; -- DROP TABLE users'; - - // After comment stripping, this becomes "SELECT * FROM posts; " with trailing space - // which should be fine, but let's test the stacked query detection - $this->expectException(ForbiddenQueryException::class); - $this->validator->validate('SELECT * FROM posts; SELECT * FROM users'); - } - - // ========================================================================= - // Comment-Based Bypass Attempts - // ========================================================================= - - #[Test] - public function it_strips_inline_comments(): void - { - // Comments should be stripped, leaving a valid query - $query = 'SELECT * FROM posts -- WHERE admin = 1'; - - // This is valid because after stripping comments it becomes "SELECT * FROM posts" - $this->validator->validate($query); - $this->assertTrue($this->validator->isValid($query)); - } - - #[Test] - public function it_strips_block_comments(): void - { - $query = 'SELECT * FROM posts /* comment */ WHERE id = 1'; - - $this->validator->validate($query); - $this->assertTrue($this->validator->isValid($query)); - } - - #[Test] - public function it_blocks_mysql_executable_comments_with_union(): void - { - // MySQL executable comments containing UNION should be blocked - // even though they look like comments, they execute in MySQL - $query = 'SELECT * FROM posts /*!50000 UNION SELECT * FROM users */'; - - $this->expectException(ForbiddenQueryException::class); - $this->validator->validate($query); - } - - #[Test] - public function it_strips_safe_mysql_executable_comments(): void - { - // Safe MySQL executable comments (without dangerous keywords) should be stripped - $query = 'SELECT * FROM posts /*!50000 WHERE id = 1 */'; - - // This is blocked because the pattern catches /*! comments followed by WHERE - // Actually this specific pattern should be OK, let's test a simpler case - $query = 'SELECT /*!50000 STRAIGHT_JOIN */ * FROM posts'; - - // Note: this will likely fail whitelist, let's disable it for this test - $validator = new SqlQueryValidator(null, false); - $validator->validate($query); - $this->assertTrue($validator->isValid($query)); - } - - // ========================================================================= - // Time-Based Attack Prevention - // ========================================================================= - - #[Test] - public function it_blocks_sleep_function(): void - { - $query = 'SELECT * FROM posts WHERE 1=1 AND SLEEP(5)'; - - $this->expectException(ForbiddenQueryException::class); - $this->validator->validate($query); - } - - #[Test] - public function it_blocks_benchmark_function(): void - { - $query = "SELECT * FROM posts WHERE BENCHMARK(10000000,SHA1('test'))"; - - $this->expectException(ForbiddenQueryException::class); - $this->validator->validate($query); - } - - // ========================================================================= - // System Table Access - // ========================================================================= - - #[Test] - public function it_blocks_information_schema_access(): void - { - $query = 'SELECT * FROM INFORMATION_SCHEMA.TABLES'; - - $this->expectException(ForbiddenQueryException::class); - $this->validator->validate($query); - } - - #[Test] - public function it_blocks_mysql_system_table_access(): void - { - $query = 'SELECT * FROM mysql.user'; - - $this->expectException(ForbiddenQueryException::class); - $this->validator->validate($query); - } - - // ========================================================================= - // Hex/Encoding Bypass Attempts - // ========================================================================= - - #[Test] - public function it_blocks_hex_encoded_values(): void - { - $query = 'SELECT * FROM posts WHERE id = 0x1'; - - $this->expectException(ForbiddenQueryException::class); - $this->validator->validate($query); - } - - #[Test] - public function it_blocks_char_function(): void - { - $query = 'SELECT * FROM posts WHERE title = CHAR(65,66,67)'; - - $this->expectException(ForbiddenQueryException::class); - $this->validator->validate($query); - } - - // ========================================================================= - // Structure Validation - // ========================================================================= - - #[Test] - public function it_requires_select_at_start(): void - { - $query = 'SHOW TABLES'; - - $this->expectException(ForbiddenQueryException::class); - $this->expectExceptionMessageMatches('/must begin with SELECT/i'); - $this->validator->validate($query); - } - - #[Test] - public function it_rejects_queries_not_starting_with_select(): void - { - $query = ' INSERT INTO posts VALUES (1)'; - - $this->expectException(ForbiddenQueryException::class); - $this->validator->validate($query); - } - - // ========================================================================= - // Whitelist Functionality - // ========================================================================= - - #[Test] - public function it_can_disable_whitelist(): void - { - $validator = new SqlQueryValidator([], false); - - // Complex query that wouldn't match default whitelist but has no dangerous patterns - // Actually, let's use a query that is blocked by pattern matching - $query = 'SELECT * FROM posts'; - - $validator->validate($query); - $this->assertTrue($validator->isValid($query)); - } - - #[Test] - public function it_can_add_custom_whitelist_patterns(): void - { - $validator = new SqlQueryValidator([], true); - - // Add a custom pattern that allows a specific query structure - $validator->addWhitelistPattern('/^\s*SELECT\s+\*\s+FROM\s+custom_table\s*$/i'); - - $query = 'SELECT * FROM custom_table'; - $validator->validate($query); - $this->assertTrue($validator->isValid($query)); - } - - #[Test] - public function it_rejects_queries_not_matching_whitelist(): void - { - $validator = new SqlQueryValidator([], true); - - // Empty whitelist means nothing is allowed - $query = 'SELECT * FROM posts'; - - $this->expectException(ForbiddenQueryException::class); - $this->expectExceptionMessageMatches('/does not match any allowed pattern/i'); - $validator->validate($query); - } - - // ========================================================================= - // Subquery Detection - // ========================================================================= - - #[Test] - public function it_blocks_subqueries_in_where_clause(): void - { - $query = 'SELECT * FROM posts WHERE id IN (SELECT user_id FROM users WHERE admin = 1)'; - - $this->expectException(ForbiddenQueryException::class); - $this->validator->validate($query); - } - - // ========================================================================= - // Edge Cases - // ========================================================================= - - #[Test] - public function it_handles_multiline_queries(): void - { - $query = 'SELECT - id, - title - FROM - posts - WHERE - status = 1'; - - $this->validator->validate($query); - $this->assertTrue($this->validator->isValid($query)); - } - - #[Test] - public function it_handles_extra_whitespace(): void - { - $query = ' SELECT * FROM posts '; - - $this->validator->validate($query); - $this->assertTrue($this->validator->isValid($query)); - } - - #[Test] - public function it_is_case_insensitive_for_keywords(): void - { - $query = 'select * from posts where ID = 1'; - - $this->validator->validate($query); - $this->assertTrue($this->validator->isValid($query)); - } - - // ========================================================================= - // Exception Details - // ========================================================================= - - #[Test] - public function exception_contains_query_and_reason(): void - { - try { - $this->validator->validate('DELETE FROM posts'); - $this->fail('Expected ForbiddenQueryException'); - } catch (ForbiddenQueryException $e) { - $this->assertEquals('DELETE FROM posts', $e->query); - $this->assertNotEmpty($e->reason); - } - } - - #[Test] - public function exception_factory_methods_work(): void - { - $e1 = ForbiddenQueryException::disallowedKeyword('SELECT', 'DELETE'); - $this->assertStringContainsString('DELETE', $e1->getMessage()); - - $e2 = ForbiddenQueryException::notWhitelisted('SELECT * FROM foo'); - $this->assertStringContainsString('allowed pattern', $e2->getMessage()); - - $e3 = ForbiddenQueryException::invalidStructure('query', 'bad structure'); - $this->assertStringContainsString('bad structure', $e3->getMessage()); - } -} diff --git a/packages/core-php/README.md b/packages/core-php/README.md deleted file mode 100644 index bac6073..0000000 --- a/packages/core-php/README.md +++ /dev/null @@ -1,161 +0,0 @@ -# Core PHP Framework - -The core framework package providing event-driven architecture, module system, and foundational features for building modular Laravel applications. - -## Installation - -```bash -composer require host-uk/core -``` - -## Key Features - -### Event-Driven Module System -Modules declare lifecycle events they're interested in and are only loaded when needed: - -```php -class Boot -{ - public static array $listens = [ - WebRoutesRegistering::class => 'onWebRoutes', - AdminPanelBooting::class => 'onAdmin', - ]; -} -``` - -### Multi-Tenant Data Isolation -Automatic workspace scoping for Eloquent models: - -```php -use Core\Mod\Tenant\Concerns\BelongsToWorkspace; - -class Product extends Model -{ - use BelongsToWorkspace; -} - -// Automatically scoped to current workspace -$products = Product::all(); -``` - -### Actions Pattern -Single-purpose business logic classes with dependency injection: - -```php -use Core\Actions\Action; - -class CreateOrder -{ - use Action; - - public function handle(User $user, array $data): Order - { - return Order::create($data); - } -} - -$order = CreateOrder::run($user, $validated); -``` - -### Activity Logging -Built-in audit trails for model changes: - -```php -use Core\Activity\Concerns\LogsActivity; - -class Order extends Model -{ - use LogsActivity; - - protected array $activityLogAttributes = ['status', 'total']; -} -``` - -### Seeder Auto-Discovery -Automatic seeder ordering via attributes: - -```php -#[SeederPriority(10)] -#[SeederAfter(FeatureSeeder::class)] -class PackageSeeder extends Seeder -{ - public function run(): void - { - // ... - } -} -``` - -### HLCRF Layout System -Data-driven composable layouts: - -```php -use Core\Front\Components\Layout; - -$page = Layout::make('HCF') - ->h('') - ->c('
Content
') - ->f('
Footer
'); -``` - -## Lifecycle Events - -| Event | Purpose | -|-------|---------| -| `WebRoutesRegistering` | Public web routes | -| `AdminPanelBooting` | Admin panel routes | -| `ApiRoutesRegistering` | REST API endpoints | -| `ClientRoutesRegistering` | Authenticated client routes | -| `ConsoleBooting` | Artisan commands | -| `McpToolsRegistering` | MCP tool handlers | -| `FrameworkBooted` | Late-stage initialization | - -## Configuration - -Publish the configuration: - -```bash -php artisan vendor:publish --tag=core-config -``` - -Configure in `config/core.php`: - -```php -return [ - 'module_paths' => [ - app_path('Core'), - app_path('Mod'), - ], - 'workspace_cache' => [ - 'enabled' => true, - 'ttl' => 3600, - ], -]; -``` - -## Artisan Commands - -```bash -php artisan make:mod Commerce # Create module -php artisan make:website Marketing # Create website -php artisan make:plug Stripe # Create plugin -``` - -## Requirements - -- PHP 8.2+ -- Laravel 11+ or 12+ - -## Documentation - -- [Main Documentation](../../README.md) -- [Patterns Guide](../../docs/patterns.md) -- [HLCRF Layout System](src/Core/Front/HLCRF.md) - -## Changelog - -See [changelog/2026/jan/features.md](changelog/2026/jan/features.md) for recent changes. - -## License - -EUPL-1.2 - See [LICENSE](../../LICENSE) for details. diff --git a/packages/core-php/TODO.md b/packages/core-php/TODO.md deleted file mode 100644 index 5c08615..0000000 --- a/packages/core-php/TODO.md +++ /dev/null @@ -1,336 +0,0 @@ -# Core-PHP TODO - -## Testing & Quality Assurance - -### High Priority - -- [ ] **Test Coverage: CDN Services** - Achieve 80%+ coverage for CDN integration - - [ ] Test BunnyCdnService upload/purge operations - - [ ] Test FluxCdnService URL generation and purging - - [ ] Test StorageOffload for S3/BunnyCDN switching - - [ ] Test AssetPipeline with versioning and minification - - [ ] Test CdnUrlBuilder with signed URLs - - **Estimated effort:** 4-6 hours - -- [ ] **Test Coverage: Activity Logging** - Add comprehensive activity tests - - [ ] Test LogsActivity trait with all CRUD operations - - [ ] Test IP hashing for GDPR compliance - - [ ] Test activity pruning command - - [ ] Test workspace scoping in activity logs - - **Estimated effort:** 3-4 hours - -- [ ] **Test Coverage: Media Processing** - Test image optimization pipeline - - [ ] Test ImageOptimizer with various formats (JPG, PNG, WebP, AVIF) - - [ ] Test ImageResizer with responsive sizes - - [ ] Test ExifStripper for privacy - - [ ] Test lazy thumbnail generation - - [ ] Test MediaConversion queuing and progress tracking - - **Estimated effort:** 5-7 hours - -- [ ] **Test Coverage: Search System** - Test unified search - - [ ] Test SearchAnalytics recording and queries - - [ ] Test SearchSuggestions with partial queries - - [ ] Test SearchHighlighter with various patterns - - [ ] Test cross-model unified search - - **Estimated effort:** 4-5 hours - -### Medium Priority - -- [ ] **Test Coverage: SEO Tools** - Test SEO metadata and generation - - [ ] Test SeoMetadata rendering (title, description, OG, Twitter) - - [ ] Test dynamic OG image generation job - - [ ] Test sitemap generation and indexing - - [ ] Test structured data (JSON-LD) generation - - [ ] Test canonical URL validation - - **Estimated effort:** 4-5 hours - -- [ ] **Test Coverage: Configuration System** - Test config profiles and versioning - - [ ] Test ConfigService with profiles - - [ ] Test ConfigVersioning and rollback - - [ ] Test ConfigExporter import/export - - [ ] Test sensitive config encryption - - [ ] Test config cache invalidation - - **Estimated effort:** 3-4 hours - -- [ ] **Test Coverage: Security Headers** - Test header middleware - - [ ] Test CSP header generation with nonces - - [ ] Test HSTS enforcement - - [ ] Test X-Frame-Options and security headers - - [ ] Test CspNonceService in views - - **Estimated effort:** 2-3 hours - -- [ ] **Test Coverage: Email Shield** - Test email validation - - [ ] Test disposable domain detection - - [ ] Test role-based email detection - - [ ] Test DNS MX record validation - - [ ] Test blocklist/allowlist functionality - - **Estimated effort:** 2-3 hours - -### Low Priority - -- [ ] **Test Coverage: Lang/Translation** - Test translation memory - - [ ] Test TranslationMemory fuzzy matching - - [ ] Test TMX import/export - - [ ] Test ICU message formatting - - [ ] Test translation coverage reporting - - **Estimated effort:** 3-4 hours - -- [ ] **Performance: Config Caching** - Optimize config queries - - [ ] Profile ConfigService query performance - - [ ] Implement query result caching beyond remember() - - [ ] Add Redis cache driver support - - **Estimated effort:** 2-3 hours - -## Features & Enhancements - -### High Priority - -- [ ] **EPIC: Core DOM Component System** - Extend `` helpers for HLCRF layouts - - [ ] **Phase 1: Architecture & Planning** (2-3 hours) - - [ ] Create `src/Core/Front/Dom/` namespace structure - - [ ] Design Blade component API (slot-based vs named components) - - [ ] Document component naming conventions - - [ ] Plan backwards compatibility with existing HLCRF Layout class - - - [ ] **Phase 2: Core DOM Components** (4-6 hours) - - [ ] Create `` component → maps to HLCRF H slot - - [ ] Create `` component → maps to HLCRF L slot - - [ ] Create `` component → maps to HLCRF C slot - - [ ] Create `` component → maps to HLCRF R slot - - [ ] Create `` component → maps to HLCRF F slot - - [ ] Create `` generic slot component - - [ ] Add automatic path tracking (H-0, L-C-2, etc.) - - [ ] Support nested layouts with path inheritance - - - [ ] **Phase 3: Layout Container Components** (3-4 hours) - - [ ] Create `` wrapper component - - [ ] Create `` component (alias for HCF layout) - - [ ] Create `` component (alias for HLCRF layout) - - [ ] Create `` component (alias for C-only layout) - - [ ] Support inline nesting syntax: `` - - - [ ] **Phase 4: Semantic HTML Components** (2-3 hours) - - [ ] Create `` with automatic semantic tags - - [ ] Create `` for sidebars - - [ ] Create `` for content blocks - - [ ] Create `` for navigation areas - - [ ] Add ARIA landmark support automatically - - - [ ] **Phase 5: Component Composition** (3-4 hours) - - [ ] Support `` for data-block attributes - - [ ] Add `` for custom named slots - - [ ] Create `` for layout grids - - [ ] Create `` - - [ ] Support responsive breakpoints in components - - - [ ] **Phase 6: Integration & Testing** (4-5 hours) - - [ ] Register all components in CoreTagCompiler - - [ ] Test component nesting and path generation - - [ ] Test with Livewire components inside slots - - [ ] Test responsive layout switching - - [ ] Create comprehensive test suite (80%+ coverage) - - [ ] Add Pest snapshots for HTML output - - - [ ] **Phase 7: Documentation & Examples** (3-4 hours) - - [ ] Create `docs/packages/core/dom-components.md` - - [ ] Document all component props and slots - - [ ] Add migration guide from PHP Layout class to Blade components - - [ ] Create example layouts (blog, dashboard, landing page) - - [ ] Add Storybook-style component gallery - - - [ ] **Phase 8: Developer Experience** (2-3 hours) - - [ ] Add IDE autocomplete hints for component props - - [ ] Create `php artisan make:layout` command - - [ ] Add validation for invalid slot combinations - - [ ] Create debug mode with visual slot boundaries - - [ ] Add performance profiling for nested layouts - - **Total Estimated Effort:** 23-32 hours - **Priority:** High - Core framework feature - **Impact:** Dramatically improves DX for building HLCRF layouts - **Dependencies:** Existing CoreTagCompiler, Layout class - - **Example Usage:** - ```blade - - - - - - - -

Sidebar Widget

-

Content

-
-
- - - -

Main Content

-

Article text...

-
-
- - - @livewire('recent-activity') - - - -

© 2026

-
-
- ``` - - **Alternative Slot-Based Syntax:** - ```blade - - - - - - -
Content
-
- - -
Footer
-
-
- ``` - -- [ ] **Feature: Seeder Dependency Resolution** - Complete seeder system - - [ ] Implement SeederRegistry with dependency graph - - [ ] Add circular dependency detection - - [ ] Support #[SeederPriority], #[SeederBefore], #[SeederAfter] - - [ ] Test with complex dependency chains - - **Estimated effort:** 4-6 hours - - **Files:** `src/Core/Database/Seeders/` - -- [ ] **Feature: Service Discovery** - Complete service registration system - - [ ] Implement ServiceDiscovery class - - [ ] Add service dependency validation - - [ ] Support version compatibility checking - - [ ] Test service resolution with dependencies - - **Estimated effort:** 3-4 hours - - **Files:** `src/Core/Service/` - -- [ ] **Feature: Tiered Cache** - Complete tiered caching implementation - - [ ] Implement TieredCacheStore with memory → Redis → file - - [ ] Add CacheWarmer for pre-population - - [ ] Add StorageMetrics for monitoring - - [ ] Test cache tier fallback behavior - - **Estimated effort:** 5-6 hours - - **Files:** `src/Core/Storage/` - -### Medium Priority - -- [ ] **Feature: Action Gate Enforcement** - Complete action gate system - - [ ] Add ActionGateMiddleware enforcement mode - - [ ] Implement training mode for learning patterns - - [ ] Add audit logging for all requests - - [ ] Test with dangerous actions - - **Estimated effort:** 4-5 hours - - **Files:** `src/Core/Bouncer/Gate/` - -- [ ] **Enhancement: Media Progress Tracking** - Real-time conversion progress - - [ ] Fire ConversionProgress events - - [ ] Add WebSocket broadcasting support - - [ ] Create Livewire progress component - - [ ] Test with large video files - - **Estimated effort:** 3-4 hours - - **Files:** `src/Core/Media/` - -- [ ] **Enhancement: SEO Score Tracking** - Complete SEO analytics - - [ ] Implement SeoScoreTrend recording - - [ ] Add SEO score calculation logic - - [ ] Create admin dashboard for SEO metrics - - [ ] Add automated SEO audit command - - **Estimated effort:** 4-5 hours - - **Files:** `src/Core/Seo/Analytics/` - -### Low Priority - -- [ ] **Enhancement: Search Analytics Dashboard** - Visual search insights - - [ ] Create Livewire component for search analytics - - [ ] Add charts for popular searches and CTR - - [ ] Show zero-result searches for improvement - - [ ] Export search analytics to CSV - - **Estimated effort:** 3-4 hours - -- [ ] **Enhancement: Email Shield Stats** - Email validation metrics - - [ ] Track disposable email blocks - - [ ] Track validation failures by reason - - [ ] Add admin dashboard for email stats - - [ ] Implement automatic pruning - - **Estimated effort:** 2-3 hours - -## Documentation - -- [x] **API Docs: Service Contracts** - Document service pattern - - [x] Add examples for ServiceDefinition - - [x] Document service versioning - - [x] Add dependency resolution examples - - **Completed:** January 2026 - - **File:** `docs/packages/core/service-contracts.md` - -- [x] **API Docs: Seeder System** - Document seeder attributes - - [x] Document dependency resolution - - [x] Add complex ordering examples - - [x] Document circular dependency errors - - **Completed:** January 2026 - - **File:** `docs/packages/core/seeder-system.md` - -## Code Quality - -- [ ] **Refactor: Extract BlocklistService Tests** - Separate test concerns - - [ ] Create BlocklistServiceTest.php - - [ ] Move tests from inline to dedicated file - - [ ] Add edge case coverage - - **Estimated effort:** 1-2 hours - -- [ ] **Refactor: Consolidate Privacy Helpers** - Single source of truth - - [ ] Move IP hashing to dedicated service - - [ ] Consolidate anonymization logic - - [ ] Add comprehensive tests - - **Estimated effort:** 2-3 hours - -- [ ] **PHPStan: Fix Level 5 Errors** - Improve type safety - - [ ] Fix union type issues in config system - - [ ] Add missing return types - - [ ] Fix property type declarations - - **Estimated effort:** 3-4 hours - -## Infrastructure - -- [x] **GitHub Template Repository** - Created host-uk/core-template - - [x] Set up base Laravel 12 app - - [x] Configure composer.json with Core packages - - [x] Update bootstrap/app.php to register providers - - [x] Create config/core.php - - [x] Update .env.example with Core variables - - [x] Write comprehensive README.md - - [x] Test `php artisan core:new` command - - **Completed:** January 2026 - - **Command:** `php artisan core:new my-project` - -- [ ] **CI/CD: Add PHP 8.3 Testing** - Future compatibility - - [ ] Test on PHP 8.3 - - [ ] Fix any deprecations - - [ ] Update composer.json PHP constraint - - **Estimated effort:** 1-2 hours - -- [ ] **CI/CD: Add Performance Benchmarks** - Track performance - - [ ] Benchmark critical paths (config load, search, etc.) - - [ ] Set performance budgets - - [ ] Fail CI on regressions - - **Estimated effort:** 3-4 hours - ---- - -## Completed (January 2026) - -- [x] **CDN integration tests** - Comprehensive test suite added -- [x] **Security: IP Hashing** - GDPR-compliant IP hashing in referral tracking -- [x] **Documentation** - Complete package documentation created - -*See `changelog/2026/jan/` for completed features.* diff --git a/packages/core-php/composer.json b/packages/core-php/composer.json deleted file mode 100644 index 8bb8d24..0000000 --- a/packages/core-php/composer.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "host-uk/core", - "description": "Modular monolith framework for Laravel - event-driven architecture with lazy module loading", - "keywords": ["laravel", "modular", "monolith", "framework", "events", "modules"], - "license": "EUPL-1.2", - "authors": [ - { - "name": "Host UK", - "email": "support@host.uk.com" - } - ], - "require": { - "php": "^8.2", - "laravel/framework": "^11.0|^12.0", - "laravel/pennant": "^1.0", - "livewire/livewire": "^3.0|^4.0" - }, - "suggest": { - "spatie/laravel-activitylog": "Required for activity logging features (^4.0)" - }, - "autoload": { - "psr-4": { - "Core\\": "src/Core/", - "Core\\Website\\": "src/Website/", - "Core\\Mod\\": "src/Mod/", - "Core\\Plug\\": "src/Plug/" - }, - "files": [ - "src/Core/Media/Thumbnail/helpers.php" - ] - }, - "autoload-dev": { - "psr-4": { - "Core\\Tests\\": "tests/", - "Mod\\": "tests/Fixtures/Mod/", - "Plug\\": "tests/Fixtures/Plug/", - "Website\\": "tests/Fixtures/Website/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Core\\LifecycleEventProvider", - "Core\\Lang\\LangServiceProvider", - "Core\\Bouncer\\Gate\\Boot" - ] - } - }, - "minimum-stability": "stable", - "prefer-stable": true -} diff --git a/packages/core-php/config/core.php b/packages/core-php/config/core.php deleted file mode 100644 index bf5f195..0000000 --- a/packages/core-php/config/core.php +++ /dev/null @@ -1,455 +0,0 @@ - [ - 'name' => env('APP_NAME', 'Core PHP'), - 'description' => env('APP_DESCRIPTION', 'A modular monolith framework'), - 'tagline' => env('APP_TAGLINE', 'Build powerful applications with a clean, modular architecture.'), - 'cta_text' => env('APP_CTA_TEXT', 'Join developers building with our framework.'), - 'icon' => env('APP_ICON', 'cube'), - 'color' => env('APP_COLOR', 'violet'), - 'logo' => env('APP_LOGO'), // Path relative to public/, e.g. 'images/logo.svg' - 'privacy_url' => env('APP_PRIVACY_URL'), - 'terms_url' => env('APP_TERMS_URL'), - 'powered_by' => env('APP_POWERED_BY'), - 'powered_by_url' => env('APP_POWERED_BY_URL'), - ], - - /* - |-------------------------------------------------------------------------- - | Module Paths - |-------------------------------------------------------------------------- - | - | Directories to scan for module Boot.php files with $listens declarations. - | Each path should be an absolute path to a directory containing modules. - | - | Example: - | 'module_paths' => [ - | app_path('Core'), - | app_path('Mod'), - | ], - | - */ - - 'module_paths' => [ - // app_path('Core'), - // app_path('Mod'), - ], - - /* - |-------------------------------------------------------------------------- - | FontAwesome Configuration - |-------------------------------------------------------------------------- - | - | Configure FontAwesome Pro detection and fallback behaviour. - | - */ - - 'fontawesome' => [ - // Set to true if you have a FontAwesome Pro licence - 'pro' => env('FONTAWESOME_PRO', false), - - // Your FontAwesome Kit ID (optional) - 'kit' => env('FONTAWESOME_KIT'), - ], - - /* - |-------------------------------------------------------------------------- - | Pro Fallback Behaviour - |-------------------------------------------------------------------------- - | - | How to handle Pro-only components when Pro packages aren't installed. - | - | Options: - | - 'error': Throw exception in dev, silent in production - | - 'fallback': Use free alternatives where possible - | - 'silent': Render nothing for Pro-only components - | - */ - - 'pro_fallback' => env('CORE_PRO_FALLBACK', 'error'), - - /* - |-------------------------------------------------------------------------- - | Icon Defaults - |-------------------------------------------------------------------------- - | - | Default icon style when not specified. Only applies when not using - | auto-detection (brand/jelly lists). - | - */ - - 'icon' => [ - 'default_style' => 'solid', - ], - - /* - |-------------------------------------------------------------------------- - | Search Configuration - |-------------------------------------------------------------------------- - | - | Configure the unified search feature including searchable API endpoints. - | Add your application's API endpoints here to include them in search results. - | - */ - - 'search' => [ - 'api_endpoints' => [ - // Example endpoints - override in your application's config - // ['method' => 'GET', 'path' => '/api/v1/users', 'description' => 'List users'], - // ['method' => 'POST', 'path' => '/api/v1/users', 'description' => 'Create user'], - ], - ], - - /* - |-------------------------------------------------------------------------- - | Email Shield Configuration - |-------------------------------------------------------------------------- - | - | Configure the Email Shield validation and statistics module. - | Statistics track daily email validation counts for monitoring and - | analysis. Old records are automatically pruned based on retention period. - | - | Schedule the prune command in your app/Console/Kernel.php: - | $schedule->command('email-shield:prune')->daily(); - | - */ - - 'email_shield' => [ - // Number of days to retain email shield statistics records. - // Records older than this will be deleted by the prune command. - // Set to 0 to disable automatic pruning. - 'retention_days' => env('CORE_EMAIL_SHIELD_RETENTION_DAYS', 90), - ], - - /* - |-------------------------------------------------------------------------- - | Admin Menu Configuration - |-------------------------------------------------------------------------- - | - | Configure the admin menu caching behaviour. Menu items are cached per - | user/workspace combination to improve performance on repeated requests. - | - */ - - 'admin_menu' => [ - // Whether to enable caching for static menu items. - // Set to false during development for instant menu updates. - 'cache_enabled' => env('CORE_ADMIN_MENU_CACHE', true), - - // Cache TTL in seconds (default: 5 minutes). - // Lower values mean more frequent cache misses but fresher menus. - 'cache_ttl' => env('CORE_ADMIN_MENU_CACHE_TTL', 300), - ], - - /* - |-------------------------------------------------------------------------- - | Storage Resilience Configuration - |-------------------------------------------------------------------------- - | - | Configure how the application handles Redis failures. When Redis becomes - | unavailable, the system can either silently fall back to database storage - | or throw an exception. - | - */ - - 'storage' => [ - // Whether to silently fall back to database when Redis fails. - // Set to false to throw exceptions on Redis failure. - 'silent_fallback' => env('CORE_STORAGE_SILENT_FALLBACK', true), - - // Log level for fallback events: 'debug', 'info', 'notice', 'warning', 'error', 'critical' - 'fallback_log_level' => env('CORE_STORAGE_FALLBACK_LOG_LEVEL', 'warning'), - - // Whether to dispatch RedisFallbackActivated events for monitoring/alerting - 'dispatch_fallback_events' => env('CORE_STORAGE_DISPATCH_EVENTS', true), - - /* - |---------------------------------------------------------------------- - | Circuit Breaker Configuration - |---------------------------------------------------------------------- - | - | The circuit breaker prevents cascading failures when Redis becomes - | unavailable. When failures exceed the threshold, the circuit opens - | and requests go directly to the fallback, avoiding repeated - | connection attempts that slow down the application. - | - */ - - 'circuit_breaker' => [ - // Enable/disable the circuit breaker - 'enabled' => env('CORE_STORAGE_CIRCUIT_BREAKER_ENABLED', true), - - // Number of failures before opening the circuit - 'failure_threshold' => env('CORE_STORAGE_CIRCUIT_BREAKER_FAILURES', 5), - - // Seconds to wait before attempting recovery (half-open state) - 'recovery_timeout' => env('CORE_STORAGE_CIRCUIT_BREAKER_RECOVERY', 30), - - // Number of successful operations to close the circuit - 'success_threshold' => env('CORE_STORAGE_CIRCUIT_BREAKER_SUCCESSES', 2), - - // Cache driver for storing circuit breaker state (use non-Redis driver) - 'state_driver' => env('CORE_STORAGE_CIRCUIT_BREAKER_DRIVER', 'file'), - ], - - /* - |---------------------------------------------------------------------- - | Storage Metrics Configuration - |---------------------------------------------------------------------- - | - | Storage metrics collect information about cache operations including - | hit/miss rates, latencies, and fallback activations. Use these - | metrics for monitoring cache health and performance tuning. - | - */ - - 'metrics' => [ - // Enable/disable metrics collection - 'enabled' => env('CORE_STORAGE_METRICS_ENABLED', true), - - // Maximum latency samples to keep per driver (for percentile calculations) - 'max_samples' => env('CORE_STORAGE_METRICS_MAX_SAMPLES', 1000), - - // Whether to log metrics events - 'log_enabled' => env('CORE_STORAGE_METRICS_LOG', true), - ], - ], - - /* - |-------------------------------------------------------------------------- - | Service Configuration - |-------------------------------------------------------------------------- - | - | Configure service discovery and dependency resolution. Services are - | discovered by scanning module paths for classes implementing - | ServiceDefinition. - | - */ - - 'services' => [ - // Whether to cache service discovery results - 'cache_discovery' => env('CORE_SERVICES_CACHE_DISCOVERY', true), - ], - - /* - |-------------------------------------------------------------------------- - | Language & Translation Configuration - |-------------------------------------------------------------------------- - | - | Configure translation fallback chains and missing key validation. - | The fallback chain allows regional locales to fall back to their base - | locale before using the application's fallback locale. - | - | Example chain: en_GB -> en -> fallback_locale (from config/app.php) - | - */ - - 'lang' => [ - // Enable locale chain fallback (e.g., en_GB -> en -> fallback) - // When true, regional locales like 'en_GB' will first try 'en' before - // falling back to the application's fallback_locale. - 'fallback_chain' => env('CORE_LANG_FALLBACK_CHAIN', true), - - // Warn about missing translation keys in development environments. - // Set to true to always enable, false to always disable, or leave - // null to auto-enable in local/development/testing environments. - 'validate_keys' => env('CORE_LANG_VALIDATE_KEYS'), - - // Log missing translation keys when validation is enabled. - 'log_missing_keys' => env('CORE_LANG_LOG_MISSING_KEYS', true), - - // Log level for missing translation key warnings. - // Options: 'debug', 'info', 'notice', 'warning', 'error', 'critical' - 'missing_key_log_level' => env('CORE_LANG_MISSING_KEY_LOG_LEVEL', 'debug'), - - // Enable ICU message format support. - // Requires the PHP intl extension for full functionality. - // When disabled, ICU patterns will use basic placeholder replacement. - 'icu_enabled' => env('CORE_LANG_ICU_ENABLED', true), - ], - - /* - |-------------------------------------------------------------------------- - | Bouncer Action Gate Configuration - |-------------------------------------------------------------------------- - | - | Configure the action whitelisting system. Philosophy: "If it wasn't - | trained, it doesn't exist." Every controller action must be explicitly - | permitted. Unknown actions are blocked (production) or prompt for - | approval (training mode). - | - */ - - 'bouncer' => [ - // Enable training mode to allow approving new actions interactively. - // In production, this should be false to enforce strict whitelisting. - // In development/staging, enable to train the system with valid actions. - 'training_mode' => env('CORE_BOUNCER_TRAINING_MODE', false), - - // Whether to enable the action gate middleware. - // Set to false to completely disable action whitelisting. - 'enabled' => env('CORE_BOUNCER_ENABLED', true), - - // Guards that should have action gating applied. - // Actions on routes using these middleware groups will be checked. - 'guarded_middleware' => ['web', 'admin', 'api', 'client'], - - // Routes matching these patterns will bypass the action gate. - // Use for login pages, public assets, health checks, etc. - 'bypass_patterns' => [ - 'login', - 'logout', - 'register', - 'password/*', - 'sanctum/*', - 'livewire/*', - '_debugbar/*', - 'horizon/*', - 'telescope/*', - ], - - // Number of days to retain action request logs. - // Set to 0 to disable automatic pruning. - 'log_retention_days' => env('CORE_BOUNCER_LOG_RETENTION', 30), - - // Whether to log allowed requests (can generate many records). - // Recommended: false in production, true during training. - 'log_allowed_requests' => env('CORE_BOUNCER_LOG_ALLOWED', false), - - /* - |---------------------------------------------------------------------- - | Honeypot Configuration - |---------------------------------------------------------------------- - | - | Configure the honeypot system that traps bots ignoring robots.txt. - | Paths listed in robots.txt as disallowed are monitored; any request - | indicates a bot that doesn't respect robots.txt. - | - */ - - 'honeypot' => [ - // Whether to auto-block IPs that hit critical honeypot paths. - // When enabled, IPs hitting paths like /admin or /.env are blocked. - // Set to false to require manual review of all honeypot hits. - 'auto_block_critical' => env('CORE_BOUNCER_HONEYPOT_AUTO_BLOCK', true), - - // Rate limiting for honeypot logging to prevent DoS via log flooding. - // Maximum number of log entries per IP within the time window. - 'rate_limit_max' => env('CORE_BOUNCER_HONEYPOT_RATE_LIMIT_MAX', 10), - - // Rate limit time window in seconds (default: 60 = 1 minute). - 'rate_limit_window' => env('CORE_BOUNCER_HONEYPOT_RATE_LIMIT_WINDOW', 60), - - // Severity levels for honeypot paths. - // 'critical' - Active probing (admin panels, config files). - // 'warning' - General robots.txt violation. - 'severity_levels' => [ - 'critical' => env('CORE_BOUNCER_HONEYPOT_SEVERITY_CRITICAL', 'critical'), - 'warning' => env('CORE_BOUNCER_HONEYPOT_SEVERITY_WARNING', 'warning'), - ], - - // Paths that indicate critical/malicious probing. - // Requests to these paths result in 'critical' severity. - // Supports prefix matching (e.g., 'admin' matches '/admin', '/admin/login'). - 'critical_paths' => [ - 'admin', - 'wp-admin', - 'wp-login.php', - 'administrator', - 'phpmyadmin', - '.env', - '.git', - ], - ], - ], - - /* - |-------------------------------------------------------------------------- - | Workspace Cache Configuration - |-------------------------------------------------------------------------- - | - | Configure workspace-scoped caching for multi-tenant resources. - | Models using the BelongsToWorkspace trait can cache their collections - | with automatic invalidation when records are created, updated, or deleted. - | - | The cache system supports both tagged cache stores (Redis, Memcached) - | and non-tagged stores (file, database, array). Tagged stores provide - | more efficient cache invalidation. - | - */ - - 'workspace_cache' => [ - // Whether to enable workspace-scoped caching. - // Set to false to completely disable caching (all queries hit the database). - 'enabled' => env('CORE_WORKSPACE_CACHE_ENABLED', true), - - // Default TTL in seconds for cached workspace queries. - // Individual queries can override this with their own TTL. - 'ttl' => env('CORE_WORKSPACE_CACHE_TTL', 300), - - // Cache key prefix to avoid collisions with other cache keys. - // Change this if you need to separate cache data between deployments. - 'prefix' => env('CORE_WORKSPACE_CACHE_PREFIX', 'workspace_cache'), - - // Whether to use cache tags if available. - // Tags provide more efficient cache invalidation (flush by workspace or model). - // Only works with tag-supporting stores (Redis, Memcached). - // Set to false to always use key-based cache management. - 'use_tags' => env('CORE_WORKSPACE_CACHE_USE_TAGS', true), - ], - - /* - |-------------------------------------------------------------------------- - | Activity Logging Configuration - |-------------------------------------------------------------------------- - | - | Configure activity logging for audit trails across modules. - | Uses spatie/laravel-activitylog under the hood with workspace-aware - | enhancements for multi-tenant environments. - | - | Models can use the Core\Activity\Concerns\LogsActivity trait to - | automatically log create, update, and delete operations. - | - */ - - 'activity' => [ - // Whether to enable activity logging globally. - // Set to false to completely disable activity logging. - 'enabled' => env('CORE_ACTIVITY_ENABLED', true), - - // The log name to use for activities. - // Different log names can be used to separate activities by context. - 'log_name' => env('CORE_ACTIVITY_LOG_NAME', 'default'), - - // Whether to include workspace_id in activity properties. - // Enable this in multi-tenant applications to scope activities per workspace. - 'include_workspace' => env('CORE_ACTIVITY_INCLUDE_WORKSPACE', true), - - // Default events to log when using the LogsActivity trait. - // Models can override this with the $activityLogEvents property. - 'default_events' => ['created', 'updated', 'deleted'], - - // Number of days to retain activity logs. - // Use the activity:prune command to clean up old logs. - // Set to 0 to disable automatic pruning. - 'retention_days' => env('CORE_ACTIVITY_RETENTION_DAYS', 90), - - // Custom Activity model class (optional). - // Set this to use a custom Activity model with additional scopes. - // Default: Core\Activity\Models\Activity::class - 'activity_model' => env('CORE_ACTIVITY_MODEL', \Core\Activity\Models\Activity::class), - ], - -]; diff --git a/packages/core-php/phpunit.xml b/packages/core-php/phpunit.xml deleted file mode 100644 index 5a49573..0000000 --- a/packages/core-php/phpunit.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - tests/Feature - src/Core/**/Tests/Feature - src/Mod/**/Tests/Feature - - - tests/Unit - src/Core/**/Tests/Unit - src/Mod/**/Tests/Unit - - - - - src/Core - - - diff --git a/packages/core-php/src/Mod/Tenant/Boot.php b/packages/core-php/src/Mod/Tenant/Boot.php deleted file mode 100644 index 354c780..0000000 --- a/packages/core-php/src/Mod/Tenant/Boot.php +++ /dev/null @@ -1,173 +0,0 @@ - - */ - public static array $listens = [ - AdminPanelBooting::class => 'onAdminPanel', - ApiRoutesRegistering::class => 'onApiRoutes', - WebRoutesRegistering::class => 'onWebRoutes', - ConsoleBooting::class => 'onConsole', - ]; - - public function register(): void - { - $this->app->singleton( - \Core\Mod\Tenant\Contracts\TwoFactorAuthenticationProvider::class, - \Core\Mod\Tenant\Services\TotpService::class - ); - - $this->app->singleton( - \Core\Mod\Tenant\Services\EntitlementService::class, - \Core\Mod\Tenant\Services\EntitlementService::class - ); - - $this->app->singleton( - \Core\Mod\Tenant\Services\WorkspaceManager::class, - \Core\Mod\Tenant\Services\WorkspaceManager::class - ); - - $this->app->singleton( - \Core\Mod\Tenant\Services\UserStatsService::class, - \Core\Mod\Tenant\Services\UserStatsService::class - ); - - $this->app->singleton( - \Core\Mod\Tenant\Services\WorkspaceService::class, - \Core\Mod\Tenant\Services\WorkspaceService::class - ); - - $this->app->singleton( - \Core\Mod\Tenant\Services\WorkspaceCacheManager::class, - \Core\Mod\Tenant\Services\WorkspaceCacheManager::class - ); - - $this->app->singleton( - \Core\Mod\Tenant\Services\UsageAlertService::class, - \Core\Mod\Tenant\Services\UsageAlertService::class - ); - - $this->app->singleton( - \Core\Mod\Tenant\Services\EntitlementWebhookService::class, - \Core\Mod\Tenant\Services\EntitlementWebhookService::class - ); - - $this->app->singleton( - \Core\Mod\Tenant\Services\WorkspaceTeamService::class, - \Core\Mod\Tenant\Services\WorkspaceTeamService::class - ); - - $this->registerBackwardCompatAliases(); - } - - protected function registerBackwardCompatAliases(): void - { - if (! class_exists(\App\Services\WorkspaceManager::class)) { - class_alias( - \Core\Mod\Tenant\Services\WorkspaceManager::class, - \App\Services\WorkspaceManager::class - ); - } - - if (! class_exists(\App\Services\UserStatsService::class)) { - class_alias( - \Core\Mod\Tenant\Services\UserStatsService::class, - \App\Services\UserStatsService::class - ); - } - - if (! class_exists(\App\Services\WorkspaceService::class)) { - class_alias( - \Core\Mod\Tenant\Services\WorkspaceService::class, - \App\Services\WorkspaceService::class - ); - } - - if (! class_exists(\App\Services\WorkspaceCacheManager::class)) { - class_alias( - \Core\Mod\Tenant\Services\WorkspaceCacheManager::class, - \App\Services\WorkspaceCacheManager::class - ); - } - } - - public function boot(): void - { - $this->loadMigrationsFrom(__DIR__.'/Migrations'); - $this->loadTranslationsFrom(__DIR__.'/Lang/en_GB', 'tenant'); - } - - // ------------------------------------------------------------------------- - // Event-driven handlers - // ------------------------------------------------------------------------- - - public function onAdminPanel(AdminPanelBooting $event): void - { - $event->views($this->moduleName, __DIR__.'/View/Blade'); - - // Admin Livewire components - $event->livewire('tenant.admin.entitlement-webhook-manager', View\Modal\Admin\EntitlementWebhookManager::class); - } - - public function onApiRoutes(ApiRoutesRegistering $event): void - { - if (file_exists(__DIR__.'/Routes/api.php')) { - $event->routes(fn () => Route::middleware('api')->group(__DIR__.'/Routes/api.php')); - } - } - - public function onWebRoutes(WebRoutesRegistering $event): void - { - $event->views($this->moduleName, __DIR__.'/View/Blade'); - - if (file_exists(__DIR__.'/Routes/web.php')) { - $event->routes(fn () => Route::middleware('web')->group(__DIR__.'/Routes/web.php')); - } - - // Account management - $event->livewire('tenant.account.cancel-deletion', View\Modal\Web\CancelDeletion::class); - $event->livewire('tenant.account.confirm-deletion', View\Modal\Web\ConfirmDeletion::class); - - // Workspace - $event->livewire('tenant.workspace.home', View\Modal\Web\WorkspaceHome::class); - } - - public function onConsole(ConsoleBooting $event): void - { - $event->middleware('admin.domain', Middleware\RequireAdminDomain::class); - $event->middleware('workspace.permission', Middleware\CheckWorkspacePermission::class); - - // Artisan commands - $event->command(Console\Commands\RefreshUserStats::class); - $event->command(Console\Commands\ProcessAccountDeletions::class); - $event->command(Console\Commands\CheckUsageAlerts::class); - $event->command(Console\Commands\ResetBillingCycles::class); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Concerns/BelongsToNamespace.php b/packages/core-php/src/Mod/Tenant/Concerns/BelongsToNamespace.php deleted file mode 100644 index ab25e5b..0000000 --- a/packages/core-php/src/Mod/Tenant/Concerns/BelongsToNamespace.php +++ /dev/null @@ -1,247 +0,0 @@ -where('is_active', true)->get(); - */ -trait BelongsToNamespace -{ - /** - * Boot the trait - sets up auto-assignment of namespace_id and cache invalidation. - */ - protected static function bootBelongsToNamespace(): void - { - // Auto-assign namespace_id when creating a model without one - static::creating(function ($model) { - if (empty($model->namespace_id)) { - $namespace = static::getCurrentNamespace(); - if ($namespace) { - $model->namespace_id = $namespace->id; - } - } - }); - - static::saved(function ($model) { - if ($model->namespace_id) { - static::clearNamespaceCache($model->namespace_id); - } - }); - - static::deleted(function ($model) { - if ($model->namespace_id) { - static::clearNamespaceCache($model->namespace_id); - } - }); - } - - /** - * Get the namespace this model belongs to. - */ - public function namespace(): BelongsTo - { - return $this->belongsTo(Namespace_::class, 'namespace_id'); - } - - /** - * Scope query to the current namespace. - */ - public function scopeOwnedByCurrentNamespace(Builder $query): Builder - { - $namespace = static::getCurrentNamespace(); - - if (! $namespace) { - return $query->whereRaw('1 = 0'); // Return empty result - } - - return $query->where('namespace_id', $namespace->id); - } - - /** - * Scope query to a specific namespace. - */ - public function scopeForNamespace(Builder $query, Namespace_|int $namespace): Builder - { - $namespaceId = $namespace instanceof Namespace_ ? $namespace->id : $namespace; - - return $query->where('namespace_id', $namespaceId); - } - - /** - * Scope query to all namespaces accessible by the current user. - */ - public function scopeAccessibleByCurrentUser(Builder $query): Builder - { - $user = auth()->user(); - - if (! $user || ! $user instanceof User) { - return $query->whereRaw('1 = 0'); // Return empty result - } - - $namespaceIds = Namespace_::accessibleBy($user)->pluck('id'); - - return $query->whereIn('namespace_id', $namespaceIds); - } - - /** - * Get all models owned by the current namespace, cached. - * - * @param int $ttl Cache TTL in seconds (default 5 minutes) - */ - public static function ownedByCurrentNamespaceCached(int $ttl = 300): Collection - { - $namespace = static::getCurrentNamespace(); - - if (! $namespace) { - return collect(); - } - - return Cache::remember( - static::namespaceCacheKey($namespace->id), - $ttl, - fn () => static::ownedByCurrentNamespace()->get() - ); - } - - /** - * Get all models for a specific namespace, cached. - * - * @param int $ttl Cache TTL in seconds (default 5 minutes) - */ - public static function forNamespaceCached(Namespace_|int $namespace, int $ttl = 300): Collection - { - $namespaceId = $namespace instanceof Namespace_ ? $namespace->id : $namespace; - - return Cache::remember( - static::namespaceCacheKey($namespaceId), - $ttl, - fn () => static::forNamespace($namespaceId)->get() - ); - } - - /** - * Get the cache key for a namespace's model collection. - */ - protected static function namespaceCacheKey(int $namespaceId): string - { - $modelClass = class_basename(static::class); - - return "namespace.{$namespaceId}.{$modelClass}"; - } - - /** - * Clear the cache for a namespace's model collection. - */ - public static function clearNamespaceCache(int $namespaceId): void - { - Cache::forget(static::namespaceCacheKey($namespaceId)); - } - - /** - * Clear cache for all namespaces accessible to current user. - */ - public static function clearAllNamespaceCache(): void - { - $user = auth()->user(); - - if ($user && $user instanceof User) { - $namespaces = Namespace_::accessibleBy($user)->get(); - foreach ($namespaces as $namespace) { - static::clearNamespaceCache($namespace->id); - } - } - } - - /** - * Get the current namespace from session/request. - */ - protected static function getCurrentNamespace(): ?Namespace_ - { - // Try to get from request attributes (set by middleware) - if (request()->attributes->has('current_namespace')) { - return request()->attributes->get('current_namespace'); - } - - // Try to get from session - $namespaceUuid = session('current_namespace_uuid'); - if ($namespaceUuid) { - $namespace = Namespace_::where('uuid', $namespaceUuid)->first(); - if ($namespace) { - return $namespace; - } - } - - // Fall back to user's default namespace - $user = auth()->user(); - if ($user && method_exists($user, 'defaultNamespace')) { - return $user->defaultNamespace(); - } - - return null; - } - - /** - * Check if this model belongs to the given namespace. - */ - public function belongsToNamespace(Namespace_|int $namespace): bool - { - $namespaceId = $namespace instanceof Namespace_ ? $namespace->id : $namespace; - - return $this->namespace_id === $namespaceId; - } - - /** - * Check if this model belongs to the current namespace. - */ - public function belongsToCurrentNamespace(): bool - { - $namespace = static::getCurrentNamespace(); - - if (! $namespace) { - return false; - } - - return $this->belongsToNamespace($namespace); - } - - /** - * Check if the current user can access this model. - */ - public function isAccessibleByCurrentUser(): bool - { - $user = auth()->user(); - - if (! $user || ! $user instanceof User) { - return false; - } - - if (! $this->namespace) { - return false; - } - - return $this->namespace->isAccessibleBy($user); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Concerns/BelongsToWorkspace.php b/packages/core-php/src/Mod/Tenant/Concerns/BelongsToWorkspace.php deleted file mode 100644 index 61d45ee..0000000 --- a/packages/core-php/src/Mod/Tenant/Concerns/BelongsToWorkspace.php +++ /dev/null @@ -1,349 +0,0 @@ -where('status', 'active')->get(); - * - * To opt out of strict mode (not recommended): - * class LegacyModel extends Model { - * use BelongsToWorkspace; - * protected bool $workspaceContextRequired = false; - * } - * - * For custom caching beyond the default ownedByCurrentWorkspace, also use HasWorkspaceCache: - * class Account extends Model { - * use BelongsToWorkspace, HasWorkspaceCache; - * - * public static function getActiveAccounts(): Collection - * { - * return static::rememberForWorkspace( - * 'active_accounts', - * 300, - * fn() => static::ownedByCurrentWorkspace()->where('status', 'active')->get() - * ); - * } - * } - */ -trait BelongsToWorkspace -{ - /** - * Boot the trait - sets up auto-assignment of workspace_id and cache invalidation. - * - * SECURITY: Throws MissingWorkspaceContextException when creating without workspace context, - * unless the model has opted out with $workspaceContextRequired = false. - */ - protected static function bootBelongsToWorkspace(): void - { - // Auto-assign workspace_id when creating a model without one - static::creating(function ($model) { - if (empty($model->workspace_id)) { - $workspace = static::getCurrentWorkspace(); - - if ($workspace) { - $model->workspace_id = $workspace->id; - - return; - } - - // No workspace context - check if we should enforce - if ($model->requiresWorkspaceContext()) { - throw MissingWorkspaceContextException::forCreate( - class_basename($model) - ); - } - } - }); - - // Clear cache on saved event (create/update) - static::saved(function ($model) { - if ($model->workspace_id) { - static::clearWorkspaceCache($model->workspace_id); - } - }); - - // Clear cache on deleted event - static::deleted(function ($model) { - if ($model->workspace_id) { - static::clearWorkspaceCache($model->workspace_id); - } - }); - } - - /** - * Determine if this model requires workspace context. - * - * Models can opt out by setting $workspaceContextRequired = false, - * but this is not recommended for security reasons. - */ - public function requiresWorkspaceContext(): bool - { - // Check model-level setting - if (property_exists($this, 'workspaceContextRequired')) { - return $this->workspaceContextRequired; - } - - // Check if global strict mode is disabled - if (! WorkspaceScope::isStrictModeEnabled()) { - return false; - } - - // Check if running from console (CLI commands may need to work without context) - if (app()->runningInConsole() && ! app()->runningUnitTests()) { - return false; - } - - // Default: require workspace context for security - return true; - } - - /** - * Get the workspace this model belongs to. - */ - public function workspace(): BelongsTo - { - return $this->belongsTo(Workspace::class); - } - - /** - * Scope query to the current user's default workspace. - * - * SECURITY: Throws MissingWorkspaceContextException when no workspace context - * is available and strict mode is enabled. - * - * @throws MissingWorkspaceContextException When workspace context is missing in strict mode - */ - public function scopeOwnedByCurrentWorkspace(Builder $query): Builder - { - $workspace = static::getCurrentWorkspace(); - - if ($workspace) { - return $query->where('workspace_id', $workspace->id); - } - - // No workspace context - check if we should enforce strict mode - if ($this->requiresWorkspaceContext()) { - throw MissingWorkspaceContextException::forScope( - class_basename($this) - ); - } - - // Non-strict mode: return empty result set (fail safe) - return $query->whereRaw('1 = 0'); - } - - /** - * Scope query to a specific workspace. - */ - public function scopeForWorkspace(Builder $query, Workspace|int $workspace): Builder - { - $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; - - return $query->where('workspace_id', $workspaceId); - } - - /** - * Get all models owned by the current workspace, cached. - * - * Uses the WorkspaceCacheManager for caching, which supports both - * tagged cache stores (Redis, Memcached) and non-tagged stores. - * - * SECURITY: Throws MissingWorkspaceContextException when no workspace context - * is available and strict mode is enabled. - * - * @param int|null $ttl Cache TTL in seconds (null = use config default) - * - * @throws MissingWorkspaceContextException When workspace context is missing in strict mode - */ - public static function ownedByCurrentWorkspaceCached(?int $ttl = null): Collection - { - $workspace = static::getCurrentWorkspace(); - - if ($workspace) { - return static::getWorkspaceCacheManager()->rememberModel( - $workspace, - static::class, - static::getDefaultCacheKey(), - $ttl, - fn () => static::ownedByCurrentWorkspace()->get() - ); - } - - // No workspace context - check if we should enforce strict mode - $instance = new static; - if ($instance->requiresWorkspaceContext()) { - throw MissingWorkspaceContextException::forScope( - class_basename(static::class) - ); - } - - // Non-strict mode: return empty collection (fail safe) - return collect(); - } - - /** - * Get all models for a specific workspace, cached. - * - * @param int|null $ttl Cache TTL in seconds (null = use config default) - */ - public static function forWorkspaceCached(Workspace|int $workspace, ?int $ttl = null): Collection - { - return static::getWorkspaceCacheManager()->rememberModel( - $workspace, - static::class, - static::getDefaultCacheKey(), - $ttl, - fn () => static::forWorkspace($workspace)->get() - ); - } - - /** - * Get the cache key for a workspace's model collection. - * - * This generates the full cache key including the workspace-scoped prefix. - */ - public static function workspaceCacheKey(int $workspaceId): string - { - return static::getWorkspaceCacheManager()->key( - $workspaceId, - static::getDefaultCacheKey() - ); - } - - /** - * Get the default cache key suffix for this model. - * - * Override this in your model to customise the cache key. - */ - protected static function getDefaultCacheKey(): string - { - return class_basename(static::class).'.all'; - } - - /** - * Clear the cache for a workspace's model collection. - * - * This clears the default cached collection. If using HasWorkspaceCache - * for custom cached queries, you may need to clear those separately. - */ - public static function clearWorkspaceCache(int $workspaceId): void - { - static::getWorkspaceCacheManager()->forget( - $workspaceId, - static::getDefaultCacheKey() - ); - } - - /** - * Clear cache for all workspaces this model exists in. - * - * For tagged cache stores (Redis), this flushes all cache for this model. - * For non-tagged stores, this clears cache for workspaces the current user has access to. - */ - public static function clearAllWorkspaceCaches(): void - { - $manager = static::getWorkspaceCacheManager(); - - // If tags are supported, we can flush all cache for this model efficiently - if ($manager->supportsTags()) { - $manager->flushModel(static::class); - - return; - } - - // For non-tagged stores, clear for all workspaces the current user has access to - $user = auth()->user(); - - if ($user && method_exists($user, 'hostWorkspaces')) { - foreach ($user->hostWorkspaces as $workspace) { - static::clearWorkspaceCache($workspace->id); - } - } - } - - /** - * Get the current user's default workspace. - * - * First checks request attributes (set by middleware), then falls back - * to the authenticated user's default workspace. - */ - protected static function getCurrentWorkspace(): ?Workspace - { - // First try to get from request attributes (set by middleware) - if (request()->attributes->has('workspace_model')) { - return request()->attributes->get('workspace_model'); - } - - // Then try to get from authenticated user - $user = auth()->user(); - - if (! $user) { - return null; - } - - // Use the Host UK method if available - if (method_exists($user, 'defaultHostWorkspace')) { - return $user->defaultHostWorkspace(); - } - - return null; - } - - /** - * Check if this model belongs to the given workspace. - */ - public function belongsToWorkspace(Workspace|int $workspace): bool - { - $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; - - return $this->workspace_id === $workspaceId; - } - - /** - * Check if this model belongs to the current user's workspace. - */ - public function belongsToCurrentWorkspace(): bool - { - $workspace = static::getCurrentWorkspace(); - - if (! $workspace) { - return false; - } - - return $this->belongsToWorkspace($workspace); - } - - /** - * Get the workspace cache manager instance. - */ - protected static function getWorkspaceCacheManager(): WorkspaceCacheManager - { - return app(WorkspaceCacheManager::class); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Concerns/HasWorkspaceCache.php b/packages/core-php/src/Mod/Tenant/Concerns/HasWorkspaceCache.php deleted file mode 100644 index 5ba50ba..0000000 --- a/packages/core-php/src/Mod/Tenant/Concerns/HasWorkspaceCache.php +++ /dev/null @@ -1,272 +0,0 @@ - static::ownedByCurrentWorkspace() - * ->where('status', 'active') - * ->get() - * ); - * } - * } - */ -trait HasWorkspaceCache -{ - /** - * Remember a value for the current workspace. - * - * @template T - * - * @param string $key The cache key (will be prefixed with workspace context) - * @param int|null $ttl TTL in seconds (null = use default from config) - * @param Closure(): T $callback The callback to generate the value - * @return T - */ - public static function rememberForWorkspace(string $key, ?int $ttl, Closure $callback): mixed - { - $workspace = static::getCurrentWorkspaceForCache(); - - if (! $workspace) { - // No workspace context - execute callback directly without caching - return $callback(); - } - - // Include model name in key to avoid collisions - $modelKey = static::getCacheKeyForModel($key); - - return static::getWorkspaceCacheManager()->rememberModel( - $workspace, - static::class, - $modelKey, - $ttl, - $callback - ); - } - - /** - * Remember a value forever for the current workspace. - * - * @template T - * - * @param Closure(): T $callback - * @return T - */ - public static function rememberForWorkspaceForever(string $key, Closure $callback): mixed - { - $workspace = static::getCurrentWorkspaceForCache(); - - if (! $workspace) { - return $callback(); - } - - $modelKey = static::getCacheKeyForModel($key); - - return static::getWorkspaceCacheManager()->rememberForever( - $workspace, - $modelKey, - $callback - ); - } - - /** - * Remember a value for a specific workspace. - * - * @template T - * - * @param Closure(): T $callback - * @return T - */ - public static function rememberForSpecificWorkspace( - Workspace|int $workspace, - string $key, - ?int $ttl, - Closure $callback - ): mixed { - $modelKey = static::getCacheKeyForModel($key); - - return static::getWorkspaceCacheManager()->rememberModel( - $workspace, - static::class, - $modelKey, - $ttl, - $callback - ); - } - - /** - * Store a value in cache for the current workspace. - */ - public static function putForWorkspace(string $key, mixed $value, ?int $ttl = null): bool - { - $workspace = static::getCurrentWorkspaceForCache(); - - if (! $workspace) { - return false; - } - - $modelKey = static::getCacheKeyForModel($key); - - return static::getWorkspaceCacheManager()->put( - $workspace, - $modelKey, - $value, - $ttl - ); - } - - /** - * Get a cached value for the current workspace. - */ - public static function getFromWorkspaceCache(string $key, mixed $default = null): mixed - { - $workspace = static::getCurrentWorkspaceForCache(); - - if (! $workspace) { - return $default; - } - - $modelKey = static::getCacheKeyForModel($key); - - return static::getWorkspaceCacheManager()->get( - $workspace, - $modelKey, - $default - ); - } - - /** - * Check if a key exists in the workspace cache. - */ - public static function hasInWorkspaceCache(string $key): bool - { - $workspace = static::getCurrentWorkspaceForCache(); - - if (! $workspace) { - return false; - } - - $modelKey = static::getCacheKeyForModel($key); - - return static::getWorkspaceCacheManager()->has( - $workspace, - $modelKey - ); - } - - /** - * Forget a specific key from the current workspace cache. - */ - public static function forgetForWorkspace(string $key): bool - { - $workspace = static::getCurrentWorkspaceForCache(); - - if (! $workspace) { - return false; - } - - $modelKey = static::getCacheKeyForModel($key); - - return static::getWorkspaceCacheManager()->forget( - $workspace, - $modelKey - ); - } - - /** - * Forget a specific key from a specific workspace cache. - */ - public static function forgetForSpecificWorkspace(Workspace|int $workspace, string $key): bool - { - $modelKey = static::getCacheKeyForModel($key); - - return static::getWorkspaceCacheManager()->forget( - $workspace, - $modelKey - ); - } - - /** - * Clear all cache for the current workspace's model data. - */ - public static function clearWorkspaceCacheForModel(): bool - { - $workspace = static::getCurrentWorkspaceForCache(); - - if (! $workspace) { - return false; - } - - // Clear the default workspace cache key - return static::getWorkspaceCacheManager()->forget( - $workspace, - static::getCacheKeyForModel('all') - ); - } - - /** - * Clear all cache for this model across all workspaces. - * Only works with tagged cache stores (Redis, Memcached). - */ - public static function clearAllWorkspaceCacheForModel(): bool - { - return static::getWorkspaceCacheManager()->flushModel(static::class); - } - - /** - * Get the cache key prefix for this model. - */ - protected static function getCacheKeyForModel(string $key): string - { - return class_basename(static::class).'.'.$key; - } - - /** - * Get the current workspace for caching. - */ - protected static function getCurrentWorkspaceForCache(): ?Workspace - { - // First try to get from request attributes (set by middleware) - if (request()->attributes->has('workspace_model')) { - return request()->attributes->get('workspace_model'); - } - - // Then try to get from authenticated user - $user = auth()->user(); - - if ($user && method_exists($user, 'defaultHostWorkspace')) { - return $user->defaultHostWorkspace(); - } - - return null; - } - - /** - * Get the workspace cache manager instance. - */ - protected static function getWorkspaceCacheManager(): WorkspaceCacheManager - { - return app(WorkspaceCacheManager::class); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Concerns/TwoFactorAuthenticatable.php b/packages/core-php/src/Mod/Tenant/Concerns/TwoFactorAuthenticatable.php deleted file mode 100644 index f838870..0000000 --- a/packages/core-php/src/Mod/Tenant/Concerns/TwoFactorAuthenticatable.php +++ /dev/null @@ -1,250 +0,0 @@ -hasOne(UserTwoFactorAuth::class, 'user_id'); - } - - /** - * Check if two-factor authentication is enabled. - */ - public function hasTwoFactorAuthEnabled(): bool - { - if ($this->twoFactorAuth) { - return ! is_null($this->twoFactorAuth->secret_key) - && ! is_null($this->twoFactorAuth->confirmed_at); - } - - return false; - } - - /** - * Get the two-factor authentication secret key. - */ - public function twoFactorAuthSecretKey(): ?string - { - return $this->twoFactorAuth?->secret_key; - } - - /** - * Get the two-factor recovery codes. - */ - public function twoFactorRecoveryCodes(): array - { - return $this->twoFactorAuth?->recovery_codes?->toArray() ?? []; - } - - /** - * Replace a used recovery code with a new one. - */ - public function twoFactorReplaceRecoveryCode(string $code): void - { - if (! $this->twoFactorAuth) { - return; - } - - $codes = $this->twoFactorRecoveryCodes(); - $index = array_search($code, $codes); - - if ($index !== false) { - $codes[$index] = $this->generateRecoveryCode(); - $this->twoFactorAuth->update(['recovery_codes' => $codes]); - } - } - - /** - * Generate a QR code SVG for two-factor setup. - */ - public function twoFactorQrCodeSvg(): string - { - $secret = $this->twoFactorAuthSecretKey(); - if (! $secret) { - return ''; - } - - $url = $this->twoFactorQrCodeUrl(); - - return $this->getTotpService()->qrCodeSvg($url); - } - - /** - * Generate the TOTP URL for QR code. - */ - public function twoFactorQrCodeUrl(): string - { - return $this->getTotpService()->qrCodeUrl( - config('app.name'), - $this->email, - $this->twoFactorAuthSecretKey() - ); - } - - /** - * Verify a TOTP code. - */ - public function verifyTwoFactorCode(string $code): bool - { - $secret = $this->twoFactorAuthSecretKey(); - if (! $secret) { - return false; - } - - return $this->getTotpService()->verify($secret, $code); - } - - /** - * Generate a new two-factor secret. - */ - public function generateTwoFactorSecret(): string - { - return $this->getTotpService()->generateSecretKey(); - } - - /** - * Verify a recovery code. - * - * @return bool True if the recovery code was valid and used - */ - public function verifyRecoveryCode(string $code): bool - { - $codes = $this->twoFactorRecoveryCodes(); - $code = strtoupper(trim($code)); - - $index = array_search($code, $codes); - - if ($index !== false) { - $this->twoFactorReplaceRecoveryCode($code); - - return true; - } - - return false; - } - - /** - * Generate a random recovery code. - */ - protected function generateRecoveryCode(): string - { - return strtoupper(bin2hex(random_bytes(5))).'-'.strtoupper(bin2hex(random_bytes(5))); - } - - /** - * Generate a set of recovery codes. - * - * @param int $count Number of codes to generate - */ - public function generateRecoveryCodes(int $count = 8): array - { - $codes = []; - - for ($i = 0; $i < $count; $i++) { - $codes[] = $this->generateRecoveryCode(); - } - - return $codes; - } - - /** - * Enable two-factor authentication for this user. - * - * Creates the 2FA record with a new secret but does not confirm it yet. - * The user must verify a code before 2FA is fully enabled. - * - * @return string The secret key for QR code generation - */ - public function enableTwoFactorAuth(): string - { - $secret = $this->generateTwoFactorSecret(); - - $this->twoFactorAuth()->updateOrCreate( - ['user_id' => $this->id], - [ - 'secret_key' => $secret, - 'recovery_codes' => null, - 'confirmed_at' => null, - ] - ); - - $this->load('twoFactorAuth'); - - return $secret; - } - - /** - * Confirm two-factor authentication after verifying a code. - * - * @return array The recovery codes - */ - public function confirmTwoFactorAuth(): array - { - if (! $this->twoFactorAuth || ! $this->twoFactorAuth->secret_key) { - throw new \RuntimeException('Two-factor authentication has not been initialised.'); - } - - $recoveryCodes = $this->generateRecoveryCodes(); - - $this->twoFactorAuth->update([ - 'recovery_codes' => $recoveryCodes, - 'confirmed_at' => now(), - ]); - - return $recoveryCodes; - } - - /** - * Disable two-factor authentication for this user. - */ - public function disableTwoFactorAuth(): void - { - $this->twoFactorAuth?->delete(); - $this->unsetRelation('twoFactorAuth'); - } - - /** - * Regenerate recovery codes. - * - * @return array The new recovery codes - */ - public function regenerateTwoFactorRecoveryCodes(): array - { - if (! $this->hasTwoFactorAuthEnabled()) { - throw new \RuntimeException('Two-factor authentication is not enabled.'); - } - - $recoveryCodes = $this->generateRecoveryCodes(); - - $this->twoFactorAuth->update([ - 'recovery_codes' => $recoveryCodes, - ]); - - return $recoveryCodes; - } - - /** - * Get the TOTP service instance. - */ - protected function getTotpService(): TwoFactorAuthenticationProvider - { - return app(TwoFactorAuthenticationProvider::class); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Console/Commands/CheckUsageAlerts.php b/packages/core-php/src/Mod/Tenant/Console/Commands/CheckUsageAlerts.php deleted file mode 100644 index 35cf2ca..0000000 --- a/packages/core-php/src/Mod/Tenant/Console/Commands/CheckUsageAlerts.php +++ /dev/null @@ -1,261 +0,0 @@ -option('dry-run'); - $verbose = $this->option('verbose'); - - if ($dryRun) { - $this->info('DRY RUN: No notifications will be sent.'); - } - - if ($workspaceOption = $this->option('workspace')) { - return $this->checkSingleWorkspace($workspaceOption, $dryRun, $verbose); - } - - return $this->checkAllWorkspaces($dryRun, $verbose); - } - - /** - * Check a single workspace. - */ - protected function checkSingleWorkspace(string $identifier, bool $dryRun, bool $verbose): int - { - $workspace = is_numeric($identifier) - ? Workspace::find($identifier) - : Workspace::where('slug', $identifier)->first(); - - if (! $workspace) { - $this->error("Workspace not found: {$identifier}"); - - return self::FAILURE; - } - - $this->info("Checking workspace: {$workspace->name} ({$workspace->slug})"); - - if ($dryRun) { - $this->showUsageStatus($workspace); - - return self::SUCCESS; - } - - $result = $this->alertService->checkWorkspace($workspace); - - $this->info("Alerts sent: {$result['alerts_sent']}"); - $this->info("Alerts resolved: {$result['alerts_resolved']}"); - - if ($verbose && ! empty($result['details'])) { - $this->newLine(); - $this->table( - ['Feature', 'Usage %', 'Threshold', 'Action'], - collect($result['details'])->map(fn ($d) => [ - $d['feature'], - $d['percentage'] !== null ? round($d['percentage'], 1).'%' : 'N/A', - $d['threshold'] ? $d['threshold'].'%' : 'N/A', - $d['alert_sent'] ? 'Alert sent' : ($d['resolved'] ? 'Resolved' : 'No action'), - ])->toArray() - ); - } - - return self::SUCCESS; - } - - /** - * Check all workspaces. - */ - protected function checkAllWorkspaces(bool $dryRun, bool $verbose): int - { - $this->info('Checking all active workspaces for usage alerts...'); - - if ($dryRun) { - $this->showAllWorkspacesStatus($verbose); - - return self::SUCCESS; - } - - $result = $this->alertService->checkAllWorkspaces(); - - $this->newLine(); - $this->info("Workspaces checked: {$result['checked']}"); - $this->info("Alerts sent: {$result['alerts_sent']}"); - $this->info("Alerts resolved: {$result['alerts_resolved']}"); - - if ($result['alerts_sent'] > 0) { - $this->comment('Usage alert notifications have been queued for delivery.'); - } - - return self::SUCCESS; - } - - /** - * Show usage status for a single workspace (dry run). - */ - protected function showUsageStatus(Workspace $workspace): void - { - $status = $this->alertService->getUsageStatus($workspace); - - if ($status->isEmpty()) { - $this->info('No features with limits found.'); - - return; - } - - $this->newLine(); - $this->table( - ['Feature', 'Used', 'Limit', 'Usage %', 'Status', 'Active Alert'], - $status->map(fn ($s) => [ - $s['name'], - $s['used'] ?? 0, - $s['limit'] ?? 'N/A', - $s['percentage'] !== null ? round($s['percentage'], 1).'%' : 'N/A', - $this->getStatusLabel($s), - $s['active_alert'] ? $s['alert_threshold'].'% alert' : '-', - ])->toArray() - ); - - $approaching = $status->filter(fn ($s) => $s['near_limit'] || $s['at_limit']); - - if ($approaching->isNotEmpty()) { - $this->newLine(); - $this->warn("Features approaching limits: {$approaching->count()}"); - - foreach ($approaching as $item) { - $wouldSend = ! $item['active_alert'] || $item['alert_threshold'] < $this->getThresholdForPercentage($item['percentage']); - - if ($wouldSend) { - $this->line(" - {$item['name']}: Would send alert"); - } else { - $this->line(" - {$item['name']}: Alert already sent"); - } - } - } - } - - /** - * Show status for all workspaces (dry run). - */ - protected function showAllWorkspacesStatus(bool $verbose): void - { - $workspaces = Workspace::query() - ->active() - ->whereHas('workspacePackages', fn ($q) => $q->active()) - ->get(); - - $this->info("Found {$workspaces->count()} active workspaces with packages."); - - $alerts = []; - - foreach ($workspaces as $workspace) { - $status = $this->alertService->getUsageStatus($workspace); - $approaching = $status->filter(fn ($s) => $s['near_limit'] || $s['at_limit']); - - if ($approaching->isNotEmpty()) { - foreach ($approaching as $item) { - $alerts[] = [ - 'workspace' => $workspace->name, - 'feature' => $item['name'], - 'used' => $item['used'], - 'limit' => $item['limit'], - 'percentage' => round($item['percentage'], 1), - 'has_alert' => $item['active_alert'] !== null, - ]; - } - } - } - - if (empty($alerts)) { - $this->info('No workspaces are approaching limits.'); - - return; - } - - $this->newLine(); - $this->warn('Found '.count($alerts).' features approaching limits:'); - $this->newLine(); - - $this->table( - ['Workspace', 'Feature', 'Used', 'Limit', '%', 'Alert Sent?'], - collect($alerts)->map(fn ($a) => [ - $a['workspace'], - $a['feature'], - $a['used'], - $a['limit'], - $a['percentage'].'%', - $a['has_alert'] ? 'Yes' : 'No', - ])->toArray() - ); - } - - /** - * Get status label for display. - */ - protected function getStatusLabel(array $status): string - { - if ($status['at_limit']) { - return 'At Limit'; - } - - if ($status['percentage'] >= 90) { - return 'Critical'; - } - - if ($status['near_limit']) { - return 'Warning'; - } - - return 'OK'; - } - - /** - * Get threshold for a given percentage. - */ - protected function getThresholdForPercentage(?float $percentage): ?int - { - if ($percentage === null) { - return null; - } - - if ($percentage >= 100) { - return 100; - } - - if ($percentage >= 90) { - return 90; - } - - if ($percentage >= 80) { - return 80; - } - - return null; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Console/Commands/ProcessAccountDeletions.php b/packages/core-php/src/Mod/Tenant/Console/Commands/ProcessAccountDeletions.php deleted file mode 100644 index 09c57a5..0000000 --- a/packages/core-php/src/Mod/Tenant/Console/Commands/ProcessAccountDeletions.php +++ /dev/null @@ -1,82 +0,0 @@ -with('user')->get(); - - if ($pendingDeletions->isEmpty()) { - $this->info('No pending account deletions to process.'); - - return self::SUCCESS; - } - - $this->info("Processing {$pendingDeletions->count()} account deletion(s)..."); - - $deleted = 0; - $failed = 0; - - foreach ($pendingDeletions as $request) { - try { - $user = $request->user; - - if (! $user) { - $this->warn("User not found for deletion request #{$request->id}"); - $request->complete(); - - continue; - } - - $this->line("Deleting account: {$user->email}"); - - DB::transaction(function () use ($request, $user) { - // Mark request as completed - $request->complete(); - - // Delete all workspaces owned by the user - if (method_exists($user, 'ownedWorkspaces')) { - $user->ownedWorkspaces()->each(function ($workspace) { - $workspace->delete(); - }); - } - - // Hard delete user account - $user->forceDelete(); - }); - - Log::info('Account deleted via scheduled task', [ - 'user_id' => $user->id, - 'email' => $user->email, - 'deletion_request_id' => $request->id, - ]); - - $deleted++; - } catch (\Exception $e) { - $this->error("Failed to delete account for request #{$request->id}: {$e->getMessage()}"); - Log::error('Failed to process account deletion', [ - 'deletion_request_id' => $request->id, - 'error' => $e->getMessage(), - ]); - $failed++; - } - } - - $this->info("Completed: {$deleted} deleted, {$failed} failed."); - - return $failed > 0 ? self::FAILURE : self::SUCCESS; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Console/Commands/RefreshUserStats.php b/packages/core-php/src/Mod/Tenant/Console/Commands/RefreshUserStats.php deleted file mode 100644 index 2e69729..0000000 --- a/packages/core-php/src/Mod/Tenant/Console/Commands/RefreshUserStats.php +++ /dev/null @@ -1,56 +0,0 @@ -option('user')) { - $this->refreshUser($userId); - - return Command::SUCCESS; - } - - // Refresh all users with stale stats (> 1 hour old) - $staleUsers = User::where(function ($query) { - $query->whereNull('stats_computed_at') - ->orWhere('stats_computed_at', '<', now()->subHour()); - })->pluck('id'); - - $this->info("Queuing stats refresh for {$staleUsers->count()} users..."); - - foreach ($staleUsers as $userId) { - ComputeUserStats::dispatch($userId)->onQueue('stats'); - } - - $this->info('Done! Stats will be computed in background.'); - - return Command::SUCCESS; - } - - protected function refreshUser(int $userId): void - { - $user = User::find($userId); - - if (! $user) { - $this->error("User {$userId} not found."); - - return; - } - - $this->info("Computing stats for user: {$user->name}..."); - ComputeUserStats::dispatchSync($userId); - $this->info('Done!'); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Console/Commands/ResetBillingCycles.php b/packages/core-php/src/Mod/Tenant/Console/Commands/ResetBillingCycles.php deleted file mode 100644 index 4c64106..0000000 --- a/packages/core-php/src/Mod/Tenant/Console/Commands/ResetBillingCycles.php +++ /dev/null @@ -1,411 +0,0 @@ -option('dry-run'); - $verbose = $this->option('verbose'); - - if ($dryRun) { - $this->info('DRY RUN: No changes will be made.'); - } - - $this->info('Starting billing cycle reset process...'); - $this->newLine(); - - if ($workspaceOption = $this->option('workspace')) { - return $this->processSingleWorkspace($workspaceOption, $dryRun, $verbose); - } - - return $this->processAllWorkspaces($dryRun, $verbose); - } - - /** - * Process a single workspace. - */ - protected function processSingleWorkspace(string $identifier, bool $dryRun, bool $verbose): int - { - $workspace = is_numeric($identifier) - ? Workspace::find($identifier) - : Workspace::where('slug', $identifier)->first(); - - if (! $workspace) { - $this->error("Workspace not found: {$identifier}"); - - return self::FAILURE; - } - - $this->info("Processing workspace: {$workspace->name} ({$workspace->slug})"); - - $result = $this->processWorkspace($workspace, $dryRun, $verbose); - - $this->outputSummary(); - - return $result ? self::SUCCESS : self::FAILURE; - } - - /** - * Process all workspaces. - */ - protected function processAllWorkspaces(bool $dryRun, bool $verbose): int - { - // Get workspaces with active packages - $workspaces = Workspace::query() - ->active() - ->whereHas('workspacePackages', fn ($q) => $q->active()) - ->get(); - - $this->info("Found {$workspaces->count()} active workspaces with packages."); - $this->newLine(); - - $bar = $this->output->createProgressBar($workspaces->count()); - $bar->start(); - - foreach ($workspaces as $workspace) { - try { - $this->processWorkspace($workspace, $dryRun, $verbose); - $this->workspacesProcessed++; - } catch (\Exception $e) { - $this->newLine(); - $this->error("Error processing workspace {$workspace->slug}: {$e->getMessage()}"); - - Log::error('Billing cycle reset failed for workspace', [ - 'workspace_id' => $workspace->id, - 'workspace_slug' => $workspace->slug, - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - } - - $bar->advance(); - } - - $bar->finish(); - $this->newLine(2); - - $this->outputSummary(); - - return self::SUCCESS; - } - - /** - * Process a single workspace's billing cycle. - */ - protected function processWorkspace(Workspace $workspace, bool $dryRun, bool $verbose): bool - { - // Get the primary (base) package to determine billing cycle - $primaryPackage = $workspace->workspacePackages() - ->whereHas('package', fn ($q) => $q->where('is_base_package', true)) - ->active() - ->first(); - - if (! $primaryPackage) { - if ($verbose) { - $this->line(" Skipping {$workspace->name}: No active base package"); - } - - return true; - } - - $cycleStart = $primaryPackage->getCurrentCycleStart(); - $cycleEnd = $primaryPackage->getCurrentCycleEnd(); - $previousCycleEnd = $cycleStart; - - // Determine if we're at a billing cycle boundary (within 24 hours of cycle start) - $isAtCycleStart = now()->diffInHours($cycleStart) < 24 && now()->gte($cycleStart); - - if ($verbose) { - $this->newLine(); - $this->line(" Workspace: {$workspace->name}"); - $this->line(" Cycle: {$cycleStart->format('Y-m-d')} to {$cycleEnd->format('Y-m-d')}"); - $this->line(' At cycle start: '.($isAtCycleStart ? 'Yes' : 'No')); - } - - // 1. Expire cycle-bound boosts from previous cycle - $expiredBoosts = $this->expireCycleBoundBoosts($workspace, $previousCycleEnd, $dryRun, $verbose); - - // 2. Reset usage counters at cycle start - if ($isAtCycleStart) { - $this->resetUsageCounters($workspace, $cycleStart, $dryRun, $verbose); - } - - // 3. Expire time-based boosts that have passed their expiry - $this->expireTimedBoosts($workspace, $dryRun, $verbose); - - // 4. Send notifications for expired boosts - if (! $dryRun && $expiredBoosts->isNotEmpty()) { - $this->sendBoostExpiryNotifications($workspace, $expiredBoosts, $verbose); - } - - return true; - } - - /** - * Expire cycle-bound boosts that should have ended in the previous cycle. - */ - protected function expireCycleBoundBoosts(Workspace $workspace, Carbon $cycleEnd, bool $dryRun, bool $verbose): Collection - { - $boosts = $workspace->boosts() - ->where('duration_type', Boost::DURATION_CYCLE_BOUND) - ->where('status', Boost::STATUS_ACTIVE) - ->where(function ($q) { - // Either no explicit expiry (cycle-bound) or expiry has passed - $q->whereNull('expires_at') - ->orWhere('expires_at', '<=', now()); - }) - ->get(); - - if ($boosts->isEmpty()) { - return collect(); - } - - if ($verbose) { - $this->line(" Found {$boosts->count()} cycle-bound boosts to expire"); - } - - if ($dryRun) { - foreach ($boosts as $boost) { - $this->line(" [DRY RUN] Would expire boost: {$boost->feature_code} (ID: {$boost->id})"); - } - - return $boosts; - } - - DB::transaction(function () use ($workspace, $boosts) { - foreach ($boosts as $boost) { - $boost->expire(); - - EntitlementLog::logBoostAction( - $workspace, - EntitlementLog::ACTION_BOOST_EXPIRED, - $boost, - source: EntitlementLog::SOURCE_SYSTEM, - metadata: [ - 'reason' => 'Billing cycle ended', - 'expired_at' => now()->toIso8601String(), - ] - ); - - $this->boostsExpired++; - } - }); - - // Invalidate entitlement cache - $this->entitlementService->invalidateCache($workspace); - - Log::info('Billing cycle: Expired cycle-bound boosts', [ - 'workspace_id' => $workspace->id, - 'workspace_slug' => $workspace->slug, - 'boosts_expired' => $boosts->count(), - 'boost_ids' => $boosts->pluck('id')->toArray(), - ]); - - return $boosts; - } - - /** - * Expire boosts with explicit time-based expiry that has passed. - */ - protected function expireTimedBoosts(Workspace $workspace, bool $dryRun, bool $verbose): void - { - $boosts = $workspace->boosts() - ->where('duration_type', Boost::DURATION_DURATION) - ->where('status', Boost::STATUS_ACTIVE) - ->where('expires_at', '<=', now()) - ->get(); - - if ($boosts->isEmpty()) { - return; - } - - if ($verbose) { - $this->line(" Found {$boosts->count()} timed boosts to expire"); - } - - if ($dryRun) { - foreach ($boosts as $boost) { - $this->line(" [DRY RUN] Would expire timed boost: {$boost->feature_code} (ID: {$boost->id})"); - } - - return; - } - - DB::transaction(function () use ($workspace, $boosts) { - foreach ($boosts as $boost) { - $boost->expire(); - - EntitlementLog::logBoostAction( - $workspace, - EntitlementLog::ACTION_BOOST_EXPIRED, - $boost, - source: EntitlementLog::SOURCE_SYSTEM, - metadata: [ - 'reason' => 'Duration expired', - 'expires_at' => $boost->expires_at->toIso8601String(), - 'expired_at' => now()->toIso8601String(), - ] - ); - - $this->boostsExpired++; - } - }); - - $this->entitlementService->invalidateCache($workspace); - } - - /** - * Reset usage counters for cycle-based features. - * - * Note: We don't actually delete usage records - instead, the EntitlementService - * calculates usage based on the current cycle start date. This method logs the - * cycle reset for audit purposes. - */ - protected function resetUsageCounters(Workspace $workspace, Carbon $cycleStart, bool $dryRun, bool $verbose): void - { - // Get count of usage records from previous cycle - $previousUsage = UsageRecord::where('workspace_id', $workspace->id) - ->where('recorded_at', '<', $cycleStart) - ->count(); - - if ($previousUsage === 0) { - return; - } - - if ($verbose) { - $this->line(" Cycle reset: {$previousUsage} usage records now in previous cycle"); - } - - if ($dryRun) { - $this->line(' [DRY RUN] Would log cycle reset for workspace'); - - return; - } - - // Log the cycle reset for audit trail - EntitlementLog::create([ - 'workspace_id' => $workspace->id, - 'action' => EntitlementLog::ACTION_CYCLE_RESET, - 'entity_type' => 'workspace', - 'entity_id' => $workspace->id, - 'source' => EntitlementLog::SOURCE_SYSTEM, - 'metadata' => [ - 'cycle_start' => $cycleStart->toIso8601String(), - 'previous_cycle_records' => $previousUsage, - 'reset_at' => now()->toIso8601String(), - ], - ]); - - $this->usageCountersReset++; - - // Invalidate usage cache so new calculations use current cycle - $this->entitlementService->invalidateCache($workspace); - - Log::info('Billing cycle: Reset usage counters', [ - 'workspace_id' => $workspace->id, - 'workspace_slug' => $workspace->slug, - 'cycle_start' => $cycleStart->toIso8601String(), - 'previous_cycle_records' => $previousUsage, - ]); - } - - /** - * Send notifications to workspace owner about expired boosts. - */ - protected function sendBoostExpiryNotifications(Workspace $workspace, Collection $expiredBoosts, bool $verbose): void - { - $owner = $workspace->owner(); - - if (! $owner) { - if ($verbose) { - $this->line(' No owner found for notification'); - } - - return; - } - - try { - $owner->notify(new BoostExpiredNotification($workspace, $expiredBoosts)); - $this->notificationsSent++; - - if ($verbose) { - $this->line(" Sent boost expiry notification to: {$owner->email}"); - } - } catch (\Exception $e) { - Log::error('Failed to send boost expiry notification', [ - 'workspace_id' => $workspace->id, - 'user_id' => $owner->id, - 'error' => $e->getMessage(), - ]); - } - } - - /** - * Output summary statistics. - */ - protected function outputSummary(): void - { - $this->info('Billing cycle reset completed.'); - $this->newLine(); - - $this->table( - ['Metric', 'Count'], - [ - ['Workspaces processed', $this->workspacesProcessed], - ['Boosts expired', $this->boostsExpired], - ['Usage cycles reset', $this->usageCountersReset], - ['Notifications sent', $this->notificationsSent], - ] - ); - - if ($this->boostsExpired > 0) { - $this->comment('Boost expiry notifications have been queued for delivery.'); - } - } -} diff --git a/packages/core-php/src/Mod/Tenant/Contracts/EntitlementWebhookEvent.php b/packages/core-php/src/Mod/Tenant/Contracts/EntitlementWebhookEvent.php deleted file mode 100644 index 569a070..0000000 --- a/packages/core-php/src/Mod/Tenant/Contracts/EntitlementWebhookEvent.php +++ /dev/null @@ -1,37 +0,0 @@ - - */ - public function payload(): array; - - /** - * Get a human-readable message for this event. - */ - public function message(): string; -} diff --git a/packages/core-php/src/Mod/Tenant/Contracts/TwoFactorAuthenticationProvider.php b/packages/core-php/src/Mod/Tenant/Contracts/TwoFactorAuthenticationProvider.php deleted file mode 100644 index eb5230b..0000000 --- a/packages/core-php/src/Mod/Tenant/Contracts/TwoFactorAuthenticationProvider.php +++ /dev/null @@ -1,36 +0,0 @@ -resolveWorkspace($request); - - $webhooks = EntitlementWebhook::query() - ->forWorkspace($workspace) - ->withCount('deliveries') - ->latest() - ->paginate($request->integer('per_page', 25)); - - return response()->json($webhooks); - } - - /** - * Create a new webhook. - */ - public function store(Request $request): JsonResponse - { - $workspace = $this->resolveWorkspace($request); - - $validated = $request->validate([ - 'name' => ['required', 'string', 'max:255'], - 'url' => ['required', 'url', 'max:2048'], - 'events' => ['required', 'array', 'min:1'], - 'events.*' => ['string', Rule::in(EntitlementWebhook::EVENTS)], - 'secret' => ['nullable', 'string', 'min:32'], - 'metadata' => ['nullable', 'array'], - ]); - - $webhook = $this->webhookService->register( - workspace: $workspace, - name: $validated['name'], - url: $validated['url'], - events: $validated['events'], - secret: $validated['secret'] ?? null, - metadata: $validated['metadata'] ?? [] - ); - - return response()->json([ - 'message' => __('Webhook created successfully'), - 'webhook' => $webhook, - 'secret' => $webhook->secret, // Return secret on creation only - ], 201); - } - - /** - * Get a specific webhook. - */ - public function show(Request $request, EntitlementWebhook $webhook): JsonResponse - { - $this->authorizeWebhook($request, $webhook); - - $webhook->loadCount('deliveries'); - $webhook->load(['deliveries' => fn ($q) => $q->latest('created_at')->limit(10)]); - - return response()->json([ - 'webhook' => $webhook, - 'available_events' => $this->webhookService->getAvailableEvents(), - ]); - } - - /** - * Update a webhook. - */ - public function update(Request $request, EntitlementWebhook $webhook): JsonResponse - { - $this->authorizeWebhook($request, $webhook); - - $validated = $request->validate([ - 'name' => ['sometimes', 'string', 'max:255'], - 'url' => ['sometimes', 'url', 'max:2048'], - 'events' => ['sometimes', 'array', 'min:1'], - 'events.*' => ['string', Rule::in(EntitlementWebhook::EVENTS)], - 'is_active' => ['sometimes', 'boolean'], - 'max_attempts' => ['sometimes', 'integer', 'min:1', 'max:10'], - 'metadata' => ['sometimes', 'array'], - ]); - - $webhook = $this->webhookService->update($webhook, $validated); - - return response()->json([ - 'message' => __('Webhook updated successfully'), - 'webhook' => $webhook, - ]); - } - - /** - * Delete a webhook. - */ - public function destroy(Request $request, EntitlementWebhook $webhook): JsonResponse - { - $this->authorizeWebhook($request, $webhook); - - $this->webhookService->unregister($webhook); - - return response()->json([ - 'message' => __('Webhook deleted successfully'), - ]); - } - - /** - * Regenerate webhook secret. - */ - public function regenerateSecret(Request $request, EntitlementWebhook $webhook): JsonResponse - { - $this->authorizeWebhook($request, $webhook); - - $secret = $webhook->regenerateSecret(); - - return response()->json([ - 'message' => __('Secret regenerated successfully'), - 'secret' => $secret, - ]); - } - - /** - * Send a test webhook. - */ - public function test(Request $request, EntitlementWebhook $webhook): JsonResponse - { - $this->authorizeWebhook($request, $webhook); - - $delivery = $this->webhookService->testWebhook($webhook); - - return response()->json([ - 'message' => $delivery->isSucceeded() - ? __('Test webhook sent successfully') - : __('Test webhook failed'), - 'delivery' => $delivery, - ]); - } - - /** - * Reset circuit breaker for a webhook. - */ - public function resetCircuitBreaker(Request $request, EntitlementWebhook $webhook): JsonResponse - { - $this->authorizeWebhook($request, $webhook); - - $this->webhookService->resetCircuitBreaker($webhook); - - return response()->json([ - 'message' => __('Webhook re-enabled successfully'), - 'webhook' => $webhook->refresh(), - ]); - } - - /** - * Get delivery history for a webhook. - */ - public function deliveries(Request $request, EntitlementWebhook $webhook): JsonResponse - { - $this->authorizeWebhook($request, $webhook); - - $deliveries = $webhook->deliveries() - ->latest('created_at') - ->paginate($request->integer('per_page', 50)); - - return response()->json($deliveries); - } - - /** - * Retry a failed delivery. - */ - public function retryDelivery(Request $request, EntitlementWebhookDelivery $delivery): JsonResponse - { - $this->authorizeWebhook($request, $delivery->webhook); - - if ($delivery->isSucceeded()) { - return response()->json([ - 'message' => __('Cannot retry a successful delivery'), - ], 422); - } - - $delivery = $this->webhookService->retryDelivery($delivery); - - return response()->json([ - 'message' => $delivery->isSucceeded() - ? __('Delivery retried successfully') - : __('Delivery retry failed'), - 'delivery' => $delivery, - ]); - } - - /** - * Get available event types. - */ - public function events(): JsonResponse - { - return response()->json([ - 'events' => $this->webhookService->getAvailableEvents(), - ]); - } - - /** - * Resolve the workspace from the request. - */ - protected function resolveWorkspace(Request $request): Workspace - { - // First try explicit workspace_id parameter - if ($request->has('workspace_id')) { - $workspace = Workspace::findOrFail($request->integer('workspace_id')); - - // Verify user has access - if (! $request->user()->workspaces->contains($workspace)) { - abort(403, 'You do not have access to this workspace'); - } - - return $workspace; - } - - // Fall back to user's default workspace - return $request->user()->defaultHostWorkspace() - ?? abort(400, 'No workspace specified and user has no default workspace'); - } - - /** - * Authorize that the user can access this webhook. - */ - protected function authorizeWebhook(Request $request, EntitlementWebhook $webhook): void - { - if (! $request->user()->workspaces->contains($webhook->workspace)) { - abort(403, 'You do not have access to this webhook'); - } - } -} diff --git a/packages/core-php/src/Mod/Tenant/Controllers/EntitlementApiController.php b/packages/core-php/src/Mod/Tenant/Controllers/EntitlementApiController.php deleted file mode 100644 index bb8dce6..0000000 --- a/packages/core-php/src/Mod/Tenant/Controllers/EntitlementApiController.php +++ /dev/null @@ -1,493 +0,0 @@ -validate([ - 'email' => 'required|email', - 'name' => 'required|string|max:255', - 'product_code' => 'required|string', - 'billing_cycle_anchor' => 'nullable|date', - 'expires_at' => 'nullable|date', - 'blesta_service_id' => 'nullable|string', - ]); - - // Find or create the user - $user = User::where('email', $validated['email'])->first(); - $isNewUser = false; - - if (! $user) { - $user = User::create([ - 'name' => $validated['name'], - 'email' => $validated['email'], - 'password' => bcrypt(Str::random(32)), // Random password, user can reset - ]); - $isNewUser = true; - - // Trigger email verification notification - event(new Registered($user)); - } - - // Find the package - $package = Package::where('code', $validated['product_code'])->first(); - - if (! $package) { - return response()->json([ - 'success' => false, - 'error' => "Package '{$validated['product_code']}' not found", - ], 404); - } - - // Get or create the user's primary workspace - $workspace = $user->ownedWorkspaces()->first(); - - if (! $workspace) { - $workspace = Workspace::create([ - 'name' => $user->name."'s Workspace", - 'slug' => Str::slug($user->name).'-'.Str::random(6), - 'domain' => 'hub.host.uk.com', - 'type' => 'custom', - ]); - - // Attach user as owner - $workspace->users()->attach($user->id, [ - 'role' => 'owner', - 'is_default' => true, - ]); - } - - // Provision the package - $workspacePackage = $this->entitlements->provisionPackage( - $workspace, - $package->code, - [ - 'source' => EntitlementLog::SOURCE_BLESTA, - 'billing_cycle_anchor' => $validated['billing_cycle_anchor'] - ? now()->parse($validated['billing_cycle_anchor']) - : now(), - 'expires_at' => $validated['expires_at'] - ? now()->parse($validated['expires_at']) - : null, - 'blesta_service_id' => $validated['blesta_service_id'], - 'metadata' => [ - 'created_via' => 'blesta_api', - 'client_email' => $validated['email'], - ], - ] - ); - - return response()->json([ - 'success' => true, - 'entitlement_id' => $workspacePackage->id, - 'workspace_id' => $workspace->id, - 'workspace_slug' => $workspace->slug, - 'package' => $package->code, - 'status' => $workspacePackage->status, - ], 201); - } - - /** - * Suspend an entitlement. - */ - public function suspend(Request $request, int $id): JsonResponse - { - $workspacePackage = WorkspacePackage::find($id); - - if (! $workspacePackage) { - return response()->json([ - 'success' => false, - 'error' => 'Entitlement not found', - ], 404); - } - - $workspace = $workspacePackage->workspace; - $workspacePackage->suspend(); - - EntitlementLog::logPackageAction( - $workspace, - EntitlementLog::ACTION_PACKAGE_SUSPENDED, - $workspacePackage, - source: EntitlementLog::SOURCE_BLESTA, - metadata: ['reason' => $request->input('reason', 'Suspended via Blesta')] - ); - - $this->entitlements->invalidateCache($workspace); - - return response()->json([ - 'success' => true, - 'entitlement_id' => $workspacePackage->id, - 'status' => $workspacePackage->fresh()->status, - ]); - } - - /** - * Unsuspend (reactivate) an entitlement. - */ - public function unsuspend(Request $request, int $id): JsonResponse - { - $workspacePackage = WorkspacePackage::find($id); - - if (! $workspacePackage) { - return response()->json([ - 'success' => false, - 'error' => 'Entitlement not found', - ], 404); - } - - $workspace = $workspacePackage->workspace; - $workspacePackage->reactivate(); - - EntitlementLog::logPackageAction( - $workspace, - EntitlementLog::ACTION_PACKAGE_REACTIVATED, - $workspacePackage, - source: EntitlementLog::SOURCE_BLESTA - ); - - $this->entitlements->invalidateCache($workspace); - - return response()->json([ - 'success' => true, - 'entitlement_id' => $workspacePackage->id, - 'status' => $workspacePackage->fresh()->status, - ]); - } - - /** - * Cancel an entitlement. - */ - public function cancel(Request $request, int $id): JsonResponse - { - $workspacePackage = WorkspacePackage::find($id); - - if (! $workspacePackage) { - return response()->json([ - 'success' => false, - 'error' => 'Entitlement not found', - ], 404); - } - - $workspace = $workspacePackage->workspace; - $workspacePackage->cancel(now()); - - EntitlementLog::logPackageAction( - $workspace, - EntitlementLog::ACTION_PACKAGE_CANCELLED, - $workspacePackage, - source: EntitlementLog::SOURCE_BLESTA, - metadata: ['reason' => $request->input('reason', 'Cancelled via Blesta')] - ); - - $this->entitlements->invalidateCache($workspace); - - return response()->json([ - 'success' => true, - 'entitlement_id' => $workspacePackage->id, - 'status' => $workspacePackage->fresh()->status, - ]); - } - - /** - * Renew an entitlement (extend expiry, reset usage). - */ - public function renew(Request $request, int $id): JsonResponse - { - $validated = $request->validate([ - 'expires_at' => 'nullable|date', - 'billing_cycle_anchor' => 'nullable|date', - ]); - - $workspacePackage = WorkspacePackage::find($id); - - if (! $workspacePackage) { - return response()->json([ - 'success' => false, - 'error' => 'Entitlement not found', - ], 404); - } - - $workspace = $workspacePackage->workspace; - - // Update dates - $updates = []; - if (isset($validated['expires_at'])) { - $updates['expires_at'] = now()->parse($validated['expires_at']); - } - if (isset($validated['billing_cycle_anchor'])) { - $updates['billing_cycle_anchor'] = now()->parse($validated['billing_cycle_anchor']); - } - - if (! empty($updates)) { - $workspacePackage->update($updates); - } - - // Expire cycle-bound boosts from the previous cycle - $this->entitlements->expireCycleBoundBoosts($workspace); - - EntitlementLog::logPackageAction( - $workspace, - EntitlementLog::ACTION_PACKAGE_RENEWED, - $workspacePackage, - source: EntitlementLog::SOURCE_BLESTA, - newValues: $updates - ); - - $this->entitlements->invalidateCache($workspace); - - return response()->json([ - 'success' => true, - 'entitlement_id' => $workspacePackage->id, - 'status' => $workspacePackage->fresh()->status, - 'expires_at' => $workspacePackage->fresh()->expires_at?->toIso8601String(), - ]); - } - - /** - * Get entitlement details. - */ - public function show(int $id): JsonResponse - { - $workspacePackage = WorkspacePackage::with(['package', 'workspace'])->find($id); - - if (! $workspacePackage) { - return response()->json([ - 'success' => false, - 'error' => 'Entitlement not found', - ], 404); - } - - return response()->json([ - 'success' => true, - 'entitlement' => [ - 'id' => $workspacePackage->id, - 'workspace_id' => $workspacePackage->workspace_id, - 'workspace_slug' => $workspacePackage->workspace->slug, - 'package_code' => $workspacePackage->package->code, - 'package_name' => $workspacePackage->package->name, - 'status' => $workspacePackage->status, - 'starts_at' => $workspacePackage->starts_at?->toIso8601String(), - 'expires_at' => $workspacePackage->expires_at?->toIso8601String(), - 'billing_cycle_anchor' => $workspacePackage->billing_cycle_anchor?->toIso8601String(), - 'blesta_service_id' => $workspacePackage->blesta_service_id, - ], - ]); - } - - // ========================================================================== - // Cross-App Entitlement API (for external services like BioHost) - // ========================================================================== - - /** - * Check if a feature is allowed for a user/workspace. - * - * Used by external apps (BioHost, etc.) to check entitlements. - * - * Query params: - * - email: User email to lookup workspace - * - feature: Feature code to check - * - quantity: Optional quantity to check (default 1) - */ - public function check(Request $request): JsonResponse - { - $validated = $request->validate([ - 'email' => 'required|email', - 'feature' => 'required|string', - 'quantity' => 'nullable|integer|min:1', - ]); - - // Find user by email - $user = User::where('email', $validated['email'])->first(); - - if (! $user) { - return response()->json([ - 'allowed' => false, - 'reason' => 'User not found', - 'feature_code' => $validated['feature'], - ], 404); - } - - // Get user's primary workspace - $workspace = $user->defaultHostWorkspace(); - - if (! $workspace) { - return response()->json([ - 'allowed' => false, - 'reason' => 'No workspace found for user', - 'feature_code' => $validated['feature'], - ], 404); - } - - // Check entitlement - $result = $this->entitlements->can( - $workspace, - $validated['feature'], - (int) ($validated['quantity'] ?? 1) - ); - - return response()->json([ - 'allowed' => $result->isAllowed(), - 'limit' => $result->limit, - 'used' => $result->used, - 'remaining' => $result->remaining, - 'unlimited' => $result->isUnlimited(), - 'usage_percentage' => $result->getUsagePercentage(), - 'feature_code' => $validated['feature'], - 'workspace_id' => $workspace->id, - ]); - } - - /** - * Record usage for a feature. - * - * Used by external apps to record usage after an action is performed. - */ - public function recordUsage(Request $request): JsonResponse - { - $validated = $request->validate([ - 'email' => 'required|email', - 'feature' => 'required|string', - 'quantity' => 'nullable|integer|min:1', - 'metadata' => 'nullable|array', - ]); - - // Find user by email - $user = User::where('email', $validated['email'])->first(); - - if (! $user) { - return response()->json([ - 'success' => false, - 'error' => 'User not found', - ], 404); - } - - // Get user's primary workspace - $workspace = $user->defaultHostWorkspace(); - - if (! $workspace) { - return response()->json([ - 'success' => false, - 'error' => 'No workspace found for user', - ], 404); - } - - // Record usage - $record = $this->entitlements->recordUsage( - $workspace, - $validated['feature'], - $validated['quantity'] ?? 1, - $user, - $validated['metadata'] ?? null - ); - - return response()->json([ - 'success' => true, - 'usage_record_id' => $record->id, - 'feature_code' => $validated['feature'], - 'quantity' => $validated['quantity'] ?? 1, - ], 201); - } - - /** - * Get usage summary for a workspace. - * - * Returns all features with their current usage for dashboard display. - */ - public function summary(Request $request, Workspace $workspace): JsonResponse - { - // Get active packages - $packages = $this->entitlements->getActivePackages($workspace); - - // Get active boosts - $boosts = $this->entitlements->getActiveBoosts($workspace); - - // Get usage summary grouped by category - $usageSummary = $this->entitlements->getUsageSummary($workspace); - - // Format features for response - $features = []; - foreach ($usageSummary as $category => $categoryFeatures) { - $features[$category] = collect($categoryFeatures)->map(fn ($f) => [ - 'code' => $f['code'], - 'name' => $f['name'], - 'limit' => $f['limit'], - 'used' => $f['used'], - 'remaining' => $f['remaining'], - 'unlimited' => $f['unlimited'], - 'percentage' => $f['percentage'], - ])->values()->toArray(); - } - - return response()->json([ - 'workspace_id' => $workspace->id, - 'packages' => $packages->map(fn ($wp) => [ - 'code' => $wp->package->code, - 'name' => $wp->package->name, - 'status' => $wp->status, - 'expires_at' => $wp->expires_at?->toIso8601String(), - ])->values(), - 'features' => $features, - 'boosts' => $boosts->map(fn ($b) => [ - 'feature' => $b->feature_code, - 'value' => $b->limit_value, - 'type' => $b->boost_type, - 'expires_at' => $b->expires_at?->toIso8601String(), - ])->values(), - ]); - } - - /** - * Get usage summary for the authenticated user's workspace. - */ - public function mySummary(Request $request): JsonResponse - { - $user = $request->user(); - - if (! $user) { - return response()->json([ - 'error' => 'Unauthenticated', - ], 401); - } - - $workspace = $user->defaultHostWorkspace(); - - if (! $workspace) { - return response()->json([ - 'error' => 'No workspace found', - ], 404); - } - - return $this->summary($request, $workspace); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Controllers/ReferralController.php b/packages/core-php/src/Mod/Tenant/Controllers/ReferralController.php deleted file mode 100644 index 2382ac1..0000000 --- a/packages/core-php/src/Mod/Tenant/Controllers/ReferralController.php +++ /dev/null @@ -1,138 +0,0 @@ -route('pricing'); - } - - // Normalise provider and model to lowercase - $provider = strtolower($provider); - $model = $model ? strtolower($model) : null; - - // Build referral data for session (includes hashed IP for fraud detection) - $referral = [ - 'provider' => $provider, - 'model' => $model, - 'referred_at' => now()->toIso8601String(), - 'ip_hash' => PrivacyHelper::hashIp($request->ip()), - ]; - - // Track the referral visit in stats (raw inbound count) - TreePlantingStats::incrementReferrals($provider, $model); - - // Store in session (primary) - includes hashed IP - $request->session()->put(self::REFERRAL_SESSION, $referral); - - // Cookie data - exclude IP for privacy (GDPR compliance) - // Provider/model is sufficient for referral attribution - $cookieData = [ - 'provider' => $provider, - 'model' => $model, - 'referred_at' => $referral['referred_at'], - ]; - - // Set 30-day cookie (backup for session expiry) - $cookie = Cookie::make( - name: self::REFERRAL_COOKIE, - value: json_encode($cookieData), - minutes: self::COOKIE_LIFETIME, - path: '/', - domain: config('session.domain'), - secure: config('app.env') === 'production', - httpOnly: true, - sameSite: 'lax' - ); - - // Redirect to pricing with ref=agent parameter - return redirect() - ->route('pricing', ['ref' => 'agent']) - ->withCookie($cookie); - } - - /** - * Get the agent referral from session or cookie. - * - * @return array{provider: string, model: ?string, referred_at: string, ip_hash?: string}|null - */ - public static function getReferral(Request $request): ?array - { - // Try session first - $referral = $request->session()->get(self::REFERRAL_SESSION); - - if ($referral) { - return $referral; - } - - // Fall back to cookie - $cookie = $request->cookie(self::REFERRAL_COOKIE); - - if ($cookie) { - try { - $decoded = json_decode($cookie, true); - if (is_array($decoded) && isset($decoded['provider'])) { - return $decoded; - } - } catch (\Throwable) { - // Cookie invalid — ignore - } - } - - return null; - } - - /** - * Clear the agent referral from session and cookie. - */ - public static function clearReferral(Request $request): void - { - $request->session()->forget(self::REFERRAL_SESSION); - Cookie::queue(Cookie::forget(self::REFERRAL_COOKIE)); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Controllers/WorkspaceController.php b/packages/core-php/src/Mod/Tenant/Controllers/WorkspaceController.php deleted file mode 100644 index 91c7d68..0000000 --- a/packages/core-php/src/Mod/Tenant/Controllers/WorkspaceController.php +++ /dev/null @@ -1,277 +0,0 @@ -user(); - - if (! $user instanceof User) { - return $this->accessDeniedResponse('Authentication required.'); - } - - $query = $user->workspaces() - ->withCount(['users', 'bioPages']) - ->orderBy('user_workspace.is_default', 'desc') - ->orderBy('workspaces.name', 'asc'); - - // Filter by type - if ($request->has('type')) { - $query->where('type', $request->input('type')); - } - - // Filter by active status - if ($request->has('is_active')) { - $query->where('is_active', filter_var($request->input('is_active'), FILTER_VALIDATE_BOOLEAN)); - } - - // Search by name - if ($request->has('search')) { - $query->where('workspaces.name', 'like', '%'.$request->input('search').'%'); - } - - $perPage = min((int) $request->input('per_page', 25), 100); - $workspaces = $query->paginate($perPage); - - return new PaginatedCollection($workspaces, WorkspaceResource::class); - } - - /** - * Get the current workspace. - * - * GET /api/v1/workspaces/current - */ - public function current(Request $request): WorkspaceResource|JsonResponse - { - $workspace = $this->resolveWorkspace($request); - - if (! $workspace) { - return $this->noWorkspaceResponse(); - } - - $workspace->loadCount(['users', 'bioPages']); - - return new WorkspaceResource($workspace); - } - - /** - * Get a single workspace. - * - * GET /api/v1/workspaces/{workspace} - */ - public function show(Request $request, Workspace $workspace): WorkspaceResource|JsonResponse - { - $user = $request->user(); - - if (! $user instanceof User) { - return $this->accessDeniedResponse('Authentication required.'); - } - - // Verify user has access to workspace - $hasAccess = $user->workspaces() - ->where('workspaces.id', $workspace->id) - ->exists(); - - if (! $hasAccess) { - return $this->notFoundResponse('Workspace'); - } - - $workspace->loadCount(['users', 'bioPages']); - - return new WorkspaceResource($workspace); - } - - /** - * Create a new workspace. - * - * POST /api/v1/workspaces - */ - public function store(Request $request): WorkspaceResource|JsonResponse - { - $user = $request->user(); - - if (! $user instanceof User) { - return $this->accessDeniedResponse('Authentication required.'); - } - - $validated = $request->validate([ - 'name' => 'required|string|max:255', - 'slug' => 'nullable|string|max:100|unique:workspaces,slug', - 'icon' => 'nullable|string|max:50', - 'color' => 'nullable|string|max:20', - 'description' => 'nullable|string|max:500', - 'type' => 'nullable|string|in:personal,team,agency,custom', - ]); - - // Generate slug if not provided - if (empty($validated['slug'])) { - $validated['slug'] = \Illuminate\Support\Str::slug($validated['name']).'-'.\Illuminate\Support\Str::random(6); - } - - // Set default domain - $validated['domain'] = 'hub.host.uk.com'; - $validated['type'] = $validated['type'] ?? 'custom'; - - $workspace = Workspace::create($validated); - - // Attach user as owner - $workspace->users()->attach($user->id, [ - 'role' => 'owner', - 'is_default' => false, - ]); - - $workspace->loadCount(['users', 'bioPages']); - - return new WorkspaceResource($workspace); - } - - /** - * Update a workspace. - * - * PUT /api/v1/workspaces/{workspace} - */ - public function update(Request $request, Workspace $workspace): WorkspaceResource|JsonResponse - { - $user = $request->user(); - - if (! $user instanceof User) { - return $this->accessDeniedResponse('Authentication required.'); - } - - // Verify user has owner/admin access - $pivot = $user->workspaces() - ->where('workspaces.id', $workspace->id) - ->first() - ?->pivot; - - if (! $pivot || ! in_array($pivot->role, ['owner', 'admin'], true)) { - return $this->accessDeniedResponse('You do not have permission to update this workspace.'); - } - - $validated = $request->validate([ - 'name' => 'sometimes|string|max:255', - 'slug' => 'sometimes|string|max:100|unique:workspaces,slug,'.$workspace->id, - 'icon' => 'nullable|string|max:50', - 'color' => 'nullable|string|max:20', - 'description' => 'nullable|string|max:500', - 'is_active' => 'sometimes|boolean', - ]); - - $workspace->update($validated); - $workspace->loadCount(['users', 'bioPages']); - - return new WorkspaceResource($workspace); - } - - /** - * Delete a workspace. - * - * DELETE /api/v1/workspaces/{workspace} - */ - public function destroy(Request $request, Workspace $workspace): JsonResponse - { - $user = $request->user(); - - if (! $user instanceof User) { - return $this->accessDeniedResponse('Authentication required.'); - } - - // Verify user is the owner - $pivot = $user->workspaces() - ->where('workspaces.id', $workspace->id) - ->first() - ?->pivot; - - if (! $pivot || $pivot->role !== 'owner') { - return $this->accessDeniedResponse('Only the workspace owner can delete a workspace.'); - } - - // Prevent deleting user's only workspace - $workspaceCount = $user->workspaces()->count(); - if ($workspaceCount <= 1) { - return response()->json([ - 'error' => 'cannot_delete', - 'message' => 'You cannot delete your only workspace.', - ], 422); - } - - $workspace->delete(); - - return response()->json(null, 204); - } - - /** - * Switch to a workspace (set as default). - * - * POST /api/v1/workspaces/{workspace}/switch - */ - public function switch(Request $request, Workspace $workspace): WorkspaceResource|JsonResponse - { - $user = $request->user(); - - if (! $user instanceof User) { - return $this->accessDeniedResponse('Authentication required.'); - } - - // Verify user has access - $hasAccess = $user->workspaces() - ->where('workspaces.id', $workspace->id) - ->exists(); - - if (! $hasAccess) { - return $this->notFoundResponse('Workspace'); - } - - // Use a single transaction with optimised query: - // Clear all defaults and set the new one in one operation using raw update - \Illuminate\Support\Facades\DB::transaction(function () use ($user, $workspace) { - // Clear all existing defaults for this user's hub workspaces - \Illuminate\Support\Facades\DB::table('user_workspace') - ->where('user_id', $user->id) - ->whereIn('workspace_id', function ($query) { - $query->select('id') - ->from('workspaces') - ->where('domain', 'hub.host.uk.com'); - }) - ->update(['is_default' => false]); - - // Set the new default - \Illuminate\Support\Facades\DB::table('user_workspace') - ->where('user_id', $user->id) - ->where('workspace_id', $workspace->id) - ->update(['is_default' => true]); - }); - - $workspace->loadCount(['users', 'bioPages']); - - return new WorkspaceResource($workspace); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Controllers/WorkspaceInvitationController.php b/packages/core-php/src/Mod/Tenant/Controllers/WorkspaceInvitationController.php deleted file mode 100644 index 999d1ff..0000000 --- a/packages/core-php/src/Mod/Tenant/Controllers/WorkspaceInvitationController.php +++ /dev/null @@ -1,68 +0,0 @@ -route('login') - ->with('error', 'This invitation link is invalid.'); - } - - // Already accepted - if ($invitation->isAccepted()) { - return redirect()->route('login') - ->with('info', 'This invitation has already been accepted.'); - } - - // Expired - if ($invitation->isExpired()) { - return redirect()->route('login') - ->with('error', 'This invitation has expired. Please ask the workspace owner to send a new invitation.'); - } - - // User not authenticated - redirect to login with intended return URL - if (! $request->user()) { - return redirect()->route('login', [ - 'email' => $invitation->email, - ])->with('invitation_token', $token) - ->with('info', "You've been invited to join {$invitation->workspace->name}. Please log in or register to accept."); - } - - // Accept the invitation - $accepted = Workspace::acceptInvitation($token, $request->user()); - - if (! $accepted) { - return redirect()->route('dashboard') - ->with('error', 'Unable to accept this invitation. It may have expired or already been used.'); - } - - // Redirect to the workspace - return redirect()->route('workspace.home', ['workspace' => $invitation->workspace->slug]) - ->with('success', "You've joined {$invitation->workspace->name}."); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Database/Factories/UserFactory.php b/packages/core-php/src/Mod/Tenant/Database/Factories/UserFactory.php deleted file mode 100644 index 3a56e26..0000000 --- a/packages/core-php/src/Mod/Tenant/Database/Factories/UserFactory.php +++ /dev/null @@ -1,73 +0,0 @@ - - */ -class UserFactory extends Factory -{ - /** - * The name of the factory's corresponding model. - * - * Uses the backward-compatible alias class to ensure type compatibility - * with existing code that expects Mod\Tenant\Models\User. - */ - protected $model = \Core\Mod\Tenant\Models\User::class; - - /** - * The current password being used by the factory. - */ - protected static ?string $password; - - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - 'name' => fake()->name(), - 'email' => fake()->unique()->safeEmail(), - 'email_verified_at' => now(), - 'password' => static::$password ??= Hash::make('password'), - 'remember_token' => Str::random(10), - 'account_type' => 'apollo', - ]; - } - - /** - * Create a Hades (admin) user. - */ - public function hades(): static - { - return $this->state(fn (array $attributes) => [ - 'account_type' => 'hades', - ]); - } - - /** - * Create an Apollo (standard) user. - */ - public function apollo(): static - { - return $this->state(fn (array $attributes) => [ - 'account_type' => 'apollo', - ]); - } - - /** - * Indicate that the model's email address should be unverified. - */ - public function unverified(): static - { - return $this->state(fn (array $attributes) => [ - 'email_verified_at' => null, - ]); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Database/Factories/UserTokenFactory.php b/packages/core-php/src/Mod/Tenant/Database/Factories/UserTokenFactory.php deleted file mode 100644 index dab5b03..0000000 --- a/packages/core-php/src/Mod/Tenant/Database/Factories/UserTokenFactory.php +++ /dev/null @@ -1,87 +0,0 @@ - - */ -class UserTokenFactory extends Factory -{ - /** - * The name of the factory's corresponding model. - * - * @var class-string - */ - protected $model = UserToken::class; - - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - $plainToken = Str::random(40); - - return [ - 'user_id' => User::factory(), - 'name' => fake()->words(2, true).' Token', - 'token' => hash('sha256', $plainToken), - 'last_used_at' => null, - 'expires_at' => null, - ]; - } - - /** - * Indicate that the token has been used recently. - */ - public function used(): static - { - return $this->state(fn (array $attributes) => [ - 'last_used_at' => now()->subMinutes(fake()->numberBetween(1, 60)), - ]); - } - - /** - * Indicate that the token expires in the future. - * - * @param int $days Number of days until expiration - */ - public function expiresIn(int $days = 30): static - { - return $this->state(fn (array $attributes) => [ - 'expires_at' => now()->addDays($days), - ]); - } - - /** - * Indicate that the token has expired. - */ - public function expired(): static - { - return $this->state(fn (array $attributes) => [ - 'expires_at' => now()->subDays(1), - ]); - } - - /** - * Create a token with a known plain-text value for testing. - * - * @param string $plainToken The plain-text token value - */ - public function withToken(string $plainToken): static - { - return $this->state(fn (array $attributes) => [ - 'token' => hash('sha256', $plainToken), - ]); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Database/Factories/WaitlistEntryFactory.php b/packages/core-php/src/Mod/Tenant/Database/Factories/WaitlistEntryFactory.php deleted file mode 100644 index 01ca0dd..0000000 --- a/packages/core-php/src/Mod/Tenant/Database/Factories/WaitlistEntryFactory.php +++ /dev/null @@ -1,59 +0,0 @@ - - */ -class WaitlistEntryFactory extends Factory -{ - protected $model = WaitlistEntry::class; - - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - 'email' => fake()->unique()->safeEmail(), - 'name' => fake()->optional(0.8)->name(), - 'source' => fake()->randomElement(['direct', 'twitter', 'linkedin', 'google', 'referral']), - 'interest' => fake()->optional(0.5)->randomElement(['SocialHost', 'BioHost', 'AnalyticsHost', 'TrustHost', 'NotifyHost']), - 'invite_code' => null, - 'invited_at' => null, - 'registered_at' => null, - 'user_id' => null, - 'notes' => null, - 'bonus_code' => null, - ]; - } - - /** - * Indicate the entry has been invited. - */ - public function invited(): static - { - return $this->state(fn (array $attributes) => [ - 'invite_code' => strtoupper(fake()->bothify('????????')), - 'invited_at' => fake()->dateTimeBetween('-30 days', 'now'), - 'bonus_code' => 'LAUNCH50', - ]); - } - - /** - * Indicate the entry has converted to a user. - */ - public function converted(): static - { - return $this->invited()->state(fn (array $attributes) => [ - 'registered_at' => fake()->dateTimeBetween($attributes['invited_at'] ?? '-7 days', 'now'), - ]); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Database/Factories/WorkspaceFactory.php b/packages/core-php/src/Mod/Tenant/Database/Factories/WorkspaceFactory.php deleted file mode 100644 index 55f4cc2..0000000 --- a/packages/core-php/src/Mod/Tenant/Database/Factories/WorkspaceFactory.php +++ /dev/null @@ -1,81 +0,0 @@ - - */ -class WorkspaceFactory extends Factory -{ - protected $model = Workspace::class; - - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - $name = fake()->company(); - $slug = fake()->unique()->slug(2); - - return [ - 'name' => $name, - 'slug' => $slug, - 'domain' => $slug.'.host.uk.com', - 'icon' => fake()->randomElement(['globe', 'building', 'newspaper', 'megaphone']), - 'color' => fake()->randomElement(['violet', 'blue', 'green', 'amber', 'rose']), - 'description' => fake()->sentence(), - 'type' => 'cms', - 'settings' => [], - 'is_active' => true, - 'sort_order' => fake()->numberBetween(1, 100), - ]; - } - - /** - * Create a CMS workspace. - */ - public function cms(): static - { - return $this->state(fn (array $attributes) => [ - 'type' => 'cms', - ]); - } - - /** - * Create a static workspace. - */ - public function static(): static - { - return $this->state(fn (array $attributes) => [ - 'type' => 'static', - ]); - } - - /** - * Create an inactive workspace. - */ - public function inactive(): static - { - return $this->state(fn (array $attributes) => [ - 'is_active' => false, - ]); - } - - /** - * Create the main workspace (used in tests). - */ - public function main(): static - { - return $this->state(fn (array $attributes) => [ - 'name' => 'Host UK', - 'slug' => 'main', - 'domain' => 'hestia.host.uk.com', - 'type' => 'cms', - ]); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Database/Factories/WorkspaceInvitationFactory.php b/packages/core-php/src/Mod/Tenant/Database/Factories/WorkspaceInvitationFactory.php deleted file mode 100644 index c1771b2..0000000 --- a/packages/core-php/src/Mod/Tenant/Database/Factories/WorkspaceInvitationFactory.php +++ /dev/null @@ -1,75 +0,0 @@ - - */ -class WorkspaceInvitationFactory extends Factory -{ - protected $model = WorkspaceInvitation::class; - - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - 'email' => fake()->unique()->safeEmail(), - 'token' => Str::random(64), - 'role' => 'member', - 'invited_by' => null, - 'expires_at' => now()->addDays(7), - 'accepted_at' => null, - ]; - } - - /** - * Indicate the invitation has been accepted. - */ - public function accepted(): static - { - return $this->state(fn (array $attributes) => [ - 'accepted_at' => fake()->dateTimeBetween('-7 days', 'now'), - ]); - } - - /** - * Indicate the invitation has expired. - */ - public function expired(): static - { - return $this->state(fn (array $attributes) => [ - 'expires_at' => fake()->dateTimeBetween('-30 days', '-1 day'), - 'accepted_at' => null, - ]); - } - - /** - * Set the role to admin. - */ - public function asAdmin(): static - { - return $this->state(fn (array $attributes) => [ - 'role' => 'admin', - ]); - } - - /** - * Set the role to owner. - */ - public function asOwner(): static - { - return $this->state(fn (array $attributes) => [ - 'role' => 'owner', - ]); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Database/Seeders/DemoTestUserSeeder.php b/packages/core-php/src/Mod/Tenant/Database/Seeders/DemoTestUserSeeder.php deleted file mode 100644 index d1da763..0000000 --- a/packages/core-php/src/Mod/Tenant/Database/Seeders/DemoTestUserSeeder.php +++ /dev/null @@ -1,170 +0,0 @@ - self::EMAIL], - [ - 'name' => 'Nyx Tester', - 'password' => Hash::make(self::PASSWORD), - 'email_verified_at' => now(), - ] - ); - - // Create or update Nyx demo workspace - $workspace = Workspace::updateOrCreate( - ['slug' => self::WORKSPACE_SLUG], - [ - 'name' => 'Nyx Demo Workspace', - 'domain' => 'nyx.host.uk.com', - 'is_active' => true, - ] - ); - - // Attach user to workspace (if not already) - if (! $workspace->users()->where('user_id', $user->id)->exists()) { - $workspace->users()->attach($user->id, [ - 'role' => 'owner', - 'is_default' => true, - ]); - } - - // Assign Nyx package (Lethean Network demo tier) - $nyxPackage = Package::where('code', 'nyx')->first(); - if ($nyxPackage) { - // Remove any existing packages - $workspace->workspacePackages()->delete(); - - // Create Nyx package assignment - $workspace->workspacePackages()->create([ - 'package_id' => $nyxPackage->id, - 'status' => 'active', - 'starts_at' => now(), - 'expires_at' => null, // No expiry for test account - ]); - } - - // Create minimal test data for the workspace - $this->createTestBioPage($workspace, $user); - $this->createTestShortLink($workspace, $user); - - $this->command->info('Nyx demo user created successfully.'); - $this->command->info("Email: {$user->email}"); - $this->command->info('Password: '.self::PASSWORD); - $this->command->info("Workspace: {$workspace->slug}"); - $this->command->info('Tier: Nyx (Lethean Network)'); - } - - /** - * Create a single test bio page. - */ - protected function createTestBioPage(Workspace $workspace, User $user): void - { - // Only create if Web Page model exists and no test page exists - if (! class_exists(\Core\Mod\Web\Models\Page::class)) { - return; - } - - $existingPage = \Core\Mod\Web\Models\Page::where('workspace_id', $workspace->id) - ->where('url', 'nyx-test') - ->first(); - - if ($existingPage) { - return; - } - - \Core\Mod\Web\Models\Page::create([ - 'workspace_id' => $workspace->id, - 'user_id' => $user->id, - 'url' => 'nyx-test', - 'type' => 'page', - 'settings' => [ - 'name' => 'Nyx Test Page', - 'description' => 'Test page for Playwright acceptance tests (Lethean Network)', - 'title' => 'Nyx Test', - 'blocks' => [ - [ - 'id' => 'header-1', - 'type' => 'header', - 'data' => [ - 'name' => 'Nyx Tester', - 'bio' => 'Lethean Network demo account', - ], - ], - [ - 'id' => 'link-1', - 'type' => 'link', - 'data' => [ - 'title' => 'Test Link', - 'url' => 'https://example.com', - ], - ], - ], - 'theme' => 'default', - 'show_branding' => true, - ], - 'is_enabled' => true, - ]); - } - - /** - * Create a single test short link. - */ - protected function createTestShortLink(Workspace $workspace, User $user): void - { - // Only create if Web Page model exists - if (! class_exists(\Core\Mod\Web\Models\Page::class)) { - return; - } - - $existingLink = \Core\Mod\Web\Models\Page::where('workspace_id', $workspace->id) - ->where('url', 'nyx-short') - ->first(); - - if ($existingLink) { - return; - } - - \Core\Mod\Web\Models\Page::create([ - 'workspace_id' => $workspace->id, - 'user_id' => $user->id, - 'url' => 'nyx-short', - 'type' => 'link', - 'location_url' => 'https://host.uk.com', - 'is_enabled' => true, - ]); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Database/Seeders/DemoWorkspaceSeeder.php b/packages/core-php/src/Mod/Tenant/Database/Seeders/DemoWorkspaceSeeder.php deleted file mode 100644 index 4b759a3..0000000 --- a/packages/core-php/src/Mod/Tenant/Database/Seeders/DemoWorkspaceSeeder.php +++ /dev/null @@ -1,165 +0,0 @@ -createDemoPackages(); - - // Create demo workspaces - $workspaces = [ - [ - 'name' => 'Demo Social', - 'slug' => 'demo-social', - 'domain' => 'demo-social.host.test', - 'description' => 'Demo workspace with SocialHost access', - 'icon' => 'share-nodes', - 'color' => 'green', - 'package' => 'demo-social', - ], - [ - 'name' => 'Demo Trust', - 'slug' => 'demo-trust', - 'domain' => 'demo-trust.host.test', - 'description' => 'Demo workspace with TrustHost access', - 'icon' => 'shield-check', - 'color' => 'orange', - 'package' => 'demo-trust', - ], - [ - 'name' => 'Demo No Services', - 'slug' => 'demo-no-services', - 'domain' => 'demo-free.host.test', - 'description' => 'Demo workspace with no service access', - 'icon' => 'user', - 'color' => 'gray', - 'package' => null, - ], - ]; - - foreach ($workspaces as $data) { - $workspace = Workspace::updateOrCreate( - ['slug' => $data['slug']], - [ - 'name' => $data['name'], - 'domain' => $data['domain'], - 'description' => $data['description'], - 'icon' => $data['icon'], - 'color' => $data['color'], - 'type' => 'custom', - 'is_active' => true, - ] - ); - - // Provision package if specified - if ($data['package']) { - $entitlements->provisionPackage($workspace, $data['package']); - } - - $this->command->info("Created demo workspace: {$data['name']}"); - } - - // Create demo user and attach to workspaces - $this->createDemoUser($workspaces); - } - - protected function createDemoPackages(): void - { - // Demo Social Package - SocialHost access - $socialPackage = Package::updateOrCreate( - ['code' => 'demo-social'], - [ - 'name' => 'Demo Social', - 'description' => 'Demo package with SocialHost access', - 'is_stackable' => false, - 'is_base_package' => true, - 'is_active' => true, - 'is_public' => false, - 'sort_order' => 900, - ] - ); - - // Attach service gate - $hostSocial = Feature::where('code', 'core.srv.social')->first(); - if ($hostSocial && ! $socialPackage->features()->where('feature_id', $hostSocial->id)->exists()) { - $socialPackage->features()->attach($hostSocial->id, ['limit_value' => null]); - } - - // Attach social features with limits - $socialAccounts = Feature::where('code', 'social.accounts')->first(); - if ($socialAccounts && ! $socialPackage->features()->where('feature_id', $socialAccounts->id)->exists()) { - $socialPackage->features()->attach($socialAccounts->id, ['limit_value' => 5]); - } - - $socialPosts = Feature::where('code', 'social.posts.scheduled')->first(); - if ($socialPosts && ! $socialPackage->features()->where('feature_id', $socialPosts->id)->exists()) { - $socialPackage->features()->attach($socialPosts->id, ['limit_value' => 50]); - } - - // Demo Trust Package - TrustHost access - $trustPackage = Package::updateOrCreate( - ['code' => 'demo-trust'], - [ - 'name' => 'Demo Trust', - 'description' => 'Demo package with TrustHost access', - 'is_stackable' => false, - 'is_base_package' => true, - 'is_active' => true, - 'is_public' => false, - 'sort_order' => 901, - ] - ); - - // Attach service gate - $hostTrust = Feature::where('code', 'core.srv.trust')->first(); - if ($hostTrust && ! $trustPackage->features()->where('feature_id', $hostTrust->id)->exists()) { - $trustPackage->features()->attach($hostTrust->id, ['limit_value' => null]); - } - - $this->command->info('Demo packages created.'); - } - - protected function createDemoUser(array $workspaces): void - { - // Find primary admin user, or create demo user as fallback - $user = User::where('email', 'snider@host.uk.com')->first() - ?? User::updateOrCreate( - ['email' => 'demo@host.uk.com'], - [ - 'name' => 'Demo User', - 'password' => bcrypt('demo-password-123'), - 'email_verified_at' => now(), - ] - ); - - // Attach to all demo workspaces - foreach ($workspaces as $data) { - $workspace = Workspace::where('slug', $data['slug'])->first(); - if ($workspace && ! $workspace->users()->where('user_id', $user->id)->exists()) { - $workspace->users()->attach($user->id, [ - 'role' => 'owner', - 'is_default' => false, // Don't change their default workspace - ]); - } - } - - $this->command->info("Demo workspaces attached to: {$user->email}"); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Database/Seeders/FeatureSeeder.php b/packages/core-php/src/Mod/Tenant/Database/Seeders/FeatureSeeder.php deleted file mode 100644 index 1e1b5b9..0000000 --- a/packages/core-php/src/Mod/Tenant/Database/Seeders/FeatureSeeder.php +++ /dev/null @@ -1,901 +0,0 @@ - 'tier.apollo', - 'name' => 'Apollo Tier', - 'description' => 'Access to Apollo tier features', - 'category' => 'tier', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 1, - ], - [ - 'code' => 'tier.hades', - 'name' => 'Hades Tier', - 'description' => 'Access to Hades tier features (developer tools)', - 'category' => 'tier', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 2, - ], - // Lethean Network designations - [ - 'code' => 'tier.nyx', - 'name' => 'Nyx Tier', - 'description' => 'Demo/test account access (Lethean Network)', - 'category' => 'tier', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 3, - ], - [ - 'code' => 'tier.stygian', - 'name' => 'Stygian Tier', - 'description' => 'Standard user access (Lethean Network)', - 'category' => 'tier', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 4, - ], - // Corporate Sponsors (Lethean Network) - [ - 'code' => 'tier.plouton', - 'name' => 'Ploutōn Tier', - 'description' => 'White label partner access (Lethean Network)', - 'category' => 'tier', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 5, - ], - [ - 'code' => 'tier.hermes', - 'name' => 'Hermes Tier', - 'description' => 'Founding patron access - seat in Elysia (Lethean Network)', - 'category' => 'tier', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 6, - ], - - // Service access gates (deny by default) - [ - 'code' => 'core.srv.social', - 'name' => 'SocialHost Access', - 'description' => 'Access to SocialHost social media management', - 'category' => 'service', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 1, - ], - [ - 'code' => 'core.srv.bio', - 'name' => 'BioHost Access', - 'description' => 'Access to BioHost link-in-bio pages', - 'category' => 'service', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 2, - ], - [ - 'code' => 'core.srv.analytics', - 'name' => 'AnalyticsHost Access', - 'description' => 'Access to AnalyticsHost privacy-focused analytics', - 'category' => 'service', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 3, - ], - [ - 'code' => 'core.srv.trust', - 'name' => 'TrustHost Access', - 'description' => 'Access to TrustHost social proof notifications', - 'category' => 'service', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 4, - ], - [ - 'code' => 'core.srv.notify', - 'name' => 'NotifyHost Access', - 'description' => 'Access to NotifyHost push notifications', - 'category' => 'service', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 5, - ], - [ - 'code' => 'core.srv.support', - 'name' => 'SupportHost Access', - 'description' => 'Access to SupportHost help desk', - 'category' => 'service', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 6, - ], - [ - 'code' => 'core.srv.web', - 'name' => 'WebHost Access', - 'description' => 'Access to WebHost site management', - 'category' => 'service', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 7, - ], - [ - 'code' => 'core.srv.commerce', - 'name' => 'Commerce Access', - 'description' => 'Access to Commerce store management', - 'category' => 'service', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 8, - ], - [ - 'code' => 'core.srv.hub', - 'name' => 'Hub Access', - 'description' => 'Access to Hub admin panel', - 'category' => 'service', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 0, // Internal service - ], - [ - 'code' => 'core.srv.agentic', - 'name' => 'Agentic Access', - 'description' => 'Access to AI agent services', - 'category' => 'service', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 9, - ], - - // Social features - [ - 'code' => 'social.accounts', - 'name' => 'Social Accounts', - 'description' => 'Number of connected social media accounts', - 'category' => 'social', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 1, - ], - [ - 'code' => 'social.posts.scheduled', - 'name' => 'Scheduled Posts', - 'description' => 'Number of scheduled posts per month', - 'category' => 'social', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_MONTHLY, - 'sort_order' => 2, - ], - [ - 'code' => 'social.workspaces', - 'name' => 'Social Workspaces', - 'description' => 'Number of social workspaces', - 'category' => 'social', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 3, - ], - [ - 'code' => 'social.posts.bulk', - 'name' => 'Bulk Post Upload', - 'description' => 'Upload multiple posts via CSV/bulk import', - 'category' => 'social', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 4, - ], - [ - 'code' => 'social.analytics', - 'name' => 'Social Analytics', - 'description' => 'Access to social media analytics', - 'category' => 'social', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 5, - ], - [ - 'code' => 'social.analytics.advanced', - 'name' => 'Advanced Analytics', - 'description' => 'Advanced reporting and analytics features', - 'category' => 'social', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 6, - ], - [ - 'code' => 'social.team', - 'name' => 'Team Collaboration', - 'description' => 'Multi-user team features for social management', - 'category' => 'social', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 7, - ], - [ - 'code' => 'social.approval_workflow', - 'name' => 'Approval Workflow', - 'description' => 'Content approval workflow before posting', - 'category' => 'social', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 8, - ], - [ - 'code' => 'social.white_label', - 'name' => 'White Label', - 'description' => 'Remove SocialHost branding', - 'category' => 'social', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 9, - ], - [ - 'code' => 'social.api_access', - 'name' => 'Social API Access', - 'description' => 'Access to SocialHost API', - 'category' => 'social', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 10, - ], - [ - 'code' => 'social.templates', - 'name' => 'Post Templates', - 'description' => 'Number of saved post templates', - 'category' => 'social', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 11, - ], - [ - 'code' => 'social.hashtag_groups', - 'name' => 'Hashtag Groups', - 'description' => 'Number of saved hashtag groups', - 'category' => 'social', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 12, - ], - [ - 'code' => 'social.ai_suggestions', - 'name' => 'AI Content Suggestions', - 'description' => 'AI-powered caption generation and content improvement', - 'category' => 'social', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 13, - ], - - // AI features - [ - 'code' => 'ai.credits', - 'name' => 'AI Credits', - 'description' => 'AI generation credits per month', - 'category' => 'ai', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_MONTHLY, - 'sort_order' => 1, - ], - [ - 'code' => 'ai.providers.claude', - 'name' => 'Claude AI', - 'description' => 'Access to Claude AI provider', - 'category' => 'ai', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 2, - ], - [ - 'code' => 'ai.providers.gemini', - 'name' => 'Gemini AI', - 'description' => 'Access to Gemini AI provider', - 'category' => 'ai', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 3, - ], - - // Team features - [ - 'code' => 'team.members', - 'name' => 'Team Members', - 'description' => 'Number of team members per workspace', - 'category' => 'team', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 1, - ], - - // API features - [ - 'code' => 'api.requests', - 'name' => 'API Requests', - 'description' => 'API requests per 30 days (rolling)', - 'category' => 'api', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_ROLLING, - 'rolling_window_days' => 30, - 'sort_order' => 1, - ], - - // MCP Quota features - [ - 'code' => 'mcp.monthly_tool_calls', - 'name' => 'MCP Tool Calls', - 'description' => 'Monthly limit for MCP tool calls', - 'category' => 'mcp', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_MONTHLY, - 'sort_order' => 1, - ], - [ - 'code' => 'mcp.monthly_tokens', - 'name' => 'MCP Tokens', - 'description' => 'Monthly limit for MCP token consumption', - 'category' => 'mcp', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_MONTHLY, - 'sort_order' => 2, - ], - - // Storage - Global pool - [ - 'code' => 'core.res.storage.total', - 'name' => 'Total Storage', - 'description' => 'Total storage across all services (MB)', - 'category' => 'storage', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 1, - ], - - // ───────────────────────────────────────────────────────────── - // lt.hn Pricing Features (numeric - ordered by sort_order) - // ───────────────────────────────────────────────────────────── - [ - 'code' => 'bio.pages', - 'name' => 'Bio Pages', - 'description' => 'Number of pages allowed', - 'category' => 'web', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 10, - ], - [ - 'code' => 'webpage.sub_pages', - 'name' => 'Sub-Pages', - 'description' => 'Additional pages under your main page', - 'category' => 'web', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 20, - ], - [ - 'code' => 'bio.blocks', - 'name' => 'Page Blocks', - 'description' => 'Number of blocks per page', - 'category' => 'web', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 30, - ], - [ - 'code' => 'bio.static_sites', - 'name' => 'Static Websites', - 'description' => 'Number of static websites allowed', - 'category' => 'web', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 40, - ], - [ - 'code' => 'bio.custom_domains', - 'name' => 'Custom Domains', - 'description' => 'Number of custom domains allowed', - 'category' => 'web', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 50, - ], - [ - 'code' => 'bio.web3_domains', - 'name' => 'Web3 Domains', - 'description' => 'Number of Web3 domains (ENS, etc.)', - 'category' => 'web', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 60, - ], - [ - 'code' => 'bio.vcard', - 'name' => 'vCard', - 'description' => 'Number of vCard downloads allowed', - 'category' => 'web', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 70, - ], - [ - 'code' => 'bio.events', - 'name' => 'Events', - 'description' => 'Number of event blocks allowed', - 'category' => 'web', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 80, - ], - [ - 'code' => 'bio.file_downloads', - 'name' => 'File Downloads', - 'description' => 'Number of file download blocks allowed', - 'category' => 'web', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 90, - ], - [ - 'code' => 'bio.splash_pages', - 'name' => 'Splash Pages', - 'description' => 'Number of splash/landing pages allowed', - 'category' => 'web', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 100, - ], - [ - 'code' => 'bio.shortened_links', - 'name' => 'Shortened Links', - 'description' => 'Number of shortened links allowed', - 'category' => 'web', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 110, - ], - [ - 'code' => 'bio.pixels', - 'name' => 'Pixels', - 'description' => 'Number of tracking pixels allowed', - 'category' => 'web', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 120, - ], - [ - 'code' => 'bio.qr_codes', - 'name' => 'QR Codes', - 'description' => 'Number of QR codes allowed', - 'category' => 'web', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 130, - ], - // ───────────────────────────────────────────────────────────── - // lt.hn Pricing Features (boolean - ordered by sort_order) - // ───────────────────────────────────────────────────────────── - [ - 'code' => 'bio.analytics.basic', - 'name' => 'Basic Analytics', - 'description' => 'Basic analytics for pages', - 'category' => 'web', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 200, - ], - [ - 'code' => 'support.community', - 'name' => 'Community Support', - 'description' => 'Access to community support', - 'category' => 'support', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 210, - ], - [ - 'code' => 'support.host.uk.com', - 'name' => 'Support', - 'description' => 'Email support access', - 'category' => 'support', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 211, - ], - [ - 'code' => 'support.priority', - 'name' => 'Priority Support', - 'description' => 'Priority support access', - 'category' => 'support', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 212, - ], - [ - 'code' => 'bio.themes', - 'name' => 'Themes', - 'description' => 'Access to page themes', - 'category' => 'web', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 220, - ], - // ───────────────────────────────────────────────────────────── - // Legacy Bio features (internal use) - // ───────────────────────────────────────────────────────────── - [ - 'code' => 'bio.shortlinks', - 'name' => 'Short Links', - 'description' => 'Number of short links allowed', - 'category' => 'web', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 150, - ], - [ - 'code' => 'bio.static', - 'name' => 'Static Pages', - 'description' => 'Number of static HTML pages allowed', - 'category' => 'web', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 151, - ], - [ - 'code' => 'bio.domains', - 'name' => 'Custom Domains (Legacy)', - 'description' => 'Number of custom domains allowed', - 'category' => 'web', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 152, - ], - [ - 'code' => 'bio.analytics_days', - 'name' => 'Analytics Retention', - 'description' => 'Days of analytics history retained', - 'category' => 'web', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 5, - ], - [ - 'code' => 'bio.tier.pro', - 'name' => 'Pro Block Types', - 'description' => 'Access to pro-tier block types', - 'category' => 'web', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 6, - ], - [ - 'code' => 'bio.tier.ultimate', - 'name' => 'Ultimate Block Types', - 'description' => 'Access to ultimate-tier block types', - 'category' => 'web', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 7, - ], - [ - 'code' => 'bio.tier.payment', - 'name' => 'Payment Block Types', - 'description' => 'Access to payment block types', - 'category' => 'web', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 8, - ], - [ - 'code' => 'web.themes.premium', - 'name' => 'Premium Themes', - 'description' => 'Access to premium page themes', - 'category' => 'web', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 9, - ], - [ - 'code' => 'bio.pwa', - 'name' => 'Progressive Web App', - 'description' => 'Turn pages into installable apps', - 'category' => 'web', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 230, - ], - - // Content features (native CMS) - [ - 'code' => 'content.mcp_access', - 'name' => 'Content MCP Access', - 'description' => 'Access to content management via MCP tools', - 'category' => 'content', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 1, - ], - [ - 'code' => 'content.items', - 'name' => 'Content Items', - 'description' => 'Number of content items (posts, pages)', - 'category' => 'content', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 2, - ], - [ - 'code' => 'content.ai_generation', - 'name' => 'AI Content Generation', - 'description' => 'Generate content using AI via MCP', - 'category' => 'content', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 3, - ], - - // Analytics features - [ - 'code' => 'analytics.sites', - 'name' => 'Analytics Sites', - 'description' => 'Number of sites to track', - 'category' => 'analytics', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 1, - ], - [ - 'code' => 'analytics.pageviews', - 'name' => 'Monthly Pageviews', - 'description' => 'Pageviews tracked per month', - 'category' => 'analytics', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_MONTHLY, - 'sort_order' => 2, - ], - - // Support features - [ - 'code' => 'support.mailboxes', - 'name' => 'Mailboxes', - 'description' => 'Number of support mailboxes', - 'category' => 'support', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 1, - ], - [ - 'code' => 'support.agents', - 'name' => 'Support Agents', - 'description' => 'Number of support agents', - 'category' => 'support', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 2, - ], - [ - 'code' => 'support.conversations', - 'name' => 'Conversations per Month', - 'description' => 'Number of conversations per month', - 'category' => 'support', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_MONTHLY, - 'sort_order' => 3, - ], - [ - 'code' => 'support.chat_widget', - 'name' => 'Live Chat Widget', - 'description' => 'Enable live chat widget', - 'category' => 'support', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 4, - ], - [ - 'code' => 'support.saved_replies', - 'name' => 'Saved Replies', - 'description' => 'Number of saved reply templates', - 'category' => 'support', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 5, - ], - [ - 'code' => 'support.custom_folders', - 'name' => 'Custom Folders', - 'description' => 'Enable custom folder organisation', - 'category' => 'support', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 6, - ], - [ - 'code' => 'support.api_access', - 'name' => 'API Access', - 'description' => 'Access to Support API endpoints', - 'category' => 'support', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 7, - ], - [ - 'code' => 'support.auto_reply', - 'name' => 'Auto Reply', - 'description' => 'Automatic reply to incoming messages', - 'category' => 'support', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 8, - ], - [ - 'code' => 'support.email_templates', - 'name' => 'Email Templates', - 'description' => 'Number of email templates', - 'category' => 'support', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 9, - ], - [ - 'code' => 'support.file_storage_mb', - 'name' => 'File Storage (MB)', - 'description' => 'File attachment storage in megabytes', - 'category' => 'support', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 10, - ], - [ - 'code' => 'support.retention_days', - 'name' => 'Retention Days', - 'description' => 'Number of days to retain conversation history', - 'category' => 'support', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 11, - ], - - // Tools features (utility tools access) - [ - 'code' => 'tool.mcp_access', - 'name' => 'Tools MCP Access', - 'description' => 'Access to utility tools via MCP API', - 'category' => 'tools', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 1, - ], - [ - 'code' => 'tool.url_shortener', - 'name' => 'URL Shortener', - 'description' => 'Create persistent short links with analytics', - 'category' => 'tools', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 2, - ], - [ - 'code' => 'tool.qr_generator', - 'name' => 'QR Code Generator', - 'description' => 'Create and save QR codes', - 'category' => 'tools', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 3, - ], - [ - 'code' => 'tool.dns_lookup', - 'name' => 'DNS Lookup', - 'description' => 'DNS record lookup tool', - 'category' => 'tools', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 4, - ], - [ - 'code' => 'tool.ssl_lookup', - 'name' => 'SSL Lookup', - 'description' => 'SSL certificate lookup tool', - 'category' => 'tools', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 5, - ], - [ - 'code' => 'tool.whois_lookup', - 'name' => 'WHOIS Lookup', - 'description' => 'Domain WHOIS lookup tool', - 'category' => 'tools', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 6, - ], - [ - 'code' => 'tool.ip_lookup', - 'name' => 'IP Lookup', - 'description' => 'IP address geolocation lookup', - 'category' => 'tools', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 7, - ], - [ - 'code' => 'tool.http_headers', - 'name' => 'HTTP Headers', - 'description' => 'HTTP header inspection tool', - 'category' => 'tools', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'sort_order' => 8, - ], - ]; - - foreach ($features as $featureData) { - Feature::updateOrCreate( - ['code' => $featureData['code']], - $featureData - ); - } - - // Create child features for storage pool - $storageParent = Feature::where('code', 'core.res.storage.total')->first(); - if ($storageParent) { - $storageChildren = [ - [ - 'code' => 'core.res.cdn', - 'name' => 'Main Site CDN', - 'description' => 'CDN storage for main site (MB)', - 'category' => 'storage', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'parent_feature_id' => $storageParent->id, - 'sort_order' => 2, - ], - [ - 'code' => 'bio.cdn', - 'name' => 'Bio CDN', - 'description' => 'CDN storage for bio pages (MB)', - 'category' => 'storage', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'parent_feature_id' => $storageParent->id, - 'sort_order' => 3, - ], - [ - 'code' => 'social.cdn', - 'name' => 'Social CDN', - 'description' => 'CDN storage for social media (MB)', - 'category' => 'storage', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'parent_feature_id' => $storageParent->id, - 'sort_order' => 4, - ], - ]; - - foreach ($storageChildren as $childData) { - Feature::updateOrCreate( - ['code' => $childData['code']], - $childData - ); - } - } - - $this->command->info('Features seeded successfully.'); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Database/Seeders/SystemWorkspaceSeeder.php b/packages/core-php/src/Mod/Tenant/Database/Seeders/SystemWorkspaceSeeder.php deleted file mode 100644 index f2f4418..0000000 --- a/packages/core-php/src/Mod/Tenant/Database/Seeders/SystemWorkspaceSeeder.php +++ /dev/null @@ -1,57 +0,0 @@ -first(); - - if (! $hermes) { - $this->command->error('Hermes package not found. Run PackageSeeder first.'); - - return; - } - - // Assign to both main and system workspaces - $slugs = ['main', 'system']; - - foreach ($slugs as $slug) { - $workspace = Workspace::where('slug', $slug)->first(); - - if (! $workspace) { - $this->command->warn("Workspace '{$slug}' not found, skipping."); - - continue; - } - - $existing = WorkspacePackage::where('workspace_id', $workspace->id) - ->where('package_id', $hermes->id) - ->first(); - - if ($existing) { - $this->command->info('Hermes already assigned to '.$workspace->name); - - continue; - } - - WorkspacePackage::create([ - 'workspace_id' => $workspace->id, - 'package_id' => $hermes->id, - 'status' => WorkspacePackage::STATUS_ACTIVE, - 'starts_at' => now(), - ]); - - $this->command->info('Hermes assigned to '.$workspace->name); - } - } -} diff --git a/packages/core-php/src/Mod/Tenant/Database/Seeders/WorkspaceSeeder.php b/packages/core-php/src/Mod/Tenant/Database/Seeders/WorkspaceSeeder.php deleted file mode 100644 index 6f01935..0000000 --- a/packages/core-php/src/Mod/Tenant/Database/Seeders/WorkspaceSeeder.php +++ /dev/null @@ -1,183 +0,0 @@ -environment('local'); - $domain = $isLocal ? 'host.test' : 'host.uk.com'; - $email = 'snider@host.uk.com'; - - // Create system user first so we can assign ownership - $systemUser = User::updateOrCreate( - ['id' => 1], - [ - 'name' => 'Snider', - 'email' => $email, - 'password' => Hash::make('change-me-in-env'), - 'tier' => UserTier::HADES, - 'tier_expires_at' => null, - 'email_verified_at' => now(), - ] - ); - - // Service workspaces - marketing domains are handled by Mod modules, not workspace routing. - // The workspace domain field is for custom user-assigned domains (e.g., mybrand.com). - // Service domains (lthn.test, social.host.test, etc.) are routed via Mod\{Service}\Boot. - $workspaces = [ - [ - 'name' => 'Host UK', - 'slug' => 'main', - 'domain' => $domain, // Main marketing site - 'icon' => 'globe', - 'color' => 'violet', - 'description' => 'Main website content', - 'type' => 'cms', - 'sort_order' => 0, - ], - [ - 'name' => 'Social', - 'slug' => 'social', - 'domain' => '', // Marketing domain routed via Mod\Social - 'icon' => 'share-nodes', - 'color' => 'green', - 'description' => 'Social media scheduling', - 'type' => 'custom', - 'sort_order' => 2, - ], - [ - 'name' => 'Analytics', - 'slug' => 'analytics', - 'domain' => '', // Marketing domain routed via Mod\Analytics - 'icon' => 'chart-line', - 'color' => 'yellow', - 'description' => 'Privacy-first analytics', - 'type' => 'custom', - 'sort_order' => 3, - ], - [ - 'name' => 'Trust', - 'slug' => 'trust', - 'domain' => '', // Marketing domain routed via Mod\Trust - 'icon' => 'shield-check', - 'color' => 'orange', - 'description' => 'Social proof widgets', - 'type' => 'custom', - 'sort_order' => 4, - ], - [ - 'name' => 'Notify', - 'slug' => 'notify', - 'domain' => '', // Marketing domain routed via Mod\Notify - 'icon' => 'bell', - 'color' => 'red', - 'description' => 'Push notifications', - 'type' => 'custom', - 'sort_order' => 5, - ], - [ - 'name' => 'LtHn', - 'slug' => 'lthn', - 'domain' => '', // Marketing domain routed via Mod\LtHn - 'icon' => 'link', - 'color' => 'cyan', - 'description' => 'lt.hn bio link service', - 'type' => 'custom', - 'sort_order' => 6, - ], - ]; - - foreach ($workspaces as $workspace) { - $ws = Workspace::updateOrCreate( - ['slug' => $workspace['slug']], - array_merge($workspace, ['is_active' => true]) - ); - - // Attach system user as owner if not already attached - if (! $ws->users()->where('user_id', $systemUser->id)->exists()) { - $ws->users()->attach($systemUser->id, [ - 'role' => 'owner', - 'is_default' => false, - ]); - } - } - - // Provision hades to main workspace only - $this->provisionWorkspaceEntitlements(); - } - - /** - * Provision packages for workspaces. - */ - protected function provisionWorkspaceEntitlements(): void - { - if (! Schema::hasTable('entitlement_workspace_packages')) { - return; - } - - // Main workspace gets full Hades access - $this->provisionPackage('main', 'hades'); - - // Service workspaces get analytics, social, trust, notify for tracking & upsell - $serviceWorkspaces = ['social', 'analytics', 'trust', 'notify', 'lthn']; - $marketingServices = [ - 'core-srv-analytics-access', - 'core-srv-social-access', - 'core-srv-trust-access', - 'core-srv-notify-access', - ]; - - foreach ($serviceWorkspaces as $workspace) { - foreach ($marketingServices as $package) { - $this->provisionPackage($workspace, $package); - } - } - } - - /** - * Provision a package to a workspace. - */ - protected function provisionPackage(string $workspaceSlug, string $packageCode): void - { - $package = Package::where('code', $packageCode)->first(); - if (! $package) { - return; - } - - $workspace = Workspace::where('slug', $workspaceSlug)->first(); - if (! $workspace) { - return; - } - - WorkspacePackage::updateOrCreate( - [ - 'workspace_id' => $workspace->id, - 'package_id' => $package->id, - ], - [ - 'status' => WorkspacePackage::STATUS_ACTIVE, - 'starts_at' => now(), - 'expires_at' => null, - ] - ); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Enums/UserTier.php b/packages/core-php/src/Mod/Tenant/Enums/UserTier.php deleted file mode 100644 index ba7b38f..0000000 --- a/packages/core-php/src/Mod/Tenant/Enums/UserTier.php +++ /dev/null @@ -1,81 +0,0 @@ - 'Free', - self::APOLLO => 'Apollo', - self::HADES => 'Hades', - }; - } - - public function color(): string - { - return match ($this) { - self::FREE => 'gray', - self::APOLLO => 'blue', - self::HADES => 'violet', - }; - } - - public function icon(): string - { - return match ($this) { - self::FREE => 'user', - self::APOLLO => 'sun', - self::HADES => 'crown', - }; - } - - public function maxWorkspaces(): int - { - return match ($this) { - self::FREE => 1, - self::APOLLO => 5, - self::HADES => -1, // Unlimited - }; - } - - public function features(): array - { - return match ($this) { - self::FREE => [ - 'basic_content_editing', - 'single_workspace', - ], - self::APOLLO => [ - 'basic_content_editing', - 'advanced_content_editing', - 'multiple_workspaces', - 'analytics_basic', - 'social_scheduling', - ], - self::HADES => [ - 'basic_content_editing', - 'advanced_content_editing', - 'multiple_workspaces', - 'unlimited_workspaces', - 'analytics_basic', - 'analytics_advanced', - 'social_scheduling', - 'social_automation', - 'api_access', - 'priority_support', - 'white_label', - ], - }; - } - - public function hasFeature(string $feature): bool - { - return in_array($feature, $this->features()); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Enums/WebhookDeliveryStatus.php b/packages/core-php/src/Mod/Tenant/Enums/WebhookDeliveryStatus.php deleted file mode 100644 index d3fef18..0000000 --- a/packages/core-php/src/Mod/Tenant/Enums/WebhookDeliveryStatus.php +++ /dev/null @@ -1,12 +0,0 @@ - $this->workspace->id, - 'workspace_name' => $this->workspace->name, - 'workspace_slug' => $this->workspace->slug, - 'boost' => [ - 'id' => $this->boost->id, - 'feature_code' => $this->boost->feature_code, - 'feature_name' => $this->feature?->name ?? ucwords(str_replace(['.', '_', '-'], ' ', $this->boost->feature_code)), - 'boost_type' => $this->boost->boost_type, - 'limit_value' => $this->boost->limit_value, - 'duration_type' => $this->boost->duration_type, - 'starts_at' => $this->boost->starts_at?->toIso8601String(), - 'expires_at' => $this->boost->expires_at?->toIso8601String(), - ], - ]; - } - - public function message(): string - { - $featureName = $this->feature?->name ?? $this->boost->feature_code; - - return "Boost activated: {$featureName} for workspace {$this->workspace->name}"; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Events/Webhook/BoostExpiredEvent.php b/packages/core-php/src/Mod/Tenant/Events/Webhook/BoostExpiredEvent.php deleted file mode 100644 index d1733b4..0000000 --- a/packages/core-php/src/Mod/Tenant/Events/Webhook/BoostExpiredEvent.php +++ /dev/null @@ -1,58 +0,0 @@ - $this->workspace->id, - 'workspace_name' => $this->workspace->name, - 'workspace_slug' => $this->workspace->slug, - 'boost' => [ - 'id' => $this->boost->id, - 'feature_code' => $this->boost->feature_code, - 'feature_name' => $this->feature?->name ?? ucwords(str_replace(['.', '_', '-'], ' ', $this->boost->feature_code)), - 'boost_type' => $this->boost->boost_type, - 'limit_value' => $this->boost->limit_value, - 'consumed_quantity' => $this->boost->consumed_quantity, - 'duration_type' => $this->boost->duration_type, - 'expired_at' => $this->boost->expires_at?->toIso8601String() ?? now()->toIso8601String(), - ], - ]; - } - - public function message(): string - { - $featureName = $this->feature?->name ?? $this->boost->feature_code; - - return "Boost expired: {$featureName} for workspace {$this->workspace->name}"; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Events/Webhook/LimitReachedEvent.php b/packages/core-php/src/Mod/Tenant/Events/Webhook/LimitReachedEvent.php deleted file mode 100644 index dc25e8f..0000000 --- a/packages/core-php/src/Mod/Tenant/Events/Webhook/LimitReachedEvent.php +++ /dev/null @@ -1,52 +0,0 @@ - $this->workspace->id, - 'workspace_name' => $this->workspace->name, - 'workspace_slug' => $this->workspace->slug, - 'feature_code' => $this->feature->code, - 'feature_name' => $this->feature->name, - 'used' => $this->used, - 'limit' => $this->limit, - 'percentage' => 100, - 'remaining' => 0, - ]; - } - - public function message(): string - { - return "Limit reached: {$this->feature->name} at 100% ({$this->used}/{$this->limit}) for workspace {$this->workspace->name}"; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Events/Webhook/LimitWarningEvent.php b/packages/core-php/src/Mod/Tenant/Events/Webhook/LimitWarningEvent.php deleted file mode 100644 index ddfdd0e..0000000 --- a/packages/core-php/src/Mod/Tenant/Events/Webhook/LimitWarningEvent.php +++ /dev/null @@ -1,56 +0,0 @@ - $this->workspace->id, - 'workspace_name' => $this->workspace->name, - 'workspace_slug' => $this->workspace->slug, - 'feature_code' => $this->feature->code, - 'feature_name' => $this->feature->name, - 'used' => $this->used, - 'limit' => $this->limit, - 'percentage' => round(($this->used / $this->limit) * 100), - 'remaining' => max(0, $this->limit - $this->used), - 'threshold' => $this->threshold, - ]; - } - - public function message(): string - { - $percentage = round(($this->used / $this->limit) * 100); - - return "Usage warning: {$this->feature->name} at {$percentage}% ({$this->used}/{$this->limit}) for workspace {$this->workspace->name}"; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Events/Webhook/PackageChangedEvent.php b/packages/core-php/src/Mod/Tenant/Events/Webhook/PackageChangedEvent.php deleted file mode 100644 index 3fbbc96..0000000 --- a/packages/core-php/src/Mod/Tenant/Events/Webhook/PackageChangedEvent.php +++ /dev/null @@ -1,67 +0,0 @@ - $this->workspace->id, - 'workspace_name' => $this->workspace->name, - 'workspace_slug' => $this->workspace->slug, - 'change_type' => $this->changeType, - 'previous_package' => $this->previousPackage ? [ - 'id' => $this->previousPackage->id, - 'code' => $this->previousPackage->code, - 'name' => $this->previousPackage->name, - ] : null, - 'new_package' => [ - 'id' => $this->newPackage->id, - 'code' => $this->newPackage->code, - 'name' => $this->newPackage->name, - ], - ]; - } - - public function message(): string - { - if ($this->changeType === 'added') { - return "Package added: {$this->newPackage->name} assigned to workspace {$this->workspace->name}"; - } - - if ($this->changeType === 'removed') { - return "Package removed from workspace {$this->workspace->name}"; - } - - $from = $this->previousPackage?->name ?? 'none'; - - return "Package changed: {$from} to {$this->newPackage->name} for workspace {$this->workspace->name}"; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Exceptions/EntitlementException.php b/packages/core-php/src/Mod/Tenant/Exceptions/EntitlementException.php deleted file mode 100644 index 4d01d67..0000000 --- a/packages/core-php/src/Mod/Tenant/Exceptions/EntitlementException.php +++ /dev/null @@ -1,46 +0,0 @@ -featureCode; - } - - /** - * Render the exception as an HTTP response. - */ - public function render($request) - { - if ($request->expectsJson()) { - return response()->json([ - 'message' => $this->getMessage(), - 'feature_code' => $this->featureCode, - ], $this->getCode()); - } - - return redirect()->back() - ->with('error', $this->getMessage()); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Exceptions/MissingWorkspaceContextException.php b/packages/core-php/src/Mod/Tenant/Exceptions/MissingWorkspaceContextException.php deleted file mode 100644 index 7fe077b..0000000 --- a/packages/core-php/src/Mod/Tenant/Exceptions/MissingWorkspaceContextException.php +++ /dev/null @@ -1,133 +0,0 @@ -withoutGlobalScope(WorkspaceScope::class) if intentionally querying across workspaces.", - operation: 'scope', - model: $model - ); - } - - /** - * Create exception for middleware. - */ - public static function forMiddleware(): self - { - return new self( - message: 'This route requires workspace context. Ensure you are accessing through a valid workspace subdomain or have a workspace session.', - operation: 'middleware' - ); - } - - /** - * Get the operation that failed. - */ - public function getOperation(): ?string - { - return $this->operation; - } - - /** - * Get the model class that was involved. - */ - public function getModel(): ?string - { - return $this->model; - } - - /** - * Render the exception as an HTTP response. - */ - public function render(Request $request): Response - { - if ($request->expectsJson()) { - return response()->json([ - 'message' => $this->getMessage(), - 'error' => 'missing_workspace_context', - 'operation' => $this->operation, - 'model' => $this->model, - ], $this->getCode()); - } - - // For web requests, show a user-friendly error page - if (view()->exists('errors.workspace-required')) { - return response()->view('errors.workspace-required', [ - 'message' => $this->getMessage(), - ], $this->getCode()); - } - - return response($this->getMessage(), $this->getCode()); - } - - /** - * Report the exception (for logging/monitoring). - */ - public function report(): bool - { - // Log this as a potential security issue - workspace context was missing - // where it should have been present - logger()->warning('Missing workspace context', [ - 'operation' => $this->operation, - 'model' => $this->model, - 'url' => request()->url(), - 'user_id' => auth()->id(), - ]); - - // Return true to indicate we've handled reporting - return true; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Features/ApolloTier.php b/packages/core-php/src/Mod/Tenant/Features/ApolloTier.php deleted file mode 100644 index da3a675..0000000 --- a/packages/core-php/src/Mod/Tenant/Features/ApolloTier.php +++ /dev/null @@ -1,79 +0,0 @@ -checkWorkspaceEntitlement($scope); - } - - if ($scope instanceof User) { - // Check user's owner workspace - $workspace = $scope->ownedWorkspaces()->first(); - if ($workspace && $this->checkWorkspaceEntitlement($workspace)) { - return true; - } - - // Legacy fallback: check user tier - return $this->checkUserTier($scope); - } - - return false; - } - - /** - * Check if workspace has Apollo or Hades tier entitlement. - */ - protected function checkWorkspaceEntitlement(Workspace $workspace): bool - { - // Apollo is active if workspace has Apollo OR Hades tier - $apolloResult = $this->entitlements->can($workspace, 'tier.apollo'); - $hadesResult = $this->entitlements->can($workspace, 'tier.hades'); - - return $apolloResult->isAllowed() || $hadesResult->isAllowed(); - } - - /** - * Legacy fallback: check user's tier attribute. - */ - protected function checkUserTier(mixed $scope): bool - { - if (method_exists($scope, 'getTier')) { - $tier = $scope->getTier(); - - return $tier === UserTier::APOLLO || $tier === UserTier::HADES; - } - - if (isset($scope->tier)) { - $tier = $scope->tier; - if (is_string($tier)) { - return in_array($tier, [UserTier::APOLLO->value, UserTier::HADES->value]); - } - - return $tier === UserTier::APOLLO || $tier === UserTier::HADES; - } - - return false; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Features/BetaFeatures.php b/packages/core-php/src/Mod/Tenant/Features/BetaFeatures.php deleted file mode 100644 index 892fc62..0000000 --- a/packages/core-php/src/Mod/Tenant/Features/BetaFeatures.php +++ /dev/null @@ -1,42 +0,0 @@ -choose(); // 10% rollout - } -} diff --git a/packages/core-php/src/Mod/Tenant/Features/HadesTier.php b/packages/core-php/src/Mod/Tenant/Features/HadesTier.php deleted file mode 100644 index 75a5788..0000000 --- a/packages/core-php/src/Mod/Tenant/Features/HadesTier.php +++ /dev/null @@ -1,70 +0,0 @@ -checkWorkspaceEntitlement($scope); - } - - if ($scope instanceof User) { - // Check user's owner workspace - $workspace = $scope->ownedWorkspaces()->first(); - if ($workspace && $this->checkWorkspaceEntitlement($workspace)) { - return true; - } - - // Legacy fallback: check user tier - return $this->checkUserTier($scope); - } - - return false; - } - - /** - * Check if workspace has Hades tier entitlement. - */ - protected function checkWorkspaceEntitlement(Workspace $workspace): bool - { - $result = $this->entitlements->can($workspace, 'tier.hades'); - - return $result->isAllowed(); - } - - /** - * Legacy fallback: check user's tier attribute. - */ - protected function checkUserTier(mixed $scope): bool - { - if (method_exists($scope, 'getTier')) { - return $scope->getTier() === UserTier::HADES; - } - - if (isset($scope->tier)) { - return $scope->tier === UserTier::HADES->value || $scope->tier === UserTier::HADES; - } - - return false; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Features/UnlimitedWorkspaces.php b/packages/core-php/src/Mod/Tenant/Features/UnlimitedWorkspaces.php deleted file mode 100644 index 4d01e69..0000000 --- a/packages/core-php/src/Mod/Tenant/Features/UnlimitedWorkspaces.php +++ /dev/null @@ -1,75 +0,0 @@ -checkWorkspaceEntitlement($scope); - } - - if ($scope instanceof User) { - // Check user's owner workspace - $workspace = $scope->ownedWorkspaces()->first(); - if ($workspace && $this->checkWorkspaceEntitlement($workspace)) { - return true; - } - - // Legacy fallback: check user tier - return $this->checkUserTier($scope); - } - - return false; - } - - /** - * Check if workspace has Hades tier entitlement (unlimited workspaces). - */ - protected function checkWorkspaceEntitlement(Workspace $workspace): bool - { - $result = $this->entitlements->can($workspace, 'tier.hades'); - - return $result->isAllowed(); - } - - /** - * Legacy fallback: check user's tier attribute. - */ - protected function checkUserTier(mixed $scope): bool - { - if (method_exists($scope, 'getTier')) { - return $scope->getTier() === UserTier::HADES; - } - - if (isset($scope->tier)) { - $tier = $scope->tier; - if (is_string($tier)) { - return $tier === UserTier::HADES->value; - } - - return $tier === UserTier::HADES; - } - - return false; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Jobs/ComputeUserStats.php b/packages/core-php/src/Mod/Tenant/Jobs/ComputeUserStats.php deleted file mode 100644 index b315eb0..0000000 --- a/packages/core-php/src/Mod/Tenant/Jobs/ComputeUserStats.php +++ /dev/null @@ -1,43 +0,0 @@ -userId); - - if (! $user) { - return; - } - - $statsService->computeStats($user); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Jobs/DispatchEntitlementWebhook.php b/packages/core-php/src/Mod/Tenant/Jobs/DispatchEntitlementWebhook.php deleted file mode 100644 index 9a64c91..0000000 --- a/packages/core-php/src/Mod/Tenant/Jobs/DispatchEntitlementWebhook.php +++ /dev/null @@ -1,188 +0,0 @@ - - */ - public array $backoff = [60, 300, 900]; // 1min, 5min, 15min - - /** - * Create a new job instance. - */ - public function __construct( - public int $webhookId, - public string $eventName, - public array $eventPayload - ) { - $this->onQueue('webhooks'); - } - - /** - * Execute the job. - */ - public function handle(): void - { - $webhook = EntitlementWebhook::find($this->webhookId); - - if (! $webhook) { - Log::warning('Entitlement webhook not found', ['webhook_id' => $this->webhookId]); - - return; - } - - // Skip if webhook is inactive (circuit breaker may have triggered) - if (! $webhook->isActive()) { - Log::info('Entitlement webhook is inactive, skipping', [ - 'webhook_id' => $this->webhookId, - 'event' => $this->eventName, - ]); - - return; - } - - $data = [ - 'event' => $this->eventName, - 'data' => $this->eventPayload, - 'timestamp' => now()->toIso8601String(), - ]; - - try { - $headers = [ - 'Content-Type' => 'application/json', - 'X-Request-Source' => config('app.name'), - 'User-Agent' => config('app.name').' Entitlement Webhook', - ]; - - if ($webhook->secret) { - $headers['X-Signature'] = hash_hmac('sha256', json_encode($data), $webhook->secret); - } - - $response = Http::withHeaders($headers) - ->timeout(10) - ->post($webhook->url, $data); - - $status = match ($response->status()) { - 200, 201, 202, 204 => WebhookDeliveryStatus::SUCCESS, - default => WebhookDeliveryStatus::FAILED, - }; - - // Create delivery record - $webhook->deliveries()->create([ - 'uuid' => Str::uuid(), - 'event' => $this->eventName, - 'attempts' => $this->attempts(), - 'status' => $status, - 'http_status' => $response->status(), - 'payload' => $data, - 'response' => $response->json() ?: ['body' => substr($response->body(), 0, 1000)], - 'created_at' => now(), - ]); - - if ($status === WebhookDeliveryStatus::SUCCESS) { - $webhook->resetFailureCount(); - Log::info('Entitlement webhook delivered successfully', [ - 'webhook_id' => $webhook->id, - 'event' => $this->eventName, - 'http_status' => $response->status(), - ]); - } else { - $webhook->incrementFailureCount(); - $webhook->updateLastDeliveryStatus($status); - - Log::warning('Entitlement webhook delivery failed', [ - 'webhook_id' => $webhook->id, - 'event' => $this->eventName, - 'http_status' => $response->status(), - 'response' => substr($response->body(), 0, 500), - ]); - - // Throw exception to trigger retry - throw new \RuntimeException("Webhook returned {$response->status()}"); - } - - $webhook->updateLastDeliveryStatus($status); - } catch (\Exception $e) { - $webhook->incrementFailureCount(); - $webhook->updateLastDeliveryStatus(WebhookDeliveryStatus::FAILED); - - // Create failure delivery record - $webhook->deliveries()->create([ - 'uuid' => Str::uuid(), - 'event' => $this->eventName, - 'attempts' => $this->attempts(), - 'status' => WebhookDeliveryStatus::FAILED, - 'payload' => $data, - 'response' => ['error' => $e->getMessage()], - 'created_at' => now(), - ]); - - Log::error('Entitlement webhook dispatch exception', [ - 'webhook_id' => $webhook->id, - 'event' => $this->eventName, - 'error' => $e->getMessage(), - 'attempt' => $this->attempts(), - ]); - - throw $e; - } - } - - /** - * Handle job failure after all retries exhausted. - */ - public function failed(\Throwable $exception): void - { - $webhook = EntitlementWebhook::find($this->webhookId); - - Log::error('Entitlement webhook job failed permanently', [ - 'webhook_id' => $this->webhookId, - 'event' => $this->eventName, - 'error' => $exception->getMessage(), - 'circuit_broken' => $webhook?->isCircuitBroken() ?? false, - ]); - } - - /** - * Get the tags that should be assigned to the job. - * - * @return array - */ - public function tags(): array - { - return [ - 'entitlement-webhook', - "webhook:{$this->webhookId}", - "event:{$this->eventName}", - ]; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Jobs/ProcessAccountDeletion.php b/packages/core-php/src/Mod/Tenant/Jobs/ProcessAccountDeletion.php deleted file mode 100644 index 1439795..0000000 --- a/packages/core-php/src/Mod/Tenant/Jobs/ProcessAccountDeletion.php +++ /dev/null @@ -1,130 +0,0 @@ -deletionRequest->id); - - if (! $request) { - Log::info('Skipping account deletion - request no longer exists', [ - 'deletion_request_id' => $this->deletionRequest->id, - ]); - - return; - } - - // Verify the request is still valid for deletion - if (! $request->isActive()) { - Log::info('Skipping account deletion - request no longer active', [ - 'deletion_request_id' => $request->id, - ]); - - return; - } - - $user = $request->user; - - if (! $user) { - Log::warning('User not found for deletion request', [ - 'deletion_request_id' => $request->id, - ]); - $request->complete(); - - return; - } - - // Update local reference - $this->deletionRequest = $request; - - $userId = $user->id; - - DB::transaction(function () use ($user) { - // Mark request as completed - $this->deletionRequest->complete(); - - // Delete all workspaces owned by the user - if (method_exists($user, 'ownedWorkspaces')) { - $user->ownedWorkspaces()->each(function ($workspace) { - $workspace->delete(); - }); - } - - // Hard delete user account - $user->forceDelete(); - }); - - Log::info('Account deleted successfully', [ - 'user_id' => $userId, - 'deletion_request_id' => $this->deletionRequest->id, - 'via' => 'job', - ]); - } - - /** - * Handle a job failure. - */ - public function failed(\Throwable $exception): void - { - Log::error('Failed to process account deletion', [ - 'deletion_request_id' => $this->deletionRequest->id, - 'error' => $exception->getMessage(), - ]); - } - - /** - * Get the tags that should be assigned to the job. - * - * @return array - */ - public function tags(): array - { - return [ - 'account-deletion', - 'user:'.$this->deletionRequest->user_id, - ]; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Lang/en_GB/tenant.php b/packages/core-php/src/Mod/Tenant/Lang/en_GB/tenant.php deleted file mode 100644 index 82b6a57..0000000 --- a/packages/core-php/src/Mod/Tenant/Lang/en_GB/tenant.php +++ /dev/null @@ -1,567 +0,0 @@ - [ - 'welcome' => 'Welcome', - 'powered_by' => 'Powered by :name\'s creator toolkit. Manage, publish, and grow your online presence.', - 'manage_content' => 'Manage Content', - 'get_early_access' => 'Get early access', - 'view_content' => 'View Content', - 'latest_posts' => 'Latest Posts', - 'pages' => 'Pages', - 'read_more' => 'Read more', - 'untitled' => 'Untitled', - 'no_content' => [ - 'title' => 'No content yet', - 'message' => 'This workspace doesn\'t have any published content.', - ], - 'create_content' => 'Create Content', - 'part_of_toolkit' => 'Part of the :name Toolkit', - 'toolkit_description' => 'Access all creator services from one unified platform', - ], - - /* - |-------------------------------------------------------------------------- - | Account Deletion - |-------------------------------------------------------------------------- - */ - 'deletion' => [ - 'invalid' => [ - 'title' => 'Link Invalid or Expired', - 'message' => 'This deletion link is no longer valid. It may have been cancelled or already used.', - ], - 'verify' => [ - 'title' => 'Verify Your Identity', - 'description' => 'Enter your password to confirm immediate account deletion for :name', - 'password_label' => 'Password', - 'password_placeholder' => 'Enter your password', - 'submit' => 'Verify & Continue', - 'changed_mind' => 'Changed your mind?', - 'cancel_link' => 'Cancel deletion', - ], - 'confirm' => [ - 'title' => 'Final Confirmation', - 'warning' => 'This action is permanent and irreversible.', - 'will_delete' => 'The following will be permanently deleted:', - 'items' => [ - 'profile' => 'Your profile and personal data', - 'workspaces' => 'All workspaces you own', - 'content' => 'All content, media, and settings', - 'social' => 'Social connections and scheduled posts', - ], - 'cancel' => 'Cancel', - 'delete_forever' => 'Delete Forever', - ], - 'deleting' => [ - 'title' => 'Deleting Account', - 'messages' => [ - 'social' => 'Disconnecting social accounts...', - 'posts' => 'Removing scheduled posts...', - 'media' => 'Deleting media files...', - 'workspaces' => 'Removing workspaces...', - 'personal' => 'Erasing personal data...', - 'final' => 'Finalizing deletion...', - ], - ], - 'goodbye' => [ - 'title' => 'F.I.N.', - 'deleted' => 'Your account has been deleted.', - 'thanks' => 'Thank you for being part of our journey.', - ], - 'cancelled' => [ - 'title' => 'Deletion Cancelled', - 'message' => 'Your account deletion has been cancelled. Your account is safe and will remain active.', - 'go_to_profile' => 'Go to Profile', - ], - 'cancel_invalid' => [ - 'title' => 'Link Invalid', - 'message' => 'This cancellation link is no longer valid. The deletion may have already been cancelled or completed.', - ], - 'processing' => 'Processing...', - 'return_home' => 'Return Home', - ], - - /* - |-------------------------------------------------------------------------- - | Admin - Workspace Manager - |-------------------------------------------------------------------------- - */ - 'admin' => [ - 'title' => 'Workspace Manager', - 'subtitle' => 'Manage workspaces and transfer resources', - 'hades_only' => 'Hades Only', - 'stats' => [ - 'total' => 'Total Workspaces', - 'active' => 'Active', - 'inactive' => 'Inactive', - ], - 'search_placeholder' => 'Search workspaces by name or slug...', - 'table' => [ - 'workspace' => 'Workspace', - 'owner' => 'Owner', - 'bio' => 'Bio', - 'social' => 'Social', - 'analytics' => 'Analytics', - 'trust' => 'Trust', - 'notify' => 'Notify', - 'commerce' => 'Commerce', - 'status' => 'Status', - 'actions' => 'Actions', - 'no_owner' => 'No owner', - 'active' => 'Active', - 'inactive' => 'Inactive', - 'empty' => 'No workspaces found matching your criteria.', - ], - 'actions' => [ - 'view_details' => 'View details', - 'edit' => 'Edit workspace', - 'change_owner' => 'Change owner', - 'transfer' => 'Transfer resources', - 'delete' => 'Delete workspace', - 'provision' => 'Provision new', - ], - 'confirm_delete' => 'Are you sure you want to delete this workspace? This cannot be undone.', - 'edit_modal' => [ - 'title' => 'Edit Workspace', - 'name' => 'Name', - 'name_placeholder' => 'Workspace name', - 'slug' => 'Slug', - 'slug_placeholder' => 'workspace-slug', - 'active' => 'Active', - 'cancel' => 'Cancel', - 'save' => 'Save Changes', - ], - 'transfer_modal' => [ - 'title' => 'Transfer Resources', - 'source' => 'Source', - 'target_workspace' => 'Target Workspace', - 'select_target' => 'Select target workspace...', - 'resources_label' => 'Resources to Transfer', - 'warning' => 'Warning: This will move all selected resource types from the source workspace to the target workspace. This action cannot be undone.', - 'cancel' => 'Cancel', - 'transfer' => 'Transfer Resources', - ], - 'owner_modal' => [ - 'title' => 'Change Workspace Owner', - 'workspace' => 'Workspace', - 'new_owner' => 'New Owner', - 'select_owner' => 'Select new owner...', - 'warning' => 'The current owner will be demoted to a member. If the new owner is not already a member, they will be added to the workspace.', - 'cancel' => 'Cancel', - 'change' => 'Change Owner', - ], - 'resources_modal' => [ - 'in' => 'in', - 'select_all' => 'Select All', - 'deselect_all' => 'Deselect All', - 'selected' => ':count selected', - 'no_resources' => 'No resources found.', - 'transfer_selected' => 'Transfer Selected', - 'select_workspace' => 'Select workspace...', - 'transfer_items' => 'Transfer :count Item|Transfer :count Items', - 'close' => 'Close', - ], - 'provision_modal' => [ - 'create' => 'Create :type', - 'workspace' => 'Workspace', - 'name' => 'Name', - 'name_placeholder' => 'Enter name...', - 'slug' => 'Slug', - 'slug_placeholder' => 'my-page', - 'url' => 'URL', - 'url_placeholder' => 'https://example.com', - 'cancel' => 'Cancel', - ], - ], - - /* - |-------------------------------------------------------------------------- - | Usage Alerts - |-------------------------------------------------------------------------- - */ - 'usage_alerts' => [ - 'threshold' => [ - 'warning' => 'Warning', - 'critical' => 'Critical', - 'limit_reached' => 'Limit Reached', - ], - 'status' => [ - 'ok' => 'OK', - 'approaching' => 'Approaching Limit', - 'at_limit' => 'At Limit', - ], - 'labels' => [ - 'used' => 'Used', - 'limit' => 'Limit', - 'remaining' => 'Remaining', - 'percentage' => 'Usage', - 'feature' => 'Feature', - ], - ], - - /* - |-------------------------------------------------------------------------- - | Emails - |-------------------------------------------------------------------------- - */ - 'emails' => [ - 'usage_alert' => [ - 'warning' => [ - 'subject' => ':feature usage at :percentage%', - 'heading' => 'Usage Warning', - 'body' => 'Your **:workspace** workspace is approaching its **:feature** limit.', - 'usage_line' => 'Current usage: :used of :limit (:percentage%)', - 'remaining_line' => 'Remaining: :remaining', - 'action_text' => 'Consider upgrading your plan to ensure uninterrupted service.', - ], - 'critical' => [ - 'subject' => 'Urgent: :feature usage at :percentage%', - 'heading' => 'Critical Usage Alert', - 'body' => 'Your **:workspace** workspace is almost at its **:feature** limit.', - 'usage_line' => 'Current usage: :used of :limit (:percentage%)', - 'remaining_line' => 'Only :remaining remaining', - 'action_text' => 'Upgrade now to avoid any service interruptions.', - ], - 'limit_reached' => [ - 'subject' => ':feature limit reached', - 'heading' => 'Limit Reached', - 'body' => 'Your **:workspace** workspace has reached its **:feature** limit.', - 'usage_line' => 'Usage: :used of :limit (100%)', - 'options_heading' => 'You will not be able to use this feature until:', - 'options' => [ - 'upgrade' => 'You upgrade to a higher plan', - 'reset' => 'Your usage resets (if applicable)', - 'reduce' => 'You reduce your current usage', - ], - ], - 'view_usage' => 'View Usage', - 'upgrade_plan' => 'Upgrade Plan', - 'help_text' => 'If you have questions about your plan, please contact our support team.', - ], - 'deletion_requested' => [ - 'subject' => 'Account Deletion Scheduled', - 'greeting' => 'Hi :name,', - 'scheduled' => 'Your :app account has been scheduled for permanent deletion.', - 'auto_delete' => 'Your account will be automatically deleted on :date (in :days days).', - 'will_delete' => 'What will be deleted:', - 'items' => [ - 'profile' => 'Your profile and personal information', - 'workspaces' => 'All workspaces you own', - 'content' => 'All content, media, and settings', - 'social' => 'Social media connections and scheduled posts', - ], - 'delete_now' => 'Want to delete immediately?', - 'delete_now_description' => 'Click the button below to delete your account right now:', - 'delete_button' => 'Delete Now', - 'changed_mind' => 'Changed your mind?', - 'changed_mind_description' => 'Click below to cancel the deletion and keep your account:', - 'cancel_button' => 'Cancel Deletion', - 'not_requested' => 'Did not request this?', - 'not_requested_description' => 'If you did not request account deletion, click the cancel button above immediately and change your password.', - ], - 'boost_expired' => [ - 'subject_single' => ':feature boost expired - :workspace', - 'subject_multiple' => ':count boosts expired - :workspace', - 'body_single' => 'A boost for **:feature** has expired in your **:workspace** workspace.', - 'body_multiple' => 'The following boosts have expired in your **:workspace** workspace:', - 'cycle_bound_note' => 'This was a cycle-bound boost that ended with your billing period.', - 'action_text' => 'You can purchase additional boosts or upgrade your plan to restore this capacity.', - 'boost_types' => [ - 'unlimited' => 'Unlimited access', - 'enable' => 'Feature access', - 'add_limit' => '+:total capacity (:consumed used)', - ], - ], - ], - - /* - |-------------------------------------------------------------------------- - | Billing Cycles - |-------------------------------------------------------------------------- - */ - 'billing' => [ - 'cycle_reset' => 'Your billing cycle has been reset.', - 'boosts_expired' => ':count boost(s) have expired.', - 'usage_reset' => 'Usage counters have been reset for the new billing period.', - ], - - /* - |-------------------------------------------------------------------------- - | Common - |-------------------------------------------------------------------------- - */ - 'common' => [ - 'na' => 'N/A', - 'none' => 'None', - 'unknown' => 'Unknown', - ], - - /* - |-------------------------------------------------------------------------- - | Errors - |-------------------------------------------------------------------------- - */ - 'errors' => [ - 'hades_required' => 'Hades tier required for this feature.', - 'unauthenticated' => 'You must be logged in to access this resource.', - 'no_workspace' => 'No workspace context available.', - 'insufficient_permissions' => 'You do not have permission to perform this action.', - ], - - /* - |-------------------------------------------------------------------------- - | Admin - Team Manager - |-------------------------------------------------------------------------- - */ - 'admin' => [ - // ... existing admin translations will be merged ... - - 'team_manager' => [ - 'title' => 'Workspace Teams', - 'subtitle' => 'Manage teams and role-based permissions for workspaces', - - 'stats' => [ - 'total_teams' => 'Total Teams', - 'total_members' => 'Total Members', - 'members_assigned' => 'Assigned to Teams', - ], - - 'search' => [ - 'placeholder' => 'Search teams by name...', - ], - - 'filter' => [ - 'all_workspaces' => 'All Workspaces', - ], - - 'columns' => [ - 'team' => 'Team', - 'workspace' => 'Workspace', - 'members' => 'Members', - 'permissions' => 'Permissions', - 'actions' => 'Actions', - ], - - 'labels' => [ - 'permissions' => 'permissions', - ], - - 'badges' => [ - 'system' => 'System', - 'default' => 'Default', - ], - - 'actions' => [ - 'create_team' => 'Create Team', - 'edit' => 'Edit', - 'delete' => 'Delete', - 'view_members' => 'View Members', - 'seed_defaults' => 'Seed Defaults', - 'migrate_members' => 'Migrate Members', - ], - - 'confirm' => [ - 'delete_team' => 'Are you sure you want to delete this team? Members will be unassigned.', - ], - - 'empty_state' => [ - 'title' => 'No teams found', - 'description' => 'Create teams to organise members and control permissions in your workspaces.', - ], - - 'modal' => [ - 'title_create' => 'Create Team', - 'title_edit' => 'Edit Team', - - 'fields' => [ - 'workspace' => 'Workspace', - 'select_workspace' => 'Select workspace...', - 'name' => 'Name', - 'name_placeholder' => 'e.g. Editors', - 'slug' => 'Slug', - 'slug_placeholder' => 'e.g. editors', - 'slug_description' => 'Leave blank to auto-generate from name.', - 'description' => 'Description', - 'colour' => 'Colour', - 'is_default' => 'Default team for new members', - 'permissions' => 'Permissions', - ], - - 'actions' => [ - 'cancel' => 'Cancel', - 'create' => 'Create Team', - 'update' => 'Update Team', - ], - ], - - 'messages' => [ - 'team_created' => 'Team created successfully.', - 'team_updated' => 'Team updated successfully.', - 'team_deleted' => 'Team deleted successfully.', - 'cannot_delete_system' => 'Cannot delete system teams.', - 'cannot_delete_has_members' => 'Cannot delete team with :count assigned member(s). Remove members first.', - 'defaults_seeded' => 'Default teams have been seeded successfully.', - 'members_migrated' => ':count member(s) have been migrated to teams.', - ], - ], - - 'member_manager' => [ - 'title' => 'Workspace Members', - 'subtitle' => 'Manage member team assignments and custom permissions', - - 'stats' => [ - 'total_members' => 'Total Members', - 'with_team' => 'Assigned to Team', - 'with_custom' => 'With Custom Permissions', - ], - - 'search' => [ - 'placeholder' => 'Search members by name or email...', - ], - - 'filter' => [ - 'all_workspaces' => 'All Workspaces', - 'all_teams' => 'All Teams', - ], - - 'columns' => [ - 'member' => 'Member', - 'workspace' => 'Workspace', - 'team' => 'Team', - 'role' => 'Legacy Role', - 'permissions' => 'Custom', - 'actions' => 'Actions', - ], - - 'labels' => [ - 'no_team' => 'No team', - 'inherited' => 'Inherited', - ], - - 'actions' => [ - 'assign_team' => 'Assign to Team', - 'remove_from_team' => 'Remove from Team', - 'custom_permissions' => 'Custom Permissions', - 'clear_permissions' => 'Clear Custom Permissions', - ], - - 'confirm' => [ - 'clear_permissions' => 'Are you sure you want to clear all custom permissions for this member?', - 'bulk_remove_team' => 'Are you sure you want to remove the selected members from their teams?', - 'bulk_clear_permissions' => 'Are you sure you want to clear custom permissions for all selected members?', - ], - - 'bulk' => [ - 'selected' => ':count selected', - 'assign_team' => 'Assign Team', - 'remove_team' => 'Remove Team', - 'clear_permissions' => 'Clear Permissions', - 'clear' => 'Clear', - ], - - 'empty_state' => [ - 'title' => 'No members found', - 'description' => 'No members match your current filter criteria.', - ], - - 'modal' => [ - 'actions' => [ - 'cancel' => 'Cancel', - 'save' => 'Save', - 'assign' => 'Assign', - ], - ], - - 'assign_modal' => [ - 'title' => 'Assign to Team', - 'team' => 'Team', - 'no_team' => 'No team (remove assignment)', - ], - - 'permissions_modal' => [ - 'title' => 'Custom Permissions', - 'team_permissions' => 'Team: :team', - 'description' => 'Custom permissions override the team permissions. Grant additional permissions or revoke specific ones.', - 'grant_label' => 'Grant Additional Permissions', - 'revoke_label' => 'Revoke Permissions', - ], - - 'bulk_assign_modal' => [ - 'title' => 'Bulk Assign Team', - 'description' => 'Assign :count selected member(s) to a team.', - 'team' => 'Team', - 'no_team' => 'No team (remove assignment)', - ], - - 'messages' => [ - 'team_assigned' => 'Member assigned to team successfully.', - 'removed_from_team' => 'Member removed from team successfully.', - 'permissions_updated' => 'Custom permissions updated successfully.', - 'permissions_cleared' => 'Custom permissions cleared successfully.', - 'no_members_selected' => 'No members selected.', - 'invalid_team' => 'Invalid team selected.', - 'bulk_team_assigned' => ':count member(s) assigned to team.', - 'bulk_removed_from_team' => ':count member(s) removed from team.', - 'bulk_permissions_cleared' => 'Custom permissions cleared for :count member(s).', - ], - ], - ], - - /* - |-------------------------------------------------------------------------- - | Entitlement Webhooks - |-------------------------------------------------------------------------- - */ - 'webhooks' => [ - 'events' => [ - 'limit_warning' => 'Limit Warning', - 'limit_reached' => 'Limit Reached', - 'package_changed' => 'Package Changed', - 'boost_activated' => 'Boost Activated', - 'boost_expired' => 'Boost Expired', - ], - 'messages' => [ - 'created' => 'Webhook created successfully.', - 'updated' => 'Webhook updated successfully.', - 'deleted' => 'Webhook deleted successfully.', - 'test_success' => 'Test webhook sent successfully.', - 'test_failed' => 'Test webhook failed.', - 'secret_regenerated' => 'Secret regenerated successfully.', - 'circuit_reset' => 'Webhook re-enabled and failure count reset.', - 'retry_success' => 'Delivery retried successfully.', - 'retry_failed' => 'Retry failed.', - ], - 'labels' => [ - 'name' => 'Name', - 'url' => 'URL', - 'events' => 'Events', - 'status' => 'Status', - 'active' => 'Active', - 'inactive' => 'Inactive', - 'circuit_broken' => 'Circuit Broken', - 'secret' => 'Secret', - 'max_attempts' => 'Max Retry Attempts', - 'deliveries' => 'Deliveries', - ], - 'descriptions' => [ - 'url' => 'The endpoint that will receive webhook POST requests.', - 'max_attempts' => 'Number of times to retry failed deliveries (1-10).', - 'inactive' => 'Inactive webhooks will not receive any events.', - 'secret' => 'Use this secret to verify webhook signatures. The signature is sent in the X-Signature header and is a HMAC-SHA256 hash of the JSON payload.', - 'save_secret' => 'Save this secret now. It will not be shown again.', - ], - ], -]; diff --git a/packages/core-php/src/Mod/Tenant/Listeners/SendWelcomeEmail.php b/packages/core-php/src/Mod/Tenant/Listeners/SendWelcomeEmail.php deleted file mode 100644 index 325b090..0000000 --- a/packages/core-php/src/Mod/Tenant/Listeners/SendWelcomeEmail.php +++ /dev/null @@ -1,21 +0,0 @@ -user->notify(new WelcomeNotification); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Mail/AccountDeletionRequested.php b/packages/core-php/src/Mod/Tenant/Mail/AccountDeletionRequested.php deleted file mode 100644 index 0577105..0000000 --- a/packages/core-php/src/Mod/Tenant/Mail/AccountDeletionRequested.php +++ /dev/null @@ -1,62 +0,0 @@ - $this->deletionRequest->user, - 'confirmationUrl' => $this->deletionRequest->confirmationUrl(), - 'cancelUrl' => $this->deletionRequest->cancelUrl(), - 'expiresAt' => $this->deletionRequest->expires_at, - 'daysRemaining' => $this->deletionRequest->daysRemaining(), - ], - ); - } - - /** - * Get the attachments for the message. - * - * @return array - */ - public function attachments(): array - { - return []; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Middleware/CheckWorkspacePermission.php b/packages/core-php/src/Mod/Tenant/Middleware/CheckWorkspacePermission.php deleted file mode 100644 index be217e3..0000000 --- a/packages/core-php/src/Mod/Tenant/Middleware/CheckWorkspacePermission.php +++ /dev/null @@ -1,96 +0,0 @@ -user(); - - if (! $user) { - abort(403, __('tenant::tenant.errors.unauthenticated')); - } - - // Get current workspace from request or user's default - $workspace = $this->getWorkspace($request); - - if (! $workspace) { - abort(403, __('tenant::tenant.errors.no_workspace')); - } - - // Set up the team service with the workspace context - $this->teamService->forWorkspace($workspace); - - // Check if user has any of the required permissions - if (! $this->teamService->hasAnyPermission($user, $permissions)) { - abort(403, __('tenant::tenant.errors.insufficient_permissions')); - } - - // Store the workspace and member in request for later use - $request->attributes->set('workspace_model', $workspace); - - $member = $this->teamService->getMember($user); - if ($member) { - $request->attributes->set('workspace_member', $member); - } - - return $next($request); - } - - protected function getWorkspace(Request $request): ?Workspace - { - // First try to get from request attributes (already resolved by other middleware) - if ($request->attributes->has('workspace_model')) { - return $request->attributes->get('workspace_model'); - } - - // Try to get from route parameter - $workspaceParam = $request->route('workspace'); - if ($workspaceParam instanceof Workspace) { - return $workspaceParam; - } - - if (is_string($workspaceParam) || is_int($workspaceParam)) { - return Workspace::where('slug', $workspaceParam) - ->orWhere('id', $workspaceParam) - ->first(); - } - - // Try to get from session - $sessionSlug = session('workspace'); - if ($sessionSlug) { - return Workspace::where('slug', $sessionSlug)->first(); - } - - // Fall back to user's default workspace - $user = $request->user(); - if ($user && method_exists($user, 'defaultHostWorkspace')) { - return $user->defaultHostWorkspace(); - } - - return null; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Middleware/RequireAdminDomain.php b/packages/core-php/src/Mod/Tenant/Middleware/RequireAdminDomain.php deleted file mode 100644 index ceae3c6..0000000 --- a/packages/core-php/src/Mod/Tenant/Middleware/RequireAdminDomain.php +++ /dev/null @@ -1,34 +0,0 @@ -attributes->get('is_admin_domain', true); - - // Allow access on admin domains or local development - if ($isAdminDomain) { - return $next($request); - } - - // On service subdomains, redirect to the public workspace page - $workspace = $request->attributes->get('workspace', 'main'); - - // Redirect to the public page for this workspace - return redirect()->route('workspace.show', ['workspace' => $workspace]); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Middleware/RequireWorkspaceContext.php b/packages/core-php/src/Mod/Tenant/Middleware/RequireWorkspaceContext.php deleted file mode 100644 index a3f4c21..0000000 --- a/packages/core-php/src/Mod/Tenant/Middleware/RequireWorkspaceContext.php +++ /dev/null @@ -1,118 +0,0 @@ -group(function () { - * Route::resource('accounts', AccountController::class); - * }); - * - * Register in Kernel.php: - * 'workspace.required' => \Core\Mod\Tenant\Middleware\RequireWorkspaceContext::class, - */ -class RequireWorkspaceContext -{ - /** - * Handle an incoming request. - * - * @throws MissingWorkspaceContextException When workspace context is missing - */ - public function handle(Request $request, Closure $next, ?string $validateAccess = null): Response - { - // Get current workspace from various sources - $workspace = $this->resolveWorkspace($request); - - if (! $workspace) { - throw MissingWorkspaceContextException::forMiddleware(); - } - - // Optionally validate user has access to the workspace - if ($validateAccess === 'validate' && auth()->check()) { - $this->validateUserAccess($request, $workspace); - } - - // Ensure workspace is set in request attributes for downstream use - if (! $request->attributes->has('workspace_model')) { - $request->attributes->set('workspace_model', $workspace); - } - - return $next($request); - } - - /** - * Resolve workspace from request. - */ - protected function resolveWorkspace(Request $request): ?Workspace - { - // 1. Check if workspace_model is already set (by ResolveWorkspaceFromSubdomain) - if ($request->attributes->has('workspace_model')) { - return $request->attributes->get('workspace_model'); - } - - // 2. Try Workspace::current() which checks multiple sources - $current = Workspace::current(); - if ($current) { - return $current; - } - - // 3. Check request input for workspace_id (API requests) - if ($workspaceId = $request->input('workspace_id')) { - return Workspace::find($workspaceId); - } - - // 4. Check header for workspace context (API requests) - if ($workspaceId = $request->header('X-Workspace-ID')) { - return Workspace::find($workspaceId); - } - - // 5. Check query parameter for workspace (API/webhook requests) - if ($workspaceSlug = $request->query('workspace')) { - return Workspace::where('slug', $workspaceSlug)->first(); - } - - return null; - } - - /** - * Validate that the authenticated user has access to the workspace. - * - * @throws MissingWorkspaceContextException When user doesn't have access - */ - protected function validateUserAccess(Request $request, Workspace $workspace): void - { - $user = auth()->user(); - - // Check if user model has workspaces relationship - if (method_exists($user, 'workspaces') || method_exists($user, 'hostWorkspaces')) { - $workspaces = method_exists($user, 'hostWorkspaces') - ? $user->hostWorkspaces - : $user->workspaces; - - if (! $workspaces->contains('id', $workspace->id)) { - throw new MissingWorkspaceContextException( - message: 'You do not have access to this workspace.', - operation: 'access', - code: 403 - ); - } - } - } -} diff --git a/packages/core-php/src/Mod/Tenant/Middleware/ResolveNamespace.php b/packages/core-php/src/Mod/Tenant/Middleware/ResolveNamespace.php deleted file mode 100644 index 9a8eed9..0000000 --- a/packages/core-php/src/Mod/Tenant/Middleware/ResolveNamespace.php +++ /dev/null @@ -1,59 +0,0 @@ -query('namespace')) { - $namespace = $this->namespaceService->findByUuid($namespaceUuid); - if ($namespace && $this->namespaceService->canAccess($namespace)) { - // Store in session for subsequent requests - $this->namespaceService->setCurrent($namespace); - $request->attributes->set('current_namespace', $namespace); - - return $next($request); - } - } - - // Try to resolve namespace from header (for API requests) - if ($namespaceUuid = $request->header('X-Namespace')) { - $namespace = $this->namespaceService->findByUuid($namespaceUuid); - if ($namespace && $this->namespaceService->canAccess($namespace)) { - $request->attributes->set('current_namespace', $namespace); - - return $next($request); - } - } - - // Try to resolve from session - $namespace = $this->namespaceService->current(); - if ($namespace) { - $request->attributes->set('current_namespace', $namespace); - } - - return $next($request); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Middleware/ResolveWorkspaceFromSubdomain.php b/packages/core-php/src/Mod/Tenant/Middleware/ResolveWorkspaceFromSubdomain.php deleted file mode 100644 index b2cb10c..0000000 --- a/packages/core-php/src/Mod/Tenant/Middleware/ResolveWorkspaceFromSubdomain.php +++ /dev/null @@ -1,142 +0,0 @@ -getHost(); - $subdomain = $this->extractSubdomain($host); - $workspace = $this->resolveWorkspaceFromSubdomain($subdomain); - - // Store subdomain info in request - $request->attributes->set('subdomain', $subdomain); - $request->attributes->set('is_admin_domain', $this->isAdminDomain($subdomain)); - - if ($workspace) { - // Wrap session operations in try-catch to handle corrupted sessions - try { - $this->workspaceService->setCurrent($workspace); - $request->attributes->set('workspace_data', $this->workspaceService->current()); - } catch (\Throwable) { - // Session write failed - continue with defaults - // ResilientSession middleware will handle the actual error - } - - $request->attributes->set('workspace', $workspace); - - // CRITICAL: Also set the Workspace MODEL instance (not array) - // This enables Workspace::current() and WorkspaceScope to work - try { - $workspaceModel = Workspace::where('slug', $workspace)->first(); - if ($workspaceModel) { - $request->attributes->set('workspace_model', $workspaceModel); - } - } catch (\Throwable) { - // Database query failed - continue without workspace model - } - } - - return $next($request); - } - - /** - * Extract subdomain from hostname. - */ - protected function extractSubdomain(string $host): string - { - $baseDomain = config('app.base_domain', 'host.uk.com'); - - // Handle localhost/dev environments - if (str_contains($host, 'localhost') || str_contains($host, '127.0.0.1') || str_ends_with($host, '.test')) { - return ''; // Treat as main domain for local dev - } - - // Check if this is our base domain - if (! str_ends_with($host, $baseDomain)) { - return ''; - } - - // Extract subdomain - $subdomain = str_replace('.'.$baseDomain, '', $host); - - // Handle bare domain (no subdomain) - if ($subdomain === $host) { - return ''; - } - - return $subdomain; - } - - /** - * Check if subdomain should serve admin panel. - */ - public function isAdminDomain(?string $subdomain): bool - { - return in_array($subdomain ?? '', $this->adminSubdomains, true); - } - - /** - * Resolve workspace slug from subdomain. - */ - protected function resolveWorkspaceFromSubdomain(string $subdomain): ?string - { - // Map subdomains to workspace slugs (must match database Workspace slugs) - $mappings = [ - // Admin/main domain aliases - 'hestia' => 'main', - 'main' => 'main', - 'www' => 'main', - 'hub' => 'main', - '' => 'main', - // Service subdomains - bio is canonical, link is alias - 'bio' => 'bio', - 'link' => 'bio', - 'social' => 'social', - 'analytics' => 'analytics', - 'stats' => 'analytics', - 'trust' => 'trust', - 'proof' => 'trust', - 'notify' => 'notify', - 'push' => 'notify', - ]; - - if (isset($mappings[$subdomain])) { - return $mappings[$subdomain]; - } - - // Check if subdomain matches a workspace slug directly - $workspace = $this->workspaceService->get($subdomain); - if ($workspace) { - return $subdomain; - } - - // Unknown subdomain - could be a user subdomain, default to main - return 'main'; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Migrations/0001_01_01_000000_create_tenant_tables.php b/packages/core-php/src/Mod/Tenant/Migrations/0001_01_01_000000_create_tenant_tables.php deleted file mode 100644 index 8f624c5..0000000 --- a/packages/core-php/src/Mod/Tenant/Migrations/0001_01_01_000000_create_tenant_tables.php +++ /dev/null @@ -1,316 +0,0 @@ -id(); - $table->string('name'); - $table->string('email')->unique(); - $table->timestamp('email_verified_at')->nullable(); - $table->string('password'); - $table->rememberToken(); - $table->string('tier')->default('free'); - $table->timestamp('tier_expires_at')->nullable(); - $table->timestamps(); - }); - - // 2. Password Reset Tokens - Schema::create('password_reset_tokens', function (Blueprint $table) { - $table->string('email')->primary(); - $table->string('token'); - $table->timestamp('created_at')->nullable(); - }); - - // 3. Sessions - Schema::create('sessions', function (Blueprint $table) { - $table->string('id')->primary(); - $table->foreignId('user_id')->nullable()->index(); - $table->string('ip_address', 45)->nullable(); - $table->text('user_agent')->nullable(); - $table->longText('payload'); - $table->integer('last_activity')->index(); - }); - - // 4. Workspaces (the tenant boundary) - Schema::create('workspaces', function (Blueprint $table) { - $table->id(); - $table->string('name'); - $table->string('slug')->unique(); - $table->string('domain')->nullable(); - $table->string('icon')->nullable(); - $table->string('color')->nullable(); - $table->text('description')->nullable(); - $table->string('type')->default('default'); - $table->json('settings')->nullable(); - $table->boolean('is_active')->default(true); - $table->integer('sort_order')->default(0); - - // WP Connector fields - $table->boolean('wp_connector_enabled')->default(false); - $table->string('wp_connector_url')->nullable(); - $table->string('wp_connector_secret')->nullable(); - $table->timestamp('wp_connector_verified_at')->nullable(); - $table->timestamp('wp_connector_last_sync')->nullable(); - $table->json('wp_connector_config')->nullable(); - - // Billing fields - $table->string('stripe_customer_id')->nullable(); - $table->string('btcpay_customer_id')->nullable(); - $table->string('billing_name')->nullable(); - $table->string('billing_email')->nullable(); - $table->string('billing_address_line1')->nullable(); - $table->string('billing_address_line2')->nullable(); - $table->string('billing_city')->nullable(); - $table->string('billing_state')->nullable(); - $table->string('billing_postal_code')->nullable(); - $table->string('billing_country')->nullable(); - $table->string('vat_number')->nullable(); - $table->string('tax_id')->nullable(); - $table->boolean('tax_exempt')->default(false); - - $table->timestamps(); - $table->softDeletes(); - }); - - // 5. User Workspace Pivot - Schema::create('user_workspace', function (Blueprint $table) { - $table->id(); - $table->foreignId('user_id')->constrained()->cascadeOnDelete(); - $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); - $table->string('role')->default('member'); - $table->boolean('is_default')->default(false); - $table->timestamps(); - - $table->unique(['user_id', 'workspace_id']); - }); - - // 6. Namespaces - Schema::create('namespaces', function (Blueprint $table) { - $table->id(); - $table->uuid('uuid')->unique(); - $table->string('name', 128); - $table->string('slug', 64); - $table->string('description', 512)->nullable(); - $table->string('icon', 64)->default('folder'); - $table->string('color', 16)->default('zinc'); - - // Polymorphic owner (User::class or Workspace::class) - $table->morphs('owner'); - - // Workspace context for billing aggregation - $table->foreignId('workspace_id')->nullable() - ->constrained()->nullOnDelete(); - - $table->json('settings')->nullable(); - $table->boolean('is_default')->default(false); - $table->boolean('is_active')->default(true); - $table->smallInteger('sort_order')->default(0); - - $table->timestamps(); - $table->softDeletes(); - - $table->unique(['owner_type', 'owner_id', 'slug']); - $table->index(['workspace_id', 'is_active']); - $table->index(['owner_type', 'owner_id', 'is_active']); - }); - - // 7. Entitlement Features - Schema::create('entitlement_features', function (Blueprint $table) { - $table->id(); - $table->string('code')->unique(); - $table->string('name'); - $table->text('description')->nullable(); - $table->string('category')->nullable(); - $table->enum('type', ['boolean', 'limit', 'unlimited'])->default('boolean'); - $table->enum('reset_type', ['none', 'monthly', 'rolling'])->default('none'); - $table->integer('rolling_window_days')->nullable(); - $table->foreignId('parent_feature_id')->nullable() - ->constrained('entitlement_features')->nullOnDelete(); - $table->integer('sort_order')->default(0); - $table->boolean('is_active')->default(true); - $table->timestamps(); - - $table->index(['category', 'sort_order']); - $table->index('category'); - }); - - // 8. Entitlement Packages - Schema::create('entitlement_packages', function (Blueprint $table) { - $table->id(); - $table->string('code')->unique(); - $table->string('name'); - $table->text('description')->nullable(); - $table->string('icon')->nullable(); - $table->string('color')->nullable(); - $table->integer('sort_order')->default(0); - $table->boolean('is_stackable')->default(true); - $table->boolean('is_base_package')->default(false); - $table->boolean('is_active')->default(true); - $table->boolean('is_public')->default(true); - $table->decimal('monthly_price', 10, 2)->nullable(); - $table->decimal('yearly_price', 10, 2)->nullable(); - $table->decimal('setup_fee', 10, 2)->default(0); - $table->unsignedInteger('trial_days')->default(0); - $table->string('stripe_monthly_price_id')->nullable(); - $table->string('stripe_yearly_price_id')->nullable(); - $table->string('btcpay_monthly_price_id')->nullable(); - $table->string('btcpay_yearly_price_id')->nullable(); - $table->string('blesta_package_id')->nullable(); - $table->timestamps(); - $table->softDeletes(); - - $table->index('blesta_package_id'); - }); - - // 9. Entitlement Package Features - Schema::create('entitlement_package_features', function (Blueprint $table) { - $table->id(); - $table->foreignId('package_id')->constrained('entitlement_packages')->cascadeOnDelete(); - $table->foreignId('feature_id')->constrained('entitlement_features')->cascadeOnDelete(); - $table->unsignedBigInteger('limit_value')->nullable(); - $table->timestamps(); - - $table->unique(['package_id', 'feature_id']); - }); - - // 10. Entitlement Workspace Packages - Schema::create('entitlement_workspace_packages', function (Blueprint $table) { - $table->id(); - $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); - $table->foreignId('package_id')->constrained('entitlement_packages')->cascadeOnDelete(); - $table->enum('status', ['active', 'suspended', 'cancelled', 'expired'])->default('active'); - $table->timestamp('starts_at')->nullable(); - $table->timestamp('expires_at')->nullable(); - $table->timestamp('billing_cycle_anchor')->nullable(); - $table->string('blesta_service_id')->nullable(); - $table->json('metadata')->nullable(); - $table->timestamps(); - $table->softDeletes(); - - $table->index(['workspace_id', 'status'], 'ent_ws_pkg_ws_status_idx'); - $table->index(['expires_at', 'status'], 'ent_ws_pkg_expires_status_idx'); - $table->index('blesta_service_id'); - }); - - // 11. Entitlement Namespace Packages - Schema::create('entitlement_namespace_packages', function (Blueprint $table) { - $table->id(); - $table->foreignId('namespace_id')->constrained('namespaces')->cascadeOnDelete(); - $table->foreignId('package_id')->constrained('entitlement_packages')->cascadeOnDelete(); - $table->enum('status', ['active', 'suspended', 'cancelled', 'expired'])->default('active'); - $table->timestamp('starts_at')->nullable(); - $table->timestamp('expires_at')->nullable(); - $table->json('metadata')->nullable(); - $table->timestamps(); - $table->softDeletes(); - - $table->index(['namespace_id', 'status']); - $table->index(['expires_at', 'status']); - }); - - // 12. Entitlement Boosts - Schema::create('entitlement_boosts', function (Blueprint $table) { - $table->id(); - $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); - $table->string('feature_code'); - $table->enum('boost_type', ['add_limit', 'enable', 'unlimited'])->default('add_limit'); - $table->enum('duration_type', ['cycle_bound', 'duration', 'permanent'])->default('cycle_bound'); - $table->unsignedBigInteger('limit_value')->nullable(); - $table->unsignedBigInteger('consumed_quantity')->default(0); - $table->enum('status', ['active', 'exhausted', 'expired', 'cancelled'])->default('active'); - $table->timestamp('starts_at')->nullable(); - $table->timestamp('expires_at')->nullable(); - $table->string('blesta_addon_id')->nullable(); - $table->json('metadata')->nullable(); - $table->timestamps(); - - $table->index(['workspace_id', 'feature_code', 'status'], 'ent_boosts_ws_feat_status_idx'); - $table->index(['expires_at', 'status'], 'ent_boosts_expires_status_idx'); - $table->index('feature_code'); - $table->index('blesta_addon_id'); - }); - - // 13. Entitlement Usage Records - Schema::create('entitlement_usage_records', function (Blueprint $table) { - $table->id(); - $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); - $table->string('feature_code'); - $table->unsignedBigInteger('quantity')->default(1); - $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); - $table->json('metadata')->nullable(); - $table->timestamp('recorded_at'); - $table->timestamps(); - - $table->index(['workspace_id', 'feature_code', 'recorded_at'], 'ent_usage_ws_feat_rec_idx'); - $table->index('recorded_at', 'ent_usage_recorded_idx'); - $table->index('feature_code'); - }); - - // 14. Entitlement Logs - Schema::create('entitlement_logs', function (Blueprint $table) { - $table->id(); - $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); - $table->string('action'); - $table->string('entity_type'); - $table->unsignedBigInteger('entity_id')->nullable(); - $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); - $table->string('source')->nullable(); - $table->json('old_values')->nullable(); - $table->json('new_values')->nullable(); - $table->json('metadata')->nullable(); - $table->timestamps(); - - $table->index(['workspace_id', 'action'], 'ent_logs_ws_action_idx'); - $table->index(['entity_type', 'entity_id'], 'ent_logs_entity_idx'); - $table->index('created_at', 'ent_logs_created_idx'); - }); - - // 15. User Two-Factor Auth - Schema::create('user_two_factor_auth', function (Blueprint $table) { - $table->id(); - $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); - $table->text('secret')->nullable(); - $table->json('recovery_codes')->nullable(); - $table->timestamp('confirmed_at')->nullable(); - $table->timestamp('enabled_at')->nullable(); - $table->timestamps(); - }); - - Schema::enableForeignKeyConstraints(); - } - - public function down(): void - { - Schema::disableForeignKeyConstraints(); - Schema::dropIfExists('user_two_factor_auth'); - Schema::dropIfExists('entitlement_logs'); - Schema::dropIfExists('entitlement_usage_records'); - Schema::dropIfExists('entitlement_boosts'); - Schema::dropIfExists('entitlement_namespace_packages'); - Schema::dropIfExists('entitlement_workspace_packages'); - Schema::dropIfExists('entitlement_package_features'); - Schema::dropIfExists('entitlement_packages'); - Schema::dropIfExists('entitlement_features'); - Schema::dropIfExists('namespaces'); - Schema::dropIfExists('user_workspace'); - Schema::dropIfExists('workspaces'); - Schema::dropIfExists('sessions'); - Schema::dropIfExists('password_reset_tokens'); - Schema::dropIfExists('users'); - Schema::enableForeignKeyConstraints(); - } -}; diff --git a/packages/core-php/src/Mod/Tenant/Migrations/2026_01_26_000000_create_workspace_invitations_table.php b/packages/core-php/src/Mod/Tenant/Migrations/2026_01_26_000000_create_workspace_invitations_table.php deleted file mode 100644 index 2429301..0000000 --- a/packages/core-php/src/Mod/Tenant/Migrations/2026_01_26_000000_create_workspace_invitations_table.php +++ /dev/null @@ -1,37 +0,0 @@ -id(); - $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); - $table->string('email'); - $table->string('token', 64)->unique(); - $table->string('role')->default('member'); - $table->foreignId('invited_by')->nullable()->constrained('users')->nullOnDelete(); - $table->timestamp('expires_at'); - $table->timestamp('accepted_at')->nullable(); - $table->timestamps(); - - $table->index(['workspace_id', 'email']); - $table->index(['email', 'accepted_at']); - $table->index('expires_at'); - }); - } - - public function down(): void - { - Schema::dropIfExists('workspace_invitations'); - } -}; diff --git a/packages/core-php/src/Mod/Tenant/Migrations/2026_01_26_120000_create_usage_alert_history_table.php b/packages/core-php/src/Mod/Tenant/Migrations/2026_01_26_120000_create_usage_alert_history_table.php deleted file mode 100644 index e9d0aca..0000000 --- a/packages/core-php/src/Mod/Tenant/Migrations/2026_01_26_120000_create_usage_alert_history_table.php +++ /dev/null @@ -1,35 +0,0 @@ -id(); - $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); - $table->string('feature_code'); - $table->unsignedTinyInteger('threshold'); // 80, 90, 100 - $table->timestamp('notified_at'); - $table->timestamp('resolved_at')->nullable(); // When usage dropped below threshold - $table->json('metadata')->nullable(); // Snapshot of usage at notification time - $table->timestamps(); - - $table->index(['workspace_id', 'feature_code', 'threshold'], 'usage_alert_ws_feat_thresh_idx'); - $table->index(['workspace_id', 'resolved_at'], 'usage_alert_ws_resolved_idx'); - }); - } - - public function down(): void - { - Schema::dropIfExists('entitlement_usage_alert_history'); - } -}; diff --git a/packages/core-php/src/Mod/Tenant/Migrations/2026_01_26_140000_create_entitlement_webhooks_tables.php b/packages/core-php/src/Mod/Tenant/Migrations/2026_01_26_140000_create_entitlement_webhooks_tables.php deleted file mode 100644 index 7bb8fdd..0000000 --- a/packages/core-php/src/Mod/Tenant/Migrations/2026_01_26_140000_create_entitlement_webhooks_tables.php +++ /dev/null @@ -1,63 +0,0 @@ -id(); - $table->uuid('uuid')->unique(); - $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); - $table->string('name'); - $table->string('url', 2048); - $table->text('secret')->nullable(); // Encrypted HMAC secret - $table->json('events'); // Array of subscribed event types - $table->boolean('is_active')->default(true); - $table->unsignedTinyInteger('max_attempts')->default(3); - $table->string('last_delivery_status')->nullable(); // pending, success, failed - $table->timestamp('last_triggered_at')->nullable(); - $table->unsignedInteger('failure_count')->default(0); - $table->json('metadata')->nullable(); // Additional configuration - $table->timestamps(); - - $table->index(['workspace_id', 'is_active'], 'ent_wh_ws_active_idx'); - $table->index('uuid'); - }); - - Schema::create('entitlement_webhook_deliveries', function (Blueprint $table) { - $table->id(); - $table->uuid('uuid'); - $table->foreignId('webhook_id') - ->constrained('entitlement_webhooks') - ->cascadeOnDelete(); - $table->string('event'); // Event name: limit_warning, limit_reached, etc. - $table->unsignedTinyInteger('attempts')->default(1); - $table->string('status'); // pending, success, failed - $table->unsignedSmallInteger('http_status')->nullable(); - $table->timestamp('resend_at')->nullable(); - $table->boolean('resent_manually')->default(false); - $table->json('payload'); - $table->json('response')->nullable(); - $table->timestamp('created_at'); - - $table->index(['webhook_id', 'status'], 'ent_wh_del_wh_status_idx'); - $table->index(['webhook_id', 'created_at'], 'ent_wh_del_wh_created_idx'); - $table->index('uuid'); - }); - } - - public function down(): void - { - Schema::dropIfExists('entitlement_webhook_deliveries'); - Schema::dropIfExists('entitlement_webhooks'); - } -}; diff --git a/packages/core-php/src/Mod/Tenant/Migrations/2026_01_26_140000_create_workspace_teams_table.php b/packages/core-php/src/Mod/Tenant/Migrations/2026_01_26_140000_create_workspace_teams_table.php deleted file mode 100644 index cf8f09e..0000000 --- a/packages/core-php/src/Mod/Tenant/Migrations/2026_01_26_140000_create_workspace_teams_table.php +++ /dev/null @@ -1,59 +0,0 @@ -id(); - $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); - $table->string('name'); - $table->string('slug'); - $table->text('description')->nullable(); - $table->json('permissions')->nullable(); - $table->boolean('is_default')->default(false); - $table->boolean('is_system')->default(false); - $table->string('colour', 32)->default('zinc'); - $table->integer('sort_order')->default(0); - $table->timestamps(); - - $table->unique(['workspace_id', 'slug']); - $table->index(['workspace_id', 'is_default']); - }); - - // 2. Enhance user_workspace pivot table - Schema::table('user_workspace', function (Blueprint $table) { - $table->foreignId('team_id')->nullable() - ->after('role') - ->constrained('workspace_teams') - ->nullOnDelete(); - $table->json('custom_permissions')->nullable()->after('team_id'); - $table->timestamp('joined_at')->nullable()->after('custom_permissions'); - $table->foreignId('invited_by')->nullable() - ->after('joined_at') - ->constrained('users') - ->nullOnDelete(); - }); - } - - public function down(): void - { - Schema::table('user_workspace', function (Blueprint $table) { - $table->dropForeign(['team_id']); - $table->dropForeign(['invited_by']); - $table->dropColumn(['team_id', 'custom_permissions', 'joined_at', 'invited_by']); - }); - - Schema::dropIfExists('workspace_teams'); - } -}; diff --git a/packages/core-php/src/Mod/Tenant/Models/AccountDeletionRequest.php b/packages/core-php/src/Mod/Tenant/Models/AccountDeletionRequest.php deleted file mode 100644 index 5716742..0000000 --- a/packages/core-php/src/Mod/Tenant/Models/AccountDeletionRequest.php +++ /dev/null @@ -1,160 +0,0 @@ - 'datetime', - 'confirmed_at' => 'datetime', - 'completed_at' => 'datetime', - 'cancelled_at' => 'datetime', - ]; - - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } - - /** - * Create a new deletion request for a user. - * Account WILL be deleted in 7 days unless cancelled. - * Clicking the email link deletes immediately after re-auth. - */ - public static function createForUser(User $user, ?string $reason = null): self - { - // Cancel any existing pending requests - static::where('user_id', $user->id) - ->whereNull('completed_at') - ->whereNull('cancelled_at') - ->delete(); - - return static::create([ - 'user_id' => $user->id, - 'token' => Str::random(64), - 'reason' => $reason, - 'expires_at' => now()->addDays(7), - ]); - } - - /** - * Find a valid request by token (for immediate deletion via email link). - */ - public static function findValidByToken(string $token): ?self - { - return static::where('token', $token) - ->whereNull('completed_at') - ->whereNull('cancelled_at') - ->first(); - } - - /** - * Get all pending requests that should be auto-deleted (past expiry). - */ - public static function pendingAutoDelete() - { - return static::where('expires_at', '<=', now()) - ->whereNull('completed_at') - ->whereNull('cancelled_at'); - } - - /** - * Check if the request is still active (not completed or cancelled). - */ - public function isActive(): bool - { - return is_null($this->completed_at) && is_null($this->cancelled_at); - } - - /** - * Check if the request is pending deletion (scheduled but not executed). - */ - public function isPending(): bool - { - return $this->isActive() && $this->expires_at->isFuture(); - } - - /** - * Check if the request is ready for auto-deletion (past expiry). - */ - public function isReadyForAutoDeletion(): bool - { - return $this->isActive() && $this->expires_at->isPast(); - } - - /** - * Mark the request as confirmed (user clicked email link). - */ - public function confirm(): self - { - $this->update(['confirmed_at' => now()]); - - return $this; - } - - /** - * Mark the request as completed (account deleted). - */ - public function complete(): self - { - $this->update(['completed_at' => now()]); - - return $this; - } - - /** - * Cancel the deletion request. - */ - public function cancel(): self - { - $this->update(['cancelled_at' => now()]); - - return $this; - } - - /** - * Get days remaining until auto-deletion. - */ - public function daysRemaining(): int - { - return max(0, (int) now()->diffInDays($this->expires_at, false)); - } - - /** - * Get hours remaining until auto-deletion. - */ - public function hoursRemaining(): int - { - return max(0, (int) now()->diffInHours($this->expires_at, false)); - } - - /** - * Get the immediate deletion URL (for email). - */ - public function confirmationUrl(): string - { - return route('account.delete.confirm', ['token' => $this->token]); - } - - /** - * Get the cancel URL. - */ - public function cancelUrl(): string - { - return route('account.delete.cancel', ['token' => $this->token]); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Models/AgentReferralBonus.php b/packages/core-php/src/Mod/Tenant/Models/AgentReferralBonus.php deleted file mode 100644 index 0f0af0e..0000000 --- a/packages/core-php/src/Mod/Tenant/Models/AgentReferralBonus.php +++ /dev/null @@ -1,110 +0,0 @@ - 'boolean', - 'last_conversion_at' => 'datetime', - 'total_conversions' => 'integer', - ]; - - /** - * Get or create a bonus record for a provider/model. - */ - public static function getOrCreate(string $provider, ?string $model = null): self - { - return static::firstOrCreate( - ['provider' => $provider, 'model' => $model], - ['next_referral_guaranteed' => false, 'total_conversions' => 0] - ); - } - - /** - * Check if the next referral is guaranteed for a provider/model. - */ - public static function hasGuaranteedReferral(string $provider, ?string $model = null): bool - { - $bonus = static::where('provider', $provider) - ->where('model', $model) - ->first(); - - return $bonus?->next_referral_guaranteed ?? false; - } - - /** - * Grant a guaranteed next referral to a provider/model. - */ - public static function grantGuaranteedReferral(string $provider, ?string $model = null): self - { - $bonus = static::getOrCreate($provider, $model); - - $bonus->update([ - 'next_referral_guaranteed' => true, - 'last_conversion_at' => now(), - 'total_conversions' => $bonus->total_conversions + 1, - ]); - - return $bonus; - } - - /** - * Consume the guaranteed referral for a provider/model. - */ - public static function consumeGuaranteedReferral(string $provider, ?string $model = null): bool - { - $bonus = static::where('provider', $provider) - ->where('model', $model) - ->where('next_referral_guaranteed', true) - ->first(); - - if (! $bonus) { - return false; - } - - $bonus->update(['next_referral_guaranteed' => false]); - - return true; - } - - /** - * Scope to a specific provider. - */ - public function scopeForProvider(Builder $query, string $provider): Builder - { - return $query->where('provider', $provider); - } - - /** - * Scope to records with guaranteed next referral. - */ - public function scopeGuaranteed(Builder $query): Builder - { - return $query->where('next_referral_guaranteed', true); - } - - /** - * Check if this bonus has a guaranteed next referral. - */ - public function hasGuarantee(): bool - { - return $this->next_referral_guaranteed; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Models/Boost.php b/packages/core-php/src/Mod/Tenant/Models/Boost.php deleted file mode 100644 index 9c43e19..0000000 --- a/packages/core-php/src/Mod/Tenant/Models/Boost.php +++ /dev/null @@ -1,220 +0,0 @@ - 'integer', - 'consumed_quantity' => 'integer', - 'starts_at' => 'datetime', - 'expires_at' => 'datetime', - 'metadata' => 'array', - ]; - - /** - * Boost types. - */ - public const BOOST_TYPE_ADD_LIMIT = 'add_limit'; - - public const BOOST_TYPE_ENABLE = 'enable'; - - public const BOOST_TYPE_UNLIMITED = 'unlimited'; - - /** - * Duration types. - */ - public const DURATION_CYCLE_BOUND = 'cycle_bound'; - - public const DURATION_DURATION = 'duration'; - - public const DURATION_PERMANENT = 'permanent'; - - /** - * Status constants. - */ - public const STATUS_ACTIVE = 'active'; - - public const STATUS_EXHAUSTED = 'exhausted'; - - public const STATUS_EXPIRED = 'expired'; - - public const STATUS_CANCELLED = 'cancelled'; - - /** - * The workspace this boost belongs to. - */ - public function workspace(): BelongsTo - { - return $this->belongsTo(Workspace::class); - } - - /** - * The namespace this boost belongs to. - */ - public function namespace(): BelongsTo - { - return $this->belongsTo(Namespace_::class, 'namespace_id'); - } - - /** - * The user this boost belongs to (for user-level boosts like vanity URLs). - */ - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } - - /** - * Scope to active boosts. - */ - public function scopeActive($query) - { - return $query->where('status', self::STATUS_ACTIVE); - } - - /** - * Scope to a specific feature. - */ - public function scopeForFeature($query, string $featureCode) - { - return $query->where('feature_code', $featureCode); - } - - /** - * Scope to usable boosts (active and not expired). - */ - public function scopeUsable($query) - { - return $query->where('status', self::STATUS_ACTIVE) - ->where(function ($q) { - $q->whereNull('expires_at') - ->orWhere('expires_at', '>', now()); - }) - ->where(function ($q) { - $q->whereNull('starts_at') - ->orWhere('starts_at', '<=', now()); - }); - } - - /** - * Check if this boost is currently usable. - */ - public function isUsable(): bool - { - if ($this->status !== self::STATUS_ACTIVE) { - return false; - } - - if ($this->starts_at && $this->starts_at->isFuture()) { - return false; - } - - if ($this->expires_at && $this->expires_at->isPast()) { - return false; - } - - return true; - } - - /** - * Get remaining limit for this boost. - */ - public function getRemainingLimit(): ?int - { - if ($this->boost_type === self::BOOST_TYPE_UNLIMITED) { - return null; // Unlimited - } - - if ($this->boost_type === self::BOOST_TYPE_ENABLE) { - return null; // Boolean, no limit - } - - return max(0, $this->limit_value - $this->consumed_quantity); - } - - /** - * Consume some of this boost's limit. - */ - public function consume(int $quantity = 1): bool - { - if (! $this->isUsable()) { - return false; - } - - if ($this->boost_type !== self::BOOST_TYPE_ADD_LIMIT) { - return true; // No consumption for enable/unlimited - } - - $remaining = $this->getRemainingLimit(); - - if ($remaining !== null && $quantity > $remaining) { - return false; - } - - $this->increment('consumed_quantity', $quantity); - - // Check if exhausted - if ($this->getRemainingLimit() === 0) { - $this->update(['status' => self::STATUS_EXHAUSTED]); - } - - return true; - } - - /** - * Check if this boost has remaining capacity. - */ - public function hasCapacity(): bool - { - if ($this->boost_type === self::BOOST_TYPE_UNLIMITED) { - return true; - } - - if ($this->boost_type === self::BOOST_TYPE_ENABLE) { - return true; - } - - return $this->getRemainingLimit() > 0; - } - - /** - * Expire this boost. - */ - public function expire(): void - { - $this->update(['status' => self::STATUS_EXPIRED]); - } - - /** - * Cancel this boost. - */ - public function cancel(): void - { - $this->update(['status' => self::STATUS_CANCELLED]); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Models/EntitlementLog.php b/packages/core-php/src/Mod/Tenant/Models/EntitlementLog.php deleted file mode 100644 index 366df43..0000000 --- a/packages/core-php/src/Mod/Tenant/Models/EntitlementLog.php +++ /dev/null @@ -1,207 +0,0 @@ - 'array', - 'new_values' => 'array', - 'metadata' => 'array', - ]; - - /** - * Action constants. - */ - public const ACTION_PACKAGE_PROVISIONED = 'package.provisioned'; - - public const ACTION_PACKAGE_SUSPENDED = 'package.suspended'; - - public const ACTION_PACKAGE_CANCELLED = 'package.cancelled'; - - public const ACTION_PACKAGE_REACTIVATED = 'package.reactivated'; - - public const ACTION_PACKAGE_RENEWED = 'package.renewed'; - - public const ACTION_PACKAGE_EXPIRED = 'package.expired'; - - public const ACTION_BOOST_PROVISIONED = 'boost.provisioned'; - - public const ACTION_BOOST_CONSUMED = 'boost.consumed'; - - public const ACTION_BOOST_EXHAUSTED = 'boost.exhausted'; - - public const ACTION_BOOST_EXPIRED = 'boost.expired'; - - public const ACTION_BOOST_CANCELLED = 'boost.cancelled'; - - public const ACTION_USAGE_RECORDED = 'usage.recorded'; - - public const ACTION_USAGE_DENIED = 'usage.denied'; - - public const ACTION_CYCLE_RESET = 'cycle.reset'; - - /** - * Source constants. - */ - public const SOURCE_BLESTA = 'blesta'; - - public const SOURCE_COMMERCE = 'commerce'; - - public const SOURCE_ADMIN = 'admin'; - - public const SOURCE_SYSTEM = 'system'; - - public const SOURCE_API = 'api'; - - /** - * The workspace this log belongs to. - */ - public function workspace(): BelongsTo - { - return $this->belongsTo(Workspace::class); - } - - /** - * The namespace this log belongs to. - */ - public function namespace(): BelongsTo - { - return $this->belongsTo(Namespace_::class, 'namespace_id'); - } - - /** - * The user who triggered this action. - */ - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } - - /** - * Scope to a specific action. - */ - public function scopeForAction($query, string $action) - { - return $query->where('action', $action); - } - - /** - * Scope to a specific entity. - */ - public function scopeForEntity($query, string $entityType, ?int $entityId = null) - { - $query->where('entity_type', $entityType); - - if ($entityId !== null) { - $query->where('entity_id', $entityId); - } - - return $query; - } - - /** - * Scope to a specific source. - */ - public function scopeFromSource($query, string $source) - { - return $query->where('source', $source); - } - - /** - * Create a log entry for a package action. - */ - public static function logPackageAction( - Workspace $workspace, - string $action, - WorkspacePackage $workspacePackage, - ?User $user = null, - ?string $source = null, - ?array $oldValues = null, - ?array $newValues = null, - ?array $metadata = null - ): self { - return self::create([ - 'workspace_id' => $workspace->id, - 'action' => $action, - 'entity_type' => WorkspacePackage::class, - 'entity_id' => $workspacePackage->id, - 'user_id' => $user?->id, - 'source' => $source, - 'old_values' => $oldValues, - 'new_values' => $newValues, - 'metadata' => $metadata, - ]); - } - - /** - * Create a log entry for a boost action. - */ - public static function logBoostAction( - Workspace $workspace, - string $action, - Boost $boost, - ?User $user = null, - ?string $source = null, - ?array $oldValues = null, - ?array $newValues = null, - ?array $metadata = null - ): self { - return self::create([ - 'workspace_id' => $workspace->id, - 'action' => $action, - 'entity_type' => Boost::class, - 'entity_id' => $boost->id, - 'user_id' => $user?->id, - 'source' => $source, - 'old_values' => $oldValues, - 'new_values' => $newValues, - 'metadata' => $metadata, - ]); - } - - /** - * Create a log entry for a usage action. - */ - public static function logUsageAction( - Workspace $workspace, - string $action, - string $featureCode, - ?User $user = null, - ?string $source = null, - ?array $metadata = null - ): self { - return self::create([ - 'workspace_id' => $workspace->id, - 'action' => $action, - 'entity_type' => 'feature', - 'entity_id' => null, - 'user_id' => $user?->id, - 'source' => $source, - 'old_values' => null, - 'new_values' => ['feature_code' => $featureCode], - 'metadata' => $metadata, - ]); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Models/EntitlementWebhook.php b/packages/core-php/src/Mod/Tenant/Models/EntitlementWebhook.php deleted file mode 100644 index 13b2895..0000000 --- a/packages/core-php/src/Mod/Tenant/Models/EntitlementWebhook.php +++ /dev/null @@ -1,245 +0,0 @@ - 'array', - 'is_active' => 'boolean', - 'max_attempts' => 'integer', - 'last_delivery_status' => WebhookDeliveryStatus::class, - 'last_triggered_at' => 'datetime', - 'failure_count' => 'integer', - 'secret' => 'encrypted', - 'metadata' => 'array', - ]; - - protected $hidden = [ - 'secret', - ]; - - /** - * Available webhook event types. - */ - public const EVENTS = [ - 'limit_warning', - 'limit_reached', - 'package_changed', - 'boost_activated', - 'boost_expired', - ]; - - /** - * Maximum consecutive failures before auto-disable (circuit breaker). - */ - public const MAX_FAILURES = 5; - - protected static function boot(): void - { - parent::boot(); - - static::creating(function (self $webhook) { - if (empty($webhook->uuid)) { - $webhook->uuid = (string) Str::uuid(); - } - }); - } - - // ------------------------------------------------------------------------- - // Relationships - // ------------------------------------------------------------------------- - - public function workspace(): BelongsTo - { - return $this->belongsTo(Workspace::class); - } - - public function deliveries(): HasMany - { - return $this->hasMany(EntitlementWebhookDelivery::class, 'webhook_id'); - } - - // ------------------------------------------------------------------------- - // Scopes - // ------------------------------------------------------------------------- - - public function scopeActive(Builder $query): Builder - { - return $query->where('is_active', true); - } - - public function scopeForEvent(Builder $query, string $event): Builder - { - return $query->whereJsonContains('events', $event); - } - - public function scopeForWorkspace(Builder $query, Workspace|int $workspace): Builder - { - $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; - - return $query->where('workspace_id', $workspaceId); - } - - // ------------------------------------------------------------------------- - // State checks - // ------------------------------------------------------------------------- - - public function isActive(): bool - { - return $this->is_active === true; - } - - public function hasEvent(string $event): bool - { - return in_array($event, $this->events ?? []); - } - - public function isCircuitBroken(): bool - { - return $this->failure_count >= self::MAX_FAILURES; - } - - // ------------------------------------------------------------------------- - // Status management - // ------------------------------------------------------------------------- - - public function incrementFailureCount(): void - { - $this->increment('failure_count'); - - // Auto-disable after too many failures (circuit breaker) - if ($this->failure_count >= self::MAX_FAILURES) { - $this->update(['is_active' => false]); - } - } - - public function resetFailureCount(): void - { - $this->update([ - 'failure_count' => 0, - 'last_triggered_at' => now(), - ]); - } - - public function updateLastDeliveryStatus(WebhookDeliveryStatus $status): void - { - $this->update(['last_delivery_status' => $status]); - } - - /** - * Trigger webhook and create delivery record. - */ - public function trigger(EntitlementWebhookEvent $event): EntitlementWebhookDelivery - { - $data = [ - 'event' => $event::name(), - 'data' => $event->payload(), - 'timestamp' => now()->toIso8601String(), - ]; - - try { - $headers = [ - 'Content-Type' => 'application/json', - 'X-Request-Source' => config('app.name'), - 'User-Agent' => config('app.name').' Entitlement Webhook', - ]; - - if ($this->secret) { - $headers['X-Signature'] = hash_hmac('sha256', json_encode($data), $this->secret); - } - - $response = Http::withHeaders($headers) - ->timeout(10) - ->post($this->url, $data); - - $status = match ($response->status()) { - 200, 201, 202, 204 => WebhookDeliveryStatus::SUCCESS, - default => WebhookDeliveryStatus::FAILED, - }; - - if ($status === WebhookDeliveryStatus::SUCCESS) { - $this->resetFailureCount(); - } else { - $this->incrementFailureCount(); - } - - $this->updateLastDeliveryStatus($status); - - return $this->deliveries()->create([ - 'uuid' => Str::uuid(), - 'event' => $event::name(), - 'status' => $status, - 'http_status' => $response->status(), - 'payload' => $data, - 'response' => $response->json() ?: ['body' => $response->body()], - 'created_at' => now(), - ]); - } catch (\Exception $e) { - $this->incrementFailureCount(); - $this->updateLastDeliveryStatus(WebhookDeliveryStatus::FAILED); - - return $this->deliveries()->create([ - 'uuid' => Str::uuid(), - 'event' => $event::name(), - 'status' => WebhookDeliveryStatus::FAILED, - 'payload' => $data, - 'response' => ['error' => $e->getMessage()], - 'created_at' => now(), - ]); - } - } - - public function getRouteKeyName(): string - { - return 'uuid'; - } - - /** - * Generate a new secret for this webhook. - */ - public function regenerateSecret(): string - { - $secret = bin2hex(random_bytes(32)); - $this->update(['secret' => $secret]); - - return $secret; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Models/EntitlementWebhookDelivery.php b/packages/core-php/src/Mod/Tenant/Models/EntitlementWebhookDelivery.php deleted file mode 100644 index 7d80e6b..0000000 --- a/packages/core-php/src/Mod/Tenant/Models/EntitlementWebhookDelivery.php +++ /dev/null @@ -1,139 +0,0 @@ - 'integer', - 'status' => WebhookDeliveryStatus::class, - 'http_status' => 'integer', - 'resend_at' => 'datetime', - 'resent_manually' => 'boolean', - 'payload' => 'array', - 'response' => 'array', - 'created_at' => 'datetime', - ]; - - /** - * Prune deliveries older than 30 days. - */ - public function prunable(): Builder - { - return static::where('created_at', '<=', Carbon::now()->subMonth()); - } - - public function webhook(): BelongsTo - { - return $this->belongsTo(EntitlementWebhook::class, 'webhook_id'); - } - - public function isSucceeded(): bool - { - return $this->status === WebhookDeliveryStatus::SUCCESS; - } - - public function isFailed(): bool - { - return $this->status === WebhookDeliveryStatus::FAILED; - } - - public function isPending(): bool - { - return $this->status === WebhookDeliveryStatus::PENDING; - } - - public function isAttemptLimitReached(): bool - { - return $this->attempts >= $this->webhook->max_attempts; - } - - public function attempt(): void - { - $this->increment('attempts'); - } - - public function setAsResentManually(): void - { - $this->resent_manually = true; - $this->save(); - } - - public function updateResendAt(Carbon|DateTimeInterface|null $datetime = null): void - { - $this->resend_at = $datetime; - $this->save(); - } - - public function getRouteKeyName(): string - { - return 'uuid'; - } - - /** - * Get the event name in a human-readable format. - */ - public function getEventDisplayName(): string - { - return match ($this->event) { - 'limit_warning' => 'Limit Warning', - 'limit_reached' => 'Limit Reached', - 'package_changed' => 'Package Changed', - 'boost_activated' => 'Boost Activated', - 'boost_expired' => 'Boost Expired', - 'test' => 'Test', - default => ucwords(str_replace('_', ' ', $this->event)), - }; - } - - /** - * Get status badge colour for display. - */ - public function getStatusColour(): string - { - return match ($this->status) { - WebhookDeliveryStatus::SUCCESS => 'green', - WebhookDeliveryStatus::FAILED => 'red', - WebhookDeliveryStatus::PENDING => 'amber', - default => 'gray', - }; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Models/Feature.php b/packages/core-php/src/Mod/Tenant/Models/Feature.php deleted file mode 100644 index 6bbf8ab..0000000 --- a/packages/core-php/src/Mod/Tenant/Models/Feature.php +++ /dev/null @@ -1,159 +0,0 @@ - 'integer', - 'sort_order' => 'integer', - 'is_active' => 'boolean', - ]; - - /** - * Feature types. - */ - public const TYPE_BOOLEAN = 'boolean'; - - public const TYPE_LIMIT = 'limit'; - - public const TYPE_UNLIMITED = 'unlimited'; - - /** - * Reset types. - */ - public const RESET_NONE = 'none'; - - public const RESET_MONTHLY = 'monthly'; - - public const RESET_ROLLING = 'rolling'; - - /** - * Packages that include this feature. - */ - public function packages(): BelongsToMany - { - return $this->belongsToMany(Package::class, 'entitlement_package_features', 'feature_id', 'package_id') - ->withPivot('limit_value') - ->withTimestamps(); - } - - /** - * Parent feature (for hierarchical limits / global pools). - */ - public function parent(): BelongsTo - { - return $this->belongsTo(Feature::class, 'parent_feature_id'); - } - - /** - * Child features (allowances within a global pool). - */ - public function children(): HasMany - { - return $this->hasMany(Feature::class, 'parent_feature_id'); - } - - /** - * Scope to active features. - */ - public function scopeActive($query) - { - return $query->where('is_active', true); - } - - /** - * Scope to features in a category. - */ - public function scopeInCategory($query, string $category) - { - return $query->where('category', $category); - } - - /** - * Scope to root features (no parent). - */ - public function scopeRoot($query) - { - return $query->whereNull('parent_feature_id'); - } - - /** - * Check if this feature is a boolean toggle. - */ - public function isBoolean(): bool - { - return $this->type === self::TYPE_BOOLEAN; - } - - /** - * Check if this feature has a usage limit. - */ - public function hasLimit(): bool - { - return $this->type === self::TYPE_LIMIT; - } - - /** - * Check if this feature is unlimited. - */ - public function isUnlimited(): bool - { - return $this->type === self::TYPE_UNLIMITED; - } - - /** - * Check if this feature resets monthly. - */ - public function resetsMonthly(): bool - { - return $this->reset_type === self::RESET_MONTHLY; - } - - /** - * Check if this feature uses rolling window reset. - */ - public function resetsRolling(): bool - { - return $this->reset_type === self::RESET_ROLLING; - } - - /** - * Check if this is a child feature (part of a global pool). - */ - public function isChildFeature(): bool - { - return $this->parent_feature_id !== null; - } - - /** - * Get the global pool feature code (parent or self). - */ - public function getPoolFeatureCode(): string - { - return $this->parent?->code ?? $this->code; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Models/NamespacePackage.php b/packages/core-php/src/Mod/Tenant/Models/NamespacePackage.php deleted file mode 100644 index 3f94bf7..0000000 --- a/packages/core-php/src/Mod/Tenant/Models/NamespacePackage.php +++ /dev/null @@ -1,176 +0,0 @@ - 'datetime', - 'expires_at' => 'datetime', - 'billing_cycle_anchor' => 'datetime', - 'metadata' => 'array', - ]; - - /** - * Status constants. - */ - public const STATUS_ACTIVE = 'active'; - - public const STATUS_SUSPENDED = 'suspended'; - - public const STATUS_CANCELLED = 'cancelled'; - - public const STATUS_EXPIRED = 'expired'; - - /** - * The namespace this package belongs to. - */ - public function namespace(): BelongsTo - { - return $this->belongsTo(Namespace_::class, 'namespace_id'); - } - - /** - * The package definition. - */ - public function package(): BelongsTo - { - return $this->belongsTo(Package::class, 'package_id'); - } - - /** - * Scope to active assignments. - */ - public function scopeActive($query) - { - return $query->where('status', self::STATUS_ACTIVE); - } - - /** - * Scope to non-expired assignments. - */ - public function scopeNotExpired($query) - { - return $query->where(function ($q) { - $q->whereNull('expires_at') - ->orWhere('expires_at', '>', now()); - }); - } - - /** - * Check if this assignment is currently active. - */ - public function isActive(): bool - { - if ($this->status !== self::STATUS_ACTIVE) { - return false; - } - - if ($this->starts_at && $this->starts_at->isFuture()) { - return false; - } - - if ($this->expires_at && $this->expires_at->isPast()) { - return false; - } - - return true; - } - - /** - * Check if this assignment is on grace period. - */ - public function onGracePeriod(): bool - { - return $this->status === self::STATUS_CANCELLED - && $this->expires_at - && $this->expires_at->isFuture(); - } - - /** - * Get the current billing cycle start date. - */ - public function getCurrentCycleStart(): Carbon - { - if (! $this->billing_cycle_anchor) { - return $this->starts_at ?? $this->created_at; - } - - $anchor = $this->billing_cycle_anchor->copy(); - $now = now(); - - // Find the most recent cycle start - while ($anchor->addMonth()->lte($now)) { - // Keep advancing until we pass now - } - - return $anchor->subMonth(); - } - - /** - * Get the current billing cycle end date. - */ - public function getCurrentCycleEnd(): Carbon - { - return $this->getCurrentCycleStart()->copy()->addMonth(); - } - - /** - * Suspend this assignment. - */ - public function suspend(): void - { - $this->update(['status' => self::STATUS_SUSPENDED]); - } - - /** - * Reactivate this assignment. - */ - public function reactivate(): void - { - $this->update(['status' => self::STATUS_ACTIVE]); - } - - /** - * Cancel this assignment. - */ - public function cancel(?Carbon $endsAt = null): void - { - $this->update([ - 'status' => self::STATUS_CANCELLED, - 'expires_at' => $endsAt ?? $this->getCurrentCycleEnd(), - ]); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Models/Namespace_.php b/packages/core-php/src/Mod/Tenant/Models/Namespace_.php deleted file mode 100644 index 6b67c09..0000000 --- a/packages/core-php/src/Mod/Tenant/Models/Namespace_.php +++ /dev/null @@ -1,321 +0,0 @@ - 'array', - 'is_default' => 'boolean', - 'is_active' => 'boolean', - 'sort_order' => 'integer', - ]; - - /** - * Boot the model. - */ - protected static function booted(): void - { - static::creating(function (self $namespace) { - if (empty($namespace->uuid)) { - $namespace->uuid = (string) Str::uuid(); - } - }); - } - - // ───────────────────────────────────────────────────────────────────────── - // Ownership Relationships - // ───────────────────────────────────────────────────────────────────────── - - /** - * Get the owner of the namespace (User or Workspace). - */ - public function owner(): MorphTo - { - return $this->morphTo(); - } - - /** - * Get the workspace for billing aggregation (if set). - * - * This is separate from owner - a user-owned namespace can still - * have a workspace context for billing purposes. - */ - public function workspace(): BelongsTo - { - return $this->belongsTo(Workspace::class); - } - - /** - * Check if this namespace is owned by a user. - */ - public function isOwnedByUser(): bool - { - return $this->owner_type === User::class; - } - - /** - * Check if this namespace is owned by a workspace. - */ - public function isOwnedByWorkspace(): bool - { - return $this->owner_type === Workspace::class; - } - - /** - * Get the owner as User (or null if workspace-owned). - */ - public function getOwnerUser(): ?User - { - if ($this->isOwnedByUser()) { - return $this->owner; - } - - return null; - } - - /** - * Get the owner as Workspace (or null if user-owned). - */ - public function getOwnerWorkspace(): ?Workspace - { - if ($this->isOwnedByWorkspace()) { - return $this->owner; - } - - return null; - } - - // ───────────────────────────────────────────────────────────────────────── - // Entitlement Relationships - // ───────────────────────────────────────────────────────────────────────── - - /** - * Active package assignments for this namespace. - */ - public function namespacePackages(): HasMany - { - return $this->hasMany(NamespacePackage::class); - } - - /** - * Active boosts for this namespace. - */ - public function boosts(): HasMany - { - return $this->hasMany(Boost::class); - } - - /** - * Usage records for this namespace. - */ - public function usageRecords(): HasMany - { - return $this->hasMany(UsageRecord::class); - } - - /** - * Entitlement logs for this namespace. - */ - public function entitlementLogs(): HasMany - { - return $this->hasMany(EntitlementLog::class); - } - - // ───────────────────────────────────────────────────────────────────────── - // Settings & Configuration - // ───────────────────────────────────────────────────────────────────────── - - /** - * Get a setting value from the settings JSON column. - */ - public function getSetting(string $key, mixed $default = null): mixed - { - return data_get($this->settings, $key, $default); - } - - /** - * Set a setting value in the settings JSON column. - */ - public function setSetting(string $key, mixed $value): self - { - $settings = $this->settings ?? []; - data_set($settings, $key, $value); - $this->settings = $settings; - - return $this; - } - - // ───────────────────────────────────────────────────────────────────────── - // Scopes - // ───────────────────────────────────────────────────────────────────────── - - /** - * Scope to only active namespaces. - */ - public function scopeActive($query) - { - return $query->where('is_active', true); - } - - /** - * Scope to order by sort order. - */ - public function scopeOrdered($query) - { - return $query->orderBy('sort_order'); - } - - /** - * Scope to namespaces owned by a specific user. - */ - public function scopeOwnedByUser($query, User|int $user) - { - $userId = $user instanceof User ? $user->id : $user; - - return $query->where('owner_type', User::class) - ->where('owner_id', $userId); - } - - /** - * Scope to namespaces owned by a specific workspace. - */ - public function scopeOwnedByWorkspace($query, Workspace|int $workspace) - { - $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; - - return $query->where('owner_type', Workspace::class) - ->where('owner_id', $workspaceId); - } - - /** - * Scope to namespaces accessible by a user (owned by user OR owned by user's workspaces). - */ - public function scopeAccessibleBy($query, User $user) - { - $workspaceIds = $user->workspaces()->pluck('workspaces.id'); - - return $query->where(function ($q) use ($user, $workspaceIds) { - // User-owned namespaces - $q->where(function ($q2) use ($user) { - $q2->where('owner_type', User::class) - ->where('owner_id', $user->id); - }); - - // Workspace-owned namespaces (where user is a member) - if ($workspaceIds->isNotEmpty()) { - $q->orWhere(function ($q2) use ($workspaceIds) { - $q2->where('owner_type', Workspace::class) - ->whereIn('owner_id', $workspaceIds); - }); - } - }); - } - - // ───────────────────────────────────────────────────────────────────────── - // Helper Methods - // ───────────────────────────────────────────────────────────────────────── - - /** - * Check if a user has access to this namespace. - */ - public function isAccessibleBy(User $user): bool - { - // User owns the namespace directly - if ($this->isOwnedByUser() && $this->owner_id === $user->id) { - return true; - } - - // Workspace owns the namespace and user is a member - if ($this->isOwnedByWorkspace()) { - return $user->workspaces()->where('workspaces.id', $this->owner_id)->exists(); - } - - return false; - } - - /** - * Get the billing context for this namespace. - * - * Returns workspace if set, otherwise falls back to owner's default workspace. - */ - public function getBillingContext(): ?Workspace - { - // Explicit workspace set for billing - if ($this->workspace_id) { - return $this->workspace; - } - - // Workspace-owned: use the owner workspace - if ($this->isOwnedByWorkspace()) { - return $this->owner; - } - - // User-owned: fall back to user's default workspace - if ($this->isOwnedByUser() && $this->owner) { - return $this->owner->defaultHostWorkspace(); - } - - return null; - } - - /** - * Get the route key name for route model binding. - */ - public function getRouteKeyName(): string - { - return 'uuid'; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Models/Package.php b/packages/core-php/src/Mod/Tenant/Models/Package.php deleted file mode 100644 index e8ef1b0..0000000 --- a/packages/core-php/src/Mod/Tenant/Models/Package.php +++ /dev/null @@ -1,244 +0,0 @@ - 'boolean', - 'is_base_package' => 'boolean', - 'is_active' => 'boolean', - 'is_public' => 'boolean', - 'sort_order' => 'integer', - 'monthly_price' => 'decimal:2', - 'yearly_price' => 'decimal:2', - 'setup_fee' => 'decimal:2', - 'trial_days' => 'integer', - ]; - - /** - * Features included in this package. - */ - public function features(): BelongsToMany - { - return $this->belongsToMany(Feature::class, 'entitlement_package_features', 'package_id', 'feature_id') - ->withPivot('limit_value') - ->withTimestamps(); - } - - /** - * Workspaces that have this package assigned. - */ - public function workspacePackages(): HasMany - { - return $this->hasMany(WorkspacePackage::class, 'package_id'); - } - - /** - * Scope to active packages. - */ - public function scopeActive($query) - { - return $query->where('is_active', true); - } - - /** - * Scope to public packages (shown on pricing page). - */ - public function scopePublic($query) - { - return $query->where('is_public', true); - } - - /** - * Scope to base packages (only one per workspace). - */ - public function scopeBase($query) - { - return $query->where('is_base_package', true); - } - - /** - * Scope to addon packages (stackable). - */ - public function scopeAddons($query) - { - return $query->where('is_base_package', false); - } - - /** - * Get the limit for a specific feature in this package. - */ - public function getFeatureLimit(string $featureCode): ?int - { - $feature = $this->features()->where('code', $featureCode)->first(); - - if (! $feature) { - return null; - } - - return $feature->pivot->limit_value; - } - - /** - * Check if package includes a feature (regardless of limit). - */ - public function hasFeature(string $featureCode): bool - { - return $this->features()->where('code', $featureCode)->exists(); - } - - // Pricing Helpers - - /** - * Check if package is free. - */ - public function isFree(): bool - { - return ($this->monthly_price ?? 0) == 0 && ($this->yearly_price ?? 0) == 0; - } - - /** - * Check if package has pricing set. - */ - public function hasPricing(): bool - { - return $this->monthly_price !== null || $this->yearly_price !== null; - } - - /** - * Get price for a billing cycle. - */ - public function getPrice(string $cycle = 'monthly'): float - { - return match ($cycle) { - 'yearly', 'annual' => (float) ($this->yearly_price ?? 0), - default => (float) ($this->monthly_price ?? 0), - }; - } - - /** - * Get yearly savings compared to monthly. - */ - public function getYearlySavings(): float - { - if (! $this->monthly_price || ! $this->yearly_price) { - return 0; - } - - $monthlyTotal = $this->monthly_price * 12; - - return max(0, $monthlyTotal - $this->yearly_price); - } - - /** - * Get yearly savings as percentage. - */ - public function getYearlySavingsPercent(): int - { - if (! $this->monthly_price || ! $this->yearly_price) { - return 0; - } - - $monthlyTotal = $this->monthly_price * 12; - if ($monthlyTotal == 0) { - return 0; - } - - return (int) round(($this->getYearlySavings() / $monthlyTotal) * 100); - } - - /** - * Get gateway price ID for a cycle. - */ - public function getGatewayPriceId(string $gateway, string $cycle = 'monthly'): ?string - { - $field = match ($cycle) { - 'yearly', 'annual' => "{$gateway}_price_id_yearly", - default => "{$gateway}_price_id_monthly", - }; - - return $this->{$field}; - } - - /** - * Check if package has trial period. - */ - public function hasTrial(): bool - { - return ($this->trial_days ?? 0) > 0; - } - - /** - * Check if package has setup fee. - */ - public function hasSetupFee(): bool - { - return ($this->setup_fee ?? 0) > 0; - } - - /** - * Scope to packages with pricing (purchasable). - */ - public function scopePurchasable($query) - { - return $query->where(function ($q) { - $q->whereNotNull('monthly_price') - ->orWhereNotNull('yearly_price'); - }); - } - - /** - * Scope to free packages. - */ - public function scopeFree($query) - { - return $query->where(function ($q) { - $q->whereNull('monthly_price') - ->orWhere('monthly_price', 0); - })->where(function ($q) { - $q->whereNull('yearly_price') - ->orWhere('yearly_price', 0); - }); - } - - /** - * Scope to order by sort_order. - */ - public function scopeOrdered($query) - { - return $query->orderBy('sort_order'); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Models/UsageAlertHistory.php b/packages/core-php/src/Mod/Tenant/Models/UsageAlertHistory.php deleted file mode 100644 index 857fa22..0000000 --- a/packages/core-php/src/Mod/Tenant/Models/UsageAlertHistory.php +++ /dev/null @@ -1,198 +0,0 @@ - 'integer', - 'notified_at' => 'datetime', - 'resolved_at' => 'datetime', - 'metadata' => 'array', - ]; - - /** - * Alert threshold levels. - */ - public const THRESHOLD_WARNING = 80; - - public const THRESHOLD_CRITICAL = 90; - - public const THRESHOLD_LIMIT = 100; - - /** - * All threshold levels in order. - */ - public const THRESHOLDS = [ - self::THRESHOLD_WARNING, - self::THRESHOLD_CRITICAL, - self::THRESHOLD_LIMIT, - ]; - - /** - * The workspace this alert belongs to. - */ - public function workspace(): BelongsTo - { - return $this->belongsTo(Workspace::class); - } - - /** - * Scope to alerts for a specific workspace. - */ - public function scopeForWorkspace($query, int $workspaceId) - { - return $query->where('workspace_id', $workspaceId); - } - - /** - * Scope to alerts for a specific feature. - */ - public function scopeForFeature($query, string $featureCode) - { - return $query->where('feature_code', $featureCode); - } - - /** - * Scope to alerts for a specific threshold. - */ - public function scopeForThreshold($query, int $threshold) - { - return $query->where('threshold', $threshold); - } - - /** - * Scope to unresolved alerts (still active). - */ - public function scopeUnresolved($query) - { - return $query->whereNull('resolved_at'); - } - - /** - * Scope to resolved alerts. - */ - public function scopeResolved($query) - { - return $query->whereNotNull('resolved_at'); - } - - /** - * Scope to recent alerts (within given days). - */ - public function scopeRecent($query, int $days = 7) - { - return $query->where('notified_at', '>=', now()->subDays($days)); - } - - /** - * Check if an alert has been sent for this workspace/feature/threshold combo. - * Only considers unresolved alerts. - */ - public static function hasActiveAlert(int $workspaceId, string $featureCode, int $threshold): bool - { - return static::query() - ->forWorkspace($workspaceId) - ->forFeature($featureCode) - ->forThreshold($threshold) - ->unresolved() - ->exists(); - } - - /** - * Get the most recent unresolved alert for a workspace/feature. - */ - public static function getActiveAlert(int $workspaceId, string $featureCode): ?self - { - return static::query() - ->forWorkspace($workspaceId) - ->forFeature($featureCode) - ->unresolved() - ->latest('notified_at') - ->first(); - } - - /** - * Record a new alert being sent. - */ - public static function record( - int $workspaceId, - string $featureCode, - int $threshold, - array $metadata = [] - ): self { - return static::create([ - 'workspace_id' => $workspaceId, - 'feature_code' => $featureCode, - 'threshold' => $threshold, - 'notified_at' => now(), - 'metadata' => $metadata, - ]); - } - - /** - * Mark this alert as resolved (usage dropped below threshold). - */ - public function resolve(): self - { - $this->update(['resolved_at' => now()]); - - return $this; - } - - /** - * Resolve all unresolved alerts for a workspace/feature. - */ - public static function resolveAllForFeature(int $workspaceId, string $featureCode): int - { - return static::query() - ->forWorkspace($workspaceId) - ->forFeature($featureCode) - ->unresolved() - ->update(['resolved_at' => now()]); - } - - /** - * Check if this alert is resolved. - */ - public function isResolved(): bool - { - return $this->resolved_at !== null; - } - - /** - * Get the threshold level name. - */ - public function getThresholdName(): string - { - return match ($this->threshold) { - self::THRESHOLD_WARNING => 'warning', - self::THRESHOLD_CRITICAL => 'critical', - self::THRESHOLD_LIMIT => 'limit_reached', - default => 'unknown', - }; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Models/UsageRecord.php b/packages/core-php/src/Mod/Tenant/Models/UsageRecord.php deleted file mode 100644 index ec44d7f..0000000 --- a/packages/core-php/src/Mod/Tenant/Models/UsageRecord.php +++ /dev/null @@ -1,121 +0,0 @@ - 'integer', - 'metadata' => 'array', - 'recorded_at' => 'datetime', - ]; - - /** - * The workspace this usage belongs to. - */ - public function workspace(): BelongsTo - { - return $this->belongsTo(Workspace::class); - } - - /** - * The namespace this usage belongs to. - */ - public function namespace(): BelongsTo - { - return $this->belongsTo(Namespace_::class, 'namespace_id'); - } - - /** - * The user who incurred this usage. - */ - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } - - /** - * Scope to a specific feature. - */ - public function scopeForFeature($query, string $featureCode) - { - return $query->where('feature_code', $featureCode); - } - - /** - * Scope to records since a date. - */ - public function scopeSince($query, Carbon $date) - { - return $query->where('recorded_at', '>=', $date); - } - - /** - * Scope to records in a date range. - */ - public function scopeBetween($query, Carbon $start, Carbon $end) - { - return $query->whereBetween('recorded_at', [$start, $end]); - } - - /** - * Scope to records in the current billing cycle. - */ - public function scopeInCurrentCycle($query, Carbon $cycleStart) - { - return $query->where('recorded_at', '>=', $cycleStart); - } - - /** - * Scope to records in a rolling window. - */ - public function scopeInRollingWindow($query, int $days) - { - return $query->where('recorded_at', '>=', now()->subDays($days)); - } - - /** - * Get total usage for a workspace + feature since a date. - */ - public static function getTotalUsage(int $workspaceId, string $featureCode, ?Carbon $since = null): int - { - $query = static::where('workspace_id', $workspaceId) - ->where('feature_code', $featureCode); - - if ($since) { - $query->where('recorded_at', '>=', $since); - } - - return (int) $query->sum('quantity'); - } - - /** - * Get total usage in a rolling window. - */ - public static function getRollingUsage(int $workspaceId, string $featureCode, int $days): int - { - return static::where('workspace_id', $workspaceId) - ->where('feature_code', $featureCode) - ->where('recorded_at', '>=', now()->subDays($days)) - ->sum('quantity'); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Models/User.php b/packages/core-php/src/Mod/Tenant/Models/User.php deleted file mode 100644 index c39f66e..0000000 --- a/packages/core-php/src/Mod/Tenant/Models/User.php +++ /dev/null @@ -1,596 +0,0 @@ - - */ - protected $fillable = [ - 'name', - 'email', - 'password', - 'tier', - 'tier_expires_at', - 'referred_by', - 'referral_count', - 'referral_activated_at', - ]; - - /** - * The attributes that should be hidden for serialization. - * - * @var list - */ - protected $hidden = [ - 'password', - 'remember_token', - ]; - - /** - * Get the attributes that should be cast. - * - * @return array - */ - protected function casts(): array - { - return [ - 'email_verified_at' => 'datetime', - 'password' => 'hashed', - 'tier' => UserTier::class, - 'tier_expires_at' => 'datetime', - 'cached_stats' => 'array', - 'stats_computed_at' => 'datetime', - 'referral_activated_at' => 'datetime', - ]; - } - - /** - * Get all workspaces this user has access to. - */ - public function workspaces(): BelongsToMany - { - return $this->belongsToMany(Workspace::class, 'user_workspace') - ->withPivot(['role', 'is_default']) - ->withTimestamps(); - } - - /** - * Alias for workspaces() - kept for backward compatibility. - */ - public function hostWorkspaces(): BelongsToMany - { - return $this->workspaces(); - } - - /** - * Get the workspaces owned by this user. - */ - public function ownedWorkspaces(): BelongsToMany - { - return $this->belongsToMany(Workspace::class, 'user_workspace') - ->wherePivot('role', 'owner') - ->withPivot(['role', 'is_default']) - ->withTimestamps(); - } - - /** - * Get the user's tier. - */ - public function getTier(): UserTier - { - // Check if tier has expired - if ($this->tier_expires_at && $this->tier_expires_at->isPast()) { - return UserTier::FREE; - } - - return $this->tier ?? UserTier::FREE; - } - - /** - * Check if user is on a paid tier. - */ - public function isPaid(): bool - { - $tier = $this->getTier(); - - return $tier === UserTier::APOLLO || $tier === UserTier::HADES; - } - - /** - * Check if user is on Hades tier. - */ - public function isHades(): bool - { - return $this->getTier() === UserTier::HADES; - } - - /** - * Check if user is on Apollo tier. - */ - public function isApollo(): bool - { - return $this->getTier() === UserTier::APOLLO; - } - - /** - * Check if user has a specific feature. - */ - public function hasFeature(string $feature): bool - { - return $this->getTier()->hasFeature($feature); - } - - /** - * Get the maximum number of workspaces for this user. - */ - public function maxWorkspaces(): int - { - return $this->getTier()->maxWorkspaces(); - } - - /** - * Check if user can add more Host Hub workspaces. - */ - public function canAddHostWorkspace(): bool - { - $max = $this->maxWorkspaces(); - if ($max === -1) { - return true; // Unlimited - } - - return $this->hostWorkspaces()->count() < $max; - } - - /** - * Get the user's default Host Hub workspace. - */ - public function defaultHostWorkspace(): ?Workspace - { - return $this->hostWorkspaces() - ->wherePivot('is_default', true) - ->first() ?? $this->hostWorkspaces()->first(); - } - - // ───────────────────────────────────────────────────────────────────────── - // Namespace Relationships - // ───────────────────────────────────────────────────────────────────────── - - /** - * Get all namespaces owned directly by this user. - */ - public function namespaces(): MorphMany - { - return $this->morphMany(Namespace_::class, 'owner'); - } - - /** - * Get the user's default namespace. - * - * Priority: - * 1. User's default namespace (is_default = true) - * 2. First active user-owned namespace - * 3. First namespace from user's default workspace - */ - public function defaultNamespace(): ?Namespace_ - { - // Try user's explicit default - $default = $this->namespaces() - ->where('is_default', true) - ->active() - ->first(); - - if ($default) { - return $default; - } - - // Try first user-owned namespace - $userOwned = $this->namespaces() - ->active() - ->ordered() - ->first(); - - if ($userOwned) { - return $userOwned; - } - - // Try namespace from user's default workspace - $workspace = $this->defaultHostWorkspace(); - if ($workspace) { - return $workspace->namespaces() - ->active() - ->ordered() - ->first(); - } - - return null; - } - - /** - * Get all namespaces accessible by this user (owned + via workspaces). - */ - public function accessibleNamespaces(): \Illuminate\Database\Eloquent\Builder - { - return Namespace_::accessibleBy($this); - } - - /** - * Check if user's email has been verified. - * Hades accounts are always considered verified. - */ - public function hasVerifiedEmail(): bool - { - // Hades accounts bypass email verification - if ($this->isHades()) { - return true; - } - - return $this->email_verified_at !== null; - } - - /** - * Mark the user's email as verified. - */ - public function markEmailAsVerified(): bool - { - return $this->forceFill([ - 'email_verified_at' => $this->freshTimestamp(), - ])->save(); - } - - /** - * Send the email verification notification. - */ - public function sendEmailVerificationNotification(): void - { - $this->notify(new \Illuminate\Auth\Notifications\VerifyEmail); - } - - /** - * Get the email address that should be used for verification. - */ - public function getEmailForVerification(): string - { - return $this->email; - } - - // ───────────────────────────────────────────────────────────────────────── - // Page Relationships - // ───────────────────────────────────────────────────────────────────────── - - /** - * Get all pages owned by this user. - */ - public function pages(): HasMany - { - return $this->hasMany(Page::class); - } - - /** - * Get all page projects (folders) owned by this user. - */ - public function pageProjects(): HasMany - { - return $this->hasMany(Project::class); - } - - /** - * Get all custom domains owned by this user. - */ - public function pageDomains(): HasMany - { - return $this->hasMany(Domain::class); - } - - /** - * Get all tracking pixels owned by this user. - */ - public function pagePixels(): HasMany - { - return $this->hasMany(Pixel::class); - } - - // ───────────────────────────────────────────────────────────────────────── - // Analytics Relationships - // ───────────────────────────────────────────────────────────────────────── - - /** - * Get all analytics websites owned by this user. - */ - public function analyticsWebsites(): HasMany - { - return $this->hasMany(AnalyticsWebsite::class); - } - - /** - * Get all analytics goals owned by this user. - */ - public function analyticsGoals(): HasMany - { - return $this->hasMany(AnalyticsGoal::class); - } - - // ───────────────────────────────────────────────────────────────────────── - // Push Notification Relationships - // ───────────────────────────────────────────────────────────────────────── - - /** - * Get all push websites owned by this user. - */ - public function pushWebsites(): HasMany - { - return $this->hasMany(PushWebsite::class); - } - - /** - * Get all push campaigns owned by this user. - */ - public function pushCampaigns(): HasMany - { - return $this->hasMany(PushCampaign::class); - } - - /** - * Get all push segments owned by this user. - */ - public function pushSegments(): HasMany - { - return $this->hasMany(PushSegment::class); - } - - /** - * Get all push flows owned by this user. - */ - public function pushFlows(): HasMany - { - return $this->hasMany(PushFlow::class); - } - - // ───────────────────────────────────────────────────────────────────────── - // Trust Widget Relationships - // ───────────────────────────────────────────────────────────────────────── - - /** - * Get all trust campaigns owned by this user. - */ - public function trustCampaigns(): HasMany - { - return $this->hasMany(TrustCampaign::class); - } - - /** - * Get all trust notifications owned by this user. - */ - public function trustNotifications(): HasMany - { - return $this->hasMany(TrustNotification::class); - } - - // ───────────────────────────────────────────────────────────────────────── - // Entitlement Relationships - // ───────────────────────────────────────────────────────────────────────── - - /** - * Get all boosts owned by this user. - */ - public function boosts(): HasMany - { - return $this->hasMany(Boost::class); - } - - /** - * Get all orders placed by this user. - */ - public function orders(): HasMany - { - return $this->hasMany(Order::class); - } - - /** - * Check if user can claim a vanity URL. - * - * Requires either: - * - A paid subscription (Creator/Agency package) - * - A one-time vanity URL boost purchase - */ - public function canClaimVanityUrl(): bool - { - // Check for vanity URL boost - $hasBoost = $this->boosts() - ->where('feature_code', 'bio.vanity_url') - ->where('status', Boost::STATUS_ACTIVE) - ->exists(); - - if ($hasBoost) { - return true; - } - - // Check for paid subscription (Creator or Agency package) - // An order with total > 0 and status = 'paid' indicates a paid subscription - $hasPaidSubscription = $this->orders() - ->where('status', 'paid') - ->where('total', '>', 0) - ->whereHas('items', function ($query) { - $query->whereIn('item_code', ['creator', 'agency']); - }) - ->exists(); - - return $hasPaidSubscription; - } - - /** - * Get the user's bio.pages entitlement (base + boosts). - */ - public function getBioPagesLimit(): int - { - // Base: 1 page for all tiers - $base = 1; - - // Add from boosts - $boostPages = $this->boosts() - ->where('feature_code', 'bio.pages') - ->where('status', Boost::STATUS_ACTIVE) - ->sum('limit_value'); - - return $base + (int) $boostPages; - } - - /** - * Check if user can create more bio pages. - */ - public function canCreateBioPage(): bool - { - return $this->pages()->rootPages()->count() < $this->getBioPagesLimit(); - } - - /** - * Get remaining bio page slots. - */ - public function remainingBioPageSlots(): int - { - return max(0, $this->getBioPagesLimit() - $this->pages()->rootPages()->count()); - } - - // ───────────────────────────────────────────────────────────────────────── - // Sub-Page Entitlements - // ───────────────────────────────────────────────────────────────────────── - - /** - * Get the user's sub-page limit (0 base + boosts). - */ - public function getSubPagesLimit(): int - { - // Base: 0 sub-pages (free tier) - $base = 0; - - // Add from boosts - $boostPages = $this->boosts() - ->where('feature_code', 'webpage.sub_pages') - ->where('status', Boost::STATUS_ACTIVE) - ->sum('limit_value'); - - return $base + (int) $boostPages; - } - - /** - * Get the total sub-pages count across all root pages. - */ - public function getSubPagesCount(): int - { - return $this->pages()->subPages()->count(); - } - - /** - * Check if user can create more sub-pages. - */ - public function canCreateSubPage(): bool - { - return $this->getSubPagesCount() < $this->getSubPagesLimit(); - } - - /** - * Get remaining sub-page slots. - */ - public function remainingSubPageSlots(): int - { - return max(0, $this->getSubPagesLimit() - $this->getSubPagesCount()); - } - - // ───────────────────────────────────────────────────────────────────────── - // Referral Relationships - // ───────────────────────────────────────────────────────────────────────── - - /** - * Get the user who referred this user. - */ - public function referrer(): BelongsTo - { - return $this->belongsTo(self::class, 'referred_by'); - } - - /** - * Get all users referred by this user. - */ - public function referrals(): HasMany - { - return $this->hasMany(self::class, 'referred_by'); - } - - /** - * Check if user has activated referrals. - */ - public function hasActivatedReferrals(): bool - { - return $this->referral_activated_at !== null; - } - - /** - * Activate referrals for this user. - */ - public function activateReferrals(): void - { - if (! $this->hasActivatedReferrals()) { - $this->update(['referral_activated_at' => now()]); - } - } - - /** - * Get referral ranking (1-based position among all users by referral count). - */ - public function getReferralRank(): int - { - if ($this->referral_count === 0) { - return 0; // Not ranked if no referrals - } - - return self::where('referral_count', '>', $this->referral_count)->count() + 1; - } - - // ───────────────────────────────────────────────────────────────────────── - // Orderable Interface - // ───────────────────────────────────────────────────────────────────────── - - public function getBillingName(): ?string - { - return $this->name; - } - - public function getBillingEmail(): string - { - return $this->email; - } - - public function getBillingAddress(): ?array - { - return null; - } - - public function getTaxCountry(): ?string - { - return null; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Models/UserToken.php b/packages/core-php/src/Mod/Tenant/Models/UserToken.php deleted file mode 100644 index 8f986e8..0000000 --- a/packages/core-php/src/Mod/Tenant/Models/UserToken.php +++ /dev/null @@ -1,126 +0,0 @@ - - */ - protected $fillable = [ - 'name', - 'token', - 'expires_at', - ]; - - /** - * The attributes that should be cast. - * - * @var array - */ - protected $casts = [ - 'last_used_at' => 'datetime', - 'expires_at' => 'datetime', - ]; - - /** - * The attributes that should be hidden for serialization. - * - * @var array - */ - protected $hidden = [ - 'token', - ]; - - /** - * Find a token by its plain-text value. - * - * Tokens are stored as SHA-256 hashes, so we hash the input - * before querying the database. - * - * @param string $token Plain-text token value - */ - public static function findToken(string $token): ?UserToken - { - return static::where('token', hash('sha256', $token))->first(); - } - - /** - * Get the user that owns the token. - * - * @return BelongsTo - */ - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } - - /** - * Determine if the token has expired. - */ - public function isExpired(): bool - { - return $this->expires_at && $this->expires_at->isPast(); - } - - /** - * Determine if the token is valid (not expired). - */ - public function isValid(): bool - { - return ! $this->isExpired(); - } - - /** - * Update the last used timestamp. - * - * Preserves the hasModifiedRecords state to avoid triggering - * model events when only updating usage tracking. - */ - public function recordUsage(): void - { - $connection = $this->getConnection(); - - // Preserve modification state if the connection supports it - if (method_exists($connection, 'hasModifiedRecords') && - method_exists($connection, 'setRecordModificationState')) { - - $hasModifiedRecords = $connection->hasModifiedRecords(); - - $this->forceFill(['last_used_at' => now()])->save(); - - $connection->setRecordModificationState($hasModifiedRecords); - } else { - // Fallback for connections that don't support modification state - $this->forceFill(['last_used_at' => now()])->save(); - } - } -} diff --git a/packages/core-php/src/Mod/Tenant/Models/UserTwoFactorAuth.php b/packages/core-php/src/Mod/Tenant/Models/UserTwoFactorAuth.php deleted file mode 100644 index f969303..0000000 --- a/packages/core-php/src/Mod/Tenant/Models/UserTwoFactorAuth.php +++ /dev/null @@ -1,38 +0,0 @@ - 'collection', - 'confirmed_at' => 'datetime', - ]; - - /** - * Get the user this 2FA belongs to. - */ - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Models/WaitlistEntry.php b/packages/core-php/src/Mod/Tenant/Models/WaitlistEntry.php deleted file mode 100644 index 092bb96..0000000 --- a/packages/core-php/src/Mod/Tenant/Models/WaitlistEntry.php +++ /dev/null @@ -1,126 +0,0 @@ - 'datetime', - 'registered_at' => 'datetime', - ]; - - /** - * Get the user this waitlist entry converted to. - */ - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } - - /** - * Scope to entries that haven't been invited yet. - */ - public function scopePending($query) - { - return $query->whereNull('invited_at'); - } - - /** - * Scope to entries that have been invited but not registered. - */ - public function scopeInvited($query) - { - return $query->whereNotNull('invited_at')->whereNull('registered_at'); - } - - /** - * Scope to entries that have converted to users. - */ - public function scopeConverted($query) - { - return $query->whereNotNull('registered_at'); - } - - /** - * Generate a unique invite code for this entry. - */ - public function generateInviteCode(): string - { - $code = strtoupper(Str::random(8)); - - // Ensure uniqueness - while (static::where('invite_code', $code)->exists()) { - $code = strtoupper(Str::random(8)); - } - - $this->update([ - 'invite_code' => $code, - 'invited_at' => now(), - 'bonus_code' => 'LAUNCH50', // Default launch bonus - ]); - - return $code; - } - - /** - * Mark this entry as registered. - */ - public function markAsRegistered(User $user): void - { - $this->update([ - 'registered_at' => now(), - 'user_id' => $user->id, - ]); - } - - /** - * Check if this entry has been invited. - */ - public function isInvited(): bool - { - return $this->invited_at !== null; - } - - /** - * Check if this entry has converted to a user. - */ - public function hasConverted(): bool - { - return $this->registered_at !== null; - } - - /** - * Find entry by invite code. - */ - public static function findByInviteCode(string $code): ?self - { - return static::where('invite_code', strtoupper($code))->first(); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Models/Workspace.php b/packages/core-php/src/Mod/Tenant/Models/Workspace.php deleted file mode 100644 index 1612846..0000000 --- a/packages/core-php/src/Mod/Tenant/Models/Workspace.php +++ /dev/null @@ -1,834 +0,0 @@ - 'array', - 'is_active' => 'boolean', - 'wp_connector_enabled' => 'boolean', - 'wp_connector_verified_at' => 'datetime', - 'wp_connector_last_sync' => 'datetime', - 'wp_connector_config' => 'array', - 'tax_exempt' => 'boolean', - ]; - - /** - * Hidden attributes (sensitive data). - */ - protected $hidden = [ - 'wp_connector_secret', - ]; - - /** - * Get the users that have access to this workspace. - */ - public function users(): BelongsToMany - { - return $this->belongsToMany(User::class, 'user_workspace') - ->withPivot(['role', 'is_default', 'team_id', 'custom_permissions', 'joined_at', 'invited_by']) - ->withTimestamps(); - } - - /** - * Get workspace members (via the enhanced pivot model). - */ - public function members(): HasMany - { - return $this->hasMany(WorkspaceMember::class); - } - - /** - * Get teams defined for this workspace. - */ - public function teams(): HasMany - { - return $this->hasMany(WorkspaceTeam::class); - } - - /** - * Get the workspace owner (user with 'owner' role). - */ - public function owner(): ?User - { - return $this->users() - ->wherePivot('role', 'owner') - ->first(); - } - - /** - * Get the default team for new members. - */ - public function defaultTeam(): ?WorkspaceTeam - { - return $this->teams()->where('is_default', true)->first(); - } - - /** - * Active package assignments for this workspace. - */ - public function workspacePackages(): HasMany - { - return $this->hasMany(WorkspacePackage::class); - } - - /** - * Get pending invitations for this workspace. - */ - public function invitations(): HasMany - { - return $this->hasMany(WorkspaceInvitation::class); - } - - /** - * Get pending invitations only. - */ - public function pendingInvitations(): HasMany - { - return $this->invitations()->pending(); - } - - // ───────────────────────────────────────────────────────────────────────── - // Namespace Relationships - // ───────────────────────────────────────────────────────────────────────── - - /** - * Get all namespaces owned by this workspace. - */ - public function namespaces(): MorphMany - { - return $this->morphMany(Namespace_::class, 'owner'); - } - - /** - * Get the workspace's default namespace. - */ - public function defaultNamespace(): ?Namespace_ - { - return $this->namespaces() - ->where('is_default', true) - ->active() - ->first() - ?? $this->namespaces()->active()->ordered()->first(); - } - - /** - * The package definitions assigned to this workspace. - */ - public function packages(): BelongsToMany - { - return $this->belongsToMany(Package::class, 'entitlement_workspace_packages', 'workspace_id', 'package_id') - ->withPivot(['status', 'starts_at', 'expires_at', 'metadata']) - ->withTimestamps(); - } - - /** - * Get a setting from the settings JSON column. - */ - public function getSetting(string $key, mixed $default = null): mixed - { - return data_get($this->settings, $key, $default); - } - - /** - * Active boosts for this workspace. - */ - public function boosts(): HasMany - { - return $this->hasMany(Boost::class); - } - - /** - * Usage records for this workspace. - */ - public function usageRecords(): HasMany - { - return $this->hasMany(UsageRecord::class); - } - - /** - * Entitlement logs for this workspace. - */ - public function entitlementLogs(): HasMany - { - return $this->hasMany(EntitlementLog::class); - } - - /** - * Usage alert history for this workspace. - */ - public function usageAlerts(): HasMany - { - return $this->hasMany(UsageAlertHistory::class); - } - - /** - * Get active (unresolved) usage alerts for this workspace. - */ - public function activeUsageAlerts(): HasMany - { - return $this->usageAlerts()->whereNull('resolved_at'); - } - - // SocialHost Relationships (Native) - - /** - * Get social accounts for this workspace. - */ - public function socialAccounts(): HasMany - { - return $this->hasMany(\Core\Mod\Social\Models\Account::class); - } - - /** - * Get social posts for this workspace. - */ - public function socialPosts(): HasMany - { - return $this->hasMany(\Core\Mod\Social\Models\Post::class); - } - - /** - * Get social media templates for this workspace. - */ - public function socialTemplates(): HasMany - { - return $this->hasMany(\Core\Mod\Social\Models\Template::class); - } - - /** - * Get social media files for this workspace. - */ - public function socialMedia(): HasMany - { - return $this->hasMany(\Core\Mod\Social\Models\Media::class); - } - - /** - * Get social hashtag groups for this workspace. - */ - public function socialHashtagGroups(): HasMany - { - return $this->hasMany(\Core\Mod\Social\Models\HashtagGroup::class); - } - - /** - * Get social webhooks for this workspace. - */ - public function socialWebhooks(): HasMany - { - return $this->hasMany(\Core\Mod\Social\Models\Webhook::class); - } - - /** - * Get social analytics for this workspace. - */ - public function socialAnalytics(): HasMany - { - return $this->hasMany(\Core\Mod\Social\Models\Analytics::class); - } - - /** - * Get social variables for this workspace. - */ - public function socialVariables(): HasMany - { - return $this->hasMany(\Core\Mod\Social\Models\Variable::class); - } - - /** - * Get posting schedule for this workspace. - */ - public function socialPostingSchedule(): HasMany - { - return $this->hasMany(\Core\Mod\Social\Models\PostingSchedule::class); - } - - /** - * Get imported posts for this workspace. - */ - public function socialImportedPosts(): HasMany - { - return $this->hasMany(\Core\Mod\Social\Models\ImportedPost::class); - } - - /** - * Get social metrics for this workspace. - */ - public function socialMetrics(): HasMany - { - return $this->hasMany(\Core\Mod\Social\Models\Metric::class); - } - - /** - * Get audience data for this workspace. - */ - public function socialAudience(): HasMany - { - return $this->hasMany(\Core\Mod\Social\Models\Audience::class); - } - - /** - * Get Facebook insights for this workspace. - */ - public function socialFacebookInsights(): HasMany - { - return $this->hasMany(\Core\Mod\Social\Models\FacebookInsight::class); - } - - /** - * Get Instagram insights for this workspace. - */ - public function socialInstagramInsights(): HasMany - { - return $this->hasMany(\Core\Mod\Social\Models\InstagramInsight::class); - } - - /** - * Get Pinterest analytics for this workspace. - */ - public function socialPinterestAnalytics(): HasMany - { - return $this->hasMany(\Core\Mod\Social\Models\PinterestAnalytic::class); - } - - /** - * Check if this workspace has SocialHost enabled (has connected social accounts). - */ - public function hasSocialHost(): bool - { - return $this->socialAccounts()->exists(); - } - - /** - * Get count of connected social accounts. - */ - public function socialAccountsCount(): int - { - return $this->socialAccounts()->count(); - } - - // NOTE: Bio service relationships (bioPages, bioProjects, bioDomains, bioPixels) - // have been moved to the Host UK app's Mod\Bio module. - - // AnalyticsHost Relationships - - /** - * Get analytics websites for this workspace (AnalyticsHost). - */ - public function analyticsSites(): HasMany - { - return $this->hasMany(\Core\Mod\Analytics\Models\Website::class); - } - - /** - * Get social analytics websites for this workspace (legacy, for SocialHost analytics). - */ - public function socialAnalyticsWebsites(): HasMany - { - return $this->hasMany(\Core\Mod\Analytics\Models\AnalyticsWebsite::class); - } - - /** - * Get analytics goals for this workspace (AnalyticsHost). - */ - public function analyticsGoals(): HasMany - { - return $this->hasMany(\Core\Mod\Analytics\Models\Goal::class); - } - - /** - * Get social analytics goals for this workspace (legacy, for SocialHost analytics). - */ - public function socialAnalyticsGoals(): HasMany - { - return $this->hasMany(\Core\Mod\Analytics\Models\AnalyticsGoal::class); - } - - // TrustHost Relationships - - /** - * Get social proof campaigns (TrustHost widgets) for this workspace. - */ - public function trustWidgets(): HasMany - { - return $this->hasMany(\Core\Mod\Trust\Models\Campaign::class); - } - - /** - * Get social proof notifications for this workspace. - */ - public function trustNotifications(): HasMany - { - return $this->hasMany(\Core\Mod\Trust\Models\Notification::class); - } - - // NotifyHost Relationships - - /** - * Get push notification websites for this workspace. - */ - public function notificationSites(): HasMany - { - return $this->hasMany(\Core\Mod\Notify\Models\PushWebsite::class); - } - - /** - * Get push campaigns for this workspace. - */ - public function pushCampaigns(): HasMany - { - return $this->hasMany(\Core\Mod\Notify\Models\PushCampaign::class); - } - - /** - * Get push flows for this workspace. - */ - public function pushFlows(): HasMany - { - return $this->hasMany(\Core\Mod\Notify\Models\PushFlow::class); - } - - /** - * Get push segments for this workspace. - */ - public function pushSegments(): HasMany - { - return $this->hasMany(\Core\Mod\Notify\Models\PushSegment::class); - } - - // API & Webhooks Relationships - - /** - * Get API keys for this workspace. - */ - public function apiKeys(): HasMany - { - return $this->hasMany(\Core\Mod\Api\Models\ApiKey::class); - } - - /** - * Get webhook endpoints for this workspace. - */ - public function webhookEndpoints(): HasMany - { - return $this->hasMany(\Core\Mod\Api\Models\WebhookEndpoint::class); - } - - /** - * Get entitlement webhooks for this workspace. - */ - public function entitlementWebhooks(): HasMany - { - return $this->hasMany(EntitlementWebhook::class); - } - - // Trees for Agents Relationships - - /** - * Get tree plantings for this workspace. - */ - public function treePlantings(): HasMany - { - return $this->hasMany(\Core\Mod\Trees\Models\TreePlanting::class); - } - - /** - * Get total trees planted for this workspace. - */ - public function treesPlanted(): int - { - return $this->treePlantings() - ->whereIn('status', ['confirmed', 'planted']) - ->sum('trees'); - } - - /** - * Get trees planted this year for this workspace. - */ - public function treesThisYear(): int - { - return $this->treePlantings() - ->whereIn('status', ['confirmed', 'planted']) - ->whereYear('created_at', now()->year) - ->sum('trees'); - } - - // Content & Media Relationships - - /** - * Get content items for this workspace. - */ - public function contentItems(): HasMany - { - return $this->hasMany(\Core\Mod\Content\Models\ContentItem::class); - } - - /** - * Get content authors for this workspace. - */ - public function contentAuthors(): HasMany - { - return $this->hasMany(\Core\Mod\Content\Models\ContentAuthor::class); - } - - // Commerce Relationships (defined in app Mod\Commerce) - - /** - * Get subscriptions for this workspace. - */ - public function subscriptions(): HasMany - { - return $this->hasMany(\Mod\Commerce\Models\Subscription::class); - } - - /** - * Get invoices for this workspace. - */ - public function invoices(): HasMany - { - return $this->hasMany(\Mod\Commerce\Models\Invoice::class); - } - - /** - * Get payment methods for this workspace. - */ - public function paymentMethods(): HasMany - { - return $this->hasMany(\Mod\Commerce\Models\PaymentMethod::class); - } - - /** - * Get orders for this workspace. - */ - public function orders(): MorphMany - { - return $this->morphMany(\Mod\Commerce\Models\Order::class, 'orderable'); - } - - // Helper Methods - - /** - * Get the currently active workspace from request context. - * - * Returns the Workspace model instance (not array). - */ - public static function current(): ?self - { - // Try to get from request attributes (set by middleware) - if (request()->attributes->has('workspace_model')) { - return request()->attributes->get('workspace_model'); - } - - // Try to get from authenticated user's default workspace - if (auth()->check() && auth()->user() instanceof \Core\Mod\Tenant\Models\User) { - return auth()->user()->defaultHostWorkspace(); - } - - // Try to resolve from subdomain via WorkspaceService - $workspaceService = app(\App\Services\WorkspaceService::class); - $slug = $workspaceService->currentSlug(); - - return static::where('slug', $slug)->first(); - } - - /** - * Check if workspace can use a feature. - */ - public function can(string $featureCode, int $quantity = 1): EntitlementResult - { - return app(EntitlementService::class)->can($this, $featureCode, $quantity); - } - - /** - * Record usage of a feature. - */ - public function recordUsage(string $featureCode, int $quantity = 1, ?User $user = null, ?array $metadata = null): UsageRecord - { - return app(EntitlementService::class)->recordUsage($this, $featureCode, $quantity, $user, $metadata); - } - - /** - * Get usage summary for all features. - */ - public function getUsageSummary(): \Illuminate\Support\Collection - { - return app(EntitlementService::class)->getUsageSummary($this); - } - - /** - * Check if workspace has a specific package. - */ - public function hasPackage(string $packageCode): bool - { - return $this->workspacePackages() - ->whereHas('package', fn ($q) => $q->where('code', $packageCode)) - ->active() - ->exists(); - } - - /** - * Check if workspace has Apollo tier. - */ - public function isApollo(): bool - { - return $this->can('tier.apollo')->isAllowed(); - } - - /** - * Check if workspace has Hades tier. - */ - public function isHades(): bool - { - return $this->can('tier.hades')->isAllowed(); - } - - // ───────────────────────────────────────────────────────────────────────── - // Workspace Invitations - // ───────────────────────────────────────────────────────────────────────── - - /** - * Invite a user to this workspace by email. - * - * @param string $email The email address to invite - * @param string $role The role to assign (owner, admin, member) - * @param User|null $invitedBy The user sending the invitation - * @param int $expiresInDays Number of days until invitation expires - */ - public function invite(string $email, string $role = 'member', ?User $invitedBy = null, int $expiresInDays = 7): WorkspaceInvitation - { - // Check if there's already a pending invitation for this email - $existing = $this->invitations() - ->where('email', $email) - ->pending() - ->first(); - - if ($existing) { - // Update existing invitation - $existing->update([ - 'role' => $role, - 'invited_by' => $invitedBy?->id, - 'expires_at' => now()->addDays($expiresInDays), - ]); - - return $existing; - } - - // Create new invitation - $invitation = $this->invitations()->create([ - 'email' => $email, - 'token' => WorkspaceInvitation::generateToken(), - 'role' => $role, - 'invited_by' => $invitedBy?->id, - 'expires_at' => now()->addDays($expiresInDays), - ]); - - // Send notification - $invitation->notify(new \Core\Mod\Tenant\Notifications\WorkspaceInvitationNotification($invitation)); - - return $invitation; - } - - /** - * Accept an invitation to this workspace using a token. - * - * @param string $token The invitation token - * @param User $user The user accepting the invitation - * @return bool True if accepted, false if invalid/expired - */ - public static function acceptInvitation(string $token, User $user): bool - { - $invitation = WorkspaceInvitation::findPendingByToken($token); - - if (! $invitation) { - return false; - } - - return $invitation->accept($user); - } - - /** - * Get the external CMS URL for this workspace. - */ - public function getCmsUrlAttribute(): string - { - return 'https://'.$this->domain; - } - - /** - * Scope to only active workspaces. - */ - public function scopeActive($query) - { - return $query->where('is_active', true); - } - - /** - * Scope to order by sort order. - */ - public function scopeOrdered($query) - { - return $query->orderBy('sort_order'); - } - - /** - * Convert to array format used by WorkspaceService. - */ - public function toServiceArray(): array - { - return [ - 'name' => $this->name, - 'slug' => $this->slug, - 'domain' => $this->domain, - 'icon' => $this->icon, - 'color' => $this->color, - 'description' => $this->description, - ]; - } - - /** - * Generate a new webhook secret for the WP connector. - */ - public function generateWpConnectorSecret(): string - { - $secret = bin2hex(random_bytes(32)); - $this->update(['wp_connector_secret' => $secret]); - - return $secret; - } - - /** - * Enable the WP connector with a URL. - */ - public function enableWpConnector(string $url): self - { - $this->update([ - 'wp_connector_enabled' => true, - 'wp_connector_url' => rtrim($url, '/'), - 'wp_connector_secret' => $this->wp_connector_secret ?? bin2hex(random_bytes(32)), - ]); - - return $this; - } - - /** - * Disable the WP connector. - */ - public function disableWpConnector(): self - { - $this->update([ - 'wp_connector_enabled' => false, - 'wp_connector_verified_at' => null, - ]); - - return $this; - } - - /** - * Mark the WP connector as verified. - */ - public function markWpConnectorVerified(): self - { - $this->update(['wp_connector_verified_at' => now()]); - - return $this; - } - - /** - * Update the last sync timestamp. - */ - public function touchWpConnectorSync(): self - { - $this->update(['wp_connector_last_sync' => now()]); - - return $this; - } - - /** - * Check if the WP connector is active and verified. - */ - public function hasActiveWpConnector(): bool - { - return $this->wp_connector_enabled - && ! empty($this->wp_connector_url) - && ! empty($this->wp_connector_secret); - } - - /** - * Get the webhook URL that external CMS should POST to. - */ - public function getWpConnectorWebhookUrlAttribute(): string - { - return route('api.webhook.content').'?workspace='.$this->slug; - } - - /** - * Validate an incoming webhook signature. - */ - public function validateWebhookSignature(string $payload, string $signature): bool - { - if (empty($this->wp_connector_secret)) { - return false; - } - - $expected = hash_hmac('sha256', $payload, $this->wp_connector_secret); - - return hash_equals($expected, $signature); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Models/WorkspaceInvitation.php b/packages/core-php/src/Mod/Tenant/Models/WorkspaceInvitation.php deleted file mode 100644 index a863a82..0000000 --- a/packages/core-php/src/Mod/Tenant/Models/WorkspaceInvitation.php +++ /dev/null @@ -1,168 +0,0 @@ - 'datetime', - 'accepted_at' => 'datetime', - ]; - - /** - * Get the workspace this invitation is for. - */ - public function workspace(): BelongsTo - { - return $this->belongsTo(Workspace::class); - } - - /** - * Get the user who sent this invitation. - */ - public function inviter(): BelongsTo - { - return $this->belongsTo(User::class, 'invited_by'); - } - - /** - * Scope to pending invitations (not accepted, not expired). - */ - public function scopePending($query) - { - return $query->whereNull('accepted_at') - ->where('expires_at', '>', now()); - } - - /** - * Scope to expired invitations. - */ - public function scopeExpired($query) - { - return $query->whereNull('accepted_at') - ->where('expires_at', '<=', now()); - } - - /** - * Scope to accepted invitations. - */ - public function scopeAccepted($query) - { - return $query->whereNotNull('accepted_at'); - } - - /** - * Check if invitation is pending (not accepted and not expired). - */ - public function isPending(): bool - { - return $this->accepted_at === null && $this->expires_at->isFuture(); - } - - /** - * Check if invitation has expired. - */ - public function isExpired(): bool - { - return $this->accepted_at === null && $this->expires_at->isPast(); - } - - /** - * Check if invitation has been accepted. - */ - public function isAccepted(): bool - { - return $this->accepted_at !== null; - } - - /** - * Generate a unique token for this invitation. - */ - public static function generateToken(): string - { - do { - $token = Str::random(64); - } while (static::where('token', $token)->exists()); - - return $token; - } - - /** - * Find invitation by token. - */ - public static function findByToken(string $token): ?self - { - return static::where('token', $token)->first(); - } - - /** - * Find pending invitation by token. - */ - public static function findPendingByToken(string $token): ?self - { - return static::where('token', $token)->pending()->first(); - } - - /** - * Accept the invitation for a user. - */ - public function accept(User $user): bool - { - if (! $this->isPending()) { - return false; - } - - // Check if user already belongs to this workspace - if ($this->workspace->users()->where('user_id', $user->id)->exists()) { - // Mark as accepted but don't add again - $this->update(['accepted_at' => now()]); - - return true; - } - - // Add user to workspace with the invited role - $this->workspace->users()->attach($user->id, [ - 'role' => $this->role, - 'is_default' => false, - ]); - - // Mark invitation as accepted - $this->update(['accepted_at' => now()]); - - return true; - } - - /** - * Get the notification routing for mail. - */ - public function routeNotificationForMail(): string - { - return $this->email; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Models/WorkspaceMember.php b/packages/core-php/src/Mod/Tenant/Models/WorkspaceMember.php deleted file mode 100644 index 6d49df7..0000000 --- a/packages/core-php/src/Mod/Tenant/Models/WorkspaceMember.php +++ /dev/null @@ -1,377 +0,0 @@ - 'array', - 'is_default' => 'boolean', - 'joined_at' => 'datetime', - ]; - - // ───────────────────────────────────────────────────────────────────────── - // Role Constants (legacy, for backwards compatibility) - // ───────────────────────────────────────────────────────────────────────── - - public const ROLE_OWNER = 'owner'; - - public const ROLE_ADMIN = 'admin'; - - public const ROLE_MEMBER = 'member'; - - // ───────────────────────────────────────────────────────────────────────── - // Relationships - // ───────────────────────────────────────────────────────────────────────── - - /** - * Get the user for this membership. - */ - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } - - /** - * Get the workspace for this membership. - */ - public function workspace(): BelongsTo - { - return $this->belongsTo(Workspace::class); - } - - /** - * Get the team for this membership. - */ - public function team(): BelongsTo - { - return $this->belongsTo(WorkspaceTeam::class, 'team_id'); - } - - /** - * Get the user who invited this member. - */ - public function inviter(): BelongsTo - { - return $this->belongsTo(User::class, 'invited_by'); - } - - // ───────────────────────────────────────────────────────────────────────── - // Scopes - // ───────────────────────────────────────────────────────────────────────── - - /** - * Scope to a specific workspace. - */ - public function scopeForWorkspace($query, Workspace|int $workspace) - { - $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; - - return $query->where('workspace_id', $workspaceId); - } - - /** - * Scope to a specific user. - */ - public function scopeForUser($query, User|int $user) - { - $userId = $user instanceof User ? $user->id : $user; - - return $query->where('user_id', $userId); - } - - /** - * Scope to members with a specific role. - */ - public function scopeWithRole($query, string $role) - { - return $query->where('role', $role); - } - - /** - * Scope to members in a specific team. - */ - public function scopeInTeam($query, WorkspaceTeam|int $team) - { - $teamId = $team instanceof WorkspaceTeam ? $team->id : $team; - - return $query->where('team_id', $teamId); - } - - /** - * Scope to owners only. - */ - public function scopeOwners($query) - { - return $query->where('role', self::ROLE_OWNER); - } - - // ───────────────────────────────────────────────────────────────────────── - // Permission Helpers - // ───────────────────────────────────────────────────────────────────────── - - /** - * Get all effective permissions for this member. - * - * Merges team permissions with custom permission overrides. - */ - public function getEffectivePermissions(): array - { - // Start with team permissions - $permissions = $this->team?->permissions ?? []; - - // Merge custom permissions (overrides) - $customPermissions = $this->custom_permissions ?? []; - - // Custom permissions can grant (+permission) or revoke (-permission) - foreach ($customPermissions as $permission) { - if (str_starts_with($permission, '-')) { - // Remove permission - $toRemove = substr($permission, 1); - $permissions = array_values(array_filter( - $permissions, - fn ($p) => $p !== $toRemove - )); - } elseif (str_starts_with($permission, '+')) { - // Add permission (explicit add) - $toAdd = substr($permission, 1); - if (! in_array($toAdd, $permissions, true)) { - $permissions[] = $toAdd; - } - } else { - // Treat as add if no prefix - if (! in_array($permission, $permissions, true)) { - $permissions[] = $permission; - } - } - } - - // Legacy fallback: if no team, derive from role - if (! $this->team_id) { - $rolePermissions = match ($this->role) { - self::ROLE_OWNER => WorkspaceTeam::getDefaultPermissionsFor(WorkspaceTeam::TEAM_OWNER), - self::ROLE_ADMIN => WorkspaceTeam::getDefaultPermissionsFor(WorkspaceTeam::TEAM_ADMIN), - default => WorkspaceTeam::getDefaultPermissionsFor(WorkspaceTeam::TEAM_MEMBER), - }; - $permissions = array_unique(array_merge($permissions, $rolePermissions)); - } - - return array_values(array_unique($permissions)); - } - - /** - * Check if this member has a specific permission. - */ - public function hasPermission(string $permission): bool - { - $permissions = $this->getEffectivePermissions(); - - // Check for exact match - if (in_array($permission, $permissions, true)) { - return true; - } - - // Check for wildcard permissions - foreach ($permissions as $perm) { - if (str_ends_with($perm, '.*')) { - $prefix = substr($perm, 0, -1); - if (str_starts_with($permission, $prefix)) { - return true; - } - } - } - - return false; - } - - /** - * Check if this member has any of the given permissions. - */ - public function hasAnyPermission(array $permissions): bool - { - foreach ($permissions as $permission) { - if ($this->hasPermission($permission)) { - return true; - } - } - - return false; - } - - /** - * Check if this member has all of the given permissions. - */ - public function hasAllPermissions(array $permissions): bool - { - foreach ($permissions as $permission) { - if (! $this->hasPermission($permission)) { - return false; - } - } - - return true; - } - - /** - * Add a custom permission override. - */ - public function grantCustomPermission(string $permission): self - { - $custom = $this->custom_permissions ?? []; - - // Remove any revocation of this permission - $custom = array_filter($custom, fn ($p) => $p !== '-'.$permission); - - // Add the permission if not already present - if (! in_array($permission, $custom, true) && ! in_array('+'.$permission, $custom, true)) { - $custom[] = '+'.$permission; - } - - $this->update(['custom_permissions' => array_values($custom)]); - - return $this; - } - - /** - * Revoke a permission via custom override. - */ - public function revokeCustomPermission(string $permission): self - { - $custom = $this->custom_permissions ?? []; - - // Remove any grant of this permission - $custom = array_filter($custom, fn ($p) => $p !== $permission && $p !== '+'.$permission); - - // Add revocation - if (! in_array('-'.$permission, $custom, true)) { - $custom[] = '-'.$permission; - } - - $this->update(['custom_permissions' => array_values($custom)]); - - return $this; - } - - /** - * Clear all custom permission overrides. - */ - public function clearCustomPermissions(): self - { - $this->update(['custom_permissions' => null]); - - return $this; - } - - // ───────────────────────────────────────────────────────────────────────── - // Helper Methods - // ───────────────────────────────────────────────────────────────────────── - - /** - * Check if this member is the workspace owner. - */ - public function isOwner(): bool - { - return $this->role === self::ROLE_OWNER - || $this->team?->slug === WorkspaceTeam::TEAM_OWNER; - } - - /** - * Check if this member is an admin. - */ - public function isAdmin(): bool - { - return $this->isOwner() - || $this->role === self::ROLE_ADMIN - || $this->team?->slug === WorkspaceTeam::TEAM_ADMIN; - } - - /** - * Assign this member to a team. - */ - public function assignToTeam(WorkspaceTeam|int $team): self - { - $teamId = $team instanceof WorkspaceTeam ? $team->id : $team; - - $this->update(['team_id' => $teamId]); - - return $this; - } - - /** - * Remove this member from their team. - */ - public function removeFromTeam(): self - { - $this->update(['team_id' => null]); - - return $this; - } - - /** - * Get the display name for this membership (team name or role). - */ - public function getDisplayRole(): string - { - if ($this->team) { - return $this->team->name; - } - - return match ($this->role) { - self::ROLE_OWNER => 'Owner', - self::ROLE_ADMIN => 'Admin', - default => 'Member', - }; - } - - /** - * Get the colour for this membership's role badge. - */ - public function getRoleColour(): string - { - if ($this->team) { - return $this->team->colour; - } - - return match ($this->role) { - self::ROLE_OWNER => 'violet', - self::ROLE_ADMIN => 'blue', - default => 'zinc', - }; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Models/WorkspacePackage.php b/packages/core-php/src/Mod/Tenant/Models/WorkspacePackage.php deleted file mode 100644 index 629073b..0000000 --- a/packages/core-php/src/Mod/Tenant/Models/WorkspacePackage.php +++ /dev/null @@ -1,164 +0,0 @@ - 'datetime', - 'expires_at' => 'datetime', - 'billing_cycle_anchor' => 'datetime', - 'metadata' => 'array', - ]; - - /** - * Status constants. - */ - public const STATUS_ACTIVE = 'active'; - - public const STATUS_SUSPENDED = 'suspended'; - - public const STATUS_CANCELLED = 'cancelled'; - - public const STATUS_EXPIRED = 'expired'; - - /** - * The workspace this package belongs to. - */ - public function workspace(): BelongsTo - { - return $this->belongsTo(Workspace::class); - } - - /** - * The package definition. - */ - public function package(): BelongsTo - { - return $this->belongsTo(Package::class, 'package_id'); - } - - /** - * Scope to active assignments. - */ - public function scopeActive($query) - { - return $query->where('status', self::STATUS_ACTIVE); - } - - /** - * Scope to non-expired assignments. - */ - public function scopeNotExpired($query) - { - return $query->where(function ($q) { - $q->whereNull('expires_at') - ->orWhere('expires_at', '>', now()); - }); - } - - /** - * Check if this assignment is currently active. - */ - public function isActive(): bool - { - if ($this->status !== self::STATUS_ACTIVE) { - return false; - } - - if ($this->starts_at && $this->starts_at->isFuture()) { - return false; - } - - if ($this->expires_at && $this->expires_at->isPast()) { - return false; - } - - return true; - } - - /** - * Check if this assignment is on grace period. - */ - public function onGracePeriod(): bool - { - return $this->status === self::STATUS_CANCELLED - && $this->expires_at - && $this->expires_at->isFuture(); - } - - /** - * Get the current billing cycle start date. - */ - public function getCurrentCycleStart(): Carbon - { - if (! $this->billing_cycle_anchor) { - return $this->starts_at ?? $this->created_at; - } - - $anchor = $this->billing_cycle_anchor->copy(); - $now = now(); - - // Find the most recent cycle start - while ($anchor->addMonth()->lte($now)) { - // Keep advancing until we pass now - } - - return $anchor->subMonth(); - } - - /** - * Get the current billing cycle end date. - */ - public function getCurrentCycleEnd(): Carbon - { - return $this->getCurrentCycleStart()->copy()->addMonth(); - } - - /** - * Suspend this assignment. - */ - public function suspend(): void - { - $this->update(['status' => self::STATUS_SUSPENDED]); - } - - /** - * Reactivate this assignment. - */ - public function reactivate(): void - { - $this->update(['status' => self::STATUS_ACTIVE]); - } - - /** - * Cancel this assignment. - */ - public function cancel(?Carbon $endsAt = null): void - { - $this->update([ - 'status' => self::STATUS_CANCELLED, - 'expires_at' => $endsAt ?? $this->getCurrentCycleEnd(), - ]); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Models/WorkspaceTeam.php b/packages/core-php/src/Mod/Tenant/Models/WorkspaceTeam.php deleted file mode 100644 index 7e11644..0000000 --- a/packages/core-php/src/Mod/Tenant/Models/WorkspaceTeam.php +++ /dev/null @@ -1,517 +0,0 @@ - 'array', - 'is_default' => 'boolean', - 'is_system' => 'boolean', - 'sort_order' => 'integer', - ]; - - // ───────────────────────────────────────────────────────────────────────── - // Boot - // ───────────────────────────────────────────────────────────────────────── - - protected static function boot(): void - { - parent::boot(); - - static::creating(function (self $team) { - if (empty($team->slug)) { - $team->slug = Str::slug($team->name); - } - }); - } - - // ───────────────────────────────────────────────────────────────────────── - // Relationships - // ───────────────────────────────────────────────────────────────────────── - - /** - * Get members assigned to this team via the pivot. - */ - public function members(): HasMany - { - return $this->hasMany(WorkspaceMember::class, 'team_id'); - } - - // ───────────────────────────────────────────────────────────────────────── - // Scopes - // ───────────────────────────────────────────────────────────────────────── - - /** - * Scope to default teams only. - */ - public function scopeDefault($query) - { - return $query->where('is_default', true); - } - - /** - * Scope to system teams only. - */ - public function scopeSystem($query) - { - return $query->where('is_system', true); - } - - /** - * Scope to custom (non-system) teams only. - */ - public function scopeCustom($query) - { - return $query->where('is_system', false); - } - - /** - * Scope ordered by sort_order. - */ - public function scopeOrdered($query) - { - return $query->orderBy('sort_order'); - } - - // ───────────────────────────────────────────────────────────────────────── - // Permission Helpers - // ───────────────────────────────────────────────────────────────────────── - - /** - * Check if this team has a specific permission. - */ - public function hasPermission(string $permission): bool - { - $permissions = $this->permissions ?? []; - - // Check for exact match - if (in_array($permission, $permissions, true)) { - return true; - } - - // Check for wildcard permissions (e.g., 'bio.*' matches 'bio.read') - foreach ($permissions as $perm) { - if (str_ends_with($perm, '.*')) { - $prefix = substr($perm, 0, -1); // Remove the '*' - if (str_starts_with($permission, $prefix)) { - return true; - } - } - } - - return false; - } - - /** - * Check if this team has any of the given permissions. - */ - public function hasAnyPermission(array $permissions): bool - { - foreach ($permissions as $permission) { - if ($this->hasPermission($permission)) { - return true; - } - } - - return false; - } - - /** - * Check if this team has all of the given permissions. - */ - public function hasAllPermissions(array $permissions): bool - { - foreach ($permissions as $permission) { - if (! $this->hasPermission($permission)) { - return false; - } - } - - return true; - } - - /** - * Grant a permission to this team. - */ - public function grantPermission(string $permission): self - { - $permissions = $this->permissions ?? []; - - if (! in_array($permission, $permissions, true)) { - $permissions[] = $permission; - $this->update(['permissions' => $permissions]); - } - - return $this; - } - - /** - * Revoke a permission from this team. - */ - public function revokePermission(string $permission): self - { - $permissions = $this->permissions ?? []; - $permissions = array_values(array_filter($permissions, fn ($p) => $p !== $permission)); - - $this->update(['permissions' => $permissions]); - - return $this; - } - - /** - * Set all permissions for this team. - */ - public function setPermissions(array $permissions): self - { - $this->update(['permissions' => $permissions]); - - return $this; - } - - // ───────────────────────────────────────────────────────────────────────── - // Static Helpers - // ───────────────────────────────────────────────────────────────────────── - - /** - * Get all available permissions grouped by category. - */ - public static function getAvailablePermissions(): array - { - return [ - 'workspace' => [ - 'label' => 'Workspace', - 'permissions' => [ - self::PERM_WORKSPACE_SETTINGS => 'Manage settings', - self::PERM_WORKSPACE_MEMBERS => 'Manage members', - self::PERM_WORKSPACE_TEAMS => 'Manage teams', - self::PERM_WORKSPACE_BILLING => 'Manage billing', - self::PERM_WORKSPACE_DELETE => 'Delete workspace', - ], - ], - 'bio' => [ - 'label' => 'BioHost', - 'permissions' => [ - self::PERM_BIO_READ => 'View pages', - self::PERM_BIO_WRITE => 'Create and edit pages', - self::PERM_BIO_ADMIN => 'Full access', - ], - ], - 'social' => [ - 'label' => 'SocialHost', - 'permissions' => [ - self::PERM_SOCIAL_READ => 'View posts and accounts', - self::PERM_SOCIAL_WRITE => 'Create and edit posts', - self::PERM_SOCIAL_ADMIN => 'Full access', - ], - ], - 'analytics' => [ - 'label' => 'AnalyticsHost', - 'permissions' => [ - self::PERM_ANALYTICS_READ => 'View analytics', - self::PERM_ANALYTICS_WRITE => 'Configure tracking', - self::PERM_ANALYTICS_ADMIN => 'Full access', - ], - ], - 'trust' => [ - 'label' => 'TrustHost', - 'permissions' => [ - self::PERM_TRUST_READ => 'View campaigns', - self::PERM_TRUST_WRITE => 'Create and edit campaigns', - self::PERM_TRUST_ADMIN => 'Full access', - ], - ], - 'notify' => [ - 'label' => 'NotifyHost', - 'permissions' => [ - self::PERM_NOTIFY_READ => 'View notifications', - self::PERM_NOTIFY_WRITE => 'Send notifications', - self::PERM_NOTIFY_ADMIN => 'Full access', - ], - ], - 'support' => [ - 'label' => 'SupportHost', - 'permissions' => [ - self::PERM_SUPPORT_READ => 'View conversations', - self::PERM_SUPPORT_WRITE => 'Reply to conversations', - self::PERM_SUPPORT_ADMIN => 'Full access', - ], - ], - 'commerce' => [ - 'label' => 'Commerce', - 'permissions' => [ - self::PERM_COMMERCE_READ => 'View orders and invoices', - self::PERM_COMMERCE_WRITE => 'Manage orders', - self::PERM_COMMERCE_ADMIN => 'Full access', - ], - ], - 'api' => [ - 'label' => 'API', - 'permissions' => [ - self::PERM_API_READ => 'View API keys', - self::PERM_API_WRITE => 'Create API keys', - self::PERM_API_ADMIN => 'Full access', - ], - ], - ]; - } - - /** - * Get flat list of all permission keys. - */ - public static function getAllPermissionKeys(): array - { - $keys = []; - foreach (self::getAvailablePermissions() as $group) { - $keys = array_merge($keys, array_keys($group['permissions'])); - } - - return $keys; - } - - /** - * Get default permissions for a given team type. - */ - public static function getDefaultPermissionsFor(string $teamSlug): array - { - return match ($teamSlug) { - self::TEAM_OWNER => self::getAllPermissionKeys(), // Owner gets all permissions - self::TEAM_ADMIN => array_filter( - self::getAllPermissionKeys(), - fn ($p) => ! in_array($p, [ - self::PERM_WORKSPACE_DELETE, - self::PERM_WORKSPACE_BILLING, - ], true) - ), - self::TEAM_MEMBER => [ - self::PERM_BIO_READ, - self::PERM_BIO_WRITE, - self::PERM_SOCIAL_READ, - self::PERM_SOCIAL_WRITE, - self::PERM_ANALYTICS_READ, - self::PERM_TRUST_READ, - self::PERM_TRUST_WRITE, - self::PERM_NOTIFY_READ, - self::PERM_NOTIFY_WRITE, - self::PERM_SUPPORT_READ, - self::PERM_SUPPORT_WRITE, - self::PERM_COMMERCE_READ, - self::PERM_API_READ, - ], - self::TEAM_VIEWER => [ - self::PERM_BIO_READ, - self::PERM_SOCIAL_READ, - self::PERM_ANALYTICS_READ, - self::PERM_TRUST_READ, - self::PERM_NOTIFY_READ, - self::PERM_SUPPORT_READ, - self::PERM_COMMERCE_READ, - self::PERM_API_READ, - ], - default => [], - }; - } - - /** - * Get the default team definitions for seeding. - */ - public static function getDefaultTeamDefinitions(): array - { - return [ - [ - 'name' => 'Owner', - 'slug' => self::TEAM_OWNER, - 'description' => 'Full ownership access to the workspace.', - 'permissions' => self::getDefaultPermissionsFor(self::TEAM_OWNER), - 'is_system' => true, - 'colour' => 'violet', - 'sort_order' => 1, - ], - [ - 'name' => 'Admin', - 'slug' => self::TEAM_ADMIN, - 'description' => 'Administrative access without billing or deletion rights.', - 'permissions' => self::getDefaultPermissionsFor(self::TEAM_ADMIN), - 'is_system' => true, - 'colour' => 'blue', - 'sort_order' => 2, - ], - [ - 'name' => 'Member', - 'slug' => self::TEAM_MEMBER, - 'description' => 'Standard member access to create and edit content.', - 'permissions' => self::getDefaultPermissionsFor(self::TEAM_MEMBER), - 'is_system' => true, - 'is_default' => true, - 'colour' => 'emerald', - 'sort_order' => 3, - ], - [ - 'name' => 'Viewer', - 'slug' => self::TEAM_VIEWER, - 'description' => 'Read-only access to view content.', - 'permissions' => self::getDefaultPermissionsFor(self::TEAM_VIEWER), - 'is_system' => true, - 'colour' => 'zinc', - 'sort_order' => 4, - ], - ]; - } - - /** - * Get available colour options for teams. - */ - public static function getColourOptions(): array - { - return [ - 'zinc' => 'Grey', - 'red' => 'Red', - 'orange' => 'Orange', - 'amber' => 'Amber', - 'yellow' => 'Yellow', - 'lime' => 'Lime', - 'green' => 'Green', - 'emerald' => 'Emerald', - 'teal' => 'Teal', - 'cyan' => 'Cyan', - 'sky' => 'Sky', - 'blue' => 'Blue', - 'indigo' => 'Indigo', - 'violet' => 'Violet', - 'purple' => 'Purple', - 'fuchsia' => 'Fuchsia', - 'pink' => 'Pink', - 'rose' => 'Rose', - ]; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Notifications/BoostExpiredNotification.php b/packages/core-php/src/Mod/Tenant/Notifications/BoostExpiredNotification.php deleted file mode 100644 index 17862c9..0000000 --- a/packages/core-php/src/Mod/Tenant/Notifications/BoostExpiredNotification.php +++ /dev/null @@ -1,144 +0,0 @@ - - */ - public function via(object $notifiable): array - { - return ['mail']; - } - - /** - * Get the mail representation of the notification. - */ - public function toMail(object $notifiable): MailMessage - { - $workspaceName = $this->workspace->name; - $appName = config('core.app.name', 'Host UK'); - $boostCount = $this->expiredBoosts->count(); - - $message = (new MailMessage) - ->subject($this->getSubject($boostCount, $workspaceName)) - ->greeting('Hi,'); - - if ($boostCount === 1) { - $boost = $this->expiredBoosts->first(); - $featureName = $this->getFeatureName($boost->feature_code); - - return $message - ->line("A boost for **{$featureName}** has expired in your **{$workspaceName}** workspace.") - ->line('This was a cycle-bound boost that ended with your billing period.') - ->line($this->getBoostDescription($boost)) - ->action('View Usage', route('hub.billing')) - ->line('You can purchase additional boosts or upgrade your plan to restore this capacity.') - ->salutation("Cheers, the {$appName} team"); - } - - // Multiple boosts expired - $message->line("The following boosts have expired in your **{$workspaceName}** workspace:"); - - foreach ($this->expiredBoosts as $boost) { - $featureName = $this->getFeatureName($boost->feature_code); - $message->line("- **{$featureName}**: {$this->getBoostDescription($boost)}"); - } - - return $message - ->line('These were cycle-bound boosts that ended with your billing period.') - ->action('View Usage', route('hub.billing')) - ->line('You can purchase additional boosts or upgrade your plan to restore this capacity.') - ->salutation("Cheers, the {$appName} team"); - } - - /** - * Get email subject. - */ - protected function getSubject(int $boostCount, string $workspaceName): string - { - if ($boostCount === 1) { - $boost = $this->expiredBoosts->first(); - $featureName = $this->getFeatureName($boost->feature_code); - - return "{$featureName} boost expired - {$workspaceName}"; - } - - return "{$boostCount} boosts expired - {$workspaceName}"; - } - - /** - * Get the feature name for a code. - */ - protected function getFeatureName(string $featureCode): string - { - $feature = Feature::where('code', $featureCode)->first(); - - return $feature?->name ?? ucwords(str_replace(['.', '_', '-'], ' ', $featureCode)); - } - - /** - * Get description of what the boost provided. - */ - protected function getBoostDescription(Boost $boost): string - { - if ($boost->boost_type === Boost::BOOST_TYPE_UNLIMITED) { - return 'Unlimited access'; - } - - if ($boost->boost_type === Boost::BOOST_TYPE_ENABLE) { - return 'Feature access'; - } - - $consumed = $boost->consumed_quantity ?? 0; - $total = $boost->limit_value ?? 0; - - return "+{$total} capacity ({$consumed} used)"; - } - - /** - * Get the array representation of the notification. - * - * @return array - */ - public function toArray(object $notifiable): array - { - return [ - 'type' => 'boost_expired', - 'workspace_id' => $this->workspace->id, - 'workspace_name' => $this->workspace->name, - 'boosts' => $this->expiredBoosts->map(fn ($boost) => [ - 'id' => $boost->id, - 'feature_code' => $boost->feature_code, - 'boost_type' => $boost->boost_type, - 'limit_value' => $boost->limit_value, - 'consumed_quantity' => $boost->consumed_quantity, - ])->toArray(), - 'count' => $this->expiredBoosts->count(), - ]; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Notifications/UsageAlertNotification.php b/packages/core-php/src/Mod/Tenant/Notifications/UsageAlertNotification.php deleted file mode 100644 index 6737bc2..0000000 --- a/packages/core-php/src/Mod/Tenant/Notifications/UsageAlertNotification.php +++ /dev/null @@ -1,162 +0,0 @@ - - */ - public function via(object $notifiable): array - { - return ['mail']; - } - - /** - * Get the mail representation of the notification. - */ - public function toMail(object $notifiable): MailMessage - { - $percentage = round(($this->used / $this->limit) * 100); - $remaining = max(0, $this->limit - $this->used); - $featureName = $this->feature->name; - $workspaceName = $this->workspace->name; - $appName = config('core.app.name', 'Host UK'); - - $message = (new MailMessage) - ->subject($this->getSubject($featureName, $percentage)); - - if ($this->threshold === UsageAlertHistory::THRESHOLD_LIMIT) { - return $this->limitReachedEmail($message, $featureName, $workspaceName, $appName); - } - - if ($this->threshold === UsageAlertHistory::THRESHOLD_CRITICAL) { - return $this->criticalEmail($message, $featureName, $workspaceName, $percentage, $remaining, $appName); - } - - return $this->warningEmail($message, $featureName, $workspaceName, $percentage, $remaining, $appName); - } - - /** - * Get email subject based on threshold. - */ - protected function getSubject(string $featureName, int $percentage): string - { - if ($this->threshold === UsageAlertHistory::THRESHOLD_LIMIT) { - return "{$featureName} limit reached"; - } - - return "{$featureName} usage at {$percentage}%"; - } - - /** - * Build warning email (80% threshold). - */ - protected function warningEmail( - MailMessage $message, - string $featureName, - string $workspaceName, - int $percentage, - int $remaining, - string $appName - ): MailMessage { - return $message - ->greeting('Hi,') - ->line("Your **{$workspaceName}** workspace is approaching its **{$featureName}** limit.") - ->line("**Current usage:** {$this->used} of {$this->limit} ({$percentage}%)") - ->line("**Remaining:** {$remaining}") - ->line('Consider upgrading your plan to ensure uninterrupted service.') - ->action('View Usage', route('hub.billing')) - ->line('If you have questions about your plan, please contact our support team.') - ->salutation("Cheers, the {$appName} team"); - } - - /** - * Build critical email (90% threshold). - */ - protected function criticalEmail( - MailMessage $message, - string $featureName, - string $workspaceName, - int $percentage, - int $remaining, - string $appName - ): MailMessage { - return $message - ->greeting('Hi,') - ->line("**Urgent:** Your **{$workspaceName}** workspace is almost at its **{$featureName}** limit.") - ->line("**Current usage:** {$this->used} of {$this->limit} ({$percentage}%)") - ->line("**Only {$remaining} remaining**") - ->line('Upgrade now to avoid any service interruptions.') - ->action('Upgrade Plan', route('hub.billing')) - ->line('Need help? Contact our support team.') - ->salutation("Cheers, the {$appName} team"); - } - - /** - * Build limit reached email (100% threshold). - */ - protected function limitReachedEmail( - MailMessage $message, - string $featureName, - string $workspaceName, - string $appName - ): MailMessage { - return $message - ->greeting('Hi,') - ->line("Your **{$workspaceName}** workspace has reached its **{$featureName}** limit.") - ->line("**Usage:** {$this->used} of {$this->limit} (100%)") - ->line('You will not be able to use this feature until:') - ->line('- You upgrade to a higher plan, or') - ->line('- Your usage resets (if applicable), or') - ->line('- You reduce your current usage') - ->action('Upgrade Plan', route('hub.billing')) - ->line('Need assistance? Our support team is here to help.') - ->salutation("Cheers, the {$appName} team"); - } - - /** - * Get the array representation of the notification. - * - * @return array - */ - public function toArray(object $notifiable): array - { - return [ - 'type' => 'usage_alert', - 'workspace_id' => $this->workspace->id, - 'workspace_name' => $this->workspace->name, - 'feature_code' => $this->feature->code, - 'feature_name' => $this->feature->name, - 'threshold' => $this->threshold, - 'used' => $this->used, - 'limit' => $this->limit, - 'percentage' => round(($this->used / $this->limit) * 100), - ]; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Notifications/WaitlistInviteNotification.php b/packages/core-php/src/Mod/Tenant/Notifications/WaitlistInviteNotification.php deleted file mode 100644 index 4798764..0000000 --- a/packages/core-php/src/Mod/Tenant/Notifications/WaitlistInviteNotification.php +++ /dev/null @@ -1,69 +0,0 @@ - - */ - public function via(object $notifiable): array - { - return ['mail']; - } - - /** - * Get the mail representation of the notification. - */ - public function toMail(object $notifiable): MailMessage - { - $registerUrl = route('register', ['invite' => $this->entry->invite_code]); - $name = $this->entry->name ?: 'there'; - - return (new MailMessage) - ->subject('Your Host UK invite is ready') - ->greeting("Hello {$name},") - ->line('Good news. Your spot on the Host UK waitlist has come up and you can now create your account.') - ->line('**Your invite code:** '.$this->entry->invite_code) - ->line('As an early member, you\'ll get **50% off your first 3 months** when you upgrade to a paid plan.') - ->action('Create your account', $registerUrl) - ->line('This invite is linked to your email address and can only be used once.') - ->line('Here\'s what you\'ll get access to:') - ->line('• **BioHost** – Create bio pages with 60+ content blocks') - ->line('• **SocialHost** – Schedule posts across 20+ social platforms') - ->line('• **AnalyticsHost** – Privacy-first website analytics') - ->line('• **TrustHost** – Social proof widgets for your site') - ->line('• **NotifyHost** – Browser push notifications') - ->line('Questions? Just reply to this email.') - ->salutation('Cheers, the Host UK team'); - } - - /** - * Get the array representation of the notification. - * - * @return array - */ - public function toArray(object $notifiable): array - { - return [ - 'type' => 'waitlist_invite', - 'invite_code' => $this->entry->invite_code, - ]; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Notifications/WelcomeNotification.php b/packages/core-php/src/Mod/Tenant/Notifications/WelcomeNotification.php deleted file mode 100644 index 42e0a2e..0000000 --- a/packages/core-php/src/Mod/Tenant/Notifications/WelcomeNotification.php +++ /dev/null @@ -1,57 +0,0 @@ - - */ - public function via(object $notifiable): array - { - return ['mail']; - } - - /** - * Get the mail representation of the notification. - */ - public function toMail(object $notifiable): MailMessage - { - return (new MailMessage) - ->subject('Welcome to Host UK') - ->greeting('Hello '.($notifiable->name ?: 'there').',') - ->line('Thanks for creating your Host UK account. You\'re all set to start building your online presence.') - ->line('Here\'s what you can do next:') - ->line('• **BioHost** – Create a bio page with 60+ content blocks') - ->line('• **SocialHost** – Schedule posts across 20+ social platforms') - ->line('• **AnalyticsHost** – Track your website visitors with privacy-first analytics') - ->line('• **TrustHost** – Add social proof widgets to your site') - ->line('• **NotifyHost** – Send browser push notifications') - ->action('Go to Dashboard', route('hub.dashboard')) - ->line('If you have any questions, just reply to this email.') - ->salutation('Cheers, the Host UK team'); - } - - /** - * Get the array representation of the notification. - * - * @return array - */ - public function toArray(object $notifiable): array - { - return [ - 'type' => 'welcome', - ]; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Notifications/WorkspaceInvitationNotification.php b/packages/core-php/src/Mod/Tenant/Notifications/WorkspaceInvitationNotification.php deleted file mode 100644 index 7879274..0000000 --- a/packages/core-php/src/Mod/Tenant/Notifications/WorkspaceInvitationNotification.php +++ /dev/null @@ -1,65 +0,0 @@ - - */ - public function via(object $notifiable): array - { - return ['mail']; - } - - /** - * Get the mail representation of the notification. - */ - public function toMail(object $notifiable): MailMessage - { - $acceptUrl = route('workspace.invitation.accept', ['token' => $this->invitation->token]); - $workspaceName = $this->invitation->workspace->name; - $inviterName = $this->invitation->inviter?->name ?? 'A team member'; - $roleName = ucfirst($this->invitation->role); - $expiresAt = $this->invitation->expires_at->format('j F Y'); - - return (new MailMessage) - ->subject("You've been invited to join {$workspaceName}") - ->greeting('Hello,') - ->line("{$inviterName} has invited you to join **{$workspaceName}** as a **{$roleName}**.") - ->action('Accept invitation', $acceptUrl) - ->line("This invitation will expire on {$expiresAt}.") - ->line('If you did not expect this invitation, you can safely ignore this email.'); - } - - /** - * Get the array representation of the notification. - * - * @return array - */ - public function toArray(object $notifiable): array - { - return [ - 'type' => 'workspace_invitation', - 'workspace_id' => $this->invitation->workspace_id, - 'workspace_name' => $this->invitation->workspace->name, - 'role' => $this->invitation->role, - ]; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Routes/api.php b/packages/core-php/src/Mod/Tenant/Routes/api.php deleted file mode 100644 index cb6cdc1..0000000 --- a/packages/core-php/src/Mod/Tenant/Routes/api.php +++ /dev/null @@ -1,82 +0,0 @@ -prefix('workspaces')->name('api.workspaces.')->group(function () { - Route::get('/', [WorkspaceController::class, 'index']) - ->name('index'); - Route::get('/current', [WorkspaceController::class, 'current']) - ->name('current'); - Route::post('/', [WorkspaceController::class, 'store']) - ->name('store'); - Route::get('/{workspace}', [WorkspaceController::class, 'show']) - ->name('show'); - Route::put('/{workspace}', [WorkspaceController::class, 'update']) - ->name('update'); - Route::delete('/{workspace}', [WorkspaceController::class, 'destroy']) - ->name('destroy'); - Route::post('/{workspace}/switch', [WorkspaceController::class, 'switch']) - ->name('switch'); -}); - -/* -|-------------------------------------------------------------------------- -| Workspaces API (API Key Auth) -|-------------------------------------------------------------------------- -| -| Read-only workspace access via API key. -| Use Authorization: Bearer hk_xxx header. -| -*/ - -Route::middleware(['api.auth', 'api.scope.enforce'])->prefix('workspaces')->name('api.key.workspaces.')->group(function () { - // Scope enforcement: GET=read (all routes here are read-only) - Route::get('/', [WorkspaceController::class, 'index'])->name('index'); - Route::get('/current', [WorkspaceController::class, 'current'])->name('current'); - Route::get('/{workspace}', [WorkspaceController::class, 'show'])->name('show'); -}); - -/* -|-------------------------------------------------------------------------- -| Entitlement Webhooks API (Auth Required) -|-------------------------------------------------------------------------- -| -| Webhook management for entitlement events. -| Session-based authentication. -| -*/ - -Route::middleware('auth')->prefix('entitlement-webhooks')->name('api.entitlement-webhooks.')->group(function () { - Route::get('/', [EntitlementWebhookController::class, 'index'])->name('index'); - Route::get('/events', [EntitlementWebhookController::class, 'events'])->name('events'); - Route::post('/', [EntitlementWebhookController::class, 'store'])->name('store'); - Route::get('/{webhook}', [EntitlementWebhookController::class, 'show'])->name('show'); - Route::put('/{webhook}', [EntitlementWebhookController::class, 'update'])->name('update'); - Route::delete('/{webhook}', [EntitlementWebhookController::class, 'destroy'])->name('destroy'); - Route::post('/{webhook}/test', [EntitlementWebhookController::class, 'test'])->name('test'); - Route::post('/{webhook}/regenerate-secret', [EntitlementWebhookController::class, 'regenerateSecret'])->name('regenerate-secret'); - Route::post('/{webhook}/reset-circuit-breaker', [EntitlementWebhookController::class, 'resetCircuitBreaker'])->name('reset-circuit-breaker'); - Route::get('/{webhook}/deliveries', [EntitlementWebhookController::class, 'deliveries'])->name('deliveries'); - Route::post('/deliveries/{delivery}/retry', [EntitlementWebhookController::class, 'retryDelivery'])->name('retry-delivery'); -}); diff --git a/packages/core-php/src/Mod/Tenant/Routes/web.php b/packages/core-php/src/Mod/Tenant/Routes/web.php deleted file mode 100644 index e3bf445..0000000 --- a/packages/core-php/src/Mod/Tenant/Routes/web.php +++ /dev/null @@ -1,59 +0,0 @@ -name('account.')->group(function () { - Route::get('/delete/{token}', ConfirmDeletion::class) - ->name('delete.confirm'); - - Route::get('/delete/{token}/cancel', CancelDeletion::class) - ->name('delete.cancel'); -}); - -/* -|-------------------------------------------------------------------------- -| Workspace Invitation Routes -|-------------------------------------------------------------------------- -| -| Token-based workspace invitation acceptance. -| Users receive these links via email to join a workspace. -| -*/ - -Route::get('/workspace/invitation/{token}', \Core\Mod\Tenant\Controllers\WorkspaceInvitationController::class) - ->name('workspace.invitation.accept'); - -/* -|-------------------------------------------------------------------------- -| Workspace Public Routes -|-------------------------------------------------------------------------- -| -| Workspace home page, typically accessed via subdomain. -| The workspace slug is resolved from subdomain middleware or route param. -| -*/ - -Route::get('/workspace/{workspace?}', WorkspaceHome::class) - ->name('workspace.home') - ->where('workspace', '[a-z0-9\-]+'); diff --git a/packages/core-php/src/Mod/Tenant/Rules/CheckUserPasswordRule.php b/packages/core-php/src/Mod/Tenant/Rules/CheckUserPasswordRule.php deleted file mode 100644 index 736a31d..0000000 --- a/packages/core-php/src/Mod/Tenant/Rules/CheckUserPasswordRule.php +++ /dev/null @@ -1,45 +0,0 @@ -user->password)) { - $fail($this->message ?: 'The password is incorrect.'); - } - } -} diff --git a/packages/core-php/src/Mod/Tenant/Rules/ResourceStatusRule.php b/packages/core-php/src/Mod/Tenant/Rules/ResourceStatusRule.php deleted file mode 100644 index 48014f0..0000000 --- a/packages/core-php/src/Mod/Tenant/Rules/ResourceStatusRule.php +++ /dev/null @@ -1,39 +0,0 @@ -value, ResourceStatus::ENABLED->value], true)) { - $fail('The :attribute must be either enabled or disabled.'); - } - } -} diff --git a/packages/core-php/src/Mod/Tenant/Scopes/WorkspaceScope.php b/packages/core-php/src/Mod/Tenant/Scopes/WorkspaceScope.php deleted file mode 100644 index 3af629f..0000000 --- a/packages/core-php/src/Mod/Tenant/Scopes/WorkspaceScope.php +++ /dev/null @@ -1,174 +0,0 @@ -hasWorkspaceColumn($model)) { - return; - } - - // Get current workspace (returns Workspace model instance) - $workspace = Workspace::current(); - - if ($workspace) { - $builder->where($model->getTable().'.workspace_id', $workspace->id); - - return; - } - - // No workspace context available - if ($this->shouldEnforceStrictMode($model)) { - throw MissingWorkspaceContextException::forScope( - class_basename($model) - ); - } - - // Non-strict mode: return empty result set (fail safe) - $builder->whereRaw('1 = 0'); - } - - /** - * Check if the model has a workspace_id column. - */ - protected function hasWorkspaceColumn(Model $model): bool - { - $fillable = $model->getFillable(); - $guarded = $model->getGuarded(); - - // Check if workspace_id is in fillable or not in guarded - return in_array('workspace_id', $fillable, true) - || (count($guarded) === 1 && $guarded[0] === '*') - || ! in_array('workspace_id', $guarded, true); - } - - /** - * Determine if strict mode should be enforced for a model. - */ - protected function shouldEnforceStrictMode(Model $model): bool - { - // Check global strict mode setting - if (! self::$strictModeEnabled) { - return false; - } - - // Check if model has opted out of strict mode - if (property_exists($model, 'workspaceScopeStrict') && $model->workspaceScopeStrict === false) { - return false; - } - - // Check if running from console (CLI commands may need to work without context) - if (app()->runningInConsole() && ! app()->runningUnitTests()) { - return false; - } - - return true; - } - - /** - * Extend the query builder with workspace-specific methods. - */ - public function extend(Builder $builder): void - { - // Add method to set workspace context for a query - $builder->macro('forWorkspace', function (Builder $builder, Workspace|int $workspace) { - $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; - - return $builder->withoutGlobalScope(WorkspaceScope::class) - ->where($builder->getModel()->getTable().'.workspace_id', $workspaceId); - }); - - // Add method to query across all workspaces (use with caution) - $builder->macro('acrossWorkspaces', function (Builder $builder) { - return $builder->withoutGlobalScope(WorkspaceScope::class); - }); - - // Add method to get current workspace for a query - $builder->macro('currentWorkspaceId', function (Builder $builder) { - $workspace = Workspace::current(); - - return $workspace?->id; - }); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Services/EntitlementResult.php b/packages/core-php/src/Mod/Tenant/Services/EntitlementResult.php deleted file mode 100644 index 078ba09..0000000 --- a/packages/core-php/src/Mod/Tenant/Services/EntitlementResult.php +++ /dev/null @@ -1,174 +0,0 @@ - true]), - ); - } - - /** - * Check if the request is allowed. - */ - public function isAllowed(): bool - { - return $this->allowed; - } - - /** - * Check if the request is denied. - */ - public function isDenied(): bool - { - return ! $this->allowed; - } - - /** - * Get the denial message. - */ - public function getMessage(): ?string - { - return $this->reason; - } - - /** - * Check if this is an unlimited feature. - */ - public function isUnlimited(): bool - { - return $this->metadata['unlimited'] ?? false; - } - - /** - * Get usage percentage (0-100). - */ - public function getUsagePercentage(): ?float - { - if ($this->limit === null || $this->limit === 0) { - return null; - } - - return min(100, ($this->used ?? 0) / $this->limit * 100); - } - - /** - * Check if usage is near the limit (> 80%). - */ - public function isNearLimit(): bool - { - $percentage = $this->getUsagePercentage(); - - return $percentage !== null && $percentage >= 80; - } - - /** - * Check if usage is at the limit. - */ - public function isAtLimit(): bool - { - return $this->remaining === 0; - } - - /** - * Get the limit value. - */ - public function getLimit(): ?int - { - return $this->limit; - } - - /** - * Get the used value. - */ - public function getUsed(): ?int - { - return $this->used; - } - - /** - * Get the remaining value. - */ - public function getRemaining(): ?int - { - return $this->remaining; - } - - /** - * Convert to array for JSON responses. - */ - public function toArray(): array - { - return [ - 'allowed' => $this->allowed, - 'reason' => $this->reason, - 'limit' => $this->limit, - 'used' => $this->used, - 'remaining' => $this->remaining, - 'feature_code' => $this->featureCode, - 'unlimited' => $this->isUnlimited(), - 'usage_percentage' => $this->getUsagePercentage(), - ]; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Services/EntitlementService.php b/packages/core-php/src/Mod/Tenant/Services/EntitlementService.php deleted file mode 100644 index 807ed68..0000000 --- a/packages/core-php/src/Mod/Tenant/Services/EntitlementService.php +++ /dev/null @@ -1,821 +0,0 @@ -getFeature($featureCode); - - if (! $feature) { - return EntitlementResult::denied( - reason: "Feature '{$featureCode}' does not exist.", - featureCode: $featureCode - ); - } - - // Get the pool feature code (parent if hierarchical) - $poolFeatureCode = $feature->getPoolFeatureCode(); - - // Get total limit from all active packages + boosts - $totalLimit = $this->getTotalLimit($workspace, $poolFeatureCode); - - if ($totalLimit === null) { - // Feature not included in any package - return EntitlementResult::denied( - reason: "Your plan does not include {$feature->name}.", - featureCode: $featureCode - ); - } - - // Check for unlimited - if ($totalLimit === -1) { - return EntitlementResult::unlimited($featureCode); - } - - // For boolean features, just check if enabled - if ($feature->isBoolean()) { - return EntitlementResult::allowed(featureCode: $featureCode); - } - - // Get current usage - $currentUsage = $this->getCurrentUsage($workspace, $poolFeatureCode, $feature); - - // Check if quantity would exceed limit - if ($currentUsage + $quantity > $totalLimit) { - return EntitlementResult::denied( - reason: "You've reached your {$feature->name} limit ({$totalLimit}).", - limit: $totalLimit, - used: $currentUsage, - featureCode: $featureCode - ); - } - - return EntitlementResult::allowed( - limit: $totalLimit, - used: $currentUsage, - featureCode: $featureCode - ); - } - - /** - * Check if a namespace can use a feature. - * - * Entitlement cascade: - * 1. Check namespace-level packages first - * 2. Fall back to workspace pool (if namespace has workspace context) - * 3. Fall back to user tier (for user-owned namespaces without workspace) - */ - public function canForNamespace(Namespace_ $namespace, string $featureCode, int $quantity = 1): EntitlementResult - { - $feature = $this->getFeature($featureCode); - - if (! $feature) { - return EntitlementResult::denied( - reason: "Feature '{$featureCode}' does not exist.", - featureCode: $featureCode - ); - } - - // Get the pool feature code (parent if hierarchical) - $poolFeatureCode = $feature->getPoolFeatureCode(); - - // Try namespace-level limit first - $totalLimit = $this->getNamespaceTotalLimit($namespace, $poolFeatureCode); - - // If not found at namespace level, try workspace fallback - if ($totalLimit === null && $namespace->workspace_id) { - $workspace = $namespace->workspace; - if ($workspace) { - $totalLimit = $this->getTotalLimit($workspace, $poolFeatureCode); - } - } - - // If still not found, try user tier fallback for user-owned namespaces - if ($totalLimit === null && $namespace->isOwnedByUser()) { - $user = $namespace->getOwnerUser(); - if ($user) { - // Check if user's tier includes this feature - if ($feature->isBoolean()) { - $hasFeature = $user->hasFeature($featureCode); - if ($hasFeature) { - return EntitlementResult::allowed(featureCode: $featureCode); - } - } - } - } - - if ($totalLimit === null) { - return EntitlementResult::denied( - reason: "Your plan does not include {$feature->name}.", - featureCode: $featureCode - ); - } - - // Check for unlimited - if ($totalLimit === -1) { - return EntitlementResult::unlimited($featureCode); - } - - // For boolean features, just check if enabled - if ($feature->isBoolean()) { - return EntitlementResult::allowed(featureCode: $featureCode); - } - - // Get current usage - $currentUsage = $this->getNamespaceCurrentUsage($namespace, $poolFeatureCode, $feature); - - // Check if quantity would exceed limit - if ($currentUsage + $quantity > $totalLimit) { - return EntitlementResult::denied( - reason: "You've reached your {$feature->name} limit ({$totalLimit}).", - limit: $totalLimit, - used: $currentUsage, - featureCode: $featureCode - ); - } - - return EntitlementResult::allowed( - limit: $totalLimit, - used: $currentUsage, - featureCode: $featureCode - ); - } - - /** - * Record usage of a feature for a namespace. - */ - public function recordNamespaceUsage( - Namespace_ $namespace, - string $featureCode, - int $quantity = 1, - ?User $user = null, - ?array $metadata = null - ): UsageRecord { - $feature = $this->getFeature($featureCode); - $poolFeatureCode = $feature?->getPoolFeatureCode() ?? $featureCode; - - $record = UsageRecord::create([ - 'namespace_id' => $namespace->id, - 'workspace_id' => $namespace->workspace_id, - 'feature_code' => $poolFeatureCode, - 'quantity' => $quantity, - 'user_id' => $user?->id, - 'metadata' => $metadata, - 'recorded_at' => now(), - ]); - - // Invalidate cache - $this->invalidateNamespaceCache($namespace); - - return $record; - } - - /** - * Record usage of a feature. - */ - public function recordUsage( - Workspace $workspace, - string $featureCode, - int $quantity = 1, - ?User $user = null, - ?array $metadata = null - ): UsageRecord { - $feature = $this->getFeature($featureCode); - $poolFeatureCode = $feature?->getPoolFeatureCode() ?? $featureCode; - - $record = UsageRecord::create([ - 'workspace_id' => $workspace->id, - 'feature_code' => $poolFeatureCode, - 'quantity' => $quantity, - 'user_id' => $user?->id, - 'metadata' => $metadata, - 'recorded_at' => now(), - ]); - - // Invalidate cache - $this->invalidateCache($workspace); - - return $record; - } - - /** - * Provision a package for a workspace. - */ - public function provisionPackage( - Workspace $workspace, - string $packageCode, - array $options = [] - ): WorkspacePackage { - $package = Package::where('code', $packageCode)->firstOrFail(); - - // Check if this is a base package and workspace already has one - if ($package->is_base_package) { - $existingBase = $workspace->workspacePackages() - ->whereHas('package', fn ($q) => $q->where('is_base_package', true)) - ->active() - ->first(); - - if ($existingBase) { - // Cancel existing base package - $existingBase->cancel(now()); - - EntitlementLog::logPackageAction( - $workspace, - EntitlementLog::ACTION_PACKAGE_CANCELLED, - $existingBase, - source: $options['source'] ?? EntitlementLog::SOURCE_SYSTEM, - metadata: ['reason' => 'Replaced by new base package'] - ); - } - } - - $workspacePackage = WorkspacePackage::create([ - 'workspace_id' => $workspace->id, - 'package_id' => $package->id, - 'status' => WorkspacePackage::STATUS_ACTIVE, - 'starts_at' => $options['starts_at'] ?? now(), - 'expires_at' => $options['expires_at'] ?? null, - 'billing_cycle_anchor' => $options['billing_cycle_anchor'] ?? now(), - 'blesta_service_id' => $options['blesta_service_id'] ?? null, - 'metadata' => $options['metadata'] ?? null, - ]); - - EntitlementLog::logPackageAction( - $workspace, - EntitlementLog::ACTION_PACKAGE_PROVISIONED, - $workspacePackage, - source: $options['source'] ?? EntitlementLog::SOURCE_SYSTEM, - newValues: $workspacePackage->toArray() - ); - - $this->invalidateCache($workspace); - - return $workspacePackage; - } - - /** - * Provision a boost for a workspace. - */ - public function provisionBoost( - Workspace $workspace, - string $featureCode, - array $options = [] - ): Boost { - $boost = Boost::create([ - 'workspace_id' => $workspace->id, - 'feature_code' => $featureCode, - 'boost_type' => $options['boost_type'] ?? Boost::BOOST_TYPE_ADD_LIMIT, - 'duration_type' => $options['duration_type'] ?? Boost::DURATION_CYCLE_BOUND, - 'limit_value' => $options['limit_value'] ?? null, - 'consumed_quantity' => 0, - 'status' => Boost::STATUS_ACTIVE, - 'starts_at' => $options['starts_at'] ?? now(), - 'expires_at' => $options['expires_at'] ?? null, - 'blesta_addon_id' => $options['blesta_addon_id'] ?? null, - 'metadata' => $options['metadata'] ?? null, - ]); - - EntitlementLog::logBoostAction( - $workspace, - EntitlementLog::ACTION_BOOST_PROVISIONED, - $boost, - source: $options['source'] ?? EntitlementLog::SOURCE_SYSTEM, - newValues: $boost->toArray() - ); - - $this->invalidateCache($workspace); - - return $boost; - } - - /** - * Get usage summary for a workspace. - */ - public function getUsageSummary(Workspace $workspace): Collection - { - $features = Feature::active()->orderBy('category')->orderBy('sort_order')->get(); - $summary = collect(); - - foreach ($features as $feature) { - $result = $this->can($workspace, $feature->code); - - $summary->push([ - 'feature' => $feature, - 'code' => $feature->code, - 'name' => $feature->name, - 'category' => $feature->category, - 'type' => $feature->type, - 'allowed' => $result->isAllowed(), - 'limit' => $result->limit, - 'used' => $result->used, - 'remaining' => $result->remaining, - 'unlimited' => $result->isUnlimited(), - 'percentage' => $result->getUsagePercentage(), - 'near_limit' => $result->isNearLimit(), - ]); - } - - return $summary->groupBy('category'); - } - - /** - * Get all active packages for a workspace. - */ - public function getActivePackages(Workspace $workspace): Collection - { - return $workspace->workspacePackages() - ->with('package.features') - ->active() - ->notExpired() - ->get(); - } - - /** - * Get all active boosts for a workspace. - */ - public function getActiveBoosts(Workspace $workspace): Collection - { - return $workspace->boosts() - ->usable() - ->orderBy('expires_at') - ->get(); - } - - /** - * Suspend a workspace's packages (e.g. for non-payment). - */ - public function suspendWorkspace(Workspace $workspace, ?string $source = null): void - { - $packages = $workspace->workspacePackages()->active()->get(); - - foreach ($packages as $workspacePackage) { - $workspacePackage->suspend(); - - EntitlementLog::logPackageAction( - $workspace, - EntitlementLog::ACTION_PACKAGE_SUSPENDED, - $workspacePackage, - source: $source ?? EntitlementLog::SOURCE_SYSTEM - ); - } - - $this->invalidateCache($workspace); - } - - /** - * Reactivate a workspace's packages. - */ - public function reactivateWorkspace(Workspace $workspace, ?string $source = null): void - { - $packages = $workspace->workspacePackages() - ->where('status', WorkspacePackage::STATUS_SUSPENDED) - ->get(); - - foreach ($packages as $workspacePackage) { - $workspacePackage->reactivate(); - - EntitlementLog::logPackageAction( - $workspace, - EntitlementLog::ACTION_PACKAGE_REACTIVATED, - $workspacePackage, - source: $source ?? EntitlementLog::SOURCE_SYSTEM - ); - } - - $this->invalidateCache($workspace); - } - - /** - * Revoke a package from a workspace (e.g. subscription cancelled). - */ - public function revokePackage(Workspace $workspace, string $packageCode, ?string $source = null): void - { - $workspacePackage = $workspace->workspacePackages() - ->whereHas('package', fn ($q) => $q->where('code', $packageCode)) - ->active() - ->first(); - - if (! $workspacePackage) { - return; - } - - $workspacePackage->update([ - 'status' => WorkspacePackage::STATUS_CANCELLED, - 'expires_at' => now(), - ]); - - EntitlementLog::logPackageAction( - $workspace, - EntitlementLog::ACTION_PACKAGE_CANCELLED, - $workspacePackage, - source: $source ?? EntitlementLog::SOURCE_SYSTEM, - metadata: ['reason' => 'Package revoked'] - ); - - $this->invalidateCache($workspace); - } - - /** - * Get the total limit for a feature across all packages + boosts. - * - * Returns null if feature not included, -1 if unlimited. - */ - protected function getTotalLimit(Workspace $workspace, string $featureCode): ?int - { - $cacheKey = "entitlement:{$workspace->id}:limit:{$featureCode}"; - - return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($workspace, $featureCode) { - $feature = $this->getFeature($featureCode); - - if (! $feature) { - return null; - } - - $totalLimit = 0; - $hasFeature = false; - - // Sum limits from active packages - $packages = $this->getActivePackages($workspace); - - foreach ($packages as $workspacePackage) { - $packageFeature = $workspacePackage->package->features - ->where('code', $featureCode) - ->first(); - - if ($packageFeature) { - $hasFeature = true; - - // Check if unlimited in this package - if ($packageFeature->type === Feature::TYPE_UNLIMITED) { - return -1; - } - - // Add limit value (null = boolean, no limit to add) - $limitValue = $packageFeature->pivot->limit_value; - if ($limitValue !== null) { - $totalLimit += $limitValue; - } - } - } - - // Add limits from active boosts - $boosts = $workspace->boosts() - ->forFeature($featureCode) - ->usable() - ->get(); - - foreach ($boosts as $boost) { - $hasFeature = true; - - if ($boost->boost_type === Boost::BOOST_TYPE_UNLIMITED) { - return -1; - } - - if ($boost->boost_type === Boost::BOOST_TYPE_ADD_LIMIT) { - $remaining = $boost->getRemainingLimit(); - if ($remaining !== null) { - $totalLimit += $remaining; - } - } - } - - return $hasFeature ? $totalLimit : null; - }); - } - - /** - * Get current usage for a feature. - */ - protected function getCurrentUsage(Workspace $workspace, string $featureCode, Feature $feature): int - { - $cacheKey = "entitlement:{$workspace->id}:usage:{$featureCode}"; - - return Cache::remember($cacheKey, 60, function () use ($workspace, $featureCode, $feature) { - // Determine the time window for usage calculation - if ($feature->resetsMonthly()) { - // Get billing cycle anchor from the primary package - $primaryPackage = $workspace->workspacePackages() - ->whereHas('package', fn ($q) => $q->where('is_base_package', true)) - ->active() - ->first(); - - $cycleStart = $primaryPackage - ? $primaryPackage->getCurrentCycleStart() - : now()->startOfMonth(); - - return UsageRecord::getTotalUsage($workspace->id, $featureCode, $cycleStart); - } - - if ($feature->resetsRolling()) { - $days = $feature->rolling_window_days ?? 30; - - return UsageRecord::getRollingUsage($workspace->id, $featureCode, $days); - } - - // No reset - all time usage - return UsageRecord::getTotalUsage($workspace->id, $featureCode); - }); - } - - /** - * Get a feature by code. - */ - protected function getFeature(string $code): ?Feature - { - return Cache::remember("feature:{$code}", self::CACHE_TTL, function () use ($code) { - return Feature::where('code', $code)->first(); - }); - } - - /** - * Invalidate all entitlement caches for a workspace. - */ - public function invalidateCache(Workspace $workspace): void - { - // We can't easily clear pattern-based cache keys with all drivers, - // so we use a version tag approach - Cache::forget("entitlement:{$workspace->id}:version"); - Cache::increment("entitlement:{$workspace->id}:version"); - - // For now, just clear specific known keys - $features = Feature::pluck('code'); - foreach ($features as $code) { - Cache::forget("entitlement:{$workspace->id}:limit:{$code}"); - Cache::forget("entitlement:{$workspace->id}:usage:{$code}"); - } - } - - /** - * Expire cycle-bound boosts at billing cycle end. - */ - public function expireCycleBoundBoosts(Workspace $workspace): void - { - $boosts = $workspace->boosts() - ->where('duration_type', Boost::DURATION_CYCLE_BOUND) - ->where('status', Boost::STATUS_ACTIVE) - ->get(); - - foreach ($boosts as $boost) { - $boost->expire(); - - EntitlementLog::logBoostAction( - $workspace, - EntitlementLog::ACTION_BOOST_EXPIRED, - $boost, - source: EntitlementLog::SOURCE_SYSTEM, - metadata: ['reason' => 'Billing cycle ended'] - ); - } - - $this->invalidateCache($workspace); - } - - // ───────────────────────────────────────────────────────────────────────── - // Namespace-specific methods - // ───────────────────────────────────────────────────────────────────────── - - /** - * Get the total limit for a feature from namespace-level packages + boosts. - * - * Returns null if feature not included, -1 if unlimited. - */ - protected function getNamespaceTotalLimit(Namespace_ $namespace, string $featureCode): ?int - { - $cacheKey = "entitlement:ns:{$namespace->id}:limit:{$featureCode}"; - - return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($namespace, $featureCode) { - $feature = $this->getFeature($featureCode); - - if (! $feature) { - return null; - } - - $totalLimit = 0; - $hasFeature = false; - - // Sum limits from active namespace packages - $packages = $namespace->namespacePackages() - ->with('package.features') - ->active() - ->notExpired() - ->get(); - - foreach ($packages as $namespacePackage) { - $packageFeature = $namespacePackage->package->features - ->where('code', $featureCode) - ->first(); - - if ($packageFeature) { - $hasFeature = true; - - // Check if unlimited in this package - if ($packageFeature->type === Feature::TYPE_UNLIMITED) { - return -1; - } - - // Add limit value (null = boolean, no limit to add) - $limitValue = $packageFeature->pivot->limit_value; - if ($limitValue !== null) { - $totalLimit += $limitValue; - } - } - } - - // Add limits from active namespace-level boosts - $boosts = $namespace->boosts() - ->forFeature($featureCode) - ->usable() - ->get(); - - foreach ($boosts as $boost) { - $hasFeature = true; - - if ($boost->boost_type === Boost::BOOST_TYPE_UNLIMITED) { - return -1; - } - - if ($boost->boost_type === Boost::BOOST_TYPE_ADD_LIMIT) { - $remaining = $boost->getRemainingLimit(); - if ($remaining !== null) { - $totalLimit += $remaining; - } - } - } - - return $hasFeature ? $totalLimit : null; - }); - } - - /** - * Get current usage for a feature at namespace level. - */ - protected function getNamespaceCurrentUsage(Namespace_ $namespace, string $featureCode, Feature $feature): int - { - $cacheKey = "entitlement:ns:{$namespace->id}:usage:{$featureCode}"; - - return Cache::remember($cacheKey, 60, function () use ($namespace, $featureCode, $feature) { - // Determine the time window for usage calculation - if ($feature->resetsMonthly()) { - // Get billing cycle anchor from the primary package - $primaryPackage = $namespace->namespacePackages() - ->whereHas('package', fn ($q) => $q->where('is_base_package', true)) - ->active() - ->first(); - - $cycleStart = $primaryPackage - ? $primaryPackage->getCurrentCycleStart() - : now()->startOfMonth(); - - return UsageRecord::where('namespace_id', $namespace->id) - ->where('feature_code', $featureCode) - ->where('recorded_at', '>=', $cycleStart) - ->sum('quantity'); - } - - if ($feature->resetsRolling()) { - $days = $feature->rolling_window_days ?? 30; - $since = now()->subDays($days); - - return UsageRecord::where('namespace_id', $namespace->id) - ->where('feature_code', $featureCode) - ->where('recorded_at', '>=', $since) - ->sum('quantity'); - } - - // No reset - all time usage - return UsageRecord::where('namespace_id', $namespace->id) - ->where('feature_code', $featureCode) - ->sum('quantity'); - }); - } - - /** - * Get usage summary for a namespace. - */ - public function getNamespaceUsageSummary(Namespace_ $namespace): Collection - { - $features = Feature::active()->orderBy('category')->orderBy('sort_order')->get(); - $summary = collect(); - - foreach ($features as $feature) { - $result = $this->canForNamespace($namespace, $feature->code); - - $summary->push([ - 'feature' => $feature, - 'code' => $feature->code, - 'name' => $feature->name, - 'category' => $feature->category, - 'type' => $feature->type, - 'allowed' => $result->isAllowed(), - 'limit' => $result->limit, - 'used' => $result->used, - 'remaining' => $result->remaining, - 'unlimited' => $result->isUnlimited(), - 'percentage' => $result->getUsagePercentage(), - 'near_limit' => $result->isNearLimit(), - ]); - } - - return $summary->groupBy('category'); - } - - /** - * Provision a package for a namespace. - */ - public function provisionNamespacePackage( - Namespace_ $namespace, - string $packageCode, - array $options = [] - ): NamespacePackage { - $package = Package::where('code', $packageCode)->firstOrFail(); - - // Check if this is a base package and namespace already has one - if ($package->is_base_package) { - $existingBase = $namespace->namespacePackages() - ->whereHas('package', fn ($q) => $q->where('is_base_package', true)) - ->active() - ->first(); - - if ($existingBase) { - // Cancel existing base package - $existingBase->cancel(now()); - } - } - - $namespacePackage = NamespacePackage::create([ - 'namespace_id' => $namespace->id, - 'package_id' => $package->id, - 'status' => NamespacePackage::STATUS_ACTIVE, - 'starts_at' => $options['starts_at'] ?? now(), - 'expires_at' => $options['expires_at'] ?? null, - 'billing_cycle_anchor' => $options['billing_cycle_anchor'] ?? now(), - 'metadata' => $options['metadata'] ?? null, - ]); - - $this->invalidateNamespaceCache($namespace); - - return $namespacePackage; - } - - /** - * Provision a boost for a namespace. - */ - public function provisionNamespaceBoost( - Namespace_ $namespace, - string $featureCode, - array $options = [] - ): Boost { - $boost = Boost::create([ - 'namespace_id' => $namespace->id, - 'workspace_id' => $namespace->workspace_id, - 'feature_code' => $featureCode, - 'boost_type' => $options['boost_type'] ?? Boost::BOOST_TYPE_ADD_LIMIT, - 'duration_type' => $options['duration_type'] ?? Boost::DURATION_CYCLE_BOUND, - 'limit_value' => $options['limit_value'] ?? null, - 'consumed_quantity' => 0, - 'status' => Boost::STATUS_ACTIVE, - 'starts_at' => $options['starts_at'] ?? now(), - 'expires_at' => $options['expires_at'] ?? null, - 'metadata' => $options['metadata'] ?? null, - ]); - - $this->invalidateNamespaceCache($namespace); - - return $boost; - } - - /** - * Invalidate all entitlement caches for a namespace. - */ - public function invalidateNamespaceCache(Namespace_ $namespace): void - { - $features = Feature::pluck('code'); - foreach ($features as $code) { - Cache::forget("entitlement:ns:{$namespace->id}:limit:{$code}"); - Cache::forget("entitlement:ns:{$namespace->id}:usage:{$code}"); - } - } -} diff --git a/packages/core-php/src/Mod/Tenant/Services/EntitlementWebhookService.php b/packages/core-php/src/Mod/Tenant/Services/EntitlementWebhookService.php deleted file mode 100644 index c9b2818..0000000 --- a/packages/core-php/src/Mod/Tenant/Services/EntitlementWebhookService.php +++ /dev/null @@ -1,361 +0,0 @@ - - */ - public function dispatch(Workspace $workspace, EntitlementWebhookEvent $event, bool $async = true): array - { - $eventName = $event::name(); - $results = []; - - $webhooks = EntitlementWebhook::query() - ->forWorkspace($workspace) - ->active() - ->forEvent($eventName) - ->get(); - - foreach ($webhooks as $webhook) { - if ($async) { - // Dispatch via job for async processing - DispatchEntitlementWebhook::dispatch($webhook->id, $eventName, $event->payload()); - - $results[] = [ - 'webhook_id' => $webhook->id, - 'success' => true, - 'queued' => true, - ]; - } else { - // Synchronous dispatch - try { - $delivery = $webhook->trigger($event); - $results[] = [ - 'webhook_id' => $webhook->id, - 'success' => $delivery->isSucceeded(), - 'delivery_id' => $delivery->id, - ]; - } catch (\Exception $e) { - Log::error('Webhook dispatch failed', [ - 'webhook_id' => $webhook->id, - 'event' => $eventName, - 'error' => $e->getMessage(), - ]); - - $results[] = [ - 'webhook_id' => $webhook->id, - 'success' => false, - 'error' => $e->getMessage(), - ]; - } - } - } - - return $results; - } - - /** - * Register a new webhook for a workspace. - */ - public function register( - Workspace $workspace, - string $name, - string $url, - array $events, - ?string $secret = null, - array $metadata = [] - ): EntitlementWebhook { - // Generate secret if not provided - $secret ??= bin2hex(random_bytes(32)); - - return EntitlementWebhook::create([ - 'workspace_id' => $workspace->id, - 'name' => $name, - 'url' => $url, - 'secret' => $secret, - 'events' => array_intersect($events, EntitlementWebhook::EVENTS), - 'is_active' => true, - 'max_attempts' => 3, - 'metadata' => $metadata, - ]); - } - - /** - * Unregister (delete) a webhook. - */ - public function unregister(EntitlementWebhook $webhook): bool - { - return $webhook->delete(); - } - - /** - * Update webhook configuration. - */ - public function update( - EntitlementWebhook $webhook, - array $attributes - ): EntitlementWebhook { - // Filter events to only allowed values - if (isset($attributes['events'])) { - $attributes['events'] = array_intersect($attributes['events'], EntitlementWebhook::EVENTS); - } - - $webhook->update($attributes); - - return $webhook->refresh(); - } - - /** - * Sign a payload with HMAC-SHA256. - */ - public function sign(array $payload, string $secret): string - { - return hash_hmac('sha256', json_encode($payload), $secret); - } - - /** - * Verify a webhook signature. - */ - public function verifySignature(array $payload, string $signature, string $secret): bool - { - $expected = $this->sign($payload, $secret); - - return hash_equals($expected, $signature); - } - - /** - * Get all available event types with descriptions. - * - * @return array}> - */ - public function getAvailableEvents(): array - { - return [ - 'limit_warning' => [ - 'name' => LimitWarningEvent::nameLocalised(), - 'description' => __('Triggered when usage reaches 80% or 90% of a feature limit'), - 'class' => LimitWarningEvent::class, - ], - 'limit_reached' => [ - 'name' => LimitReachedEvent::nameLocalised(), - 'description' => __('Triggered when usage reaches 100% of a feature limit'), - 'class' => LimitReachedEvent::class, - ], - 'package_changed' => [ - 'name' => PackageChangedEvent::nameLocalised(), - 'description' => __('Triggered when a workspace package is added, changed, or removed'), - 'class' => PackageChangedEvent::class, - ], - 'boost_activated' => [ - 'name' => BoostActivatedEvent::nameLocalised(), - 'description' => __('Triggered when a boost is activated for a workspace'), - 'class' => BoostActivatedEvent::class, - ], - 'boost_expired' => [ - 'name' => BoostExpiredEvent::nameLocalised(), - 'description' => __('Triggered when a boost expires'), - 'class' => BoostExpiredEvent::class, - ], - ]; - } - - /** - * Get event names as a simple array for forms. - * - * @return array - */ - public function getEventOptions(): array - { - $events = $this->getAvailableEvents(); - $options = []; - - foreach ($events as $key => $event) { - $options[$key] = $event['name']; - } - - return $options; - } - - /** - * Test a webhook by sending a test event. - */ - public function testWebhook(EntitlementWebhook $webhook): EntitlementWebhookDelivery - { - $testPayload = [ - 'event' => 'test', - 'data' => [ - 'webhook_id' => $webhook->id, - 'webhook_name' => $webhook->name, - 'message' => 'This is a test webhook delivery from '.$webhook->workspace->name, - 'subscribed_events' => $webhook->events, - ], - 'timestamp' => now()->toIso8601String(), - ]; - - try { - $headers = [ - 'Content-Type' => 'application/json', - 'X-Request-Source' => config('app.name'), - 'User-Agent' => config('app.name').' Entitlement Webhook', - 'X-Test-Webhook' => 'true', - ]; - - if ($webhook->secret) { - $headers['X-Signature'] = $this->sign($testPayload, $webhook->secret); - } - - $response = Http::withHeaders($headers) - ->timeout(10) - ->post($webhook->url, $testPayload); - - $status = in_array($response->status(), [200, 201, 202, 204]) - ? WebhookDeliveryStatus::SUCCESS - : WebhookDeliveryStatus::FAILED; - - return $webhook->deliveries()->create([ - 'uuid' => Str::uuid(), - 'event' => 'test', - 'status' => $status, - 'http_status' => $response->status(), - 'payload' => $testPayload, - 'response' => $response->json() ?: ['body' => $response->body()], - 'created_at' => now(), - ]); - } catch (\Exception $e) { - return $webhook->deliveries()->create([ - 'uuid' => Str::uuid(), - 'event' => 'test', - 'status' => WebhookDeliveryStatus::FAILED, - 'payload' => $testPayload, - 'response' => ['error' => $e->getMessage()], - 'created_at' => now(), - ]); - } - } - - /** - * Retry a failed delivery. - */ - public function retryDelivery(EntitlementWebhookDelivery $delivery): EntitlementWebhookDelivery - { - $webhook = $delivery->webhook; - - if (! $webhook->isActive()) { - throw new \RuntimeException('Cannot retry delivery for inactive webhook'); - } - - $payload = $delivery->payload; - - try { - $headers = [ - 'Content-Type' => 'application/json', - 'X-Request-Source' => config('app.name'), - 'User-Agent' => config('app.name').' Entitlement Webhook', - 'X-Retry-Attempt' => (string) ($delivery->attempts + 1), - ]; - - if ($webhook->secret) { - $headers['X-Signature'] = $this->sign($payload, $webhook->secret); - } - - $response = Http::withHeaders($headers) - ->timeout(10) - ->post($webhook->url, $payload); - - $status = in_array($response->status(), [200, 201, 202, 204]) - ? WebhookDeliveryStatus::SUCCESS - : WebhookDeliveryStatus::FAILED; - - $delivery->update([ - 'attempts' => $delivery->attempts + 1, - 'status' => $status, - 'http_status' => $response->status(), - 'response' => $response->json() ?: ['body' => $response->body()], - 'resent_manually' => true, - ]); - - if ($status === WebhookDeliveryStatus::SUCCESS) { - $webhook->resetFailureCount(); - } else { - $webhook->incrementFailureCount(); - } - - $webhook->updateLastDeliveryStatus($status); - - return $delivery; - } catch (\Exception $e) { - $delivery->update([ - 'attempts' => $delivery->attempts + 1, - 'status' => WebhookDeliveryStatus::FAILED, - 'response' => ['error' => $e->getMessage()], - 'resent_manually' => true, - ]); - - $webhook->incrementFailureCount(); - $webhook->updateLastDeliveryStatus(WebhookDeliveryStatus::FAILED); - - return $delivery; - } - } - - /** - * Re-enable a circuit-broken webhook after fixing the issue. - */ - public function resetCircuitBreaker(EntitlementWebhook $webhook): void - { - $webhook->update([ - 'is_active' => true, - 'failure_count' => 0, - ]); - } - - /** - * Get webhooks for a workspace. - */ - public function getWebhooksForWorkspace(Workspace $workspace): \Illuminate\Database\Eloquent\Collection - { - return EntitlementWebhook::query() - ->forWorkspace($workspace) - ->with(['deliveries' => fn ($q) => $q->latest('created_at')->limit(5)]) - ->latest() - ->get(); - } - - /** - * Get delivery history for a webhook. - */ - public function getDeliveryHistory(EntitlementWebhook $webhook, int $limit = 50): \Illuminate\Database\Eloquent\Collection - { - return $webhook->deliveries() - ->latest('created_at') - ->limit($limit) - ->get(); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Services/NamespaceManager.php b/packages/core-php/src/Mod/Tenant/Services/NamespaceManager.php deleted file mode 100644 index 531539c..0000000 --- a/packages/core-php/src/Mod/Tenant/Services/NamespaceManager.php +++ /dev/null @@ -1,278 +0,0 @@ -fill([ - 'name' => $data['name'], - 'slug' => $data['slug'] ?? Str::slug($data['name']), - 'description' => $data['description'] ?? null, - 'icon' => $data['icon'] ?? 'folder', - 'color' => $data['color'] ?? 'zinc', - 'owner_type' => User::class, - 'owner_id' => $user->id, - 'workspace_id' => $data['workspace_id'] ?? null, - 'settings' => $data['settings'] ?? null, - 'is_default' => $data['is_default'] ?? false, - 'is_active' => $data['is_active'] ?? true, - 'sort_order' => $data['sort_order'] ?? 0, - ]); - - // If this is marked as default, unset other defaults - if ($namespace->is_default) { - Namespace_::ownedByUser($user) - ->where('is_default', true) - ->update(['is_default' => false]); - } - - $namespace->save(); - - // Invalidate cache - $this->namespaceService->invalidateUserCache($user); - - return $namespace; - } - - /** - * Create a namespace for a workspace. - */ - public function createForWorkspace(Workspace $workspace, array $data): Namespace_ - { - $namespace = new Namespace_(); - $namespace->fill([ - 'name' => $data['name'], - 'slug' => $data['slug'] ?? Str::slug($data['name']), - 'description' => $data['description'] ?? null, - 'icon' => $data['icon'] ?? 'folder', - 'color' => $data['color'] ?? 'zinc', - 'owner_type' => Workspace::class, - 'owner_id' => $workspace->id, - 'workspace_id' => $workspace->id, // Billing context is the owner workspace - 'settings' => $data['settings'] ?? null, - 'is_default' => $data['is_default'] ?? false, - 'is_active' => $data['is_active'] ?? true, - 'sort_order' => $data['sort_order'] ?? 0, - ]); - - // If this is marked as default, unset other defaults - if ($namespace->is_default) { - Namespace_::ownedByWorkspace($workspace) - ->where('is_default', true) - ->update(['is_default' => false]); - } - - $namespace->save(); - - // Invalidate cache for all workspace members - foreach ($workspace->users as $member) { - $this->namespaceService->invalidateUserCache($member); - } - - return $namespace; - } - - /** - * Create the default namespace for a user. - * - * This is typically called when a user first signs up. - */ - public function createDefaultForUser(User $user): Namespace_ - { - return $this->createForUser($user, [ - 'name' => 'Personal', - 'slug' => 'personal', - 'description' => 'Your personal workspace', - 'icon' => 'user', - 'color' => 'blue', - 'is_default' => true, - ]); - } - - /** - * Create the default namespace for a workspace. - * - * This is typically called when a workspace is created. - */ - public function createDefaultForWorkspace(Workspace $workspace): Namespace_ - { - return $this->createForWorkspace($workspace, [ - 'name' => $workspace->name, - 'slug' => 'default', - 'description' => "Default namespace for {$workspace->name}", - 'icon' => $workspace->icon ?? 'building', - 'color' => $workspace->color ?? 'zinc', - 'is_default' => true, - ]); - } - - /** - * Update a namespace. - */ - public function update(Namespace_ $namespace, array $data): Namespace_ - { - $wasDefault = $namespace->is_default; - - $namespace->fill(array_filter([ - 'name' => $data['name'] ?? null, - 'slug' => $data['slug'] ?? null, - 'description' => $data['description'] ?? null, - 'icon' => $data['icon'] ?? null, - 'color' => $data['color'] ?? null, - 'workspace_id' => array_key_exists('workspace_id', $data) ? $data['workspace_id'] : $namespace->workspace_id, - 'settings' => $data['settings'] ?? null, - 'is_default' => $data['is_default'] ?? null, - 'is_active' => $data['is_active'] ?? null, - 'sort_order' => $data['sort_order'] ?? null, - ], fn ($v) => $v !== null)); - - // If becoming default, unset other defaults for same owner - if (! $wasDefault && $namespace->is_default) { - Namespace_::where('owner_type', $namespace->owner_type) - ->where('owner_id', $namespace->owner_id) - ->where('id', '!=', $namespace->id) - ->where('is_default', true) - ->update(['is_default' => false]); - } - - $namespace->save(); - - // Invalidate cache - $this->namespaceService->invalidateCache($namespace->uuid); - $this->invalidateCacheForOwner($namespace); - - return $namespace; - } - - /** - * Delete (soft delete) a namespace. - */ - public function delete(Namespace_ $namespace): bool - { - // Invalidate cache first - $this->namespaceService->invalidateCache($namespace->uuid); - $this->invalidateCacheForOwner($namespace); - - // If this was the default, make another one default - if ($namespace->is_default) { - $newDefault = Namespace_::where('owner_type', $namespace->owner_type) - ->where('owner_id', $namespace->owner_id) - ->where('id', '!=', $namespace->id) - ->active() - ->ordered() - ->first(); - - if ($newDefault) { - $newDefault->update(['is_default' => true]); - } - } - - return $namespace->delete(); - } - - /** - * Restore a soft-deleted namespace. - */ - public function restore(Namespace_ $namespace): bool - { - $result = $namespace->restore(); - - // Invalidate cache - $this->namespaceService->invalidateCache($namespace->uuid); - $this->invalidateCacheForOwner($namespace); - - return $result; - } - - /** - * Set a namespace as the default for its owner. - */ - public function setAsDefault(Namespace_ $namespace): Namespace_ - { - // Unset other defaults - Namespace_::where('owner_type', $namespace->owner_type) - ->where('owner_id', $namespace->owner_id) - ->where('id', '!=', $namespace->id) - ->where('is_default', true) - ->update(['is_default' => false]); - - // Set this as default - $namespace->update(['is_default' => true]); - - // Invalidate cache - $this->invalidateCacheForOwner($namespace); - - return $namespace; - } - - /** - * Transfer a namespace to a new owner. - */ - public function transfer(Namespace_ $namespace, User|Workspace $newOwner): Namespace_ - { - $oldOwnerType = $namespace->owner_type; - $oldOwnerId = $namespace->owner_id; - - // Update ownership - $namespace->update([ - 'owner_type' => $newOwner::class, - 'owner_id' => $newOwner->id, - 'is_default' => false, // Can't be default in new context automatically - ]); - - // Invalidate cache - $this->namespaceService->invalidateCache($namespace->uuid); - - // Invalidate for old owner - if ($oldOwnerType === User::class) { - $this->namespaceService->invalidateUserCache(User::find($oldOwnerId)); - } else { - $workspace = Workspace::find($oldOwnerId); - foreach ($workspace->users as $member) { - $this->namespaceService->invalidateUserCache($member); - } - } - - // Invalidate for new owner - $this->invalidateCacheForOwner($namespace); - - return $namespace; - } - - /** - * Invalidate cache for the owner of a namespace. - */ - protected function invalidateCacheForOwner(Namespace_ $namespace): void - { - if ($namespace->isOwnedByUser()) { - $this->namespaceService->invalidateUserCache($namespace->owner); - } else { - foreach ($namespace->owner->users as $member) { - $this->namespaceService->invalidateUserCache($member); - } - } - } -} diff --git a/packages/core-php/src/Mod/Tenant/Services/NamespaceService.php b/packages/core-php/src/Mod/Tenant/Services/NamespaceService.php deleted file mode 100644 index 91418d2..0000000 --- a/packages/core-php/src/Mod/Tenant/Services/NamespaceService.php +++ /dev/null @@ -1,288 +0,0 @@ -attributes->has('current_namespace')) { - return request()->attributes->get('current_namespace'); - } - - // Try from session - $uuid = session('current_namespace_uuid'); - if ($uuid) { - $namespace = $this->findByUuid($uuid); - if ($namespace && $this->canAccess($namespace)) { - return $namespace; - } - } - - // Fall back to user's default - return $this->defaultForCurrentUser(); - } - - /** - * Get the current namespace UUID from session. - */ - public function currentUuid(): ?string - { - return session('current_namespace_uuid'); - } - - /** - * Set the current namespace in session. - */ - public function setCurrent(Namespace_|string $namespace): void - { - $uuid = $namespace instanceof Namespace_ ? $namespace->uuid : $namespace; - - session(['current_namespace_uuid' => $uuid]); - } - - /** - * Clear the current namespace from session. - */ - public function clearCurrent(): void - { - session()->forget('current_namespace_uuid'); - } - - /** - * Find a namespace by UUID. - */ - public function findByUuid(string $uuid): ?Namespace_ - { - return Cache::remember( - "namespace:uuid:{$uuid}", - self::CACHE_TTL, - fn () => Namespace_::where('uuid', $uuid)->first() - ); - } - - /** - * Find a namespace by slug within an owner context. - */ - public function findBySlug(string $slug, User|Workspace $owner): ?Namespace_ - { - return Namespace_::where('owner_type', $owner::class) - ->where('owner_id', $owner->id) - ->where('slug', $slug) - ->first(); - } - - /** - * Get the default namespace for the current authenticated user. - */ - public function defaultForCurrentUser(): ?Namespace_ - { - $user = auth()->user(); - - if (! $user instanceof User) { - return null; - } - - return $this->defaultForUser($user); - } - - /** - * Get the default namespace for a user. - * - * Priority: - * 1. User's default namespace (is_default = true) - * 2. First active user-owned namespace - * 3. First namespace from user's default workspace - */ - public function defaultForUser(User $user): ?Namespace_ - { - // Try user's explicit default - $default = Namespace_::ownedByUser($user) - ->where('is_default', true) - ->active() - ->first(); - - if ($default) { - return $default; - } - - // Try first user-owned namespace - $userOwned = Namespace_::ownedByUser($user) - ->active() - ->ordered() - ->first(); - - if ($userOwned) { - return $userOwned; - } - - // Try namespace from user's default workspace - $workspace = $user->defaultHostWorkspace(); - if ($workspace) { - return Namespace_::ownedByWorkspace($workspace) - ->active() - ->ordered() - ->first(); - } - - return null; - } - - /** - * Get all namespaces accessible by the current user. - */ - public function accessibleByCurrentUser(): Collection - { - $user = auth()->user(); - - if (! $user instanceof User) { - return collect(); - } - - return $this->accessibleByUser($user); - } - - /** - * Get all namespaces accessible by a user. - */ - public function accessibleByUser(User $user): Collection - { - return Cache::remember( - "user:{$user->id}:accessible_namespaces", - self::CACHE_TTL, - fn () => Namespace_::accessibleBy($user) - ->active() - ->ordered() - ->get() - ); - } - - /** - * Get all namespaces owned by a user. - */ - public function ownedByUser(User $user): Collection - { - return Namespace_::ownedByUser($user) - ->active() - ->ordered() - ->get(); - } - - /** - * Get all namespaces owned by a workspace. - */ - public function ownedByWorkspace(Workspace $workspace): Collection - { - return Namespace_::ownedByWorkspace($workspace) - ->active() - ->ordered() - ->get(); - } - - /** - * Check if the current user can access a namespace. - */ - public function canAccess(Namespace_ $namespace): bool - { - $user = auth()->user(); - - if (! $user instanceof User) { - return false; - } - - return $namespace->isAccessibleBy($user); - } - - /** - * Group namespaces by owner type for UI display. - * - * Returns: - * [ - * 'personal' => Collection of user-owned namespaces, - * 'workspaces' => [ - * ['workspace' => Workspace, 'namespaces' => Collection], - * ... - * ] - * ] - */ - public function groupedForCurrentUser(): array - { - $user = auth()->user(); - - if (! $user instanceof User) { - return ['personal' => collect(), 'workspaces' => []]; - } - - return $this->groupedForUser($user); - } - - /** - * Group namespaces by owner type for a user. - */ - public function groupedForUser(User $user): array - { - $personal = Namespace_::ownedByUser($user) - ->active() - ->ordered() - ->get(); - - $workspaces = []; - foreach ($user->workspaces()->active()->get() as $workspace) { - $namespaces = Namespace_::ownedByWorkspace($workspace) - ->active() - ->ordered() - ->get(); - - if ($namespaces->isNotEmpty()) { - $workspaces[] = [ - 'workspace' => $workspace, - 'namespaces' => $namespaces, - ]; - } - } - - return [ - 'personal' => $personal, - 'workspaces' => $workspaces, - ]; - } - - /** - * Invalidate namespace cache for a user. - */ - public function invalidateUserCache(User $user): void - { - Cache::forget("user:{$user->id}:accessible_namespaces"); - } - - /** - * Invalidate namespace cache by UUID. - */ - public function invalidateCache(string $uuid): void - { - Cache::forget("namespace:uuid:{$uuid}"); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Services/TotpService.php b/packages/core-php/src/Mod/Tenant/Services/TotpService.php deleted file mode 100644 index 54de492..0000000 --- a/packages/core-php/src/Mod/Tenant/Services/TotpService.php +++ /dev/null @@ -1,194 +0,0 @@ -base32Encode($secret); - } - - /** - * Generate QR code URL for authenticator app setup. - * - * @param string $name Application/account name - * @param string $email User email - * @param string $secret TOTP secret key - */ - public function qrCodeUrl(string $name, string $email, string $secret): string - { - $encodedName = rawurlencode($name); - $encodedEmail = rawurlencode($email); - - return "otpauth://totp/{$encodedName}:{$encodedEmail}?secret={$secret}&issuer={$encodedName}&algorithm=SHA1&digits=6&period=30"; - } - - /** - * Generate a QR code SVG for the given URL. - */ - public function qrCodeSvg(string $url): string - { - $options = new QROptions([ - 'outputType' => QRCode::OUTPUT_MARKUP_SVG, - 'eccLevel' => QRCode::ECC_M, - 'imageBase64' => false, - 'addQuietzone' => true, - 'quietzoneSize' => 2, - 'drawLightModules' => false, - 'svgViewBoxSize' => 200, - ]); - - return (new QRCode($options))->render($url); - } - - /** - * Verify a TOTP code against the secret. - * - * @param string $secret TOTP secret key (base32 encoded) - * @param string $code User-provided 6-digit code - */ - public function verify(string $secret, string $code): bool - { - // Remove any spaces or dashes from the code - $code = preg_replace('/[^0-9]/', '', $code); - - if (strlen($code) !== self::CODE_LENGTH) { - return false; - } - - $secretBytes = $this->base32Decode($secret); - $timestamp = time(); - - // Check current time and adjacent windows for clock drift - for ($i = -self::WINDOW; $i <= self::WINDOW; $i++) { - $calculatedCode = $this->generateCode($secretBytes, $timestamp + ($i * self::TIME_STEP)); - - if (hash_equals($calculatedCode, $code)) { - return true; - } - } - - return false; - } - - /** - * Generate a TOTP code for a given timestamp. - */ - protected function generateCode(string $secretBytes, int $timestamp): string - { - $counter = (int) floor($timestamp / self::TIME_STEP); - - // Pack counter as 64-bit big-endian - $counterBytes = pack('N*', 0, $counter); - - // Generate HMAC - $hash = hash_hmac(self::ALGORITHM, $counterBytes, $secretBytes, true); - - // Dynamic truncation - $offset = ord($hash[strlen($hash) - 1]) & 0x0F; - $binary = - ((ord($hash[$offset]) & 0x7F) << 24) | - ((ord($hash[$offset + 1]) & 0xFF) << 16) | - ((ord($hash[$offset + 2]) & 0xFF) << 8) | - (ord($hash[$offset + 3]) & 0xFF); - - $otp = $binary % (10 ** self::CODE_LENGTH); - - return str_pad((string) $otp, self::CODE_LENGTH, '0', STR_PAD_LEFT); - } - - /** - * Encode bytes as base32. - */ - protected function base32Encode(string $data): string - { - $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; - $binary = ''; - - foreach (str_split($data) as $char) { - $binary .= str_pad(decbin(ord($char)), 8, '0', STR_PAD_LEFT); - } - - $encoded = ''; - $chunks = str_split($binary, 5); - - foreach ($chunks as $chunk) { - if (strlen($chunk) < 5) { - $chunk = str_pad($chunk, 5, '0', STR_PAD_RIGHT); - } - $encoded .= $alphabet[bindec($chunk)]; - } - - return $encoded; - } - - /** - * Decode base32 to bytes. - */ - protected function base32Decode(string $data): string - { - $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; - $data = strtoupper($data); - $data = rtrim($data, '='); - - $binary = ''; - foreach (str_split($data) as $char) { - $index = strpos($alphabet, $char); - if ($index === false) { - continue; - } - $binary .= str_pad(decbin($index), 5, '0', STR_PAD_LEFT); - } - - $decoded = ''; - $chunks = str_split($binary, 8); - - foreach ($chunks as $chunk) { - if (strlen($chunk) === 8) { - $decoded .= chr(bindec($chunk)); - } - } - - return $decoded; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Services/UsageAlertService.php b/packages/core-php/src/Mod/Tenant/Services/UsageAlertService.php deleted file mode 100644 index af0dd68..0000000 --- a/packages/core-php/src/Mod/Tenant/Services/UsageAlertService.php +++ /dev/null @@ -1,356 +0,0 @@ - 0, - 'alerts_sent' => 0, - 'alerts_resolved' => 0, - ]; - - // Get all active workspaces with packages - $workspaces = Workspace::query() - ->active() - ->whereHas('workspacePackages', fn ($q) => $q->active()) - ->get(); - - foreach ($workspaces as $workspace) { - $result = $this->checkWorkspace($workspace); - $stats['checked']++; - $stats['alerts_sent'] += $result['alerts_sent']; - $stats['alerts_resolved'] += $result['alerts_resolved']; - } - - return $stats; - } - - /** - * Check a single workspace for usage alerts. - * - * @return array{alerts_sent: int, alerts_resolved: int, details: array} - */ - public function checkWorkspace(Workspace $workspace): array - { - $alertsSent = 0; - $alertsResolved = 0; - $details = []; - - // Get all features with limits (not boolean, not unlimited) - $features = Feature::active() - ->where('type', Feature::TYPE_LIMIT) - ->get(); - - foreach ($features as $feature) { - $result = $this->checkFeatureUsage($workspace, $feature); - - if ($result['alert_sent']) { - $alertsSent++; - } - - if ($result['resolved']) { - $alertsResolved++; - } - - if ($result['alert_sent'] || $result['resolved']) { - $details[] = $result; - } - } - - return [ - 'alerts_sent' => $alertsSent, - 'alerts_resolved' => $alertsResolved, - 'details' => $details, - ]; - } - - /** - * Check usage for a specific feature and send alert if needed. - * - * @return array{feature: string, percentage: float|null, threshold: int|null, alert_sent: bool, resolved: bool} - */ - public function checkFeatureUsage(Workspace $workspace, Feature $feature): array - { - $result = [ - 'feature' => $feature->code, - 'percentage' => null, - 'threshold' => null, - 'alert_sent' => false, - 'resolved' => false, - ]; - - // Get entitlement check result - $entitlement = $this->entitlementService->can($workspace, $feature->code); - - // Skip if unlimited or no limit - if ($entitlement->isUnlimited() || $entitlement->limit === null || $entitlement->limit === 0) { - // Check if there are any unresolved alerts to clear - $resolved = UsageAlertHistory::resolveAllForFeature($workspace->id, $feature->code); - $result['resolved'] = $resolved > 0; - - return $result; - } - - $percentage = $entitlement->getUsagePercentage(); - $result['percentage'] = $percentage; - - // Determine the applicable threshold - $applicableThreshold = $this->getApplicableThreshold($percentage); - - // If usage dropped below all thresholds, resolve any active alerts - if ($applicableThreshold === null) { - $resolved = UsageAlertHistory::resolveAllForFeature($workspace->id, $feature->code); - $result['resolved'] = $resolved > 0; - - return $result; - } - - $result['threshold'] = $applicableThreshold; - - // Check if we've already sent an alert for this threshold - if (UsageAlertHistory::hasActiveAlert($workspace->id, $feature->code, $applicableThreshold)) { - return $result; - } - - // Send the alert - $this->sendAlert($workspace, $feature, $applicableThreshold, $entitlement->used, $entitlement->limit); - $result['alert_sent'] = true; - - return $result; - } - - /** - * Determine which threshold applies based on usage percentage. - */ - protected function getApplicableThreshold(?float $percentage): ?int - { - if ($percentage === null) { - return null; - } - - // Return the highest applicable threshold - if ($percentage >= UsageAlertHistory::THRESHOLD_LIMIT) { - return UsageAlertHistory::THRESHOLD_LIMIT; - } - - if ($percentage >= UsageAlertHistory::THRESHOLD_CRITICAL) { - return UsageAlertHistory::THRESHOLD_CRITICAL; - } - - if ($percentage >= UsageAlertHistory::THRESHOLD_WARNING) { - return UsageAlertHistory::THRESHOLD_WARNING; - } - - return null; - } - - /** - * Send a usage alert notification. - */ - protected function sendAlert( - Workspace $workspace, - Feature $feature, - int $threshold, - int $used, - int $limit - ): void { - // Get workspace owner to notify - $owner = $workspace->owner(); - - if (! $owner) { - Log::warning('Cannot send usage alert: workspace has no owner', [ - 'workspace_id' => $workspace->id, - 'feature_code' => $feature->code, - 'threshold' => $threshold, - ]); - - return; - } - - // Record the alert - UsageAlertHistory::record( - workspaceId: $workspace->id, - featureCode: $feature->code, - threshold: $threshold, - metadata: [ - 'used' => $used, - 'limit' => $limit, - 'percentage' => round(($used / $limit) * 100), - 'notified_user_id' => $owner->id, - ] - ); - - // Send notification - $owner->notify(new UsageAlertNotification( - workspace: $workspace, - feature: $feature, - threshold: $threshold, - used: $used, - limit: $limit - )); - - Log::info('Usage alert sent', [ - 'workspace_id' => $workspace->id, - 'workspace_name' => $workspace->name, - 'feature_code' => $feature->code, - 'threshold' => $threshold, - 'used' => $used, - 'limit' => $limit, - 'user_id' => $owner->id, - 'user_email' => $owner->email, - ]); - - // Dispatch webhook event - $this->dispatchWebhook($workspace, $feature, $threshold, $used, $limit); - } - - /** - * Dispatch webhook event for usage alert. - */ - protected function dispatchWebhook( - Workspace $workspace, - Feature $feature, - int $threshold, - int $used, - int $limit - ): void { - // Lazy load webhook service if not injected - $webhookService = $this->webhookService ?? app(EntitlementWebhookService::class); - - // Create appropriate event based on threshold - if ($threshold === UsageAlertHistory::THRESHOLD_LIMIT) { - $event = new LimitReachedEvent($workspace, $feature, $used, $limit); - } else { - $event = new LimitWarningEvent($workspace, $feature, $used, $limit, $threshold); - } - - // Dispatch to all matching webhooks (async) - try { - $webhookService->dispatch($workspace, $event); - } catch (\Exception $e) { - Log::error('Failed to dispatch usage alert webhook', [ - 'workspace_id' => $workspace->id, - 'feature_code' => $feature->code, - 'threshold' => $threshold, - 'error' => $e->getMessage(), - ]); - } - } - - /** - * Get current alert status for a workspace. - * - * Returns all features that have active alerts. - */ - public function getActiveAlertsForWorkspace(Workspace $workspace): Collection - { - return UsageAlertHistory::query() - ->forWorkspace($workspace->id) - ->unresolved() - ->with('workspace') - ->orderBy('threshold', 'desc') - ->orderBy('notified_at', 'desc') - ->get(); - } - - /** - * Get usage status for all features in a workspace. - * - * Returns features approaching limits with their alert status. - */ - public function getUsageStatus(Workspace $workspace): Collection - { - $features = Feature::active() - ->where('type', Feature::TYPE_LIMIT) - ->get(); - - return $features->map(function (Feature $feature) use ($workspace) { - $entitlement = $this->entitlementService->can($workspace, $feature->code); - $percentage = $entitlement->getUsagePercentage(); - $activeAlert = UsageAlertHistory::getActiveAlert($workspace->id, $feature->code); - - return [ - 'feature' => $feature, - 'code' => $feature->code, - 'name' => $feature->name, - 'used' => $entitlement->used, - 'limit' => $entitlement->limit, - 'percentage' => $percentage, - 'unlimited' => $entitlement->isUnlimited(), - 'near_limit' => $entitlement->isNearLimit(), - 'at_limit' => $entitlement->isAtLimit(), - 'active_alert' => $activeAlert, - 'alert_threshold' => $activeAlert?->threshold, - ]; - })->filter(fn ($item) => $item['limit'] !== null && ! $item['unlimited']); - } - - /** - * Manually resolve an alert (e.g., after user upgrades). - */ - public function resolveAlert(int $alertId): bool - { - $alert = UsageAlertHistory::find($alertId); - - if (! $alert || $alert->isResolved()) { - return false; - } - - $alert->resolve(); - - Log::info('Usage alert manually resolved', [ - 'alert_id' => $alertId, - 'workspace_id' => $alert->workspace_id, - 'feature_code' => $alert->feature_code, - ]); - - return true; - } - - /** - * Get alert history for a workspace. - */ - public function getAlertHistory(Workspace $workspace, int $days = 30): Collection - { - return UsageAlertHistory::query() - ->forWorkspace($workspace->id) - ->where('notified_at', '>=', now()->subDays($days)) - ->orderBy('notified_at', 'desc') - ->get(); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Services/UserStatsService.php b/packages/core-php/src/Mod/Tenant/Services/UserStatsService.php deleted file mode 100644 index 103caea..0000000 --- a/packages/core-php/src/Mod/Tenant/Services/UserStatsService.php +++ /dev/null @@ -1,284 +0,0 @@ -getTier(); - - $stats = [ - 'quotas' => $this->computeQuotas($user, $tier), - 'services' => $this->computeServiceStats($user), - 'activity' => $this->getRecentActivity($user), - ]; - - // Save to user record - $user->cached_stats = $stats; - $user->stats_computed_at = now(); - $user->save(); - - return $stats; - } - - /** - * Get cached stats or compute fresh if stale (> 5 minutes). - */ - public function getStats(User $user): array - { - // Return cached if fresh (computed within last 5 minutes) - if ($user->stats_computed_at && $user->stats_computed_at->gt(now()->subMinutes(5))) { - return $user->cached_stats ?? $this->getDefaultStats($user); - } - - // For page loads, return cached data immediately and queue refresh - if ($user->cached_stats) { - // Queue background refresh - dispatch(new \Core\Mod\Tenant\Jobs\ComputeUserStats($user->id))->onQueue('stats'); - - return $user->cached_stats; - } - - // No cached data - compute synchronously (first time only) - return $this->computeStats($user); - } - - /** - * Get default stats structure for a user tier. - */ - public function getDefaultStats(User $user): array - { - $tier = $user->getTier(); - - return [ - 'quotas' => $this->getTierLimits($tier), - 'services' => $this->getDefaultServiceStats(), - 'activity' => [], - ]; - } - - /** - * Compute actual quota usage for user. - */ - protected function computeQuotas(User $user, UserTier $tier): array - { - $limits = $this->getTierLimits($tier); - - // Compute actual usage - // Host Hub workspaces the user has access to (via pivot table) - $workspaceCount = $user->hostWorkspaces()->count(); - $limits['workspaces']['used'] = $workspaceCount; - - // Social accounts across all workspaces - // TODO: Implement when social accounts are linked - // $socialAccountCount = ... - - // Scheduled posts - // TODO: Implement when scheduled posts are linked - // $scheduledPostCount = ... - - // Storage usage - // TODO: Implement when media storage tracking is added - // $storageUsed = ... - - return $limits; - } - - /** - * Get tier limits configuration. - */ - protected function getTierLimits(UserTier $tier): array - { - return match ($tier) { - UserTier::HADES => [ - 'workspaces' => ['used' => 0, 'limit' => null, 'label' => 'Workspaces'], - 'social_accounts' => ['used' => 0, 'limit' => null, 'label' => 'Social Accounts'], - 'scheduled_posts' => ['used' => 0, 'limit' => null, 'label' => 'Scheduled Posts'], - 'storage' => ['used' => 0, 'limit' => null, 'label' => 'Storage (GB)'], - ], - UserTier::APOLLO => [ - 'workspaces' => ['used' => 0, 'limit' => 5, 'label' => 'Workspaces'], - 'social_accounts' => ['used' => 0, 'limit' => 25, 'label' => 'Social Accounts'], - 'scheduled_posts' => ['used' => 0, 'limit' => 500, 'label' => 'Scheduled Posts'], - 'storage' => ['used' => 0, 'limit' => 10, 'label' => 'Storage (GB)'], - ], - default => [ - 'workspaces' => ['used' => 0, 'limit' => 1, 'label' => 'Workspaces'], - 'social_accounts' => ['used' => 0, 'limit' => 5, 'label' => 'Social Accounts'], - 'scheduled_posts' => ['used' => 0, 'limit' => 50, 'label' => 'Scheduled Posts'], - 'storage' => ['used' => 0, 'limit' => 1, 'label' => 'Storage (GB)'], - ], - }; - } - - /** - * Compute service stats for user. - */ - protected function computeServiceStats(User $user): array - { - $services = [ - [ - 'name' => 'SocialHost', - 'icon' => 'fa-share-nodes', - 'color' => 'bg-blue-500', - 'status' => 'inactive', - 'stat' => 'Not configured', - ], - [ - 'name' => 'BioHost', - 'icon' => 'fa-id-card', - 'color' => 'bg-violet-500', - 'status' => 'inactive', - 'stat' => 'Not configured', - ], - [ - 'name' => 'AnalyticsHost', - 'icon' => 'fa-chart-line', - 'color' => 'bg-green-500', - 'status' => 'inactive', - 'stat' => 'Not configured', - ], - [ - 'name' => 'TrustHost', - 'icon' => 'fa-shield-check', - 'color' => 'bg-amber-500', - 'status' => 'inactive', - 'stat' => 'Not configured', - ], - ]; - - // Check for active Host Hub workspaces (via pivot table) - $workspaceCount = $user->hostWorkspaces()->count(); - - if ($workspaceCount > 0) { - // SocialHost - check for social accounts - // TODO: Check social accounts when integration is complete - $services[0]['status'] = 'active'; - $services[0]['stat'] = $workspaceCount.' workspace(s)'; - - // BioHost - check for bio pages - // TODO: Check for bio pages when implemented - } - - return $services; - } - - /** - * Get default service stats. - */ - protected function getDefaultServiceStats(): array - { - return [ - [ - 'name' => 'SocialHost', - 'icon' => 'fa-share-nodes', - 'color' => 'bg-blue-500', - 'status' => 'inactive', - 'stat' => 'Not configured', - ], - [ - 'name' => 'BioHost', - 'icon' => 'fa-id-card', - 'color' => 'bg-violet-500', - 'status' => 'inactive', - 'stat' => 'Not configured', - ], - [ - 'name' => 'AnalyticsHost', - 'icon' => 'fa-chart-line', - 'color' => 'bg-green-500', - 'status' => 'inactive', - 'stat' => 'Not configured', - ], - [ - 'name' => 'TrustHost', - 'icon' => 'fa-shield-check', - 'color' => 'bg-amber-500', - 'status' => 'inactive', - 'stat' => 'Not configured', - ], - ]; - } - - /** - * Get recent activity for user. - */ - protected function getRecentActivity(User $user): array - { - // TODO: Implement actual activity logging - // For now return empty - activities will be added when actions are performed - return []; - } - - /** - * Get cached timezone list. - */ - public static function getTimezoneList(): array - { - return Cache::remember('timezone_list', 86400, function () { - $groups = []; - $timezones = \DateTimeZone::listIdentifiers(\DateTimeZone::ALL); - - foreach ($timezones as $tz) { - $parts = explode('/', $tz, 2); - $group = $parts[0] ?? 'Other'; - $label = $parts[1] ?? $tz; - - if (! isset($groups[$group])) { - $groups[$group] = []; - } - - $groups[$group][$tz] = str_replace('_', ' ', $label); - } - - ksort($groups); - foreach ($groups as &$items) { - asort($items); - } - - return $groups; - }); - } - - /** - * Get cached locale list. - */ - public static function getLocaleList(): array - { - return Cache::remember('locale_list', 86400, function () { - $locales = [ - 'en-GB' => 'English (UK)', - 'en-US' => 'English (US)', - 'es' => 'Español', - 'fr' => 'Français', - 'de' => 'Deutsch', - 'it' => 'Italiano', - 'pt' => 'Português', - 'nl' => 'Nederlands', - 'pl' => 'Polski', - 'ru' => 'Русский', - 'ja' => '日本語', - 'zh' => '中文', - 'ko' => '한국어', - 'ar' => 'العربية', - ]; - - $result = []; - foreach ($locales as $code => $name) { - $result[] = ['long' => $code, 'name' => $name]; - } - - return $result; - }); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Services/WorkspaceCacheManager.php b/packages/core-php/src/Mod/Tenant/Services/WorkspaceCacheManager.php deleted file mode 100644 index ef046f8..0000000 --- a/packages/core-php/src/Mod/Tenant/Services/WorkspaceCacheManager.php +++ /dev/null @@ -1,458 +0,0 @@ -remember($workspace, 'key', 300, fn() => expensive_query()); - * - * // Clear all cache for a workspace - * $manager->flush($workspace); - * - * // Get cache statistics (useful for debugging) - * $stats = $manager->stats($workspace); - */ -class WorkspaceCacheManager -{ - /** - * Track all cache keys used (for non-tagged stores). - * This allows us to clear cache for a workspace even without tags. - */ - protected static array $keyRegistry = []; - - /** - * Configuration cache. - */ - protected ?array $config = null; - - /** - * Get the configuration for workspace caching. - */ - public function config(?string $key = null, mixed $default = null): mixed - { - if ($this->config === null) { - $this->config = config('core.workspace_cache', [ - 'enabled' => true, - 'ttl' => 300, - 'prefix' => 'workspace_cache', - 'use_tags' => true, - ]); - } - - if ($key === null) { - return $this->config; - } - - return $this->config[$key] ?? $default; - } - - /** - * Check if workspace caching is enabled. - */ - public function isEnabled(): bool - { - return (bool) $this->config('enabled', true); - } - - /** - * Get the cache prefix. - */ - public function prefix(): string - { - return $this->config('prefix', 'workspace_cache'); - } - - /** - * Get the default TTL. - */ - public function defaultTtl(): int - { - return (int) $this->config('ttl', 300); - } - - /** - * Check if the current cache store supports tags. - */ - public function supportsTags(): bool - { - if (! $this->config('use_tags', true)) { - return false; - } - - try { - return Cache::getStore() instanceof TaggableStore; - } catch (\Throwable) { - return false; - } - } - - /** - * Get the workspace tag name. - */ - public function workspaceTag(Workspace|int $workspace): string - { - $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; - - return $this->prefix().":workspace:{$workspaceId}"; - } - - /** - * Get the model tag name. - */ - public function modelTag(string $modelClass): string - { - $modelName = class_basename($modelClass); - - return $this->prefix().":model:{$modelName}"; - } - - /** - * Generate a cache key for a workspace-scoped value. - */ - public function key(Workspace|int $workspace, string $key): string - { - $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; - - return "{$this->prefix()}.{$workspaceId}.{$key}"; - } - - /** - * Remember a value in the cache for a workspace. - * - * @template T - * - * @param Workspace|int $workspace The workspace context - * @param string $key The cache key (will be prefixed automatically) - * @param int|null $ttl TTL in seconds (null = use default) - * @param Closure(): T $callback The callback to generate the value - * @return T - */ - public function remember(Workspace|int $workspace, string $key, ?int $ttl, Closure $callback): mixed - { - if (! $this->isEnabled()) { - return $callback(); - } - - $fullKey = $this->key($workspace, $key); - $ttl = $ttl ?? $this->defaultTtl(); - - // Register the key for later cleanup - $this->registerKey($workspace, $fullKey); - - if ($this->supportsTags()) { - return Cache::tags([$this->workspaceTag($workspace)]) - ->remember($fullKey, $ttl, $callback); - } - - return Cache::remember($fullKey, $ttl, $callback); - } - - /** - * Remember a value forever in the cache for a workspace. - * - * @template T - * - * @param Closure(): T $callback - * @return T - */ - public function rememberForever(Workspace|int $workspace, string $key, Closure $callback): mixed - { - if (! $this->isEnabled()) { - return $callback(); - } - - $fullKey = $this->key($workspace, $key); - - // Register the key for later cleanup - $this->registerKey($workspace, $fullKey); - - if ($this->supportsTags()) { - return Cache::tags([$this->workspaceTag($workspace)]) - ->rememberForever($fullKey, $callback); - } - - return Cache::rememberForever($fullKey, $callback); - } - - /** - * Store a value in the cache for a workspace. - */ - public function put(Workspace|int $workspace, string $key, mixed $value, ?int $ttl = null): bool - { - if (! $this->isEnabled()) { - return false; - } - - $fullKey = $this->key($workspace, $key); - $ttl = $ttl ?? $this->defaultTtl(); - - // Register the key for later cleanup - $this->registerKey($workspace, $fullKey); - - if ($this->supportsTags()) { - return Cache::tags([$this->workspaceTag($workspace)]) - ->put($fullKey, $value, $ttl); - } - - return Cache::put($fullKey, $value, $ttl); - } - - /** - * Get a value from the cache. - */ - public function get(Workspace|int $workspace, string $key, mixed $default = null): mixed - { - if (! $this->isEnabled()) { - return $default; - } - - $fullKey = $this->key($workspace, $key); - - if ($this->supportsTags()) { - return Cache::tags([$this->workspaceTag($workspace)]) - ->get($fullKey, $default); - } - - return Cache::get($fullKey, $default); - } - - /** - * Check if a key exists in the cache. - */ - public function has(Workspace|int $workspace, string $key): bool - { - if (! $this->isEnabled()) { - return false; - } - - $fullKey = $this->key($workspace, $key); - - if ($this->supportsTags()) { - return Cache::tags([$this->workspaceTag($workspace)]) - ->has($fullKey); - } - - return Cache::has($fullKey); - } - - /** - * Remove a specific key from the cache. - */ - public function forget(Workspace|int $workspace, string $key): bool - { - $fullKey = $this->key($workspace, $key); - - // Unregister the key - $this->unregisterKey($workspace, $fullKey); - - if ($this->supportsTags()) { - return Cache::tags([$this->workspaceTag($workspace)]) - ->forget($fullKey); - } - - return Cache::forget($fullKey); - } - - /** - * Flush all cache for a specific workspace. - */ - public function flush(Workspace|int $workspace): bool - { - $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; - - if ($this->supportsTags()) { - $result = Cache::tags([$this->workspaceTag($workspace)])->flush(); - $this->clearKeyRegistry($workspaceId); - - return $result; - } - - // For non-tagged stores, we need to clear each registered key - return $this->flushRegisteredKeys($workspaceId); - } - - /** - * Flush cache for a specific model across all workspaces. - * Useful when a model's caching logic changes. - */ - public function flushModel(string $modelClass): bool - { - if ($this->supportsTags()) { - return Cache::tags([$this->modelTag($modelClass)])->flush(); - } - - // For non-tagged stores, we would need to track model-specific keys - // This is a best-effort operation - Log::warning("WorkspaceCacheManager: Cannot flush model cache without tags for {$modelClass}"); - - return false; - } - - /** - * Remember a model collection for a workspace with proper tagging. - * - * @template T - * - * @param Closure(): T $callback - * @return T - */ - public function rememberModel( - Workspace|int $workspace, - string $modelClass, - string $key, - ?int $ttl, - Closure $callback - ): mixed { - if (! $this->isEnabled()) { - return $callback(); - } - - $fullKey = $this->key($workspace, $key); - $ttl = $ttl ?? $this->defaultTtl(); - - // Register the key for later cleanup - $this->registerKey($workspace, $fullKey); - - if ($this->supportsTags()) { - return Cache::tags([ - $this->workspaceTag($workspace), - $this->modelTag($modelClass), - ])->remember($fullKey, $ttl, $callback); - } - - return Cache::remember($fullKey, $ttl, $callback); - } - - /** - * Get cache statistics for a workspace. - * - * This is useful for debugging and monitoring cache usage. - */ - public function stats(Workspace|int $workspace): array - { - $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; - - $keys = self::$keyRegistry[$workspaceId] ?? []; - - $stats = [ - 'workspace_id' => $workspaceId, - 'enabled' => $this->isEnabled(), - 'supports_tags' => $this->supportsTags(), - 'prefix' => $this->prefix(), - 'default_ttl' => $this->defaultTtl(), - 'registered_keys' => count($keys), - 'keys' => $keys, - ]; - - // If we can, check which keys actually exist in cache - $existingKeys = 0; - foreach ($keys as $key) { - if (Cache::has($key)) { - $existingKeys++; - } - } - $stats['existing_keys'] = $existingKeys; - - return $stats; - } - - /** - * Get all registered keys for a workspace. - */ - public function getRegisteredKeys(Workspace|int $workspace): array - { - $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; - - return self::$keyRegistry[$workspaceId] ?? []; - } - - /** - * Register a cache key for a workspace. - * This allows us to track all keys for cleanup later. - */ - protected function registerKey(Workspace|int $workspace, string $key): void - { - $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; - - if (! isset(self::$keyRegistry[$workspaceId])) { - self::$keyRegistry[$workspaceId] = []; - } - - if (! in_array($key, self::$keyRegistry[$workspaceId], true)) { - self::$keyRegistry[$workspaceId][] = $key; - } - } - - /** - * Unregister a cache key for a workspace. - */ - protected function unregisterKey(Workspace|int $workspace, string $key): void - { - $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; - - if (isset(self::$keyRegistry[$workspaceId])) { - self::$keyRegistry[$workspaceId] = array_filter( - self::$keyRegistry[$workspaceId], - fn ($k) => $k !== $key - ); - } - } - - /** - * Clear the key registry for a workspace. - */ - protected function clearKeyRegistry(int $workspaceId): void - { - unset(self::$keyRegistry[$workspaceId]); - } - - /** - * Flush all registered keys for a workspace (non-tagged stores). - */ - protected function flushRegisteredKeys(int $workspaceId): bool - { - $keys = self::$keyRegistry[$workspaceId] ?? []; - - foreach ($keys as $key) { - Cache::forget($key); - } - - $this->clearKeyRegistry($workspaceId); - - return true; - } - - /** - * Reset the key registry (useful for testing). - */ - public static function resetKeyRegistry(): void - { - self::$keyRegistry = []; - } - - /** - * Override configuration (useful for testing). - */ - public function setConfig(array $config): void - { - $this->config = $config; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Services/WorkspaceManager.php b/packages/core-php/src/Mod/Tenant/Services/WorkspaceManager.php deleted file mode 100644 index f3b9a48..0000000 --- a/packages/core-php/src/Mod/Tenant/Services/WorkspaceManager.php +++ /dev/null @@ -1,221 +0,0 @@ -attributes->set('workspace_model', $workspace); - - // Also cache it for quick retrieval - Cache::put("workspace.current.{$workspace->id}", $workspace, now()->addMinutes(5)); - } - - /** - * Forget the current workspace from request context. - */ - public function forgetCurrent(): void - { - if (request()->attributes->has('workspace_model')) { - $workspace = request()->attributes->get('workspace_model'); - Cache::forget("workspace.current.{$workspace->id}"); - request()->attributes->remove('workspace_model'); - } - } - - /** - * Get the current workspace. - */ - public function current(): ?Workspace - { - return Workspace::current(); - } - - /** - * Get all workspaces for the authenticated user. - */ - public function all(): Collection|array - { - if (! auth()->check()) { - return collect([]); - } - - /** @var User $user */ - $user = auth()->user(); - - return $user instanceof User - ? $user->workspaces - : collect([]); - } - - /** - * Load workspace by ID and set as current. - */ - public function loadById(int $id): bool - { - $workspace = Workspace::find($id); - - if (! $workspace) { - return false; - } - - $this->setCurrent($workspace); - - return true; - } - - /** - * Load workspace by UUID and set as current. - */ - public function loadByUuid(string $uuid): bool - { - $workspace = Workspace::where('uuid', $uuid)->first(); - - if (! $workspace) { - return false; - } - - $this->setCurrent($workspace); - - return true; - } - - /** - * Load workspace by slug and set as current. - */ - public function loadBySlug(string $slug): bool - { - $workspace = Workspace::where('slug', $slug)->first(); - - if (! $workspace) { - return false; - } - - $this->setCurrent($workspace); - - return true; - } - - /** - * Get unique validation rule for a column scoped to workspace. - * - * This ensures uniqueness within a workspace context (e.g., account names, - * template titles) rather than globally. - */ - public function uniqueRule(string $table, string $column = 'id', bool $softDelete = false): Rule - { - $workspace = $this->current(); - - $rule = Rule::unique($table, $column); - - if ($workspace) { - $rule->where('workspace_id', $workspace->id); - } - - if ($softDelete) { - $rule->whereNull('deleted_at'); - } - - return $rule; - } - - /** - * Get exists validation rule for a column scoped to workspace. - */ - public function existsRule(string $table, string $column = 'id', bool $softDelete = false): Rule - { - $workspace = $this->current(); - - $rule = Rule::exists($table, $column); - - if ($workspace) { - $rule->where('workspace_id', $workspace->id); - } - - if ($softDelete) { - $rule->whereNull('deleted_at'); - } - - return $rule; - } - - /** - * Create a new workspace for a user. - */ - public function create(User $user, array $attributes): Workspace - { - $workspace = Workspace::create($attributes); - - // Attach user as owner - $workspace->users()->attach($user->id, [ - 'role' => 'owner', - 'is_default' => true, - ]); - - return $workspace; - } - - /** - * Add a user to a workspace. - */ - public function addUser(Workspace $workspace, User $user, string $role = 'member', bool $isDefault = false): void - { - $workspace->users()->syncWithoutDetaching([ - $user->id => [ - 'role' => $role, - 'is_default' => $isDefault, - ], - ]); - } - - /** - * Remove a user from a workspace. - */ - public function removeUser(Workspace $workspace, User $user): void - { - $workspace->users()->detach($user->id); - } - - /** - * Switch user's default workspace. - */ - public function setDefault(User $user, Workspace $workspace): void - { - // Remove default flag from all workspaces - $user->workspaces()->updateExistingPivot( - $user->workspaces()->pluck('workspaces.id')->toArray(), - ['is_default' => false] - ); - - // Set this one as default - $user->workspaces()->updateExistingPivot($workspace->id, ['is_default' => true]); - } - - /** - * Check if workspace has capacity for new resources. - */ - public function hasCapacity(Workspace $workspace, string $featureCode, int $quantity = 1): bool - { - return $workspace->can($featureCode, $quantity)->isAllowed(); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Services/WorkspaceService.php b/packages/core-php/src/Mod/Tenant/Services/WorkspaceService.php deleted file mode 100644 index 30d08fc..0000000 --- a/packages/core-php/src/Mod/Tenant/Services/WorkspaceService.php +++ /dev/null @@ -1,156 +0,0 @@ - - */ - public function all(): array - { - $user = auth()->user(); - if (! $user) { - return []; - } - - return $user->workspaces() - ->active() - ->ordered() - ->get() - ->keyBy('slug') - ->map(fn (Workspace $w) => $w->toServiceArray()) - ->toArray(); - } - - /** - * Get the current workspace slug from session. - */ - public function currentSlug(): string - { - return Session::get('workspace', 'main'); - } - - /** - * Get the current workspace as array. - */ - public function current(): array - { - $workspace = $this->currentModel(); - - return $workspace?->toServiceArray() ?? [ - 'name' => 'No Workspace', - 'slug' => 'main', - 'domain' => '', - 'icon' => 'globe', - 'color' => 'zinc', - 'description' => 'Select a workspace', - ]; - } - - /** - * Get the current workspace model. - */ - public function currentModel(): ?Workspace - { - $slug = $this->currentSlug(); - $user = auth()->user(); - - if (! $user) { - return null; - } - - // Try to find in user's workspaces - $workspace = $user->workspaces()->where('slug', $slug)->first(); - - // Fall back to default workspace - if (! $workspace) { - $workspace = $user->workspaces()->wherePivot('is_default', true)->first() - ?? $user->workspaces()->first(); - - if ($workspace) { - Session::put('workspace', $workspace->slug); - } - } - - return $workspace; - } - - /** - * Set the current workspace by slug. - */ - public function setCurrent(string $slug): bool - { - $user = auth()->user(); - if (! $user) { - return false; - } - - // Verify user has access to this workspace - $workspace = $user->workspaces()->where('slug', $slug)->first(); - if (! $workspace) { - return false; - } - - Session::put('workspace', $slug); - - return true; - } - - /** - * Get a specific workspace by slug (as array). - */ - public function get(string $slug): ?array - { - $workspace = Workspace::where('slug', $slug)->first(); - - return $workspace?->toServiceArray(); - } - - /** - * Get a workspace model by slug. - */ - public function getModel(string $slug): ?Workspace - { - return Workspace::where('slug', $slug)->first(); - } - - /** - * Find workspace by subdomain. - */ - public function findBySubdomain(string $subdomain): ?array - { - // Check for exact slug match first - $workspace = Workspace::where('slug', $subdomain)->first(); - if ($workspace) { - return $workspace->toServiceArray(); - } - - // Check domain contains subdomain - $workspace = Workspace::where('domain', 'LIKE', "{$subdomain}.%")->first(); - - return $workspace?->toServiceArray(); - } - - /** - * Get workspace slug from subdomain. - */ - public function getSlugFromSubdomain(string $subdomain): ?string - { - $workspace = $this->findBySubdomain($subdomain); - - return $workspace['slug'] ?? null; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Services/WorkspaceTeamService.php b/packages/core-php/src/Mod/Tenant/Services/WorkspaceTeamService.php deleted file mode 100644 index 34dcf0e..0000000 --- a/packages/core-php/src/Mod/Tenant/Services/WorkspaceTeamService.php +++ /dev/null @@ -1,629 +0,0 @@ -workspace = $workspace; - } - - /** - * Set the workspace context. - */ - public function forWorkspace(Workspace $workspace): self - { - $this->workspace = $workspace; - - return $this; - } - - /** - * Get the current workspace, resolving from context if needed. - */ - protected function getWorkspace(): ?Workspace - { - if ($this->workspace) { - return $this->workspace; - } - - // Try authenticated user's default workspace first - $this->workspace = auth()->user()?->defaultHostWorkspace(); - - // Fall back to session workspace if set - if (! $this->workspace) { - $sessionWorkspaceId = session('workspace_id'); - if ($sessionWorkspaceId) { - $this->workspace = Workspace::find($sessionWorkspaceId); - } - } - - return $this->workspace; - } - - // ───────────────────────────────────────────────────────────────────────── - // Team Management - // ───────────────────────────────────────────────────────────────────────── - - /** - * Get all teams for the workspace. - */ - public function getTeams(): Collection - { - $workspace = $this->getWorkspace(); - if (! $workspace) { - return new Collection; - } - - return WorkspaceTeam::where('workspace_id', $workspace->id) - ->ordered() - ->get(); - } - - /** - * Get a specific team by ID. - */ - public function getTeam(int $teamId): ?WorkspaceTeam - { - $workspace = $this->getWorkspace(); - if (! $workspace) { - return null; - } - - return WorkspaceTeam::where('workspace_id', $workspace->id) - ->where('id', $teamId) - ->first(); - } - - /** - * Get a specific team by slug. - */ - public function getTeamBySlug(string $slug): ?WorkspaceTeam - { - $workspace = $this->getWorkspace(); - if (! $workspace) { - return null; - } - - return WorkspaceTeam::where('workspace_id', $workspace->id) - ->where('slug', $slug) - ->first(); - } - - /** - * Get the default team for new members. - */ - public function getDefaultTeam(): ?WorkspaceTeam - { - $workspace = $this->getWorkspace(); - if (! $workspace) { - return null; - } - - return WorkspaceTeam::where('workspace_id', $workspace->id) - ->where('is_default', true) - ->first(); - } - - /** - * Create a new team. - */ - public function createTeam(array $data): WorkspaceTeam - { - $workspace = $this->getWorkspace(); - if (! $workspace) { - throw new \RuntimeException('No workspace context available.'); - } - - $team = WorkspaceTeam::create([ - 'workspace_id' => $workspace->id, - 'name' => $data['name'], - 'slug' => $data['slug'] ?? null, - 'description' => $data['description'] ?? null, - 'permissions' => $data['permissions'] ?? [], - 'is_default' => $data['is_default'] ?? false, - 'is_system' => $data['is_system'] ?? false, - 'colour' => $data['colour'] ?? 'zinc', - 'sort_order' => $data['sort_order'] ?? 0, - ]); - - // If this is the new default, unset other defaults - if ($team->is_default) { - WorkspaceTeam::where('workspace_id', $workspace->id) - ->where('id', '!=', $team->id) - ->where('is_default', true) - ->update(['is_default' => false]); - } - - Log::info('Workspace team created', [ - 'team_id' => $team->id, - 'team_name' => $team->name, - 'workspace_id' => $workspace->id, - ]); - - return $team; - } - - /** - * Update an existing team. - */ - public function updateTeam(WorkspaceTeam $team, array $data): WorkspaceTeam - { - $workspace = $this->getWorkspace(); - - // Don't allow updating system teams' slug - if ($team->is_system && isset($data['slug'])) { - unset($data['slug']); - } - - $team->update($data); - - // If this is the new default, unset other defaults - if (($data['is_default'] ?? false) && $workspace) { - WorkspaceTeam::where('workspace_id', $workspace->id) - ->where('id', '!=', $team->id) - ->where('is_default', true) - ->update(['is_default' => false]); - } - - Log::info('Workspace team updated', [ - 'team_id' => $team->id, - 'team_name' => $team->name, - 'workspace_id' => $team->workspace_id, - ]); - - return $team; - } - - /** - * Delete a team (only non-system teams). - */ - public function deleteTeam(WorkspaceTeam $team): bool - { - if ($team->is_system) { - throw new \RuntimeException('Cannot delete system teams.'); - } - - // Check if team has any members assigned - $memberCount = WorkspaceMember::where('team_id', $team->id)->count(); - if ($memberCount > 0) { - throw new \RuntimeException( - "Cannot delete team with {$memberCount} assigned members. Remove members first." - ); - } - - $teamId = $team->id; - $teamName = $team->name; - $workspaceId = $team->workspace_id; - - $team->delete(); - - Log::info('Workspace team deleted', [ - 'team_id' => $teamId, - 'team_name' => $teamName, - 'workspace_id' => $workspaceId, - ]); - - return true; - } - - // ───────────────────────────────────────────────────────────────────────── - // Member Management - // ───────────────────────────────────────────────────────────────────────── - - /** - * Get a member record for a user in the workspace. - */ - public function getMember(User|int $user): ?WorkspaceMember - { - $workspace = $this->getWorkspace(); - if (! $workspace) { - return null; - } - - $userId = $user instanceof User ? $user->id : $user; - - return WorkspaceMember::where('workspace_id', $workspace->id) - ->where('user_id', $userId) - ->first(); - } - - /** - * Get all members in the workspace. - */ - public function getMembers(): Collection - { - $workspace = $this->getWorkspace(); - if (! $workspace) { - return new Collection; - } - - return WorkspaceMember::where('workspace_id', $workspace->id) - ->with(['user', 'team', 'inviter']) - ->get(); - } - - /** - * Get all members in a specific team. - */ - public function getTeamMembers(WorkspaceTeam|int $team): Collection - { - $workspace = $this->getWorkspace(); - if (! $workspace) { - return new Collection; - } - - $teamId = $team instanceof WorkspaceTeam ? $team->id : $team; - - return WorkspaceMember::where('workspace_id', $workspace->id) - ->where('team_id', $teamId) - ->with(['user', 'team', 'inviter']) - ->get(); - } - - /** - * Add a member to a team. - */ - public function addMemberToTeam(User|int $user, WorkspaceTeam|int $team): WorkspaceMember - { - $workspace = $this->getWorkspace(); - if (! $workspace) { - throw new \RuntimeException('No workspace context available.'); - } - - $userId = $user instanceof User ? $user->id : $user; - $teamId = $team instanceof WorkspaceTeam ? $team->id : $team; - - // Verify team belongs to workspace - $teamModel = WorkspaceTeam::where('workspace_id', $workspace->id) - ->where('id', $teamId) - ->first(); - - if (! $teamModel) { - throw new \RuntimeException('Team does not belong to the current workspace.'); - } - - $member = WorkspaceMember::where('workspace_id', $workspace->id) - ->where('user_id', $userId) - ->first(); - - if (! $member) { - throw new \RuntimeException('User is not a member of this workspace.'); - } - - $member->update(['team_id' => $teamId]); - - Log::info('Member added to team', [ - 'user_id' => $userId, - 'team_id' => $teamId, - 'team_name' => $teamModel->name, - 'workspace_id' => $workspace->id, - ]); - - return $member->fresh(); - } - - /** - * Remove a member from their team. - */ - public function removeMemberFromTeam(User|int $user): WorkspaceMember - { - $workspace = $this->getWorkspace(); - if (! $workspace) { - throw new \RuntimeException('No workspace context available.'); - } - - $userId = $user instanceof User ? $user->id : $user; - - $member = WorkspaceMember::where('workspace_id', $workspace->id) - ->where('user_id', $userId) - ->first(); - - if (! $member) { - throw new \RuntimeException('User is not a member of this workspace.'); - } - - $oldTeamId = $member->team_id; - $member->update(['team_id' => null]); - - Log::info('Member removed from team', [ - 'user_id' => $userId, - 'old_team_id' => $oldTeamId, - 'workspace_id' => $workspace->id, - ]); - - return $member->fresh(); - } - - /** - * Set custom permissions for a member. - */ - public function setMemberCustomPermissions(User|int $user, array $customPermissions): WorkspaceMember - { - $workspace = $this->getWorkspace(); - if (! $workspace) { - throw new \RuntimeException('No workspace context available.'); - } - - $userId = $user instanceof User ? $user->id : $user; - - $member = WorkspaceMember::where('workspace_id', $workspace->id) - ->where('user_id', $userId) - ->first(); - - if (! $member) { - throw new \RuntimeException('User is not a member of this workspace.'); - } - - $member->update(['custom_permissions' => $customPermissions]); - - Log::info('Member custom permissions updated', [ - 'user_id' => $userId, - 'workspace_id' => $workspace->id, - 'custom_permissions' => $customPermissions, - ]); - - return $member->fresh(); - } - - // ───────────────────────────────────────────────────────────────────────── - // Permission Checks - // ───────────────────────────────────────────────────────────────────────── - - /** - * Get all effective permissions for a user in the workspace. - */ - public function getMemberPermissions(User|int $user): array - { - $member = $this->getMember($user); - - if (! $member) { - return []; - } - - return $member->getEffectivePermissions(); - } - - /** - * Check if a user has a specific permission in the workspace. - */ - public function hasPermission(User|int $user, string $permission): bool - { - $member = $this->getMember($user); - - if (! $member) { - return false; - } - - return $member->hasPermission($permission); - } - - /** - * Check if a user has any of the given permissions. - */ - public function hasAnyPermission(User|int $user, array $permissions): bool - { - $member = $this->getMember($user); - - if (! $member) { - return false; - } - - return $member->hasAnyPermission($permissions); - } - - /** - * Check if a user has all of the given permissions. - */ - public function hasAllPermissions(User|int $user, array $permissions): bool - { - $member = $this->getMember($user); - - if (! $member) { - return false; - } - - return $member->hasAllPermissions($permissions); - } - - /** - * Check if a user is the workspace owner. - */ - public function isOwner(User|int $user): bool - { - $member = $this->getMember($user); - - return $member?->isOwner() ?? false; - } - - /** - * Check if a user is a workspace admin. - */ - public function isAdmin(User|int $user): bool - { - $member = $this->getMember($user); - - return $member?->isAdmin() ?? false; - } - - // ───────────────────────────────────────────────────────────────────────── - // Member Queries - // ───────────────────────────────────────────────────────────────────────── - - /** - * Get members with a specific permission. - */ - public function getMembersWithPermission(string $permission): Collection - { - $members = $this->getMembers(); - - return $members->filter(fn ($member) => $member->hasPermission($permission)); - } - - /** - * Count members in the workspace. - */ - public function countMembers(): int - { - $workspace = $this->getWorkspace(); - if (! $workspace) { - return 0; - } - - return WorkspaceMember::where('workspace_id', $workspace->id)->count(); - } - - /** - * Count members in a specific team. - */ - public function countTeamMembers(WorkspaceTeam|int $team): int - { - $workspace = $this->getWorkspace(); - if (! $workspace) { - return 0; - } - - $teamId = $team instanceof WorkspaceTeam ? $team->id : $team; - - return WorkspaceMember::where('workspace_id', $workspace->id) - ->where('team_id', $teamId) - ->count(); - } - - // ───────────────────────────────────────────────────────────────────────── - // Seeding - // ───────────────────────────────────────────────────────────────────────── - - /** - * Seed default teams for a workspace. - */ - public function seedDefaultTeams(?Workspace $workspace = null): Collection - { - $workspace = $workspace ?? $this->getWorkspace(); - if (! $workspace) { - throw new \RuntimeException('No workspace context available for seeding.'); - } - - $teams = new Collection; - - foreach (WorkspaceTeam::getDefaultTeamDefinitions() as $definition) { - // Check if team already exists - $existing = WorkspaceTeam::where('workspace_id', $workspace->id) - ->where('slug', $definition['slug']) - ->first(); - - if ($existing) { - $teams->push($existing); - - continue; - } - - $team = WorkspaceTeam::create([ - 'workspace_id' => $workspace->id, - 'name' => $definition['name'], - 'slug' => $definition['slug'], - 'description' => $definition['description'], - 'permissions' => $definition['permissions'], - 'is_default' => $definition['is_default'] ?? false, - 'is_system' => $definition['is_system'] ?? false, - 'colour' => $definition['colour'] ?? 'zinc', - 'sort_order' => $definition['sort_order'] ?? 0, - ]); - - $teams->push($team); - } - - Log::info('Default workspace teams seeded', [ - 'workspace_id' => $workspace->id, - 'teams_count' => $teams->count(), - ]); - - return $teams; - } - - /** - * Ensure default teams exist for the workspace, creating them if needed. - */ - public function ensureDefaultTeams(): Collection - { - $workspace = $this->getWorkspace(); - if (! $workspace) { - return new Collection; - } - - // Check if any teams exist - $existingCount = WorkspaceTeam::where('workspace_id', $workspace->id)->count(); - - if ($existingCount === 0) { - return $this->seedDefaultTeams($workspace); - } - - return $this->getTeams(); - } - - /** - * Migrate existing members to appropriate teams based on their role. - */ - public function migrateExistingMembers(): int - { - $workspace = $this->getWorkspace(); - if (! $workspace) { - return 0; - } - - // Ensure teams exist - $this->ensureDefaultTeams(); - - $ownerTeam = $this->getTeamBySlug(WorkspaceTeam::TEAM_OWNER); - $adminTeam = $this->getTeamBySlug(WorkspaceTeam::TEAM_ADMIN); - $memberTeam = $this->getTeamBySlug(WorkspaceTeam::TEAM_MEMBER); - - $migrated = 0; - - DB::transaction(function () use ($workspace, $ownerTeam, $adminTeam, $memberTeam, &$migrated) { - // Get members without team assignments - $members = WorkspaceMember::where('workspace_id', $workspace->id) - ->whereNull('team_id') - ->get(); - - foreach ($members as $member) { - $teamId = match ($member->role) { - WorkspaceMember::ROLE_OWNER => $ownerTeam?->id, - WorkspaceMember::ROLE_ADMIN => $adminTeam?->id, - default => $memberTeam?->id, - }; - - if ($teamId) { - $member->update([ - 'team_id' => $teamId, - 'joined_at' => $member->joined_at ?? $member->created_at, - ]); - $migrated++; - } - } - }); - - Log::info('Workspace members migrated to teams', [ - 'workspace_id' => $workspace->id, - 'migrated_count' => $migrated, - ]); - - return $migrated; - } -} diff --git a/packages/core-php/src/Mod/Tenant/Tests/Feature/AccountDeletionTest.php b/packages/core-php/src/Mod/Tenant/Tests/Feature/AccountDeletionTest.php deleted file mode 100644 index 7d9455b..0000000 --- a/packages/core-php/src/Mod/Tenant/Tests/Feature/AccountDeletionTest.php +++ /dev/null @@ -1,334 +0,0 @@ -user = User::factory()->create(); - $this->workspace = Workspace::factory()->create(); - $this->workspace->users()->attach($this->user->id, [ - 'role' => 'owner', - 'is_default' => true, - ]); -}); - -describe('AccountDeletionRequest Model', function () { - describe('createForUser()', function () { - it('creates a new deletion request', function () { - $request = AccountDeletionRequest::createForUser($this->user); - - expect($request)->toBeInstanceOf(AccountDeletionRequest::class) - ->and($request->user_id)->toBe($this->user->id) - ->and($request->token)->toHaveLength(64) - ->and($request->completed_at)->toBeNull() - ->and($request->cancelled_at)->toBeNull(); - }); - - it('sets expiry based on configured grace period', function () { - config(['tenant.deletion.grace_period_days' => 14]); - - $this->travelTo(now()->startOfDay()); - $request = AccountDeletionRequest::createForUser($this->user); - - // Expiry should be 14 days in the future - expect((int) abs($request->expires_at->startOfDay()->diffInDays(now()->startOfDay())))->toBe(14); - }); - - it('stores optional reason', function () { - $reason = 'Switching to competitor'; - - $request = AccountDeletionRequest::createForUser($this->user, $reason); - - expect($request->reason)->toBe($reason); - }); - - it('cancels existing pending requests', function () { - $oldRequest = AccountDeletionRequest::createForUser($this->user); - $oldRequestId = $oldRequest->id; - - $newRequest = AccountDeletionRequest::createForUser($this->user); - - expect(AccountDeletionRequest::find($oldRequestId))->toBeNull() - ->and($newRequest->id)->not->toBe($oldRequestId); - }); - - it('does not affect completed requests', function () { - $completedRequest = AccountDeletionRequest::createForUser($this->user); - $completedRequest->complete(); - - $newRequest = AccountDeletionRequest::createForUser($this->user); - - expect(AccountDeletionRequest::find($completedRequest->id))->not->toBeNull() - ->and($newRequest->id)->not->toBe($completedRequest->id); - }); - }); - - describe('findValidByToken()', function () { - it('finds valid request by token', function () { - $request = AccountDeletionRequest::createForUser($this->user); - - $found = AccountDeletionRequest::findValidByToken($request->token); - - expect($found)->not->toBeNull() - ->and($found->id)->toBe($request->id); - }); - - it('returns null for completed request', function () { - $request = AccountDeletionRequest::createForUser($this->user); - $request->complete(); - - $found = AccountDeletionRequest::findValidByToken($request->token); - - expect($found)->toBeNull(); - }); - - it('returns null for cancelled request', function () { - $request = AccountDeletionRequest::createForUser($this->user); - $request->cancel(); - - $found = AccountDeletionRequest::findValidByToken($request->token); - - expect($found)->toBeNull(); - }); - - it('returns null for invalid token', function () { - AccountDeletionRequest::createForUser($this->user); - - $found = AccountDeletionRequest::findValidByToken('invalid-token'); - - expect($found)->toBeNull(); - }); - }); - - describe('pendingAutoDelete()', function () { - it('returns requests past expiry date', function () { - $request = AccountDeletionRequest::createForUser($this->user); - $request->update(['expires_at' => now()->subDay()]); - - $pending = AccountDeletionRequest::pendingAutoDelete()->get(); - - expect($pending)->toHaveCount(1) - ->and($pending->first()->id)->toBe($request->id); - }); - - it('excludes requests not yet expired', function () { - AccountDeletionRequest::createForUser($this->user); - - $pending = AccountDeletionRequest::pendingAutoDelete()->get(); - - expect($pending)->toHaveCount(0); - }); - - it('excludes completed requests', function () { - $request = AccountDeletionRequest::createForUser($this->user); - $request->update(['expires_at' => now()->subDay()]); - $request->complete(); - - $pending = AccountDeletionRequest::pendingAutoDelete()->get(); - - expect($pending)->toHaveCount(0); - }); - - it('excludes cancelled requests', function () { - $request = AccountDeletionRequest::createForUser($this->user); - $request->update(['expires_at' => now()->subDay()]); - $request->cancel(); - - $pending = AccountDeletionRequest::pendingAutoDelete()->get(); - - expect($pending)->toHaveCount(0); - }); - }); - - describe('state methods', function () { - it('isActive returns true for pending requests', function () { - $request = AccountDeletionRequest::createForUser($this->user); - - expect($request->isActive())->toBeTrue(); - }); - - it('isActive returns false after completion', function () { - $request = AccountDeletionRequest::createForUser($this->user); - $request->complete(); - - expect($request->isActive())->toBeFalse(); - }); - - it('isActive returns false after cancellation', function () { - $request = AccountDeletionRequest::createForUser($this->user); - $request->cancel(); - - expect($request->isActive())->toBeFalse(); - }); - - it('isPending returns true for future expiry', function () { - $request = AccountDeletionRequest::createForUser($this->user); - - expect($request->isPending())->toBeTrue(); - }); - - it('isReadyForAutoDeletion returns true for past expiry', function () { - $request = AccountDeletionRequest::createForUser($this->user); - $request->update(['expires_at' => now()->subDay()]); - - expect($request->isReadyForAutoDeletion())->toBeTrue(); - }); - }); - - describe('time helpers', function () { - it('calculates days remaining approximately', function () { - $this->travelTo(now()->startOfDay()); - - $request = AccountDeletionRequest::createForUser($this->user); - $request->update(['expires_at' => now()->startOfDay()->addDays(5)]); - - // Use startOfDay to avoid timing issues - expect($request->daysRemaining())->toBeGreaterThanOrEqual(4) - ->and($request->daysRemaining())->toBeLessThanOrEqual(5); - }); - - it('calculates hours remaining approximately', function () { - $this->travelTo(now()->startOfHour()); - - $request = AccountDeletionRequest::createForUser($this->user); - $request->update(['expires_at' => now()->startOfHour()->addHours(48)]); - - expect($request->hoursRemaining())->toBeGreaterThanOrEqual(47) - ->and($request->hoursRemaining())->toBeLessThanOrEqual(48); - }); - - it('returns zero for past expiry', function () { - $request = AccountDeletionRequest::createForUser($this->user); - $request->update(['expires_at' => now()->subDays(2)]); - - expect($request->daysRemaining())->toBe(0) - ->and($request->hoursRemaining())->toBe(0); - }); - }); - - describe('URL helpers', function () { - it('generates confirmation URL with token', function () { - $request = AccountDeletionRequest::createForUser($this->user); - - $url = $request->confirmationUrl(); - - expect($url)->toContain($request->token) - ->and($url)->toContain('account/delete'); - }); - - it('generates cancel URL with token', function () { - $request = AccountDeletionRequest::createForUser($this->user); - - $url = $request->cancelUrl(); - - expect($url)->toContain($request->token) - ->and($url)->toContain('cancel'); - }); - }); -}); - -describe('ProcessAccountDeletion Job', function () { - it('deletes user account', function () { - $request = AccountDeletionRequest::createForUser($this->user); - $request->update(['expires_at' => now()->subDay()]); - - $job = new ProcessAccountDeletion($request); - $job->handle(); - - // User should be deleted - expect(User::find($this->user->id))->toBeNull(); - - // Note: AccountDeletionRequest is also deleted due to CASCADE constraint - // This is expected behaviour as we want the request deleted when user is deleted - }); - - it('deletes user workspaces', function () { - $request = AccountDeletionRequest::createForUser($this->user); - $request->update(['expires_at' => now()->subDay()]); - $workspaceId = $this->workspace->id; - - $job = new ProcessAccountDeletion($request); - $job->handle(); - - expect(Workspace::find($workspaceId))->toBeNull(); - }); - - it('skips if request no longer active', function () { - $request = AccountDeletionRequest::createForUser($this->user); - $request->cancel(); - - $job = new ProcessAccountDeletion($request); - $job->handle(); - - expect(User::find($this->user->id))->not->toBeNull(); - }); - - it('handles missing user gracefully', function () { - $request = AccountDeletionRequest::createForUser($this->user); - $this->user->forceDelete(); - - // Request is deleted due to CASCADE, job should handle this gracefully - $job = new ProcessAccountDeletion($request); - - // Should not throw - $job->handle(); - - // Just verify user is still gone - expect(User::find($this->user->id))->toBeNull(); - }); -}); - -describe('ProcessAccountDeletions Command', function () { - it('processes expired deletion requests', function () { - $request = AccountDeletionRequest::createForUser($this->user); - $request->update(['expires_at' => now()->subDay()]); - - $this->artisan('accounts:process-deletions') - ->assertSuccessful() - ->expectsOutputToContain('1 deleted'); - - expect(User::find($this->user->id))->toBeNull(); - }); - - it('skips non-expired requests', function () { - AccountDeletionRequest::createForUser($this->user); - - $this->artisan('accounts:process-deletions') - ->assertSuccessful() - ->expectsOutputToContain('No pending account deletions'); - - expect(User::find($this->user->id))->not->toBeNull(); - }); - - it('supports dry-run mode', function () { - $request = AccountDeletionRequest::createForUser($this->user); - $request->update(['expires_at' => now()->subDay()]); - - $this->artisan('accounts:process-deletions', ['--dry-run' => true]) - ->assertSuccessful() - ->expectsOutputToContain('DRY RUN'); - - // User should still exist - expect(User::find($this->user->id))->not->toBeNull(); - }); - - it('supports queue mode', function () { - Queue::fake(); - - $request = AccountDeletionRequest::createForUser($this->user); - $request->update(['expires_at' => now()->subDay()]); - - $this->artisan('accounts:process-deletions', ['--queue' => true]) - ->assertSuccessful() - ->expectsOutputToContain('queued'); - - Queue::assertPushed(ProcessAccountDeletion::class); - }); -}); diff --git a/packages/core-php/src/Mod/Tenant/Tests/Feature/AuthenticationTest.php b/packages/core-php/src/Mod/Tenant/Tests/Feature/AuthenticationTest.php deleted file mode 100644 index baf4c48..0000000 --- a/packages/core-php/src/Mod/Tenant/Tests/Feature/AuthenticationTest.php +++ /dev/null @@ -1,124 +0,0 @@ -create($attributes); - } - - public function test_login_page_is_accessible(): void - { - $response = $this->get('/login'); - - $response->assertStatus(200); - $response->assertSee('Sign in to Host UK'); - } - - public function test_guests_are_redirected_from_hub_to_login(): void - { - $response = $this->get('/hub'); - - $response->assertRedirect('/login'); - } - - public function test_guests_are_redirected_from_hub_dashboard_to_login(): void - { - $response = $this->get('/hub/dashboard'); - - $response->assertRedirect('/login'); - } - - public function test_user_can_login_with_valid_credentials(): void - { - $user = $this->createUser([ - 'email' => 'test@example.com', - 'password' => bcrypt('password'), - ]); - - Livewire::test(Login::class) - ->set('email', 'test@example.com') - ->set('password', 'password') - ->call('login') - ->assertRedirect(route('hub.home')); - - $this->assertAuthenticated(); - } - - public function test_user_cannot_login_with_invalid_credentials(): void - { - $user = $this->createUser([ - 'email' => 'test@example.com', - 'password' => bcrypt('password'), - ]); - - Livewire::test(Login::class) - ->set('email', 'test@example.com') - ->set('password', 'wrong-password') - ->call('login') - ->assertHasErrors('email'); - - $this->assertGuest(); - } - - public function test_authenticated_user_is_redirected_from_login_to_hub(): void - { - $user = $this->createUser(); - - $response = $this->actingAs($user)->get('/login'); - - $response->assertRedirect('/hub'); - } - - public function test_authenticated_user_can_access_hub(): void - { - $user = $this->createUser(); - - $response = $this->actingAs($user)->get('/hub'); - - $response->assertStatus(200); - } - - public function test_user_can_logout_via_post(): void - { - $user = $this->createUser(); - - $this->actingAs($user); - $this->assertAuthenticated(); - - $response = $this->post('/logout'); - - $this->assertGuest(); - $response->assertRedirect('/'); - } - - public function test_user_can_logout_via_get(): void - { - $user = $this->createUser(); - - $this->actingAs($user); - $this->assertAuthenticated(); - - $response = $this->get('/logout'); - - $this->assertGuest(); - $response->assertRedirect('/'); - } - - public function test_marketing_page_is_accessible_without_auth(): void - { - $response = $this->get('/'); - - $response->assertStatus(200); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Tests/Feature/EntitlementApiTest.php b/packages/core-php/src/Mod/Tenant/Tests/Feature/EntitlementApiTest.php deleted file mode 100644 index 523e16e..0000000 --- a/packages/core-php/src/Mod/Tenant/Tests/Feature/EntitlementApiTest.php +++ /dev/null @@ -1,251 +0,0 @@ -user = User::factory()->create(); - $this->workspace = Workspace::factory()->create(); - $this->workspace->users()->attach($this->user->id, [ - 'role' => 'owner', - 'is_default' => true, - ]); - - // Create features - $this->socialAccountsFeature = Feature::create([ - 'code' => 'social.accounts', - 'name' => 'Social Accounts', - 'description' => 'Connected social accounts', - 'category' => 'social', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - 'is_active' => true, - ]); - - $this->socialPostsFeature = Feature::create([ - 'code' => 'social.posts.scheduled', - 'name' => 'Scheduled Posts', - 'description' => 'Monthly scheduled posts', - 'category' => 'social', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_MONTHLY, - 'is_active' => true, - ]); - - // Create package - $this->creatorPackage = Package::create([ - 'code' => 'social-creator', - 'name' => 'SocialHost Creator', - 'description' => 'For individual creators', - 'is_stackable' => false, - 'is_base_package' => true, - 'is_active' => true, - ]); - - $this->creatorPackage->features()->attach($this->socialAccountsFeature->id, ['limit_value' => 5]); - $this->creatorPackage->features()->attach($this->socialPostsFeature->id, ['limit_value' => 30]); - - $this->service = app(EntitlementService::class); -}); - -describe('Entitlement API', function () { - describe('GET /api/v1/entitlements/check', function () { - it('requires authentication', function () { - $response = $this->getJson('/api/v1/entitlements/check?email='.$this->user->email.'&feature=social.accounts'); - - $response->assertStatus(401); - }); - - it('returns 404 for non-existent user', function () { - $this->actingAs($this->user); - - $response = $this->getJson('/api/v1/entitlements/check?email=nonexistent@example.com&feature=social.accounts'); - - $response->assertStatus(404) - ->assertJson([ - 'allowed' => false, - 'reason' => 'User not found', - ]); - }); - - it('returns 404 when user has no workspace', function () { - $this->actingAs($this->user); - $this->workspace->users()->detach($this->user->id); - - $response = $this->getJson('/api/v1/entitlements/check?email='.$this->user->email.'&feature=social.accounts'); - - $response->assertStatus(404) - ->assertJson([ - 'allowed' => false, - 'reason' => 'No workspace found for user', - ]); - }); - - it('denies when user has no package', function () { - $this->actingAs($this->user); - - $response = $this->getJson('/api/v1/entitlements/check?email='.$this->user->email.'&feature=social.accounts'); - - $response->assertStatus(200) - ->assertJson([ - 'allowed' => false, - 'feature_code' => 'social.accounts', - ]); - }); - - it('allows when user has package with feature', function () { - $this->actingAs($this->user); - $this->service->provisionPackage($this->workspace, 'social-creator'); - - $response = $this->getJson('/api/v1/entitlements/check?email='.$this->user->email.'&feature=social.accounts'); - - $response->assertStatus(200) - ->assertJson([ - 'allowed' => true, - 'limit' => 5, - 'used' => 0, - 'remaining' => 5, - 'unlimited' => false, - 'feature_code' => 'social.accounts', - 'workspace_id' => $this->workspace->id, - ]); - }); - - it('respects quantity parameter', function () { - $this->actingAs($this->user); - $this->service->provisionPackage($this->workspace, 'social-creator'); - - // Use 4 of 5 allowed - $this->service->recordUsage($this->workspace, 'social.accounts', quantity: 4); - Cache::flush(); - - // Request 2 more (exceeds remaining) - $response = $this->getJson('/api/v1/entitlements/check?email='.$this->user->email.'&feature=social.accounts&quantity=2'); - - $response->assertStatus(200) - ->assertJson([ - 'allowed' => false, - 'remaining' => 1, - ]); - }); - }); - - describe('POST /api/v1/entitlements/usage', function () { - it('requires authentication', function () { - $response = $this->postJson('/api/v1/entitlements/usage', [ - 'email' => $this->user->email, - 'feature' => 'social.posts.scheduled', - ]); - - $response->assertStatus(401); - }); - - it('records usage successfully', function () { - $this->actingAs($this->user); - $this->service->provisionPackage($this->workspace, 'social-creator'); - - $response = $this->postJson('/api/v1/entitlements/usage', [ - 'email' => $this->user->email, - 'feature' => 'social.posts.scheduled', - 'quantity' => 3, - ]); - - $response->assertStatus(201) - ->assertJson([ - 'success' => true, - 'feature_code' => 'social.posts.scheduled', - 'quantity' => 3, - ]); - - // Verify usage was recorded - Cache::flush(); - $result = $this->service->can($this->workspace, 'social.posts.scheduled'); - expect($result->used)->toBe(3); - }); - - it('records usage with metadata', function () { - $this->actingAs($this->user); - $this->service->provisionPackage($this->workspace, 'social-creator'); - - $response = $this->postJson('/api/v1/entitlements/usage', [ - 'email' => $this->user->email, - 'feature' => 'social.posts.scheduled', - 'metadata' => ['source' => 'biohost', 'post_id' => 'abc123'], - ]); - - $response->assertStatus(201) - ->assertJson([ - 'success' => true, - ]); - }); - }); - - describe('GET /api/v1/entitlements/summary', function () { - it('requires authentication', function () { - $response = $this->getJson('/api/v1/entitlements/summary'); - - $response->assertStatus(401); - }); - - it('returns summary for authenticated user', function () { - $this->actingAs($this->user); - $this->service->provisionPackage($this->workspace, 'social-creator'); - - $response = $this->getJson('/api/v1/entitlements/summary'); - - $response->assertStatus(200) - ->assertJsonStructure([ - 'workspace_id', - 'packages', - 'features' => [ - 'social' => [ - '*' => ['code', 'name', 'limit', 'used', 'remaining', 'unlimited', 'percentage'], - ], - ], - 'boosts', - ]); - }); - - it('includes package information', function () { - $this->actingAs($this->user); - $this->service->provisionPackage($this->workspace, 'social-creator'); - - $response = $this->getJson('/api/v1/entitlements/summary'); - - $response->assertStatus(200); - - $packages = $response->json('packages'); - expect($packages)->toHaveCount(1); - expect($packages[0]['code'])->toBe('social-creator'); - }); - }); - - describe('GET /api/v1/entitlements/summary/{workspace}', function () { - it('requires authentication', function () { - $response = $this->getJson('/api/v1/entitlements/summary/'.$this->workspace->id); - - $response->assertStatus(401); - }); - - it('returns summary for specified workspace', function () { - $this->actingAs($this->user); - $this->service->provisionPackage($this->workspace, 'social-creator'); - - $response = $this->getJson('/api/v1/entitlements/summary/'.$this->workspace->id); - - $response->assertStatus(200) - ->assertJson([ - 'workspace_id' => $this->workspace->id, - ]); - }); - }); -}); diff --git a/packages/core-php/src/Mod/Tenant/Tests/Feature/EntitlementServiceTest.php b/packages/core-php/src/Mod/Tenant/Tests/Feature/EntitlementServiceTest.php deleted file mode 100644 index eae441f..0000000 --- a/packages/core-php/src/Mod/Tenant/Tests/Feature/EntitlementServiceTest.php +++ /dev/null @@ -1,641 +0,0 @@ -user = User::factory()->create(); - $this->workspace = Workspace::factory()->create(); - $this->workspace->users()->attach($this->user->id, [ - 'role' => 'owner', - 'is_default' => true, - ]); - - // Create features - $this->aiCreditsFeature = Feature::create([ - 'code' => 'ai.credits', - 'name' => 'AI Credits', - 'description' => 'AI generation credits', - 'category' => 'ai', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_MONTHLY, - 'is_active' => true, - 'sort_order' => 1, - ]); - - $this->apolloTierFeature = Feature::create([ - 'code' => 'tier.apollo', - 'name' => 'Apollo Tier', - 'description' => 'Apollo tier access', - 'category' => 'tier', - 'type' => Feature::TYPE_BOOLEAN, - 'reset_type' => Feature::RESET_NONE, - 'is_active' => true, - 'sort_order' => 1, - ]); - - $this->socialPostsFeature = Feature::create([ - 'code' => 'social.posts', - 'name' => 'Scheduled Posts', - 'description' => 'Monthly scheduled posts', - 'category' => 'social', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_MONTHLY, - 'is_active' => true, - 'sort_order' => 1, - ]); - - // Create packages - $this->creatorPackage = Package::create([ - 'code' => 'creator', - 'name' => 'Creator', - 'description' => 'For individual creators', - 'is_stackable' => false, - 'is_base_package' => true, - 'is_active' => true, - 'is_public' => true, - 'sort_order' => 1, - ]); - - $this->agencyPackage = Package::create([ - 'code' => 'agency', - 'name' => 'Agency', - 'description' => 'For agencies', - 'is_stackable' => false, - 'is_base_package' => true, - 'is_active' => true, - 'is_public' => true, - 'sort_order' => 2, - ]); - - // Attach features to packages - $this->creatorPackage->features()->attach($this->aiCreditsFeature->id, ['limit_value' => 100]); - $this->creatorPackage->features()->attach($this->apolloTierFeature->id, ['limit_value' => null]); - $this->creatorPackage->features()->attach($this->socialPostsFeature->id, ['limit_value' => 50]); - - $this->agencyPackage->features()->attach($this->aiCreditsFeature->id, ['limit_value' => 500]); - $this->agencyPackage->features()->attach($this->apolloTierFeature->id, ['limit_value' => null]); - $this->agencyPackage->features()->attach($this->socialPostsFeature->id, ['limit_value' => 200]); - - $this->service = app(EntitlementService::class); -}); - -describe('EntitlementService', function () { - describe('can() method', function () { - it('denies access when workspace has no packages', function () { - $result = $this->service->can($this->workspace, 'ai.credits'); - - expect($result)->toBeInstanceOf(EntitlementResult::class) - ->and($result->isAllowed())->toBeFalse() - ->and($result->isDenied())->toBeTrue() - ->and($result->reason)->toContain('plan does not include'); - }); - - it('allows access when workspace has package with feature', function () { - $this->service->provisionPackage($this->workspace, 'creator'); - - $result = $this->service->can($this->workspace, 'ai.credits'); - - expect($result->isAllowed())->toBeTrue() - ->and($result->limit)->toBe(100) - ->and($result->used)->toBe(0); - }); - - it('allows boolean features without limits', function () { - $this->service->provisionPackage($this->workspace, 'creator'); - - $result = $this->service->can($this->workspace, 'tier.apollo'); - - expect($result->isAllowed())->toBeTrue() - ->and($result->limit)->toBeNull(); - }); - - it('denies access when limit is exceeded', function () { - $this->service->provisionPackage($this->workspace, 'creator'); - - // Record usage up to the limit - for ($i = 0; $i < 100; $i++) { - UsageRecord::create([ - 'workspace_id' => $this->workspace->id, - 'feature_code' => 'ai.credits', - 'quantity' => 1, - 'recorded_at' => now(), - ]); - } - - Cache::flush(); - $result = $this->service->can($this->workspace, 'ai.credits'); - - expect($result->isDenied())->toBeTrue() - ->and($result->used)->toBe(100) - ->and($result->limit)->toBe(100) - ->and($result->reason)->toContain('reached your'); - }); - - it('allows access when quantity is within remaining limit', function () { - $this->service->provisionPackage($this->workspace, 'creator'); - - // Use 50 credits - UsageRecord::create([ - 'workspace_id' => $this->workspace->id, - 'feature_code' => 'ai.credits', - 'quantity' => 50, - 'recorded_at' => now(), - ]); - - Cache::flush(); - $result = $this->service->can($this->workspace, 'ai.credits', quantity: 25); - - expect($result->isAllowed())->toBeTrue() - ->and($result->remaining)->toBe(50); - }); - - it('denies access when requested quantity exceeds remaining', function () { - $this->service->provisionPackage($this->workspace, 'creator'); - - // Use 90 credits - UsageRecord::create([ - 'workspace_id' => $this->workspace->id, - 'feature_code' => 'ai.credits', - 'quantity' => 90, - 'recorded_at' => now(), - ]); - - Cache::flush(); - $result = $this->service->can($this->workspace, 'ai.credits', quantity: 20); - - expect($result->isDenied())->toBeTrue() - ->and($result->used)->toBe(90) - ->and($result->remaining)->toBe(10); - }); - - it('denies access for non-existent feature', function () { - $result = $this->service->can($this->workspace, 'non.existent.feature'); - - expect($result->isDenied())->toBeTrue() - ->and($result->reason)->toContain('does not exist'); - }); - }); - - describe('recordUsage() method', function () { - it('creates a usage record', function () { - $this->service->provisionPackage($this->workspace, 'creator'); - - $record = $this->service->recordUsage( - $this->workspace, - 'ai.credits', - quantity: 5, - user: $this->user - ); - - expect($record)->toBeInstanceOf(UsageRecord::class) - ->and($record->workspace_id)->toBe($this->workspace->id) - ->and($record->feature_code)->toBe('ai.credits') - ->and($record->quantity)->toBe(5) - ->and($record->user_id)->toBe($this->user->id); - }); - - it('records usage with metadata', function () { - $this->service->provisionPackage($this->workspace, 'creator'); - - $record = $this->service->recordUsage( - $this->workspace, - 'ai.credits', - quantity: 1, - metadata: ['model' => 'claude-3', 'tokens' => 1500] - ); - - expect($record->metadata)->toBe(['model' => 'claude-3', 'tokens' => 1500]); - }); - - it('invalidates cache after recording usage', function () { - $this->service->provisionPackage($this->workspace, 'creator'); - - // Warm up cache - $this->service->can($this->workspace, 'ai.credits'); - - // Record usage - $this->service->recordUsage($this->workspace, 'ai.credits', quantity: 10); - - // Check that usage is reflected (cache was invalidated) - $result = $this->service->can($this->workspace, 'ai.credits'); - - expect($result->used)->toBe(10); - }); - }); - - describe('provisionPackage() method', function () { - it('provisions a package to workspace', function () { - $workspacePackage = $this->service->provisionPackage($this->workspace, 'creator'); - - expect($workspacePackage)->toBeInstanceOf(WorkspacePackage::class) - ->and($workspacePackage->workspace_id)->toBe($this->workspace->id) - ->and($workspacePackage->package->code)->toBe('creator') - ->and($workspacePackage->status)->toBe(WorkspacePackage::STATUS_ACTIVE); - }); - - it('creates an entitlement log entry', function () { - $this->service->provisionPackage($this->workspace, 'creator', [ - 'source' => EntitlementLog::SOURCE_BLESTA, - ]); - - $log = EntitlementLog::where('workspace_id', $this->workspace->id) - ->where('action', EntitlementLog::ACTION_PACKAGE_PROVISIONED) - ->first(); - - expect($log)->not->toBeNull() - ->and($log->source)->toBe(EntitlementLog::SOURCE_BLESTA); - }); - - it('replaces existing base package when provisioning new base package', function () { - // Provision creator package - $creatorWp = $this->service->provisionPackage($this->workspace, 'creator'); - - // Provision agency package (should cancel creator) - $agencyWp = $this->service->provisionPackage($this->workspace, 'agency'); - - // Refresh creator package - $creatorWp->refresh(); - - expect($creatorWp->status)->toBe(WorkspacePackage::STATUS_CANCELLED) - ->and($agencyWp->status)->toBe(WorkspacePackage::STATUS_ACTIVE); - }); - - it('sets billing cycle anchor', function () { - $anchor = now()->subDays(15); - - $workspacePackage = $this->service->provisionPackage($this->workspace, 'creator', [ - 'billing_cycle_anchor' => $anchor, - ]); - - expect($workspacePackage->billing_cycle_anchor->toDateString()) - ->toBe($anchor->toDateString()); - }); - - it('stores blesta service id', function () { - $workspacePackage = $this->service->provisionPackage($this->workspace, 'creator', [ - 'blesta_service_id' => 'blesta_12345', - ]); - - expect($workspacePackage->blesta_service_id)->toBe('blesta_12345'); - }); - }); - - describe('provisionBoost() method', function () { - it('provisions a boost to workspace', function () { - $this->service->provisionPackage($this->workspace, 'creator'); - - $boost = $this->service->provisionBoost($this->workspace, 'ai.credits', [ - 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, - 'limit_value' => 100, - 'duration_type' => Boost::DURATION_CYCLE_BOUND, - ]); - - expect($boost)->toBeInstanceOf(Boost::class) - ->and($boost->workspace_id)->toBe($this->workspace->id) - ->and($boost->feature_code)->toBe('ai.credits') - ->and($boost->limit_value)->toBe(100) - ->and($boost->status)->toBe(Boost::STATUS_ACTIVE); - }); - - it('adds boost limit to package limit', function () { - $this->service->provisionPackage($this->workspace, 'creator'); // 100 credits - - $this->service->provisionBoost($this->workspace, 'ai.credits', [ - 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, - 'limit_value' => 50, - ]); - - Cache::flush(); - $result = $this->service->can($this->workspace, 'ai.credits'); - - expect($result->limit)->toBe(150); // 100 + 50 - }); - - it('creates an entitlement log entry for boost', function () { - $this->service->provisionBoost($this->workspace, 'ai.credits', [ - 'limit_value' => 100, - ]); - - $log = EntitlementLog::where('workspace_id', $this->workspace->id) - ->where('action', EntitlementLog::ACTION_BOOST_PROVISIONED) - ->first(); - - expect($log)->not->toBeNull(); - }); - }); - - describe('suspendWorkspace() method', function () { - it('suspends all active packages', function () { - $workspacePackage = $this->service->provisionPackage($this->workspace, 'creator'); - - $this->service->suspendWorkspace($this->workspace); - - $workspacePackage->refresh(); - - expect($workspacePackage->status)->toBe(WorkspacePackage::STATUS_SUSPENDED); - }); - - it('creates suspension log entries', function () { - $this->service->provisionPackage($this->workspace, 'creator'); - - $this->service->suspendWorkspace($this->workspace, EntitlementLog::SOURCE_BLESTA); - - $log = EntitlementLog::where('workspace_id', $this->workspace->id) - ->where('action', EntitlementLog::ACTION_PACKAGE_SUSPENDED) - ->first(); - - expect($log)->not->toBeNull() - ->and($log->source)->toBe(EntitlementLog::SOURCE_BLESTA); - }); - - it('denies access after suspension', function () { - $this->service->provisionPackage($this->workspace, 'creator'); - - // Can access before suspension - expect($this->service->can($this->workspace, 'ai.credits')->isAllowed())->toBeTrue(); - - $this->service->suspendWorkspace($this->workspace); - Cache::flush(); - - // Cannot access after suspension - expect($this->service->can($this->workspace, 'ai.credits')->isDenied())->toBeTrue(); - }); - }); - - describe('reactivateWorkspace() method', function () { - it('reactivates suspended packages', function () { - $workspacePackage = $this->service->provisionPackage($this->workspace, 'creator'); - $this->service->suspendWorkspace($this->workspace); - - $this->service->reactivateWorkspace($this->workspace); - - $workspacePackage->refresh(); - - expect($workspacePackage->status)->toBe(WorkspacePackage::STATUS_ACTIVE); - }); - - it('creates reactivation log entries', function () { - $this->service->provisionPackage($this->workspace, 'creator'); - $this->service->suspendWorkspace($this->workspace); - - $this->service->reactivateWorkspace($this->workspace, EntitlementLog::SOURCE_BLESTA); - - $log = EntitlementLog::where('workspace_id', $this->workspace->id) - ->where('action', EntitlementLog::ACTION_PACKAGE_REACTIVATED) - ->first(); - - expect($log)->not->toBeNull() - ->and($log->source)->toBe(EntitlementLog::SOURCE_BLESTA); - }); - - it('restores access after reactivation', function () { - $this->service->provisionPackage($this->workspace, 'creator'); - $this->service->suspendWorkspace($this->workspace); - - $this->service->reactivateWorkspace($this->workspace); - Cache::flush(); - - expect($this->service->can($this->workspace, 'ai.credits')->isAllowed())->toBeTrue(); - }); - }); - - describe('getUsageSummary() method', function () { - it('returns usage summary for all features', function () { - $this->service->provisionPackage($this->workspace, 'creator'); - - $summary = $this->service->getUsageSummary($this->workspace); - - expect($summary)->toBeInstanceOf(\Illuminate\Support\Collection::class) - ->and($summary->has('ai'))->toBeTrue() - ->and($summary->has('tier'))->toBeTrue() - ->and($summary->has('social'))->toBeTrue(); - }); - - it('includes usage percentages', function () { - $this->service->provisionPackage($this->workspace, 'creator'); - - // Use 50 of 100 credits - $this->service->recordUsage($this->workspace, 'ai.credits', quantity: 50); - - $summary = $this->service->getUsageSummary($this->workspace); - $aiFeature = $summary->get('ai')->first(); - - expect($aiFeature['used'])->toBe(50) - ->and($aiFeature['limit'])->toBe(100) - ->and((int) $aiFeature['percentage'])->toBe(50); - }); - }); - - describe('getActivePackages() method', function () { - it('returns only active packages', function () { - $this->service->provisionPackage($this->workspace, 'creator'); - $this->service->suspendWorkspace($this->workspace); - - $activePackages = $this->service->getActivePackages($this->workspace); - - expect($activePackages)->toHaveCount(0); - }); - - it('excludes expired packages', function () { - $wp = $this->service->provisionPackage($this->workspace, 'creator', [ - 'expires_at' => now()->subDay(), - ]); - - $activePackages = $this->service->getActivePackages($this->workspace); - - expect($activePackages)->toHaveCount(0); - }); - }); - - describe('getActiveBoosts() method', function () { - it('returns only active boosts', function () { - $boost = $this->service->provisionBoost($this->workspace, 'ai.credits', [ - 'limit_value' => 100, - ]); - - $activeBoosts = $this->service->getActiveBoosts($this->workspace); - - expect($activeBoosts)->toHaveCount(1); - - // Cancel the boost - $boost->update(['status' => Boost::STATUS_CANCELLED]); - - $activeBoosts = $this->service->getActiveBoosts($this->workspace); - - expect($activeBoosts)->toHaveCount(0); - }); - }); - - describe('revokePackage() method', function () { - it('revokes an active package', function () { - $workspacePackage = $this->service->provisionPackage($this->workspace, 'creator'); - - expect($workspacePackage->status)->toBe(WorkspacePackage::STATUS_ACTIVE); - - $this->service->revokePackage($this->workspace, 'creator'); - - $workspacePackage->refresh(); - - expect($workspacePackage->status)->toBe(WorkspacePackage::STATUS_CANCELLED) - ->and($workspacePackage->expires_at)->not->toBeNull(); - }); - - it('creates a cancellation log entry', function () { - $this->service->provisionPackage($this->workspace, 'creator'); - - $this->service->revokePackage($this->workspace, 'creator', EntitlementLog::SOURCE_SYSTEM); - - $log = EntitlementLog::where('workspace_id', $this->workspace->id) - ->where('action', EntitlementLog::ACTION_PACKAGE_CANCELLED) - ->first(); - - expect($log)->not->toBeNull() - ->and($log->source)->toBe(EntitlementLog::SOURCE_SYSTEM); - }); - - it('denies access after package revocation', function () { - $this->service->provisionPackage($this->workspace, 'creator'); - - // Can access before revocation - expect($this->service->can($this->workspace, 'ai.credits')->isAllowed())->toBeTrue(); - - $this->service->revokePackage($this->workspace, 'creator'); - Cache::flush(); - - // Cannot access after revocation - expect($this->service->can($this->workspace, 'ai.credits')->isDenied())->toBeTrue(); - }); - - it('does nothing when package does not exist', function () { - // Should not throw, just return silently - $this->service->revokePackage($this->workspace, 'nonexistent-package'); - - // No log entries should be created - $log = EntitlementLog::where('workspace_id', $this->workspace->id) - ->where('action', EntitlementLog::ACTION_PACKAGE_CANCELLED) - ->first(); - - expect($log)->toBeNull(); - }); - - it('does nothing when package already cancelled', function () { - $workspacePackage = $this->service->provisionPackage($this->workspace, 'creator'); - $workspacePackage->update(['status' => WorkspacePackage::STATUS_CANCELLED]); - - // Should not throw - $this->service->revokePackage($this->workspace, 'creator'); - - // Only one log entry (from provisioning, not cancellation) - $logs = EntitlementLog::where('workspace_id', $this->workspace->id) - ->where('action', EntitlementLog::ACTION_PACKAGE_CANCELLED) - ->count(); - - expect($logs)->toBe(0); - }); - - it('invalidates cache after revocation', function () { - $this->service->provisionPackage($this->workspace, 'creator'); - - // Warm up cache - $this->service->can($this->workspace, 'ai.credits'); - - // Revoke - $this->service->revokePackage($this->workspace, 'creator'); - - // Check that revocation is reflected (cache was invalidated) - $result = $this->service->can($this->workspace, 'ai.credits'); - - expect($result->isDenied())->toBeTrue(); - }); - }); - - describe('expireCycleBoundBoosts() method', function () { - it('expires cycle-bound boosts', function () { - $boost = $this->service->provisionBoost($this->workspace, 'ai.credits', [ - 'limit_value' => 100, - 'duration_type' => Boost::DURATION_CYCLE_BOUND, - ]); - - $this->service->expireCycleBoundBoosts($this->workspace); - - $boost->refresh(); - - expect($boost->status)->toBe(Boost::STATUS_EXPIRED); - }); - - it('does not expire permanent boosts', function () { - $boost = $this->service->provisionBoost($this->workspace, 'ai.credits', [ - 'limit_value' => 100, - 'duration_type' => Boost::DURATION_PERMANENT, - ]); - - $this->service->expireCycleBoundBoosts($this->workspace); - - $boost->refresh(); - - expect($boost->status)->toBe(Boost::STATUS_ACTIVE); - }); - - it('creates expiration log entries', function () { - $this->service->provisionBoost($this->workspace, 'ai.credits', [ - 'limit_value' => 100, - 'duration_type' => Boost::DURATION_CYCLE_BOUND, - ]); - - $this->service->expireCycleBoundBoosts($this->workspace); - - $log = EntitlementLog::where('workspace_id', $this->workspace->id) - ->where('action', EntitlementLog::ACTION_BOOST_EXPIRED) - ->first(); - - expect($log)->not->toBeNull(); - }); - }); -}); - -describe('EntitlementResult', function () { - it('calculates remaining correctly', function () { - $result = EntitlementResult::allowed(limit: 100, used: 75, featureCode: 'test'); - - expect($result->remaining)->toBe(25); - }); - - it('calculates usage percentage correctly', function () { - $result = EntitlementResult::allowed(limit: 100, used: 75, featureCode: 'test'); - - expect((int) $result->getUsagePercentage())->toBe(75); - }); - - it('identifies near limit correctly', function () { - $result = EntitlementResult::allowed(limit: 100, used: 85, featureCode: 'test'); - - expect($result->isNearLimit())->toBeTrue(); - - $result2 = EntitlementResult::allowed(limit: 100, used: 50, featureCode: 'test'); - - expect($result2->isNearLimit())->toBeFalse(); - }); - - it('identifies unlimited correctly', function () { - $result = EntitlementResult::unlimited('test'); - - expect($result->isUnlimited())->toBeTrue() - ->and($result->isAllowed())->toBeTrue(); - }); -}); diff --git a/packages/core-php/src/Mod/Tenant/Tests/Feature/Guards/AccessTokenGuardTest.php b/packages/core-php/src/Mod/Tenant/Tests/Feature/Guards/AccessTokenGuardTest.php deleted file mode 100644 index 31008b9..0000000 --- a/packages/core-php/src/Mod/Tenant/Tests/Feature/Guards/AccessTokenGuardTest.php +++ /dev/null @@ -1,180 +0,0 @@ -create(); - $result = $user->createToken('Test Token'); - - // Test the guard directly by invoking it with a mock request - $guard = new \Core\Mod\Api\Guards\AccessTokenGuard(app('auth')); - $request = \Illuminate\Http\Request::create('/test', 'GET'); - $request->headers->set('Authorization', "Bearer {$result['token']}"); - - $authenticatedUser = $guard($request); - - expect($authenticatedUser)->not->toBeNull(); - expect($authenticatedUser->id)->toBe($user->id); -}); - -test('cannot authenticate with invalid token', function () { - $response = $this->getJson('/api/v1/social/posts', [ - 'Authorization' => 'Bearer invalid-token-that-does-not-exist', - ]); - - $response->assertUnauthorized(); -}); - -test('cannot authenticate with expired token', function () { - $user = User::factory()->create(); - $token = UserToken::factory() - ->for($user) - ->expired() - ->withToken('expired-token-12345') - ->create(); - - $response = $this->getJson('/api/v1/social/posts', [ - 'Authorization' => 'Bearer expired-token-12345', - ]); - - $response->assertUnauthorized(); -}); - -test('cannot authenticate without authorization header', function () { - $response = $this->getJson('/api/v1/social/posts'); - - $response->assertUnauthorized(); -}); - -test('token last_used_at is updated on successful authentication', function () { - $user = User::factory()->create(); - $result = $user->createToken('Test Token'); - $tokenModel = $result['model']; - - expect($tokenModel->last_used_at)->toBeNull(); - - // Test the guard directly by invoking it with a mock request - $guard = new \Core\Mod\Api\Guards\AccessTokenGuard(app('auth')); - $request = \Illuminate\Http\Request::create('/test', 'GET'); - $request->headers->set('Authorization', "Bearer {$result['token']}"); - - $guard($request); - - // Refresh the token model and check last_used_at was updated - $tokenModel->refresh(); - expect($tokenModel->last_used_at)->not->toBeNull(); - expect($tokenModel->last_used_at->timestamp)->toBeGreaterThan(now()->subMinute()->timestamp); -}); - -test('user can create multiple tokens with different names', function () { - $user = User::factory()->create(); - - $token1 = $user->createToken('Mobile App'); - $token2 = $user->createToken('Web Dashboard'); - $token3 = $user->createToken('CI/CD Pipeline'); - - expect($user->tokens)->toHaveCount(3); - expect($user->tokens->pluck('name')->toArray())->toBe([ - 'Mobile App', - 'Web Dashboard', - 'CI/CD Pipeline', - ]); -}); - -test('user can revoke a specific token', function () { - $user = User::factory()->create(); - - $token1 = $user->createToken('Token 1'); - $token2 = $user->createToken('Token 2'); - - expect($user->tokens)->toHaveCount(2); - - $user->revokeToken($token1['model']->id); - - $user->refresh(); - expect($user->tokens)->toHaveCount(1); - expect($user->tokens->first()->name)->toBe('Token 2'); -}); - -test('user can revoke all tokens', function () { - $user = User::factory()->create(); - - $user->createToken('Token 1'); - $user->createToken('Token 2'); - $user->createToken('Token 3'); - - expect($user->tokens)->toHaveCount(3); - - $user->revokeAllTokens(); - - $user->refresh(); - expect($user->tokens)->toHaveCount(0); -}); - -test('tokens are automatically deleted when user is deleted', function () { - $user = User::factory()->create(); - $result = $user->createToken('Test Token'); - $tokenId = $result['model']->id; - - expect(UserToken::find($tokenId))->not->toBeNull(); - - $user->delete(); - - // Token should be deleted due to CASCADE constraint - expect(UserToken::find($tokenId))->toBeNull(); -}); - -test('tokens are stored as hashed values', function () { - $user = User::factory()->create(); - $result = $user->createToken('Test Token'); - $plainToken = $result['token']; - $tokenModel = $result['model']; - - // The stored token should NOT match the plain text token - expect($tokenModel->token)->not->toBe($plainToken); - - // But it should match the SHA-256 hash - expect($tokenModel->token)->toBe(hash('sha256', $plainToken)); -}); - -test('can create token with expiry date', function () { - $user = User::factory()->create(); - $expiryDate = now()->addDays(30); - - $result = $user->createToken('Temporary Token', $expiryDate); - $tokenModel = $result['model']; - - expect($tokenModel->expires_at)->not->toBeNull(); - expect($tokenModel->expires_at->timestamp)->toBe($expiryDate->timestamp); - expect($tokenModel->isValid())->toBeTrue(); - expect($tokenModel->isExpired())->toBeFalse(); -}); - -test('expired tokens are marked as invalid', function () { - $token = UserToken::factory() - ->expired() - ->create(); - - expect($token->isExpired())->toBeTrue(); - expect($token->isValid())->toBeFalse(); -}); - -test('non-expired tokens are marked as valid', function () { - $token = UserToken::factory() - ->expiresIn(30) - ->create(); - - expect($token->isExpired())->toBeFalse(); - expect($token->isValid())->toBeTrue(); -}); - -test('tokens without expiry date are always valid', function () { - $token = UserToken::factory()->create(); - - expect($token->expires_at)->toBeNull(); - expect($token->isExpired())->toBeFalse(); - expect($token->isValid())->toBeTrue(); -}); diff --git a/packages/core-php/src/Mod/Tenant/Tests/Feature/ProfileTest.php b/packages/core-php/src/Mod/Tenant/Tests/Feature/ProfileTest.php deleted file mode 100644 index 0c75a4b..0000000 --- a/packages/core-php/src/Mod/Tenant/Tests/Feature/ProfileTest.php +++ /dev/null @@ -1,131 +0,0 @@ -create($attributes); - } - - public function test_profile_page_is_accessible_when_authenticated(): void - { - $user = $this->createUser(); - - $response = $this->actingAs($user)->get('/hub/profile'); - - $response->assertStatus(200); - $response->assertSee('Usage'); - } - - public function test_profile_page_redirects_guests_to_login(): void - { - $response = $this->get('/hub/profile'); - - $response->assertRedirect('/login'); - } - - public function test_profile_displays_user_name(): void - { - $user = $this->createUser([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); - - $component = Livewire::actingAs($user)->test(Profile::class); - - $this->assertEquals('Test User', $component->get('userName')); - $this->assertEquals('test@example.com', $component->get('userEmail')); - } - - public function test_profile_calculates_user_initials(): void - { - $user = $this->createUser(['name' => 'John Doe']); - - $component = Livewire::actingAs($user)->test(Profile::class); - - $this->assertEquals('JD', $component->get('userInitials')); - } - - public function test_profile_calculates_initials_for_single_name(): void - { - $user = $this->createUser(['name' => 'Madonna']); - - $component = Livewire::actingAs($user)->test(Profile::class); - - $this->assertEquals('M', $component->get('userInitials')); - } - - public function test_profile_loads_quotas(): void - { - $user = $this->createUser(); - - $component = Livewire::actingAs($user)->test(Profile::class); - - $quotas = $component->get('quotas'); - - $this->assertArrayHasKey('workspaces', $quotas); - $this->assertArrayHasKey('social_accounts', $quotas); - $this->assertArrayHasKey('scheduled_posts', $quotas); - $this->assertArrayHasKey('storage', $quotas); - } - - public function test_profile_loads_service_stats(): void - { - $user = $this->createUser(); - - $component = Livewire::actingAs($user)->test(Profile::class); - - $stats = $component->get('serviceStats'); - - $this->assertNotEmpty($stats); - $this->assertArrayHasKey('name', $stats[0]); - $this->assertArrayHasKey('icon', $stats[0]); - $this->assertArrayHasKey('color', $stats[0]); - $this->assertArrayHasKey('status', $stats[0]); - } - - public function test_profile_loads_recent_activity(): void - { - $user = $this->createUser(); - - $component = Livewire::actingAs($user)->test(Profile::class); - - $activity = $component->get('recentActivity'); - - $this->assertIsArray($activity); - } - - public function test_profile_shows_member_since_date(): void - { - $user = $this->createUser(); - - $component = Livewire::actingAs($user)->test(Profile::class); - - $memberSince = $component->get('memberSince'); - - $this->assertNotNull($memberSince); - $this->assertMatchesRegularExpression('/\w+ \d{4}/', $memberSince); - } - - public function test_profile_shows_user_tier(): void - { - $user = $this->createUser(); - - $component = Livewire::actingAs($user)->test(Profile::class); - - $userTier = $component->get('userTier'); - - $this->assertNotNull($userTier); - $this->assertContains($userTier, ['Free', 'Apollo', 'Hades']); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Tests/Feature/ResetBillingCyclesTest.php b/packages/core-php/src/Mod/Tenant/Tests/Feature/ResetBillingCyclesTest.php deleted file mode 100644 index 6196ee5..0000000 --- a/packages/core-php/src/Mod/Tenant/Tests/Feature/ResetBillingCyclesTest.php +++ /dev/null @@ -1,462 +0,0 @@ -user = User::factory()->create(); - $this->workspace = Workspace::factory()->create(); - $this->workspace->users()->attach($this->user->id, [ - 'role' => 'owner', - 'is_default' => true, - ]); - - // Create features - $this->aiCreditsFeature = Feature::create([ - 'code' => 'ai.credits', - 'name' => 'AI Credits', - 'description' => 'AI generation credits', - 'category' => 'ai', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_MONTHLY, - 'is_active' => true, - 'sort_order' => 1, - ]); - - $this->socialPostsFeature = Feature::create([ - 'code' => 'social.posts', - 'name' => 'Scheduled Posts', - 'description' => 'Monthly scheduled posts', - 'category' => 'social', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_MONTHLY, - 'is_active' => true, - 'sort_order' => 1, - ]); - - // Create base package - $this->creatorPackage = Package::create([ - 'code' => 'creator', - 'name' => 'Creator', - 'description' => 'For individual creators', - 'is_stackable' => false, - 'is_base_package' => true, - 'is_active' => true, - 'is_public' => true, - 'sort_order' => 1, - ]); - - $this->creatorPackage->features()->attach($this->aiCreditsFeature->id, ['limit_value' => 100]); - $this->creatorPackage->features()->attach($this->socialPostsFeature->id, ['limit_value' => 50]); - - $this->service = app(EntitlementService::class); -}); - -describe('ResetBillingCycles Command', function () { - describe('expiring cycle-bound boosts', function () { - it('expires cycle-bound boosts', function () { - // Provision package - $this->service->provisionPackage($this->workspace, 'creator', [ - 'billing_cycle_anchor' => now()->startOfMonth(), - ]); - - // Create cycle-bound boost - $boost = Boost::create([ - 'workspace_id' => $this->workspace->id, - 'feature_code' => 'ai.credits', - 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, - 'duration_type' => Boost::DURATION_CYCLE_BOUND, - 'limit_value' => 50, - 'consumed_quantity' => 10, - 'status' => Boost::STATUS_ACTIVE, - 'starts_at' => now()->subDays(15), - ]); - - // Run command - $this->artisan('tenant:reset-billing-cycles', [ - '--workspace' => $this->workspace->id, - ])->assertExitCode(0); - - $boost->refresh(); - - expect($boost->status)->toBe(Boost::STATUS_EXPIRED); - }); - - it('does not expire permanent boosts', function () { - $this->service->provisionPackage($this->workspace, 'creator', [ - 'billing_cycle_anchor' => now()->startOfMonth(), - ]); - - $boost = Boost::create([ - 'workspace_id' => $this->workspace->id, - 'feature_code' => 'ai.credits', - 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, - 'duration_type' => Boost::DURATION_PERMANENT, - 'limit_value' => 50, - 'consumed_quantity' => 0, - 'status' => Boost::STATUS_ACTIVE, - 'starts_at' => now()->subDays(15), - ]); - - $this->artisan('tenant:reset-billing-cycles', [ - '--workspace' => $this->workspace->id, - ])->assertExitCode(0); - - $boost->refresh(); - - expect($boost->status)->toBe(Boost::STATUS_ACTIVE); - }); - - it('creates audit log entries for expired boosts', function () { - $this->service->provisionPackage($this->workspace, 'creator', [ - 'billing_cycle_anchor' => now()->startOfMonth(), - ]); - - Boost::create([ - 'workspace_id' => $this->workspace->id, - 'feature_code' => 'ai.credits', - 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, - 'duration_type' => Boost::DURATION_CYCLE_BOUND, - 'limit_value' => 50, - 'consumed_quantity' => 0, - 'status' => Boost::STATUS_ACTIVE, - 'starts_at' => now()->subDays(15), - ]); - - $this->artisan('tenant:reset-billing-cycles', [ - '--workspace' => $this->workspace->id, - ])->assertExitCode(0); - - $log = EntitlementLog::where('workspace_id', $this->workspace->id) - ->where('action', EntitlementLog::ACTION_BOOST_EXPIRED) - ->first(); - - expect($log)->not->toBeNull() - ->and($log->metadata['reason'])->toBe('Billing cycle ended'); - }); - }); - - describe('expiring timed boosts', function () { - it('expires boosts past their expiry date', function () { - $this->service->provisionPackage($this->workspace, 'creator', [ - 'billing_cycle_anchor' => now()->startOfMonth(), - ]); - - $boost = Boost::create([ - 'workspace_id' => $this->workspace->id, - 'feature_code' => 'ai.credits', - 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, - 'duration_type' => Boost::DURATION_DURATION, - 'limit_value' => 100, - 'consumed_quantity' => 0, - 'status' => Boost::STATUS_ACTIVE, - 'starts_at' => now()->subDays(30), - 'expires_at' => now()->subDay(), // Expired yesterday - ]); - - $this->artisan('tenant:reset-billing-cycles', [ - '--workspace' => $this->workspace->id, - ])->assertExitCode(0); - - $boost->refresh(); - - expect($boost->status)->toBe(Boost::STATUS_EXPIRED); - }); - - it('does not expire boosts with future expiry', function () { - $this->service->provisionPackage($this->workspace, 'creator', [ - 'billing_cycle_anchor' => now()->startOfMonth(), - ]); - - $boost = Boost::create([ - 'workspace_id' => $this->workspace->id, - 'feature_code' => 'ai.credits', - 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, - 'duration_type' => Boost::DURATION_DURATION, - 'limit_value' => 100, - 'consumed_quantity' => 0, - 'status' => Boost::STATUS_ACTIVE, - 'starts_at' => now(), - 'expires_at' => now()->addWeek(), // Expires next week - ]); - - $this->artisan('tenant:reset-billing-cycles', [ - '--workspace' => $this->workspace->id, - ])->assertExitCode(0); - - $boost->refresh(); - - expect($boost->status)->toBe(Boost::STATUS_ACTIVE); - }); - }); - - describe('notifications', function () { - it('sends notification to workspace owner when boosts expire', function () { - $this->service->provisionPackage($this->workspace, 'creator', [ - 'billing_cycle_anchor' => now()->startOfMonth(), - ]); - - Boost::create([ - 'workspace_id' => $this->workspace->id, - 'feature_code' => 'ai.credits', - 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, - 'duration_type' => Boost::DURATION_CYCLE_BOUND, - 'limit_value' => 50, - 'consumed_quantity' => 10, - 'status' => Boost::STATUS_ACTIVE, - 'starts_at' => now()->subDays(15), - ]); - - $this->artisan('tenant:reset-billing-cycles', [ - '--workspace' => $this->workspace->id, - ])->assertExitCode(0); - - Notification::assertSentTo( - $this->user, - BoostExpiredNotification::class - ); - }); - - it('does not send notification in dry-run mode', function () { - $this->service->provisionPackage($this->workspace, 'creator', [ - 'billing_cycle_anchor' => now()->startOfMonth(), - ]); - - Boost::create([ - 'workspace_id' => $this->workspace->id, - 'feature_code' => 'ai.credits', - 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, - 'duration_type' => Boost::DURATION_CYCLE_BOUND, - 'limit_value' => 50, - 'consumed_quantity' => 0, - 'status' => Boost::STATUS_ACTIVE, - 'starts_at' => now()->subDays(15), - ]); - - $this->artisan('tenant:reset-billing-cycles', [ - '--workspace' => $this->workspace->id, - '--dry-run' => true, - ])->assertExitCode(0); - - Notification::assertNothingSent(); - }); - }); - - describe('dry-run mode', function () { - it('does not modify boosts in dry-run mode', function () { - $this->service->provisionPackage($this->workspace, 'creator', [ - 'billing_cycle_anchor' => now()->startOfMonth(), - ]); - - $boost = Boost::create([ - 'workspace_id' => $this->workspace->id, - 'feature_code' => 'ai.credits', - 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, - 'duration_type' => Boost::DURATION_CYCLE_BOUND, - 'limit_value' => 50, - 'consumed_quantity' => 0, - 'status' => Boost::STATUS_ACTIVE, - 'starts_at' => now()->subDays(15), - ]); - - $this->artisan('tenant:reset-billing-cycles', [ - '--workspace' => $this->workspace->id, - '--dry-run' => true, - ])->assertExitCode(0); - - $boost->refresh(); - - expect($boost->status)->toBe(Boost::STATUS_ACTIVE); - }); - - it('does not create log entries in dry-run mode', function () { - $this->service->provisionPackage($this->workspace, 'creator', [ - 'billing_cycle_anchor' => now()->startOfMonth(), - ]); - - Boost::create([ - 'workspace_id' => $this->workspace->id, - 'feature_code' => 'ai.credits', - 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, - 'duration_type' => Boost::DURATION_CYCLE_BOUND, - 'limit_value' => 50, - 'consumed_quantity' => 0, - 'status' => Boost::STATUS_ACTIVE, - 'starts_at' => now()->subDays(15), - ]); - - // Clear any existing logs - EntitlementLog::where('workspace_id', $this->workspace->id)->delete(); - - $this->artisan('tenant:reset-billing-cycles', [ - '--workspace' => $this->workspace->id, - '--dry-run' => true, - ])->assertExitCode(0); - - $logs = EntitlementLog::where('workspace_id', $this->workspace->id) - ->where('action', EntitlementLog::ACTION_BOOST_EXPIRED) - ->count(); - - expect($logs)->toBe(0); - }); - }); - - describe('processing all workspaces', function () { - it('processes multiple workspaces', function () { - // Create second workspace - $workspace2 = Workspace::factory()->create(['is_active' => true]); - $user2 = User::factory()->create(); - $workspace2->users()->attach($user2->id, ['role' => 'owner', 'is_default' => true]); - - // Provision packages for both - $this->service->provisionPackage($this->workspace, 'creator', [ - 'billing_cycle_anchor' => now()->startOfMonth(), - ]); - $this->service->provisionPackage($workspace2, 'creator', [ - 'billing_cycle_anchor' => now()->startOfMonth(), - ]); - - // Create boosts for both - Boost::create([ - 'workspace_id' => $this->workspace->id, - 'feature_code' => 'ai.credits', - 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, - 'duration_type' => Boost::DURATION_CYCLE_BOUND, - 'limit_value' => 50, - 'status' => Boost::STATUS_ACTIVE, - 'starts_at' => now()->subDays(15), - ]); - - Boost::create([ - 'workspace_id' => $workspace2->id, - 'feature_code' => 'ai.credits', - 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, - 'duration_type' => Boost::DURATION_CYCLE_BOUND, - 'limit_value' => 100, - 'status' => Boost::STATUS_ACTIVE, - 'starts_at' => now()->subDays(15), - ]); - - $this->artisan('tenant:reset-billing-cycles') - ->assertExitCode(0); - - // Both boosts should be expired - expect(Boost::where('status', Boost::STATUS_EXPIRED)->count())->toBe(2); - }); - - it('skips workspaces without active packages', function () { - // Don't provision a package for this workspace - $workspace2 = Workspace::factory()->create(['is_active' => true]); - - $this->artisan('tenant:reset-billing-cycles') - ->assertExitCode(0); - - // No errors should occur - }); - - it('skips inactive workspaces', function () { - $this->workspace->update(['is_active' => false]); - - $this->service->provisionPackage($this->workspace, 'creator', [ - 'billing_cycle_anchor' => now()->startOfMonth(), - ]); - - Boost::create([ - 'workspace_id' => $this->workspace->id, - 'feature_code' => 'ai.credits', - 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, - 'duration_type' => Boost::DURATION_CYCLE_BOUND, - 'limit_value' => 50, - 'status' => Boost::STATUS_ACTIVE, - 'starts_at' => now()->subDays(15), - ]); - - $this->artisan('tenant:reset-billing-cycles') - ->assertExitCode(0); - - // Boost should not be expired (workspace is inactive) - expect(Boost::where('status', Boost::STATUS_ACTIVE)->count())->toBe(1); - }); - }); - - describe('usage counter reset logging', function () { - it('logs cycle reset when at cycle boundary with previous usage', function () { - // Set billing cycle to start today - $this->service->provisionPackage($this->workspace, 'creator', [ - 'billing_cycle_anchor' => now(), - ]); - - // Create usage record from previous cycle - UsageRecord::create([ - 'workspace_id' => $this->workspace->id, - 'feature_code' => 'ai.credits', - 'quantity' => 25, - 'recorded_at' => now()->subMonth(), // Previous cycle - ]); - - // Clear logs from provisioning - EntitlementLog::where('workspace_id', $this->workspace->id)->delete(); - - $this->artisan('tenant:reset-billing-cycles', [ - '--workspace' => $this->workspace->id, - ])->assertExitCode(0); - - $log = EntitlementLog::where('workspace_id', $this->workspace->id) - ->where('action', 'cycle.reset') - ->first(); - - expect($log)->not->toBeNull() - ->and($log->metadata['previous_cycle_records'])->toBe(1); - }); - }); - - describe('cache invalidation', function () { - it('invalidates entitlement cache after processing', function () { - $this->service->provisionPackage($this->workspace, 'creator', [ - 'billing_cycle_anchor' => now()->startOfMonth(), - ]); - - // Create and verify boost is counted in limit - $boost = Boost::create([ - 'workspace_id' => $this->workspace->id, - 'feature_code' => 'ai.credits', - 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, - 'duration_type' => Boost::DURATION_CYCLE_BOUND, - 'limit_value' => 50, - 'consumed_quantity' => 0, - 'status' => Boost::STATUS_ACTIVE, - 'starts_at' => now()->subDays(15), - ]); - - Cache::flush(); - $resultBefore = $this->service->can($this->workspace, 'ai.credits'); - - expect($resultBefore->limit)->toBe(150); // 100 + 50 boost - - // Run command - $this->artisan('tenant:reset-billing-cycles', [ - '--workspace' => $this->workspace->id, - ])->assertExitCode(0); - - // Limit should be back to package only - $resultAfter = $this->service->can($this->workspace, 'ai.credits'); - - expect($resultAfter->limit)->toBe(100); - }); - }); -}); diff --git a/packages/core-php/src/Mod/Tenant/Tests/Feature/SettingsTest.php b/packages/core-php/src/Mod/Tenant/Tests/Feature/SettingsTest.php deleted file mode 100644 index eeffc65..0000000 --- a/packages/core-php/src/Mod/Tenant/Tests/Feature/SettingsTest.php +++ /dev/null @@ -1,215 +0,0 @@ -create($attributes); - } - - public function test_settings_page_is_accessible_when_authenticated(): void - { - $user = $this->createUser(); - - $response = $this->actingAs($user)->get('/hub/settings'); - - $response->assertStatus(200); - $response->assertSee('Account Settings'); - } - - public function test_settings_page_redirects_guests_to_login(): void - { - $response = $this->get('/hub/settings'); - - $response->assertRedirect('/login'); - } - - public function test_user_can_update_profile_information(): void - { - $user = $this->createUser([ - 'name' => 'Original Name', - 'email' => 'original@example.com', - ]); - - Livewire::actingAs($user) - ->test(Settings::class) - ->set('name', 'Updated Name') - ->set('email', 'updated@example.com') - ->call('updateProfile') - ->assertHasNoErrors(); - - $user->refresh(); - $this->assertEquals('Updated Name', $user->name); - $this->assertEquals('updated@example.com', $user->email); - } - - public function test_profile_update_validates_required_fields(): void - { - $user = $this->createUser(); - - Livewire::actingAs($user) - ->test(Settings::class) - ->set('name', '') - ->set('email', '') - ->call('updateProfile') - ->assertHasErrors(['name', 'email']); - } - - public function test_profile_update_validates_email_format(): void - { - $user = $this->createUser(); - - Livewire::actingAs($user) - ->test(Settings::class) - ->set('email', 'not-an-email') - ->call('updateProfile') - ->assertHasErrors(['email']); - } - - public function test_profile_update_validates_unique_email(): void - { - $existingUser = $this->createUser(['email' => 'existing@example.com']); - $user = $this->createUser(['email' => 'test@example.com']); - - Livewire::actingAs($user) - ->test(Settings::class) - ->set('email', 'existing@example.com') - ->call('updateProfile') - ->assertHasErrors(['email']); - } - - public function test_user_can_keep_same_email(): void - { - $user = $this->createUser(['email' => 'same@example.com']); - - Livewire::actingAs($user) - ->test(Settings::class) - ->set('name', 'New Name') - ->set('email', 'same@example.com') - ->call('updateProfile') - ->assertHasNoErrors(); - - $user->refresh(); - $this->assertEquals('same@example.com', $user->email); - } - - public function test_user_can_update_password(): void - { - $user = $this->createUser([ - 'password' => Hash::make('current-password'), - ]); - - Livewire::actingAs($user) - ->test(Settings::class) - ->set('current_password', 'current-password') - ->set('new_password', 'new-secure-password') - ->set('new_password_confirmation', 'new-secure-password') - ->call('updatePassword') - ->assertHasNoErrors(); - - $user->refresh(); - $this->assertTrue(Hash::check('new-secure-password', $user->password)); - } - - public function test_password_update_requires_current_password(): void - { - $user = $this->createUser([ - 'password' => Hash::make('current-password'), - ]); - - Livewire::actingAs($user) - ->test(Settings::class) - ->set('current_password', 'wrong-password') - ->set('new_password', 'new-secure-password') - ->set('new_password_confirmation', 'new-secure-password') - ->call('updatePassword') - ->assertHasErrors(['current_password']); - } - - public function test_password_update_requires_confirmation(): void - { - $user = $this->createUser([ - 'password' => Hash::make('current-password'), - ]); - - Livewire::actingAs($user) - ->test(Settings::class) - ->set('current_password', 'current-password') - ->set('new_password', 'new-secure-password') - ->set('new_password_confirmation', 'different-password') - ->call('updatePassword') - ->assertHasErrors(['new_password']); - } - - public function test_user_can_update_preferences(): void - { - $user = $this->createUser(); - - Livewire::actingAs($user) - ->test(Settings::class) - ->set('timezone', 'America/New_York') - ->set('time_format', 24) - ->set('week_starts_on', 0) - ->call('updatePreferences') - ->assertHasNoErrors(); - - // Verify settings were saved - $timezoneSetting = Setting::where('user_id', $user->id) - ->where('name', 'timezone') - ->first(); - - $this->assertEquals('America/New_York', $timezoneSetting->payload); - } - - public function test_preferences_validates_timezone(): void - { - $user = $this->createUser(); - - Livewire::actingAs($user) - ->test(Settings::class) - ->set('timezone', 'Invalid/Timezone') - ->call('updatePreferences') - ->assertHasErrors(['timezone']); - } - - public function test_settings_loads_existing_preferences(): void - { - $user = $this->createUser(); - - // Set some preferences - Setting::create([ - 'user_id' => $user->id, - 'name' => 'timezone', - 'payload' => 'Europe/London', - ]); - - $component = Livewire::actingAs($user)->test(Settings::class); - - $this->assertEquals('Europe/London', $component->get('timezone')); - } - - public function test_settings_shows_user_name_and_email(): void - { - $user = $this->createUser([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); - - $component = Livewire::actingAs($user)->test(Settings::class); - - $this->assertEquals('Test User', $component->get('name')); - $this->assertEquals('test@example.com', $component->get('email')); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Tests/Feature/TwoFactorAuthenticatableTest.php b/packages/core-php/src/Mod/Tenant/Tests/Feature/TwoFactorAuthenticatableTest.php deleted file mode 100644 index fbfe594..0000000 --- a/packages/core-php/src/Mod/Tenant/Tests/Feature/TwoFactorAuthenticatableTest.php +++ /dev/null @@ -1,334 +0,0 @@ -user = User::factory()->create(); -}); - -describe('TwoFactorAuthenticatable Trait', function () { - describe('twoFactorAuth() relationship', function () { - it('returns HasOne relationship', function () { - expect($this->user->twoFactorAuth())->toBeInstanceOf( - \Illuminate\Database\Eloquent\Relations\HasOne::class - ); - }); - - it('returns null when no 2FA record exists', function () { - expect($this->user->twoFactorAuth)->toBeNull(); - }); - - it('returns 2FA record when it exists', function () { - $twoFactorAuth = UserTwoFactorAuth::create([ - 'user_id' => $this->user->id, - 'secret_key' => 'JBSWY3DPEHPK3PXP', - 'recovery_codes' => ['code1', 'code2'], - 'confirmed_at' => now(), - ]); - - $this->user->refresh(); - - expect($this->user->twoFactorAuth)->toBeInstanceOf(UserTwoFactorAuth::class) - ->and($this->user->twoFactorAuth->id)->toBe($twoFactorAuth->id); - }); - }); - - describe('hasTwoFactorAuthEnabled()', function () { - it('returns false when no 2FA record exists', function () { - expect($this->user->hasTwoFactorAuthEnabled())->toBeFalse(); - }); - - it('returns false when 2FA record exists but secret_key is null', function () { - UserTwoFactorAuth::create([ - 'user_id' => $this->user->id, - 'secret_key' => null, - 'recovery_codes' => [], - 'confirmed_at' => now(), - ]); - - $this->user->refresh(); - - expect($this->user->hasTwoFactorAuthEnabled())->toBeFalse(); - }); - - it('returns false when 2FA record exists but confirmed_at is null', function () { - UserTwoFactorAuth::create([ - 'user_id' => $this->user->id, - 'secret_key' => 'JBSWY3DPEHPK3PXP', - 'recovery_codes' => [], - 'confirmed_at' => null, - ]); - - $this->user->refresh(); - - expect($this->user->hasTwoFactorAuthEnabled())->toBeFalse(); - }); - - it('returns true when 2FA is fully enabled', function () { - UserTwoFactorAuth::create([ - 'user_id' => $this->user->id, - 'secret_key' => 'JBSWY3DPEHPK3PXP', - 'recovery_codes' => ['code1', 'code2'], - 'confirmed_at' => now(), - ]); - - $this->user->refresh(); - - expect($this->user->hasTwoFactorAuthEnabled())->toBeTrue(); - }); - }); - - describe('twoFactorAuthSecretKey()', function () { - it('returns null when no 2FA record exists', function () { - expect($this->user->twoFactorAuthSecretKey())->toBeNull(); - }); - - it('returns the secret key when 2FA record exists', function () { - $secretKey = 'JBSWY3DPEHPK3PXP'; - - UserTwoFactorAuth::create([ - 'user_id' => $this->user->id, - 'secret_key' => $secretKey, - 'recovery_codes' => [], - ]); - - $this->user->refresh(); - - expect($this->user->twoFactorAuthSecretKey())->toBe($secretKey); - }); - }); - - describe('twoFactorRecoveryCodes()', function () { - it('returns empty array when no 2FA record exists', function () { - expect($this->user->twoFactorRecoveryCodes())->toBe([]); - }); - - it('returns empty array when recovery_codes is null', function () { - UserTwoFactorAuth::create([ - 'user_id' => $this->user->id, - 'secret_key' => 'JBSWY3DPEHPK3PXP', - 'recovery_codes' => null, - ]); - - $this->user->refresh(); - - expect($this->user->twoFactorRecoveryCodes())->toBe([]); - }); - - it('returns recovery codes as array', function () { - $codes = ['CODE1-CODE1', 'CODE2-CODE2', 'CODE3-CODE3']; - - UserTwoFactorAuth::create([ - 'user_id' => $this->user->id, - 'secret_key' => 'JBSWY3DPEHPK3PXP', - 'recovery_codes' => $codes, - ]); - - $this->user->refresh(); - - expect($this->user->twoFactorRecoveryCodes())->toBe($codes); - }); - }); - - describe('twoFactorReplaceRecoveryCode()', function () { - it('does nothing when no 2FA record exists', function () { - // Should not throw - $this->user->twoFactorReplaceRecoveryCode('nonexistent'); - - expect($this->user->twoFactorAuth)->toBeNull(); - }); - - it('does nothing when code is not found in recovery codes', function () { - $codes = ['CODE1-CODE1', 'CODE2-CODE2', 'CODE3-CODE3']; - - UserTwoFactorAuth::create([ - 'user_id' => $this->user->id, - 'secret_key' => 'JBSWY3DPEHPK3PXP', - 'recovery_codes' => $codes, - ]); - - $this->user->refresh(); - - $this->user->twoFactorReplaceRecoveryCode('NONEXISTENT'); - - $this->user->refresh(); - - expect($this->user->twoFactorRecoveryCodes())->toBe($codes); - }); - - it('replaces a used recovery code with a new one', function () { - $codes = ['CODE1-CODE1', 'CODE2-CODE2', 'CODE3-CODE3']; - - UserTwoFactorAuth::create([ - 'user_id' => $this->user->id, - 'secret_key' => 'JBSWY3DPEHPK3PXP', - 'recovery_codes' => $codes, - ]); - - $this->user->refresh(); - - $this->user->twoFactorReplaceRecoveryCode('CODE2-CODE2'); - - $this->user->refresh(); - $newCodes = $this->user->twoFactorRecoveryCodes(); - - // Should still have 3 codes - expect($newCodes)->toHaveCount(3) - // First and third codes should be unchanged - ->and($newCodes[0])->toBe('CODE1-CODE1') - ->and($newCodes[2])->toBe('CODE3-CODE3') - // Second code should be different and in the expected format - ->and($newCodes[1])->not->toBe('CODE2-CODE2') - ->and($newCodes[1])->toMatch('/^[A-F0-9]{10}-[A-F0-9]{10}$/'); - }); - }); - - describe('twoFactorQrCodeUrl()', function () { - it('generates valid TOTP URL', function () { - $secretKey = 'JBSWY3DPEHPK3PXP'; - - UserTwoFactorAuth::create([ - 'user_id' => $this->user->id, - 'secret_key' => $secretKey, - 'recovery_codes' => [], - ]); - - $this->user->refresh(); - - $url = $this->user->twoFactorQrCodeUrl(); - - expect($url)->toStartWith('otpauth://totp/') - ->and($url)->toContain($secretKey) - ->and($url)->toContain(rawurlencode($this->user->email)) - ->and($url)->toContain('issuer='); - }); - - it('includes app name in the URL', function () { - $appName = config('app.name'); - - UserTwoFactorAuth::create([ - 'user_id' => $this->user->id, - 'secret_key' => 'JBSWY3DPEHPK3PXP', - 'recovery_codes' => [], - ]); - - $this->user->refresh(); - - $url = $this->user->twoFactorQrCodeUrl(); - - expect($url)->toContain(rawurlencode($appName)); - }); - }); - - describe('twoFactorQrCodeSvg()', function () { - it('returns empty string when no secret exists', function () { - expect($this->user->twoFactorQrCodeSvg())->toBe(''); - }); - - it('returns SVG content when secret exists', function () { - UserTwoFactorAuth::create([ - 'user_id' => $this->user->id, - 'secret_key' => 'JBSWY3DPEHPK3PXP', - 'recovery_codes' => [], - ]); - - $this->user->refresh(); - - $svg = $this->user->twoFactorQrCodeSvg(); - - expect($svg)->toStartWith('and($svg)->toContain(''); - }); - }); - - describe('generateRecoveryCode() via twoFactorReplaceRecoveryCode()', function () { - it('generates codes in the expected format', function () { - $codes = ['TESTCODE1']; - - UserTwoFactorAuth::create([ - 'user_id' => $this->user->id, - 'secret_key' => 'JBSWY3DPEHPK3PXP', - 'recovery_codes' => $codes, - ]); - - $this->user->refresh(); - - $this->user->twoFactorReplaceRecoveryCode('TESTCODE1'); - - $this->user->refresh(); - $newCode = $this->user->twoFactorRecoveryCodes()[0]; - - // Format: 10 uppercase hex chars - 10 uppercase hex chars - expect($newCode)->toMatch('/^[A-F0-9]{10}-[A-F0-9]{10}$/'); - }); - - it('generates unique codes', function () { - $codes = ['CODE1', 'CODE2', 'CODE3']; - - UserTwoFactorAuth::create([ - 'user_id' => $this->user->id, - 'secret_key' => 'JBSWY3DPEHPK3PXP', - 'recovery_codes' => $codes, - ]); - - $this->user->refresh(); - - // Replace all codes - $this->user->twoFactorReplaceRecoveryCode('CODE1'); - $this->user->refresh(); - $this->user->twoFactorReplaceRecoveryCode('CODE2'); - $this->user->refresh(); - $this->user->twoFactorReplaceRecoveryCode('CODE3'); - $this->user->refresh(); - - $newCodes = $this->user->twoFactorRecoveryCodes(); - - // All codes should be unique - expect(array_unique($newCodes))->toHaveCount(3); - }); - }); -}); - -describe('UserTwoFactorAuth Model', function () { - it('belongs to a user', function () { - $twoFactorAuth = UserTwoFactorAuth::create([ - 'user_id' => $this->user->id, - 'secret_key' => 'JBSWY3DPEHPK3PXP', - 'recovery_codes' => [], - ]); - - expect($twoFactorAuth->user)->toBeInstanceOf(User::class) - ->and($twoFactorAuth->user->id)->toBe($this->user->id); - }); - - it('casts recovery_codes to collection', function () { - $codes = ['CODE1', 'CODE2']; - - $twoFactorAuth = UserTwoFactorAuth::create([ - 'user_id' => $this->user->id, - 'secret_key' => 'JBSWY3DPEHPK3PXP', - 'recovery_codes' => $codes, - ]); - - expect($twoFactorAuth->recovery_codes)->toBeInstanceOf(\Illuminate\Support\Collection::class) - ->and($twoFactorAuth->recovery_codes->toArray())->toBe($codes); - }); - - it('casts confirmed_at to datetime', function () { - $confirmedAt = now(); - - $twoFactorAuth = UserTwoFactorAuth::create([ - 'user_id' => $this->user->id, - 'secret_key' => 'JBSWY3DPEHPK3PXP', - 'recovery_codes' => [], - 'confirmed_at' => $confirmedAt, - ]); - - expect($twoFactorAuth->confirmed_at)->toBeInstanceOf(\Carbon\Carbon::class); - }); -}); diff --git a/packages/core-php/src/Mod/Tenant/Tests/Feature/UsageAlertServiceTest.php b/packages/core-php/src/Mod/Tenant/Tests/Feature/UsageAlertServiceTest.php deleted file mode 100644 index 463f090..0000000 --- a/packages/core-php/src/Mod/Tenant/Tests/Feature/UsageAlertServiceTest.php +++ /dev/null @@ -1,261 +0,0 @@ -entitlementService = app(EntitlementService::class); - $this->alertService = app(UsageAlertService::class); - } - - public function test_it_sends_warning_alert_at_80_percent(): void - { - Notification::fake(); - - // Create feature with limit - $feature = Feature::factory()->create([ - 'code' => 'test.feature', - 'name' => 'Test Feature', - 'type' => Feature::TYPE_LIMIT, - ]); - - // Create package with limit of 10 - $package = Package::factory()->create(['code' => 'test-package', 'is_base_package' => true]); - $package->features()->attach($feature->id, ['limit_value' => 10]); - - // Create workspace with owner - $user = User::factory()->create(); - $workspace = Workspace::factory()->create(); - $workspace->users()->attach($user->id, ['role' => 'owner']); - - // Provision package - $this->entitlementService->provisionPackage($workspace, 'test-package'); - - // Record 8 uses (80%) - for ($i = 0; $i < 8; $i++) { - $this->entitlementService->recordUsage($workspace, 'test.feature', 1); - } - - // Check for alerts - $result = $this->alertService->checkWorkspace($workspace); - - // Should send one alert - $this->assertEquals(1, $result['alerts_sent']); - - // Notification should be sent to owner - Notification::assertSentTo( - $user, - UsageAlertNotification::class, - fn ($notification) => $notification->threshold === UsageAlertHistory::THRESHOLD_WARNING - ); - - // Alert should be recorded - $this->assertDatabaseHas('entitlement_usage_alert_history', [ - 'workspace_id' => $workspace->id, - 'feature_code' => 'test.feature', - 'threshold' => 80, - ]); - } - - public function test_it_does_not_send_duplicate_alerts(): void - { - Notification::fake(); - - $feature = Feature::factory()->create([ - 'code' => 'test.feature', - 'name' => 'Test Feature', - 'type' => Feature::TYPE_LIMIT, - ]); - - $package = Package::factory()->create(['code' => 'test-package', 'is_base_package' => true]); - $package->features()->attach($feature->id, ['limit_value' => 10]); - - $user = User::factory()->create(); - $workspace = Workspace::factory()->create(); - $workspace->users()->attach($user->id, ['role' => 'owner']); - - $this->entitlementService->provisionPackage($workspace, 'test-package'); - - // Record 8 uses (80%) - for ($i = 0; $i < 8; $i++) { - $this->entitlementService->recordUsage($workspace, 'test.feature', 1); - } - - // First check - should send alert - $result1 = $this->alertService->checkWorkspace($workspace); - $this->assertEquals(1, $result1['alerts_sent']); - - // Second check - should NOT send duplicate - $result2 = $this->alertService->checkWorkspace($workspace); - $this->assertEquals(0, $result2['alerts_sent']); - - // Only one notification should be sent - Notification::assertSentToTimes($user, UsageAlertNotification::class, 1); - } - - public function test_it_sends_escalating_alerts_at_different_thresholds(): void - { - Notification::fake(); - - $feature = Feature::factory()->create([ - 'code' => 'test.feature', - 'name' => 'Test Feature', - 'type' => Feature::TYPE_LIMIT, - ]); - - $package = Package::factory()->create(['code' => 'test-package', 'is_base_package' => true]); - $package->features()->attach($feature->id, ['limit_value' => 10]); - - $user = User::factory()->create(); - $workspace = Workspace::factory()->create(); - $workspace->users()->attach($user->id, ['role' => 'owner']); - - $this->entitlementService->provisionPackage($workspace, 'test-package'); - - // Record 8 uses (80%) - warning - for ($i = 0; $i < 8; $i++) { - $this->entitlementService->recordUsage($workspace, 'test.feature', 1); - } - $this->alertService->checkWorkspace($workspace); - - // Record 1 more (90%) - critical - $this->entitlementService->recordUsage($workspace, 'test.feature', 1); - $result = $this->alertService->checkWorkspace($workspace); - $this->assertEquals(1, $result['alerts_sent']); - - // Record 1 more (100%) - limit reached - $this->entitlementService->recordUsage($workspace, 'test.feature', 1); - $result = $this->alertService->checkWorkspace($workspace); - $this->assertEquals(1, $result['alerts_sent']); - - // Should have 3 notifications total - Notification::assertSentToTimes($user, UsageAlertNotification::class, 3); - } - - public function test_it_resolves_alerts_when_usage_drops(): void - { - $feature = Feature::factory()->create([ - 'code' => 'test.feature', - 'name' => 'Test Feature', - 'type' => Feature::TYPE_LIMIT, - 'reset_type' => Feature::RESET_NONE, - ]); - - $workspace = Workspace::factory()->create(); - - // Create an unresolved alert - UsageAlertHistory::record( - workspaceId: $workspace->id, - featureCode: 'test.feature', - threshold: 80, - metadata: ['used' => 8, 'limit' => 10] - ); - - $this->assertDatabaseHas('entitlement_usage_alert_history', [ - 'workspace_id' => $workspace->id, - 'feature_code' => 'test.feature', - 'resolved_at' => null, - ]); - - // Resolve alerts - $resolved = UsageAlertHistory::resolveAllForFeature($workspace->id, 'test.feature'); - - $this->assertEquals(1, $resolved); - $this->assertDatabaseMissing('entitlement_usage_alert_history', [ - 'workspace_id' => $workspace->id, - 'feature_code' => 'test.feature', - 'resolved_at' => null, - ]); - } - - public function test_it_skips_unlimited_features(): void - { - Notification::fake(); - - $feature = Feature::factory()->create([ - 'code' => 'unlimited.feature', - 'name' => 'Unlimited Feature', - 'type' => Feature::TYPE_UNLIMITED, - ]); - - $user = User::factory()->create(); - $workspace = Workspace::factory()->create(); - $workspace->users()->attach($user->id, ['role' => 'owner']); - - $result = $this->alertService->checkFeatureUsage($workspace, $feature); - - $this->assertFalse($result['alert_sent']); - Notification::assertNothingSent(); - } - - public function test_it_skips_boolean_features(): void - { - Notification::fake(); - - $feature = Feature::factory()->create([ - 'code' => 'boolean.feature', - 'name' => 'Boolean Feature', - 'type' => Feature::TYPE_BOOLEAN, - ]); - - $user = User::factory()->create(); - $workspace = Workspace::factory()->create(); - $workspace->users()->attach($user->id, ['role' => 'owner']); - - // Boolean features should be skipped by the service - // since they don't have limits to check against - $result = $this->alertService->checkFeatureUsage($workspace, $feature); - - $this->assertFalse($result['alert_sent']); - Notification::assertNothingSent(); - } - - public function test_get_active_alerts_returns_unresolved_only(): void - { - $workspace = Workspace::factory()->create(); - - // Create resolved alert - $resolved = UsageAlertHistory::record( - workspaceId: $workspace->id, - featureCode: 'feature.a', - threshold: 80 - ); - $resolved->resolve(); - - // Create unresolved alert - UsageAlertHistory::record( - workspaceId: $workspace->id, - featureCode: 'feature.b', - threshold: 90 - ); - - $activeAlerts = $this->alertService->getActiveAlertsForWorkspace($workspace); - - $this->assertCount(1, $activeAlerts); - $this->assertEquals('feature.b', $activeAlerts->first()->feature_code); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Tests/Feature/WaitlistTest.php b/packages/core-php/src/Mod/Tenant/Tests/Feature/WaitlistTest.php deleted file mode 100644 index 315a8f8..0000000 --- a/packages/core-php/src/Mod/Tenant/Tests/Feature/WaitlistTest.php +++ /dev/null @@ -1,181 +0,0 @@ -get('/waitlist') - ->assertStatus(200) - ->assertSeeLivewire(Waitlist::class); - }); - - it('requires email', function () { - Livewire::test(Waitlist::class) - ->call('submit') - ->assertHasErrors(['email']); - }); - - it('validates email format', function () { - Livewire::test(Waitlist::class) - ->set('email', 'not-an-email') - ->call('submit') - ->assertHasErrors(['email']); - }); - - it('successfully creates waitlist entry', function () { - Livewire::test(Waitlist::class) - ->set('email', 'newuser@example.com') - ->set('name', 'New User') - ->set('interest', 'SocialHost') - ->call('submit') - ->assertHasNoErrors() - ->assertSet('submitted', true); - - $this->assertDatabaseHas('waitlist_entries', [ - 'email' => 'newuser@example.com', - 'name' => 'New User', - 'interest' => 'SocialHost', - ]); - }); - - it('shows position after signup', function () { - // Create some existing entries - WaitlistEntry::factory()->count(5)->create(); - - $component = Livewire::test(Waitlist::class) - ->set('email', 'position-test@example.com') - ->call('submit') - ->assertSet('submitted', true); - - expect($component->get('position'))->toBe(6); - }); - - it('rejects duplicate email', function () { - WaitlistEntry::factory()->create(['email' => 'existing@example.com']); - - Livewire::test(Waitlist::class) - ->set('email', 'existing@example.com') - ->call('submit') - ->assertHasErrors(['email']) - ->assertSet('submitted', false); - }); - - it('allows submission without name', function () { - Livewire::test(Waitlist::class) - ->set('email', 'noname@example.com') - ->call('submit') - ->assertHasNoErrors() - ->assertSet('submitted', true); - - $this->assertDatabaseHas('waitlist_entries', [ - 'email' => 'noname@example.com', - 'name' => null, - ]); - }); - - it('rate limits submissions', function () { - // Submit 3 times (the limit) - for ($i = 1; $i <= 3; $i++) { - Livewire::test(Waitlist::class) - ->set('email', "user{$i}@example.com") - ->call('submit') - ->assertHasNoErrors(); - } - - // 4th submission should be rate limited - Livewire::test(Waitlist::class) - ->set('email', 'user4@example.com') - ->call('submit') - ->assertHasErrors(['email']); - }); - - it('stores referer source', function () { - Livewire::test(Waitlist::class) - ->set('email', 'referer-test@example.com') - ->call('submit'); - - $entry = WaitlistEntry::where('email', 'referer-test@example.com')->first(); - expect($entry->source)->not->toBeNull(); - }); -}); - -describe('Waitlist Entry Model', function () { - it('can be created with factory', function () { - $entry = WaitlistEntry::factory()->create(); - - expect($entry)->toBeInstanceOf(WaitlistEntry::class) - ->and($entry->email)->not->toBeNull(); - }); - - it('generates invite code when inviting', function () { - $entry = WaitlistEntry::factory()->create([ - 'invite_code' => null, - 'invited_at' => null, - ]); - - expect($entry->invite_code)->toBeNull(); - - $entry->update([ - 'invite_code' => \Illuminate\Support\Str::random(16), - 'invited_at' => now(), - ]); - - expect($entry->invite_code)->not->toBeNull() - ->and(strlen($entry->invite_code))->toBe(16); - }); -}); - -describe('Waitlist Invite Notification', function () { - it('can be rendered', function () { - $entry = WaitlistEntry::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - 'invite_code' => 'TESTCODE123', - ]); - - $notification = new WaitlistInviteNotification($entry); - $mailMessage = $notification->toMail($entry); - - expect($mailMessage->subject)->toBe('Your Host UK invite is ready') - ->and($mailMessage->greeting)->toBe('Hello Test User,'); - }); - - it('uses fallback greeting without name', function () { - $entry = WaitlistEntry::factory()->create([ - 'name' => null, - 'email' => 'noname@example.com', - 'invite_code' => 'TESTCODE456', - ]); - - $notification = new WaitlistInviteNotification($entry); - $mailMessage = $notification->toMail($entry); - - expect($mailMessage->greeting)->toBe('Hello there,'); - }); - - it('is queued', function () { - Notification::fake(); - - $entry = WaitlistEntry::factory()->create([ - 'invite_code' => 'QUEUETEST123', - ]); - - $entry->notify(new WaitlistInviteNotification($entry)); - - Notification::assertSentTo($entry, WaitlistInviteNotification::class); - }); -}); diff --git a/packages/core-php/src/Mod/Tenant/Tests/Feature/WorkspaceCacheTest.php b/packages/core-php/src/Mod/Tenant/Tests/Feature/WorkspaceCacheTest.php deleted file mode 100644 index 3ca4cbf..0000000 --- a/packages/core-php/src/Mod/Tenant/Tests/Feature/WorkspaceCacheTest.php +++ /dev/null @@ -1,584 +0,0 @@ -cacheManager = app(WorkspaceCacheManager::class); - $this->cacheManager->setConfig([ - 'enabled' => true, - 'ttl' => 300, - 'prefix' => 'test_workspace_cache', - 'use_tags' => false, // Use non-tagged mode for tests (array driver doesn't support tags) - ]); - - // Enable strict mode for tests - WorkspaceScope::enableStrictMode(); - - // Create test data - $this->user = User::factory()->create(['name' => 'Test User']); - $this->workspace = Workspace::factory()->create(['name' => 'Test Workspace']); - $this->otherWorkspace = Workspace::factory()->create(['name' => 'Other Workspace']); - - $this->user->hostWorkspaces()->attach($this->workspace, ['role' => 'owner', 'is_default' => true]); - $this->user->hostWorkspaces()->attach($this->otherWorkspace, ['role' => 'member', 'is_default' => false]); - - // Clear any existing cache - Cache::flush(); - } - - protected function tearDown(): void - { - WorkspaceScope::enableStrictMode(); - WorkspaceCacheManager::resetKeyRegistry(); - parent::tearDown(); - } - - // ------------------------------------------------------------------------- - // WorkspaceCacheManager Basic Tests - // ------------------------------------------------------------------------- - - public function test_cache_manager_can_be_resolved(): void - { - $manager = app(WorkspaceCacheManager::class); - - $this->assertInstanceOf(WorkspaceCacheManager::class, $manager); - } - - public function test_cache_manager_generates_correct_keys(): void - { - $key = $this->cacheManager->key($this->workspace, 'test_key'); - - $this->assertStringContainsString((string) $this->workspace->id, $key); - $this->assertStringContainsString('test_key', $key); - $this->assertStringContainsString('test_workspace_cache', $key); - } - - public function test_cache_manager_workspace_tag_generation(): void - { - $tag = $this->cacheManager->workspaceTag($this->workspace); - - $this->assertStringContainsString((string) $this->workspace->id, $tag); - $this->assertStringContainsString('workspace', $tag); - } - - public function test_cache_manager_model_tag_generation(): void - { - $tag = $this->cacheManager->modelTag(Account::class); - - $this->assertStringContainsString('Account', $tag); - $this->assertStringContainsString('model', $tag); - } - - // ------------------------------------------------------------------------- - // Cache Hit/Miss Tests - // ------------------------------------------------------------------------- - - public function test_cache_remember_stores_and_retrieves_value(): void - { - $callCount = 0; - - // First call - should execute callback - $result1 = $this->cacheManager->remember($this->workspace, 'test', 300, function () use (&$callCount) { - $callCount++; - - return 'cached_value'; - }); - - // Second call - should use cache - $result2 = $this->cacheManager->remember($this->workspace, 'test', 300, function () use (&$callCount) { - $callCount++; - - return 'new_value'; - }); - - $this->assertEquals('cached_value', $result1); - $this->assertEquals('cached_value', $result2); - $this->assertEquals(1, $callCount, 'Callback should only be called once'); - } - - public function test_cache_miss_executes_callback(): void - { - $callCount = 0; - - $result = $this->cacheManager->remember($this->workspace, 'new_key', 300, function () use (&$callCount) { - $callCount++; - - return 'fresh_value'; - }); - - $this->assertEquals('fresh_value', $result); - $this->assertEquals(1, $callCount); - } - - public function test_cache_can_store_collections(): void - { - $collection = collect(['item1', 'item2', 'item3']); - - $this->cacheManager->put($this->workspace, 'collection_test', $collection, 300); - - $retrieved = $this->cacheManager->get($this->workspace, 'collection_test'); - - $this->assertInstanceOf(Collection::class, $retrieved); - $this->assertEquals($collection->toArray(), $retrieved->toArray()); - } - - public function test_cache_has_returns_correct_boolean(): void - { - $this->assertFalse($this->cacheManager->has($this->workspace, 'nonexistent')); - - $this->cacheManager->put($this->workspace, 'exists', 'value', 300); - - $this->assertTrue($this->cacheManager->has($this->workspace, 'exists')); - } - - // ------------------------------------------------------------------------- - // Cache Invalidation Tests - // ------------------------------------------------------------------------- - - public function test_cache_forget_removes_key(): void - { - $this->cacheManager->put($this->workspace, 'to_forget', 'value', 300); - $this->assertTrue($this->cacheManager->has($this->workspace, 'to_forget')); - - $result = $this->cacheManager->forget($this->workspace, 'to_forget'); - - $this->assertTrue($result); - $this->assertFalse($this->cacheManager->has($this->workspace, 'to_forget')); - } - - public function test_cache_flush_clears_all_workspace_keys(): void - { - // Store multiple keys - $this->cacheManager->put($this->workspace, 'key1', 'value1', 300); - $this->cacheManager->put($this->workspace, 'key2', 'value2', 300); - $this->cacheManager->put($this->workspace, 'key3', 'value3', 300); - - // Verify keys exist - $this->assertTrue($this->cacheManager->has($this->workspace, 'key1')); - $this->assertTrue($this->cacheManager->has($this->workspace, 'key2')); - $this->assertTrue($this->cacheManager->has($this->workspace, 'key3')); - - // Flush all keys for workspace - $this->cacheManager->flush($this->workspace); - - // Verify keys are gone - $this->assertFalse($this->cacheManager->has($this->workspace, 'key1')); - $this->assertFalse($this->cacheManager->has($this->workspace, 'key2')); - $this->assertFalse($this->cacheManager->has($this->workspace, 'key3')); - } - - public function test_model_save_clears_workspace_cache(): void - { - $this->actingAs($this->user); - request()->attributes->set('workspace_model', $this->workspace); - - // Create an account (bypassing strict mode for setup) - WorkspaceScope::withoutStrictMode(function () { - Account::factory()->create(['workspace_id' => $this->workspace->id]); - }); - - // Cache the collection - $cached = Account::ownedByCurrentWorkspaceCached(); - $this->assertCount(1, $cached); - - // Create another account - this should clear the cache - WorkspaceScope::withoutStrictMode(function () { - Account::factory()->create(['workspace_id' => $this->workspace->id]); - }); - - // Get the collection again - should reflect the new data - $refreshed = Account::ownedByCurrentWorkspaceCached(); - $this->assertCount(2, $refreshed); - } - - public function test_model_delete_clears_workspace_cache(): void - { - $this->actingAs($this->user); - request()->attributes->set('workspace_model', $this->workspace); - - // Create accounts - $account = null; - WorkspaceScope::withoutStrictMode(function () use (&$account) { - $account = Account::factory()->create(['workspace_id' => $this->workspace->id]); - Account::factory()->create(['workspace_id' => $this->workspace->id]); - }); - - // Cache the collection - $cached = Account::ownedByCurrentWorkspaceCached(); - $this->assertCount(2, $cached); - - // Delete one account - this should clear the cache - WorkspaceScope::withoutStrictMode(function () use ($account) { - $account->delete(); - }); - - // Get the collection again - should reflect the deletion - $refreshed = Account::ownedByCurrentWorkspaceCached(); - $this->assertCount(1, $refreshed); - } - - // ------------------------------------------------------------------------- - // Multi-Workspace Isolation Tests - // ------------------------------------------------------------------------- - - public function test_cache_is_isolated_between_workspaces(): void - { - // Store different values in different workspaces - $this->cacheManager->put($this->workspace, 'shared_key', 'workspace1_value', 300); - $this->cacheManager->put($this->otherWorkspace, 'shared_key', 'workspace2_value', 300); - - // Retrieve values - $value1 = $this->cacheManager->get($this->workspace, 'shared_key'); - $value2 = $this->cacheManager->get($this->otherWorkspace, 'shared_key'); - - $this->assertEquals('workspace1_value', $value1); - $this->assertEquals('workspace2_value', $value2); - } - - public function test_flush_only_affects_target_workspace(): void - { - // Store values in both workspaces - $this->cacheManager->put($this->workspace, 'key', 'value1', 300); - $this->cacheManager->put($this->otherWorkspace, 'key', 'value2', 300); - - // Flush only the first workspace - $this->cacheManager->flush($this->workspace); - - // First workspace key should be gone - $this->assertFalse($this->cacheManager->has($this->workspace, 'key')); - - // Other workspace key should still exist - $this->assertTrue($this->cacheManager->has($this->otherWorkspace, 'key')); - $this->assertEquals('value2', $this->cacheManager->get($this->otherWorkspace, 'key')); - } - - public function test_model_caching_respects_workspace_context(): void - { - $this->actingAs($this->user); - - // Create accounts in different workspaces - WorkspaceScope::withoutStrictMode(function () { - Account::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Account 1']); - Account::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Account 2']); - Account::factory()->create(['workspace_id' => $this->otherWorkspace->id, 'name' => 'Other Account']); - }); - - // Set context to first workspace - request()->attributes->set('workspace_model', $this->workspace); - - // Cache should only contain first workspace's accounts - $cached = Account::ownedByCurrentWorkspaceCached(); - $this->assertCount(2, $cached); - $this->assertTrue($cached->pluck('name')->contains('Account 1')); - $this->assertTrue($cached->pluck('name')->contains('Account 2')); - $this->assertFalse($cached->pluck('name')->contains('Other Account')); - - // Switch context to other workspace - request()->attributes->set('workspace_model', $this->otherWorkspace); - - // Cache should only contain other workspace's accounts - $otherCached = Account::ownedByCurrentWorkspaceCached(); - $this->assertCount(1, $otherCached); - $this->assertTrue($otherCached->pluck('name')->contains('Other Account')); - } - - // ------------------------------------------------------------------------- - // Configuration Tests - // ------------------------------------------------------------------------- - - public function test_cache_disabled_when_config_disabled(): void - { - $this->cacheManager->setConfig([ - 'enabled' => false, - 'ttl' => 300, - 'prefix' => 'test', - 'use_tags' => false, - ]); - - $callCount = 0; - - // Both calls should execute the callback because caching is disabled - $this->cacheManager->remember($this->workspace, 'test', 300, function () use (&$callCount) { - $callCount++; - - return 'value'; - }); - - $this->cacheManager->remember($this->workspace, 'test', 300, function () use (&$callCount) { - $callCount++; - - return 'value'; - }); - - $this->assertEquals(2, $callCount, 'Both calls should execute callback when cache is disabled'); - } - - public function test_default_ttl_used_when_null_passed(): void - { - $this->cacheManager->setConfig([ - 'enabled' => true, - 'ttl' => 600, - 'prefix' => 'test', - 'use_tags' => false, - ]); - - $this->assertEquals(600, $this->cacheManager->defaultTtl()); - } - - public function test_custom_prefix_used_in_keys(): void - { - $this->cacheManager->setConfig([ - 'enabled' => true, - 'ttl' => 300, - 'prefix' => 'custom_prefix', - 'use_tags' => false, - ]); - - $key = $this->cacheManager->key($this->workspace, 'test'); - - $this->assertStringContainsString('custom_prefix', $key); - } - - // ------------------------------------------------------------------------- - // Cache Statistics Tests - // ------------------------------------------------------------------------- - - public function test_stats_returns_workspace_cache_info(): void - { - $this->cacheManager->put($this->workspace, 'key1', 'value1', 300); - $this->cacheManager->put($this->workspace, 'key2', 'value2', 300); - - $stats = $this->cacheManager->stats($this->workspace); - - $this->assertEquals($this->workspace->id, $stats['workspace_id']); - $this->assertTrue($stats['enabled']); - $this->assertIsInt($stats['registered_keys']); - $this->assertIsArray($stats['keys']); - } - - public function test_get_registered_keys_returns_workspace_keys(): void - { - $this->cacheManager->put($this->workspace, 'key1', 'value1', 300); - $this->cacheManager->put($this->workspace, 'key2', 'value2', 300); - - $keys = $this->cacheManager->getRegisteredKeys($this->workspace); - - $this->assertCount(2, $keys); - } - - // ------------------------------------------------------------------------- - // HasWorkspaceCache Trait Tests - // ------------------------------------------------------------------------- - - public function test_has_workspace_cache_remember_for_workspace(): void - { - $this->actingAs($this->user); - request()->attributes->set('workspace_model', $this->workspace); - - // Create a model class that uses HasWorkspaceCache - $testModel = new class extends Model - { - use BelongsToWorkspace; - use HasWorkspaceCache; - - protected $table = 'test_cache_models'; - }; - - $callCount = 0; - - // First call - should execute callback - $result1 = $testModel::rememberForWorkspace('custom_key', 300, function () use (&$callCount) { - $callCount++; - - return collect(['item1', 'item2']); - }); - - // Second call - should use cache - $result2 = $testModel::rememberForWorkspace('custom_key', 300, function () use (&$callCount) { - $callCount++; - - return collect(['different']); - }); - - $this->assertEquals(['item1', 'item2'], $result1->toArray()); - $this->assertEquals(['item1', 'item2'], $result2->toArray()); - $this->assertEquals(1, $callCount); - } - - public function test_has_workspace_cache_forget_for_workspace(): void - { - $this->actingAs($this->user); - request()->attributes->set('workspace_model', $this->workspace); - - $testModel = new class extends Model - { - use BelongsToWorkspace; - use HasWorkspaceCache; - - protected $table = 'test_cache_models'; - }; - - // Store a value - $testModel::putForWorkspace('to_forget', 'value', 300); - $this->assertTrue($testModel::hasInWorkspaceCache('to_forget')); - - // Forget it - $testModel::forgetForWorkspace('to_forget'); - $this->assertFalse($testModel::hasInWorkspaceCache('to_forget')); - } - - public function test_has_workspace_cache_without_context_returns_callback_result(): void - { - // Ensure no workspace context - request()->attributes->remove('workspace_model'); - WorkspaceScope::disableStrictMode(); - - $testModel = new class extends Model - { - use BelongsToWorkspace; - use HasWorkspaceCache; - - protected $table = 'test_cache_models'; - - protected bool $workspaceContextRequired = false; - }; - - $callCount = 0; - - // Without context, should always execute callback (no caching) - $result = $testModel::rememberForWorkspace('key', 300, function () use (&$callCount) { - $callCount++; - - return 'uncached_value'; - }); - - $this->assertEquals('uncached_value', $result); - $this->assertEquals(1, $callCount); - - WorkspaceScope::enableStrictMode(); - } - - // ------------------------------------------------------------------------- - // BelongsToWorkspace Caching Tests - // ------------------------------------------------------------------------- - - public function test_owned_by_current_workspace_cached_uses_cache_manager(): void - { - $this->actingAs($this->user); - request()->attributes->set('workspace_model', $this->workspace); - - // Create an account - WorkspaceScope::withoutStrictMode(function () { - Account::factory()->create(['workspace_id' => $this->workspace->id]); - }); - - // First call - should cache - $result1 = Account::ownedByCurrentWorkspaceCached(); - - // Verify result - $this->assertCount(1, $result1); - - // Check that cache key was registered - $keys = $this->cacheManager->getRegisteredKeys($this->workspace); - $this->assertNotEmpty($keys); - } - - public function test_for_workspace_cached_caches_for_specific_workspace(): void - { - // Create accounts in the workspace - WorkspaceScope::withoutStrictMode(function () { - Account::factory()->count(3)->create(['workspace_id' => $this->workspace->id]); - }); - - $callCount = 0; - - // Manually test caching behavior - $firstCall = Account::forWorkspaceCached($this->workspace, 300); - $this->assertCount(3, $firstCall); - - // Second call should use cache (we can't easily verify this without mocking, - // but we can verify the result is consistent) - $secondCall = Account::forWorkspaceCached($this->workspace, 300); - $this->assertCount(3, $secondCall); - } - - public function test_workspace_cache_key_includes_model_name(): void - { - $key = Account::workspaceCacheKey($this->workspace->id); - - $this->assertStringContainsString('Account', $key); - $this->assertStringContainsString((string) $this->workspace->id, $key); - } - - public function test_clear_all_workspace_caches_clears_user_workspaces(): void - { - $this->actingAs($this->user); - - // Cache data in both workspaces - request()->attributes->set('workspace_model', $this->workspace); - WorkspaceScope::withoutStrictMode(function () { - Account::factory()->create(['workspace_id' => $this->workspace->id]); - }); - Account::ownedByCurrentWorkspaceCached(); - - request()->attributes->set('workspace_model', $this->otherWorkspace); - WorkspaceScope::withoutStrictMode(function () { - Account::factory()->create(['workspace_id' => $this->otherWorkspace->id]); - }); - Account::ownedByCurrentWorkspaceCached(); - - // Clear all caches for the model - Account::clearAllWorkspaceCaches(); - - // Note: Without tags, this clears cache for all workspaces the user has access to - // The cache should be empty for both workspaces now - $keys1 = $this->cacheManager->getRegisteredKeys($this->workspace); - $keys2 = $this->cacheManager->getRegisteredKeys($this->otherWorkspace); - - // After clearing, the registered keys should be empty or the cache values should be missing - // (depending on implementation details) - $this->assertCount(0, $keys1); - $this->assertCount(0, $keys2); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Tests/Feature/WorkspaceInvitationTest.php b/packages/core-php/src/Mod/Tenant/Tests/Feature/WorkspaceInvitationTest.php deleted file mode 100644 index 9c088d5..0000000 --- a/packages/core-php/src/Mod/Tenant/Tests/Feature/WorkspaceInvitationTest.php +++ /dev/null @@ -1,192 +0,0 @@ -create(); - $workspace = Workspace::factory()->create(); - $workspace->users()->attach($owner->id, ['role' => 'owner']); - - $invitation = $workspace->invite('newuser@example.com', 'member', $owner); - - $this->assertDatabaseHas('workspace_invitations', [ - 'workspace_id' => $workspace->id, - 'email' => 'newuser@example.com', - 'role' => 'member', - 'invited_by' => $owner->id, - ]); - - $this->assertNotNull($invitation->token); - $this->assertTrue($invitation->isPending()); - $this->assertFalse($invitation->isExpired()); - $this->assertFalse($invitation->isAccepted()); - - Notification::assertSentTo($invitation, WorkspaceInvitationNotification::class); - } - - public function test_invitation_expires_after_set_days(): void - { - $workspace = Workspace::factory()->create(); - $invitation = $workspace->invite('test@example.com', 'member', null, 3); - - $this->assertTrue($invitation->expires_at->isBetween( - now()->addDays(2)->addHours(23), - now()->addDays(3)->addHours(1) - )); - } - - public function test_user_can_accept_invitation(): void - { - $workspace = Workspace::factory()->create(); - $user = User::factory()->create(['email' => 'invited@example.com']); - - $invitation = WorkspaceInvitation::factory()->create([ - 'workspace_id' => $workspace->id, - 'email' => 'invited@example.com', - 'role' => 'admin', - ]); - - $result = $invitation->accept($user); - - $this->assertTrue($result); - $this->assertTrue($invitation->fresh()->isAccepted()); - $this->assertTrue($workspace->users()->where('user_id', $user->id)->exists()); - $this->assertEquals('admin', $workspace->users()->find($user->id)->pivot->role); - } - - public function test_expired_invitation_cannot_be_accepted(): void - { - $workspace = Workspace::factory()->create(); - $user = User::factory()->create(); - - $invitation = WorkspaceInvitation::factory()->expired()->create([ - 'workspace_id' => $workspace->id, - ]); - - $result = $invitation->accept($user); - - $this->assertFalse($result); - $this->assertFalse($workspace->users()->where('user_id', $user->id)->exists()); - } - - public function test_already_accepted_invitation_cannot_be_reused(): void - { - $workspace = Workspace::factory()->create(); - $user = User::factory()->create(); - - $invitation = WorkspaceInvitation::factory()->accepted()->create([ - 'workspace_id' => $workspace->id, - ]); - - $result = $invitation->accept($user); - - $this->assertFalse($result); - } - - public function test_resending_invitation_updates_existing(): void - { - Notification::fake(); - - $workspace = Workspace::factory()->create(); - $owner = User::factory()->create(); - - // First invitation as member - $first = $workspace->invite('test@example.com', 'member', $owner); - $firstToken = $first->token; - - // Second invitation as admin - should update existing - $second = $workspace->invite('test@example.com', 'admin', $owner); - - $this->assertEquals($first->id, $second->id); - $this->assertEquals($firstToken, $second->token); // Token unchanged - $this->assertEquals('admin', $second->role); - - // Should only have one invitation - $this->assertEquals(1, $workspace->invitations()->count()); - } - - public function test_static_accept_invitation_method(): void - { - $workspace = Workspace::factory()->create(); - $user = User::factory()->create(); - - $invitation = WorkspaceInvitation::factory()->create([ - 'workspace_id' => $workspace->id, - 'role' => 'member', - ]); - - $result = Workspace::acceptInvitation($invitation->token, $user); - - $this->assertTrue($result); - $this->assertTrue($workspace->users()->where('user_id', $user->id)->exists()); - } - - public function test_static_accept_with_invalid_token_returns_false(): void - { - $user = User::factory()->create(); - - $result = Workspace::acceptInvitation('invalid-token', $user); - - $this->assertFalse($result); - } - - public function test_user_already_in_workspace_still_accepts(): void - { - $workspace = Workspace::factory()->create(); - $user = User::factory()->create(); - - // User already in workspace - $workspace->users()->attach($user->id, ['role' => 'member']); - - $invitation = WorkspaceInvitation::factory()->create([ - 'workspace_id' => $workspace->id, - 'email' => $user->email, - 'role' => 'admin', - ]); - - $result = $invitation->accept($user); - - $this->assertTrue($result); - $this->assertTrue($invitation->fresh()->isAccepted()); - // Role should remain as original (member), not updated to admin - $this->assertEquals('member', $workspace->users()->find($user->id)->pivot->role); - } - - public function test_invitation_scopes(): void - { - $workspace = Workspace::factory()->create(); - - $pending = WorkspaceInvitation::factory()->create([ - 'workspace_id' => $workspace->id, - ]); - - $expired = WorkspaceInvitation::factory()->expired()->create([ - 'workspace_id' => $workspace->id, - ]); - - $accepted = WorkspaceInvitation::factory()->accepted()->create([ - 'workspace_id' => $workspace->id, - ]); - - $this->assertEquals(1, WorkspaceInvitation::pending()->count()); - $this->assertEquals(1, WorkspaceInvitation::expired()->count()); - $this->assertEquals(1, WorkspaceInvitation::accepted()->count()); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Tests/Feature/WorkspaceSecurityTest.php b/packages/core-php/src/Mod/Tenant/Tests/Feature/WorkspaceSecurityTest.php deleted file mode 100644 index a7a090b..0000000 --- a/packages/core-php/src/Mod/Tenant/Tests/Feature/WorkspaceSecurityTest.php +++ /dev/null @@ -1,433 +0,0 @@ -user = User::factory()->create(['name' => 'Test User']); - $this->workspace = Workspace::factory()->create(['name' => 'Test Workspace']); - $this->user->hostWorkspaces()->attach($this->workspace, ['role' => 'owner', 'is_default' => true]); - } - - protected function tearDown(): void - { - // Reset to default state - WorkspaceScope::enableStrictMode(); - parent::tearDown(); - } - - // ───────────────────────────────────────────────────────────────────────── - // MissingWorkspaceContextException Tests - // ───────────────────────────────────────────────────────────────────────── - - public function test_exception_for_model_has_correct_message(): void - { - $exception = MissingWorkspaceContextException::forModel('Account', 'query'); - - $this->assertStringContainsString('Account', $exception->getMessage()); - $this->assertStringContainsString('query', $exception->getMessage()); - $this->assertEquals('query', $exception->getOperation()); - $this->assertEquals('Account', $exception->getModel()); - } - - public function test_exception_for_create_has_correct_message(): void - { - $exception = MissingWorkspaceContextException::forCreate('Account'); - - $this->assertStringContainsString('Account', $exception->getMessage()); - $this->assertStringContainsString('create', $exception->getMessage()); - $this->assertEquals('create', $exception->getOperation()); - } - - public function test_exception_for_scope_has_correct_message(): void - { - $exception = MissingWorkspaceContextException::forScope('Account'); - - $this->assertStringContainsString('Account', $exception->getMessage()); - $this->assertStringContainsString('scope', $exception->getMessage()); - $this->assertEquals('scope', $exception->getOperation()); - } - - public function test_exception_renders_json_for_api_requests(): void - { - $exception = MissingWorkspaceContextException::forMiddleware(); - $request = Request::create('/api/test', 'GET'); - $request->headers->set('Accept', 'application/json'); - - $response = $exception->render($request); - - $this->assertEquals(403, $response->getStatusCode()); - $content = json_decode($response->getContent(), true); - $this->assertArrayHasKey('error', $content); - $this->assertEquals('missing_workspace_context', $content['error']); - } - - // ───────────────────────────────────────────────────────────────────────── - // WorkspaceScope Strict Mode Tests - // ───────────────────────────────────────────────────────────────────────── - - public function test_workspace_scope_throws_in_strict_mode_without_context(): void - { - WorkspaceScope::enableStrictMode(); - - // Ensure no workspace context - request()->attributes->remove('workspace_model'); - - $this->expectException(MissingWorkspaceContextException::class); - $this->expectExceptionMessage('scope'); - - // This should throw because no workspace context is available - Account::query()->get(); - } - - public function test_workspace_scope_works_with_valid_context(): void - { - $this->actingAs($this->user); - - // Create an account for this workspace - WorkspaceScope::withoutStrictMode(function () { - Account::factory()->create(['workspace_id' => $this->workspace->id]); - }); - - // Set workspace context - request()->attributes->set('workspace_model', $this->workspace); - - // Should not throw - $accounts = Account::query()->get(); - - $this->assertCount(1, $accounts); - } - - public function test_workspace_scope_strict_mode_can_be_disabled(): void - { - // Ensure no workspace context - request()->attributes->remove('workspace_model'); - - WorkspaceScope::disableStrictMode(); - - // Should not throw, but return empty result - $accounts = Account::query()->get(); - - $this->assertCount(0, $accounts); - - // Re-enable for other tests - WorkspaceScope::enableStrictMode(); - } - - public function test_without_strict_mode_callback_restores_state(): void - { - WorkspaceScope::enableStrictMode(); - $this->assertTrue(WorkspaceScope::isStrictModeEnabled()); - - WorkspaceScope::withoutStrictMode(function () { - $this->assertFalse(WorkspaceScope::isStrictModeEnabled()); - }); - - $this->assertTrue(WorkspaceScope::isStrictModeEnabled()); - } - - public function test_for_workspace_macro_bypasses_strict_mode(): void - { - // Ensure no current workspace context - request()->attributes->remove('workspace_model'); - - // Create data - WorkspaceScope::withoutStrictMode(function () { - Account::factory()->create(['workspace_id' => $this->workspace->id]); - }); - - // forWorkspace should work even without global context - $accounts = Account::query()->forWorkspace($this->workspace)->get(); - - $this->assertCount(1, $accounts); - } - - public function test_across_workspaces_macro_bypasses_strict_mode(): void - { - // Ensure no current workspace context - request()->attributes->remove('workspace_model'); - - // Create data in multiple workspaces - $workspace2 = Workspace::factory()->create(); - - WorkspaceScope::withoutStrictMode(function () use ($workspace2) { - Account::factory()->create(['workspace_id' => $this->workspace->id]); - Account::factory()->create(['workspace_id' => $workspace2->id]); - }); - - // acrossWorkspaces should work without context - $accounts = Account::query()->acrossWorkspaces()->get(); - - $this->assertCount(2, $accounts); - } - - // ───────────────────────────────────────────────────────────────────────── - // BelongsToWorkspace Trait Tests - // ───────────────────────────────────────────────────────────────────────── - - public function test_creating_model_without_workspace_throws_in_strict_mode(): void - { - // Ensure no workspace context - request()->attributes->remove('workspace_model'); - WorkspaceScope::enableStrictMode(); - - $this->expectException(MissingWorkspaceContextException::class); - $this->expectExceptionMessage('create'); - - Account::create([ - 'uuid' => \Illuminate\Support\Str::uuid(), - 'provider' => 'twitter', - 'provider_id' => '12345', - 'name' => 'Test Account', - 'credentials' => collect(['access_token' => 'test-token']), - ]); - } - - public function test_creating_model_with_explicit_workspace_id_succeeds(): void - { - // Ensure no workspace context - request()->attributes->remove('workspace_model'); - - // Should succeed because workspace_id is explicitly provided - $account = Account::create([ - 'uuid' => \Illuminate\Support\Str::uuid(), - 'workspace_id' => $this->workspace->id, - 'provider' => 'twitter', - 'provider_id' => '12345', - 'name' => 'Test Account', - 'credentials' => collect(['access_token' => 'test-token']), - ]); - - $this->assertEquals($this->workspace->id, $account->workspace_id); - } - - public function test_creating_model_with_workspace_context_auto_assigns(): void - { - $this->actingAs($this->user); - request()->attributes->set('workspace_model', $this->workspace); - - $account = Account::create([ - 'uuid' => \Illuminate\Support\Str::uuid(), - 'provider' => 'twitter', - 'provider_id' => '12345', - 'name' => 'Test Account', - 'credentials' => collect(['access_token' => 'test-token']), - ]); - - $this->assertEquals($this->workspace->id, $account->workspace_id); - } - - public function test_owned_by_current_workspace_throws_without_context(): void - { - // Ensure no workspace context - request()->attributes->remove('workspace_model'); - WorkspaceScope::enableStrictMode(); - - $this->expectException(MissingWorkspaceContextException::class); - - Account::ownedByCurrentWorkspace()->get(); - } - - public function test_owned_by_current_workspace_cached_throws_without_context(): void - { - // Ensure no workspace context - request()->attributes->remove('workspace_model'); - WorkspaceScope::enableStrictMode(); - - $this->expectException(MissingWorkspaceContextException::class); - - Account::ownedByCurrentWorkspaceCached(); - } - - // ───────────────────────────────────────────────────────────────────────── - // RequireWorkspaceContext Middleware Tests - // ───────────────────────────────────────────────────────────────────────── - - public function test_middleware_throws_without_workspace_context(): void - { - $middleware = new RequireWorkspaceContext; - $request = Request::create('/test', 'GET'); - - $this->expectException(MissingWorkspaceContextException::class); - - $middleware->handle($request, fn () => response('OK')); - } - - public function test_middleware_passes_with_workspace_model_attribute(): void - { - $middleware = new RequireWorkspaceContext; - $request = Request::create('/test', 'GET'); - $request->attributes->set('workspace_model', $this->workspace); - - $response = $middleware->handle($request, fn () => response('OK')); - - $this->assertEquals(200, $response->getStatusCode()); - } - - public function test_middleware_resolves_workspace_from_header(): void - { - $middleware = new RequireWorkspaceContext; - $request = Request::create('/test', 'GET'); - $request->headers->set('X-Workspace-ID', (string) $this->workspace->id); - - $response = $middleware->handle($request, fn () => response('OK')); - - $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals($this->workspace->id, $request->attributes->get('workspace_model')->id); - } - - public function test_middleware_resolves_workspace_from_query(): void - { - $middleware = new RequireWorkspaceContext; - $request = Request::create('/test?workspace='.$this->workspace->slug, 'GET'); - - $response = $middleware->handle($request, fn () => response('OK')); - - $this->assertEquals(200, $response->getStatusCode()); - } - - public function test_middleware_validates_user_access_when_requested(): void - { - $middleware = new RequireWorkspaceContext; - - // Create another workspace the user doesn't have access to - $otherWorkspace = Workspace::factory()->create(['name' => 'Other Workspace']); - - $this->actingAs($this->user); - $request = Request::create('/test', 'GET'); - $request->setUserResolver(fn () => $this->user); - $request->attributes->set('workspace_model', $otherWorkspace); - - $this->expectException(MissingWorkspaceContextException::class); - $this->expectExceptionMessage('do not have access'); - - $middleware->handle($request, fn () => response('OK'), 'validate'); - } - - public function test_middleware_allows_access_to_user_workspace(): void - { - $middleware = new RequireWorkspaceContext; - - $this->actingAs($this->user); - $request = Request::create('/test', 'GET'); - $request->setUserResolver(fn () => $this->user); - $request->attributes->set('workspace_model', $this->workspace); - - $response = $middleware->handle($request, fn () => response('OK'), 'validate'); - - $this->assertEquals(200, $response->getStatusCode()); - } - - // ───────────────────────────────────────────────────────────────────────── - // Cross-Tenant Isolation Tests - // ───────────────────────────────────────────────────────────────────────── - - public function test_cannot_query_other_workspace_data_with_scoped_query(): void - { - $workspace2 = Workspace::factory()->create(['name' => 'Workspace 2']); - - // Create accounts in both workspaces (bypass strict mode for setup) - WorkspaceScope::withoutStrictMode(function () use ($workspace2) { - Account::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Account 1']); - Account::factory()->create(['workspace_id' => $workspace2->id, 'name' => 'Account 2']); - }); - - // Set context to workspace 1 - request()->attributes->set('workspace_model', $this->workspace); - - // Should only see workspace 1's accounts - $accounts = Account::query()->get(); - $this->assertCount(1, $accounts); - $this->assertEquals('Account 1', $accounts->first()->name); - } - - public function test_model_belongs_to_workspace_check_works(): void - { - $workspace2 = Workspace::factory()->create(); - - $account = null; - WorkspaceScope::withoutStrictMode(function () use (&$account) { - $account = Account::factory()->create(['workspace_id' => $this->workspace->id]); - }); - - $this->assertTrue($account->belongsToWorkspace($this->workspace)); - $this->assertTrue($account->belongsToWorkspace($this->workspace->id)); - $this->assertFalse($account->belongsToWorkspace($workspace2)); - $this->assertFalse($account->belongsToWorkspace($workspace2->id)); - } - - public function test_model_belongs_to_current_workspace_check_works(): void - { - $workspace2 = Workspace::factory()->create(); - - $account1 = null; - $account2 = null; - WorkspaceScope::withoutStrictMode(function () use (&$account1, &$account2, $workspace2) { - $account1 = Account::factory()->create(['workspace_id' => $this->workspace->id]); - $account2 = Account::factory()->create(['workspace_id' => $workspace2->id]); - }); - - // Set current workspace - request()->attributes->set('workspace_model', $this->workspace); - - $this->assertTrue($account1->belongsToCurrentWorkspace()); - $this->assertFalse($account2->belongsToCurrentWorkspace()); - } - - // ───────────────────────────────────────────────────────────────────────── - // Model Opt-Out Tests - // ───────────────────────────────────────────────────────────────────────── - - public function test_model_can_opt_out_of_strict_workspace_context(): void - { - // Create a test model class that opts out - $model = new class extends Model - { - use BelongsToWorkspace; - - protected $table = 'test_models'; - - protected bool $workspaceContextRequired = false; - }; - - // Ensure no workspace context - request()->attributes->remove('workspace_model'); - WorkspaceScope::enableStrictMode(); - - // Should not throw because model opted out - $this->assertFalse($model->requiresWorkspaceContext()); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Tests/Feature/WorkspaceTenancyTest.php b/packages/core-php/src/Mod/Tenant/Tests/Feature/WorkspaceTenancyTest.php deleted file mode 100644 index 5485481..0000000 --- a/packages/core-php/src/Mod/Tenant/Tests/Feature/WorkspaceTenancyTest.php +++ /dev/null @@ -1,165 +0,0 @@ -userA = User::factory()->create(['name' => 'User A']); - $this->userB = User::factory()->create(['name' => 'User B']); - - $this->workspaceA = Workspace::factory()->create(['name' => 'Workspace A']); - $this->workspaceB = Workspace::factory()->create(['name' => 'Workspace B']); - - // Attach users to their workspaces - $this->userA->hostWorkspaces()->attach($this->workspaceA, ['role' => 'owner', 'is_default' => true]); - $this->userB->hostWorkspaces()->attach($this->workspaceB, ['role' => 'owner', 'is_default' => true]); - } - - public function test_workspace_has_relationship_methods_for_all_services() - { - $workspace = Workspace::factory()->create(); - - // Test that all relationship methods exist and return correct type - $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class, $workspace->socialAccounts()); - $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class, $workspace->socialPosts()); - $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class, $workspace->analyticsSites()); - $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class, $workspace->trustWidgets()); - $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class, $workspace->notificationSites()); - $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class, $workspace->pushCampaigns()); - // NOTE: bioPages relationship has been moved to Host UK app's Mod\Bio module - } - - public function test_workspace_current_resolves_from_authenticated_user() - { - $this->actingAs($this->userA); - - $current = Workspace::current(); - - $this->assertNotNull($current); - $this->assertEquals($this->workspaceA->id, $current->id); - } - - public function test_workspace_scoping_isolates_data_between_workspaces() - { - // Create social accounts for each workspace - $accountA = Account::factory()->create([ - 'workspace_id' => $this->workspaceA->id, - 'name' => 'Account A', - ]); - - $accountB = Account::factory()->create([ - 'workspace_id' => $this->workspaceB->id, - 'name' => 'Account B', - ]); - - // User A should only see their workspace's account - $this->actingAs($this->userA); - $accountsForUserA = Account::ownedByCurrentWorkspace()->get(); - $this->assertCount(1, $accountsForUserA); - $this->assertEquals('Account A', $accountsForUserA->first()->name); - - // User B should only see their workspace's account - $this->actingAs($this->userB); - $accountsForUserB = Account::ownedByCurrentWorkspace()->get(); - $this->assertCount(1, $accountsForUserB); - $this->assertEquals('Account B', $accountsForUserB->first()->name); - } - - public function test_workspace_relationships_return_correct_models() - { - // Create various resources for workspace A - Account::factory()->create(['workspace_id' => $this->workspaceA->id]); - Account::factory()->create(['workspace_id' => $this->workspaceA->id]); - Website::factory()->create(['workspace_id' => $this->workspaceA->id]); - - // Create some for workspace B (should not appear) - Account::factory()->create(['workspace_id' => $this->workspaceB->id]); - - $this->assertEquals(2, $this->workspaceA->socialAccounts()->count()); - $this->assertEquals(1, $this->workspaceA->analyticsSites()->count()); - - // Workspace B should have different counts - $this->assertEquals(1, $this->workspaceB->socialAccounts()->count()); - } - - public function test_models_with_workspace_trait_auto_assign_workspace_on_create() - { - $this->actingAs($this->userA); - - // When creating a model with BelongsToWorkspace trait, - // it should auto-assign the current user's workspace - $account = Account::create([ - 'uuid' => \Illuminate\Support\Str::uuid(), - 'provider' => 'twitter', - 'provider_id' => '12345', - 'name' => 'Test Account', - 'credentials' => collect(['access_token' => 'test-token']), - ]); - - $this->assertEquals($this->workspaceA->id, $account->workspace_id); - } - - public function test_workspace_scope_prevents_cross_workspace_access() - { - $accountA = Account::factory()->create([ - 'workspace_id' => $this->workspaceA->id, - 'uuid' => 'uuid-a', - ]); - - $accountB = Account::factory()->create([ - 'workspace_id' => $this->workspaceB->id, - 'uuid' => 'uuid-b', - ]); - - $this->actingAs($this->userA); - - // User A should be able to find their account - $found = Account::ownedByCurrentWorkspace()->where('uuid', 'uuid-a')->first(); - $this->assertNotNull($found); - - // User A should NOT be able to find User B's account via scoped query - $notFound = Account::ownedByCurrentWorkspace()->where('uuid', 'uuid-b')->first(); - $this->assertNull($notFound); - - // But should be able to find it if scope is explicitly bypassed - $foundWithoutScope = Account::withoutGlobalScopes()->where('uuid', 'uuid-b')->first(); - $this->assertNotNull($foundWithoutScope); - } - - public function test_belongs_to_workspace_method_checks_ownership() - { - $accountA = Account::factory()->create(['workspace_id' => $this->workspaceA->id]); - $accountB = Account::factory()->create(['workspace_id' => $this->workspaceB->id]); - - $this->assertTrue($accountA->belongsToWorkspace($this->workspaceA)); - $this->assertFalse($accountA->belongsToWorkspace($this->workspaceB)); - - $this->assertTrue($accountB->belongsToWorkspace($this->workspaceB)); - $this->assertFalse($accountB->belongsToWorkspace($this->workspaceA)); - } -} diff --git a/packages/core-php/src/Mod/Tenant/View/Blade/admin/entitlement-webhook-manager.blade.php b/packages/core-php/src/Mod/Tenant/View/Blade/admin/entitlement-webhook-manager.blade.php deleted file mode 100644 index d852625..0000000 --- a/packages/core-php/src/Mod/Tenant/View/Blade/admin/entitlement-webhook-manager.blade.php +++ /dev/null @@ -1,401 +0,0 @@ -
- {{-- Stats --}} -
- -
-
- -
-
-
{{ number_format($this->stats['total']) }}
-
Total Webhooks
-
-
-
- - -
-
- -
-
-
{{ number_format($this->stats['active']) }}
-
Active
-
-
-
- - -
-
- -
-
-
{{ number_format($this->stats['circuit_broken']) }}
-
Circuit Broken
-
-
-
-
- - {{-- Message --}} - @if($message) -
- - {{ $message }} - -
- @endif - - {{-- Filters --}} - -
-
- -
- -
- - - @foreach($this->workspaces as $workspace) - - @endforeach - -
- -
- - - - - - -
- - - - New Webhook - -
-
- - {{-- Webhooks Table --}} - - - - - Webhook - Workspace - Events - Status - Deliveries - Actions - - - - @forelse($this->webhooks as $webhook) - - -
-
{{ $webhook->name }}
-
- {{ $webhook->url }} -
-
-
- - - {{ $webhook->workspace?->name ?? 'N/A' }} - - - -
- @foreach($webhook->events as $event) - {{ $event }} - @endforeach -
-
- - - @if($webhook->isCircuitBroken()) - Circuit Broken - @elseif($webhook->is_active) - Active - @else - Inactive - @endif - - @if($webhook->last_delivery_status) -
- - Last: {{ ucfirst($webhook->last_delivery_status->value) }} - -
- @endif -
- - - - - - - - - - - - - - - - Edit - - - - - Send Test - - - - - View Deliveries - - - - - Regenerate Secret - - - @if($webhook->isCircuitBroken()) - - - Reset Circuit Breaker - - @endif - - - - - @if($webhook->is_active) - - Disable - @else - - Enable - @endif - - - - - Delete - - - -
- @empty - - - No webhooks found. Create one to get started. - - - @endforelse -
-
- -
- {{ $this->webhooks->links() }} -
-
- - {{-- Create/Edit Modal --}} - - - {{ $editingId ? 'Edit Webhook' : 'Create Webhook' }} - - - -
- @if(!$editingId) - - Workspace - - - @foreach($this->workspaces as $workspace) - - @endforeach - - - - @endif - - - Name - - - - - - URL - - - The endpoint that will receive webhook POST requests. - - - - Events -
- @foreach($this->availableEvents as $eventKey => $eventInfo) - - @endforeach -
- -
- - - Max Retry Attempts - - Number of times to retry failed deliveries (1-10). - - - - - Inactive webhooks will not receive any events. - -
-
- - - Cancel - - {{ $editingId ? 'Update' : 'Create' }} - - -
- - {{-- Deliveries Modal --}} - - - Delivery History - - - - - - - Event - Status - HTTP - Attempts - Time - - - - - @forelse($this->recentDeliveries as $delivery) - - - {{ $delivery->getEventDisplayName() }} - - - - - {{ ucfirst($delivery->status->value) }} - - - - - {{ $delivery->http_status ?? '-' }} - - - - {{ $delivery->attempts }} - - - - - {{ $delivery->created_at->diffForHumans() }} - - - - - @if($delivery->isFailed()) - - Retry - - @endif - - - @empty - - - No deliveries yet. - - - @endforelse - - - - - - Close - - - - {{-- Secret Modal --}} - - - Webhook Secret - - - - - Save this secret now. It will not be shown again. - - -
- {{ $displaySecret }} -
- -

- Use this secret to verify webhook signatures. The signature is sent in the - X-Signature header - and is a HMAC-SHA256 hash of the JSON payload. -

-
- - - - I've saved the secret - - -
-
diff --git a/packages/core-php/src/Mod/Tenant/View/Blade/admin/workspace-details.blade.php b/packages/core-php/src/Mod/Tenant/View/Blade/admin/workspace-details.blade.php deleted file mode 100644 index 1c5bf09..0000000 --- a/packages/core-php/src/Mod/Tenant/View/Blade/admin/workspace-details.blade.php +++ /dev/null @@ -1,696 +0,0 @@ -
- {{-- Header --}} -
-
- - - Workspaces - - / - {{ $workspace->name }} -
- -
-
-
- -
-
-

{{ $workspace->name }}

-
- {{ $workspace->slug }} - @if($workspace->is_active) - - Active - - @else - - Inactive - - @endif -
-
-
- - - Hades Only - -
-
- - {{-- Action message --}} - @if($actionMessage) -
-
- - {{ $actionMessage }} -
-
- @endif - - {{-- Tabs --}} -
- -
- - {{-- Tab Content --}} -
- {{-- Overview Tab --}} - @if($activeTab === 'overview') -
- {{-- Quick Stats --}} -
- {{-- Owner Card --}} -
-

Workspace Owner

- @php $owner = $workspace->owner(); @endphp - @if($owner) -
-
- -
-
-
{{ $owner->name }}
-
{{ $owner->email }}
-
-
- @else -
No owner assigned
- @endif -
- - {{-- Packages Card --}} -
-

Active Packages

- @if($this->activePackages->count() > 0) -
- @foreach($this->activePackages as $wp) -
-
-
- -
-
-
{{ $wp->package?->name ?? 'Unknown' }}
-
{{ $wp->package?->code ?? '' }}
-
-
-
- - {{ ucfirst($wp->status) }} - - @if($wp->expires_at) -
Expires {{ $wp->expires_at->format('d M Y') }}
- @endif -
-
- @endforeach -
- @else -
No packages assigned
- @endif -
- - {{-- Boosts Card --}} -
-

Active Boosts

- @if($this->activeBoosts->count() > 0) -
- @foreach($this->activeBoosts as $boost) -
-
-
- -
-
-
{{ $boost->feature_code }}
-
{{ str_replace('_', ' ', $boost->boost_type) }}@if($boost->limit_value) · +{{ number_format($boost->limit_value) }}@endif
-
-
-
- - {{ ucfirst($boost->status) }} - - @if($boost->expires_at) -
Expires {{ $boost->expires_at->format('d M Y') }}
- @else -
Permanent
- @endif -
-
- @endforeach -
- @else -
No boosts active
- @endif -
- - {{-- Subscription Card --}} - @if($this->subscriptionInfo) -
-

Subscription

-
-
-
- -
-
-
{{ $this->subscriptionInfo['plan'] }}
-
Renews {{ $this->subscriptionInfo['current_period_end'] }}
-
-
- @if($this->subscriptionInfo['amount']) -
-
{{ $this->subscriptionInfo['currency'] }} {{ $this->subscriptionInfo['amount'] }}
-
/month
-
- @endif -
-
- @endif -
- - {{-- Sidebar Stats --}} -
-
-

Quick Stats

-
-
- Team Members - {{ $this->teamMembers->count() }} -
- @foreach(array_slice($this->resourceCounts, 0, 5) as $resource) -
- {{ $resource['label'] }} - {{ $resource['count'] }} -
- @endforeach -
-
- - {{-- Workspace Info --}} -
-

Details

-
-
-
Created
-
{{ $workspace->created_at->format('d M Y') }}
-
-
-
- Domain - -
-
{{ $workspace->domain ?: 'Not set' }}
-
- @if($workspace->wp_connector_enabled) -
-
WP Connector
-
- Connected -
-
- @endif -
-
-
-
- @endif - - {{-- Team Tab --}} - @if($activeTab === 'team') -
-
-

Team Members ({{ $this->teamMembers->count() }})

- - - Add Member - -
-
- @forelse($this->teamMembers as $member) -
-
-
- -
-
-
{{ $member->name }}
-
{{ $member->email }}
-
-
-
- - {{ ucfirst($member->pivot->role) }} - - @if($member->pivot->role !== 'owner') -
- - -
- @endif -
-
- @empty -
- No team members found. -
- @endforelse -
-
- @endif - - {{-- Entitlements Tab --}} - @if($activeTab === 'entitlements') -
- {{-- Stats Header --}} -
-
-

Entitlement Overview

-
- - - Add Package - - - - Add Entitlement - -
-
-
-
-
{{ $this->entitlementStats['total'] }}
-
Total Features
-
-
-
{{ $this->entitlementStats['allowed'] }}
-
Allowed
-
-
-
{{ $this->entitlementStats['denied'] }}
-
Not Included
-
-
-
{{ $this->entitlementStats['near_limit'] }}
-
Near Limit
-
-
-
{{ $this->entitlementStats['packages'] }}
-
Packages
-
-
-
{{ $this->entitlementStats['boosts'] }}
-
Boosts
-
-
-
- - {{-- Active Boosts Section --}} - @if($this->activeBoosts->count() > 0) -
-
-

- - Active Boosts ({{ $this->activeBoosts->count() }}) -

-
-
- @foreach($this->activeBoosts as $boost) -
-
-
- -
-
-
{{ $boost->feature_code }}
-
- {{ str_replace('_', ' ', $boost->boost_type) }} - @if($boost->limit_value) - · +{{ number_format($boost->limit_value) }} - @endif - @if($boost->expires_at) - - · Expires {{ $boost->expires_at->format('d M Y') }} - - @else - · Permanent - @endif -
-
-
- -
- @endforeach -
-
- @endif - - {{-- Resolved Entitlements by Category --}} - @forelse($this->resolvedEntitlements as $category => $features) -
-
-

{{ $category ?: 'General' }}

-
-
- @foreach($features as $entitlement) -
-
- {{-- Status indicator --}} -
- -
-
-
{{ $entitlement['name'] }}
-
{{ $entitlement['code'] }}
-
-
- -
- {{-- Type badge --}} - - @if($entitlement['type'] === 'boolean') - Toggle - @elseif($entitlement['unlimited']) - Unlimited - @else - Limit - @endif - - - {{-- Usage info --}} - @if($entitlement['type'] !== 'boolean' && !$entitlement['unlimited'] && $entitlement['allowed']) -
-
- {{ number_format($entitlement['used'] ?? 0) }} - / {{ number_format($entitlement['limit']) }} -
- @if($entitlement['limit'] > 0) - @php $percent = $entitlement['percentage'] ?? 0; @endphp -
-
-
- @endif -
- @elseif($entitlement['unlimited']) -
- - - {{ number_format($entitlement['used'] ?? 0) }} used - -
- @elseif(!$entitlement['allowed']) -
- Not included -
- @endif -
-
- @endforeach -
-
- @empty -
- No entitlements configured. -
- @endforelse - - {{-- Packages Section --}} - @if($this->workspacePackages->count() > 0) -
-
-

- - Assigned Packages ({{ $this->workspacePackages->count() }}) -

-
-
- @foreach($this->workspacePackages as $wp) -
-
-
- -
-
-
{{ $wp->package?->name ?? 'Unknown' }}
-
- {{ $wp->package?->code ?? '' }} - @if($wp->expires_at) - - · Expires {{ $wp->expires_at->format('d M Y') }} - - @endif -
-
-
-
- - {{ ucfirst($wp->status) }} - - @if($wp->status === 'active') - - @elseif($wp->status === 'suspended') - - @endif - -
-
- @endforeach -
-
- @endif -
- @endif - - {{-- Resources Tab --}} - @if($activeTab === 'resources') -
- @foreach($this->resourceCounts as $resource) -
-
-
- -
-
-
{{ number_format($resource['count']) }}
-
{{ $resource['label'] }}
-
- @endforeach -
- - @if(count($this->resourceCounts) === 0) -
- No resources configured for this workspace. -
- @endif - @endif - - {{-- Activity Tab --}} - @if($activeTab === 'activity') -
-
-

Recent Activity

-
-
- @forelse($this->recentActivity as $activity) -
-
- -
-
-
{{ $activity['message'] }}
- @if($activity['detail']) -
{{ $activity['detail'] }}
- @endif -
{{ $activity['created_at']->diffForHumans() }}
-
-
- @empty -
- No recent activity found. -
- @endforelse -
-
- @endif -
- - {{-- Add Member Modal --}} - - Add Team Member - -
- - Select user... - @foreach($this->availableUsers as $user) - {{ $user->name }} ({{ $user->email }}) - @endforeach - - - - Member - Admin - Owner - - -
- Cancel - Add Member -
-
-
- - {{-- Edit Member Modal --}} - - Edit Member Role - -
- - Member - Admin - Owner - - -
- - Changing to Owner will transfer ownership from the current owner. - -
- -
- Cancel - Update Role -
-
-
- - {{-- Edit Domain Modal --}} - - Edit Domain - -
- - -
- - Enter the domain without the protocol (e.g., example.com not https://example.com). - -
- -
- Cancel - Save Domain -
-
-
- - {{-- Add Package Modal --}} - - Add Package - -
- - @foreach($this->allPackages as $package) - - {{ $package->name }} ({{ $package->code }}) - - @endforeach - - -
- - The package will be assigned immediately with no expiry date. You can modify or remove it later. - -
- -
- Cancel - Add Package -
-
-
- - {{-- Add Entitlement Modal --}} - - Add Entitlement - -
- - @foreach($this->allFeatures->groupBy('category') as $category => $features) - ── {{ ucfirst($category ?: 'General') }} ── - @foreach($features as $feature) - - {{ $feature->name }} ({{ $feature->code }}) - - @endforeach - @endforeach - - - - Enable (Toggle on) - Add Limit (Extra quota) - Unlimited - - - @if($entitlementType === 'add_limit') - - @endif - - - Permanent - Expires on date - - - @if($entitlementDuration === 'duration') - - @endif - -
- - This will create a boost that grants the selected feature directly to this workspace, independent of packages. - -
- -
- Cancel - Add Entitlement -
-
-
-
diff --git a/packages/core-php/src/Mod/Tenant/View/Blade/admin/workspace-manager.blade.php b/packages/core-php/src/Mod/Tenant/View/Blade/admin/workspace-manager.blade.php deleted file mode 100644 index 63bb9de..0000000 --- a/packages/core-php/src/Mod/Tenant/View/Blade/admin/workspace-manager.blade.php +++ /dev/null @@ -1,417 +0,0 @@ - - - - - {{ __('tenant::tenant.admin.hades_only') }} - - - - {{-- Action message --}} - @if($actionMessage) -
-
- - {{ $actionMessage }} -
-
- @endif - - {{-- Stats Grid --}} -
-
-
{{ number_format($stats['total']) }}
-
{{ __('tenant::tenant.admin.stats.total') }}
-
-
-
{{ number_format($stats['active']) }}
-
{{ __('tenant::tenant.admin.stats.active') }}
-
-
-
{{ number_format($stats['inactive']) }}
-
{{ __('tenant::tenant.admin.stats.inactive') }}
-
-
- - {{-- Search --}} - - - - - - {{-- Workspace Table --}} -
-
- - - - - - - - - - - - - - - - - @forelse($this->workspaces as $workspace) - - - - - - - - - - - - - @empty - - - - @endforelse - -
{{ __('tenant::tenant.admin.table.workspace') }}{{ __('tenant::tenant.admin.table.owner') }}{{ __('tenant::tenant.admin.table.bio') }}{{ __('tenant::tenant.admin.table.social') }}{{ __('tenant::tenant.admin.table.analytics') }}{{ __('tenant::tenant.admin.table.trust') }}{{ __('tenant::tenant.admin.table.notify') }}{{ __('tenant::tenant.admin.table.commerce') }}{{ __('tenant::tenant.admin.table.status') }}{{ __('tenant::tenant.admin.table.actions') }}
- -
- -
-
-
{{ $workspace->name }}
-
{{ $workspace->slug }}
-
-
-
- @php $owner = $workspace->owner(); @endphp - @if($owner) -
{{ $owner->name }}
-
{{ $owner->email }}
- @else - {{ __('tenant::tenant.admin.table.no_owner') }} - @endif -
- @php - $bioPages = $workspace->bio_pages_count ?? 0; - $bioProjects = $workspace->bio_projects_count ?? 0; - @endphp - @if($bioPages > 0 || $bioProjects > 0) - - @else - - @endif - - @if(($workspace->social_accounts_count ?? 0) > 0) - - @else - - @endif - - @if(($workspace->analytics_sites_count ?? 0) > 0) - - @else - - @endif - - @if(($workspace->trust_widgets_count ?? 0) > 0) - - @else - - @endif - - @if(($workspace->notification_sites_count ?? 0) > 0) - - @else - - @endif - - - - - @if($workspace->is_active) - - {{ __('tenant::tenant.admin.table.active') }} - - @else - - {{ __('tenant::tenant.admin.table.inactive') }} - - @endif - -
- - - - - - - -
-
- {{ __('tenant::tenant.admin.table.empty') }} -
-
- @if($this->workspaces->hasPages()) -
- {{ $this->workspaces->links() }} -
- @endif -
- - {{-- Edit Workspace Modal --}} - - {{ __('tenant::tenant.admin.edit_modal.title') }} - -
- - - -
- - {{ __('tenant::tenant.admin.edit_modal.active') }} -
- -
- {{ __('tenant::tenant.admin.edit_modal.cancel') }} - {{ __('tenant::tenant.admin.edit_modal.save') }} -
- -
- - {{-- Transfer Resources Modal --}} - - {{ __('tenant::tenant.admin.transfer_modal.title') }} - -
- @if($sourceWorkspaceId) - @php $sourceWorkspace = $this->allWorkspaces->firstWhere('id', $sourceWorkspaceId); @endphp -
- - {{ __('tenant::tenant.admin.transfer_modal.source') }}: {{ $sourceWorkspace?->name ?? 'Unknown' }} ({{ $sourceWorkspace?->slug ?? '' }}) - -
- @endif - - - {{ __('tenant::tenant.admin.transfer_modal.select_target') }} - @foreach($this->allWorkspaces as $ws) - @if($ws->id !== $sourceWorkspaceId) - {{ $ws->name }} ({{ $ws->slug }}) - @endif - @endforeach - - - - {{ __('tenant::tenant.admin.transfer_modal.resources_label') }} -
- @foreach($this->resourceTypes as $key => $type) - - @endforeach -
-
- -
- - {{ __('tenant::tenant.admin.transfer_modal.warning') }} - -
- -
- {{ __('tenant::tenant.admin.transfer_modal.cancel') }} - - {{ __('tenant::tenant.admin.transfer_modal.transfer') }} - -
-
-
- - {{-- Change Owner Modal --}} - - {{ __('tenant::tenant.admin.owner_modal.title') }} - -
- @if($ownerWorkspaceId) - @php $ownerWorkspace = $this->allWorkspaces->firstWhere('id', $ownerWorkspaceId); @endphp -
- - {{ __('tenant::tenant.admin.owner_modal.workspace') }}: {{ $ownerWorkspace?->name ?? 'Unknown' }} - -
- @endif - - - {{ __('tenant::tenant.admin.owner_modal.select_owner') }} - @foreach($this->allUsers as $user) - {{ $user->name }} ({{ $user->email }}) - @endforeach - - -
- - {{ __('tenant::tenant.admin.owner_modal.warning') }} - -
- -
- {{ __('tenant::tenant.admin.owner_modal.cancel') }} - - {{ __('tenant::tenant.admin.owner_modal.change') }} - -
-
-
- - {{-- Resource Viewer Modal --}} - - @php - $resourceWorkspace = $this->allWorkspaces->firstWhere('id', $resourcesWorkspaceId); - $resourceTypeInfo = $this->resourceTypes[$resourcesType] ?? null; - @endphp - - {{ $resourceTypeInfo['label'] ?? 'Resources' }} - {{ __('tenant::tenant.admin.resources_modal.in') }} {{ $resourceWorkspace?->name ?? 'Unknown' }} - - -
- {{-- Selection controls --}} -
-
- {{ __('tenant::tenant.admin.resources_modal.select_all') }} - {{ __('tenant::tenant.admin.resources_modal.deselect_all') }} -
- {{ __('tenant::tenant.admin.resources_modal.selected', ['count' => count($selectedResources)]) }} -
- - {{-- Resource list --}} -
- @forelse($this->currentResources as $resource) -
-
-
- @if(in_array($resource['id'], $selectedResources)) - - @endif -
-
-
-
{{ $resource['name'] }}
- @if($resource['detail']) -
{{ $resource['detail'] }}
- @endif -
-
- {{ $resource['created_at'] }} -
-
- @empty -
{{ __('tenant::tenant.admin.resources_modal.no_resources') }}
- @endforelse -
- - {{-- Transfer section --}} - @if(count($this->currentResources) > 0) -
- {{ __('tenant::tenant.admin.resources_modal.transfer_selected') }} -
-
- - {{ __('tenant::tenant.admin.resources_modal.select_workspace') }} - @foreach($this->allWorkspaces as $ws) - @if($ws->id !== $resourcesWorkspaceId) - {{ $ws->name }} ({{ $ws->slug }}) - @endif - @endforeach - -
- - {{ trans_choice('tenant::tenant.admin.resources_modal.transfer_items', count($selectedResources), ['count' => count($selectedResources)]) }} - -
-
- @endif - -
- {{ __('tenant::tenant.admin.resources_modal.close') }} -
-
-
- - {{-- Provision Resource Modal --}} - - @php - $provisionWorkspace = $this->allWorkspaces->firstWhere('id', $provisionWorkspaceId); - $config = $this->provisionConfig[$provisionType] ?? null; - @endphp - -
- @if($config) -
- -
- @endif - {{ __('tenant::tenant.admin.provision_modal.create', ['type' => $config['label'] ?? 'Resource']) }} -
-
- -
- @if($provisionWorkspace) -
- - {{ __('tenant::tenant.admin.provision_modal.workspace') }}: {{ $provisionWorkspace->name }} - -
- @endif - - - - @if($config && in_array('slug', $config['fields'] ?? [])) - - @endif - - @if($config && in_array('url', $config['fields'] ?? [])) - - @endif - -
- {{ __('tenant::tenant.admin.provision_modal.cancel') }} - - {{ __('tenant::tenant.admin.provision_modal.create', ['type' => $config['label'] ?? 'Resource']) }} - -
-
-
-
diff --git a/packages/core-php/src/Mod/Tenant/View/Blade/emails/account-deletion-requested.blade.php b/packages/core-php/src/Mod/Tenant/View/Blade/emails/account-deletion-requested.blade.php deleted file mode 100644 index 72b647e..0000000 --- a/packages/core-php/src/Mod/Tenant/View/Blade/emails/account-deletion-requested.blade.php +++ /dev/null @@ -1,44 +0,0 @@ -@php - $appName = config('core.app.name', __('core::core.brand.name')); -@endphp - - -# {{ __('tenant::tenant.emails.deletion_requested.subject') }} - -{{ __('tenant::tenant.emails.deletion_requested.greeting', ['name' => $user->name]) }} - -{{ __('tenant::tenant.emails.deletion_requested.scheduled', ['app' => $appName]) }} - -**{{ __('tenant::tenant.emails.deletion_requested.auto_delete', ['date' => $expiresAt->format('F j, Y \a\t g:i A'), 'days' => $daysRemaining]) }}** - -**{{ __('tenant::tenant.emails.deletion_requested.will_delete') }}** -- {{ __('tenant::tenant.emails.deletion_requested.items.profile') }} -- {{ __('tenant::tenant.emails.deletion_requested.items.workspaces') }} -- {{ __('tenant::tenant.emails.deletion_requested.items.content') }} -- {{ __('tenant::tenant.emails.deletion_requested.items.social') }} - -**{{ __('tenant::tenant.emails.deletion_requested.delete_now') }}** -{{ __('tenant::tenant.emails.deletion_requested.delete_now_description') }} - - -{{ __('tenant::tenant.emails.deletion_requested.delete_button') }} - - -**{{ __('tenant::tenant.emails.deletion_requested.changed_mind') }}** -{{ __('tenant::tenant.emails.deletion_requested.changed_mind_description') }} - - -{{ __('tenant::tenant.emails.deletion_requested.cancel_button') }} - - -**{{ __('tenant::tenant.emails.deletion_requested.not_requested') }}** -{{ __('tenant::tenant.emails.deletion_requested.not_requested_description') }} - -Thanks,
-{{ $appName }} - - -{{ __('tenant::tenant.emails.deletion_requested.delete_button') }}: {{ $confirmationUrl }}
-{{ __('tenant::tenant.emails.deletion_requested.cancel_button') }}: {{ $cancelUrl }} -
-
diff --git a/packages/core-php/src/Mod/Tenant/View/Blade/emails/usage-alert.blade.php b/packages/core-php/src/Mod/Tenant/View/Blade/emails/usage-alert.blade.php deleted file mode 100644 index ef8290e..0000000 --- a/packages/core-php/src/Mod/Tenant/View/Blade/emails/usage-alert.blade.php +++ /dev/null @@ -1,60 +0,0 @@ -@php - $appName = config('core.app.name', __('core::core.brand.name')); - $isLimit = $threshold === \Core\Mod\Tenant\Models\UsageAlertHistory::THRESHOLD_LIMIT; - $isCritical = $threshold === \Core\Mod\Tenant\Models\UsageAlertHistory::THRESHOLD_CRITICAL; -@endphp - - -@if($isLimit) -# {{ __('tenant::tenant.emails.usage_alert.limit_reached.heading') }} - -{{ __('tenant::tenant.emails.usage_alert.limit_reached.body', ['workspace' => $workspaceName, 'feature' => $featureName]) }} - -**{{ __('tenant::tenant.emails.usage_alert.limit_reached.usage_line', ['used' => $used, 'limit' => $limit]) }}** - -**{{ __('tenant::tenant.emails.usage_alert.limit_reached.options_heading') }}** -- {{ __('tenant::tenant.emails.usage_alert.limit_reached.options.upgrade') }} -- {{ __('tenant::tenant.emails.usage_alert.limit_reached.options.reset') }} -- {{ __('tenant::tenant.emails.usage_alert.limit_reached.options.reduce') }} - - -{{ __('tenant::tenant.emails.usage_alert.upgrade_plan') }} - - -@elseif($isCritical) -# {{ __('tenant::tenant.emails.usage_alert.critical.heading') }} - -{{ __('tenant::tenant.emails.usage_alert.critical.body', ['workspace' => $workspaceName, 'feature' => $featureName]) }} - -**{{ __('tenant::tenant.emails.usage_alert.critical.usage_line', ['used' => $used, 'limit' => $limit, 'percentage' => $percentage]) }}** - -**{{ __('tenant::tenant.emails.usage_alert.critical.remaining_line', ['remaining' => $remaining]) }}** - -{{ __('tenant::tenant.emails.usage_alert.critical.action_text') }} - - -{{ __('tenant::tenant.emails.usage_alert.upgrade_plan') }} - - -@else -# {{ __('tenant::tenant.emails.usage_alert.warning.heading') }} - -{{ __('tenant::tenant.emails.usage_alert.warning.body', ['workspace' => $workspaceName, 'feature' => $featureName]) }} - -**{{ __('tenant::tenant.emails.usage_alert.warning.usage_line', ['used' => $used, 'limit' => $limit, 'percentage' => $percentage]) }}** - -**{{ __('tenant::tenant.emails.usage_alert.warning.remaining_line', ['remaining' => $remaining]) }}** - -{{ __('tenant::tenant.emails.usage_alert.warning.action_text') }} - - -{{ __('tenant::tenant.emails.usage_alert.view_usage') }} - - -@endif - -{{ __('tenant::tenant.emails.usage_alert.help_text') }} - -Thanks,
-{{ $appName }} -
diff --git a/packages/core-php/src/Mod/Tenant/View/Blade/web/account/cancel-deletion.blade.php b/packages/core-php/src/Mod/Tenant/View/Blade/web/account/cancel-deletion.blade.php deleted file mode 100644 index 49b97a2..0000000 --- a/packages/core-php/src/Mod/Tenant/View/Blade/web/account/cancel-deletion.blade.php +++ /dev/null @@ -1,28 +0,0 @@ -
- @if($status === 'success') -
- - - -
-

{{ __('tenant::tenant.deletion.cancelled.title') }}

-

{{ __('tenant::tenant.deletion.cancelled.message') }}

- - {{ __('tenant::tenant.deletion.cancelled.go_to_profile') }} - - @elseif($status === 'invalid') -
- - - -
-

{{ __('tenant::tenant.deletion.cancel_invalid.title') }}

-

{{ __('tenant::tenant.deletion.cancel_invalid.message') }}

- - {{ __('tenant::tenant.deletion.return_home') }} - - @else -
-

{{ __('tenant::tenant.deletion.processing') }}

- @endif -
diff --git a/packages/core-php/src/Mod/Tenant/View/Blade/web/account/confirm-deletion.blade.php b/packages/core-php/src/Mod/Tenant/View/Blade/web/account/confirm-deletion.blade.php deleted file mode 100644 index 25ec3d8..0000000 --- a/packages/core-php/src/Mod/Tenant/View/Blade/web/account/confirm-deletion.blade.php +++ /dev/null @@ -1,220 +0,0 @@ -
- {{-- Invalid/Expired Token --}} - @if($step === 'invalid') -
-
- - - -
-

{{ __('tenant::tenant.deletion.invalid.title') }}

-

{{ __('tenant::tenant.deletion.invalid.message') }}

- - {{ __('tenant::tenant.deletion.return_home') }} - -
- @endif - - {{-- Step 1: Password Verification --}} - @if($step === 'verify') -
-
- - - -
-

{{ __('tenant::tenant.deletion.verify.title') }}

-

{{ __('tenant::tenant.deletion.verify.description', ['name' => $userName]) }}

-
- -
-
- - - @if($error) -

{{ $error }}

- @endif -
- - -
- -

- {{ __('tenant::tenant.deletion.verify.changed_mind') }} {{ __('tenant::tenant.deletion.verify.cancel_link') }} -

- @endif - - {{-- Step 2: Final Confirmation --}} - @if($step === 'confirm') -
-
- - - -
-

{{ __('tenant::tenant.deletion.confirm.title') }}

-

{!! __('tenant::tenant.deletion.confirm.warning') !!}

-
- -
-

{{ __('tenant::tenant.deletion.confirm.will_delete') }}

-
    -
  • - - {{ __('tenant::tenant.deletion.confirm.items.profile') }} -
  • -
  • - - {{ __('tenant::tenant.deletion.confirm.items.workspaces') }} -
  • -
  • - - {{ __('tenant::tenant.deletion.confirm.items.content') }} -
  • -
  • - - {{ __('tenant::tenant.deletion.confirm.items.social') }} -
  • -
-
- -
- - {{ __('tenant::tenant.deletion.confirm.cancel') }} - - -
- @endif - - {{-- Step 3: Deleting Animation --}} - @if($step === 'deleting') -
-
- - - - -
- -
-
- -

{{ __('tenant::tenant.deletion.deleting.title') }}

-

-
- @endif - - {{-- Step 4: Goodbye with Typewriter Effect --}} - @if($step === 'goodbye') -
-
- - _ -
- -
-

{{ __('tenant::tenant.deletion.goodbye.deleted') }}

-

{{ __('tenant::tenant.deletion.goodbye.thanks') }}

- - - - - - {{ __('tenant::tenant.deletion.return_home') }} - -
-
- @endif -
diff --git a/packages/core-php/src/Mod/Tenant/View/Blade/web/workspace/home.blade.php b/packages/core-php/src/Mod/Tenant/View/Blade/web/workspace/home.blade.php deleted file mode 100644 index 1186197..0000000 --- a/packages/core-php/src/Mod/Tenant/View/Blade/web/workspace/home.blade.php +++ /dev/null @@ -1,156 +0,0 @@ -@php - $appName = config('core.app.name', __('core::core.brand.name')); -@endphp - -
- -
-
-
-
- - - {{ $workspace['name'] }} - -
-

- {{ $workspace['description'] ?? __('tenant::tenant.workspace.welcome') }} -

-

- {{ __('tenant::tenant.workspace.powered_by', ['name' => $appName]) }} -

- -
-
-
- - -
-
- @if($loading) -
-
-
- @else - - @if(!empty($content['posts'])) -
-

{{ __('tenant::tenant.workspace.latest_posts') }}

-
- @foreach($content['posts'] as $post) - - @endforeach -
-
- @endif - - - @if(!empty($content['pages'])) -
-

{{ __('tenant::tenant.workspace.pages') }}

- -
- @endif - - @if(empty($content['posts']) && empty($content['pages'])) -
- Vi with empty folder -

{{ __('tenant::tenant.workspace.no_content.title') }}

-

{{ __('tenant::tenant.workspace.no_content.message') }}

- @auth - - - {{ __('tenant::tenant.workspace.create_content') }} - - @endauth -
- @endif - @endif -
-
- - -
-
-
-

{{ __('tenant::tenant.workspace.part_of_toolkit', ['name' => $appName]) }}

-

{{ __('tenant::tenant.workspace.toolkit_description') }}

-
-
- @php - $services = [ - ['name' => 'BioHost', 'icon' => 'link', 'color' => 'blue', 'slug' => 'link'], - ['name' => 'SocialHost', 'icon' => 'share-nodes', 'color' => 'green', 'slug' => 'social'], - ['name' => 'Analytics', 'icon' => 'chart-line', 'color' => 'yellow', 'slug' => 'analytics'], - ['name' => 'TrustHost', 'icon' => 'shield-check', 'color' => 'orange', 'slug' => 'trust'], - ['name' => 'NotifyHost', 'icon' => 'bell', 'color' => 'red', 'slug' => 'notify'], - ['name' => 'Hestia', 'icon' => 'globe', 'color' => 'violet', 'slug' => 'main'], - ]; - @endphp - @foreach($services as $service) - -
- -
- {{ $service['name'] }} -
- @endforeach -
-
-
-
diff --git a/packages/core-php/src/Mod/Tenant/View/Modal/Admin/EntitlementWebhookManager.php b/packages/core-php/src/Mod/Tenant/View/Modal/Admin/EntitlementWebhookManager.php deleted file mode 100644 index 7e3ea60..0000000 --- a/packages/core-php/src/Mod/Tenant/View/Modal/Admin/EntitlementWebhookManager.php +++ /dev/null @@ -1,356 +0,0 @@ - ['except' => ''], - 'workspaceId' => ['except' => null], - 'statusFilter' => ['except' => ''], - ]; - - protected array $rules = [ - 'name' => 'required|string|max:255', - 'url' => 'required|url|max:2048', - 'events' => 'required|array|min:1', - 'events.*' => 'string', - 'isActive' => 'boolean', - 'maxAttempts' => 'required|integer|min:1|max:10', - ]; - - public function mount(): void - { - if (! auth()->user()?->isHades()) { - abort(403, 'Hades tier required for webhook administration.'); - } - } - - public function updatingSearch(): void - { - $this->resetPage(); - } - - public function updatingWorkspaceId(): void - { - $this->resetPage(); - } - - #[Computed] - public function webhooks() - { - return EntitlementWebhook::query() - ->with('workspace') - ->withCount('deliveries') - ->when($this->workspaceId, fn ($q) => $q->where('workspace_id', $this->workspaceId)) - ->when($this->search, function ($query) { - $query->where(function ($q) { - $q->where('name', 'like', "%{$this->search}%") - ->orWhere('url', 'like', "%{$this->search}%"); - }); - }) - ->when($this->statusFilter === 'active', fn ($q) => $q->active()) - ->when($this->statusFilter === 'inactive', fn ($q) => $q->where('is_active', false)) - ->when($this->statusFilter === 'circuit_broken', fn ($q) => $q->where('failure_count', '>=', EntitlementWebhook::MAX_FAILURES)) - ->latest() - ->paginate(25); - } - - #[Computed] - public function workspaces() - { - return Workspace::query() - ->select('id', 'name', 'slug') - ->orderBy('name') - ->get(); - } - - #[Computed] - public function availableEvents(): array - { - return app(EntitlementWebhookService::class)->getAvailableEvents(); - } - - #[Computed] - public function recentDeliveries() - { - if (! $this->viewingWebhookId) { - return collect(); - } - - return EntitlementWebhookDelivery::query() - ->where('webhook_id', $this->viewingWebhookId) - ->latest('created_at') - ->limit(50) - ->get(); - } - - // ------------------------------------------------------------------------- - // Create/Edit Methods - // ------------------------------------------------------------------------- - - public function create(): void - { - $this->reset(['editingId', 'name', 'url', 'events', 'maxAttempts']); - $this->isActive = true; - $this->maxAttempts = 3; - $this->showFormModal = true; - } - - public function edit(int $id): void - { - $webhook = EntitlementWebhook::findOrFail($id); - - $this->editingId = $webhook->id; - $this->name = $webhook->name; - $this->url = $webhook->url; - $this->events = $webhook->events; - $this->isActive = $webhook->is_active; - $this->maxAttempts = $webhook->max_attempts; - $this->workspaceId = $webhook->workspace_id; - $this->showFormModal = true; - } - - public function save(): void - { - $this->validate(); - - // Filter events to only valid ones - $validEvents = array_intersect($this->events, EntitlementWebhook::EVENTS); - - if (empty($validEvents)) { - $this->addError('events', 'At least one valid event must be selected.'); - - return; - } - - if ($this->editingId) { - $webhook = EntitlementWebhook::findOrFail($this->editingId); - $webhook->update([ - 'name' => $this->name, - 'url' => $this->url, - 'events' => $validEvents, - 'is_active' => $this->isActive, - 'max_attempts' => $this->maxAttempts, - ]); - - $this->setMessage('Webhook updated successfully.'); - } else { - if (! $this->workspaceId) { - $this->addError('workspaceId', 'Please select a workspace.'); - - return; - } - - $workspace = Workspace::findOrFail($this->workspaceId); - $webhook = app(EntitlementWebhookService::class)->register( - workspace: $workspace, - name: $this->name, - url: $this->url, - events: $validEvents - ); - - $webhook->update([ - 'is_active' => $this->isActive, - 'max_attempts' => $this->maxAttempts, - ]); - - // Show the secret to the user - $this->displaySecret = $webhook->secret; - $this->showSecretModal = true; - - $this->setMessage('Webhook created successfully. Please save the secret below.'); - } - - $this->showFormModal = false; - $this->reset(['editingId', 'name', 'url', 'events']); - } - - public function closeFormModal(): void - { - $this->showFormModal = false; - $this->reset(['editingId', 'name', 'url', 'events']); - $this->resetValidation(); - } - - // ------------------------------------------------------------------------- - // Action Methods - // ------------------------------------------------------------------------- - - public function toggleActive(int $id): void - { - $webhook = EntitlementWebhook::findOrFail($id); - $webhook->update(['is_active' => ! $webhook->is_active]); - - $this->setMessage($webhook->is_active ? 'Webhook enabled.' : 'Webhook disabled.'); - } - - public function delete(int $id): void - { - $webhook = EntitlementWebhook::findOrFail($id); - $webhook->delete(); - - $this->setMessage('Webhook deleted.'); - } - - public function testWebhook(int $id): void - { - $webhook = EntitlementWebhook::findOrFail($id); - $delivery = app(EntitlementWebhookService::class)->testWebhook($webhook); - - if ($delivery->isSucceeded()) { - $this->setMessage('Test webhook sent successfully.'); - } else { - $this->setMessage('Test webhook failed. Check delivery history for details.', 'error'); - } - } - - public function regenerateSecret(int $id): void - { - $webhook = EntitlementWebhook::findOrFail($id); - $secret = $webhook->regenerateSecret(); - - $this->displaySecret = $secret; - $this->showSecretModal = true; - } - - public function resetCircuitBreaker(int $id): void - { - $webhook = EntitlementWebhook::findOrFail($id); - app(EntitlementWebhookService::class)->resetCircuitBreaker($webhook); - - $this->setMessage('Webhook re-enabled and failure count reset.'); - } - - // ------------------------------------------------------------------------- - // Deliveries Modal - // ------------------------------------------------------------------------- - - public function viewDeliveries(int $id): void - { - $this->viewingWebhookId = $id; - $this->showDeliveriesModal = true; - } - - public function closeDeliveriesModal(): void - { - $this->showDeliveriesModal = false; - $this->viewingWebhookId = null; - } - - public function retryDelivery(int $deliveryId): void - { - $delivery = EntitlementWebhookDelivery::findOrFail($deliveryId); - - try { - $result = app(EntitlementWebhookService::class)->retryDelivery($delivery); - - if ($result->isSucceeded()) { - $this->setMessage('Delivery retried successfully.'); - } else { - $this->setMessage('Retry failed. Check delivery details.', 'error'); - } - } catch (\Exception $e) { - $this->setMessage($e->getMessage(), 'error'); - } - } - - // ------------------------------------------------------------------------- - // Secret Modal - // ------------------------------------------------------------------------- - - public function closeSecretModal(): void - { - $this->showSecretModal = false; - $this->displaySecret = null; - } - - // ------------------------------------------------------------------------- - // Helper Methods - // ------------------------------------------------------------------------- - - protected function setMessage(string $message, string $type = 'success'): void - { - $this->message = $message; - $this->messageType = $type; - } - - public function clearMessage(): void - { - $this->message = ''; - } - - #[Computed] - public function stats(): array - { - $query = EntitlementWebhook::query(); - - if ($this->workspaceId) { - $query->where('workspace_id', $this->workspaceId); - } - - return [ - 'total' => (clone $query)->count(), - 'active' => (clone $query)->where('is_active', true)->count(), - 'circuit_broken' => (clone $query)->where('failure_count', '>=', EntitlementWebhook::MAX_FAILURES)->count(), - ]; - } - - public function render(): View - { - return view('tenant::admin.entitlement-webhook-manager') - ->layout('hub::admin.layouts.app', ['title' => 'Entitlement Webhooks']); - } -} diff --git a/packages/core-php/src/Mod/Tenant/View/Modal/Admin/WorkspaceDetails.php b/packages/core-php/src/Mod/Tenant/View/Modal/Admin/WorkspaceDetails.php deleted file mode 100644 index 8f0c0c0..0000000 --- a/packages/core-php/src/Mod/Tenant/View/Modal/Admin/WorkspaceDetails.php +++ /dev/null @@ -1,584 +0,0 @@ -user()?->isHades()) { - abort(403, 'Hades tier required for workspace administration.'); - } - - $this->workspace = Workspace::findOrFail($id); - } - - #[Computed] - public function teamMembers() - { - return $this->workspace->users() - ->orderByRaw("FIELD(user_workspace.role, 'owner', 'admin', 'member')") - ->orderBy('name') - ->get(); - } - - #[Computed] - public function availableUsers() - { - $existingIds = $this->workspace->users()->pluck('users.id')->toArray(); - - return User::whereNotIn('id', $existingIds) - ->orderBy('name') - ->get(['id', 'name', 'email']); - } - - #[Computed] - public function resourceCounts(): array - { - $counts = []; - $schema = \Illuminate\Support\Facades\Schema::getFacadeRoot(); - - $resources = [ - ['relation' => 'bioPages', 'label' => 'Bio Pages', 'icon' => 'link', 'color' => 'blue', 'model' => \Core\Mod\Web\Models\Page::class], - ['relation' => 'bioProjects', 'label' => 'Bio Projects', 'icon' => 'folder', 'color' => 'indigo', 'model' => \Core\Mod\Web\Models\Project::class], - ['relation' => 'socialAccounts', 'label' => 'Social Accounts', 'icon' => 'share-nodes', 'color' => 'purple', 'model' => \Core\Mod\Social\Models\Account::class], - ['relation' => 'socialPosts', 'label' => 'Social Posts', 'icon' => 'paper-plane', 'color' => 'pink', 'model' => \Core\Mod\Social\Models\Post::class], - ['relation' => 'analyticsSites', 'label' => 'Analytics Sites', 'icon' => 'chart-line', 'color' => 'cyan', 'model' => \Core\Mod\Analytics\Models\Website::class], - ['relation' => 'trustWidgets', 'label' => 'Trust Campaigns', 'icon' => 'shield-check', 'color' => 'emerald', 'model' => \Core\Mod\Trust\Models\Campaign::class], - ['relation' => 'notificationSites', 'label' => 'Notification Sites', 'icon' => 'bell', 'color' => 'amber', 'model' => \Core\Mod\Notify\Models\PushWebsite::class], - ['relation' => 'contentItems', 'label' => 'Content Items', 'icon' => 'file-lines', 'color' => 'slate', 'model' => \Core\Mod\Content\Models\ContentItem::class], - ['relation' => 'apiKeys', 'label' => 'API Keys', 'icon' => 'key', 'color' => 'rose', 'model' => \Core\Mod\Api\Models\ApiKey::class], - ]; - - foreach ($resources as $resource) { - if (class_exists($resource['model'])) { - try { - $counts[] = [ - 'label' => $resource['label'], - 'icon' => $resource['icon'], - 'color' => $resource['color'], - 'count' => $this->workspace->{$resource['relation']}()->count(), - ]; - } catch (\Exception $e) { - // Skip if relation fails - } - } - } - - return $counts; - } - - #[Computed] - public function recentActivity() - { - $activities = collect(); - - // Entitlement logs - if (class_exists(\Core\Mod\Tenant\Models\EntitlementLog::class)) { - try { - $logs = $this->workspace->entitlementLogs() - ->with('user', 'feature') - ->latest() - ->take(10) - ->get() - ->map(fn ($log) => [ - 'type' => 'entitlement', - 'icon' => $log->action === 'allowed' ? 'check-circle' : 'times-circle', - 'color' => $log->action === 'allowed' ? 'green' : 'red', - 'message' => ($log->user?->name ?? 'System').' '.($log->action === 'allowed' ? 'used' : 'was denied').' '.$log->feature?->name, - 'detail' => $log->reason, - 'created_at' => $log->created_at, - ]); - $activities = $activities->merge($logs); - } catch (\Exception $e) { - // Skip - } - } - - // Usage records - if (class_exists(\Core\Mod\Tenant\Models\UsageRecord::class)) { - try { - $usage = $this->workspace->usageRecords() - ->with('user', 'feature') - ->latest() - ->take(10) - ->get() - ->map(fn ($record) => [ - 'type' => 'usage', - 'icon' => 'chart-bar', - 'color' => 'blue', - 'message' => ($record->user?->name ?? 'System').' used '.$record->quantity.' '.$record->feature?->name, - 'detail' => null, - 'created_at' => $record->created_at, - ]); - $activities = $activities->merge($usage); - } catch (\Exception $e) { - // Skip - } - } - - return $activities->sortByDesc('created_at')->take(15)->values(); - } - - #[Computed] - public function activePackages() - { - return $this->workspace->workspacePackages() - ->with('package') - ->active() - ->get(); - } - - #[Computed] - public function subscriptionInfo(): ?array - { - $subscription = $this->workspace->activeSubscription(); - - if (! $subscription) { - return null; - } - - return [ - 'plan' => $subscription->plan_name ?? 'Unknown', - 'status' => $subscription->status, - 'current_period_end' => $subscription->current_period_end?->format('d M Y'), - 'amount' => $subscription->amount ? number_format($subscription->amount / 100, 2) : null, - 'currency' => $subscription->currency ?? 'GBP', - ]; - } - - public function setTab(string $tab): void - { - $this->activeTab = $tab; - } - - // Team management - - public function openAddMember(): void - { - $this->newMemberId = null; - $this->newMemberRole = 'member'; - $this->showAddMemberModal = true; - } - - public function closeAddMember(): void - { - $this->showAddMemberModal = false; - $this->reset(['newMemberId', 'newMemberRole']); - } - - public function addMember(): void - { - if (! $this->newMemberId) { - $this->actionMessage = 'Please select a user.'; - $this->actionType = 'error'; - - return; - } - - $user = User::findOrFail($this->newMemberId); - - $this->workspace->users()->attach($user->id, ['role' => $this->newMemberRole]); - - $this->closeAddMember(); - $this->actionMessage = "{$user->name} added to workspace as {$this->newMemberRole}."; - $this->actionType = 'success'; - unset($this->teamMembers, $this->availableUsers); - } - - public function openEditMember(int $userId): void - { - $member = $this->workspace->users()->where('user_id', $userId)->first(); - if (! $member) { - return; - } - - $this->editingMemberId = $userId; - $this->editingMemberRole = $member->pivot->role ?? 'member'; - $this->showEditMemberModal = true; - } - - public function closeEditMember(): void - { - $this->showEditMemberModal = false; - $this->reset(['editingMemberId', 'editingMemberRole']); - } - - public function updateMemberRole(): void - { - if (! $this->editingMemberId) { - return; - } - - $this->workspace->users()->updateExistingPivot($this->editingMemberId, [ - 'role' => $this->editingMemberRole, - ]); - - $user = User::find($this->editingMemberId); - $this->closeEditMember(); - $this->actionMessage = "{$user?->name}'s role updated to {$this->editingMemberRole}."; - $this->actionType = 'success'; - unset($this->teamMembers); - } - - public function removeMember(int $userId): void - { - $member = $this->workspace->users()->where('user_id', $userId)->first(); - - if ($member?->pivot?->role === 'owner') { - $this->actionMessage = 'Cannot remove the workspace owner. Transfer ownership first.'; - $this->actionType = 'error'; - - return; - } - - $this->workspace->users()->detach($userId); - - $this->actionMessage = "{$member?->name} removed from workspace."; - $this->actionType = 'success'; - unset($this->teamMembers, $this->availableUsers); - } - - // Domain management - - public function openEditDomain(): void - { - $this->editingDomain = $this->workspace->domain ?? ''; - $this->showEditDomainModal = true; - } - - public function closeEditDomain(): void - { - $this->showEditDomainModal = false; - $this->reset(['editingDomain']); - } - - public function saveDomain(): void - { - $domain = trim($this->editingDomain); - - // Remove protocol if present - $domain = preg_replace('#^https?://#', '', $domain); - $domain = rtrim($domain, '/'); - - $this->workspace->update(['domain' => $domain ?: null]); - $this->workspace->refresh(); - - $this->closeEditDomain(); - $this->actionMessage = $domain ? "Domain updated to {$domain}." : 'Domain removed.'; - $this->actionType = 'success'; - } - - // Entitlements tab - - #[Computed] - public function allPackages() - { - return \Core\Mod\Tenant\Models\Package::active() - ->ordered() - ->get(); - } - - #[Computed] - public function allFeatures() - { - return \Core\Mod\Tenant\Models\Feature::active() - ->orderBy('category') - ->orderBy('sort_order') - ->get(); - } - - #[Computed] - public function activeBoosts() - { - return $this->workspace->boosts() - ->usable() - ->orderBy('feature_code') - ->get(); - } - - #[Computed] - public function entitlementStats(): array - { - $resolved = $this->resolvedEntitlements; - $total = 0; - $allowed = 0; - $denied = 0; - $nearLimit = 0; - - foreach ($resolved as $category => $features) { - foreach ($features as $feature) { - $total++; - if ($feature['allowed']) { - $allowed++; - if ($feature['near_limit']) { - $nearLimit++; - } - } else { - $denied++; - } - } - } - - return [ - 'total' => $total, - 'allowed' => $allowed, - 'denied' => $denied, - 'near_limit' => $nearLimit, - 'packages' => $this->workspacePackages->count(), - 'boosts' => $this->activeBoosts->count(), - ]; - } - - #[Computed] - public function workspacePackages() - { - return $this->workspace->workspacePackages() - ->with(['package.features']) - ->get(); - } - - #[Computed] - public function usageSummary() - { - try { - return $this->workspace->getUsageSummary(); - } catch (\Exception $e) { - return collect(); - } - } - - #[Computed] - public function resolvedEntitlements() - { - try { - return app(\Core\Mod\Tenant\Services\EntitlementService::class) - ->getUsageSummary($this->workspace); - } catch (\Exception $e) { - return collect(); - } - } - - public function openAddPackage(): void - { - $this->selectedPackageId = null; - $this->showAddPackageModal = true; - } - - public function closeAddPackage(): void - { - $this->showAddPackageModal = false; - $this->reset(['selectedPackageId']); - } - - public function addPackage(): void - { - if (! $this->selectedPackageId) { - $this->actionMessage = 'Please select a package.'; - $this->actionType = 'error'; - - return; - } - - $package = \Core\Mod\Tenant\Models\Package::findOrFail($this->selectedPackageId); - - // Check if already assigned - $existing = $this->workspace->workspacePackages() - ->where('package_id', $package->id) - ->where('status', 'active') - ->exists(); - - if ($existing) { - $this->actionMessage = "Package '{$package->name}' is already assigned."; - $this->actionType = 'error'; - - return; - } - - \Core\Mod\Tenant\Models\WorkspacePackage::create([ - 'workspace_id' => $this->workspace->id, - 'package_id' => $package->id, - 'status' => 'active', - 'starts_at' => now(), - ]); - - $this->closeAddPackage(); - $this->actionMessage = "Package '{$package->name}' assigned to workspace."; - $this->actionType = 'success'; - unset($this->workspacePackages, $this->activePackages); - } - - public function removePackage(int $workspacePackageId): void - { - $wp = \Core\Mod\Tenant\Models\WorkspacePackage::where('workspace_id', $this->workspace->id) - ->findOrFail($workspacePackageId); - - $packageName = $wp->package?->name ?? 'Package'; - $wp->delete(); - - $this->actionMessage = "Package '{$packageName}' removed from workspace."; - $this->actionType = 'success'; - unset($this->workspacePackages, $this->activePackages); - } - - public function suspendPackage(int $workspacePackageId): void - { - $wp = \Core\Mod\Tenant\Models\WorkspacePackage::where('workspace_id', $this->workspace->id) - ->findOrFail($workspacePackageId); - - $wp->suspend(); - - $this->actionMessage = "Package '{$wp->package?->name}' suspended."; - $this->actionType = 'warning'; - unset($this->workspacePackages, $this->activePackages); - } - - public function reactivatePackage(int $workspacePackageId): void - { - $wp = \Core\Mod\Tenant\Models\WorkspacePackage::where('workspace_id', $this->workspace->id) - ->findOrFail($workspacePackageId); - - $wp->reactivate(); - - $this->actionMessage = "Package '{$wp->package?->name}' reactivated."; - $this->actionType = 'success'; - unset($this->workspacePackages, $this->activePackages); - } - - // Entitlement (Boost) management - - public function openAddEntitlement(): void - { - $this->selectedFeatureCode = null; - $this->entitlementType = 'enable'; - $this->entitlementLimit = null; - $this->entitlementDuration = 'permanent'; - $this->entitlementExpiresAt = null; - $this->showAddEntitlementModal = true; - } - - public function closeAddEntitlement(): void - { - $this->showAddEntitlementModal = false; - $this->reset(['selectedFeatureCode', 'entitlementType', 'entitlementLimit', 'entitlementDuration', 'entitlementExpiresAt']); - } - - public function addEntitlement(): void - { - if (! $this->selectedFeatureCode) { - $this->actionMessage = 'Please select a feature.'; - $this->actionType = 'error'; - - return; - } - - $feature = \Core\Mod\Tenant\Models\Feature::where('code', $this->selectedFeatureCode)->first(); - - if (! $feature) { - $this->actionMessage = 'Feature not found.'; - $this->actionType = 'error'; - - return; - } - - // Map type to boost type constant - $boostType = match ($this->entitlementType) { - 'enable' => \Core\Mod\Tenant\Models\Boost::BOOST_TYPE_ENABLE, - 'add_limit' => \Core\Mod\Tenant\Models\Boost::BOOST_TYPE_ADD_LIMIT, - 'unlimited' => \Core\Mod\Tenant\Models\Boost::BOOST_TYPE_UNLIMITED, - default => \Core\Mod\Tenant\Models\Boost::BOOST_TYPE_ENABLE, - }; - - $durationType = $this->entitlementDuration === 'permanent' - ? \Core\Mod\Tenant\Models\Boost::DURATION_PERMANENT - : \Core\Mod\Tenant\Models\Boost::DURATION_DURATION; - - \Core\Mod\Tenant\Models\Boost::create([ - 'workspace_id' => $this->workspace->id, - 'feature_code' => $this->selectedFeatureCode, - 'boost_type' => $boostType, - 'duration_type' => $durationType, - 'limit_value' => $this->entitlementType === 'add_limit' ? $this->entitlementLimit : null, - 'consumed_quantity' => 0, - 'status' => \Core\Mod\Tenant\Models\Boost::STATUS_ACTIVE, - 'starts_at' => now(), - 'expires_at' => $this->entitlementExpiresAt ? \Carbon\Carbon::parse($this->entitlementExpiresAt) : null, - 'metadata' => ['granted_by' => auth()->id(), 'granted_at' => now()->toDateTimeString()], - ]); - - $this->closeAddEntitlement(); - $this->actionMessage = "Entitlement '{$feature->name}' granted to workspace."; - $this->actionType = 'success'; - unset($this->activeBoosts, $this->resolvedEntitlements, $this->entitlementStats); - } - - public function removeBoost(int $boostId): void - { - $boost = \Core\Mod\Tenant\Models\Boost::where('workspace_id', $this->workspace->id) - ->findOrFail($boostId); - - $featureCode = $boost->feature_code; - $boost->cancel(); - - $this->actionMessage = "Entitlement '{$featureCode}' removed."; - $this->actionType = 'success'; - unset($this->activeBoosts, $this->resolvedEntitlements, $this->entitlementStats); - } - - public function render() - { - return view('tenant::admin.workspace-details') - ->layout('hub::admin.layouts.app', ['title' => 'Workspace: '.$this->workspace->name]); - } -} diff --git a/packages/core-php/src/Mod/Tenant/View/Modal/Admin/WorkspaceManager.php b/packages/core-php/src/Mod/Tenant/View/Modal/Admin/WorkspaceManager.php deleted file mode 100644 index f01fa04..0000000 --- a/packages/core-php/src/Mod/Tenant/View/Modal/Admin/WorkspaceManager.php +++ /dev/null @@ -1,666 +0,0 @@ - ['except' => ''], - ]; - - protected array $rules = [ - 'name' => 'required|string|max:255', - 'slug' => 'required|string|max:255|alpha_dash', - 'isActive' => 'boolean', - ]; - - public function mount(): void - { - if (! auth()->user()?->isHades()) { - abort(403, 'Hades tier required for workspace administration.'); - } - } - - public function updatingSearch(): void - { - $this->resetPage(); - } - - #[Computed] - public function workspaces() - { - return Workspace::query() - ->withCount($this->getAvailableRelations()) - ->when($this->search, function ($query) { - $query->where(function ($q) { - $q->where('name', 'like', "%{$this->search}%") - ->orWhere('slug', 'like', "%{$this->search}%"); - }); - }) - ->orderBy('name') - ->paginate(20); - } - - /** - * Get relations that are available for counting. - * Filters out relations whose models don't exist yet or have incompatible schemas. - */ - protected function getAvailableRelations(): array - { - $relations = []; - - // Check each relation's model exists and has workspace_id column - $checks = [ - 'bioPages' => ['model' => \Core\Mod\Web\Models\Page::class, 'table' => 'pages'], - 'bioProjects' => ['model' => \Core\Mod\Web\Models\Project::class, 'table' => 'page_projects'], - 'socialAccounts' => ['model' => \Core\Mod\Social\Models\Account::class, 'table' => 'social_accounts'], - 'analyticsSites' => ['model' => \Core\Mod\Analytics\Models\Website::class, 'table' => 'analytics_websites'], - 'trustWidgets' => ['model' => \Core\Mod\Trust\Models\Campaign::class, 'table' => 'trust_campaigns'], - 'notificationSites' => ['model' => \Core\Mod\Notify\Models\PushWebsite::class, 'table' => 'push_websites'], - ]; - - $schema = \Illuminate\Support\Facades\Schema::getFacadeRoot(); - - foreach ($checks as $relation => $info) { - if (class_exists($info['model'])) { - // Verify the table has workspace_id column - try { - if ($schema->hasColumn($info['table'], 'workspace_id')) { - $relations[] = $relation; - } - } catch (\Exception $e) { - // Table might not exist yet, skip - } - } - } - - return $relations; - } - - #[Computed] - public function allWorkspaces() - { - return Workspace::orderBy('name')->get(['id', 'name', 'slug']); - } - - #[Computed] - public function resourceTypes(): array - { - $types = []; - $schema = \Illuminate\Support\Facades\Schema::getFacadeRoot(); - - // Only include resource types for models that exist and have valid relations - $checks = [ - 'bio_pages' => ['model' => \Core\Mod\Web\Models\Page::class, 'table' => 'pages', 'label' => 'Bio Pages', 'relation' => 'bioPages', 'icon' => 'link'], - 'bio_projects' => ['model' => \Core\Mod\Web\Models\Project::class, 'table' => 'page_projects', 'label' => 'Bio Projects', 'relation' => 'bioProjects', 'icon' => 'folder'], - 'social_accounts' => ['model' => \Core\Mod\Social\Models\Account::class, 'table' => 'social_accounts', 'label' => 'Social Accounts', 'relation' => 'socialAccounts', 'icon' => 'share-nodes'], - 'analytics_sites' => ['model' => \Core\Mod\Analytics\Models\Website::class, 'table' => 'analytics_websites', 'label' => 'Analytics Sites', 'relation' => 'analyticsSites', 'icon' => 'chart-line'], - 'trust_widgets' => ['model' => \Core\Mod\Trust\Models\Campaign::class, 'table' => 'trust_campaigns', 'label' => 'Trust Campaigns', 'relation' => 'trustWidgets', 'icon' => 'shield-check'], - 'notification_sites' => ['model' => \Core\Mod\Notify\Models\PushWebsite::class, 'table' => 'push_websites', 'label' => 'Notification Sites', 'relation' => 'notificationSites', 'icon' => 'bell'], - ]; - - foreach ($checks as $key => $info) { - if (class_exists($info['model'])) { - try { - if ($schema->hasColumn($info['table'], 'workspace_id')) { - $types[$key] = [ - 'label' => $info['label'], - 'relation' => $info['relation'], - 'icon' => $info['icon'], - ]; - } - } catch (\Exception $e) { - // Table might not exist yet, skip - } - } - } - - return $types; - } - - public function openEdit(int $id): void - { - $workspace = Workspace::findOrFail($id); - $this->editingId = $id; - $this->name = $workspace->name; - $this->slug = $workspace->slug; - $this->isActive = $workspace->is_active; - } - - public function closeEdit(): void - { - $this->editingId = null; - $this->reset(['name', 'slug', 'isActive']); - $this->resetErrorBag(); - } - - public function save(): void - { - $this->validate(); - - $workspace = Workspace::findOrFail($this->editingId); - - // Check if slug is unique (excluding current workspace) - $slugExists = Workspace::where('slug', $this->slug) - ->where('id', '!=', $this->editingId) - ->exists(); - - if ($slugExists) { - $this->addError('slug', 'This slug is already in use.'); - - return; - } - - $workspace->update([ - 'name' => $this->name, - 'slug' => $this->slug, - 'is_active' => $this->isActive, - ]); - - $this->closeEdit(); - $this->actionMessage = "Workspace '{$workspace->name}' updated successfully."; - $this->actionType = 'success'; - unset($this->workspaces); - } - - public function delete(int $id): void - { - $workspace = Workspace::withCount($this->getAvailableRelations())->findOrFail($id); - - // Check for resources (safely get counts that might not exist) - $totalResources = ($workspace->bio_pages_count ?? 0) - + ($workspace->bio_projects_count ?? 0) - + ($workspace->social_accounts_count ?? 0) - + ($workspace->analytics_sites_count ?? 0) - + ($workspace->trust_widgets_count ?? 0) - + ($workspace->notification_sites_count ?? 0) - + ($workspace->orders_count ?? 0); - - if ($totalResources > 0) { - $this->actionMessage = "Cannot delete workspace '{$workspace->name}'. It has {$totalResources} resources. Transfer or delete them first."; - $this->actionType = 'error'; - - return; - } - - // Check for users - if ($workspace->users()->count() > 0) { - $this->actionMessage = "Cannot delete workspace '{$workspace->name}'. It still has users assigned."; - $this->actionType = 'error'; - - return; - } - - $workspaceName = $workspace->name; - $workspace->delete(); - - $this->actionMessage = "Workspace '{$workspaceName}' deleted successfully."; - $this->actionType = 'success'; - unset($this->workspaces); - } - - public function openTransfer(int $workspaceId): void - { - $this->sourceWorkspaceId = $workspaceId; - $this->targetWorkspaceId = null; - $this->selectedResourceTypes = []; - $this->showTransferModal = true; - } - - public function closeTransfer(): void - { - $this->showTransferModal = false; - $this->reset(['sourceWorkspaceId', 'targetWorkspaceId', 'selectedResourceTypes']); - } - - public function executeTransfer(): void - { - if (! $this->sourceWorkspaceId || ! $this->targetWorkspaceId) { - $this->actionMessage = 'Please select both source and target workspaces.'; - $this->actionType = 'error'; - - return; - } - - if ($this->sourceWorkspaceId === $this->targetWorkspaceId) { - $this->actionMessage = 'Source and target workspaces cannot be the same.'; - $this->actionType = 'error'; - - return; - } - - if (empty($this->selectedResourceTypes)) { - $this->actionMessage = 'Please select at least one resource type to transfer.'; - $this->actionType = 'error'; - - return; - } - - $source = Workspace::findOrFail($this->sourceWorkspaceId); - $target = Workspace::findOrFail($this->targetWorkspaceId); - $resourceTypes = $this->resourceTypes; - $transferred = []; - - DB::transaction(function () use ($source, $target, $resourceTypes, &$transferred) { - foreach ($this->selectedResourceTypes as $type) { - if (! isset($resourceTypes[$type])) { - continue; - } - - $relation = $resourceTypes[$type]['relation']; - $count = $source->{$relation}()->count(); - - if ($count > 0) { - $source->{$relation}()->update(['workspace_id' => $target->id]); - $transferred[$resourceTypes[$type]['label']] = $count; - } - } - }); - - $this->closeTransfer(); - - if (empty($transferred)) { - $this->actionMessage = 'No resources were transferred (source had no resources of selected types).'; - $this->actionType = 'warning'; - } else { - $summary = collect($transferred) - ->map(fn ($count, $label) => "{$count} {$label}") - ->join(', '); - $this->actionMessage = "Transferred {$summary} from '{$source->name}' to '{$target->name}'."; - $this->actionType = 'success'; - } - - unset($this->workspaces); - } - - #[Computed] - public function allUsers() - { - return User::orderBy('name')->get(['id', 'name', 'email']); - } - - public function openChangeOwner(int $workspaceId): void - { - $workspace = Workspace::findOrFail($workspaceId); - $this->ownerWorkspaceId = $workspaceId; - $this->newOwnerId = $workspace->owner()?->id; - $this->showOwnerModal = true; - } - - public function closeChangeOwner(): void - { - $this->showOwnerModal = false; - $this->reset(['ownerWorkspaceId', 'newOwnerId']); - } - - public function changeOwner(): void - { - if (! $this->ownerWorkspaceId || ! $this->newOwnerId) { - $this->actionMessage = 'Please select a new owner.'; - $this->actionType = 'error'; - - return; - } - - $workspace = Workspace::findOrFail($this->ownerWorkspaceId); - $newOwner = User::findOrFail($this->newOwnerId); - $oldOwner = $workspace->owner(); - - DB::transaction(function () use ($workspace, $newOwner, $oldOwner) { - // Remove owner role from current owner (if exists) - if ($oldOwner) { - $workspace->users()->updateExistingPivot($oldOwner->id, ['role' => 'member']); - } - - // Check if new owner is already a member - if ($workspace->users()->where('user_id', $newOwner->id)->exists()) { - // Update existing membership to owner - $workspace->users()->updateExistingPivot($newOwner->id, ['role' => 'owner']); - } else { - // Add new owner to workspace - $workspace->users()->attach($newOwner->id, ['role' => 'owner']); - } - }); - - $this->closeChangeOwner(); - $this->actionMessage = "Ownership of '{$workspace->name}' transferred to {$newOwner->name}."; - $this->actionType = 'success'; - unset($this->workspaces); - } - - public function openResources(int $workspaceId, string $type): void - { - $this->resourcesWorkspaceId = $workspaceId; - $this->resourcesType = $type; - $this->selectedResources = []; - $this->resourcesTargetWorkspaceId = null; - $this->showResourcesModal = true; - } - - public function closeResources(): void - { - $this->showResourcesModal = false; - $this->reset(['resourcesWorkspaceId', 'resourcesType', 'selectedResources', 'resourcesTargetWorkspaceId']); - } - - #[Computed] - public function currentResources(): array - { - if (! $this->resourcesWorkspaceId || ! $this->resourcesType) { - return []; - } - - $resourceTypes = $this->resourceTypes; - if (! isset($resourceTypes[$this->resourcesType])) { - return []; - } - - $workspace = Workspace::find($this->resourcesWorkspaceId); - if (! $workspace) { - return []; - } - - $relation = $resourceTypes[$this->resourcesType]['relation']; - - return $workspace->{$relation}() - ->get() - ->map(function ($item) { - return [ - 'id' => $item->id, - 'name' => $item->name ?? $item->title ?? "#{$item->id}", - 'detail' => $item->url ?? $item->domain ?? $item->email ?? $item->slug ?? null, - 'created_at' => $item->created_at?->format('d M Y'), - ]; - }) - ->toArray(); - } - - public function toggleResourceSelection(int $id): void - { - if (in_array($id, $this->selectedResources)) { - $this->selectedResources = array_values(array_diff($this->selectedResources, [$id])); - } else { - $this->selectedResources[] = $id; - } - } - - public function selectAllResources(): void - { - $this->selectedResources = collect($this->currentResources)->pluck('id')->toArray(); - } - - public function deselectAllResources(): void - { - $this->selectedResources = []; - } - - public function transferSelectedResources(): void - { - if (empty($this->selectedResources)) { - $this->actionMessage = 'Please select at least one resource to transfer.'; - $this->actionType = 'error'; - - return; - } - - if (! $this->resourcesTargetWorkspaceId) { - $this->actionMessage = 'Please select a target workspace.'; - $this->actionType = 'error'; - - return; - } - - if ($this->resourcesWorkspaceId === $this->resourcesTargetWorkspaceId) { - $this->actionMessage = 'Source and target workspaces cannot be the same.'; - $this->actionType = 'error'; - - return; - } - - $resourceTypes = $this->resourceTypes; - if (! isset($resourceTypes[$this->resourcesType])) { - $this->actionMessage = 'Invalid resource type.'; - $this->actionType = 'error'; - - return; - } - - $workspace = Workspace::findOrFail($this->resourcesWorkspaceId); - $target = Workspace::findOrFail($this->resourcesTargetWorkspaceId); - $relation = $resourceTypes[$this->resourcesType]['relation']; - $label = $resourceTypes[$this->resourcesType]['label']; - - $count = $workspace->{$relation}() - ->whereIn('id', $this->selectedResources) - ->update(['workspace_id' => $target->id]); - - $this->closeResources(); - $this->actionMessage = "Transferred {$count} {$label} from '{$workspace->name}' to '{$target->name}'."; - $this->actionType = 'success'; - unset($this->workspaces); - } - - public function openProvision(int $workspaceId, string $type): void - { - $this->provisionWorkspaceId = $workspaceId; - $this->provisionType = $type; - $this->provisionName = ''; - $this->provisionUrl = ''; - $this->showProvisionModal = true; - } - - public function closeProvision(): void - { - $this->showProvisionModal = false; - $this->reset(['provisionWorkspaceId', 'provisionType', 'provisionName', 'provisionUrl', 'provisionSlug']); - } - - #[Computed] - public function provisionConfig(): array - { - return [ - 'bio_pages' => [ - 'label' => 'Bio Page', - 'icon' => 'link', - 'color' => 'blue', - 'fields' => ['name', 'slug'], - 'model' => \Core\Mod\Web\Models\Page::class, - 'defaults' => ['type' => 'page', 'is_enabled' => true], - ], - 'social_accounts' => [ - 'label' => 'Social Account', - 'icon' => 'share-nodes', - 'color' => 'purple', - 'fields' => ['name'], - 'model' => \Core\Mod\Social\Models\Account::class, - 'defaults' => ['provider' => 'manual', 'status' => 'active'], - ], - 'analytics_sites' => [ - 'label' => 'Analytics Site', - 'icon' => 'chart-line', - 'color' => 'cyan', - 'fields' => ['name', 'url'], - 'model' => \Core\Mod\Analytics\Models\Website::class, - 'defaults' => ['tracking_enabled' => true, 'is_enabled' => true], - ], - 'trust_widgets' => [ - 'label' => 'Trust Campaign', - 'icon' => 'shield-check', - 'color' => 'emerald', - 'fields' => ['name'], - 'model' => \Core\Mod\Trust\Models\Campaign::class, - 'defaults' => ['status' => 'draft'], - ], - 'notification_sites' => [ - 'label' => 'Notification Site', - 'icon' => 'bell', - 'color' => 'amber', - 'fields' => ['name', 'url'], - 'model' => \Core\Mod\Notify\Models\PushWebsite::class, - 'defaults' => ['status' => 'active'], - ], - ]; - } - - public function provisionResource(): void - { - $config = $this->provisionConfig[$this->provisionType] ?? null; - - if (! $config || ! class_exists($config['model'])) { - $this->actionMessage = 'Invalid resource type or model not available.'; - $this->actionType = 'error'; - - return; - } - - if (empty($this->provisionName)) { - $this->actionMessage = 'Please enter a name.'; - $this->actionType = 'error'; - - return; - } - - if (in_array('url', $config['fields']) && empty($this->provisionUrl)) { - $this->actionMessage = 'Please enter a URL.'; - $this->actionType = 'error'; - - return; - } - - if (in_array('slug', $config['fields']) && empty($this->provisionSlug)) { - $this->actionMessage = 'Please enter a slug.'; - $this->actionType = 'error'; - - return; - } - - $workspace = Workspace::findOrFail($this->provisionWorkspaceId); - - $data = array_merge($config['defaults'], [ - 'workspace_id' => $workspace->id, - ]); - - // Handle name - for bio pages it goes in settings - if ($this->provisionType === 'bio_pages') { - $data['settings'] = ['page_title' => $this->provisionName]; - } else { - $data['name'] = $this->provisionName; - } - - // Add slug for bio pages - if (in_array('slug', $config['fields']) && $this->provisionSlug) { - $data['url'] = \Illuminate\Support\Str::slug($this->provisionSlug); - } - - // Add URL-related fields if applicable - if (in_array('url', $config['fields']) && $this->provisionUrl) { - $url = $this->provisionUrl; - if (! str_starts_with($url, 'http://') && ! str_starts_with($url, 'https://')) { - $url = 'https://'.$url; - } - $parsed = parse_url($url); - $data['url'] = $url; - $data['host'] = $parsed['host'] ?? null; - $data['scheme'] = $parsed['scheme'] ?? 'https'; - } - - // Add user_id if the model expects it - if (auth()->check()) { - $data['user_id'] = auth()->id(); - } - - try { - $config['model']::create($data); - - $this->closeProvision(); - $this->actionMessage = "{$config['label']} '{$this->provisionName}' created in '{$workspace->name}'."; - $this->actionType = 'success'; - unset($this->workspaces); - } catch (\Exception $e) { - $this->actionMessage = "Failed to create resource: {$e->getMessage()}"; - $this->actionType = 'error'; - } - } - - public function getStats(): array - { - return [ - 'total' => Workspace::count(), - 'active' => Workspace::where('is_active', true)->count(), - 'inactive' => Workspace::where('is_active', false)->count(), - ]; - } - - public function render() - { - return view('tenant::admin.workspace-manager', [ - 'stats' => $this->getStats(), - ])->layout('hub::admin.layouts.app', ['title' => 'Workspace Manager']); - } -} diff --git a/packages/core-php/src/Mod/Tenant/View/Modal/Web/CancelDeletion.php b/packages/core-php/src/Mod/Tenant/View/Modal/Web/CancelDeletion.php deleted file mode 100644 index 5b838fc..0000000 --- a/packages/core-php/src/Mod/Tenant/View/Modal/Web/CancelDeletion.php +++ /dev/null @@ -1,36 +0,0 @@ -token = $token; - $deletionRequest = AccountDeletionRequest::findValidByToken($token); - - if (! $deletionRequest) { - $this->status = 'invalid'; - - return; - } - - // Cancel the deletion request - $deletionRequest->cancel(); - $this->status = 'success'; - } - - public function render() - { - return view('tenant::web.account.cancel-deletion'); - } -} diff --git a/packages/core-php/src/Mod/Tenant/View/Modal/Web/ConfirmDeletion.php b/packages/core-php/src/Mod/Tenant/View/Modal/Web/ConfirmDeletion.php deleted file mode 100644 index 6cc7512..0000000 --- a/packages/core-php/src/Mod/Tenant/View/Modal/Web/ConfirmDeletion.php +++ /dev/null @@ -1,116 +0,0 @@ -token = $token; - $this->deletionRequest = AccountDeletionRequest::findValidByToken($token); - - if (! $this->deletionRequest) { - $this->step = 'invalid'; - - return; - } - - $this->userName = $this->deletionRequest->user->name; - - // Even if logged in, require re-authentication for security - $this->step = 'verify'; - } - - public function verifyPassword(): void - { - $this->error = ''; - - if (! $this->deletionRequest || ! $this->deletionRequest->isActive()) { - $this->step = 'invalid'; - - return; - } - - $user = $this->deletionRequest->user; - - if (! Hash::check($this->password, $user->password)) { - $this->error = 'The password you entered is incorrect.'; - - return; - } - - // Log the user in for this session - Auth::login($user); - $this->step = 'confirm'; - } - - public function confirmDeletion(): void - { - if (! $this->deletionRequest || ! $this->deletionRequest->isActive()) { - $this->step = 'invalid'; - - return; - } - - $this->step = 'deleting'; - - // Process deletion in background after animation starts - $this->dispatch('start-deletion'); - } - - public function executeDelete(): void - { - if (! $this->deletionRequest || ! $this->deletionRequest->isActive()) { - return; - } - - $user = $this->deletionRequest->user; - - DB::transaction(function () use ($user) { - // Mark request as confirmed and completed - $this->deletionRequest->confirm(); - $this->deletionRequest->complete(); - - // Delete all workspaces owned by the user - if (method_exists($user, 'ownedWorkspaces')) { - $user->ownedWorkspaces()->each(function ($workspace) { - $workspace->delete(); - }); - } - - // Hard delete user account - $user->forceDelete(); - }); - - Auth::logout(); - session()->invalidate(); - session()->regenerateToken(); - - $this->step = 'goodbye'; - } - - public function render() - { - return view('tenant::web.account.confirm-deletion'); - } -} diff --git a/packages/core-php/src/Mod/Tenant/View/Modal/Web/WorkspaceHome.php b/packages/core-php/src/Mod/Tenant/View/Modal/Web/WorkspaceHome.php deleted file mode 100644 index 7176030..0000000 --- a/packages/core-php/src/Mod/Tenant/View/Modal/Web/WorkspaceHome.php +++ /dev/null @@ -1,67 +0,0 @@ -attributes->get('workspace', 'main'); - - $this->workspace = $workspaceService->get($slug) ?? $workspaceService->get('main'); - - // Load workspace content from native content - $this->loadContent(); - } - - protected function loadContent(): void - { - try { - $workspaceModel = Workspace::where('slug', $this->workspace['slug'])->first(); - if (! $workspaceModel) { - $this->content = ['posts' => [], 'pages' => []]; - $this->loading = false; - - return; - } - - $render = app(ContentRender::class); - $homepage = $render->getHomepage($workspaceModel); - - $this->content = [ - 'posts' => $homepage['posts'] ?? [], - 'pages' => [], // Pages not included in homepage response - ]; - } catch (\Exception $e) { - $this->content = [ - 'posts' => [], - 'pages' => [], - ]; - } - - $this->loading = false; - } - - public function render() - { - return view('tenant::web.workspace.home') - ->layout('components.layouts.workspace', [ - 'title' => $this->workspace['name'].' | Host UK', - 'workspace' => $this->workspace, - ]); - } -} diff --git a/packages/core-php/tests/TestCase.php b/packages/core-php/tests/TestCase.php deleted file mode 100644 index 6f1c046..0000000 --- a/packages/core-php/tests/TestCase.php +++ /dev/null @@ -1,29 +0,0 @@ -set('app.path', $this->getFixturePath()); - } - - protected function getFixturePath(string $path = ''): string - { - return __DIR__.'/Fixtures'.($path ? "/{$path}" : ''); - } -} diff --git a/phpunit.xml b/phpunit.xml index 12bb738..e6d3db7 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -13,30 +13,14 @@ tests/Feature - packages/core-php/tests/Feature - packages/core-php/src/Core/**/Tests/Feature - packages/core-php/src/Mod/**/Tests/Feature - packages/core-admin/tests/Feature - packages/core-api/tests/Feature - packages/core-mcp/tests/Feature tests/Unit - packages/core-php/tests/Unit - packages/core-php/src/Core/**/Tests/Unit - packages/core-php/src/Mod/**/Tests/Unit - packages/core-admin/tests/Unit - packages/core-api/tests/Unit - packages/core-mcp/tests/Unit - app - packages/core-php/src - packages/core-admin/src - packages/core-api/src - packages/core-mcp/src + src diff --git a/public/index.php b/public/index.php deleted file mode 100644 index 111d2fc..0000000 --- a/public/index.php +++ /dev/null @@ -1,9 +0,0 @@ - li { - position: relative; -} - -.fa-li { - inset-inline-start: calc(-1 * var(--fa-li-width, 2em)); - position: absolute; - text-align: center; - width: var(--fa-li-width, 2em); - line-height: inherit; -} - -/* Heads Up: Bordered Icons will not be supported in the future! - - This feature will be deprecated in the next major release of Font Awesome (v8)! - - You may continue to use it in this version *v7), but it will not be supported in Font Awesome v8. -*/ -/* Notes: -* --@{v.$css-prefix}-border-width = 1/16 by default (to render as ~1px based on a 16px default font-size) -* --@{v.$css-prefix}-border-padding = - ** 3/16 for vertical padding (to give ~2px of vertical whitespace around an icon considering it's vertical alignment) - ** 4/16 for horizontal padding (to give ~4px of horizontal whitespace around an icon) -*/ -.fa-border { - border-color: var(--fa-border-color, #eee); - border-radius: var(--fa-border-radius, 0.1em); - border-style: var(--fa-border-style, solid); - border-width: var(--fa-border-width, 0.0625em); - box-sizing: var(--fa-border-box-sizing, content-box); - padding: var(--fa-border-padding, 0.1875em 0.25em); -} - -.fa-pull-left, -.fa-pull-start { - float: inline-start; - margin-inline-end: var(--fa-pull-margin, 0.3em); -} - -.fa-pull-right, -.fa-pull-end { - float: inline-end; - margin-inline-start: var(--fa-pull-margin, 0.3em); -} - -.fa-beat { - animation-name: fa-beat; - animation-delay: var(--fa-animation-delay, 0s); - animation-direction: var(--fa-animation-direction, normal); - animation-duration: var(--fa-animation-duration, 1s); - animation-iteration-count: var(--fa-animation-iteration-count, infinite); - animation-timing-function: var(--fa-animation-timing, ease-in-out); -} - -.fa-bounce { - animation-name: fa-bounce; - animation-delay: var(--fa-animation-delay, 0s); - animation-direction: var(--fa-animation-direction, normal); - animation-duration: var(--fa-animation-duration, 1s); - animation-iteration-count: var(--fa-animation-iteration-count, infinite); - animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.28, 0.84, 0.42, 1)); -} - -.fa-fade { - animation-name: fa-fade; - animation-delay: var(--fa-animation-delay, 0s); - animation-direction: var(--fa-animation-direction, normal); - animation-duration: var(--fa-animation-duration, 1s); - animation-iteration-count: var(--fa-animation-iteration-count, infinite); - animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1)); -} - -.fa-beat-fade { - animation-name: fa-beat-fade; - animation-delay: var(--fa-animation-delay, 0s); - animation-direction: var(--fa-animation-direction, normal); - animation-duration: var(--fa-animation-duration, 1s); - animation-iteration-count: var(--fa-animation-iteration-count, infinite); - animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1)); -} - -.fa-flip { - animation-name: fa-flip; - animation-delay: var(--fa-animation-delay, 0s); - animation-direction: var(--fa-animation-direction, normal); - animation-duration: var(--fa-animation-duration, 1s); - animation-iteration-count: var(--fa-animation-iteration-count, infinite); - animation-timing-function: var(--fa-animation-timing, ease-in-out); -} - -.fa-shake { - animation-name: fa-shake; - animation-delay: var(--fa-animation-delay, 0s); - animation-direction: var(--fa-animation-direction, normal); - animation-duration: var(--fa-animation-duration, 1s); - animation-iteration-count: var(--fa-animation-iteration-count, infinite); - animation-timing-function: var(--fa-animation-timing, linear); -} - -.fa-spin { - animation-name: fa-spin; - animation-delay: var(--fa-animation-delay, 0s); - animation-direction: var(--fa-animation-direction, normal); - animation-duration: var(--fa-animation-duration, 2s); - animation-iteration-count: var(--fa-animation-iteration-count, infinite); - animation-timing-function: var(--fa-animation-timing, linear); -} - -.fa-spin-reverse { - --fa-animation-direction: reverse; -} - -.fa-pulse, -.fa-spin-pulse { - animation-name: fa-spin; - animation-direction: var(--fa-animation-direction, normal); - animation-duration: var(--fa-animation-duration, 1s); - animation-iteration-count: var(--fa-animation-iteration-count, infinite); - animation-timing-function: var(--fa-animation-timing, steps(8)); -} - -@media (prefers-reduced-motion: reduce) { - .fa-beat, - .fa-bounce, - .fa-fade, - .fa-beat-fade, - .fa-flip, - .fa-pulse, - .fa-shake, - .fa-spin, - .fa-spin-pulse { - animation: none !important; - transition: none !important; - } -} -@keyframes fa-beat { - 0%, 90% { - transform: scale(1); - } - 45% { - transform: scale(var(--fa-beat-scale, 1.25)); - } -} -@keyframes fa-bounce { - 0% { - transform: scale(1, 1) translateY(0); - } - 10% { - transform: scale(var(--fa-bounce-start-scale-x, 1.1), var(--fa-bounce-start-scale-y, 0.9)) translateY(0); - } - 30% { - transform: scale(var(--fa-bounce-jump-scale-x, 0.9), var(--fa-bounce-jump-scale-y, 1.1)) translateY(var(--fa-bounce-height, -0.5em)); - } - 50% { - transform: scale(var(--fa-bounce-land-scale-x, 1.05), var(--fa-bounce-land-scale-y, 0.95)) translateY(0); - } - 57% { - transform: scale(1, 1) translateY(var(--fa-bounce-rebound, -0.125em)); - } - 64% { - transform: scale(1, 1) translateY(0); - } - 100% { - transform: scale(1, 1) translateY(0); - } -} -@keyframes fa-fade { - 50% { - opacity: var(--fa-fade-opacity, 0.4); - } -} -@keyframes fa-beat-fade { - 0%, 100% { - opacity: var(--fa-beat-fade-opacity, 0.4); - transform: scale(1); - } - 50% { - opacity: 1; - transform: scale(var(--fa-beat-fade-scale, 1.125)); - } -} -@keyframes fa-flip { - 50% { - transform: rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), var(--fa-flip-angle, -180deg)); - } -} -@keyframes fa-shake { - 0% { - transform: rotate(-15deg); - } - 4% { - transform: rotate(15deg); - } - 8%, 24% { - transform: rotate(-18deg); - } - 12%, 28% { - transform: rotate(18deg); - } - 16% { - transform: rotate(-22deg); - } - 20% { - transform: rotate(22deg); - } - 32% { - transform: rotate(-12deg); - } - 36% { - transform: rotate(12deg); - } - 40%, 100% { - transform: rotate(0deg); - } -} -@keyframes fa-spin { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } -} -.fa-rotate-90 { - transform: rotate(90deg); -} - -.fa-rotate-180 { - transform: rotate(180deg); -} - -.fa-rotate-270 { - transform: rotate(270deg); -} - -.fa-flip-horizontal { - transform: scale(-1, 1); -} - -.fa-flip-vertical { - transform: scale(1, -1); -} - -.fa-flip-both, -.fa-flip-horizontal.fa-flip-vertical { - transform: scale(-1, -1); -} - -.fa-rotate-by { - transform: rotate(var(--fa-rotate-angle, 0)); -} - -.fa-stack { - display: inline-block; - height: 2em; - line-height: 2em; - position: relative; - vertical-align: middle; - width: 2.5em; -} - -.fa-stack-1x, -.fa-stack-2x { - --fa-width: 100%; - inset: 0; - position: absolute; - text-align: center; - width: var(--fa-width); - z-index: var(--fa-stack-z-index, auto); -} - -.fa-stack-1x { - line-height: inherit; -} - -.fa-stack-2x { - font-size: 2em; -} - -.fa-inverse { - color: var(--fa-inverse, #fff); -} - -/* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen - readers do not read off random characters that represent icons */ - -.fa-0 { - --fa: "\30 "; -} - -.fa-1 { - --fa: "\31 "; -} - -.fa-2 { - --fa: "\32 "; -} - -.fa-3 { - --fa: "\33 "; -} - -.fa-4 { - --fa: "\34 "; -} - -.fa-5 { - --fa: "\35 "; -} - -.fa-6 { - --fa: "\36 "; -} - -.fa-7 { - --fa: "\37 "; -} - -.fa-8 { - --fa: "\38 "; -} - -.fa-9 { - --fa: "\39 "; -} - -.fa-exclamation { - --fa: "\!"; -} - -.fa-ditto { - --fa: "\""; -} - -.fa-hashtag { - --fa: "\#"; -} - -.fa-dollar-sign { - --fa: "\$"; -} - -.fa-dollar { - --fa: "\$"; -} - -.fa-usd { - --fa: "\$"; -} - -.fa-percent { - --fa: "\%"; -} - -.fa-percentage { - --fa: "\%"; -} - -.fa-ampersand { - --fa: "\&"; -} - -.fa-apostrophe { - --fa: "\'"; -} - -.fa-bracket-round { - --fa: "\("; -} - -.fa-parenthesis { - --fa: "\("; -} - -.fa-bracket-round-right { - --fa: "\)"; -} - -.fa-asterisk { - --fa: "\*"; -} - -.fa-plus { - --fa: "\+"; -} - -.fa-add { - --fa: "\+"; -} - -.fa-comma { - --fa: "\,"; -} - -.fa-hyphen { - --fa: "\-"; -} - -.fa-period { - --fa: "\."; -} - -.fa-slash-forward { - --fa: "\/"; -} - -.fa-colon { - --fa: "\:"; -} - -.fa-semicolon { - --fa: "\;"; -} - -.fa-less-than { - --fa: "\<"; -} - -.fa-equals { - --fa: "\="; -} - -.fa-greater-than { - --fa: "\>"; -} - -.fa-question { - --fa: "\?"; -} - -.fa-at { - --fa: "\@"; -} - -.fa-a { - --fa: "A"; -} - -.fa-b { - --fa: "B"; -} - -.fa-c { - --fa: "C"; -} - -.fa-d { - --fa: "D"; -} - -.fa-e { - --fa: "E"; -} - -.fa-f { - --fa: "F"; -} - -.fa-g { - --fa: "G"; -} - -.fa-h { - --fa: "H"; -} - -.fa-i { - --fa: "I"; -} - -.fa-j { - --fa: "J"; -} - -.fa-k { - --fa: "K"; -} - -.fa-l { - --fa: "L"; -} - -.fa-m { - --fa: "M"; -} - -.fa-n { - --fa: "N"; -} - -.fa-o { - --fa: "O"; -} - -.fa-p { - --fa: "P"; -} - -.fa-q { - --fa: "Q"; -} - -.fa-r { - --fa: "R"; -} - -.fa-s { - --fa: "S"; -} - -.fa-t { - --fa: "T"; -} - -.fa-u { - --fa: "U"; -} - -.fa-v { - --fa: "V"; -} - -.fa-w { - --fa: "W"; -} - -.fa-x { - --fa: "X"; -} - -.fa-y { - --fa: "Y"; -} - -.fa-z { - --fa: "Z"; -} - -.fa-bracket-square { - --fa: "\["; -} - -.fa-bracket { - --fa: "\["; -} - -.fa-bracket-left { - --fa: "\["; -} - -.fa-slash-back { - --fa: "\\"; -} - -.fa-bracket-square-right { - --fa: "\]"; -} - -.fa-accent-grave { - --fa: "\`"; -} - -.fa-bracket-curly { - --fa: "\{"; -} - -.fa-bracket-curly-left { - --fa: "\{"; -} - -.fa-pipe { - --fa: "\|"; -} - -.fa-bracket-curly-right { - --fa: "\}"; -} - -.fa-tilde { - --fa: "\~"; -} - -.fa-caravan-simple { - --fa: "\e000"; -} - -.fa-caravan-alt { - --fa: "\e000"; -} - -.fa-cat-space { - --fa: "\e001"; -} - -.fa-coffee-pot { - --fa: "\e002"; -} - -.fa-comet { - --fa: "\e003"; -} - -.fa-fan-table { - --fa: "\e004"; -} - -.fa-faucet { - --fa: "\e005"; -} - -.fa-faucet-drip { - --fa: "\e006"; -} - -.fa-galaxy { - --fa: "\e008"; -} - -.fa-garage { - --fa: "\e009"; -} - -.fa-garage-car { - --fa: "\e00a"; -} - -.fa-garage-open { - --fa: "\e00b"; -} - -.fa-heat { - --fa: "\e00c"; -} - -.fa-house-chimney-window { - --fa: "\e00d"; -} - -.fa-house-day { - --fa: "\e00e"; -} - -.fa-house-person-leave { - --fa: "\e00f"; -} - -.fa-house-leave { - --fa: "\e00f"; -} - -.fa-house-person-depart { - --fa: "\e00f"; -} - -.fa-house-night { - --fa: "\e010"; -} - -.fa-house-person-return { - --fa: "\e011"; -} - -.fa-house-person-arrive { - --fa: "\e011"; -} - -.fa-house-return { - --fa: "\e011"; -} - -.fa-house-signal { - --fa: "\e012"; -} - -.fa-lamp-desk { - --fa: "\e014"; -} - -.fa-lamp-floor { - --fa: "\e015"; -} - -.fa-light-ceiling { - --fa: "\e016"; -} - -.fa-light-switch { - --fa: "\e017"; -} - -.fa-light-switch-off { - --fa: "\e018"; -} - -.fa-light-switch-on { - --fa: "\e019"; -} - -.fa-microwave { - --fa: "\e01b"; -} - -.fa-outlet { - --fa: "\e01c"; -} - -.fa-oven { - --fa: "\e01d"; -} - -.fa-planet-moon { - --fa: "\e01f"; -} - -.fa-planet-ringed { - --fa: "\e020"; -} - -.fa-police-box { - --fa: "\e021"; -} - -.fa-person-to-portal { - --fa: "\e022"; -} - -.fa-portal-enter { - --fa: "\e022"; -} - -.fa-person-from-portal { - --fa: "\e023"; -} - -.fa-portal-exit { - --fa: "\e023"; -} - -.fa-radar { - --fa: "\e024"; -} - -.fa-raygun { - --fa: "\e025"; -} - -.fa-refrigerator { - --fa: "\e026"; -} - -.fa-rocket-launch { - --fa: "\e027"; -} - -.fa-sensor { - --fa: "\e028"; -} - -.fa-sensor-triangle-exclamation { - --fa: "\e029"; -} - -.fa-sensor-alert { - --fa: "\e029"; -} - -.fa-sensor-fire { - --fa: "\e02a"; -} - -.fa-sensor-on { - --fa: "\e02b"; -} - -.fa-sensor-cloud { - --fa: "\e02c"; -} - -.fa-sensor-smoke { - --fa: "\e02c"; -} - -.fa-siren { - --fa: "\e02d"; -} - -.fa-siren-on { - --fa: "\e02e"; -} - -.fa-solar-system { - --fa: "\e02f"; -} - -.fa-circle-sort { - --fa: "\e030"; -} - -.fa-sort-circle { - --fa: "\e030"; -} - -.fa-circle-sort-down { - --fa: "\e031"; -} - -.fa-sort-circle-down { - --fa: "\e031"; -} - -.fa-circle-sort-up { - --fa: "\e032"; -} - -.fa-sort-circle-up { - --fa: "\e032"; -} - -.fa-space-station-moon { - --fa: "\e033"; -} - -.fa-space-station-moon-construction { - --fa: "\e034"; -} - -.fa-space-station-moon-alt { - --fa: "\e034"; -} - -.fa-sprinkler { - --fa: "\e035"; -} - -.fa-star-shooting { - --fa: "\e036"; -} - -.fa-starfighter { - --fa: "\e037"; -} - -.fa-starfighter-twin-ion-engine { - --fa: "\e038"; -} - -.fa-starfighter-alt { - --fa: "\e038"; -} - -.fa-starship { - --fa: "\e039"; -} - -.fa-starship-freighter { - --fa: "\e03a"; -} - -.fa-sword-laser { - --fa: "\e03b"; -} - -.fa-sword-laser-alt { - --fa: "\e03c"; -} - -.fa-swords-laser { - --fa: "\e03d"; -} - -.fa-telescope { - --fa: "\e03e"; -} - -.fa-temperature-arrow-down { - --fa: "\e03f"; -} - -.fa-temperature-down { - --fa: "\e03f"; -} - -.fa-temperature-arrow-up { - --fa: "\e040"; -} - -.fa-temperature-up { - --fa: "\e040"; -} - -.fa-trailer { - --fa: "\e041"; -} - -.fa-transporter { - --fa: "\e042"; -} - -.fa-transporter-1 { - --fa: "\e043"; -} - -.fa-transporter-2 { - --fa: "\e044"; -} - -.fa-transporter-3 { - --fa: "\e045"; -} - -.fa-transporter-empty { - --fa: "\e046"; -} - -.fa-ufo { - --fa: "\e047"; -} - -.fa-ufo-beam { - --fa: "\e048"; -} - -.fa-user-alien { - --fa: "\e04a"; -} - -.fa-user-robot { - --fa: "\e04b"; -} - -.fa-user-visor { - --fa: "\e04c"; -} - -.fa-vacuum { - --fa: "\e04d"; -} - -.fa-vacuum-robot { - --fa: "\e04e"; -} - -.fa-window-frame { - --fa: "\e04f"; -} - -.fa-window-frame-open { - --fa: "\e050"; -} - -.fa-coffin-cross { - --fa: "\e051"; -} - -.fa-folder-arrow-down { - --fa: "\e053"; -} - -.fa-folder-download { - --fa: "\e053"; -} - -.fa-folder-arrow-up { - --fa: "\e054"; -} - -.fa-folder-upload { - --fa: "\e054"; -} - -.fa-user-unlock { - --fa: "\e058"; -} - -.fa-bacteria { - --fa: "\e059"; -} - -.fa-bacterium { - --fa: "\e05a"; -} - -.fa-box-tissue { - --fa: "\e05b"; -} - -.fa-hand-holding-medical { - --fa: "\e05c"; -} - -.fa-hand-sparkles { - --fa: "\e05d"; -} - -.fa-hands-bubbles { - --fa: "\e05e"; -} - -.fa-hands-wash { - --fa: "\e05e"; -} - -.fa-handshake-slash { - --fa: "\e060"; -} - -.fa-handshake-alt-slash { - --fa: "\e060"; -} - -.fa-handshake-simple-slash { - --fa: "\e060"; -} - -.fa-head-side-cough { - --fa: "\e061"; -} - -.fa-head-side-cough-slash { - --fa: "\e062"; -} - -.fa-head-side-mask { - --fa: "\e063"; -} - -.fa-head-side-virus { - --fa: "\e064"; -} - -.fa-house-chimney-user { - --fa: "\e065"; -} - -.fa-house-laptop { - --fa: "\e066"; -} - -.fa-laptop-house { - --fa: "\e066"; -} - -.fa-lungs-virus { - --fa: "\e067"; -} - -.fa-people-arrows { - --fa: "\e068"; -} - -.fa-people-arrows-left-right { - --fa: "\e068"; -} - -.fa-plane-slash { - --fa: "\e069"; -} - -.fa-pump-medical { - --fa: "\e06a"; -} - -.fa-pump-soap { - --fa: "\e06b"; -} - -.fa-shield-virus { - --fa: "\e06c"; -} - -.fa-sink { - --fa: "\e06d"; -} - -.fa-soap { - --fa: "\e06e"; -} - -.fa-stopwatch-20 { - --fa: "\e06f"; -} - -.fa-shop-slash { - --fa: "\e070"; -} - -.fa-store-alt-slash { - --fa: "\e070"; -} - -.fa-store-slash { - --fa: "\e071"; -} - -.fa-toilet-paper-slash { - --fa: "\e072"; -} - -.fa-users-slash { - --fa: "\e073"; -} - -.fa-virus { - --fa: "\e074"; -} - -.fa-virus-slash { - --fa: "\e075"; -} - -.fa-viruses { - --fa: "\e076"; -} - -.fa-vest { - --fa: "\e085"; -} - -.fa-vest-patches { - --fa: "\e086"; -} - -.fa-airplay { - --fa: "\e089"; -} - -.fa-alt { - --fa: "\e08a"; -} - -.fa-angle { - --fa: "\e08c"; -} - -.fa-angle-90 { - --fa: "\e08d"; -} - -.fa-apple-core { - --fa: "\e08f"; -} - -.fa-arrow-down-from-dotted-line { - --fa: "\e090"; -} - -.fa-arrow-down-left { - --fa: "\e091"; -} - -.fa-arrow-down-left-and-arrow-up-right-to-center { - --fa: "\e092"; -} - -.fa-arrow-down-right { - --fa: "\e093"; -} - -.fa-arrow-down-to-bracket { - --fa: "\e094"; -} - -.fa-arrow-down-to-dotted-line { - --fa: "\e095"; -} - -.fa-arrow-down-to-square { - --fa: "\e096"; -} - -.fa-arrow-trend-down { - --fa: "\e097"; -} - -.fa-arrow-trend-up { - --fa: "\e098"; -} - -.fa-arrow-up-arrow-down { - --fa: "\e099"; -} - -.fa-sort-up-down { - --fa: "\e099"; -} - -.fa-arrow-up-from-bracket { - --fa: "\e09a"; -} - -.fa-arrow-up-from-dotted-line { - --fa: "\e09b"; -} - -.fa-arrow-up-from-square { - --fa: "\e09c"; -} - -.fa-arrow-up-left { - --fa: "\e09d"; -} - -.fa-arrow-up-left-from-circle { - --fa: "\e09e"; -} - -.fa-arrow-up-right { - --fa: "\e09f"; -} - -.fa-arrow-up-right-and-arrow-down-left-from-center { - --fa: "\e0a0"; -} - -.fa-arrow-up-to-dotted-line { - --fa: "\e0a1"; -} - -.fa-arrows-cross { - --fa: "\e0a2"; -} - -.fa-arrows-from-dotted-line { - --fa: "\e0a3"; -} - -.fa-arrows-from-line { - --fa: "\e0a4"; -} - -.fa-arrows-minimize { - --fa: "\e0a5"; -} - -.fa-compress-arrows { - --fa: "\e0a5"; -} - -.fa-arrows-to-dotted-line { - --fa: "\e0a6"; -} - -.fa-arrows-to-line { - --fa: "\e0a7"; -} - -.fa-audio-description-slash { - --fa: "\e0a8"; -} - -.fa-austral-sign { - --fa: "\e0a9"; -} - -.fa-avocado { - --fa: "\e0aa"; -} - -.fa-award-simple { - --fa: "\e0ab"; -} - -.fa-baht-sign { - --fa: "\e0ac"; -} - -.fa-bars-filter { - --fa: "\e0ad"; -} - -.fa-bars-sort { - --fa: "\e0ae"; -} - -.fa-basket-shopping-simple { - --fa: "\e0af"; -} - -.fa-shopping-basket-alt { - --fa: "\e0af"; -} - -.fa-battery-exclamation { - --fa: "\e0b0"; -} - -.fa-battery-low { - --fa: "\e0b1"; -} - -.fa-battery-1 { - --fa: "\e0b1"; -} - -.fa-bee { - --fa: "\e0b2"; -} - -.fa-beer-mug { - --fa: "\e0b3"; -} - -.fa-beer-foam { - --fa: "\e0b3"; -} - -.fa-bitcoin-sign { - --fa: "\e0b4"; -} - -.fa-block-quote { - --fa: "\e0b5"; -} - -.fa-bolt-auto { - --fa: "\e0b6"; -} - -.fa-bolt-lightning { - --fa: "\e0b7"; -} - -.fa-bolt-slash { - --fa: "\e0b8"; -} - -.fa-book-arrow-right { - --fa: "\e0b9"; -} - -.fa-book-arrow-up { - --fa: "\e0ba"; -} - -.fa-book-bookmark { - --fa: "\e0bb"; -} - -.fa-book-circle-arrow-right { - --fa: "\e0bc"; -} - -.fa-book-circle-arrow-up { - --fa: "\e0bd"; -} - -.fa-book-copy { - --fa: "\e0be"; -} - -.fa-book-font { - --fa: "\e0bf"; -} - -.fa-book-open-cover { - --fa: "\e0c0"; -} - -.fa-book-open-alt { - --fa: "\e0c0"; -} - -.fa-book-section { - --fa: "\e0c1"; -} - -.fa-book-law { - --fa: "\e0c1"; -} - -.fa-bookmark-slash { - --fa: "\e0c2"; -} - -.fa-bowling-ball-pin { - --fa: "\e0c3"; -} - -.fa-box-circle-check { - --fa: "\e0c4"; -} - -.fa-brackets-round { - --fa: "\e0c5"; -} - -.fa-parentheses { - --fa: "\e0c5"; -} - -.fa-brain-circuit { - --fa: "\e0c6"; -} - -.fa-brake-warning { - --fa: "\e0c7"; -} - -.fa-briefcase-blank { - --fa: "\e0c8"; -} - -.fa-brightness { - --fa: "\e0c9"; -} - -.fa-brightness-low { - --fa: "\e0ca"; -} - -.fa-browsers { - --fa: "\e0cb"; -} - -.fa-buildings { - --fa: "\e0cc"; -} - -.fa-burger-fries { - --fa: "\e0cd"; -} - -.fa-burger-glass { - --fa: "\e0ce"; -} - -.fa-calendar-arrow-down { - --fa: "\e0d0"; -} - -.fa-calendar-download { - --fa: "\e0d0"; -} - -.fa-calendar-arrow-up { - --fa: "\e0d1"; -} - -.fa-calendar-upload { - --fa: "\e0d1"; -} - -.fa-calendar-clock { - --fa: "\e0d2"; -} - -.fa-calendar-time { - --fa: "\e0d2"; -} - -.fa-calendar-heart { - --fa: "\e0d3"; -} - -.fa-calendar-image { - --fa: "\e0d4"; -} - -.fa-calendar-lines { - --fa: "\e0d5"; -} - -.fa-calendar-note { - --fa: "\e0d5"; -} - -.fa-calendar-range { - --fa: "\e0d6"; -} - -.fa-calendars { - --fa: "\e0d7"; -} - -.fa-camera-rotate { - --fa: "\e0d8"; -} - -.fa-camera-slash { - --fa: "\e0d9"; -} - -.fa-camera-viewfinder { - --fa: "\e0da"; -} - -.fa-screenshot { - --fa: "\e0da"; -} - -.fa-cart-minus { - --fa: "\e0db"; -} - -.fa-cart-shopping-fast { - --fa: "\e0dc"; -} - -.fa-cart-xmark { - --fa: "\e0dd"; -} - -.fa-castle { - --fa: "\e0de"; -} - -.fa-cedi-sign { - --fa: "\e0df"; -} - -.fa-chart-bullet { - --fa: "\e0e1"; -} - -.fa-chart-candlestick { - --fa: "\e0e2"; -} - -.fa-chart-column { - --fa: "\e0e3"; -} - -.fa-chart-gantt { - --fa: "\e0e4"; -} - -.fa-chart-line-up { - --fa: "\e0e5"; -} - -.fa-chart-pyramid { - --fa: "\e0e6"; -} - -.fa-chart-radar { - --fa: "\e0e7"; -} - -.fa-chart-scatter-3d { - --fa: "\e0e8"; -} - -.fa-chart-scatter-bubble { - --fa: "\e0e9"; -} - -.fa-chart-tree-map { - --fa: "\e0ea"; -} - -.fa-chart-waterfall { - --fa: "\e0eb"; -} - -.fa-cherries { - --fa: "\e0ec"; -} - -.fa-circle-0 { - --fa: "\e0ed"; -} - -.fa-circle-1 { - --fa: "\e0ee"; -} - -.fa-circle-2 { - --fa: "\e0ef"; -} - -.fa-circle-3 { - --fa: "\e0f0"; -} - -.fa-circle-4 { - --fa: "\e0f1"; -} - -.fa-circle-5 { - --fa: "\e0f2"; -} - -.fa-circle-6 { - --fa: "\e0f3"; -} - -.fa-circle-7 { - --fa: "\e0f4"; -} - -.fa-circle-8 { - --fa: "\e0f5"; -} - -.fa-circle-9 { - --fa: "\e0f6"; -} - -.fa-circle-a { - --fa: "\e0f7"; -} - -.fa-circle-ampersand { - --fa: "\e0f8"; -} - -.fa-circle-arrow-down-left { - --fa: "\e0f9"; -} - -.fa-circle-arrow-down-right { - --fa: "\e0fa"; -} - -.fa-circle-arrow-up-left { - --fa: "\e0fb"; -} - -.fa-circle-arrow-up-right { - --fa: "\e0fc"; -} - -.fa-circle-b { - --fa: "\e0fd"; -} - -.fa-circle-bolt { - --fa: "\e0fe"; -} - -.fa-circle-book-open { - --fa: "\e0ff"; -} - -.fa-book-circle { - --fa: "\e0ff"; -} - -.fa-circle-bookmark { - --fa: "\e100"; -} - -.fa-bookmark-circle { - --fa: "\e100"; -} - -.fa-circle-c { - --fa: "\e101"; -} - -.fa-circle-calendar { - --fa: "\e102"; -} - -.fa-calendar-circle { - --fa: "\e102"; -} - -.fa-circle-camera { - --fa: "\e103"; -} - -.fa-camera-circle { - --fa: "\e103"; -} - -.fa-circle-d { - --fa: "\e104"; -} - -.fa-circle-dashed { - --fa: "\e105"; -} - -.fa-circle-divide { - --fa: "\e106"; -} - -.fa-circle-down-left { - --fa: "\e107"; -} - -.fa-circle-down-right { - --fa: "\e108"; -} - -.fa-circle-e { - --fa: "\e109"; -} - -.fa-circle-ellipsis { - --fa: "\e10a"; -} - -.fa-circle-ellipsis-vertical { - --fa: "\e10b"; -} - -.fa-circle-envelope { - --fa: "\e10c"; -} - -.fa-envelope-circle { - --fa: "\e10c"; -} - -.fa-circle-exclamation-check { - --fa: "\e10d"; -} - -.fa-circle-f { - --fa: "\e10e"; -} - -.fa-circle-g { - --fa: "\e10f"; -} - -.fa-circle-half { - --fa: "\e110"; -} - -.fa-circle-i { - --fa: "\e111"; -} - -.fa-circle-j { - --fa: "\e112"; -} - -.fa-circle-k { - --fa: "\e113"; -} - -.fa-circle-l { - --fa: "\e114"; -} - -.fa-circle-m { - --fa: "\e115"; -} - -.fa-circle-microphone { - --fa: "\e116"; -} - -.fa-microphone-circle { - --fa: "\e116"; -} - -.fa-circle-microphone-lines { - --fa: "\e117"; -} - -.fa-microphone-circle-alt { - --fa: "\e117"; -} - -.fa-circle-n { - --fa: "\e118"; -} - -.fa-circle-o { - --fa: "\e119"; -} - -.fa-circle-p { - --fa: "\e11a"; -} - -.fa-circle-phone { - --fa: "\e11b"; -} - -.fa-phone-circle { - --fa: "\e11b"; -} - -.fa-circle-phone-flip { - --fa: "\e11c"; -} - -.fa-phone-circle-alt { - --fa: "\e11c"; -} - -.fa-circle-phone-hangup { - --fa: "\e11d"; -} - -.fa-phone-circle-down { - --fa: "\e11d"; -} - -.fa-circle-q { - --fa: "\e11e"; -} - -.fa-circle-quarter { - --fa: "\e11f"; -} - -.fa-circle-r { - --fa: "\e120"; -} - -.fa-circle-s { - --fa: "\e121"; -} - -.fa-circle-small { - --fa: "\e122"; -} - -.fa-circle-star { - --fa: "\e123"; -} - -.fa-star-circle { - --fa: "\e123"; -} - -.fa-circle-t { - --fa: "\e124"; -} - -.fa-circle-three-quarters { - --fa: "\e125"; -} - -.fa-circle-trash { - --fa: "\e126"; -} - -.fa-trash-circle { - --fa: "\e126"; -} - -.fa-circle-u { - --fa: "\e127"; -} - -.fa-circle-up-left { - --fa: "\e128"; -} - -.fa-circle-up-right { - --fa: "\e129"; -} - -.fa-circle-v { - --fa: "\e12a"; -} - -.fa-circle-video { - --fa: "\e12b"; -} - -.fa-video-circle { - --fa: "\e12b"; -} - -.fa-circle-w { - --fa: "\e12c"; -} - -.fa-circle-waveform-lines { - --fa: "\e12d"; -} - -.fa-waveform-circle { - --fa: "\e12d"; -} - -.fa-circle-x { - --fa: "\e12e"; -} - -.fa-circle-y { - --fa: "\e12f"; -} - -.fa-circle-z { - --fa: "\e130"; -} - -.fa-clapperboard { - --fa: "\e131"; -} - -.fa-clapperboard-play { - --fa: "\e132"; -} - -.fa-clipboard-medical { - --fa: "\e133"; -} - -.fa-clock-desk { - --fa: "\e134"; -} - -.fa-closed-captioning-slash { - --fa: "\e135"; -} - -.fa-clothes-hanger { - --fa: "\e136"; -} - -.fa-cloud-slash { - --fa: "\e137"; -} - -.fa-cloud-word { - --fa: "\e138"; -} - -.fa-clover { - --fa: "\e139"; -} - -.fa-code-compare { - --fa: "\e13a"; -} - -.fa-code-fork { - --fa: "\e13b"; -} - -.fa-code-pull-request { - --fa: "\e13c"; -} - -.fa-code-simple { - --fa: "\e13d"; -} - -.fa-coffee-bean { - --fa: "\e13e"; -} - -.fa-coffee-beans { - --fa: "\e13f"; -} - -.fa-colon-sign { - --fa: "\e140"; -} - -.fa-command { - --fa: "\e142"; -} - -.fa-comment-arrow-down { - --fa: "\e143"; -} - -.fa-comment-arrow-up { - --fa: "\e144"; -} - -.fa-comment-arrow-up-right { - --fa: "\e145"; -} - -.fa-comment-captions { - --fa: "\e146"; -} - -.fa-comment-code { - --fa: "\e147"; -} - -.fa-comment-image { - --fa: "\e148"; -} - -.fa-comment-middle { - --fa: "\e149"; -} - -.fa-comment-middle-top { - --fa: "\e14a"; -} - -.fa-comment-question { - --fa: "\e14b"; -} - -.fa-comment-quote { - --fa: "\e14c"; -} - -.fa-comment-text { - --fa: "\e14d"; -} - -.fa-comments-question { - --fa: "\e14e"; -} - -.fa-comments-question-check { - --fa: "\e14f"; -} - -.fa-conveyor-belt-empty { - --fa: "\e150"; -} - -.fa-crate-empty { - --fa: "\e151"; -} - -.fa-cruzeiro-sign { - --fa: "\e152"; -} - -.fa-delete-right { - --fa: "\e154"; -} - -.fa-desktop-arrow-down { - --fa: "\e155"; -} - -.fa-diagram-lean-canvas { - --fa: "\e156"; -} - -.fa-diagram-nested { - --fa: "\e157"; -} - -.fa-diagram-sankey { - --fa: "\e158"; -} - -.fa-diagram-venn { - --fa: "\e15a"; -} - -.fa-dial { - --fa: "\e15b"; -} - -.fa-dial-med-high { - --fa: "\e15b"; -} - -.fa-dial-high { - --fa: "\e15c"; -} - -.fa-dial-low { - --fa: "\e15d"; -} - -.fa-dial-max { - --fa: "\e15e"; -} - -.fa-dial-med { - --fa: "\e15f"; -} - -.fa-dial-med-low { - --fa: "\e160"; -} - -.fa-dial-min { - --fa: "\e161"; -} - -.fa-dial-off { - --fa: "\e162"; -} - -.fa-display { - --fa: "\e163"; -} - -.fa-display-arrow-down { - --fa: "\e164"; -} - -.fa-display-code { - --fa: "\e165"; -} - -.fa-desktop-code { - --fa: "\e165"; -} - -.fa-display-medical { - --fa: "\e166"; -} - -.fa-desktop-medical { - --fa: "\e166"; -} - -.fa-dolphin { - --fa: "\e168"; -} - -.fa-dong-sign { - --fa: "\e169"; -} - -.fa-down-left { - --fa: "\e16a"; -} - -.fa-down-right { - --fa: "\e16b"; -} - -.fa-eggplant { - --fa: "\e16c"; -} - -.fa-elevator { - --fa: "\e16d"; -} - -.fa-engine { - --fa: "\e16e"; -} - -.fa-envelope-dot { - --fa: "\e16f"; -} - -.fa-envelope-badge { - --fa: "\e16f"; -} - -.fa-envelopes { - --fa: "\e170"; -} - -.fa-escalator { - --fa: "\e171"; -} - -.fa-eye-dropper-full { - --fa: "\e172"; -} - -.fa-eye-dropper-half { - --fa: "\e173"; -} - -.fa-ferris-wheel { - --fa: "\e174"; -} - -.fa-file-binary { - --fa: "\e175"; -} - -.fa-file-heart { - --fa: "\e176"; -} - -.fa-file-plus-minus { - --fa: "\e177"; -} - -.fa-files { - --fa: "\e178"; -} - -.fa-film-slash { - --fa: "\e179"; -} - -.fa-films { - --fa: "\e17a"; -} - -.fa-filter-circle-xmark { - --fa: "\e17b"; -} - -.fa-filter-list { - --fa: "\e17c"; -} - -.fa-filter-slash { - --fa: "\e17d"; -} - -.fa-filters { - --fa: "\e17e"; -} - -.fa-fire-hydrant { - --fa: "\e17f"; -} - -.fa-floppy-disk-circle-arrow-right { - --fa: "\e180"; -} - -.fa-save-circle-arrow-right { - --fa: "\e180"; -} - -.fa-floppy-disk-circle-xmark { - --fa: "\e181"; -} - -.fa-floppy-disk-times { - --fa: "\e181"; -} - -.fa-save-circle-xmark { - --fa: "\e181"; -} - -.fa-save-times { - --fa: "\e181"; -} - -.fa-floppy-disk-pen { - --fa: "\e182"; -} - -.fa-floppy-disks { - --fa: "\e183"; -} - -.fa-florin-sign { - --fa: "\e184"; -} - -.fa-folder-closed { - --fa: "\e185"; -} - -.fa-folder-bookmark { - --fa: "\e186"; -} - -.fa-folder-gear { - --fa: "\e187"; -} - -.fa-folder-cog { - --fa: "\e187"; -} - -.fa-folder-grid { - --fa: "\e188"; -} - -.fa-folder-heart { - --fa: "\e189"; -} - -.fa-folder-image { - --fa: "\e18a"; -} - -.fa-folder-magnifying-glass { - --fa: "\e18b"; -} - -.fa-folder-search { - --fa: "\e18b"; -} - -.fa-folder-medical { - --fa: "\e18c"; -} - -.fa-folder-music { - --fa: "\e18d"; -} - -.fa-folder-user { - --fa: "\e18e"; -} - -.fa-franc-sign { - --fa: "\e18f"; -} - -.fa-gif { - --fa: "\e190"; -} - -.fa-glass-empty { - --fa: "\e191"; -} - -.fa-glass-half { - --fa: "\e192"; -} - -.fa-glass-half-empty { - --fa: "\e192"; -} - -.fa-glass-half-full { - --fa: "\e192"; -} - -.fa-grate { - --fa: "\e193"; -} - -.fa-grate-droplet { - --fa: "\e194"; -} - -.fa-grid { - --fa: "\e195"; -} - -.fa-grid-3 { - --fa: "\e195"; -} - -.fa-grid-2 { - --fa: "\e196"; -} - -.fa-grid-2-plus { - --fa: "\e197"; -} - -.fa-grid-4 { - --fa: "\e198"; -} - -.fa-grid-5 { - --fa: "\e199"; -} - -.fa-guarani-sign { - --fa: "\e19a"; -} - -.fa-gun { - --fa: "\e19b"; -} - -.fa-gun-slash { - --fa: "\e19c"; -} - -.fa-gun-squirt { - --fa: "\e19d"; -} - -.fa-hand-back-point-down { - --fa: "\e19e"; -} - -.fa-hand-back-point-left { - --fa: "\e19f"; -} - -.fa-hand-back-point-ribbon { - --fa: "\e1a0"; -} - -.fa-hand-back-point-right { - --fa: "\e1a1"; -} - -.fa-hand-back-point-up { - --fa: "\e1a2"; -} - -.fa-hand-fingers-crossed { - --fa: "\e1a3"; -} - -.fa-hand-holding-skull { - --fa: "\e1a4"; -} - -.fa-hand-love { - --fa: "\e1a5"; -} - -.fa-hand-point-ribbon { - --fa: "\e1a6"; -} - -.fa-hand-wave { - --fa: "\e1a7"; -} - -.fa-hands-clapping { - --fa: "\e1a8"; -} - -.fa-hand-horns { - --fa: "\e1a9"; -} - -.fa-head-side-heart { - --fa: "\e1aa"; -} - -.fa-heart-half { - --fa: "\e1ab"; -} - -.fa-heart-half-stroke { - --fa: "\e1ac"; -} - -.fa-heart-half-alt { - --fa: "\e1ac"; -} - -.fa-hexagon-divide { - --fa: "\e1ad"; -} - -.fa-high-definition { - --fa: "\e1ae"; -} - -.fa-rectangle-hd { - --fa: "\e1ae"; -} - -.fa-highlighter-line { - --fa: "\e1af"; -} - -.fa-house-user { - --fa: "\e1b0"; -} - -.fa-home-user { - --fa: "\e1b0"; -} - -.fa-house-building { - --fa: "\e1b1"; -} - -.fa-house-chimney-heart { - --fa: "\e1b2"; -} - -.fa-house-tree { - --fa: "\e1b3"; -} - -.fa-house-turret { - --fa: "\e1b4"; -} - -.fa-image-landscape { - --fa: "\e1b5"; -} - -.fa-landscape { - --fa: "\e1b5"; -} - -.fa-image-polaroid-user { - --fa: "\e1b6"; -} - -.fa-image-slash { - --fa: "\e1b7"; -} - -.fa-image-user { - --fa: "\e1b8"; -} - -.fa-images-user { - --fa: "\e1b9"; -} - -.fa-inbox-full { - --fa: "\e1ba"; -} - -.fa-inboxes { - --fa: "\e1bb"; -} - -.fa-indian-rupee-sign { - --fa: "\e1bc"; -} - -.fa-indian-rupee { - --fa: "\e1bc"; -} - -.fa-inr { - --fa: "\e1bc"; -} - -.fa-input-numeric { - --fa: "\e1bd"; -} - -.fa-input-pipe { - --fa: "\e1be"; -} - -.fa-input-text { - --fa: "\e1bf"; -} - -.fa-keyboard-brightness { - --fa: "\e1c0"; -} - -.fa-keyboard-brightness-low { - --fa: "\e1c1"; -} - -.fa-keyboard-down { - --fa: "\e1c2"; -} - -.fa-keyboard-left { - --fa: "\e1c3"; -} - -.fa-kip-sign { - --fa: "\e1c4"; -} - -.fa-lamp-street { - --fa: "\e1c5"; -} - -.fa-laptop-arrow-down { - --fa: "\e1c6"; -} - -.fa-laptop-slash { - --fa: "\e1c7"; -} - -.fa-lari-sign { - --fa: "\e1c8"; -} - -.fa-lasso-sparkles { - --fa: "\e1c9"; -} - -.fa-lightbulb-exclamation-on { - --fa: "\e1ca"; -} - -.fa-link-horizontal { - --fa: "\e1cb"; -} - -.fa-chain-horizontal { - --fa: "\e1cb"; -} - -.fa-link-horizontal-slash { - --fa: "\e1cc"; -} - -.fa-chain-horizontal-slash { - --fa: "\e1cc"; -} - -.fa-link-simple { - --fa: "\e1cd"; -} - -.fa-link-simple-slash { - --fa: "\e1ce"; -} - -.fa-list-dropdown { - --fa: "\e1cf"; -} - -.fa-list-radio { - --fa: "\e1d0"; -} - -.fa-list-timeline { - --fa: "\e1d1"; -} - -.fa-list-tree { - --fa: "\e1d2"; -} - -.fa-litecoin-sign { - --fa: "\e1d3"; -} - -.fa-loader { - --fa: "\e1d4"; -} - -.fa-manat-sign { - --fa: "\e1d5"; -} - -.fa-manhole { - --fa: "\e1d6"; -} - -.fa-mask-face { - --fa: "\e1d7"; -} - -.fa-memo { - --fa: "\e1d8"; -} - -.fa-memo-circle-check { - --fa: "\e1d9"; -} - -.fa-memo-pad { - --fa: "\e1da"; -} - -.fa-message-arrow-down { - --fa: "\e1db"; -} - -.fa-comment-alt-arrow-down { - --fa: "\e1db"; -} - -.fa-message-arrow-up { - --fa: "\e1dc"; -} - -.fa-comment-alt-arrow-up { - --fa: "\e1dc"; -} - -.fa-message-arrow-up-right { - --fa: "\e1dd"; -} - -.fa-message-captions { - --fa: "\e1de"; -} - -.fa-comment-alt-captions { - --fa: "\e1de"; -} - -.fa-message-code { - --fa: "\e1df"; -} - -.fa-message-image { - --fa: "\e1e0"; -} - -.fa-comment-alt-image { - --fa: "\e1e0"; -} - -.fa-message-middle { - --fa: "\e1e1"; -} - -.fa-comment-middle-alt { - --fa: "\e1e1"; -} - -.fa-message-middle-top { - --fa: "\e1e2"; -} - -.fa-comment-middle-top-alt { - --fa: "\e1e2"; -} - -.fa-message-question { - --fa: "\e1e3"; -} - -.fa-message-quote { - --fa: "\e1e4"; -} - -.fa-comment-alt-quote { - --fa: "\e1e4"; -} - -.fa-message-sms { - --fa: "\e1e5"; -} - -.fa-message-text { - --fa: "\e1e6"; -} - -.fa-comment-alt-text { - --fa: "\e1e6"; -} - -.fa-messages-question { - --fa: "\e1e7"; -} - -.fa-meter { - --fa: "\e1e8"; -} - -.fa-meter-bolt { - --fa: "\e1e9"; -} - -.fa-meter-droplet { - --fa: "\e1ea"; -} - -.fa-meter-fire { - --fa: "\e1eb"; -} - -.fa-microchip-ai { - --fa: "\e1ec"; -} - -.fa-mill-sign { - --fa: "\e1ed"; -} - -.fa-mobile-notch { - --fa: "\e1ee"; -} - -.fa-mobile-iphone { - --fa: "\e1ee"; -} - -.fa-mobile-signal { - --fa: "\e1ef"; -} - -.fa-mobile-signal-out { - --fa: "\e1f0"; -} - -.fa-money-bill-simple { - --fa: "\e1f1"; -} - -.fa-money-bill-simple-wave { - --fa: "\e1f2"; -} - -.fa-money-bills { - --fa: "\e1f3"; -} - -.fa-money-bills-simple { - --fa: "\e1f4"; -} - -.fa-money-bills-alt { - --fa: "\e1f4"; -} - -.fa-mug-tea-saucer { - --fa: "\e1f5"; -} - -.fa-naira-sign { - --fa: "\e1f6"; -} - -.fa-nfc { - --fa: "\e1f7"; -} - -.fa-nfc-lock { - --fa: "\e1f8"; -} - -.fa-nfc-magnifying-glass { - --fa: "\e1f9"; -} - -.fa-nfc-pen { - --fa: "\e1fa"; -} - -.fa-nfc-signal { - --fa: "\e1fb"; -} - -.fa-nfc-slash { - --fa: "\e1fc"; -} - -.fa-nfc-trash { - --fa: "\e1fd"; -} - -.fa-notdef { - --fa: "\e1fe"; -} - -.fa-note { - --fa: "\e1ff"; -} - -.fa-note-medical { - --fa: "\e200"; -} - -.fa-notebook { - --fa: "\e201"; -} - -.fa-notes { - --fa: "\e202"; -} - -.fa-octagon-divide { - --fa: "\e203"; -} - -.fa-octagon-exclamation { - --fa: "\e204"; -} - -.fa-oil-can-drip { - --fa: "\e205"; -} - -.fa-paintbrush-pencil { - --fa: "\e206"; -} - -.fa-pallet-box { - --fa: "\e208"; -} - -.fa-panorama { - --fa: "\e209"; -} - -.fa-paper-plane-top { - --fa: "\e20a"; -} - -.fa-paper-plane-alt { - --fa: "\e20a"; -} - -.fa-send { - --fa: "\e20a"; -} - -.fa-peach { - --fa: "\e20b"; -} - -.fa-pear { - --fa: "\e20c"; -} - -.fa-pedestal { - --fa: "\e20d"; -} - -.fa-pen-circle { - --fa: "\e20e"; -} - -.fa-pen-clip-slash { - --fa: "\e20f"; -} - -.fa-pen-alt-slash { - --fa: "\e20f"; -} - -.fa-pen-fancy-slash { - --fa: "\e210"; -} - -.fa-pen-field { - --fa: "\e211"; -} - -.fa-pen-line { - --fa: "\e212"; -} - -.fa-pen-slash { - --fa: "\e213"; -} - -.fa-pen-swirl { - --fa: "\e214"; -} - -.fa-pencil-slash { - --fa: "\e215"; -} - -.fa-people { - --fa: "\e216"; -} - -.fa-people-dress { - --fa: "\e217"; -} - -.fa-people-dress-simple { - --fa: "\e218"; -} - -.fa-people-pants { - --fa: "\e219"; -} - -.fa-people-pants-simple { - --fa: "\e21a"; -} - -.fa-people-simple { - --fa: "\e21b"; -} - -.fa-person-dress-simple { - --fa: "\e21c"; -} - -.fa-person-pinball { - --fa: "\e21d"; -} - -.fa-person-seat { - --fa: "\e21e"; -} - -.fa-person-seat-reclined { - --fa: "\e21f"; -} - -.fa-person-simple { - --fa: "\e220"; -} - -.fa-peseta-sign { - --fa: "\e221"; -} - -.fa-peso-sign { - --fa: "\e222"; -} - -.fa-phone-arrow-down-left { - --fa: "\e223"; -} - -.fa-phone-arrow-down { - --fa: "\e223"; -} - -.fa-phone-incoming { - --fa: "\e223"; -} - -.fa-phone-arrow-up-right { - --fa: "\e224"; -} - -.fa-phone-arrow-up { - --fa: "\e224"; -} - -.fa-phone-outgoing { - --fa: "\e224"; -} - -.fa-phone-hangup { - --fa: "\e225"; -} - -.fa-phone-missed { - --fa: "\e226"; -} - -.fa-phone-xmark { - --fa: "\e227"; -} - -.fa-photo-film-music { - --fa: "\e228"; -} - -.fa-pinball { - --fa: "\e229"; -} - -.fa-plane-prop { - --fa: "\e22b"; -} - -.fa-plane-tail { - --fa: "\e22c"; -} - -.fa-plane-up { - --fa: "\e22d"; -} - -.fa-plane-up-slash { - --fa: "\e22e"; -} - -.fa-play-pause { - --fa: "\e22f"; -} - -.fa-puzzle-piece-simple { - --fa: "\e231"; -} - -.fa-puzzle-piece-alt { - --fa: "\e231"; -} - -.fa-quotes { - --fa: "\e234"; -} - -.fa-rectangle-pro { - --fa: "\e235"; -} - -.fa-pro { - --fa: "\e235"; -} - -.fa-rectangle-terminal { - --fa: "\e236"; -} - -.fa-rectangle-vertical-history { - --fa: "\e237"; -} - -.fa-reel { - --fa: "\e238"; -} - -.fa-reply-clock { - --fa: "\e239"; -} - -.fa-reply-time { - --fa: "\e239"; -} - -.fa-restroom-simple { - --fa: "\e23a"; -} - -.fa-rhombus { - --fa: "\e23b"; -} - -.fa-rotate-exclamation { - --fa: "\e23c"; -} - -.fa-rupiah-sign { - --fa: "\e23d"; -} - -.fa-screencast { - --fa: "\e23e"; -} - -.fa-scribble { - --fa: "\e23f"; -} - -.fa-sd-cards { - --fa: "\e240"; -} - -.fa-seal { - --fa: "\e241"; -} - -.fa-seal-exclamation { - --fa: "\e242"; -} - -.fa-seal-question { - --fa: "\e243"; -} - -.fa-seat-airline { - --fa: "\e244"; -} - -.fa-shelves-empty { - --fa: "\e246"; -} - -.fa-shield-exclamation { - --fa: "\e247"; -} - -.fa-shield-keyhole { - --fa: "\e248"; -} - -.fa-shield-minus { - --fa: "\e249"; -} - -.fa-shield-plus { - --fa: "\e24a"; -} - -.fa-shield-slash { - --fa: "\e24b"; -} - -.fa-shield-xmark { - --fa: "\e24c"; -} - -.fa-shield-times { - --fa: "\e24c"; -} - -.fa-shower-down { - --fa: "\e24d"; -} - -.fa-shower-alt { - --fa: "\e24d"; -} - -.fa-sidebar { - --fa: "\e24e"; -} - -.fa-sidebar-flip { - --fa: "\e24f"; -} - -.fa-signal-stream-slash { - --fa: "\e250"; -} - -.fa-sim-cards { - --fa: "\e251"; -} - -.fa-slider { - --fa: "\e252"; -} - -.fa-sliders-simple { - --fa: "\e253"; -} - -.fa-split { - --fa: "\e254"; -} - -.fa-square-0 { - --fa: "\e255"; -} - -.fa-square-1 { - --fa: "\e256"; -} - -.fa-square-2 { - --fa: "\e257"; -} - -.fa-square-3 { - --fa: "\e258"; -} - -.fa-square-4 { - --fa: "\e259"; -} - -.fa-square-5 { - --fa: "\e25a"; -} - -.fa-square-6 { - --fa: "\e25b"; -} - -.fa-square-7 { - --fa: "\e25c"; -} - -.fa-square-8 { - --fa: "\e25d"; -} - -.fa-square-9 { - --fa: "\e25e"; -} - -.fa-square-a { - --fa: "\e25f"; -} - -.fa-square-ampersand { - --fa: "\e260"; -} - -.fa-square-arrow-down-left { - --fa: "\e261"; -} - -.fa-square-arrow-down-right { - --fa: "\e262"; -} - -.fa-square-arrow-up-left { - --fa: "\e263"; -} - -.fa-square-b { - --fa: "\e264"; -} - -.fa-square-bolt { - --fa: "\e265"; -} - -.fa-square-c { - --fa: "\e266"; -} - -.fa-square-code { - --fa: "\e267"; -} - -.fa-square-d { - --fa: "\e268"; -} - -.fa-square-dashed { - --fa: "\e269"; -} - -.fa-square-divide { - --fa: "\e26a"; -} - -.fa-square-down-left { - --fa: "\e26b"; -} - -.fa-square-down-right { - --fa: "\e26c"; -} - -.fa-square-e { - --fa: "\e26d"; -} - -.fa-square-ellipsis { - --fa: "\e26e"; -} - -.fa-square-ellipsis-vertical { - --fa: "\e26f"; -} - -.fa-square-f { - --fa: "\e270"; -} - -.fa-square-g { - --fa: "\e271"; -} - -.fa-square-i { - --fa: "\e272"; -} - -.fa-square-j { - --fa: "\e273"; -} - -.fa-square-k { - --fa: "\e274"; -} - -.fa-square-l { - --fa: "\e275"; -} - -.fa-square-m { - --fa: "\e276"; -} - -.fa-square-n { - --fa: "\e277"; -} - -.fa-square-o { - --fa: "\e278"; -} - -.fa-square-p { - --fa: "\e279"; -} - -.fa-square-phone-hangup { - --fa: "\e27a"; -} - -.fa-phone-square-down { - --fa: "\e27a"; -} - -.fa-square-q { - --fa: "\e27b"; -} - -.fa-square-r { - --fa: "\e27c"; -} - -.fa-square-s { - --fa: "\e27d"; -} - -.fa-square-small { - --fa: "\e27e"; -} - -.fa-square-star { - --fa: "\e27f"; -} - -.fa-square-t { - --fa: "\e280"; -} - -.fa-square-u { - --fa: "\e281"; -} - -.fa-square-up-left { - --fa: "\e282"; -} - -.fa-square-user { - --fa: "\e283"; -} - -.fa-square-v { - --fa: "\e284"; -} - -.fa-square-w { - --fa: "\e285"; -} - -.fa-square-x { - --fa: "\e286"; -} - -.fa-square-y { - --fa: "\e287"; -} - -.fa-square-z { - --fa: "\e288"; -} - -.fa-stairs { - --fa: "\e289"; -} - -.fa-standard-definition { - --fa: "\e28a"; -} - -.fa-rectangle-sd { - --fa: "\e28a"; -} - -.fa-star-sharp { - --fa: "\e28b"; -} - -.fa-star-sharp-half { - --fa: "\e28c"; -} - -.fa-star-sharp-half-stroke { - --fa: "\e28d"; -} - -.fa-star-sharp-half-alt { - --fa: "\e28d"; -} - -.fa-starfighter-twin-ion-engine-advanced { - --fa: "\e28e"; -} - -.fa-starfighter-alt-advanced { - --fa: "\e28e"; -} - -.fa-sun-bright { - --fa: "\e28f"; -} - -.fa-sun-alt { - --fa: "\e28f"; -} - -.fa-table-layout { - --fa: "\e290"; -} - -.fa-table-pivot { - --fa: "\e291"; -} - -.fa-table-rows { - --fa: "\e292"; -} - -.fa-rows { - --fa: "\e292"; -} - -.fa-table-tree { - --fa: "\e293"; -} - -.fa-tally-1 { - --fa: "\e294"; -} - -.fa-tally-2 { - --fa: "\e295"; -} - -.fa-tally-3 { - --fa: "\e296"; -} - -.fa-tally-4 { - --fa: "\e297"; -} - -.fa-taxi-bus { - --fa: "\e298"; -} - -.fa-temperature-list { - --fa: "\e299"; -} - -.fa-ticket-airline { - --fa: "\e29a"; -} - -.fa-ticket-perforated-plane { - --fa: "\e29a"; -} - -.fa-ticket-plane { - --fa: "\e29a"; -} - -.fa-tickets-airline { - --fa: "\e29b"; -} - -.fa-tickets-perforated-plane { - --fa: "\e29b"; -} - -.fa-tickets-plane { - --fa: "\e29b"; -} - -.fa-timeline { - --fa: "\e29c"; -} - -.fa-timeline-arrow { - --fa: "\e29d"; -} - -.fa-timer { - --fa: "\e29e"; -} - -.fa-toilet-paper-under { - --fa: "\e2a0"; -} - -.fa-toilet-paper-blank-under { - --fa: "\e2a0"; -} - -.fa-toilet-paper-reverse { - --fa: "\e2a0"; -} - -.fa-toilet-paper-reverse-alt { - --fa: "\e2a0"; -} - -.fa-toilet-paper-under-slash { - --fa: "\e2a1"; -} - -.fa-toilet-paper-reverse-slash { - --fa: "\e2a1"; -} - -.fa-tower-control { - --fa: "\e2a2"; -} - -.fa-train-subway-tunnel { - --fa: "\e2a3"; -} - -.fa-subway-tunnel { - --fa: "\e2a3"; -} - -.fa-transformer-bolt { - --fa: "\e2a4"; -} - -.fa-transporter-4 { - --fa: "\e2a5"; -} - -.fa-transporter-5 { - --fa: "\e2a6"; -} - -.fa-transporter-6 { - --fa: "\e2a7"; -} - -.fa-transporter-7 { - --fa: "\e2a8"; -} - -.fa-trash-can-check { - --fa: "\e2a9"; -} - -.fa-trash-can-clock { - --fa: "\e2aa"; -} - -.fa-trash-can-list { - --fa: "\e2ab"; -} - -.fa-trash-can-plus { - --fa: "\e2ac"; -} - -.fa-trash-can-slash { - --fa: "\e2ad"; -} - -.fa-trash-alt-slash { - --fa: "\e2ad"; -} - -.fa-trash-can-xmark { - --fa: "\e2ae"; -} - -.fa-trash-check { - --fa: "\e2af"; -} - -.fa-trash-clock { - --fa: "\e2b0"; -} - -.fa-trash-list { - --fa: "\e2b1"; -} - -.fa-trash-plus { - --fa: "\e2b2"; -} - -.fa-trash-slash { - --fa: "\e2b3"; -} - -.fa-trash-xmark { - --fa: "\e2b4"; -} - -.fa-truck-container-empty { - --fa: "\e2b5"; -} - -.fa-truck-flatbed { - --fa: "\e2b6"; -} - -.fa-truck-front { - --fa: "\e2b7"; -} - -.fa-truck-tow { - --fa: "\e2b8"; -} - -.fa-tty-answer { - --fa: "\e2b9"; -} - -.fa-teletype-answer { - --fa: "\e2b9"; -} - -.fa-tugrik-sign { - --fa: "\e2ba"; -} - -.fa-turkish-lira-sign { - --fa: "\e2bb"; -} - -.fa-try { - --fa: "\e2bb"; -} - -.fa-turkish-lira { - --fa: "\e2bb"; -} - -.fa-umbrella-simple { - --fa: "\e2bc"; -} - -.fa-umbrella-alt { - --fa: "\e2bc"; -} - -.fa-up-left { - --fa: "\e2bd"; -} - -.fa-up-right { - --fa: "\e2be"; -} - -.fa-user-bounty-hunter { - --fa: "\e2bf"; -} - -.fa-user-pilot { - --fa: "\e2c0"; -} - -.fa-user-pilot-tie { - --fa: "\e2c1"; -} - -.fa-user-shakespeare { - --fa: "\e2c2"; -} - -.fa-utility-pole { - --fa: "\e2c3"; -} - -.fa-utility-pole-double { - --fa: "\e2c4"; -} - -.fa-vault { - --fa: "\e2c5"; -} - -.fa-video-arrow-down-left { - --fa: "\e2c8"; -} - -.fa-video-arrow-up-right { - --fa: "\e2c9"; -} - -.fa-wand-magic-sparkles { - --fa: "\e2ca"; -} - -.fa-magic-wand-sparkles { - --fa: "\e2ca"; -} - -.fa-watch-apple { - --fa: "\e2cb"; -} - -.fa-watch-smart { - --fa: "\e2cc"; -} - -.fa-wheat-awn { - --fa: "\e2cd"; -} - -.fa-wheat-alt { - --fa: "\e2cd"; -} - -.fa-wheelchair-move { - --fa: "\e2ce"; -} - -.fa-wheelchair-alt { - --fa: "\e2ce"; -} - -.fa-wifi-exclamation { - --fa: "\e2cf"; -} - -.fa-wrench-simple { - --fa: "\e2d1"; -} - -.fa-robot-astromech { - --fa: "\e2d2"; -} - -.fa-360-degrees { - --fa: "\e2dc"; -} - -.fa-aperture { - --fa: "\e2df"; -} - -.fa-arrow-turn-down-left { - --fa: "\e2e1"; -} - -.fa-balloon { - --fa: "\e2e3"; -} - -.fa-balloons { - --fa: "\e2e4"; -} - -.fa-banana { - --fa: "\e2e5"; -} - -.fa-bangladeshi-taka-sign { - --fa: "\e2e6"; -} - -.fa-bench-tree { - --fa: "\e2e7"; -} - -.fa-blueberries { - --fa: "\e2e8"; -} - -.fa-bowl-chopsticks { - --fa: "\e2e9"; -} - -.fa-bowl-chopsticks-noodles { - --fa: "\e2ea"; -} - -.fa-bowl-rice { - --fa: "\e2eb"; -} - -.fa-briefcase-arrow-right { - --fa: "\e2f2"; -} - -.fa-citrus { - --fa: "\e2f4"; -} - -.fa-citrus-slice { - --fa: "\e2f5"; -} - -.fa-coconut { - --fa: "\e2f6"; -} - -.fa-display-slash { - --fa: "\e2fa"; -} - -.fa-desktop-slash { - --fa: "\e2fa"; -} - -.fa-face-explode { - --fa: "\e2fe"; -} - -.fa-exploding-head { - --fa: "\e2fe"; -} - -.fa-face-viewfinder { - --fa: "\e2ff"; -} - -.fa-family { - --fa: "\e300"; -} - -.fa-family-dress { - --fa: "\e301"; -} - -.fa-family-pants { - --fa: "\e302"; -} - -.fa-fence { - --fa: "\e303"; -} - -.fa-fish-bones { - --fa: "\e304"; -} - -.fa-grapes { - --fa: "\e306"; -} - -.fa-kiwi-fruit { - --fa: "\e30c"; -} - -.fa-mango { - --fa: "\e30f"; -} - -.fa-melon { - --fa: "\e310"; -} - -.fa-melon-slice { - --fa: "\e311"; -} - -.fa-money-from-bracket { - --fa: "\e312"; -} - -.fa-money-simple-from-bracket { - --fa: "\e313"; -} - -.fa-olive { - --fa: "\e316"; -} - -.fa-olive-branch { - --fa: "\e317"; -} - -.fa-option { - --fa: "\e318"; -} - -.fa-party-bell { - --fa: "\e31a"; -} - -.fa-party-horn { - --fa: "\e31b"; -} - -.fa-peapod { - --fa: "\e31c"; -} - -.fa-person-pregnant { - --fa: "\e31e"; -} - -.fa-pineapple { - --fa: "\e31f"; -} - -.fa-rectangle-code { - --fa: "\e322"; -} - -.fa-rectangles-mixed { - --fa: "\e323"; -} - -.fa-roller-coaster { - --fa: "\e324"; -} - -.fa-square-quote { - --fa: "\e329"; -} - -.fa-square-terminal { - --fa: "\e32a"; -} - -.fa-strawberry { - --fa: "\e32b"; -} - -.fa-table-picnic { - --fa: "\e32d"; -} - -.fa-thought-bubble { - --fa: "\e32e"; -} - -.fa-tick { - --fa: "\e32f"; -} - -.fa-tomato { - --fa: "\e330"; -} - -.fa-turn-down-left { - --fa: "\e331"; -} - -.fa-user-police { - --fa: "\e333"; -} - -.fa-user-police-tie { - --fa: "\e334"; -} - -.fa-watermelon-slice { - --fa: "\e337"; -} - -.fa-wheat-awn-slash { - --fa: "\e338"; -} - -.fa-wheat-slash { - --fa: "\e339"; -} - -.fa-badminton { - --fa: "\e33a"; -} - -.fa-binary { - --fa: "\e33b"; -} - -.fa-binary-circle-check { - --fa: "\e33c"; -} - -.fa-binary-lock { - --fa: "\e33d"; -} - -.fa-binary-slash { - --fa: "\e33e"; -} - -.fa-boot-heeled { - --fa: "\e33f"; -} - -.fa-car-bolt { - --fa: "\e341"; -} - -.fa-car-circle-bolt { - --fa: "\e342"; -} - -.fa-car-mirrors { - --fa: "\e343"; -} - -.fa-car-side-bolt { - --fa: "\e344"; -} - -.fa-clock-eight { - --fa: "\e345"; -} - -.fa-clock-eight-thirty { - --fa: "\e346"; -} - -.fa-clock-eleven { - --fa: "\e347"; -} - -.fa-clock-eleven-thirty { - --fa: "\e348"; -} - -.fa-clock-five { - --fa: "\e349"; -} - -.fa-clock-five-thirty { - --fa: "\e34a"; -} - -.fa-clock-four-thirty { - --fa: "\e34b"; -} - -.fa-clock-nine { - --fa: "\e34c"; -} - -.fa-clock-nine-thirty { - --fa: "\e34d"; -} - -.fa-clock-one { - --fa: "\e34e"; -} - -.fa-clock-one-thirty { - --fa: "\e34f"; -} - -.fa-clock-seven { - --fa: "\e350"; -} - -.fa-clock-seven-thirty { - --fa: "\e351"; -} - -.fa-clock-six { - --fa: "\e352"; -} - -.fa-clock-six-thirty { - --fa: "\e353"; -} - -.fa-clock-ten { - --fa: "\e354"; -} - -.fa-clock-ten-thirty { - --fa: "\e355"; -} - -.fa-clock-three { - --fa: "\e356"; -} - -.fa-clock-three-thirty { - --fa: "\e357"; -} - -.fa-clock-twelve { - --fa: "\e358"; -} - -.fa-clock-twelve-thirty { - --fa: "\e359"; -} - -.fa-clock-two { - --fa: "\e35a"; -} - -.fa-clock-two-thirty { - --fa: "\e35b"; -} - -.fa-cloud-check { - --fa: "\e35c"; -} - -.fa-cloud-minus { - --fa: "\e35d"; -} - -.fa-cloud-plus { - --fa: "\e35e"; -} - -.fa-cloud-xmark { - --fa: "\e35f"; -} - -.fa-columns-3 { - --fa: "\e361"; -} - -.fa-crystal-ball { - --fa: "\e362"; -} - -.fa-cup-straw { - --fa: "\e363"; -} - -.fa-cup-straw-swoosh { - --fa: "\e364"; -} - -.fa-distribute-spacing-horizontal { - --fa: "\e365"; -} - -.fa-distribute-spacing-vertical { - --fa: "\e366"; -} - -.fa-eyes { - --fa: "\e367"; -} - -.fa-face-angry-horns { - --fa: "\e368"; -} - -.fa-face-anguished { - --fa: "\e369"; -} - -.fa-face-anxious-sweat { - --fa: "\e36a"; -} - -.fa-face-astonished { - --fa: "\e36b"; -} - -.fa-face-confounded { - --fa: "\e36c"; -} - -.fa-face-confused { - --fa: "\e36d"; -} - -.fa-face-cowboy-hat { - --fa: "\e36e"; -} - -.fa-face-disappointed { - --fa: "\e36f"; -} - -.fa-face-disguise { - --fa: "\e370"; -} - -.fa-face-downcast-sweat { - --fa: "\e371"; -} - -.fa-face-drooling { - --fa: "\e372"; -} - -.fa-face-expressionless { - --fa: "\e373"; -} - -.fa-face-eyes-xmarks { - --fa: "\e374"; -} - -.fa-face-fearful { - --fa: "\e375"; -} - -.fa-face-frown-slight { - --fa: "\e376"; -} - -.fa-face-glasses { - --fa: "\e377"; -} - -.fa-face-hand-over-mouth { - --fa: "\e378"; -} - -.fa-face-hand-yawn { - --fa: "\e379"; -} - -.fa-face-head-bandage { - --fa: "\e37a"; -} - -.fa-face-hushed { - --fa: "\e37b"; -} - -.fa-face-icicles { - --fa: "\e37c"; -} - -.fa-face-kiss-closed-eyes { - --fa: "\e37d"; -} - -.fa-face-lying { - --fa: "\e37e"; -} - -.fa-face-mask { - --fa: "\e37f"; -} - -.fa-face-monocle { - --fa: "\e380"; -} - -.fa-face-nauseated { - --fa: "\e381"; -} - -.fa-face-nose-steam { - --fa: "\e382"; -} - -.fa-face-party { - --fa: "\e383"; -} - -.fa-face-pensive { - --fa: "\e384"; -} - -.fa-face-persevering { - --fa: "\e385"; -} - -.fa-face-pleading { - --fa: "\e386"; -} - -.fa-face-pouting { - --fa: "\e387"; -} - -.fa-face-raised-eyebrow { - --fa: "\e388"; -} - -.fa-face-relieved { - --fa: "\e389"; -} - -.fa-face-sad-sweat { - --fa: "\e38a"; -} - -.fa-face-scream { - --fa: "\e38b"; -} - -.fa-face-shush { - --fa: "\e38c"; -} - -.fa-face-sleeping { - --fa: "\e38d"; -} - -.fa-face-sleepy { - --fa: "\e38e"; -} - -.fa-face-smile-halo { - --fa: "\e38f"; -} - -.fa-face-smile-hearts { - --fa: "\e390"; -} - -.fa-face-smile-horns { - --fa: "\e391"; -} - -.fa-face-smile-relaxed { - --fa: "\e392"; -} - -.fa-face-smile-tear { - --fa: "\e393"; -} - -.fa-face-smile-tongue { - --fa: "\e394"; -} - -.fa-face-smile-upside-down { - --fa: "\e395"; -} - -.fa-face-smiling-hands { - --fa: "\e396"; -} - -.fa-face-smirking { - --fa: "\e397"; -} - -.fa-face-sunglasses { - --fa: "\e398"; -} - -.fa-face-swear { - --fa: "\e399"; -} - -.fa-face-thermometer { - --fa: "\e39a"; -} - -.fa-face-thinking { - --fa: "\e39b"; -} - -.fa-face-tissue { - --fa: "\e39c"; -} - -.fa-face-tongue-money { - --fa: "\e39d"; -} - -.fa-face-tongue-sweat { - --fa: "\e39e"; -} - -.fa-face-unamused { - --fa: "\e39f"; -} - -.fa-face-vomit { - --fa: "\e3a0"; -} - -.fa-face-weary { - --fa: "\e3a1"; -} - -.fa-face-woozy { - --fa: "\e3a2"; -} - -.fa-face-worried { - --fa: "\e3a3"; -} - -.fa-face-zany { - --fa: "\e3a4"; -} - -.fa-face-zipper { - --fa: "\e3a5"; -} - -.fa-file-lock { - --fa: "\e3a6"; -} - -.fa-file-slash { - --fa: "\e3a7"; -} - -.fa-fishing-rod { - --fa: "\e3a8"; -} - -.fa-flying-disc { - --fa: "\e3a9"; -} - -.fa-gallery-thumbnails { - --fa: "\e3aa"; -} - -.fa-goal-net { - --fa: "\e3ab"; -} - -.fa-golf-flag-hole { - --fa: "\e3ac"; -} - -.fa-grid-dividers { - --fa: "\e3ad"; -} - -.fa-hockey-stick-puck { - --fa: "\e3ae"; -} - -.fa-house-chimney { - --fa: "\e3af"; -} - -.fa-home-lg { - --fa: "\e3af"; -} - -.fa-house-chimney-blank { - --fa: "\e3b0"; -} - -.fa-house-crack { - --fa: "\e3b1"; -} - -.fa-house-medical { - --fa: "\e3b2"; -} - -.fa-house-window { - --fa: "\e3b3"; -} - -.fa-key-skeleton-left-right { - --fa: "\e3b4"; -} - -.fa-lacrosse-stick { - --fa: "\e3b5"; -} - -.fa-lacrosse-stick-ball { - --fa: "\e3b6"; -} - -.fa-mask-snorkel { - --fa: "\e3b7"; -} - -.fa-message-bot { - --fa: "\e3b8"; -} - -.fa-moped { - --fa: "\e3b9"; -} - -.fa-nesting-dolls { - --fa: "\e3ba"; -} - -.fa-objects-align-bottom { - --fa: "\e3bb"; -} - -.fa-objects-align-center-horizontal { - --fa: "\e3bc"; -} - -.fa-objects-align-center-vertical { - --fa: "\e3bd"; -} - -.fa-objects-align-left { - --fa: "\e3be"; -} - -.fa-objects-align-right { - --fa: "\e3bf"; -} - -.fa-objects-align-top { - --fa: "\e3c0"; -} - -.fa-objects-column { - --fa: "\e3c1"; -} - -.fa-paperclip-vertical { - --fa: "\e3c2"; -} - -.fa-pinata { - --fa: "\e3c3"; -} - -.fa-pipe-smoking { - --fa: "\e3c4"; -} - -.fa-pool-8-ball { - --fa: "\e3c5"; -} - -.fa-rugby-ball { - --fa: "\e3c6"; -} - -.fa-shirt-long-sleeve { - --fa: "\e3c7"; -} - -.fa-shirt-running { - --fa: "\e3c8"; -} - -.fa-shirt-tank-top { - --fa: "\e3c9"; -} - -.fa-signature-lock { - --fa: "\e3ca"; -} - -.fa-signature-slash { - --fa: "\e3cb"; -} - -.fa-ski-boot { - --fa: "\e3cc"; -} - -.fa-ski-boot-ski { - --fa: "\e3cd"; -} - -.fa-slot-machine { - --fa: "\e3ce"; -} - -.fa-teddy-bear { - --fa: "\e3cf"; -} - -.fa-truck-bolt { - --fa: "\e3d0"; -} - -.fa-uniform-martial-arts { - --fa: "\e3d1"; -} - -.fa-user-chef { - --fa: "\e3d2"; -} - -.fa-user-hair-buns { - --fa: "\e3d3"; -} - -.fa-arrow-left-long-to-line { - --fa: "\e3d4"; -} - -.fa-arrow-right-long-to-line { - --fa: "\e3d5"; -} - -.fa-arrow-turn-down-right { - --fa: "\e3d6"; -} - -.fa-bagel { - --fa: "\e3d7"; -} - -.fa-baguette { - --fa: "\e3d8"; -} - -.fa-blanket-fire { - --fa: "\e3da"; -} - -.fa-block-brick { - --fa: "\e3db"; -} - -.fa-wall-brick { - --fa: "\e3db"; -} - -.fa-block-brick-fire { - --fa: "\e3dc"; -} - -.fa-firewall { - --fa: "\e3dc"; -} - -.fa-block-question { - --fa: "\e3dd"; -} - -.fa-bowl-scoop { - --fa: "\e3de"; -} - -.fa-bowl-shaved-ice { - --fa: "\e3de"; -} - -.fa-bowl-scoops { - --fa: "\e3df"; -} - -.fa-bowl-spoon { - --fa: "\e3e0"; -} - -.fa-bread-slice-butter { - --fa: "\e3e1"; -} - -.fa-broccoli { - --fa: "\e3e2"; -} - -.fa-burger-lettuce { - --fa: "\e3e3"; -} - -.fa-butter { - --fa: "\e3e4"; -} - -.fa-cake-slice { - --fa: "\e3e5"; -} - -.fa-shortcake { - --fa: "\e3e5"; -} - -.fa-can-food { - --fa: "\e3e6"; -} - -.fa-candy { - --fa: "\e3e7"; -} - -.fa-candy-bar { - --fa: "\e3e8"; -} - -.fa-chocolate-bar { - --fa: "\e3e8"; -} - -.fa-card-club { - --fa: "\e3e9"; -} - -.fa-card-diamond { - --fa: "\e3ea"; -} - -.fa-card-heart { - --fa: "\e3eb"; -} - -.fa-card-spade { - --fa: "\e3ec"; -} - -.fa-cards { - --fa: "\e3ed"; -} - -.fa-cart-arrow-up { - --fa: "\e3ee"; -} - -.fa-cart-circle-arrow-down { - --fa: "\e3ef"; -} - -.fa-cart-circle-arrow-up { - --fa: "\e3f0"; -} - -.fa-cart-circle-check { - --fa: "\e3f1"; -} - -.fa-cart-circle-exclamation { - --fa: "\e3f2"; -} - -.fa-cart-circle-plus { - --fa: "\e3f3"; -} - -.fa-cart-circle-xmark { - --fa: "\e3f4"; -} - -.fa-cent-sign { - --fa: "\e3f5"; -} - -.fa-chestnut { - --fa: "\e3f6"; -} - -.fa-chopsticks { - --fa: "\e3f7"; -} - -.fa-circle-quarters { - --fa: "\e3f8"; -} - -.fa-code-pull-request-closed { - --fa: "\e3f9"; -} - -.fa-code-pull-request-draft { - --fa: "\e3fa"; -} - -.fa-coin-blank { - --fa: "\e3fb"; -} - -.fa-coin-front { - --fa: "\e3fc"; -} - -.fa-coin-vertical { - --fa: "\e3fd"; -} - -.fa-corner { - --fa: "\e3fe"; -} - -.fa-crab { - --fa: "\e3ff"; -} - -.fa-soft-serve { - --fa: "\e400"; -} - -.fa-creemee { - --fa: "\e400"; -} - -.fa-cucumber { - --fa: "\e401"; -} - -.fa-cupcake { - --fa: "\e402"; -} - -.fa-custard { - --fa: "\e403"; -} - -.fa-dash { - --fa: "\e404"; -} - -.fa-minus-large { - --fa: "\e404"; -} - -.fa-diamond-exclamation { - --fa: "\e405"; -} - -.fa-donut { - --fa: "\e406"; -} - -.fa-doughnut { - --fa: "\e406"; -} - -.fa-down-from-dotted-line { - --fa: "\e407"; -} - -.fa-down-to-dotted-line { - --fa: "\e408"; -} - -.fa-face-awesome { - --fa: "\e409"; -} - -.fa-gave-dandy { - --fa: "\e409"; -} - -.fa-falafel { - --fa: "\e40a"; -} - -.fa-flatbread { - --fa: "\e40b"; -} - -.fa-flatbread-stuffed { - --fa: "\e40c"; -} - -.fa-fondue-pot { - --fa: "\e40d"; -} - -.fa-garlic { - --fa: "\e40e"; -} - -.fa-grip-dots { - --fa: "\e410"; -} - -.fa-grip-dots-vertical { - --fa: "\e411"; -} - -.fa-h5 { - --fa: "\e412"; -} - -.fa-h6 { - --fa: "\e413"; -} - -.fa-hammer-crash { - --fa: "\e414"; -} - -.fa-hashtag-lock { - --fa: "\e415"; -} - -.fa-hexagon-check { - --fa: "\e416"; -} - -.fa-hexagon-exclamation { - --fa: "\e417"; -} - -.fa-honey-pot { - --fa: "\e418"; -} - -.fa-hose { - --fa: "\e419"; -} - -.fa-hose-reel { - --fa: "\e41a"; -} - -.fa-hourglass-clock { - --fa: "\e41b"; -} - -.fa-hundred-points { - --fa: "\e41c"; -} - -.fa-100 { - --fa: "\e41c"; -} - -.fa-leafy-green { - --fa: "\e41d"; -} - -.fa-left-long-to-line { - --fa: "\e41e"; -} - -.fa-light-emergency { - --fa: "\e41f"; -} - -.fa-light-emergency-on { - --fa: "\e420"; -} - -.fa-lobster { - --fa: "\e421"; -} - -.fa-lock-a { - --fa: "\e422"; -} - -.fa-lock-hashtag { - --fa: "\e423"; -} - -.fa-lollipop { - --fa: "\e424"; -} - -.fa-lollypop { - --fa: "\e424"; -} - -.fa-mushroom { - --fa: "\e425"; -} - -.fa-octagon-check { - --fa: "\e426"; -} - -.fa-onion { - --fa: "\e427"; -} - -.fa-page { - --fa: "\e428"; -} - -.fa-page-caret-down { - --fa: "\e429"; -} - -.fa-file-caret-down { - --fa: "\e429"; -} - -.fa-page-caret-up { - --fa: "\e42a"; -} - -.fa-file-caret-up { - --fa: "\e42a"; -} - -.fa-pan-food { - --fa: "\e42b"; -} - -.fa-pan-frying { - --fa: "\e42c"; -} - -.fa-pancakes { - --fa: "\e42d"; -} - -.fa-panel-ews { - --fa: "\e42e"; -} - -.fa-panel-fire { - --fa: "\e42f"; -} - -.fa-peanut { - --fa: "\e430"; -} - -.fa-peanuts { - --fa: "\e431"; -} - -.fa-pepper { - --fa: "\e432"; -} - -.fa-person-to-door { - --fa: "\e433"; -} - -.fa-phone-intercom { - --fa: "\e434"; -} - -.fa-pickleball { - --fa: "\e435"; -} - -.fa-pipe-circle-check { - --fa: "\e436"; -} - -.fa-pipe-collar { - --fa: "\e437"; -} - -.fa-pipe-section { - --fa: "\e438"; -} - -.fa-pipe-valve { - --fa: "\e439"; -} - -.fa-plate-utensils { - --fa: "\e43b"; -} - -.fa-plus-minus { - --fa: "\e43c"; -} - -.fa-pompebled { - --fa: "\e43d"; -} - -.fa-popsicle { - --fa: "\e43e"; -} - -.fa-pot-food { - --fa: "\e43f"; -} - -.fa-potato { - --fa: "\e440"; -} - -.fa-pretzel { - --fa: "\e441"; -} - -.fa-pump { - --fa: "\e442"; -} - -.fa-puzzle { - --fa: "\e443"; -} - -.fa-right-long-to-line { - --fa: "\e444"; -} - -.fa-sailboat { - --fa: "\e445"; -} - -.fa-salt-shaker { - --fa: "\e446"; -} - -.fa-section { - --fa: "\e447"; -} - -.fa-shrimp { - --fa: "\e448"; -} - -.fa-shutters { - --fa: "\e449"; -} - -.fa-sportsball { - --fa: "\e44b"; -} - -.fa-sprinkler-ceiling { - --fa: "\e44c"; -} - -.fa-square-a-lock { - --fa: "\e44d"; -} - -.fa-square-quarters { - --fa: "\e44e"; -} - -.fa-square-ring { - --fa: "\e44f"; -} - -.fa-squid { - --fa: "\e450"; -} - -.fa-tamale { - --fa: "\e451"; -} - -.fa-tank-water { - --fa: "\e452"; -} - -.fa-train-track { - --fa: "\e453"; -} - -.fa-train-tunnel { - --fa: "\e454"; -} - -.fa-turn-down-right { - --fa: "\e455"; -} - -.fa-up-from-dotted-line { - --fa: "\e456"; -} - -.fa-up-to-dotted-line { - --fa: "\e457"; -} - -.fa-user-doctor-hair { - --fa: "\e458"; -} - -.fa-user-doctor-hair-long { - --fa: "\e459"; -} - -.fa-user-hair { - --fa: "\e45a"; -} - -.fa-user-hair-long { - --fa: "\e45b"; -} - -.fa-user-hair-mullet { - --fa: "\e45c"; -} - -.fa-business-front { - --fa: "\e45c"; -} - -.fa-party-back { - --fa: "\e45c"; -} - -.fa-trian-balbot { - --fa: "\e45c"; -} - -.fa-user-nurse-hair { - --fa: "\e45d"; -} - -.fa-user-nurse-hair-long { - --fa: "\e45e"; -} - -.fa-user-tie-hair { - --fa: "\e45f"; -} - -.fa-user-tie-hair-long { - --fa: "\e460"; -} - -.fa-user-vneck { - --fa: "\e461"; -} - -.fa-user-vneck-hair { - --fa: "\e462"; -} - -.fa-user-vneck-hair-long { - --fa: "\e463"; -} - -.fa-utensils-slash { - --fa: "\e464"; -} - -.fa-vent-damper { - --fa: "\e465"; -} - -.fa-waffle { - --fa: "\e466"; -} - -.fa-00 { - --fa: "\e467"; -} - -.fa-apartment { - --fa: "\e468"; -} - -.fa-bird { - --fa: "\e469"; -} - -.fa-block { - --fa: "\e46a"; -} - -.fa-bowl-soft-serve { - --fa: "\e46b"; -} - -.fa-brazilian-real-sign { - --fa: "\e46c"; -} - -.fa-cabin { - --fa: "\e46d"; -} - -.fa-calendar-circle-exclamation { - --fa: "\e46e"; -} - -.fa-calendar-circle-minus { - --fa: "\e46f"; -} - -.fa-calendar-circle-plus { - --fa: "\e470"; -} - -.fa-calendar-circle-user { - --fa: "\e471"; -} - -.fa-calendar-lines-pen { - --fa: "\e472"; -} - -.fa-chart-simple { - --fa: "\e473"; -} - -.fa-chart-simple-horizontal { - --fa: "\e474"; -} - -.fa-diagram-cells { - --fa: "\e475"; -} - -.fa-diagram-next { - --fa: "\e476"; -} - -.fa-diagram-predecessor { - --fa: "\e477"; -} - -.fa-diagram-previous { - --fa: "\e478"; -} - -.fa-diagram-subtask { - --fa: "\e479"; -} - -.fa-diagram-successor { - --fa: "\e47a"; -} - -.fa-earth-oceania { - --fa: "\e47b"; -} - -.fa-globe-oceania { - --fa: "\e47b"; -} - -.fa-face-beam-hand-over-mouth { - --fa: "\e47c"; -} - -.fa-face-clouds { - --fa: "\e47d"; -} - -.fa-face-diagonal-mouth { - --fa: "\e47e"; -} - -.fa-face-dotted { - --fa: "\e47f"; -} - -.fa-face-exhaling { - --fa: "\e480"; -} - -.fa-face-hand-peeking { - --fa: "\e481"; -} - -.fa-face-holding-back-tears { - --fa: "\e482"; -} - -.fa-face-melting { - --fa: "\e483"; -} - -.fa-face-saluting { - --fa: "\e484"; -} - -.fa-face-spiral-eyes { - --fa: "\e485"; -} - -.fa-fort { - --fa: "\e486"; -} - -.fa-house-blank { - --fa: "\e487"; -} - -.fa-home-blank { - --fa: "\e487"; -} - -.fa-square-kanban { - --fa: "\e488"; -} - -.fa-square-list { - --fa: "\e489"; -} - -.fa-sushi { - --fa: "\e48a"; -} - -.fa-nigiri { - --fa: "\e48a"; -} - -.fa-sushi-roll { - --fa: "\e48b"; -} - -.fa-maki-roll { - --fa: "\e48b"; -} - -.fa-makizushi { - --fa: "\e48b"; -} - -.fa-album-circle-plus { - --fa: "\e48c"; -} - -.fa-album-circle-user { - --fa: "\e48d"; -} - -.fa-album-collection-circle-plus { - --fa: "\e48e"; -} - -.fa-album-collection-circle-user { - --fa: "\e48f"; -} - -.fa-bug-slash { - --fa: "\e490"; -} - -.fa-cloud-exclamation { - --fa: "\e491"; -} - -.fa-cloud-question { - --fa: "\e492"; -} - -.fa-file-circle-info { - --fa: "\e493"; -} - -.fa-file-circle-plus { - --fa: "\e494"; -} - -.fa-frame { - --fa: "\e495"; -} - -.fa-gauge-circle-bolt { - --fa: "\e496"; -} - -.fa-gauge-circle-minus { - --fa: "\e497"; -} - -.fa-gauge-circle-plus { - --fa: "\e498"; -} - -.fa-memo-circle-info { - --fa: "\e49a"; -} - -.fa-object-exclude { - --fa: "\e49c"; -} - -.fa-object-intersect { - --fa: "\e49d"; -} - -.fa-object-subtract { - --fa: "\e49e"; -} - -.fa-object-union { - --fa: "\e49f"; -} - -.fa-pen-nib-slash { - --fa: "\e4a1"; -} - -.fa-rectangle-history { - --fa: "\e4a2"; -} - -.fa-rectangle-history-circle-plus { - --fa: "\e4a3"; -} - -.fa-rectangle-history-circle-user { - --fa: "\e4a4"; -} - -.fa-shop-lock { - --fa: "\e4a5"; -} - -.fa-store-lock { - --fa: "\e4a6"; -} - -.fa-user-robot-xmarks { - --fa: "\e4a7"; -} - -.fa-virus-covid { - --fa: "\e4a8"; -} - -.fa-virus-covid-slash { - --fa: "\e4a9"; -} - -.fa-anchor-circle-check { - --fa: "\e4aa"; -} - -.fa-anchor-circle-exclamation { - --fa: "\e4ab"; -} - -.fa-anchor-circle-xmark { - --fa: "\e4ac"; -} - -.fa-anchor-lock { - --fa: "\e4ad"; -} - -.fa-arrow-down-to-arc { - --fa: "\e4ae"; -} - -.fa-arrow-down-up-across-line { - --fa: "\e4af"; -} - -.fa-arrow-down-up-lock { - --fa: "\e4b0"; -} - -.fa-arrow-right-from-arc { - --fa: "\e4b1"; -} - -.fa-arrow-right-to-arc { - --fa: "\e4b2"; -} - -.fa-arrow-right-to-city { - --fa: "\e4b3"; -} - -.fa-arrow-up-from-arc { - --fa: "\e4b4"; -} - -.fa-arrow-up-from-ground-water { - --fa: "\e4b5"; -} - -.fa-arrow-up-from-water-pump { - --fa: "\e4b6"; -} - -.fa-arrow-up-right-dots { - --fa: "\e4b7"; -} - -.fa-arrows-down-to-line { - --fa: "\e4b8"; -} - -.fa-arrows-down-to-people { - --fa: "\e4b9"; -} - -.fa-arrows-left-right-to-line { - --fa: "\e4ba"; -} - -.fa-arrows-spin { - --fa: "\e4bb"; -} - -.fa-arrows-split-up-and-left { - --fa: "\e4bc"; -} - -.fa-arrows-to-circle { - --fa: "\e4bd"; -} - -.fa-arrows-to-dot { - --fa: "\e4be"; -} - -.fa-arrows-to-eye { - --fa: "\e4bf"; -} - -.fa-arrows-turn-right { - --fa: "\e4c0"; -} - -.fa-arrows-turn-to-dots { - --fa: "\e4c1"; -} - -.fa-arrows-up-to-line { - --fa: "\e4c2"; -} - -.fa-bore-hole { - --fa: "\e4c3"; -} - -.fa-bottle-droplet { - --fa: "\e4c4"; -} - -.fa-bottle-water { - --fa: "\e4c5"; -} - -.fa-bowl-food { - --fa: "\e4c6"; -} - -.fa-boxes-packing { - --fa: "\e4c7"; -} - -.fa-bridge { - --fa: "\e4c8"; -} - -.fa-bridge-circle-check { - --fa: "\e4c9"; -} - -.fa-bridge-circle-exclamation { - --fa: "\e4ca"; -} - -.fa-bridge-circle-xmark { - --fa: "\e4cb"; -} - -.fa-bridge-lock { - --fa: "\e4cc"; -} - -.fa-bridge-suspension { - --fa: "\e4cd"; -} - -.fa-bridge-water { - --fa: "\e4ce"; -} - -.fa-bucket { - --fa: "\e4cf"; -} - -.fa-bugs { - --fa: "\e4d0"; -} - -.fa-building-circle-arrow-right { - --fa: "\e4d1"; -} - -.fa-building-circle-check { - --fa: "\e4d2"; -} - -.fa-building-circle-exclamation { - --fa: "\e4d3"; -} - -.fa-building-circle-xmark { - --fa: "\e4d4"; -} - -.fa-building-flag { - --fa: "\e4d5"; -} - -.fa-building-lock { - --fa: "\e4d6"; -} - -.fa-building-ngo { - --fa: "\e4d7"; -} - -.fa-building-shield { - --fa: "\e4d8"; -} - -.fa-building-un { - --fa: "\e4d9"; -} - -.fa-building-user { - --fa: "\e4da"; -} - -.fa-building-wheat { - --fa: "\e4db"; -} - -.fa-burst { - --fa: "\e4dc"; -} - -.fa-car-on { - --fa: "\e4dd"; -} - -.fa-car-tunnel { - --fa: "\e4de"; -} - -.fa-cards-blank { - --fa: "\e4df"; -} - -.fa-child-combatant { - --fa: "\e4e0"; -} - -.fa-child-rifle { - --fa: "\e4e0"; -} - -.fa-children { - --fa: "\e4e1"; -} - -.fa-circle-nodes { - --fa: "\e4e2"; -} - -.fa-clipboard-question { - --fa: "\e4e3"; -} - -.fa-cloud-showers-water { - --fa: "\e4e4"; -} - -.fa-computer { - --fa: "\e4e5"; -} - -.fa-cubes-stacked { - --fa: "\e4e6"; -} - -.fa-down-to-bracket { - --fa: "\e4e7"; -} - -.fa-envelope-circle-check { - --fa: "\e4e8"; -} - -.fa-explosion { - --fa: "\e4e9"; -} - -.fa-ferry { - --fa: "\e4ea"; -} - -.fa-file-circle-exclamation { - --fa: "\e4eb"; -} - -.fa-file-circle-minus { - --fa: "\e4ed"; -} - -.fa-file-circle-question { - --fa: "\e4ef"; -} - -.fa-file-shield { - --fa: "\e4f0"; -} - -.fa-fire-burner { - --fa: "\e4f1"; -} - -.fa-fish-fins { - --fa: "\e4f2"; -} - -.fa-flask-vial { - --fa: "\e4f3"; -} - -.fa-glass-water { - --fa: "\e4f4"; -} - -.fa-glass-water-droplet { - --fa: "\e4f5"; -} - -.fa-group-arrows-rotate { - --fa: "\e4f6"; -} - -.fa-hand-holding-hand { - --fa: "\e4f7"; -} - -.fa-handcuffs { - --fa: "\e4f8"; -} - -.fa-hands-bound { - --fa: "\e4f9"; -} - -.fa-hands-holding-child { - --fa: "\e4fa"; -} - -.fa-hands-holding-circle { - --fa: "\e4fb"; -} - -.fa-heart-circle-bolt { - --fa: "\e4fc"; -} - -.fa-heart-circle-check { - --fa: "\e4fd"; -} - -.fa-heart-circle-exclamation { - --fa: "\e4fe"; -} - -.fa-heart-circle-minus { - --fa: "\e4ff"; -} - -.fa-heart-circle-plus { - --fa: "\e500"; -} - -.fa-heart-circle-xmark { - --fa: "\e501"; -} - -.fa-helicopter-symbol { - --fa: "\e502"; -} - -.fa-helmet-un { - --fa: "\e503"; -} - -.fa-hexagon-image { - --fa: "\e504"; -} - -.fa-hexagon-vertical-nft { - --fa: "\e505"; -} - -.fa-hexagon-vertical-nft-slanted { - --fa: "\e505"; -} - -.fa-hill-avalanche { - --fa: "\e507"; -} - -.fa-hill-rockslide { - --fa: "\e508"; -} - -.fa-house-circle-check { - --fa: "\e509"; -} - -.fa-house-circle-exclamation { - --fa: "\e50a"; -} - -.fa-house-circle-xmark { - --fa: "\e50b"; -} - -.fa-house-fire { - --fa: "\e50c"; -} - -.fa-house-flag { - --fa: "\e50d"; -} - -.fa-house-flood-water { - --fa: "\e50e"; -} - -.fa-house-flood-water-circle-arrow-right { - --fa: "\e50f"; -} - -.fa-house-lock { - --fa: "\e510"; -} - -.fa-house-medical-circle-check { - --fa: "\e511"; -} - -.fa-house-medical-circle-exclamation { - --fa: "\e512"; -} - -.fa-house-medical-circle-xmark { - --fa: "\e513"; -} - -.fa-house-medical-flag { - --fa: "\e514"; -} - -.fa-house-tsunami { - --fa: "\e515"; -} - -.fa-jar { - --fa: "\e516"; -} - -.fa-jar-wheat { - --fa: "\e517"; -} - -.fa-jet-fighter-up { - --fa: "\e518"; -} - -.fa-jug-detergent { - --fa: "\e519"; -} - -.fa-kitchen-set { - --fa: "\e51a"; -} - -.fa-land-mine-on { - --fa: "\e51b"; -} - -.fa-landmark-flag { - --fa: "\e51c"; -} - -.fa-laptop-file { - --fa: "\e51d"; -} - -.fa-lines-leaning { - --fa: "\e51e"; -} - -.fa-location-pin-lock { - --fa: "\e51f"; -} - -.fa-locust { - --fa: "\e520"; -} - -.fa-magnifying-glass-arrow-right { - --fa: "\e521"; -} - -.fa-magnifying-glass-chart { - --fa: "\e522"; -} - -.fa-mars-and-venus-burst { - --fa: "\e523"; -} - -.fa-mask-ventilator { - --fa: "\e524"; -} - -.fa-mattress-pillow { - --fa: "\e525"; -} - -.fa-merge { - --fa: "\e526"; -} - -.fa-mobile-retro { - --fa: "\e527"; -} - -.fa-money-bill-transfer { - --fa: "\e528"; -} - -.fa-money-bill-trend-up { - --fa: "\e529"; -} - -.fa-money-bill-wheat { - --fa: "\e52a"; -} - -.fa-mosquito { - --fa: "\e52b"; -} - -.fa-mosquito-net { - --fa: "\e52c"; -} - -.fa-mound { - --fa: "\e52d"; -} - -.fa-mountain-city { - --fa: "\e52e"; -} - -.fa-mountain-sun { - --fa: "\e52f"; -} - -.fa-nfc-symbol { - --fa: "\e531"; -} - -.fa-oil-well { - --fa: "\e532"; -} - -.fa-people-group { - --fa: "\e533"; -} - -.fa-people-line { - --fa: "\e534"; -} - -.fa-people-pulling { - --fa: "\e535"; -} - -.fa-people-robbery { - --fa: "\e536"; -} - -.fa-people-roof { - --fa: "\e537"; -} - -.fa-person-arrow-down-to-line { - --fa: "\e538"; -} - -.fa-person-arrow-up-from-line { - --fa: "\e539"; -} - -.fa-person-breastfeeding { - --fa: "\e53a"; -} - -.fa-person-burst { - --fa: "\e53b"; -} - -.fa-person-cane { - --fa: "\e53c"; -} - -.fa-person-chalkboard { - --fa: "\e53d"; -} - -.fa-person-circle-check { - --fa: "\e53e"; -} - -.fa-person-circle-exclamation { - --fa: "\e53f"; -} - -.fa-person-circle-minus { - --fa: "\e540"; -} - -.fa-person-circle-plus { - --fa: "\e541"; -} - -.fa-person-circle-question { - --fa: "\e542"; -} - -.fa-person-circle-xmark { - --fa: "\e543"; -} - -.fa-person-dress-burst { - --fa: "\e544"; -} - -.fa-person-drowning { - --fa: "\e545"; -} - -.fa-person-falling { - --fa: "\e546"; -} - -.fa-person-falling-burst { - --fa: "\e547"; -} - -.fa-person-half-dress { - --fa: "\e548"; -} - -.fa-person-harassing { - --fa: "\e549"; -} - -.fa-person-military-pointing { - --fa: "\e54a"; -} - -.fa-person-military-rifle { - --fa: "\e54b"; -} - -.fa-person-military-to-person { - --fa: "\e54c"; -} - -.fa-person-rays { - --fa: "\e54d"; -} - -.fa-person-rifle { - --fa: "\e54e"; -} - -.fa-person-shelter { - --fa: "\e54f"; -} - -.fa-person-walking-arrow-loop-left { - --fa: "\e551"; -} - -.fa-person-walking-arrow-right { - --fa: "\e552"; -} - -.fa-person-walking-dashed-line-arrow-right { - --fa: "\e553"; -} - -.fa-person-walking-luggage { - --fa: "\e554"; -} - -.fa-plane-circle-check { - --fa: "\e555"; -} - -.fa-plane-circle-exclamation { - --fa: "\e556"; -} - -.fa-plane-circle-xmark { - --fa: "\e557"; -} - -.fa-plane-lock { - --fa: "\e558"; -} - -.fa-plate-wheat { - --fa: "\e55a"; -} - -.fa-plug-circle-bolt { - --fa: "\e55b"; -} - -.fa-plug-circle-check { - --fa: "\e55c"; -} - -.fa-plug-circle-exclamation { - --fa: "\e55d"; -} - -.fa-plug-circle-minus { - --fa: "\e55e"; -} - -.fa-plug-circle-plus { - --fa: "\e55f"; -} - -.fa-plug-circle-xmark { - --fa: "\e560"; -} - -.fa-ranking-star { - --fa: "\e561"; -} - -.fa-road-barrier { - --fa: "\e562"; -} - -.fa-road-bridge { - --fa: "\e563"; -} - -.fa-road-circle-check { - --fa: "\e564"; -} - -.fa-road-circle-exclamation { - --fa: "\e565"; -} - -.fa-road-circle-xmark { - --fa: "\e566"; -} - -.fa-road-lock { - --fa: "\e567"; -} - -.fa-road-spikes { - --fa: "\e568"; -} - -.fa-rug { - --fa: "\e569"; -} - -.fa-sack-xmark { - --fa: "\e56a"; -} - -.fa-school-circle-check { - --fa: "\e56b"; -} - -.fa-school-circle-exclamation { - --fa: "\e56c"; -} - -.fa-school-circle-xmark { - --fa: "\e56d"; -} - -.fa-school-flag { - --fa: "\e56e"; -} - -.fa-school-lock { - --fa: "\e56f"; -} - -.fa-sheet-plastic { - --fa: "\e571"; -} - -.fa-shield-cat { - --fa: "\e572"; -} - -.fa-shield-dog { - --fa: "\e573"; -} - -.fa-shield-heart { - --fa: "\e574"; -} - -.fa-shield-quartered { - --fa: "\e575"; -} - -.fa-square-nfi { - --fa: "\e576"; -} - -.fa-square-person-confined { - --fa: "\e577"; -} - -.fa-square-virus { - --fa: "\e578"; -} - -.fa-staff-snake { - --fa: "\e579"; -} - -.fa-rod-asclepius { - --fa: "\e579"; -} - -.fa-rod-snake { - --fa: "\e579"; -} - -.fa-staff-aesculapius { - --fa: "\e579"; -} - -.fa-sun-plant-wilt { - --fa: "\e57a"; -} - -.fa-tarp { - --fa: "\e57b"; -} - -.fa-tarp-droplet { - --fa: "\e57c"; -} - -.fa-tent { - --fa: "\e57d"; -} - -.fa-tent-arrow-down-to-line { - --fa: "\e57e"; -} - -.fa-tent-arrow-left-right { - --fa: "\e57f"; -} - -.fa-tent-arrow-turn-left { - --fa: "\e580"; -} - -.fa-tent-arrows-down { - --fa: "\e581"; -} - -.fa-tents { - --fa: "\e582"; -} - -.fa-toilet-portable { - --fa: "\e583"; -} - -.fa-toilets-portable { - --fa: "\e584"; -} - -.fa-tower-cell { - --fa: "\e585"; -} - -.fa-tower-observation { - --fa: "\e586"; -} - -.fa-tree-city { - --fa: "\e587"; -} - -.fa-trillium { - --fa: "\e588"; -} - -.fa-trowel { - --fa: "\e589"; -} - -.fa-trowel-bricks { - --fa: "\e58a"; -} - -.fa-truck-arrow-right { - --fa: "\e58b"; -} - -.fa-truck-droplet { - --fa: "\e58c"; -} - -.fa-truck-field { - --fa: "\e58d"; -} - -.fa-truck-field-un { - --fa: "\e58e"; -} - -.fa-truck-plane { - --fa: "\e58f"; -} - -.fa-up-from-bracket { - --fa: "\e590"; -} - -.fa-users-between-lines { - --fa: "\e591"; -} - -.fa-users-line { - --fa: "\e592"; -} - -.fa-users-rays { - --fa: "\e593"; -} - -.fa-users-rectangle { - --fa: "\e594"; -} - -.fa-users-viewfinder { - --fa: "\e595"; -} - -.fa-vial-circle-check { - --fa: "\e596"; -} - -.fa-vial-virus { - --fa: "\e597"; -} - -.fa-wheat-awn-circle-exclamation { - --fa: "\e598"; -} - -.fa-worm { - --fa: "\e599"; -} - -.fa-xmarks-lines { - --fa: "\e59a"; -} - -.fa-xmark-large { - --fa: "\e59b"; -} - -.fa-child-dress { - --fa: "\e59c"; -} - -.fa-child-reaching { - --fa: "\e59d"; -} - -.fa-plus-large { - --fa: "\e59e"; -} - -.fa-crosshairs-simple { - --fa: "\e59f"; -} - -.fa-file-circle-check { - --fa: "\e5a0"; -} - -.fa-file-circle-xmark { - --fa: "\e5a1"; -} - -.fa-gamepad-modern { - --fa: "\e5a2"; -} - -.fa-gamepad-alt { - --fa: "\e5a2"; -} - -.fa-grill { - --fa: "\e5a3"; -} - -.fa-grill-fire { - --fa: "\e5a4"; -} - -.fa-grill-hot { - --fa: "\e5a5"; -} - -.fa-lightbulb-cfl { - --fa: "\e5a6"; -} - -.fa-lightbulb-cfl-on { - --fa: "\e5a7"; -} - -.fa-mouse-field { - --fa: "\e5a8"; -} - -.fa-person-through-window { - --fa: "\e5a9"; -} - -.fa-plant-wilt { - --fa: "\e5aa"; -} - -.fa-ring-diamond { - --fa: "\e5ab"; -} - -.fa-stapler { - --fa: "\e5af"; -} - -.fa-toggle-large-off { - --fa: "\e5b0"; -} - -.fa-toggle-large-on { - --fa: "\e5b1"; -} - -.fa-toilet-paper-check { - --fa: "\e5b2"; -} - -.fa-toilet-paper-xmark { - --fa: "\e5b3"; -} - -.fa-train-tram { - --fa: "\e5b4"; -} - -.fa-buoy { - --fa: "\e5b5"; -} - -.fa-buoy-mooring { - --fa: "\e5b6"; -} - -.fa-diamond-half { - --fa: "\e5b7"; -} - -.fa-diamond-half-stroke { - --fa: "\e5b8"; -} - -.fa-game-console-handheld-crank { - --fa: "\e5b9"; -} - -.fa-interrobang { - --fa: "\e5ba"; -} - -.fa-mailbox-flag-up { - --fa: "\e5bb"; -} - -.fa-mustache { - --fa: "\e5bc"; -} - -.fa-nose { - --fa: "\e5bd"; -} - -.fa-phone-arrow-right { - --fa: "\e5be"; -} - -.fa-pickaxe { - --fa: "\e5bf"; -} - -.fa-prescription-bottle-pill { - --fa: "\e5c0"; -} - -.fa-snowflake-droplets { - --fa: "\e5c1"; -} - -.fa-square-dashed-circle-plus { - --fa: "\e5c2"; -} - -.fa-tricycle { - --fa: "\e5c3"; -} - -.fa-tricycle-adult { - --fa: "\e5c4"; -} - -.fa-user-magnifying-glass { - --fa: "\e5c5"; -} - -.fa-comment-heart { - --fa: "\e5c8"; -} - -.fa-message-heart { - --fa: "\e5c9"; -} - -.fa-pencil-mechanical { - --fa: "\e5ca"; -} - -.fa-skeleton-ribs { - --fa: "\e5cb"; -} - -.fa-billboard { - --fa: "\e5cd"; -} - -.fa-circle-euro { - --fa: "\e5ce"; -} - -.fa-circle-sterling { - --fa: "\e5cf"; -} - -.fa-circle-yen { - --fa: "\e5d0"; -} - -.fa-broom-wide { - --fa: "\e5d1"; -} - -.fa-wreath-laurel { - --fa: "\e5d2"; -} - -.fa-circle-quarter-stroke { - --fa: "\e5d3"; -} - -.fa-circle-three-quarters-stroke { - --fa: "\e5d4"; -} - -.fa-webhook { - --fa: "\e5d5"; -} - -.fa-sparkle { - --fa: "\e5d6"; -} - -.fa-chart-line-up-down { - --fa: "\e5d7"; -} - -.fa-chart-mixed-up-circle-currency { - --fa: "\e5d8"; -} - -.fa-chart-mixed-up-circle-dollar { - --fa: "\e5d9"; -} - -.fa-grid-round { - --fa: "\e5da"; -} - -.fa-grid-round-2 { - --fa: "\e5db"; -} - -.fa-grid-round-2-plus { - --fa: "\e5dc"; -} - -.fa-grid-round-4 { - --fa: "\e5dd"; -} - -.fa-grid-round-5 { - --fa: "\e5de"; -} - -.fa-arrow-progress { - --fa: "\e5df"; -} - -.fa-right-left-large { - --fa: "\e5e1"; -} - -.fa-calendar-users { - --fa: "\e5e2"; -} - -.fa-display-chart-up { - --fa: "\e5e3"; -} - -.fa-display-chart-up-circle-currency { - --fa: "\e5e5"; -} - -.fa-display-chart-up-circle-dollar { - --fa: "\e5e6"; -} - -.fa-laptop-binary { - --fa: "\e5e7"; -} - -.fa-gear-code { - --fa: "\e5e8"; -} - -.fa-gear-complex { - --fa: "\e5e9"; -} - -.fa-gear-complex-code { - --fa: "\e5eb"; -} - -.fa-file-doc { - --fa: "\e5ed"; -} - -.fa-file-zip { - --fa: "\e5ee"; -} - -.fa-flask-gear { - --fa: "\e5f1"; -} - -.fa-bag-seedling { - --fa: "\e5f2"; -} - -.fa-bin-bottles { - --fa: "\e5f5"; -} - -.fa-bin-bottles-recycle { - --fa: "\e5f6"; -} - -.fa-bin-recycle { - --fa: "\e5f7"; -} - -.fa-conveyor-belt-arm { - --fa: "\e5f8"; -} - -.fa-jug-bottle { - --fa: "\e5fb"; -} - -.fa-lightbulb-gear { - --fa: "\e5fd"; -} - -.fa-dinosaur { - --fa: "\e5fe"; -} - -.fa-person-running-fast { - --fa: "\e5ff"; -} - -.fa-circles-overlap { - --fa: "\e600"; -} - -.fa-cloud-binary { - --fa: "\e601"; -} - -.fa-chf-sign { - --fa: "\e602"; -} - -.fa-user-group-simple { - --fa: "\e603"; -} - -.fa-chart-pie-simple-circle-currency { - --fa: "\e604"; -} - -.fa-chart-pie-simple-circle-dollar { - --fa: "\e605"; -} - -.fa-hat-beach { - --fa: "\e606"; -} - -.fa-person-dress-fairy { - --fa: "\e607"; -} - -.fa-person-fairy { - --fa: "\e608"; -} - -.fa-swap { - --fa: "\e609"; -} - -.fa-swap-arrows { - --fa: "\e60a"; -} - -.fa-angles-up-down { - --fa: "\e60d"; -} - -.fa-globe-pointer { - --fa: "\e60e"; -} - -.fa-subtitles { - --fa: "\e60f"; -} - -.fa-subtitles-slash { - --fa: "\e610"; -} - -.fa-head-side-gear { - --fa: "\e611"; -} - -.fa-lighthouse { - --fa: "\e612"; -} - -.fa-raccoon { - --fa: "\e613"; -} - -.fa-arrow-down-from-arc { - --fa: "\e614"; -} - -.fa-arrow-left-from-arc { - --fa: "\e615"; -} - -.fa-arrow-left-to-arc { - --fa: "\e616"; -} - -.fa-arrow-up-to-arc { - --fa: "\e617"; -} - -.fa-building-magnifying-glass { - --fa: "\e61c"; -} - -.fa-building-memo { - --fa: "\e61e"; -} - -.fa-hammer-brush { - --fa: "\e620"; -} - -.fa-hand-holding-circle-dollar { - --fa: "\e621"; -} - -.fa-landmark-magnifying-glass { - --fa: "\e622"; -} - -.fa-sign-post { - --fa: "\e624"; -} - -.fa-sign-posts { - --fa: "\e625"; -} - -.fa-sign-posts-wrench { - --fa: "\e626"; -} - -.fa-tent-double-peak { - --fa: "\e627"; -} - -.fa-truck-utensils { - --fa: "\e628"; -} - -.fa-t-rex { - --fa: "\e629"; -} - -.fa-spinner-scale { - --fa: "\e62a"; -} - -.fa-bell-ring { - --fa: "\e62c"; -} - -.fa-arrows-rotate-reverse { - --fa: "\e630"; -} - -.fa-rotate-reverse { - --fa: "\e631"; -} - -.fa-arrow-turn-left { - --fa: "\e632"; -} - -.fa-arrow-turn-left-down { - --fa: "\e633"; -} - -.fa-arrow-turn-left-up { - --fa: "\e634"; -} - -.fa-arrow-turn-right { - --fa: "\e635"; -} - -.fa-turn-left { - --fa: "\e636"; -} - -.fa-turn-left-down { - --fa: "\e637"; -} - -.fa-turn-left-up { - --fa: "\e638"; -} - -.fa-turn-right { - --fa: "\e639"; -} - -.fa-location-arrow-up { - --fa: "\e63a"; -} - -.fa-ticket-perforated { - --fa: "\e63e"; -} - -.fa-tickets-perforated { - --fa: "\e63f"; -} - -.fa-cannon { - --fa: "\e642"; -} - -.fa-court-sport { - --fa: "\e643"; -} - -.fa-file-eps { - --fa: "\e644"; -} - -.fa-file-gif { - --fa: "\e645"; -} - -.fa-file-jpg { - --fa: "\e646"; -} - -.fa-file-mov { - --fa: "\e647"; -} - -.fa-file-mp3 { - --fa: "\e648"; -} - -.fa-file-mp4 { - --fa: "\e649"; -} - -.fa-file-ppt { - --fa: "\e64a"; -} - -.fa-file-svg { - --fa: "\e64b"; -} - -.fa-file-vector { - --fa: "\e64c"; -} - -.fa-file-xls { - --fa: "\e64d"; -} - -.fa-folder-check { - --fa: "\e64e"; -} - -.fa-chart-kanban { - --fa: "\e64f"; -} - -.fa-bag-shopping-minus { - --fa: "\e650"; -} - -.fa-bag-shopping-plus { - --fa: "\e651"; -} - -.fa-basket-shopping-minus { - --fa: "\e652"; -} - -.fa-basket-shopping-plus { - --fa: "\e653"; -} - -.fa-file-xml { - --fa: "\e654"; -} - -.fa-bulldozer { - --fa: "\e655"; -} - -.fa-excavator { - --fa: "\e656"; -} - -.fa-truck-ladder { - --fa: "\e657"; -} - -.fa-tickets { - --fa: "\e658"; -} - -.fa-tickets-simple { - --fa: "\e659"; -} - -.fa-truck-fire { - --fa: "\e65a"; -} - -.fa-wave { - --fa: "\e65b"; -} - -.fa-waves-sine { - --fa: "\e65d"; -} - -.fa-magnifying-glass-arrows-rotate { - --fa: "\e65e"; -} - -.fa-magnifying-glass-music { - --fa: "\e65f"; -} - -.fa-magnifying-glass-play { - --fa: "\e660"; -} - -.fa-magnifying-glass-waveform { - --fa: "\e661"; -} - -.fa-music-magnifying-glass { - --fa: "\e662"; -} - -.fa-reflect-horizontal { - --fa: "\e664"; -} - -.fa-reflect-vertical { - --fa: "\e665"; -} - -.fa-file-png { - --fa: "\e666"; -} - -.fa-arrow-down-from-bracket { - --fa: "\e667"; -} - -.fa-arrow-left-from-bracket { - --fa: "\e668"; -} - -.fa-arrow-left-to-bracket { - --fa: "\e669"; -} - -.fa-arrow-up-to-bracket { - --fa: "\e66a"; -} - -.fa-down-from-bracket { - --fa: "\e66b"; -} - -.fa-left-from-bracket { - --fa: "\e66c"; -} - -.fa-left-to-bracket { - --fa: "\e66d"; -} - -.fa-up-to-bracket { - --fa: "\e66e"; -} - -.fa-reflect-both { - --fa: "\e66f"; -} - -.fa-file-cad { - --fa: "\e672"; -} - -.fa-bottle-baby { - --fa: "\e673"; -} - -.fa-table-cells-column-lock { - --fa: "\e678"; -} - -.fa-table-cells-lock { - --fa: "\e679"; -} - -.fa-table-cells-row-lock { - --fa: "\e67a"; -} - -.fa-circle-wifi { - --fa: "\e67d"; -} - -.fa-circle-wifi-circle-wifi { - --fa: "\e67e"; -} - -.fa-circle-wifi-group { - --fa: "\e67e"; -} - -.fa-circle-gf { - --fa: "\e67f"; -} - -.fa-ant { - --fa: "\e680"; -} - -.fa-caduceus { - --fa: "\e681"; -} - -.fa-web-awesome { - --fa: "\e682"; -} - -.fa-globe-wifi { - --fa: "\e685"; -} - -.fa-hydra { - --fa: "\e686"; -} - -.fa-lightbulb-message { - --fa: "\e687"; -} - -.fa-octopus { - --fa: "\e688"; -} - -.fa-user-beard-bolt { - --fa: "\e689"; -} - -.fa-user-hoodie { - --fa: "\e68a"; -} - -.fa-diamonds-4 { - --fa: "\e68b"; -} - -.fa-thumbtack-slash { - --fa: "\e68f"; -} - -.fa-thumb-tack-slash { - --fa: "\e68f"; -} - -.fa-table-cells-column-unlock { - --fa: "\e690"; -} - -.fa-table-cells-row-unlock { - --fa: "\e691"; -} - -.fa-table-cells-unlock { - --fa: "\e692"; -} - -.fa-chart-diagram { - --fa: "\e695"; -} - -.fa-comment-nodes { - --fa: "\e696"; -} - -.fa-file-fragment { - --fa: "\e697"; -} - -.fa-file-half-dashed { - --fa: "\e698"; -} - -.fa-hexagon-nodes { - --fa: "\e699"; -} - -.fa-hexagon-nodes-bolt { - --fa: "\e69a"; -} - -.fa-square-binary { - --fa: "\e69b"; -} - -.fa-carpool { - --fa: "\e69c"; -} - -.fa-car-people { - --fa: "\e69c"; -} - -.fa-chart-sine { - --fa: "\e69d"; -} - -.fa-chart-fft { - --fa: "\e69e"; -} - -.fa-circles-overlap-3 { - --fa: "\e6a1"; -} - -.fa-pronoun { - --fa: "\e6a1"; -} - -.fa-bar-progress { - --fa: "\e6a4"; -} - -.fa-bar-progress-empty { - --fa: "\e6a5"; -} - -.fa-bar-progress-full { - --fa: "\e6a6"; -} - -.fa-bar-progress-half { - --fa: "\e6a7"; -} - -.fa-bar-progress-quarter { - --fa: "\e6a8"; -} - -.fa-bar-progress-three-quarters { - --fa: "\e6a9"; -} - -.fa-grid-2-minus { - --fa: "\e6aa"; -} - -.fa-grid-round-2-minus { - --fa: "\e6ab"; -} - -.fa-table-cells-columns { - --fa: "\e6ac"; -} - -.fa-table-cells-header { - --fa: "\e6ad"; -} - -.fa-table-cells-header-lock { - --fa: "\e6ae"; -} - -.fa-table-cells-header-unlock { - --fa: "\e6af"; -} - -.fa-table-cells-rows { - --fa: "\e6b0"; -} - -.fa-circle-equals { - --fa: "\e6b1"; -} - -.fa-hexagon-equals { - --fa: "\e6b2"; -} - -.fa-octagon-equals { - --fa: "\e6b3"; -} - -.fa-rectangle-minus { - --fa: "\e6b4"; -} - -.fa-rectangle-plus { - --fa: "\e6b5"; -} - -.fa-square-equals { - --fa: "\e6b6"; -} - -.fa-arrow-down-long-to-line { - --fa: "\e6b7"; -} - -.fa-arrow-left-arrow-right { - --fa: "\e6b8"; -} - -.fa-arrow-left-from-dotted-line { - --fa: "\e6b9"; -} - -.fa-arrow-left-to-dotted-line { - --fa: "\e6ba"; -} - -.fa-arrow-right-from-dotted-line { - --fa: "\e6bb"; -} - -.fa-arrow-right-to-dotted-line { - --fa: "\e6bc"; -} - -.fa-arrow-up-long-to-line { - --fa: "\e6bd"; -} - -.fa-direction-left-right { - --fa: "\e6be"; -} - -.fa-direction-up-down { - --fa: "\e6bf"; -} - -.fa-down-long-to-line { - --fa: "\e6c0"; -} - -.fa-down-up { - --fa: "\e6c1"; -} - -.fa-left-from-dotted-line { - --fa: "\e6c2"; -} - -.fa-left-to-dotted-line { - --fa: "\e6c3"; -} - -.fa-right-from-dotted-line { - --fa: "\e6c4"; -} - -.fa-right-to-dotted-line { - --fa: "\e6c5"; -} - -.fa-up-long-to-line { - --fa: "\e6c6"; -} - -.fa-barn { - --fa: "\e6c7"; -} - -.fa-circle-house { - --fa: "\e6c8"; -} - -.fa-garage-empty { - --fa: "\e6c9"; -} - -.fa-house-unlock { - --fa: "\e6ca"; -} - -.fa-school-unlock { - --fa: "\e6cb"; -} - -.fa-stadium { - --fa: "\e6cc"; -} - -.fa-tent-circus { - --fa: "\e6cd"; -} - -.fa-ball-yarn { - --fa: "\e6ce"; -} - -.fa-bra { - --fa: "\e6cf"; -} - -.fa-briefs { - --fa: "\e6d0"; -} - -.fa-dress { - --fa: "\e6d1"; -} - -.fa-jeans { - --fa: "\e6d2"; -} - -.fa-jeans-straight { - --fa: "\e6d3"; -} - -.fa-panties { - --fa: "\e6d4"; -} - -.fa-pants { - --fa: "\e6d5"; -} - -.fa-pants-straight { - --fa: "\e6d6"; -} - -.fa-shirt-jersey { - --fa: "\e6d7"; -} - -.fa-shoe { - --fa: "\e6d8"; -} - -.fa-shorts { - --fa: "\e6d9"; -} - -.fa-sneaker { - --fa: "\e6da"; -} - -.fa-circle-share-nodes { - --fa: "\e6db"; -} - -.fa-comment-dot { - --fa: "\e6dc"; -} - -.fa-comment-waveform { - --fa: "\e6dd"; -} - -.fa-envelope-circle-user { - --fa: "\e6de"; -} - -.fa-message-dot { - --fa: "\e6df"; -} - -.fa-message-waveform { - --fa: "\e6e0"; -} - -.fa-phone-connection { - --fa: "\e6e1"; -} - -.fa-phone-waveform { - --fa: "\e6e2"; -} - -.fa-postage-stamp { - --fa: "\e6e3"; -} - -.fa-circle-florin { - --fa: "\e6e4"; -} - -.fa-circle-ruble { - --fa: "\e6e5"; -} - -.fa-square-chf { - --fa: "\e6e6"; -} - -.fa-square-lira { - --fa: "\e6e7"; -} - -.fa-norwegian-krone-sign { - --fa: "\e6e8"; -} - -.fa-circle-renminbi { - --fa: "\e6e9"; -} - -.fa-square-peseta { - --fa: "\e6ea"; -} - -.fa-circle-brazilian-real { - --fa: "\e6eb"; -} - -.fa-circle-won { - --fa: "\e6ec"; -} - -.fa-square-cruzeiro { - --fa: "\e6ed"; -} - -.fa-circle-currency { - --fa: "\e6ee"; -} - -.fa-circle-hryvnia { - --fa: "\e6ef"; -} - -.fa-square-cent { - --fa: "\e6f0"; -} - -.fa-square-brazilian-real { - --fa: "\e6f1"; -} - -.fa-square-bitcoin { - --fa: "\e6f2"; -} - -.fa-circle-peruvian-soles { - --fa: "\e6f3"; -} - -.fa-circle-litecoin { - --fa: "\e6f4"; -} - -.fa-square-indian-rupee { - --fa: "\e6f5"; -} - -.fa-circle-lira { - --fa: "\e6f6"; -} - -.fa-square-litecoin { - --fa: "\e6f7"; -} - -.fa-square-ruble { - --fa: "\e6f8"; -} - -.fa-circle-malaysian-ringgit { - --fa: "\e6f9"; -} - -.fa-malaysian-ringgit-sign { - --fa: "\e6fa"; -} - -.fa-circle-manat { - --fa: "\e6fb"; -} - -.fa-circle-colon { - --fa: "\e6fc"; -} - -.fa-circle-kip { - --fa: "\e6fd"; -} - -.fa-australian-dollar-sign { - --fa: "\e6fe"; -} - -.fa-circle-peso { - --fa: "\e6ff"; -} - -.fa-circle-polish-zloty { - --fa: "\e700"; -} - -.fa-circle-bangladeshi-taka { - --fa: "\e701"; -} - -.fa-circle-mill { - --fa: "\e702"; -} - -.fa-circle-shekel { - --fa: "\e703"; -} - -.fa-square-manat { - --fa: "\e704"; -} - -.fa-peruvian-soles-sign { - --fa: "\e705"; -} - -.fa-circle-rupiah { - --fa: "\e706"; -} - -.fa-square-norwegian-krone { - --fa: "\e707"; -} - -.fa-square-naira { - --fa: "\e708"; -} - -.fa-square-won { - --fa: "\e709"; -} - -.fa-square-mill { - --fa: "\e70a"; -} - -.fa-polish-zloty-sign { - --fa: "\e70b"; -} - -.fa-square-currency { - --fa: "\e70c"; -} - -.fa-square-kip { - --fa: "\e70d"; -} - -.fa-square-guarani { - --fa: "\e70e"; -} - -.fa-square-dong { - --fa: "\e70f"; -} - -.fa-square-hryvnia { - --fa: "\e710"; -} - -.fa-circle-tugrik { - --fa: "\e711"; -} - -.fa-square-rupiah { - --fa: "\e712"; -} - -.fa-square-sterling { - --fa: "\e713"; -} - -.fa-circle-rupee { - --fa: "\e714"; -} - -.fa-square-rupee { - --fa: "\e715"; -} - -.fa-square-peruvian-soles { - --fa: "\e716"; -} - -.fa-square-florin { - --fa: "\e717"; -} - -.fa-square-australian-dollar { - --fa: "\e718"; -} - -.fa-square-baht { - --fa: "\e719"; -} - -.fa-square-peso { - --fa: "\e71a"; -} - -.fa-circle-austral { - --fa: "\e71b"; -} - -.fa-square-swedish-krona { - --fa: "\e71c"; -} - -.fa-circle-lari { - --fa: "\e71d"; -} - -.fa-circleapore-dollar { - --fa: "\e71e"; -} - -.fa-square-turkish-lira { - --fa: "\e71f"; -} - -.fa-danish-krone-sign { - --fa: "\e720"; -} - -.fa-circle-franc { - --fa: "\e721"; -} - -.fa-circle-cruzeiro { - --fa: "\e722"; -} - -.fa-circle-dong { - --fa: "\e723"; -} - -.fa-square-yen { - --fa: "\e724"; -} - -.fa-circle-tenge { - --fa: "\e725"; -} - -.fa-square-austral { - --fa: "\e726"; -} - -.fa-square-eurozone { - --fa: "\e727"; -} - -.fa-square-tugrik { - --fa: "\e728"; -} - -.fa-square-cedi { - --fa: "\e729"; -} - -.fa-circle-cent { - --fa: "\e72a"; -} - -.fa-currency-sign { - --fa: "\e72b"; -} - -.fa-circle-chf { - --fa: "\e72c"; -} - -.fa-circle-baht { - --fa: "\e72d"; -} - -.fa-signapore-dollar-sign { - --fa: "\e72e"; -} - -.fa-square-franc { - --fa: "\e72f"; -} - -.fa-circle-australian-dollar { - --fa: "\e730"; -} - -.fa-square-tenge { - --fa: "\e731"; -} - -.fa-square-euro { - --fa: "\e732"; -} - -.fa-squareapore-dollar { - --fa: "\e733"; -} - -.fa-circle-indian-rupee { - --fa: "\e734"; -} - -.fa-square-shekel { - --fa: "\e735"; -} - -.fa-square-polish-zloty { - --fa: "\e736"; -} - -.fa-circle-bitcoin { - --fa: "\e737"; -} - -.fa-circle-norwegian-krone { - --fa: "\e738"; -} - -.fa-circle-turkish-lira { - --fa: "\e739"; -} - -.fa-square-colon { - --fa: "\e73a"; -} - -.fa-circle-guarani { - --fa: "\e73b"; -} - -.fa-renminbi-sign { - --fa: "\e73c"; -} - -.fa-square-renminbi { - --fa: "\e73d"; -} - -.fa-swedish-krona-sign { - --fa: "\e73e"; -} - -.fa-square-lari { - --fa: "\e73f"; -} - -.fa-eurozone-sign { - --fa: "\e740"; -} - -.fa-circle-peseta { - --fa: "\e741"; -} - -.fa-circle-cedi { - --fa: "\e742"; -} - -.fa-circle-swedish-krona { - --fa: "\e743"; -} - -.fa-square-bangladeshi-taka { - --fa: "\e744"; -} - -.fa-circle-eurozone { - --fa: "\e745"; -} - -.fa-circle-danish-krone { - --fa: "\e746"; -} - -.fa-square-danish-krone { - --fa: "\e747"; -} - -.fa-square-malaysian-ringgit { - --fa: "\e748"; -} - -.fa-circle-naira { - --fa: "\e749"; -} - -.fa-mobile-arrow-down { - --fa: "\e74b"; -} - -.fa-clone-plus { - --fa: "\e74c"; -} - -.fa-paintbrush-fine-slash { - --fa: "\e74d"; -} - -.fa-paintbrush-slash { - --fa: "\e74e"; -} - -.fa-pencil-line { - --fa: "\e74f"; -} - -.fa-slider-circle { - --fa: "\e750"; -} - -.fa-thumbtack-angle { - --fa: "\e751"; -} - -.fa-thumbtack-angle-slash { - --fa: "\e752"; -} - -.fa-book-open-lines { - --fa: "\e753"; -} - -.fa-book-spine { - --fa: "\e754"; -} - -.fa-bookmark-plus { - --fa: "\e755"; -} - -.fa-clipboard-clock { - --fa: "\e756"; -} - -.fa-clipboard-exclamation { - --fa: "\e757"; -} - -.fa-file-ban { - --fa: "\e758"; -} - -.fa-notes-sticky { - --fa: "\e759"; -} - -.fa-capsule { - --fa: "\e75a"; -} - -.fa-ear-circle-checkmark { - --fa: "\e75b"; -} - -.fa-ear-triangle-exclamation { - --fa: "\e75c"; -} - -.fa-ear-waveform { - --fa: "\e75d"; -} - -.fa-head-side-circuit { - --fa: "\e75e"; -} - -.fa-head-side-speak { - --fa: "\e75f"; -} - -.fa-microphone-signal-meter { - --fa: "\e760"; -} - -.fa-spine { - --fa: "\e761"; -} - -.fa-vial-vertical { - --fa: "\e762"; -} - -.fa-bin { - --fa: "\e763"; -} - -.fa-seat { - --fa: "\e764"; -} - -.fa-seats { - --fa: "\e765"; -} - -.fa-camera-circle-ellipsis { - --fa: "\e766"; -} - -.fa-camera-clock { - --fa: "\e767"; -} - -.fa-camera-shutter { - --fa: "\e768"; -} - -.fa-film-music { - --fa: "\e769"; -} - -.fa-film-stack { - --fa: "\e76b"; -} - -.fa-image-circle-arrow-down { - --fa: "\e76c"; -} - -.fa-image-circle-check { - --fa: "\e76d"; -} - -.fa-image-circle-plus { - --fa: "\e76e"; -} - -.fa-image-circle-xmark { - --fa: "\e76f"; -} - -.fa-image-music { - --fa: "\e770"; -} - -.fa-image-stack { - --fa: "\e771"; -} - -.fa-rectangle-4k { - --fa: "\e772"; -} - -.fa-rectangle-high-dynamic-range { - --fa: "\e773"; -} - -.fa-rectangle-hdr { - --fa: "\e773"; -} - -.fa-rectangle-video-on-demand { - --fa: "\e774"; -} - -.fa-user-viewfinder { - --fa: "\e775"; -} - -.fa-video-down-to-line { - --fa: "\e776"; -} - -.fa-video-question { - --fa: "\e777"; -} - -.fa-gas-pump-left { - --fa: "\e778"; -} - -.fa-gas-pump-right { - --fa: "\e779"; -} - -.fa-location-arrow-slash { - --fa: "\e77a"; -} - -.fa-airplay-audio { - --fa: "\e77b"; -} - -.fa-headphones-slash { - --fa: "\e77c"; -} - -.fa-microphone-circle-plus { - --fa: "\e77d"; -} - -.fa-microphone-circle-xmark { - --fa: "\e77e"; -} - -.fa-open-captioning { - --fa: "\e77f"; -} - -.fa-play-flip { - --fa: "\e780"; -} - -.fa-square-microphone { - --fa: "\e781"; -} - -.fa-trombone { - --fa: "\e782"; -} - -.fa-person-arms-raised { - --fa: "\e783"; -} - -.fa-person-basketball { - --fa: "\e784"; -} - -.fa-person-carry-empty { - --fa: "\e785"; -} - -.fa-person-golfing { - --fa: "\e786"; -} - -.fa-person-limbs-wide { - --fa: "\e787"; -} - -.fa-person-seat-window { - --fa: "\e788"; -} - -.fa-person-soccer { - --fa: "\e789"; -} - -.fa-person-swimming-pool { - --fa: "\e78a"; -} - -.fa-person-swimming-water { - --fa: "\e78b"; -} - -.fa-person-water-arms-raised { - --fa: "\e78c"; -} - -.fa-person-waving { - --fa: "\e78d"; -} - -.fa-heart-slash { - --fa: "\e78e"; -} - -.fa-hearts { - --fa: "\e78f"; -} - -.fa-pentagon { - --fa: "\e790"; -} - -.fa-rectangle-tall { - --fa: "\e791"; -} - -.fa-square-half { - --fa: "\e792"; -} - -.fa-square-half-stroke { - --fa: "\e793"; -} - -.fa-box-arrow-down { - --fa: "\e794"; -} - -.fa-box-arrow-down-arrow-up { - --fa: "\e795"; -} - -.fa-box-arrow-down-magnifying-glass { - --fa: "\e796"; -} - -.fa-box-isometric { - --fa: "\e797"; -} - -.fa-box-isometric-tape { - --fa: "\e798"; -} - -.fa-qrcode-read { - --fa: "\e799"; -} - -.fa-shop-24 { - --fa: "\e79a"; -} - -.fa-store-24 { - --fa: "\e79b"; -} - -.fa-face-shaking { - --fa: "\e79c"; -} - -.fa-face-shaking-horizontal { - --fa: "\e79d"; -} - -.fa-face-shaking-vertical { - --fa: "\e79e"; -} - -.fa-circle-user-circle-check { - --fa: "\e79f"; -} - -.fa-circle-user-circle-exclamation { - --fa: "\e7a0"; -} - -.fa-circle-user-circle-minus { - --fa: "\e7a1"; -} - -.fa-circle-user-circle-moon { - --fa: "\e7a2"; -} - -.fa-circle-user-circle-plus { - --fa: "\e7a3"; -} - -.fa-circle-user-circle-question { - --fa: "\e7a4"; -} - -.fa-circle-user-circle-user { - --fa: "\e7a5"; -} - -.fa-circle-user-circle-xmark { - --fa: "\e7a6"; -} - -.fa-circle-user-clock { - --fa: "\e7a7"; -} - -.fa-user-beard { - --fa: "\e7a8"; -} - -.fa-user-chef-hair-long { - --fa: "\e7a9"; -} - -.fa-user-circle-minus { - --fa: "\e7aa"; -} - -.fa-user-circle-plus { - --fa: "\e7ab"; -} - -.fa-user-dashed { - --fa: "\e7ac"; -} - -.fa-user-doctor-hair-mullet { - --fa: "\e7ad"; -} - -.fa-user-hat-tie { - --fa: "\e7ae"; -} - -.fa-user-hat-tie-magnifying-glass { - --fa: "\e7af"; -} - -.fa-user-key { - --fa: "\e7b0"; -} - -.fa-user-message { - --fa: "\e7b1"; -} - -.fa-user-microphone { - --fa: "\e7b2"; -} - -.fa-user-pilot-hair-long { - --fa: "\e7b3"; -} - -.fa-user-pilot-tie-hair-long { - --fa: "\e7b4"; -} - -.fa-user-police-hair-long { - --fa: "\e7b5"; -} - -.fa-user-police-tie-hair-long { - --fa: "\e7b6"; -} - -.fa-user-question { - --fa: "\e7b7"; -} - -.fa-user-sith { - --fa: "\e7b8"; -} - -.fa-user-tie-hair-mullet { - --fa: "\e7b9"; -} - -.fa-user-vneck-hair-mullet { - --fa: "\e7ba"; -} - -.fa-plane-flying { - --fa: "\e7bb"; -} - -.fa-plane-landing-gear { - --fa: "\e7bc"; -} - -.fa-rocket-vertical { - --fa: "\e7bd"; -} - -.fa-seat-airline-window { - --fa: "\e7be"; -} - -.fa-shuttle-space-vertical { - --fa: "\e7bf"; -} - -.fa-car-key { - --fa: "\e7c0"; -} - -.fa-car-siren { - --fa: "\e7c1"; -} - -.fa-car-siren-on { - --fa: "\e7c2"; -} - -.fa-scooter { - --fa: "\e7c3"; -} - -.fa-snowmobile-blank { - --fa: "\e7c4"; -} - -.fa-stair-car { - --fa: "\e7c5"; -} - -.fa-truck-suv { - --fa: "\e7c6"; -} - -.fa-unicycle { - --fa: "\e7c7"; -} - -.fa-van { - --fa: "\e7c8"; -} - -.fa-moon-star { - --fa: "\e7c9"; -} - -.fa-rainbow-half { - --fa: "\e7ca"; -} - -.fa-temperature-slash { - --fa: "\e7cb"; -} - -.fa-dialpad { - --fa: "\e7cc"; -} - -.fa-computer-mouse-button-left { - --fa: "\e7cd"; -} - -.fa-computer-mouse-button-right { - --fa: "\e7ce"; -} - -.fa-dot { - --fa: "\e7d1"; -} - -.fa-folder-arrow-left { - --fa: "\e7d2"; -} - -.fa-folder-arrow-right { - --fa: "\e7d3"; -} - -.fa-wireless { - --fa: "\e7df"; -} - -.fa-circle-moon { - --fa: "\e7e0"; -} - -.fa-person-meditating { - --fa: "\e7e1"; -} - -.fa-baseball-bat { - --fa: "\e7e5"; -} - -.fa-hockey-stick { - --fa: "\e7e6"; -} - -.fa-arrow-u-turn-down-left { - --fa: "\e7e7"; -} - -.fa-arrow-u-turn-down-right { - --fa: "\e7e8"; -} - -.fa-arrow-u-turn-left-down { - --fa: "\e7e9"; -} - -.fa-arrow-u-turn-left-up { - --fa: "\e7ea"; -} - -.fa-arrow-u-turn-right-down { - --fa: "\e7eb"; -} - -.fa-arrow-u-turn-right-up { - --fa: "\e7ec"; -} - -.fa-arrow-u-turn-up-left { - --fa: "\e7ed"; -} - -.fa-arrow-u-turn-up-right { - --fa: "\e7ee"; -} - -.fa-u-turn-down-left { - --fa: "\e7ef"; -} - -.fa-u-turn-down-right { - --fa: "\e7f0"; -} - -.fa-u-turn-left-down { - --fa: "\e7f1"; -} - -.fa-u-turn { - --fa: "\e7f1"; -} - -.fa-u-turn-left-up { - --fa: "\e7f2"; -} - -.fa-u-turn-right-down { - --fa: "\e7f3"; -} - -.fa-u-turn-right-up { - --fa: "\e7f4"; -} - -.fa-u-turn-up-left { - --fa: "\e7f5"; -} - -.fa-u-turn-up-right { - --fa: "\e7f6"; -} - -.fa-triple-chevrons-down { - --fa: "\e7f7"; -} - -.fa-triple-chevrons-left { - --fa: "\e7f8"; -} - -.fa-triple-chevrons-right { - --fa: "\e7f9"; -} - -.fa-triple-chevrons-up { - --fa: "\e7fa"; -} - -.fa-file-aiff { - --fa: "\e7fb"; -} - -.fa-file-odf { - --fa: "\e7fc"; -} - -.fa-file-tex { - --fa: "\e7fd"; -} - -.fa-file-wav { - --fa: "\e7fe"; -} - -.fa-droplet-plus { - --fa: "\e800"; -} - -.fa-hand-holding-star { - --fa: "\e801"; -} - -.fa-transmission { - --fa: "\e802"; -} - -.fa-alarm-minus { - --fa: "\e803"; -} - -.fa-file-brackets-curly { - --fa: "\e804"; -} - -.fa-file-midi { - --fa: "\e805"; -} - -.fa-midi { - --fa: "\e806"; -} - -.fa-non-binary { - --fa: "\e807"; -} - -.fa-rectangle-beta { - --fa: "\e808"; -} - -.fa-shield-user { - --fa: "\e809"; -} - -.fa-spiral { - --fa: "\e80a"; -} - -.fa-picture-in-picture { - --fa: "\e80b"; -} - -.fa-circle-half-horizontal { - --fa: "\e80c"; -} - -.fa-circle-half-stroke-horizontal { - --fa: "\e80d"; -} - -.fa-square-half-horizontal { - --fa: "\e80e"; -} - -.fa-square-half-stroke-horizontal { - --fa: "\e80f"; -} - -.fa-ship-large { - --fa: "\e810"; -} - -.fa-butterfly { - --fa: "\e811"; -} - -.fa-mobile-rotate { - --fa: "\e813"; -} - -.fa-mobile-rotate-reverse { - --fa: "\e814"; -} - -.fa-mobile-slash { - --fa: "\e815"; -} - -.fa-mobile-vibrate { - --fa: "\e816"; -} - -.fa-mobile-vibrate-slash { - --fa: "\e817"; -} - -.fa-almost-equal-to { - --fa: "\e818"; -} - -.fa-sneaker-running { - --fa: "\e819"; -} - -.fa-horseshoe { - --fa: "\e81a"; -} - -.fa-single-quote-left { - --fa: "\e81b"; -} - -.fa-single-quote-right { - --fa: "\e81c"; -} - -.fa-bus-side { - --fa: "\e81d"; -} - -.fa-bus-stop { - --fa: "\e81e"; -} - -.fa-train-stop { - --fa: "\e81f"; -} - -.fa-septagon { - --fa: "\e820"; -} - -.fa-heptagon { - --fa: "\e820"; -} - -.fa-mailbox-open-empty { - --fa: "\e821"; -} - -.fa-mailbox-open-letter { - --fa: "\e823"; -} - -.fa-lychee { - --fa: "\e824"; -} - -.fa-tank-recovery { - --fa: "\e825"; -} - -.fa-transducer { - --fa: "\e826"; -} - -.fa-box-arrow-up { - --fa: "\e827"; -} - -.fa-box-magnifying-glass { - --fa: "\e828"; -} - -.fa-envelope-ribbon { - --fa: "\e829"; -} - -.fa-envelope-certificate { - --fa: "\e829"; -} - -.fa-water-temperature { - --fa: "\e82a"; -} - -.fa-water-temp { - --fa: "\e82a"; -} - -.fa-aeropress { - --fa: "\e82b"; -} - -.fa-caret-large-down { - --fa: "\e82c"; -} - -.fa-caret-large-left { - --fa: "\e82d"; -} - -.fa-caret-large-right { - --fa: "\e82e"; -} - -.fa-caret-large-up { - --fa: "\e82f"; -} - -.fa-chemex { - --fa: "\e830"; -} - -.fa-hand-shaka { - --fa: "\e831"; -} - -.fa-kettlebell { - --fa: "\e832"; -} - -.fa-foot-wing { - --fa: "\e834"; -} - -.fa-pump-impeller { - --fa: "\e835"; -} - -.fa-arrow-rotate-left-10 { - --fa: "\e836"; -} - -.fa-arrow-rotate-right-10 { - --fa: "\e837"; -} - -.fa-martini-glass-empty { - --fa: "\f000"; -} - -.fa-glass-martini { - --fa: "\f000"; -} - -.fa-music { - --fa: "\f001"; -} - -.fa-magnifying-glass { - --fa: "\f002"; -} - -.fa-search { - --fa: "\f002"; -} - -.fa-heart { - --fa: "\f004"; -} - -.fa-star { - --fa: "\f005"; -} - -.fa-user { - --fa: "\f007"; -} - -.fa-user-alt { - --fa: "\f007"; -} - -.fa-user-large { - --fa: "\f007"; -} - -.fa-film { - --fa: "\f008"; -} - -.fa-film-alt { - --fa: "\f008"; -} - -.fa-film-simple { - --fa: "\f008"; -} - -.fa-table-cells-large { - --fa: "\f009"; -} - -.fa-th-large { - --fa: "\f009"; -} - -.fa-table-cells { - --fa: "\f00a"; -} - -.fa-th { - --fa: "\f00a"; -} - -.fa-table-list { - --fa: "\f00b"; -} - -.fa-th-list { - --fa: "\f00b"; -} - -.fa-check { - --fa: "\f00c"; -} - -.fa-xmark { - --fa: "\f00d"; -} - -.fa-close { - --fa: "\f00d"; -} - -.fa-multiply { - --fa: "\f00d"; -} - -.fa-remove { - --fa: "\f00d"; -} - -.fa-times { - --fa: "\f00d"; -} - -.fa-magnifying-glass-plus { - --fa: "\f00e"; -} - -.fa-search-plus { - --fa: "\f00e"; -} - -.fa-magnifying-glass-minus { - --fa: "\f010"; -} - -.fa-search-minus { - --fa: "\f010"; -} - -.fa-power-off { - --fa: "\f011"; -} - -.fa-signal { - --fa: "\f012"; -} - -.fa-signal-5 { - --fa: "\f012"; -} - -.fa-signal-perfect { - --fa: "\f012"; -} - -.fa-gear { - --fa: "\f013"; -} - -.fa-cog { - --fa: "\f013"; -} - -.fa-house { - --fa: "\f015"; -} - -.fa-home { - --fa: "\f015"; -} - -.fa-home-alt { - --fa: "\f015"; -} - -.fa-home-lg-alt { - --fa: "\f015"; -} - -.fa-clock { - --fa: "\f017"; -} - -.fa-clock-four { - --fa: "\f017"; -} - -.fa-road { - --fa: "\f018"; -} - -.fa-download { - --fa: "\f019"; -} - -.fa-inbox { - --fa: "\f01c"; -} - -.fa-arrow-rotate-right { - --fa: "\f01e"; -} - -.fa-arrow-right-rotate { - --fa: "\f01e"; -} - -.fa-arrow-rotate-forward { - --fa: "\f01e"; -} - -.fa-redo { - --fa: "\f01e"; -} - -.fa-arrows-rotate { - --fa: "\f021"; -} - -.fa-refresh { - --fa: "\f021"; -} - -.fa-sync { - --fa: "\f021"; -} - -.fa-rectangle-list { - --fa: "\f022"; -} - -.fa-list-alt { - --fa: "\f022"; -} - -.fa-lock { - --fa: "\f023"; -} - -.fa-flag { - --fa: "\f024"; -} - -.fa-headphones { - --fa: "\f025"; -} - -.fa-headphones-alt { - --fa: "\f025"; -} - -.fa-headphones-simple { - --fa: "\f025"; -} - -.fa-volume-off { - --fa: "\f026"; -} - -.fa-volume-low { - --fa: "\f027"; -} - -.fa-volume-down { - --fa: "\f027"; -} - -.fa-volume-high { - --fa: "\f028"; -} - -.fa-volume-up { - --fa: "\f028"; -} - -.fa-qrcode { - --fa: "\f029"; -} - -.fa-barcode { - --fa: "\f02a"; -} - -.fa-tag { - --fa: "\f02b"; -} - -.fa-tags { - --fa: "\f02c"; -} - -.fa-book { - --fa: "\f02d"; -} - -.fa-bookmark { - --fa: "\f02e"; -} - -.fa-print { - --fa: "\f02f"; -} - -.fa-camera { - --fa: "\f030"; -} - -.fa-camera-alt { - --fa: "\f030"; -} - -.fa-font { - --fa: "\f031"; -} - -.fa-bold { - --fa: "\f032"; -} - -.fa-italic { - --fa: "\f033"; -} - -.fa-text-height { - --fa: "\f034"; -} - -.fa-text-width { - --fa: "\f035"; -} - -.fa-align-left { - --fa: "\f036"; -} - -.fa-align-center { - --fa: "\f037"; -} - -.fa-align-right { - --fa: "\f038"; -} - -.fa-align-justify { - --fa: "\f039"; -} - -.fa-list { - --fa: "\f03a"; -} - -.fa-list-squares { - --fa: "\f03a"; -} - -.fa-outdent { - --fa: "\f03b"; -} - -.fa-dedent { - --fa: "\f03b"; -} - -.fa-indent { - --fa: "\f03c"; -} - -.fa-video { - --fa: "\f03d"; -} - -.fa-video-camera { - --fa: "\f03d"; -} - -.fa-image { - --fa: "\f03e"; -} - -.fa-location-pin { - --fa: "\f041"; -} - -.fa-map-marker { - --fa: "\f041"; -} - -.fa-circle-half-stroke { - --fa: "\f042"; -} - -.fa-adjust { - --fa: "\f042"; -} - -.fa-droplet { - --fa: "\f043"; -} - -.fa-tint { - --fa: "\f043"; -} - -.fa-pen-to-square { - --fa: "\f044"; -} - -.fa-edit { - --fa: "\f044"; -} - -.fa-arrows-up-down-left-right { - --fa: "\f047"; -} - -.fa-arrows { - --fa: "\f047"; -} - -.fa-backward-step { - --fa: "\f048"; -} - -.fa-step-backward { - --fa: "\f048"; -} - -.fa-backward-fast { - --fa: "\f049"; -} - -.fa-fast-backward { - --fa: "\f049"; -} - -.fa-backward { - --fa: "\f04a"; -} - -.fa-play { - --fa: "\f04b"; -} - -.fa-pause { - --fa: "\f04c"; -} - -.fa-stop { - --fa: "\f04d"; -} - -.fa-forward { - --fa: "\f04e"; -} - -.fa-forward-fast { - --fa: "\f050"; -} - -.fa-fast-forward { - --fa: "\f050"; -} - -.fa-forward-step { - --fa: "\f051"; -} - -.fa-step-forward { - --fa: "\f051"; -} - -.fa-eject { - --fa: "\f052"; -} - -.fa-chevron-left { - --fa: "\f053"; -} - -.fa-chevron-right { - --fa: "\f054"; -} - -.fa-circle-plus { - --fa: "\f055"; -} - -.fa-plus-circle { - --fa: "\f055"; -} - -.fa-circle-minus { - --fa: "\f056"; -} - -.fa-minus-circle { - --fa: "\f056"; -} - -.fa-circle-xmark { - --fa: "\f057"; -} - -.fa-times-circle { - --fa: "\f057"; -} - -.fa-xmark-circle { - --fa: "\f057"; -} - -.fa-circle-check { - --fa: "\f058"; -} - -.fa-check-circle { - --fa: "\f058"; -} - -.fa-circle-question { - --fa: "\f059"; -} - -.fa-question-circle { - --fa: "\f059"; -} - -.fa-circle-info { - --fa: "\f05a"; -} - -.fa-info-circle { - --fa: "\f05a"; -} - -.fa-crosshairs { - --fa: "\f05b"; -} - -.fa-ban { - --fa: "\f05e"; -} - -.fa-cancel { - --fa: "\f05e"; -} - -.fa-arrow-left { - --fa: "\f060"; -} - -.fa-arrow-right { - --fa: "\f061"; -} - -.fa-arrow-up { - --fa: "\f062"; -} - -.fa-arrow-down { - --fa: "\f063"; -} - -.fa-share { - --fa: "\f064"; -} - -.fa-mail-forward { - --fa: "\f064"; -} - -.fa-expand { - --fa: "\f065"; -} - -.fa-compress { - --fa: "\f066"; -} - -.fa-minus { - --fa: "\f068"; -} - -.fa-subtract { - --fa: "\f068"; -} - -.fa-circle-exclamation { - --fa: "\f06a"; -} - -.fa-exclamation-circle { - --fa: "\f06a"; -} - -.fa-gift { - --fa: "\f06b"; -} - -.fa-leaf { - --fa: "\f06c"; -} - -.fa-fire { - --fa: "\f06d"; -} - -.fa-eye { - --fa: "\f06e"; -} - -.fa-eye-slash { - --fa: "\f070"; -} - -.fa-triangle-exclamation { - --fa: "\f071"; -} - -.fa-exclamation-triangle { - --fa: "\f071"; -} - -.fa-warning { - --fa: "\f071"; -} - -.fa-plane { - --fa: "\f072"; -} - -.fa-calendar-days { - --fa: "\f073"; -} - -.fa-calendar-alt { - --fa: "\f073"; -} - -.fa-shuffle { - --fa: "\f074"; -} - -.fa-random { - --fa: "\f074"; -} - -.fa-comment { - --fa: "\f075"; -} - -.fa-magnet { - --fa: "\f076"; -} - -.fa-chevron-up { - --fa: "\f077"; -} - -.fa-chevron-down { - --fa: "\f078"; -} - -.fa-retweet { - --fa: "\f079"; -} - -.fa-cart-shopping { - --fa: "\f07a"; -} - -.fa-shopping-cart { - --fa: "\f07a"; -} - -.fa-folder { - --fa: "\f07b"; -} - -.fa-folder-blank { - --fa: "\f07b"; -} - -.fa-folder-open { - --fa: "\f07c"; -} - -.fa-arrows-up-down { - --fa: "\f07d"; -} - -.fa-arrows-v { - --fa: "\f07d"; -} - -.fa-arrows-left-right { - --fa: "\f07e"; -} - -.fa-arrows-h { - --fa: "\f07e"; -} - -.fa-chart-bar { - --fa: "\f080"; -} - -.fa-bar-chart { - --fa: "\f080"; -} - -.fa-camera-retro { - --fa: "\f083"; -} - -.fa-key { - --fa: "\f084"; -} - -.fa-gears { - --fa: "\f085"; -} - -.fa-cogs { - --fa: "\f085"; -} - -.fa-comments { - --fa: "\f086"; -} - -.fa-star-half { - --fa: "\f089"; -} - -.fa-arrow-right-from-bracket { - --fa: "\f08b"; -} - -.fa-sign-out { - --fa: "\f08b"; -} - -.fa-thumbtack { - --fa: "\f08d"; -} - -.fa-thumb-tack { - --fa: "\f08d"; -} - -.fa-arrow-up-right-from-square { - --fa: "\f08e"; -} - -.fa-external-link { - --fa: "\f08e"; -} - -.fa-arrow-right-to-bracket { - --fa: "\f090"; -} - -.fa-sign-in { - --fa: "\f090"; -} - -.fa-trophy { - --fa: "\f091"; -} - -.fa-upload { - --fa: "\f093"; -} - -.fa-lemon { - --fa: "\f094"; -} - -.fa-phone { - --fa: "\f095"; -} - -.fa-square-phone { - --fa: "\f098"; -} - -.fa-phone-square { - --fa: "\f098"; -} - -.fa-unlock { - --fa: "\f09c"; -} - -.fa-credit-card { - --fa: "\f09d"; -} - -.fa-credit-card-alt { - --fa: "\f09d"; -} - -.fa-rss { - --fa: "\f09e"; -} - -.fa-feed { - --fa: "\f09e"; -} - -.fa-hard-drive { - --fa: "\f0a0"; -} - -.fa-hdd { - --fa: "\f0a0"; -} - -.fa-bullhorn { - --fa: "\f0a1"; -} - -.fa-certificate { - --fa: "\f0a3"; -} - -.fa-hand-point-right { - --fa: "\f0a4"; -} - -.fa-hand-point-left { - --fa: "\f0a5"; -} - -.fa-hand-point-up { - --fa: "\f0a6"; -} - -.fa-hand-point-down { - --fa: "\f0a7"; -} - -.fa-circle-arrow-left { - --fa: "\f0a8"; -} - -.fa-arrow-circle-left { - --fa: "\f0a8"; -} - -.fa-circle-arrow-right { - --fa: "\f0a9"; -} - -.fa-arrow-circle-right { - --fa: "\f0a9"; -} - -.fa-circle-arrow-up { - --fa: "\f0aa"; -} - -.fa-arrow-circle-up { - --fa: "\f0aa"; -} - -.fa-circle-arrow-down { - --fa: "\f0ab"; -} - -.fa-arrow-circle-down { - --fa: "\f0ab"; -} - -.fa-globe { - --fa: "\f0ac"; -} - -.fa-wrench { - --fa: "\f0ad"; -} - -.fa-list-check { - --fa: "\f0ae"; -} - -.fa-tasks { - --fa: "\f0ae"; -} - -.fa-filter { - --fa: "\f0b0"; -} - -.fa-briefcase { - --fa: "\f0b1"; -} - -.fa-up-down-left-right { - --fa: "\f0b2"; -} - -.fa-arrows-alt { - --fa: "\f0b2"; -} - -.fa-users { - --fa: "\f0c0"; -} - -.fa-link { - --fa: "\f0c1"; -} - -.fa-chain { - --fa: "\f0c1"; -} - -.fa-cloud { - --fa: "\f0c2"; -} - -.fa-flask { - --fa: "\f0c3"; -} - -.fa-scissors { - --fa: "\f0c4"; -} - -.fa-cut { - --fa: "\f0c4"; -} - -.fa-copy { - --fa: "\f0c5"; -} - -.fa-paperclip { - --fa: "\f0c6"; -} - -.fa-floppy-disk { - --fa: "\f0c7"; -} - -.fa-save { - --fa: "\f0c7"; -} - -.fa-square { - --fa: "\f0c8"; -} - -.fa-bars { - --fa: "\f0c9"; -} - -.fa-navicon { - --fa: "\f0c9"; -} - -.fa-list-ul { - --fa: "\f0ca"; -} - -.fa-list-dots { - --fa: "\f0ca"; -} - -.fa-list-ol { - --fa: "\f0cb"; -} - -.fa-list-1-2 { - --fa: "\f0cb"; -} - -.fa-list-numeric { - --fa: "\f0cb"; -} - -.fa-strikethrough { - --fa: "\f0cc"; -} - -.fa-underline { - --fa: "\f0cd"; -} - -.fa-table { - --fa: "\f0ce"; -} - -.fa-wand-magic { - --fa: "\f0d0"; -} - -.fa-magic { - --fa: "\f0d0"; -} - -.fa-truck { - --fa: "\f0d1"; -} - -.fa-money-bill { - --fa: "\f0d6"; -} - -.fa-caret-down { - --fa: "\f0d7"; -} - -.fa-caret-up { - --fa: "\f0d8"; -} - -.fa-caret-left { - --fa: "\f0d9"; -} - -.fa-caret-right { - --fa: "\f0da"; -} - -.fa-table-columns { - --fa: "\f0db"; -} - -.fa-columns { - --fa: "\f0db"; -} - -.fa-sort { - --fa: "\f0dc"; -} - -.fa-unsorted { - --fa: "\f0dc"; -} - -.fa-sort-down { - --fa: "\f0dd"; -} - -.fa-sort-desc { - --fa: "\f0dd"; -} - -.fa-sort-up { - --fa: "\f0de"; -} - -.fa-sort-asc { - --fa: "\f0de"; -} - -.fa-envelope { - --fa: "\f0e0"; -} - -.fa-arrow-rotate-left { - --fa: "\f0e2"; -} - -.fa-arrow-left-rotate { - --fa: "\f0e2"; -} - -.fa-arrow-rotate-back { - --fa: "\f0e2"; -} - -.fa-arrow-rotate-backward { - --fa: "\f0e2"; -} - -.fa-undo { - --fa: "\f0e2"; -} - -.fa-gavel { - --fa: "\f0e3"; -} - -.fa-legal { - --fa: "\f0e3"; -} - -.fa-bolt { - --fa: "\f0e7"; -} - -.fa-zap { - --fa: "\f0e7"; -} - -.fa-sitemap { - --fa: "\f0e8"; -} - -.fa-umbrella { - --fa: "\f0e9"; -} - -.fa-paste { - --fa: "\f0ea"; -} - -.fa-file-clipboard { - --fa: "\f0ea"; -} - -.fa-lightbulb { - --fa: "\f0eb"; -} - -.fa-arrow-right-arrow-left { - --fa: "\f0ec"; -} - -.fa-exchange { - --fa: "\f0ec"; -} - -.fa-cloud-arrow-down { - --fa: "\f0ed"; -} - -.fa-cloud-download { - --fa: "\f0ed"; -} - -.fa-cloud-download-alt { - --fa: "\f0ed"; -} - -.fa-cloud-arrow-up { - --fa: "\f0ee"; -} - -.fa-cloud-upload { - --fa: "\f0ee"; -} - -.fa-cloud-upload-alt { - --fa: "\f0ee"; -} - -.fa-user-doctor { - --fa: "\f0f0"; -} - -.fa-user-md { - --fa: "\f0f0"; -} - -.fa-stethoscope { - --fa: "\f0f1"; -} - -.fa-suitcase { - --fa: "\f0f2"; -} - -.fa-bell { - --fa: "\f0f3"; -} - -.fa-mug-saucer { - --fa: "\f0f4"; -} - -.fa-coffee { - --fa: "\f0f4"; -} - -.fa-hospital { - --fa: "\f0f8"; -} - -.fa-hospital-alt { - --fa: "\f0f8"; -} - -.fa-hospital-wide { - --fa: "\f0f8"; -} - -.fa-truck-medical { - --fa: "\f0f9"; -} - -.fa-ambulance { - --fa: "\f0f9"; -} - -.fa-suitcase-medical { - --fa: "\f0fa"; -} - -.fa-medkit { - --fa: "\f0fa"; -} - -.fa-jet-fighter { - --fa: "\f0fb"; -} - -.fa-fighter-jet { - --fa: "\f0fb"; -} - -.fa-beer-mug-empty { - --fa: "\f0fc"; -} - -.fa-beer { - --fa: "\f0fc"; -} - -.fa-square-h { - --fa: "\f0fd"; -} - -.fa-h-square { - --fa: "\f0fd"; -} - -.fa-square-plus { - --fa: "\f0fe"; -} - -.fa-plus-square { - --fa: "\f0fe"; -} - -.fa-angles-left { - --fa: "\f100"; -} - -.fa-angle-double-left { - --fa: "\f100"; -} - -.fa-angles-right { - --fa: "\f101"; -} - -.fa-angle-double-right { - --fa: "\f101"; -} - -.fa-angles-up { - --fa: "\f102"; -} - -.fa-angle-double-up { - --fa: "\f102"; -} - -.fa-angles-down { - --fa: "\f103"; -} - -.fa-angle-double-down { - --fa: "\f103"; -} - -.fa-angle-left { - --fa: "\f104"; -} - -.fa-angle-right { - --fa: "\f105"; -} - -.fa-angle-up { - --fa: "\f106"; -} - -.fa-angle-down { - --fa: "\f107"; -} - -.fa-laptop { - --fa: "\f109"; -} - -.fa-tablet-button { - --fa: "\f10a"; -} - -.fa-mobile-button { - --fa: "\f10b"; -} - -.fa-quote-left { - --fa: "\f10d"; -} - -.fa-quote-left-alt { - --fa: "\f10d"; -} - -.fa-quote-right { - --fa: "\f10e"; -} - -.fa-quote-right-alt { - --fa: "\f10e"; -} - -.fa-spinner { - --fa: "\f110"; -} - -.fa-circle { - --fa: "\f111"; -} - -.fa-face-smile { - --fa: "\f118"; -} - -.fa-smile { - --fa: "\f118"; -} - -.fa-face-frown { - --fa: "\f119"; -} - -.fa-frown { - --fa: "\f119"; -} - -.fa-face-meh { - --fa: "\f11a"; -} - -.fa-meh { - --fa: "\f11a"; -} - -.fa-gamepad { - --fa: "\f11b"; -} - -.fa-keyboard { - --fa: "\f11c"; -} - -.fa-flag-checkered { - --fa: "\f11e"; -} - -.fa-terminal { - --fa: "\f120"; -} - -.fa-code { - --fa: "\f121"; -} - -.fa-reply-all { - --fa: "\f122"; -} - -.fa-mail-reply-all { - --fa: "\f122"; -} - -.fa-location-arrow { - --fa: "\f124"; -} - -.fa-crop { - --fa: "\f125"; -} - -.fa-code-branch { - --fa: "\f126"; -} - -.fa-link-slash { - --fa: "\f127"; -} - -.fa-chain-broken { - --fa: "\f127"; -} - -.fa-chain-slash { - --fa: "\f127"; -} - -.fa-unlink { - --fa: "\f127"; -} - -.fa-info { - --fa: "\f129"; -} - -.fa-superscript { - --fa: "\f12b"; -} - -.fa-subscript { - --fa: "\f12c"; -} - -.fa-eraser { - --fa: "\f12d"; -} - -.fa-puzzle-piece { - --fa: "\f12e"; -} - -.fa-microphone { - --fa: "\f130"; -} - -.fa-microphone-slash { - --fa: "\f131"; -} - -.fa-shield { - --fa: "\f132"; -} - -.fa-shield-blank { - --fa: "\f132"; -} - -.fa-calendar { - --fa: "\f133"; -} - -.fa-fire-extinguisher { - --fa: "\f134"; -} - -.fa-rocket { - --fa: "\f135"; -} - -.fa-circle-chevron-left { - --fa: "\f137"; -} - -.fa-chevron-circle-left { - --fa: "\f137"; -} - -.fa-circle-chevron-right { - --fa: "\f138"; -} - -.fa-chevron-circle-right { - --fa: "\f138"; -} - -.fa-circle-chevron-up { - --fa: "\f139"; -} - -.fa-chevron-circle-up { - --fa: "\f139"; -} - -.fa-circle-chevron-down { - --fa: "\f13a"; -} - -.fa-chevron-circle-down { - --fa: "\f13a"; -} - -.fa-anchor { - --fa: "\f13d"; -} - -.fa-unlock-keyhole { - --fa: "\f13e"; -} - -.fa-unlock-alt { - --fa: "\f13e"; -} - -.fa-bullseye { - --fa: "\f140"; -} - -.fa-ellipsis { - --fa: "\f141"; -} - -.fa-ellipsis-h { - --fa: "\f141"; -} - -.fa-ellipsis-vertical { - --fa: "\f142"; -} - -.fa-ellipsis-v { - --fa: "\f142"; -} - -.fa-square-rss { - --fa: "\f143"; -} - -.fa-rss-square { - --fa: "\f143"; -} - -.fa-circle-play { - --fa: "\f144"; -} - -.fa-play-circle { - --fa: "\f144"; -} - -.fa-ticket { - --fa: "\f145"; -} - -.fa-square-minus { - --fa: "\f146"; -} - -.fa-minus-square { - --fa: "\f146"; -} - -.fa-arrow-turn-up { - --fa: "\f148"; -} - -.fa-level-up { - --fa: "\f148"; -} - -.fa-arrow-turn-down { - --fa: "\f149"; -} - -.fa-level-down { - --fa: "\f149"; -} - -.fa-square-check { - --fa: "\f14a"; -} - -.fa-check-square { - --fa: "\f14a"; -} - -.fa-square-pen { - --fa: "\f14b"; -} - -.fa-pen-square { - --fa: "\f14b"; -} - -.fa-pencil-square { - --fa: "\f14b"; -} - -.fa-square-arrow-up-right { - --fa: "\f14c"; -} - -.fa-external-link-square { - --fa: "\f14c"; -} - -.fa-share-from-square { - --fa: "\f14d"; -} - -.fa-share-square { - --fa: "\f14d"; -} - -.fa-compass { - --fa: "\f14e"; -} - -.fa-square-caret-down { - --fa: "\f150"; -} - -.fa-caret-square-down { - --fa: "\f150"; -} - -.fa-square-caret-up { - --fa: "\f151"; -} - -.fa-caret-square-up { - --fa: "\f151"; -} - -.fa-square-caret-right { - --fa: "\f152"; -} - -.fa-caret-square-right { - --fa: "\f152"; -} - -.fa-euro-sign { - --fa: "\f153"; -} - -.fa-eur { - --fa: "\f153"; -} - -.fa-euro { - --fa: "\f153"; -} - -.fa-sterling-sign { - --fa: "\f154"; -} - -.fa-gbp { - --fa: "\f154"; -} - -.fa-pound-sign { - --fa: "\f154"; -} - -.fa-rupee-sign { - --fa: "\f156"; -} - -.fa-rupee { - --fa: "\f156"; -} - -.fa-yen-sign { - --fa: "\f157"; -} - -.fa-cny { - --fa: "\f157"; -} - -.fa-jpy { - --fa: "\f157"; -} - -.fa-rmb { - --fa: "\f157"; -} - -.fa-yen { - --fa: "\f157"; -} - -.fa-ruble-sign { - --fa: "\f158"; -} - -.fa-rouble { - --fa: "\f158"; -} - -.fa-rub { - --fa: "\f158"; -} - -.fa-ruble { - --fa: "\f158"; -} - -.fa-won-sign { - --fa: "\f159"; -} - -.fa-krw { - --fa: "\f159"; -} - -.fa-won { - --fa: "\f159"; -} - -.fa-file { - --fa: "\f15b"; -} - -.fa-file-lines { - --fa: "\f15c"; -} - -.fa-file-alt { - --fa: "\f15c"; -} - -.fa-file-text { - --fa: "\f15c"; -} - -.fa-arrow-down-a-z { - --fa: "\f15d"; -} - -.fa-sort-alpha-asc { - --fa: "\f15d"; -} - -.fa-sort-alpha-down { - --fa: "\f15d"; -} - -.fa-arrow-up-a-z { - --fa: "\f15e"; -} - -.fa-sort-alpha-up { - --fa: "\f15e"; -} - -.fa-arrow-down-wide-short { - --fa: "\f160"; -} - -.fa-sort-amount-asc { - --fa: "\f160"; -} - -.fa-sort-amount-down { - --fa: "\f160"; -} - -.fa-arrow-up-wide-short { - --fa: "\f161"; -} - -.fa-sort-amount-up { - --fa: "\f161"; -} - -.fa-arrow-down-1-9 { - --fa: "\f162"; -} - -.fa-sort-numeric-asc { - --fa: "\f162"; -} - -.fa-sort-numeric-down { - --fa: "\f162"; -} - -.fa-arrow-up-1-9 { - --fa: "\f163"; -} - -.fa-sort-numeric-up { - --fa: "\f163"; -} - -.fa-thumbs-up { - --fa: "\f164"; -} - -.fa-thumbs-down { - --fa: "\f165"; -} - -.fa-arrow-down-long { - --fa: "\f175"; -} - -.fa-long-arrow-down { - --fa: "\f175"; -} - -.fa-arrow-up-long { - --fa: "\f176"; -} - -.fa-long-arrow-up { - --fa: "\f176"; -} - -.fa-arrow-left-long { - --fa: "\f177"; -} - -.fa-long-arrow-left { - --fa: "\f177"; -} - -.fa-arrow-right-long { - --fa: "\f178"; -} - -.fa-long-arrow-right { - --fa: "\f178"; -} - -.fa-person-dress { - --fa: "\f182"; -} - -.fa-female { - --fa: "\f182"; -} - -.fa-person { - --fa: "\f183"; -} - -.fa-male { - --fa: "\f183"; -} - -.fa-sun { - --fa: "\f185"; -} - -.fa-moon { - --fa: "\f186"; -} - -.fa-box-archive { - --fa: "\f187"; -} - -.fa-archive { - --fa: "\f187"; -} - -.fa-bug { - --fa: "\f188"; -} - -.fa-square-caret-left { - --fa: "\f191"; -} - -.fa-caret-square-left { - --fa: "\f191"; -} - -.fa-circle-dot { - --fa: "\f192"; -} - -.fa-dot-circle { - --fa: "\f192"; -} - -.fa-wheelchair { - --fa: "\f193"; -} - -.fa-lira-sign { - --fa: "\f195"; -} - -.fa-shuttle-space { - --fa: "\f197"; -} - -.fa-space-shuttle { - --fa: "\f197"; -} - -.fa-square-envelope { - --fa: "\f199"; -} - -.fa-envelope-square { - --fa: "\f199"; -} - -.fa-building-columns { - --fa: "\f19c"; -} - -.fa-bank { - --fa: "\f19c"; -} - -.fa-institution { - --fa: "\f19c"; -} - -.fa-museum { - --fa: "\f19c"; -} - -.fa-university { - --fa: "\f19c"; -} - -.fa-graduation-cap { - --fa: "\f19d"; -} - -.fa-mortar-board { - --fa: "\f19d"; -} - -.fa-language { - --fa: "\f1ab"; -} - -.fa-fax { - --fa: "\f1ac"; -} - -.fa-building { - --fa: "\f1ad"; -} - -.fa-child { - --fa: "\f1ae"; -} - -.fa-paw { - --fa: "\f1b0"; -} - -.fa-cube { - --fa: "\f1b2"; -} - -.fa-cubes { - --fa: "\f1b3"; -} - -.fa-recycle { - --fa: "\f1b8"; -} - -.fa-car { - --fa: "\f1b9"; -} - -.fa-automobile { - --fa: "\f1b9"; -} - -.fa-taxi { - --fa: "\f1ba"; -} - -.fa-cab { - --fa: "\f1ba"; -} - -.fa-tree { - --fa: "\f1bb"; -} - -.fa-database { - --fa: "\f1c0"; -} - -.fa-file-pdf { - --fa: "\f1c1"; -} - -.fa-file-word { - --fa: "\f1c2"; -} - -.fa-file-excel { - --fa: "\f1c3"; -} - -.fa-file-powerpoint { - --fa: "\f1c4"; -} - -.fa-file-image { - --fa: "\f1c5"; -} - -.fa-file-zipper { - --fa: "\f1c6"; -} - -.fa-file-archive { - --fa: "\f1c6"; -} - -.fa-file-audio { - --fa: "\f1c7"; -} - -.fa-file-video { - --fa: "\f1c8"; -} - -.fa-file-code { - --fa: "\f1c9"; -} - -.fa-life-ring { - --fa: "\f1cd"; -} - -.fa-circle-notch { - --fa: "\f1ce"; -} - -.fa-paper-plane { - --fa: "\f1d8"; -} - -.fa-clock-rotate-left { - --fa: "\f1da"; -} - -.fa-history { - --fa: "\f1da"; -} - -.fa-heading { - --fa: "\f1dc"; -} - -.fa-header { - --fa: "\f1dc"; -} - -.fa-paragraph { - --fa: "\f1dd"; -} - -.fa-sliders { - --fa: "\f1de"; -} - -.fa-sliders-h { - --fa: "\f1de"; -} - -.fa-share-nodes { - --fa: "\f1e0"; -} - -.fa-share-alt { - --fa: "\f1e0"; -} - -.fa-square-share-nodes { - --fa: "\f1e1"; -} - -.fa-share-alt-square { - --fa: "\f1e1"; -} - -.fa-bomb { - --fa: "\f1e2"; -} - -.fa-futbol { - --fa: "\f1e3"; -} - -.fa-futbol-ball { - --fa: "\f1e3"; -} - -.fa-soccer-ball { - --fa: "\f1e3"; -} - -.fa-tty { - --fa: "\f1e4"; -} - -.fa-teletype { - --fa: "\f1e4"; -} - -.fa-binoculars { - --fa: "\f1e5"; -} - -.fa-plug { - --fa: "\f1e6"; -} - -.fa-newspaper { - --fa: "\f1ea"; -} - -.fa-wifi { - --fa: "\f1eb"; -} - -.fa-wifi-3 { - --fa: "\f1eb"; -} - -.fa-wifi-strong { - --fa: "\f1eb"; -} - -.fa-calculator { - --fa: "\f1ec"; -} - -.fa-bell-slash { - --fa: "\f1f6"; -} - -.fa-trash { - --fa: "\f1f8"; -} - -.fa-copyright { - --fa: "\f1f9"; -} - -.fa-eye-dropper { - --fa: "\f1fb"; -} - -.fa-eye-dropper-empty { - --fa: "\f1fb"; -} - -.fa-eyedropper { - --fa: "\f1fb"; -} - -.fa-paintbrush { - --fa: "\f1fc"; -} - -.fa-paint-brush { - --fa: "\f1fc"; -} - -.fa-cake-candles { - --fa: "\f1fd"; -} - -.fa-birthday-cake { - --fa: "\f1fd"; -} - -.fa-cake { - --fa: "\f1fd"; -} - -.fa-chart-area { - --fa: "\f1fe"; -} - -.fa-area-chart { - --fa: "\f1fe"; -} - -.fa-chart-pie { - --fa: "\f200"; -} - -.fa-pie-chart { - --fa: "\f200"; -} - -.fa-chart-line { - --fa: "\f201"; -} - -.fa-line-chart { - --fa: "\f201"; -} - -.fa-toggle-off { - --fa: "\f204"; -} - -.fa-toggle-on { - --fa: "\f205"; -} - -.fa-bicycle { - --fa: "\f206"; -} - -.fa-bus { - --fa: "\f207"; -} - -.fa-closed-captioning { - --fa: "\f20a"; -} - -.fa-shekel-sign { - --fa: "\f20b"; -} - -.fa-ils { - --fa: "\f20b"; -} - -.fa-shekel { - --fa: "\f20b"; -} - -.fa-sheqel { - --fa: "\f20b"; -} - -.fa-sheqel-sign { - --fa: "\f20b"; -} - -.fa-cart-plus { - --fa: "\f217"; -} - -.fa-cart-arrow-down { - --fa: "\f218"; -} - -.fa-diamond { - --fa: "\f219"; -} - -.fa-ship { - --fa: "\f21a"; -} - -.fa-user-secret { - --fa: "\f21b"; -} - -.fa-motorcycle { - --fa: "\f21c"; -} - -.fa-street-view { - --fa: "\f21d"; -} - -.fa-heart-pulse { - --fa: "\f21e"; -} - -.fa-heartbeat { - --fa: "\f21e"; -} - -.fa-venus { - --fa: "\f221"; -} - -.fa-mars { - --fa: "\f222"; -} - -.fa-mercury { - --fa: "\f223"; -} - -.fa-mars-and-venus { - --fa: "\f224"; -} - -.fa-transgender { - --fa: "\f225"; -} - -.fa-transgender-alt { - --fa: "\f225"; -} - -.fa-venus-double { - --fa: "\f226"; -} - -.fa-mars-double { - --fa: "\f227"; -} - -.fa-venus-mars { - --fa: "\f228"; -} - -.fa-mars-stroke { - --fa: "\f229"; -} - -.fa-mars-stroke-up { - --fa: "\f22a"; -} - -.fa-mars-stroke-v { - --fa: "\f22a"; -} - -.fa-mars-stroke-right { - --fa: "\f22b"; -} - -.fa-mars-stroke-h { - --fa: "\f22b"; -} - -.fa-neuter { - --fa: "\f22c"; -} - -.fa-genderless { - --fa: "\f22d"; -} - -.fa-server { - --fa: "\f233"; -} - -.fa-user-plus { - --fa: "\f234"; -} - -.fa-user-xmark { - --fa: "\f235"; -} - -.fa-user-times { - --fa: "\f235"; -} - -.fa-bed { - --fa: "\f236"; -} - -.fa-train { - --fa: "\f238"; -} - -.fa-train-subway { - --fa: "\f239"; -} - -.fa-subway { - --fa: "\f239"; -} - -.fa-battery-full { - --fa: "\f240"; -} - -.fa-battery { - --fa: "\f240"; -} - -.fa-battery-5 { - --fa: "\f240"; -} - -.fa-battery-three-quarters { - --fa: "\f241"; -} - -.fa-battery-4 { - --fa: "\f241"; -} - -.fa-battery-half { - --fa: "\f242"; -} - -.fa-battery-3 { - --fa: "\f242"; -} - -.fa-battery-quarter { - --fa: "\f243"; -} - -.fa-battery-2 { - --fa: "\f243"; -} - -.fa-battery-empty { - --fa: "\f244"; -} - -.fa-battery-0 { - --fa: "\f244"; -} - -.fa-arrow-pointer { - --fa: "\f245"; -} - -.fa-mouse-pointer { - --fa: "\f245"; -} - -.fa-i-cursor { - --fa: "\f246"; -} - -.fa-object-group { - --fa: "\f247"; -} - -.fa-object-ungroup { - --fa: "\f248"; -} - -.fa-note-sticky { - --fa: "\f249"; -} - -.fa-sticky-note { - --fa: "\f249"; -} - -.fa-clone { - --fa: "\f24d"; -} - -.fa-scale-balanced { - --fa: "\f24e"; -} - -.fa-balance-scale { - --fa: "\f24e"; -} - -.fa-hourglass-start { - --fa: "\f251"; -} - -.fa-hourglass-1 { - --fa: "\f251"; -} - -.fa-hourglass-half { - --fa: "\f252"; -} - -.fa-hourglass-2 { - --fa: "\f252"; -} - -.fa-hourglass-end { - --fa: "\f253"; -} - -.fa-hourglass-3 { - --fa: "\f253"; -} - -.fa-hourglass { - --fa: "\f254"; -} - -.fa-hourglass-empty { - --fa: "\f254"; -} - -.fa-hand-back-fist { - --fa: "\f255"; -} - -.fa-hand-rock { - --fa: "\f255"; -} - -.fa-hand { - --fa: "\f256"; -} - -.fa-hand-paper { - --fa: "\f256"; -} - -.fa-hand-scissors { - --fa: "\f257"; -} - -.fa-hand-lizard { - --fa: "\f258"; -} - -.fa-hand-spock { - --fa: "\f259"; -} - -.fa-hand-pointer { - --fa: "\f25a"; -} - -.fa-hand-peace { - --fa: "\f25b"; -} - -.fa-trademark { - --fa: "\f25c"; -} - -.fa-registered { - --fa: "\f25d"; -} - -.fa-tv { - --fa: "\f26c"; -} - -.fa-television { - --fa: "\f26c"; -} - -.fa-tv-alt { - --fa: "\f26c"; -} - -.fa-calendar-plus { - --fa: "\f271"; -} - -.fa-calendar-minus { - --fa: "\f272"; -} - -.fa-calendar-xmark { - --fa: "\f273"; -} - -.fa-calendar-times { - --fa: "\f273"; -} - -.fa-calendar-check { - --fa: "\f274"; -} - -.fa-industry { - --fa: "\f275"; -} - -.fa-map-pin { - --fa: "\f276"; -} - -.fa-signs-post { - --fa: "\f277"; -} - -.fa-map-signs { - --fa: "\f277"; -} - -.fa-map { - --fa: "\f279"; -} - -.fa-message { - --fa: "\f27a"; -} - -.fa-comment-alt { - --fa: "\f27a"; -} - -.fa-circle-pause { - --fa: "\f28b"; -} - -.fa-pause-circle { - --fa: "\f28b"; -} - -.fa-circle-stop { - --fa: "\f28d"; -} - -.fa-stop-circle { - --fa: "\f28d"; -} - -.fa-bag-shopping { - --fa: "\f290"; -} - -.fa-shopping-bag { - --fa: "\f290"; -} - -.fa-basket-shopping { - --fa: "\f291"; -} - -.fa-shopping-basket { - --fa: "\f291"; -} - -.fa-bluetooth { - --fa: "\f293"; -} - -.fa-universal-access { - --fa: "\f29a"; -} - -.fa-person-walking-with-cane { - --fa: "\f29d"; -} - -.fa-blind { - --fa: "\f29d"; -} - -.fa-audio-description { - --fa: "\f29e"; -} - -.fa-phone-volume { - --fa: "\f2a0"; -} - -.fa-volume-control-phone { - --fa: "\f2a0"; -} - -.fa-braille { - --fa: "\f2a1"; -} - -.fa-ear-listen { - --fa: "\f2a2"; -} - -.fa-assistive-listening-systems { - --fa: "\f2a2"; -} - -.fa-hands-asl-interpreting { - --fa: "\f2a3"; -} - -.fa-american-sign-language-interpreting { - --fa: "\f2a3"; -} - -.fa-asl-interpreting { - --fa: "\f2a3"; -} - -.fa-hands-american-sign-language-interpreting { - --fa: "\f2a3"; -} - -.fa-ear-deaf { - --fa: "\f2a4"; -} - -.fa-deaf { - --fa: "\f2a4"; -} - -.fa-deafness { - --fa: "\f2a4"; -} - -.fa-hard-of-hearing { - --fa: "\f2a4"; -} - -.fa-hands { - --fa: "\f2a7"; -} - -.fa-sign-language { - --fa: "\f2a7"; -} - -.fa-signing { - --fa: "\f2a7"; -} - -.fa-eye-low-vision { - --fa: "\f2a8"; -} - -.fa-low-vision { - --fa: "\f2a8"; -} - -.fa-font-awesome { - --fa: "\f2b4"; -} - -.fa-font-awesome-flag { - --fa: "\f2b4"; -} - -.fa-font-awesome-logo-full { - --fa: "\f2b4"; -} - -.fa-handshake { - --fa: "\f2b5"; -} - -.fa-handshake-alt { - --fa: "\f2b5"; -} - -.fa-handshake-simple { - --fa: "\f2b5"; -} - -.fa-envelope-open { - --fa: "\f2b6"; -} - -.fa-address-book { - --fa: "\f2b9"; -} - -.fa-contact-book { - --fa: "\f2b9"; -} - -.fa-address-card { - --fa: "\f2bb"; -} - -.fa-contact-card { - --fa: "\f2bb"; -} - -.fa-vcard { - --fa: "\f2bb"; -} - -.fa-circle-user { - --fa: "\f2bd"; -} - -.fa-user-circle { - --fa: "\f2bd"; -} - -.fa-id-badge { - --fa: "\f2c1"; -} - -.fa-id-card { - --fa: "\f2c2"; -} - -.fa-drivers-license { - --fa: "\f2c2"; -} - -.fa-temperature-full { - --fa: "\f2c7"; -} - -.fa-temperature-4 { - --fa: "\f2c7"; -} - -.fa-thermometer-4 { - --fa: "\f2c7"; -} - -.fa-thermometer-full { - --fa: "\f2c7"; -} - -.fa-temperature-three-quarters { - --fa: "\f2c8"; -} - -.fa-temperature-3 { - --fa: "\f2c8"; -} - -.fa-thermometer-3 { - --fa: "\f2c8"; -} - -.fa-thermometer-three-quarters { - --fa: "\f2c8"; -} - -.fa-temperature-half { - --fa: "\f2c9"; -} - -.fa-temperature-2 { - --fa: "\f2c9"; -} - -.fa-thermometer-2 { - --fa: "\f2c9"; -} - -.fa-thermometer-half { - --fa: "\f2c9"; -} - -.fa-temperature-quarter { - --fa: "\f2ca"; -} - -.fa-temperature-1 { - --fa: "\f2ca"; -} - -.fa-thermometer-1 { - --fa: "\f2ca"; -} - -.fa-thermometer-quarter { - --fa: "\f2ca"; -} - -.fa-temperature-empty { - --fa: "\f2cb"; -} - -.fa-temperature-0 { - --fa: "\f2cb"; -} - -.fa-thermometer-0 { - --fa: "\f2cb"; -} - -.fa-thermometer-empty { - --fa: "\f2cb"; -} - -.fa-shower { - --fa: "\f2cc"; -} - -.fa-bath { - --fa: "\f2cd"; -} - -.fa-bathtub { - --fa: "\f2cd"; -} - -.fa-podcast { - --fa: "\f2ce"; -} - -.fa-window-maximize { - --fa: "\f2d0"; -} - -.fa-window-minimize { - --fa: "\f2d1"; -} - -.fa-window-restore { - --fa: "\f2d2"; -} - -.fa-square-xmark { - --fa: "\f2d3"; -} - -.fa-times-square { - --fa: "\f2d3"; -} - -.fa-xmark-square { - --fa: "\f2d3"; -} - -.fa-microchip { - --fa: "\f2db"; -} - -.fa-snowflake { - --fa: "\f2dc"; -} - -.fa-watch { - --fa: "\f2e1"; -} - -.fa-volume-slash { - --fa: "\f2e2"; -} - -.fa-fork { - --fa: "\f2e3"; -} - -.fa-utensil-fork { - --fa: "\f2e3"; -} - -.fa-knife { - --fa: "\f2e4"; -} - -.fa-utensil-knife { - --fa: "\f2e4"; -} - -.fa-spoon { - --fa: "\f2e5"; -} - -.fa-utensil-spoon { - --fa: "\f2e5"; -} - -.fa-fork-knife { - --fa: "\f2e6"; -} - -.fa-utensils-alt { - --fa: "\f2e6"; -} - -.fa-utensils { - --fa: "\f2e7"; -} - -.fa-cutlery { - --fa: "\f2e7"; -} - -.fa-circle-dollar { - --fa: "\f2e8"; -} - -.fa-dollar-circle { - --fa: "\f2e8"; -} - -.fa-usd-circle { - --fa: "\f2e8"; -} - -.fa-square-dollar { - --fa: "\f2e9"; -} - -.fa-dollar-square { - --fa: "\f2e9"; -} - -.fa-usd-square { - --fa: "\f2e9"; -} - -.fa-rotate-left { - --fa: "\f2ea"; -} - -.fa-rotate-back { - --fa: "\f2ea"; -} - -.fa-rotate-backward { - --fa: "\f2ea"; -} - -.fa-undo-alt { - --fa: "\f2ea"; -} - -.fa-trophy-star { - --fa: "\f2eb"; -} - -.fa-trophy-alt { - --fa: "\f2eb"; -} - -.fa-triangle { - --fa: "\f2ec"; -} - -.fa-trash-can { - --fa: "\f2ed"; -} - -.fa-trash-alt { - --fa: "\f2ed"; -} - -.fa-hexagon-xmark { - --fa: "\f2ee"; -} - -.fa-times-hexagon { - --fa: "\f2ee"; -} - -.fa-xmark-hexagon { - --fa: "\f2ee"; -} - -.fa-octagon-xmark { - --fa: "\f2f0"; -} - -.fa-times-octagon { - --fa: "\f2f0"; -} - -.fa-xmark-octagon { - --fa: "\f2f0"; -} - -.fa-rotate { - --fa: "\f2f1"; -} - -.fa-sync-alt { - --fa: "\f2f1"; -} - -.fa-stopwatch { - --fa: "\f2f2"; -} - -.fa-star-exclamation { - --fa: "\f2f3"; -} - -.fa-spade { - --fa: "\f2f4"; -} - -.fa-right-from-bracket { - --fa: "\f2f5"; -} - -.fa-sign-out-alt { - --fa: "\f2f5"; -} - -.fa-right-to-bracket { - --fa: "\f2f6"; -} - -.fa-sign-in-alt { - --fa: "\f2f6"; -} - -.fa-shield-check { - --fa: "\f2f7"; -} - -.fa-scrubber { - --fa: "\f2f8"; -} - -.fa-rotate-right { - --fa: "\f2f9"; -} - -.fa-redo-alt { - --fa: "\f2f9"; -} - -.fa-rotate-forward { - --fa: "\f2f9"; -} - -.fa-rectangle { - --fa: "\f2fa"; -} - -.fa-rectangle-landscape { - --fa: "\f2fa"; -} - -.fa-rectangle-vertical { - --fa: "\f2fb"; -} - -.fa-rectangle-portrait { - --fa: "\f2fb"; -} - -.fa-rectangle-wide { - --fa: "\f2fc"; -} - -.fa-square-question { - --fa: "\f2fd"; -} - -.fa-question-square { - --fa: "\f2fd"; -} - -.fa-poo { - --fa: "\f2fe"; -} - -.fa-hexagon-plus { - --fa: "\f300"; -} - -.fa-plus-hexagon { - --fa: "\f300"; -} - -.fa-octagon-plus { - --fa: "\f301"; -} - -.fa-plus-octagon { - --fa: "\f301"; -} - -.fa-images { - --fa: "\f302"; -} - -.fa-pencil { - --fa: "\f303"; -} - -.fa-pencil-alt { - --fa: "\f303"; -} - -.fa-pen { - --fa: "\f304"; -} - -.fa-pen-clip { - --fa: "\f305"; -} - -.fa-pen-alt { - --fa: "\f305"; -} - -.fa-octagon { - --fa: "\f306"; -} - -.fa-hexagon-minus { - --fa: "\f307"; -} - -.fa-minus-hexagon { - --fa: "\f307"; -} - -.fa-octagon-minus { - --fa: "\f308"; -} - -.fa-minus-octagon { - --fa: "\f308"; -} - -.fa-down-long { - --fa: "\f309"; -} - -.fa-long-arrow-alt-down { - --fa: "\f309"; -} - -.fa-left-long { - --fa: "\f30a"; -} - -.fa-long-arrow-alt-left { - --fa: "\f30a"; -} - -.fa-right-long { - --fa: "\f30b"; -} - -.fa-long-arrow-alt-right { - --fa: "\f30b"; -} - -.fa-up-long { - --fa: "\f30c"; -} - -.fa-long-arrow-alt-up { - --fa: "\f30c"; -} - -.fa-lock-keyhole { - --fa: "\f30d"; -} - -.fa-lock-alt { - --fa: "\f30d"; -} - -.fa-jack-o-lantern { - --fa: "\f30e"; -} - -.fa-square-info { - --fa: "\f30f"; -} - -.fa-info-square { - --fa: "\f30f"; -} - -.fa-inbox-in { - --fa: "\f310"; -} - -.fa-inbox-arrow-down { - --fa: "\f310"; -} - -.fa-inbox-out { - --fa: "\f311"; -} - -.fa-inbox-arrow-up { - --fa: "\f311"; -} - -.fa-hexagon { - --fa: "\f312"; -} - -.fa-h1 { - --fa: "\f313"; -} - -.fa-h2 { - --fa: "\f314"; -} - -.fa-h3 { - --fa: "\f315"; -} - -.fa-file-check { - --fa: "\f316"; -} - -.fa-file-xmark { - --fa: "\f317"; -} - -.fa-file-times { - --fa: "\f317"; -} - -.fa-file-minus { - --fa: "\f318"; -} - -.fa-file-plus { - --fa: "\f319"; -} - -.fa-file-exclamation { - --fa: "\f31a"; -} - -.fa-file-pen { - --fa: "\f31c"; -} - -.fa-file-edit { - --fa: "\f31c"; -} - -.fa-arrows-maximize { - --fa: "\f31d"; -} - -.fa-expand-arrows { - --fa: "\f31d"; -} - -.fa-maximize { - --fa: "\f31e"; -} - -.fa-expand-arrows-alt { - --fa: "\f31e"; -} - -.fa-expand-wide { - --fa: "\f320"; -} - -.fa-square-exclamation { - --fa: "\f321"; -} - -.fa-exclamation-square { - --fa: "\f321"; -} - -.fa-chevrons-down { - --fa: "\f322"; -} - -.fa-chevron-double-down { - --fa: "\f322"; -} - -.fa-chevrons-left { - --fa: "\f323"; -} - -.fa-chevron-double-left { - --fa: "\f323"; -} - -.fa-chevrons-right { - --fa: "\f324"; -} - -.fa-chevron-double-right { - --fa: "\f324"; -} - -.fa-chevrons-up { - --fa: "\f325"; -} - -.fa-chevron-double-up { - --fa: "\f325"; -} - -.fa-compress-wide { - --fa: "\f326"; -} - -.fa-club { - --fa: "\f327"; -} - -.fa-clipboard { - --fa: "\f328"; -} - -.fa-square-chevron-down { - --fa: "\f329"; -} - -.fa-chevron-square-down { - --fa: "\f329"; -} - -.fa-square-chevron-left { - --fa: "\f32a"; -} - -.fa-chevron-square-left { - --fa: "\f32a"; -} - -.fa-square-chevron-right { - --fa: "\f32b"; -} - -.fa-chevron-square-right { - --fa: "\f32b"; -} - -.fa-square-chevron-up { - --fa: "\f32c"; -} - -.fa-chevron-square-up { - --fa: "\f32c"; -} - -.fa-circle-caret-down { - --fa: "\f32d"; -} - -.fa-caret-circle-down { - --fa: "\f32d"; -} - -.fa-circle-caret-left { - --fa: "\f32e"; -} - -.fa-caret-circle-left { - --fa: "\f32e"; -} - -.fa-circle-caret-right { - --fa: "\f330"; -} - -.fa-caret-circle-right { - --fa: "\f330"; -} - -.fa-circle-caret-up { - --fa: "\f331"; -} - -.fa-caret-circle-up { - --fa: "\f331"; -} - -.fa-calendar-pen { - --fa: "\f333"; -} - -.fa-calendar-edit { - --fa: "\f333"; -} - -.fa-calendar-exclamation { - --fa: "\f334"; -} - -.fa-badge { - --fa: "\f335"; -} - -.fa-badge-check { - --fa: "\f336"; -} - -.fa-left-right { - --fa: "\f337"; -} - -.fa-arrows-alt-h { - --fa: "\f337"; -} - -.fa-up-down { - --fa: "\f338"; -} - -.fa-arrows-alt-v { - --fa: "\f338"; -} - -.fa-square-arrow-down { - --fa: "\f339"; -} - -.fa-arrow-square-down { - --fa: "\f339"; -} - -.fa-square-arrow-left { - --fa: "\f33a"; -} - -.fa-arrow-square-left { - --fa: "\f33a"; -} - -.fa-square-arrow-right { - --fa: "\f33b"; -} - -.fa-arrow-square-right { - --fa: "\f33b"; -} - -.fa-square-arrow-up { - --fa: "\f33c"; -} - -.fa-arrow-square-up { - --fa: "\f33c"; -} - -.fa-arrow-down-to-line { - --fa: "\f33d"; -} - -.fa-arrow-to-bottom { - --fa: "\f33d"; -} - -.fa-arrow-left-to-line { - --fa: "\f33e"; -} - -.fa-arrow-to-left { - --fa: "\f33e"; -} - -.fa-arrow-right-to-line { - --fa: "\f340"; -} - -.fa-arrow-to-right { - --fa: "\f340"; -} - -.fa-arrow-up-to-line { - --fa: "\f341"; -} - -.fa-arrow-to-top { - --fa: "\f341"; -} - -.fa-arrow-up-from-line { - --fa: "\f342"; -} - -.fa-arrow-from-bottom { - --fa: "\f342"; -} - -.fa-arrow-right-from-line { - --fa: "\f343"; -} - -.fa-arrow-from-left { - --fa: "\f343"; -} - -.fa-arrow-left-from-line { - --fa: "\f344"; -} - -.fa-arrow-from-right { - --fa: "\f344"; -} - -.fa-arrow-down-from-line { - --fa: "\f345"; -} - -.fa-arrow-from-top { - --fa: "\f345"; -} - -.fa-up-from-line { - --fa: "\f346"; -} - -.fa-arrow-alt-from-bottom { - --fa: "\f346"; -} - -.fa-right-from-line { - --fa: "\f347"; -} - -.fa-arrow-alt-from-left { - --fa: "\f347"; -} - -.fa-left-from-line { - --fa: "\f348"; -} - -.fa-arrow-alt-from-right { - --fa: "\f348"; -} - -.fa-down-from-line { - --fa: "\f349"; -} - -.fa-arrow-alt-from-top { - --fa: "\f349"; -} - -.fa-down-to-line { - --fa: "\f34a"; -} - -.fa-arrow-alt-to-bottom { - --fa: "\f34a"; -} - -.fa-left-to-line { - --fa: "\f34b"; -} - -.fa-arrow-alt-to-left { - --fa: "\f34b"; -} - -.fa-right-to-line { - --fa: "\f34c"; -} - -.fa-arrow-alt-to-right { - --fa: "\f34c"; -} - -.fa-up-to-line { - --fa: "\f34d"; -} - -.fa-arrow-alt-to-top { - --fa: "\f34d"; -} - -.fa-alarm-clock { - --fa: "\f34e"; -} - -.fa-square-down { - --fa: "\f350"; -} - -.fa-arrow-alt-square-down { - --fa: "\f350"; -} - -.fa-square-left { - --fa: "\f351"; -} - -.fa-arrow-alt-square-left { - --fa: "\f351"; -} - -.fa-square-right { - --fa: "\f352"; -} - -.fa-arrow-alt-square-right { - --fa: "\f352"; -} - -.fa-square-up { - --fa: "\f353"; -} - -.fa-arrow-alt-square-up { - --fa: "\f353"; -} - -.fa-down { - --fa: "\f354"; -} - -.fa-arrow-alt-down { - --fa: "\f354"; -} - -.fa-left { - --fa: "\f355"; -} - -.fa-arrow-alt-left { - --fa: "\f355"; -} - -.fa-right { - --fa: "\f356"; -} - -.fa-arrow-alt-right { - --fa: "\f356"; -} - -.fa-up { - --fa: "\f357"; -} - -.fa-arrow-alt-up { - --fa: "\f357"; -} - -.fa-circle-down { - --fa: "\f358"; -} - -.fa-arrow-alt-circle-down { - --fa: "\f358"; -} - -.fa-circle-left { - --fa: "\f359"; -} - -.fa-arrow-alt-circle-left { - --fa: "\f359"; -} - -.fa-circle-right { - --fa: "\f35a"; -} - -.fa-arrow-alt-circle-right { - --fa: "\f35a"; -} - -.fa-circle-up { - --fa: "\f35b"; -} - -.fa-arrow-alt-circle-up { - --fa: "\f35b"; -} - -.fa-up-right-from-square { - --fa: "\f35d"; -} - -.fa-external-link-alt { - --fa: "\f35d"; -} - -.fa-square-up-right { - --fa: "\f360"; -} - -.fa-external-link-square-alt { - --fa: "\f360"; -} - -.fa-arrows-retweet { - --fa: "\f361"; -} - -.fa-retweet-alt { - --fa: "\f361"; -} - -.fa-right-left { - --fa: "\f362"; -} - -.fa-exchange-alt { - --fa: "\f362"; -} - -.fa-repeat { - --fa: "\f363"; -} - -.fa-arrows-repeat { - --fa: "\f364"; -} - -.fa-repeat-alt { - --fa: "\f364"; -} - -.fa-repeat-1 { - --fa: "\f365"; -} - -.fa-arrows-repeat-1 { - --fa: "\f366"; -} - -.fa-repeat-1-alt { - --fa: "\f366"; -} - -.fa-share-all { - --fa: "\f367"; -} - -.fa-battery-bolt { - --fa: "\f376"; -} - -.fa-battery-slash { - --fa: "\f377"; -} - -.fa-browser { - --fa: "\f37e"; -} - -.fa-code-commit { - --fa: "\f386"; -} - -.fa-code-merge { - --fa: "\f387"; -} - -.fa-credit-card-blank { - --fa: "\f389"; -} - -.fa-credit-card-front { - --fa: "\f38a"; -} - -.fa-desktop { - --fa: "\f390"; -} - -.fa-desktop-alt { - --fa: "\f390"; -} - -.fa-ellipsis-stroke { - --fa: "\f39b"; -} - -.fa-ellipsis-h-alt { - --fa: "\f39b"; -} - -.fa-ellipsis-stroke-vertical { - --fa: "\f39c"; -} - -.fa-ellipsis-v-alt { - --fa: "\f39c"; -} - -.fa-gem { - --fa: "\f3a5"; -} - -.fa-industry-windows { - --fa: "\f3b3"; -} - -.fa-industry-alt { - --fa: "\f3b3"; -} - -.fa-turn-down { - --fa: "\f3be"; -} - -.fa-level-down-alt { - --fa: "\f3be"; -} - -.fa-turn-up { - --fa: "\f3bf"; -} - -.fa-level-up-alt { - --fa: "\f3bf"; -} - -.fa-lock-open { - --fa: "\f3c1"; -} - -.fa-lock-keyhole-open { - --fa: "\f3c2"; -} - -.fa-lock-open-alt { - --fa: "\f3c2"; -} - -.fa-location-dot { - --fa: "\f3c5"; -} - -.fa-map-marker-alt { - --fa: "\f3c5"; -} - -.fa-microphone-lines { - --fa: "\f3c9"; -} - -.fa-microphone-alt { - --fa: "\f3c9"; -} - -.fa-mobile-screen-button { - --fa: "\f3cd"; -} - -.fa-mobile-alt { - --fa: "\f3cd"; -} - -.fa-mobile { - --fa: "\f3ce"; -} - -.fa-mobile-android { - --fa: "\f3ce"; -} - -.fa-mobile-phone { - --fa: "\f3ce"; -} - -.fa-mobile-screen { - --fa: "\f3cf"; -} - -.fa-mobile-android-alt { - --fa: "\f3cf"; -} - -.fa-money-bill-1 { - --fa: "\f3d1"; -} - -.fa-money-bill-alt { - --fa: "\f3d1"; -} - -.fa-phone-slash { - --fa: "\f3dd"; -} - -.fa-plane-engines { - --fa: "\f3de"; -} - -.fa-plane-alt { - --fa: "\f3de"; -} - -.fa-image-portrait { - --fa: "\f3e0"; -} - -.fa-portrait { - --fa: "\f3e0"; -} - -.fa-reply { - --fa: "\f3e5"; -} - -.fa-mail-reply { - --fa: "\f3e5"; -} - -.fa-shield-halved { - --fa: "\f3ed"; -} - -.fa-shield-alt { - --fa: "\f3ed"; -} - -.fa-square-sliders { - --fa: "\f3f0"; -} - -.fa-sliders-h-square { - --fa: "\f3f0"; -} - -.fa-sliders-up { - --fa: "\f3f1"; -} - -.fa-sliders-v { - --fa: "\f3f1"; -} - -.fa-square-sliders-vertical { - --fa: "\f3f2"; -} - -.fa-sliders-v-square { - --fa: "\f3f2"; -} - -.fa-spinner-third { - --fa: "\f3f4"; -} - -.fa-tablet-screen-button { - --fa: "\f3fa"; -} - -.fa-tablet-alt { - --fa: "\f3fa"; -} - -.fa-tablet { - --fa: "\f3fb"; -} - -.fa-tablet-android { - --fa: "\f3fb"; -} - -.fa-tablet-screen { - --fa: "\f3fc"; -} - -.fa-tablet-android-alt { - --fa: "\f3fc"; -} - -.fa-ticket-simple { - --fa: "\f3ff"; -} - -.fa-ticket-alt { - --fa: "\f3ff"; -} - -.fa-tree-deciduous { - --fa: "\f400"; -} - -.fa-tree-alt { - --fa: "\f400"; -} - -.fa-tv-retro { - --fa: "\f401"; -} - -.fa-window { - --fa: "\f40e"; -} - -.fa-window-flip { - --fa: "\f40f"; -} - -.fa-window-alt { - --fa: "\f40f"; -} - -.fa-rectangle-xmark { - --fa: "\f410"; -} - -.fa-rectangle-times { - --fa: "\f410"; -} - -.fa-times-rectangle { - --fa: "\f410"; -} - -.fa-window-close { - --fa: "\f410"; -} - -.fa-down-left-and-up-right-to-center { - --fa: "\f422"; -} - -.fa-compress-alt { - --fa: "\f422"; -} - -.fa-up-right-and-down-left-from-center { - --fa: "\f424"; -} - -.fa-expand-alt { - --fa: "\f424"; -} - -.fa-baseball-bat-ball { - --fa: "\f432"; -} - -.fa-baseball { - --fa: "\f433"; -} - -.fa-baseball-ball { - --fa: "\f433"; -} - -.fa-basketball { - --fa: "\f434"; -} - -.fa-basketball-ball { - --fa: "\f434"; -} - -.fa-basketball-hoop { - --fa: "\f435"; -} - -.fa-bowling-ball { - --fa: "\f436"; -} - -.fa-bowling-pins { - --fa: "\f437"; -} - -.fa-boxing-glove { - --fa: "\f438"; -} - -.fa-glove-boxing { - --fa: "\f438"; -} - -.fa-chess { - --fa: "\f439"; -} - -.fa-chess-bishop { - --fa: "\f43a"; -} - -.fa-chess-bishop-piece { - --fa: "\f43b"; -} - -.fa-chess-bishop-alt { - --fa: "\f43b"; -} - -.fa-chess-board { - --fa: "\f43c"; -} - -.fa-chess-clock { - --fa: "\f43d"; -} - -.fa-chess-clock-flip { - --fa: "\f43e"; -} - -.fa-chess-clock-alt { - --fa: "\f43e"; -} - -.fa-chess-king { - --fa: "\f43f"; -} - -.fa-chess-king-piece { - --fa: "\f440"; -} - -.fa-chess-king-alt { - --fa: "\f440"; -} - -.fa-chess-knight { - --fa: "\f441"; -} - -.fa-chess-knight-piece { - --fa: "\f442"; -} - -.fa-chess-knight-alt { - --fa: "\f442"; -} - -.fa-chess-pawn { - --fa: "\f443"; -} - -.fa-chess-pawn-piece { - --fa: "\f444"; -} - -.fa-chess-pawn-alt { - --fa: "\f444"; -} - -.fa-chess-queen { - --fa: "\f445"; -} - -.fa-chess-queen-piece { - --fa: "\f446"; -} - -.fa-chess-queen-alt { - --fa: "\f446"; -} - -.fa-chess-rook { - --fa: "\f447"; -} - -.fa-chess-rook-piece { - --fa: "\f448"; -} - -.fa-chess-rook-alt { - --fa: "\f448"; -} - -.fa-cricket-bat-ball { - --fa: "\f449"; -} - -.fa-cricket { - --fa: "\f449"; -} - -.fa-curling-stone { - --fa: "\f44a"; -} - -.fa-curling { - --fa: "\f44a"; -} - -.fa-dumbbell { - --fa: "\f44b"; -} - -.fa-field-hockey-stick-ball { - --fa: "\f44c"; -} - -.fa-field-hockey { - --fa: "\f44c"; -} - -.fa-football { - --fa: "\f44e"; -} - -.fa-football-ball { - --fa: "\f44e"; -} - -.fa-football-helmet { - --fa: "\f44f"; -} - -.fa-golf-ball-tee { - --fa: "\f450"; -} - -.fa-golf-ball { - --fa: "\f450"; -} - -.fa-golf-club { - --fa: "\f451"; -} - -.fa-hockey-puck { - --fa: "\f453"; -} - -.fa-hockey-sticks { - --fa: "\f454"; -} - -.fa-luchador-mask { - --fa: "\f455"; -} - -.fa-luchador { - --fa: "\f455"; -} - -.fa-mask-luchador { - --fa: "\f455"; -} - -.fa-flag-pennant { - --fa: "\f456"; -} - -.fa-pennant { - --fa: "\f456"; -} - -.fa-broom-ball { - --fa: "\f458"; -} - -.fa-quidditch { - --fa: "\f458"; -} - -.fa-quidditch-broom-ball { - --fa: "\f458"; -} - -.fa-racquet { - --fa: "\f45a"; -} - -.fa-shuttlecock { - --fa: "\f45b"; -} - -.fa-square-full { - --fa: "\f45c"; -} - -.fa-table-tennis-paddle-ball { - --fa: "\f45d"; -} - -.fa-ping-pong-paddle-ball { - --fa: "\f45d"; -} - -.fa-table-tennis { - --fa: "\f45d"; -} - -.fa-tennis-ball { - --fa: "\f45e"; -} - -.fa-volleyball { - --fa: "\f45f"; -} - -.fa-volleyball-ball { - --fa: "\f45f"; -} - -.fa-whistle { - --fa: "\f460"; -} - -.fa-hand-dots { - --fa: "\f461"; -} - -.fa-allergies { - --fa: "\f461"; -} - -.fa-bandage { - --fa: "\f462"; -} - -.fa-band-aid { - --fa: "\f462"; -} - -.fa-rectangle-barcode { - --fa: "\f463"; -} - -.fa-barcode-alt { - --fa: "\f463"; -} - -.fa-barcode-read { - --fa: "\f464"; -} - -.fa-barcode-scan { - --fa: "\f465"; -} - -.fa-box { - --fa: "\f466"; -} - -.fa-box-check { - --fa: "\f467"; -} - -.fa-boxes-stacked { - --fa: "\f468"; -} - -.fa-boxes { - --fa: "\f468"; -} - -.fa-boxes-alt { - --fa: "\f468"; -} - -.fa-briefcase-medical { - --fa: "\f469"; -} - -.fa-fire-flame-simple { - --fa: "\f46a"; -} - -.fa-burn { - --fa: "\f46a"; -} - -.fa-capsules { - --fa: "\f46b"; -} - -.fa-clipboard-check { - --fa: "\f46c"; -} - -.fa-clipboard-list { - --fa: "\f46d"; -} - -.fa-conveyor-belt { - --fa: "\f46e"; -} - -.fa-conveyor-belt-boxes { - --fa: "\f46f"; -} - -.fa-conveyor-belt-alt { - --fa: "\f46f"; -} - -.fa-person-dots-from-line { - --fa: "\f470"; -} - -.fa-diagnoses { - --fa: "\f470"; -} - -.fa-dna { - --fa: "\f471"; -} - -.fa-dolly { - --fa: "\f472"; -} - -.fa-dolly-box { - --fa: "\f472"; -} - -.fa-dolly-empty { - --fa: "\f473"; -} - -.fa-cart-flatbed { - --fa: "\f474"; -} - -.fa-dolly-flatbed { - --fa: "\f474"; -} - -.fa-cart-flatbed-boxes { - --fa: "\f475"; -} - -.fa-dolly-flatbed-alt { - --fa: "\f475"; -} - -.fa-cart-flatbed-empty { - --fa: "\f476"; -} - -.fa-dolly-flatbed-empty { - --fa: "\f476"; -} - -.fa-file-medical { - --fa: "\f477"; -} - -.fa-file-waveform { - --fa: "\f478"; -} - -.fa-file-medical-alt { - --fa: "\f478"; -} - -.fa-kit-medical { - --fa: "\f479"; -} - -.fa-first-aid { - --fa: "\f479"; -} - -.fa-forklift { - --fa: "\f47a"; -} - -.fa-hand-holding-box { - --fa: "\f47b"; -} - -.fa-hands-holding-diamond { - --fa: "\f47c"; -} - -.fa-hand-receiving { - --fa: "\f47c"; -} - -.fa-circle-h { - --fa: "\f47e"; -} - -.fa-hospital-symbol { - --fa: "\f47e"; -} - -.fa-id-card-clip { - --fa: "\f47f"; -} - -.fa-id-card-alt { - --fa: "\f47f"; -} - -.fa-shelves { - --fa: "\f480"; -} - -.fa-inventory { - --fa: "\f480"; -} - -.fa-notes-medical { - --fa: "\f481"; -} - -.fa-pallet { - --fa: "\f482"; -} - -.fa-pallet-boxes { - --fa: "\f483"; -} - -.fa-palette-boxes { - --fa: "\f483"; -} - -.fa-pallet-alt { - --fa: "\f483"; -} - -.fa-pills { - --fa: "\f484"; -} - -.fa-prescription-bottle { - --fa: "\f485"; -} - -.fa-prescription-bottle-medical { - --fa: "\f486"; -} - -.fa-prescription-bottle-alt { - --fa: "\f486"; -} - -.fa-bed-pulse { - --fa: "\f487"; -} - -.fa-procedures { - --fa: "\f487"; -} - -.fa-scanner-gun { - --fa: "\f488"; -} - -.fa-scanner { - --fa: "\f488"; -} - -.fa-scanner-keyboard { - --fa: "\f489"; -} - -.fa-scanner-touchscreen { - --fa: "\f48a"; -} - -.fa-truck-fast { - --fa: "\f48b"; -} - -.fa-shipping-fast { - --fa: "\f48b"; -} - -.fa-truck-clock { - --fa: "\f48c"; -} - -.fa-shipping-timed { - --fa: "\f48c"; -} - -.fa-smoking { - --fa: "\f48d"; -} - -.fa-syringe { - --fa: "\f48e"; -} - -.fa-tablet-rugged { - --fa: "\f48f"; -} - -.fa-tablets { - --fa: "\f490"; -} - -.fa-thermometer { - --fa: "\f491"; -} - -.fa-vial { - --fa: "\f492"; -} - -.fa-vials { - --fa: "\f493"; -} - -.fa-warehouse { - --fa: "\f494"; -} - -.fa-warehouse-full { - --fa: "\f495"; -} - -.fa-warehouse-alt { - --fa: "\f495"; -} - -.fa-weight-scale { - --fa: "\f496"; -} - -.fa-weight { - --fa: "\f496"; -} - -.fa-x-ray { - --fa: "\f497"; -} - -.fa-blanket { - --fa: "\f498"; -} - -.fa-book-heart { - --fa: "\f499"; -} - -.fa-box-taped { - --fa: "\f49a"; -} - -.fa-box-alt { - --fa: "\f49a"; -} - -.fa-square-fragile { - --fa: "\f49b"; -} - -.fa-box-fragile { - --fa: "\f49b"; -} - -.fa-square-wine-glass-crack { - --fa: "\f49b"; -} - -.fa-box-open-full { - --fa: "\f49c"; -} - -.fa-box-full { - --fa: "\f49c"; -} - -.fa-box-heart { - --fa: "\f49d"; -} - -.fa-box-open { - --fa: "\f49e"; -} - -.fa-square-this-way-up { - --fa: "\f49f"; -} - -.fa-box-up { - --fa: "\f49f"; -} - -.fa-box-dollar { - --fa: "\f4a0"; -} - -.fa-box-usd { - --fa: "\f4a0"; -} - -.fa-message-check { - --fa: "\f4a2"; -} - -.fa-comment-alt-check { - --fa: "\f4a2"; -} - -.fa-message-dots { - --fa: "\f4a3"; -} - -.fa-comment-alt-dots { - --fa: "\f4a3"; -} - -.fa-messaging { - --fa: "\f4a3"; -} - -.fa-message-pen { - --fa: "\f4a4"; -} - -.fa-comment-alt-edit { - --fa: "\f4a4"; -} - -.fa-message-edit { - --fa: "\f4a4"; -} - -.fa-message-exclamation { - --fa: "\f4a5"; -} - -.fa-comment-alt-exclamation { - --fa: "\f4a5"; -} - -.fa-message-lines { - --fa: "\f4a6"; -} - -.fa-comment-alt-lines { - --fa: "\f4a6"; -} - -.fa-message-minus { - --fa: "\f4a7"; -} - -.fa-comment-alt-minus { - --fa: "\f4a7"; -} - -.fa-message-plus { - --fa: "\f4a8"; -} - -.fa-comment-alt-plus { - --fa: "\f4a8"; -} - -.fa-message-slash { - --fa: "\f4a9"; -} - -.fa-comment-alt-slash { - --fa: "\f4a9"; -} - -.fa-message-smile { - --fa: "\f4aa"; -} - -.fa-comment-alt-smile { - --fa: "\f4aa"; -} - -.fa-message-xmark { - --fa: "\f4ab"; -} - -.fa-comment-alt-times { - --fa: "\f4ab"; -} - -.fa-message-times { - --fa: "\f4ab"; -} - -.fa-comment-check { - --fa: "\f4ac"; -} - -.fa-comment-dots { - --fa: "\f4ad"; -} - -.fa-commenting { - --fa: "\f4ad"; -} - -.fa-comment-pen { - --fa: "\f4ae"; -} - -.fa-comment-edit { - --fa: "\f4ae"; -} - -.fa-comment-exclamation { - --fa: "\f4af"; -} - -.fa-comment-lines { - --fa: "\f4b0"; -} - -.fa-comment-minus { - --fa: "\f4b1"; -} - -.fa-comment-plus { - --fa: "\f4b2"; -} - -.fa-comment-slash { - --fa: "\f4b3"; -} - -.fa-comment-smile { - --fa: "\f4b4"; -} - -.fa-comment-xmark { - --fa: "\f4b5"; -} - -.fa-comment-times { - --fa: "\f4b5"; -} - -.fa-messages { - --fa: "\f4b6"; -} - -.fa-comments-alt { - --fa: "\f4b6"; -} - -.fa-container-storage { - --fa: "\f4b7"; -} - -.fa-couch { - --fa: "\f4b8"; -} - -.fa-circle-dollar-to-slot { - --fa: "\f4b9"; -} - -.fa-donate { - --fa: "\f4b9"; -} - -.fa-dove { - --fa: "\f4ba"; -} - -.fa-wine-glass-crack { - --fa: "\f4bb"; -} - -.fa-fragile { - --fa: "\f4bb"; -} - -.fa-hand-heart { - --fa: "\f4bc"; -} - -.fa-hand-holding { - --fa: "\f4bd"; -} - -.fa-hand-holding-heart { - --fa: "\f4be"; -} - -.fa-hand-holding-seedling { - --fa: "\f4bf"; -} - -.fa-hand-holding-dollar { - --fa: "\f4c0"; -} - -.fa-hand-holding-usd { - --fa: "\f4c0"; -} - -.fa-hand-holding-droplet { - --fa: "\f4c1"; -} - -.fa-hand-holding-water { - --fa: "\f4c1"; -} - -.fa-hands-holding { - --fa: "\f4c2"; -} - -.fa-hands-holding-heart { - --fa: "\f4c3"; -} - -.fa-hands-heart { - --fa: "\f4c3"; -} - -.fa-handshake-angle { - --fa: "\f4c4"; -} - -.fa-hands-helping { - --fa: "\f4c4"; -} - -.fa-hands-holding-dollar { - --fa: "\f4c5"; -} - -.fa-hands-usd { - --fa: "\f4c5"; -} - -.fa-circle-heart { - --fa: "\f4c7"; -} - -.fa-heart-circle { - --fa: "\f4c7"; -} - -.fa-square-heart { - --fa: "\f4c8"; -} - -.fa-heart-square { - --fa: "\f4c8"; -} - -.fa-house-heart { - --fa: "\f4c9"; -} - -.fa-home-heart { - --fa: "\f4c9"; -} - -.fa-lamp { - --fa: "\f4ca"; -} - -.fa-leaf-heart { - --fa: "\f4cb"; -} - -.fa-loveseat { - --fa: "\f4cc"; -} - -.fa-couch-small { - --fa: "\f4cc"; -} - -.fa-parachute-box { - --fa: "\f4cd"; -} - -.fa-people-carry-box { - --fa: "\f4ce"; -} - -.fa-people-carry { - --fa: "\f4ce"; -} - -.fa-person-carry-box { - --fa: "\f4cf"; -} - -.fa-person-carry { - --fa: "\f4cf"; -} - -.fa-person-dolly { - --fa: "\f4d0"; -} - -.fa-person-dolly-empty { - --fa: "\f4d1"; -} - -.fa-phone-plus { - --fa: "\f4d2"; -} - -.fa-piggy-bank { - --fa: "\f4d3"; -} - -.fa-ramp-loading { - --fa: "\f4d4"; -} - -.fa-ribbon { - --fa: "\f4d6"; -} - -.fa-route { - --fa: "\f4d7"; -} - -.fa-seedling { - --fa: "\f4d8"; -} - -.fa-sprout { - --fa: "\f4d8"; -} - -.fa-sign-hanging { - --fa: "\f4d9"; -} - -.fa-sign { - --fa: "\f4d9"; -} - -.fa-face-smile-wink { - --fa: "\f4da"; -} - -.fa-smile-wink { - --fa: "\f4da"; -} - -.fa-tape { - --fa: "\f4db"; -} - -.fa-truck-container { - --fa: "\f4dc"; -} - -.fa-truck-ramp-couch { - --fa: "\f4dd"; -} - -.fa-truck-couch { - --fa: "\f4dd"; -} - -.fa-truck-ramp-box { - --fa: "\f4de"; -} - -.fa-truck-loading { - --fa: "\f4de"; -} - -.fa-truck-moving { - --fa: "\f4df"; -} - -.fa-truck-ramp { - --fa: "\f4e0"; -} - -.fa-video-plus { - --fa: "\f4e1"; -} - -.fa-video-slash { - --fa: "\f4e2"; -} - -.fa-wine-glass { - --fa: "\f4e3"; -} - -.fa-user-astronaut { - --fa: "\f4fb"; -} - -.fa-user-check { - --fa: "\f4fc"; -} - -.fa-user-clock { - --fa: "\f4fd"; -} - -.fa-user-gear { - --fa: "\f4fe"; -} - -.fa-user-cog { - --fa: "\f4fe"; -} - -.fa-user-pen { - --fa: "\f4ff"; -} - -.fa-user-edit { - --fa: "\f4ff"; -} - -.fa-user-group { - --fa: "\f500"; -} - -.fa-user-friends { - --fa: "\f500"; -} - -.fa-user-graduate { - --fa: "\f501"; -} - -.fa-user-lock { - --fa: "\f502"; -} - -.fa-user-minus { - --fa: "\f503"; -} - -.fa-user-ninja { - --fa: "\f504"; -} - -.fa-user-shield { - --fa: "\f505"; -} - -.fa-user-slash { - --fa: "\f506"; -} - -.fa-user-alt-slash { - --fa: "\f506"; -} - -.fa-user-large-slash { - --fa: "\f506"; -} - -.fa-user-tag { - --fa: "\f507"; -} - -.fa-user-tie { - --fa: "\f508"; -} - -.fa-users-gear { - --fa: "\f509"; -} - -.fa-users-cog { - --fa: "\f509"; -} - -.fa-scale-unbalanced { - --fa: "\f515"; -} - -.fa-balance-scale-left { - --fa: "\f515"; -} - -.fa-scale-unbalanced-flip { - --fa: "\f516"; -} - -.fa-balance-scale-right { - --fa: "\f516"; -} - -.fa-blender { - --fa: "\f517"; -} - -.fa-book-open { - --fa: "\f518"; -} - -.fa-tower-broadcast { - --fa: "\f519"; -} - -.fa-broadcast-tower { - --fa: "\f519"; -} - -.fa-broom { - --fa: "\f51a"; -} - -.fa-chalkboard { - --fa: "\f51b"; -} - -.fa-blackboard { - --fa: "\f51b"; -} - -.fa-chalkboard-user { - --fa: "\f51c"; -} - -.fa-chalkboard-teacher { - --fa: "\f51c"; -} - -.fa-church { - --fa: "\f51d"; -} - -.fa-coins { - --fa: "\f51e"; -} - -.fa-compact-disc { - --fa: "\f51f"; -} - -.fa-crow { - --fa: "\f520"; -} - -.fa-crown { - --fa: "\f521"; -} - -.fa-dice { - --fa: "\f522"; -} - -.fa-dice-five { - --fa: "\f523"; -} - -.fa-dice-four { - --fa: "\f524"; -} - -.fa-dice-one { - --fa: "\f525"; -} - -.fa-dice-six { - --fa: "\f526"; -} - -.fa-dice-three { - --fa: "\f527"; -} - -.fa-dice-two { - --fa: "\f528"; -} - -.fa-divide { - --fa: "\f529"; -} - -.fa-door-closed { - --fa: "\f52a"; -} - -.fa-door-open { - --fa: "\f52b"; -} - -.fa-feather { - --fa: "\f52d"; -} - -.fa-frog { - --fa: "\f52e"; -} - -.fa-gas-pump { - --fa: "\f52f"; -} - -.fa-glasses { - --fa: "\f530"; -} - -.fa-greater-than-equal { - --fa: "\f532"; -} - -.fa-helicopter { - --fa: "\f533"; -} - -.fa-infinity { - --fa: "\f534"; -} - -.fa-kiwi-bird { - --fa: "\f535"; -} - -.fa-less-than-equal { - --fa: "\f537"; -} - -.fa-memory { - --fa: "\f538"; -} - -.fa-microphone-lines-slash { - --fa: "\f539"; -} - -.fa-microphone-alt-slash { - --fa: "\f539"; -} - -.fa-money-bill-wave { - --fa: "\f53a"; -} - -.fa-money-bill-1-wave { - --fa: "\f53b"; -} - -.fa-money-bill-wave-alt { - --fa: "\f53b"; -} - -.fa-money-check { - --fa: "\f53c"; -} - -.fa-money-check-dollar { - --fa: "\f53d"; -} - -.fa-money-check-alt { - --fa: "\f53d"; -} - -.fa-not-equal { - --fa: "\f53e"; -} - -.fa-palette { - --fa: "\f53f"; -} - -.fa-square-parking { - --fa: "\f540"; -} - -.fa-parking { - --fa: "\f540"; -} - -.fa-diagram-project { - --fa: "\f542"; -} - -.fa-project-diagram { - --fa: "\f542"; -} - -.fa-receipt { - --fa: "\f543"; -} - -.fa-robot { - --fa: "\f544"; -} - -.fa-ruler { - --fa: "\f545"; -} - -.fa-ruler-combined { - --fa: "\f546"; -} - -.fa-ruler-horizontal { - --fa: "\f547"; -} - -.fa-ruler-vertical { - --fa: "\f548"; -} - -.fa-school { - --fa: "\f549"; -} - -.fa-screwdriver { - --fa: "\f54a"; -} - -.fa-shoe-prints { - --fa: "\f54b"; -} - -.fa-skull { - --fa: "\f54c"; -} - -.fa-ban-smoking { - --fa: "\f54d"; -} - -.fa-smoking-ban { - --fa: "\f54d"; -} - -.fa-store { - --fa: "\f54e"; -} - -.fa-shop { - --fa: "\f54f"; -} - -.fa-store-alt { - --fa: "\f54f"; -} - -.fa-bars-staggered { - --fa: "\f550"; -} - -.fa-reorder { - --fa: "\f550"; -} - -.fa-stream { - --fa: "\f550"; -} - -.fa-stroopwafel { - --fa: "\f551"; -} - -.fa-toolbox { - --fa: "\f552"; -} - -.fa-shirt { - --fa: "\f553"; -} - -.fa-t-shirt { - --fa: "\f553"; -} - -.fa-tshirt { - --fa: "\f553"; -} - -.fa-person-walking { - --fa: "\f554"; -} - -.fa-walking { - --fa: "\f554"; -} - -.fa-wallet { - --fa: "\f555"; -} - -.fa-face-angry { - --fa: "\f556"; -} - -.fa-angry { - --fa: "\f556"; -} - -.fa-archway { - --fa: "\f557"; -} - -.fa-book-atlas { - --fa: "\f558"; -} - -.fa-atlas { - --fa: "\f558"; -} - -.fa-award { - --fa: "\f559"; -} - -.fa-delete-left { - --fa: "\f55a"; -} - -.fa-backspace { - --fa: "\f55a"; -} - -.fa-bezier-curve { - --fa: "\f55b"; -} - -.fa-bong { - --fa: "\f55c"; -} - -.fa-brush { - --fa: "\f55d"; -} - -.fa-bus-simple { - --fa: "\f55e"; -} - -.fa-bus-alt { - --fa: "\f55e"; -} - -.fa-cannabis { - --fa: "\f55f"; -} - -.fa-check-double { - --fa: "\f560"; -} - -.fa-martini-glass-citrus { - --fa: "\f561"; -} - -.fa-cocktail { - --fa: "\f561"; -} - -.fa-bell-concierge { - --fa: "\f562"; -} - -.fa-concierge-bell { - --fa: "\f562"; -} - -.fa-cookie { - --fa: "\f563"; -} - -.fa-cookie-bite { - --fa: "\f564"; -} - -.fa-crop-simple { - --fa: "\f565"; -} - -.fa-crop-alt { - --fa: "\f565"; -} - -.fa-tachograph-digital { - --fa: "\f566"; -} - -.fa-digital-tachograph { - --fa: "\f566"; -} - -.fa-face-dizzy { - --fa: "\f567"; -} - -.fa-dizzy { - --fa: "\f567"; -} - -.fa-compass-drafting { - --fa: "\f568"; -} - -.fa-drafting-compass { - --fa: "\f568"; -} - -.fa-drum { - --fa: "\f569"; -} - -.fa-drum-steelpan { - --fa: "\f56a"; -} - -.fa-feather-pointed { - --fa: "\f56b"; -} - -.fa-feather-alt { - --fa: "\f56b"; -} - -.fa-file-contract { - --fa: "\f56c"; -} - -.fa-file-arrow-down { - --fa: "\f56d"; -} - -.fa-file-download { - --fa: "\f56d"; -} - -.fa-file-export { - --fa: "\f56e"; -} - -.fa-arrow-right-from-file { - --fa: "\f56e"; -} - -.fa-file-import { - --fa: "\f56f"; -} - -.fa-arrow-right-to-file { - --fa: "\f56f"; -} - -.fa-file-invoice { - --fa: "\f570"; -} - -.fa-file-invoice-dollar { - --fa: "\f571"; -} - -.fa-file-prescription { - --fa: "\f572"; -} - -.fa-file-signature { - --fa: "\f573"; -} - -.fa-file-arrow-up { - --fa: "\f574"; -} - -.fa-file-upload { - --fa: "\f574"; -} - -.fa-fill { - --fa: "\f575"; -} - -.fa-fill-drip { - --fa: "\f576"; -} - -.fa-fingerprint { - --fa: "\f577"; -} - -.fa-fish { - --fa: "\f578"; -} - -.fa-face-flushed { - --fa: "\f579"; -} - -.fa-flushed { - --fa: "\f579"; -} - -.fa-face-frown-open { - --fa: "\f57a"; -} - -.fa-frown-open { - --fa: "\f57a"; -} - -.fa-martini-glass { - --fa: "\f57b"; -} - -.fa-glass-martini-alt { - --fa: "\f57b"; -} - -.fa-earth-africa { - --fa: "\f57c"; -} - -.fa-globe-africa { - --fa: "\f57c"; -} - -.fa-earth-americas { - --fa: "\f57d"; -} - -.fa-earth { - --fa: "\f57d"; -} - -.fa-earth-america { - --fa: "\f57d"; -} - -.fa-globe-americas { - --fa: "\f57d"; -} - -.fa-earth-asia { - --fa: "\f57e"; -} - -.fa-globe-asia { - --fa: "\f57e"; -} - -.fa-face-grimace { - --fa: "\f57f"; -} - -.fa-grimace { - --fa: "\f57f"; -} - -.fa-face-grin { - --fa: "\f580"; -} - -.fa-grin { - --fa: "\f580"; -} - -.fa-face-grin-wide { - --fa: "\f581"; -} - -.fa-grin-alt { - --fa: "\f581"; -} - -.fa-face-grin-beam { - --fa: "\f582"; -} - -.fa-grin-beam { - --fa: "\f582"; -} - -.fa-face-grin-beam-sweat { - --fa: "\f583"; -} - -.fa-grin-beam-sweat { - --fa: "\f583"; -} - -.fa-face-grin-hearts { - --fa: "\f584"; -} - -.fa-grin-hearts { - --fa: "\f584"; -} - -.fa-face-grin-squint { - --fa: "\f585"; -} - -.fa-grin-squint { - --fa: "\f585"; -} - -.fa-face-grin-squint-tears { - --fa: "\f586"; -} - -.fa-grin-squint-tears { - --fa: "\f586"; -} - -.fa-face-grin-stars { - --fa: "\f587"; -} - -.fa-grin-stars { - --fa: "\f587"; -} - -.fa-face-grin-tears { - --fa: "\f588"; -} - -.fa-grin-tears { - --fa: "\f588"; -} - -.fa-face-grin-tongue { - --fa: "\f589"; -} - -.fa-grin-tongue { - --fa: "\f589"; -} - -.fa-face-grin-tongue-squint { - --fa: "\f58a"; -} - -.fa-grin-tongue-squint { - --fa: "\f58a"; -} - -.fa-face-grin-tongue-wink { - --fa: "\f58b"; -} - -.fa-grin-tongue-wink { - --fa: "\f58b"; -} - -.fa-face-grin-wink { - --fa: "\f58c"; -} - -.fa-grin-wink { - --fa: "\f58c"; -} - -.fa-grip { - --fa: "\f58d"; -} - -.fa-grid-horizontal { - --fa: "\f58d"; -} - -.fa-grip-horizontal { - --fa: "\f58d"; -} - -.fa-grip-vertical { - --fa: "\f58e"; -} - -.fa-grid-vertical { - --fa: "\f58e"; -} - -.fa-headset { - --fa: "\f590"; -} - -.fa-highlighter { - --fa: "\f591"; -} - -.fa-hot-tub-person { - --fa: "\f593"; -} - -.fa-hot-tub { - --fa: "\f593"; -} - -.fa-hotel { - --fa: "\f594"; -} - -.fa-joint { - --fa: "\f595"; -} - -.fa-face-kiss { - --fa: "\f596"; -} - -.fa-kiss { - --fa: "\f596"; -} - -.fa-face-kiss-beam { - --fa: "\f597"; -} - -.fa-kiss-beam { - --fa: "\f597"; -} - -.fa-face-kiss-wink-heart { - --fa: "\f598"; -} - -.fa-kiss-wink-heart { - --fa: "\f598"; -} - -.fa-face-laugh { - --fa: "\f599"; -} - -.fa-laugh { - --fa: "\f599"; -} - -.fa-face-laugh-beam { - --fa: "\f59a"; -} - -.fa-laugh-beam { - --fa: "\f59a"; -} - -.fa-face-laugh-squint { - --fa: "\f59b"; -} - -.fa-laugh-squint { - --fa: "\f59b"; -} - -.fa-face-laugh-wink { - --fa: "\f59c"; -} - -.fa-laugh-wink { - --fa: "\f59c"; -} - -.fa-cart-flatbed-suitcase { - --fa: "\f59d"; -} - -.fa-luggage-cart { - --fa: "\f59d"; -} - -.fa-map-location { - --fa: "\f59f"; -} - -.fa-map-marked { - --fa: "\f59f"; -} - -.fa-map-location-dot { - --fa: "\f5a0"; -} - -.fa-map-marked-alt { - --fa: "\f5a0"; -} - -.fa-marker { - --fa: "\f5a1"; -} - -.fa-medal { - --fa: "\f5a2"; -} - -.fa-face-meh-blank { - --fa: "\f5a4"; -} - -.fa-meh-blank { - --fa: "\f5a4"; -} - -.fa-face-rolling-eyes { - --fa: "\f5a5"; -} - -.fa-meh-rolling-eyes { - --fa: "\f5a5"; -} - -.fa-monument { - --fa: "\f5a6"; -} - -.fa-mortar-pestle { - --fa: "\f5a7"; -} - -.fa-paintbrush-fine { - --fa: "\f5a9"; -} - -.fa-paint-brush-alt { - --fa: "\f5a9"; -} - -.fa-paint-brush-fine { - --fa: "\f5a9"; -} - -.fa-paintbrush-alt { - --fa: "\f5a9"; -} - -.fa-paint-roller { - --fa: "\f5aa"; -} - -.fa-passport { - --fa: "\f5ab"; -} - -.fa-pen-fancy { - --fa: "\f5ac"; -} - -.fa-pen-nib { - --fa: "\f5ad"; -} - -.fa-pen-ruler { - --fa: "\f5ae"; -} - -.fa-pencil-ruler { - --fa: "\f5ae"; -} - -.fa-plane-arrival { - --fa: "\f5af"; -} - -.fa-plane-departure { - --fa: "\f5b0"; -} - -.fa-prescription { - --fa: "\f5b1"; -} - -.fa-face-sad-cry { - --fa: "\f5b3"; -} - -.fa-sad-cry { - --fa: "\f5b3"; -} - -.fa-face-sad-tear { - --fa: "\f5b4"; -} - -.fa-sad-tear { - --fa: "\f5b4"; -} - -.fa-van-shuttle { - --fa: "\f5b6"; -} - -.fa-shuttle-van { - --fa: "\f5b6"; -} - -.fa-signature { - --fa: "\f5b7"; -} - -.fa-face-smile-beam { - --fa: "\f5b8"; -} - -.fa-smile-beam { - --fa: "\f5b8"; -} - -.fa-face-smile-plus { - --fa: "\f5b9"; -} - -.fa-smile-plus { - --fa: "\f5b9"; -} - -.fa-solar-panel { - --fa: "\f5ba"; -} - -.fa-spa { - --fa: "\f5bb"; -} - -.fa-splotch { - --fa: "\f5bc"; -} - -.fa-spray-can { - --fa: "\f5bd"; -} - -.fa-stamp { - --fa: "\f5bf"; -} - -.fa-star-half-stroke { - --fa: "\f5c0"; -} - -.fa-star-half-alt { - --fa: "\f5c0"; -} - -.fa-suitcase-rolling { - --fa: "\f5c1"; -} - -.fa-face-surprise { - --fa: "\f5c2"; -} - -.fa-surprise { - --fa: "\f5c2"; -} - -.fa-swatchbook { - --fa: "\f5c3"; -} - -.fa-person-swimming { - --fa: "\f5c4"; -} - -.fa-swimmer { - --fa: "\f5c4"; -} - -.fa-water-ladder { - --fa: "\f5c5"; -} - -.fa-ladder-water { - --fa: "\f5c5"; -} - -.fa-swimming-pool { - --fa: "\f5c5"; -} - -.fa-droplet-slash { - --fa: "\f5c7"; -} - -.fa-tint-slash { - --fa: "\f5c7"; -} - -.fa-face-tired { - --fa: "\f5c8"; -} - -.fa-tired { - --fa: "\f5c8"; -} - -.fa-tooth { - --fa: "\f5c9"; -} - -.fa-umbrella-beach { - --fa: "\f5ca"; -} - -.fa-weight-hanging { - --fa: "\f5cd"; -} - -.fa-wine-glass-empty { - --fa: "\f5ce"; -} - -.fa-wine-glass-alt { - --fa: "\f5ce"; -} - -.fa-spray-can-sparkles { - --fa: "\f5d0"; -} - -.fa-air-freshener { - --fa: "\f5d0"; -} - -.fa-apple-whole { - --fa: "\f5d1"; -} - -.fa-apple-alt { - --fa: "\f5d1"; -} - -.fa-atom { - --fa: "\f5d2"; -} - -.fa-atom-simple { - --fa: "\f5d3"; -} - -.fa-atom-alt { - --fa: "\f5d3"; -} - -.fa-backpack { - --fa: "\f5d4"; -} - -.fa-bell-school { - --fa: "\f5d5"; -} - -.fa-bell-school-slash { - --fa: "\f5d6"; -} - -.fa-bone { - --fa: "\f5d7"; -} - -.fa-bone-break { - --fa: "\f5d8"; -} - -.fa-book-blank { - --fa: "\f5d9"; -} - -.fa-book-alt { - --fa: "\f5d9"; -} - -.fa-book-open-reader { - --fa: "\f5da"; -} - -.fa-book-reader { - --fa: "\f5da"; -} - -.fa-books { - --fa: "\f5db"; -} - -.fa-brain { - --fa: "\f5dc"; -} - -.fa-bus-school { - --fa: "\f5dd"; -} - -.fa-car-rear { - --fa: "\f5de"; -} - -.fa-car-alt { - --fa: "\f5de"; -} - -.fa-car-battery { - --fa: "\f5df"; -} - -.fa-battery-car { - --fa: "\f5df"; -} - -.fa-car-bump { - --fa: "\f5e0"; -} - -.fa-car-burst { - --fa: "\f5e1"; -} - -.fa-car-crash { - --fa: "\f5e1"; -} - -.fa-car-garage { - --fa: "\f5e2"; -} - -.fa-car-wrench { - --fa: "\f5e3"; -} - -.fa-car-mechanic { - --fa: "\f5e3"; -} - -.fa-car-side { - --fa: "\f5e4"; -} - -.fa-car-tilt { - --fa: "\f5e5"; -} - -.fa-car-wash { - --fa: "\f5e6"; -} - -.fa-charging-station { - --fa: "\f5e7"; -} - -.fa-clipboard-prescription { - --fa: "\f5e8"; -} - -.fa-compass-slash { - --fa: "\f5e9"; -} - -.fa-diploma { - --fa: "\f5ea"; -} - -.fa-scroll-ribbon { - --fa: "\f5ea"; -} - -.fa-diamond-turn-right { - --fa: "\f5eb"; -} - -.fa-directions { - --fa: "\f5eb"; -} - -.fa-do-not-enter { - --fa: "\f5ec"; -} - -.fa-draw-circle { - --fa: "\f5ed"; -} - -.fa-vector-circle { - --fa: "\f5ed"; -} - -.fa-draw-polygon { - --fa: "\f5ee"; -} - -.fa-vector-polygon { - --fa: "\f5ee"; -} - -.fa-draw-square { - --fa: "\f5ef"; -} - -.fa-vector-square { - --fa: "\f5ef"; -} - -.fa-ear { - --fa: "\f5f0"; -} - -.fa-engine-warning { - --fa: "\f5f2"; -} - -.fa-engine-exclamation { - --fa: "\f5f2"; -} - -.fa-file-certificate { - --fa: "\f5f3"; -} - -.fa-file-award { - --fa: "\f5f3"; -} - -.fa-gas-pump-slash { - --fa: "\f5f4"; -} - -.fa-glasses-round { - --fa: "\f5f5"; -} - -.fa-glasses-alt { - --fa: "\f5f5"; -} - -.fa-globe-stand { - --fa: "\f5f6"; -} - -.fa-wave-pulse { - --fa: "\f5f8"; -} - -.fa-heart-rate { - --fa: "\f5f8"; -} - -.fa-inhaler { - --fa: "\f5f9"; -} - -.fa-kidneys { - --fa: "\f5fb"; -} - -.fa-laptop-code { - --fa: "\f5fc"; -} - -.fa-layer-group { - --fa: "\f5fd"; -} - -.fa-layer-minus { - --fa: "\f5fe"; -} - -.fa-layer-group-minus { - --fa: "\f5fe"; -} - -.fa-layer-plus { - --fa: "\f5ff"; -} - -.fa-layer-group-plus { - --fa: "\f5ff"; -} - -.fa-lips { - --fa: "\f600"; -} - -.fa-location-crosshairs { - --fa: "\f601"; -} - -.fa-location { - --fa: "\f601"; -} - -.fa-circle-location-arrow { - --fa: "\f602"; -} - -.fa-location-circle { - --fa: "\f602"; -} - -.fa-location-crosshairs-slash { - --fa: "\f603"; -} - -.fa-location-slash { - --fa: "\f603"; -} - -.fa-lungs { - --fa: "\f604"; -} - -.fa-location-dot-slash { - --fa: "\f605"; -} - -.fa-map-marker-alt-slash { - --fa: "\f605"; -} - -.fa-location-check { - --fa: "\f606"; -} - -.fa-map-marker-check { - --fa: "\f606"; -} - -.fa-location-pen { - --fa: "\f607"; -} - -.fa-map-marker-edit { - --fa: "\f607"; -} - -.fa-location-exclamation { - --fa: "\f608"; -} - -.fa-map-marker-exclamation { - --fa: "\f608"; -} - -.fa-location-minus { - --fa: "\f609"; -} - -.fa-map-marker-minus { - --fa: "\f609"; -} - -.fa-location-plus { - --fa: "\f60a"; -} - -.fa-map-marker-plus { - --fa: "\f60a"; -} - -.fa-location-question { - --fa: "\f60b"; -} - -.fa-map-marker-question { - --fa: "\f60b"; -} - -.fa-location-pin-slash { - --fa: "\f60c"; -} - -.fa-map-marker-slash { - --fa: "\f60c"; -} - -.fa-location-smile { - --fa: "\f60d"; -} - -.fa-map-marker-smile { - --fa: "\f60d"; -} - -.fa-location-xmark { - --fa: "\f60e"; -} - -.fa-map-marker-times { - --fa: "\f60e"; -} - -.fa-map-marker-xmark { - --fa: "\f60e"; -} - -.fa-microscope { - --fa: "\f610"; -} - -.fa-monitor-waveform { - --fa: "\f611"; -} - -.fa-monitor-heart-rate { - --fa: "\f611"; -} - -.fa-oil-can { - --fa: "\f613"; -} - -.fa-oil-temperature { - --fa: "\f614"; -} - -.fa-oil-temp { - --fa: "\f614"; -} - -.fa-circle-parking { - --fa: "\f615"; -} - -.fa-parking-circle { - --fa: "\f615"; -} - -.fa-ban-parking { - --fa: "\f616"; -} - -.fa-parking-circle-slash { - --fa: "\f616"; -} - -.fa-square-parking-slash { - --fa: "\f617"; -} - -.fa-parking-slash { - --fa: "\f617"; -} - -.fa-pen-paintbrush { - --fa: "\f618"; -} - -.fa-pencil-paintbrush { - --fa: "\f618"; -} - -.fa-poop { - --fa: "\f619"; -} - -.fa-route-highway { - --fa: "\f61a"; -} - -.fa-route-interstate { - --fa: "\f61b"; -} - -.fa-ruler-triangle { - --fa: "\f61c"; -} - -.fa-scalpel { - --fa: "\f61d"; -} - -.fa-scalpel-line-dashed { - --fa: "\f61e"; -} - -.fa-scalpel-path { - --fa: "\f61e"; -} - -.fa-shapes { - --fa: "\f61f"; -} - -.fa-triangle-circle-square { - --fa: "\f61f"; -} - -.fa-skeleton { - --fa: "\f620"; -} - -.fa-star-of-life { - --fa: "\f621"; -} - -.fa-steering-wheel { - --fa: "\f622"; -} - -.fa-stomach { - --fa: "\f623"; -} - -.fa-gauge { - --fa: "\f624"; -} - -.fa-dashboard { - --fa: "\f624"; -} - -.fa-gauge-med { - --fa: "\f624"; -} - -.fa-tachometer-alt-average { - --fa: "\f624"; -} - -.fa-gauge-high { - --fa: "\f625"; -} - -.fa-tachometer-alt { - --fa: "\f625"; -} - -.fa-tachometer-alt-fast { - --fa: "\f625"; -} - -.fa-gauge-max { - --fa: "\f626"; -} - -.fa-tachometer-alt-fastest { - --fa: "\f626"; -} - -.fa-gauge-low { - --fa: "\f627"; -} - -.fa-tachometer-alt-slow { - --fa: "\f627"; -} - -.fa-gauge-min { - --fa: "\f628"; -} - -.fa-tachometer-alt-slowest { - --fa: "\f628"; -} - -.fa-gauge-simple { - --fa: "\f629"; -} - -.fa-gauge-simple-med { - --fa: "\f629"; -} - -.fa-tachometer-average { - --fa: "\f629"; -} - -.fa-gauge-simple-high { - --fa: "\f62a"; -} - -.fa-tachometer { - --fa: "\f62a"; -} - -.fa-tachometer-fast { - --fa: "\f62a"; -} - -.fa-gauge-simple-max { - --fa: "\f62b"; -} - -.fa-tachometer-fastest { - --fa: "\f62b"; -} - -.fa-gauge-simple-low { - --fa: "\f62c"; -} - -.fa-tachometer-slow { - --fa: "\f62c"; -} - -.fa-gauge-simple-min { - --fa: "\f62d"; -} - -.fa-tachometer-slowest { - --fa: "\f62d"; -} - -.fa-teeth { - --fa: "\f62e"; -} - -.fa-teeth-open { - --fa: "\f62f"; -} - -.fa-masks-theater { - --fa: "\f630"; -} - -.fa-theater-masks { - --fa: "\f630"; -} - -.fa-tire { - --fa: "\f631"; -} - -.fa-tire-flat { - --fa: "\f632"; -} - -.fa-tire-pressure-warning { - --fa: "\f633"; -} - -.fa-tire-rugged { - --fa: "\f634"; -} - -.fa-toothbrush { - --fa: "\f635"; -} - -.fa-traffic-cone { - --fa: "\f636"; -} - -.fa-traffic-light { - --fa: "\f637"; -} - -.fa-traffic-light-go { - --fa: "\f638"; -} - -.fa-traffic-light-slow { - --fa: "\f639"; -} - -.fa-traffic-light-stop { - --fa: "\f63a"; -} - -.fa-truck-monster { - --fa: "\f63b"; -} - -.fa-truck-pickup { - --fa: "\f63c"; -} - -.fa-screen-users { - --fa: "\f63d"; -} - -.fa-users-class { - --fa: "\f63d"; -} - -.fa-watch-fitness { - --fa: "\f63e"; -} - -.fa-abacus { - --fa: "\f640"; -} - -.fa-rectangle-ad { - --fa: "\f641"; -} - -.fa-ad { - --fa: "\f641"; -} - -.fa-chart-mixed { - --fa: "\f643"; -} - -.fa-analytics { - --fa: "\f643"; -} - -.fa-ankh { - --fa: "\f644"; -} - -.fa-badge-dollar { - --fa: "\f645"; -} - -.fa-badge-percent { - --fa: "\f646"; -} - -.fa-book-bible { - --fa: "\f647"; -} - -.fa-bible { - --fa: "\f647"; -} - -.fa-bullseye-arrow { - --fa: "\f648"; -} - -.fa-bullseye-pointer { - --fa: "\f649"; -} - -.fa-business-time { - --fa: "\f64a"; -} - -.fa-briefcase-clock { - --fa: "\f64a"; -} - -.fa-cabinet-filing { - --fa: "\f64b"; -} - -.fa-calculator-simple { - --fa: "\f64c"; -} - -.fa-calculator-alt { - --fa: "\f64c"; -} - -.fa-chart-line-down { - --fa: "\f64d"; -} - -.fa-chart-pie-simple { - --fa: "\f64e"; -} - -.fa-chart-pie-alt { - --fa: "\f64e"; -} - -.fa-city { - --fa: "\f64f"; -} - -.fa-message-dollar { - --fa: "\f650"; -} - -.fa-comment-alt-dollar { - --fa: "\f650"; -} - -.fa-comment-dollar { - --fa: "\f651"; -} - -.fa-messages-dollar { - --fa: "\f652"; -} - -.fa-comments-alt-dollar { - --fa: "\f652"; -} - -.fa-comments-dollar { - --fa: "\f653"; -} - -.fa-cross { - --fa: "\f654"; -} - -.fa-dharmachakra { - --fa: "\f655"; -} - -.fa-empty-set { - --fa: "\f656"; -} - -.fa-envelope-open-dollar { - --fa: "\f657"; -} - -.fa-envelope-open-text { - --fa: "\f658"; -} - -.fa-file-chart-column { - --fa: "\f659"; -} - -.fa-file-chart-line { - --fa: "\f659"; -} - -.fa-file-chart-pie { - --fa: "\f65a"; -} - -.fa-file-spreadsheet { - --fa: "\f65b"; -} - -.fa-file-user { - --fa: "\f65c"; -} - -.fa-folder-minus { - --fa: "\f65d"; -} - -.fa-folder-plus { - --fa: "\f65e"; -} - -.fa-folder-xmark { - --fa: "\f65f"; -} - -.fa-folder-times { - --fa: "\f65f"; -} - -.fa-folders { - --fa: "\f660"; -} - -.fa-function { - --fa: "\f661"; -} - -.fa-filter-circle-dollar { - --fa: "\f662"; -} - -.fa-funnel-dollar { - --fa: "\f662"; -} - -.fa-gift-card { - --fa: "\f663"; -} - -.fa-gopuram { - --fa: "\f664"; -} - -.fa-hamsa { - --fa: "\f665"; -} - -.fa-bahai { - --fa: "\f666"; -} - -.fa-haykal { - --fa: "\f666"; -} - -.fa-integral { - --fa: "\f667"; -} - -.fa-intersection { - --fa: "\f668"; -} - -.fa-jedi { - --fa: "\f669"; -} - -.fa-book-journal-whills { - --fa: "\f66a"; -} - -.fa-journal-whills { - --fa: "\f66a"; -} - -.fa-kaaba { - --fa: "\f66b"; -} - -.fa-keynote { - --fa: "\f66c"; -} - -.fa-khanda { - --fa: "\f66d"; -} - -.fa-lambda { - --fa: "\f66e"; -} - -.fa-landmark { - --fa: "\f66f"; -} - -.fa-lightbulb-dollar { - --fa: "\f670"; -} - -.fa-lightbulb-exclamation { - --fa: "\f671"; -} - -.fa-lightbulb-on { - --fa: "\f672"; -} - -.fa-lightbulb-slash { - --fa: "\f673"; -} - -.fa-envelopes-bulk { - --fa: "\f674"; -} - -.fa-mail-bulk { - --fa: "\f674"; -} - -.fa-megaphone { - --fa: "\f675"; -} - -.fa-menorah { - --fa: "\f676"; -} - -.fa-brain-arrow-curved-right { - --fa: "\f677"; -} - -.fa-mind-share { - --fa: "\f677"; -} - -.fa-mosque { - --fa: "\f678"; -} - -.fa-om { - --fa: "\f679"; -} - -.fa-omega { - --fa: "\f67a"; -} - -.fa-spaghetti-monster-flying { - --fa: "\f67b"; -} - -.fa-pastafarianism { - --fa: "\f67b"; -} - -.fa-peace { - --fa: "\f67c"; -} - -.fa-phone-office { - --fa: "\f67d"; -} - -.fa-pi { - --fa: "\f67e"; -} - -.fa-place-of-worship { - --fa: "\f67f"; -} - -.fa-podium { - --fa: "\f680"; -} - -.fa-square-poll-vertical { - --fa: "\f681"; -} - -.fa-poll { - --fa: "\f681"; -} - -.fa-square-poll-horizontal { - --fa: "\f682"; -} - -.fa-poll-h { - --fa: "\f682"; -} - -.fa-person-praying { - --fa: "\f683"; -} - -.fa-pray { - --fa: "\f683"; -} - -.fa-hands-praying { - --fa: "\f684"; -} - -.fa-praying-hands { - --fa: "\f684"; -} - -.fa-presentation-screen { - --fa: "\f685"; -} - -.fa-presentation { - --fa: "\f685"; -} - -.fa-print-slash { - --fa: "\f686"; -} - -.fa-book-quran { - --fa: "\f687"; -} - -.fa-quran { - --fa: "\f687"; -} - -.fa-magnifying-glass-dollar { - --fa: "\f688"; -} - -.fa-search-dollar { - --fa: "\f688"; -} - -.fa-magnifying-glass-location { - --fa: "\f689"; -} - -.fa-search-location { - --fa: "\f689"; -} - -.fa-shredder { - --fa: "\f68a"; -} - -.fa-sigma { - --fa: "\f68b"; -} - -.fa-signal-weak { - --fa: "\f68c"; -} - -.fa-signal-1 { - --fa: "\f68c"; -} - -.fa-signal-fair { - --fa: "\f68d"; -} - -.fa-signal-2 { - --fa: "\f68d"; -} - -.fa-signal-good { - --fa: "\f68e"; -} - -.fa-signal-3 { - --fa: "\f68e"; -} - -.fa-signal-strong { - --fa: "\f68f"; -} - -.fa-signal-4 { - --fa: "\f68f"; -} - -.fa-signal-bars { - --fa: "\f690"; -} - -.fa-signal-alt { - --fa: "\f690"; -} - -.fa-signal-alt-4 { - --fa: "\f690"; -} - -.fa-signal-bars-strong { - --fa: "\f690"; -} - -.fa-signal-bars-weak { - --fa: "\f691"; -} - -.fa-signal-alt-1 { - --fa: "\f691"; -} - -.fa-signal-bars-fair { - --fa: "\f692"; -} - -.fa-signal-alt-2 { - --fa: "\f692"; -} - -.fa-signal-bars-good { - --fa: "\f693"; -} - -.fa-signal-alt-3 { - --fa: "\f693"; -} - -.fa-signal-bars-slash { - --fa: "\f694"; -} - -.fa-signal-alt-slash { - --fa: "\f694"; -} - -.fa-signal-slash { - --fa: "\f695"; -} - -.fa-socks { - --fa: "\f696"; -} - -.fa-square-root { - --fa: "\f697"; -} - -.fa-square-root-variable { - --fa: "\f698"; -} - -.fa-square-root-alt { - --fa: "\f698"; -} - -.fa-star-and-crescent { - --fa: "\f699"; -} - -.fa-star-of-david { - --fa: "\f69a"; -} - -.fa-synagogue { - --fa: "\f69b"; -} - -.fa-tally { - --fa: "\f69c"; -} - -.fa-tally-5 { - --fa: "\f69c"; -} - -.fa-theta { - --fa: "\f69e"; -} - -.fa-scroll-torah { - --fa: "\f6a0"; -} - -.fa-torah { - --fa: "\f6a0"; -} - -.fa-torii-gate { - --fa: "\f6a1"; -} - -.fa-union { - --fa: "\f6a2"; -} - -.fa-chart-user { - --fa: "\f6a3"; -} - -.fa-user-chart { - --fa: "\f6a3"; -} - -.fa-user-crown { - --fa: "\f6a4"; -} - -.fa-user-group-crown { - --fa: "\f6a5"; -} - -.fa-users-crown { - --fa: "\f6a5"; -} - -.fa-value-absolute { - --fa: "\f6a6"; -} - -.fa-vihara { - --fa: "\f6a7"; -} - -.fa-volume { - --fa: "\f6a8"; -} - -.fa-volume-medium { - --fa: "\f6a8"; -} - -.fa-volume-xmark { - --fa: "\f6a9"; -} - -.fa-volume-mute { - --fa: "\f6a9"; -} - -.fa-volume-times { - --fa: "\f6a9"; -} - -.fa-wifi-weak { - --fa: "\f6aa"; -} - -.fa-wifi-1 { - --fa: "\f6aa"; -} - -.fa-wifi-fair { - --fa: "\f6ab"; -} - -.fa-wifi-2 { - --fa: "\f6ab"; -} - -.fa-wifi-slash { - --fa: "\f6ac"; -} - -.fa-yin-yang { - --fa: "\f6ad"; -} - -.fa-acorn { - --fa: "\f6ae"; -} - -.fa-alicorn { - --fa: "\f6b0"; -} - -.fa-crate-apple { - --fa: "\f6b1"; -} - -.fa-apple-crate { - --fa: "\f6b1"; -} - -.fa-axe { - --fa: "\f6b2"; -} - -.fa-axe-battle { - --fa: "\f6b3"; -} - -.fa-badger-honey { - --fa: "\f6b4"; -} - -.fa-bat { - --fa: "\f6b5"; -} - -.fa-blender-phone { - --fa: "\f6b6"; -} - -.fa-book-skull { - --fa: "\f6b7"; -} - -.fa-book-dead { - --fa: "\f6b7"; -} - -.fa-book-sparkles { - --fa: "\f6b8"; -} - -.fa-book-spells { - --fa: "\f6b8"; -} - -.fa-bow-arrow { - --fa: "\f6b9"; -} - -.fa-campfire { - --fa: "\f6ba"; -} - -.fa-campground { - --fa: "\f6bb"; -} - -.fa-candle-holder { - --fa: "\f6bc"; -} - -.fa-candy-corn { - --fa: "\f6bd"; -} - -.fa-cat { - --fa: "\f6be"; -} - -.fa-cauldron { - --fa: "\f6bf"; -} - -.fa-chair { - --fa: "\f6c0"; -} - -.fa-chair-office { - --fa: "\f6c1"; -} - -.fa-claw-marks { - --fa: "\f6c2"; -} - -.fa-cloud-moon { - --fa: "\f6c3"; -} - -.fa-cloud-sun { - --fa: "\f6c4"; -} - -.fa-cup-togo { - --fa: "\f6c5"; -} - -.fa-coffee-togo { - --fa: "\f6c5"; -} - -.fa-coffin { - --fa: "\f6c6"; -} - -.fa-corn { - --fa: "\f6c7"; -} - -.fa-cow { - --fa: "\f6c8"; -} - -.fa-dagger { - --fa: "\f6cb"; -} - -.fa-dice-d10 { - --fa: "\f6cd"; -} - -.fa-dice-d12 { - --fa: "\f6ce"; -} - -.fa-dice-d20 { - --fa: "\f6cf"; -} - -.fa-dice-d4 { - --fa: "\f6d0"; -} - -.fa-dice-d6 { - --fa: "\f6d1"; -} - -.fa-dice-d8 { - --fa: "\f6d2"; -} - -.fa-dog { - --fa: "\f6d3"; -} - -.fa-dog-leashed { - --fa: "\f6d4"; -} - -.fa-dragon { - --fa: "\f6d5"; -} - -.fa-drumstick { - --fa: "\f6d6"; -} - -.fa-drumstick-bite { - --fa: "\f6d7"; -} - -.fa-duck { - --fa: "\f6d8"; -} - -.fa-dungeon { - --fa: "\f6d9"; -} - -.fa-elephant { - --fa: "\f6da"; -} - -.fa-eye-evil { - --fa: "\f6db"; -} - -.fa-file-csv { - --fa: "\f6dd"; -} - -.fa-hand-fist { - --fa: "\f6de"; -} - -.fa-fist-raised { - --fa: "\f6de"; -} - -.fa-fire-flame { - --fa: "\f6df"; -} - -.fa-flame { - --fa: "\f6df"; -} - -.fa-flask-round-poison { - --fa: "\f6e0"; -} - -.fa-flask-poison { - --fa: "\f6e0"; -} - -.fa-flask-round-potion { - --fa: "\f6e1"; -} - -.fa-flask-potion { - --fa: "\f6e1"; -} - -.fa-ghost { - --fa: "\f6e2"; -} - -.fa-hammer { - --fa: "\f6e3"; -} - -.fa-hammer-war { - --fa: "\f6e4"; -} - -.fa-hand-holding-magic { - --fa: "\f6e5"; -} - -.fa-hanukiah { - --fa: "\f6e6"; -} - -.fa-hat-witch { - --fa: "\f6e7"; -} - -.fa-hat-wizard { - --fa: "\f6e8"; -} - -.fa-head-side { - --fa: "\f6e9"; -} - -.fa-head-side-goggles { - --fa: "\f6ea"; -} - -.fa-head-vr { - --fa: "\f6ea"; -} - -.fa-helmet-battle { - --fa: "\f6eb"; -} - -.fa-person-hiking { - --fa: "\f6ec"; -} - -.fa-hiking { - --fa: "\f6ec"; -} - -.fa-hippo { - --fa: "\f6ed"; -} - -.fa-hockey-mask { - --fa: "\f6ee"; -} - -.fa-hood-cloak { - --fa: "\f6ef"; -} - -.fa-horse { - --fa: "\f6f0"; -} - -.fa-house-chimney-crack { - --fa: "\f6f1"; -} - -.fa-house-damage { - --fa: "\f6f1"; -} - -.fa-hryvnia-sign { - --fa: "\f6f2"; -} - -.fa-hryvnia { - --fa: "\f6f2"; -} - -.fa-key-skeleton { - --fa: "\f6f3"; -} - -.fa-kite { - --fa: "\f6f4"; -} - -.fa-knife-kitchen { - --fa: "\f6f5"; -} - -.fa-leaf-maple { - --fa: "\f6f6"; -} - -.fa-leaf-oak { - --fa: "\f6f7"; -} - -.fa-mace { - --fa: "\f6f8"; -} - -.fa-mandolin { - --fa: "\f6f9"; -} - -.fa-mask { - --fa: "\f6fa"; -} - -.fa-monkey { - --fa: "\f6fb"; -} - -.fa-mountain { - --fa: "\f6fc"; -} - -.fa-mountains { - --fa: "\f6fd"; -} - -.fa-narwhal { - --fa: "\f6fe"; -} - -.fa-network-wired { - --fa: "\f6ff"; -} - -.fa-otter { - --fa: "\f700"; -} - -.fa-paw-simple { - --fa: "\f701"; -} - -.fa-paw-alt { - --fa: "\f701"; -} - -.fa-paw-claws { - --fa: "\f702"; -} - -.fa-pegasus { - --fa: "\f703"; -} - -.fa-pie { - --fa: "\f705"; -} - -.fa-pig { - --fa: "\f706"; -} - -.fa-pumpkin { - --fa: "\f707"; -} - -.fa-rabbit { - --fa: "\f708"; -} - -.fa-rabbit-running { - --fa: "\f709"; -} - -.fa-rabbit-fast { - --fa: "\f709"; -} - -.fa-ram { - --fa: "\f70a"; -} - -.fa-ring { - --fa: "\f70b"; -} - -.fa-person-running { - --fa: "\f70c"; -} - -.fa-running { - --fa: "\f70c"; -} - -.fa-scarecrow { - --fa: "\f70d"; -} - -.fa-scroll { - --fa: "\f70e"; -} - -.fa-scroll-old { - --fa: "\f70f"; -} - -.fa-scythe { - --fa: "\f710"; -} - -.fa-sheep { - --fa: "\f711"; -} - -.fa-shield-cross { - --fa: "\f712"; -} - -.fa-shovel { - --fa: "\f713"; -} - -.fa-skull-crossbones { - --fa: "\f714"; -} - -.fa-slash { - --fa: "\f715"; -} - -.fa-snake { - --fa: "\f716"; -} - -.fa-spider { - --fa: "\f717"; -} - -.fa-spider-black-widow { - --fa: "\f718"; -} - -.fa-spider-web { - --fa: "\f719"; -} - -.fa-squirrel { - --fa: "\f71a"; -} - -.fa-staff { - --fa: "\f71b"; -} - -.fa-sword { - --fa: "\f71c"; -} - -.fa-swords { - --fa: "\f71d"; -} - -.fa-toilet-paper { - --fa: "\f71e"; -} - -.fa-toilet-paper-alt { - --fa: "\f71e"; -} - -.fa-toilet-paper-blank { - --fa: "\f71e"; -} - -.fa-tombstone { - --fa: "\f720"; -} - -.fa-tombstone-blank { - --fa: "\f721"; -} - -.fa-tombstone-alt { - --fa: "\f721"; -} - -.fa-tractor { - --fa: "\f722"; -} - -.fa-treasure-chest { - --fa: "\f723"; -} - -.fa-trees { - --fa: "\f724"; -} - -.fa-turkey { - --fa: "\f725"; -} - -.fa-turtle { - --fa: "\f726"; -} - -.fa-unicorn { - --fa: "\f727"; -} - -.fa-user-injured { - --fa: "\f728"; -} - -.fa-vr-cardboard { - --fa: "\f729"; -} - -.fa-wand { - --fa: "\f72a"; -} - -.fa-wand-sparkles { - --fa: "\f72b"; -} - -.fa-whale { - --fa: "\f72c"; -} - -.fa-wheat { - --fa: "\f72d"; -} - -.fa-wind { - --fa: "\f72e"; -} - -.fa-wine-bottle { - --fa: "\f72f"; -} - -.fa-ballot { - --fa: "\f732"; -} - -.fa-ballot-check { - --fa: "\f733"; -} - -.fa-booth-curtain { - --fa: "\f734"; -} - -.fa-box-ballot { - --fa: "\f735"; -} - -.fa-calendar-star { - --fa: "\f736"; -} - -.fa-clipboard-list-check { - --fa: "\f737"; -} - -.fa-cloud-drizzle { - --fa: "\f738"; -} - -.fa-cloud-hail { - --fa: "\f739"; -} - -.fa-cloud-hail-mixed { - --fa: "\f73a"; -} - -.fa-cloud-meatball { - --fa: "\f73b"; -} - -.fa-cloud-moon-rain { - --fa: "\f73c"; -} - -.fa-cloud-rain { - --fa: "\f73d"; -} - -.fa-cloud-rainbow { - --fa: "\f73e"; -} - -.fa-cloud-showers { - --fa: "\f73f"; -} - -.fa-cloud-showers-heavy { - --fa: "\f740"; -} - -.fa-cloud-sleet { - --fa: "\f741"; -} - -.fa-cloud-snow { - --fa: "\f742"; -} - -.fa-cloud-sun-rain { - --fa: "\f743"; -} - -.fa-clouds { - --fa: "\f744"; -} - -.fa-clouds-moon { - --fa: "\f745"; -} - -.fa-clouds-sun { - --fa: "\f746"; -} - -.fa-democrat { - --fa: "\f747"; -} - -.fa-droplet-degree { - --fa: "\f748"; -} - -.fa-dewpoint { - --fa: "\f748"; -} - -.fa-eclipse { - --fa: "\f749"; -} - -.fa-moon-over-sun { - --fa: "\f74a"; -} - -.fa-eclipse-alt { - --fa: "\f74a"; -} - -.fa-fire-smoke { - --fa: "\f74b"; -} - -.fa-flag-swallowtail { - --fa: "\f74c"; -} - -.fa-flag-alt { - --fa: "\f74c"; -} - -.fa-flag-usa { - --fa: "\f74d"; -} - -.fa-cloud-fog { - --fa: "\f74e"; -} - -.fa-fog { - --fa: "\f74e"; -} - -.fa-house-water { - --fa: "\f74f"; -} - -.fa-house-flood { - --fa: "\f74f"; -} - -.fa-droplet-percent { - --fa: "\f750"; -} - -.fa-humidity { - --fa: "\f750"; -} - -.fa-hurricane { - --fa: "\f751"; -} - -.fa-landmark-dome { - --fa: "\f752"; -} - -.fa-landmark-alt { - --fa: "\f752"; -} - -.fa-meteor { - --fa: "\f753"; -} - -.fa-moon-cloud { - --fa: "\f754"; -} - -.fa-moon-stars { - --fa: "\f755"; -} - -.fa-person-booth { - --fa: "\f756"; -} - -.fa-person-sign { - --fa: "\f757"; -} - -.fa-podium-star { - --fa: "\f758"; -} - -.fa-poll-people { - --fa: "\f759"; -} - -.fa-poo-storm { - --fa: "\f75a"; -} - -.fa-poo-bolt { - --fa: "\f75a"; -} - -.fa-rainbow { - --fa: "\f75b"; -} - -.fa-raindrops { - --fa: "\f75c"; -} - -.fa-republican { - --fa: "\f75e"; -} - -.fa-smog { - --fa: "\f75f"; -} - -.fa-smoke { - --fa: "\f760"; -} - -.fa-snow-blowing { - --fa: "\f761"; -} - -.fa-stars { - --fa: "\f762"; -} - -.fa-sun-cloud { - --fa: "\f763"; -} - -.fa-sun-dust { - --fa: "\f764"; -} - -.fa-sun-haze { - --fa: "\f765"; -} - -.fa-sunrise { - --fa: "\f766"; -} - -.fa-sunset { - --fa: "\f767"; -} - -.fa-temperature-snow { - --fa: "\f768"; -} - -.fa-temperature-frigid { - --fa: "\f768"; -} - -.fa-temperature-high { - --fa: "\f769"; -} - -.fa-temperature-sun { - --fa: "\f76a"; -} - -.fa-temperature-hot { - --fa: "\f76a"; -} - -.fa-temperature-low { - --fa: "\f76b"; -} - -.fa-cloud-bolt { - --fa: "\f76c"; -} - -.fa-thunderstorm { - --fa: "\f76c"; -} - -.fa-cloud-bolt-moon { - --fa: "\f76d"; -} - -.fa-thunderstorm-moon { - --fa: "\f76d"; -} - -.fa-cloud-bolt-sun { - --fa: "\f76e"; -} - -.fa-thunderstorm-sun { - --fa: "\f76e"; -} - -.fa-tornado { - --fa: "\f76f"; -} - -.fa-volcano { - --fa: "\f770"; -} - -.fa-xmark-to-slot { - --fa: "\f771"; -} - -.fa-times-to-slot { - --fa: "\f771"; -} - -.fa-vote-nay { - --fa: "\f771"; -} - -.fa-check-to-slot { - --fa: "\f772"; -} - -.fa-vote-yea { - --fa: "\f772"; -} - -.fa-water { - --fa: "\f773"; -} - -.fa-water-arrow-down { - --fa: "\f774"; -} - -.fa-water-lower { - --fa: "\f774"; -} - -.fa-water-arrow-up { - --fa: "\f775"; -} - -.fa-water-rise { - --fa: "\f775"; -} - -.fa-wind-warning { - --fa: "\f776"; -} - -.fa-wind-circle-exclamation { - --fa: "\f776"; -} - -.fa-windsock { - --fa: "\f777"; -} - -.fa-angel { - --fa: "\f779"; -} - -.fa-baby { - --fa: "\f77c"; -} - -.fa-baby-carriage { - --fa: "\f77d"; -} - -.fa-carriage-baby { - --fa: "\f77d"; -} - -.fa-ball-pile { - --fa: "\f77e"; -} - -.fa-bells { - --fa: "\f77f"; -} - -.fa-biohazard { - --fa: "\f780"; -} - -.fa-blog { - --fa: "\f781"; -} - -.fa-boot { - --fa: "\f782"; -} - -.fa-calendar-day { - --fa: "\f783"; -} - -.fa-calendar-week { - --fa: "\f784"; -} - -.fa-candy-cane { - --fa: "\f786"; -} - -.fa-carrot { - --fa: "\f787"; -} - -.fa-cash-register { - --fa: "\f788"; -} - -.fa-chart-network { - --fa: "\f78a"; -} - -.fa-chimney { - --fa: "\f78b"; -} - -.fa-minimize { - --fa: "\f78c"; -} - -.fa-compress-arrows-alt { - --fa: "\f78c"; -} - -.fa-deer { - --fa: "\f78e"; -} - -.fa-deer-rudolph { - --fa: "\f78f"; -} - -.fa-dreidel { - --fa: "\f792"; -} - -.fa-dumpster { - --fa: "\f793"; -} - -.fa-dumpster-fire { - --fa: "\f794"; -} - -.fa-ear-muffs { - --fa: "\f795"; -} - -.fa-ethernet { - --fa: "\f796"; -} - -.fa-fireplace { - --fa: "\f79a"; -} - -.fa-snowman-head { - --fa: "\f79b"; -} - -.fa-frosty-head { - --fa: "\f79b"; -} - -.fa-gifts { - --fa: "\f79c"; -} - -.fa-gingerbread-man { - --fa: "\f79d"; -} - -.fa-champagne-glass { - --fa: "\f79e"; -} - -.fa-glass-champagne { - --fa: "\f79e"; -} - -.fa-champagne-glasses { - --fa: "\f79f"; -} - -.fa-glass-cheers { - --fa: "\f79f"; -} - -.fa-whiskey-glass { - --fa: "\f7a0"; -} - -.fa-glass-whiskey { - --fa: "\f7a0"; -} - -.fa-whiskey-glass-ice { - --fa: "\f7a1"; -} - -.fa-glass-whiskey-rocks { - --fa: "\f7a1"; -} - -.fa-earth-europe { - --fa: "\f7a2"; -} - -.fa-globe-europe { - --fa: "\f7a2"; -} - -.fa-globe-snow { - --fa: "\f7a3"; -} - -.fa-grip-lines { - --fa: "\f7a4"; -} - -.fa-grip-lines-vertical { - --fa: "\f7a5"; -} - -.fa-guitar { - --fa: "\f7a6"; -} - -.fa-hat-santa { - --fa: "\f7a7"; -} - -.fa-hat-winter { - --fa: "\f7a8"; -} - -.fa-heart-crack { - --fa: "\f7a9"; -} - -.fa-heart-broken { - --fa: "\f7a9"; -} - -.fa-holly-berry { - --fa: "\f7aa"; -} - -.fa-horse-head { - --fa: "\f7ab"; -} - -.fa-ice-skate { - --fa: "\f7ac"; -} - -.fa-icicles { - --fa: "\f7ad"; -} - -.fa-igloo { - --fa: "\f7ae"; -} - -.fa-lights-holiday { - --fa: "\f7b2"; -} - -.fa-mistletoe { - --fa: "\f7b4"; -} - -.fa-mitten { - --fa: "\f7b5"; -} - -.fa-mug-hot { - --fa: "\f7b6"; -} - -.fa-mug-marshmallows { - --fa: "\f7b7"; -} - -.fa-ornament { - --fa: "\f7b8"; -} - -.fa-radiation { - --fa: "\f7b9"; -} - -.fa-circle-radiation { - --fa: "\f7ba"; -} - -.fa-radiation-alt { - --fa: "\f7ba"; -} - -.fa-restroom { - --fa: "\f7bd"; -} - -.fa-rv { - --fa: "\f7be"; -} - -.fa-satellite { - --fa: "\f7bf"; -} - -.fa-satellite-dish { - --fa: "\f7c0"; -} - -.fa-scarf { - --fa: "\f7c1"; -} - -.fa-sd-card { - --fa: "\f7c2"; -} - -.fa-shovel-snow { - --fa: "\f7c3"; -} - -.fa-sim-card { - --fa: "\f7c4"; -} - -.fa-person-skating { - --fa: "\f7c5"; -} - -.fa-skating { - --fa: "\f7c5"; -} - -.fa-person-ski-jumping { - --fa: "\f7c7"; -} - -.fa-ski-jump { - --fa: "\f7c7"; -} - -.fa-person-ski-lift { - --fa: "\f7c8"; -} - -.fa-ski-lift { - --fa: "\f7c8"; -} - -.fa-person-skiing { - --fa: "\f7c9"; -} - -.fa-skiing { - --fa: "\f7c9"; -} - -.fa-person-skiing-nordic { - --fa: "\f7ca"; -} - -.fa-skiing-nordic { - --fa: "\f7ca"; -} - -.fa-person-sledding { - --fa: "\f7cb"; -} - -.fa-sledding { - --fa: "\f7cb"; -} - -.fa-sleigh { - --fa: "\f7cc"; -} - -.fa-comment-sms { - --fa: "\f7cd"; -} - -.fa-sms { - --fa: "\f7cd"; -} - -.fa-person-snowboarding { - --fa: "\f7ce"; -} - -.fa-snowboarding { - --fa: "\f7ce"; -} - -.fa-snowflakes { - --fa: "\f7cf"; -} - -.fa-snowman { - --fa: "\f7d0"; -} - -.fa-person-snowmobiling { - --fa: "\f7d1"; -} - -.fa-snowmobile { - --fa: "\f7d1"; -} - -.fa-snowplow { - --fa: "\f7d2"; -} - -.fa-star-christmas { - --fa: "\f7d4"; -} - -.fa-stocking { - --fa: "\f7d5"; -} - -.fa-tenge-sign { - --fa: "\f7d7"; -} - -.fa-tenge { - --fa: "\f7d7"; -} - -.fa-toilet { - --fa: "\f7d8"; -} - -.fa-screwdriver-wrench { - --fa: "\f7d9"; -} - -.fa-tools { - --fa: "\f7d9"; -} - -.fa-cable-car { - --fa: "\f7da"; -} - -.fa-tram { - --fa: "\f7da"; -} - -.fa-tree-christmas { - --fa: "\f7db"; -} - -.fa-tree-decorated { - --fa: "\f7dc"; -} - -.fa-tree-large { - --fa: "\f7dd"; -} - -.fa-truck-plow { - --fa: "\f7de"; -} - -.fa-wreath { - --fa: "\f7e2"; -} - -.fa-fire-flame-curved { - --fa: "\f7e4"; -} - -.fa-fire-alt { - --fa: "\f7e4"; -} - -.fa-bacon { - --fa: "\f7e5"; -} - -.fa-book-medical { - --fa: "\f7e6"; -} - -.fa-book-user { - --fa: "\f7e7"; -} - -.fa-books-medical { - --fa: "\f7e8"; -} - -.fa-brackets-square { - --fa: "\f7e9"; -} - -.fa-brackets { - --fa: "\f7e9"; -} - -.fa-brackets-curly { - --fa: "\f7ea"; -} - -.fa-bread-loaf { - --fa: "\f7eb"; -} - -.fa-bread-slice { - --fa: "\f7ec"; -} - -.fa-burrito { - --fa: "\f7ed"; -} - -.fa-chart-scatter { - --fa: "\f7ee"; -} - -.fa-cheese { - --fa: "\f7ef"; -} - -.fa-cheese-swiss { - --fa: "\f7f0"; -} - -.fa-burger-cheese { - --fa: "\f7f1"; -} - -.fa-cheeseburger { - --fa: "\f7f1"; -} - -.fa-house-chimney-medical { - --fa: "\f7f2"; -} - -.fa-clinic-medical { - --fa: "\f7f2"; -} - -.fa-clipboard-user { - --fa: "\f7f3"; -} - -.fa-message-medical { - --fa: "\f7f4"; -} - -.fa-comment-alt-medical { - --fa: "\f7f4"; -} - -.fa-comment-medical { - --fa: "\f7f5"; -} - -.fa-croissant { - --fa: "\f7f6"; -} - -.fa-crutch { - --fa: "\f7f7"; -} - -.fa-crutches { - --fa: "\f7f8"; -} - -.fa-ban-bug { - --fa: "\f7f9"; -} - -.fa-debug { - --fa: "\f7f9"; -} - -.fa-disease { - --fa: "\f7fa"; -} - -.fa-egg { - --fa: "\f7fb"; -} - -.fa-egg-fried { - --fa: "\f7fc"; -} - -.fa-files-medical { - --fa: "\f7fd"; -} - -.fa-fish-cooked { - --fa: "\f7fe"; -} - -.fa-flower { - --fa: "\f7ff"; -} - -.fa-flower-daffodil { - --fa: "\f800"; -} - -.fa-flower-tulip { - --fa: "\f801"; -} - -.fa-folder-tree { - --fa: "\f802"; -} - -.fa-french-fries { - --fa: "\f803"; -} - -.fa-glass { - --fa: "\f804"; -} - -.fa-burger { - --fa: "\f805"; -} - -.fa-hamburger { - --fa: "\f805"; -} - -.fa-hand-middle-finger { - --fa: "\f806"; -} - -.fa-helmet-safety { - --fa: "\f807"; -} - -.fa-hard-hat { - --fa: "\f807"; -} - -.fa-hat-hard { - --fa: "\f807"; -} - -.fa-head-side-brain { - --fa: "\f808"; -} - -.fa-head-side-medical { - --fa: "\f809"; -} - -.fa-hospital-user { - --fa: "\f80d"; -} - -.fa-hospitals { - --fa: "\f80e"; -} - -.fa-hotdog { - --fa: "\f80f"; -} - -.fa-ice-cream { - --fa: "\f810"; -} - -.fa-island-tropical { - --fa: "\f811"; -} - -.fa-island-tree-palm { - --fa: "\f811"; -} - -.fa-laptop-medical { - --fa: "\f812"; -} - -.fa-mailbox { - --fa: "\f813"; -} - -.fa-meat { - --fa: "\f814"; -} - -.fa-pager { - --fa: "\f815"; -} - -.fa-pepper-hot { - --fa: "\f816"; -} - -.fa-pizza { - --fa: "\f817"; -} - -.fa-pizza-slice { - --fa: "\f818"; -} - -.fa-popcorn { - --fa: "\f819"; -} - -.fa-print-magnifying-glass { - --fa: "\f81a"; -} - -.fa-print-search { - --fa: "\f81a"; -} - -.fa-rings-wedding { - --fa: "\f81b"; -} - -.fa-sack { - --fa: "\f81c"; -} - -.fa-sack-dollar { - --fa: "\f81d"; -} - -.fa-salad { - --fa: "\f81e"; -} - -.fa-bowl-salad { - --fa: "\f81e"; -} - -.fa-sandwich { - --fa: "\f81f"; -} - -.fa-sausage { - --fa: "\f820"; -} - -.fa-shish-kebab { - --fa: "\f821"; -} - -.fa-sickle { - --fa: "\f822"; -} - -.fa-bowl-hot { - --fa: "\f823"; -} - -.fa-soup { - --fa: "\f823"; -} - -.fa-steak { - --fa: "\f824"; -} - -.fa-stretcher { - --fa: "\f825"; -} - -.fa-taco { - --fa: "\f826"; -} - -.fa-book-tanakh { - --fa: "\f827"; -} - -.fa-tanakh { - --fa: "\f827"; -} - -.fa-bars-progress { - --fa: "\f828"; -} - -.fa-tasks-alt { - --fa: "\f828"; -} - -.fa-trash-arrow-up { - --fa: "\f829"; -} - -.fa-trash-restore { - --fa: "\f829"; -} - -.fa-trash-can-arrow-up { - --fa: "\f82a"; -} - -.fa-trash-restore-alt { - --fa: "\f82a"; -} - -.fa-tree-palm { - --fa: "\f82b"; -} - -.fa-user-helmet-safety { - --fa: "\f82c"; -} - -.fa-user-construction { - --fa: "\f82c"; -} - -.fa-user-hard-hat { - --fa: "\f82c"; -} - -.fa-user-headset { - --fa: "\f82d"; -} - -.fa-user-doctor-message { - --fa: "\f82e"; -} - -.fa-user-md-chat { - --fa: "\f82e"; -} - -.fa-user-nurse { - --fa: "\f82f"; -} - -.fa-users-medical { - --fa: "\f830"; -} - -.fa-walker { - --fa: "\f831"; -} - -.fa-camera-web { - --fa: "\f832"; -} - -.fa-webcam { - --fa: "\f832"; -} - -.fa-camera-web-slash { - --fa: "\f833"; -} - -.fa-webcam-slash { - --fa: "\f833"; -} - -.fa-wave-square { - --fa: "\f83e"; -} - -.fa-alarm-exclamation { - --fa: "\f843"; -} - -.fa-alarm-plus { - --fa: "\f844"; -} - -.fa-alarm-snooze { - --fa: "\f845"; -} - -.fa-align-slash { - --fa: "\f846"; -} - -.fa-bags-shopping { - --fa: "\f847"; -} - -.fa-bell-exclamation { - --fa: "\f848"; -} - -.fa-bell-plus { - --fa: "\f849"; -} - -.fa-person-biking { - --fa: "\f84a"; -} - -.fa-biking { - --fa: "\f84a"; -} - -.fa-person-biking-mountain { - --fa: "\f84b"; -} - -.fa-biking-mountain { - --fa: "\f84b"; -} - -.fa-border-all { - --fa: "\f84c"; -} - -.fa-border-bottom { - --fa: "\f84d"; -} - -.fa-border-inner { - --fa: "\f84e"; -} - -.fa-border-left { - --fa: "\f84f"; -} - -.fa-border-none { - --fa: "\f850"; -} - -.fa-border-outer { - --fa: "\f851"; -} - -.fa-border-right { - --fa: "\f852"; -} - -.fa-border-top-left { - --fa: "\f853"; -} - -.fa-border-style { - --fa: "\f853"; -} - -.fa-border-bottom-right { - --fa: "\f854"; -} - -.fa-border-style-alt { - --fa: "\f854"; -} - -.fa-border-top { - --fa: "\f855"; -} - -.fa-bring-forward { - --fa: "\f856"; -} - -.fa-bring-front { - --fa: "\f857"; -} - -.fa-burger-soda { - --fa: "\f858"; -} - -.fa-car-building { - --fa: "\f859"; -} - -.fa-car-bus { - --fa: "\f85a"; -} - -.fa-cars { - --fa: "\f85b"; -} - -.fa-coin { - --fa: "\f85c"; -} - -.fa-triangle-person-digging { - --fa: "\f85d"; -} - -.fa-construction { - --fa: "\f85d"; -} - -.fa-person-digging { - --fa: "\f85e"; -} - -.fa-digging { - --fa: "\f85e"; -} - -.fa-drone { - --fa: "\f85f"; -} - -.fa-drone-front { - --fa: "\f860"; -} - -.fa-drone-alt { - --fa: "\f860"; -} - -.fa-dryer { - --fa: "\f861"; -} - -.fa-dryer-heat { - --fa: "\f862"; -} - -.fa-dryer-alt { - --fa: "\f862"; -} - -.fa-fan { - --fa: "\f863"; -} - -.fa-farm { - --fa: "\f864"; -} - -.fa-barn-silo { - --fa: "\f864"; -} - -.fa-file-magnifying-glass { - --fa: "\f865"; -} - -.fa-file-search { - --fa: "\f865"; -} - -.fa-font-case { - --fa: "\f866"; -} - -.fa-game-board { - --fa: "\f867"; -} - -.fa-game-board-simple { - --fa: "\f868"; -} - -.fa-game-board-alt { - --fa: "\f868"; -} - -.fa-glass-citrus { - --fa: "\f869"; -} - -.fa-h4 { - --fa: "\f86a"; -} - -.fa-hat-chef { - --fa: "\f86b"; -} - -.fa-horizontal-rule { - --fa: "\f86c"; -} - -.fa-icons { - --fa: "\f86d"; -} - -.fa-heart-music-camera-bolt { - --fa: "\f86d"; -} - -.fa-symbols { - --fa: "\f86e"; -} - -.fa-icons-alt { - --fa: "\f86e"; -} - -.fa-kerning { - --fa: "\f86f"; -} - -.fa-line-columns { - --fa: "\f870"; -} - -.fa-line-height { - --fa: "\f871"; -} - -.fa-money-check-pen { - --fa: "\f872"; -} - -.fa-money-check-edit { - --fa: "\f872"; -} - -.fa-money-check-dollar-pen { - --fa: "\f873"; -} - -.fa-money-check-edit-alt { - --fa: "\f873"; -} - -.fa-mug { - --fa: "\f874"; -} - -.fa-mug-tea { - --fa: "\f875"; -} - -.fa-overline { - --fa: "\f876"; -} - -.fa-file-dashed-line { - --fa: "\f877"; -} - -.fa-page-break { - --fa: "\f877"; -} - -.fa-paragraph-left { - --fa: "\f878"; -} - -.fa-paragraph-rtl { - --fa: "\f878"; -} - -.fa-phone-flip { - --fa: "\f879"; -} - -.fa-phone-alt { - --fa: "\f879"; -} - -.fa-laptop-mobile { - --fa: "\f87a"; -} - -.fa-phone-laptop { - --fa: "\f87a"; -} - -.fa-square-phone-flip { - --fa: "\f87b"; -} - -.fa-phone-square-alt { - --fa: "\f87b"; -} - -.fa-photo-film { - --fa: "\f87c"; -} - -.fa-photo-video { - --fa: "\f87c"; -} - -.fa-text-slash { - --fa: "\f87d"; -} - -.fa-remove-format { - --fa: "\f87d"; -} - -.fa-send-back { - --fa: "\f87e"; -} - -.fa-send-backward { - --fa: "\f87f"; -} - -.fa-snooze { - --fa: "\f880"; -} - -.fa-zzz { - --fa: "\f880"; -} - -.fa-arrow-down-z-a { - --fa: "\f881"; -} - -.fa-sort-alpha-desc { - --fa: "\f881"; -} - -.fa-sort-alpha-down-alt { - --fa: "\f881"; -} - -.fa-arrow-up-z-a { - --fa: "\f882"; -} - -.fa-sort-alpha-up-alt { - --fa: "\f882"; -} - -.fa-arrow-down-arrow-up { - --fa: "\f883"; -} - -.fa-sort-alt { - --fa: "\f883"; -} - -.fa-arrow-down-short-wide { - --fa: "\f884"; -} - -.fa-sort-amount-desc { - --fa: "\f884"; -} - -.fa-sort-amount-down-alt { - --fa: "\f884"; -} - -.fa-arrow-up-short-wide { - --fa: "\f885"; -} - -.fa-sort-amount-up-alt { - --fa: "\f885"; -} - -.fa-arrow-down-9-1 { - --fa: "\f886"; -} - -.fa-sort-numeric-desc { - --fa: "\f886"; -} - -.fa-sort-numeric-down-alt { - --fa: "\f886"; -} - -.fa-arrow-up-9-1 { - --fa: "\f887"; -} - -.fa-sort-numeric-up-alt { - --fa: "\f887"; -} - -.fa-arrow-down-triangle-square { - --fa: "\f888"; -} - -.fa-sort-shapes-down { - --fa: "\f888"; -} - -.fa-arrow-down-square-triangle { - --fa: "\f889"; -} - -.fa-sort-shapes-down-alt { - --fa: "\f889"; -} - -.fa-arrow-up-triangle-square { - --fa: "\f88a"; -} - -.fa-sort-shapes-up { - --fa: "\f88a"; -} - -.fa-arrow-up-square-triangle { - --fa: "\f88b"; -} - -.fa-sort-shapes-up-alt { - --fa: "\f88b"; -} - -.fa-arrow-down-big-small { - --fa: "\f88c"; -} - -.fa-sort-size-down { - --fa: "\f88c"; -} - -.fa-arrow-down-small-big { - --fa: "\f88d"; -} - -.fa-sort-size-down-alt { - --fa: "\f88d"; -} - -.fa-arrow-up-big-small { - --fa: "\f88e"; -} - -.fa-sort-size-up { - --fa: "\f88e"; -} - -.fa-arrow-up-small-big { - --fa: "\f88f"; -} - -.fa-sort-size-up-alt { - --fa: "\f88f"; -} - -.fa-sparkles { - --fa: "\f890"; -} - -.fa-spell-check { - --fa: "\f891"; -} - -.fa-sunglasses { - --fa: "\f892"; -} - -.fa-text { - --fa: "\f893"; -} - -.fa-text-size { - --fa: "\f894"; -} - -.fa-trash-undo { - --fa: "\f895"; -} - -.fa-trash-arrow-turn-left { - --fa: "\f895"; -} - -.fa-trash-can-undo { - --fa: "\f896"; -} - -.fa-trash-can-arrow-turn-left { - --fa: "\f896"; -} - -.fa-trash-undo-alt { - --fa: "\f896"; -} - -.fa-voicemail { - --fa: "\f897"; -} - -.fa-washing-machine { - --fa: "\f898"; -} - -.fa-washer { - --fa: "\f898"; -} - -.fa-wave-sine { - --fa: "\f899"; -} - -.fa-wave-triangle { - --fa: "\f89a"; -} - -.fa-wind-turbine { - --fa: "\f89b"; -} - -.fa-border-center-h { - --fa: "\f89c"; -} - -.fa-border-center-v { - --fa: "\f89d"; -} - -.fa-album { - --fa: "\f89f"; -} - -.fa-album-collection { - --fa: "\f8a0"; -} - -.fa-amp-guitar { - --fa: "\f8a1"; -} - -.fa-badge-sheriff { - --fa: "\f8a2"; -} - -.fa-banjo { - --fa: "\f8a3"; -} - -.fa-cassette-betamax { - --fa: "\f8a4"; -} - -.fa-betamax { - --fa: "\f8a4"; -} - -.fa-boombox { - --fa: "\f8a5"; -} - -.fa-cactus { - --fa: "\f8a7"; -} - -.fa-camcorder { - --fa: "\f8a8"; -} - -.fa-video-handheld { - --fa: "\f8a8"; -} - -.fa-camera-movie { - --fa: "\f8a9"; -} - -.fa-camera-polaroid { - --fa: "\f8aa"; -} - -.fa-cassette-tape { - --fa: "\f8ab"; -} - -.fa-camera-cctv { - --fa: "\f8ac"; -} - -.fa-cctv { - --fa: "\f8ac"; -} - -.fa-clarinet { - --fa: "\f8ad"; -} - -.fa-cloud-music { - --fa: "\f8ae"; -} - -.fa-message-music { - --fa: "\f8af"; -} - -.fa-comment-alt-music { - --fa: "\f8af"; -} - -.fa-comment-music { - --fa: "\f8b0"; -} - -.fa-computer-classic { - --fa: "\f8b1"; -} - -.fa-computer-speaker { - --fa: "\f8b2"; -} - -.fa-cowbell { - --fa: "\f8b3"; -} - -.fa-cowbell-circle-plus { - --fa: "\f8b4"; -} - -.fa-cowbell-more { - --fa: "\f8b4"; -} - -.fa-disc-drive { - --fa: "\f8b5"; -} - -.fa-file-music { - --fa: "\f8b6"; -} - -.fa-film-canister { - --fa: "\f8b7"; -} - -.fa-film-cannister { - --fa: "\f8b7"; -} - -.fa-flashlight { - --fa: "\f8b8"; -} - -.fa-flute { - --fa: "\f8b9"; -} - -.fa-flux-capacitor { - --fa: "\f8ba"; -} - -.fa-game-console-handheld { - --fa: "\f8bb"; -} - -.fa-gramophone { - --fa: "\f8bd"; -} - -.fa-guitar-electric { - --fa: "\f8be"; -} - -.fa-guitars { - --fa: "\f8bf"; -} - -.fa-hat-cowboy { - --fa: "\f8c0"; -} - -.fa-hat-cowboy-side { - --fa: "\f8c1"; -} - -.fa-head-side-headphones { - --fa: "\f8c2"; -} - -.fa-horse-saddle { - --fa: "\f8c3"; -} - -.fa-image-polaroid { - --fa: "\f8c4"; -} - -.fa-joystick { - --fa: "\f8c5"; -} - -.fa-jug { - --fa: "\f8c6"; -} - -.fa-kazoo { - --fa: "\f8c7"; -} - -.fa-lasso { - --fa: "\f8c8"; -} - -.fa-list-music { - --fa: "\f8c9"; -} - -.fa-microphone-stand { - --fa: "\f8cb"; -} - -.fa-computer-mouse { - --fa: "\f8cc"; -} - -.fa-mouse { - --fa: "\f8cc"; -} - -.fa-computer-mouse-scrollwheel { - --fa: "\f8cd"; -} - -.fa-mouse-alt { - --fa: "\f8cd"; -} - -.fa-mp3-player { - --fa: "\f8ce"; -} - -.fa-music-note { - --fa: "\f8cf"; -} - -.fa-music-alt { - --fa: "\f8cf"; -} - -.fa-music-note-slash { - --fa: "\f8d0"; -} - -.fa-music-alt-slash { - --fa: "\f8d0"; -} - -.fa-music-slash { - --fa: "\f8d1"; -} - -.fa-phone-rotary { - --fa: "\f8d3"; -} - -.fa-piano { - --fa: "\f8d4"; -} - -.fa-piano-keyboard { - --fa: "\f8d5"; -} - -.fa-projector { - --fa: "\f8d6"; -} - -.fa-radio { - --fa: "\f8d7"; -} - -.fa-radio-tuner { - --fa: "\f8d8"; -} - -.fa-radio-alt { - --fa: "\f8d8"; -} - -.fa-record-vinyl { - --fa: "\f8d9"; -} - -.fa-router { - --fa: "\f8da"; -} - -.fa-saxophone-fire { - --fa: "\f8db"; -} - -.fa-sax-hot { - --fa: "\f8db"; -} - -.fa-saxophone { - --fa: "\f8dc"; -} - -.fa-signal-stream { - --fa: "\f8dd"; -} - -.fa-skull-cow { - --fa: "\f8de"; -} - -.fa-speaker { - --fa: "\f8df"; -} - -.fa-speakers { - --fa: "\f8e0"; -} - -.fa-triangle-instrument { - --fa: "\f8e2"; -} - -.fa-triangle-music { - --fa: "\f8e2"; -} - -.fa-trumpet { - --fa: "\f8e3"; -} - -.fa-turntable { - --fa: "\f8e4"; -} - -.fa-tv-music { - --fa: "\f8e6"; -} - -.fa-typewriter { - --fa: "\f8e7"; -} - -.fa-usb-drive { - --fa: "\f8e9"; -} - -.fa-user-cowboy { - --fa: "\f8ea"; -} - -.fa-user-music { - --fa: "\f8eb"; -} - -.fa-cassette-vhs { - --fa: "\f8ec"; -} - -.fa-vhs { - --fa: "\f8ec"; -} - -.fa-violin { - --fa: "\f8ed"; -} - -.fa-wagon-covered { - --fa: "\f8ee"; -} - -.fa-walkie-talkie { - --fa: "\f8ef"; -} - -.fa-watch-calculator { - --fa: "\f8f0"; -} - -.fa-waveform { - --fa: "\f8f1"; -} - -.fa-waveform-lines { - --fa: "\f8f2"; -} - -.fa-waveform-path { - --fa: "\f8f2"; -} - -.fa-scanner-image { - --fa: "\f8f3"; -} - -.fa-air-conditioner { - --fa: "\f8f4"; -} - -.fa-alien { - --fa: "\f8f5"; -} - -.fa-alien-8bit { - --fa: "\f8f6"; -} - -.fa-alien-monster { - --fa: "\f8f6"; -} - -.fa-bed-front { - --fa: "\f8f7"; -} - -.fa-bed-alt { - --fa: "\f8f7"; -} - -.fa-bed-bunk { - --fa: "\f8f8"; -} - -.fa-bed-empty { - --fa: "\f8f9"; -} - -.fa-bell-on { - --fa: "\f8fa"; -} - -.fa-blinds { - --fa: "\f8fb"; -} - -.fa-blinds-open { - --fa: "\f8fc"; -} - -.fa-blinds-raised { - --fa: "\f8fd"; -} - -.fa-camera-security { - --fa: "\f8fe"; -} - -.fa-camera-home { - --fa: "\f8fe"; -} - -.fa-caravan { - --fa: "\f8ff"; -} -:root, :host { - --fa-family-brands: "Font Awesome 7 Brands"; - --fa-font-brands: normal 400 1em/1 var(--fa-family-brands); -} - -@font-face { - font-family: "Font Awesome 7 Brands"; - font-style: normal; - font-weight: 400; - font-display: block; - src: url("../webfonts/fa-brands-400.woff2"); -} -.fab, -.fa-brands, -.fa-classic.fa-brands { - --fa-family: var(--fa-family-brands); - --fa-style: 400; -} - -.fa-firefox-browser { - --fa: "\e007"; -} - -.fa-ideal { - --fa: "\e013"; -} - -.fa-microblog { - --fa: "\e01a"; -} - -.fa-square-pied-piper { - --fa: "\e01e"; -} - -.fa-pied-piper-square { - --fa: "\e01e"; -} - -.fa-unity { - --fa: "\e049"; -} - -.fa-dailymotion { - --fa: "\e052"; -} - -.fa-square-instagram { - --fa: "\e055"; -} - -.fa-instagram-square { - --fa: "\e055"; -} - -.fa-mixer { - --fa: "\e056"; -} - -.fa-shopify { - --fa: "\e057"; -} - -.fa-deezer { - --fa: "\e077"; -} - -.fa-edge-legacy { - --fa: "\e078"; -} - -.fa-google-pay { - --fa: "\e079"; -} - -.fa-rust { - --fa: "\e07a"; -} - -.fa-tiktok { - --fa: "\e07b"; -} - -.fa-unsplash { - --fa: "\e07c"; -} - -.fa-cloudflare { - --fa: "\e07d"; -} - -.fa-guilded { - --fa: "\e07e"; -} - -.fa-hive { - --fa: "\e07f"; -} - -.fa-42-group { - --fa: "\e080"; -} - -.fa-innosoft { - --fa: "\e080"; -} - -.fa-instalod { - --fa: "\e081"; -} - -.fa-octopus-deploy { - --fa: "\e082"; -} - -.fa-perbyte { - --fa: "\e083"; -} - -.fa-uncharted { - --fa: "\e084"; -} - -.fa-watchman-monitoring { - --fa: "\e087"; -} - -.fa-wodu { - --fa: "\e088"; -} - -.fa-wirsindhandwerk { - --fa: "\e2d0"; -} - -.fa-wsh { - --fa: "\e2d0"; -} - -.fa-bots { - --fa: "\e340"; -} - -.fa-cmplid { - --fa: "\e360"; -} - -.fa-bilibili { - --fa: "\e3d9"; -} - -.fa-golang { - --fa: "\e40f"; -} - -.fa-pix { - --fa: "\e43a"; -} - -.fa-sitrox { - --fa: "\e44a"; -} - -.fa-hashnode { - --fa: "\e499"; -} - -.fa-meta { - --fa: "\e49b"; -} - -.fa-padlet { - --fa: "\e4a0"; -} - -.fa-nfc-directional { - --fa: "\e530"; -} - -.fa-nfc-symbol { - --fa: "\e531"; -} - -.fa-screenpal { - --fa: "\e570"; -} - -.fa-space-awesome { - --fa: "\e5ac"; -} - -.fa-square-font-awesome { - --fa: "\e5ad"; -} - -.fa-square-gitlab { - --fa: "\e5ae"; -} - -.fa-gitlab-square { - --fa: "\e5ae"; -} - -.fa-odysee { - --fa: "\e5c6"; -} - -.fa-stubber { - --fa: "\e5c7"; -} - -.fa-debian { - --fa: "\e60b"; -} - -.fa-shoelace { - --fa: "\e60c"; -} - -.fa-threads { - --fa: "\e618"; -} - -.fa-square-threads { - --fa: "\e619"; -} - -.fa-square-x-twitter { - --fa: "\e61a"; -} - -.fa-x-twitter { - --fa: "\e61b"; -} - -.fa-opensuse { - --fa: "\e62b"; -} - -.fa-letterboxd { - --fa: "\e62d"; -} - -.fa-square-letterboxd { - --fa: "\e62e"; -} - -.fa-mintbit { - --fa: "\e62f"; -} - -.fa-google-scholar { - --fa: "\e63b"; -} - -.fa-brave { - --fa: "\e63c"; -} - -.fa-brave-reverse { - --fa: "\e63d"; -} - -.fa-pixiv { - --fa: "\e640"; -} - -.fa-upwork { - --fa: "\e641"; -} - -.fa-webflow { - --fa: "\e65c"; -} - -.fa-signal-messenger { - --fa: "\e663"; -} - -.fa-bluesky { - --fa: "\e671"; -} - -.fa-jxl { - --fa: "\e67b"; -} - -.fa-square-upwork { - --fa: "\e67c"; -} - -.fa-web-awesome { - --fa: "\e682"; -} - -.fa-square-web-awesome { - --fa: "\e683"; -} - -.fa-square-web-awesome-stroke { - --fa: "\e684"; -} - -.fa-dart-lang { - --fa: "\e693"; -} - -.fa-flutter { - --fa: "\e694"; -} - -.fa-files-pinwheel { - --fa: "\e69f"; -} - -.fa-css { - --fa: "\e6a2"; -} - -.fa-square-bluesky { - --fa: "\e6a3"; -} - -.fa-openai { - --fa: "\e7cf"; -} - -.fa-square-linkedin { - --fa: "\e7d0"; -} - -.fa-cash-app { - --fa: "\e7d4"; -} - -.fa-disqus { - --fa: "\e7d5"; -} - -.fa-eleventy { - --fa: "\e7d6"; -} - -.fa-11ty { - --fa: "\e7d6"; -} - -.fa-kakao-talk { - --fa: "\e7d7"; -} - -.fa-linktree { - --fa: "\e7d8"; -} - -.fa-notion { - --fa: "\e7d9"; -} - -.fa-pandora { - --fa: "\e7da"; -} - -.fa-pixelfed { - --fa: "\e7db"; -} - -.fa-tidal { - --fa: "\e7dc"; -} - -.fa-vsco { - --fa: "\e7dd"; -} - -.fa-w3c { - --fa: "\e7de"; -} - -.fa-lumon { - --fa: "\e7e2"; -} - -.fa-lumon-drop { - --fa: "\e7e3"; -} - -.fa-square-figma { - --fa: "\e7e4"; -} - -.fa-tex { - --fa: "\e7ff"; -} - -.fa-duolingo { - --fa: "\e812"; -} - -.fa-supportnow { - --fa: "\e833"; -} - -.fa-tor-browser { - --fa: "\e838"; -} - -.fa-square-twitter { - --fa: "\f081"; -} - -.fa-twitter-square { - --fa: "\f081"; -} - -.fa-square-facebook { - --fa: "\f082"; -} - -.fa-facebook-square { - --fa: "\f082"; -} - -.fa-linkedin { - --fa: "\f08c"; -} - -.fa-square-github { - --fa: "\f092"; -} - -.fa-github-square { - --fa: "\f092"; -} - -.fa-twitter { - --fa: "\f099"; -} - -.fa-facebook { - --fa: "\f09a"; -} - -.fa-github { - --fa: "\f09b"; -} - -.fa-pinterest { - --fa: "\f0d2"; -} - -.fa-square-pinterest { - --fa: "\f0d3"; -} - -.fa-pinterest-square { - --fa: "\f0d3"; -} - -.fa-square-google-plus { - --fa: "\f0d4"; -} - -.fa-google-plus-square { - --fa: "\f0d4"; -} - -.fa-google-plus-g { - --fa: "\f0d5"; -} - -.fa-linkedin-in { - --fa: "\f0e1"; -} - -.fa-github-alt { - --fa: "\f113"; -} - -.fa-maxcdn { - --fa: "\f136"; -} - -.fa-html5 { - --fa: "\f13b"; -} - -.fa-css3 { - --fa: "\f13c"; -} - -.fa-btc { - --fa: "\f15a"; -} - -.fa-youtube { - --fa: "\f167"; -} - -.fa-xing { - --fa: "\f168"; -} - -.fa-square-xing { - --fa: "\f169"; -} - -.fa-xing-square { - --fa: "\f169"; -} - -.fa-dropbox { - --fa: "\f16b"; -} - -.fa-stack-overflow { - --fa: "\f16c"; -} - -.fa-instagram { - --fa: "\f16d"; -} - -.fa-flickr { - --fa: "\f16e"; -} - -.fa-adn { - --fa: "\f170"; -} - -.fa-bitbucket { - --fa: "\f171"; -} - -.fa-tumblr { - --fa: "\f173"; -} - -.fa-square-tumblr { - --fa: "\f174"; -} - -.fa-tumblr-square { - --fa: "\f174"; -} - -.fa-apple { - --fa: "\f179"; -} - -.fa-windows { - --fa: "\f17a"; -} - -.fa-android { - --fa: "\f17b"; -} - -.fa-linux { - --fa: "\f17c"; -} - -.fa-dribbble { - --fa: "\f17d"; -} - -.fa-skype { - --fa: "\f17e"; -} - -.fa-foursquare { - --fa: "\f180"; -} - -.fa-trello { - --fa: "\f181"; -} - -.fa-gratipay { - --fa: "\f184"; -} - -.fa-vk { - --fa: "\f189"; -} - -.fa-weibo { - --fa: "\f18a"; -} - -.fa-renren { - --fa: "\f18b"; -} - -.fa-pagelines { - --fa: "\f18c"; -} - -.fa-stack-exchange { - --fa: "\f18d"; -} - -.fa-square-vimeo { - --fa: "\f194"; -} - -.fa-vimeo-square { - --fa: "\f194"; -} - -.fa-slack { - --fa: "\f198"; -} - -.fa-slack-hash { - --fa: "\f198"; -} - -.fa-wordpress { - --fa: "\f19a"; -} - -.fa-openid { - --fa: "\f19b"; -} - -.fa-yahoo { - --fa: "\f19e"; -} - -.fa-google { - --fa: "\f1a0"; -} - -.fa-reddit { - --fa: "\f1a1"; -} - -.fa-square-reddit { - --fa: "\f1a2"; -} - -.fa-reddit-square { - --fa: "\f1a2"; -} - -.fa-stumbleupon-circle { - --fa: "\f1a3"; -} - -.fa-stumbleupon { - --fa: "\f1a4"; -} - -.fa-delicious { - --fa: "\f1a5"; -} - -.fa-digg { - --fa: "\f1a6"; -} - -.fa-pied-piper-pp { - --fa: "\f1a7"; -} - -.fa-pied-piper-alt { - --fa: "\f1a8"; -} - -.fa-drupal { - --fa: "\f1a9"; -} - -.fa-joomla { - --fa: "\f1aa"; -} - -.fa-behance { - --fa: "\f1b4"; -} - -.fa-square-behance { - --fa: "\f1b5"; -} - -.fa-behance-square { - --fa: "\f1b5"; -} - -.fa-steam { - --fa: "\f1b6"; -} - -.fa-square-steam { - --fa: "\f1b7"; -} - -.fa-steam-square { - --fa: "\f1b7"; -} - -.fa-spotify { - --fa: "\f1bc"; -} - -.fa-deviantart { - --fa: "\f1bd"; -} - -.fa-soundcloud { - --fa: "\f1be"; -} - -.fa-vine { - --fa: "\f1ca"; -} - -.fa-codepen { - --fa: "\f1cb"; -} - -.fa-jsfiddle { - --fa: "\f1cc"; -} - -.fa-rebel { - --fa: "\f1d0"; -} - -.fa-empire { - --fa: "\f1d1"; -} - -.fa-square-git { - --fa: "\f1d2"; -} - -.fa-git-square { - --fa: "\f1d2"; -} - -.fa-git { - --fa: "\f1d3"; -} - -.fa-hacker-news { - --fa: "\f1d4"; -} - -.fa-tencent-weibo { - --fa: "\f1d5"; -} - -.fa-qq { - --fa: "\f1d6"; -} - -.fa-weixin { - --fa: "\f1d7"; -} - -.fa-slideshare { - --fa: "\f1e7"; -} - -.fa-twitch { - --fa: "\f1e8"; -} - -.fa-yelp { - --fa: "\f1e9"; -} - -.fa-paypal { - --fa: "\f1ed"; -} - -.fa-google-wallet { - --fa: "\f1ee"; -} - -.fa-cc-visa { - --fa: "\f1f0"; -} - -.fa-cc-mastercard { - --fa: "\f1f1"; -} - -.fa-cc-discover { - --fa: "\f1f2"; -} - -.fa-cc-amex { - --fa: "\f1f3"; -} - -.fa-cc-paypal { - --fa: "\f1f4"; -} - -.fa-cc-stripe { - --fa: "\f1f5"; -} - -.fa-lastfm { - --fa: "\f202"; -} - -.fa-square-lastfm { - --fa: "\f203"; -} - -.fa-lastfm-square { - --fa: "\f203"; -} - -.fa-ioxhost { - --fa: "\f208"; -} - -.fa-angellist { - --fa: "\f209"; -} - -.fa-buysellads { - --fa: "\f20d"; -} - -.fa-connectdevelop { - --fa: "\f20e"; -} - -.fa-dashcube { - --fa: "\f210"; -} - -.fa-forumbee { - --fa: "\f211"; -} - -.fa-leanpub { - --fa: "\f212"; -} - -.fa-sellsy { - --fa: "\f213"; -} - -.fa-shirtsinbulk { - --fa: "\f214"; -} - -.fa-simplybuilt { - --fa: "\f215"; -} - -.fa-skyatlas { - --fa: "\f216"; -} - -.fa-pinterest-p { - --fa: "\f231"; -} - -.fa-whatsapp { - --fa: "\f232"; -} - -.fa-viacoin { - --fa: "\f237"; -} - -.fa-medium { - --fa: "\f23a"; -} - -.fa-medium-m { - --fa: "\f23a"; -} - -.fa-y-combinator { - --fa: "\f23b"; -} - -.fa-optin-monster { - --fa: "\f23c"; -} - -.fa-opencart { - --fa: "\f23d"; -} - -.fa-expeditedssl { - --fa: "\f23e"; -} - -.fa-cc-jcb { - --fa: "\f24b"; -} - -.fa-cc-diners-club { - --fa: "\f24c"; -} - -.fa-creative-commons { - --fa: "\f25e"; -} - -.fa-gg { - --fa: "\f260"; -} - -.fa-gg-circle { - --fa: "\f261"; -} - -.fa-odnoklassniki { - --fa: "\f263"; -} - -.fa-square-odnoklassniki { - --fa: "\f264"; -} - -.fa-odnoklassniki-square { - --fa: "\f264"; -} - -.fa-get-pocket { - --fa: "\f265"; -} - -.fa-wikipedia-w { - --fa: "\f266"; -} - -.fa-safari { - --fa: "\f267"; -} - -.fa-chrome { - --fa: "\f268"; -} - -.fa-firefox { - --fa: "\f269"; -} - -.fa-opera { - --fa: "\f26a"; -} - -.fa-internet-explorer { - --fa: "\f26b"; -} - -.fa-contao { - --fa: "\f26d"; -} - -.fa-500px { - --fa: "\f26e"; -} - -.fa-amazon { - --fa: "\f270"; -} - -.fa-houzz { - --fa: "\f27c"; -} - -.fa-vimeo-v { - --fa: "\f27d"; -} - -.fa-black-tie { - --fa: "\f27e"; -} - -.fa-fonticons { - --fa: "\f280"; -} - -.fa-reddit-alien { - --fa: "\f281"; -} - -.fa-edge { - --fa: "\f282"; -} - -.fa-codiepie { - --fa: "\f284"; -} - -.fa-modx { - --fa: "\f285"; -} - -.fa-fort-awesome { - --fa: "\f286"; -} - -.fa-usb { - --fa: "\f287"; -} - -.fa-product-hunt { - --fa: "\f288"; -} - -.fa-mixcloud { - --fa: "\f289"; -} - -.fa-scribd { - --fa: "\f28a"; -} - -.fa-bluetooth { - --fa: "\f293"; -} - -.fa-bluetooth-b { - --fa: "\f294"; -} - -.fa-gitlab { - --fa: "\f296"; -} - -.fa-wpbeginner { - --fa: "\f297"; -} - -.fa-wpforms { - --fa: "\f298"; -} - -.fa-envira { - --fa: "\f299"; -} - -.fa-glide { - --fa: "\f2a5"; -} - -.fa-glide-g { - --fa: "\f2a6"; -} - -.fa-viadeo { - --fa: "\f2a9"; -} - -.fa-square-viadeo { - --fa: "\f2aa"; -} - -.fa-viadeo-square { - --fa: "\f2aa"; -} - -.fa-snapchat { - --fa: "\f2ab"; -} - -.fa-snapchat-ghost { - --fa: "\f2ab"; -} - -.fa-square-snapchat { - --fa: "\f2ad"; -} - -.fa-snapchat-square { - --fa: "\f2ad"; -} - -.fa-pied-piper { - --fa: "\f2ae"; -} - -.fa-first-order { - --fa: "\f2b0"; -} - -.fa-yoast { - --fa: "\f2b1"; -} - -.fa-themeisle { - --fa: "\f2b2"; -} - -.fa-google-plus { - --fa: "\f2b3"; -} - -.fa-font-awesome { - --fa: "\f2b4"; -} - -.fa-font-awesome-flag { - --fa: "\f2b4"; -} - -.fa-font-awesome-logo-full { - --fa: "\f2b4"; -} - -.fa-linode { - --fa: "\f2b8"; -} - -.fa-quora { - --fa: "\f2c4"; -} - -.fa-free-code-camp { - --fa: "\f2c5"; -} - -.fa-telegram { - --fa: "\f2c6"; -} - -.fa-telegram-plane { - --fa: "\f2c6"; -} - -.fa-bandcamp { - --fa: "\f2d5"; -} - -.fa-grav { - --fa: "\f2d6"; -} - -.fa-etsy { - --fa: "\f2d7"; -} - -.fa-imdb { - --fa: "\f2d8"; -} - -.fa-ravelry { - --fa: "\f2d9"; -} - -.fa-sellcast { - --fa: "\f2da"; -} - -.fa-superpowers { - --fa: "\f2dd"; -} - -.fa-wpexplorer { - --fa: "\f2de"; -} - -.fa-meetup { - --fa: "\f2e0"; -} - -.fa-square-font-awesome-stroke { - --fa: "\f35c"; -} - -.fa-font-awesome-alt { - --fa: "\f35c"; -} - -.fa-accessible-icon { - --fa: "\f368"; -} - -.fa-accusoft { - --fa: "\f369"; -} - -.fa-adversal { - --fa: "\f36a"; -} - -.fa-affiliatetheme { - --fa: "\f36b"; -} - -.fa-algolia { - --fa: "\f36c"; -} - -.fa-amilia { - --fa: "\f36d"; -} - -.fa-angrycreative { - --fa: "\f36e"; -} - -.fa-app-store { - --fa: "\f36f"; -} - -.fa-app-store-ios { - --fa: "\f370"; -} - -.fa-apper { - --fa: "\f371"; -} - -.fa-asymmetrik { - --fa: "\f372"; -} - -.fa-audible { - --fa: "\f373"; -} - -.fa-avianex { - --fa: "\f374"; -} - -.fa-aws { - --fa: "\f375"; -} - -.fa-bimobject { - --fa: "\f378"; -} - -.fa-bitcoin { - --fa: "\f379"; -} - -.fa-bity { - --fa: "\f37a"; -} - -.fa-blackberry { - --fa: "\f37b"; -} - -.fa-blogger { - --fa: "\f37c"; -} - -.fa-blogger-b { - --fa: "\f37d"; -} - -.fa-buromobelexperte { - --fa: "\f37f"; -} - -.fa-centercode { - --fa: "\f380"; -} - -.fa-cloudscale { - --fa: "\f383"; -} - -.fa-cloudsmith { - --fa: "\f384"; -} - -.fa-cloudversify { - --fa: "\f385"; -} - -.fa-cpanel { - --fa: "\f388"; -} - -.fa-css3-alt { - --fa: "\f38b"; -} - -.fa-cuttlefish { - --fa: "\f38c"; -} - -.fa-d-and-d { - --fa: "\f38d"; -} - -.fa-deploydog { - --fa: "\f38e"; -} - -.fa-deskpro { - --fa: "\f38f"; -} - -.fa-digital-ocean { - --fa: "\f391"; -} - -.fa-discord { - --fa: "\f392"; -} - -.fa-discourse { - --fa: "\f393"; -} - -.fa-dochub { - --fa: "\f394"; -} - -.fa-docker { - --fa: "\f395"; -} - -.fa-draft2digital { - --fa: "\f396"; -} - -.fa-square-dribbble { - --fa: "\f397"; -} - -.fa-dribbble-square { - --fa: "\f397"; -} - -.fa-dyalog { - --fa: "\f399"; -} - -.fa-earlybirds { - --fa: "\f39a"; -} - -.fa-erlang { - --fa: "\f39d"; -} - -.fa-facebook-f { - --fa: "\f39e"; -} - -.fa-facebook-messenger { - --fa: "\f39f"; -} - -.fa-firstdraft { - --fa: "\f3a1"; -} - -.fa-fonticons-fi { - --fa: "\f3a2"; -} - -.fa-fort-awesome-alt { - --fa: "\f3a3"; -} - -.fa-freebsd { - --fa: "\f3a4"; -} - -.fa-gitkraken { - --fa: "\f3a6"; -} - -.fa-gofore { - --fa: "\f3a7"; -} - -.fa-goodreads { - --fa: "\f3a8"; -} - -.fa-goodreads-g { - --fa: "\f3a9"; -} - -.fa-google-drive { - --fa: "\f3aa"; -} - -.fa-google-play { - --fa: "\f3ab"; -} - -.fa-gripfire { - --fa: "\f3ac"; -} - -.fa-grunt { - --fa: "\f3ad"; -} - -.fa-gulp { - --fa: "\f3ae"; -} - -.fa-square-hacker-news { - --fa: "\f3af"; -} - -.fa-hacker-news-square { - --fa: "\f3af"; -} - -.fa-hire-a-helper { - --fa: "\f3b0"; -} - -.fa-hotjar { - --fa: "\f3b1"; -} - -.fa-hubspot { - --fa: "\f3b2"; -} - -.fa-itunes { - --fa: "\f3b4"; -} - -.fa-itunes-note { - --fa: "\f3b5"; -} - -.fa-jenkins { - --fa: "\f3b6"; -} - -.fa-joget { - --fa: "\f3b7"; -} - -.fa-js { - --fa: "\f3b8"; -} - -.fa-square-js { - --fa: "\f3b9"; -} - -.fa-js-square { - --fa: "\f3b9"; -} - -.fa-keycdn { - --fa: "\f3ba"; -} - -.fa-kickstarter { - --fa: "\f3bb"; -} - -.fa-square-kickstarter { - --fa: "\f3bb"; -} - -.fa-kickstarter-k { - --fa: "\f3bc"; -} - -.fa-laravel { - --fa: "\f3bd"; -} - -.fa-line { - --fa: "\f3c0"; -} - -.fa-lyft { - --fa: "\f3c3"; -} - -.fa-magento { - --fa: "\f3c4"; -} - -.fa-medapps { - --fa: "\f3c6"; -} - -.fa-medrt { - --fa: "\f3c8"; -} - -.fa-microsoft { - --fa: "\f3ca"; -} - -.fa-mix { - --fa: "\f3cb"; -} - -.fa-mizuni { - --fa: "\f3cc"; -} - -.fa-monero { - --fa: "\f3d0"; -} - -.fa-napster { - --fa: "\f3d2"; -} - -.fa-node-js { - --fa: "\f3d3"; -} - -.fa-npm { - --fa: "\f3d4"; -} - -.fa-ns8 { - --fa: "\f3d5"; -} - -.fa-nutritionix { - --fa: "\f3d6"; -} - -.fa-page4 { - --fa: "\f3d7"; -} - -.fa-palfed { - --fa: "\f3d8"; -} - -.fa-patreon { - --fa: "\f3d9"; -} - -.fa-periscope { - --fa: "\f3da"; -} - -.fa-phabricator { - --fa: "\f3db"; -} - -.fa-phoenix-framework { - --fa: "\f3dc"; -} - -.fa-playstation { - --fa: "\f3df"; -} - -.fa-pushed { - --fa: "\f3e1"; -} - -.fa-python { - --fa: "\f3e2"; -} - -.fa-red-river { - --fa: "\f3e3"; -} - -.fa-wpressr { - --fa: "\f3e4"; -} - -.fa-rendact { - --fa: "\f3e4"; -} - -.fa-replyd { - --fa: "\f3e6"; -} - -.fa-resolving { - --fa: "\f3e7"; -} - -.fa-rocketchat { - --fa: "\f3e8"; -} - -.fa-rockrms { - --fa: "\f3e9"; -} - -.fa-schlix { - --fa: "\f3ea"; -} - -.fa-searchengin { - --fa: "\f3eb"; -} - -.fa-servicestack { - --fa: "\f3ec"; -} - -.fa-sistrix { - --fa: "\f3ee"; -} - -.fa-speakap { - --fa: "\f3f3"; -} - -.fa-staylinked { - --fa: "\f3f5"; -} - -.fa-steam-symbol { - --fa: "\f3f6"; -} - -.fa-sticker-mule { - --fa: "\f3f7"; -} - -.fa-studiovinari { - --fa: "\f3f8"; -} - -.fa-supple { - --fa: "\f3f9"; -} - -.fa-uber { - --fa: "\f402"; -} - -.fa-uikit { - --fa: "\f403"; -} - -.fa-uniregistry { - --fa: "\f404"; -} - -.fa-untappd { - --fa: "\f405"; -} - -.fa-ussunnah { - --fa: "\f407"; -} - -.fa-vaadin { - --fa: "\f408"; -} - -.fa-viber { - --fa: "\f409"; -} - -.fa-vimeo { - --fa: "\f40a"; -} - -.fa-vnv { - --fa: "\f40b"; -} - -.fa-square-whatsapp { - --fa: "\f40c"; -} - -.fa-whatsapp-square { - --fa: "\f40c"; -} - -.fa-whmcs { - --fa: "\f40d"; -} - -.fa-wordpress-simple { - --fa: "\f411"; -} - -.fa-xbox { - --fa: "\f412"; -} - -.fa-yandex { - --fa: "\f413"; -} - -.fa-yandex-international { - --fa: "\f414"; -} - -.fa-apple-pay { - --fa: "\f415"; -} - -.fa-cc-apple-pay { - --fa: "\f416"; -} - -.fa-fly { - --fa: "\f417"; -} - -.fa-node { - --fa: "\f419"; -} - -.fa-osi { - --fa: "\f41a"; -} - -.fa-react { - --fa: "\f41b"; -} - -.fa-autoprefixer { - --fa: "\f41c"; -} - -.fa-less { - --fa: "\f41d"; -} - -.fa-sass { - --fa: "\f41e"; -} - -.fa-vuejs { - --fa: "\f41f"; -} - -.fa-angular { - --fa: "\f420"; -} - -.fa-aviato { - --fa: "\f421"; -} - -.fa-ember { - --fa: "\f423"; -} - -.fa-gitter { - --fa: "\f426"; -} - -.fa-hooli { - --fa: "\f427"; -} - -.fa-strava { - --fa: "\f428"; -} - -.fa-stripe { - --fa: "\f429"; -} - -.fa-stripe-s { - --fa: "\f42a"; -} - -.fa-typo3 { - --fa: "\f42b"; -} - -.fa-amazon-pay { - --fa: "\f42c"; -} - -.fa-cc-amazon-pay { - --fa: "\f42d"; -} - -.fa-ethereum { - --fa: "\f42e"; -} - -.fa-korvue { - --fa: "\f42f"; -} - -.fa-elementor { - --fa: "\f430"; -} - -.fa-square-youtube { - --fa: "\f431"; -} - -.fa-youtube-square { - --fa: "\f431"; -} - -.fa-flipboard { - --fa: "\f44d"; -} - -.fa-hips { - --fa: "\f452"; -} - -.fa-php { - --fa: "\f457"; -} - -.fa-quinscape { - --fa: "\f459"; -} - -.fa-readme { - --fa: "\f4d5"; -} - -.fa-java { - --fa: "\f4e4"; -} - -.fa-pied-piper-hat { - --fa: "\f4e5"; -} - -.fa-creative-commons-by { - --fa: "\f4e7"; -} - -.fa-creative-commons-nc { - --fa: "\f4e8"; -} - -.fa-creative-commons-nc-eu { - --fa: "\f4e9"; -} - -.fa-creative-commons-nc-jp { - --fa: "\f4ea"; -} - -.fa-creative-commons-nd { - --fa: "\f4eb"; -} - -.fa-creative-commons-pd { - --fa: "\f4ec"; -} - -.fa-creative-commons-pd-alt { - --fa: "\f4ed"; -} - -.fa-creative-commons-remix { - --fa: "\f4ee"; -} - -.fa-creative-commons-sa { - --fa: "\f4ef"; -} - -.fa-creative-commons-sampling { - --fa: "\f4f0"; -} - -.fa-creative-commons-sampling-plus { - --fa: "\f4f1"; -} - -.fa-creative-commons-share { - --fa: "\f4f2"; -} - -.fa-creative-commons-zero { - --fa: "\f4f3"; -} - -.fa-ebay { - --fa: "\f4f4"; -} - -.fa-keybase { - --fa: "\f4f5"; -} - -.fa-mastodon { - --fa: "\f4f6"; -} - -.fa-r-project { - --fa: "\f4f7"; -} - -.fa-researchgate { - --fa: "\f4f8"; -} - -.fa-teamspeak { - --fa: "\f4f9"; -} - -.fa-first-order-alt { - --fa: "\f50a"; -} - -.fa-fulcrum { - --fa: "\f50b"; -} - -.fa-galactic-republic { - --fa: "\f50c"; -} - -.fa-galactic-senate { - --fa: "\f50d"; -} - -.fa-jedi-order { - --fa: "\f50e"; -} - -.fa-mandalorian { - --fa: "\f50f"; -} - -.fa-old-republic { - --fa: "\f510"; -} - -.fa-phoenix-squadron { - --fa: "\f511"; -} - -.fa-sith { - --fa: "\f512"; -} - -.fa-trade-federation { - --fa: "\f513"; -} - -.fa-wolf-pack-battalion { - --fa: "\f514"; -} - -.fa-hornbill { - --fa: "\f592"; -} - -.fa-mailchimp { - --fa: "\f59e"; -} - -.fa-megaport { - --fa: "\f5a3"; -} - -.fa-nimblr { - --fa: "\f5a8"; -} - -.fa-rev { - --fa: "\f5b2"; -} - -.fa-shopware { - --fa: "\f5b5"; -} - -.fa-squarespace { - --fa: "\f5be"; -} - -.fa-themeco { - --fa: "\f5c6"; -} - -.fa-weebly { - --fa: "\f5cc"; -} - -.fa-wix { - --fa: "\f5cf"; -} - -.fa-ello { - --fa: "\f5f1"; -} - -.fa-hackerrank { - --fa: "\f5f7"; -} - -.fa-kaggle { - --fa: "\f5fa"; -} - -.fa-markdown { - --fa: "\f60f"; -} - -.fa-neos { - --fa: "\f612"; -} - -.fa-zhihu { - --fa: "\f63f"; -} - -.fa-alipay { - --fa: "\f642"; -} - -.fa-the-red-yeti { - --fa: "\f69d"; -} - -.fa-critical-role { - --fa: "\f6c9"; -} - -.fa-d-and-d-beyond { - --fa: "\f6ca"; -} - -.fa-dev { - --fa: "\f6cc"; -} - -.fa-fantasy-flight-games { - --fa: "\f6dc"; -} - -.fa-wizards-of-the-coast { - --fa: "\f730"; -} - -.fa-think-peaks { - --fa: "\f731"; -} - -.fa-reacteurope { - --fa: "\f75d"; -} - -.fa-artstation { - --fa: "\f77a"; -} - -.fa-atlassian { - --fa: "\f77b"; -} - -.fa-canadian-maple-leaf { - --fa: "\f785"; -} - -.fa-centos { - --fa: "\f789"; -} - -.fa-confluence { - --fa: "\f78d"; -} - -.fa-dhl { - --fa: "\f790"; -} - -.fa-diaspora { - --fa: "\f791"; -} - -.fa-fedex { - --fa: "\f797"; -} - -.fa-fedora { - --fa: "\f798"; -} - -.fa-figma { - --fa: "\f799"; -} - -.fa-intercom { - --fa: "\f7af"; -} - -.fa-invision { - --fa: "\f7b0"; -} - -.fa-jira { - --fa: "\f7b1"; -} - -.fa-mendeley { - --fa: "\f7b3"; -} - -.fa-raspberry-pi { - --fa: "\f7bb"; -} - -.fa-redhat { - --fa: "\f7bc"; -} - -.fa-sketch { - --fa: "\f7c6"; -} - -.fa-sourcetree { - --fa: "\f7d3"; -} - -.fa-suse { - --fa: "\f7d6"; -} - -.fa-ubuntu { - --fa: "\f7df"; -} - -.fa-ups { - --fa: "\f7e0"; -} - -.fa-usps { - --fa: "\f7e1"; -} - -.fa-yarn { - --fa: "\f7e3"; -} - -.fa-airbnb { - --fa: "\f834"; -} - -.fa-battle-net { - --fa: "\f835"; -} - -.fa-bootstrap { - --fa: "\f836"; -} - -.fa-buffer { - --fa: "\f837"; -} - -.fa-chromecast { - --fa: "\f838"; -} - -.fa-evernote { - --fa: "\f839"; -} - -.fa-itch-io { - --fa: "\f83a"; -} - -.fa-salesforce { - --fa: "\f83b"; -} - -.fa-speaker-deck { - --fa: "\f83c"; -} - -.fa-symfony { - --fa: "\f83d"; -} - -.fa-waze { - --fa: "\f83f"; -} - -.fa-yammer { - --fa: "\f840"; -} - -.fa-git-alt { - --fa: "\f841"; -} - -.fa-stackpath { - --fa: "\f842"; -} - -.fa-cotton-bureau { - --fa: "\f89e"; -} - -.fa-buy-n-large { - --fa: "\f8a6"; -} - -.fa-mdb { - --fa: "\f8ca"; -} - -.fa-orcid { - --fa: "\f8d2"; -} - -.fa-swift { - --fa: "\f8e1"; -} - -.fa-umbraco { - --fa: "\f8e8"; -}:root, :host { - --fa-family-duotone: "Font Awesome 7 Duotone"; - --fa-font-duotone: normal 900 1em/1 var(--fa-family-duotone); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-duotone: var(--fa-family-duotone); -} - -@font-face { - font-family: "Font Awesome 7 Duotone"; - font-style: normal; - font-weight: 900; - font-display: block; - src: url("../webfonts/fa-duotone-900.woff2"); -} -.fad, -.fa-duotone { - --fa-family: var(--fa-family-duotone); - --fa-style: 900; - position: relative; - letter-spacing: normal; -} - -.fad::before, -.fa-duotone::before { - position: absolute; - color: var(--fa-primary-color, currentColor); - opacity: var(--fa-primary-opacity, 1); -} - -.fad::after, -.fa-duotone::after { - color: var(--fa-secondary-color, currentColor); - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .fad::before, -.fa-swap-opacity .fa-duotone::before, -.fad.fa-swap-opacity::before, -.fa-duotone.fa-swap-opacity::before { - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .fad::after, -.fa-swap-opacity .fa-duotone::after, -.fad.fa-swap-opacity::after, -.fa-duotone.fa-swap-opacity::after { - opacity: var(--fa-primary-opacity, 1); -} - -.fad.fa-li, -.fa-duotone.fa-li, -.fad.fa-stack-1x, -.fa-duotone.fa-stack-1x, -.fad.fa-stack-2x, -.fa-duotone.fa-stack-2x { - position: absolute; -}:root, :host { - --fa-family-classic: "Font Awesome 7 Pro"; - --fa-font-light: normal 300 1em/1 var(--fa-family-classic); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-classic: var(--fa-family-classic); -} - -@font-face { - font-family: "Font Awesome 7 Pro"; - font-style: normal; - font-weight: 300; - font-display: block; - src: url("../webfonts/fa-light-300.woff2"); -} -.fal { - --fa-family: var(--fa-family-classic); - --fa-style: 300; -} - -.fa-classic { - --fa-family: var(--fa-family-classic); -} - -.fa-light { - --fa-style: 300; -}:root, :host { - --fa-family-classic: "Font Awesome 7 Pro"; - --fa-font-regular: normal 400 1em/1 var(--fa-family-classic); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-classic: var(--fa-family-classic); -} - -@font-face { - font-family: "Font Awesome 7 Pro"; - font-style: normal; - font-weight: 400; - font-display: block; - src: url("../webfonts/fa-regular-400.woff2"); -} -.far { - --fa-family: var(--fa-family-classic); - --fa-style: 400; -} - -.fa-classic { - --fa-family: var(--fa-family-classic); -} - -.fa-regular { - --fa-style: 400; -}:root, :host { - --fa-family-classic: "Font Awesome 7 Pro"; - --fa-font-solid: normal 900 1em/1 var(--fa-family-classic); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-classic: var(--fa-family-classic); -} - -@font-face { - font-family: "Font Awesome 7 Pro"; - font-style: normal; - font-weight: 900; - font-display: block; - src: url("../webfonts/fa-solid-900.woff2"); -} -.fas { - --fa-family: var(--fa-family-classic); - --fa-style: 900; -} - -.fa-classic { - --fa-family: var(--fa-family-classic); -} - -.fa-solid { - --fa-style: 900; -}:root, :host { - --fa-family-classic: "Font Awesome 7 Pro"; - --fa-font-thin: normal 100 1em/1 var(--fa-family-classic); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-classic: var(--fa-family-classic); -} - -@font-face { - font-family: "Font Awesome 7 Pro"; - font-style: normal; - font-weight: 100; - font-display: block; - src: url("../webfonts/fa-thin-100.woff2"); -} -.fat { - --fa-family: var(--fa-family-classic); - --fa-style: 100; -} - -.fa-classic { - --fa-family: var(--fa-family-classic); -} - -.fa-thin { - --fa-style: 100; -}@font-face { - font-family: "Font Awesome 5 Brands"; - font-display: block; - font-weight: 400; - src: url("../webfonts/fa-brands-400.woff2") format("woff2"); -} -@font-face { - font-family: "Font Awesome 5 Pro"; - font-display: block; - font-weight: 900; - src: url("../webfonts/fa-solid-900.woff2") format("woff2"); -} -@font-face { - font-family: "Font Awesome 5 Pro"; - font-display: block; - font-weight: 400; - src: url("../webfonts/fa-regular-400.woff2") format("woff2"); -} -@font-face { - font-family: "Font Awesome 5 Pro"; - font-display: block; - font-weight: 300; - src: url("../webfonts/fa-light-300.woff2") format("woff2"); -} -@font-face { - font-family: "Font Awesome 5 Duotone"; - font-display: block; - font-weight: 900; - src: url("../webfonts/fa-duotone-900.woff2") format("woff2"); -}@font-face { - font-family: "FontAwesome"; - font-display: block; - src: url("../webfonts/fa-solid-900.woff2") format("woff2"); -} -@font-face { - font-family: "FontAwesome"; - font-display: block; - src: url("../webfonts/fa-brands-400.woff2") format("woff2"); -} -@font-face { - font-family: "FontAwesome"; - font-display: block; - src: url("../webfonts/fa-regular-400.woff2") format("woff2"); - unicode-range: U+F003, U+F006, U+F014, U+F016-F017, U+F01A-F01B, U+F01D, U+F022, U+F03E, U+F044, U+F046, U+F05C-F05D, U+F06E, U+F070, U+F087-F088, U+F08A, U+F094, U+F096-F097, U+F09D, U+F0A0, U+F0A2, U+F0A4-F0A7, U+F0C5, U+F0C7, U+F0E5-F0E6, U+F0EB, U+F0F6-F0F8, U+F10C, U+F114-F115, U+F118-F11A, U+F11C-F11D, U+F133, U+F147, U+F14E, U+F150-F152, U+F185-F186, U+F18E, U+F190-F192, U+F196, U+F1C1-F1C9, U+F1D9, U+F1DB, U+F1E3, U+F1EA, U+F1F7, U+F1F9, U+F20A, U+F247-F248, U+F24A, U+F24D, U+F255-F25B, U+F25D, U+F271-F274, U+F278, U+F27B, U+F28C, U+F28E, U+F29C, U+F2B5, U+F2B7, U+F2BA, U+F2BC, U+F2BE, U+F2C0-F2C1, U+F2C3, U+F2D0, U+F2D2, U+F2D4, U+F2DC; -} -@font-face { - font-family: "FontAwesome"; - font-display: block; - src: url("../webfonts/fa-v4compatibility.woff2") format("woff2"); - unicode-range: U+F041, U+F047, U+F065-F066, U+F07D-F07E, U+F080, U+F08B, U+F08E, U+F090, U+F09A, U+F0AC, U+F0AE, U+F0B2, U+F0D0, U+F0D6, U+F0E4, U+F0EC, U+F10A-F10B, U+F123, U+F13E, U+F148-F149, U+F14C, U+F156, U+F15E, U+F160-F161, U+F163, U+F175-F178, U+F195, U+F1F8, U+F219, U+F27A; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/all.min.css b/public/vendor/fontawesome/css/all.min.css deleted file mode 100644 index 4e9a621..0000000 --- a/public/vendor/fontawesome/css/all.min.css +++ /dev/null @@ -1,9 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -.fa,.fa-brands,.fa-chisel,.fa-classic,.fa-duotone,.fa-etch,.fa-jelly,.fa-jelly-duo,.fa-jelly-fill,.fa-light,.fa-notdog,.fa-notdog-duo,.fa-regular,.fa-semibold,.fa-sharp,.fa-sharp-duotone,.fa-slab,.fa-slab-press,.fa-solid,.fa-thin,.fa-thumbprint,.fa-utility,.fa-utility-duo,.fa-utility-fill,.fa-whiteboard,.fab,.facr,.fad,.fadl,.fadr,.fadt,.faes,.fajdr,.fajfr,.fajr,.fal,.fands,.fans,.far,.fas,.fasdl,.fasdr,.fasds,.fasdt,.fasl,.faslpr,.faslr,.fasr,.fass,.fast,.fat,.fatl,.faudsb,.faufsb,.fausb,.fawsb{--_fa-family:var(--fa-family,var(--fa-style-family,"Font Awesome 7 Pro"));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;display:var(--fa-display,inline-block);font-family:var(--_fa-family);font-feature-settings:normal;font-style:normal;font-synthesis:none;font-variant:normal;font-weight:var(--fa-style,900);line-height:1;text-align:center;text-rendering:auto;width:var(--fa-width,1.25em)}:is(.fas,.fass,.far,.fasr,.fal,.fasl,.fat,.fast,.fad,.fadr,.fadl,.fadt,.fasds,.fasdr,.fasdl,.fasdt,.fab,.faslr,.faslpr,.fawsb,.fatl,.fans,.fands,.faes,.fajr,.fajfr,.fajdr,.facr,.fausb,.faudsb,.faufsb,.fa-solid,.fa-semibold,.fa-regular,.fa-light,.fa-thin,.fa-brands,.fa-classic,.fa-duotone,.fa-sharp,.fa-sharp-duotone,.fa-chisel,.fa-etch,.fa-jelly,.fa-jelly-duo,.fa-jelly-fill,.fa-notdog,.fa-notdog-duo,.fa-slab,.fa-slab-press,.fa-thumbprint,.fa-utility,.fa-utility-duo,.fa-utility-fill,.fa-whiteboard,.fa):before{content:var(--fa)/""}@supports not (content:""/""){:is(.fas,.fass,.far,.fasr,.fal,.fasl,.fat,.fast,.fad,.fadr,.fadl,.fadt,.fasds,.fasdr,.fasdl,.fasdt,.fab,.faslr,.faslpr,.fawsb,.fatl,.fans,.fands,.faes,.fajr,.fajfr,.fajdr,.facr,.fausb,.faudsb,.faufsb,.fa-solid,.fa-semibold,.fa-regular,.fa-light,.fa-thin,.fa-brands,.fa-classic,.fa-duotone,.fa-sharp,.fa-sharp-duotone,.fa-chisel,.fa-etch,.fa-jelly,.fa-jelly-duo,.fa-jelly-fill,.fa-notdog,.fa-notdog-duo,.fa-slab,.fa-slab-press,.fa-thumbprint,.fa-utility,.fa-utility-duo,.fa-utility-fill,.fa-whiteboard,.fa):before{content:var(--fa)}}:is(.fad,.fa-duotone,.fadr,.fadl,.fadt,.fasds,.fa-sharp-duotone,.fasdr,.fasdl,.fasdt,.fatl,.fa-thumbprint,.fands,.fa-notdog-duo,.fajdr,.fa-jelly-duo,.faudsb,.fa-utility-duo):after{content:var(--fa);font-feature-settings:"ss01"}@supports not (content:""/""){:is(.fad,.fa-duotone,.fadr,.fadl,.fadt,.fasds,.fa-sharp-duotone,.fasdr,.fasdl,.fasdt,.fatl,.fa-thumbprint,.fands,.fa-notdog-duo,.fajdr,.fa-jelly-duo,.faudsb,.fa-utility-duo):after{content:var(--fa)}}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-2xs{font-size:.625em;line-height:.1em;vertical-align:.225em}.fa-xs{font-size:.75em;line-height:.08333em;vertical-align:.125em}.fa-sm{font-size:.875em;line-height:.07143em;vertical-align:.05357em}.fa-lg{font-size:1.25em;line-height:.05em;vertical-align:-.075em}.fa-xl{font-size:1.5em;line-height:.04167em;vertical-align:-.125em}.fa-2xl{font-size:2em;line-height:.03125em;vertical-align:-.1875em}.fa-width-auto{--fa-width:auto}.fa-fw,.fa-width-fixed{--fa-width:1.25em}.fa-ul{list-style-type:none;margin-inline-start:var(--fa-li-margin,2.5em);padding-inline-start:0}.fa-ul>li{position:relative}.fa-li{inset-inline-start:calc(var(--fa-li-width, 2em)*-1);position:absolute;text-align:center;width:var(--fa-li-width,2em);line-height:inherit}.fa-border{border-radius:var(--fa-border-radius,.1em);border:var(--fa-border-width,.0625em) var(--fa-border-style,solid) var(--fa-border-color,#eee);box-sizing:var(--fa-border-box-sizing,content-box);padding:var(--fa-border-padding,.1875em .25em)}.fa-pull-left,.fa-pull-start{float:inline-start;margin-inline-end:var(--fa-pull-margin,.3em)}.fa-pull-end,.fa-pull-right{float:inline-end;margin-inline-start:var(--fa-pull-margin,.3em)}.fa-beat{animation-name:fa-beat;animation-delay:var(--fa-animation-delay,0s);animation-direction:var(--fa-animation-direction,normal);animation-duration:var(--fa-animation-duration,1s);animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-bounce{animation-name:fa-bounce;animation-delay:var(--fa-animation-delay,0s);animation-direction:var(--fa-animation-direction,normal);animation-duration:var(--fa-animation-duration,1s);animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1))}.fa-fade{animation-name:fa-fade;animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-beat-fade,.fa-fade{animation-delay:var(--fa-animation-delay,0s);animation-direction:var(--fa-animation-direction,normal);animation-duration:var(--fa-animation-duration,1s)}.fa-beat-fade{animation-name:fa-beat-fade;animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-flip{animation-name:fa-flip;animation-delay:var(--fa-animation-delay,0s);animation-direction:var(--fa-animation-direction,normal);animation-duration:var(--fa-animation-duration,1s);animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-shake{animation-name:fa-shake;animation-duration:var(--fa-animation-duration,1s);animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,linear)}.fa-shake,.fa-spin{animation-delay:var(--fa-animation-delay,0s);animation-direction:var(--fa-animation-direction,normal)}.fa-spin{animation-name:fa-spin;animation-duration:var(--fa-animation-duration,2s);animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,linear)}.fa-spin-reverse{--fa-animation-direction:reverse}.fa-pulse,.fa-spin-pulse{animation-name:fa-spin;animation-direction:var(--fa-animation-direction,normal);animation-duration:var(--fa-animation-duration,1s);animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,steps(8))}@media (prefers-reduced-motion:reduce){.fa-beat,.fa-beat-fade,.fa-bounce,.fa-fade,.fa-flip,.fa-pulse,.fa-shake,.fa-spin,.fa-spin-pulse{animation:none!important;transition:none!important}}@keyframes fa-beat{0%,90%{transform:scale(1)}45%{transform:scale(var(--fa-beat-scale,1.25))}}@keyframes fa-bounce{0%{transform:scale(1) translateY(0)}10%{transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em))}64%{transform:scale(1) translateY(0)}to{transform:scale(1) translateY(0)}}@keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);transform:scale(1)}50%{opacity:1;transform:scale(var(--fa-beat-fade-scale,1.125))}}@keyframes fa-flip{50%{transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@keyframes fa-shake{0%{transform:rotate(-15deg)}4%{transform:rotate(15deg)}8%,24%{transform:rotate(-18deg)}12%,28%{transform:rotate(18deg)}16%{transform:rotate(-22deg)}20%{transform:rotate(22deg)}32%{transform:rotate(-12deg)}36%{transform:rotate(12deg)}40%,to{transform:rotate(0deg)}}@keyframes fa-spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}.fa-rotate-90{transform:rotate(90deg)}.fa-rotate-180{transform:rotate(180deg)}.fa-rotate-270{transform:rotate(270deg)}.fa-flip-horizontal{transform:scaleX(-1)}.fa-flip-vertical{transform:scaleY(-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{transform:scale(-1)}.fa-rotate-by{transform:rotate(var(--fa-rotate-angle,0))}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{--fa-width:100%;inset:0;position:absolute;text-align:center;width:var(--fa-width);z-index:var(--fa-stack-z-index,auto)}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:var(--fa-inverse,#fff)} - -.fa-0{--fa:"\30 "}.fa-1{--fa:"\31 "}.fa-2{--fa:"\32 "}.fa-3{--fa:"\33 "}.fa-4{--fa:"\34 "}.fa-5{--fa:"\35 "}.fa-6{--fa:"\36 "}.fa-7{--fa:"\37 "}.fa-8{--fa:"\38 "}.fa-9{--fa:"\39 "}.fa-exclamation{--fa:"\!"}.fa-ditto{--fa:'"'}.fa-hashtag{--fa:"\#"}.fa-dollar,.fa-dollar-sign,.fa-usd{--fa:"\$"}.fa-percent,.fa-percentage{--fa:"\%"}.fa-ampersand{--fa:"\&"}.fa-apostrophe{--fa:"'"}.fa-bracket-round,.fa-parenthesis{--fa:"\("}.fa-bracket-round-right{--fa:"\)"}.fa-asterisk{--fa:"\*"}.fa-add,.fa-plus{--fa:"\+"}.fa-comma{--fa:"\,"}.fa-hyphen{--fa:"\-"}.fa-period{--fa:"\."}.fa-slash-forward{--fa:"\/"}.fa-colon{--fa:"\:"}.fa-semicolon{--fa:"\;"}.fa-less-than{--fa:"\<"}.fa-equals{--fa:"\="}.fa-greater-than{--fa:"\>"}.fa-question{--fa:"\?"}.fa-at{--fa:"\@"}.fa-a{--fa:"A"}.fa-b{--fa:"B"}.fa-c{--fa:"C"}.fa-d{--fa:"D"}.fa-e{--fa:"E"}.fa-f{--fa:"F"}.fa-g{--fa:"G"}.fa-h{--fa:"H"}.fa-i{--fa:"I"}.fa-j{--fa:"J"}.fa-k{--fa:"K"}.fa-l{--fa:"L"}.fa-m{--fa:"M"}.fa-n{--fa:"N"}.fa-o{--fa:"O"}.fa-p{--fa:"P"}.fa-q{--fa:"Q"}.fa-r{--fa:"R"}.fa-s{--fa:"S"}.fa-t{--fa:"T"}.fa-u{--fa:"U"}.fa-v{--fa:"V"}.fa-w{--fa:"W"}.fa-x{--fa:"X"}.fa-y{--fa:"Y"}.fa-z{--fa:"Z"}.fa-bracket,.fa-bracket-left,.fa-bracket-square{--fa:"\["}.fa-slash-back{--fa:"\\"}.fa-bracket-square-right{--fa:"\]"}.fa-accent-grave{--fa:"\`"}.fa-bracket-curly,.fa-bracket-curly-left{--fa:"\{"}.fa-pipe{--fa:"\|"}.fa-bracket-curly-right{--fa:"\}"}.fa-tilde{--fa:"\~"}.fa-caravan-alt,.fa-caravan-simple{--fa:"\e000"}.fa-cat-space{--fa:"\e001"}.fa-coffee-pot{--fa:"\e002"}.fa-comet{--fa:"\e003"}.fa-fan-table{--fa:"\e004"}.fa-faucet{--fa:"\e005"}.fa-faucet-drip{--fa:"\e006"}.fa-galaxy{--fa:"\e008"}.fa-garage{--fa:"\e009"}.fa-garage-car{--fa:"\e00a"}.fa-garage-open{--fa:"\e00b"}.fa-heat{--fa:"\e00c"}.fa-house-chimney-window{--fa:"\e00d"}.fa-house-day{--fa:"\e00e"}.fa-house-leave,.fa-house-person-depart,.fa-house-person-leave{--fa:"\e00f"}.fa-house-night{--fa:"\e010"}.fa-house-person-arrive,.fa-house-person-return,.fa-house-return{--fa:"\e011"}.fa-house-signal{--fa:"\e012"}.fa-lamp-desk{--fa:"\e014"}.fa-lamp-floor{--fa:"\e015"}.fa-light-ceiling{--fa:"\e016"}.fa-light-switch{--fa:"\e017"}.fa-light-switch-off{--fa:"\e018"}.fa-light-switch-on{--fa:"\e019"}.fa-microwave{--fa:"\e01b"}.fa-outlet{--fa:"\e01c"}.fa-oven{--fa:"\e01d"}.fa-planet-moon{--fa:"\e01f"}.fa-planet-ringed{--fa:"\e020"}.fa-police-box{--fa:"\e021"}.fa-person-to-portal,.fa-portal-enter{--fa:"\e022"}.fa-person-from-portal,.fa-portal-exit{--fa:"\e023"}.fa-radar{--fa:"\e024"}.fa-raygun{--fa:"\e025"}.fa-refrigerator{--fa:"\e026"}.fa-rocket-launch{--fa:"\e027"}.fa-sensor{--fa:"\e028"}.fa-sensor-alert,.fa-sensor-triangle-exclamation{--fa:"\e029"}.fa-sensor-fire{--fa:"\e02a"}.fa-sensor-on{--fa:"\e02b"}.fa-sensor-cloud,.fa-sensor-smoke{--fa:"\e02c"}.fa-siren{--fa:"\e02d"}.fa-siren-on{--fa:"\e02e"}.fa-solar-system{--fa:"\e02f"}.fa-circle-sort,.fa-sort-circle{--fa:"\e030"}.fa-circle-sort-down,.fa-sort-circle-down{--fa:"\e031"}.fa-circle-sort-up,.fa-sort-circle-up{--fa:"\e032"}.fa-space-station-moon{--fa:"\e033"}.fa-space-station-moon-alt,.fa-space-station-moon-construction{--fa:"\e034"}.fa-sprinkler{--fa:"\e035"}.fa-star-shooting{--fa:"\e036"}.fa-starfighter{--fa:"\e037"}.fa-starfighter-alt,.fa-starfighter-twin-ion-engine{--fa:"\e038"}.fa-starship{--fa:"\e039"}.fa-starship-freighter{--fa:"\e03a"}.fa-sword-laser{--fa:"\e03b"}.fa-sword-laser-alt{--fa:"\e03c"}.fa-swords-laser{--fa:"\e03d"}.fa-telescope{--fa:"\e03e"}.fa-temperature-arrow-down,.fa-temperature-down{--fa:"\e03f"}.fa-temperature-arrow-up,.fa-temperature-up{--fa:"\e040"}.fa-trailer{--fa:"\e041"}.fa-transporter{--fa:"\e042"}.fa-transporter-1{--fa:"\e043"}.fa-transporter-2{--fa:"\e044"}.fa-transporter-3{--fa:"\e045"}.fa-transporter-empty{--fa:"\e046"}.fa-ufo{--fa:"\e047"}.fa-ufo-beam{--fa:"\e048"}.fa-user-alien{--fa:"\e04a"}.fa-user-robot{--fa:"\e04b"}.fa-user-visor{--fa:"\e04c"}.fa-vacuum{--fa:"\e04d"}.fa-vacuum-robot{--fa:"\e04e"}.fa-window-frame{--fa:"\e04f"}.fa-window-frame-open{--fa:"\e050"}.fa-coffin-cross{--fa:"\e051"}.fa-folder-arrow-down,.fa-folder-download{--fa:"\e053"}.fa-folder-arrow-up,.fa-folder-upload{--fa:"\e054"}.fa-user-unlock{--fa:"\e058"}.fa-bacteria{--fa:"\e059"}.fa-bacterium{--fa:"\e05a"}.fa-box-tissue{--fa:"\e05b"}.fa-hand-holding-medical{--fa:"\e05c"}.fa-hand-sparkles{--fa:"\e05d"}.fa-hands-bubbles,.fa-hands-wash{--fa:"\e05e"}.fa-handshake-alt-slash,.fa-handshake-simple-slash,.fa-handshake-slash{--fa:"\e060"}.fa-head-side-cough{--fa:"\e061"}.fa-head-side-cough-slash{--fa:"\e062"}.fa-head-side-mask{--fa:"\e063"}.fa-head-side-virus{--fa:"\e064"}.fa-house-chimney-user{--fa:"\e065"}.fa-house-laptop,.fa-laptop-house{--fa:"\e066"}.fa-lungs-virus{--fa:"\e067"}.fa-people-arrows,.fa-people-arrows-left-right{--fa:"\e068"}.fa-plane-slash{--fa:"\e069"}.fa-pump-medical{--fa:"\e06a"}.fa-pump-soap{--fa:"\e06b"}.fa-shield-virus{--fa:"\e06c"}.fa-sink{--fa:"\e06d"}.fa-soap{--fa:"\e06e"}.fa-stopwatch-20{--fa:"\e06f"}.fa-shop-slash,.fa-store-alt-slash{--fa:"\e070"}.fa-store-slash{--fa:"\e071"}.fa-toilet-paper-slash{--fa:"\e072"}.fa-users-slash{--fa:"\e073"}.fa-virus{--fa:"\e074"}.fa-virus-slash{--fa:"\e075"}.fa-viruses{--fa:"\e076"}.fa-vest{--fa:"\e085"}.fa-vest-patches{--fa:"\e086"}.fa-airplay{--fa:"\e089"}.fa-alt{--fa:"\e08a"}.fa-angle{--fa:"\e08c"}.fa-angle-90{--fa:"\e08d"}.fa-apple-core{--fa:"\e08f"}.fa-arrow-down-from-dotted-line{--fa:"\e090"}.fa-arrow-down-left{--fa:"\e091"}.fa-arrow-down-left-and-arrow-up-right-to-center{--fa:"\e092"}.fa-arrow-down-right{--fa:"\e093"}.fa-arrow-down-to-bracket{--fa:"\e094"}.fa-arrow-down-to-dotted-line{--fa:"\e095"}.fa-arrow-down-to-square{--fa:"\e096"}.fa-arrow-trend-down{--fa:"\e097"}.fa-arrow-trend-up{--fa:"\e098"}.fa-arrow-up-arrow-down,.fa-sort-up-down{--fa:"\e099"}.fa-arrow-up-from-bracket{--fa:"\e09a"}.fa-arrow-up-from-dotted-line{--fa:"\e09b"}.fa-arrow-up-from-square{--fa:"\e09c"}.fa-arrow-up-left{--fa:"\e09d"}.fa-arrow-up-left-from-circle{--fa:"\e09e"}.fa-arrow-up-right{--fa:"\e09f"}.fa-arrow-up-right-and-arrow-down-left-from-center{--fa:"\e0a0"}.fa-arrow-up-to-dotted-line{--fa:"\e0a1"}.fa-arrows-cross{--fa:"\e0a2"}.fa-arrows-from-dotted-line{--fa:"\e0a3"}.fa-arrows-from-line{--fa:"\e0a4"}.fa-arrows-minimize,.fa-compress-arrows{--fa:"\e0a5"}.fa-arrows-to-dotted-line{--fa:"\e0a6"}.fa-arrows-to-line{--fa:"\e0a7"}.fa-audio-description-slash{--fa:"\e0a8"}.fa-austral-sign{--fa:"\e0a9"}.fa-avocado{--fa:"\e0aa"}.fa-award-simple{--fa:"\e0ab"}.fa-baht-sign{--fa:"\e0ac"}.fa-bars-filter{--fa:"\e0ad"}.fa-bars-sort{--fa:"\e0ae"}.fa-basket-shopping-simple,.fa-shopping-basket-alt{--fa:"\e0af"}.fa-battery-exclamation{--fa:"\e0b0"}.fa-battery-1,.fa-battery-low{--fa:"\e0b1"}.fa-bee{--fa:"\e0b2"}.fa-beer-foam,.fa-beer-mug{--fa:"\e0b3"}.fa-bitcoin-sign{--fa:"\e0b4"}.fa-block-quote{--fa:"\e0b5"}.fa-bolt-auto{--fa:"\e0b6"}.fa-bolt-lightning{--fa:"\e0b7"}.fa-bolt-slash{--fa:"\e0b8"}.fa-book-arrow-right{--fa:"\e0b9"}.fa-book-arrow-up{--fa:"\e0ba"}.fa-book-bookmark{--fa:"\e0bb"}.fa-book-circle-arrow-right{--fa:"\e0bc"}.fa-book-circle-arrow-up{--fa:"\e0bd"}.fa-book-copy{--fa:"\e0be"}.fa-book-font{--fa:"\e0bf"}.fa-book-open-alt,.fa-book-open-cover{--fa:"\e0c0"}.fa-book-law,.fa-book-section{--fa:"\e0c1"}.fa-bookmark-slash{--fa:"\e0c2"}.fa-bowling-ball-pin{--fa:"\e0c3"}.fa-box-circle-check{--fa:"\e0c4"}.fa-brackets-round,.fa-parentheses{--fa:"\e0c5"}.fa-brain-circuit{--fa:"\e0c6"}.fa-brake-warning{--fa:"\e0c7"}.fa-briefcase-blank{--fa:"\e0c8"}.fa-brightness{--fa:"\e0c9"}.fa-brightness-low{--fa:"\e0ca"}.fa-browsers{--fa:"\e0cb"}.fa-buildings{--fa:"\e0cc"}.fa-burger-fries{--fa:"\e0cd"}.fa-burger-glass{--fa:"\e0ce"}.fa-calendar-arrow-down,.fa-calendar-download{--fa:"\e0d0"}.fa-calendar-arrow-up,.fa-calendar-upload{--fa:"\e0d1"}.fa-calendar-clock,.fa-calendar-time{--fa:"\e0d2"}.fa-calendar-heart{--fa:"\e0d3"}.fa-calendar-image{--fa:"\e0d4"}.fa-calendar-lines,.fa-calendar-note{--fa:"\e0d5"}.fa-calendar-range{--fa:"\e0d6"}.fa-calendars{--fa:"\e0d7"}.fa-camera-rotate{--fa:"\e0d8"}.fa-camera-slash{--fa:"\e0d9"}.fa-camera-viewfinder,.fa-screenshot{--fa:"\e0da"}.fa-cart-minus{--fa:"\e0db"}.fa-cart-shopping-fast{--fa:"\e0dc"}.fa-cart-xmark{--fa:"\e0dd"}.fa-castle{--fa:"\e0de"}.fa-cedi-sign{--fa:"\e0df"}.fa-chart-bullet{--fa:"\e0e1"}.fa-chart-candlestick{--fa:"\e0e2"}.fa-chart-column{--fa:"\e0e3"}.fa-chart-gantt{--fa:"\e0e4"}.fa-chart-line-up{--fa:"\e0e5"}.fa-chart-pyramid{--fa:"\e0e6"}.fa-chart-radar{--fa:"\e0e7"}.fa-chart-scatter-3d{--fa:"\e0e8"}.fa-chart-scatter-bubble{--fa:"\e0e9"}.fa-chart-tree-map{--fa:"\e0ea"}.fa-chart-waterfall{--fa:"\e0eb"}.fa-cherries{--fa:"\e0ec"}.fa-circle-0{--fa:"\e0ed"}.fa-circle-1{--fa:"\e0ee"}.fa-circle-2{--fa:"\e0ef"}.fa-circle-3{--fa:"\e0f0"}.fa-circle-4{--fa:"\e0f1"}.fa-circle-5{--fa:"\e0f2"}.fa-circle-6{--fa:"\e0f3"}.fa-circle-7{--fa:"\e0f4"}.fa-circle-8{--fa:"\e0f5"}.fa-circle-9{--fa:"\e0f6"}.fa-circle-a{--fa:"\e0f7"}.fa-circle-ampersand{--fa:"\e0f8"}.fa-circle-arrow-down-left{--fa:"\e0f9"}.fa-circle-arrow-down-right{--fa:"\e0fa"}.fa-circle-arrow-up-left{--fa:"\e0fb"}.fa-circle-arrow-up-right{--fa:"\e0fc"}.fa-circle-b{--fa:"\e0fd"}.fa-circle-bolt{--fa:"\e0fe"}.fa-book-circle,.fa-circle-book-open{--fa:"\e0ff"}.fa-bookmark-circle,.fa-circle-bookmark{--fa:"\e100"}.fa-circle-c{--fa:"\e101"}.fa-calendar-circle,.fa-circle-calendar{--fa:"\e102"}.fa-camera-circle,.fa-circle-camera{--fa:"\e103"}.fa-circle-d{--fa:"\e104"}.fa-circle-dashed{--fa:"\e105"}.fa-circle-divide{--fa:"\e106"}.fa-circle-down-left{--fa:"\e107"}.fa-circle-down-right{--fa:"\e108"}.fa-circle-e{--fa:"\e109"}.fa-circle-ellipsis{--fa:"\e10a"}.fa-circle-ellipsis-vertical{--fa:"\e10b"}.fa-circle-envelope,.fa-envelope-circle{--fa:"\e10c"}.fa-circle-exclamation-check{--fa:"\e10d"}.fa-circle-f{--fa:"\e10e"}.fa-circle-g{--fa:"\e10f"}.fa-circle-half{--fa:"\e110"}.fa-circle-i{--fa:"\e111"}.fa-circle-j{--fa:"\e112"}.fa-circle-k{--fa:"\e113"}.fa-circle-l{--fa:"\e114"}.fa-circle-m{--fa:"\e115"}.fa-circle-microphone,.fa-microphone-circle{--fa:"\e116"}.fa-circle-microphone-lines,.fa-microphone-circle-alt{--fa:"\e117"}.fa-circle-n{--fa:"\e118"}.fa-circle-o{--fa:"\e119"}.fa-circle-p{--fa:"\e11a"}.fa-circle-phone,.fa-phone-circle{--fa:"\e11b"}.fa-circle-phone-flip,.fa-phone-circle-alt{--fa:"\e11c"}.fa-circle-phone-hangup,.fa-phone-circle-down{--fa:"\e11d"}.fa-circle-q{--fa:"\e11e"}.fa-circle-quarter{--fa:"\e11f"}.fa-circle-r{--fa:"\e120"}.fa-circle-s{--fa:"\e121"}.fa-circle-small{--fa:"\e122"}.fa-circle-star,.fa-star-circle{--fa:"\e123"}.fa-circle-t{--fa:"\e124"}.fa-circle-three-quarters{--fa:"\e125"}.fa-circle-trash,.fa-trash-circle{--fa:"\e126"}.fa-circle-u{--fa:"\e127"}.fa-circle-up-left{--fa:"\e128"}.fa-circle-up-right{--fa:"\e129"}.fa-circle-v{--fa:"\e12a"}.fa-circle-video,.fa-video-circle{--fa:"\e12b"}.fa-circle-w{--fa:"\e12c"}.fa-circle-waveform-lines,.fa-waveform-circle{--fa:"\e12d"}.fa-circle-x{--fa:"\e12e"}.fa-circle-y{--fa:"\e12f"}.fa-circle-z{--fa:"\e130"}.fa-clapperboard{--fa:"\e131"}.fa-clapperboard-play{--fa:"\e132"}.fa-clipboard-medical{--fa:"\e133"}.fa-clock-desk{--fa:"\e134"}.fa-closed-captioning-slash{--fa:"\e135"}.fa-clothes-hanger{--fa:"\e136"}.fa-cloud-slash{--fa:"\e137"}.fa-cloud-word{--fa:"\e138"}.fa-clover{--fa:"\e139"}.fa-code-compare{--fa:"\e13a"}.fa-code-fork{--fa:"\e13b"}.fa-code-pull-request{--fa:"\e13c"}.fa-code-simple{--fa:"\e13d"}.fa-coffee-bean{--fa:"\e13e"}.fa-coffee-beans{--fa:"\e13f"}.fa-colon-sign{--fa:"\e140"}.fa-command{--fa:"\e142"}.fa-comment-arrow-down{--fa:"\e143"}.fa-comment-arrow-up{--fa:"\e144"}.fa-comment-arrow-up-right{--fa:"\e145"}.fa-comment-captions{--fa:"\e146"}.fa-comment-code{--fa:"\e147"}.fa-comment-image{--fa:"\e148"}.fa-comment-middle{--fa:"\e149"}.fa-comment-middle-top{--fa:"\e14a"}.fa-comment-question{--fa:"\e14b"}.fa-comment-quote{--fa:"\e14c"}.fa-comment-text{--fa:"\e14d"}.fa-comments-question{--fa:"\e14e"}.fa-comments-question-check{--fa:"\e14f"}.fa-conveyor-belt-empty{--fa:"\e150"}.fa-crate-empty{--fa:"\e151"}.fa-cruzeiro-sign{--fa:"\e152"}.fa-delete-right{--fa:"\e154"}.fa-desktop-arrow-down{--fa:"\e155"}.fa-diagram-lean-canvas{--fa:"\e156"}.fa-diagram-nested{--fa:"\e157"}.fa-diagram-sankey{--fa:"\e158"}.fa-diagram-venn{--fa:"\e15a"}.fa-dial,.fa-dial-med-high{--fa:"\e15b"}.fa-dial-high{--fa:"\e15c"}.fa-dial-low{--fa:"\e15d"}.fa-dial-max{--fa:"\e15e"}.fa-dial-med{--fa:"\e15f"}.fa-dial-med-low{--fa:"\e160"}.fa-dial-min{--fa:"\e161"}.fa-dial-off{--fa:"\e162"}.fa-display{--fa:"\e163"}.fa-display-arrow-down{--fa:"\e164"}.fa-desktop-code,.fa-display-code{--fa:"\e165"}.fa-desktop-medical,.fa-display-medical{--fa:"\e166"}.fa-dolphin{--fa:"\e168"}.fa-dong-sign{--fa:"\e169"}.fa-down-left{--fa:"\e16a"}.fa-down-right{--fa:"\e16b"}.fa-eggplant{--fa:"\e16c"}.fa-elevator{--fa:"\e16d"}.fa-engine{--fa:"\e16e"}.fa-envelope-badge,.fa-envelope-dot{--fa:"\e16f"}.fa-envelopes{--fa:"\e170"}.fa-escalator{--fa:"\e171"}.fa-eye-dropper-full{--fa:"\e172"}.fa-eye-dropper-half{--fa:"\e173"}.fa-ferris-wheel{--fa:"\e174"}.fa-file-binary{--fa:"\e175"}.fa-file-heart{--fa:"\e176"}.fa-file-plus-minus{--fa:"\e177"}.fa-files{--fa:"\e178"}.fa-film-slash{--fa:"\e179"}.fa-films{--fa:"\e17a"}.fa-filter-circle-xmark{--fa:"\e17b"}.fa-filter-list{--fa:"\e17c"}.fa-filter-slash{--fa:"\e17d"}.fa-filters{--fa:"\e17e"}.fa-fire-hydrant{--fa:"\e17f"}.fa-floppy-disk-circle-arrow-right,.fa-save-circle-arrow-right{--fa:"\e180"}.fa-floppy-disk-circle-xmark,.fa-floppy-disk-times,.fa-save-circle-xmark,.fa-save-times{--fa:"\e181"}.fa-floppy-disk-pen{--fa:"\e182"}.fa-floppy-disks{--fa:"\e183"}.fa-florin-sign{--fa:"\e184"}.fa-folder-closed{--fa:"\e185"}.fa-folder-bookmark{--fa:"\e186"}.fa-folder-cog,.fa-folder-gear{--fa:"\e187"}.fa-folder-grid{--fa:"\e188"}.fa-folder-heart{--fa:"\e189"}.fa-folder-image{--fa:"\e18a"}.fa-folder-magnifying-glass,.fa-folder-search{--fa:"\e18b"}.fa-folder-medical{--fa:"\e18c"}.fa-folder-music{--fa:"\e18d"}.fa-folder-user{--fa:"\e18e"}.fa-franc-sign{--fa:"\e18f"}.fa-gif{--fa:"\e190"}.fa-glass-empty{--fa:"\e191"}.fa-glass-half,.fa-glass-half-empty,.fa-glass-half-full{--fa:"\e192"}.fa-grate{--fa:"\e193"}.fa-grate-droplet{--fa:"\e194"}.fa-grid,.fa-grid-3{--fa:"\e195"}.fa-grid-2{--fa:"\e196"}.fa-grid-2-plus{--fa:"\e197"}.fa-grid-4{--fa:"\e198"}.fa-grid-5{--fa:"\e199"}.fa-guarani-sign{--fa:"\e19a"}.fa-gun{--fa:"\e19b"}.fa-gun-slash{--fa:"\e19c"}.fa-gun-squirt{--fa:"\e19d"}.fa-hand-back-point-down{--fa:"\e19e"}.fa-hand-back-point-left{--fa:"\e19f"}.fa-hand-back-point-ribbon{--fa:"\e1a0"}.fa-hand-back-point-right{--fa:"\e1a1"}.fa-hand-back-point-up{--fa:"\e1a2"}.fa-hand-fingers-crossed{--fa:"\e1a3"}.fa-hand-holding-skull{--fa:"\e1a4"}.fa-hand-love{--fa:"\e1a5"}.fa-hand-point-ribbon{--fa:"\e1a6"}.fa-hand-wave{--fa:"\e1a7"}.fa-hands-clapping{--fa:"\e1a8"}.fa-hand-horns{--fa:"\e1a9"}.fa-head-side-heart{--fa:"\e1aa"}.fa-heart-half{--fa:"\e1ab"}.fa-heart-half-alt,.fa-heart-half-stroke{--fa:"\e1ac"}.fa-hexagon-divide{--fa:"\e1ad"}.fa-high-definition,.fa-rectangle-hd{--fa:"\e1ae"}.fa-highlighter-line{--fa:"\e1af"}.fa-home-user,.fa-house-user{--fa:"\e1b0"}.fa-house-building{--fa:"\e1b1"}.fa-house-chimney-heart{--fa:"\e1b2"}.fa-house-tree{--fa:"\e1b3"}.fa-house-turret{--fa:"\e1b4"}.fa-image-landscape,.fa-landscape{--fa:"\e1b5"}.fa-image-polaroid-user{--fa:"\e1b6"}.fa-image-slash{--fa:"\e1b7"}.fa-image-user{--fa:"\e1b8"}.fa-images-user{--fa:"\e1b9"}.fa-inbox-full{--fa:"\e1ba"}.fa-inboxes{--fa:"\e1bb"}.fa-indian-rupee,.fa-indian-rupee-sign,.fa-inr{--fa:"\e1bc"}.fa-input-numeric{--fa:"\e1bd"}.fa-input-pipe{--fa:"\e1be"}.fa-input-text{--fa:"\e1bf"}.fa-keyboard-brightness{--fa:"\e1c0"}.fa-keyboard-brightness-low{--fa:"\e1c1"}.fa-keyboard-down{--fa:"\e1c2"}.fa-keyboard-left{--fa:"\e1c3"}.fa-kip-sign{--fa:"\e1c4"}.fa-lamp-street{--fa:"\e1c5"}.fa-laptop-arrow-down{--fa:"\e1c6"}.fa-laptop-slash{--fa:"\e1c7"}.fa-lari-sign{--fa:"\e1c8"}.fa-lasso-sparkles{--fa:"\e1c9"}.fa-lightbulb-exclamation-on{--fa:"\e1ca"}.fa-chain-horizontal,.fa-link-horizontal{--fa:"\e1cb"}.fa-chain-horizontal-slash,.fa-link-horizontal-slash{--fa:"\e1cc"}.fa-link-simple{--fa:"\e1cd"}.fa-link-simple-slash{--fa:"\e1ce"}.fa-list-dropdown{--fa:"\e1cf"}.fa-list-radio{--fa:"\e1d0"}.fa-list-timeline{--fa:"\e1d1"}.fa-list-tree{--fa:"\e1d2"}.fa-litecoin-sign{--fa:"\e1d3"}.fa-loader{--fa:"\e1d4"}.fa-manat-sign{--fa:"\e1d5"}.fa-manhole{--fa:"\e1d6"}.fa-mask-face{--fa:"\e1d7"}.fa-memo{--fa:"\e1d8"}.fa-memo-circle-check{--fa:"\e1d9"}.fa-memo-pad{--fa:"\e1da"}.fa-comment-alt-arrow-down,.fa-message-arrow-down{--fa:"\e1db"}.fa-comment-alt-arrow-up,.fa-message-arrow-up{--fa:"\e1dc"}.fa-message-arrow-up-right{--fa:"\e1dd"}.fa-comment-alt-captions,.fa-message-captions{--fa:"\e1de"}.fa-message-code{--fa:"\e1df"}.fa-comment-alt-image,.fa-message-image{--fa:"\e1e0"}.fa-comment-middle-alt,.fa-message-middle{--fa:"\e1e1"}.fa-comment-middle-top-alt,.fa-message-middle-top{--fa:"\e1e2"}.fa-message-question{--fa:"\e1e3"}.fa-comment-alt-quote,.fa-message-quote{--fa:"\e1e4"}.fa-message-sms{--fa:"\e1e5"}.fa-comment-alt-text,.fa-message-text{--fa:"\e1e6"}.fa-messages-question{--fa:"\e1e7"}.fa-meter{--fa:"\e1e8"}.fa-meter-bolt{--fa:"\e1e9"}.fa-meter-droplet{--fa:"\e1ea"}.fa-meter-fire{--fa:"\e1eb"}.fa-microchip-ai{--fa:"\e1ec"}.fa-mill-sign{--fa:"\e1ed"}.fa-mobile-iphone,.fa-mobile-notch{--fa:"\e1ee"}.fa-mobile-signal{--fa:"\e1ef"}.fa-mobile-signal-out{--fa:"\e1f0"}.fa-money-bill-simple{--fa:"\e1f1"}.fa-money-bill-simple-wave{--fa:"\e1f2"}.fa-money-bills{--fa:"\e1f3"}.fa-money-bills-alt,.fa-money-bills-simple{--fa:"\e1f4"}.fa-mug-tea-saucer{--fa:"\e1f5"}.fa-naira-sign{--fa:"\e1f6"}.fa-nfc{--fa:"\e1f7"}.fa-nfc-lock{--fa:"\e1f8"}.fa-nfc-magnifying-glass{--fa:"\e1f9"}.fa-nfc-pen{--fa:"\e1fa"}.fa-nfc-signal{--fa:"\e1fb"}.fa-nfc-slash{--fa:"\e1fc"}.fa-nfc-trash{--fa:"\e1fd"}.fa-notdef{--fa:"\e1fe"}.fa-note{--fa:"\e1ff"}.fa-note-medical{--fa:"\e200"}.fa-notebook{--fa:"\e201"}.fa-notes{--fa:"\e202"}.fa-octagon-divide{--fa:"\e203"}.fa-octagon-exclamation{--fa:"\e204"}.fa-oil-can-drip{--fa:"\e205"}.fa-paintbrush-pencil{--fa:"\e206"}.fa-pallet-box{--fa:"\e208"}.fa-panorama{--fa:"\e209"}.fa-paper-plane-alt,.fa-paper-plane-top,.fa-send{--fa:"\e20a"}.fa-peach{--fa:"\e20b"}.fa-pear{--fa:"\e20c"}.fa-pedestal{--fa:"\e20d"}.fa-pen-circle{--fa:"\e20e"}.fa-pen-alt-slash,.fa-pen-clip-slash{--fa:"\e20f"}.fa-pen-fancy-slash{--fa:"\e210"}.fa-pen-field{--fa:"\e211"}.fa-pen-line{--fa:"\e212"}.fa-pen-slash{--fa:"\e213"}.fa-pen-swirl{--fa:"\e214"}.fa-pencil-slash{--fa:"\e215"}.fa-people{--fa:"\e216"}.fa-people-dress{--fa:"\e217"}.fa-people-dress-simple{--fa:"\e218"}.fa-people-pants{--fa:"\e219"}.fa-people-pants-simple{--fa:"\e21a"}.fa-people-simple{--fa:"\e21b"}.fa-person-dress-simple{--fa:"\e21c"}.fa-person-pinball{--fa:"\e21d"}.fa-person-seat{--fa:"\e21e"}.fa-person-seat-reclined{--fa:"\e21f"}.fa-person-simple{--fa:"\e220"}.fa-peseta-sign{--fa:"\e221"}.fa-peso-sign{--fa:"\e222"}.fa-phone-arrow-down,.fa-phone-arrow-down-left,.fa-phone-incoming{--fa:"\e223"}.fa-phone-arrow-up,.fa-phone-arrow-up-right,.fa-phone-outgoing{--fa:"\e224"}.fa-phone-hangup{--fa:"\e225"}.fa-phone-missed{--fa:"\e226"}.fa-phone-xmark{--fa:"\e227"}.fa-photo-film-music{--fa:"\e228"}.fa-pinball{--fa:"\e229"}.fa-plane-prop{--fa:"\e22b"}.fa-plane-tail{--fa:"\e22c"}.fa-plane-up{--fa:"\e22d"}.fa-plane-up-slash{--fa:"\e22e"}.fa-play-pause{--fa:"\e22f"}.fa-puzzle-piece-alt,.fa-puzzle-piece-simple{--fa:"\e231"}.fa-quotes{--fa:"\e234"}.fa-pro,.fa-rectangle-pro{--fa:"\e235"}.fa-rectangle-terminal{--fa:"\e236"}.fa-rectangle-vertical-history{--fa:"\e237"}.fa-reel{--fa:"\e238"}.fa-reply-clock,.fa-reply-time{--fa:"\e239"}.fa-restroom-simple{--fa:"\e23a"}.fa-rhombus{--fa:"\e23b"}.fa-rotate-exclamation{--fa:"\e23c"}.fa-rupiah-sign{--fa:"\e23d"}.fa-screencast{--fa:"\e23e"}.fa-scribble{--fa:"\e23f"}.fa-sd-cards{--fa:"\e240"}.fa-seal{--fa:"\e241"}.fa-seal-exclamation{--fa:"\e242"}.fa-seal-question{--fa:"\e243"}.fa-seat-airline{--fa:"\e244"}.fa-shelves-empty{--fa:"\e246"}.fa-shield-exclamation{--fa:"\e247"}.fa-shield-keyhole{--fa:"\e248"}.fa-shield-minus{--fa:"\e249"}.fa-shield-plus{--fa:"\e24a"}.fa-shield-slash{--fa:"\e24b"}.fa-shield-times,.fa-shield-xmark{--fa:"\e24c"}.fa-shower-alt,.fa-shower-down{--fa:"\e24d"}.fa-sidebar{--fa:"\e24e"}.fa-sidebar-flip{--fa:"\e24f"}.fa-signal-stream-slash{--fa:"\e250"}.fa-sim-cards{--fa:"\e251"}.fa-slider{--fa:"\e252"}.fa-sliders-simple{--fa:"\e253"}.fa-split{--fa:"\e254"}.fa-square-0{--fa:"\e255"}.fa-square-1{--fa:"\e256"}.fa-square-2{--fa:"\e257"}.fa-square-3{--fa:"\e258"}.fa-square-4{--fa:"\e259"}.fa-square-5{--fa:"\e25a"}.fa-square-6{--fa:"\e25b"}.fa-square-7{--fa:"\e25c"}.fa-square-8{--fa:"\e25d"}.fa-square-9{--fa:"\e25e"}.fa-square-a{--fa:"\e25f"}.fa-square-ampersand{--fa:"\e260"}.fa-square-arrow-down-left{--fa:"\e261"}.fa-square-arrow-down-right{--fa:"\e262"}.fa-square-arrow-up-left{--fa:"\e263"}.fa-square-b{--fa:"\e264"}.fa-square-bolt{--fa:"\e265"}.fa-square-c{--fa:"\e266"}.fa-square-code{--fa:"\e267"}.fa-square-d{--fa:"\e268"}.fa-square-dashed{--fa:"\e269"}.fa-square-divide{--fa:"\e26a"}.fa-square-down-left{--fa:"\e26b"}.fa-square-down-right{--fa:"\e26c"}.fa-square-e{--fa:"\e26d"}.fa-square-ellipsis{--fa:"\e26e"}.fa-square-ellipsis-vertical{--fa:"\e26f"}.fa-square-f{--fa:"\e270"}.fa-square-g{--fa:"\e271"}.fa-square-i{--fa:"\e272"}.fa-square-j{--fa:"\e273"}.fa-square-k{--fa:"\e274"}.fa-square-l{--fa:"\e275"}.fa-square-m{--fa:"\e276"}.fa-square-n{--fa:"\e277"}.fa-square-o{--fa:"\e278"}.fa-square-p{--fa:"\e279"}.fa-phone-square-down,.fa-square-phone-hangup{--fa:"\e27a"}.fa-square-q{--fa:"\e27b"}.fa-square-r{--fa:"\e27c"}.fa-square-s{--fa:"\e27d"}.fa-square-small{--fa:"\e27e"}.fa-square-star{--fa:"\e27f"}.fa-square-t{--fa:"\e280"}.fa-square-u{--fa:"\e281"}.fa-square-up-left{--fa:"\e282"}.fa-square-user{--fa:"\e283"}.fa-square-v{--fa:"\e284"}.fa-square-w{--fa:"\e285"}.fa-square-x{--fa:"\e286"}.fa-square-y{--fa:"\e287"}.fa-square-z{--fa:"\e288"}.fa-stairs{--fa:"\e289"}.fa-rectangle-sd,.fa-standard-definition{--fa:"\e28a"}.fa-star-sharp{--fa:"\e28b"}.fa-star-sharp-half{--fa:"\e28c"}.fa-star-sharp-half-alt,.fa-star-sharp-half-stroke{--fa:"\e28d"}.fa-starfighter-alt-advanced,.fa-starfighter-twin-ion-engine-advanced{--fa:"\e28e"}.fa-sun-alt,.fa-sun-bright{--fa:"\e28f"}.fa-table-layout{--fa:"\e290"}.fa-table-pivot{--fa:"\e291"}.fa-rows,.fa-table-rows{--fa:"\e292"}.fa-table-tree{--fa:"\e293"}.fa-tally-1{--fa:"\e294"}.fa-tally-2{--fa:"\e295"}.fa-tally-3{--fa:"\e296"}.fa-tally-4{--fa:"\e297"}.fa-taxi-bus{--fa:"\e298"}.fa-temperature-list{--fa:"\e299"}.fa-ticket-airline,.fa-ticket-perforated-plane,.fa-ticket-plane{--fa:"\e29a"}.fa-tickets-airline,.fa-tickets-perforated-plane,.fa-tickets-plane{--fa:"\e29b"}.fa-timeline{--fa:"\e29c"}.fa-timeline-arrow{--fa:"\e29d"}.fa-timer{--fa:"\e29e"}.fa-toilet-paper-blank-under,.fa-toilet-paper-reverse,.fa-toilet-paper-reverse-alt,.fa-toilet-paper-under{--fa:"\e2a0"}.fa-toilet-paper-reverse-slash,.fa-toilet-paper-under-slash{--fa:"\e2a1"}.fa-tower-control{--fa:"\e2a2"}.fa-subway-tunnel,.fa-train-subway-tunnel{--fa:"\e2a3"}.fa-transformer-bolt{--fa:"\e2a4"}.fa-transporter-4{--fa:"\e2a5"}.fa-transporter-5{--fa:"\e2a6"}.fa-transporter-6{--fa:"\e2a7"}.fa-transporter-7{--fa:"\e2a8"}.fa-trash-can-check{--fa:"\e2a9"}.fa-trash-can-clock{--fa:"\e2aa"}.fa-trash-can-list{--fa:"\e2ab"}.fa-trash-can-plus{--fa:"\e2ac"}.fa-trash-alt-slash,.fa-trash-can-slash{--fa:"\e2ad"}.fa-trash-can-xmark{--fa:"\e2ae"}.fa-trash-check{--fa:"\e2af"}.fa-trash-clock{--fa:"\e2b0"}.fa-trash-list{--fa:"\e2b1"}.fa-trash-plus{--fa:"\e2b2"}.fa-trash-slash{--fa:"\e2b3"}.fa-trash-xmark{--fa:"\e2b4"}.fa-truck-container-empty{--fa:"\e2b5"}.fa-truck-flatbed{--fa:"\e2b6"}.fa-truck-front{--fa:"\e2b7"}.fa-truck-tow{--fa:"\e2b8"}.fa-teletype-answer,.fa-tty-answer{--fa:"\e2b9"}.fa-tugrik-sign{--fa:"\e2ba"}.fa-try,.fa-turkish-lira,.fa-turkish-lira-sign{--fa:"\e2bb"}.fa-umbrella-alt,.fa-umbrella-simple{--fa:"\e2bc"}.fa-up-left{--fa:"\e2bd"}.fa-up-right{--fa:"\e2be"}.fa-user-bounty-hunter{--fa:"\e2bf"}.fa-user-pilot{--fa:"\e2c0"}.fa-user-pilot-tie{--fa:"\e2c1"}.fa-user-shakespeare{--fa:"\e2c2"}.fa-utility-pole{--fa:"\e2c3"}.fa-utility-pole-double{--fa:"\e2c4"}.fa-vault{--fa:"\e2c5"}.fa-video-arrow-down-left{--fa:"\e2c8"}.fa-video-arrow-up-right{--fa:"\e2c9"}.fa-magic-wand-sparkles,.fa-wand-magic-sparkles{--fa:"\e2ca"}.fa-watch-apple{--fa:"\e2cb"}.fa-watch-smart{--fa:"\e2cc"}.fa-wheat-alt,.fa-wheat-awn{--fa:"\e2cd"}.fa-wheelchair-alt,.fa-wheelchair-move{--fa:"\e2ce"}.fa-wifi-exclamation{--fa:"\e2cf"}.fa-wrench-simple{--fa:"\e2d1"}.fa-robot-astromech{--fa:"\e2d2"}.fa-360-degrees{--fa:"\e2dc"}.fa-aperture{--fa:"\e2df"}.fa-arrow-turn-down-left{--fa:"\e2e1"}.fa-balloon{--fa:"\e2e3"}.fa-balloons{--fa:"\e2e4"}.fa-banana{--fa:"\e2e5"}.fa-bangladeshi-taka-sign{--fa:"\e2e6"}.fa-bench-tree{--fa:"\e2e7"}.fa-blueberries{--fa:"\e2e8"}.fa-bowl-chopsticks{--fa:"\e2e9"}.fa-bowl-chopsticks-noodles{--fa:"\e2ea"}.fa-bowl-rice{--fa:"\e2eb"}.fa-briefcase-arrow-right{--fa:"\e2f2"}.fa-citrus{--fa:"\e2f4"}.fa-citrus-slice{--fa:"\e2f5"}.fa-coconut{--fa:"\e2f6"}.fa-desktop-slash,.fa-display-slash{--fa:"\e2fa"}.fa-exploding-head,.fa-face-explode{--fa:"\e2fe"}.fa-face-viewfinder{--fa:"\e2ff"}.fa-family{--fa:"\e300"}.fa-family-dress{--fa:"\e301"}.fa-family-pants{--fa:"\e302"}.fa-fence{--fa:"\e303"}.fa-fish-bones{--fa:"\e304"}.fa-grapes{--fa:"\e306"}.fa-kiwi-fruit{--fa:"\e30c"}.fa-mango{--fa:"\e30f"}.fa-melon{--fa:"\e310"}.fa-melon-slice{--fa:"\e311"}.fa-money-from-bracket{--fa:"\e312"}.fa-money-simple-from-bracket{--fa:"\e313"}.fa-olive{--fa:"\e316"}.fa-olive-branch{--fa:"\e317"}.fa-option{--fa:"\e318"}.fa-party-bell{--fa:"\e31a"}.fa-party-horn{--fa:"\e31b"}.fa-peapod{--fa:"\e31c"}.fa-person-pregnant{--fa:"\e31e"}.fa-pineapple{--fa:"\e31f"}.fa-rectangle-code{--fa:"\e322"}.fa-rectangles-mixed{--fa:"\e323"}.fa-roller-coaster{--fa:"\e324"}.fa-square-quote{--fa:"\e329"}.fa-square-terminal{--fa:"\e32a"}.fa-strawberry{--fa:"\e32b"}.fa-table-picnic{--fa:"\e32d"}.fa-thought-bubble{--fa:"\e32e"}.fa-tick{--fa:"\e32f"}.fa-tomato{--fa:"\e330"}.fa-turn-down-left{--fa:"\e331"}.fa-user-police{--fa:"\e333"}.fa-user-police-tie{--fa:"\e334"}.fa-watermelon-slice{--fa:"\e337"}.fa-wheat-awn-slash{--fa:"\e338"}.fa-wheat-slash{--fa:"\e339"}.fa-badminton{--fa:"\e33a"}.fa-binary{--fa:"\e33b"}.fa-binary-circle-check{--fa:"\e33c"}.fa-binary-lock{--fa:"\e33d"}.fa-binary-slash{--fa:"\e33e"}.fa-boot-heeled{--fa:"\e33f"}.fa-car-bolt{--fa:"\e341"}.fa-car-circle-bolt{--fa:"\e342"}.fa-car-mirrors{--fa:"\e343"}.fa-car-side-bolt{--fa:"\e344"}.fa-clock-eight{--fa:"\e345"}.fa-clock-eight-thirty{--fa:"\e346"}.fa-clock-eleven{--fa:"\e347"}.fa-clock-eleven-thirty{--fa:"\e348"}.fa-clock-five{--fa:"\e349"}.fa-clock-five-thirty{--fa:"\e34a"}.fa-clock-four-thirty{--fa:"\e34b"}.fa-clock-nine{--fa:"\e34c"}.fa-clock-nine-thirty{--fa:"\e34d"}.fa-clock-one{--fa:"\e34e"}.fa-clock-one-thirty{--fa:"\e34f"}.fa-clock-seven{--fa:"\e350"}.fa-clock-seven-thirty{--fa:"\e351"}.fa-clock-six{--fa:"\e352"}.fa-clock-six-thirty{--fa:"\e353"}.fa-clock-ten{--fa:"\e354"}.fa-clock-ten-thirty{--fa:"\e355"}.fa-clock-three{--fa:"\e356"}.fa-clock-three-thirty{--fa:"\e357"}.fa-clock-twelve{--fa:"\e358"}.fa-clock-twelve-thirty{--fa:"\e359"}.fa-clock-two{--fa:"\e35a"}.fa-clock-two-thirty{--fa:"\e35b"}.fa-cloud-check{--fa:"\e35c"}.fa-cloud-minus{--fa:"\e35d"}.fa-cloud-plus{--fa:"\e35e"}.fa-cloud-xmark{--fa:"\e35f"}.fa-columns-3{--fa:"\e361"}.fa-crystal-ball{--fa:"\e362"}.fa-cup-straw{--fa:"\e363"}.fa-cup-straw-swoosh{--fa:"\e364"}.fa-distribute-spacing-horizontal{--fa:"\e365"}.fa-distribute-spacing-vertical{--fa:"\e366"}.fa-eyes{--fa:"\e367"}.fa-face-angry-horns{--fa:"\e368"}.fa-face-anguished{--fa:"\e369"}.fa-face-anxious-sweat{--fa:"\e36a"}.fa-face-astonished{--fa:"\e36b"}.fa-face-confounded{--fa:"\e36c"}.fa-face-confused{--fa:"\e36d"}.fa-face-cowboy-hat{--fa:"\e36e"}.fa-face-disappointed{--fa:"\e36f"}.fa-face-disguise{--fa:"\e370"}.fa-face-downcast-sweat{--fa:"\e371"}.fa-face-drooling{--fa:"\e372"}.fa-face-expressionless{--fa:"\e373"}.fa-face-eyes-xmarks{--fa:"\e374"}.fa-face-fearful{--fa:"\e375"}.fa-face-frown-slight{--fa:"\e376"}.fa-face-glasses{--fa:"\e377"}.fa-face-hand-over-mouth{--fa:"\e378"}.fa-face-hand-yawn{--fa:"\e379"}.fa-face-head-bandage{--fa:"\e37a"}.fa-face-hushed{--fa:"\e37b"}.fa-face-icicles{--fa:"\e37c"}.fa-face-kiss-closed-eyes{--fa:"\e37d"}.fa-face-lying{--fa:"\e37e"}.fa-face-mask{--fa:"\e37f"}.fa-face-monocle{--fa:"\e380"}.fa-face-nauseated{--fa:"\e381"}.fa-face-nose-steam{--fa:"\e382"}.fa-face-party{--fa:"\e383"}.fa-face-pensive{--fa:"\e384"}.fa-face-persevering{--fa:"\e385"}.fa-face-pleading{--fa:"\e386"}.fa-face-pouting{--fa:"\e387"}.fa-face-raised-eyebrow{--fa:"\e388"}.fa-face-relieved{--fa:"\e389"}.fa-face-sad-sweat{--fa:"\e38a"}.fa-face-scream{--fa:"\e38b"}.fa-face-shush{--fa:"\e38c"}.fa-face-sleeping{--fa:"\e38d"}.fa-face-sleepy{--fa:"\e38e"}.fa-face-smile-halo{--fa:"\e38f"}.fa-face-smile-hearts{--fa:"\e390"}.fa-face-smile-horns{--fa:"\e391"}.fa-face-smile-relaxed{--fa:"\e392"}.fa-face-smile-tear{--fa:"\e393"}.fa-face-smile-tongue{--fa:"\e394"}.fa-face-smile-upside-down{--fa:"\e395"}.fa-face-smiling-hands{--fa:"\e396"}.fa-face-smirking{--fa:"\e397"}.fa-face-sunglasses{--fa:"\e398"}.fa-face-swear{--fa:"\e399"}.fa-face-thermometer{--fa:"\e39a"}.fa-face-thinking{--fa:"\e39b"}.fa-face-tissue{--fa:"\e39c"}.fa-face-tongue-money{--fa:"\e39d"}.fa-face-tongue-sweat{--fa:"\e39e"}.fa-face-unamused{--fa:"\e39f"}.fa-face-vomit{--fa:"\e3a0"}.fa-face-weary{--fa:"\e3a1"}.fa-face-woozy{--fa:"\e3a2"}.fa-face-worried{--fa:"\e3a3"}.fa-face-zany{--fa:"\e3a4"}.fa-face-zipper{--fa:"\e3a5"}.fa-file-lock{--fa:"\e3a6"}.fa-file-slash{--fa:"\e3a7"}.fa-fishing-rod{--fa:"\e3a8"}.fa-flying-disc{--fa:"\e3a9"}.fa-gallery-thumbnails{--fa:"\e3aa"}.fa-goal-net{--fa:"\e3ab"}.fa-golf-flag-hole{--fa:"\e3ac"}.fa-grid-dividers{--fa:"\e3ad"}.fa-hockey-stick-puck{--fa:"\e3ae"}.fa-home-lg,.fa-house-chimney{--fa:"\e3af"}.fa-house-chimney-blank{--fa:"\e3b0"}.fa-house-crack{--fa:"\e3b1"}.fa-house-medical{--fa:"\e3b2"}.fa-house-window{--fa:"\e3b3"}.fa-key-skeleton-left-right{--fa:"\e3b4"}.fa-lacrosse-stick{--fa:"\e3b5"}.fa-lacrosse-stick-ball{--fa:"\e3b6"}.fa-mask-snorkel{--fa:"\e3b7"}.fa-message-bot{--fa:"\e3b8"}.fa-moped{--fa:"\e3b9"}.fa-nesting-dolls{--fa:"\e3ba"}.fa-objects-align-bottom{--fa:"\e3bb"}.fa-objects-align-center-horizontal{--fa:"\e3bc"}.fa-objects-align-center-vertical{--fa:"\e3bd"}.fa-objects-align-left{--fa:"\e3be"}.fa-objects-align-right{--fa:"\e3bf"}.fa-objects-align-top{--fa:"\e3c0"}.fa-objects-column{--fa:"\e3c1"}.fa-paperclip-vertical{--fa:"\e3c2"}.fa-pinata{--fa:"\e3c3"}.fa-pipe-smoking{--fa:"\e3c4"}.fa-pool-8-ball{--fa:"\e3c5"}.fa-rugby-ball{--fa:"\e3c6"}.fa-shirt-long-sleeve{--fa:"\e3c7"}.fa-shirt-running{--fa:"\e3c8"}.fa-shirt-tank-top{--fa:"\e3c9"}.fa-signature-lock{--fa:"\e3ca"}.fa-signature-slash{--fa:"\e3cb"}.fa-ski-boot{--fa:"\e3cc"}.fa-ski-boot-ski{--fa:"\e3cd"}.fa-slot-machine{--fa:"\e3ce"}.fa-teddy-bear{--fa:"\e3cf"}.fa-truck-bolt{--fa:"\e3d0"}.fa-uniform-martial-arts{--fa:"\e3d1"}.fa-user-chef{--fa:"\e3d2"}.fa-user-hair-buns{--fa:"\e3d3"}.fa-arrow-left-long-to-line{--fa:"\e3d4"}.fa-arrow-right-long-to-line{--fa:"\e3d5"}.fa-arrow-turn-down-right{--fa:"\e3d6"}.fa-bagel{--fa:"\e3d7"}.fa-baguette{--fa:"\e3d8"}.fa-blanket-fire{--fa:"\e3da"}.fa-block-brick,.fa-wall-brick{--fa:"\e3db"}.fa-block-brick-fire,.fa-firewall{--fa:"\e3dc"}.fa-block-question{--fa:"\e3dd"}.fa-bowl-scoop,.fa-bowl-shaved-ice{--fa:"\e3de"}.fa-bowl-scoops{--fa:"\e3df"}.fa-bowl-spoon{--fa:"\e3e0"}.fa-bread-slice-butter{--fa:"\e3e1"}.fa-broccoli{--fa:"\e3e2"}.fa-burger-lettuce{--fa:"\e3e3"}.fa-butter{--fa:"\e3e4"}.fa-cake-slice,.fa-shortcake{--fa:"\e3e5"}.fa-can-food{--fa:"\e3e6"}.fa-candy{--fa:"\e3e7"}.fa-candy-bar,.fa-chocolate-bar{--fa:"\e3e8"}.fa-card-club{--fa:"\e3e9"}.fa-card-diamond{--fa:"\e3ea"}.fa-card-heart{--fa:"\e3eb"}.fa-card-spade{--fa:"\e3ec"}.fa-cards{--fa:"\e3ed"}.fa-cart-arrow-up{--fa:"\e3ee"}.fa-cart-circle-arrow-down{--fa:"\e3ef"}.fa-cart-circle-arrow-up{--fa:"\e3f0"}.fa-cart-circle-check{--fa:"\e3f1"}.fa-cart-circle-exclamation{--fa:"\e3f2"}.fa-cart-circle-plus{--fa:"\e3f3"}.fa-cart-circle-xmark{--fa:"\e3f4"}.fa-cent-sign{--fa:"\e3f5"}.fa-chestnut{--fa:"\e3f6"}.fa-chopsticks{--fa:"\e3f7"}.fa-circle-quarters{--fa:"\e3f8"}.fa-code-pull-request-closed{--fa:"\e3f9"}.fa-code-pull-request-draft{--fa:"\e3fa"}.fa-coin-blank{--fa:"\e3fb"}.fa-coin-front{--fa:"\e3fc"}.fa-coin-vertical{--fa:"\e3fd"}.fa-corner{--fa:"\e3fe"}.fa-crab{--fa:"\e3ff"}.fa-creemee,.fa-soft-serve{--fa:"\e400"}.fa-cucumber{--fa:"\e401"}.fa-cupcake{--fa:"\e402"}.fa-custard{--fa:"\e403"}.fa-dash,.fa-minus-large{--fa:"\e404"}.fa-diamond-exclamation{--fa:"\e405"}.fa-donut,.fa-doughnut{--fa:"\e406"}.fa-down-from-dotted-line{--fa:"\e407"}.fa-down-to-dotted-line{--fa:"\e408"}.fa-face-awesome,.fa-gave-dandy{--fa:"\e409"}.fa-falafel{--fa:"\e40a"}.fa-flatbread{--fa:"\e40b"}.fa-flatbread-stuffed{--fa:"\e40c"}.fa-fondue-pot{--fa:"\e40d"}.fa-garlic{--fa:"\e40e"}.fa-grip-dots{--fa:"\e410"}.fa-grip-dots-vertical{--fa:"\e411"}.fa-h5{--fa:"\e412"}.fa-h6{--fa:"\e413"}.fa-hammer-crash{--fa:"\e414"}.fa-hashtag-lock{--fa:"\e415"}.fa-hexagon-check{--fa:"\e416"}.fa-hexagon-exclamation{--fa:"\e417"}.fa-honey-pot{--fa:"\e418"}.fa-hose{--fa:"\e419"}.fa-hose-reel{--fa:"\e41a"}.fa-hourglass-clock{--fa:"\e41b"}.fa-100,.fa-hundred-points{--fa:"\e41c"}.fa-leafy-green{--fa:"\e41d"}.fa-left-long-to-line{--fa:"\e41e"}.fa-light-emergency{--fa:"\e41f"}.fa-light-emergency-on{--fa:"\e420"}.fa-lobster{--fa:"\e421"}.fa-lock-a{--fa:"\e422"}.fa-lock-hashtag{--fa:"\e423"}.fa-lollipop,.fa-lollypop{--fa:"\e424"}.fa-mushroom{--fa:"\e425"}.fa-octagon-check{--fa:"\e426"}.fa-onion{--fa:"\e427"}.fa-page{--fa:"\e428"}.fa-file-caret-down,.fa-page-caret-down{--fa:"\e429"}.fa-file-caret-up,.fa-page-caret-up{--fa:"\e42a"}.fa-pan-food{--fa:"\e42b"}.fa-pan-frying{--fa:"\e42c"}.fa-pancakes{--fa:"\e42d"}.fa-panel-ews{--fa:"\e42e"}.fa-panel-fire{--fa:"\e42f"}.fa-peanut{--fa:"\e430"}.fa-peanuts{--fa:"\e431"}.fa-pepper{--fa:"\e432"}.fa-person-to-door{--fa:"\e433"}.fa-phone-intercom{--fa:"\e434"}.fa-pickleball{--fa:"\e435"}.fa-pipe-circle-check{--fa:"\e436"}.fa-pipe-collar{--fa:"\e437"}.fa-pipe-section{--fa:"\e438"}.fa-pipe-valve{--fa:"\e439"}.fa-plate-utensils{--fa:"\e43b"}.fa-plus-minus{--fa:"\e43c"}.fa-pompebled{--fa:"\e43d"}.fa-popsicle{--fa:"\e43e"}.fa-pot-food{--fa:"\e43f"}.fa-potato{--fa:"\e440"}.fa-pretzel{--fa:"\e441"}.fa-pump{--fa:"\e442"}.fa-puzzle{--fa:"\e443"}.fa-right-long-to-line{--fa:"\e444"}.fa-sailboat{--fa:"\e445"}.fa-salt-shaker{--fa:"\e446"}.fa-section{--fa:"\e447"}.fa-shrimp{--fa:"\e448"}.fa-shutters{--fa:"\e449"}.fa-sportsball{--fa:"\e44b"}.fa-sprinkler-ceiling{--fa:"\e44c"}.fa-square-a-lock{--fa:"\e44d"}.fa-square-quarters{--fa:"\e44e"}.fa-square-ring{--fa:"\e44f"}.fa-squid{--fa:"\e450"}.fa-tamale{--fa:"\e451"}.fa-tank-water{--fa:"\e452"}.fa-train-track{--fa:"\e453"}.fa-train-tunnel{--fa:"\e454"}.fa-turn-down-right{--fa:"\e455"}.fa-up-from-dotted-line{--fa:"\e456"}.fa-up-to-dotted-line{--fa:"\e457"}.fa-user-doctor-hair{--fa:"\e458"}.fa-user-doctor-hair-long{--fa:"\e459"}.fa-user-hair{--fa:"\e45a"}.fa-user-hair-long{--fa:"\e45b"}.fa-business-front,.fa-party-back,.fa-trian-balbot,.fa-user-hair-mullet{--fa:"\e45c"}.fa-user-nurse-hair{--fa:"\e45d"}.fa-user-nurse-hair-long{--fa:"\e45e"}.fa-user-tie-hair{--fa:"\e45f"}.fa-user-tie-hair-long{--fa:"\e460"}.fa-user-vneck{--fa:"\e461"}.fa-user-vneck-hair{--fa:"\e462"}.fa-user-vneck-hair-long{--fa:"\e463"}.fa-utensils-slash{--fa:"\e464"}.fa-vent-damper{--fa:"\e465"}.fa-waffle{--fa:"\e466"}.fa-00{--fa:"\e467"}.fa-apartment{--fa:"\e468"}.fa-bird{--fa:"\e469"}.fa-block{--fa:"\e46a"}.fa-bowl-soft-serve{--fa:"\e46b"}.fa-brazilian-real-sign{--fa:"\e46c"}.fa-cabin{--fa:"\e46d"}.fa-calendar-circle-exclamation{--fa:"\e46e"}.fa-calendar-circle-minus{--fa:"\e46f"}.fa-calendar-circle-plus{--fa:"\e470"}.fa-calendar-circle-user{--fa:"\e471"}.fa-calendar-lines-pen{--fa:"\e472"}.fa-chart-simple{--fa:"\e473"}.fa-chart-simple-horizontal{--fa:"\e474"}.fa-diagram-cells{--fa:"\e475"}.fa-diagram-next{--fa:"\e476"}.fa-diagram-predecessor{--fa:"\e477"}.fa-diagram-previous{--fa:"\e478"}.fa-diagram-subtask{--fa:"\e479"}.fa-diagram-successor{--fa:"\e47a"}.fa-earth-oceania,.fa-globe-oceania{--fa:"\e47b"}.fa-face-beam-hand-over-mouth{--fa:"\e47c"}.fa-face-clouds{--fa:"\e47d"}.fa-face-diagonal-mouth{--fa:"\e47e"}.fa-face-dotted{--fa:"\e47f"}.fa-face-exhaling{--fa:"\e480"}.fa-face-hand-peeking{--fa:"\e481"}.fa-face-holding-back-tears{--fa:"\e482"}.fa-face-melting{--fa:"\e483"}.fa-face-saluting{--fa:"\e484"}.fa-face-spiral-eyes{--fa:"\e485"}.fa-fort{--fa:"\e486"}.fa-home-blank,.fa-house-blank{--fa:"\e487"}.fa-square-kanban{--fa:"\e488"}.fa-square-list{--fa:"\e489"}.fa-nigiri,.fa-sushi{--fa:"\e48a"}.fa-maki-roll,.fa-makizushi,.fa-sushi-roll{--fa:"\e48b"}.fa-album-circle-plus{--fa:"\e48c"}.fa-album-circle-user{--fa:"\e48d"}.fa-album-collection-circle-plus{--fa:"\e48e"}.fa-album-collection-circle-user{--fa:"\e48f"}.fa-bug-slash{--fa:"\e490"}.fa-cloud-exclamation{--fa:"\e491"}.fa-cloud-question{--fa:"\e492"}.fa-file-circle-info{--fa:"\e493"}.fa-file-circle-plus{--fa:"\e494"}.fa-frame{--fa:"\e495"}.fa-gauge-circle-bolt{--fa:"\e496"}.fa-gauge-circle-minus{--fa:"\e497"}.fa-gauge-circle-plus{--fa:"\e498"}.fa-memo-circle-info{--fa:"\e49a"}.fa-object-exclude{--fa:"\e49c"}.fa-object-intersect{--fa:"\e49d"}.fa-object-subtract{--fa:"\e49e"}.fa-object-union{--fa:"\e49f"}.fa-pen-nib-slash{--fa:"\e4a1"}.fa-rectangle-history{--fa:"\e4a2"}.fa-rectangle-history-circle-plus{--fa:"\e4a3"}.fa-rectangle-history-circle-user{--fa:"\e4a4"}.fa-shop-lock{--fa:"\e4a5"}.fa-store-lock{--fa:"\e4a6"}.fa-user-robot-xmarks{--fa:"\e4a7"}.fa-virus-covid{--fa:"\e4a8"}.fa-virus-covid-slash{--fa:"\e4a9"}.fa-anchor-circle-check{--fa:"\e4aa"}.fa-anchor-circle-exclamation{--fa:"\e4ab"}.fa-anchor-circle-xmark{--fa:"\e4ac"}.fa-anchor-lock{--fa:"\e4ad"}.fa-arrow-down-to-arc{--fa:"\e4ae"}.fa-arrow-down-up-across-line{--fa:"\e4af"}.fa-arrow-down-up-lock{--fa:"\e4b0"}.fa-arrow-right-from-arc{--fa:"\e4b1"}.fa-arrow-right-to-arc{--fa:"\e4b2"}.fa-arrow-right-to-city{--fa:"\e4b3"}.fa-arrow-up-from-arc{--fa:"\e4b4"}.fa-arrow-up-from-ground-water{--fa:"\e4b5"}.fa-arrow-up-from-water-pump{--fa:"\e4b6"}.fa-arrow-up-right-dots{--fa:"\e4b7"}.fa-arrows-down-to-line{--fa:"\e4b8"}.fa-arrows-down-to-people{--fa:"\e4b9"}.fa-arrows-left-right-to-line{--fa:"\e4ba"}.fa-arrows-spin{--fa:"\e4bb"}.fa-arrows-split-up-and-left{--fa:"\e4bc"}.fa-arrows-to-circle{--fa:"\e4bd"}.fa-arrows-to-dot{--fa:"\e4be"}.fa-arrows-to-eye{--fa:"\e4bf"}.fa-arrows-turn-right{--fa:"\e4c0"}.fa-arrows-turn-to-dots{--fa:"\e4c1"}.fa-arrows-up-to-line{--fa:"\e4c2"}.fa-bore-hole{--fa:"\e4c3"}.fa-bottle-droplet{--fa:"\e4c4"}.fa-bottle-water{--fa:"\e4c5"}.fa-bowl-food{--fa:"\e4c6"}.fa-boxes-packing{--fa:"\e4c7"}.fa-bridge{--fa:"\e4c8"}.fa-bridge-circle-check{--fa:"\e4c9"}.fa-bridge-circle-exclamation{--fa:"\e4ca"}.fa-bridge-circle-xmark{--fa:"\e4cb"}.fa-bridge-lock{--fa:"\e4cc"}.fa-bridge-suspension{--fa:"\e4cd"}.fa-bridge-water{--fa:"\e4ce"}.fa-bucket{--fa:"\e4cf"}.fa-bugs{--fa:"\e4d0"}.fa-building-circle-arrow-right{--fa:"\e4d1"}.fa-building-circle-check{--fa:"\e4d2"}.fa-building-circle-exclamation{--fa:"\e4d3"}.fa-building-circle-xmark{--fa:"\e4d4"}.fa-building-flag{--fa:"\e4d5"}.fa-building-lock{--fa:"\e4d6"}.fa-building-ngo{--fa:"\e4d7"}.fa-building-shield{--fa:"\e4d8"}.fa-building-un{--fa:"\e4d9"}.fa-building-user{--fa:"\e4da"}.fa-building-wheat{--fa:"\e4db"}.fa-burst{--fa:"\e4dc"}.fa-car-on{--fa:"\e4dd"}.fa-car-tunnel{--fa:"\e4de"}.fa-cards-blank{--fa:"\e4df"}.fa-child-combatant,.fa-child-rifle{--fa:"\e4e0"}.fa-children{--fa:"\e4e1"}.fa-circle-nodes{--fa:"\e4e2"}.fa-clipboard-question{--fa:"\e4e3"}.fa-cloud-showers-water{--fa:"\e4e4"}.fa-computer{--fa:"\e4e5"}.fa-cubes-stacked{--fa:"\e4e6"}.fa-down-to-bracket{--fa:"\e4e7"}.fa-envelope-circle-check{--fa:"\e4e8"}.fa-explosion{--fa:"\e4e9"}.fa-ferry{--fa:"\e4ea"}.fa-file-circle-exclamation{--fa:"\e4eb"}.fa-file-circle-minus{--fa:"\e4ed"}.fa-file-circle-question{--fa:"\e4ef"}.fa-file-shield{--fa:"\e4f0"}.fa-fire-burner{--fa:"\e4f1"}.fa-fish-fins{--fa:"\e4f2"}.fa-flask-vial{--fa:"\e4f3"}.fa-glass-water{--fa:"\e4f4"}.fa-glass-water-droplet{--fa:"\e4f5"}.fa-group-arrows-rotate{--fa:"\e4f6"}.fa-hand-holding-hand{--fa:"\e4f7"}.fa-handcuffs{--fa:"\e4f8"}.fa-hands-bound{--fa:"\e4f9"}.fa-hands-holding-child{--fa:"\e4fa"}.fa-hands-holding-circle{--fa:"\e4fb"}.fa-heart-circle-bolt{--fa:"\e4fc"}.fa-heart-circle-check{--fa:"\e4fd"}.fa-heart-circle-exclamation{--fa:"\e4fe"}.fa-heart-circle-minus{--fa:"\e4ff"}.fa-heart-circle-plus{--fa:"\e500"}.fa-heart-circle-xmark{--fa:"\e501"}.fa-helicopter-symbol{--fa:"\e502"}.fa-helmet-un{--fa:"\e503"}.fa-hexagon-image{--fa:"\e504"}.fa-hexagon-vertical-nft,.fa-hexagon-vertical-nft-slanted{--fa:"\e505"}.fa-hill-avalanche{--fa:"\e507"}.fa-hill-rockslide{--fa:"\e508"}.fa-house-circle-check{--fa:"\e509"}.fa-house-circle-exclamation{--fa:"\e50a"}.fa-house-circle-xmark{--fa:"\e50b"}.fa-house-fire{--fa:"\e50c"}.fa-house-flag{--fa:"\e50d"}.fa-house-flood-water{--fa:"\e50e"}.fa-house-flood-water-circle-arrow-right{--fa:"\e50f"}.fa-house-lock{--fa:"\e510"}.fa-house-medical-circle-check{--fa:"\e511"}.fa-house-medical-circle-exclamation{--fa:"\e512"}.fa-house-medical-circle-xmark{--fa:"\e513"}.fa-house-medical-flag{--fa:"\e514"}.fa-house-tsunami{--fa:"\e515"}.fa-jar{--fa:"\e516"}.fa-jar-wheat{--fa:"\e517"}.fa-jet-fighter-up{--fa:"\e518"}.fa-jug-detergent{--fa:"\e519"}.fa-kitchen-set{--fa:"\e51a"}.fa-land-mine-on{--fa:"\e51b"}.fa-landmark-flag{--fa:"\e51c"}.fa-laptop-file{--fa:"\e51d"}.fa-lines-leaning{--fa:"\e51e"}.fa-location-pin-lock{--fa:"\e51f"}.fa-locust{--fa:"\e520"}.fa-magnifying-glass-arrow-right{--fa:"\e521"}.fa-magnifying-glass-chart{--fa:"\e522"}.fa-mars-and-venus-burst{--fa:"\e523"}.fa-mask-ventilator{--fa:"\e524"}.fa-mattress-pillow{--fa:"\e525"}.fa-merge{--fa:"\e526"}.fa-mobile-retro{--fa:"\e527"}.fa-money-bill-transfer{--fa:"\e528"}.fa-money-bill-trend-up{--fa:"\e529"}.fa-money-bill-wheat{--fa:"\e52a"}.fa-mosquito{--fa:"\e52b"}.fa-mosquito-net{--fa:"\e52c"}.fa-mound{--fa:"\e52d"}.fa-mountain-city{--fa:"\e52e"}.fa-mountain-sun{--fa:"\e52f"}.fa-oil-well{--fa:"\e532"}.fa-people-group{--fa:"\e533"}.fa-people-line{--fa:"\e534"}.fa-people-pulling{--fa:"\e535"}.fa-people-robbery{--fa:"\e536"}.fa-people-roof{--fa:"\e537"}.fa-person-arrow-down-to-line{--fa:"\e538"}.fa-person-arrow-up-from-line{--fa:"\e539"}.fa-person-breastfeeding{--fa:"\e53a"}.fa-person-burst{--fa:"\e53b"}.fa-person-cane{--fa:"\e53c"}.fa-person-chalkboard{--fa:"\e53d"}.fa-person-circle-check{--fa:"\e53e"}.fa-person-circle-exclamation{--fa:"\e53f"}.fa-person-circle-minus{--fa:"\e540"}.fa-person-circle-plus{--fa:"\e541"}.fa-person-circle-question{--fa:"\e542"}.fa-person-circle-xmark{--fa:"\e543"}.fa-person-dress-burst{--fa:"\e544"}.fa-person-drowning{--fa:"\e545"}.fa-person-falling{--fa:"\e546"}.fa-person-falling-burst{--fa:"\e547"}.fa-person-half-dress{--fa:"\e548"}.fa-person-harassing{--fa:"\e549"}.fa-person-military-pointing{--fa:"\e54a"}.fa-person-military-rifle{--fa:"\e54b"}.fa-person-military-to-person{--fa:"\e54c"}.fa-person-rays{--fa:"\e54d"}.fa-person-rifle{--fa:"\e54e"}.fa-person-shelter{--fa:"\e54f"}.fa-person-walking-arrow-loop-left{--fa:"\e551"}.fa-person-walking-arrow-right{--fa:"\e552"}.fa-person-walking-dashed-line-arrow-right{--fa:"\e553"}.fa-person-walking-luggage{--fa:"\e554"}.fa-plane-circle-check{--fa:"\e555"}.fa-plane-circle-exclamation{--fa:"\e556"}.fa-plane-circle-xmark{--fa:"\e557"}.fa-plane-lock{--fa:"\e558"}.fa-plate-wheat{--fa:"\e55a"}.fa-plug-circle-bolt{--fa:"\e55b"}.fa-plug-circle-check{--fa:"\e55c"}.fa-plug-circle-exclamation{--fa:"\e55d"}.fa-plug-circle-minus{--fa:"\e55e"}.fa-plug-circle-plus{--fa:"\e55f"}.fa-plug-circle-xmark{--fa:"\e560"}.fa-ranking-star{--fa:"\e561"}.fa-road-barrier{--fa:"\e562"}.fa-road-bridge{--fa:"\e563"}.fa-road-circle-check{--fa:"\e564"}.fa-road-circle-exclamation{--fa:"\e565"}.fa-road-circle-xmark{--fa:"\e566"}.fa-road-lock{--fa:"\e567"}.fa-road-spikes{--fa:"\e568"}.fa-rug{--fa:"\e569"}.fa-sack-xmark{--fa:"\e56a"}.fa-school-circle-check{--fa:"\e56b"}.fa-school-circle-exclamation{--fa:"\e56c"}.fa-school-circle-xmark{--fa:"\e56d"}.fa-school-flag{--fa:"\e56e"}.fa-school-lock{--fa:"\e56f"}.fa-sheet-plastic{--fa:"\e571"}.fa-shield-cat{--fa:"\e572"}.fa-shield-dog{--fa:"\e573"}.fa-shield-heart{--fa:"\e574"}.fa-shield-quartered{--fa:"\e575"}.fa-square-nfi{--fa:"\e576"}.fa-square-person-confined{--fa:"\e577"}.fa-square-virus{--fa:"\e578"}.fa-rod-asclepius,.fa-rod-snake,.fa-staff-aesculapius,.fa-staff-snake{--fa:"\e579"}.fa-sun-plant-wilt{--fa:"\e57a"}.fa-tarp{--fa:"\e57b"}.fa-tarp-droplet{--fa:"\e57c"}.fa-tent{--fa:"\e57d"}.fa-tent-arrow-down-to-line{--fa:"\e57e"}.fa-tent-arrow-left-right{--fa:"\e57f"}.fa-tent-arrow-turn-left{--fa:"\e580"}.fa-tent-arrows-down{--fa:"\e581"}.fa-tents{--fa:"\e582"}.fa-toilet-portable{--fa:"\e583"}.fa-toilets-portable{--fa:"\e584"}.fa-tower-cell{--fa:"\e585"}.fa-tower-observation{--fa:"\e586"}.fa-tree-city{--fa:"\e587"}.fa-trillium{--fa:"\e588"}.fa-trowel{--fa:"\e589"}.fa-trowel-bricks{--fa:"\e58a"}.fa-truck-arrow-right{--fa:"\e58b"}.fa-truck-droplet{--fa:"\e58c"}.fa-truck-field{--fa:"\e58d"}.fa-truck-field-un{--fa:"\e58e"}.fa-truck-plane{--fa:"\e58f"}.fa-up-from-bracket{--fa:"\e590"}.fa-users-between-lines{--fa:"\e591"}.fa-users-line{--fa:"\e592"}.fa-users-rays{--fa:"\e593"}.fa-users-rectangle{--fa:"\e594"}.fa-users-viewfinder{--fa:"\e595"}.fa-vial-circle-check{--fa:"\e596"}.fa-vial-virus{--fa:"\e597"}.fa-wheat-awn-circle-exclamation{--fa:"\e598"}.fa-worm{--fa:"\e599"}.fa-xmarks-lines{--fa:"\e59a"}.fa-xmark-large{--fa:"\e59b"}.fa-child-dress{--fa:"\e59c"}.fa-child-reaching{--fa:"\e59d"}.fa-plus-large{--fa:"\e59e"}.fa-crosshairs-simple{--fa:"\e59f"}.fa-file-circle-check{--fa:"\e5a0"}.fa-file-circle-xmark{--fa:"\e5a1"}.fa-gamepad-alt,.fa-gamepad-modern{--fa:"\e5a2"}.fa-grill{--fa:"\e5a3"}.fa-grill-fire{--fa:"\e5a4"}.fa-grill-hot{--fa:"\e5a5"}.fa-lightbulb-cfl{--fa:"\e5a6"}.fa-lightbulb-cfl-on{--fa:"\e5a7"}.fa-mouse-field{--fa:"\e5a8"}.fa-person-through-window{--fa:"\e5a9"}.fa-plant-wilt{--fa:"\e5aa"}.fa-ring-diamond{--fa:"\e5ab"}.fa-stapler{--fa:"\e5af"}.fa-toggle-large-off{--fa:"\e5b0"}.fa-toggle-large-on{--fa:"\e5b1"}.fa-toilet-paper-check{--fa:"\e5b2"}.fa-toilet-paper-xmark{--fa:"\e5b3"}.fa-train-tram{--fa:"\e5b4"}.fa-buoy{--fa:"\e5b5"}.fa-buoy-mooring{--fa:"\e5b6"}.fa-diamond-half{--fa:"\e5b7"}.fa-diamond-half-stroke{--fa:"\e5b8"}.fa-game-console-handheld-crank{--fa:"\e5b9"}.fa-interrobang{--fa:"\e5ba"}.fa-mailbox-flag-up{--fa:"\e5bb"}.fa-mustache{--fa:"\e5bc"}.fa-nose{--fa:"\e5bd"}.fa-phone-arrow-right{--fa:"\e5be"}.fa-pickaxe{--fa:"\e5bf"}.fa-prescription-bottle-pill{--fa:"\e5c0"}.fa-snowflake-droplets{--fa:"\e5c1"}.fa-square-dashed-circle-plus{--fa:"\e5c2"}.fa-tricycle{--fa:"\e5c3"}.fa-tricycle-adult{--fa:"\e5c4"}.fa-user-magnifying-glass{--fa:"\e5c5"}.fa-comment-heart{--fa:"\e5c8"}.fa-message-heart{--fa:"\e5c9"}.fa-pencil-mechanical{--fa:"\e5ca"}.fa-skeleton-ribs{--fa:"\e5cb"}.fa-billboard{--fa:"\e5cd"}.fa-circle-euro{--fa:"\e5ce"}.fa-circle-sterling{--fa:"\e5cf"}.fa-circle-yen{--fa:"\e5d0"}.fa-broom-wide{--fa:"\e5d1"}.fa-wreath-laurel{--fa:"\e5d2"}.fa-circle-quarter-stroke{--fa:"\e5d3"}.fa-circle-three-quarters-stroke{--fa:"\e5d4"}.fa-webhook{--fa:"\e5d5"}.fa-sparkle{--fa:"\e5d6"}.fa-chart-line-up-down{--fa:"\e5d7"}.fa-chart-mixed-up-circle-currency{--fa:"\e5d8"}.fa-chart-mixed-up-circle-dollar{--fa:"\e5d9"}.fa-grid-round{--fa:"\e5da"}.fa-grid-round-2{--fa:"\e5db"}.fa-grid-round-2-plus{--fa:"\e5dc"}.fa-grid-round-4{--fa:"\e5dd"}.fa-grid-round-5{--fa:"\e5de"}.fa-arrow-progress{--fa:"\e5df"}.fa-right-left-large{--fa:"\e5e1"}.fa-calendar-users{--fa:"\e5e2"}.fa-display-chart-up{--fa:"\e5e3"}.fa-display-chart-up-circle-currency{--fa:"\e5e5"}.fa-display-chart-up-circle-dollar{--fa:"\e5e6"}.fa-laptop-binary{--fa:"\e5e7"}.fa-gear-code{--fa:"\e5e8"}.fa-gear-complex{--fa:"\e5e9"}.fa-gear-complex-code{--fa:"\e5eb"}.fa-file-doc{--fa:"\e5ed"}.fa-file-zip{--fa:"\e5ee"}.fa-flask-gear{--fa:"\e5f1"}.fa-bag-seedling{--fa:"\e5f2"}.fa-bin-bottles{--fa:"\e5f5"}.fa-bin-bottles-recycle{--fa:"\e5f6"}.fa-bin-recycle{--fa:"\e5f7"}.fa-conveyor-belt-arm{--fa:"\e5f8"}.fa-jug-bottle{--fa:"\e5fb"}.fa-lightbulb-gear{--fa:"\e5fd"}.fa-dinosaur{--fa:"\e5fe"}.fa-person-running-fast{--fa:"\e5ff"}.fa-circles-overlap{--fa:"\e600"}.fa-cloud-binary{--fa:"\e601"}.fa-chf-sign{--fa:"\e602"}.fa-user-group-simple{--fa:"\e603"}.fa-chart-pie-simple-circle-currency{--fa:"\e604"}.fa-chart-pie-simple-circle-dollar{--fa:"\e605"}.fa-hat-beach{--fa:"\e606"}.fa-person-dress-fairy{--fa:"\e607"}.fa-person-fairy{--fa:"\e608"}.fa-swap{--fa:"\e609"}.fa-swap-arrows{--fa:"\e60a"}.fa-angles-up-down{--fa:"\e60d"}.fa-globe-pointer{--fa:"\e60e"}.fa-subtitles{--fa:"\e60f"}.fa-subtitles-slash{--fa:"\e610"}.fa-head-side-gear{--fa:"\e611"}.fa-lighthouse{--fa:"\e612"}.fa-raccoon{--fa:"\e613"}.fa-arrow-down-from-arc{--fa:"\e614"}.fa-arrow-left-from-arc{--fa:"\e615"}.fa-arrow-left-to-arc{--fa:"\e616"}.fa-arrow-up-to-arc{--fa:"\e617"}.fa-building-magnifying-glass{--fa:"\e61c"}.fa-building-memo{--fa:"\e61e"}.fa-hammer-brush{--fa:"\e620"}.fa-hand-holding-circle-dollar{--fa:"\e621"}.fa-landmark-magnifying-glass{--fa:"\e622"}.fa-sign-post{--fa:"\e624"}.fa-sign-posts{--fa:"\e625"}.fa-sign-posts-wrench{--fa:"\e626"}.fa-tent-double-peak{--fa:"\e627"}.fa-truck-utensils{--fa:"\e628"}.fa-t-rex{--fa:"\e629"}.fa-spinner-scale{--fa:"\e62a"}.fa-bell-ring{--fa:"\e62c"}.fa-arrows-rotate-reverse{--fa:"\e630"}.fa-rotate-reverse{--fa:"\e631"}.fa-arrow-turn-left{--fa:"\e632"}.fa-arrow-turn-left-down{--fa:"\e633"}.fa-arrow-turn-left-up{--fa:"\e634"}.fa-arrow-turn-right{--fa:"\e635"}.fa-turn-left{--fa:"\e636"}.fa-turn-left-down{--fa:"\e637"}.fa-turn-left-up{--fa:"\e638"}.fa-turn-right{--fa:"\e639"}.fa-location-arrow-up{--fa:"\e63a"}.fa-ticket-perforated{--fa:"\e63e"}.fa-tickets-perforated{--fa:"\e63f"}.fa-cannon{--fa:"\e642"}.fa-court-sport{--fa:"\e643"}.fa-file-eps{--fa:"\e644"}.fa-file-gif{--fa:"\e645"}.fa-file-jpg{--fa:"\e646"}.fa-file-mov{--fa:"\e647"}.fa-file-mp3{--fa:"\e648"}.fa-file-mp4{--fa:"\e649"}.fa-file-ppt{--fa:"\e64a"}.fa-file-svg{--fa:"\e64b"}.fa-file-vector{--fa:"\e64c"}.fa-file-xls{--fa:"\e64d"}.fa-folder-check{--fa:"\e64e"}.fa-chart-kanban{--fa:"\e64f"}.fa-bag-shopping-minus{--fa:"\e650"}.fa-bag-shopping-plus{--fa:"\e651"}.fa-basket-shopping-minus{--fa:"\e652"}.fa-basket-shopping-plus{--fa:"\e653"}.fa-file-xml{--fa:"\e654"}.fa-bulldozer{--fa:"\e655"}.fa-excavator{--fa:"\e656"}.fa-truck-ladder{--fa:"\e657"}.fa-tickets{--fa:"\e658"}.fa-tickets-simple{--fa:"\e659"}.fa-truck-fire{--fa:"\e65a"}.fa-wave{--fa:"\e65b"}.fa-waves-sine{--fa:"\e65d"}.fa-magnifying-glass-arrows-rotate{--fa:"\e65e"}.fa-magnifying-glass-music{--fa:"\e65f"}.fa-magnifying-glass-play{--fa:"\e660"}.fa-magnifying-glass-waveform{--fa:"\e661"}.fa-music-magnifying-glass{--fa:"\e662"}.fa-reflect-horizontal{--fa:"\e664"}.fa-reflect-vertical{--fa:"\e665"}.fa-file-png{--fa:"\e666"}.fa-arrow-down-from-bracket{--fa:"\e667"}.fa-arrow-left-from-bracket{--fa:"\e668"}.fa-arrow-left-to-bracket{--fa:"\e669"}.fa-arrow-up-to-bracket{--fa:"\e66a"}.fa-down-from-bracket{--fa:"\e66b"}.fa-left-from-bracket{--fa:"\e66c"}.fa-left-to-bracket{--fa:"\e66d"}.fa-up-to-bracket{--fa:"\e66e"}.fa-reflect-both{--fa:"\e66f"}.fa-file-cad{--fa:"\e672"}.fa-bottle-baby{--fa:"\e673"}.fa-table-cells-column-lock{--fa:"\e678"}.fa-table-cells-lock{--fa:"\e679"}.fa-table-cells-row-lock{--fa:"\e67a"}.fa-circle-wifi{--fa:"\e67d"}.fa-circle-wifi-circle-wifi,.fa-circle-wifi-group{--fa:"\e67e"}.fa-circle-gf{--fa:"\e67f"}.fa-ant{--fa:"\e680"}.fa-caduceus{--fa:"\e681"}.fa-globe-wifi{--fa:"\e685"}.fa-hydra{--fa:"\e686"}.fa-lightbulb-message{--fa:"\e687"}.fa-octopus{--fa:"\e688"}.fa-user-beard-bolt{--fa:"\e689"}.fa-user-hoodie{--fa:"\e68a"}.fa-diamonds-4{--fa:"\e68b"}.fa-thumb-tack-slash,.fa-thumbtack-slash{--fa:"\e68f"}.fa-table-cells-column-unlock{--fa:"\e690"}.fa-table-cells-row-unlock{--fa:"\e691"}.fa-table-cells-unlock{--fa:"\e692"}.fa-chart-diagram{--fa:"\e695"}.fa-comment-nodes{--fa:"\e696"}.fa-file-fragment{--fa:"\e697"}.fa-file-half-dashed{--fa:"\e698"}.fa-hexagon-nodes{--fa:"\e699"}.fa-hexagon-nodes-bolt{--fa:"\e69a"}.fa-square-binary{--fa:"\e69b"}.fa-car-people,.fa-carpool{--fa:"\e69c"}.fa-chart-sine{--fa:"\e69d"}.fa-chart-fft{--fa:"\e69e"}.fa-circles-overlap-3,.fa-pronoun{--fa:"\e6a1"}.fa-bar-progress{--fa:"\e6a4"}.fa-bar-progress-empty{--fa:"\e6a5"}.fa-bar-progress-full{--fa:"\e6a6"}.fa-bar-progress-half{--fa:"\e6a7"}.fa-bar-progress-quarter{--fa:"\e6a8"}.fa-bar-progress-three-quarters{--fa:"\e6a9"}.fa-grid-2-minus{--fa:"\e6aa"}.fa-grid-round-2-minus{--fa:"\e6ab"}.fa-table-cells-columns{--fa:"\e6ac"}.fa-table-cells-header{--fa:"\e6ad"}.fa-table-cells-header-lock{--fa:"\e6ae"}.fa-table-cells-header-unlock{--fa:"\e6af"}.fa-table-cells-rows{--fa:"\e6b0"}.fa-circle-equals{--fa:"\e6b1"}.fa-hexagon-equals{--fa:"\e6b2"}.fa-octagon-equals{--fa:"\e6b3"}.fa-rectangle-minus{--fa:"\e6b4"}.fa-rectangle-plus{--fa:"\e6b5"}.fa-square-equals{--fa:"\e6b6"}.fa-arrow-down-long-to-line{--fa:"\e6b7"}.fa-arrow-left-arrow-right{--fa:"\e6b8"}.fa-arrow-left-from-dotted-line{--fa:"\e6b9"}.fa-arrow-left-to-dotted-line{--fa:"\e6ba"}.fa-arrow-right-from-dotted-line{--fa:"\e6bb"}.fa-arrow-right-to-dotted-line{--fa:"\e6bc"}.fa-arrow-up-long-to-line{--fa:"\e6bd"}.fa-direction-left-right{--fa:"\e6be"}.fa-direction-up-down{--fa:"\e6bf"}.fa-down-long-to-line{--fa:"\e6c0"}.fa-down-up{--fa:"\e6c1"}.fa-left-from-dotted-line{--fa:"\e6c2"}.fa-left-to-dotted-line{--fa:"\e6c3"}.fa-right-from-dotted-line{--fa:"\e6c4"}.fa-right-to-dotted-line{--fa:"\e6c5"}.fa-up-long-to-line{--fa:"\e6c6"}.fa-barn{--fa:"\e6c7"}.fa-circle-house{--fa:"\e6c8"}.fa-garage-empty{--fa:"\e6c9"}.fa-house-unlock{--fa:"\e6ca"}.fa-school-unlock{--fa:"\e6cb"}.fa-stadium{--fa:"\e6cc"}.fa-tent-circus{--fa:"\e6cd"}.fa-ball-yarn{--fa:"\e6ce"}.fa-bra{--fa:"\e6cf"}.fa-briefs{--fa:"\e6d0"}.fa-dress{--fa:"\e6d1"}.fa-jeans{--fa:"\e6d2"}.fa-jeans-straight{--fa:"\e6d3"}.fa-panties{--fa:"\e6d4"}.fa-pants{--fa:"\e6d5"}.fa-pants-straight{--fa:"\e6d6"}.fa-shirt-jersey{--fa:"\e6d7"}.fa-shoe{--fa:"\e6d8"}.fa-shorts{--fa:"\e6d9"}.fa-sneaker{--fa:"\e6da"}.fa-circle-share-nodes{--fa:"\e6db"}.fa-comment-dot{--fa:"\e6dc"}.fa-comment-waveform{--fa:"\e6dd"}.fa-envelope-circle-user{--fa:"\e6de"}.fa-message-dot{--fa:"\e6df"}.fa-message-waveform{--fa:"\e6e0"}.fa-phone-connection{--fa:"\e6e1"}.fa-phone-waveform{--fa:"\e6e2"}.fa-postage-stamp{--fa:"\e6e3"}.fa-circle-florin{--fa:"\e6e4"}.fa-circle-ruble{--fa:"\e6e5"}.fa-square-chf{--fa:"\e6e6"}.fa-square-lira{--fa:"\e6e7"}.fa-norwegian-krone-sign{--fa:"\e6e8"}.fa-circle-renminbi{--fa:"\e6e9"}.fa-square-peseta{--fa:"\e6ea"}.fa-circle-brazilian-real{--fa:"\e6eb"}.fa-circle-won{--fa:"\e6ec"}.fa-square-cruzeiro{--fa:"\e6ed"}.fa-circle-currency{--fa:"\e6ee"}.fa-circle-hryvnia{--fa:"\e6ef"}.fa-square-cent{--fa:"\e6f0"}.fa-square-brazilian-real{--fa:"\e6f1"}.fa-square-bitcoin{--fa:"\e6f2"}.fa-circle-peruvian-soles{--fa:"\e6f3"}.fa-circle-litecoin{--fa:"\e6f4"}.fa-square-indian-rupee{--fa:"\e6f5"}.fa-circle-lira{--fa:"\e6f6"}.fa-square-litecoin{--fa:"\e6f7"}.fa-square-ruble{--fa:"\e6f8"}.fa-circle-malaysian-ringgit{--fa:"\e6f9"}.fa-malaysian-ringgit-sign{--fa:"\e6fa"}.fa-circle-manat{--fa:"\e6fb"}.fa-circle-colon{--fa:"\e6fc"}.fa-circle-kip{--fa:"\e6fd"}.fa-australian-dollar-sign{--fa:"\e6fe"}.fa-circle-peso{--fa:"\e6ff"}.fa-circle-polish-zloty{--fa:"\e700"}.fa-circle-bangladeshi-taka{--fa:"\e701"}.fa-circle-mill{--fa:"\e702"}.fa-circle-shekel{--fa:"\e703"}.fa-square-manat{--fa:"\e704"}.fa-peruvian-soles-sign{--fa:"\e705"}.fa-circle-rupiah{--fa:"\e706"}.fa-square-norwegian-krone{--fa:"\e707"}.fa-square-naira{--fa:"\e708"}.fa-square-won{--fa:"\e709"}.fa-square-mill{--fa:"\e70a"}.fa-polish-zloty-sign{--fa:"\e70b"}.fa-square-currency{--fa:"\e70c"}.fa-square-kip{--fa:"\e70d"}.fa-square-guarani{--fa:"\e70e"}.fa-square-dong{--fa:"\e70f"}.fa-square-hryvnia{--fa:"\e710"}.fa-circle-tugrik{--fa:"\e711"}.fa-square-rupiah{--fa:"\e712"}.fa-square-sterling{--fa:"\e713"}.fa-circle-rupee{--fa:"\e714"}.fa-square-rupee{--fa:"\e715"}.fa-square-peruvian-soles{--fa:"\e716"}.fa-square-florin{--fa:"\e717"}.fa-square-australian-dollar{--fa:"\e718"}.fa-square-baht{--fa:"\e719"}.fa-square-peso{--fa:"\e71a"}.fa-circle-austral{--fa:"\e71b"}.fa-square-swedish-krona{--fa:"\e71c"}.fa-circle-lari{--fa:"\e71d"}.fa-circleapore-dollar{--fa:"\e71e"}.fa-square-turkish-lira{--fa:"\e71f"}.fa-danish-krone-sign{--fa:"\e720"}.fa-circle-franc{--fa:"\e721"}.fa-circle-cruzeiro{--fa:"\e722"}.fa-circle-dong{--fa:"\e723"}.fa-square-yen{--fa:"\e724"}.fa-circle-tenge{--fa:"\e725"}.fa-square-austral{--fa:"\e726"}.fa-square-eurozone{--fa:"\e727"}.fa-square-tugrik{--fa:"\e728"}.fa-square-cedi{--fa:"\e729"}.fa-circle-cent{--fa:"\e72a"}.fa-currency-sign{--fa:"\e72b"}.fa-circle-chf{--fa:"\e72c"}.fa-circle-baht{--fa:"\e72d"}.fa-signapore-dollar-sign{--fa:"\e72e"}.fa-square-franc{--fa:"\e72f"}.fa-circle-australian-dollar{--fa:"\e730"}.fa-square-tenge{--fa:"\e731"}.fa-square-euro{--fa:"\e732"}.fa-squareapore-dollar{--fa:"\e733"}.fa-circle-indian-rupee{--fa:"\e734"}.fa-square-shekel{--fa:"\e735"}.fa-square-polish-zloty{--fa:"\e736"}.fa-circle-bitcoin{--fa:"\e737"}.fa-circle-norwegian-krone{--fa:"\e738"}.fa-circle-turkish-lira{--fa:"\e739"}.fa-square-colon{--fa:"\e73a"}.fa-circle-guarani{--fa:"\e73b"}.fa-renminbi-sign{--fa:"\e73c"}.fa-square-renminbi{--fa:"\e73d"}.fa-swedish-krona-sign{--fa:"\e73e"}.fa-square-lari{--fa:"\e73f"}.fa-eurozone-sign{--fa:"\e740"}.fa-circle-peseta{--fa:"\e741"}.fa-circle-cedi{--fa:"\e742"}.fa-circle-swedish-krona{--fa:"\e743"}.fa-square-bangladeshi-taka{--fa:"\e744"}.fa-circle-eurozone{--fa:"\e745"}.fa-circle-danish-krone{--fa:"\e746"}.fa-square-danish-krone{--fa:"\e747"}.fa-square-malaysian-ringgit{--fa:"\e748"}.fa-circle-naira{--fa:"\e749"}.fa-mobile-arrow-down{--fa:"\e74b"}.fa-clone-plus{--fa:"\e74c"}.fa-paintbrush-fine-slash{--fa:"\e74d"}.fa-paintbrush-slash{--fa:"\e74e"}.fa-pencil-line{--fa:"\e74f"}.fa-slider-circle{--fa:"\e750"}.fa-thumbtack-angle{--fa:"\e751"}.fa-thumbtack-angle-slash{--fa:"\e752"}.fa-book-open-lines{--fa:"\e753"}.fa-book-spine{--fa:"\e754"}.fa-bookmark-plus{--fa:"\e755"}.fa-clipboard-clock{--fa:"\e756"}.fa-clipboard-exclamation{--fa:"\e757"}.fa-file-ban{--fa:"\e758"}.fa-notes-sticky{--fa:"\e759"}.fa-capsule{--fa:"\e75a"}.fa-ear-circle-checkmark{--fa:"\e75b"}.fa-ear-triangle-exclamation{--fa:"\e75c"}.fa-ear-waveform{--fa:"\e75d"}.fa-head-side-circuit{--fa:"\e75e"}.fa-head-side-speak{--fa:"\e75f"}.fa-microphone-signal-meter{--fa:"\e760"}.fa-spine{--fa:"\e761"}.fa-vial-vertical{--fa:"\e762"}.fa-bin{--fa:"\e763"}.fa-seat{--fa:"\e764"}.fa-seats{--fa:"\e765"}.fa-camera-circle-ellipsis{--fa:"\e766"}.fa-camera-clock{--fa:"\e767"}.fa-camera-shutter{--fa:"\e768"}.fa-film-music{--fa:"\e769"}.fa-film-stack{--fa:"\e76b"}.fa-image-circle-arrow-down{--fa:"\e76c"}.fa-image-circle-check{--fa:"\e76d"}.fa-image-circle-plus{--fa:"\e76e"}.fa-image-circle-xmark{--fa:"\e76f"}.fa-image-music{--fa:"\e770"}.fa-image-stack{--fa:"\e771"}.fa-rectangle-4k{--fa:"\e772"}.fa-rectangle-hdr,.fa-rectangle-high-dynamic-range{--fa:"\e773"}.fa-rectangle-video-on-demand{--fa:"\e774"}.fa-user-viewfinder{--fa:"\e775"}.fa-video-down-to-line{--fa:"\e776"}.fa-video-question{--fa:"\e777"}.fa-gas-pump-left{--fa:"\e778"}.fa-gas-pump-right{--fa:"\e779"}.fa-location-arrow-slash{--fa:"\e77a"}.fa-airplay-audio{--fa:"\e77b"}.fa-headphones-slash{--fa:"\e77c"}.fa-microphone-circle-plus{--fa:"\e77d"}.fa-microphone-circle-xmark{--fa:"\e77e"}.fa-open-captioning{--fa:"\e77f"}.fa-play-flip{--fa:"\e780"}.fa-square-microphone{--fa:"\e781"}.fa-trombone{--fa:"\e782"}.fa-person-arms-raised{--fa:"\e783"}.fa-person-basketball{--fa:"\e784"}.fa-person-carry-empty{--fa:"\e785"}.fa-person-golfing{--fa:"\e786"}.fa-person-limbs-wide{--fa:"\e787"}.fa-person-seat-window{--fa:"\e788"}.fa-person-soccer{--fa:"\e789"}.fa-person-swimming-pool{--fa:"\e78a"}.fa-person-swimming-water{--fa:"\e78b"}.fa-person-water-arms-raised{--fa:"\e78c"}.fa-person-waving{--fa:"\e78d"}.fa-heart-slash{--fa:"\e78e"}.fa-hearts{--fa:"\e78f"}.fa-pentagon{--fa:"\e790"}.fa-rectangle-tall{--fa:"\e791"}.fa-square-half{--fa:"\e792"}.fa-square-half-stroke{--fa:"\e793"}.fa-box-arrow-down{--fa:"\e794"}.fa-box-arrow-down-arrow-up{--fa:"\e795"}.fa-box-arrow-down-magnifying-glass{--fa:"\e796"}.fa-box-isometric{--fa:"\e797"}.fa-box-isometric-tape{--fa:"\e798"}.fa-qrcode-read{--fa:"\e799"}.fa-shop-24{--fa:"\e79a"}.fa-store-24{--fa:"\e79b"}.fa-face-shaking{--fa:"\e79c"}.fa-face-shaking-horizontal{--fa:"\e79d"}.fa-face-shaking-vertical{--fa:"\e79e"}.fa-circle-user-circle-check{--fa:"\e79f"}.fa-circle-user-circle-exclamation{--fa:"\e7a0"}.fa-circle-user-circle-minus{--fa:"\e7a1"}.fa-circle-user-circle-moon{--fa:"\e7a2"}.fa-circle-user-circle-plus{--fa:"\e7a3"}.fa-circle-user-circle-question{--fa:"\e7a4"}.fa-circle-user-circle-user{--fa:"\e7a5"}.fa-circle-user-circle-xmark{--fa:"\e7a6"}.fa-circle-user-clock{--fa:"\e7a7"}.fa-user-beard{--fa:"\e7a8"}.fa-user-chef-hair-long{--fa:"\e7a9"}.fa-user-circle-minus{--fa:"\e7aa"}.fa-user-circle-plus{--fa:"\e7ab"}.fa-user-dashed{--fa:"\e7ac"}.fa-user-doctor-hair-mullet{--fa:"\e7ad"}.fa-user-hat-tie{--fa:"\e7ae"}.fa-user-hat-tie-magnifying-glass{--fa:"\e7af"}.fa-user-key{--fa:"\e7b0"}.fa-user-message{--fa:"\e7b1"}.fa-user-microphone{--fa:"\e7b2"}.fa-user-pilot-hair-long{--fa:"\e7b3"}.fa-user-pilot-tie-hair-long{--fa:"\e7b4"}.fa-user-police-hair-long{--fa:"\e7b5"}.fa-user-police-tie-hair-long{--fa:"\e7b6"}.fa-user-question{--fa:"\e7b7"}.fa-user-sith{--fa:"\e7b8"}.fa-user-tie-hair-mullet{--fa:"\e7b9"}.fa-user-vneck-hair-mullet{--fa:"\e7ba"}.fa-plane-flying{--fa:"\e7bb"}.fa-plane-landing-gear{--fa:"\e7bc"}.fa-rocket-vertical{--fa:"\e7bd"}.fa-seat-airline-window{--fa:"\e7be"}.fa-shuttle-space-vertical{--fa:"\e7bf"}.fa-car-key{--fa:"\e7c0"}.fa-car-siren{--fa:"\e7c1"}.fa-car-siren-on{--fa:"\e7c2"}.fa-scooter{--fa:"\e7c3"}.fa-snowmobile-blank{--fa:"\e7c4"}.fa-stair-car{--fa:"\e7c5"}.fa-truck-suv{--fa:"\e7c6"}.fa-unicycle{--fa:"\e7c7"}.fa-van{--fa:"\e7c8"}.fa-moon-star{--fa:"\e7c9"}.fa-rainbow-half{--fa:"\e7ca"}.fa-temperature-slash{--fa:"\e7cb"}.fa-dialpad{--fa:"\e7cc"}.fa-computer-mouse-button-left{--fa:"\e7cd"}.fa-computer-mouse-button-right{--fa:"\e7ce"}.fa-dot{--fa:"\e7d1"}.fa-folder-arrow-left{--fa:"\e7d2"}.fa-folder-arrow-right{--fa:"\e7d3"}.fa-wireless{--fa:"\e7df"}.fa-circle-moon{--fa:"\e7e0"}.fa-person-meditating{--fa:"\e7e1"}.fa-baseball-bat{--fa:"\e7e5"}.fa-hockey-stick{--fa:"\e7e6"}.fa-arrow-u-turn-down-left{--fa:"\e7e7"}.fa-arrow-u-turn-down-right{--fa:"\e7e8"}.fa-arrow-u-turn-left-down{--fa:"\e7e9"}.fa-arrow-u-turn-left-up{--fa:"\e7ea"}.fa-arrow-u-turn-right-down{--fa:"\e7eb"}.fa-arrow-u-turn-right-up{--fa:"\e7ec"}.fa-arrow-u-turn-up-left{--fa:"\e7ed"}.fa-arrow-u-turn-up-right{--fa:"\e7ee"}.fa-u-turn-down-left{--fa:"\e7ef"}.fa-u-turn-down-right{--fa:"\e7f0"}.fa-u-turn,.fa-u-turn-left-down{--fa:"\e7f1"}.fa-u-turn-left-up{--fa:"\e7f2"}.fa-u-turn-right-down{--fa:"\e7f3"}.fa-u-turn-right-up{--fa:"\e7f4"}.fa-u-turn-up-left{--fa:"\e7f5"}.fa-u-turn-up-right{--fa:"\e7f6"}.fa-triple-chevrons-down{--fa:"\e7f7"}.fa-triple-chevrons-left{--fa:"\e7f8"}.fa-triple-chevrons-right{--fa:"\e7f9"}.fa-triple-chevrons-up{--fa:"\e7fa"}.fa-file-aiff{--fa:"\e7fb"}.fa-file-odf{--fa:"\e7fc"}.fa-file-tex{--fa:"\e7fd"}.fa-file-wav{--fa:"\e7fe"}.fa-droplet-plus{--fa:"\e800"}.fa-hand-holding-star{--fa:"\e801"}.fa-transmission{--fa:"\e802"}.fa-alarm-minus{--fa:"\e803"}.fa-file-brackets-curly{--fa:"\e804"}.fa-file-midi{--fa:"\e805"}.fa-midi{--fa:"\e806"}.fa-non-binary{--fa:"\e807"}.fa-rectangle-beta{--fa:"\e808"}.fa-shield-user{--fa:"\e809"}.fa-spiral{--fa:"\e80a"}.fa-picture-in-picture{--fa:"\e80b"}.fa-circle-half-horizontal{--fa:"\e80c"}.fa-circle-half-stroke-horizontal{--fa:"\e80d"}.fa-square-half-horizontal{--fa:"\e80e"}.fa-square-half-stroke-horizontal{--fa:"\e80f"}.fa-ship-large{--fa:"\e810"}.fa-butterfly{--fa:"\e811"}.fa-mobile-rotate{--fa:"\e813"}.fa-mobile-rotate-reverse{--fa:"\e814"}.fa-mobile-slash{--fa:"\e815"}.fa-mobile-vibrate{--fa:"\e816"}.fa-mobile-vibrate-slash{--fa:"\e817"}.fa-almost-equal-to{--fa:"\e818"}.fa-sneaker-running{--fa:"\e819"}.fa-horseshoe{--fa:"\e81a"}.fa-single-quote-left{--fa:"\e81b"}.fa-single-quote-right{--fa:"\e81c"}.fa-bus-side{--fa:"\e81d"}.fa-bus-stop{--fa:"\e81e"}.fa-train-stop{--fa:"\e81f"}.fa-heptagon,.fa-septagon{--fa:"\e820"}.fa-mailbox-open-empty{--fa:"\e821"}.fa-mailbox-open-letter{--fa:"\e823"}.fa-lychee{--fa:"\e824"}.fa-tank-recovery{--fa:"\e825"}.fa-transducer{--fa:"\e826"}.fa-box-arrow-up{--fa:"\e827"}.fa-box-magnifying-glass{--fa:"\e828"}.fa-envelope-certificate,.fa-envelope-ribbon{--fa:"\e829"}.fa-water-temp,.fa-water-temperature{--fa:"\e82a"}.fa-aeropress{--fa:"\e82b"}.fa-caret-large-down{--fa:"\e82c"}.fa-caret-large-left{--fa:"\e82d"}.fa-caret-large-right{--fa:"\e82e"}.fa-caret-large-up{--fa:"\e82f"}.fa-chemex{--fa:"\e830"}.fa-hand-shaka{--fa:"\e831"}.fa-kettlebell{--fa:"\e832"}.fa-foot-wing{--fa:"\e834"}.fa-pump-impeller{--fa:"\e835"}.fa-arrow-rotate-left-10{--fa:"\e836"}.fa-arrow-rotate-right-10{--fa:"\e837"}.fa-glass-martini,.fa-martini-glass-empty{--fa:"\f000"}.fa-music{--fa:"\f001"}.fa-magnifying-glass,.fa-search{--fa:"\f002"}.fa-heart{--fa:"\f004"}.fa-star{--fa:"\f005"}.fa-user,.fa-user-alt,.fa-user-large{--fa:"\f007"}.fa-film,.fa-film-alt,.fa-film-simple{--fa:"\f008"}.fa-table-cells-large,.fa-th-large{--fa:"\f009"}.fa-table-cells,.fa-th{--fa:"\f00a"}.fa-table-list,.fa-th-list{--fa:"\f00b"}.fa-check{--fa:"\f00c"}.fa-close,.fa-multiply,.fa-remove,.fa-times,.fa-xmark{--fa:"\f00d"}.fa-magnifying-glass-plus,.fa-search-plus{--fa:"\f00e"}.fa-magnifying-glass-minus,.fa-search-minus{--fa:"\f010"}.fa-power-off{--fa:"\f011"}.fa-signal,.fa-signal-5,.fa-signal-perfect{--fa:"\f012"}.fa-cog,.fa-gear{--fa:"\f013"}.fa-home,.fa-home-alt,.fa-home-lg-alt,.fa-house{--fa:"\f015"}.fa-clock,.fa-clock-four{--fa:"\f017"}.fa-road{--fa:"\f018"}.fa-download{--fa:"\f019"}.fa-inbox{--fa:"\f01c"}.fa-arrow-right-rotate,.fa-arrow-rotate-forward,.fa-arrow-rotate-right,.fa-redo{--fa:"\f01e"}.fa-arrows-rotate,.fa-refresh,.fa-sync{--fa:"\f021"}.fa-list-alt,.fa-rectangle-list{--fa:"\f022"}.fa-lock{--fa:"\f023"}.fa-flag{--fa:"\f024"}.fa-headphones,.fa-headphones-alt,.fa-headphones-simple{--fa:"\f025"}.fa-volume-off{--fa:"\f026"}.fa-volume-down,.fa-volume-low{--fa:"\f027"}.fa-volume-high,.fa-volume-up{--fa:"\f028"}.fa-qrcode{--fa:"\f029"}.fa-barcode{--fa:"\f02a"}.fa-tag{--fa:"\f02b"}.fa-tags{--fa:"\f02c"}.fa-book{--fa:"\f02d"}.fa-bookmark{--fa:"\f02e"}.fa-print{--fa:"\f02f"}.fa-camera,.fa-camera-alt{--fa:"\f030"}.fa-font{--fa:"\f031"}.fa-bold{--fa:"\f032"}.fa-italic{--fa:"\f033"}.fa-text-height{--fa:"\f034"}.fa-text-width{--fa:"\f035"}.fa-align-left{--fa:"\f036"}.fa-align-center{--fa:"\f037"}.fa-align-right{--fa:"\f038"}.fa-align-justify{--fa:"\f039"}.fa-list,.fa-list-squares{--fa:"\f03a"}.fa-dedent,.fa-outdent{--fa:"\f03b"}.fa-indent{--fa:"\f03c"}.fa-video,.fa-video-camera{--fa:"\f03d"}.fa-image{--fa:"\f03e"}.fa-location-pin,.fa-map-marker{--fa:"\f041"}.fa-adjust,.fa-circle-half-stroke{--fa:"\f042"}.fa-droplet,.fa-tint{--fa:"\f043"}.fa-edit,.fa-pen-to-square{--fa:"\f044"}.fa-arrows,.fa-arrows-up-down-left-right{--fa:"\f047"}.fa-backward-step,.fa-step-backward{--fa:"\f048"}.fa-backward-fast,.fa-fast-backward{--fa:"\f049"}.fa-backward{--fa:"\f04a"}.fa-play{--fa:"\f04b"}.fa-pause{--fa:"\f04c"}.fa-stop{--fa:"\f04d"}.fa-forward{--fa:"\f04e"}.fa-fast-forward,.fa-forward-fast{--fa:"\f050"}.fa-forward-step,.fa-step-forward{--fa:"\f051"}.fa-eject{--fa:"\f052"}.fa-chevron-left{--fa:"\f053"}.fa-chevron-right{--fa:"\f054"}.fa-circle-plus,.fa-plus-circle{--fa:"\f055"}.fa-circle-minus,.fa-minus-circle{--fa:"\f056"}.fa-circle-xmark,.fa-times-circle,.fa-xmark-circle{--fa:"\f057"}.fa-check-circle,.fa-circle-check{--fa:"\f058"}.fa-circle-question,.fa-question-circle{--fa:"\f059"}.fa-circle-info,.fa-info-circle{--fa:"\f05a"}.fa-crosshairs{--fa:"\f05b"}.fa-ban,.fa-cancel{--fa:"\f05e"}.fa-arrow-left{--fa:"\f060"}.fa-arrow-right{--fa:"\f061"}.fa-arrow-up{--fa:"\f062"}.fa-arrow-down{--fa:"\f063"}.fa-mail-forward,.fa-share{--fa:"\f064"}.fa-expand{--fa:"\f065"}.fa-compress{--fa:"\f066"}.fa-minus,.fa-subtract{--fa:"\f068"}.fa-circle-exclamation,.fa-exclamation-circle{--fa:"\f06a"}.fa-gift{--fa:"\f06b"}.fa-leaf{--fa:"\f06c"}.fa-fire{--fa:"\f06d"}.fa-eye{--fa:"\f06e"}.fa-eye-slash{--fa:"\f070"}.fa-exclamation-triangle,.fa-triangle-exclamation,.fa-warning{--fa:"\f071"}.fa-plane{--fa:"\f072"}.fa-calendar-alt,.fa-calendar-days{--fa:"\f073"}.fa-random,.fa-shuffle{--fa:"\f074"}.fa-comment{--fa:"\f075"}.fa-magnet{--fa:"\f076"}.fa-chevron-up{--fa:"\f077"}.fa-chevron-down{--fa:"\f078"}.fa-retweet{--fa:"\f079"}.fa-cart-shopping,.fa-shopping-cart{--fa:"\f07a"}.fa-folder,.fa-folder-blank{--fa:"\f07b"}.fa-folder-open{--fa:"\f07c"}.fa-arrows-up-down,.fa-arrows-v{--fa:"\f07d"}.fa-arrows-h,.fa-arrows-left-right{--fa:"\f07e"}.fa-bar-chart,.fa-chart-bar{--fa:"\f080"}.fa-camera-retro{--fa:"\f083"}.fa-key{--fa:"\f084"}.fa-cogs,.fa-gears{--fa:"\f085"}.fa-comments{--fa:"\f086"}.fa-star-half{--fa:"\f089"}.fa-arrow-right-from-bracket,.fa-sign-out{--fa:"\f08b"}.fa-thumb-tack,.fa-thumbtack{--fa:"\f08d"}.fa-arrow-up-right-from-square,.fa-external-link{--fa:"\f08e"}.fa-arrow-right-to-bracket,.fa-sign-in{--fa:"\f090"}.fa-trophy{--fa:"\f091"}.fa-upload{--fa:"\f093"}.fa-lemon{--fa:"\f094"}.fa-phone{--fa:"\f095"}.fa-phone-square,.fa-square-phone{--fa:"\f098"}.fa-unlock{--fa:"\f09c"}.fa-credit-card,.fa-credit-card-alt{--fa:"\f09d"}.fa-feed,.fa-rss{--fa:"\f09e"}.fa-hard-drive,.fa-hdd{--fa:"\f0a0"}.fa-bullhorn{--fa:"\f0a1"}.fa-certificate{--fa:"\f0a3"}.fa-hand-point-right{--fa:"\f0a4"}.fa-hand-point-left{--fa:"\f0a5"}.fa-hand-point-up{--fa:"\f0a6"}.fa-hand-point-down{--fa:"\f0a7"}.fa-arrow-circle-left,.fa-circle-arrow-left{--fa:"\f0a8"}.fa-arrow-circle-right,.fa-circle-arrow-right{--fa:"\f0a9"}.fa-arrow-circle-up,.fa-circle-arrow-up{--fa:"\f0aa"}.fa-arrow-circle-down,.fa-circle-arrow-down{--fa:"\f0ab"}.fa-globe{--fa:"\f0ac"}.fa-wrench{--fa:"\f0ad"}.fa-list-check,.fa-tasks{--fa:"\f0ae"}.fa-filter{--fa:"\f0b0"}.fa-briefcase{--fa:"\f0b1"}.fa-arrows-alt,.fa-up-down-left-right{--fa:"\f0b2"}.fa-users{--fa:"\f0c0"}.fa-chain,.fa-link{--fa:"\f0c1"}.fa-cloud{--fa:"\f0c2"}.fa-flask{--fa:"\f0c3"}.fa-cut,.fa-scissors{--fa:"\f0c4"}.fa-copy{--fa:"\f0c5"}.fa-paperclip{--fa:"\f0c6"}.fa-floppy-disk,.fa-save{--fa:"\f0c7"}.fa-square{--fa:"\f0c8"}.fa-bars,.fa-navicon{--fa:"\f0c9"}.fa-list-dots,.fa-list-ul{--fa:"\f0ca"}.fa-list-1-2,.fa-list-numeric,.fa-list-ol{--fa:"\f0cb"}.fa-strikethrough{--fa:"\f0cc"}.fa-underline{--fa:"\f0cd"}.fa-table{--fa:"\f0ce"}.fa-magic,.fa-wand-magic{--fa:"\f0d0"}.fa-truck{--fa:"\f0d1"}.fa-money-bill{--fa:"\f0d6"}.fa-caret-down{--fa:"\f0d7"}.fa-caret-up{--fa:"\f0d8"}.fa-caret-left{--fa:"\f0d9"}.fa-caret-right{--fa:"\f0da"}.fa-columns,.fa-table-columns{--fa:"\f0db"}.fa-sort,.fa-unsorted{--fa:"\f0dc"}.fa-sort-desc,.fa-sort-down{--fa:"\f0dd"}.fa-sort-asc,.fa-sort-up{--fa:"\f0de"}.fa-envelope{--fa:"\f0e0"}.fa-arrow-left-rotate,.fa-arrow-rotate-back,.fa-arrow-rotate-backward,.fa-arrow-rotate-left,.fa-undo{--fa:"\f0e2"}.fa-gavel,.fa-legal{--fa:"\f0e3"}.fa-bolt,.fa-zap{--fa:"\f0e7"}.fa-sitemap{--fa:"\f0e8"}.fa-umbrella{--fa:"\f0e9"}.fa-file-clipboard,.fa-paste{--fa:"\f0ea"}.fa-lightbulb{--fa:"\f0eb"}.fa-arrow-right-arrow-left,.fa-exchange{--fa:"\f0ec"}.fa-cloud-arrow-down,.fa-cloud-download,.fa-cloud-download-alt{--fa:"\f0ed"}.fa-cloud-arrow-up,.fa-cloud-upload,.fa-cloud-upload-alt{--fa:"\f0ee"}.fa-user-doctor,.fa-user-md{--fa:"\f0f0"}.fa-stethoscope{--fa:"\f0f1"}.fa-suitcase{--fa:"\f0f2"}.fa-bell{--fa:"\f0f3"}.fa-coffee,.fa-mug-saucer{--fa:"\f0f4"}.fa-hospital,.fa-hospital-alt,.fa-hospital-wide{--fa:"\f0f8"}.fa-ambulance,.fa-truck-medical{--fa:"\f0f9"}.fa-medkit,.fa-suitcase-medical{--fa:"\f0fa"}.fa-fighter-jet,.fa-jet-fighter{--fa:"\f0fb"}.fa-beer,.fa-beer-mug-empty{--fa:"\f0fc"}.fa-h-square,.fa-square-h{--fa:"\f0fd"}.fa-plus-square,.fa-square-plus{--fa:"\f0fe"}.fa-angle-double-left,.fa-angles-left{--fa:"\f100"}.fa-angle-double-right,.fa-angles-right{--fa:"\f101"}.fa-angle-double-up,.fa-angles-up{--fa:"\f102"}.fa-angle-double-down,.fa-angles-down{--fa:"\f103"}.fa-angle-left{--fa:"\f104"}.fa-angle-right{--fa:"\f105"}.fa-angle-up{--fa:"\f106"}.fa-angle-down{--fa:"\f107"}.fa-laptop{--fa:"\f109"}.fa-tablet-button{--fa:"\f10a"}.fa-mobile-button{--fa:"\f10b"}.fa-quote-left,.fa-quote-left-alt{--fa:"\f10d"}.fa-quote-right,.fa-quote-right-alt{--fa:"\f10e"}.fa-spinner{--fa:"\f110"}.fa-circle{--fa:"\f111"}.fa-face-smile,.fa-smile{--fa:"\f118"}.fa-face-frown,.fa-frown{--fa:"\f119"}.fa-face-meh,.fa-meh{--fa:"\f11a"}.fa-gamepad{--fa:"\f11b"}.fa-keyboard{--fa:"\f11c"}.fa-flag-checkered{--fa:"\f11e"}.fa-terminal{--fa:"\f120"}.fa-code{--fa:"\f121"}.fa-mail-reply-all,.fa-reply-all{--fa:"\f122"}.fa-location-arrow{--fa:"\f124"}.fa-crop{--fa:"\f125"}.fa-code-branch{--fa:"\f126"}.fa-chain-broken,.fa-chain-slash,.fa-link-slash,.fa-unlink{--fa:"\f127"}.fa-info{--fa:"\f129"}.fa-superscript{--fa:"\f12b"}.fa-subscript{--fa:"\f12c"}.fa-eraser{--fa:"\f12d"}.fa-puzzle-piece{--fa:"\f12e"}.fa-microphone{--fa:"\f130"}.fa-microphone-slash{--fa:"\f131"}.fa-shield,.fa-shield-blank{--fa:"\f132"}.fa-calendar{--fa:"\f133"}.fa-fire-extinguisher{--fa:"\f134"}.fa-rocket{--fa:"\f135"}.fa-chevron-circle-left,.fa-circle-chevron-left{--fa:"\f137"}.fa-chevron-circle-right,.fa-circle-chevron-right{--fa:"\f138"}.fa-chevron-circle-up,.fa-circle-chevron-up{--fa:"\f139"}.fa-chevron-circle-down,.fa-circle-chevron-down{--fa:"\f13a"}.fa-anchor{--fa:"\f13d"}.fa-unlock-alt,.fa-unlock-keyhole{--fa:"\f13e"}.fa-bullseye{--fa:"\f140"}.fa-ellipsis,.fa-ellipsis-h{--fa:"\f141"}.fa-ellipsis-v,.fa-ellipsis-vertical{--fa:"\f142"}.fa-rss-square,.fa-square-rss{--fa:"\f143"}.fa-circle-play,.fa-play-circle{--fa:"\f144"}.fa-ticket{--fa:"\f145"}.fa-minus-square,.fa-square-minus{--fa:"\f146"}.fa-arrow-turn-up,.fa-level-up{--fa:"\f148"}.fa-arrow-turn-down,.fa-level-down{--fa:"\f149"}.fa-check-square,.fa-square-check{--fa:"\f14a"}.fa-pen-square,.fa-pencil-square,.fa-square-pen{--fa:"\f14b"}.fa-external-link-square,.fa-square-arrow-up-right{--fa:"\f14c"}.fa-share-from-square,.fa-share-square{--fa:"\f14d"}.fa-compass{--fa:"\f14e"}.fa-caret-square-down,.fa-square-caret-down{--fa:"\f150"}.fa-caret-square-up,.fa-square-caret-up{--fa:"\f151"}.fa-caret-square-right,.fa-square-caret-right{--fa:"\f152"}.fa-eur,.fa-euro,.fa-euro-sign{--fa:"\f153"}.fa-gbp,.fa-pound-sign,.fa-sterling-sign{--fa:"\f154"}.fa-rupee,.fa-rupee-sign{--fa:"\f156"}.fa-cny,.fa-jpy,.fa-rmb,.fa-yen,.fa-yen-sign{--fa:"\f157"}.fa-rouble,.fa-rub,.fa-ruble,.fa-ruble-sign{--fa:"\f158"}.fa-krw,.fa-won,.fa-won-sign{--fa:"\f159"}.fa-file{--fa:"\f15b"}.fa-file-alt,.fa-file-lines,.fa-file-text{--fa:"\f15c"}.fa-arrow-down-a-z,.fa-sort-alpha-asc,.fa-sort-alpha-down{--fa:"\f15d"}.fa-arrow-up-a-z,.fa-sort-alpha-up{--fa:"\f15e"}.fa-arrow-down-wide-short,.fa-sort-amount-asc,.fa-sort-amount-down{--fa:"\f160"}.fa-arrow-up-wide-short,.fa-sort-amount-up{--fa:"\f161"}.fa-arrow-down-1-9,.fa-sort-numeric-asc,.fa-sort-numeric-down{--fa:"\f162"}.fa-arrow-up-1-9,.fa-sort-numeric-up{--fa:"\f163"}.fa-thumbs-up{--fa:"\f164"}.fa-thumbs-down{--fa:"\f165"}.fa-arrow-down-long,.fa-long-arrow-down{--fa:"\f175"}.fa-arrow-up-long,.fa-long-arrow-up{--fa:"\f176"}.fa-arrow-left-long,.fa-long-arrow-left{--fa:"\f177"}.fa-arrow-right-long,.fa-long-arrow-right{--fa:"\f178"}.fa-female,.fa-person-dress{--fa:"\f182"}.fa-male,.fa-person{--fa:"\f183"}.fa-sun{--fa:"\f185"}.fa-moon{--fa:"\f186"}.fa-archive,.fa-box-archive{--fa:"\f187"}.fa-bug{--fa:"\f188"}.fa-caret-square-left,.fa-square-caret-left{--fa:"\f191"}.fa-circle-dot,.fa-dot-circle{--fa:"\f192"}.fa-wheelchair{--fa:"\f193"}.fa-lira-sign{--fa:"\f195"}.fa-shuttle-space,.fa-space-shuttle{--fa:"\f197"}.fa-envelope-square,.fa-square-envelope{--fa:"\f199"}.fa-bank,.fa-building-columns,.fa-institution,.fa-museum,.fa-university{--fa:"\f19c"}.fa-graduation-cap,.fa-mortar-board{--fa:"\f19d"}.fa-language{--fa:"\f1ab"}.fa-fax{--fa:"\f1ac"}.fa-building{--fa:"\f1ad"}.fa-child{--fa:"\f1ae"}.fa-paw{--fa:"\f1b0"}.fa-cube{--fa:"\f1b2"}.fa-cubes{--fa:"\f1b3"}.fa-recycle{--fa:"\f1b8"}.fa-automobile,.fa-car{--fa:"\f1b9"}.fa-cab,.fa-taxi{--fa:"\f1ba"}.fa-tree{--fa:"\f1bb"}.fa-database{--fa:"\f1c0"}.fa-file-pdf{--fa:"\f1c1"}.fa-file-word{--fa:"\f1c2"}.fa-file-excel{--fa:"\f1c3"}.fa-file-powerpoint{--fa:"\f1c4"}.fa-file-image{--fa:"\f1c5"}.fa-file-archive,.fa-file-zipper{--fa:"\f1c6"}.fa-file-audio{--fa:"\f1c7"}.fa-file-video{--fa:"\f1c8"}.fa-file-code{--fa:"\f1c9"}.fa-life-ring{--fa:"\f1cd"}.fa-circle-notch{--fa:"\f1ce"}.fa-paper-plane{--fa:"\f1d8"}.fa-clock-rotate-left,.fa-history{--fa:"\f1da"}.fa-header,.fa-heading{--fa:"\f1dc"}.fa-paragraph{--fa:"\f1dd"}.fa-sliders,.fa-sliders-h{--fa:"\f1de"}.fa-share-alt,.fa-share-nodes{--fa:"\f1e0"}.fa-share-alt-square,.fa-square-share-nodes{--fa:"\f1e1"}.fa-bomb{--fa:"\f1e2"}.fa-futbol,.fa-futbol-ball,.fa-soccer-ball{--fa:"\f1e3"}.fa-teletype,.fa-tty{--fa:"\f1e4"}.fa-binoculars{--fa:"\f1e5"}.fa-plug{--fa:"\f1e6"}.fa-newspaper{--fa:"\f1ea"}.fa-wifi,.fa-wifi-3,.fa-wifi-strong{--fa:"\f1eb"}.fa-calculator{--fa:"\f1ec"}.fa-bell-slash{--fa:"\f1f6"}.fa-trash{--fa:"\f1f8"}.fa-copyright{--fa:"\f1f9"}.fa-eye-dropper,.fa-eye-dropper-empty,.fa-eyedropper{--fa:"\f1fb"}.fa-paint-brush,.fa-paintbrush{--fa:"\f1fc"}.fa-birthday-cake,.fa-cake,.fa-cake-candles{--fa:"\f1fd"}.fa-area-chart,.fa-chart-area{--fa:"\f1fe"}.fa-chart-pie,.fa-pie-chart{--fa:"\f200"}.fa-chart-line,.fa-line-chart{--fa:"\f201"}.fa-toggle-off{--fa:"\f204"}.fa-toggle-on{--fa:"\f205"}.fa-bicycle{--fa:"\f206"}.fa-bus{--fa:"\f207"}.fa-closed-captioning{--fa:"\f20a"}.fa-ils,.fa-shekel,.fa-shekel-sign,.fa-sheqel,.fa-sheqel-sign{--fa:"\f20b"}.fa-cart-plus{--fa:"\f217"}.fa-cart-arrow-down{--fa:"\f218"}.fa-diamond{--fa:"\f219"}.fa-ship{--fa:"\f21a"}.fa-user-secret{--fa:"\f21b"}.fa-motorcycle{--fa:"\f21c"}.fa-street-view{--fa:"\f21d"}.fa-heart-pulse,.fa-heartbeat{--fa:"\f21e"}.fa-venus{--fa:"\f221"}.fa-mars{--fa:"\f222"}.fa-mercury{--fa:"\f223"}.fa-mars-and-venus{--fa:"\f224"}.fa-transgender,.fa-transgender-alt{--fa:"\f225"}.fa-venus-double{--fa:"\f226"}.fa-mars-double{--fa:"\f227"}.fa-venus-mars{--fa:"\f228"}.fa-mars-stroke{--fa:"\f229"}.fa-mars-stroke-up,.fa-mars-stroke-v{--fa:"\f22a"}.fa-mars-stroke-h,.fa-mars-stroke-right{--fa:"\f22b"}.fa-neuter{--fa:"\f22c"}.fa-genderless{--fa:"\f22d"}.fa-server{--fa:"\f233"}.fa-user-plus{--fa:"\f234"}.fa-user-times,.fa-user-xmark{--fa:"\f235"}.fa-bed{--fa:"\f236"}.fa-train{--fa:"\f238"}.fa-subway,.fa-train-subway{--fa:"\f239"}.fa-battery,.fa-battery-5,.fa-battery-full{--fa:"\f240"}.fa-battery-4,.fa-battery-three-quarters{--fa:"\f241"}.fa-battery-3,.fa-battery-half{--fa:"\f242"}.fa-battery-2,.fa-battery-quarter{--fa:"\f243"}.fa-battery-0,.fa-battery-empty{--fa:"\f244"}.fa-arrow-pointer,.fa-mouse-pointer{--fa:"\f245"}.fa-i-cursor{--fa:"\f246"}.fa-object-group{--fa:"\f247"}.fa-object-ungroup{--fa:"\f248"}.fa-note-sticky,.fa-sticky-note{--fa:"\f249"}.fa-clone{--fa:"\f24d"}.fa-balance-scale,.fa-scale-balanced{--fa:"\f24e"}.fa-hourglass-1,.fa-hourglass-start{--fa:"\f251"}.fa-hourglass-2,.fa-hourglass-half{--fa:"\f252"}.fa-hourglass-3,.fa-hourglass-end{--fa:"\f253"}.fa-hourglass,.fa-hourglass-empty{--fa:"\f254"}.fa-hand-back-fist,.fa-hand-rock{--fa:"\f255"}.fa-hand,.fa-hand-paper{--fa:"\f256"}.fa-hand-scissors{--fa:"\f257"}.fa-hand-lizard{--fa:"\f258"}.fa-hand-spock{--fa:"\f259"}.fa-hand-pointer{--fa:"\f25a"}.fa-hand-peace{--fa:"\f25b"}.fa-trademark{--fa:"\f25c"}.fa-registered{--fa:"\f25d"}.fa-television,.fa-tv,.fa-tv-alt{--fa:"\f26c"}.fa-calendar-plus{--fa:"\f271"}.fa-calendar-minus{--fa:"\f272"}.fa-calendar-times,.fa-calendar-xmark{--fa:"\f273"}.fa-calendar-check{--fa:"\f274"}.fa-industry{--fa:"\f275"}.fa-map-pin{--fa:"\f276"}.fa-map-signs,.fa-signs-post{--fa:"\f277"}.fa-map{--fa:"\f279"}.fa-comment-alt,.fa-message{--fa:"\f27a"}.fa-circle-pause,.fa-pause-circle{--fa:"\f28b"}.fa-circle-stop,.fa-stop-circle{--fa:"\f28d"}.fa-bag-shopping,.fa-shopping-bag{--fa:"\f290"}.fa-basket-shopping,.fa-shopping-basket{--fa:"\f291"}.fa-universal-access{--fa:"\f29a"}.fa-blind,.fa-person-walking-with-cane{--fa:"\f29d"}.fa-audio-description{--fa:"\f29e"}.fa-phone-volume,.fa-volume-control-phone{--fa:"\f2a0"}.fa-braille{--fa:"\f2a1"}.fa-assistive-listening-systems,.fa-ear-listen{--fa:"\f2a2"}.fa-american-sign-language-interpreting,.fa-asl-interpreting,.fa-hands-american-sign-language-interpreting,.fa-hands-asl-interpreting{--fa:"\f2a3"}.fa-deaf,.fa-deafness,.fa-ear-deaf,.fa-hard-of-hearing{--fa:"\f2a4"}.fa-hands,.fa-sign-language,.fa-signing{--fa:"\f2a7"}.fa-eye-low-vision,.fa-low-vision{--fa:"\f2a8"}.fa-handshake,.fa-handshake-alt,.fa-handshake-simple{--fa:"\f2b5"}.fa-envelope-open{--fa:"\f2b6"}.fa-address-book,.fa-contact-book{--fa:"\f2b9"}.fa-address-card,.fa-contact-card,.fa-vcard{--fa:"\f2bb"}.fa-circle-user,.fa-user-circle{--fa:"\f2bd"}.fa-id-badge{--fa:"\f2c1"}.fa-drivers-license,.fa-id-card{--fa:"\f2c2"}.fa-temperature-4,.fa-temperature-full,.fa-thermometer-4,.fa-thermometer-full{--fa:"\f2c7"}.fa-temperature-3,.fa-temperature-three-quarters,.fa-thermometer-3,.fa-thermometer-three-quarters{--fa:"\f2c8"}.fa-temperature-2,.fa-temperature-half,.fa-thermometer-2,.fa-thermometer-half{--fa:"\f2c9"}.fa-temperature-1,.fa-temperature-quarter,.fa-thermometer-1,.fa-thermometer-quarter{--fa:"\f2ca"}.fa-temperature-0,.fa-temperature-empty,.fa-thermometer-0,.fa-thermometer-empty{--fa:"\f2cb"}.fa-shower{--fa:"\f2cc"}.fa-bath,.fa-bathtub{--fa:"\f2cd"}.fa-podcast{--fa:"\f2ce"}.fa-window-maximize{--fa:"\f2d0"}.fa-window-minimize{--fa:"\f2d1"}.fa-window-restore{--fa:"\f2d2"}.fa-square-xmark,.fa-times-square,.fa-xmark-square{--fa:"\f2d3"}.fa-microchip{--fa:"\f2db"}.fa-snowflake{--fa:"\f2dc"}.fa-watch{--fa:"\f2e1"}.fa-volume-slash{--fa:"\f2e2"}.fa-fork,.fa-utensil-fork{--fa:"\f2e3"}.fa-knife,.fa-utensil-knife{--fa:"\f2e4"}.fa-spoon,.fa-utensil-spoon{--fa:"\f2e5"}.fa-fork-knife,.fa-utensils-alt{--fa:"\f2e6"}.fa-cutlery,.fa-utensils{--fa:"\f2e7"}.fa-circle-dollar,.fa-dollar-circle,.fa-usd-circle{--fa:"\f2e8"}.fa-dollar-square,.fa-square-dollar,.fa-usd-square{--fa:"\f2e9"}.fa-rotate-back,.fa-rotate-backward,.fa-rotate-left,.fa-undo-alt{--fa:"\f2ea"}.fa-trophy-alt,.fa-trophy-star{--fa:"\f2eb"}.fa-triangle{--fa:"\f2ec"}.fa-trash-alt,.fa-trash-can{--fa:"\f2ed"}.fa-hexagon-xmark,.fa-times-hexagon,.fa-xmark-hexagon{--fa:"\f2ee"}.fa-octagon-xmark,.fa-times-octagon,.fa-xmark-octagon{--fa:"\f2f0"}.fa-rotate,.fa-sync-alt{--fa:"\f2f1"}.fa-stopwatch{--fa:"\f2f2"}.fa-star-exclamation{--fa:"\f2f3"}.fa-spade{--fa:"\f2f4"}.fa-right-from-bracket,.fa-sign-out-alt{--fa:"\f2f5"}.fa-right-to-bracket,.fa-sign-in-alt{--fa:"\f2f6"}.fa-shield-check{--fa:"\f2f7"}.fa-scrubber{--fa:"\f2f8"}.fa-redo-alt,.fa-rotate-forward,.fa-rotate-right{--fa:"\f2f9"}.fa-rectangle,.fa-rectangle-landscape{--fa:"\f2fa"}.fa-rectangle-portrait,.fa-rectangle-vertical{--fa:"\f2fb"}.fa-rectangle-wide{--fa:"\f2fc"}.fa-question-square,.fa-square-question{--fa:"\f2fd"}.fa-poo{--fa:"\f2fe"}.fa-hexagon-plus,.fa-plus-hexagon{--fa:"\f300"}.fa-octagon-plus,.fa-plus-octagon{--fa:"\f301"}.fa-images{--fa:"\f302"}.fa-pencil,.fa-pencil-alt{--fa:"\f303"}.fa-pen{--fa:"\f304"}.fa-pen-alt,.fa-pen-clip{--fa:"\f305"}.fa-octagon{--fa:"\f306"}.fa-hexagon-minus,.fa-minus-hexagon{--fa:"\f307"}.fa-minus-octagon,.fa-octagon-minus{--fa:"\f308"}.fa-down-long,.fa-long-arrow-alt-down{--fa:"\f309"}.fa-left-long,.fa-long-arrow-alt-left{--fa:"\f30a"}.fa-long-arrow-alt-right,.fa-right-long{--fa:"\f30b"}.fa-long-arrow-alt-up,.fa-up-long{--fa:"\f30c"}.fa-lock-alt,.fa-lock-keyhole{--fa:"\f30d"}.fa-jack-o-lantern{--fa:"\f30e"}.fa-info-square,.fa-square-info{--fa:"\f30f"}.fa-inbox-arrow-down,.fa-inbox-in{--fa:"\f310"}.fa-inbox-arrow-up,.fa-inbox-out{--fa:"\f311"}.fa-hexagon{--fa:"\f312"}.fa-h1{--fa:"\f313"}.fa-h2{--fa:"\f314"}.fa-h3{--fa:"\f315"}.fa-file-check{--fa:"\f316"}.fa-file-times,.fa-file-xmark{--fa:"\f317"}.fa-file-minus{--fa:"\f318"}.fa-file-plus{--fa:"\f319"}.fa-file-exclamation{--fa:"\f31a"}.fa-file-edit,.fa-file-pen{--fa:"\f31c"}.fa-arrows-maximize,.fa-expand-arrows{--fa:"\f31d"}.fa-expand-arrows-alt,.fa-maximize{--fa:"\f31e"}.fa-expand-wide{--fa:"\f320"}.fa-exclamation-square,.fa-square-exclamation{--fa:"\f321"}.fa-chevron-double-down,.fa-chevrons-down{--fa:"\f322"}.fa-chevron-double-left,.fa-chevrons-left{--fa:"\f323"}.fa-chevron-double-right,.fa-chevrons-right{--fa:"\f324"}.fa-chevron-double-up,.fa-chevrons-up{--fa:"\f325"}.fa-compress-wide{--fa:"\f326"}.fa-club{--fa:"\f327"}.fa-clipboard{--fa:"\f328"}.fa-chevron-square-down,.fa-square-chevron-down{--fa:"\f329"}.fa-chevron-square-left,.fa-square-chevron-left{--fa:"\f32a"}.fa-chevron-square-right,.fa-square-chevron-right{--fa:"\f32b"}.fa-chevron-square-up,.fa-square-chevron-up{--fa:"\f32c"}.fa-caret-circle-down,.fa-circle-caret-down{--fa:"\f32d"}.fa-caret-circle-left,.fa-circle-caret-left{--fa:"\f32e"}.fa-caret-circle-right,.fa-circle-caret-right{--fa:"\f330"}.fa-caret-circle-up,.fa-circle-caret-up{--fa:"\f331"}.fa-calendar-edit,.fa-calendar-pen{--fa:"\f333"}.fa-calendar-exclamation{--fa:"\f334"}.fa-badge{--fa:"\f335"}.fa-badge-check{--fa:"\f336"}.fa-arrows-alt-h,.fa-left-right{--fa:"\f337"}.fa-arrows-alt-v,.fa-up-down{--fa:"\f338"}.fa-arrow-square-down,.fa-square-arrow-down{--fa:"\f339"}.fa-arrow-square-left,.fa-square-arrow-left{--fa:"\f33a"}.fa-arrow-square-right,.fa-square-arrow-right{--fa:"\f33b"}.fa-arrow-square-up,.fa-square-arrow-up{--fa:"\f33c"}.fa-arrow-down-to-line,.fa-arrow-to-bottom{--fa:"\f33d"}.fa-arrow-left-to-line,.fa-arrow-to-left{--fa:"\f33e"}.fa-arrow-right-to-line,.fa-arrow-to-right{--fa:"\f340"}.fa-arrow-to-top,.fa-arrow-up-to-line{--fa:"\f341"}.fa-arrow-from-bottom,.fa-arrow-up-from-line{--fa:"\f342"}.fa-arrow-from-left,.fa-arrow-right-from-line{--fa:"\f343"}.fa-arrow-from-right,.fa-arrow-left-from-line{--fa:"\f344"}.fa-arrow-down-from-line,.fa-arrow-from-top{--fa:"\f345"}.fa-arrow-alt-from-bottom,.fa-up-from-line{--fa:"\f346"}.fa-arrow-alt-from-left,.fa-right-from-line{--fa:"\f347"}.fa-arrow-alt-from-right,.fa-left-from-line{--fa:"\f348"}.fa-arrow-alt-from-top,.fa-down-from-line{--fa:"\f349"}.fa-arrow-alt-to-bottom,.fa-down-to-line{--fa:"\f34a"}.fa-arrow-alt-to-left,.fa-left-to-line{--fa:"\f34b"}.fa-arrow-alt-to-right,.fa-right-to-line{--fa:"\f34c"}.fa-arrow-alt-to-top,.fa-up-to-line{--fa:"\f34d"}.fa-alarm-clock{--fa:"\f34e"}.fa-arrow-alt-square-down,.fa-square-down{--fa:"\f350"}.fa-arrow-alt-square-left,.fa-square-left{--fa:"\f351"}.fa-arrow-alt-square-right,.fa-square-right{--fa:"\f352"}.fa-arrow-alt-square-up,.fa-square-up{--fa:"\f353"}.fa-arrow-alt-down,.fa-down{--fa:"\f354"}.fa-arrow-alt-left,.fa-left{--fa:"\f355"}.fa-arrow-alt-right,.fa-right{--fa:"\f356"}.fa-arrow-alt-up,.fa-up{--fa:"\f357"}.fa-arrow-alt-circle-down,.fa-circle-down{--fa:"\f358"}.fa-arrow-alt-circle-left,.fa-circle-left{--fa:"\f359"}.fa-arrow-alt-circle-right,.fa-circle-right{--fa:"\f35a"}.fa-arrow-alt-circle-up,.fa-circle-up{--fa:"\f35b"}.fa-external-link-alt,.fa-up-right-from-square{--fa:"\f35d"}.fa-external-link-square-alt,.fa-square-up-right{--fa:"\f360"}.fa-arrows-retweet,.fa-retweet-alt{--fa:"\f361"}.fa-exchange-alt,.fa-right-left{--fa:"\f362"}.fa-repeat{--fa:"\f363"}.fa-arrows-repeat,.fa-repeat-alt{--fa:"\f364"}.fa-repeat-1{--fa:"\f365"}.fa-arrows-repeat-1,.fa-repeat-1-alt{--fa:"\f366"}.fa-share-all{--fa:"\f367"}.fa-battery-bolt{--fa:"\f376"}.fa-battery-slash{--fa:"\f377"}.fa-browser{--fa:"\f37e"}.fa-code-commit{--fa:"\f386"}.fa-code-merge{--fa:"\f387"}.fa-credit-card-blank{--fa:"\f389"}.fa-credit-card-front{--fa:"\f38a"}.fa-desktop,.fa-desktop-alt{--fa:"\f390"}.fa-ellipsis-h-alt,.fa-ellipsis-stroke{--fa:"\f39b"}.fa-ellipsis-stroke-vertical,.fa-ellipsis-v-alt{--fa:"\f39c"}.fa-gem{--fa:"\f3a5"}.fa-industry-alt,.fa-industry-windows{--fa:"\f3b3"}.fa-level-down-alt,.fa-turn-down{--fa:"\f3be"}.fa-level-up-alt,.fa-turn-up{--fa:"\f3bf"}.fa-lock-open{--fa:"\f3c1"}.fa-lock-keyhole-open,.fa-lock-open-alt{--fa:"\f3c2"}.fa-location-dot,.fa-map-marker-alt{--fa:"\f3c5"}.fa-microphone-alt,.fa-microphone-lines{--fa:"\f3c9"}.fa-mobile-alt,.fa-mobile-screen-button{--fa:"\f3cd"}.fa-mobile,.fa-mobile-android,.fa-mobile-phone{--fa:"\f3ce"}.fa-mobile-android-alt,.fa-mobile-screen{--fa:"\f3cf"}.fa-money-bill-1,.fa-money-bill-alt{--fa:"\f3d1"}.fa-phone-slash{--fa:"\f3dd"}.fa-plane-alt,.fa-plane-engines{--fa:"\f3de"}.fa-image-portrait,.fa-portrait{--fa:"\f3e0"}.fa-mail-reply,.fa-reply{--fa:"\f3e5"}.fa-shield-alt,.fa-shield-halved{--fa:"\f3ed"}.fa-sliders-h-square,.fa-square-sliders{--fa:"\f3f0"}.fa-sliders-up,.fa-sliders-v{--fa:"\f3f1"}.fa-sliders-v-square,.fa-square-sliders-vertical{--fa:"\f3f2"}.fa-spinner-third{--fa:"\f3f4"}.fa-tablet-alt,.fa-tablet-screen-button{--fa:"\f3fa"}.fa-tablet,.fa-tablet-android{--fa:"\f3fb"}.fa-tablet-android-alt,.fa-tablet-screen{--fa:"\f3fc"}.fa-ticket-alt,.fa-ticket-simple{--fa:"\f3ff"}.fa-tree-alt,.fa-tree-deciduous{--fa:"\f400"}.fa-tv-retro{--fa:"\f401"}.fa-window{--fa:"\f40e"}.fa-window-alt,.fa-window-flip{--fa:"\f40f"}.fa-rectangle-times,.fa-rectangle-xmark,.fa-times-rectangle,.fa-window-close{--fa:"\f410"}.fa-compress-alt,.fa-down-left-and-up-right-to-center{--fa:"\f422"}.fa-expand-alt,.fa-up-right-and-down-left-from-center{--fa:"\f424"}.fa-baseball-bat-ball{--fa:"\f432"}.fa-baseball,.fa-baseball-ball{--fa:"\f433"}.fa-basketball,.fa-basketball-ball{--fa:"\f434"}.fa-basketball-hoop{--fa:"\f435"}.fa-bowling-ball{--fa:"\f436"}.fa-bowling-pins{--fa:"\f437"}.fa-boxing-glove,.fa-glove-boxing{--fa:"\f438"}.fa-chess{--fa:"\f439"}.fa-chess-bishop{--fa:"\f43a"}.fa-chess-bishop-alt,.fa-chess-bishop-piece{--fa:"\f43b"}.fa-chess-board{--fa:"\f43c"}.fa-chess-clock{--fa:"\f43d"}.fa-chess-clock-alt,.fa-chess-clock-flip{--fa:"\f43e"}.fa-chess-king{--fa:"\f43f"}.fa-chess-king-alt,.fa-chess-king-piece{--fa:"\f440"}.fa-chess-knight{--fa:"\f441"}.fa-chess-knight-alt,.fa-chess-knight-piece{--fa:"\f442"}.fa-chess-pawn{--fa:"\f443"}.fa-chess-pawn-alt,.fa-chess-pawn-piece{--fa:"\f444"}.fa-chess-queen{--fa:"\f445"}.fa-chess-queen-alt,.fa-chess-queen-piece{--fa:"\f446"}.fa-chess-rook{--fa:"\f447"}.fa-chess-rook-alt,.fa-chess-rook-piece{--fa:"\f448"}.fa-cricket,.fa-cricket-bat-ball{--fa:"\f449"}.fa-curling,.fa-curling-stone{--fa:"\f44a"}.fa-dumbbell{--fa:"\f44b"}.fa-field-hockey,.fa-field-hockey-stick-ball{--fa:"\f44c"}.fa-football,.fa-football-ball{--fa:"\f44e"}.fa-football-helmet{--fa:"\f44f"}.fa-golf-ball,.fa-golf-ball-tee{--fa:"\f450"}.fa-golf-club{--fa:"\f451"}.fa-hockey-puck{--fa:"\f453"}.fa-hockey-sticks{--fa:"\f454"}.fa-luchador,.fa-luchador-mask,.fa-mask-luchador{--fa:"\f455"}.fa-flag-pennant,.fa-pennant{--fa:"\f456"}.fa-broom-ball,.fa-quidditch,.fa-quidditch-broom-ball{--fa:"\f458"}.fa-racquet{--fa:"\f45a"}.fa-shuttlecock{--fa:"\f45b"}.fa-square-full{--fa:"\f45c"}.fa-ping-pong-paddle-ball,.fa-table-tennis,.fa-table-tennis-paddle-ball{--fa:"\f45d"}.fa-tennis-ball{--fa:"\f45e"}.fa-volleyball,.fa-volleyball-ball{--fa:"\f45f"}.fa-whistle{--fa:"\f460"}.fa-allergies,.fa-hand-dots{--fa:"\f461"}.fa-band-aid,.fa-bandage{--fa:"\f462"}.fa-barcode-alt,.fa-rectangle-barcode{--fa:"\f463"}.fa-barcode-read{--fa:"\f464"}.fa-barcode-scan{--fa:"\f465"}.fa-box{--fa:"\f466"}.fa-box-check{--fa:"\f467"}.fa-boxes,.fa-boxes-alt,.fa-boxes-stacked{--fa:"\f468"}.fa-briefcase-medical{--fa:"\f469"}.fa-burn,.fa-fire-flame-simple{--fa:"\f46a"}.fa-capsules{--fa:"\f46b"}.fa-clipboard-check{--fa:"\f46c"}.fa-clipboard-list{--fa:"\f46d"}.fa-conveyor-belt{--fa:"\f46e"}.fa-conveyor-belt-alt,.fa-conveyor-belt-boxes{--fa:"\f46f"}.fa-diagnoses,.fa-person-dots-from-line{--fa:"\f470"}.fa-dna{--fa:"\f471"}.fa-dolly,.fa-dolly-box{--fa:"\f472"}.fa-dolly-empty{--fa:"\f473"}.fa-cart-flatbed,.fa-dolly-flatbed{--fa:"\f474"}.fa-cart-flatbed-boxes,.fa-dolly-flatbed-alt{--fa:"\f475"}.fa-cart-flatbed-empty,.fa-dolly-flatbed-empty{--fa:"\f476"}.fa-file-medical{--fa:"\f477"}.fa-file-medical-alt,.fa-file-waveform{--fa:"\f478"}.fa-first-aid,.fa-kit-medical{--fa:"\f479"}.fa-forklift{--fa:"\f47a"}.fa-hand-holding-box{--fa:"\f47b"}.fa-hand-receiving,.fa-hands-holding-diamond{--fa:"\f47c"}.fa-circle-h,.fa-hospital-symbol{--fa:"\f47e"}.fa-id-card-alt,.fa-id-card-clip{--fa:"\f47f"}.fa-inventory,.fa-shelves{--fa:"\f480"}.fa-notes-medical{--fa:"\f481"}.fa-pallet{--fa:"\f482"}.fa-palette-boxes,.fa-pallet-alt,.fa-pallet-boxes{--fa:"\f483"}.fa-pills{--fa:"\f484"}.fa-prescription-bottle{--fa:"\f485"}.fa-prescription-bottle-alt,.fa-prescription-bottle-medical{--fa:"\f486"}.fa-bed-pulse,.fa-procedures{--fa:"\f487"}.fa-scanner,.fa-scanner-gun{--fa:"\f488"}.fa-scanner-keyboard{--fa:"\f489"}.fa-scanner-touchscreen{--fa:"\f48a"}.fa-shipping-fast,.fa-truck-fast{--fa:"\f48b"}.fa-shipping-timed,.fa-truck-clock{--fa:"\f48c"}.fa-smoking{--fa:"\f48d"}.fa-syringe{--fa:"\f48e"}.fa-tablet-rugged{--fa:"\f48f"}.fa-tablets{--fa:"\f490"}.fa-thermometer{--fa:"\f491"}.fa-vial{--fa:"\f492"}.fa-vials{--fa:"\f493"}.fa-warehouse{--fa:"\f494"}.fa-warehouse-alt,.fa-warehouse-full{--fa:"\f495"}.fa-weight,.fa-weight-scale{--fa:"\f496"}.fa-x-ray{--fa:"\f497"}.fa-blanket{--fa:"\f498"}.fa-book-heart{--fa:"\f499"}.fa-box-alt,.fa-box-taped{--fa:"\f49a"}.fa-box-fragile,.fa-square-fragile,.fa-square-wine-glass-crack{--fa:"\f49b"}.fa-box-full,.fa-box-open-full{--fa:"\f49c"}.fa-box-heart{--fa:"\f49d"}.fa-box-open{--fa:"\f49e"}.fa-box-up,.fa-square-this-way-up{--fa:"\f49f"}.fa-box-dollar,.fa-box-usd{--fa:"\f4a0"}.fa-comment-alt-check,.fa-message-check{--fa:"\f4a2"}.fa-comment-alt-dots,.fa-message-dots,.fa-messaging{--fa:"\f4a3"}.fa-comment-alt-edit,.fa-message-edit,.fa-message-pen{--fa:"\f4a4"}.fa-comment-alt-exclamation,.fa-message-exclamation{--fa:"\f4a5"}.fa-comment-alt-lines,.fa-message-lines{--fa:"\f4a6"}.fa-comment-alt-minus,.fa-message-minus{--fa:"\f4a7"}.fa-comment-alt-plus,.fa-message-plus{--fa:"\f4a8"}.fa-comment-alt-slash,.fa-message-slash{--fa:"\f4a9"}.fa-comment-alt-smile,.fa-message-smile{--fa:"\f4aa"}.fa-comment-alt-times,.fa-message-times,.fa-message-xmark{--fa:"\f4ab"}.fa-comment-check{--fa:"\f4ac"}.fa-comment-dots,.fa-commenting{--fa:"\f4ad"}.fa-comment-edit,.fa-comment-pen{--fa:"\f4ae"}.fa-comment-exclamation{--fa:"\f4af"}.fa-comment-lines{--fa:"\f4b0"}.fa-comment-minus{--fa:"\f4b1"}.fa-comment-plus{--fa:"\f4b2"}.fa-comment-slash{--fa:"\f4b3"}.fa-comment-smile{--fa:"\f4b4"}.fa-comment-times,.fa-comment-xmark{--fa:"\f4b5"}.fa-comments-alt,.fa-messages{--fa:"\f4b6"}.fa-container-storage{--fa:"\f4b7"}.fa-couch{--fa:"\f4b8"}.fa-circle-dollar-to-slot,.fa-donate{--fa:"\f4b9"}.fa-dove{--fa:"\f4ba"}.fa-fragile,.fa-wine-glass-crack{--fa:"\f4bb"}.fa-hand-heart{--fa:"\f4bc"}.fa-hand-holding{--fa:"\f4bd"}.fa-hand-holding-heart{--fa:"\f4be"}.fa-hand-holding-seedling{--fa:"\f4bf"}.fa-hand-holding-dollar,.fa-hand-holding-usd{--fa:"\f4c0"}.fa-hand-holding-droplet,.fa-hand-holding-water{--fa:"\f4c1"}.fa-hands-holding{--fa:"\f4c2"}.fa-hands-heart,.fa-hands-holding-heart{--fa:"\f4c3"}.fa-hands-helping,.fa-handshake-angle{--fa:"\f4c4"}.fa-hands-holding-dollar,.fa-hands-usd{--fa:"\f4c5"}.fa-circle-heart,.fa-heart-circle{--fa:"\f4c7"}.fa-heart-square,.fa-square-heart{--fa:"\f4c8"}.fa-home-heart,.fa-house-heart{--fa:"\f4c9"}.fa-lamp{--fa:"\f4ca"}.fa-leaf-heart{--fa:"\f4cb"}.fa-couch-small,.fa-loveseat{--fa:"\f4cc"}.fa-parachute-box{--fa:"\f4cd"}.fa-people-carry,.fa-people-carry-box{--fa:"\f4ce"}.fa-person-carry,.fa-person-carry-box{--fa:"\f4cf"}.fa-person-dolly{--fa:"\f4d0"}.fa-person-dolly-empty{--fa:"\f4d1"}.fa-phone-plus{--fa:"\f4d2"}.fa-piggy-bank{--fa:"\f4d3"}.fa-ramp-loading{--fa:"\f4d4"}.fa-ribbon{--fa:"\f4d6"}.fa-route{--fa:"\f4d7"}.fa-seedling,.fa-sprout{--fa:"\f4d8"}.fa-sign,.fa-sign-hanging{--fa:"\f4d9"}.fa-face-smile-wink,.fa-smile-wink{--fa:"\f4da"}.fa-tape{--fa:"\f4db"}.fa-truck-container{--fa:"\f4dc"}.fa-truck-couch,.fa-truck-ramp-couch{--fa:"\f4dd"}.fa-truck-loading,.fa-truck-ramp-box{--fa:"\f4de"}.fa-truck-moving{--fa:"\f4df"}.fa-truck-ramp{--fa:"\f4e0"}.fa-video-plus{--fa:"\f4e1"}.fa-video-slash{--fa:"\f4e2"}.fa-wine-glass{--fa:"\f4e3"}.fa-user-astronaut{--fa:"\f4fb"}.fa-user-check{--fa:"\f4fc"}.fa-user-clock{--fa:"\f4fd"}.fa-user-cog,.fa-user-gear{--fa:"\f4fe"}.fa-user-edit,.fa-user-pen{--fa:"\f4ff"}.fa-user-friends,.fa-user-group{--fa:"\f500"}.fa-user-graduate{--fa:"\f501"}.fa-user-lock{--fa:"\f502"}.fa-user-minus{--fa:"\f503"}.fa-user-ninja{--fa:"\f504"}.fa-user-shield{--fa:"\f505"}.fa-user-alt-slash,.fa-user-large-slash,.fa-user-slash{--fa:"\f506"}.fa-user-tag{--fa:"\f507"}.fa-user-tie{--fa:"\f508"}.fa-users-cog,.fa-users-gear{--fa:"\f509"}.fa-balance-scale-left,.fa-scale-unbalanced{--fa:"\f515"}.fa-balance-scale-right,.fa-scale-unbalanced-flip{--fa:"\f516"}.fa-blender{--fa:"\f517"}.fa-book-open{--fa:"\f518"}.fa-broadcast-tower,.fa-tower-broadcast{--fa:"\f519"}.fa-broom{--fa:"\f51a"}.fa-blackboard,.fa-chalkboard{--fa:"\f51b"}.fa-chalkboard-teacher,.fa-chalkboard-user{--fa:"\f51c"}.fa-church{--fa:"\f51d"}.fa-coins{--fa:"\f51e"}.fa-compact-disc{--fa:"\f51f"}.fa-crow{--fa:"\f520"}.fa-crown{--fa:"\f521"}.fa-dice{--fa:"\f522"}.fa-dice-five{--fa:"\f523"}.fa-dice-four{--fa:"\f524"}.fa-dice-one{--fa:"\f525"}.fa-dice-six{--fa:"\f526"}.fa-dice-three{--fa:"\f527"}.fa-dice-two{--fa:"\f528"}.fa-divide{--fa:"\f529"}.fa-door-closed{--fa:"\f52a"}.fa-door-open{--fa:"\f52b"}.fa-feather{--fa:"\f52d"}.fa-frog{--fa:"\f52e"}.fa-gas-pump{--fa:"\f52f"}.fa-glasses{--fa:"\f530"}.fa-greater-than-equal{--fa:"\f532"}.fa-helicopter{--fa:"\f533"}.fa-infinity{--fa:"\f534"}.fa-kiwi-bird{--fa:"\f535"}.fa-less-than-equal{--fa:"\f537"}.fa-memory{--fa:"\f538"}.fa-microphone-alt-slash,.fa-microphone-lines-slash{--fa:"\f539"}.fa-money-bill-wave{--fa:"\f53a"}.fa-money-bill-1-wave,.fa-money-bill-wave-alt{--fa:"\f53b"}.fa-money-check{--fa:"\f53c"}.fa-money-check-alt,.fa-money-check-dollar{--fa:"\f53d"}.fa-not-equal{--fa:"\f53e"}.fa-palette{--fa:"\f53f"}.fa-parking,.fa-square-parking{--fa:"\f540"}.fa-diagram-project,.fa-project-diagram{--fa:"\f542"}.fa-receipt{--fa:"\f543"}.fa-robot{--fa:"\f544"}.fa-ruler{--fa:"\f545"}.fa-ruler-combined{--fa:"\f546"}.fa-ruler-horizontal{--fa:"\f547"}.fa-ruler-vertical{--fa:"\f548"}.fa-school{--fa:"\f549"}.fa-screwdriver{--fa:"\f54a"}.fa-shoe-prints{--fa:"\f54b"}.fa-skull{--fa:"\f54c"}.fa-ban-smoking,.fa-smoking-ban{--fa:"\f54d"}.fa-store{--fa:"\f54e"}.fa-shop,.fa-store-alt{--fa:"\f54f"}.fa-bars-staggered,.fa-reorder,.fa-stream{--fa:"\f550"}.fa-stroopwafel{--fa:"\f551"}.fa-toolbox{--fa:"\f552"}.fa-shirt,.fa-t-shirt,.fa-tshirt{--fa:"\f553"}.fa-person-walking,.fa-walking{--fa:"\f554"}.fa-wallet{--fa:"\f555"}.fa-angry,.fa-face-angry{--fa:"\f556"}.fa-archway{--fa:"\f557"}.fa-atlas,.fa-book-atlas{--fa:"\f558"}.fa-award{--fa:"\f559"}.fa-backspace,.fa-delete-left{--fa:"\f55a"}.fa-bezier-curve{--fa:"\f55b"}.fa-bong{--fa:"\f55c"}.fa-brush{--fa:"\f55d"}.fa-bus-alt,.fa-bus-simple{--fa:"\f55e"}.fa-cannabis{--fa:"\f55f"}.fa-check-double{--fa:"\f560"}.fa-cocktail,.fa-martini-glass-citrus{--fa:"\f561"}.fa-bell-concierge,.fa-concierge-bell{--fa:"\f562"}.fa-cookie{--fa:"\f563"}.fa-cookie-bite{--fa:"\f564"}.fa-crop-alt,.fa-crop-simple{--fa:"\f565"}.fa-digital-tachograph,.fa-tachograph-digital{--fa:"\f566"}.fa-dizzy,.fa-face-dizzy{--fa:"\f567"}.fa-compass-drafting,.fa-drafting-compass{--fa:"\f568"}.fa-drum{--fa:"\f569"}.fa-drum-steelpan{--fa:"\f56a"}.fa-feather-alt,.fa-feather-pointed{--fa:"\f56b"}.fa-file-contract{--fa:"\f56c"}.fa-file-arrow-down,.fa-file-download{--fa:"\f56d"}.fa-arrow-right-from-file,.fa-file-export{--fa:"\f56e"}.fa-arrow-right-to-file,.fa-file-import{--fa:"\f56f"}.fa-file-invoice{--fa:"\f570"}.fa-file-invoice-dollar{--fa:"\f571"}.fa-file-prescription{--fa:"\f572"}.fa-file-signature{--fa:"\f573"}.fa-file-arrow-up,.fa-file-upload{--fa:"\f574"}.fa-fill{--fa:"\f575"}.fa-fill-drip{--fa:"\f576"}.fa-fingerprint{--fa:"\f577"}.fa-fish{--fa:"\f578"}.fa-face-flushed,.fa-flushed{--fa:"\f579"}.fa-face-frown-open,.fa-frown-open{--fa:"\f57a"}.fa-glass-martini-alt,.fa-martini-glass{--fa:"\f57b"}.fa-earth-africa,.fa-globe-africa{--fa:"\f57c"}.fa-earth,.fa-earth-america,.fa-earth-americas,.fa-globe-americas{--fa:"\f57d"}.fa-earth-asia,.fa-globe-asia{--fa:"\f57e"}.fa-face-grimace,.fa-grimace{--fa:"\f57f"}.fa-face-grin,.fa-grin{--fa:"\f580"}.fa-face-grin-wide,.fa-grin-alt{--fa:"\f581"}.fa-face-grin-beam,.fa-grin-beam{--fa:"\f582"}.fa-face-grin-beam-sweat,.fa-grin-beam-sweat{--fa:"\f583"}.fa-face-grin-hearts,.fa-grin-hearts{--fa:"\f584"}.fa-face-grin-squint,.fa-grin-squint{--fa:"\f585"}.fa-face-grin-squint-tears,.fa-grin-squint-tears{--fa:"\f586"}.fa-face-grin-stars,.fa-grin-stars{--fa:"\f587"}.fa-face-grin-tears,.fa-grin-tears{--fa:"\f588"}.fa-face-grin-tongue,.fa-grin-tongue{--fa:"\f589"}.fa-face-grin-tongue-squint,.fa-grin-tongue-squint{--fa:"\f58a"}.fa-face-grin-tongue-wink,.fa-grin-tongue-wink{--fa:"\f58b"}.fa-face-grin-wink,.fa-grin-wink{--fa:"\f58c"}.fa-grid-horizontal,.fa-grip,.fa-grip-horizontal{--fa:"\f58d"}.fa-grid-vertical,.fa-grip-vertical{--fa:"\f58e"}.fa-headset{--fa:"\f590"}.fa-highlighter{--fa:"\f591"}.fa-hot-tub,.fa-hot-tub-person{--fa:"\f593"}.fa-hotel{--fa:"\f594"}.fa-joint{--fa:"\f595"}.fa-face-kiss,.fa-kiss{--fa:"\f596"}.fa-face-kiss-beam,.fa-kiss-beam{--fa:"\f597"}.fa-face-kiss-wink-heart,.fa-kiss-wink-heart{--fa:"\f598"}.fa-face-laugh,.fa-laugh{--fa:"\f599"}.fa-face-laugh-beam,.fa-laugh-beam{--fa:"\f59a"}.fa-face-laugh-squint,.fa-laugh-squint{--fa:"\f59b"}.fa-face-laugh-wink,.fa-laugh-wink{--fa:"\f59c"}.fa-cart-flatbed-suitcase,.fa-luggage-cart{--fa:"\f59d"}.fa-map-location,.fa-map-marked{--fa:"\f59f"}.fa-map-location-dot,.fa-map-marked-alt{--fa:"\f5a0"}.fa-marker{--fa:"\f5a1"}.fa-medal{--fa:"\f5a2"}.fa-face-meh-blank,.fa-meh-blank{--fa:"\f5a4"}.fa-face-rolling-eyes,.fa-meh-rolling-eyes{--fa:"\f5a5"}.fa-monument{--fa:"\f5a6"}.fa-mortar-pestle{--fa:"\f5a7"}.fa-paint-brush-alt,.fa-paint-brush-fine,.fa-paintbrush-alt,.fa-paintbrush-fine{--fa:"\f5a9"}.fa-paint-roller{--fa:"\f5aa"}.fa-passport{--fa:"\f5ab"}.fa-pen-fancy{--fa:"\f5ac"}.fa-pen-nib{--fa:"\f5ad"}.fa-pen-ruler,.fa-pencil-ruler{--fa:"\f5ae"}.fa-plane-arrival{--fa:"\f5af"}.fa-plane-departure{--fa:"\f5b0"}.fa-prescription{--fa:"\f5b1"}.fa-face-sad-cry,.fa-sad-cry{--fa:"\f5b3"}.fa-face-sad-tear,.fa-sad-tear{--fa:"\f5b4"}.fa-shuttle-van,.fa-van-shuttle{--fa:"\f5b6"}.fa-signature{--fa:"\f5b7"}.fa-face-smile-beam,.fa-smile-beam{--fa:"\f5b8"}.fa-face-smile-plus,.fa-smile-plus{--fa:"\f5b9"}.fa-solar-panel{--fa:"\f5ba"}.fa-spa{--fa:"\f5bb"}.fa-splotch{--fa:"\f5bc"}.fa-spray-can{--fa:"\f5bd"}.fa-stamp{--fa:"\f5bf"}.fa-star-half-alt,.fa-star-half-stroke{--fa:"\f5c0"}.fa-suitcase-rolling{--fa:"\f5c1"}.fa-face-surprise,.fa-surprise{--fa:"\f5c2"}.fa-swatchbook{--fa:"\f5c3"}.fa-person-swimming,.fa-swimmer{--fa:"\f5c4"}.fa-ladder-water,.fa-swimming-pool,.fa-water-ladder{--fa:"\f5c5"}.fa-droplet-slash,.fa-tint-slash{--fa:"\f5c7"}.fa-face-tired,.fa-tired{--fa:"\f5c8"}.fa-tooth{--fa:"\f5c9"}.fa-umbrella-beach{--fa:"\f5ca"}.fa-weight-hanging{--fa:"\f5cd"}.fa-wine-glass-alt,.fa-wine-glass-empty{--fa:"\f5ce"}.fa-air-freshener,.fa-spray-can-sparkles{--fa:"\f5d0"}.fa-apple-alt,.fa-apple-whole{--fa:"\f5d1"}.fa-atom{--fa:"\f5d2"}.fa-atom-alt,.fa-atom-simple{--fa:"\f5d3"}.fa-backpack{--fa:"\f5d4"}.fa-bell-school{--fa:"\f5d5"}.fa-bell-school-slash{--fa:"\f5d6"}.fa-bone{--fa:"\f5d7"}.fa-bone-break{--fa:"\f5d8"}.fa-book-alt,.fa-book-blank{--fa:"\f5d9"}.fa-book-open-reader,.fa-book-reader{--fa:"\f5da"}.fa-books{--fa:"\f5db"}.fa-brain{--fa:"\f5dc"}.fa-bus-school{--fa:"\f5dd"}.fa-car-alt,.fa-car-rear{--fa:"\f5de"}.fa-battery-car,.fa-car-battery{--fa:"\f5df"}.fa-car-bump{--fa:"\f5e0"}.fa-car-burst,.fa-car-crash{--fa:"\f5e1"}.fa-car-garage{--fa:"\f5e2"}.fa-car-mechanic,.fa-car-wrench{--fa:"\f5e3"}.fa-car-side{--fa:"\f5e4"}.fa-car-tilt{--fa:"\f5e5"}.fa-car-wash{--fa:"\f5e6"}.fa-charging-station{--fa:"\f5e7"}.fa-clipboard-prescription{--fa:"\f5e8"}.fa-compass-slash{--fa:"\f5e9"}.fa-diploma,.fa-scroll-ribbon{--fa:"\f5ea"}.fa-diamond-turn-right,.fa-directions{--fa:"\f5eb"}.fa-do-not-enter{--fa:"\f5ec"}.fa-draw-circle,.fa-vector-circle{--fa:"\f5ed"}.fa-draw-polygon,.fa-vector-polygon{--fa:"\f5ee"}.fa-draw-square,.fa-vector-square{--fa:"\f5ef"}.fa-ear{--fa:"\f5f0"}.fa-engine-exclamation,.fa-engine-warning{--fa:"\f5f2"}.fa-file-award,.fa-file-certificate{--fa:"\f5f3"}.fa-gas-pump-slash{--fa:"\f5f4"}.fa-glasses-alt,.fa-glasses-round{--fa:"\f5f5"}.fa-globe-stand{--fa:"\f5f6"}.fa-heart-rate,.fa-wave-pulse{--fa:"\f5f8"}.fa-inhaler{--fa:"\f5f9"}.fa-kidneys{--fa:"\f5fb"}.fa-laptop-code{--fa:"\f5fc"}.fa-layer-group{--fa:"\f5fd"}.fa-layer-group-minus,.fa-layer-minus{--fa:"\f5fe"}.fa-layer-group-plus,.fa-layer-plus{--fa:"\f5ff"}.fa-lips{--fa:"\f600"}.fa-location,.fa-location-crosshairs{--fa:"\f601"}.fa-circle-location-arrow,.fa-location-circle{--fa:"\f602"}.fa-location-crosshairs-slash,.fa-location-slash{--fa:"\f603"}.fa-lungs{--fa:"\f604"}.fa-location-dot-slash,.fa-map-marker-alt-slash{--fa:"\f605"}.fa-location-check,.fa-map-marker-check{--fa:"\f606"}.fa-location-pen,.fa-map-marker-edit{--fa:"\f607"}.fa-location-exclamation,.fa-map-marker-exclamation{--fa:"\f608"}.fa-location-minus,.fa-map-marker-minus{--fa:"\f609"}.fa-location-plus,.fa-map-marker-plus{--fa:"\f60a"}.fa-location-question,.fa-map-marker-question{--fa:"\f60b"}.fa-location-pin-slash,.fa-map-marker-slash{--fa:"\f60c"}.fa-location-smile,.fa-map-marker-smile{--fa:"\f60d"}.fa-location-xmark,.fa-map-marker-times,.fa-map-marker-xmark{--fa:"\f60e"}.fa-microscope{--fa:"\f610"}.fa-monitor-heart-rate,.fa-monitor-waveform{--fa:"\f611"}.fa-oil-can{--fa:"\f613"}.fa-oil-temp,.fa-oil-temperature{--fa:"\f614"}.fa-circle-parking,.fa-parking-circle{--fa:"\f615"}.fa-ban-parking,.fa-parking-circle-slash{--fa:"\f616"}.fa-parking-slash,.fa-square-parking-slash{--fa:"\f617"}.fa-pen-paintbrush,.fa-pencil-paintbrush{--fa:"\f618"}.fa-poop{--fa:"\f619"}.fa-route-highway{--fa:"\f61a"}.fa-route-interstate{--fa:"\f61b"}.fa-ruler-triangle{--fa:"\f61c"}.fa-scalpel{--fa:"\f61d"}.fa-scalpel-line-dashed,.fa-scalpel-path{--fa:"\f61e"}.fa-shapes,.fa-triangle-circle-square{--fa:"\f61f"}.fa-skeleton{--fa:"\f620"}.fa-star-of-life{--fa:"\f621"}.fa-steering-wheel{--fa:"\f622"}.fa-stomach{--fa:"\f623"}.fa-dashboard,.fa-gauge,.fa-gauge-med,.fa-tachometer-alt-average{--fa:"\f624"}.fa-gauge-high,.fa-tachometer-alt,.fa-tachometer-alt-fast{--fa:"\f625"}.fa-gauge-max,.fa-tachometer-alt-fastest{--fa:"\f626"}.fa-gauge-low,.fa-tachometer-alt-slow{--fa:"\f627"}.fa-gauge-min,.fa-tachometer-alt-slowest{--fa:"\f628"}.fa-gauge-simple,.fa-gauge-simple-med,.fa-tachometer-average{--fa:"\f629"}.fa-gauge-simple-high,.fa-tachometer,.fa-tachometer-fast{--fa:"\f62a"}.fa-gauge-simple-max,.fa-tachometer-fastest{--fa:"\f62b"}.fa-gauge-simple-low,.fa-tachometer-slow{--fa:"\f62c"}.fa-gauge-simple-min,.fa-tachometer-slowest{--fa:"\f62d"}.fa-teeth{--fa:"\f62e"}.fa-teeth-open{--fa:"\f62f"}.fa-masks-theater,.fa-theater-masks{--fa:"\f630"}.fa-tire{--fa:"\f631"}.fa-tire-flat{--fa:"\f632"}.fa-tire-pressure-warning{--fa:"\f633"}.fa-tire-rugged{--fa:"\f634"}.fa-toothbrush{--fa:"\f635"}.fa-traffic-cone{--fa:"\f636"}.fa-traffic-light{--fa:"\f637"}.fa-traffic-light-go{--fa:"\f638"}.fa-traffic-light-slow{--fa:"\f639"}.fa-traffic-light-stop{--fa:"\f63a"}.fa-truck-monster{--fa:"\f63b"}.fa-truck-pickup{--fa:"\f63c"}.fa-screen-users,.fa-users-class{--fa:"\f63d"}.fa-watch-fitness{--fa:"\f63e"}.fa-abacus{--fa:"\f640"}.fa-ad,.fa-rectangle-ad{--fa:"\f641"}.fa-analytics,.fa-chart-mixed{--fa:"\f643"}.fa-ankh{--fa:"\f644"}.fa-badge-dollar{--fa:"\f645"}.fa-badge-percent{--fa:"\f646"}.fa-bible,.fa-book-bible{--fa:"\f647"}.fa-bullseye-arrow{--fa:"\f648"}.fa-bullseye-pointer{--fa:"\f649"}.fa-briefcase-clock,.fa-business-time{--fa:"\f64a"}.fa-cabinet-filing{--fa:"\f64b"}.fa-calculator-alt,.fa-calculator-simple{--fa:"\f64c"}.fa-chart-line-down{--fa:"\f64d"}.fa-chart-pie-alt,.fa-chart-pie-simple{--fa:"\f64e"}.fa-city{--fa:"\f64f"}.fa-comment-alt-dollar,.fa-message-dollar{--fa:"\f650"}.fa-comment-dollar{--fa:"\f651"}.fa-comments-alt-dollar,.fa-messages-dollar{--fa:"\f652"}.fa-comments-dollar{--fa:"\f653"}.fa-cross{--fa:"\f654"}.fa-dharmachakra{--fa:"\f655"}.fa-empty-set{--fa:"\f656"}.fa-envelope-open-dollar{--fa:"\f657"}.fa-envelope-open-text{--fa:"\f658"}.fa-file-chart-column,.fa-file-chart-line{--fa:"\f659"}.fa-file-chart-pie{--fa:"\f65a"}.fa-file-spreadsheet{--fa:"\f65b"}.fa-file-user{--fa:"\f65c"}.fa-folder-minus{--fa:"\f65d"}.fa-folder-plus{--fa:"\f65e"}.fa-folder-times,.fa-folder-xmark{--fa:"\f65f"}.fa-folders{--fa:"\f660"}.fa-function{--fa:"\f661"}.fa-filter-circle-dollar,.fa-funnel-dollar{--fa:"\f662"}.fa-gift-card{--fa:"\f663"}.fa-gopuram{--fa:"\f664"}.fa-hamsa{--fa:"\f665"}.fa-bahai,.fa-haykal{--fa:"\f666"}.fa-integral{--fa:"\f667"}.fa-intersection{--fa:"\f668"}.fa-jedi{--fa:"\f669"}.fa-book-journal-whills,.fa-journal-whills{--fa:"\f66a"}.fa-kaaba{--fa:"\f66b"}.fa-keynote{--fa:"\f66c"}.fa-khanda{--fa:"\f66d"}.fa-lambda{--fa:"\f66e"}.fa-landmark{--fa:"\f66f"}.fa-lightbulb-dollar{--fa:"\f670"}.fa-lightbulb-exclamation{--fa:"\f671"}.fa-lightbulb-on{--fa:"\f672"}.fa-lightbulb-slash{--fa:"\f673"}.fa-envelopes-bulk,.fa-mail-bulk{--fa:"\f674"}.fa-megaphone{--fa:"\f675"}.fa-menorah{--fa:"\f676"}.fa-brain-arrow-curved-right,.fa-mind-share{--fa:"\f677"}.fa-mosque{--fa:"\f678"}.fa-om{--fa:"\f679"}.fa-omega{--fa:"\f67a"}.fa-pastafarianism,.fa-spaghetti-monster-flying{--fa:"\f67b"}.fa-peace{--fa:"\f67c"}.fa-phone-office{--fa:"\f67d"}.fa-pi{--fa:"\f67e"}.fa-place-of-worship{--fa:"\f67f"}.fa-podium{--fa:"\f680"}.fa-poll,.fa-square-poll-vertical{--fa:"\f681"}.fa-poll-h,.fa-square-poll-horizontal{--fa:"\f682"}.fa-person-praying,.fa-pray{--fa:"\f683"}.fa-hands-praying,.fa-praying-hands{--fa:"\f684"}.fa-presentation,.fa-presentation-screen{--fa:"\f685"}.fa-print-slash{--fa:"\f686"}.fa-book-quran,.fa-quran{--fa:"\f687"}.fa-magnifying-glass-dollar,.fa-search-dollar{--fa:"\f688"}.fa-magnifying-glass-location,.fa-search-location{--fa:"\f689"}.fa-shredder{--fa:"\f68a"}.fa-sigma{--fa:"\f68b"}.fa-signal-1,.fa-signal-weak{--fa:"\f68c"}.fa-signal-2,.fa-signal-fair{--fa:"\f68d"}.fa-signal-3,.fa-signal-good{--fa:"\f68e"}.fa-signal-4,.fa-signal-strong{--fa:"\f68f"}.fa-signal-alt,.fa-signal-alt-4,.fa-signal-bars,.fa-signal-bars-strong{--fa:"\f690"}.fa-signal-alt-1,.fa-signal-bars-weak{--fa:"\f691"}.fa-signal-alt-2,.fa-signal-bars-fair{--fa:"\f692"}.fa-signal-alt-3,.fa-signal-bars-good{--fa:"\f693"}.fa-signal-alt-slash,.fa-signal-bars-slash{--fa:"\f694"}.fa-signal-slash{--fa:"\f695"}.fa-socks{--fa:"\f696"}.fa-square-root{--fa:"\f697"}.fa-square-root-alt,.fa-square-root-variable{--fa:"\f698"}.fa-star-and-crescent{--fa:"\f699"}.fa-star-of-david{--fa:"\f69a"}.fa-synagogue{--fa:"\f69b"}.fa-tally,.fa-tally-5{--fa:"\f69c"}.fa-theta{--fa:"\f69e"}.fa-scroll-torah,.fa-torah{--fa:"\f6a0"}.fa-torii-gate{--fa:"\f6a1"}.fa-union{--fa:"\f6a2"}.fa-chart-user,.fa-user-chart{--fa:"\f6a3"}.fa-user-crown{--fa:"\f6a4"}.fa-user-group-crown,.fa-users-crown{--fa:"\f6a5"}.fa-value-absolute{--fa:"\f6a6"}.fa-vihara{--fa:"\f6a7"}.fa-volume,.fa-volume-medium{--fa:"\f6a8"}.fa-volume-mute,.fa-volume-times,.fa-volume-xmark{--fa:"\f6a9"}.fa-wifi-1,.fa-wifi-weak{--fa:"\f6aa"}.fa-wifi-2,.fa-wifi-fair{--fa:"\f6ab"}.fa-wifi-slash{--fa:"\f6ac"}.fa-yin-yang{--fa:"\f6ad"}.fa-acorn{--fa:"\f6ae"}.fa-alicorn{--fa:"\f6b0"}.fa-crate-apple{--fa:"\f6b1"}.fa-apple-crate{--fa:"\f6b1"}.fa-axe{--fa:"\f6b2"}.fa-axe-battle{--fa:"\f6b3"}.fa-badger-honey{--fa:"\f6b4"}.fa-bat{--fa:"\f6b5"}.fa-blender-phone{--fa:"\f6b6"}.fa-book-dead,.fa-book-skull{--fa:"\f6b7"}.fa-book-sparkles,.fa-book-spells{--fa:"\f6b8"}.fa-bow-arrow{--fa:"\f6b9"}.fa-campfire{--fa:"\f6ba"}.fa-campground{--fa:"\f6bb"}.fa-candle-holder{--fa:"\f6bc"}.fa-candy-corn{--fa:"\f6bd"}.fa-cat{--fa:"\f6be"}.fa-cauldron{--fa:"\f6bf"}.fa-chair{--fa:"\f6c0"}.fa-chair-office{--fa:"\f6c1"}.fa-claw-marks{--fa:"\f6c2"}.fa-cloud-moon{--fa:"\f6c3"}.fa-cloud-sun{--fa:"\f6c4"}.fa-coffee-togo,.fa-cup-togo{--fa:"\f6c5"}.fa-coffin{--fa:"\f6c6"}.fa-corn{--fa:"\f6c7"}.fa-cow{--fa:"\f6c8"}.fa-dagger{--fa:"\f6cb"}.fa-dice-d10{--fa:"\f6cd"}.fa-dice-d12{--fa:"\f6ce"}.fa-dice-d20{--fa:"\f6cf"}.fa-dice-d4{--fa:"\f6d0"}.fa-dice-d6{--fa:"\f6d1"}.fa-dice-d8{--fa:"\f6d2"}.fa-dog{--fa:"\f6d3"}.fa-dog-leashed{--fa:"\f6d4"}.fa-dragon{--fa:"\f6d5"}.fa-drumstick{--fa:"\f6d6"}.fa-drumstick-bite{--fa:"\f6d7"}.fa-duck{--fa:"\f6d8"}.fa-dungeon{--fa:"\f6d9"}.fa-elephant{--fa:"\f6da"}.fa-eye-evil{--fa:"\f6db"}.fa-file-csv{--fa:"\f6dd"}.fa-fist-raised,.fa-hand-fist{--fa:"\f6de"}.fa-fire-flame,.fa-flame{--fa:"\f6df"}.fa-flask-poison,.fa-flask-round-poison{--fa:"\f6e0"}.fa-flask-potion,.fa-flask-round-potion{--fa:"\f6e1"}.fa-ghost{--fa:"\f6e2"}.fa-hammer{--fa:"\f6e3"}.fa-hammer-war{--fa:"\f6e4"}.fa-hand-holding-magic{--fa:"\f6e5"}.fa-hanukiah{--fa:"\f6e6"}.fa-hat-witch{--fa:"\f6e7"}.fa-hat-wizard{--fa:"\f6e8"}.fa-head-side{--fa:"\f6e9"}.fa-head-side-goggles,.fa-head-vr{--fa:"\f6ea"}.fa-helmet-battle{--fa:"\f6eb"}.fa-hiking,.fa-person-hiking{--fa:"\f6ec"}.fa-hippo{--fa:"\f6ed"}.fa-hockey-mask{--fa:"\f6ee"}.fa-hood-cloak{--fa:"\f6ef"}.fa-horse{--fa:"\f6f0"}.fa-house-chimney-crack,.fa-house-damage{--fa:"\f6f1"}.fa-hryvnia,.fa-hryvnia-sign{--fa:"\f6f2"}.fa-key-skeleton{--fa:"\f6f3"}.fa-kite{--fa:"\f6f4"}.fa-knife-kitchen{--fa:"\f6f5"}.fa-leaf-maple{--fa:"\f6f6"}.fa-leaf-oak{--fa:"\f6f7"}.fa-mace{--fa:"\f6f8"}.fa-mandolin{--fa:"\f6f9"}.fa-mask{--fa:"\f6fa"}.fa-monkey{--fa:"\f6fb"}.fa-mountain{--fa:"\f6fc"}.fa-mountains{--fa:"\f6fd"}.fa-narwhal{--fa:"\f6fe"}.fa-network-wired{--fa:"\f6ff"}.fa-otter{--fa:"\f700"}.fa-paw-alt,.fa-paw-simple{--fa:"\f701"}.fa-paw-claws{--fa:"\f702"}.fa-pegasus{--fa:"\f703"}.fa-pie{--fa:"\f705"}.fa-pig{--fa:"\f706"}.fa-pumpkin{--fa:"\f707"}.fa-rabbit{--fa:"\f708"}.fa-rabbit-fast,.fa-rabbit-running{--fa:"\f709"}.fa-ram{--fa:"\f70a"}.fa-ring{--fa:"\f70b"}.fa-person-running,.fa-running{--fa:"\f70c"}.fa-scarecrow{--fa:"\f70d"}.fa-scroll{--fa:"\f70e"}.fa-scroll-old{--fa:"\f70f"}.fa-scythe{--fa:"\f710"}.fa-sheep{--fa:"\f711"}.fa-shield-cross{--fa:"\f712"}.fa-shovel{--fa:"\f713"}.fa-skull-crossbones{--fa:"\f714"}.fa-slash{--fa:"\f715"}.fa-snake{--fa:"\f716"}.fa-spider{--fa:"\f717"}.fa-spider-black-widow{--fa:"\f718"}.fa-spider-web{--fa:"\f719"}.fa-squirrel{--fa:"\f71a"}.fa-staff{--fa:"\f71b"}.fa-sword{--fa:"\f71c"}.fa-swords{--fa:"\f71d"}.fa-toilet-paper,.fa-toilet-paper-alt,.fa-toilet-paper-blank{--fa:"\f71e"}.fa-tombstone{--fa:"\f720"}.fa-tombstone-alt,.fa-tombstone-blank{--fa:"\f721"}.fa-tractor{--fa:"\f722"}.fa-treasure-chest{--fa:"\f723"}.fa-trees{--fa:"\f724"}.fa-turkey{--fa:"\f725"}.fa-turtle{--fa:"\f726"}.fa-unicorn{--fa:"\f727"}.fa-user-injured{--fa:"\f728"}.fa-vr-cardboard{--fa:"\f729"}.fa-wand{--fa:"\f72a"}.fa-wand-sparkles{--fa:"\f72b"}.fa-whale{--fa:"\f72c"}.fa-wheat{--fa:"\f72d"}.fa-wind{--fa:"\f72e"}.fa-wine-bottle{--fa:"\f72f"}.fa-ballot{--fa:"\f732"}.fa-ballot-check{--fa:"\f733"}.fa-booth-curtain{--fa:"\f734"}.fa-box-ballot{--fa:"\f735"}.fa-calendar-star{--fa:"\f736"}.fa-clipboard-list-check{--fa:"\f737"}.fa-cloud-drizzle{--fa:"\f738"}.fa-cloud-hail{--fa:"\f739"}.fa-cloud-hail-mixed{--fa:"\f73a"}.fa-cloud-meatball{--fa:"\f73b"}.fa-cloud-moon-rain{--fa:"\f73c"}.fa-cloud-rain{--fa:"\f73d"}.fa-cloud-rainbow{--fa:"\f73e"}.fa-cloud-showers{--fa:"\f73f"}.fa-cloud-showers-heavy{--fa:"\f740"}.fa-cloud-sleet{--fa:"\f741"}.fa-cloud-snow{--fa:"\f742"}.fa-cloud-sun-rain{--fa:"\f743"}.fa-clouds{--fa:"\f744"}.fa-clouds-moon{--fa:"\f745"}.fa-clouds-sun{--fa:"\f746"}.fa-democrat{--fa:"\f747"}.fa-dewpoint,.fa-droplet-degree{--fa:"\f748"}.fa-eclipse{--fa:"\f749"}.fa-eclipse-alt,.fa-moon-over-sun{--fa:"\f74a"}.fa-fire-smoke{--fa:"\f74b"}.fa-flag-alt,.fa-flag-swallowtail{--fa:"\f74c"}.fa-flag-usa{--fa:"\f74d"}.fa-cloud-fog,.fa-fog{--fa:"\f74e"}.fa-house-flood,.fa-house-water{--fa:"\f74f"}.fa-droplet-percent,.fa-humidity{--fa:"\f750"}.fa-hurricane{--fa:"\f751"}.fa-landmark-alt,.fa-landmark-dome{--fa:"\f752"}.fa-meteor{--fa:"\f753"}.fa-moon-cloud{--fa:"\f754"}.fa-moon-stars{--fa:"\f755"}.fa-person-booth{--fa:"\f756"}.fa-person-sign{--fa:"\f757"}.fa-podium-star{--fa:"\f758"}.fa-poll-people{--fa:"\f759"}.fa-poo-bolt,.fa-poo-storm{--fa:"\f75a"}.fa-rainbow{--fa:"\f75b"}.fa-raindrops{--fa:"\f75c"}.fa-republican{--fa:"\f75e"}.fa-smog{--fa:"\f75f"}.fa-smoke{--fa:"\f760"}.fa-snow-blowing{--fa:"\f761"}.fa-stars{--fa:"\f762"}.fa-sun-cloud{--fa:"\f763"}.fa-sun-dust{--fa:"\f764"}.fa-sun-haze{--fa:"\f765"}.fa-sunrise{--fa:"\f766"}.fa-sunset{--fa:"\f767"}.fa-temperature-frigid,.fa-temperature-snow{--fa:"\f768"}.fa-temperature-high{--fa:"\f769"}.fa-temperature-hot,.fa-temperature-sun{--fa:"\f76a"}.fa-temperature-low{--fa:"\f76b"}.fa-cloud-bolt,.fa-thunderstorm{--fa:"\f76c"}.fa-cloud-bolt-moon,.fa-thunderstorm-moon{--fa:"\f76d"}.fa-cloud-bolt-sun,.fa-thunderstorm-sun{--fa:"\f76e"}.fa-tornado{--fa:"\f76f"}.fa-volcano{--fa:"\f770"}.fa-times-to-slot,.fa-vote-nay,.fa-xmark-to-slot{--fa:"\f771"}.fa-check-to-slot,.fa-vote-yea{--fa:"\f772"}.fa-water{--fa:"\f773"}.fa-water-arrow-down,.fa-water-lower{--fa:"\f774"}.fa-water-arrow-up,.fa-water-rise{--fa:"\f775"}.fa-wind-circle-exclamation,.fa-wind-warning{--fa:"\f776"}.fa-windsock{--fa:"\f777"}.fa-angel{--fa:"\f779"}.fa-baby{--fa:"\f77c"}.fa-baby-carriage,.fa-carriage-baby{--fa:"\f77d"}.fa-ball-pile{--fa:"\f77e"}.fa-bells{--fa:"\f77f"}.fa-biohazard{--fa:"\f780"}.fa-blog{--fa:"\f781"}.fa-boot{--fa:"\f782"}.fa-calendar-day{--fa:"\f783"}.fa-calendar-week{--fa:"\f784"}.fa-candy-cane{--fa:"\f786"}.fa-carrot{--fa:"\f787"}.fa-cash-register{--fa:"\f788"}.fa-chart-network{--fa:"\f78a"}.fa-chimney{--fa:"\f78b"}.fa-compress-arrows-alt,.fa-minimize{--fa:"\f78c"}.fa-deer{--fa:"\f78e"}.fa-deer-rudolph{--fa:"\f78f"}.fa-dreidel{--fa:"\f792"}.fa-dumpster{--fa:"\f793"}.fa-dumpster-fire{--fa:"\f794"}.fa-ear-muffs{--fa:"\f795"}.fa-ethernet{--fa:"\f796"}.fa-fireplace{--fa:"\f79a"}.fa-frosty-head,.fa-snowman-head{--fa:"\f79b"}.fa-gifts{--fa:"\f79c"}.fa-gingerbread-man{--fa:"\f79d"}.fa-champagne-glass,.fa-glass-champagne{--fa:"\f79e"}.fa-champagne-glasses,.fa-glass-cheers{--fa:"\f79f"}.fa-glass-whiskey,.fa-whiskey-glass{--fa:"\f7a0"}.fa-glass-whiskey-rocks,.fa-whiskey-glass-ice{--fa:"\f7a1"}.fa-earth-europe,.fa-globe-europe{--fa:"\f7a2"}.fa-globe-snow{--fa:"\f7a3"}.fa-grip-lines{--fa:"\f7a4"}.fa-grip-lines-vertical{--fa:"\f7a5"}.fa-guitar{--fa:"\f7a6"}.fa-hat-santa{--fa:"\f7a7"}.fa-hat-winter{--fa:"\f7a8"}.fa-heart-broken,.fa-heart-crack{--fa:"\f7a9"}.fa-holly-berry{--fa:"\f7aa"}.fa-horse-head{--fa:"\f7ab"}.fa-ice-skate{--fa:"\f7ac"}.fa-icicles{--fa:"\f7ad"}.fa-igloo{--fa:"\f7ae"}.fa-lights-holiday{--fa:"\f7b2"}.fa-mistletoe{--fa:"\f7b4"}.fa-mitten{--fa:"\f7b5"}.fa-mug-hot{--fa:"\f7b6"}.fa-mug-marshmallows{--fa:"\f7b7"}.fa-ornament{--fa:"\f7b8"}.fa-radiation{--fa:"\f7b9"}.fa-circle-radiation,.fa-radiation-alt{--fa:"\f7ba"}.fa-restroom{--fa:"\f7bd"}.fa-rv{--fa:"\f7be"}.fa-satellite{--fa:"\f7bf"}.fa-satellite-dish{--fa:"\f7c0"}.fa-scarf{--fa:"\f7c1"}.fa-sd-card{--fa:"\f7c2"}.fa-shovel-snow{--fa:"\f7c3"}.fa-sim-card{--fa:"\f7c4"}.fa-person-skating,.fa-skating{--fa:"\f7c5"}.fa-person-ski-jumping,.fa-ski-jump{--fa:"\f7c7"}.fa-person-ski-lift,.fa-ski-lift{--fa:"\f7c8"}.fa-person-skiing,.fa-skiing{--fa:"\f7c9"}.fa-person-skiing-nordic,.fa-skiing-nordic{--fa:"\f7ca"}.fa-person-sledding,.fa-sledding{--fa:"\f7cb"}.fa-sleigh{--fa:"\f7cc"}.fa-comment-sms,.fa-sms{--fa:"\f7cd"}.fa-person-snowboarding,.fa-snowboarding{--fa:"\f7ce"}.fa-snowflakes{--fa:"\f7cf"}.fa-snowman{--fa:"\f7d0"}.fa-person-snowmobiling,.fa-snowmobile{--fa:"\f7d1"}.fa-snowplow{--fa:"\f7d2"}.fa-star-christmas{--fa:"\f7d4"}.fa-stocking{--fa:"\f7d5"}.fa-tenge,.fa-tenge-sign{--fa:"\f7d7"}.fa-toilet{--fa:"\f7d8"}.fa-screwdriver-wrench,.fa-tools{--fa:"\f7d9"}.fa-cable-car,.fa-tram{--fa:"\f7da"}.fa-tree-christmas{--fa:"\f7db"}.fa-tree-decorated{--fa:"\f7dc"}.fa-tree-large{--fa:"\f7dd"}.fa-truck-plow{--fa:"\f7de"}.fa-wreath{--fa:"\f7e2"}.fa-fire-alt,.fa-fire-flame-curved{--fa:"\f7e4"}.fa-bacon{--fa:"\f7e5"}.fa-book-medical{--fa:"\f7e6"}.fa-book-user{--fa:"\f7e7"}.fa-books-medical{--fa:"\f7e8"}.fa-brackets,.fa-brackets-square{--fa:"\f7e9"}.fa-brackets-curly{--fa:"\f7ea"}.fa-bread-loaf{--fa:"\f7eb"}.fa-bread-slice{--fa:"\f7ec"}.fa-burrito{--fa:"\f7ed"}.fa-chart-scatter{--fa:"\f7ee"}.fa-cheese{--fa:"\f7ef"}.fa-cheese-swiss{--fa:"\f7f0"}.fa-burger-cheese,.fa-cheeseburger{--fa:"\f7f1"}.fa-clinic-medical,.fa-house-chimney-medical{--fa:"\f7f2"}.fa-clipboard-user{--fa:"\f7f3"}.fa-comment-alt-medical,.fa-message-medical{--fa:"\f7f4"}.fa-comment-medical{--fa:"\f7f5"}.fa-croissant{--fa:"\f7f6"}.fa-crutch{--fa:"\f7f7"}.fa-crutches{--fa:"\f7f8"}.fa-ban-bug,.fa-debug{--fa:"\f7f9"}.fa-disease{--fa:"\f7fa"}.fa-egg{--fa:"\f7fb"}.fa-egg-fried{--fa:"\f7fc"}.fa-files-medical{--fa:"\f7fd"}.fa-fish-cooked{--fa:"\f7fe"}.fa-flower{--fa:"\f7ff"}.fa-flower-daffodil{--fa:"\f800"}.fa-flower-tulip{--fa:"\f801"}.fa-folder-tree{--fa:"\f802"}.fa-french-fries{--fa:"\f803"}.fa-glass{--fa:"\f804"}.fa-burger,.fa-hamburger{--fa:"\f805"}.fa-hand-middle-finger{--fa:"\f806"}.fa-hard-hat,.fa-hat-hard,.fa-helmet-safety{--fa:"\f807"}.fa-head-side-brain{--fa:"\f808"}.fa-head-side-medical{--fa:"\f809"}.fa-hospital-user{--fa:"\f80d"}.fa-hospitals{--fa:"\f80e"}.fa-hotdog{--fa:"\f80f"}.fa-ice-cream{--fa:"\f810"}.fa-island-tree-palm,.fa-island-tropical{--fa:"\f811"}.fa-laptop-medical{--fa:"\f812"}.fa-mailbox{--fa:"\f813"}.fa-meat{--fa:"\f814"}.fa-pager{--fa:"\f815"}.fa-pepper-hot{--fa:"\f816"}.fa-pizza{--fa:"\f817"}.fa-pizza-slice{--fa:"\f818"}.fa-popcorn{--fa:"\f819"}.fa-print-magnifying-glass,.fa-print-search{--fa:"\f81a"}.fa-rings-wedding{--fa:"\f81b"}.fa-sack{--fa:"\f81c"}.fa-sack-dollar{--fa:"\f81d"}.fa-bowl-salad,.fa-salad{--fa:"\f81e"}.fa-sandwich{--fa:"\f81f"}.fa-sausage{--fa:"\f820"}.fa-shish-kebab{--fa:"\f821"}.fa-sickle{--fa:"\f822"}.fa-bowl-hot,.fa-soup{--fa:"\f823"}.fa-steak{--fa:"\f824"}.fa-stretcher{--fa:"\f825"}.fa-taco{--fa:"\f826"}.fa-book-tanakh,.fa-tanakh{--fa:"\f827"}.fa-bars-progress,.fa-tasks-alt{--fa:"\f828"}.fa-trash-arrow-up,.fa-trash-restore{--fa:"\f829"}.fa-trash-can-arrow-up,.fa-trash-restore-alt{--fa:"\f82a"}.fa-tree-palm{--fa:"\f82b"}.fa-user-construction,.fa-user-hard-hat,.fa-user-helmet-safety{--fa:"\f82c"}.fa-user-headset{--fa:"\f82d"}.fa-user-doctor-message,.fa-user-md-chat{--fa:"\f82e"}.fa-user-nurse{--fa:"\f82f"}.fa-users-medical{--fa:"\f830"}.fa-walker{--fa:"\f831"}.fa-camera-web,.fa-webcam{--fa:"\f832"}.fa-camera-web-slash,.fa-webcam-slash{--fa:"\f833"}.fa-wave-square{--fa:"\f83e"}.fa-alarm-exclamation{--fa:"\f843"}.fa-alarm-plus{--fa:"\f844"}.fa-alarm-snooze{--fa:"\f845"}.fa-align-slash{--fa:"\f846"}.fa-bags-shopping{--fa:"\f847"}.fa-bell-exclamation{--fa:"\f848"}.fa-bell-plus{--fa:"\f849"}.fa-biking,.fa-person-biking{--fa:"\f84a"}.fa-biking-mountain,.fa-person-biking-mountain{--fa:"\f84b"}.fa-border-all{--fa:"\f84c"}.fa-border-bottom{--fa:"\f84d"}.fa-border-inner{--fa:"\f84e"}.fa-border-left{--fa:"\f84f"}.fa-border-none{--fa:"\f850"}.fa-border-outer{--fa:"\f851"}.fa-border-right{--fa:"\f852"}.fa-border-style,.fa-border-top-left{--fa:"\f853"}.fa-border-bottom-right,.fa-border-style-alt{--fa:"\f854"}.fa-border-top{--fa:"\f855"}.fa-bring-forward{--fa:"\f856"}.fa-bring-front{--fa:"\f857"}.fa-burger-soda{--fa:"\f858"}.fa-car-building{--fa:"\f859"}.fa-car-bus{--fa:"\f85a"}.fa-cars{--fa:"\f85b"}.fa-coin{--fa:"\f85c"}.fa-construction,.fa-triangle-person-digging{--fa:"\f85d"}.fa-digging,.fa-person-digging{--fa:"\f85e"}.fa-drone{--fa:"\f85f"}.fa-drone-alt,.fa-drone-front{--fa:"\f860"}.fa-dryer{--fa:"\f861"}.fa-dryer-alt,.fa-dryer-heat{--fa:"\f862"}.fa-fan{--fa:"\f863"}.fa-barn-silo,.fa-farm{--fa:"\f864"}.fa-file-magnifying-glass,.fa-file-search{--fa:"\f865"}.fa-font-case{--fa:"\f866"}.fa-game-board{--fa:"\f867"}.fa-game-board-alt,.fa-game-board-simple{--fa:"\f868"}.fa-glass-citrus{--fa:"\f869"}.fa-h4{--fa:"\f86a"}.fa-hat-chef{--fa:"\f86b"}.fa-horizontal-rule{--fa:"\f86c"}.fa-heart-music-camera-bolt,.fa-icons{--fa:"\f86d"}.fa-icons-alt,.fa-symbols{--fa:"\f86e"}.fa-kerning{--fa:"\f86f"}.fa-line-columns{--fa:"\f870"}.fa-line-height{--fa:"\f871"}.fa-money-check-edit,.fa-money-check-pen{--fa:"\f872"}.fa-money-check-dollar-pen,.fa-money-check-edit-alt{--fa:"\f873"}.fa-mug{--fa:"\f874"}.fa-mug-tea{--fa:"\f875"}.fa-overline{--fa:"\f876"}.fa-file-dashed-line,.fa-page-break{--fa:"\f877"}.fa-paragraph-left,.fa-paragraph-rtl{--fa:"\f878"}.fa-phone-alt,.fa-phone-flip{--fa:"\f879"}.fa-laptop-mobile,.fa-phone-laptop{--fa:"\f87a"}.fa-phone-square-alt,.fa-square-phone-flip{--fa:"\f87b"}.fa-photo-film,.fa-photo-video{--fa:"\f87c"}.fa-remove-format,.fa-text-slash{--fa:"\f87d"}.fa-send-back{--fa:"\f87e"}.fa-send-backward{--fa:"\f87f"}.fa-snooze,.fa-zzz{--fa:"\f880"}.fa-arrow-down-z-a,.fa-sort-alpha-desc,.fa-sort-alpha-down-alt{--fa:"\f881"}.fa-arrow-up-z-a,.fa-sort-alpha-up-alt{--fa:"\f882"}.fa-arrow-down-arrow-up,.fa-sort-alt{--fa:"\f883"}.fa-arrow-down-short-wide,.fa-sort-amount-desc,.fa-sort-amount-down-alt{--fa:"\f884"}.fa-arrow-up-short-wide,.fa-sort-amount-up-alt{--fa:"\f885"}.fa-arrow-down-9-1,.fa-sort-numeric-desc,.fa-sort-numeric-down-alt{--fa:"\f886"}.fa-arrow-up-9-1,.fa-sort-numeric-up-alt{--fa:"\f887"}.fa-arrow-down-triangle-square,.fa-sort-shapes-down{--fa:"\f888"}.fa-arrow-down-square-triangle,.fa-sort-shapes-down-alt{--fa:"\f889"}.fa-arrow-up-triangle-square,.fa-sort-shapes-up{--fa:"\f88a"}.fa-arrow-up-square-triangle,.fa-sort-shapes-up-alt{--fa:"\f88b"}.fa-arrow-down-big-small,.fa-sort-size-down{--fa:"\f88c"}.fa-arrow-down-small-big,.fa-sort-size-down-alt{--fa:"\f88d"}.fa-arrow-up-big-small,.fa-sort-size-up{--fa:"\f88e"}.fa-arrow-up-small-big,.fa-sort-size-up-alt{--fa:"\f88f"}.fa-sparkles{--fa:"\f890"}.fa-spell-check{--fa:"\f891"}.fa-sunglasses{--fa:"\f892"}.fa-text{--fa:"\f893"}.fa-text-size{--fa:"\f894"}.fa-trash-arrow-turn-left,.fa-trash-undo{--fa:"\f895"}.fa-trash-can-arrow-turn-left,.fa-trash-can-undo,.fa-trash-undo-alt{--fa:"\f896"}.fa-voicemail{--fa:"\f897"}.fa-washer,.fa-washing-machine{--fa:"\f898"}.fa-wave-sine{--fa:"\f899"}.fa-wave-triangle{--fa:"\f89a"}.fa-wind-turbine{--fa:"\f89b"}.fa-border-center-h{--fa:"\f89c"}.fa-border-center-v{--fa:"\f89d"}.fa-album{--fa:"\f89f"}.fa-album-collection{--fa:"\f8a0"}.fa-amp-guitar{--fa:"\f8a1"}.fa-badge-sheriff{--fa:"\f8a2"}.fa-banjo{--fa:"\f8a3"}.fa-betamax,.fa-cassette-betamax{--fa:"\f8a4"}.fa-boombox{--fa:"\f8a5"}.fa-cactus{--fa:"\f8a7"}.fa-camcorder,.fa-video-handheld{--fa:"\f8a8"}.fa-camera-movie{--fa:"\f8a9"}.fa-camera-polaroid{--fa:"\f8aa"}.fa-cassette-tape{--fa:"\f8ab"}.fa-camera-cctv,.fa-cctv{--fa:"\f8ac"}.fa-clarinet{--fa:"\f8ad"}.fa-cloud-music{--fa:"\f8ae"}.fa-comment-alt-music,.fa-message-music{--fa:"\f8af"}.fa-comment-music{--fa:"\f8b0"}.fa-computer-classic{--fa:"\f8b1"}.fa-computer-speaker{--fa:"\f8b2"}.fa-cowbell{--fa:"\f8b3"}.fa-cowbell-circle-plus,.fa-cowbell-more{--fa:"\f8b4"}.fa-disc-drive{--fa:"\f8b5"}.fa-file-music{--fa:"\f8b6"}.fa-film-canister,.fa-film-cannister{--fa:"\f8b7"}.fa-flashlight{--fa:"\f8b8"}.fa-flute{--fa:"\f8b9"}.fa-flux-capacitor{--fa:"\f8ba"}.fa-game-console-handheld{--fa:"\f8bb"}.fa-gramophone{--fa:"\f8bd"}.fa-guitar-electric{--fa:"\f8be"}.fa-guitars{--fa:"\f8bf"}.fa-hat-cowboy{--fa:"\f8c0"}.fa-hat-cowboy-side{--fa:"\f8c1"}.fa-head-side-headphones{--fa:"\f8c2"}.fa-horse-saddle{--fa:"\f8c3"}.fa-image-polaroid{--fa:"\f8c4"}.fa-joystick{--fa:"\f8c5"}.fa-jug{--fa:"\f8c6"}.fa-kazoo{--fa:"\f8c7"}.fa-lasso{--fa:"\f8c8"}.fa-list-music{--fa:"\f8c9"}.fa-microphone-stand{--fa:"\f8cb"}.fa-computer-mouse,.fa-mouse{--fa:"\f8cc"}.fa-computer-mouse-scrollwheel,.fa-mouse-alt{--fa:"\f8cd"}.fa-mp3-player{--fa:"\f8ce"}.fa-music-alt,.fa-music-note{--fa:"\f8cf"}.fa-music-alt-slash,.fa-music-note-slash{--fa:"\f8d0"}.fa-music-slash{--fa:"\f8d1"}.fa-phone-rotary{--fa:"\f8d3"}.fa-piano{--fa:"\f8d4"}.fa-piano-keyboard{--fa:"\f8d5"}.fa-projector{--fa:"\f8d6"}.fa-radio{--fa:"\f8d7"}.fa-radio-alt,.fa-radio-tuner{--fa:"\f8d8"}.fa-record-vinyl{--fa:"\f8d9"}.fa-router{--fa:"\f8da"}.fa-sax-hot,.fa-saxophone-fire{--fa:"\f8db"}.fa-saxophone{--fa:"\f8dc"}.fa-signal-stream{--fa:"\f8dd"}.fa-skull-cow{--fa:"\f8de"}.fa-speaker{--fa:"\f8df"}.fa-speakers{--fa:"\f8e0"}.fa-triangle-instrument,.fa-triangle-music{--fa:"\f8e2"}.fa-trumpet{--fa:"\f8e3"}.fa-turntable{--fa:"\f8e4"}.fa-tv-music{--fa:"\f8e6"}.fa-typewriter{--fa:"\f8e7"}.fa-usb-drive{--fa:"\f8e9"}.fa-user-cowboy{--fa:"\f8ea"}.fa-user-music{--fa:"\f8eb"}.fa-cassette-vhs,.fa-vhs{--fa:"\f8ec"}.fa-violin{--fa:"\f8ed"}.fa-wagon-covered{--fa:"\f8ee"}.fa-walkie-talkie{--fa:"\f8ef"}.fa-watch-calculator{--fa:"\f8f0"}.fa-waveform{--fa:"\f8f1"}.fa-waveform-lines,.fa-waveform-path{--fa:"\f8f2"}.fa-scanner-image{--fa:"\f8f3"}.fa-air-conditioner{--fa:"\f8f4"}.fa-alien{--fa:"\f8f5"}.fa-alien-8bit,.fa-alien-monster{--fa:"\f8f6"}.fa-bed-alt,.fa-bed-front{--fa:"\f8f7"}.fa-bed-bunk{--fa:"\f8f8"}.fa-bed-empty{--fa:"\f8f9"}.fa-bell-on{--fa:"\f8fa"}.fa-blinds{--fa:"\f8fb"}.fa-blinds-open{--fa:"\f8fc"}.fa-blinds-raised{--fa:"\f8fd"}.fa-camera-home,.fa-camera-security{--fa:"\f8fe"}.fa-caravan{--fa:"\f8ff"} -:host,:root{--fa-family-brands:"Font Awesome 7 Brands";--fa-font-brands:normal 400 1em/1 var(--fa-family-brands)}@font-face{font-family:"Font Awesome 7 Brands";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-brands-400.woff2)}.fa-brands,.fa-classic.fa-brands,.fab{--fa-family:var(--fa-family-brands);--fa-style:400}.fa-firefox-browser{--fa:"\e007"}.fa-ideal{--fa:"\e013"}.fa-microblog{--fa:"\e01a"}.fa-pied-piper-square,.fa-square-pied-piper{--fa:"\e01e"}.fa-unity{--fa:"\e049"}.fa-dailymotion{--fa:"\e052"}.fa-instagram-square,.fa-square-instagram{--fa:"\e055"}.fa-mixer{--fa:"\e056"}.fa-shopify{--fa:"\e057"}.fa-deezer{--fa:"\e077"}.fa-edge-legacy{--fa:"\e078"}.fa-google-pay{--fa:"\e079"}.fa-rust{--fa:"\e07a"}.fa-tiktok{--fa:"\e07b"}.fa-unsplash{--fa:"\e07c"}.fa-cloudflare{--fa:"\e07d"}.fa-guilded{--fa:"\e07e"}.fa-hive{--fa:"\e07f"}.fa-42-group,.fa-innosoft{--fa:"\e080"}.fa-instalod{--fa:"\e081"}.fa-octopus-deploy{--fa:"\e082"}.fa-perbyte{--fa:"\e083"}.fa-uncharted{--fa:"\e084"}.fa-watchman-monitoring{--fa:"\e087"}.fa-wodu{--fa:"\e088"}.fa-wirsindhandwerk,.fa-wsh{--fa:"\e2d0"}.fa-bots{--fa:"\e340"}.fa-cmplid{--fa:"\e360"}.fa-bilibili{--fa:"\e3d9"}.fa-golang{--fa:"\e40f"}.fa-pix{--fa:"\e43a"}.fa-sitrox{--fa:"\e44a"}.fa-hashnode{--fa:"\e499"}.fa-meta{--fa:"\e49b"}.fa-padlet{--fa:"\e4a0"}.fa-nfc-directional{--fa:"\e530"}.fa-nfc-symbol{--fa:"\e531"}.fa-screenpal{--fa:"\e570"}.fa-space-awesome{--fa:"\e5ac"}.fa-square-font-awesome{--fa:"\e5ad"}.fa-gitlab-square,.fa-square-gitlab{--fa:"\e5ae"}.fa-odysee{--fa:"\e5c6"}.fa-stubber{--fa:"\e5c7"}.fa-debian{--fa:"\e60b"}.fa-shoelace{--fa:"\e60c"}.fa-threads{--fa:"\e618"}.fa-square-threads{--fa:"\e619"}.fa-square-x-twitter{--fa:"\e61a"}.fa-x-twitter{--fa:"\e61b"}.fa-opensuse{--fa:"\e62b"}.fa-letterboxd{--fa:"\e62d"}.fa-square-letterboxd{--fa:"\e62e"}.fa-mintbit{--fa:"\e62f"}.fa-google-scholar{--fa:"\e63b"}.fa-brave{--fa:"\e63c"}.fa-brave-reverse{--fa:"\e63d"}.fa-pixiv{--fa:"\e640"}.fa-upwork{--fa:"\e641"}.fa-webflow{--fa:"\e65c"}.fa-signal-messenger{--fa:"\e663"}.fa-bluesky{--fa:"\e671"}.fa-jxl{--fa:"\e67b"}.fa-square-upwork{--fa:"\e67c"}.fa-web-awesome{--fa:"\e682"}.fa-square-web-awesome{--fa:"\e683"}.fa-square-web-awesome-stroke{--fa:"\e684"}.fa-dart-lang{--fa:"\e693"}.fa-flutter{--fa:"\e694"}.fa-files-pinwheel{--fa:"\e69f"}.fa-css{--fa:"\e6a2"}.fa-square-bluesky{--fa:"\e6a3"}.fa-openai{--fa:"\e7cf"}.fa-square-linkedin{--fa:"\e7d0"}.fa-cash-app{--fa:"\e7d4"}.fa-disqus{--fa:"\e7d5"}.fa-11ty,.fa-eleventy{--fa:"\e7d6"}.fa-kakao-talk{--fa:"\e7d7"}.fa-linktree{--fa:"\e7d8"}.fa-notion{--fa:"\e7d9"}.fa-pandora{--fa:"\e7da"}.fa-pixelfed{--fa:"\e7db"}.fa-tidal{--fa:"\e7dc"}.fa-vsco{--fa:"\e7dd"}.fa-w3c{--fa:"\e7de"}.fa-lumon{--fa:"\e7e2"}.fa-lumon-drop{--fa:"\e7e3"}.fa-square-figma{--fa:"\e7e4"}.fa-tex{--fa:"\e7ff"}.fa-duolingo{--fa:"\e812"}.fa-supportnow{--fa:"\e833"}.fa-tor-browser{--fa:"\e838"}.fa-square-twitter,.fa-twitter-square{--fa:"\f081"}.fa-facebook-square,.fa-square-facebook{--fa:"\f082"}.fa-linkedin{--fa:"\f08c"}.fa-github-square,.fa-square-github{--fa:"\f092"}.fa-twitter{--fa:"\f099"}.fa-facebook{--fa:"\f09a"}.fa-github{--fa:"\f09b"}.fa-pinterest{--fa:"\f0d2"}.fa-pinterest-square,.fa-square-pinterest{--fa:"\f0d3"}.fa-google-plus-square,.fa-square-google-plus{--fa:"\f0d4"}.fa-google-plus-g{--fa:"\f0d5"}.fa-linkedin-in{--fa:"\f0e1"}.fa-github-alt{--fa:"\f113"}.fa-maxcdn{--fa:"\f136"}.fa-html5{--fa:"\f13b"}.fa-css3{--fa:"\f13c"}.fa-btc{--fa:"\f15a"}.fa-youtube{--fa:"\f167"}.fa-xing{--fa:"\f168"}.fa-square-xing,.fa-xing-square{--fa:"\f169"}.fa-dropbox{--fa:"\f16b"}.fa-stack-overflow{--fa:"\f16c"}.fa-instagram{--fa:"\f16d"}.fa-flickr{--fa:"\f16e"}.fa-adn{--fa:"\f170"}.fa-bitbucket{--fa:"\f171"}.fa-tumblr{--fa:"\f173"}.fa-square-tumblr,.fa-tumblr-square{--fa:"\f174"}.fa-apple{--fa:"\f179"}.fa-windows{--fa:"\f17a"}.fa-android{--fa:"\f17b"}.fa-linux{--fa:"\f17c"}.fa-dribbble{--fa:"\f17d"}.fa-skype{--fa:"\f17e"}.fa-foursquare{--fa:"\f180"}.fa-trello{--fa:"\f181"}.fa-gratipay{--fa:"\f184"}.fa-vk{--fa:"\f189"}.fa-weibo{--fa:"\f18a"}.fa-renren{--fa:"\f18b"}.fa-pagelines{--fa:"\f18c"}.fa-stack-exchange{--fa:"\f18d"}.fa-square-vimeo,.fa-vimeo-square{--fa:"\f194"}.fa-slack,.fa-slack-hash{--fa:"\f198"}.fa-wordpress{--fa:"\f19a"}.fa-openid{--fa:"\f19b"}.fa-yahoo{--fa:"\f19e"}.fa-google{--fa:"\f1a0"}.fa-reddit{--fa:"\f1a1"}.fa-reddit-square,.fa-square-reddit{--fa:"\f1a2"}.fa-stumbleupon-circle{--fa:"\f1a3"}.fa-stumbleupon{--fa:"\f1a4"}.fa-delicious{--fa:"\f1a5"}.fa-digg{--fa:"\f1a6"}.fa-pied-piper-pp{--fa:"\f1a7"}.fa-pied-piper-alt{--fa:"\f1a8"}.fa-drupal{--fa:"\f1a9"}.fa-joomla{--fa:"\f1aa"}.fa-behance{--fa:"\f1b4"}.fa-behance-square,.fa-square-behance{--fa:"\f1b5"}.fa-steam{--fa:"\f1b6"}.fa-square-steam,.fa-steam-square{--fa:"\f1b7"}.fa-spotify{--fa:"\f1bc"}.fa-deviantart{--fa:"\f1bd"}.fa-soundcloud{--fa:"\f1be"}.fa-vine{--fa:"\f1ca"}.fa-codepen{--fa:"\f1cb"}.fa-jsfiddle{--fa:"\f1cc"}.fa-rebel{--fa:"\f1d0"}.fa-empire{--fa:"\f1d1"}.fa-git-square,.fa-square-git{--fa:"\f1d2"}.fa-git{--fa:"\f1d3"}.fa-hacker-news{--fa:"\f1d4"}.fa-tencent-weibo{--fa:"\f1d5"}.fa-qq{--fa:"\f1d6"}.fa-weixin{--fa:"\f1d7"}.fa-slideshare{--fa:"\f1e7"}.fa-twitch{--fa:"\f1e8"}.fa-yelp{--fa:"\f1e9"}.fa-paypal{--fa:"\f1ed"}.fa-google-wallet{--fa:"\f1ee"}.fa-cc-visa{--fa:"\f1f0"}.fa-cc-mastercard{--fa:"\f1f1"}.fa-cc-discover{--fa:"\f1f2"}.fa-cc-amex{--fa:"\f1f3"}.fa-cc-paypal{--fa:"\f1f4"}.fa-cc-stripe{--fa:"\f1f5"}.fa-lastfm{--fa:"\f202"}.fa-lastfm-square,.fa-square-lastfm{--fa:"\f203"}.fa-ioxhost{--fa:"\f208"}.fa-angellist{--fa:"\f209"}.fa-buysellads{--fa:"\f20d"}.fa-connectdevelop{--fa:"\f20e"}.fa-dashcube{--fa:"\f210"}.fa-forumbee{--fa:"\f211"}.fa-leanpub{--fa:"\f212"}.fa-sellsy{--fa:"\f213"}.fa-shirtsinbulk{--fa:"\f214"}.fa-simplybuilt{--fa:"\f215"}.fa-skyatlas{--fa:"\f216"}.fa-pinterest-p{--fa:"\f231"}.fa-whatsapp{--fa:"\f232"}.fa-viacoin{--fa:"\f237"}.fa-medium,.fa-medium-m{--fa:"\f23a"}.fa-y-combinator{--fa:"\f23b"}.fa-optin-monster{--fa:"\f23c"}.fa-opencart{--fa:"\f23d"}.fa-expeditedssl{--fa:"\f23e"}.fa-cc-jcb{--fa:"\f24b"}.fa-cc-diners-club{--fa:"\f24c"}.fa-creative-commons{--fa:"\f25e"}.fa-gg{--fa:"\f260"}.fa-gg-circle{--fa:"\f261"}.fa-odnoklassniki{--fa:"\f263"}.fa-odnoklassniki-square,.fa-square-odnoklassniki{--fa:"\f264"}.fa-get-pocket{--fa:"\f265"}.fa-wikipedia-w{--fa:"\f266"}.fa-safari{--fa:"\f267"}.fa-chrome{--fa:"\f268"}.fa-firefox{--fa:"\f269"}.fa-opera{--fa:"\f26a"}.fa-internet-explorer{--fa:"\f26b"}.fa-contao{--fa:"\f26d"}.fa-500px{--fa:"\f26e"}.fa-amazon{--fa:"\f270"}.fa-houzz{--fa:"\f27c"}.fa-vimeo-v{--fa:"\f27d"}.fa-black-tie{--fa:"\f27e"}.fa-fonticons{--fa:"\f280"}.fa-reddit-alien{--fa:"\f281"}.fa-edge{--fa:"\f282"}.fa-codiepie{--fa:"\f284"}.fa-modx{--fa:"\f285"}.fa-fort-awesome{--fa:"\f286"}.fa-usb{--fa:"\f287"}.fa-product-hunt{--fa:"\f288"}.fa-mixcloud{--fa:"\f289"}.fa-scribd{--fa:"\f28a"}.fa-bluetooth{--fa:"\f293"}.fa-bluetooth-b{--fa:"\f294"}.fa-gitlab{--fa:"\f296"}.fa-wpbeginner{--fa:"\f297"}.fa-wpforms{--fa:"\f298"}.fa-envira{--fa:"\f299"}.fa-glide{--fa:"\f2a5"}.fa-glide-g{--fa:"\f2a6"}.fa-viadeo{--fa:"\f2a9"}.fa-square-viadeo,.fa-viadeo-square{--fa:"\f2aa"}.fa-snapchat,.fa-snapchat-ghost{--fa:"\f2ab"}.fa-snapchat-square,.fa-square-snapchat{--fa:"\f2ad"}.fa-pied-piper{--fa:"\f2ae"}.fa-first-order{--fa:"\f2b0"}.fa-yoast{--fa:"\f2b1"}.fa-themeisle{--fa:"\f2b2"}.fa-google-plus{--fa:"\f2b3"}.fa-font-awesome,.fa-font-awesome-flag,.fa-font-awesome-logo-full{--fa:"\f2b4"}.fa-linode{--fa:"\f2b8"}.fa-quora{--fa:"\f2c4"}.fa-free-code-camp{--fa:"\f2c5"}.fa-telegram,.fa-telegram-plane{--fa:"\f2c6"}.fa-bandcamp{--fa:"\f2d5"}.fa-grav{--fa:"\f2d6"}.fa-etsy{--fa:"\f2d7"}.fa-imdb{--fa:"\f2d8"}.fa-ravelry{--fa:"\f2d9"}.fa-sellcast{--fa:"\f2da"}.fa-superpowers{--fa:"\f2dd"}.fa-wpexplorer{--fa:"\f2de"}.fa-meetup{--fa:"\f2e0"}.fa-font-awesome-alt,.fa-square-font-awesome-stroke{--fa:"\f35c"}.fa-accessible-icon{--fa:"\f368"}.fa-accusoft{--fa:"\f369"}.fa-adversal{--fa:"\f36a"}.fa-affiliatetheme{--fa:"\f36b"}.fa-algolia{--fa:"\f36c"}.fa-amilia{--fa:"\f36d"}.fa-angrycreative{--fa:"\f36e"}.fa-app-store{--fa:"\f36f"}.fa-app-store-ios{--fa:"\f370"}.fa-apper{--fa:"\f371"}.fa-asymmetrik{--fa:"\f372"}.fa-audible{--fa:"\f373"}.fa-avianex{--fa:"\f374"}.fa-aws{--fa:"\f375"}.fa-bimobject{--fa:"\f378"}.fa-bitcoin{--fa:"\f379"}.fa-bity{--fa:"\f37a"}.fa-blackberry{--fa:"\f37b"}.fa-blogger{--fa:"\f37c"}.fa-blogger-b{--fa:"\f37d"}.fa-buromobelexperte{--fa:"\f37f"}.fa-centercode{--fa:"\f380"}.fa-cloudscale{--fa:"\f383"}.fa-cloudsmith{--fa:"\f384"}.fa-cloudversify{--fa:"\f385"}.fa-cpanel{--fa:"\f388"}.fa-css3-alt{--fa:"\f38b"}.fa-cuttlefish{--fa:"\f38c"}.fa-d-and-d{--fa:"\f38d"}.fa-deploydog{--fa:"\f38e"}.fa-deskpro{--fa:"\f38f"}.fa-digital-ocean{--fa:"\f391"}.fa-discord{--fa:"\f392"}.fa-discourse{--fa:"\f393"}.fa-dochub{--fa:"\f394"}.fa-docker{--fa:"\f395"}.fa-draft2digital{--fa:"\f396"}.fa-dribbble-square,.fa-square-dribbble{--fa:"\f397"}.fa-dyalog{--fa:"\f399"}.fa-earlybirds{--fa:"\f39a"}.fa-erlang{--fa:"\f39d"}.fa-facebook-f{--fa:"\f39e"}.fa-facebook-messenger{--fa:"\f39f"}.fa-firstdraft{--fa:"\f3a1"}.fa-fonticons-fi{--fa:"\f3a2"}.fa-fort-awesome-alt{--fa:"\f3a3"}.fa-freebsd{--fa:"\f3a4"}.fa-gitkraken{--fa:"\f3a6"}.fa-gofore{--fa:"\f3a7"}.fa-goodreads{--fa:"\f3a8"}.fa-goodreads-g{--fa:"\f3a9"}.fa-google-drive{--fa:"\f3aa"}.fa-google-play{--fa:"\f3ab"}.fa-gripfire{--fa:"\f3ac"}.fa-grunt{--fa:"\f3ad"}.fa-gulp{--fa:"\f3ae"}.fa-hacker-news-square,.fa-square-hacker-news{--fa:"\f3af"}.fa-hire-a-helper{--fa:"\f3b0"}.fa-hotjar{--fa:"\f3b1"}.fa-hubspot{--fa:"\f3b2"}.fa-itunes{--fa:"\f3b4"}.fa-itunes-note{--fa:"\f3b5"}.fa-jenkins{--fa:"\f3b6"}.fa-joget{--fa:"\f3b7"}.fa-js{--fa:"\f3b8"}.fa-js-square,.fa-square-js{--fa:"\f3b9"}.fa-keycdn{--fa:"\f3ba"}.fa-kickstarter,.fa-square-kickstarter{--fa:"\f3bb"}.fa-kickstarter-k{--fa:"\f3bc"}.fa-laravel{--fa:"\f3bd"}.fa-line{--fa:"\f3c0"}.fa-lyft{--fa:"\f3c3"}.fa-magento{--fa:"\f3c4"}.fa-medapps{--fa:"\f3c6"}.fa-medrt{--fa:"\f3c8"}.fa-microsoft{--fa:"\f3ca"}.fa-mix{--fa:"\f3cb"}.fa-mizuni{--fa:"\f3cc"}.fa-monero{--fa:"\f3d0"}.fa-napster{--fa:"\f3d2"}.fa-node-js{--fa:"\f3d3"}.fa-npm{--fa:"\f3d4"}.fa-ns8{--fa:"\f3d5"}.fa-nutritionix{--fa:"\f3d6"}.fa-page4{--fa:"\f3d7"}.fa-palfed{--fa:"\f3d8"}.fa-patreon{--fa:"\f3d9"}.fa-periscope{--fa:"\f3da"}.fa-phabricator{--fa:"\f3db"}.fa-phoenix-framework{--fa:"\f3dc"}.fa-playstation{--fa:"\f3df"}.fa-pushed{--fa:"\f3e1"}.fa-python{--fa:"\f3e2"}.fa-red-river{--fa:"\f3e3"}.fa-rendact,.fa-wpressr{--fa:"\f3e4"}.fa-replyd{--fa:"\f3e6"}.fa-resolving{--fa:"\f3e7"}.fa-rocketchat{--fa:"\f3e8"}.fa-rockrms{--fa:"\f3e9"}.fa-schlix{--fa:"\f3ea"}.fa-searchengin{--fa:"\f3eb"}.fa-servicestack{--fa:"\f3ec"}.fa-sistrix{--fa:"\f3ee"}.fa-speakap{--fa:"\f3f3"}.fa-staylinked{--fa:"\f3f5"}.fa-steam-symbol{--fa:"\f3f6"}.fa-sticker-mule{--fa:"\f3f7"}.fa-studiovinari{--fa:"\f3f8"}.fa-supple{--fa:"\f3f9"}.fa-uber{--fa:"\f402"}.fa-uikit{--fa:"\f403"}.fa-uniregistry{--fa:"\f404"}.fa-untappd{--fa:"\f405"}.fa-ussunnah{--fa:"\f407"}.fa-vaadin{--fa:"\f408"}.fa-viber{--fa:"\f409"}.fa-vimeo{--fa:"\f40a"}.fa-vnv{--fa:"\f40b"}.fa-square-whatsapp,.fa-whatsapp-square{--fa:"\f40c"}.fa-whmcs{--fa:"\f40d"}.fa-wordpress-simple{--fa:"\f411"}.fa-xbox{--fa:"\f412"}.fa-yandex{--fa:"\f413"}.fa-yandex-international{--fa:"\f414"}.fa-apple-pay{--fa:"\f415"}.fa-cc-apple-pay{--fa:"\f416"}.fa-fly{--fa:"\f417"}.fa-node{--fa:"\f419"}.fa-osi{--fa:"\f41a"}.fa-react{--fa:"\f41b"}.fa-autoprefixer{--fa:"\f41c"}.fa-less{--fa:"\f41d"}.fa-sass{--fa:"\f41e"}.fa-vuejs{--fa:"\f41f"}.fa-angular{--fa:"\f420"}.fa-aviato{--fa:"\f421"}.fa-ember{--fa:"\f423"}.fa-gitter{--fa:"\f426"}.fa-hooli{--fa:"\f427"}.fa-strava{--fa:"\f428"}.fa-stripe{--fa:"\f429"}.fa-stripe-s{--fa:"\f42a"}.fa-typo3{--fa:"\f42b"}.fa-amazon-pay{--fa:"\f42c"}.fa-cc-amazon-pay{--fa:"\f42d"}.fa-ethereum{--fa:"\f42e"}.fa-korvue{--fa:"\f42f"}.fa-elementor{--fa:"\f430"}.fa-square-youtube,.fa-youtube-square{--fa:"\f431"}.fa-flipboard{--fa:"\f44d"}.fa-hips{--fa:"\f452"}.fa-php{--fa:"\f457"}.fa-quinscape{--fa:"\f459"}.fa-readme{--fa:"\f4d5"}.fa-java{--fa:"\f4e4"}.fa-pied-piper-hat{--fa:"\f4e5"}.fa-creative-commons-by{--fa:"\f4e7"}.fa-creative-commons-nc{--fa:"\f4e8"}.fa-creative-commons-nc-eu{--fa:"\f4e9"}.fa-creative-commons-nc-jp{--fa:"\f4ea"}.fa-creative-commons-nd{--fa:"\f4eb"}.fa-creative-commons-pd{--fa:"\f4ec"}.fa-creative-commons-pd-alt{--fa:"\f4ed"}.fa-creative-commons-remix{--fa:"\f4ee"}.fa-creative-commons-sa{--fa:"\f4ef"}.fa-creative-commons-sampling{--fa:"\f4f0"}.fa-creative-commons-sampling-plus{--fa:"\f4f1"}.fa-creative-commons-share{--fa:"\f4f2"}.fa-creative-commons-zero{--fa:"\f4f3"}.fa-ebay{--fa:"\f4f4"}.fa-keybase{--fa:"\f4f5"}.fa-mastodon{--fa:"\f4f6"}.fa-r-project{--fa:"\f4f7"}.fa-researchgate{--fa:"\f4f8"}.fa-teamspeak{--fa:"\f4f9"}.fa-first-order-alt{--fa:"\f50a"}.fa-fulcrum{--fa:"\f50b"}.fa-galactic-republic{--fa:"\f50c"}.fa-galactic-senate{--fa:"\f50d"}.fa-jedi-order{--fa:"\f50e"}.fa-mandalorian{--fa:"\f50f"}.fa-old-republic{--fa:"\f510"}.fa-phoenix-squadron{--fa:"\f511"}.fa-sith{--fa:"\f512"}.fa-trade-federation{--fa:"\f513"}.fa-wolf-pack-battalion{--fa:"\f514"}.fa-hornbill{--fa:"\f592"}.fa-mailchimp{--fa:"\f59e"}.fa-megaport{--fa:"\f5a3"}.fa-nimblr{--fa:"\f5a8"}.fa-rev{--fa:"\f5b2"}.fa-shopware{--fa:"\f5b5"}.fa-squarespace{--fa:"\f5be"}.fa-themeco{--fa:"\f5c6"}.fa-weebly{--fa:"\f5cc"}.fa-wix{--fa:"\f5cf"}.fa-ello{--fa:"\f5f1"}.fa-hackerrank{--fa:"\f5f7"}.fa-kaggle{--fa:"\f5fa"}.fa-markdown{--fa:"\f60f"}.fa-neos{--fa:"\f612"}.fa-zhihu{--fa:"\f63f"}.fa-alipay{--fa:"\f642"}.fa-the-red-yeti{--fa:"\f69d"}.fa-critical-role{--fa:"\f6c9"}.fa-d-and-d-beyond{--fa:"\f6ca"}.fa-dev{--fa:"\f6cc"}.fa-fantasy-flight-games{--fa:"\f6dc"}.fa-wizards-of-the-coast{--fa:"\f730"}.fa-think-peaks{--fa:"\f731"}.fa-reacteurope{--fa:"\f75d"}.fa-artstation{--fa:"\f77a"}.fa-atlassian{--fa:"\f77b"}.fa-canadian-maple-leaf{--fa:"\f785"}.fa-centos{--fa:"\f789"}.fa-confluence{--fa:"\f78d"}.fa-dhl{--fa:"\f790"}.fa-diaspora{--fa:"\f791"}.fa-fedex{--fa:"\f797"}.fa-fedora{--fa:"\f798"}.fa-figma{--fa:"\f799"}.fa-intercom{--fa:"\f7af"}.fa-invision{--fa:"\f7b0"}.fa-jira{--fa:"\f7b1"}.fa-mendeley{--fa:"\f7b3"}.fa-raspberry-pi{--fa:"\f7bb"}.fa-redhat{--fa:"\f7bc"}.fa-sketch{--fa:"\f7c6"}.fa-sourcetree{--fa:"\f7d3"}.fa-suse{--fa:"\f7d6"}.fa-ubuntu{--fa:"\f7df"}.fa-ups{--fa:"\f7e0"}.fa-usps{--fa:"\f7e1"}.fa-yarn{--fa:"\f7e3"}.fa-airbnb{--fa:"\f834"}.fa-battle-net{--fa:"\f835"}.fa-bootstrap{--fa:"\f836"}.fa-buffer{--fa:"\f837"}.fa-chromecast{--fa:"\f838"}.fa-evernote{--fa:"\f839"}.fa-itch-io{--fa:"\f83a"}.fa-salesforce{--fa:"\f83b"}.fa-speaker-deck{--fa:"\f83c"}.fa-symfony{--fa:"\f83d"}.fa-waze{--fa:"\f83f"}.fa-yammer{--fa:"\f840"}.fa-git-alt{--fa:"\f841"}.fa-stackpath{--fa:"\f842"}.fa-cotton-bureau{--fa:"\f89e"}.fa-buy-n-large{--fa:"\f8a6"}.fa-mdb{--fa:"\f8ca"}.fa-orcid{--fa:"\f8d2"}.fa-swift{--fa:"\f8e1"}.fa-umbraco{--fa:"\f8e8"}:host,:root{--fa-family-duotone:"Font Awesome 7 Duotone";--fa-font-duotone:normal 900 1em/1 var(--fa-family-duotone);--fa-style-family-duotone:var(--fa-family-duotone)}@font-face{font-family:"Font Awesome 7 Duotone";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-duotone-900.woff2)}.fa-duotone,.fad{--fa-family:var(--fa-family-duotone);--fa-style:900;position:relative;letter-spacing:normal}.fa-duotone:before,.fad:before{position:absolute;color:var(--fa-primary-color,currentColor);opacity:var(--fa-primary-opacity,1)}.fa-duotone:after,.fad:after{color:var(--fa-secondary-color,currentColor)}.fa-duotone.fa-swap-opacity:before,.fa-duotone:after,.fa-swap-opacity .fa-duotone:before,.fa-swap-opacity .fad:before,.fad.fa-swap-opacity:before,.fad:after{opacity:var(--fa-secondary-opacity,.4)}.fa-duotone.fa-swap-opacity:after,.fa-swap-opacity .fa-duotone:after,.fa-swap-opacity .fad:after,.fad.fa-swap-opacity:after{opacity:var(--fa-primary-opacity,1)}.fa-duotone.fa-li,.fa-duotone.fa-stack-1x,.fa-duotone.fa-stack-2x,.fad.fa-li,.fad.fa-stack-1x,.fad.fa-stack-2x{position:absolute}:host,:root{--fa-font-light:normal 300 1em/1 var(--fa-family-classic)}@font-face{font-family:"Font Awesome 7 Pro";font-style:normal;font-weight:300;font-display:block;src:url(../webfonts/fa-light-300.woff2)}.fal{--fa-family:var(--fa-family-classic)}.fa-light,.fal{--fa-style:300}:host,:root{--fa-font-regular:normal 400 1em/1 var(--fa-family-classic)}@font-face{font-family:"Font Awesome 7 Pro";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-regular-400.woff2)}.far{--fa-family:var(--fa-family-classic)}.fa-regular,.far{--fa-style:400}:host,:root{--fa-font-solid:normal 900 1em/1 var(--fa-family-classic)}@font-face{font-family:"Font Awesome 7 Pro";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.woff2)}.fas{--fa-family:var(--fa-family-classic)}.fa-solid,.fas{--fa-style:900}:host,:root{--fa-family-classic:"Font Awesome 7 Pro";--fa-font-thin:normal 100 1em/1 var(--fa-family-classic);--fa-style-family-classic:var(--fa-family-classic)}@font-face{font-family:"Font Awesome 7 Pro";font-style:normal;font-weight:100;font-display:block;src:url(../webfonts/fa-thin-100.woff2)}.fat{--fa-style:100}.fa-classic,.fat{--fa-family:var(--fa-family-classic)}.fa-thin{--fa-style:100}@font-face{font-family:"Font Awesome 5 Brands";font-display:block;font-weight:400;src:url(../webfonts/fa-brands-400.woff2) format("woff2")}@font-face{font-family:"Font Awesome 5 Pro";font-display:block;font-weight:900;src:url(../webfonts/fa-solid-900.woff2) format("woff2")}@font-face{font-family:"Font Awesome 5 Pro";font-display:block;font-weight:400;src:url(../webfonts/fa-regular-400.woff2) format("woff2")}@font-face{font-family:"Font Awesome 5 Pro";font-display:block;font-weight:300;src:url(../webfonts/fa-light-300.woff2) format("woff2")}@font-face{font-family:"Font Awesome 5 Duotone";font-display:block;font-weight:900;src:url(../webfonts/fa-duotone-900.woff2) format("woff2")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-brands-400.woff2) format("woff2")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2");unicode-range:u+f003,u+f006,u+f014,u+f016-f017,u+f01a-f01b,u+f01d,u+f022,u+f03e,u+f044,u+f046,u+f05c-f05d,u+f06e,u+f070,u+f087-f088,u+f08a,u+f094,u+f096-f097,u+f09d,u+f0a0,u+f0a2,u+f0a4-f0a7,u+f0c5,u+f0c7,u+f0e5-f0e6,u+f0eb,u+f0f6-f0f8,u+f10c,u+f114-f115,u+f118-f11a,u+f11c-f11d,u+f133,u+f147,u+f14e,u+f150-f152,u+f185-f186,u+f18e,u+f190-f192,u+f196,u+f1c1-f1c9,u+f1d9,u+f1db,u+f1e3,u+f1ea,u+f1f7,u+f1f9,u+f20a,u+f247-f248,u+f24a,u+f24d,u+f255-f25b,u+f25d,u+f271-f274,u+f278,u+f27b,u+f28c,u+f28e,u+f29c,u+f2b5,u+f2b7,u+f2ba,u+f2bc,u+f2be,u+f2c0-f2c1,u+f2c3,u+f2d0,u+f2d2,u+f2d4,u+f2dc}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-v4compatibility.woff2) format("woff2");unicode-range:u+f041,u+f047,u+f065-f066,u+f07d-f07e,u+f080,u+f08b,u+f08e,u+f090,u+f09a,u+f0ac,u+f0ae,u+f0b2,u+f0d0,u+f0d6,u+f0e4,u+f0ec,u+f10a-f10b,u+f123,u+f13e,u+f148-f149,u+f14c,u+f156,u+f15e,u+f160-f161,u+f163,u+f175-f178,u+f195,u+f1f8,u+f219,u+f27a} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/brands.css b/public/vendor/fontawesome/css/brands.css deleted file mode 100644 index 889fb16..0000000 --- a/public/vendor/fontawesome/css/brands.css +++ /dev/null @@ -1,2227 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-brands: "Font Awesome 7 Brands"; - --fa-font-brands: normal 400 1em/1 var(--fa-family-brands); -} - -@font-face { - font-family: "Font Awesome 7 Brands"; - font-style: normal; - font-weight: 400; - font-display: block; - src: url("../webfonts/fa-brands-400.woff2"); -} -.fab, -.fa-brands, -.fa-classic.fa-brands { - --fa-family: var(--fa-family-brands); - --fa-style: 400; -} - -.fa-firefox-browser { - --fa: "\e007"; -} - -.fa-ideal { - --fa: "\e013"; -} - -.fa-microblog { - --fa: "\e01a"; -} - -.fa-square-pied-piper { - --fa: "\e01e"; -} - -.fa-pied-piper-square { - --fa: "\e01e"; -} - -.fa-unity { - --fa: "\e049"; -} - -.fa-dailymotion { - --fa: "\e052"; -} - -.fa-square-instagram { - --fa: "\e055"; -} - -.fa-instagram-square { - --fa: "\e055"; -} - -.fa-mixer { - --fa: "\e056"; -} - -.fa-shopify { - --fa: "\e057"; -} - -.fa-deezer { - --fa: "\e077"; -} - -.fa-edge-legacy { - --fa: "\e078"; -} - -.fa-google-pay { - --fa: "\e079"; -} - -.fa-rust { - --fa: "\e07a"; -} - -.fa-tiktok { - --fa: "\e07b"; -} - -.fa-unsplash { - --fa: "\e07c"; -} - -.fa-cloudflare { - --fa: "\e07d"; -} - -.fa-guilded { - --fa: "\e07e"; -} - -.fa-hive { - --fa: "\e07f"; -} - -.fa-42-group { - --fa: "\e080"; -} - -.fa-innosoft { - --fa: "\e080"; -} - -.fa-instalod { - --fa: "\e081"; -} - -.fa-octopus-deploy { - --fa: "\e082"; -} - -.fa-perbyte { - --fa: "\e083"; -} - -.fa-uncharted { - --fa: "\e084"; -} - -.fa-watchman-monitoring { - --fa: "\e087"; -} - -.fa-wodu { - --fa: "\e088"; -} - -.fa-wirsindhandwerk { - --fa: "\e2d0"; -} - -.fa-wsh { - --fa: "\e2d0"; -} - -.fa-bots { - --fa: "\e340"; -} - -.fa-cmplid { - --fa: "\e360"; -} - -.fa-bilibili { - --fa: "\e3d9"; -} - -.fa-golang { - --fa: "\e40f"; -} - -.fa-pix { - --fa: "\e43a"; -} - -.fa-sitrox { - --fa: "\e44a"; -} - -.fa-hashnode { - --fa: "\e499"; -} - -.fa-meta { - --fa: "\e49b"; -} - -.fa-padlet { - --fa: "\e4a0"; -} - -.fa-nfc-directional { - --fa: "\e530"; -} - -.fa-nfc-symbol { - --fa: "\e531"; -} - -.fa-screenpal { - --fa: "\e570"; -} - -.fa-space-awesome { - --fa: "\e5ac"; -} - -.fa-square-font-awesome { - --fa: "\e5ad"; -} - -.fa-square-gitlab { - --fa: "\e5ae"; -} - -.fa-gitlab-square { - --fa: "\e5ae"; -} - -.fa-odysee { - --fa: "\e5c6"; -} - -.fa-stubber { - --fa: "\e5c7"; -} - -.fa-debian { - --fa: "\e60b"; -} - -.fa-shoelace { - --fa: "\e60c"; -} - -.fa-threads { - --fa: "\e618"; -} - -.fa-square-threads { - --fa: "\e619"; -} - -.fa-square-x-twitter { - --fa: "\e61a"; -} - -.fa-x-twitter { - --fa: "\e61b"; -} - -.fa-opensuse { - --fa: "\e62b"; -} - -.fa-letterboxd { - --fa: "\e62d"; -} - -.fa-square-letterboxd { - --fa: "\e62e"; -} - -.fa-mintbit { - --fa: "\e62f"; -} - -.fa-google-scholar { - --fa: "\e63b"; -} - -.fa-brave { - --fa: "\e63c"; -} - -.fa-brave-reverse { - --fa: "\e63d"; -} - -.fa-pixiv { - --fa: "\e640"; -} - -.fa-upwork { - --fa: "\e641"; -} - -.fa-webflow { - --fa: "\e65c"; -} - -.fa-signal-messenger { - --fa: "\e663"; -} - -.fa-bluesky { - --fa: "\e671"; -} - -.fa-jxl { - --fa: "\e67b"; -} - -.fa-square-upwork { - --fa: "\e67c"; -} - -.fa-web-awesome { - --fa: "\e682"; -} - -.fa-square-web-awesome { - --fa: "\e683"; -} - -.fa-square-web-awesome-stroke { - --fa: "\e684"; -} - -.fa-dart-lang { - --fa: "\e693"; -} - -.fa-flutter { - --fa: "\e694"; -} - -.fa-files-pinwheel { - --fa: "\e69f"; -} - -.fa-css { - --fa: "\e6a2"; -} - -.fa-square-bluesky { - --fa: "\e6a3"; -} - -.fa-openai { - --fa: "\e7cf"; -} - -.fa-square-linkedin { - --fa: "\e7d0"; -} - -.fa-cash-app { - --fa: "\e7d4"; -} - -.fa-disqus { - --fa: "\e7d5"; -} - -.fa-eleventy { - --fa: "\e7d6"; -} - -.fa-11ty { - --fa: "\e7d6"; -} - -.fa-kakao-talk { - --fa: "\e7d7"; -} - -.fa-linktree { - --fa: "\e7d8"; -} - -.fa-notion { - --fa: "\e7d9"; -} - -.fa-pandora { - --fa: "\e7da"; -} - -.fa-pixelfed { - --fa: "\e7db"; -} - -.fa-tidal { - --fa: "\e7dc"; -} - -.fa-vsco { - --fa: "\e7dd"; -} - -.fa-w3c { - --fa: "\e7de"; -} - -.fa-lumon { - --fa: "\e7e2"; -} - -.fa-lumon-drop { - --fa: "\e7e3"; -} - -.fa-square-figma { - --fa: "\e7e4"; -} - -.fa-tex { - --fa: "\e7ff"; -} - -.fa-duolingo { - --fa: "\e812"; -} - -.fa-supportnow { - --fa: "\e833"; -} - -.fa-tor-browser { - --fa: "\e838"; -} - -.fa-square-twitter { - --fa: "\f081"; -} - -.fa-twitter-square { - --fa: "\f081"; -} - -.fa-square-facebook { - --fa: "\f082"; -} - -.fa-facebook-square { - --fa: "\f082"; -} - -.fa-linkedin { - --fa: "\f08c"; -} - -.fa-square-github { - --fa: "\f092"; -} - -.fa-github-square { - --fa: "\f092"; -} - -.fa-twitter { - --fa: "\f099"; -} - -.fa-facebook { - --fa: "\f09a"; -} - -.fa-github { - --fa: "\f09b"; -} - -.fa-pinterest { - --fa: "\f0d2"; -} - -.fa-square-pinterest { - --fa: "\f0d3"; -} - -.fa-pinterest-square { - --fa: "\f0d3"; -} - -.fa-square-google-plus { - --fa: "\f0d4"; -} - -.fa-google-plus-square { - --fa: "\f0d4"; -} - -.fa-google-plus-g { - --fa: "\f0d5"; -} - -.fa-linkedin-in { - --fa: "\f0e1"; -} - -.fa-github-alt { - --fa: "\f113"; -} - -.fa-maxcdn { - --fa: "\f136"; -} - -.fa-html5 { - --fa: "\f13b"; -} - -.fa-css3 { - --fa: "\f13c"; -} - -.fa-btc { - --fa: "\f15a"; -} - -.fa-youtube { - --fa: "\f167"; -} - -.fa-xing { - --fa: "\f168"; -} - -.fa-square-xing { - --fa: "\f169"; -} - -.fa-xing-square { - --fa: "\f169"; -} - -.fa-dropbox { - --fa: "\f16b"; -} - -.fa-stack-overflow { - --fa: "\f16c"; -} - -.fa-instagram { - --fa: "\f16d"; -} - -.fa-flickr { - --fa: "\f16e"; -} - -.fa-adn { - --fa: "\f170"; -} - -.fa-bitbucket { - --fa: "\f171"; -} - -.fa-tumblr { - --fa: "\f173"; -} - -.fa-square-tumblr { - --fa: "\f174"; -} - -.fa-tumblr-square { - --fa: "\f174"; -} - -.fa-apple { - --fa: "\f179"; -} - -.fa-windows { - --fa: "\f17a"; -} - -.fa-android { - --fa: "\f17b"; -} - -.fa-linux { - --fa: "\f17c"; -} - -.fa-dribbble { - --fa: "\f17d"; -} - -.fa-skype { - --fa: "\f17e"; -} - -.fa-foursquare { - --fa: "\f180"; -} - -.fa-trello { - --fa: "\f181"; -} - -.fa-gratipay { - --fa: "\f184"; -} - -.fa-vk { - --fa: "\f189"; -} - -.fa-weibo { - --fa: "\f18a"; -} - -.fa-renren { - --fa: "\f18b"; -} - -.fa-pagelines { - --fa: "\f18c"; -} - -.fa-stack-exchange { - --fa: "\f18d"; -} - -.fa-square-vimeo { - --fa: "\f194"; -} - -.fa-vimeo-square { - --fa: "\f194"; -} - -.fa-slack { - --fa: "\f198"; -} - -.fa-slack-hash { - --fa: "\f198"; -} - -.fa-wordpress { - --fa: "\f19a"; -} - -.fa-openid { - --fa: "\f19b"; -} - -.fa-yahoo { - --fa: "\f19e"; -} - -.fa-google { - --fa: "\f1a0"; -} - -.fa-reddit { - --fa: "\f1a1"; -} - -.fa-square-reddit { - --fa: "\f1a2"; -} - -.fa-reddit-square { - --fa: "\f1a2"; -} - -.fa-stumbleupon-circle { - --fa: "\f1a3"; -} - -.fa-stumbleupon { - --fa: "\f1a4"; -} - -.fa-delicious { - --fa: "\f1a5"; -} - -.fa-digg { - --fa: "\f1a6"; -} - -.fa-pied-piper-pp { - --fa: "\f1a7"; -} - -.fa-pied-piper-alt { - --fa: "\f1a8"; -} - -.fa-drupal { - --fa: "\f1a9"; -} - -.fa-joomla { - --fa: "\f1aa"; -} - -.fa-behance { - --fa: "\f1b4"; -} - -.fa-square-behance { - --fa: "\f1b5"; -} - -.fa-behance-square { - --fa: "\f1b5"; -} - -.fa-steam { - --fa: "\f1b6"; -} - -.fa-square-steam { - --fa: "\f1b7"; -} - -.fa-steam-square { - --fa: "\f1b7"; -} - -.fa-spotify { - --fa: "\f1bc"; -} - -.fa-deviantart { - --fa: "\f1bd"; -} - -.fa-soundcloud { - --fa: "\f1be"; -} - -.fa-vine { - --fa: "\f1ca"; -} - -.fa-codepen { - --fa: "\f1cb"; -} - -.fa-jsfiddle { - --fa: "\f1cc"; -} - -.fa-rebel { - --fa: "\f1d0"; -} - -.fa-empire { - --fa: "\f1d1"; -} - -.fa-square-git { - --fa: "\f1d2"; -} - -.fa-git-square { - --fa: "\f1d2"; -} - -.fa-git { - --fa: "\f1d3"; -} - -.fa-hacker-news { - --fa: "\f1d4"; -} - -.fa-tencent-weibo { - --fa: "\f1d5"; -} - -.fa-qq { - --fa: "\f1d6"; -} - -.fa-weixin { - --fa: "\f1d7"; -} - -.fa-slideshare { - --fa: "\f1e7"; -} - -.fa-twitch { - --fa: "\f1e8"; -} - -.fa-yelp { - --fa: "\f1e9"; -} - -.fa-paypal { - --fa: "\f1ed"; -} - -.fa-google-wallet { - --fa: "\f1ee"; -} - -.fa-cc-visa { - --fa: "\f1f0"; -} - -.fa-cc-mastercard { - --fa: "\f1f1"; -} - -.fa-cc-discover { - --fa: "\f1f2"; -} - -.fa-cc-amex { - --fa: "\f1f3"; -} - -.fa-cc-paypal { - --fa: "\f1f4"; -} - -.fa-cc-stripe { - --fa: "\f1f5"; -} - -.fa-lastfm { - --fa: "\f202"; -} - -.fa-square-lastfm { - --fa: "\f203"; -} - -.fa-lastfm-square { - --fa: "\f203"; -} - -.fa-ioxhost { - --fa: "\f208"; -} - -.fa-angellist { - --fa: "\f209"; -} - -.fa-buysellads { - --fa: "\f20d"; -} - -.fa-connectdevelop { - --fa: "\f20e"; -} - -.fa-dashcube { - --fa: "\f210"; -} - -.fa-forumbee { - --fa: "\f211"; -} - -.fa-leanpub { - --fa: "\f212"; -} - -.fa-sellsy { - --fa: "\f213"; -} - -.fa-shirtsinbulk { - --fa: "\f214"; -} - -.fa-simplybuilt { - --fa: "\f215"; -} - -.fa-skyatlas { - --fa: "\f216"; -} - -.fa-pinterest-p { - --fa: "\f231"; -} - -.fa-whatsapp { - --fa: "\f232"; -} - -.fa-viacoin { - --fa: "\f237"; -} - -.fa-medium { - --fa: "\f23a"; -} - -.fa-medium-m { - --fa: "\f23a"; -} - -.fa-y-combinator { - --fa: "\f23b"; -} - -.fa-optin-monster { - --fa: "\f23c"; -} - -.fa-opencart { - --fa: "\f23d"; -} - -.fa-expeditedssl { - --fa: "\f23e"; -} - -.fa-cc-jcb { - --fa: "\f24b"; -} - -.fa-cc-diners-club { - --fa: "\f24c"; -} - -.fa-creative-commons { - --fa: "\f25e"; -} - -.fa-gg { - --fa: "\f260"; -} - -.fa-gg-circle { - --fa: "\f261"; -} - -.fa-odnoklassniki { - --fa: "\f263"; -} - -.fa-square-odnoklassniki { - --fa: "\f264"; -} - -.fa-odnoklassniki-square { - --fa: "\f264"; -} - -.fa-get-pocket { - --fa: "\f265"; -} - -.fa-wikipedia-w { - --fa: "\f266"; -} - -.fa-safari { - --fa: "\f267"; -} - -.fa-chrome { - --fa: "\f268"; -} - -.fa-firefox { - --fa: "\f269"; -} - -.fa-opera { - --fa: "\f26a"; -} - -.fa-internet-explorer { - --fa: "\f26b"; -} - -.fa-contao { - --fa: "\f26d"; -} - -.fa-500px { - --fa: "\f26e"; -} - -.fa-amazon { - --fa: "\f270"; -} - -.fa-houzz { - --fa: "\f27c"; -} - -.fa-vimeo-v { - --fa: "\f27d"; -} - -.fa-black-tie { - --fa: "\f27e"; -} - -.fa-fonticons { - --fa: "\f280"; -} - -.fa-reddit-alien { - --fa: "\f281"; -} - -.fa-edge { - --fa: "\f282"; -} - -.fa-codiepie { - --fa: "\f284"; -} - -.fa-modx { - --fa: "\f285"; -} - -.fa-fort-awesome { - --fa: "\f286"; -} - -.fa-usb { - --fa: "\f287"; -} - -.fa-product-hunt { - --fa: "\f288"; -} - -.fa-mixcloud { - --fa: "\f289"; -} - -.fa-scribd { - --fa: "\f28a"; -} - -.fa-bluetooth { - --fa: "\f293"; -} - -.fa-bluetooth-b { - --fa: "\f294"; -} - -.fa-gitlab { - --fa: "\f296"; -} - -.fa-wpbeginner { - --fa: "\f297"; -} - -.fa-wpforms { - --fa: "\f298"; -} - -.fa-envira { - --fa: "\f299"; -} - -.fa-glide { - --fa: "\f2a5"; -} - -.fa-glide-g { - --fa: "\f2a6"; -} - -.fa-viadeo { - --fa: "\f2a9"; -} - -.fa-square-viadeo { - --fa: "\f2aa"; -} - -.fa-viadeo-square { - --fa: "\f2aa"; -} - -.fa-snapchat { - --fa: "\f2ab"; -} - -.fa-snapchat-ghost { - --fa: "\f2ab"; -} - -.fa-square-snapchat { - --fa: "\f2ad"; -} - -.fa-snapchat-square { - --fa: "\f2ad"; -} - -.fa-pied-piper { - --fa: "\f2ae"; -} - -.fa-first-order { - --fa: "\f2b0"; -} - -.fa-yoast { - --fa: "\f2b1"; -} - -.fa-themeisle { - --fa: "\f2b2"; -} - -.fa-google-plus { - --fa: "\f2b3"; -} - -.fa-font-awesome { - --fa: "\f2b4"; -} - -.fa-font-awesome-flag { - --fa: "\f2b4"; -} - -.fa-font-awesome-logo-full { - --fa: "\f2b4"; -} - -.fa-linode { - --fa: "\f2b8"; -} - -.fa-quora { - --fa: "\f2c4"; -} - -.fa-free-code-camp { - --fa: "\f2c5"; -} - -.fa-telegram { - --fa: "\f2c6"; -} - -.fa-telegram-plane { - --fa: "\f2c6"; -} - -.fa-bandcamp { - --fa: "\f2d5"; -} - -.fa-grav { - --fa: "\f2d6"; -} - -.fa-etsy { - --fa: "\f2d7"; -} - -.fa-imdb { - --fa: "\f2d8"; -} - -.fa-ravelry { - --fa: "\f2d9"; -} - -.fa-sellcast { - --fa: "\f2da"; -} - -.fa-superpowers { - --fa: "\f2dd"; -} - -.fa-wpexplorer { - --fa: "\f2de"; -} - -.fa-meetup { - --fa: "\f2e0"; -} - -.fa-square-font-awesome-stroke { - --fa: "\f35c"; -} - -.fa-font-awesome-alt { - --fa: "\f35c"; -} - -.fa-accessible-icon { - --fa: "\f368"; -} - -.fa-accusoft { - --fa: "\f369"; -} - -.fa-adversal { - --fa: "\f36a"; -} - -.fa-affiliatetheme { - --fa: "\f36b"; -} - -.fa-algolia { - --fa: "\f36c"; -} - -.fa-amilia { - --fa: "\f36d"; -} - -.fa-angrycreative { - --fa: "\f36e"; -} - -.fa-app-store { - --fa: "\f36f"; -} - -.fa-app-store-ios { - --fa: "\f370"; -} - -.fa-apper { - --fa: "\f371"; -} - -.fa-asymmetrik { - --fa: "\f372"; -} - -.fa-audible { - --fa: "\f373"; -} - -.fa-avianex { - --fa: "\f374"; -} - -.fa-aws { - --fa: "\f375"; -} - -.fa-bimobject { - --fa: "\f378"; -} - -.fa-bitcoin { - --fa: "\f379"; -} - -.fa-bity { - --fa: "\f37a"; -} - -.fa-blackberry { - --fa: "\f37b"; -} - -.fa-blogger { - --fa: "\f37c"; -} - -.fa-blogger-b { - --fa: "\f37d"; -} - -.fa-buromobelexperte { - --fa: "\f37f"; -} - -.fa-centercode { - --fa: "\f380"; -} - -.fa-cloudscale { - --fa: "\f383"; -} - -.fa-cloudsmith { - --fa: "\f384"; -} - -.fa-cloudversify { - --fa: "\f385"; -} - -.fa-cpanel { - --fa: "\f388"; -} - -.fa-css3-alt { - --fa: "\f38b"; -} - -.fa-cuttlefish { - --fa: "\f38c"; -} - -.fa-d-and-d { - --fa: "\f38d"; -} - -.fa-deploydog { - --fa: "\f38e"; -} - -.fa-deskpro { - --fa: "\f38f"; -} - -.fa-digital-ocean { - --fa: "\f391"; -} - -.fa-discord { - --fa: "\f392"; -} - -.fa-discourse { - --fa: "\f393"; -} - -.fa-dochub { - --fa: "\f394"; -} - -.fa-docker { - --fa: "\f395"; -} - -.fa-draft2digital { - --fa: "\f396"; -} - -.fa-square-dribbble { - --fa: "\f397"; -} - -.fa-dribbble-square { - --fa: "\f397"; -} - -.fa-dyalog { - --fa: "\f399"; -} - -.fa-earlybirds { - --fa: "\f39a"; -} - -.fa-erlang { - --fa: "\f39d"; -} - -.fa-facebook-f { - --fa: "\f39e"; -} - -.fa-facebook-messenger { - --fa: "\f39f"; -} - -.fa-firstdraft { - --fa: "\f3a1"; -} - -.fa-fonticons-fi { - --fa: "\f3a2"; -} - -.fa-fort-awesome-alt { - --fa: "\f3a3"; -} - -.fa-freebsd { - --fa: "\f3a4"; -} - -.fa-gitkraken { - --fa: "\f3a6"; -} - -.fa-gofore { - --fa: "\f3a7"; -} - -.fa-goodreads { - --fa: "\f3a8"; -} - -.fa-goodreads-g { - --fa: "\f3a9"; -} - -.fa-google-drive { - --fa: "\f3aa"; -} - -.fa-google-play { - --fa: "\f3ab"; -} - -.fa-gripfire { - --fa: "\f3ac"; -} - -.fa-grunt { - --fa: "\f3ad"; -} - -.fa-gulp { - --fa: "\f3ae"; -} - -.fa-square-hacker-news { - --fa: "\f3af"; -} - -.fa-hacker-news-square { - --fa: "\f3af"; -} - -.fa-hire-a-helper { - --fa: "\f3b0"; -} - -.fa-hotjar { - --fa: "\f3b1"; -} - -.fa-hubspot { - --fa: "\f3b2"; -} - -.fa-itunes { - --fa: "\f3b4"; -} - -.fa-itunes-note { - --fa: "\f3b5"; -} - -.fa-jenkins { - --fa: "\f3b6"; -} - -.fa-joget { - --fa: "\f3b7"; -} - -.fa-js { - --fa: "\f3b8"; -} - -.fa-square-js { - --fa: "\f3b9"; -} - -.fa-js-square { - --fa: "\f3b9"; -} - -.fa-keycdn { - --fa: "\f3ba"; -} - -.fa-kickstarter { - --fa: "\f3bb"; -} - -.fa-square-kickstarter { - --fa: "\f3bb"; -} - -.fa-kickstarter-k { - --fa: "\f3bc"; -} - -.fa-laravel { - --fa: "\f3bd"; -} - -.fa-line { - --fa: "\f3c0"; -} - -.fa-lyft { - --fa: "\f3c3"; -} - -.fa-magento { - --fa: "\f3c4"; -} - -.fa-medapps { - --fa: "\f3c6"; -} - -.fa-medrt { - --fa: "\f3c8"; -} - -.fa-microsoft { - --fa: "\f3ca"; -} - -.fa-mix { - --fa: "\f3cb"; -} - -.fa-mizuni { - --fa: "\f3cc"; -} - -.fa-monero { - --fa: "\f3d0"; -} - -.fa-napster { - --fa: "\f3d2"; -} - -.fa-node-js { - --fa: "\f3d3"; -} - -.fa-npm { - --fa: "\f3d4"; -} - -.fa-ns8 { - --fa: "\f3d5"; -} - -.fa-nutritionix { - --fa: "\f3d6"; -} - -.fa-page4 { - --fa: "\f3d7"; -} - -.fa-palfed { - --fa: "\f3d8"; -} - -.fa-patreon { - --fa: "\f3d9"; -} - -.fa-periscope { - --fa: "\f3da"; -} - -.fa-phabricator { - --fa: "\f3db"; -} - -.fa-phoenix-framework { - --fa: "\f3dc"; -} - -.fa-playstation { - --fa: "\f3df"; -} - -.fa-pushed { - --fa: "\f3e1"; -} - -.fa-python { - --fa: "\f3e2"; -} - -.fa-red-river { - --fa: "\f3e3"; -} - -.fa-wpressr { - --fa: "\f3e4"; -} - -.fa-rendact { - --fa: "\f3e4"; -} - -.fa-replyd { - --fa: "\f3e6"; -} - -.fa-resolving { - --fa: "\f3e7"; -} - -.fa-rocketchat { - --fa: "\f3e8"; -} - -.fa-rockrms { - --fa: "\f3e9"; -} - -.fa-schlix { - --fa: "\f3ea"; -} - -.fa-searchengin { - --fa: "\f3eb"; -} - -.fa-servicestack { - --fa: "\f3ec"; -} - -.fa-sistrix { - --fa: "\f3ee"; -} - -.fa-speakap { - --fa: "\f3f3"; -} - -.fa-staylinked { - --fa: "\f3f5"; -} - -.fa-steam-symbol { - --fa: "\f3f6"; -} - -.fa-sticker-mule { - --fa: "\f3f7"; -} - -.fa-studiovinari { - --fa: "\f3f8"; -} - -.fa-supple { - --fa: "\f3f9"; -} - -.fa-uber { - --fa: "\f402"; -} - -.fa-uikit { - --fa: "\f403"; -} - -.fa-uniregistry { - --fa: "\f404"; -} - -.fa-untappd { - --fa: "\f405"; -} - -.fa-ussunnah { - --fa: "\f407"; -} - -.fa-vaadin { - --fa: "\f408"; -} - -.fa-viber { - --fa: "\f409"; -} - -.fa-vimeo { - --fa: "\f40a"; -} - -.fa-vnv { - --fa: "\f40b"; -} - -.fa-square-whatsapp { - --fa: "\f40c"; -} - -.fa-whatsapp-square { - --fa: "\f40c"; -} - -.fa-whmcs { - --fa: "\f40d"; -} - -.fa-wordpress-simple { - --fa: "\f411"; -} - -.fa-xbox { - --fa: "\f412"; -} - -.fa-yandex { - --fa: "\f413"; -} - -.fa-yandex-international { - --fa: "\f414"; -} - -.fa-apple-pay { - --fa: "\f415"; -} - -.fa-cc-apple-pay { - --fa: "\f416"; -} - -.fa-fly { - --fa: "\f417"; -} - -.fa-node { - --fa: "\f419"; -} - -.fa-osi { - --fa: "\f41a"; -} - -.fa-react { - --fa: "\f41b"; -} - -.fa-autoprefixer { - --fa: "\f41c"; -} - -.fa-less { - --fa: "\f41d"; -} - -.fa-sass { - --fa: "\f41e"; -} - -.fa-vuejs { - --fa: "\f41f"; -} - -.fa-angular { - --fa: "\f420"; -} - -.fa-aviato { - --fa: "\f421"; -} - -.fa-ember { - --fa: "\f423"; -} - -.fa-gitter { - --fa: "\f426"; -} - -.fa-hooli { - --fa: "\f427"; -} - -.fa-strava { - --fa: "\f428"; -} - -.fa-stripe { - --fa: "\f429"; -} - -.fa-stripe-s { - --fa: "\f42a"; -} - -.fa-typo3 { - --fa: "\f42b"; -} - -.fa-amazon-pay { - --fa: "\f42c"; -} - -.fa-cc-amazon-pay { - --fa: "\f42d"; -} - -.fa-ethereum { - --fa: "\f42e"; -} - -.fa-korvue { - --fa: "\f42f"; -} - -.fa-elementor { - --fa: "\f430"; -} - -.fa-square-youtube { - --fa: "\f431"; -} - -.fa-youtube-square { - --fa: "\f431"; -} - -.fa-flipboard { - --fa: "\f44d"; -} - -.fa-hips { - --fa: "\f452"; -} - -.fa-php { - --fa: "\f457"; -} - -.fa-quinscape { - --fa: "\f459"; -} - -.fa-readme { - --fa: "\f4d5"; -} - -.fa-java { - --fa: "\f4e4"; -} - -.fa-pied-piper-hat { - --fa: "\f4e5"; -} - -.fa-creative-commons-by { - --fa: "\f4e7"; -} - -.fa-creative-commons-nc { - --fa: "\f4e8"; -} - -.fa-creative-commons-nc-eu { - --fa: "\f4e9"; -} - -.fa-creative-commons-nc-jp { - --fa: "\f4ea"; -} - -.fa-creative-commons-nd { - --fa: "\f4eb"; -} - -.fa-creative-commons-pd { - --fa: "\f4ec"; -} - -.fa-creative-commons-pd-alt { - --fa: "\f4ed"; -} - -.fa-creative-commons-remix { - --fa: "\f4ee"; -} - -.fa-creative-commons-sa { - --fa: "\f4ef"; -} - -.fa-creative-commons-sampling { - --fa: "\f4f0"; -} - -.fa-creative-commons-sampling-plus { - --fa: "\f4f1"; -} - -.fa-creative-commons-share { - --fa: "\f4f2"; -} - -.fa-creative-commons-zero { - --fa: "\f4f3"; -} - -.fa-ebay { - --fa: "\f4f4"; -} - -.fa-keybase { - --fa: "\f4f5"; -} - -.fa-mastodon { - --fa: "\f4f6"; -} - -.fa-r-project { - --fa: "\f4f7"; -} - -.fa-researchgate { - --fa: "\f4f8"; -} - -.fa-teamspeak { - --fa: "\f4f9"; -} - -.fa-first-order-alt { - --fa: "\f50a"; -} - -.fa-fulcrum { - --fa: "\f50b"; -} - -.fa-galactic-republic { - --fa: "\f50c"; -} - -.fa-galactic-senate { - --fa: "\f50d"; -} - -.fa-jedi-order { - --fa: "\f50e"; -} - -.fa-mandalorian { - --fa: "\f50f"; -} - -.fa-old-republic { - --fa: "\f510"; -} - -.fa-phoenix-squadron { - --fa: "\f511"; -} - -.fa-sith { - --fa: "\f512"; -} - -.fa-trade-federation { - --fa: "\f513"; -} - -.fa-wolf-pack-battalion { - --fa: "\f514"; -} - -.fa-hornbill { - --fa: "\f592"; -} - -.fa-mailchimp { - --fa: "\f59e"; -} - -.fa-megaport { - --fa: "\f5a3"; -} - -.fa-nimblr { - --fa: "\f5a8"; -} - -.fa-rev { - --fa: "\f5b2"; -} - -.fa-shopware { - --fa: "\f5b5"; -} - -.fa-squarespace { - --fa: "\f5be"; -} - -.fa-themeco { - --fa: "\f5c6"; -} - -.fa-weebly { - --fa: "\f5cc"; -} - -.fa-wix { - --fa: "\f5cf"; -} - -.fa-ello { - --fa: "\f5f1"; -} - -.fa-hackerrank { - --fa: "\f5f7"; -} - -.fa-kaggle { - --fa: "\f5fa"; -} - -.fa-markdown { - --fa: "\f60f"; -} - -.fa-neos { - --fa: "\f612"; -} - -.fa-zhihu { - --fa: "\f63f"; -} - -.fa-alipay { - --fa: "\f642"; -} - -.fa-the-red-yeti { - --fa: "\f69d"; -} - -.fa-critical-role { - --fa: "\f6c9"; -} - -.fa-d-and-d-beyond { - --fa: "\f6ca"; -} - -.fa-dev { - --fa: "\f6cc"; -} - -.fa-fantasy-flight-games { - --fa: "\f6dc"; -} - -.fa-wizards-of-the-coast { - --fa: "\f730"; -} - -.fa-think-peaks { - --fa: "\f731"; -} - -.fa-reacteurope { - --fa: "\f75d"; -} - -.fa-artstation { - --fa: "\f77a"; -} - -.fa-atlassian { - --fa: "\f77b"; -} - -.fa-canadian-maple-leaf { - --fa: "\f785"; -} - -.fa-centos { - --fa: "\f789"; -} - -.fa-confluence { - --fa: "\f78d"; -} - -.fa-dhl { - --fa: "\f790"; -} - -.fa-diaspora { - --fa: "\f791"; -} - -.fa-fedex { - --fa: "\f797"; -} - -.fa-fedora { - --fa: "\f798"; -} - -.fa-figma { - --fa: "\f799"; -} - -.fa-intercom { - --fa: "\f7af"; -} - -.fa-invision { - --fa: "\f7b0"; -} - -.fa-jira { - --fa: "\f7b1"; -} - -.fa-mendeley { - --fa: "\f7b3"; -} - -.fa-raspberry-pi { - --fa: "\f7bb"; -} - -.fa-redhat { - --fa: "\f7bc"; -} - -.fa-sketch { - --fa: "\f7c6"; -} - -.fa-sourcetree { - --fa: "\f7d3"; -} - -.fa-suse { - --fa: "\f7d6"; -} - -.fa-ubuntu { - --fa: "\f7df"; -} - -.fa-ups { - --fa: "\f7e0"; -} - -.fa-usps { - --fa: "\f7e1"; -} - -.fa-yarn { - --fa: "\f7e3"; -} - -.fa-airbnb { - --fa: "\f834"; -} - -.fa-battle-net { - --fa: "\f835"; -} - -.fa-bootstrap { - --fa: "\f836"; -} - -.fa-buffer { - --fa: "\f837"; -} - -.fa-chromecast { - --fa: "\f838"; -} - -.fa-evernote { - --fa: "\f839"; -} - -.fa-itch-io { - --fa: "\f83a"; -} - -.fa-salesforce { - --fa: "\f83b"; -} - -.fa-speaker-deck { - --fa: "\f83c"; -} - -.fa-symfony { - --fa: "\f83d"; -} - -.fa-waze { - --fa: "\f83f"; -} - -.fa-yammer { - --fa: "\f840"; -} - -.fa-git-alt { - --fa: "\f841"; -} - -.fa-stackpath { - --fa: "\f842"; -} - -.fa-cotton-bureau { - --fa: "\f89e"; -} - -.fa-buy-n-large { - --fa: "\f8a6"; -} - -.fa-mdb { - --fa: "\f8ca"; -} - -.fa-orcid { - --fa: "\f8d2"; -} - -.fa-swift { - --fa: "\f8e1"; -} - -.fa-umbraco { - --fa: "\f8e8"; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/brands.min.css b/public/vendor/fontawesome/css/brands.min.css deleted file mode 100644 index 8eacbc8..0000000 --- a/public/vendor/fontawesome/css/brands.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-brands:"Font Awesome 7 Brands";--fa-font-brands:normal 400 1em/1 var(--fa-family-brands)}@font-face{font-family:"Font Awesome 7 Brands";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-brands-400.woff2)}.fa-brands,.fa-classic.fa-brands,.fab{--fa-family:var(--fa-family-brands);--fa-style:400}.fa-firefox-browser{--fa:"\e007"}.fa-ideal{--fa:"\e013"}.fa-microblog{--fa:"\e01a"}.fa-pied-piper-square,.fa-square-pied-piper{--fa:"\e01e"}.fa-unity{--fa:"\e049"}.fa-dailymotion{--fa:"\e052"}.fa-instagram-square,.fa-square-instagram{--fa:"\e055"}.fa-mixer{--fa:"\e056"}.fa-shopify{--fa:"\e057"}.fa-deezer{--fa:"\e077"}.fa-edge-legacy{--fa:"\e078"}.fa-google-pay{--fa:"\e079"}.fa-rust{--fa:"\e07a"}.fa-tiktok{--fa:"\e07b"}.fa-unsplash{--fa:"\e07c"}.fa-cloudflare{--fa:"\e07d"}.fa-guilded{--fa:"\e07e"}.fa-hive{--fa:"\e07f"}.fa-42-group,.fa-innosoft{--fa:"\e080"}.fa-instalod{--fa:"\e081"}.fa-octopus-deploy{--fa:"\e082"}.fa-perbyte{--fa:"\e083"}.fa-uncharted{--fa:"\e084"}.fa-watchman-monitoring{--fa:"\e087"}.fa-wodu{--fa:"\e088"}.fa-wirsindhandwerk,.fa-wsh{--fa:"\e2d0"}.fa-bots{--fa:"\e340"}.fa-cmplid{--fa:"\e360"}.fa-bilibili{--fa:"\e3d9"}.fa-golang{--fa:"\e40f"}.fa-pix{--fa:"\e43a"}.fa-sitrox{--fa:"\e44a"}.fa-hashnode{--fa:"\e499"}.fa-meta{--fa:"\e49b"}.fa-padlet{--fa:"\e4a0"}.fa-nfc-directional{--fa:"\e530"}.fa-nfc-symbol{--fa:"\e531"}.fa-screenpal{--fa:"\e570"}.fa-space-awesome{--fa:"\e5ac"}.fa-square-font-awesome{--fa:"\e5ad"}.fa-gitlab-square,.fa-square-gitlab{--fa:"\e5ae"}.fa-odysee{--fa:"\e5c6"}.fa-stubber{--fa:"\e5c7"}.fa-debian{--fa:"\e60b"}.fa-shoelace{--fa:"\e60c"}.fa-threads{--fa:"\e618"}.fa-square-threads{--fa:"\e619"}.fa-square-x-twitter{--fa:"\e61a"}.fa-x-twitter{--fa:"\e61b"}.fa-opensuse{--fa:"\e62b"}.fa-letterboxd{--fa:"\e62d"}.fa-square-letterboxd{--fa:"\e62e"}.fa-mintbit{--fa:"\e62f"}.fa-google-scholar{--fa:"\e63b"}.fa-brave{--fa:"\e63c"}.fa-brave-reverse{--fa:"\e63d"}.fa-pixiv{--fa:"\e640"}.fa-upwork{--fa:"\e641"}.fa-webflow{--fa:"\e65c"}.fa-signal-messenger{--fa:"\e663"}.fa-bluesky{--fa:"\e671"}.fa-jxl{--fa:"\e67b"}.fa-square-upwork{--fa:"\e67c"}.fa-web-awesome{--fa:"\e682"}.fa-square-web-awesome{--fa:"\e683"}.fa-square-web-awesome-stroke{--fa:"\e684"}.fa-dart-lang{--fa:"\e693"}.fa-flutter{--fa:"\e694"}.fa-files-pinwheel{--fa:"\e69f"}.fa-css{--fa:"\e6a2"}.fa-square-bluesky{--fa:"\e6a3"}.fa-openai{--fa:"\e7cf"}.fa-square-linkedin{--fa:"\e7d0"}.fa-cash-app{--fa:"\e7d4"}.fa-disqus{--fa:"\e7d5"}.fa-11ty,.fa-eleventy{--fa:"\e7d6"}.fa-kakao-talk{--fa:"\e7d7"}.fa-linktree{--fa:"\e7d8"}.fa-notion{--fa:"\e7d9"}.fa-pandora{--fa:"\e7da"}.fa-pixelfed{--fa:"\e7db"}.fa-tidal{--fa:"\e7dc"}.fa-vsco{--fa:"\e7dd"}.fa-w3c{--fa:"\e7de"}.fa-lumon{--fa:"\e7e2"}.fa-lumon-drop{--fa:"\e7e3"}.fa-square-figma{--fa:"\e7e4"}.fa-tex{--fa:"\e7ff"}.fa-duolingo{--fa:"\e812"}.fa-supportnow{--fa:"\e833"}.fa-tor-browser{--fa:"\e838"}.fa-square-twitter,.fa-twitter-square{--fa:"\f081"}.fa-facebook-square,.fa-square-facebook{--fa:"\f082"}.fa-linkedin{--fa:"\f08c"}.fa-github-square,.fa-square-github{--fa:"\f092"}.fa-twitter{--fa:"\f099"}.fa-facebook{--fa:"\f09a"}.fa-github{--fa:"\f09b"}.fa-pinterest{--fa:"\f0d2"}.fa-pinterest-square,.fa-square-pinterest{--fa:"\f0d3"}.fa-google-plus-square,.fa-square-google-plus{--fa:"\f0d4"}.fa-google-plus-g{--fa:"\f0d5"}.fa-linkedin-in{--fa:"\f0e1"}.fa-github-alt{--fa:"\f113"}.fa-maxcdn{--fa:"\f136"}.fa-html5{--fa:"\f13b"}.fa-css3{--fa:"\f13c"}.fa-btc{--fa:"\f15a"}.fa-youtube{--fa:"\f167"}.fa-xing{--fa:"\f168"}.fa-square-xing,.fa-xing-square{--fa:"\f169"}.fa-dropbox{--fa:"\f16b"}.fa-stack-overflow{--fa:"\f16c"}.fa-instagram{--fa:"\f16d"}.fa-flickr{--fa:"\f16e"}.fa-adn{--fa:"\f170"}.fa-bitbucket{--fa:"\f171"}.fa-tumblr{--fa:"\f173"}.fa-square-tumblr,.fa-tumblr-square{--fa:"\f174"}.fa-apple{--fa:"\f179"}.fa-windows{--fa:"\f17a"}.fa-android{--fa:"\f17b"}.fa-linux{--fa:"\f17c"}.fa-dribbble{--fa:"\f17d"}.fa-skype{--fa:"\f17e"}.fa-foursquare{--fa:"\f180"}.fa-trello{--fa:"\f181"}.fa-gratipay{--fa:"\f184"}.fa-vk{--fa:"\f189"}.fa-weibo{--fa:"\f18a"}.fa-renren{--fa:"\f18b"}.fa-pagelines{--fa:"\f18c"}.fa-stack-exchange{--fa:"\f18d"}.fa-square-vimeo,.fa-vimeo-square{--fa:"\f194"}.fa-slack,.fa-slack-hash{--fa:"\f198"}.fa-wordpress{--fa:"\f19a"}.fa-openid{--fa:"\f19b"}.fa-yahoo{--fa:"\f19e"}.fa-google{--fa:"\f1a0"}.fa-reddit{--fa:"\f1a1"}.fa-reddit-square,.fa-square-reddit{--fa:"\f1a2"}.fa-stumbleupon-circle{--fa:"\f1a3"}.fa-stumbleupon{--fa:"\f1a4"}.fa-delicious{--fa:"\f1a5"}.fa-digg{--fa:"\f1a6"}.fa-pied-piper-pp{--fa:"\f1a7"}.fa-pied-piper-alt{--fa:"\f1a8"}.fa-drupal{--fa:"\f1a9"}.fa-joomla{--fa:"\f1aa"}.fa-behance{--fa:"\f1b4"}.fa-behance-square,.fa-square-behance{--fa:"\f1b5"}.fa-steam{--fa:"\f1b6"}.fa-square-steam,.fa-steam-square{--fa:"\f1b7"}.fa-spotify{--fa:"\f1bc"}.fa-deviantart{--fa:"\f1bd"}.fa-soundcloud{--fa:"\f1be"}.fa-vine{--fa:"\f1ca"}.fa-codepen{--fa:"\f1cb"}.fa-jsfiddle{--fa:"\f1cc"}.fa-rebel{--fa:"\f1d0"}.fa-empire{--fa:"\f1d1"}.fa-git-square,.fa-square-git{--fa:"\f1d2"}.fa-git{--fa:"\f1d3"}.fa-hacker-news{--fa:"\f1d4"}.fa-tencent-weibo{--fa:"\f1d5"}.fa-qq{--fa:"\f1d6"}.fa-weixin{--fa:"\f1d7"}.fa-slideshare{--fa:"\f1e7"}.fa-twitch{--fa:"\f1e8"}.fa-yelp{--fa:"\f1e9"}.fa-paypal{--fa:"\f1ed"}.fa-google-wallet{--fa:"\f1ee"}.fa-cc-visa{--fa:"\f1f0"}.fa-cc-mastercard{--fa:"\f1f1"}.fa-cc-discover{--fa:"\f1f2"}.fa-cc-amex{--fa:"\f1f3"}.fa-cc-paypal{--fa:"\f1f4"}.fa-cc-stripe{--fa:"\f1f5"}.fa-lastfm{--fa:"\f202"}.fa-lastfm-square,.fa-square-lastfm{--fa:"\f203"}.fa-ioxhost{--fa:"\f208"}.fa-angellist{--fa:"\f209"}.fa-buysellads{--fa:"\f20d"}.fa-connectdevelop{--fa:"\f20e"}.fa-dashcube{--fa:"\f210"}.fa-forumbee{--fa:"\f211"}.fa-leanpub{--fa:"\f212"}.fa-sellsy{--fa:"\f213"}.fa-shirtsinbulk{--fa:"\f214"}.fa-simplybuilt{--fa:"\f215"}.fa-skyatlas{--fa:"\f216"}.fa-pinterest-p{--fa:"\f231"}.fa-whatsapp{--fa:"\f232"}.fa-viacoin{--fa:"\f237"}.fa-medium,.fa-medium-m{--fa:"\f23a"}.fa-y-combinator{--fa:"\f23b"}.fa-optin-monster{--fa:"\f23c"}.fa-opencart{--fa:"\f23d"}.fa-expeditedssl{--fa:"\f23e"}.fa-cc-jcb{--fa:"\f24b"}.fa-cc-diners-club{--fa:"\f24c"}.fa-creative-commons{--fa:"\f25e"}.fa-gg{--fa:"\f260"}.fa-gg-circle{--fa:"\f261"}.fa-odnoklassniki{--fa:"\f263"}.fa-odnoklassniki-square,.fa-square-odnoklassniki{--fa:"\f264"}.fa-get-pocket{--fa:"\f265"}.fa-wikipedia-w{--fa:"\f266"}.fa-safari{--fa:"\f267"}.fa-chrome{--fa:"\f268"}.fa-firefox{--fa:"\f269"}.fa-opera{--fa:"\f26a"}.fa-internet-explorer{--fa:"\f26b"}.fa-contao{--fa:"\f26d"}.fa-500px{--fa:"\f26e"}.fa-amazon{--fa:"\f270"}.fa-houzz{--fa:"\f27c"}.fa-vimeo-v{--fa:"\f27d"}.fa-black-tie{--fa:"\f27e"}.fa-fonticons{--fa:"\f280"}.fa-reddit-alien{--fa:"\f281"}.fa-edge{--fa:"\f282"}.fa-codiepie{--fa:"\f284"}.fa-modx{--fa:"\f285"}.fa-fort-awesome{--fa:"\f286"}.fa-usb{--fa:"\f287"}.fa-product-hunt{--fa:"\f288"}.fa-mixcloud{--fa:"\f289"}.fa-scribd{--fa:"\f28a"}.fa-bluetooth{--fa:"\f293"}.fa-bluetooth-b{--fa:"\f294"}.fa-gitlab{--fa:"\f296"}.fa-wpbeginner{--fa:"\f297"}.fa-wpforms{--fa:"\f298"}.fa-envira{--fa:"\f299"}.fa-glide{--fa:"\f2a5"}.fa-glide-g{--fa:"\f2a6"}.fa-viadeo{--fa:"\f2a9"}.fa-square-viadeo,.fa-viadeo-square{--fa:"\f2aa"}.fa-snapchat,.fa-snapchat-ghost{--fa:"\f2ab"}.fa-snapchat-square,.fa-square-snapchat{--fa:"\f2ad"}.fa-pied-piper{--fa:"\f2ae"}.fa-first-order{--fa:"\f2b0"}.fa-yoast{--fa:"\f2b1"}.fa-themeisle{--fa:"\f2b2"}.fa-google-plus{--fa:"\f2b3"}.fa-font-awesome,.fa-font-awesome-flag,.fa-font-awesome-logo-full{--fa:"\f2b4"}.fa-linode{--fa:"\f2b8"}.fa-quora{--fa:"\f2c4"}.fa-free-code-camp{--fa:"\f2c5"}.fa-telegram,.fa-telegram-plane{--fa:"\f2c6"}.fa-bandcamp{--fa:"\f2d5"}.fa-grav{--fa:"\f2d6"}.fa-etsy{--fa:"\f2d7"}.fa-imdb{--fa:"\f2d8"}.fa-ravelry{--fa:"\f2d9"}.fa-sellcast{--fa:"\f2da"}.fa-superpowers{--fa:"\f2dd"}.fa-wpexplorer{--fa:"\f2de"}.fa-meetup{--fa:"\f2e0"}.fa-font-awesome-alt,.fa-square-font-awesome-stroke{--fa:"\f35c"}.fa-accessible-icon{--fa:"\f368"}.fa-accusoft{--fa:"\f369"}.fa-adversal{--fa:"\f36a"}.fa-affiliatetheme{--fa:"\f36b"}.fa-algolia{--fa:"\f36c"}.fa-amilia{--fa:"\f36d"}.fa-angrycreative{--fa:"\f36e"}.fa-app-store{--fa:"\f36f"}.fa-app-store-ios{--fa:"\f370"}.fa-apper{--fa:"\f371"}.fa-asymmetrik{--fa:"\f372"}.fa-audible{--fa:"\f373"}.fa-avianex{--fa:"\f374"}.fa-aws{--fa:"\f375"}.fa-bimobject{--fa:"\f378"}.fa-bitcoin{--fa:"\f379"}.fa-bity{--fa:"\f37a"}.fa-blackberry{--fa:"\f37b"}.fa-blogger{--fa:"\f37c"}.fa-blogger-b{--fa:"\f37d"}.fa-buromobelexperte{--fa:"\f37f"}.fa-centercode{--fa:"\f380"}.fa-cloudscale{--fa:"\f383"}.fa-cloudsmith{--fa:"\f384"}.fa-cloudversify{--fa:"\f385"}.fa-cpanel{--fa:"\f388"}.fa-css3-alt{--fa:"\f38b"}.fa-cuttlefish{--fa:"\f38c"}.fa-d-and-d{--fa:"\f38d"}.fa-deploydog{--fa:"\f38e"}.fa-deskpro{--fa:"\f38f"}.fa-digital-ocean{--fa:"\f391"}.fa-discord{--fa:"\f392"}.fa-discourse{--fa:"\f393"}.fa-dochub{--fa:"\f394"}.fa-docker{--fa:"\f395"}.fa-draft2digital{--fa:"\f396"}.fa-dribbble-square,.fa-square-dribbble{--fa:"\f397"}.fa-dyalog{--fa:"\f399"}.fa-earlybirds{--fa:"\f39a"}.fa-erlang{--fa:"\f39d"}.fa-facebook-f{--fa:"\f39e"}.fa-facebook-messenger{--fa:"\f39f"}.fa-firstdraft{--fa:"\f3a1"}.fa-fonticons-fi{--fa:"\f3a2"}.fa-fort-awesome-alt{--fa:"\f3a3"}.fa-freebsd{--fa:"\f3a4"}.fa-gitkraken{--fa:"\f3a6"}.fa-gofore{--fa:"\f3a7"}.fa-goodreads{--fa:"\f3a8"}.fa-goodreads-g{--fa:"\f3a9"}.fa-google-drive{--fa:"\f3aa"}.fa-google-play{--fa:"\f3ab"}.fa-gripfire{--fa:"\f3ac"}.fa-grunt{--fa:"\f3ad"}.fa-gulp{--fa:"\f3ae"}.fa-hacker-news-square,.fa-square-hacker-news{--fa:"\f3af"}.fa-hire-a-helper{--fa:"\f3b0"}.fa-hotjar{--fa:"\f3b1"}.fa-hubspot{--fa:"\f3b2"}.fa-itunes{--fa:"\f3b4"}.fa-itunes-note{--fa:"\f3b5"}.fa-jenkins{--fa:"\f3b6"}.fa-joget{--fa:"\f3b7"}.fa-js{--fa:"\f3b8"}.fa-js-square,.fa-square-js{--fa:"\f3b9"}.fa-keycdn{--fa:"\f3ba"}.fa-kickstarter,.fa-square-kickstarter{--fa:"\f3bb"}.fa-kickstarter-k{--fa:"\f3bc"}.fa-laravel{--fa:"\f3bd"}.fa-line{--fa:"\f3c0"}.fa-lyft{--fa:"\f3c3"}.fa-magento{--fa:"\f3c4"}.fa-medapps{--fa:"\f3c6"}.fa-medrt{--fa:"\f3c8"}.fa-microsoft{--fa:"\f3ca"}.fa-mix{--fa:"\f3cb"}.fa-mizuni{--fa:"\f3cc"}.fa-monero{--fa:"\f3d0"}.fa-napster{--fa:"\f3d2"}.fa-node-js{--fa:"\f3d3"}.fa-npm{--fa:"\f3d4"}.fa-ns8{--fa:"\f3d5"}.fa-nutritionix{--fa:"\f3d6"}.fa-page4{--fa:"\f3d7"}.fa-palfed{--fa:"\f3d8"}.fa-patreon{--fa:"\f3d9"}.fa-periscope{--fa:"\f3da"}.fa-phabricator{--fa:"\f3db"}.fa-phoenix-framework{--fa:"\f3dc"}.fa-playstation{--fa:"\f3df"}.fa-pushed{--fa:"\f3e1"}.fa-python{--fa:"\f3e2"}.fa-red-river{--fa:"\f3e3"}.fa-rendact,.fa-wpressr{--fa:"\f3e4"}.fa-replyd{--fa:"\f3e6"}.fa-resolving{--fa:"\f3e7"}.fa-rocketchat{--fa:"\f3e8"}.fa-rockrms{--fa:"\f3e9"}.fa-schlix{--fa:"\f3ea"}.fa-searchengin{--fa:"\f3eb"}.fa-servicestack{--fa:"\f3ec"}.fa-sistrix{--fa:"\f3ee"}.fa-speakap{--fa:"\f3f3"}.fa-staylinked{--fa:"\f3f5"}.fa-steam-symbol{--fa:"\f3f6"}.fa-sticker-mule{--fa:"\f3f7"}.fa-studiovinari{--fa:"\f3f8"}.fa-supple{--fa:"\f3f9"}.fa-uber{--fa:"\f402"}.fa-uikit{--fa:"\f403"}.fa-uniregistry{--fa:"\f404"}.fa-untappd{--fa:"\f405"}.fa-ussunnah{--fa:"\f407"}.fa-vaadin{--fa:"\f408"}.fa-viber{--fa:"\f409"}.fa-vimeo{--fa:"\f40a"}.fa-vnv{--fa:"\f40b"}.fa-square-whatsapp,.fa-whatsapp-square{--fa:"\f40c"}.fa-whmcs{--fa:"\f40d"}.fa-wordpress-simple{--fa:"\f411"}.fa-xbox{--fa:"\f412"}.fa-yandex{--fa:"\f413"}.fa-yandex-international{--fa:"\f414"}.fa-apple-pay{--fa:"\f415"}.fa-cc-apple-pay{--fa:"\f416"}.fa-fly{--fa:"\f417"}.fa-node{--fa:"\f419"}.fa-osi{--fa:"\f41a"}.fa-react{--fa:"\f41b"}.fa-autoprefixer{--fa:"\f41c"}.fa-less{--fa:"\f41d"}.fa-sass{--fa:"\f41e"}.fa-vuejs{--fa:"\f41f"}.fa-angular{--fa:"\f420"}.fa-aviato{--fa:"\f421"}.fa-ember{--fa:"\f423"}.fa-gitter{--fa:"\f426"}.fa-hooli{--fa:"\f427"}.fa-strava{--fa:"\f428"}.fa-stripe{--fa:"\f429"}.fa-stripe-s{--fa:"\f42a"}.fa-typo3{--fa:"\f42b"}.fa-amazon-pay{--fa:"\f42c"}.fa-cc-amazon-pay{--fa:"\f42d"}.fa-ethereum{--fa:"\f42e"}.fa-korvue{--fa:"\f42f"}.fa-elementor{--fa:"\f430"}.fa-square-youtube,.fa-youtube-square{--fa:"\f431"}.fa-flipboard{--fa:"\f44d"}.fa-hips{--fa:"\f452"}.fa-php{--fa:"\f457"}.fa-quinscape{--fa:"\f459"}.fa-readme{--fa:"\f4d5"}.fa-java{--fa:"\f4e4"}.fa-pied-piper-hat{--fa:"\f4e5"}.fa-creative-commons-by{--fa:"\f4e7"}.fa-creative-commons-nc{--fa:"\f4e8"}.fa-creative-commons-nc-eu{--fa:"\f4e9"}.fa-creative-commons-nc-jp{--fa:"\f4ea"}.fa-creative-commons-nd{--fa:"\f4eb"}.fa-creative-commons-pd{--fa:"\f4ec"}.fa-creative-commons-pd-alt{--fa:"\f4ed"}.fa-creative-commons-remix{--fa:"\f4ee"}.fa-creative-commons-sa{--fa:"\f4ef"}.fa-creative-commons-sampling{--fa:"\f4f0"}.fa-creative-commons-sampling-plus{--fa:"\f4f1"}.fa-creative-commons-share{--fa:"\f4f2"}.fa-creative-commons-zero{--fa:"\f4f3"}.fa-ebay{--fa:"\f4f4"}.fa-keybase{--fa:"\f4f5"}.fa-mastodon{--fa:"\f4f6"}.fa-r-project{--fa:"\f4f7"}.fa-researchgate{--fa:"\f4f8"}.fa-teamspeak{--fa:"\f4f9"}.fa-first-order-alt{--fa:"\f50a"}.fa-fulcrum{--fa:"\f50b"}.fa-galactic-republic{--fa:"\f50c"}.fa-galactic-senate{--fa:"\f50d"}.fa-jedi-order{--fa:"\f50e"}.fa-mandalorian{--fa:"\f50f"}.fa-old-republic{--fa:"\f510"}.fa-phoenix-squadron{--fa:"\f511"}.fa-sith{--fa:"\f512"}.fa-trade-federation{--fa:"\f513"}.fa-wolf-pack-battalion{--fa:"\f514"}.fa-hornbill{--fa:"\f592"}.fa-mailchimp{--fa:"\f59e"}.fa-megaport{--fa:"\f5a3"}.fa-nimblr{--fa:"\f5a8"}.fa-rev{--fa:"\f5b2"}.fa-shopware{--fa:"\f5b5"}.fa-squarespace{--fa:"\f5be"}.fa-themeco{--fa:"\f5c6"}.fa-weebly{--fa:"\f5cc"}.fa-wix{--fa:"\f5cf"}.fa-ello{--fa:"\f5f1"}.fa-hackerrank{--fa:"\f5f7"}.fa-kaggle{--fa:"\f5fa"}.fa-markdown{--fa:"\f60f"}.fa-neos{--fa:"\f612"}.fa-zhihu{--fa:"\f63f"}.fa-alipay{--fa:"\f642"}.fa-the-red-yeti{--fa:"\f69d"}.fa-critical-role{--fa:"\f6c9"}.fa-d-and-d-beyond{--fa:"\f6ca"}.fa-dev{--fa:"\f6cc"}.fa-fantasy-flight-games{--fa:"\f6dc"}.fa-wizards-of-the-coast{--fa:"\f730"}.fa-think-peaks{--fa:"\f731"}.fa-reacteurope{--fa:"\f75d"}.fa-artstation{--fa:"\f77a"}.fa-atlassian{--fa:"\f77b"}.fa-canadian-maple-leaf{--fa:"\f785"}.fa-centos{--fa:"\f789"}.fa-confluence{--fa:"\f78d"}.fa-dhl{--fa:"\f790"}.fa-diaspora{--fa:"\f791"}.fa-fedex{--fa:"\f797"}.fa-fedora{--fa:"\f798"}.fa-figma{--fa:"\f799"}.fa-intercom{--fa:"\f7af"}.fa-invision{--fa:"\f7b0"}.fa-jira{--fa:"\f7b1"}.fa-mendeley{--fa:"\f7b3"}.fa-raspberry-pi{--fa:"\f7bb"}.fa-redhat{--fa:"\f7bc"}.fa-sketch{--fa:"\f7c6"}.fa-sourcetree{--fa:"\f7d3"}.fa-suse{--fa:"\f7d6"}.fa-ubuntu{--fa:"\f7df"}.fa-ups{--fa:"\f7e0"}.fa-usps{--fa:"\f7e1"}.fa-yarn{--fa:"\f7e3"}.fa-airbnb{--fa:"\f834"}.fa-battle-net{--fa:"\f835"}.fa-bootstrap{--fa:"\f836"}.fa-buffer{--fa:"\f837"}.fa-chromecast{--fa:"\f838"}.fa-evernote{--fa:"\f839"}.fa-itch-io{--fa:"\f83a"}.fa-salesforce{--fa:"\f83b"}.fa-speaker-deck{--fa:"\f83c"}.fa-symfony{--fa:"\f83d"}.fa-waze{--fa:"\f83f"}.fa-yammer{--fa:"\f840"}.fa-git-alt{--fa:"\f841"}.fa-stackpath{--fa:"\f842"}.fa-cotton-bureau{--fa:"\f89e"}.fa-buy-n-large{--fa:"\f8a6"}.fa-mdb{--fa:"\f8ca"}.fa-orcid{--fa:"\f8d2"}.fa-swift{--fa:"\f8e1"}.fa-umbraco{--fa:"\f8e8"} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/chisel-regular.css b/public/vendor/fontawesome/css/chisel-regular.css deleted file mode 100644 index 6eec49c..0000000 --- a/public/vendor/fontawesome/css/chisel-regular.css +++ /dev/null @@ -1,31 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-chisel: "Font Awesome 7 Chisel"; - --fa-font-chisel-regular: normal 400 1em/1 var(--fa-family-chisel); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-chisel: var(--fa-family-chisel); -} - -@font-face { - font-family: "Font Awesome 7 Chisel"; - font-style: normal; - font-weight: 400; - font-display: block; - src: url("../webfonts/fa-chisel-regular-400.woff2"); -} -.facr { - --fa-family: var(--fa-family-chisel); - --fa-style: 400; -} - -.fa-chisel { - --fa-family: var(--fa-family-chisel); -} - -.fa-regular { - --fa-style: 400; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/chisel-regular.min.css b/public/vendor/fontawesome/css/chisel-regular.min.css deleted file mode 100644 index afc80e0..0000000 --- a/public/vendor/fontawesome/css/chisel-regular.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-chisel:"Font Awesome 7 Chisel";--fa-font-chisel-regular:normal 400 1em/1 var(--fa-family-chisel);--fa-style-family-chisel:var(--fa-family-chisel)}@font-face{font-family:"Font Awesome 7 Chisel";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-chisel-regular-400.woff2)}.facr{--fa-style:400}.fa-chisel,.facr{--fa-family:var(--fa-family-chisel)}.fa-regular{--fa-style:400} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/duotone-light.css b/public/vendor/fontawesome/css/duotone-light.css deleted file mode 100644 index 27c6730..0000000 --- a/public/vendor/fontawesome/css/duotone-light.css +++ /dev/null @@ -1,71 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-duotone: "Font Awesome 7 Duotone"; - --fa-font-duotone-light: normal 300 1em/1 var(--fa-family-duotone); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-duotone: var(--fa-family-duotone); -} - -@font-face { - font-family: "Font Awesome 7 Duotone"; - font-style: normal; - font-weight: 300; - font-display: block; - src: url("../webfonts/fa-duotone-light-300.woff2"); -} -.fadl { - --fa-family: var(--fa-family-duotone); - --fa-style: 300; - position: relative; - letter-spacing: normal; -} - -.fa-duotone { - --fa-family: var(--fa-family-duotone); - position: relative; - letter-spacing: normal; -} - -.fa-light { - --fa-style: 300; -} - -.fadl::before, -.fa-duotone::before { - position: absolute; - color: var(--fa-primary-color, currentColor); - opacity: var(--fa-primary-opacity, 1); -} - -.fadl::after, -.fa-duotone::after { - color: var(--fa-secondary-color, currentColor); - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .fadl::before, -.fa-swap-opacity .fa-duotone::before, -.fa-swap-opacity.fadl::before, -.fa-swap-opacity.fa-duotone::before { - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .fadl::after, -.fa-swap-opacity .fa-duotone::after, -.fa-swap-opacity.fadl::after, -.fa-swap-opacity.fa-duotone::after { - opacity: var(--fa-primary-opacity, 1); -} - -.fa-li.fadl, -.fa-li.fa-duotone, -.fa-stack-1x.fadl, -.fa-stack-1x.fa-duotone, -.fa-stack-2x.fadl, -.fa-stack-2x.fa-duotone { - position: absolute; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/duotone-light.min.css b/public/vendor/fontawesome/css/duotone-light.min.css deleted file mode 100644 index 2ace5c2..0000000 --- a/public/vendor/fontawesome/css/duotone-light.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-duotone:"Font Awesome 7 Duotone";--fa-font-duotone-light:normal 300 1em/1 var(--fa-family-duotone);--fa-style-family-duotone:var(--fa-family-duotone)}@font-face{font-family:"Font Awesome 7 Duotone";font-style:normal;font-weight:300;font-display:block;src:url(../webfonts/fa-duotone-light-300.woff2)}.fadl{--fa-style:300}.fa-duotone,.fadl{--fa-family:var(--fa-family-duotone);position:relative;letter-spacing:normal}.fa-light{--fa-style:300}.fa-duotone:before,.fadl:before{position:absolute;color:var(--fa-primary-color,currentColor);opacity:var(--fa-primary-opacity,1)}.fa-duotone:after,.fadl:after{color:var(--fa-secondary-color,currentColor)}.fa-duotone:after,.fa-swap-opacity.fa-duotone:before,.fa-swap-opacity .fa-duotone:before,.fa-swap-opacity.fadl:before,.fa-swap-opacity .fadl:before,.fadl:after{opacity:var(--fa-secondary-opacity,.4)}.fa-swap-opacity.fa-duotone:after,.fa-swap-opacity .fa-duotone:after,.fa-swap-opacity.fadl:after,.fa-swap-opacity .fadl:after{opacity:var(--fa-primary-opacity,1)}.fa-li.fa-duotone,.fa-li.fadl,.fa-stack-1x.fa-duotone,.fa-stack-1x.fadl,.fa-stack-2x.fa-duotone,.fa-stack-2x.fadl{position:absolute} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/duotone-regular.css b/public/vendor/fontawesome/css/duotone-regular.css deleted file mode 100644 index c423dfd..0000000 --- a/public/vendor/fontawesome/css/duotone-regular.css +++ /dev/null @@ -1,71 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-duotone: "Font Awesome 7 Duotone"; - --fa-font-duotone-regular: normal 400 1em/1 var(--fa-family-duotone); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-duotone: var(--fa-family-duotone); -} - -@font-face { - font-family: "Font Awesome 7 Duotone"; - font-style: normal; - font-weight: 400; - font-display: block; - src: url("../webfonts/fa-duotone-regular-400.woff2"); -} -.fadr { - --fa-family: var(--fa-family-duotone); - --fa-style: 400; - position: relative; - letter-spacing: normal; -} - -.fa-duotone { - --fa-family: var(--fa-family-duotone); - position: relative; - letter-spacing: normal; -} - -.fa-regular { - --fa-style: 400; -} - -.fadr::before, -.fa-duotone::before { - position: absolute; - color: var(--fa-primary-color, currentColor); - opacity: var(--fa-primary-opacity, 1); -} - -.fadr::after, -.fa-duotone::after { - color: var(--fa-secondary-color, currentColor); - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .fadr::before, -.fa-swap-opacity .fa-duotone::before, -.fa-swap-opacity.fadr::before, -.fa-swap-opacity.fa-duotone::before { - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .fadr::after, -.fa-swap-opacity .fa-duotone::after, -.fa-swap-opacity.fadr::after, -.fa-swap-opacity.fa-duotone::after { - opacity: var(--fa-primary-opacity, 1); -} - -.fa-li.fadr, -.fa-li.fa-duotone, -.fa-stack-1x.fadr, -.fa-stack-1x.fa-duotone, -.fa-stack-2x.fadr, -.fa-stack-2x.fa-duotone { - position: absolute; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/duotone-regular.min.css b/public/vendor/fontawesome/css/duotone-regular.min.css deleted file mode 100644 index d190a62..0000000 --- a/public/vendor/fontawesome/css/duotone-regular.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-duotone:"Font Awesome 7 Duotone";--fa-font-duotone-regular:normal 400 1em/1 var(--fa-family-duotone);--fa-style-family-duotone:var(--fa-family-duotone)}@font-face{font-family:"Font Awesome 7 Duotone";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-duotone-regular-400.woff2)}.fadr{--fa-style:400}.fa-duotone,.fadr{--fa-family:var(--fa-family-duotone);position:relative;letter-spacing:normal}.fa-regular{--fa-style:400}.fa-duotone:before,.fadr:before{position:absolute;color:var(--fa-primary-color,currentColor);opacity:var(--fa-primary-opacity,1)}.fa-duotone:after,.fadr:after{color:var(--fa-secondary-color,currentColor)}.fa-duotone:after,.fa-swap-opacity.fa-duotone:before,.fa-swap-opacity .fa-duotone:before,.fa-swap-opacity.fadr:before,.fa-swap-opacity .fadr:before,.fadr:after{opacity:var(--fa-secondary-opacity,.4)}.fa-swap-opacity.fa-duotone:after,.fa-swap-opacity .fa-duotone:after,.fa-swap-opacity.fadr:after,.fa-swap-opacity .fadr:after{opacity:var(--fa-primary-opacity,1)}.fa-li.fa-duotone,.fa-li.fadr,.fa-stack-1x.fa-duotone,.fa-stack-1x.fadr,.fa-stack-2x.fa-duotone,.fa-stack-2x.fadr{position:absolute} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/duotone-thin.css b/public/vendor/fontawesome/css/duotone-thin.css deleted file mode 100644 index f70a20c..0000000 --- a/public/vendor/fontawesome/css/duotone-thin.css +++ /dev/null @@ -1,71 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-duotone: "Font Awesome 7 Duotone"; - --fa-font-duotone-thin: normal 100 1em/1 var(--fa-family-duotone); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-duotone: var(--fa-family-duotone); -} - -@font-face { - font-family: "Font Awesome 7 Duotone"; - font-style: normal; - font-weight: 100; - font-display: block; - src: url("../webfonts/fa-duotone-thin-100.woff2"); -} -.fadt { - --fa-family: var(--fa-family-duotone); - --fa-style: 100; - position: relative; - letter-spacing: normal; -} - -.fa-duotone { - --fa-family: var(--fa-family-duotone); - position: relative; - letter-spacing: normal; -} - -.fa-thin { - --fa-style: 100; -} - -.fadt::before, -.fa-duotone::before { - position: absolute; - color: var(--fa-primary-color, currentColor); - opacity: var(--fa-primary-opacity, 1); -} - -.fadt::after, -.fa-duotone::after { - color: var(--fa-secondary-color, currentColor); - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .fadt::before, -.fa-swap-opacity .fa-duotone::before, -.fa-swap-opacity.fadt::before, -.fa-swap-opacity.fa-duotone::before { - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .fadt::after, -.fa-swap-opacity .fa-duotone::after, -.fa-swap-opacity.fadt::after, -.fa-swap-opacity.fa-duotone::after { - opacity: var(--fa-primary-opacity, 1); -} - -.fa-li.fadt, -.fa-li.fa-duotone, -.fa-stack-1x.fadt, -.fa-stack-1x.fa-duotone, -.fa-stack-2x.fadt, -.fa-stack-2x.fa-duotone { - position: absolute; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/duotone-thin.min.css b/public/vendor/fontawesome/css/duotone-thin.min.css deleted file mode 100644 index 8704b0c..0000000 --- a/public/vendor/fontawesome/css/duotone-thin.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-duotone:"Font Awesome 7 Duotone";--fa-font-duotone-thin:normal 100 1em/1 var(--fa-family-duotone);--fa-style-family-duotone:var(--fa-family-duotone)}@font-face{font-family:"Font Awesome 7 Duotone";font-style:normal;font-weight:100;font-display:block;src:url(../webfonts/fa-duotone-thin-100.woff2)}.fadt{--fa-style:100}.fa-duotone,.fadt{--fa-family:var(--fa-family-duotone);position:relative;letter-spacing:normal}.fa-thin{--fa-style:100}.fa-duotone:before,.fadt:before{position:absolute;color:var(--fa-primary-color,currentColor);opacity:var(--fa-primary-opacity,1)}.fa-duotone:after,.fadt:after{color:var(--fa-secondary-color,currentColor)}.fa-duotone:after,.fa-swap-opacity.fa-duotone:before,.fa-swap-opacity .fa-duotone:before,.fa-swap-opacity.fadt:before,.fa-swap-opacity .fadt:before,.fadt:after{opacity:var(--fa-secondary-opacity,.4)}.fa-swap-opacity.fa-duotone:after,.fa-swap-opacity .fa-duotone:after,.fa-swap-opacity.fadt:after,.fa-swap-opacity .fadt:after{opacity:var(--fa-primary-opacity,1)}.fa-li.fa-duotone,.fa-li.fadt,.fa-stack-1x.fa-duotone,.fa-stack-1x.fadt,.fa-stack-2x.fa-duotone,.fa-stack-2x.fadt{position:absolute} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/duotone.css b/public/vendor/fontawesome/css/duotone.css deleted file mode 100644 index b0d8ae5..0000000 --- a/public/vendor/fontawesome/css/duotone.css +++ /dev/null @@ -1,62 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-duotone: "Font Awesome 7 Duotone"; - --fa-font-duotone: normal 900 1em/1 var(--fa-family-duotone); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-duotone: var(--fa-family-duotone); -} - -@font-face { - font-family: "Font Awesome 7 Duotone"; - font-style: normal; - font-weight: 900; - font-display: block; - src: url("../webfonts/fa-duotone-900.woff2"); -} -.fad, -.fa-duotone { - --fa-family: var(--fa-family-duotone); - --fa-style: 900; - position: relative; - letter-spacing: normal; -} - -.fad::before, -.fa-duotone::before { - position: absolute; - color: var(--fa-primary-color, currentColor); - opacity: var(--fa-primary-opacity, 1); -} - -.fad::after, -.fa-duotone::after { - color: var(--fa-secondary-color, currentColor); - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .fad::before, -.fa-swap-opacity .fa-duotone::before, -.fad.fa-swap-opacity::before, -.fa-duotone.fa-swap-opacity::before { - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .fad::after, -.fa-swap-opacity .fa-duotone::after, -.fad.fa-swap-opacity::after, -.fa-duotone.fa-swap-opacity::after { - opacity: var(--fa-primary-opacity, 1); -} - -.fad.fa-li, -.fa-duotone.fa-li, -.fad.fa-stack-1x, -.fa-duotone.fa-stack-1x, -.fad.fa-stack-2x, -.fa-duotone.fa-stack-2x { - position: absolute; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/duotone.min.css b/public/vendor/fontawesome/css/duotone.min.css deleted file mode 100644 index 3cffe6b..0000000 --- a/public/vendor/fontawesome/css/duotone.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-duotone:"Font Awesome 7 Duotone";--fa-font-duotone:normal 900 1em/1 var(--fa-family-duotone);--fa-style-family-duotone:var(--fa-family-duotone)}@font-face{font-family:"Font Awesome 7 Duotone";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-duotone-900.woff2)}.fa-duotone,.fad{--fa-family:var(--fa-family-duotone);--fa-style:900;position:relative;letter-spacing:normal}.fa-duotone:before,.fad:before{position:absolute;color:var(--fa-primary-color,currentColor);opacity:var(--fa-primary-opacity,1)}.fa-duotone:after,.fad:after{color:var(--fa-secondary-color,currentColor)}.fa-duotone.fa-swap-opacity:before,.fa-duotone:after,.fa-swap-opacity .fa-duotone:before,.fa-swap-opacity .fad:before,.fad.fa-swap-opacity:before,.fad:after{opacity:var(--fa-secondary-opacity,.4)}.fa-duotone.fa-swap-opacity:after,.fa-swap-opacity .fa-duotone:after,.fa-swap-opacity .fad:after,.fad.fa-swap-opacity:after{opacity:var(--fa-primary-opacity,1)}.fa-duotone.fa-li,.fa-duotone.fa-stack-1x,.fa-duotone.fa-stack-2x,.fad.fa-li,.fad.fa-stack-1x,.fad.fa-stack-2x{position:absolute} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/etch-solid.css b/public/vendor/fontawesome/css/etch-solid.css deleted file mode 100644 index a29e7d5..0000000 --- a/public/vendor/fontawesome/css/etch-solid.css +++ /dev/null @@ -1,31 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-etch: "Font Awesome 7 Etch"; - --fa-font-etch-solid: normal 900 1em/1 var(--fa-family-etch); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-etch: var(--fa-family-etch); -} - -@font-face { - font-family: "Font Awesome 7 Etch"; - font-style: normal; - font-weight: 900; - font-display: block; - src: url("../webfonts/fa-etch-solid-900.woff2"); -} -.faes { - --fa-family: var(--fa-family-etch); - --fa-style: 900; -} - -.fa-etch { - --fa-family: var(--fa-family-etch); -} - -.fa-solid { - --fa-style: 900; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/etch-solid.min.css b/public/vendor/fontawesome/css/etch-solid.min.css deleted file mode 100644 index 5fb6e6d..0000000 --- a/public/vendor/fontawesome/css/etch-solid.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-etch:"Font Awesome 7 Etch";--fa-font-etch-solid:normal 900 1em/1 var(--fa-family-etch);--fa-style-family-etch:var(--fa-family-etch)}@font-face{font-family:"Font Awesome 7 Etch";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-etch-solid-900.woff2)}.faes{--fa-style:900}.fa-etch,.faes{--fa-family:var(--fa-family-etch)}.fa-solid{--fa-style:900} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/fontawesome.css b/public/vendor/fontawesome/css/fontawesome.css deleted file mode 100644 index bb70012..0000000 --- a/public/vendor/fontawesome/css/fontawesome.css +++ /dev/null @@ -1,19421 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -.fa-solid, -.fa-regular, -.fa-brands, -.fa-classic, -.fas, -.far, -.fab, -.fal, -.fat, -.fad, -.fadr, -.fadl, -.fadt, -.fass, -.fasr, -.fasl, -.fast, -.fasds, -.fasdr, -.fasdl, -.fasdt, -.faslr, -.faslpr, -.fawsb, -.fatl, -.fans, -.fands, -.faes, -.fajr, -.fajfr, -.fajdr, -.facr, -.fausb, -.faudsb, -.faufsb, -.fa-solid, -.fa-semibold, -.fa-regular, -.fa-light, -.fa-thin, -.fa-brands, -.fa-classic, -.fa-duotone, -.fa-sharp, -.fa-sharp-duotone, -.fa-chisel, -.fa-etch, -.fa-jelly, -.fa-jelly-duo, -.fa-jelly-fill, -.fa-notdog, -.fa-notdog-duo, -.fa-slab, -.fa-slab-press, -.fa-thumbprint, -.fa-utility, -.fa-utility-duo, -.fa-utility-fill, -.fa-whiteboard, -.fa { - --_fa-family: var(--fa-family, var(--fa-style-family, "Font Awesome 7 Pro")); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - display: var(--fa-display, inline-block); - font-family: var(--_fa-family); - font-feature-settings: normal; - font-style: normal; - font-synthesis: none; - font-variant: normal; - font-weight: var(--fa-style, 900); - line-height: 1; - text-align: center; - text-rendering: auto; - width: var(--fa-width, 1.25em); -} - -:is(.fas, -.fass, -.far, -.fasr, -.fal, -.fasl, -.fat, -.fast, -.fad, -.fadr, -.fadl, -.fadt, -.fasds, -.fasdr, -.fasdl, -.fasdt, -.fab, -.faslr, -.faslpr, -.fawsb, -.fatl, -.fans, -.fands, -.faes, -.fajr, -.fajfr, -.fajdr, -.facr, -.fausb, -.faudsb, -.faufsb, -.fa-solid, -.fa-semibold, -.fa-regular, -.fa-light, -.fa-thin, -.fa-brands, -.fa-classic, -.fa-duotone, -.fa-sharp, -.fa-sharp-duotone, -.fa-chisel, -.fa-etch, -.fa-jelly, -.fa-jelly-duo, -.fa-jelly-fill, -.fa-notdog, -.fa-notdog-duo, -.fa-slab, -.fa-slab-press, -.fa-thumbprint, -.fa-utility, -.fa-utility-duo, -.fa-utility-fill, -.fa-whiteboard, -.fa)::before { - content: var(--fa)/""; -} - -@supports not (content: ""/"") { - :is(.fas, - .fass, - .far, - .fasr, - .fal, - .fasl, - .fat, - .fast, - .fad, - .fadr, - .fadl, - .fadt, - .fasds, - .fasdr, - .fasdl, - .fasdt, - .fab, - .faslr, - .faslpr, - .fawsb, - .fatl, - .fans, - .fands, - .faes, - .fajr, - .fajfr, - .fajdr, - .facr, - .fausb, - .faudsb, - .faufsb, - .fa-solid, - .fa-semibold, - .fa-regular, - .fa-light, - .fa-thin, - .fa-brands, - .fa-classic, - .fa-duotone, - .fa-sharp, - .fa-sharp-duotone, - .fa-chisel, - .fa-etch, - .fa-jelly, - .fa-jelly-duo, - .fa-jelly-fill, - .fa-notdog, - .fa-notdog-duo, - .fa-slab, - .fa-slab-press, - .fa-thumbprint, - .fa-utility, - .fa-utility-duo, - .fa-utility-fill, - .fa-whiteboard, - .fa)::before { - content: var(--fa); - } -} -:is(.fad, -.fa-duotone, -.fadr, -.fadl, -.fadt, -.fasds, -.fa-sharp-duotone, -.fasdr, -.fasdl, -.fasdt, -.fatl, -.fa-thumbprint, -.fands, -.fa-notdog-duo, -.fajdr, -.fa-jelly-duo, -.faudsb, -.fa-utility-duo)::after { - content: var(--fa); - font-feature-settings: "ss01"; -} - -@supports not (content: ""/"") { - :is(.fad, - .fa-duotone, - .fadr, - .fadl, - .fadt, - .fasds, - .fa-sharp-duotone, - .fasdr, - .fasdl, - .fasdt, - .fatl, - .fa-thumbprint, - .fands, - .fa-notdog-duo, - .fajdr, - .fa-jelly-duo, - .faudsb, - .fa-utility-duo)::after { - content: var(--fa); - } -} -.fa-1x { - font-size: 1em; -} - -.fa-2x { - font-size: 2em; -} - -.fa-3x { - font-size: 3em; -} - -.fa-4x { - font-size: 4em; -} - -.fa-5x { - font-size: 5em; -} - -.fa-6x { - font-size: 6em; -} - -.fa-7x { - font-size: 7em; -} - -.fa-8x { - font-size: 8em; -} - -.fa-9x { - font-size: 9em; -} - -.fa-10x { - font-size: 10em; -} - -.fa-2xs { - font-size: calc(10 / 16 * 1em); /* converts a 10px size into an em-based value that's relative to the scale's 16px base */ - line-height: calc(1 / 10 * 1em); /* sets the line-height of the icon back to that of it's parent */ - vertical-align: calc((6 / 10 - 0.375) * 1em); /* vertically centers the icon taking into account the surrounding text's descender */ -} - -.fa-xs { - font-size: calc(12 / 16 * 1em); /* converts a 12px size into an em-based value that's relative to the scale's 16px base */ - line-height: calc(1 / 12 * 1em); /* sets the line-height of the icon back to that of it's parent */ - vertical-align: calc((6 / 12 - 0.375) * 1em); /* vertically centers the icon taking into account the surrounding text's descender */ -} - -.fa-sm { - font-size: calc(14 / 16 * 1em); /* converts a 14px size into an em-based value that's relative to the scale's 16px base */ - line-height: calc(1 / 14 * 1em); /* sets the line-height of the icon back to that of it's parent */ - vertical-align: calc((6 / 14 - 0.375) * 1em); /* vertically centers the icon taking into account the surrounding text's descender */ -} - -.fa-lg { - font-size: calc(20 / 16 * 1em); /* converts a 20px size into an em-based value that's relative to the scale's 16px base */ - line-height: calc(1 / 20 * 1em); /* sets the line-height of the icon back to that of it's parent */ - vertical-align: calc((6 / 20 - 0.375) * 1em); /* vertically centers the icon taking into account the surrounding text's descender */ -} - -.fa-xl { - font-size: calc(24 / 16 * 1em); /* converts a 24px size into an em-based value that's relative to the scale's 16px base */ - line-height: calc(1 / 24 * 1em); /* sets the line-height of the icon back to that of it's parent */ - vertical-align: calc((6 / 24 - 0.375) * 1em); /* vertically centers the icon taking into account the surrounding text's descender */ -} - -.fa-2xl { - font-size: calc(32 / 16 * 1em); /* converts a 32px size into an em-based value that's relative to the scale's 16px base */ - line-height: calc(1 / 32 * 1em); /* sets the line-height of the icon back to that of it's parent */ - vertical-align: calc((6 / 32 - 0.375) * 1em); /* vertically centers the icon taking into account the surrounding text's descender */ -} - -.fa-width-auto { - --fa-width: auto; -} - -.fa-fw, -.fa-width-fixed { - --fa-width: 1.25em; -} - -.fa-ul { - list-style-type: none; - margin-inline-start: var(--fa-li-margin, 2.5em); - padding-inline-start: 0; -} -.fa-ul > li { - position: relative; -} - -.fa-li { - inset-inline-start: calc(-1 * var(--fa-li-width, 2em)); - position: absolute; - text-align: center; - width: var(--fa-li-width, 2em); - line-height: inherit; -} - -/* Heads Up: Bordered Icons will not be supported in the future! - - This feature will be deprecated in the next major release of Font Awesome (v8)! - - You may continue to use it in this version *v7), but it will not be supported in Font Awesome v8. -*/ -/* Notes: -* --@{v.$css-prefix}-border-width = 1/16 by default (to render as ~1px based on a 16px default font-size) -* --@{v.$css-prefix}-border-padding = - ** 3/16 for vertical padding (to give ~2px of vertical whitespace around an icon considering it's vertical alignment) - ** 4/16 for horizontal padding (to give ~4px of horizontal whitespace around an icon) -*/ -.fa-border { - border-color: var(--fa-border-color, #eee); - border-radius: var(--fa-border-radius, 0.1em); - border-style: var(--fa-border-style, solid); - border-width: var(--fa-border-width, 0.0625em); - box-sizing: var(--fa-border-box-sizing, content-box); - padding: var(--fa-border-padding, 0.1875em 0.25em); -} - -.fa-pull-left, -.fa-pull-start { - float: inline-start; - margin-inline-end: var(--fa-pull-margin, 0.3em); -} - -.fa-pull-right, -.fa-pull-end { - float: inline-end; - margin-inline-start: var(--fa-pull-margin, 0.3em); -} - -.fa-beat { - animation-name: fa-beat; - animation-delay: var(--fa-animation-delay, 0s); - animation-direction: var(--fa-animation-direction, normal); - animation-duration: var(--fa-animation-duration, 1s); - animation-iteration-count: var(--fa-animation-iteration-count, infinite); - animation-timing-function: var(--fa-animation-timing, ease-in-out); -} - -.fa-bounce { - animation-name: fa-bounce; - animation-delay: var(--fa-animation-delay, 0s); - animation-direction: var(--fa-animation-direction, normal); - animation-duration: var(--fa-animation-duration, 1s); - animation-iteration-count: var(--fa-animation-iteration-count, infinite); - animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.28, 0.84, 0.42, 1)); -} - -.fa-fade { - animation-name: fa-fade; - animation-delay: var(--fa-animation-delay, 0s); - animation-direction: var(--fa-animation-direction, normal); - animation-duration: var(--fa-animation-duration, 1s); - animation-iteration-count: var(--fa-animation-iteration-count, infinite); - animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1)); -} - -.fa-beat-fade { - animation-name: fa-beat-fade; - animation-delay: var(--fa-animation-delay, 0s); - animation-direction: var(--fa-animation-direction, normal); - animation-duration: var(--fa-animation-duration, 1s); - animation-iteration-count: var(--fa-animation-iteration-count, infinite); - animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1)); -} - -.fa-flip { - animation-name: fa-flip; - animation-delay: var(--fa-animation-delay, 0s); - animation-direction: var(--fa-animation-direction, normal); - animation-duration: var(--fa-animation-duration, 1s); - animation-iteration-count: var(--fa-animation-iteration-count, infinite); - animation-timing-function: var(--fa-animation-timing, ease-in-out); -} - -.fa-shake { - animation-name: fa-shake; - animation-delay: var(--fa-animation-delay, 0s); - animation-direction: var(--fa-animation-direction, normal); - animation-duration: var(--fa-animation-duration, 1s); - animation-iteration-count: var(--fa-animation-iteration-count, infinite); - animation-timing-function: var(--fa-animation-timing, linear); -} - -.fa-spin { - animation-name: fa-spin; - animation-delay: var(--fa-animation-delay, 0s); - animation-direction: var(--fa-animation-direction, normal); - animation-duration: var(--fa-animation-duration, 2s); - animation-iteration-count: var(--fa-animation-iteration-count, infinite); - animation-timing-function: var(--fa-animation-timing, linear); -} - -.fa-spin-reverse { - --fa-animation-direction: reverse; -} - -.fa-pulse, -.fa-spin-pulse { - animation-name: fa-spin; - animation-direction: var(--fa-animation-direction, normal); - animation-duration: var(--fa-animation-duration, 1s); - animation-iteration-count: var(--fa-animation-iteration-count, infinite); - animation-timing-function: var(--fa-animation-timing, steps(8)); -} - -@media (prefers-reduced-motion: reduce) { - .fa-beat, - .fa-bounce, - .fa-fade, - .fa-beat-fade, - .fa-flip, - .fa-pulse, - .fa-shake, - .fa-spin, - .fa-spin-pulse { - animation: none !important; - transition: none !important; - } -} -@keyframes fa-beat { - 0%, 90% { - transform: scale(1); - } - 45% { - transform: scale(var(--fa-beat-scale, 1.25)); - } -} -@keyframes fa-bounce { - 0% { - transform: scale(1, 1) translateY(0); - } - 10% { - transform: scale(var(--fa-bounce-start-scale-x, 1.1), var(--fa-bounce-start-scale-y, 0.9)) translateY(0); - } - 30% { - transform: scale(var(--fa-bounce-jump-scale-x, 0.9), var(--fa-bounce-jump-scale-y, 1.1)) translateY(var(--fa-bounce-height, -0.5em)); - } - 50% { - transform: scale(var(--fa-bounce-land-scale-x, 1.05), var(--fa-bounce-land-scale-y, 0.95)) translateY(0); - } - 57% { - transform: scale(1, 1) translateY(var(--fa-bounce-rebound, -0.125em)); - } - 64% { - transform: scale(1, 1) translateY(0); - } - 100% { - transform: scale(1, 1) translateY(0); - } -} -@keyframes fa-fade { - 50% { - opacity: var(--fa-fade-opacity, 0.4); - } -} -@keyframes fa-beat-fade { - 0%, 100% { - opacity: var(--fa-beat-fade-opacity, 0.4); - transform: scale(1); - } - 50% { - opacity: 1; - transform: scale(var(--fa-beat-fade-scale, 1.125)); - } -} -@keyframes fa-flip { - 50% { - transform: rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), var(--fa-flip-angle, -180deg)); - } -} -@keyframes fa-shake { - 0% { - transform: rotate(-15deg); - } - 4% { - transform: rotate(15deg); - } - 8%, 24% { - transform: rotate(-18deg); - } - 12%, 28% { - transform: rotate(18deg); - } - 16% { - transform: rotate(-22deg); - } - 20% { - transform: rotate(22deg); - } - 32% { - transform: rotate(-12deg); - } - 36% { - transform: rotate(12deg); - } - 40%, 100% { - transform: rotate(0deg); - } -} -@keyframes fa-spin { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } -} -.fa-rotate-90 { - transform: rotate(90deg); -} - -.fa-rotate-180 { - transform: rotate(180deg); -} - -.fa-rotate-270 { - transform: rotate(270deg); -} - -.fa-flip-horizontal { - transform: scale(-1, 1); -} - -.fa-flip-vertical { - transform: scale(1, -1); -} - -.fa-flip-both, -.fa-flip-horizontal.fa-flip-vertical { - transform: scale(-1, -1); -} - -.fa-rotate-by { - transform: rotate(var(--fa-rotate-angle, 0)); -} - -.fa-stack { - display: inline-block; - height: 2em; - line-height: 2em; - position: relative; - vertical-align: middle; - width: 2.5em; -} - -.fa-stack-1x, -.fa-stack-2x { - --fa-width: 100%; - inset: 0; - position: absolute; - text-align: center; - width: var(--fa-width); - z-index: var(--fa-stack-z-index, auto); -} - -.fa-stack-1x { - line-height: inherit; -} - -.fa-stack-2x { - font-size: 2em; -} - -.fa-inverse { - color: var(--fa-inverse, #fff); -} - -/* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen - readers do not read off random characters that represent icons */ - -.fa-0 { - --fa: "\30 "; -} - -.fa-1 { - --fa: "\31 "; -} - -.fa-2 { - --fa: "\32 "; -} - -.fa-3 { - --fa: "\33 "; -} - -.fa-4 { - --fa: "\34 "; -} - -.fa-5 { - --fa: "\35 "; -} - -.fa-6 { - --fa: "\36 "; -} - -.fa-7 { - --fa: "\37 "; -} - -.fa-8 { - --fa: "\38 "; -} - -.fa-9 { - --fa: "\39 "; -} - -.fa-exclamation { - --fa: "\!"; -} - -.fa-ditto { - --fa: "\""; -} - -.fa-hashtag { - --fa: "\#"; -} - -.fa-dollar-sign { - --fa: "\$"; -} - -.fa-dollar { - --fa: "\$"; -} - -.fa-usd { - --fa: "\$"; -} - -.fa-percent { - --fa: "\%"; -} - -.fa-percentage { - --fa: "\%"; -} - -.fa-ampersand { - --fa: "\&"; -} - -.fa-apostrophe { - --fa: "\'"; -} - -.fa-bracket-round { - --fa: "\("; -} - -.fa-parenthesis { - --fa: "\("; -} - -.fa-bracket-round-right { - --fa: "\)"; -} - -.fa-asterisk { - --fa: "\*"; -} - -.fa-plus { - --fa: "\+"; -} - -.fa-add { - --fa: "\+"; -} - -.fa-comma { - --fa: "\,"; -} - -.fa-hyphen { - --fa: "\-"; -} - -.fa-period { - --fa: "\."; -} - -.fa-slash-forward { - --fa: "\/"; -} - -.fa-colon { - --fa: "\:"; -} - -.fa-semicolon { - --fa: "\;"; -} - -.fa-less-than { - --fa: "\<"; -} - -.fa-equals { - --fa: "\="; -} - -.fa-greater-than { - --fa: "\>"; -} - -.fa-question { - --fa: "\?"; -} - -.fa-at { - --fa: "\@"; -} - -.fa-a { - --fa: "A"; -} - -.fa-b { - --fa: "B"; -} - -.fa-c { - --fa: "C"; -} - -.fa-d { - --fa: "D"; -} - -.fa-e { - --fa: "E"; -} - -.fa-f { - --fa: "F"; -} - -.fa-g { - --fa: "G"; -} - -.fa-h { - --fa: "H"; -} - -.fa-i { - --fa: "I"; -} - -.fa-j { - --fa: "J"; -} - -.fa-k { - --fa: "K"; -} - -.fa-l { - --fa: "L"; -} - -.fa-m { - --fa: "M"; -} - -.fa-n { - --fa: "N"; -} - -.fa-o { - --fa: "O"; -} - -.fa-p { - --fa: "P"; -} - -.fa-q { - --fa: "Q"; -} - -.fa-r { - --fa: "R"; -} - -.fa-s { - --fa: "S"; -} - -.fa-t { - --fa: "T"; -} - -.fa-u { - --fa: "U"; -} - -.fa-v { - --fa: "V"; -} - -.fa-w { - --fa: "W"; -} - -.fa-x { - --fa: "X"; -} - -.fa-y { - --fa: "Y"; -} - -.fa-z { - --fa: "Z"; -} - -.fa-bracket-square { - --fa: "\["; -} - -.fa-bracket { - --fa: "\["; -} - -.fa-bracket-left { - --fa: "\["; -} - -.fa-slash-back { - --fa: "\\"; -} - -.fa-bracket-square-right { - --fa: "\]"; -} - -.fa-accent-grave { - --fa: "\`"; -} - -.fa-bracket-curly { - --fa: "\{"; -} - -.fa-bracket-curly-left { - --fa: "\{"; -} - -.fa-pipe { - --fa: "\|"; -} - -.fa-bracket-curly-right { - --fa: "\}"; -} - -.fa-tilde { - --fa: "\~"; -} - -.fa-caravan-simple { - --fa: "\e000"; -} - -.fa-caravan-alt { - --fa: "\e000"; -} - -.fa-cat-space { - --fa: "\e001"; -} - -.fa-coffee-pot { - --fa: "\e002"; -} - -.fa-comet { - --fa: "\e003"; -} - -.fa-fan-table { - --fa: "\e004"; -} - -.fa-faucet { - --fa: "\e005"; -} - -.fa-faucet-drip { - --fa: "\e006"; -} - -.fa-galaxy { - --fa: "\e008"; -} - -.fa-garage { - --fa: "\e009"; -} - -.fa-garage-car { - --fa: "\e00a"; -} - -.fa-garage-open { - --fa: "\e00b"; -} - -.fa-heat { - --fa: "\e00c"; -} - -.fa-house-chimney-window { - --fa: "\e00d"; -} - -.fa-house-day { - --fa: "\e00e"; -} - -.fa-house-person-leave { - --fa: "\e00f"; -} - -.fa-house-leave { - --fa: "\e00f"; -} - -.fa-house-person-depart { - --fa: "\e00f"; -} - -.fa-house-night { - --fa: "\e010"; -} - -.fa-house-person-return { - --fa: "\e011"; -} - -.fa-house-person-arrive { - --fa: "\e011"; -} - -.fa-house-return { - --fa: "\e011"; -} - -.fa-house-signal { - --fa: "\e012"; -} - -.fa-lamp-desk { - --fa: "\e014"; -} - -.fa-lamp-floor { - --fa: "\e015"; -} - -.fa-light-ceiling { - --fa: "\e016"; -} - -.fa-light-switch { - --fa: "\e017"; -} - -.fa-light-switch-off { - --fa: "\e018"; -} - -.fa-light-switch-on { - --fa: "\e019"; -} - -.fa-microwave { - --fa: "\e01b"; -} - -.fa-outlet { - --fa: "\e01c"; -} - -.fa-oven { - --fa: "\e01d"; -} - -.fa-planet-moon { - --fa: "\e01f"; -} - -.fa-planet-ringed { - --fa: "\e020"; -} - -.fa-police-box { - --fa: "\e021"; -} - -.fa-person-to-portal { - --fa: "\e022"; -} - -.fa-portal-enter { - --fa: "\e022"; -} - -.fa-person-from-portal { - --fa: "\e023"; -} - -.fa-portal-exit { - --fa: "\e023"; -} - -.fa-radar { - --fa: "\e024"; -} - -.fa-raygun { - --fa: "\e025"; -} - -.fa-refrigerator { - --fa: "\e026"; -} - -.fa-rocket-launch { - --fa: "\e027"; -} - -.fa-sensor { - --fa: "\e028"; -} - -.fa-sensor-triangle-exclamation { - --fa: "\e029"; -} - -.fa-sensor-alert { - --fa: "\e029"; -} - -.fa-sensor-fire { - --fa: "\e02a"; -} - -.fa-sensor-on { - --fa: "\e02b"; -} - -.fa-sensor-cloud { - --fa: "\e02c"; -} - -.fa-sensor-smoke { - --fa: "\e02c"; -} - -.fa-siren { - --fa: "\e02d"; -} - -.fa-siren-on { - --fa: "\e02e"; -} - -.fa-solar-system { - --fa: "\e02f"; -} - -.fa-circle-sort { - --fa: "\e030"; -} - -.fa-sort-circle { - --fa: "\e030"; -} - -.fa-circle-sort-down { - --fa: "\e031"; -} - -.fa-sort-circle-down { - --fa: "\e031"; -} - -.fa-circle-sort-up { - --fa: "\e032"; -} - -.fa-sort-circle-up { - --fa: "\e032"; -} - -.fa-space-station-moon { - --fa: "\e033"; -} - -.fa-space-station-moon-construction { - --fa: "\e034"; -} - -.fa-space-station-moon-alt { - --fa: "\e034"; -} - -.fa-sprinkler { - --fa: "\e035"; -} - -.fa-star-shooting { - --fa: "\e036"; -} - -.fa-starfighter { - --fa: "\e037"; -} - -.fa-starfighter-twin-ion-engine { - --fa: "\e038"; -} - -.fa-starfighter-alt { - --fa: "\e038"; -} - -.fa-starship { - --fa: "\e039"; -} - -.fa-starship-freighter { - --fa: "\e03a"; -} - -.fa-sword-laser { - --fa: "\e03b"; -} - -.fa-sword-laser-alt { - --fa: "\e03c"; -} - -.fa-swords-laser { - --fa: "\e03d"; -} - -.fa-telescope { - --fa: "\e03e"; -} - -.fa-temperature-arrow-down { - --fa: "\e03f"; -} - -.fa-temperature-down { - --fa: "\e03f"; -} - -.fa-temperature-arrow-up { - --fa: "\e040"; -} - -.fa-temperature-up { - --fa: "\e040"; -} - -.fa-trailer { - --fa: "\e041"; -} - -.fa-transporter { - --fa: "\e042"; -} - -.fa-transporter-1 { - --fa: "\e043"; -} - -.fa-transporter-2 { - --fa: "\e044"; -} - -.fa-transporter-3 { - --fa: "\e045"; -} - -.fa-transporter-empty { - --fa: "\e046"; -} - -.fa-ufo { - --fa: "\e047"; -} - -.fa-ufo-beam { - --fa: "\e048"; -} - -.fa-user-alien { - --fa: "\e04a"; -} - -.fa-user-robot { - --fa: "\e04b"; -} - -.fa-user-visor { - --fa: "\e04c"; -} - -.fa-vacuum { - --fa: "\e04d"; -} - -.fa-vacuum-robot { - --fa: "\e04e"; -} - -.fa-window-frame { - --fa: "\e04f"; -} - -.fa-window-frame-open { - --fa: "\e050"; -} - -.fa-coffin-cross { - --fa: "\e051"; -} - -.fa-folder-arrow-down { - --fa: "\e053"; -} - -.fa-folder-download { - --fa: "\e053"; -} - -.fa-folder-arrow-up { - --fa: "\e054"; -} - -.fa-folder-upload { - --fa: "\e054"; -} - -.fa-user-unlock { - --fa: "\e058"; -} - -.fa-bacteria { - --fa: "\e059"; -} - -.fa-bacterium { - --fa: "\e05a"; -} - -.fa-box-tissue { - --fa: "\e05b"; -} - -.fa-hand-holding-medical { - --fa: "\e05c"; -} - -.fa-hand-sparkles { - --fa: "\e05d"; -} - -.fa-hands-bubbles { - --fa: "\e05e"; -} - -.fa-hands-wash { - --fa: "\e05e"; -} - -.fa-handshake-slash { - --fa: "\e060"; -} - -.fa-handshake-alt-slash { - --fa: "\e060"; -} - -.fa-handshake-simple-slash { - --fa: "\e060"; -} - -.fa-head-side-cough { - --fa: "\e061"; -} - -.fa-head-side-cough-slash { - --fa: "\e062"; -} - -.fa-head-side-mask { - --fa: "\e063"; -} - -.fa-head-side-virus { - --fa: "\e064"; -} - -.fa-house-chimney-user { - --fa: "\e065"; -} - -.fa-house-laptop { - --fa: "\e066"; -} - -.fa-laptop-house { - --fa: "\e066"; -} - -.fa-lungs-virus { - --fa: "\e067"; -} - -.fa-people-arrows { - --fa: "\e068"; -} - -.fa-people-arrows-left-right { - --fa: "\e068"; -} - -.fa-plane-slash { - --fa: "\e069"; -} - -.fa-pump-medical { - --fa: "\e06a"; -} - -.fa-pump-soap { - --fa: "\e06b"; -} - -.fa-shield-virus { - --fa: "\e06c"; -} - -.fa-sink { - --fa: "\e06d"; -} - -.fa-soap { - --fa: "\e06e"; -} - -.fa-stopwatch-20 { - --fa: "\e06f"; -} - -.fa-shop-slash { - --fa: "\e070"; -} - -.fa-store-alt-slash { - --fa: "\e070"; -} - -.fa-store-slash { - --fa: "\e071"; -} - -.fa-toilet-paper-slash { - --fa: "\e072"; -} - -.fa-users-slash { - --fa: "\e073"; -} - -.fa-virus { - --fa: "\e074"; -} - -.fa-virus-slash { - --fa: "\e075"; -} - -.fa-viruses { - --fa: "\e076"; -} - -.fa-vest { - --fa: "\e085"; -} - -.fa-vest-patches { - --fa: "\e086"; -} - -.fa-airplay { - --fa: "\e089"; -} - -.fa-alt { - --fa: "\e08a"; -} - -.fa-angle { - --fa: "\e08c"; -} - -.fa-angle-90 { - --fa: "\e08d"; -} - -.fa-apple-core { - --fa: "\e08f"; -} - -.fa-arrow-down-from-dotted-line { - --fa: "\e090"; -} - -.fa-arrow-down-left { - --fa: "\e091"; -} - -.fa-arrow-down-left-and-arrow-up-right-to-center { - --fa: "\e092"; -} - -.fa-arrow-down-right { - --fa: "\e093"; -} - -.fa-arrow-down-to-bracket { - --fa: "\e094"; -} - -.fa-arrow-down-to-dotted-line { - --fa: "\e095"; -} - -.fa-arrow-down-to-square { - --fa: "\e096"; -} - -.fa-arrow-trend-down { - --fa: "\e097"; -} - -.fa-arrow-trend-up { - --fa: "\e098"; -} - -.fa-arrow-up-arrow-down { - --fa: "\e099"; -} - -.fa-sort-up-down { - --fa: "\e099"; -} - -.fa-arrow-up-from-bracket { - --fa: "\e09a"; -} - -.fa-arrow-up-from-dotted-line { - --fa: "\e09b"; -} - -.fa-arrow-up-from-square { - --fa: "\e09c"; -} - -.fa-arrow-up-left { - --fa: "\e09d"; -} - -.fa-arrow-up-left-from-circle { - --fa: "\e09e"; -} - -.fa-arrow-up-right { - --fa: "\e09f"; -} - -.fa-arrow-up-right-and-arrow-down-left-from-center { - --fa: "\e0a0"; -} - -.fa-arrow-up-to-dotted-line { - --fa: "\e0a1"; -} - -.fa-arrows-cross { - --fa: "\e0a2"; -} - -.fa-arrows-from-dotted-line { - --fa: "\e0a3"; -} - -.fa-arrows-from-line { - --fa: "\e0a4"; -} - -.fa-arrows-minimize { - --fa: "\e0a5"; -} - -.fa-compress-arrows { - --fa: "\e0a5"; -} - -.fa-arrows-to-dotted-line { - --fa: "\e0a6"; -} - -.fa-arrows-to-line { - --fa: "\e0a7"; -} - -.fa-audio-description-slash { - --fa: "\e0a8"; -} - -.fa-austral-sign { - --fa: "\e0a9"; -} - -.fa-avocado { - --fa: "\e0aa"; -} - -.fa-award-simple { - --fa: "\e0ab"; -} - -.fa-baht-sign { - --fa: "\e0ac"; -} - -.fa-bars-filter { - --fa: "\e0ad"; -} - -.fa-bars-sort { - --fa: "\e0ae"; -} - -.fa-basket-shopping-simple { - --fa: "\e0af"; -} - -.fa-shopping-basket-alt { - --fa: "\e0af"; -} - -.fa-battery-exclamation { - --fa: "\e0b0"; -} - -.fa-battery-low { - --fa: "\e0b1"; -} - -.fa-battery-1 { - --fa: "\e0b1"; -} - -.fa-bee { - --fa: "\e0b2"; -} - -.fa-beer-mug { - --fa: "\e0b3"; -} - -.fa-beer-foam { - --fa: "\e0b3"; -} - -.fa-bitcoin-sign { - --fa: "\e0b4"; -} - -.fa-block-quote { - --fa: "\e0b5"; -} - -.fa-bolt-auto { - --fa: "\e0b6"; -} - -.fa-bolt-lightning { - --fa: "\e0b7"; -} - -.fa-bolt-slash { - --fa: "\e0b8"; -} - -.fa-book-arrow-right { - --fa: "\e0b9"; -} - -.fa-book-arrow-up { - --fa: "\e0ba"; -} - -.fa-book-bookmark { - --fa: "\e0bb"; -} - -.fa-book-circle-arrow-right { - --fa: "\e0bc"; -} - -.fa-book-circle-arrow-up { - --fa: "\e0bd"; -} - -.fa-book-copy { - --fa: "\e0be"; -} - -.fa-book-font { - --fa: "\e0bf"; -} - -.fa-book-open-cover { - --fa: "\e0c0"; -} - -.fa-book-open-alt { - --fa: "\e0c0"; -} - -.fa-book-section { - --fa: "\e0c1"; -} - -.fa-book-law { - --fa: "\e0c1"; -} - -.fa-bookmark-slash { - --fa: "\e0c2"; -} - -.fa-bowling-ball-pin { - --fa: "\e0c3"; -} - -.fa-box-circle-check { - --fa: "\e0c4"; -} - -.fa-brackets-round { - --fa: "\e0c5"; -} - -.fa-parentheses { - --fa: "\e0c5"; -} - -.fa-brain-circuit { - --fa: "\e0c6"; -} - -.fa-brake-warning { - --fa: "\e0c7"; -} - -.fa-briefcase-blank { - --fa: "\e0c8"; -} - -.fa-brightness { - --fa: "\e0c9"; -} - -.fa-brightness-low { - --fa: "\e0ca"; -} - -.fa-browsers { - --fa: "\e0cb"; -} - -.fa-buildings { - --fa: "\e0cc"; -} - -.fa-burger-fries { - --fa: "\e0cd"; -} - -.fa-burger-glass { - --fa: "\e0ce"; -} - -.fa-calendar-arrow-down { - --fa: "\e0d0"; -} - -.fa-calendar-download { - --fa: "\e0d0"; -} - -.fa-calendar-arrow-up { - --fa: "\e0d1"; -} - -.fa-calendar-upload { - --fa: "\e0d1"; -} - -.fa-calendar-clock { - --fa: "\e0d2"; -} - -.fa-calendar-time { - --fa: "\e0d2"; -} - -.fa-calendar-heart { - --fa: "\e0d3"; -} - -.fa-calendar-image { - --fa: "\e0d4"; -} - -.fa-calendar-lines { - --fa: "\e0d5"; -} - -.fa-calendar-note { - --fa: "\e0d5"; -} - -.fa-calendar-range { - --fa: "\e0d6"; -} - -.fa-calendars { - --fa: "\e0d7"; -} - -.fa-camera-rotate { - --fa: "\e0d8"; -} - -.fa-camera-slash { - --fa: "\e0d9"; -} - -.fa-camera-viewfinder { - --fa: "\e0da"; -} - -.fa-screenshot { - --fa: "\e0da"; -} - -.fa-cart-minus { - --fa: "\e0db"; -} - -.fa-cart-shopping-fast { - --fa: "\e0dc"; -} - -.fa-cart-xmark { - --fa: "\e0dd"; -} - -.fa-castle { - --fa: "\e0de"; -} - -.fa-cedi-sign { - --fa: "\e0df"; -} - -.fa-chart-bullet { - --fa: "\e0e1"; -} - -.fa-chart-candlestick { - --fa: "\e0e2"; -} - -.fa-chart-column { - --fa: "\e0e3"; -} - -.fa-chart-gantt { - --fa: "\e0e4"; -} - -.fa-chart-line-up { - --fa: "\e0e5"; -} - -.fa-chart-pyramid { - --fa: "\e0e6"; -} - -.fa-chart-radar { - --fa: "\e0e7"; -} - -.fa-chart-scatter-3d { - --fa: "\e0e8"; -} - -.fa-chart-scatter-bubble { - --fa: "\e0e9"; -} - -.fa-chart-tree-map { - --fa: "\e0ea"; -} - -.fa-chart-waterfall { - --fa: "\e0eb"; -} - -.fa-cherries { - --fa: "\e0ec"; -} - -.fa-circle-0 { - --fa: "\e0ed"; -} - -.fa-circle-1 { - --fa: "\e0ee"; -} - -.fa-circle-2 { - --fa: "\e0ef"; -} - -.fa-circle-3 { - --fa: "\e0f0"; -} - -.fa-circle-4 { - --fa: "\e0f1"; -} - -.fa-circle-5 { - --fa: "\e0f2"; -} - -.fa-circle-6 { - --fa: "\e0f3"; -} - -.fa-circle-7 { - --fa: "\e0f4"; -} - -.fa-circle-8 { - --fa: "\e0f5"; -} - -.fa-circle-9 { - --fa: "\e0f6"; -} - -.fa-circle-a { - --fa: "\e0f7"; -} - -.fa-circle-ampersand { - --fa: "\e0f8"; -} - -.fa-circle-arrow-down-left { - --fa: "\e0f9"; -} - -.fa-circle-arrow-down-right { - --fa: "\e0fa"; -} - -.fa-circle-arrow-up-left { - --fa: "\e0fb"; -} - -.fa-circle-arrow-up-right { - --fa: "\e0fc"; -} - -.fa-circle-b { - --fa: "\e0fd"; -} - -.fa-circle-bolt { - --fa: "\e0fe"; -} - -.fa-circle-book-open { - --fa: "\e0ff"; -} - -.fa-book-circle { - --fa: "\e0ff"; -} - -.fa-circle-bookmark { - --fa: "\e100"; -} - -.fa-bookmark-circle { - --fa: "\e100"; -} - -.fa-circle-c { - --fa: "\e101"; -} - -.fa-circle-calendar { - --fa: "\e102"; -} - -.fa-calendar-circle { - --fa: "\e102"; -} - -.fa-circle-camera { - --fa: "\e103"; -} - -.fa-camera-circle { - --fa: "\e103"; -} - -.fa-circle-d { - --fa: "\e104"; -} - -.fa-circle-dashed { - --fa: "\e105"; -} - -.fa-circle-divide { - --fa: "\e106"; -} - -.fa-circle-down-left { - --fa: "\e107"; -} - -.fa-circle-down-right { - --fa: "\e108"; -} - -.fa-circle-e { - --fa: "\e109"; -} - -.fa-circle-ellipsis { - --fa: "\e10a"; -} - -.fa-circle-ellipsis-vertical { - --fa: "\e10b"; -} - -.fa-circle-envelope { - --fa: "\e10c"; -} - -.fa-envelope-circle { - --fa: "\e10c"; -} - -.fa-circle-exclamation-check { - --fa: "\e10d"; -} - -.fa-circle-f { - --fa: "\e10e"; -} - -.fa-circle-g { - --fa: "\e10f"; -} - -.fa-circle-half { - --fa: "\e110"; -} - -.fa-circle-i { - --fa: "\e111"; -} - -.fa-circle-j { - --fa: "\e112"; -} - -.fa-circle-k { - --fa: "\e113"; -} - -.fa-circle-l { - --fa: "\e114"; -} - -.fa-circle-m { - --fa: "\e115"; -} - -.fa-circle-microphone { - --fa: "\e116"; -} - -.fa-microphone-circle { - --fa: "\e116"; -} - -.fa-circle-microphone-lines { - --fa: "\e117"; -} - -.fa-microphone-circle-alt { - --fa: "\e117"; -} - -.fa-circle-n { - --fa: "\e118"; -} - -.fa-circle-o { - --fa: "\e119"; -} - -.fa-circle-p { - --fa: "\e11a"; -} - -.fa-circle-phone { - --fa: "\e11b"; -} - -.fa-phone-circle { - --fa: "\e11b"; -} - -.fa-circle-phone-flip { - --fa: "\e11c"; -} - -.fa-phone-circle-alt { - --fa: "\e11c"; -} - -.fa-circle-phone-hangup { - --fa: "\e11d"; -} - -.fa-phone-circle-down { - --fa: "\e11d"; -} - -.fa-circle-q { - --fa: "\e11e"; -} - -.fa-circle-quarter { - --fa: "\e11f"; -} - -.fa-circle-r { - --fa: "\e120"; -} - -.fa-circle-s { - --fa: "\e121"; -} - -.fa-circle-small { - --fa: "\e122"; -} - -.fa-circle-star { - --fa: "\e123"; -} - -.fa-star-circle { - --fa: "\e123"; -} - -.fa-circle-t { - --fa: "\e124"; -} - -.fa-circle-three-quarters { - --fa: "\e125"; -} - -.fa-circle-trash { - --fa: "\e126"; -} - -.fa-trash-circle { - --fa: "\e126"; -} - -.fa-circle-u { - --fa: "\e127"; -} - -.fa-circle-up-left { - --fa: "\e128"; -} - -.fa-circle-up-right { - --fa: "\e129"; -} - -.fa-circle-v { - --fa: "\e12a"; -} - -.fa-circle-video { - --fa: "\e12b"; -} - -.fa-video-circle { - --fa: "\e12b"; -} - -.fa-circle-w { - --fa: "\e12c"; -} - -.fa-circle-waveform-lines { - --fa: "\e12d"; -} - -.fa-waveform-circle { - --fa: "\e12d"; -} - -.fa-circle-x { - --fa: "\e12e"; -} - -.fa-circle-y { - --fa: "\e12f"; -} - -.fa-circle-z { - --fa: "\e130"; -} - -.fa-clapperboard { - --fa: "\e131"; -} - -.fa-clapperboard-play { - --fa: "\e132"; -} - -.fa-clipboard-medical { - --fa: "\e133"; -} - -.fa-clock-desk { - --fa: "\e134"; -} - -.fa-closed-captioning-slash { - --fa: "\e135"; -} - -.fa-clothes-hanger { - --fa: "\e136"; -} - -.fa-cloud-slash { - --fa: "\e137"; -} - -.fa-cloud-word { - --fa: "\e138"; -} - -.fa-clover { - --fa: "\e139"; -} - -.fa-code-compare { - --fa: "\e13a"; -} - -.fa-code-fork { - --fa: "\e13b"; -} - -.fa-code-pull-request { - --fa: "\e13c"; -} - -.fa-code-simple { - --fa: "\e13d"; -} - -.fa-coffee-bean { - --fa: "\e13e"; -} - -.fa-coffee-beans { - --fa: "\e13f"; -} - -.fa-colon-sign { - --fa: "\e140"; -} - -.fa-command { - --fa: "\e142"; -} - -.fa-comment-arrow-down { - --fa: "\e143"; -} - -.fa-comment-arrow-up { - --fa: "\e144"; -} - -.fa-comment-arrow-up-right { - --fa: "\e145"; -} - -.fa-comment-captions { - --fa: "\e146"; -} - -.fa-comment-code { - --fa: "\e147"; -} - -.fa-comment-image { - --fa: "\e148"; -} - -.fa-comment-middle { - --fa: "\e149"; -} - -.fa-comment-middle-top { - --fa: "\e14a"; -} - -.fa-comment-question { - --fa: "\e14b"; -} - -.fa-comment-quote { - --fa: "\e14c"; -} - -.fa-comment-text { - --fa: "\e14d"; -} - -.fa-comments-question { - --fa: "\e14e"; -} - -.fa-comments-question-check { - --fa: "\e14f"; -} - -.fa-conveyor-belt-empty { - --fa: "\e150"; -} - -.fa-crate-empty { - --fa: "\e151"; -} - -.fa-cruzeiro-sign { - --fa: "\e152"; -} - -.fa-delete-right { - --fa: "\e154"; -} - -.fa-desktop-arrow-down { - --fa: "\e155"; -} - -.fa-diagram-lean-canvas { - --fa: "\e156"; -} - -.fa-diagram-nested { - --fa: "\e157"; -} - -.fa-diagram-sankey { - --fa: "\e158"; -} - -.fa-diagram-venn { - --fa: "\e15a"; -} - -.fa-dial { - --fa: "\e15b"; -} - -.fa-dial-med-high { - --fa: "\e15b"; -} - -.fa-dial-high { - --fa: "\e15c"; -} - -.fa-dial-low { - --fa: "\e15d"; -} - -.fa-dial-max { - --fa: "\e15e"; -} - -.fa-dial-med { - --fa: "\e15f"; -} - -.fa-dial-med-low { - --fa: "\e160"; -} - -.fa-dial-min { - --fa: "\e161"; -} - -.fa-dial-off { - --fa: "\e162"; -} - -.fa-display { - --fa: "\e163"; -} - -.fa-display-arrow-down { - --fa: "\e164"; -} - -.fa-display-code { - --fa: "\e165"; -} - -.fa-desktop-code { - --fa: "\e165"; -} - -.fa-display-medical { - --fa: "\e166"; -} - -.fa-desktop-medical { - --fa: "\e166"; -} - -.fa-dolphin { - --fa: "\e168"; -} - -.fa-dong-sign { - --fa: "\e169"; -} - -.fa-down-left { - --fa: "\e16a"; -} - -.fa-down-right { - --fa: "\e16b"; -} - -.fa-eggplant { - --fa: "\e16c"; -} - -.fa-elevator { - --fa: "\e16d"; -} - -.fa-engine { - --fa: "\e16e"; -} - -.fa-envelope-dot { - --fa: "\e16f"; -} - -.fa-envelope-badge { - --fa: "\e16f"; -} - -.fa-envelopes { - --fa: "\e170"; -} - -.fa-escalator { - --fa: "\e171"; -} - -.fa-eye-dropper-full { - --fa: "\e172"; -} - -.fa-eye-dropper-half { - --fa: "\e173"; -} - -.fa-ferris-wheel { - --fa: "\e174"; -} - -.fa-file-binary { - --fa: "\e175"; -} - -.fa-file-heart { - --fa: "\e176"; -} - -.fa-file-plus-minus { - --fa: "\e177"; -} - -.fa-files { - --fa: "\e178"; -} - -.fa-film-slash { - --fa: "\e179"; -} - -.fa-films { - --fa: "\e17a"; -} - -.fa-filter-circle-xmark { - --fa: "\e17b"; -} - -.fa-filter-list { - --fa: "\e17c"; -} - -.fa-filter-slash { - --fa: "\e17d"; -} - -.fa-filters { - --fa: "\e17e"; -} - -.fa-fire-hydrant { - --fa: "\e17f"; -} - -.fa-floppy-disk-circle-arrow-right { - --fa: "\e180"; -} - -.fa-save-circle-arrow-right { - --fa: "\e180"; -} - -.fa-floppy-disk-circle-xmark { - --fa: "\e181"; -} - -.fa-floppy-disk-times { - --fa: "\e181"; -} - -.fa-save-circle-xmark { - --fa: "\e181"; -} - -.fa-save-times { - --fa: "\e181"; -} - -.fa-floppy-disk-pen { - --fa: "\e182"; -} - -.fa-floppy-disks { - --fa: "\e183"; -} - -.fa-florin-sign { - --fa: "\e184"; -} - -.fa-folder-closed { - --fa: "\e185"; -} - -.fa-folder-bookmark { - --fa: "\e186"; -} - -.fa-folder-gear { - --fa: "\e187"; -} - -.fa-folder-cog { - --fa: "\e187"; -} - -.fa-folder-grid { - --fa: "\e188"; -} - -.fa-folder-heart { - --fa: "\e189"; -} - -.fa-folder-image { - --fa: "\e18a"; -} - -.fa-folder-magnifying-glass { - --fa: "\e18b"; -} - -.fa-folder-search { - --fa: "\e18b"; -} - -.fa-folder-medical { - --fa: "\e18c"; -} - -.fa-folder-music { - --fa: "\e18d"; -} - -.fa-folder-user { - --fa: "\e18e"; -} - -.fa-franc-sign { - --fa: "\e18f"; -} - -.fa-gif { - --fa: "\e190"; -} - -.fa-glass-empty { - --fa: "\e191"; -} - -.fa-glass-half { - --fa: "\e192"; -} - -.fa-glass-half-empty { - --fa: "\e192"; -} - -.fa-glass-half-full { - --fa: "\e192"; -} - -.fa-grate { - --fa: "\e193"; -} - -.fa-grate-droplet { - --fa: "\e194"; -} - -.fa-grid { - --fa: "\e195"; -} - -.fa-grid-3 { - --fa: "\e195"; -} - -.fa-grid-2 { - --fa: "\e196"; -} - -.fa-grid-2-plus { - --fa: "\e197"; -} - -.fa-grid-4 { - --fa: "\e198"; -} - -.fa-grid-5 { - --fa: "\e199"; -} - -.fa-guarani-sign { - --fa: "\e19a"; -} - -.fa-gun { - --fa: "\e19b"; -} - -.fa-gun-slash { - --fa: "\e19c"; -} - -.fa-gun-squirt { - --fa: "\e19d"; -} - -.fa-hand-back-point-down { - --fa: "\e19e"; -} - -.fa-hand-back-point-left { - --fa: "\e19f"; -} - -.fa-hand-back-point-ribbon { - --fa: "\e1a0"; -} - -.fa-hand-back-point-right { - --fa: "\e1a1"; -} - -.fa-hand-back-point-up { - --fa: "\e1a2"; -} - -.fa-hand-fingers-crossed { - --fa: "\e1a3"; -} - -.fa-hand-holding-skull { - --fa: "\e1a4"; -} - -.fa-hand-love { - --fa: "\e1a5"; -} - -.fa-hand-point-ribbon { - --fa: "\e1a6"; -} - -.fa-hand-wave { - --fa: "\e1a7"; -} - -.fa-hands-clapping { - --fa: "\e1a8"; -} - -.fa-hand-horns { - --fa: "\e1a9"; -} - -.fa-head-side-heart { - --fa: "\e1aa"; -} - -.fa-heart-half { - --fa: "\e1ab"; -} - -.fa-heart-half-stroke { - --fa: "\e1ac"; -} - -.fa-heart-half-alt { - --fa: "\e1ac"; -} - -.fa-hexagon-divide { - --fa: "\e1ad"; -} - -.fa-high-definition { - --fa: "\e1ae"; -} - -.fa-rectangle-hd { - --fa: "\e1ae"; -} - -.fa-highlighter-line { - --fa: "\e1af"; -} - -.fa-house-user { - --fa: "\e1b0"; -} - -.fa-home-user { - --fa: "\e1b0"; -} - -.fa-house-building { - --fa: "\e1b1"; -} - -.fa-house-chimney-heart { - --fa: "\e1b2"; -} - -.fa-house-tree { - --fa: "\e1b3"; -} - -.fa-house-turret { - --fa: "\e1b4"; -} - -.fa-image-landscape { - --fa: "\e1b5"; -} - -.fa-landscape { - --fa: "\e1b5"; -} - -.fa-image-polaroid-user { - --fa: "\e1b6"; -} - -.fa-image-slash { - --fa: "\e1b7"; -} - -.fa-image-user { - --fa: "\e1b8"; -} - -.fa-images-user { - --fa: "\e1b9"; -} - -.fa-inbox-full { - --fa: "\e1ba"; -} - -.fa-inboxes { - --fa: "\e1bb"; -} - -.fa-indian-rupee-sign { - --fa: "\e1bc"; -} - -.fa-indian-rupee { - --fa: "\e1bc"; -} - -.fa-inr { - --fa: "\e1bc"; -} - -.fa-input-numeric { - --fa: "\e1bd"; -} - -.fa-input-pipe { - --fa: "\e1be"; -} - -.fa-input-text { - --fa: "\e1bf"; -} - -.fa-keyboard-brightness { - --fa: "\e1c0"; -} - -.fa-keyboard-brightness-low { - --fa: "\e1c1"; -} - -.fa-keyboard-down { - --fa: "\e1c2"; -} - -.fa-keyboard-left { - --fa: "\e1c3"; -} - -.fa-kip-sign { - --fa: "\e1c4"; -} - -.fa-lamp-street { - --fa: "\e1c5"; -} - -.fa-laptop-arrow-down { - --fa: "\e1c6"; -} - -.fa-laptop-slash { - --fa: "\e1c7"; -} - -.fa-lari-sign { - --fa: "\e1c8"; -} - -.fa-lasso-sparkles { - --fa: "\e1c9"; -} - -.fa-lightbulb-exclamation-on { - --fa: "\e1ca"; -} - -.fa-link-horizontal { - --fa: "\e1cb"; -} - -.fa-chain-horizontal { - --fa: "\e1cb"; -} - -.fa-link-horizontal-slash { - --fa: "\e1cc"; -} - -.fa-chain-horizontal-slash { - --fa: "\e1cc"; -} - -.fa-link-simple { - --fa: "\e1cd"; -} - -.fa-link-simple-slash { - --fa: "\e1ce"; -} - -.fa-list-dropdown { - --fa: "\e1cf"; -} - -.fa-list-radio { - --fa: "\e1d0"; -} - -.fa-list-timeline { - --fa: "\e1d1"; -} - -.fa-list-tree { - --fa: "\e1d2"; -} - -.fa-litecoin-sign { - --fa: "\e1d3"; -} - -.fa-loader { - --fa: "\e1d4"; -} - -.fa-manat-sign { - --fa: "\e1d5"; -} - -.fa-manhole { - --fa: "\e1d6"; -} - -.fa-mask-face { - --fa: "\e1d7"; -} - -.fa-memo { - --fa: "\e1d8"; -} - -.fa-memo-circle-check { - --fa: "\e1d9"; -} - -.fa-memo-pad { - --fa: "\e1da"; -} - -.fa-message-arrow-down { - --fa: "\e1db"; -} - -.fa-comment-alt-arrow-down { - --fa: "\e1db"; -} - -.fa-message-arrow-up { - --fa: "\e1dc"; -} - -.fa-comment-alt-arrow-up { - --fa: "\e1dc"; -} - -.fa-message-arrow-up-right { - --fa: "\e1dd"; -} - -.fa-message-captions { - --fa: "\e1de"; -} - -.fa-comment-alt-captions { - --fa: "\e1de"; -} - -.fa-message-code { - --fa: "\e1df"; -} - -.fa-message-image { - --fa: "\e1e0"; -} - -.fa-comment-alt-image { - --fa: "\e1e0"; -} - -.fa-message-middle { - --fa: "\e1e1"; -} - -.fa-comment-middle-alt { - --fa: "\e1e1"; -} - -.fa-message-middle-top { - --fa: "\e1e2"; -} - -.fa-comment-middle-top-alt { - --fa: "\e1e2"; -} - -.fa-message-question { - --fa: "\e1e3"; -} - -.fa-message-quote { - --fa: "\e1e4"; -} - -.fa-comment-alt-quote { - --fa: "\e1e4"; -} - -.fa-message-sms { - --fa: "\e1e5"; -} - -.fa-message-text { - --fa: "\e1e6"; -} - -.fa-comment-alt-text { - --fa: "\e1e6"; -} - -.fa-messages-question { - --fa: "\e1e7"; -} - -.fa-meter { - --fa: "\e1e8"; -} - -.fa-meter-bolt { - --fa: "\e1e9"; -} - -.fa-meter-droplet { - --fa: "\e1ea"; -} - -.fa-meter-fire { - --fa: "\e1eb"; -} - -.fa-microchip-ai { - --fa: "\e1ec"; -} - -.fa-mill-sign { - --fa: "\e1ed"; -} - -.fa-mobile-notch { - --fa: "\e1ee"; -} - -.fa-mobile-iphone { - --fa: "\e1ee"; -} - -.fa-mobile-signal { - --fa: "\e1ef"; -} - -.fa-mobile-signal-out { - --fa: "\e1f0"; -} - -.fa-money-bill-simple { - --fa: "\e1f1"; -} - -.fa-money-bill-simple-wave { - --fa: "\e1f2"; -} - -.fa-money-bills { - --fa: "\e1f3"; -} - -.fa-money-bills-simple { - --fa: "\e1f4"; -} - -.fa-money-bills-alt { - --fa: "\e1f4"; -} - -.fa-mug-tea-saucer { - --fa: "\e1f5"; -} - -.fa-naira-sign { - --fa: "\e1f6"; -} - -.fa-nfc { - --fa: "\e1f7"; -} - -.fa-nfc-lock { - --fa: "\e1f8"; -} - -.fa-nfc-magnifying-glass { - --fa: "\e1f9"; -} - -.fa-nfc-pen { - --fa: "\e1fa"; -} - -.fa-nfc-signal { - --fa: "\e1fb"; -} - -.fa-nfc-slash { - --fa: "\e1fc"; -} - -.fa-nfc-trash { - --fa: "\e1fd"; -} - -.fa-notdef { - --fa: "\e1fe"; -} - -.fa-note { - --fa: "\e1ff"; -} - -.fa-note-medical { - --fa: "\e200"; -} - -.fa-notebook { - --fa: "\e201"; -} - -.fa-notes { - --fa: "\e202"; -} - -.fa-octagon-divide { - --fa: "\e203"; -} - -.fa-octagon-exclamation { - --fa: "\e204"; -} - -.fa-oil-can-drip { - --fa: "\e205"; -} - -.fa-paintbrush-pencil { - --fa: "\e206"; -} - -.fa-pallet-box { - --fa: "\e208"; -} - -.fa-panorama { - --fa: "\e209"; -} - -.fa-paper-plane-top { - --fa: "\e20a"; -} - -.fa-paper-plane-alt { - --fa: "\e20a"; -} - -.fa-send { - --fa: "\e20a"; -} - -.fa-peach { - --fa: "\e20b"; -} - -.fa-pear { - --fa: "\e20c"; -} - -.fa-pedestal { - --fa: "\e20d"; -} - -.fa-pen-circle { - --fa: "\e20e"; -} - -.fa-pen-clip-slash { - --fa: "\e20f"; -} - -.fa-pen-alt-slash { - --fa: "\e20f"; -} - -.fa-pen-fancy-slash { - --fa: "\e210"; -} - -.fa-pen-field { - --fa: "\e211"; -} - -.fa-pen-line { - --fa: "\e212"; -} - -.fa-pen-slash { - --fa: "\e213"; -} - -.fa-pen-swirl { - --fa: "\e214"; -} - -.fa-pencil-slash { - --fa: "\e215"; -} - -.fa-people { - --fa: "\e216"; -} - -.fa-people-dress { - --fa: "\e217"; -} - -.fa-people-dress-simple { - --fa: "\e218"; -} - -.fa-people-pants { - --fa: "\e219"; -} - -.fa-people-pants-simple { - --fa: "\e21a"; -} - -.fa-people-simple { - --fa: "\e21b"; -} - -.fa-person-dress-simple { - --fa: "\e21c"; -} - -.fa-person-pinball { - --fa: "\e21d"; -} - -.fa-person-seat { - --fa: "\e21e"; -} - -.fa-person-seat-reclined { - --fa: "\e21f"; -} - -.fa-person-simple { - --fa: "\e220"; -} - -.fa-peseta-sign { - --fa: "\e221"; -} - -.fa-peso-sign { - --fa: "\e222"; -} - -.fa-phone-arrow-down-left { - --fa: "\e223"; -} - -.fa-phone-arrow-down { - --fa: "\e223"; -} - -.fa-phone-incoming { - --fa: "\e223"; -} - -.fa-phone-arrow-up-right { - --fa: "\e224"; -} - -.fa-phone-arrow-up { - --fa: "\e224"; -} - -.fa-phone-outgoing { - --fa: "\e224"; -} - -.fa-phone-hangup { - --fa: "\e225"; -} - -.fa-phone-missed { - --fa: "\e226"; -} - -.fa-phone-xmark { - --fa: "\e227"; -} - -.fa-photo-film-music { - --fa: "\e228"; -} - -.fa-pinball { - --fa: "\e229"; -} - -.fa-plane-prop { - --fa: "\e22b"; -} - -.fa-plane-tail { - --fa: "\e22c"; -} - -.fa-plane-up { - --fa: "\e22d"; -} - -.fa-plane-up-slash { - --fa: "\e22e"; -} - -.fa-play-pause { - --fa: "\e22f"; -} - -.fa-puzzle-piece-simple { - --fa: "\e231"; -} - -.fa-puzzle-piece-alt { - --fa: "\e231"; -} - -.fa-quotes { - --fa: "\e234"; -} - -.fa-rectangle-pro { - --fa: "\e235"; -} - -.fa-pro { - --fa: "\e235"; -} - -.fa-rectangle-terminal { - --fa: "\e236"; -} - -.fa-rectangle-vertical-history { - --fa: "\e237"; -} - -.fa-reel { - --fa: "\e238"; -} - -.fa-reply-clock { - --fa: "\e239"; -} - -.fa-reply-time { - --fa: "\e239"; -} - -.fa-restroom-simple { - --fa: "\e23a"; -} - -.fa-rhombus { - --fa: "\e23b"; -} - -.fa-rotate-exclamation { - --fa: "\e23c"; -} - -.fa-rupiah-sign { - --fa: "\e23d"; -} - -.fa-screencast { - --fa: "\e23e"; -} - -.fa-scribble { - --fa: "\e23f"; -} - -.fa-sd-cards { - --fa: "\e240"; -} - -.fa-seal { - --fa: "\e241"; -} - -.fa-seal-exclamation { - --fa: "\e242"; -} - -.fa-seal-question { - --fa: "\e243"; -} - -.fa-seat-airline { - --fa: "\e244"; -} - -.fa-shelves-empty { - --fa: "\e246"; -} - -.fa-shield-exclamation { - --fa: "\e247"; -} - -.fa-shield-keyhole { - --fa: "\e248"; -} - -.fa-shield-minus { - --fa: "\e249"; -} - -.fa-shield-plus { - --fa: "\e24a"; -} - -.fa-shield-slash { - --fa: "\e24b"; -} - -.fa-shield-xmark { - --fa: "\e24c"; -} - -.fa-shield-times { - --fa: "\e24c"; -} - -.fa-shower-down { - --fa: "\e24d"; -} - -.fa-shower-alt { - --fa: "\e24d"; -} - -.fa-sidebar { - --fa: "\e24e"; -} - -.fa-sidebar-flip { - --fa: "\e24f"; -} - -.fa-signal-stream-slash { - --fa: "\e250"; -} - -.fa-sim-cards { - --fa: "\e251"; -} - -.fa-slider { - --fa: "\e252"; -} - -.fa-sliders-simple { - --fa: "\e253"; -} - -.fa-split { - --fa: "\e254"; -} - -.fa-square-0 { - --fa: "\e255"; -} - -.fa-square-1 { - --fa: "\e256"; -} - -.fa-square-2 { - --fa: "\e257"; -} - -.fa-square-3 { - --fa: "\e258"; -} - -.fa-square-4 { - --fa: "\e259"; -} - -.fa-square-5 { - --fa: "\e25a"; -} - -.fa-square-6 { - --fa: "\e25b"; -} - -.fa-square-7 { - --fa: "\e25c"; -} - -.fa-square-8 { - --fa: "\e25d"; -} - -.fa-square-9 { - --fa: "\e25e"; -} - -.fa-square-a { - --fa: "\e25f"; -} - -.fa-square-ampersand { - --fa: "\e260"; -} - -.fa-square-arrow-down-left { - --fa: "\e261"; -} - -.fa-square-arrow-down-right { - --fa: "\e262"; -} - -.fa-square-arrow-up-left { - --fa: "\e263"; -} - -.fa-square-b { - --fa: "\e264"; -} - -.fa-square-bolt { - --fa: "\e265"; -} - -.fa-square-c { - --fa: "\e266"; -} - -.fa-square-code { - --fa: "\e267"; -} - -.fa-square-d { - --fa: "\e268"; -} - -.fa-square-dashed { - --fa: "\e269"; -} - -.fa-square-divide { - --fa: "\e26a"; -} - -.fa-square-down-left { - --fa: "\e26b"; -} - -.fa-square-down-right { - --fa: "\e26c"; -} - -.fa-square-e { - --fa: "\e26d"; -} - -.fa-square-ellipsis { - --fa: "\e26e"; -} - -.fa-square-ellipsis-vertical { - --fa: "\e26f"; -} - -.fa-square-f { - --fa: "\e270"; -} - -.fa-square-g { - --fa: "\e271"; -} - -.fa-square-i { - --fa: "\e272"; -} - -.fa-square-j { - --fa: "\e273"; -} - -.fa-square-k { - --fa: "\e274"; -} - -.fa-square-l { - --fa: "\e275"; -} - -.fa-square-m { - --fa: "\e276"; -} - -.fa-square-n { - --fa: "\e277"; -} - -.fa-square-o { - --fa: "\e278"; -} - -.fa-square-p { - --fa: "\e279"; -} - -.fa-square-phone-hangup { - --fa: "\e27a"; -} - -.fa-phone-square-down { - --fa: "\e27a"; -} - -.fa-square-q { - --fa: "\e27b"; -} - -.fa-square-r { - --fa: "\e27c"; -} - -.fa-square-s { - --fa: "\e27d"; -} - -.fa-square-small { - --fa: "\e27e"; -} - -.fa-square-star { - --fa: "\e27f"; -} - -.fa-square-t { - --fa: "\e280"; -} - -.fa-square-u { - --fa: "\e281"; -} - -.fa-square-up-left { - --fa: "\e282"; -} - -.fa-square-user { - --fa: "\e283"; -} - -.fa-square-v { - --fa: "\e284"; -} - -.fa-square-w { - --fa: "\e285"; -} - -.fa-square-x { - --fa: "\e286"; -} - -.fa-square-y { - --fa: "\e287"; -} - -.fa-square-z { - --fa: "\e288"; -} - -.fa-stairs { - --fa: "\e289"; -} - -.fa-standard-definition { - --fa: "\e28a"; -} - -.fa-rectangle-sd { - --fa: "\e28a"; -} - -.fa-star-sharp { - --fa: "\e28b"; -} - -.fa-star-sharp-half { - --fa: "\e28c"; -} - -.fa-star-sharp-half-stroke { - --fa: "\e28d"; -} - -.fa-star-sharp-half-alt { - --fa: "\e28d"; -} - -.fa-starfighter-twin-ion-engine-advanced { - --fa: "\e28e"; -} - -.fa-starfighter-alt-advanced { - --fa: "\e28e"; -} - -.fa-sun-bright { - --fa: "\e28f"; -} - -.fa-sun-alt { - --fa: "\e28f"; -} - -.fa-table-layout { - --fa: "\e290"; -} - -.fa-table-pivot { - --fa: "\e291"; -} - -.fa-table-rows { - --fa: "\e292"; -} - -.fa-rows { - --fa: "\e292"; -} - -.fa-table-tree { - --fa: "\e293"; -} - -.fa-tally-1 { - --fa: "\e294"; -} - -.fa-tally-2 { - --fa: "\e295"; -} - -.fa-tally-3 { - --fa: "\e296"; -} - -.fa-tally-4 { - --fa: "\e297"; -} - -.fa-taxi-bus { - --fa: "\e298"; -} - -.fa-temperature-list { - --fa: "\e299"; -} - -.fa-ticket-airline { - --fa: "\e29a"; -} - -.fa-ticket-perforated-plane { - --fa: "\e29a"; -} - -.fa-ticket-plane { - --fa: "\e29a"; -} - -.fa-tickets-airline { - --fa: "\e29b"; -} - -.fa-tickets-perforated-plane { - --fa: "\e29b"; -} - -.fa-tickets-plane { - --fa: "\e29b"; -} - -.fa-timeline { - --fa: "\e29c"; -} - -.fa-timeline-arrow { - --fa: "\e29d"; -} - -.fa-timer { - --fa: "\e29e"; -} - -.fa-toilet-paper-under { - --fa: "\e2a0"; -} - -.fa-toilet-paper-blank-under { - --fa: "\e2a0"; -} - -.fa-toilet-paper-reverse { - --fa: "\e2a0"; -} - -.fa-toilet-paper-reverse-alt { - --fa: "\e2a0"; -} - -.fa-toilet-paper-under-slash { - --fa: "\e2a1"; -} - -.fa-toilet-paper-reverse-slash { - --fa: "\e2a1"; -} - -.fa-tower-control { - --fa: "\e2a2"; -} - -.fa-train-subway-tunnel { - --fa: "\e2a3"; -} - -.fa-subway-tunnel { - --fa: "\e2a3"; -} - -.fa-transformer-bolt { - --fa: "\e2a4"; -} - -.fa-transporter-4 { - --fa: "\e2a5"; -} - -.fa-transporter-5 { - --fa: "\e2a6"; -} - -.fa-transporter-6 { - --fa: "\e2a7"; -} - -.fa-transporter-7 { - --fa: "\e2a8"; -} - -.fa-trash-can-check { - --fa: "\e2a9"; -} - -.fa-trash-can-clock { - --fa: "\e2aa"; -} - -.fa-trash-can-list { - --fa: "\e2ab"; -} - -.fa-trash-can-plus { - --fa: "\e2ac"; -} - -.fa-trash-can-slash { - --fa: "\e2ad"; -} - -.fa-trash-alt-slash { - --fa: "\e2ad"; -} - -.fa-trash-can-xmark { - --fa: "\e2ae"; -} - -.fa-trash-check { - --fa: "\e2af"; -} - -.fa-trash-clock { - --fa: "\e2b0"; -} - -.fa-trash-list { - --fa: "\e2b1"; -} - -.fa-trash-plus { - --fa: "\e2b2"; -} - -.fa-trash-slash { - --fa: "\e2b3"; -} - -.fa-trash-xmark { - --fa: "\e2b4"; -} - -.fa-truck-container-empty { - --fa: "\e2b5"; -} - -.fa-truck-flatbed { - --fa: "\e2b6"; -} - -.fa-truck-front { - --fa: "\e2b7"; -} - -.fa-truck-tow { - --fa: "\e2b8"; -} - -.fa-tty-answer { - --fa: "\e2b9"; -} - -.fa-teletype-answer { - --fa: "\e2b9"; -} - -.fa-tugrik-sign { - --fa: "\e2ba"; -} - -.fa-turkish-lira-sign { - --fa: "\e2bb"; -} - -.fa-try { - --fa: "\e2bb"; -} - -.fa-turkish-lira { - --fa: "\e2bb"; -} - -.fa-umbrella-simple { - --fa: "\e2bc"; -} - -.fa-umbrella-alt { - --fa: "\e2bc"; -} - -.fa-up-left { - --fa: "\e2bd"; -} - -.fa-up-right { - --fa: "\e2be"; -} - -.fa-user-bounty-hunter { - --fa: "\e2bf"; -} - -.fa-user-pilot { - --fa: "\e2c0"; -} - -.fa-user-pilot-tie { - --fa: "\e2c1"; -} - -.fa-user-shakespeare { - --fa: "\e2c2"; -} - -.fa-utility-pole { - --fa: "\e2c3"; -} - -.fa-utility-pole-double { - --fa: "\e2c4"; -} - -.fa-vault { - --fa: "\e2c5"; -} - -.fa-video-arrow-down-left { - --fa: "\e2c8"; -} - -.fa-video-arrow-up-right { - --fa: "\e2c9"; -} - -.fa-wand-magic-sparkles { - --fa: "\e2ca"; -} - -.fa-magic-wand-sparkles { - --fa: "\e2ca"; -} - -.fa-watch-apple { - --fa: "\e2cb"; -} - -.fa-watch-smart { - --fa: "\e2cc"; -} - -.fa-wheat-awn { - --fa: "\e2cd"; -} - -.fa-wheat-alt { - --fa: "\e2cd"; -} - -.fa-wheelchair-move { - --fa: "\e2ce"; -} - -.fa-wheelchair-alt { - --fa: "\e2ce"; -} - -.fa-wifi-exclamation { - --fa: "\e2cf"; -} - -.fa-wrench-simple { - --fa: "\e2d1"; -} - -.fa-robot-astromech { - --fa: "\e2d2"; -} - -.fa-360-degrees { - --fa: "\e2dc"; -} - -.fa-aperture { - --fa: "\e2df"; -} - -.fa-arrow-turn-down-left { - --fa: "\e2e1"; -} - -.fa-balloon { - --fa: "\e2e3"; -} - -.fa-balloons { - --fa: "\e2e4"; -} - -.fa-banana { - --fa: "\e2e5"; -} - -.fa-bangladeshi-taka-sign { - --fa: "\e2e6"; -} - -.fa-bench-tree { - --fa: "\e2e7"; -} - -.fa-blueberries { - --fa: "\e2e8"; -} - -.fa-bowl-chopsticks { - --fa: "\e2e9"; -} - -.fa-bowl-chopsticks-noodles { - --fa: "\e2ea"; -} - -.fa-bowl-rice { - --fa: "\e2eb"; -} - -.fa-briefcase-arrow-right { - --fa: "\e2f2"; -} - -.fa-citrus { - --fa: "\e2f4"; -} - -.fa-citrus-slice { - --fa: "\e2f5"; -} - -.fa-coconut { - --fa: "\e2f6"; -} - -.fa-display-slash { - --fa: "\e2fa"; -} - -.fa-desktop-slash { - --fa: "\e2fa"; -} - -.fa-face-explode { - --fa: "\e2fe"; -} - -.fa-exploding-head { - --fa: "\e2fe"; -} - -.fa-face-viewfinder { - --fa: "\e2ff"; -} - -.fa-family { - --fa: "\e300"; -} - -.fa-family-dress { - --fa: "\e301"; -} - -.fa-family-pants { - --fa: "\e302"; -} - -.fa-fence { - --fa: "\e303"; -} - -.fa-fish-bones { - --fa: "\e304"; -} - -.fa-grapes { - --fa: "\e306"; -} - -.fa-kiwi-fruit { - --fa: "\e30c"; -} - -.fa-mango { - --fa: "\e30f"; -} - -.fa-melon { - --fa: "\e310"; -} - -.fa-melon-slice { - --fa: "\e311"; -} - -.fa-money-from-bracket { - --fa: "\e312"; -} - -.fa-money-simple-from-bracket { - --fa: "\e313"; -} - -.fa-olive { - --fa: "\e316"; -} - -.fa-olive-branch { - --fa: "\e317"; -} - -.fa-option { - --fa: "\e318"; -} - -.fa-party-bell { - --fa: "\e31a"; -} - -.fa-party-horn { - --fa: "\e31b"; -} - -.fa-peapod { - --fa: "\e31c"; -} - -.fa-person-pregnant { - --fa: "\e31e"; -} - -.fa-pineapple { - --fa: "\e31f"; -} - -.fa-rectangle-code { - --fa: "\e322"; -} - -.fa-rectangles-mixed { - --fa: "\e323"; -} - -.fa-roller-coaster { - --fa: "\e324"; -} - -.fa-square-quote { - --fa: "\e329"; -} - -.fa-square-terminal { - --fa: "\e32a"; -} - -.fa-strawberry { - --fa: "\e32b"; -} - -.fa-table-picnic { - --fa: "\e32d"; -} - -.fa-thought-bubble { - --fa: "\e32e"; -} - -.fa-tick { - --fa: "\e32f"; -} - -.fa-tomato { - --fa: "\e330"; -} - -.fa-turn-down-left { - --fa: "\e331"; -} - -.fa-user-police { - --fa: "\e333"; -} - -.fa-user-police-tie { - --fa: "\e334"; -} - -.fa-watermelon-slice { - --fa: "\e337"; -} - -.fa-wheat-awn-slash { - --fa: "\e338"; -} - -.fa-wheat-slash { - --fa: "\e339"; -} - -.fa-badminton { - --fa: "\e33a"; -} - -.fa-binary { - --fa: "\e33b"; -} - -.fa-binary-circle-check { - --fa: "\e33c"; -} - -.fa-binary-lock { - --fa: "\e33d"; -} - -.fa-binary-slash { - --fa: "\e33e"; -} - -.fa-boot-heeled { - --fa: "\e33f"; -} - -.fa-car-bolt { - --fa: "\e341"; -} - -.fa-car-circle-bolt { - --fa: "\e342"; -} - -.fa-car-mirrors { - --fa: "\e343"; -} - -.fa-car-side-bolt { - --fa: "\e344"; -} - -.fa-clock-eight { - --fa: "\e345"; -} - -.fa-clock-eight-thirty { - --fa: "\e346"; -} - -.fa-clock-eleven { - --fa: "\e347"; -} - -.fa-clock-eleven-thirty { - --fa: "\e348"; -} - -.fa-clock-five { - --fa: "\e349"; -} - -.fa-clock-five-thirty { - --fa: "\e34a"; -} - -.fa-clock-four-thirty { - --fa: "\e34b"; -} - -.fa-clock-nine { - --fa: "\e34c"; -} - -.fa-clock-nine-thirty { - --fa: "\e34d"; -} - -.fa-clock-one { - --fa: "\e34e"; -} - -.fa-clock-one-thirty { - --fa: "\e34f"; -} - -.fa-clock-seven { - --fa: "\e350"; -} - -.fa-clock-seven-thirty { - --fa: "\e351"; -} - -.fa-clock-six { - --fa: "\e352"; -} - -.fa-clock-six-thirty { - --fa: "\e353"; -} - -.fa-clock-ten { - --fa: "\e354"; -} - -.fa-clock-ten-thirty { - --fa: "\e355"; -} - -.fa-clock-three { - --fa: "\e356"; -} - -.fa-clock-three-thirty { - --fa: "\e357"; -} - -.fa-clock-twelve { - --fa: "\e358"; -} - -.fa-clock-twelve-thirty { - --fa: "\e359"; -} - -.fa-clock-two { - --fa: "\e35a"; -} - -.fa-clock-two-thirty { - --fa: "\e35b"; -} - -.fa-cloud-check { - --fa: "\e35c"; -} - -.fa-cloud-minus { - --fa: "\e35d"; -} - -.fa-cloud-plus { - --fa: "\e35e"; -} - -.fa-cloud-xmark { - --fa: "\e35f"; -} - -.fa-columns-3 { - --fa: "\e361"; -} - -.fa-crystal-ball { - --fa: "\e362"; -} - -.fa-cup-straw { - --fa: "\e363"; -} - -.fa-cup-straw-swoosh { - --fa: "\e364"; -} - -.fa-distribute-spacing-horizontal { - --fa: "\e365"; -} - -.fa-distribute-spacing-vertical { - --fa: "\e366"; -} - -.fa-eyes { - --fa: "\e367"; -} - -.fa-face-angry-horns { - --fa: "\e368"; -} - -.fa-face-anguished { - --fa: "\e369"; -} - -.fa-face-anxious-sweat { - --fa: "\e36a"; -} - -.fa-face-astonished { - --fa: "\e36b"; -} - -.fa-face-confounded { - --fa: "\e36c"; -} - -.fa-face-confused { - --fa: "\e36d"; -} - -.fa-face-cowboy-hat { - --fa: "\e36e"; -} - -.fa-face-disappointed { - --fa: "\e36f"; -} - -.fa-face-disguise { - --fa: "\e370"; -} - -.fa-face-downcast-sweat { - --fa: "\e371"; -} - -.fa-face-drooling { - --fa: "\e372"; -} - -.fa-face-expressionless { - --fa: "\e373"; -} - -.fa-face-eyes-xmarks { - --fa: "\e374"; -} - -.fa-face-fearful { - --fa: "\e375"; -} - -.fa-face-frown-slight { - --fa: "\e376"; -} - -.fa-face-glasses { - --fa: "\e377"; -} - -.fa-face-hand-over-mouth { - --fa: "\e378"; -} - -.fa-face-hand-yawn { - --fa: "\e379"; -} - -.fa-face-head-bandage { - --fa: "\e37a"; -} - -.fa-face-hushed { - --fa: "\e37b"; -} - -.fa-face-icicles { - --fa: "\e37c"; -} - -.fa-face-kiss-closed-eyes { - --fa: "\e37d"; -} - -.fa-face-lying { - --fa: "\e37e"; -} - -.fa-face-mask { - --fa: "\e37f"; -} - -.fa-face-monocle { - --fa: "\e380"; -} - -.fa-face-nauseated { - --fa: "\e381"; -} - -.fa-face-nose-steam { - --fa: "\e382"; -} - -.fa-face-party { - --fa: "\e383"; -} - -.fa-face-pensive { - --fa: "\e384"; -} - -.fa-face-persevering { - --fa: "\e385"; -} - -.fa-face-pleading { - --fa: "\e386"; -} - -.fa-face-pouting { - --fa: "\e387"; -} - -.fa-face-raised-eyebrow { - --fa: "\e388"; -} - -.fa-face-relieved { - --fa: "\e389"; -} - -.fa-face-sad-sweat { - --fa: "\e38a"; -} - -.fa-face-scream { - --fa: "\e38b"; -} - -.fa-face-shush { - --fa: "\e38c"; -} - -.fa-face-sleeping { - --fa: "\e38d"; -} - -.fa-face-sleepy { - --fa: "\e38e"; -} - -.fa-face-smile-halo { - --fa: "\e38f"; -} - -.fa-face-smile-hearts { - --fa: "\e390"; -} - -.fa-face-smile-horns { - --fa: "\e391"; -} - -.fa-face-smile-relaxed { - --fa: "\e392"; -} - -.fa-face-smile-tear { - --fa: "\e393"; -} - -.fa-face-smile-tongue { - --fa: "\e394"; -} - -.fa-face-smile-upside-down { - --fa: "\e395"; -} - -.fa-face-smiling-hands { - --fa: "\e396"; -} - -.fa-face-smirking { - --fa: "\e397"; -} - -.fa-face-sunglasses { - --fa: "\e398"; -} - -.fa-face-swear { - --fa: "\e399"; -} - -.fa-face-thermometer { - --fa: "\e39a"; -} - -.fa-face-thinking { - --fa: "\e39b"; -} - -.fa-face-tissue { - --fa: "\e39c"; -} - -.fa-face-tongue-money { - --fa: "\e39d"; -} - -.fa-face-tongue-sweat { - --fa: "\e39e"; -} - -.fa-face-unamused { - --fa: "\e39f"; -} - -.fa-face-vomit { - --fa: "\e3a0"; -} - -.fa-face-weary { - --fa: "\e3a1"; -} - -.fa-face-woozy { - --fa: "\e3a2"; -} - -.fa-face-worried { - --fa: "\e3a3"; -} - -.fa-face-zany { - --fa: "\e3a4"; -} - -.fa-face-zipper { - --fa: "\e3a5"; -} - -.fa-file-lock { - --fa: "\e3a6"; -} - -.fa-file-slash { - --fa: "\e3a7"; -} - -.fa-fishing-rod { - --fa: "\e3a8"; -} - -.fa-flying-disc { - --fa: "\e3a9"; -} - -.fa-gallery-thumbnails { - --fa: "\e3aa"; -} - -.fa-goal-net { - --fa: "\e3ab"; -} - -.fa-golf-flag-hole { - --fa: "\e3ac"; -} - -.fa-grid-dividers { - --fa: "\e3ad"; -} - -.fa-hockey-stick-puck { - --fa: "\e3ae"; -} - -.fa-house-chimney { - --fa: "\e3af"; -} - -.fa-home-lg { - --fa: "\e3af"; -} - -.fa-house-chimney-blank { - --fa: "\e3b0"; -} - -.fa-house-crack { - --fa: "\e3b1"; -} - -.fa-house-medical { - --fa: "\e3b2"; -} - -.fa-house-window { - --fa: "\e3b3"; -} - -.fa-key-skeleton-left-right { - --fa: "\e3b4"; -} - -.fa-lacrosse-stick { - --fa: "\e3b5"; -} - -.fa-lacrosse-stick-ball { - --fa: "\e3b6"; -} - -.fa-mask-snorkel { - --fa: "\e3b7"; -} - -.fa-message-bot { - --fa: "\e3b8"; -} - -.fa-moped { - --fa: "\e3b9"; -} - -.fa-nesting-dolls { - --fa: "\e3ba"; -} - -.fa-objects-align-bottom { - --fa: "\e3bb"; -} - -.fa-objects-align-center-horizontal { - --fa: "\e3bc"; -} - -.fa-objects-align-center-vertical { - --fa: "\e3bd"; -} - -.fa-objects-align-left { - --fa: "\e3be"; -} - -.fa-objects-align-right { - --fa: "\e3bf"; -} - -.fa-objects-align-top { - --fa: "\e3c0"; -} - -.fa-objects-column { - --fa: "\e3c1"; -} - -.fa-paperclip-vertical { - --fa: "\e3c2"; -} - -.fa-pinata { - --fa: "\e3c3"; -} - -.fa-pipe-smoking { - --fa: "\e3c4"; -} - -.fa-pool-8-ball { - --fa: "\e3c5"; -} - -.fa-rugby-ball { - --fa: "\e3c6"; -} - -.fa-shirt-long-sleeve { - --fa: "\e3c7"; -} - -.fa-shirt-running { - --fa: "\e3c8"; -} - -.fa-shirt-tank-top { - --fa: "\e3c9"; -} - -.fa-signature-lock { - --fa: "\e3ca"; -} - -.fa-signature-slash { - --fa: "\e3cb"; -} - -.fa-ski-boot { - --fa: "\e3cc"; -} - -.fa-ski-boot-ski { - --fa: "\e3cd"; -} - -.fa-slot-machine { - --fa: "\e3ce"; -} - -.fa-teddy-bear { - --fa: "\e3cf"; -} - -.fa-truck-bolt { - --fa: "\e3d0"; -} - -.fa-uniform-martial-arts { - --fa: "\e3d1"; -} - -.fa-user-chef { - --fa: "\e3d2"; -} - -.fa-user-hair-buns { - --fa: "\e3d3"; -} - -.fa-arrow-left-long-to-line { - --fa: "\e3d4"; -} - -.fa-arrow-right-long-to-line { - --fa: "\e3d5"; -} - -.fa-arrow-turn-down-right { - --fa: "\e3d6"; -} - -.fa-bagel { - --fa: "\e3d7"; -} - -.fa-baguette { - --fa: "\e3d8"; -} - -.fa-blanket-fire { - --fa: "\e3da"; -} - -.fa-block-brick { - --fa: "\e3db"; -} - -.fa-wall-brick { - --fa: "\e3db"; -} - -.fa-block-brick-fire { - --fa: "\e3dc"; -} - -.fa-firewall { - --fa: "\e3dc"; -} - -.fa-block-question { - --fa: "\e3dd"; -} - -.fa-bowl-scoop { - --fa: "\e3de"; -} - -.fa-bowl-shaved-ice { - --fa: "\e3de"; -} - -.fa-bowl-scoops { - --fa: "\e3df"; -} - -.fa-bowl-spoon { - --fa: "\e3e0"; -} - -.fa-bread-slice-butter { - --fa: "\e3e1"; -} - -.fa-broccoli { - --fa: "\e3e2"; -} - -.fa-burger-lettuce { - --fa: "\e3e3"; -} - -.fa-butter { - --fa: "\e3e4"; -} - -.fa-cake-slice { - --fa: "\e3e5"; -} - -.fa-shortcake { - --fa: "\e3e5"; -} - -.fa-can-food { - --fa: "\e3e6"; -} - -.fa-candy { - --fa: "\e3e7"; -} - -.fa-candy-bar { - --fa: "\e3e8"; -} - -.fa-chocolate-bar { - --fa: "\e3e8"; -} - -.fa-card-club { - --fa: "\e3e9"; -} - -.fa-card-diamond { - --fa: "\e3ea"; -} - -.fa-card-heart { - --fa: "\e3eb"; -} - -.fa-card-spade { - --fa: "\e3ec"; -} - -.fa-cards { - --fa: "\e3ed"; -} - -.fa-cart-arrow-up { - --fa: "\e3ee"; -} - -.fa-cart-circle-arrow-down { - --fa: "\e3ef"; -} - -.fa-cart-circle-arrow-up { - --fa: "\e3f0"; -} - -.fa-cart-circle-check { - --fa: "\e3f1"; -} - -.fa-cart-circle-exclamation { - --fa: "\e3f2"; -} - -.fa-cart-circle-plus { - --fa: "\e3f3"; -} - -.fa-cart-circle-xmark { - --fa: "\e3f4"; -} - -.fa-cent-sign { - --fa: "\e3f5"; -} - -.fa-chestnut { - --fa: "\e3f6"; -} - -.fa-chopsticks { - --fa: "\e3f7"; -} - -.fa-circle-quarters { - --fa: "\e3f8"; -} - -.fa-code-pull-request-closed { - --fa: "\e3f9"; -} - -.fa-code-pull-request-draft { - --fa: "\e3fa"; -} - -.fa-coin-blank { - --fa: "\e3fb"; -} - -.fa-coin-front { - --fa: "\e3fc"; -} - -.fa-coin-vertical { - --fa: "\e3fd"; -} - -.fa-corner { - --fa: "\e3fe"; -} - -.fa-crab { - --fa: "\e3ff"; -} - -.fa-soft-serve { - --fa: "\e400"; -} - -.fa-creemee { - --fa: "\e400"; -} - -.fa-cucumber { - --fa: "\e401"; -} - -.fa-cupcake { - --fa: "\e402"; -} - -.fa-custard { - --fa: "\e403"; -} - -.fa-dash { - --fa: "\e404"; -} - -.fa-minus-large { - --fa: "\e404"; -} - -.fa-diamond-exclamation { - --fa: "\e405"; -} - -.fa-donut { - --fa: "\e406"; -} - -.fa-doughnut { - --fa: "\e406"; -} - -.fa-down-from-dotted-line { - --fa: "\e407"; -} - -.fa-down-to-dotted-line { - --fa: "\e408"; -} - -.fa-face-awesome { - --fa: "\e409"; -} - -.fa-gave-dandy { - --fa: "\e409"; -} - -.fa-falafel { - --fa: "\e40a"; -} - -.fa-flatbread { - --fa: "\e40b"; -} - -.fa-flatbread-stuffed { - --fa: "\e40c"; -} - -.fa-fondue-pot { - --fa: "\e40d"; -} - -.fa-garlic { - --fa: "\e40e"; -} - -.fa-grip-dots { - --fa: "\e410"; -} - -.fa-grip-dots-vertical { - --fa: "\e411"; -} - -.fa-h5 { - --fa: "\e412"; -} - -.fa-h6 { - --fa: "\e413"; -} - -.fa-hammer-crash { - --fa: "\e414"; -} - -.fa-hashtag-lock { - --fa: "\e415"; -} - -.fa-hexagon-check { - --fa: "\e416"; -} - -.fa-hexagon-exclamation { - --fa: "\e417"; -} - -.fa-honey-pot { - --fa: "\e418"; -} - -.fa-hose { - --fa: "\e419"; -} - -.fa-hose-reel { - --fa: "\e41a"; -} - -.fa-hourglass-clock { - --fa: "\e41b"; -} - -.fa-hundred-points { - --fa: "\e41c"; -} - -.fa-100 { - --fa: "\e41c"; -} - -.fa-leafy-green { - --fa: "\e41d"; -} - -.fa-left-long-to-line { - --fa: "\e41e"; -} - -.fa-light-emergency { - --fa: "\e41f"; -} - -.fa-light-emergency-on { - --fa: "\e420"; -} - -.fa-lobster { - --fa: "\e421"; -} - -.fa-lock-a { - --fa: "\e422"; -} - -.fa-lock-hashtag { - --fa: "\e423"; -} - -.fa-lollipop { - --fa: "\e424"; -} - -.fa-lollypop { - --fa: "\e424"; -} - -.fa-mushroom { - --fa: "\e425"; -} - -.fa-octagon-check { - --fa: "\e426"; -} - -.fa-onion { - --fa: "\e427"; -} - -.fa-page { - --fa: "\e428"; -} - -.fa-page-caret-down { - --fa: "\e429"; -} - -.fa-file-caret-down { - --fa: "\e429"; -} - -.fa-page-caret-up { - --fa: "\e42a"; -} - -.fa-file-caret-up { - --fa: "\e42a"; -} - -.fa-pan-food { - --fa: "\e42b"; -} - -.fa-pan-frying { - --fa: "\e42c"; -} - -.fa-pancakes { - --fa: "\e42d"; -} - -.fa-panel-ews { - --fa: "\e42e"; -} - -.fa-panel-fire { - --fa: "\e42f"; -} - -.fa-peanut { - --fa: "\e430"; -} - -.fa-peanuts { - --fa: "\e431"; -} - -.fa-pepper { - --fa: "\e432"; -} - -.fa-person-to-door { - --fa: "\e433"; -} - -.fa-phone-intercom { - --fa: "\e434"; -} - -.fa-pickleball { - --fa: "\e435"; -} - -.fa-pipe-circle-check { - --fa: "\e436"; -} - -.fa-pipe-collar { - --fa: "\e437"; -} - -.fa-pipe-section { - --fa: "\e438"; -} - -.fa-pipe-valve { - --fa: "\e439"; -} - -.fa-plate-utensils { - --fa: "\e43b"; -} - -.fa-plus-minus { - --fa: "\e43c"; -} - -.fa-pompebled { - --fa: "\e43d"; -} - -.fa-popsicle { - --fa: "\e43e"; -} - -.fa-pot-food { - --fa: "\e43f"; -} - -.fa-potato { - --fa: "\e440"; -} - -.fa-pretzel { - --fa: "\e441"; -} - -.fa-pump { - --fa: "\e442"; -} - -.fa-puzzle { - --fa: "\e443"; -} - -.fa-right-long-to-line { - --fa: "\e444"; -} - -.fa-sailboat { - --fa: "\e445"; -} - -.fa-salt-shaker { - --fa: "\e446"; -} - -.fa-section { - --fa: "\e447"; -} - -.fa-shrimp { - --fa: "\e448"; -} - -.fa-shutters { - --fa: "\e449"; -} - -.fa-sportsball { - --fa: "\e44b"; -} - -.fa-sprinkler-ceiling { - --fa: "\e44c"; -} - -.fa-square-a-lock { - --fa: "\e44d"; -} - -.fa-square-quarters { - --fa: "\e44e"; -} - -.fa-square-ring { - --fa: "\e44f"; -} - -.fa-squid { - --fa: "\e450"; -} - -.fa-tamale { - --fa: "\e451"; -} - -.fa-tank-water { - --fa: "\e452"; -} - -.fa-train-track { - --fa: "\e453"; -} - -.fa-train-tunnel { - --fa: "\e454"; -} - -.fa-turn-down-right { - --fa: "\e455"; -} - -.fa-up-from-dotted-line { - --fa: "\e456"; -} - -.fa-up-to-dotted-line { - --fa: "\e457"; -} - -.fa-user-doctor-hair { - --fa: "\e458"; -} - -.fa-user-doctor-hair-long { - --fa: "\e459"; -} - -.fa-user-hair { - --fa: "\e45a"; -} - -.fa-user-hair-long { - --fa: "\e45b"; -} - -.fa-user-hair-mullet { - --fa: "\e45c"; -} - -.fa-business-front { - --fa: "\e45c"; -} - -.fa-party-back { - --fa: "\e45c"; -} - -.fa-trian-balbot { - --fa: "\e45c"; -} - -.fa-user-nurse-hair { - --fa: "\e45d"; -} - -.fa-user-nurse-hair-long { - --fa: "\e45e"; -} - -.fa-user-tie-hair { - --fa: "\e45f"; -} - -.fa-user-tie-hair-long { - --fa: "\e460"; -} - -.fa-user-vneck { - --fa: "\e461"; -} - -.fa-user-vneck-hair { - --fa: "\e462"; -} - -.fa-user-vneck-hair-long { - --fa: "\e463"; -} - -.fa-utensils-slash { - --fa: "\e464"; -} - -.fa-vent-damper { - --fa: "\e465"; -} - -.fa-waffle { - --fa: "\e466"; -} - -.fa-00 { - --fa: "\e467"; -} - -.fa-apartment { - --fa: "\e468"; -} - -.fa-bird { - --fa: "\e469"; -} - -.fa-block { - --fa: "\e46a"; -} - -.fa-bowl-soft-serve { - --fa: "\e46b"; -} - -.fa-brazilian-real-sign { - --fa: "\e46c"; -} - -.fa-cabin { - --fa: "\e46d"; -} - -.fa-calendar-circle-exclamation { - --fa: "\e46e"; -} - -.fa-calendar-circle-minus { - --fa: "\e46f"; -} - -.fa-calendar-circle-plus { - --fa: "\e470"; -} - -.fa-calendar-circle-user { - --fa: "\e471"; -} - -.fa-calendar-lines-pen { - --fa: "\e472"; -} - -.fa-chart-simple { - --fa: "\e473"; -} - -.fa-chart-simple-horizontal { - --fa: "\e474"; -} - -.fa-diagram-cells { - --fa: "\e475"; -} - -.fa-diagram-next { - --fa: "\e476"; -} - -.fa-diagram-predecessor { - --fa: "\e477"; -} - -.fa-diagram-previous { - --fa: "\e478"; -} - -.fa-diagram-subtask { - --fa: "\e479"; -} - -.fa-diagram-successor { - --fa: "\e47a"; -} - -.fa-earth-oceania { - --fa: "\e47b"; -} - -.fa-globe-oceania { - --fa: "\e47b"; -} - -.fa-face-beam-hand-over-mouth { - --fa: "\e47c"; -} - -.fa-face-clouds { - --fa: "\e47d"; -} - -.fa-face-diagonal-mouth { - --fa: "\e47e"; -} - -.fa-face-dotted { - --fa: "\e47f"; -} - -.fa-face-exhaling { - --fa: "\e480"; -} - -.fa-face-hand-peeking { - --fa: "\e481"; -} - -.fa-face-holding-back-tears { - --fa: "\e482"; -} - -.fa-face-melting { - --fa: "\e483"; -} - -.fa-face-saluting { - --fa: "\e484"; -} - -.fa-face-spiral-eyes { - --fa: "\e485"; -} - -.fa-fort { - --fa: "\e486"; -} - -.fa-house-blank { - --fa: "\e487"; -} - -.fa-home-blank { - --fa: "\e487"; -} - -.fa-square-kanban { - --fa: "\e488"; -} - -.fa-square-list { - --fa: "\e489"; -} - -.fa-sushi { - --fa: "\e48a"; -} - -.fa-nigiri { - --fa: "\e48a"; -} - -.fa-sushi-roll { - --fa: "\e48b"; -} - -.fa-maki-roll { - --fa: "\e48b"; -} - -.fa-makizushi { - --fa: "\e48b"; -} - -.fa-album-circle-plus { - --fa: "\e48c"; -} - -.fa-album-circle-user { - --fa: "\e48d"; -} - -.fa-album-collection-circle-plus { - --fa: "\e48e"; -} - -.fa-album-collection-circle-user { - --fa: "\e48f"; -} - -.fa-bug-slash { - --fa: "\e490"; -} - -.fa-cloud-exclamation { - --fa: "\e491"; -} - -.fa-cloud-question { - --fa: "\e492"; -} - -.fa-file-circle-info { - --fa: "\e493"; -} - -.fa-file-circle-plus { - --fa: "\e494"; -} - -.fa-frame { - --fa: "\e495"; -} - -.fa-gauge-circle-bolt { - --fa: "\e496"; -} - -.fa-gauge-circle-minus { - --fa: "\e497"; -} - -.fa-gauge-circle-plus { - --fa: "\e498"; -} - -.fa-memo-circle-info { - --fa: "\e49a"; -} - -.fa-object-exclude { - --fa: "\e49c"; -} - -.fa-object-intersect { - --fa: "\e49d"; -} - -.fa-object-subtract { - --fa: "\e49e"; -} - -.fa-object-union { - --fa: "\e49f"; -} - -.fa-pen-nib-slash { - --fa: "\e4a1"; -} - -.fa-rectangle-history { - --fa: "\e4a2"; -} - -.fa-rectangle-history-circle-plus { - --fa: "\e4a3"; -} - -.fa-rectangle-history-circle-user { - --fa: "\e4a4"; -} - -.fa-shop-lock { - --fa: "\e4a5"; -} - -.fa-store-lock { - --fa: "\e4a6"; -} - -.fa-user-robot-xmarks { - --fa: "\e4a7"; -} - -.fa-virus-covid { - --fa: "\e4a8"; -} - -.fa-virus-covid-slash { - --fa: "\e4a9"; -} - -.fa-anchor-circle-check { - --fa: "\e4aa"; -} - -.fa-anchor-circle-exclamation { - --fa: "\e4ab"; -} - -.fa-anchor-circle-xmark { - --fa: "\e4ac"; -} - -.fa-anchor-lock { - --fa: "\e4ad"; -} - -.fa-arrow-down-to-arc { - --fa: "\e4ae"; -} - -.fa-arrow-down-up-across-line { - --fa: "\e4af"; -} - -.fa-arrow-down-up-lock { - --fa: "\e4b0"; -} - -.fa-arrow-right-from-arc { - --fa: "\e4b1"; -} - -.fa-arrow-right-to-arc { - --fa: "\e4b2"; -} - -.fa-arrow-right-to-city { - --fa: "\e4b3"; -} - -.fa-arrow-up-from-arc { - --fa: "\e4b4"; -} - -.fa-arrow-up-from-ground-water { - --fa: "\e4b5"; -} - -.fa-arrow-up-from-water-pump { - --fa: "\e4b6"; -} - -.fa-arrow-up-right-dots { - --fa: "\e4b7"; -} - -.fa-arrows-down-to-line { - --fa: "\e4b8"; -} - -.fa-arrows-down-to-people { - --fa: "\e4b9"; -} - -.fa-arrows-left-right-to-line { - --fa: "\e4ba"; -} - -.fa-arrows-spin { - --fa: "\e4bb"; -} - -.fa-arrows-split-up-and-left { - --fa: "\e4bc"; -} - -.fa-arrows-to-circle { - --fa: "\e4bd"; -} - -.fa-arrows-to-dot { - --fa: "\e4be"; -} - -.fa-arrows-to-eye { - --fa: "\e4bf"; -} - -.fa-arrows-turn-right { - --fa: "\e4c0"; -} - -.fa-arrows-turn-to-dots { - --fa: "\e4c1"; -} - -.fa-arrows-up-to-line { - --fa: "\e4c2"; -} - -.fa-bore-hole { - --fa: "\e4c3"; -} - -.fa-bottle-droplet { - --fa: "\e4c4"; -} - -.fa-bottle-water { - --fa: "\e4c5"; -} - -.fa-bowl-food { - --fa: "\e4c6"; -} - -.fa-boxes-packing { - --fa: "\e4c7"; -} - -.fa-bridge { - --fa: "\e4c8"; -} - -.fa-bridge-circle-check { - --fa: "\e4c9"; -} - -.fa-bridge-circle-exclamation { - --fa: "\e4ca"; -} - -.fa-bridge-circle-xmark { - --fa: "\e4cb"; -} - -.fa-bridge-lock { - --fa: "\e4cc"; -} - -.fa-bridge-suspension { - --fa: "\e4cd"; -} - -.fa-bridge-water { - --fa: "\e4ce"; -} - -.fa-bucket { - --fa: "\e4cf"; -} - -.fa-bugs { - --fa: "\e4d0"; -} - -.fa-building-circle-arrow-right { - --fa: "\e4d1"; -} - -.fa-building-circle-check { - --fa: "\e4d2"; -} - -.fa-building-circle-exclamation { - --fa: "\e4d3"; -} - -.fa-building-circle-xmark { - --fa: "\e4d4"; -} - -.fa-building-flag { - --fa: "\e4d5"; -} - -.fa-building-lock { - --fa: "\e4d6"; -} - -.fa-building-ngo { - --fa: "\e4d7"; -} - -.fa-building-shield { - --fa: "\e4d8"; -} - -.fa-building-un { - --fa: "\e4d9"; -} - -.fa-building-user { - --fa: "\e4da"; -} - -.fa-building-wheat { - --fa: "\e4db"; -} - -.fa-burst { - --fa: "\e4dc"; -} - -.fa-car-on { - --fa: "\e4dd"; -} - -.fa-car-tunnel { - --fa: "\e4de"; -} - -.fa-cards-blank { - --fa: "\e4df"; -} - -.fa-child-combatant { - --fa: "\e4e0"; -} - -.fa-child-rifle { - --fa: "\e4e0"; -} - -.fa-children { - --fa: "\e4e1"; -} - -.fa-circle-nodes { - --fa: "\e4e2"; -} - -.fa-clipboard-question { - --fa: "\e4e3"; -} - -.fa-cloud-showers-water { - --fa: "\e4e4"; -} - -.fa-computer { - --fa: "\e4e5"; -} - -.fa-cubes-stacked { - --fa: "\e4e6"; -} - -.fa-down-to-bracket { - --fa: "\e4e7"; -} - -.fa-envelope-circle-check { - --fa: "\e4e8"; -} - -.fa-explosion { - --fa: "\e4e9"; -} - -.fa-ferry { - --fa: "\e4ea"; -} - -.fa-file-circle-exclamation { - --fa: "\e4eb"; -} - -.fa-file-circle-minus { - --fa: "\e4ed"; -} - -.fa-file-circle-question { - --fa: "\e4ef"; -} - -.fa-file-shield { - --fa: "\e4f0"; -} - -.fa-fire-burner { - --fa: "\e4f1"; -} - -.fa-fish-fins { - --fa: "\e4f2"; -} - -.fa-flask-vial { - --fa: "\e4f3"; -} - -.fa-glass-water { - --fa: "\e4f4"; -} - -.fa-glass-water-droplet { - --fa: "\e4f5"; -} - -.fa-group-arrows-rotate { - --fa: "\e4f6"; -} - -.fa-hand-holding-hand { - --fa: "\e4f7"; -} - -.fa-handcuffs { - --fa: "\e4f8"; -} - -.fa-hands-bound { - --fa: "\e4f9"; -} - -.fa-hands-holding-child { - --fa: "\e4fa"; -} - -.fa-hands-holding-circle { - --fa: "\e4fb"; -} - -.fa-heart-circle-bolt { - --fa: "\e4fc"; -} - -.fa-heart-circle-check { - --fa: "\e4fd"; -} - -.fa-heart-circle-exclamation { - --fa: "\e4fe"; -} - -.fa-heart-circle-minus { - --fa: "\e4ff"; -} - -.fa-heart-circle-plus { - --fa: "\e500"; -} - -.fa-heart-circle-xmark { - --fa: "\e501"; -} - -.fa-helicopter-symbol { - --fa: "\e502"; -} - -.fa-helmet-un { - --fa: "\e503"; -} - -.fa-hexagon-image { - --fa: "\e504"; -} - -.fa-hexagon-vertical-nft { - --fa: "\e505"; -} - -.fa-hexagon-vertical-nft-slanted { - --fa: "\e505"; -} - -.fa-hill-avalanche { - --fa: "\e507"; -} - -.fa-hill-rockslide { - --fa: "\e508"; -} - -.fa-house-circle-check { - --fa: "\e509"; -} - -.fa-house-circle-exclamation { - --fa: "\e50a"; -} - -.fa-house-circle-xmark { - --fa: "\e50b"; -} - -.fa-house-fire { - --fa: "\e50c"; -} - -.fa-house-flag { - --fa: "\e50d"; -} - -.fa-house-flood-water { - --fa: "\e50e"; -} - -.fa-house-flood-water-circle-arrow-right { - --fa: "\e50f"; -} - -.fa-house-lock { - --fa: "\e510"; -} - -.fa-house-medical-circle-check { - --fa: "\e511"; -} - -.fa-house-medical-circle-exclamation { - --fa: "\e512"; -} - -.fa-house-medical-circle-xmark { - --fa: "\e513"; -} - -.fa-house-medical-flag { - --fa: "\e514"; -} - -.fa-house-tsunami { - --fa: "\e515"; -} - -.fa-jar { - --fa: "\e516"; -} - -.fa-jar-wheat { - --fa: "\e517"; -} - -.fa-jet-fighter-up { - --fa: "\e518"; -} - -.fa-jug-detergent { - --fa: "\e519"; -} - -.fa-kitchen-set { - --fa: "\e51a"; -} - -.fa-land-mine-on { - --fa: "\e51b"; -} - -.fa-landmark-flag { - --fa: "\e51c"; -} - -.fa-laptop-file { - --fa: "\e51d"; -} - -.fa-lines-leaning { - --fa: "\e51e"; -} - -.fa-location-pin-lock { - --fa: "\e51f"; -} - -.fa-locust { - --fa: "\e520"; -} - -.fa-magnifying-glass-arrow-right { - --fa: "\e521"; -} - -.fa-magnifying-glass-chart { - --fa: "\e522"; -} - -.fa-mars-and-venus-burst { - --fa: "\e523"; -} - -.fa-mask-ventilator { - --fa: "\e524"; -} - -.fa-mattress-pillow { - --fa: "\e525"; -} - -.fa-merge { - --fa: "\e526"; -} - -.fa-mobile-retro { - --fa: "\e527"; -} - -.fa-money-bill-transfer { - --fa: "\e528"; -} - -.fa-money-bill-trend-up { - --fa: "\e529"; -} - -.fa-money-bill-wheat { - --fa: "\e52a"; -} - -.fa-mosquito { - --fa: "\e52b"; -} - -.fa-mosquito-net { - --fa: "\e52c"; -} - -.fa-mound { - --fa: "\e52d"; -} - -.fa-mountain-city { - --fa: "\e52e"; -} - -.fa-mountain-sun { - --fa: "\e52f"; -} - -.fa-nfc-symbol { - --fa: "\e531"; -} - -.fa-oil-well { - --fa: "\e532"; -} - -.fa-people-group { - --fa: "\e533"; -} - -.fa-people-line { - --fa: "\e534"; -} - -.fa-people-pulling { - --fa: "\e535"; -} - -.fa-people-robbery { - --fa: "\e536"; -} - -.fa-people-roof { - --fa: "\e537"; -} - -.fa-person-arrow-down-to-line { - --fa: "\e538"; -} - -.fa-person-arrow-up-from-line { - --fa: "\e539"; -} - -.fa-person-breastfeeding { - --fa: "\e53a"; -} - -.fa-person-burst { - --fa: "\e53b"; -} - -.fa-person-cane { - --fa: "\e53c"; -} - -.fa-person-chalkboard { - --fa: "\e53d"; -} - -.fa-person-circle-check { - --fa: "\e53e"; -} - -.fa-person-circle-exclamation { - --fa: "\e53f"; -} - -.fa-person-circle-minus { - --fa: "\e540"; -} - -.fa-person-circle-plus { - --fa: "\e541"; -} - -.fa-person-circle-question { - --fa: "\e542"; -} - -.fa-person-circle-xmark { - --fa: "\e543"; -} - -.fa-person-dress-burst { - --fa: "\e544"; -} - -.fa-person-drowning { - --fa: "\e545"; -} - -.fa-person-falling { - --fa: "\e546"; -} - -.fa-person-falling-burst { - --fa: "\e547"; -} - -.fa-person-half-dress { - --fa: "\e548"; -} - -.fa-person-harassing { - --fa: "\e549"; -} - -.fa-person-military-pointing { - --fa: "\e54a"; -} - -.fa-person-military-rifle { - --fa: "\e54b"; -} - -.fa-person-military-to-person { - --fa: "\e54c"; -} - -.fa-person-rays { - --fa: "\e54d"; -} - -.fa-person-rifle { - --fa: "\e54e"; -} - -.fa-person-shelter { - --fa: "\e54f"; -} - -.fa-person-walking-arrow-loop-left { - --fa: "\e551"; -} - -.fa-person-walking-arrow-right { - --fa: "\e552"; -} - -.fa-person-walking-dashed-line-arrow-right { - --fa: "\e553"; -} - -.fa-person-walking-luggage { - --fa: "\e554"; -} - -.fa-plane-circle-check { - --fa: "\e555"; -} - -.fa-plane-circle-exclamation { - --fa: "\e556"; -} - -.fa-plane-circle-xmark { - --fa: "\e557"; -} - -.fa-plane-lock { - --fa: "\e558"; -} - -.fa-plate-wheat { - --fa: "\e55a"; -} - -.fa-plug-circle-bolt { - --fa: "\e55b"; -} - -.fa-plug-circle-check { - --fa: "\e55c"; -} - -.fa-plug-circle-exclamation { - --fa: "\e55d"; -} - -.fa-plug-circle-minus { - --fa: "\e55e"; -} - -.fa-plug-circle-plus { - --fa: "\e55f"; -} - -.fa-plug-circle-xmark { - --fa: "\e560"; -} - -.fa-ranking-star { - --fa: "\e561"; -} - -.fa-road-barrier { - --fa: "\e562"; -} - -.fa-road-bridge { - --fa: "\e563"; -} - -.fa-road-circle-check { - --fa: "\e564"; -} - -.fa-road-circle-exclamation { - --fa: "\e565"; -} - -.fa-road-circle-xmark { - --fa: "\e566"; -} - -.fa-road-lock { - --fa: "\e567"; -} - -.fa-road-spikes { - --fa: "\e568"; -} - -.fa-rug { - --fa: "\e569"; -} - -.fa-sack-xmark { - --fa: "\e56a"; -} - -.fa-school-circle-check { - --fa: "\e56b"; -} - -.fa-school-circle-exclamation { - --fa: "\e56c"; -} - -.fa-school-circle-xmark { - --fa: "\e56d"; -} - -.fa-school-flag { - --fa: "\e56e"; -} - -.fa-school-lock { - --fa: "\e56f"; -} - -.fa-sheet-plastic { - --fa: "\e571"; -} - -.fa-shield-cat { - --fa: "\e572"; -} - -.fa-shield-dog { - --fa: "\e573"; -} - -.fa-shield-heart { - --fa: "\e574"; -} - -.fa-shield-quartered { - --fa: "\e575"; -} - -.fa-square-nfi { - --fa: "\e576"; -} - -.fa-square-person-confined { - --fa: "\e577"; -} - -.fa-square-virus { - --fa: "\e578"; -} - -.fa-staff-snake { - --fa: "\e579"; -} - -.fa-rod-asclepius { - --fa: "\e579"; -} - -.fa-rod-snake { - --fa: "\e579"; -} - -.fa-staff-aesculapius { - --fa: "\e579"; -} - -.fa-sun-plant-wilt { - --fa: "\e57a"; -} - -.fa-tarp { - --fa: "\e57b"; -} - -.fa-tarp-droplet { - --fa: "\e57c"; -} - -.fa-tent { - --fa: "\e57d"; -} - -.fa-tent-arrow-down-to-line { - --fa: "\e57e"; -} - -.fa-tent-arrow-left-right { - --fa: "\e57f"; -} - -.fa-tent-arrow-turn-left { - --fa: "\e580"; -} - -.fa-tent-arrows-down { - --fa: "\e581"; -} - -.fa-tents { - --fa: "\e582"; -} - -.fa-toilet-portable { - --fa: "\e583"; -} - -.fa-toilets-portable { - --fa: "\e584"; -} - -.fa-tower-cell { - --fa: "\e585"; -} - -.fa-tower-observation { - --fa: "\e586"; -} - -.fa-tree-city { - --fa: "\e587"; -} - -.fa-trillium { - --fa: "\e588"; -} - -.fa-trowel { - --fa: "\e589"; -} - -.fa-trowel-bricks { - --fa: "\e58a"; -} - -.fa-truck-arrow-right { - --fa: "\e58b"; -} - -.fa-truck-droplet { - --fa: "\e58c"; -} - -.fa-truck-field { - --fa: "\e58d"; -} - -.fa-truck-field-un { - --fa: "\e58e"; -} - -.fa-truck-plane { - --fa: "\e58f"; -} - -.fa-up-from-bracket { - --fa: "\e590"; -} - -.fa-users-between-lines { - --fa: "\e591"; -} - -.fa-users-line { - --fa: "\e592"; -} - -.fa-users-rays { - --fa: "\e593"; -} - -.fa-users-rectangle { - --fa: "\e594"; -} - -.fa-users-viewfinder { - --fa: "\e595"; -} - -.fa-vial-circle-check { - --fa: "\e596"; -} - -.fa-vial-virus { - --fa: "\e597"; -} - -.fa-wheat-awn-circle-exclamation { - --fa: "\e598"; -} - -.fa-worm { - --fa: "\e599"; -} - -.fa-xmarks-lines { - --fa: "\e59a"; -} - -.fa-xmark-large { - --fa: "\e59b"; -} - -.fa-child-dress { - --fa: "\e59c"; -} - -.fa-child-reaching { - --fa: "\e59d"; -} - -.fa-plus-large { - --fa: "\e59e"; -} - -.fa-crosshairs-simple { - --fa: "\e59f"; -} - -.fa-file-circle-check { - --fa: "\e5a0"; -} - -.fa-file-circle-xmark { - --fa: "\e5a1"; -} - -.fa-gamepad-modern { - --fa: "\e5a2"; -} - -.fa-gamepad-alt { - --fa: "\e5a2"; -} - -.fa-grill { - --fa: "\e5a3"; -} - -.fa-grill-fire { - --fa: "\e5a4"; -} - -.fa-grill-hot { - --fa: "\e5a5"; -} - -.fa-lightbulb-cfl { - --fa: "\e5a6"; -} - -.fa-lightbulb-cfl-on { - --fa: "\e5a7"; -} - -.fa-mouse-field { - --fa: "\e5a8"; -} - -.fa-person-through-window { - --fa: "\e5a9"; -} - -.fa-plant-wilt { - --fa: "\e5aa"; -} - -.fa-ring-diamond { - --fa: "\e5ab"; -} - -.fa-stapler { - --fa: "\e5af"; -} - -.fa-toggle-large-off { - --fa: "\e5b0"; -} - -.fa-toggle-large-on { - --fa: "\e5b1"; -} - -.fa-toilet-paper-check { - --fa: "\e5b2"; -} - -.fa-toilet-paper-xmark { - --fa: "\e5b3"; -} - -.fa-train-tram { - --fa: "\e5b4"; -} - -.fa-buoy { - --fa: "\e5b5"; -} - -.fa-buoy-mooring { - --fa: "\e5b6"; -} - -.fa-diamond-half { - --fa: "\e5b7"; -} - -.fa-diamond-half-stroke { - --fa: "\e5b8"; -} - -.fa-game-console-handheld-crank { - --fa: "\e5b9"; -} - -.fa-interrobang { - --fa: "\e5ba"; -} - -.fa-mailbox-flag-up { - --fa: "\e5bb"; -} - -.fa-mustache { - --fa: "\e5bc"; -} - -.fa-nose { - --fa: "\e5bd"; -} - -.fa-phone-arrow-right { - --fa: "\e5be"; -} - -.fa-pickaxe { - --fa: "\e5bf"; -} - -.fa-prescription-bottle-pill { - --fa: "\e5c0"; -} - -.fa-snowflake-droplets { - --fa: "\e5c1"; -} - -.fa-square-dashed-circle-plus { - --fa: "\e5c2"; -} - -.fa-tricycle { - --fa: "\e5c3"; -} - -.fa-tricycle-adult { - --fa: "\e5c4"; -} - -.fa-user-magnifying-glass { - --fa: "\e5c5"; -} - -.fa-comment-heart { - --fa: "\e5c8"; -} - -.fa-message-heart { - --fa: "\e5c9"; -} - -.fa-pencil-mechanical { - --fa: "\e5ca"; -} - -.fa-skeleton-ribs { - --fa: "\e5cb"; -} - -.fa-billboard { - --fa: "\e5cd"; -} - -.fa-circle-euro { - --fa: "\e5ce"; -} - -.fa-circle-sterling { - --fa: "\e5cf"; -} - -.fa-circle-yen { - --fa: "\e5d0"; -} - -.fa-broom-wide { - --fa: "\e5d1"; -} - -.fa-wreath-laurel { - --fa: "\e5d2"; -} - -.fa-circle-quarter-stroke { - --fa: "\e5d3"; -} - -.fa-circle-three-quarters-stroke { - --fa: "\e5d4"; -} - -.fa-webhook { - --fa: "\e5d5"; -} - -.fa-sparkle { - --fa: "\e5d6"; -} - -.fa-chart-line-up-down { - --fa: "\e5d7"; -} - -.fa-chart-mixed-up-circle-currency { - --fa: "\e5d8"; -} - -.fa-chart-mixed-up-circle-dollar { - --fa: "\e5d9"; -} - -.fa-grid-round { - --fa: "\e5da"; -} - -.fa-grid-round-2 { - --fa: "\e5db"; -} - -.fa-grid-round-2-plus { - --fa: "\e5dc"; -} - -.fa-grid-round-4 { - --fa: "\e5dd"; -} - -.fa-grid-round-5 { - --fa: "\e5de"; -} - -.fa-arrow-progress { - --fa: "\e5df"; -} - -.fa-right-left-large { - --fa: "\e5e1"; -} - -.fa-calendar-users { - --fa: "\e5e2"; -} - -.fa-display-chart-up { - --fa: "\e5e3"; -} - -.fa-display-chart-up-circle-currency { - --fa: "\e5e5"; -} - -.fa-display-chart-up-circle-dollar { - --fa: "\e5e6"; -} - -.fa-laptop-binary { - --fa: "\e5e7"; -} - -.fa-gear-code { - --fa: "\e5e8"; -} - -.fa-gear-complex { - --fa: "\e5e9"; -} - -.fa-gear-complex-code { - --fa: "\e5eb"; -} - -.fa-file-doc { - --fa: "\e5ed"; -} - -.fa-file-zip { - --fa: "\e5ee"; -} - -.fa-flask-gear { - --fa: "\e5f1"; -} - -.fa-bag-seedling { - --fa: "\e5f2"; -} - -.fa-bin-bottles { - --fa: "\e5f5"; -} - -.fa-bin-bottles-recycle { - --fa: "\e5f6"; -} - -.fa-bin-recycle { - --fa: "\e5f7"; -} - -.fa-conveyor-belt-arm { - --fa: "\e5f8"; -} - -.fa-jug-bottle { - --fa: "\e5fb"; -} - -.fa-lightbulb-gear { - --fa: "\e5fd"; -} - -.fa-dinosaur { - --fa: "\e5fe"; -} - -.fa-person-running-fast { - --fa: "\e5ff"; -} - -.fa-circles-overlap { - --fa: "\e600"; -} - -.fa-cloud-binary { - --fa: "\e601"; -} - -.fa-chf-sign { - --fa: "\e602"; -} - -.fa-user-group-simple { - --fa: "\e603"; -} - -.fa-chart-pie-simple-circle-currency { - --fa: "\e604"; -} - -.fa-chart-pie-simple-circle-dollar { - --fa: "\e605"; -} - -.fa-hat-beach { - --fa: "\e606"; -} - -.fa-person-dress-fairy { - --fa: "\e607"; -} - -.fa-person-fairy { - --fa: "\e608"; -} - -.fa-swap { - --fa: "\e609"; -} - -.fa-swap-arrows { - --fa: "\e60a"; -} - -.fa-angles-up-down { - --fa: "\e60d"; -} - -.fa-globe-pointer { - --fa: "\e60e"; -} - -.fa-subtitles { - --fa: "\e60f"; -} - -.fa-subtitles-slash { - --fa: "\e610"; -} - -.fa-head-side-gear { - --fa: "\e611"; -} - -.fa-lighthouse { - --fa: "\e612"; -} - -.fa-raccoon { - --fa: "\e613"; -} - -.fa-arrow-down-from-arc { - --fa: "\e614"; -} - -.fa-arrow-left-from-arc { - --fa: "\e615"; -} - -.fa-arrow-left-to-arc { - --fa: "\e616"; -} - -.fa-arrow-up-to-arc { - --fa: "\e617"; -} - -.fa-building-magnifying-glass { - --fa: "\e61c"; -} - -.fa-building-memo { - --fa: "\e61e"; -} - -.fa-hammer-brush { - --fa: "\e620"; -} - -.fa-hand-holding-circle-dollar { - --fa: "\e621"; -} - -.fa-landmark-magnifying-glass { - --fa: "\e622"; -} - -.fa-sign-post { - --fa: "\e624"; -} - -.fa-sign-posts { - --fa: "\e625"; -} - -.fa-sign-posts-wrench { - --fa: "\e626"; -} - -.fa-tent-double-peak { - --fa: "\e627"; -} - -.fa-truck-utensils { - --fa: "\e628"; -} - -.fa-t-rex { - --fa: "\e629"; -} - -.fa-spinner-scale { - --fa: "\e62a"; -} - -.fa-bell-ring { - --fa: "\e62c"; -} - -.fa-arrows-rotate-reverse { - --fa: "\e630"; -} - -.fa-rotate-reverse { - --fa: "\e631"; -} - -.fa-arrow-turn-left { - --fa: "\e632"; -} - -.fa-arrow-turn-left-down { - --fa: "\e633"; -} - -.fa-arrow-turn-left-up { - --fa: "\e634"; -} - -.fa-arrow-turn-right { - --fa: "\e635"; -} - -.fa-turn-left { - --fa: "\e636"; -} - -.fa-turn-left-down { - --fa: "\e637"; -} - -.fa-turn-left-up { - --fa: "\e638"; -} - -.fa-turn-right { - --fa: "\e639"; -} - -.fa-location-arrow-up { - --fa: "\e63a"; -} - -.fa-ticket-perforated { - --fa: "\e63e"; -} - -.fa-tickets-perforated { - --fa: "\e63f"; -} - -.fa-cannon { - --fa: "\e642"; -} - -.fa-court-sport { - --fa: "\e643"; -} - -.fa-file-eps { - --fa: "\e644"; -} - -.fa-file-gif { - --fa: "\e645"; -} - -.fa-file-jpg { - --fa: "\e646"; -} - -.fa-file-mov { - --fa: "\e647"; -} - -.fa-file-mp3 { - --fa: "\e648"; -} - -.fa-file-mp4 { - --fa: "\e649"; -} - -.fa-file-ppt { - --fa: "\e64a"; -} - -.fa-file-svg { - --fa: "\e64b"; -} - -.fa-file-vector { - --fa: "\e64c"; -} - -.fa-file-xls { - --fa: "\e64d"; -} - -.fa-folder-check { - --fa: "\e64e"; -} - -.fa-chart-kanban { - --fa: "\e64f"; -} - -.fa-bag-shopping-minus { - --fa: "\e650"; -} - -.fa-bag-shopping-plus { - --fa: "\e651"; -} - -.fa-basket-shopping-minus { - --fa: "\e652"; -} - -.fa-basket-shopping-plus { - --fa: "\e653"; -} - -.fa-file-xml { - --fa: "\e654"; -} - -.fa-bulldozer { - --fa: "\e655"; -} - -.fa-excavator { - --fa: "\e656"; -} - -.fa-truck-ladder { - --fa: "\e657"; -} - -.fa-tickets { - --fa: "\e658"; -} - -.fa-tickets-simple { - --fa: "\e659"; -} - -.fa-truck-fire { - --fa: "\e65a"; -} - -.fa-wave { - --fa: "\e65b"; -} - -.fa-waves-sine { - --fa: "\e65d"; -} - -.fa-magnifying-glass-arrows-rotate { - --fa: "\e65e"; -} - -.fa-magnifying-glass-music { - --fa: "\e65f"; -} - -.fa-magnifying-glass-play { - --fa: "\e660"; -} - -.fa-magnifying-glass-waveform { - --fa: "\e661"; -} - -.fa-music-magnifying-glass { - --fa: "\e662"; -} - -.fa-reflect-horizontal { - --fa: "\e664"; -} - -.fa-reflect-vertical { - --fa: "\e665"; -} - -.fa-file-png { - --fa: "\e666"; -} - -.fa-arrow-down-from-bracket { - --fa: "\e667"; -} - -.fa-arrow-left-from-bracket { - --fa: "\e668"; -} - -.fa-arrow-left-to-bracket { - --fa: "\e669"; -} - -.fa-arrow-up-to-bracket { - --fa: "\e66a"; -} - -.fa-down-from-bracket { - --fa: "\e66b"; -} - -.fa-left-from-bracket { - --fa: "\e66c"; -} - -.fa-left-to-bracket { - --fa: "\e66d"; -} - -.fa-up-to-bracket { - --fa: "\e66e"; -} - -.fa-reflect-both { - --fa: "\e66f"; -} - -.fa-file-cad { - --fa: "\e672"; -} - -.fa-bottle-baby { - --fa: "\e673"; -} - -.fa-table-cells-column-lock { - --fa: "\e678"; -} - -.fa-table-cells-lock { - --fa: "\e679"; -} - -.fa-table-cells-row-lock { - --fa: "\e67a"; -} - -.fa-circle-wifi { - --fa: "\e67d"; -} - -.fa-circle-wifi-circle-wifi { - --fa: "\e67e"; -} - -.fa-circle-wifi-group { - --fa: "\e67e"; -} - -.fa-circle-gf { - --fa: "\e67f"; -} - -.fa-ant { - --fa: "\e680"; -} - -.fa-caduceus { - --fa: "\e681"; -} - -.fa-web-awesome { - --fa: "\e682"; -} - -.fa-globe-wifi { - --fa: "\e685"; -} - -.fa-hydra { - --fa: "\e686"; -} - -.fa-lightbulb-message { - --fa: "\e687"; -} - -.fa-octopus { - --fa: "\e688"; -} - -.fa-user-beard-bolt { - --fa: "\e689"; -} - -.fa-user-hoodie { - --fa: "\e68a"; -} - -.fa-diamonds-4 { - --fa: "\e68b"; -} - -.fa-thumbtack-slash { - --fa: "\e68f"; -} - -.fa-thumb-tack-slash { - --fa: "\e68f"; -} - -.fa-table-cells-column-unlock { - --fa: "\e690"; -} - -.fa-table-cells-row-unlock { - --fa: "\e691"; -} - -.fa-table-cells-unlock { - --fa: "\e692"; -} - -.fa-chart-diagram { - --fa: "\e695"; -} - -.fa-comment-nodes { - --fa: "\e696"; -} - -.fa-file-fragment { - --fa: "\e697"; -} - -.fa-file-half-dashed { - --fa: "\e698"; -} - -.fa-hexagon-nodes { - --fa: "\e699"; -} - -.fa-hexagon-nodes-bolt { - --fa: "\e69a"; -} - -.fa-square-binary { - --fa: "\e69b"; -} - -.fa-carpool { - --fa: "\e69c"; -} - -.fa-car-people { - --fa: "\e69c"; -} - -.fa-chart-sine { - --fa: "\e69d"; -} - -.fa-chart-fft { - --fa: "\e69e"; -} - -.fa-circles-overlap-3 { - --fa: "\e6a1"; -} - -.fa-pronoun { - --fa: "\e6a1"; -} - -.fa-bar-progress { - --fa: "\e6a4"; -} - -.fa-bar-progress-empty { - --fa: "\e6a5"; -} - -.fa-bar-progress-full { - --fa: "\e6a6"; -} - -.fa-bar-progress-half { - --fa: "\e6a7"; -} - -.fa-bar-progress-quarter { - --fa: "\e6a8"; -} - -.fa-bar-progress-three-quarters { - --fa: "\e6a9"; -} - -.fa-grid-2-minus { - --fa: "\e6aa"; -} - -.fa-grid-round-2-minus { - --fa: "\e6ab"; -} - -.fa-table-cells-columns { - --fa: "\e6ac"; -} - -.fa-table-cells-header { - --fa: "\e6ad"; -} - -.fa-table-cells-header-lock { - --fa: "\e6ae"; -} - -.fa-table-cells-header-unlock { - --fa: "\e6af"; -} - -.fa-table-cells-rows { - --fa: "\e6b0"; -} - -.fa-circle-equals { - --fa: "\e6b1"; -} - -.fa-hexagon-equals { - --fa: "\e6b2"; -} - -.fa-octagon-equals { - --fa: "\e6b3"; -} - -.fa-rectangle-minus { - --fa: "\e6b4"; -} - -.fa-rectangle-plus { - --fa: "\e6b5"; -} - -.fa-square-equals { - --fa: "\e6b6"; -} - -.fa-arrow-down-long-to-line { - --fa: "\e6b7"; -} - -.fa-arrow-left-arrow-right { - --fa: "\e6b8"; -} - -.fa-arrow-left-from-dotted-line { - --fa: "\e6b9"; -} - -.fa-arrow-left-to-dotted-line { - --fa: "\e6ba"; -} - -.fa-arrow-right-from-dotted-line { - --fa: "\e6bb"; -} - -.fa-arrow-right-to-dotted-line { - --fa: "\e6bc"; -} - -.fa-arrow-up-long-to-line { - --fa: "\e6bd"; -} - -.fa-direction-left-right { - --fa: "\e6be"; -} - -.fa-direction-up-down { - --fa: "\e6bf"; -} - -.fa-down-long-to-line { - --fa: "\e6c0"; -} - -.fa-down-up { - --fa: "\e6c1"; -} - -.fa-left-from-dotted-line { - --fa: "\e6c2"; -} - -.fa-left-to-dotted-line { - --fa: "\e6c3"; -} - -.fa-right-from-dotted-line { - --fa: "\e6c4"; -} - -.fa-right-to-dotted-line { - --fa: "\e6c5"; -} - -.fa-up-long-to-line { - --fa: "\e6c6"; -} - -.fa-barn { - --fa: "\e6c7"; -} - -.fa-circle-house { - --fa: "\e6c8"; -} - -.fa-garage-empty { - --fa: "\e6c9"; -} - -.fa-house-unlock { - --fa: "\e6ca"; -} - -.fa-school-unlock { - --fa: "\e6cb"; -} - -.fa-stadium { - --fa: "\e6cc"; -} - -.fa-tent-circus { - --fa: "\e6cd"; -} - -.fa-ball-yarn { - --fa: "\e6ce"; -} - -.fa-bra { - --fa: "\e6cf"; -} - -.fa-briefs { - --fa: "\e6d0"; -} - -.fa-dress { - --fa: "\e6d1"; -} - -.fa-jeans { - --fa: "\e6d2"; -} - -.fa-jeans-straight { - --fa: "\e6d3"; -} - -.fa-panties { - --fa: "\e6d4"; -} - -.fa-pants { - --fa: "\e6d5"; -} - -.fa-pants-straight { - --fa: "\e6d6"; -} - -.fa-shirt-jersey { - --fa: "\e6d7"; -} - -.fa-shoe { - --fa: "\e6d8"; -} - -.fa-shorts { - --fa: "\e6d9"; -} - -.fa-sneaker { - --fa: "\e6da"; -} - -.fa-circle-share-nodes { - --fa: "\e6db"; -} - -.fa-comment-dot { - --fa: "\e6dc"; -} - -.fa-comment-waveform { - --fa: "\e6dd"; -} - -.fa-envelope-circle-user { - --fa: "\e6de"; -} - -.fa-message-dot { - --fa: "\e6df"; -} - -.fa-message-waveform { - --fa: "\e6e0"; -} - -.fa-phone-connection { - --fa: "\e6e1"; -} - -.fa-phone-waveform { - --fa: "\e6e2"; -} - -.fa-postage-stamp { - --fa: "\e6e3"; -} - -.fa-circle-florin { - --fa: "\e6e4"; -} - -.fa-circle-ruble { - --fa: "\e6e5"; -} - -.fa-square-chf { - --fa: "\e6e6"; -} - -.fa-square-lira { - --fa: "\e6e7"; -} - -.fa-norwegian-krone-sign { - --fa: "\e6e8"; -} - -.fa-circle-renminbi { - --fa: "\e6e9"; -} - -.fa-square-peseta { - --fa: "\e6ea"; -} - -.fa-circle-brazilian-real { - --fa: "\e6eb"; -} - -.fa-circle-won { - --fa: "\e6ec"; -} - -.fa-square-cruzeiro { - --fa: "\e6ed"; -} - -.fa-circle-currency { - --fa: "\e6ee"; -} - -.fa-circle-hryvnia { - --fa: "\e6ef"; -} - -.fa-square-cent { - --fa: "\e6f0"; -} - -.fa-square-brazilian-real { - --fa: "\e6f1"; -} - -.fa-square-bitcoin { - --fa: "\e6f2"; -} - -.fa-circle-peruvian-soles { - --fa: "\e6f3"; -} - -.fa-circle-litecoin { - --fa: "\e6f4"; -} - -.fa-square-indian-rupee { - --fa: "\e6f5"; -} - -.fa-circle-lira { - --fa: "\e6f6"; -} - -.fa-square-litecoin { - --fa: "\e6f7"; -} - -.fa-square-ruble { - --fa: "\e6f8"; -} - -.fa-circle-malaysian-ringgit { - --fa: "\e6f9"; -} - -.fa-malaysian-ringgit-sign { - --fa: "\e6fa"; -} - -.fa-circle-manat { - --fa: "\e6fb"; -} - -.fa-circle-colon { - --fa: "\e6fc"; -} - -.fa-circle-kip { - --fa: "\e6fd"; -} - -.fa-australian-dollar-sign { - --fa: "\e6fe"; -} - -.fa-circle-peso { - --fa: "\e6ff"; -} - -.fa-circle-polish-zloty { - --fa: "\e700"; -} - -.fa-circle-bangladeshi-taka { - --fa: "\e701"; -} - -.fa-circle-mill { - --fa: "\e702"; -} - -.fa-circle-shekel { - --fa: "\e703"; -} - -.fa-square-manat { - --fa: "\e704"; -} - -.fa-peruvian-soles-sign { - --fa: "\e705"; -} - -.fa-circle-rupiah { - --fa: "\e706"; -} - -.fa-square-norwegian-krone { - --fa: "\e707"; -} - -.fa-square-naira { - --fa: "\e708"; -} - -.fa-square-won { - --fa: "\e709"; -} - -.fa-square-mill { - --fa: "\e70a"; -} - -.fa-polish-zloty-sign { - --fa: "\e70b"; -} - -.fa-square-currency { - --fa: "\e70c"; -} - -.fa-square-kip { - --fa: "\e70d"; -} - -.fa-square-guarani { - --fa: "\e70e"; -} - -.fa-square-dong { - --fa: "\e70f"; -} - -.fa-square-hryvnia { - --fa: "\e710"; -} - -.fa-circle-tugrik { - --fa: "\e711"; -} - -.fa-square-rupiah { - --fa: "\e712"; -} - -.fa-square-sterling { - --fa: "\e713"; -} - -.fa-circle-rupee { - --fa: "\e714"; -} - -.fa-square-rupee { - --fa: "\e715"; -} - -.fa-square-peruvian-soles { - --fa: "\e716"; -} - -.fa-square-florin { - --fa: "\e717"; -} - -.fa-square-australian-dollar { - --fa: "\e718"; -} - -.fa-square-baht { - --fa: "\e719"; -} - -.fa-square-peso { - --fa: "\e71a"; -} - -.fa-circle-austral { - --fa: "\e71b"; -} - -.fa-square-swedish-krona { - --fa: "\e71c"; -} - -.fa-circle-lari { - --fa: "\e71d"; -} - -.fa-circleapore-dollar { - --fa: "\e71e"; -} - -.fa-square-turkish-lira { - --fa: "\e71f"; -} - -.fa-danish-krone-sign { - --fa: "\e720"; -} - -.fa-circle-franc { - --fa: "\e721"; -} - -.fa-circle-cruzeiro { - --fa: "\e722"; -} - -.fa-circle-dong { - --fa: "\e723"; -} - -.fa-square-yen { - --fa: "\e724"; -} - -.fa-circle-tenge { - --fa: "\e725"; -} - -.fa-square-austral { - --fa: "\e726"; -} - -.fa-square-eurozone { - --fa: "\e727"; -} - -.fa-square-tugrik { - --fa: "\e728"; -} - -.fa-square-cedi { - --fa: "\e729"; -} - -.fa-circle-cent { - --fa: "\e72a"; -} - -.fa-currency-sign { - --fa: "\e72b"; -} - -.fa-circle-chf { - --fa: "\e72c"; -} - -.fa-circle-baht { - --fa: "\e72d"; -} - -.fa-signapore-dollar-sign { - --fa: "\e72e"; -} - -.fa-square-franc { - --fa: "\e72f"; -} - -.fa-circle-australian-dollar { - --fa: "\e730"; -} - -.fa-square-tenge { - --fa: "\e731"; -} - -.fa-square-euro { - --fa: "\e732"; -} - -.fa-squareapore-dollar { - --fa: "\e733"; -} - -.fa-circle-indian-rupee { - --fa: "\e734"; -} - -.fa-square-shekel { - --fa: "\e735"; -} - -.fa-square-polish-zloty { - --fa: "\e736"; -} - -.fa-circle-bitcoin { - --fa: "\e737"; -} - -.fa-circle-norwegian-krone { - --fa: "\e738"; -} - -.fa-circle-turkish-lira { - --fa: "\e739"; -} - -.fa-square-colon { - --fa: "\e73a"; -} - -.fa-circle-guarani { - --fa: "\e73b"; -} - -.fa-renminbi-sign { - --fa: "\e73c"; -} - -.fa-square-renminbi { - --fa: "\e73d"; -} - -.fa-swedish-krona-sign { - --fa: "\e73e"; -} - -.fa-square-lari { - --fa: "\e73f"; -} - -.fa-eurozone-sign { - --fa: "\e740"; -} - -.fa-circle-peseta { - --fa: "\e741"; -} - -.fa-circle-cedi { - --fa: "\e742"; -} - -.fa-circle-swedish-krona { - --fa: "\e743"; -} - -.fa-square-bangladeshi-taka { - --fa: "\e744"; -} - -.fa-circle-eurozone { - --fa: "\e745"; -} - -.fa-circle-danish-krone { - --fa: "\e746"; -} - -.fa-square-danish-krone { - --fa: "\e747"; -} - -.fa-square-malaysian-ringgit { - --fa: "\e748"; -} - -.fa-circle-naira { - --fa: "\e749"; -} - -.fa-mobile-arrow-down { - --fa: "\e74b"; -} - -.fa-clone-plus { - --fa: "\e74c"; -} - -.fa-paintbrush-fine-slash { - --fa: "\e74d"; -} - -.fa-paintbrush-slash { - --fa: "\e74e"; -} - -.fa-pencil-line { - --fa: "\e74f"; -} - -.fa-slider-circle { - --fa: "\e750"; -} - -.fa-thumbtack-angle { - --fa: "\e751"; -} - -.fa-thumbtack-angle-slash { - --fa: "\e752"; -} - -.fa-book-open-lines { - --fa: "\e753"; -} - -.fa-book-spine { - --fa: "\e754"; -} - -.fa-bookmark-plus { - --fa: "\e755"; -} - -.fa-clipboard-clock { - --fa: "\e756"; -} - -.fa-clipboard-exclamation { - --fa: "\e757"; -} - -.fa-file-ban { - --fa: "\e758"; -} - -.fa-notes-sticky { - --fa: "\e759"; -} - -.fa-capsule { - --fa: "\e75a"; -} - -.fa-ear-circle-checkmark { - --fa: "\e75b"; -} - -.fa-ear-triangle-exclamation { - --fa: "\e75c"; -} - -.fa-ear-waveform { - --fa: "\e75d"; -} - -.fa-head-side-circuit { - --fa: "\e75e"; -} - -.fa-head-side-speak { - --fa: "\e75f"; -} - -.fa-microphone-signal-meter { - --fa: "\e760"; -} - -.fa-spine { - --fa: "\e761"; -} - -.fa-vial-vertical { - --fa: "\e762"; -} - -.fa-bin { - --fa: "\e763"; -} - -.fa-seat { - --fa: "\e764"; -} - -.fa-seats { - --fa: "\e765"; -} - -.fa-camera-circle-ellipsis { - --fa: "\e766"; -} - -.fa-camera-clock { - --fa: "\e767"; -} - -.fa-camera-shutter { - --fa: "\e768"; -} - -.fa-film-music { - --fa: "\e769"; -} - -.fa-film-stack { - --fa: "\e76b"; -} - -.fa-image-circle-arrow-down { - --fa: "\e76c"; -} - -.fa-image-circle-check { - --fa: "\e76d"; -} - -.fa-image-circle-plus { - --fa: "\e76e"; -} - -.fa-image-circle-xmark { - --fa: "\e76f"; -} - -.fa-image-music { - --fa: "\e770"; -} - -.fa-image-stack { - --fa: "\e771"; -} - -.fa-rectangle-4k { - --fa: "\e772"; -} - -.fa-rectangle-high-dynamic-range { - --fa: "\e773"; -} - -.fa-rectangle-hdr { - --fa: "\e773"; -} - -.fa-rectangle-video-on-demand { - --fa: "\e774"; -} - -.fa-user-viewfinder { - --fa: "\e775"; -} - -.fa-video-down-to-line { - --fa: "\e776"; -} - -.fa-video-question { - --fa: "\e777"; -} - -.fa-gas-pump-left { - --fa: "\e778"; -} - -.fa-gas-pump-right { - --fa: "\e779"; -} - -.fa-location-arrow-slash { - --fa: "\e77a"; -} - -.fa-airplay-audio { - --fa: "\e77b"; -} - -.fa-headphones-slash { - --fa: "\e77c"; -} - -.fa-microphone-circle-plus { - --fa: "\e77d"; -} - -.fa-microphone-circle-xmark { - --fa: "\e77e"; -} - -.fa-open-captioning { - --fa: "\e77f"; -} - -.fa-play-flip { - --fa: "\e780"; -} - -.fa-square-microphone { - --fa: "\e781"; -} - -.fa-trombone { - --fa: "\e782"; -} - -.fa-person-arms-raised { - --fa: "\e783"; -} - -.fa-person-basketball { - --fa: "\e784"; -} - -.fa-person-carry-empty { - --fa: "\e785"; -} - -.fa-person-golfing { - --fa: "\e786"; -} - -.fa-person-limbs-wide { - --fa: "\e787"; -} - -.fa-person-seat-window { - --fa: "\e788"; -} - -.fa-person-soccer { - --fa: "\e789"; -} - -.fa-person-swimming-pool { - --fa: "\e78a"; -} - -.fa-person-swimming-water { - --fa: "\e78b"; -} - -.fa-person-water-arms-raised { - --fa: "\e78c"; -} - -.fa-person-waving { - --fa: "\e78d"; -} - -.fa-heart-slash { - --fa: "\e78e"; -} - -.fa-hearts { - --fa: "\e78f"; -} - -.fa-pentagon { - --fa: "\e790"; -} - -.fa-rectangle-tall { - --fa: "\e791"; -} - -.fa-square-half { - --fa: "\e792"; -} - -.fa-square-half-stroke { - --fa: "\e793"; -} - -.fa-box-arrow-down { - --fa: "\e794"; -} - -.fa-box-arrow-down-arrow-up { - --fa: "\e795"; -} - -.fa-box-arrow-down-magnifying-glass { - --fa: "\e796"; -} - -.fa-box-isometric { - --fa: "\e797"; -} - -.fa-box-isometric-tape { - --fa: "\e798"; -} - -.fa-qrcode-read { - --fa: "\e799"; -} - -.fa-shop-24 { - --fa: "\e79a"; -} - -.fa-store-24 { - --fa: "\e79b"; -} - -.fa-face-shaking { - --fa: "\e79c"; -} - -.fa-face-shaking-horizontal { - --fa: "\e79d"; -} - -.fa-face-shaking-vertical { - --fa: "\e79e"; -} - -.fa-circle-user-circle-check { - --fa: "\e79f"; -} - -.fa-circle-user-circle-exclamation { - --fa: "\e7a0"; -} - -.fa-circle-user-circle-minus { - --fa: "\e7a1"; -} - -.fa-circle-user-circle-moon { - --fa: "\e7a2"; -} - -.fa-circle-user-circle-plus { - --fa: "\e7a3"; -} - -.fa-circle-user-circle-question { - --fa: "\e7a4"; -} - -.fa-circle-user-circle-user { - --fa: "\e7a5"; -} - -.fa-circle-user-circle-xmark { - --fa: "\e7a6"; -} - -.fa-circle-user-clock { - --fa: "\e7a7"; -} - -.fa-user-beard { - --fa: "\e7a8"; -} - -.fa-user-chef-hair-long { - --fa: "\e7a9"; -} - -.fa-user-circle-minus { - --fa: "\e7aa"; -} - -.fa-user-circle-plus { - --fa: "\e7ab"; -} - -.fa-user-dashed { - --fa: "\e7ac"; -} - -.fa-user-doctor-hair-mullet { - --fa: "\e7ad"; -} - -.fa-user-hat-tie { - --fa: "\e7ae"; -} - -.fa-user-hat-tie-magnifying-glass { - --fa: "\e7af"; -} - -.fa-user-key { - --fa: "\e7b0"; -} - -.fa-user-message { - --fa: "\e7b1"; -} - -.fa-user-microphone { - --fa: "\e7b2"; -} - -.fa-user-pilot-hair-long { - --fa: "\e7b3"; -} - -.fa-user-pilot-tie-hair-long { - --fa: "\e7b4"; -} - -.fa-user-police-hair-long { - --fa: "\e7b5"; -} - -.fa-user-police-tie-hair-long { - --fa: "\e7b6"; -} - -.fa-user-question { - --fa: "\e7b7"; -} - -.fa-user-sith { - --fa: "\e7b8"; -} - -.fa-user-tie-hair-mullet { - --fa: "\e7b9"; -} - -.fa-user-vneck-hair-mullet { - --fa: "\e7ba"; -} - -.fa-plane-flying { - --fa: "\e7bb"; -} - -.fa-plane-landing-gear { - --fa: "\e7bc"; -} - -.fa-rocket-vertical { - --fa: "\e7bd"; -} - -.fa-seat-airline-window { - --fa: "\e7be"; -} - -.fa-shuttle-space-vertical { - --fa: "\e7bf"; -} - -.fa-car-key { - --fa: "\e7c0"; -} - -.fa-car-siren { - --fa: "\e7c1"; -} - -.fa-car-siren-on { - --fa: "\e7c2"; -} - -.fa-scooter { - --fa: "\e7c3"; -} - -.fa-snowmobile-blank { - --fa: "\e7c4"; -} - -.fa-stair-car { - --fa: "\e7c5"; -} - -.fa-truck-suv { - --fa: "\e7c6"; -} - -.fa-unicycle { - --fa: "\e7c7"; -} - -.fa-van { - --fa: "\e7c8"; -} - -.fa-moon-star { - --fa: "\e7c9"; -} - -.fa-rainbow-half { - --fa: "\e7ca"; -} - -.fa-temperature-slash { - --fa: "\e7cb"; -} - -.fa-dialpad { - --fa: "\e7cc"; -} - -.fa-computer-mouse-button-left { - --fa: "\e7cd"; -} - -.fa-computer-mouse-button-right { - --fa: "\e7ce"; -} - -.fa-dot { - --fa: "\e7d1"; -} - -.fa-folder-arrow-left { - --fa: "\e7d2"; -} - -.fa-folder-arrow-right { - --fa: "\e7d3"; -} - -.fa-wireless { - --fa: "\e7df"; -} - -.fa-circle-moon { - --fa: "\e7e0"; -} - -.fa-person-meditating { - --fa: "\e7e1"; -} - -.fa-baseball-bat { - --fa: "\e7e5"; -} - -.fa-hockey-stick { - --fa: "\e7e6"; -} - -.fa-arrow-u-turn-down-left { - --fa: "\e7e7"; -} - -.fa-arrow-u-turn-down-right { - --fa: "\e7e8"; -} - -.fa-arrow-u-turn-left-down { - --fa: "\e7e9"; -} - -.fa-arrow-u-turn-left-up { - --fa: "\e7ea"; -} - -.fa-arrow-u-turn-right-down { - --fa: "\e7eb"; -} - -.fa-arrow-u-turn-right-up { - --fa: "\e7ec"; -} - -.fa-arrow-u-turn-up-left { - --fa: "\e7ed"; -} - -.fa-arrow-u-turn-up-right { - --fa: "\e7ee"; -} - -.fa-u-turn-down-left { - --fa: "\e7ef"; -} - -.fa-u-turn-down-right { - --fa: "\e7f0"; -} - -.fa-u-turn-left-down { - --fa: "\e7f1"; -} - -.fa-u-turn { - --fa: "\e7f1"; -} - -.fa-u-turn-left-up { - --fa: "\e7f2"; -} - -.fa-u-turn-right-down { - --fa: "\e7f3"; -} - -.fa-u-turn-right-up { - --fa: "\e7f4"; -} - -.fa-u-turn-up-left { - --fa: "\e7f5"; -} - -.fa-u-turn-up-right { - --fa: "\e7f6"; -} - -.fa-triple-chevrons-down { - --fa: "\e7f7"; -} - -.fa-triple-chevrons-left { - --fa: "\e7f8"; -} - -.fa-triple-chevrons-right { - --fa: "\e7f9"; -} - -.fa-triple-chevrons-up { - --fa: "\e7fa"; -} - -.fa-file-aiff { - --fa: "\e7fb"; -} - -.fa-file-odf { - --fa: "\e7fc"; -} - -.fa-file-tex { - --fa: "\e7fd"; -} - -.fa-file-wav { - --fa: "\e7fe"; -} - -.fa-droplet-plus { - --fa: "\e800"; -} - -.fa-hand-holding-star { - --fa: "\e801"; -} - -.fa-transmission { - --fa: "\e802"; -} - -.fa-alarm-minus { - --fa: "\e803"; -} - -.fa-file-brackets-curly { - --fa: "\e804"; -} - -.fa-file-midi { - --fa: "\e805"; -} - -.fa-midi { - --fa: "\e806"; -} - -.fa-non-binary { - --fa: "\e807"; -} - -.fa-rectangle-beta { - --fa: "\e808"; -} - -.fa-shield-user { - --fa: "\e809"; -} - -.fa-spiral { - --fa: "\e80a"; -} - -.fa-picture-in-picture { - --fa: "\e80b"; -} - -.fa-circle-half-horizontal { - --fa: "\e80c"; -} - -.fa-circle-half-stroke-horizontal { - --fa: "\e80d"; -} - -.fa-square-half-horizontal { - --fa: "\e80e"; -} - -.fa-square-half-stroke-horizontal { - --fa: "\e80f"; -} - -.fa-ship-large { - --fa: "\e810"; -} - -.fa-butterfly { - --fa: "\e811"; -} - -.fa-mobile-rotate { - --fa: "\e813"; -} - -.fa-mobile-rotate-reverse { - --fa: "\e814"; -} - -.fa-mobile-slash { - --fa: "\e815"; -} - -.fa-mobile-vibrate { - --fa: "\e816"; -} - -.fa-mobile-vibrate-slash { - --fa: "\e817"; -} - -.fa-almost-equal-to { - --fa: "\e818"; -} - -.fa-sneaker-running { - --fa: "\e819"; -} - -.fa-horseshoe { - --fa: "\e81a"; -} - -.fa-single-quote-left { - --fa: "\e81b"; -} - -.fa-single-quote-right { - --fa: "\e81c"; -} - -.fa-bus-side { - --fa: "\e81d"; -} - -.fa-bus-stop { - --fa: "\e81e"; -} - -.fa-train-stop { - --fa: "\e81f"; -} - -.fa-septagon { - --fa: "\e820"; -} - -.fa-heptagon { - --fa: "\e820"; -} - -.fa-mailbox-open-empty { - --fa: "\e821"; -} - -.fa-mailbox-open-letter { - --fa: "\e823"; -} - -.fa-lychee { - --fa: "\e824"; -} - -.fa-tank-recovery { - --fa: "\e825"; -} - -.fa-transducer { - --fa: "\e826"; -} - -.fa-box-arrow-up { - --fa: "\e827"; -} - -.fa-box-magnifying-glass { - --fa: "\e828"; -} - -.fa-envelope-ribbon { - --fa: "\e829"; -} - -.fa-envelope-certificate { - --fa: "\e829"; -} - -.fa-water-temperature { - --fa: "\e82a"; -} - -.fa-water-temp { - --fa: "\e82a"; -} - -.fa-aeropress { - --fa: "\e82b"; -} - -.fa-caret-large-down { - --fa: "\e82c"; -} - -.fa-caret-large-left { - --fa: "\e82d"; -} - -.fa-caret-large-right { - --fa: "\e82e"; -} - -.fa-caret-large-up { - --fa: "\e82f"; -} - -.fa-chemex { - --fa: "\e830"; -} - -.fa-hand-shaka { - --fa: "\e831"; -} - -.fa-kettlebell { - --fa: "\e832"; -} - -.fa-foot-wing { - --fa: "\e834"; -} - -.fa-pump-impeller { - --fa: "\e835"; -} - -.fa-arrow-rotate-left-10 { - --fa: "\e836"; -} - -.fa-arrow-rotate-right-10 { - --fa: "\e837"; -} - -.fa-martini-glass-empty { - --fa: "\f000"; -} - -.fa-glass-martini { - --fa: "\f000"; -} - -.fa-music { - --fa: "\f001"; -} - -.fa-magnifying-glass { - --fa: "\f002"; -} - -.fa-search { - --fa: "\f002"; -} - -.fa-heart { - --fa: "\f004"; -} - -.fa-star { - --fa: "\f005"; -} - -.fa-user { - --fa: "\f007"; -} - -.fa-user-alt { - --fa: "\f007"; -} - -.fa-user-large { - --fa: "\f007"; -} - -.fa-film { - --fa: "\f008"; -} - -.fa-film-alt { - --fa: "\f008"; -} - -.fa-film-simple { - --fa: "\f008"; -} - -.fa-table-cells-large { - --fa: "\f009"; -} - -.fa-th-large { - --fa: "\f009"; -} - -.fa-table-cells { - --fa: "\f00a"; -} - -.fa-th { - --fa: "\f00a"; -} - -.fa-table-list { - --fa: "\f00b"; -} - -.fa-th-list { - --fa: "\f00b"; -} - -.fa-check { - --fa: "\f00c"; -} - -.fa-xmark { - --fa: "\f00d"; -} - -.fa-close { - --fa: "\f00d"; -} - -.fa-multiply { - --fa: "\f00d"; -} - -.fa-remove { - --fa: "\f00d"; -} - -.fa-times { - --fa: "\f00d"; -} - -.fa-magnifying-glass-plus { - --fa: "\f00e"; -} - -.fa-search-plus { - --fa: "\f00e"; -} - -.fa-magnifying-glass-minus { - --fa: "\f010"; -} - -.fa-search-minus { - --fa: "\f010"; -} - -.fa-power-off { - --fa: "\f011"; -} - -.fa-signal { - --fa: "\f012"; -} - -.fa-signal-5 { - --fa: "\f012"; -} - -.fa-signal-perfect { - --fa: "\f012"; -} - -.fa-gear { - --fa: "\f013"; -} - -.fa-cog { - --fa: "\f013"; -} - -.fa-house { - --fa: "\f015"; -} - -.fa-home { - --fa: "\f015"; -} - -.fa-home-alt { - --fa: "\f015"; -} - -.fa-home-lg-alt { - --fa: "\f015"; -} - -.fa-clock { - --fa: "\f017"; -} - -.fa-clock-four { - --fa: "\f017"; -} - -.fa-road { - --fa: "\f018"; -} - -.fa-download { - --fa: "\f019"; -} - -.fa-inbox { - --fa: "\f01c"; -} - -.fa-arrow-rotate-right { - --fa: "\f01e"; -} - -.fa-arrow-right-rotate { - --fa: "\f01e"; -} - -.fa-arrow-rotate-forward { - --fa: "\f01e"; -} - -.fa-redo { - --fa: "\f01e"; -} - -.fa-arrows-rotate { - --fa: "\f021"; -} - -.fa-refresh { - --fa: "\f021"; -} - -.fa-sync { - --fa: "\f021"; -} - -.fa-rectangle-list { - --fa: "\f022"; -} - -.fa-list-alt { - --fa: "\f022"; -} - -.fa-lock { - --fa: "\f023"; -} - -.fa-flag { - --fa: "\f024"; -} - -.fa-headphones { - --fa: "\f025"; -} - -.fa-headphones-alt { - --fa: "\f025"; -} - -.fa-headphones-simple { - --fa: "\f025"; -} - -.fa-volume-off { - --fa: "\f026"; -} - -.fa-volume-low { - --fa: "\f027"; -} - -.fa-volume-down { - --fa: "\f027"; -} - -.fa-volume-high { - --fa: "\f028"; -} - -.fa-volume-up { - --fa: "\f028"; -} - -.fa-qrcode { - --fa: "\f029"; -} - -.fa-barcode { - --fa: "\f02a"; -} - -.fa-tag { - --fa: "\f02b"; -} - -.fa-tags { - --fa: "\f02c"; -} - -.fa-book { - --fa: "\f02d"; -} - -.fa-bookmark { - --fa: "\f02e"; -} - -.fa-print { - --fa: "\f02f"; -} - -.fa-camera { - --fa: "\f030"; -} - -.fa-camera-alt { - --fa: "\f030"; -} - -.fa-font { - --fa: "\f031"; -} - -.fa-bold { - --fa: "\f032"; -} - -.fa-italic { - --fa: "\f033"; -} - -.fa-text-height { - --fa: "\f034"; -} - -.fa-text-width { - --fa: "\f035"; -} - -.fa-align-left { - --fa: "\f036"; -} - -.fa-align-center { - --fa: "\f037"; -} - -.fa-align-right { - --fa: "\f038"; -} - -.fa-align-justify { - --fa: "\f039"; -} - -.fa-list { - --fa: "\f03a"; -} - -.fa-list-squares { - --fa: "\f03a"; -} - -.fa-outdent { - --fa: "\f03b"; -} - -.fa-dedent { - --fa: "\f03b"; -} - -.fa-indent { - --fa: "\f03c"; -} - -.fa-video { - --fa: "\f03d"; -} - -.fa-video-camera { - --fa: "\f03d"; -} - -.fa-image { - --fa: "\f03e"; -} - -.fa-location-pin { - --fa: "\f041"; -} - -.fa-map-marker { - --fa: "\f041"; -} - -.fa-circle-half-stroke { - --fa: "\f042"; -} - -.fa-adjust { - --fa: "\f042"; -} - -.fa-droplet { - --fa: "\f043"; -} - -.fa-tint { - --fa: "\f043"; -} - -.fa-pen-to-square { - --fa: "\f044"; -} - -.fa-edit { - --fa: "\f044"; -} - -.fa-arrows-up-down-left-right { - --fa: "\f047"; -} - -.fa-arrows { - --fa: "\f047"; -} - -.fa-backward-step { - --fa: "\f048"; -} - -.fa-step-backward { - --fa: "\f048"; -} - -.fa-backward-fast { - --fa: "\f049"; -} - -.fa-fast-backward { - --fa: "\f049"; -} - -.fa-backward { - --fa: "\f04a"; -} - -.fa-play { - --fa: "\f04b"; -} - -.fa-pause { - --fa: "\f04c"; -} - -.fa-stop { - --fa: "\f04d"; -} - -.fa-forward { - --fa: "\f04e"; -} - -.fa-forward-fast { - --fa: "\f050"; -} - -.fa-fast-forward { - --fa: "\f050"; -} - -.fa-forward-step { - --fa: "\f051"; -} - -.fa-step-forward { - --fa: "\f051"; -} - -.fa-eject { - --fa: "\f052"; -} - -.fa-chevron-left { - --fa: "\f053"; -} - -.fa-chevron-right { - --fa: "\f054"; -} - -.fa-circle-plus { - --fa: "\f055"; -} - -.fa-plus-circle { - --fa: "\f055"; -} - -.fa-circle-minus { - --fa: "\f056"; -} - -.fa-minus-circle { - --fa: "\f056"; -} - -.fa-circle-xmark { - --fa: "\f057"; -} - -.fa-times-circle { - --fa: "\f057"; -} - -.fa-xmark-circle { - --fa: "\f057"; -} - -.fa-circle-check { - --fa: "\f058"; -} - -.fa-check-circle { - --fa: "\f058"; -} - -.fa-circle-question { - --fa: "\f059"; -} - -.fa-question-circle { - --fa: "\f059"; -} - -.fa-circle-info { - --fa: "\f05a"; -} - -.fa-info-circle { - --fa: "\f05a"; -} - -.fa-crosshairs { - --fa: "\f05b"; -} - -.fa-ban { - --fa: "\f05e"; -} - -.fa-cancel { - --fa: "\f05e"; -} - -.fa-arrow-left { - --fa: "\f060"; -} - -.fa-arrow-right { - --fa: "\f061"; -} - -.fa-arrow-up { - --fa: "\f062"; -} - -.fa-arrow-down { - --fa: "\f063"; -} - -.fa-share { - --fa: "\f064"; -} - -.fa-mail-forward { - --fa: "\f064"; -} - -.fa-expand { - --fa: "\f065"; -} - -.fa-compress { - --fa: "\f066"; -} - -.fa-minus { - --fa: "\f068"; -} - -.fa-subtract { - --fa: "\f068"; -} - -.fa-circle-exclamation { - --fa: "\f06a"; -} - -.fa-exclamation-circle { - --fa: "\f06a"; -} - -.fa-gift { - --fa: "\f06b"; -} - -.fa-leaf { - --fa: "\f06c"; -} - -.fa-fire { - --fa: "\f06d"; -} - -.fa-eye { - --fa: "\f06e"; -} - -.fa-eye-slash { - --fa: "\f070"; -} - -.fa-triangle-exclamation { - --fa: "\f071"; -} - -.fa-exclamation-triangle { - --fa: "\f071"; -} - -.fa-warning { - --fa: "\f071"; -} - -.fa-plane { - --fa: "\f072"; -} - -.fa-calendar-days { - --fa: "\f073"; -} - -.fa-calendar-alt { - --fa: "\f073"; -} - -.fa-shuffle { - --fa: "\f074"; -} - -.fa-random { - --fa: "\f074"; -} - -.fa-comment { - --fa: "\f075"; -} - -.fa-magnet { - --fa: "\f076"; -} - -.fa-chevron-up { - --fa: "\f077"; -} - -.fa-chevron-down { - --fa: "\f078"; -} - -.fa-retweet { - --fa: "\f079"; -} - -.fa-cart-shopping { - --fa: "\f07a"; -} - -.fa-shopping-cart { - --fa: "\f07a"; -} - -.fa-folder { - --fa: "\f07b"; -} - -.fa-folder-blank { - --fa: "\f07b"; -} - -.fa-folder-open { - --fa: "\f07c"; -} - -.fa-arrows-up-down { - --fa: "\f07d"; -} - -.fa-arrows-v { - --fa: "\f07d"; -} - -.fa-arrows-left-right { - --fa: "\f07e"; -} - -.fa-arrows-h { - --fa: "\f07e"; -} - -.fa-chart-bar { - --fa: "\f080"; -} - -.fa-bar-chart { - --fa: "\f080"; -} - -.fa-camera-retro { - --fa: "\f083"; -} - -.fa-key { - --fa: "\f084"; -} - -.fa-gears { - --fa: "\f085"; -} - -.fa-cogs { - --fa: "\f085"; -} - -.fa-comments { - --fa: "\f086"; -} - -.fa-star-half { - --fa: "\f089"; -} - -.fa-arrow-right-from-bracket { - --fa: "\f08b"; -} - -.fa-sign-out { - --fa: "\f08b"; -} - -.fa-thumbtack { - --fa: "\f08d"; -} - -.fa-thumb-tack { - --fa: "\f08d"; -} - -.fa-arrow-up-right-from-square { - --fa: "\f08e"; -} - -.fa-external-link { - --fa: "\f08e"; -} - -.fa-arrow-right-to-bracket { - --fa: "\f090"; -} - -.fa-sign-in { - --fa: "\f090"; -} - -.fa-trophy { - --fa: "\f091"; -} - -.fa-upload { - --fa: "\f093"; -} - -.fa-lemon { - --fa: "\f094"; -} - -.fa-phone { - --fa: "\f095"; -} - -.fa-square-phone { - --fa: "\f098"; -} - -.fa-phone-square { - --fa: "\f098"; -} - -.fa-unlock { - --fa: "\f09c"; -} - -.fa-credit-card { - --fa: "\f09d"; -} - -.fa-credit-card-alt { - --fa: "\f09d"; -} - -.fa-rss { - --fa: "\f09e"; -} - -.fa-feed { - --fa: "\f09e"; -} - -.fa-hard-drive { - --fa: "\f0a0"; -} - -.fa-hdd { - --fa: "\f0a0"; -} - -.fa-bullhorn { - --fa: "\f0a1"; -} - -.fa-certificate { - --fa: "\f0a3"; -} - -.fa-hand-point-right { - --fa: "\f0a4"; -} - -.fa-hand-point-left { - --fa: "\f0a5"; -} - -.fa-hand-point-up { - --fa: "\f0a6"; -} - -.fa-hand-point-down { - --fa: "\f0a7"; -} - -.fa-circle-arrow-left { - --fa: "\f0a8"; -} - -.fa-arrow-circle-left { - --fa: "\f0a8"; -} - -.fa-circle-arrow-right { - --fa: "\f0a9"; -} - -.fa-arrow-circle-right { - --fa: "\f0a9"; -} - -.fa-circle-arrow-up { - --fa: "\f0aa"; -} - -.fa-arrow-circle-up { - --fa: "\f0aa"; -} - -.fa-circle-arrow-down { - --fa: "\f0ab"; -} - -.fa-arrow-circle-down { - --fa: "\f0ab"; -} - -.fa-globe { - --fa: "\f0ac"; -} - -.fa-wrench { - --fa: "\f0ad"; -} - -.fa-list-check { - --fa: "\f0ae"; -} - -.fa-tasks { - --fa: "\f0ae"; -} - -.fa-filter { - --fa: "\f0b0"; -} - -.fa-briefcase { - --fa: "\f0b1"; -} - -.fa-up-down-left-right { - --fa: "\f0b2"; -} - -.fa-arrows-alt { - --fa: "\f0b2"; -} - -.fa-users { - --fa: "\f0c0"; -} - -.fa-link { - --fa: "\f0c1"; -} - -.fa-chain { - --fa: "\f0c1"; -} - -.fa-cloud { - --fa: "\f0c2"; -} - -.fa-flask { - --fa: "\f0c3"; -} - -.fa-scissors { - --fa: "\f0c4"; -} - -.fa-cut { - --fa: "\f0c4"; -} - -.fa-copy { - --fa: "\f0c5"; -} - -.fa-paperclip { - --fa: "\f0c6"; -} - -.fa-floppy-disk { - --fa: "\f0c7"; -} - -.fa-save { - --fa: "\f0c7"; -} - -.fa-square { - --fa: "\f0c8"; -} - -.fa-bars { - --fa: "\f0c9"; -} - -.fa-navicon { - --fa: "\f0c9"; -} - -.fa-list-ul { - --fa: "\f0ca"; -} - -.fa-list-dots { - --fa: "\f0ca"; -} - -.fa-list-ol { - --fa: "\f0cb"; -} - -.fa-list-1-2 { - --fa: "\f0cb"; -} - -.fa-list-numeric { - --fa: "\f0cb"; -} - -.fa-strikethrough { - --fa: "\f0cc"; -} - -.fa-underline { - --fa: "\f0cd"; -} - -.fa-table { - --fa: "\f0ce"; -} - -.fa-wand-magic { - --fa: "\f0d0"; -} - -.fa-magic { - --fa: "\f0d0"; -} - -.fa-truck { - --fa: "\f0d1"; -} - -.fa-money-bill { - --fa: "\f0d6"; -} - -.fa-caret-down { - --fa: "\f0d7"; -} - -.fa-caret-up { - --fa: "\f0d8"; -} - -.fa-caret-left { - --fa: "\f0d9"; -} - -.fa-caret-right { - --fa: "\f0da"; -} - -.fa-table-columns { - --fa: "\f0db"; -} - -.fa-columns { - --fa: "\f0db"; -} - -.fa-sort { - --fa: "\f0dc"; -} - -.fa-unsorted { - --fa: "\f0dc"; -} - -.fa-sort-down { - --fa: "\f0dd"; -} - -.fa-sort-desc { - --fa: "\f0dd"; -} - -.fa-sort-up { - --fa: "\f0de"; -} - -.fa-sort-asc { - --fa: "\f0de"; -} - -.fa-envelope { - --fa: "\f0e0"; -} - -.fa-arrow-rotate-left { - --fa: "\f0e2"; -} - -.fa-arrow-left-rotate { - --fa: "\f0e2"; -} - -.fa-arrow-rotate-back { - --fa: "\f0e2"; -} - -.fa-arrow-rotate-backward { - --fa: "\f0e2"; -} - -.fa-undo { - --fa: "\f0e2"; -} - -.fa-gavel { - --fa: "\f0e3"; -} - -.fa-legal { - --fa: "\f0e3"; -} - -.fa-bolt { - --fa: "\f0e7"; -} - -.fa-zap { - --fa: "\f0e7"; -} - -.fa-sitemap { - --fa: "\f0e8"; -} - -.fa-umbrella { - --fa: "\f0e9"; -} - -.fa-paste { - --fa: "\f0ea"; -} - -.fa-file-clipboard { - --fa: "\f0ea"; -} - -.fa-lightbulb { - --fa: "\f0eb"; -} - -.fa-arrow-right-arrow-left { - --fa: "\f0ec"; -} - -.fa-exchange { - --fa: "\f0ec"; -} - -.fa-cloud-arrow-down { - --fa: "\f0ed"; -} - -.fa-cloud-download { - --fa: "\f0ed"; -} - -.fa-cloud-download-alt { - --fa: "\f0ed"; -} - -.fa-cloud-arrow-up { - --fa: "\f0ee"; -} - -.fa-cloud-upload { - --fa: "\f0ee"; -} - -.fa-cloud-upload-alt { - --fa: "\f0ee"; -} - -.fa-user-doctor { - --fa: "\f0f0"; -} - -.fa-user-md { - --fa: "\f0f0"; -} - -.fa-stethoscope { - --fa: "\f0f1"; -} - -.fa-suitcase { - --fa: "\f0f2"; -} - -.fa-bell { - --fa: "\f0f3"; -} - -.fa-mug-saucer { - --fa: "\f0f4"; -} - -.fa-coffee { - --fa: "\f0f4"; -} - -.fa-hospital { - --fa: "\f0f8"; -} - -.fa-hospital-alt { - --fa: "\f0f8"; -} - -.fa-hospital-wide { - --fa: "\f0f8"; -} - -.fa-truck-medical { - --fa: "\f0f9"; -} - -.fa-ambulance { - --fa: "\f0f9"; -} - -.fa-suitcase-medical { - --fa: "\f0fa"; -} - -.fa-medkit { - --fa: "\f0fa"; -} - -.fa-jet-fighter { - --fa: "\f0fb"; -} - -.fa-fighter-jet { - --fa: "\f0fb"; -} - -.fa-beer-mug-empty { - --fa: "\f0fc"; -} - -.fa-beer { - --fa: "\f0fc"; -} - -.fa-square-h { - --fa: "\f0fd"; -} - -.fa-h-square { - --fa: "\f0fd"; -} - -.fa-square-plus { - --fa: "\f0fe"; -} - -.fa-plus-square { - --fa: "\f0fe"; -} - -.fa-angles-left { - --fa: "\f100"; -} - -.fa-angle-double-left { - --fa: "\f100"; -} - -.fa-angles-right { - --fa: "\f101"; -} - -.fa-angle-double-right { - --fa: "\f101"; -} - -.fa-angles-up { - --fa: "\f102"; -} - -.fa-angle-double-up { - --fa: "\f102"; -} - -.fa-angles-down { - --fa: "\f103"; -} - -.fa-angle-double-down { - --fa: "\f103"; -} - -.fa-angle-left { - --fa: "\f104"; -} - -.fa-angle-right { - --fa: "\f105"; -} - -.fa-angle-up { - --fa: "\f106"; -} - -.fa-angle-down { - --fa: "\f107"; -} - -.fa-laptop { - --fa: "\f109"; -} - -.fa-tablet-button { - --fa: "\f10a"; -} - -.fa-mobile-button { - --fa: "\f10b"; -} - -.fa-quote-left { - --fa: "\f10d"; -} - -.fa-quote-left-alt { - --fa: "\f10d"; -} - -.fa-quote-right { - --fa: "\f10e"; -} - -.fa-quote-right-alt { - --fa: "\f10e"; -} - -.fa-spinner { - --fa: "\f110"; -} - -.fa-circle { - --fa: "\f111"; -} - -.fa-face-smile { - --fa: "\f118"; -} - -.fa-smile { - --fa: "\f118"; -} - -.fa-face-frown { - --fa: "\f119"; -} - -.fa-frown { - --fa: "\f119"; -} - -.fa-face-meh { - --fa: "\f11a"; -} - -.fa-meh { - --fa: "\f11a"; -} - -.fa-gamepad { - --fa: "\f11b"; -} - -.fa-keyboard { - --fa: "\f11c"; -} - -.fa-flag-checkered { - --fa: "\f11e"; -} - -.fa-terminal { - --fa: "\f120"; -} - -.fa-code { - --fa: "\f121"; -} - -.fa-reply-all { - --fa: "\f122"; -} - -.fa-mail-reply-all { - --fa: "\f122"; -} - -.fa-location-arrow { - --fa: "\f124"; -} - -.fa-crop { - --fa: "\f125"; -} - -.fa-code-branch { - --fa: "\f126"; -} - -.fa-link-slash { - --fa: "\f127"; -} - -.fa-chain-broken { - --fa: "\f127"; -} - -.fa-chain-slash { - --fa: "\f127"; -} - -.fa-unlink { - --fa: "\f127"; -} - -.fa-info { - --fa: "\f129"; -} - -.fa-superscript { - --fa: "\f12b"; -} - -.fa-subscript { - --fa: "\f12c"; -} - -.fa-eraser { - --fa: "\f12d"; -} - -.fa-puzzle-piece { - --fa: "\f12e"; -} - -.fa-microphone { - --fa: "\f130"; -} - -.fa-microphone-slash { - --fa: "\f131"; -} - -.fa-shield { - --fa: "\f132"; -} - -.fa-shield-blank { - --fa: "\f132"; -} - -.fa-calendar { - --fa: "\f133"; -} - -.fa-fire-extinguisher { - --fa: "\f134"; -} - -.fa-rocket { - --fa: "\f135"; -} - -.fa-circle-chevron-left { - --fa: "\f137"; -} - -.fa-chevron-circle-left { - --fa: "\f137"; -} - -.fa-circle-chevron-right { - --fa: "\f138"; -} - -.fa-chevron-circle-right { - --fa: "\f138"; -} - -.fa-circle-chevron-up { - --fa: "\f139"; -} - -.fa-chevron-circle-up { - --fa: "\f139"; -} - -.fa-circle-chevron-down { - --fa: "\f13a"; -} - -.fa-chevron-circle-down { - --fa: "\f13a"; -} - -.fa-anchor { - --fa: "\f13d"; -} - -.fa-unlock-keyhole { - --fa: "\f13e"; -} - -.fa-unlock-alt { - --fa: "\f13e"; -} - -.fa-bullseye { - --fa: "\f140"; -} - -.fa-ellipsis { - --fa: "\f141"; -} - -.fa-ellipsis-h { - --fa: "\f141"; -} - -.fa-ellipsis-vertical { - --fa: "\f142"; -} - -.fa-ellipsis-v { - --fa: "\f142"; -} - -.fa-square-rss { - --fa: "\f143"; -} - -.fa-rss-square { - --fa: "\f143"; -} - -.fa-circle-play { - --fa: "\f144"; -} - -.fa-play-circle { - --fa: "\f144"; -} - -.fa-ticket { - --fa: "\f145"; -} - -.fa-square-minus { - --fa: "\f146"; -} - -.fa-minus-square { - --fa: "\f146"; -} - -.fa-arrow-turn-up { - --fa: "\f148"; -} - -.fa-level-up { - --fa: "\f148"; -} - -.fa-arrow-turn-down { - --fa: "\f149"; -} - -.fa-level-down { - --fa: "\f149"; -} - -.fa-square-check { - --fa: "\f14a"; -} - -.fa-check-square { - --fa: "\f14a"; -} - -.fa-square-pen { - --fa: "\f14b"; -} - -.fa-pen-square { - --fa: "\f14b"; -} - -.fa-pencil-square { - --fa: "\f14b"; -} - -.fa-square-arrow-up-right { - --fa: "\f14c"; -} - -.fa-external-link-square { - --fa: "\f14c"; -} - -.fa-share-from-square { - --fa: "\f14d"; -} - -.fa-share-square { - --fa: "\f14d"; -} - -.fa-compass { - --fa: "\f14e"; -} - -.fa-square-caret-down { - --fa: "\f150"; -} - -.fa-caret-square-down { - --fa: "\f150"; -} - -.fa-square-caret-up { - --fa: "\f151"; -} - -.fa-caret-square-up { - --fa: "\f151"; -} - -.fa-square-caret-right { - --fa: "\f152"; -} - -.fa-caret-square-right { - --fa: "\f152"; -} - -.fa-euro-sign { - --fa: "\f153"; -} - -.fa-eur { - --fa: "\f153"; -} - -.fa-euro { - --fa: "\f153"; -} - -.fa-sterling-sign { - --fa: "\f154"; -} - -.fa-gbp { - --fa: "\f154"; -} - -.fa-pound-sign { - --fa: "\f154"; -} - -.fa-rupee-sign { - --fa: "\f156"; -} - -.fa-rupee { - --fa: "\f156"; -} - -.fa-yen-sign { - --fa: "\f157"; -} - -.fa-cny { - --fa: "\f157"; -} - -.fa-jpy { - --fa: "\f157"; -} - -.fa-rmb { - --fa: "\f157"; -} - -.fa-yen { - --fa: "\f157"; -} - -.fa-ruble-sign { - --fa: "\f158"; -} - -.fa-rouble { - --fa: "\f158"; -} - -.fa-rub { - --fa: "\f158"; -} - -.fa-ruble { - --fa: "\f158"; -} - -.fa-won-sign { - --fa: "\f159"; -} - -.fa-krw { - --fa: "\f159"; -} - -.fa-won { - --fa: "\f159"; -} - -.fa-file { - --fa: "\f15b"; -} - -.fa-file-lines { - --fa: "\f15c"; -} - -.fa-file-alt { - --fa: "\f15c"; -} - -.fa-file-text { - --fa: "\f15c"; -} - -.fa-arrow-down-a-z { - --fa: "\f15d"; -} - -.fa-sort-alpha-asc { - --fa: "\f15d"; -} - -.fa-sort-alpha-down { - --fa: "\f15d"; -} - -.fa-arrow-up-a-z { - --fa: "\f15e"; -} - -.fa-sort-alpha-up { - --fa: "\f15e"; -} - -.fa-arrow-down-wide-short { - --fa: "\f160"; -} - -.fa-sort-amount-asc { - --fa: "\f160"; -} - -.fa-sort-amount-down { - --fa: "\f160"; -} - -.fa-arrow-up-wide-short { - --fa: "\f161"; -} - -.fa-sort-amount-up { - --fa: "\f161"; -} - -.fa-arrow-down-1-9 { - --fa: "\f162"; -} - -.fa-sort-numeric-asc { - --fa: "\f162"; -} - -.fa-sort-numeric-down { - --fa: "\f162"; -} - -.fa-arrow-up-1-9 { - --fa: "\f163"; -} - -.fa-sort-numeric-up { - --fa: "\f163"; -} - -.fa-thumbs-up { - --fa: "\f164"; -} - -.fa-thumbs-down { - --fa: "\f165"; -} - -.fa-arrow-down-long { - --fa: "\f175"; -} - -.fa-long-arrow-down { - --fa: "\f175"; -} - -.fa-arrow-up-long { - --fa: "\f176"; -} - -.fa-long-arrow-up { - --fa: "\f176"; -} - -.fa-arrow-left-long { - --fa: "\f177"; -} - -.fa-long-arrow-left { - --fa: "\f177"; -} - -.fa-arrow-right-long { - --fa: "\f178"; -} - -.fa-long-arrow-right { - --fa: "\f178"; -} - -.fa-person-dress { - --fa: "\f182"; -} - -.fa-female { - --fa: "\f182"; -} - -.fa-person { - --fa: "\f183"; -} - -.fa-male { - --fa: "\f183"; -} - -.fa-sun { - --fa: "\f185"; -} - -.fa-moon { - --fa: "\f186"; -} - -.fa-box-archive { - --fa: "\f187"; -} - -.fa-archive { - --fa: "\f187"; -} - -.fa-bug { - --fa: "\f188"; -} - -.fa-square-caret-left { - --fa: "\f191"; -} - -.fa-caret-square-left { - --fa: "\f191"; -} - -.fa-circle-dot { - --fa: "\f192"; -} - -.fa-dot-circle { - --fa: "\f192"; -} - -.fa-wheelchair { - --fa: "\f193"; -} - -.fa-lira-sign { - --fa: "\f195"; -} - -.fa-shuttle-space { - --fa: "\f197"; -} - -.fa-space-shuttle { - --fa: "\f197"; -} - -.fa-square-envelope { - --fa: "\f199"; -} - -.fa-envelope-square { - --fa: "\f199"; -} - -.fa-building-columns { - --fa: "\f19c"; -} - -.fa-bank { - --fa: "\f19c"; -} - -.fa-institution { - --fa: "\f19c"; -} - -.fa-museum { - --fa: "\f19c"; -} - -.fa-university { - --fa: "\f19c"; -} - -.fa-graduation-cap { - --fa: "\f19d"; -} - -.fa-mortar-board { - --fa: "\f19d"; -} - -.fa-language { - --fa: "\f1ab"; -} - -.fa-fax { - --fa: "\f1ac"; -} - -.fa-building { - --fa: "\f1ad"; -} - -.fa-child { - --fa: "\f1ae"; -} - -.fa-paw { - --fa: "\f1b0"; -} - -.fa-cube { - --fa: "\f1b2"; -} - -.fa-cubes { - --fa: "\f1b3"; -} - -.fa-recycle { - --fa: "\f1b8"; -} - -.fa-car { - --fa: "\f1b9"; -} - -.fa-automobile { - --fa: "\f1b9"; -} - -.fa-taxi { - --fa: "\f1ba"; -} - -.fa-cab { - --fa: "\f1ba"; -} - -.fa-tree { - --fa: "\f1bb"; -} - -.fa-database { - --fa: "\f1c0"; -} - -.fa-file-pdf { - --fa: "\f1c1"; -} - -.fa-file-word { - --fa: "\f1c2"; -} - -.fa-file-excel { - --fa: "\f1c3"; -} - -.fa-file-powerpoint { - --fa: "\f1c4"; -} - -.fa-file-image { - --fa: "\f1c5"; -} - -.fa-file-zipper { - --fa: "\f1c6"; -} - -.fa-file-archive { - --fa: "\f1c6"; -} - -.fa-file-audio { - --fa: "\f1c7"; -} - -.fa-file-video { - --fa: "\f1c8"; -} - -.fa-file-code { - --fa: "\f1c9"; -} - -.fa-life-ring { - --fa: "\f1cd"; -} - -.fa-circle-notch { - --fa: "\f1ce"; -} - -.fa-paper-plane { - --fa: "\f1d8"; -} - -.fa-clock-rotate-left { - --fa: "\f1da"; -} - -.fa-history { - --fa: "\f1da"; -} - -.fa-heading { - --fa: "\f1dc"; -} - -.fa-header { - --fa: "\f1dc"; -} - -.fa-paragraph { - --fa: "\f1dd"; -} - -.fa-sliders { - --fa: "\f1de"; -} - -.fa-sliders-h { - --fa: "\f1de"; -} - -.fa-share-nodes { - --fa: "\f1e0"; -} - -.fa-share-alt { - --fa: "\f1e0"; -} - -.fa-square-share-nodes { - --fa: "\f1e1"; -} - -.fa-share-alt-square { - --fa: "\f1e1"; -} - -.fa-bomb { - --fa: "\f1e2"; -} - -.fa-futbol { - --fa: "\f1e3"; -} - -.fa-futbol-ball { - --fa: "\f1e3"; -} - -.fa-soccer-ball { - --fa: "\f1e3"; -} - -.fa-tty { - --fa: "\f1e4"; -} - -.fa-teletype { - --fa: "\f1e4"; -} - -.fa-binoculars { - --fa: "\f1e5"; -} - -.fa-plug { - --fa: "\f1e6"; -} - -.fa-newspaper { - --fa: "\f1ea"; -} - -.fa-wifi { - --fa: "\f1eb"; -} - -.fa-wifi-3 { - --fa: "\f1eb"; -} - -.fa-wifi-strong { - --fa: "\f1eb"; -} - -.fa-calculator { - --fa: "\f1ec"; -} - -.fa-bell-slash { - --fa: "\f1f6"; -} - -.fa-trash { - --fa: "\f1f8"; -} - -.fa-copyright { - --fa: "\f1f9"; -} - -.fa-eye-dropper { - --fa: "\f1fb"; -} - -.fa-eye-dropper-empty { - --fa: "\f1fb"; -} - -.fa-eyedropper { - --fa: "\f1fb"; -} - -.fa-paintbrush { - --fa: "\f1fc"; -} - -.fa-paint-brush { - --fa: "\f1fc"; -} - -.fa-cake-candles { - --fa: "\f1fd"; -} - -.fa-birthday-cake { - --fa: "\f1fd"; -} - -.fa-cake { - --fa: "\f1fd"; -} - -.fa-chart-area { - --fa: "\f1fe"; -} - -.fa-area-chart { - --fa: "\f1fe"; -} - -.fa-chart-pie { - --fa: "\f200"; -} - -.fa-pie-chart { - --fa: "\f200"; -} - -.fa-chart-line { - --fa: "\f201"; -} - -.fa-line-chart { - --fa: "\f201"; -} - -.fa-toggle-off { - --fa: "\f204"; -} - -.fa-toggle-on { - --fa: "\f205"; -} - -.fa-bicycle { - --fa: "\f206"; -} - -.fa-bus { - --fa: "\f207"; -} - -.fa-closed-captioning { - --fa: "\f20a"; -} - -.fa-shekel-sign { - --fa: "\f20b"; -} - -.fa-ils { - --fa: "\f20b"; -} - -.fa-shekel { - --fa: "\f20b"; -} - -.fa-sheqel { - --fa: "\f20b"; -} - -.fa-sheqel-sign { - --fa: "\f20b"; -} - -.fa-cart-plus { - --fa: "\f217"; -} - -.fa-cart-arrow-down { - --fa: "\f218"; -} - -.fa-diamond { - --fa: "\f219"; -} - -.fa-ship { - --fa: "\f21a"; -} - -.fa-user-secret { - --fa: "\f21b"; -} - -.fa-motorcycle { - --fa: "\f21c"; -} - -.fa-street-view { - --fa: "\f21d"; -} - -.fa-heart-pulse { - --fa: "\f21e"; -} - -.fa-heartbeat { - --fa: "\f21e"; -} - -.fa-venus { - --fa: "\f221"; -} - -.fa-mars { - --fa: "\f222"; -} - -.fa-mercury { - --fa: "\f223"; -} - -.fa-mars-and-venus { - --fa: "\f224"; -} - -.fa-transgender { - --fa: "\f225"; -} - -.fa-transgender-alt { - --fa: "\f225"; -} - -.fa-venus-double { - --fa: "\f226"; -} - -.fa-mars-double { - --fa: "\f227"; -} - -.fa-venus-mars { - --fa: "\f228"; -} - -.fa-mars-stroke { - --fa: "\f229"; -} - -.fa-mars-stroke-up { - --fa: "\f22a"; -} - -.fa-mars-stroke-v { - --fa: "\f22a"; -} - -.fa-mars-stroke-right { - --fa: "\f22b"; -} - -.fa-mars-stroke-h { - --fa: "\f22b"; -} - -.fa-neuter { - --fa: "\f22c"; -} - -.fa-genderless { - --fa: "\f22d"; -} - -.fa-server { - --fa: "\f233"; -} - -.fa-user-plus { - --fa: "\f234"; -} - -.fa-user-xmark { - --fa: "\f235"; -} - -.fa-user-times { - --fa: "\f235"; -} - -.fa-bed { - --fa: "\f236"; -} - -.fa-train { - --fa: "\f238"; -} - -.fa-train-subway { - --fa: "\f239"; -} - -.fa-subway { - --fa: "\f239"; -} - -.fa-battery-full { - --fa: "\f240"; -} - -.fa-battery { - --fa: "\f240"; -} - -.fa-battery-5 { - --fa: "\f240"; -} - -.fa-battery-three-quarters { - --fa: "\f241"; -} - -.fa-battery-4 { - --fa: "\f241"; -} - -.fa-battery-half { - --fa: "\f242"; -} - -.fa-battery-3 { - --fa: "\f242"; -} - -.fa-battery-quarter { - --fa: "\f243"; -} - -.fa-battery-2 { - --fa: "\f243"; -} - -.fa-battery-empty { - --fa: "\f244"; -} - -.fa-battery-0 { - --fa: "\f244"; -} - -.fa-arrow-pointer { - --fa: "\f245"; -} - -.fa-mouse-pointer { - --fa: "\f245"; -} - -.fa-i-cursor { - --fa: "\f246"; -} - -.fa-object-group { - --fa: "\f247"; -} - -.fa-object-ungroup { - --fa: "\f248"; -} - -.fa-note-sticky { - --fa: "\f249"; -} - -.fa-sticky-note { - --fa: "\f249"; -} - -.fa-clone { - --fa: "\f24d"; -} - -.fa-scale-balanced { - --fa: "\f24e"; -} - -.fa-balance-scale { - --fa: "\f24e"; -} - -.fa-hourglass-start { - --fa: "\f251"; -} - -.fa-hourglass-1 { - --fa: "\f251"; -} - -.fa-hourglass-half { - --fa: "\f252"; -} - -.fa-hourglass-2 { - --fa: "\f252"; -} - -.fa-hourglass-end { - --fa: "\f253"; -} - -.fa-hourglass-3 { - --fa: "\f253"; -} - -.fa-hourglass { - --fa: "\f254"; -} - -.fa-hourglass-empty { - --fa: "\f254"; -} - -.fa-hand-back-fist { - --fa: "\f255"; -} - -.fa-hand-rock { - --fa: "\f255"; -} - -.fa-hand { - --fa: "\f256"; -} - -.fa-hand-paper { - --fa: "\f256"; -} - -.fa-hand-scissors { - --fa: "\f257"; -} - -.fa-hand-lizard { - --fa: "\f258"; -} - -.fa-hand-spock { - --fa: "\f259"; -} - -.fa-hand-pointer { - --fa: "\f25a"; -} - -.fa-hand-peace { - --fa: "\f25b"; -} - -.fa-trademark { - --fa: "\f25c"; -} - -.fa-registered { - --fa: "\f25d"; -} - -.fa-tv { - --fa: "\f26c"; -} - -.fa-television { - --fa: "\f26c"; -} - -.fa-tv-alt { - --fa: "\f26c"; -} - -.fa-calendar-plus { - --fa: "\f271"; -} - -.fa-calendar-minus { - --fa: "\f272"; -} - -.fa-calendar-xmark { - --fa: "\f273"; -} - -.fa-calendar-times { - --fa: "\f273"; -} - -.fa-calendar-check { - --fa: "\f274"; -} - -.fa-industry { - --fa: "\f275"; -} - -.fa-map-pin { - --fa: "\f276"; -} - -.fa-signs-post { - --fa: "\f277"; -} - -.fa-map-signs { - --fa: "\f277"; -} - -.fa-map { - --fa: "\f279"; -} - -.fa-message { - --fa: "\f27a"; -} - -.fa-comment-alt { - --fa: "\f27a"; -} - -.fa-circle-pause { - --fa: "\f28b"; -} - -.fa-pause-circle { - --fa: "\f28b"; -} - -.fa-circle-stop { - --fa: "\f28d"; -} - -.fa-stop-circle { - --fa: "\f28d"; -} - -.fa-bag-shopping { - --fa: "\f290"; -} - -.fa-shopping-bag { - --fa: "\f290"; -} - -.fa-basket-shopping { - --fa: "\f291"; -} - -.fa-shopping-basket { - --fa: "\f291"; -} - -.fa-bluetooth { - --fa: "\f293"; -} - -.fa-universal-access { - --fa: "\f29a"; -} - -.fa-person-walking-with-cane { - --fa: "\f29d"; -} - -.fa-blind { - --fa: "\f29d"; -} - -.fa-audio-description { - --fa: "\f29e"; -} - -.fa-phone-volume { - --fa: "\f2a0"; -} - -.fa-volume-control-phone { - --fa: "\f2a0"; -} - -.fa-braille { - --fa: "\f2a1"; -} - -.fa-ear-listen { - --fa: "\f2a2"; -} - -.fa-assistive-listening-systems { - --fa: "\f2a2"; -} - -.fa-hands-asl-interpreting { - --fa: "\f2a3"; -} - -.fa-american-sign-language-interpreting { - --fa: "\f2a3"; -} - -.fa-asl-interpreting { - --fa: "\f2a3"; -} - -.fa-hands-american-sign-language-interpreting { - --fa: "\f2a3"; -} - -.fa-ear-deaf { - --fa: "\f2a4"; -} - -.fa-deaf { - --fa: "\f2a4"; -} - -.fa-deafness { - --fa: "\f2a4"; -} - -.fa-hard-of-hearing { - --fa: "\f2a4"; -} - -.fa-hands { - --fa: "\f2a7"; -} - -.fa-sign-language { - --fa: "\f2a7"; -} - -.fa-signing { - --fa: "\f2a7"; -} - -.fa-eye-low-vision { - --fa: "\f2a8"; -} - -.fa-low-vision { - --fa: "\f2a8"; -} - -.fa-font-awesome { - --fa: "\f2b4"; -} - -.fa-font-awesome-flag { - --fa: "\f2b4"; -} - -.fa-font-awesome-logo-full { - --fa: "\f2b4"; -} - -.fa-handshake { - --fa: "\f2b5"; -} - -.fa-handshake-alt { - --fa: "\f2b5"; -} - -.fa-handshake-simple { - --fa: "\f2b5"; -} - -.fa-envelope-open { - --fa: "\f2b6"; -} - -.fa-address-book { - --fa: "\f2b9"; -} - -.fa-contact-book { - --fa: "\f2b9"; -} - -.fa-address-card { - --fa: "\f2bb"; -} - -.fa-contact-card { - --fa: "\f2bb"; -} - -.fa-vcard { - --fa: "\f2bb"; -} - -.fa-circle-user { - --fa: "\f2bd"; -} - -.fa-user-circle { - --fa: "\f2bd"; -} - -.fa-id-badge { - --fa: "\f2c1"; -} - -.fa-id-card { - --fa: "\f2c2"; -} - -.fa-drivers-license { - --fa: "\f2c2"; -} - -.fa-temperature-full { - --fa: "\f2c7"; -} - -.fa-temperature-4 { - --fa: "\f2c7"; -} - -.fa-thermometer-4 { - --fa: "\f2c7"; -} - -.fa-thermometer-full { - --fa: "\f2c7"; -} - -.fa-temperature-three-quarters { - --fa: "\f2c8"; -} - -.fa-temperature-3 { - --fa: "\f2c8"; -} - -.fa-thermometer-3 { - --fa: "\f2c8"; -} - -.fa-thermometer-three-quarters { - --fa: "\f2c8"; -} - -.fa-temperature-half { - --fa: "\f2c9"; -} - -.fa-temperature-2 { - --fa: "\f2c9"; -} - -.fa-thermometer-2 { - --fa: "\f2c9"; -} - -.fa-thermometer-half { - --fa: "\f2c9"; -} - -.fa-temperature-quarter { - --fa: "\f2ca"; -} - -.fa-temperature-1 { - --fa: "\f2ca"; -} - -.fa-thermometer-1 { - --fa: "\f2ca"; -} - -.fa-thermometer-quarter { - --fa: "\f2ca"; -} - -.fa-temperature-empty { - --fa: "\f2cb"; -} - -.fa-temperature-0 { - --fa: "\f2cb"; -} - -.fa-thermometer-0 { - --fa: "\f2cb"; -} - -.fa-thermometer-empty { - --fa: "\f2cb"; -} - -.fa-shower { - --fa: "\f2cc"; -} - -.fa-bath { - --fa: "\f2cd"; -} - -.fa-bathtub { - --fa: "\f2cd"; -} - -.fa-podcast { - --fa: "\f2ce"; -} - -.fa-window-maximize { - --fa: "\f2d0"; -} - -.fa-window-minimize { - --fa: "\f2d1"; -} - -.fa-window-restore { - --fa: "\f2d2"; -} - -.fa-square-xmark { - --fa: "\f2d3"; -} - -.fa-times-square { - --fa: "\f2d3"; -} - -.fa-xmark-square { - --fa: "\f2d3"; -} - -.fa-microchip { - --fa: "\f2db"; -} - -.fa-snowflake { - --fa: "\f2dc"; -} - -.fa-watch { - --fa: "\f2e1"; -} - -.fa-volume-slash { - --fa: "\f2e2"; -} - -.fa-fork { - --fa: "\f2e3"; -} - -.fa-utensil-fork { - --fa: "\f2e3"; -} - -.fa-knife { - --fa: "\f2e4"; -} - -.fa-utensil-knife { - --fa: "\f2e4"; -} - -.fa-spoon { - --fa: "\f2e5"; -} - -.fa-utensil-spoon { - --fa: "\f2e5"; -} - -.fa-fork-knife { - --fa: "\f2e6"; -} - -.fa-utensils-alt { - --fa: "\f2e6"; -} - -.fa-utensils { - --fa: "\f2e7"; -} - -.fa-cutlery { - --fa: "\f2e7"; -} - -.fa-circle-dollar { - --fa: "\f2e8"; -} - -.fa-dollar-circle { - --fa: "\f2e8"; -} - -.fa-usd-circle { - --fa: "\f2e8"; -} - -.fa-square-dollar { - --fa: "\f2e9"; -} - -.fa-dollar-square { - --fa: "\f2e9"; -} - -.fa-usd-square { - --fa: "\f2e9"; -} - -.fa-rotate-left { - --fa: "\f2ea"; -} - -.fa-rotate-back { - --fa: "\f2ea"; -} - -.fa-rotate-backward { - --fa: "\f2ea"; -} - -.fa-undo-alt { - --fa: "\f2ea"; -} - -.fa-trophy-star { - --fa: "\f2eb"; -} - -.fa-trophy-alt { - --fa: "\f2eb"; -} - -.fa-triangle { - --fa: "\f2ec"; -} - -.fa-trash-can { - --fa: "\f2ed"; -} - -.fa-trash-alt { - --fa: "\f2ed"; -} - -.fa-hexagon-xmark { - --fa: "\f2ee"; -} - -.fa-times-hexagon { - --fa: "\f2ee"; -} - -.fa-xmark-hexagon { - --fa: "\f2ee"; -} - -.fa-octagon-xmark { - --fa: "\f2f0"; -} - -.fa-times-octagon { - --fa: "\f2f0"; -} - -.fa-xmark-octagon { - --fa: "\f2f0"; -} - -.fa-rotate { - --fa: "\f2f1"; -} - -.fa-sync-alt { - --fa: "\f2f1"; -} - -.fa-stopwatch { - --fa: "\f2f2"; -} - -.fa-star-exclamation { - --fa: "\f2f3"; -} - -.fa-spade { - --fa: "\f2f4"; -} - -.fa-right-from-bracket { - --fa: "\f2f5"; -} - -.fa-sign-out-alt { - --fa: "\f2f5"; -} - -.fa-right-to-bracket { - --fa: "\f2f6"; -} - -.fa-sign-in-alt { - --fa: "\f2f6"; -} - -.fa-shield-check { - --fa: "\f2f7"; -} - -.fa-scrubber { - --fa: "\f2f8"; -} - -.fa-rotate-right { - --fa: "\f2f9"; -} - -.fa-redo-alt { - --fa: "\f2f9"; -} - -.fa-rotate-forward { - --fa: "\f2f9"; -} - -.fa-rectangle { - --fa: "\f2fa"; -} - -.fa-rectangle-landscape { - --fa: "\f2fa"; -} - -.fa-rectangle-vertical { - --fa: "\f2fb"; -} - -.fa-rectangle-portrait { - --fa: "\f2fb"; -} - -.fa-rectangle-wide { - --fa: "\f2fc"; -} - -.fa-square-question { - --fa: "\f2fd"; -} - -.fa-question-square { - --fa: "\f2fd"; -} - -.fa-poo { - --fa: "\f2fe"; -} - -.fa-hexagon-plus { - --fa: "\f300"; -} - -.fa-plus-hexagon { - --fa: "\f300"; -} - -.fa-octagon-plus { - --fa: "\f301"; -} - -.fa-plus-octagon { - --fa: "\f301"; -} - -.fa-images { - --fa: "\f302"; -} - -.fa-pencil { - --fa: "\f303"; -} - -.fa-pencil-alt { - --fa: "\f303"; -} - -.fa-pen { - --fa: "\f304"; -} - -.fa-pen-clip { - --fa: "\f305"; -} - -.fa-pen-alt { - --fa: "\f305"; -} - -.fa-octagon { - --fa: "\f306"; -} - -.fa-hexagon-minus { - --fa: "\f307"; -} - -.fa-minus-hexagon { - --fa: "\f307"; -} - -.fa-octagon-minus { - --fa: "\f308"; -} - -.fa-minus-octagon { - --fa: "\f308"; -} - -.fa-down-long { - --fa: "\f309"; -} - -.fa-long-arrow-alt-down { - --fa: "\f309"; -} - -.fa-left-long { - --fa: "\f30a"; -} - -.fa-long-arrow-alt-left { - --fa: "\f30a"; -} - -.fa-right-long { - --fa: "\f30b"; -} - -.fa-long-arrow-alt-right { - --fa: "\f30b"; -} - -.fa-up-long { - --fa: "\f30c"; -} - -.fa-long-arrow-alt-up { - --fa: "\f30c"; -} - -.fa-lock-keyhole { - --fa: "\f30d"; -} - -.fa-lock-alt { - --fa: "\f30d"; -} - -.fa-jack-o-lantern { - --fa: "\f30e"; -} - -.fa-square-info { - --fa: "\f30f"; -} - -.fa-info-square { - --fa: "\f30f"; -} - -.fa-inbox-in { - --fa: "\f310"; -} - -.fa-inbox-arrow-down { - --fa: "\f310"; -} - -.fa-inbox-out { - --fa: "\f311"; -} - -.fa-inbox-arrow-up { - --fa: "\f311"; -} - -.fa-hexagon { - --fa: "\f312"; -} - -.fa-h1 { - --fa: "\f313"; -} - -.fa-h2 { - --fa: "\f314"; -} - -.fa-h3 { - --fa: "\f315"; -} - -.fa-file-check { - --fa: "\f316"; -} - -.fa-file-xmark { - --fa: "\f317"; -} - -.fa-file-times { - --fa: "\f317"; -} - -.fa-file-minus { - --fa: "\f318"; -} - -.fa-file-plus { - --fa: "\f319"; -} - -.fa-file-exclamation { - --fa: "\f31a"; -} - -.fa-file-pen { - --fa: "\f31c"; -} - -.fa-file-edit { - --fa: "\f31c"; -} - -.fa-arrows-maximize { - --fa: "\f31d"; -} - -.fa-expand-arrows { - --fa: "\f31d"; -} - -.fa-maximize { - --fa: "\f31e"; -} - -.fa-expand-arrows-alt { - --fa: "\f31e"; -} - -.fa-expand-wide { - --fa: "\f320"; -} - -.fa-square-exclamation { - --fa: "\f321"; -} - -.fa-exclamation-square { - --fa: "\f321"; -} - -.fa-chevrons-down { - --fa: "\f322"; -} - -.fa-chevron-double-down { - --fa: "\f322"; -} - -.fa-chevrons-left { - --fa: "\f323"; -} - -.fa-chevron-double-left { - --fa: "\f323"; -} - -.fa-chevrons-right { - --fa: "\f324"; -} - -.fa-chevron-double-right { - --fa: "\f324"; -} - -.fa-chevrons-up { - --fa: "\f325"; -} - -.fa-chevron-double-up { - --fa: "\f325"; -} - -.fa-compress-wide { - --fa: "\f326"; -} - -.fa-club { - --fa: "\f327"; -} - -.fa-clipboard { - --fa: "\f328"; -} - -.fa-square-chevron-down { - --fa: "\f329"; -} - -.fa-chevron-square-down { - --fa: "\f329"; -} - -.fa-square-chevron-left { - --fa: "\f32a"; -} - -.fa-chevron-square-left { - --fa: "\f32a"; -} - -.fa-square-chevron-right { - --fa: "\f32b"; -} - -.fa-chevron-square-right { - --fa: "\f32b"; -} - -.fa-square-chevron-up { - --fa: "\f32c"; -} - -.fa-chevron-square-up { - --fa: "\f32c"; -} - -.fa-circle-caret-down { - --fa: "\f32d"; -} - -.fa-caret-circle-down { - --fa: "\f32d"; -} - -.fa-circle-caret-left { - --fa: "\f32e"; -} - -.fa-caret-circle-left { - --fa: "\f32e"; -} - -.fa-circle-caret-right { - --fa: "\f330"; -} - -.fa-caret-circle-right { - --fa: "\f330"; -} - -.fa-circle-caret-up { - --fa: "\f331"; -} - -.fa-caret-circle-up { - --fa: "\f331"; -} - -.fa-calendar-pen { - --fa: "\f333"; -} - -.fa-calendar-edit { - --fa: "\f333"; -} - -.fa-calendar-exclamation { - --fa: "\f334"; -} - -.fa-badge { - --fa: "\f335"; -} - -.fa-badge-check { - --fa: "\f336"; -} - -.fa-left-right { - --fa: "\f337"; -} - -.fa-arrows-alt-h { - --fa: "\f337"; -} - -.fa-up-down { - --fa: "\f338"; -} - -.fa-arrows-alt-v { - --fa: "\f338"; -} - -.fa-square-arrow-down { - --fa: "\f339"; -} - -.fa-arrow-square-down { - --fa: "\f339"; -} - -.fa-square-arrow-left { - --fa: "\f33a"; -} - -.fa-arrow-square-left { - --fa: "\f33a"; -} - -.fa-square-arrow-right { - --fa: "\f33b"; -} - -.fa-arrow-square-right { - --fa: "\f33b"; -} - -.fa-square-arrow-up { - --fa: "\f33c"; -} - -.fa-arrow-square-up { - --fa: "\f33c"; -} - -.fa-arrow-down-to-line { - --fa: "\f33d"; -} - -.fa-arrow-to-bottom { - --fa: "\f33d"; -} - -.fa-arrow-left-to-line { - --fa: "\f33e"; -} - -.fa-arrow-to-left { - --fa: "\f33e"; -} - -.fa-arrow-right-to-line { - --fa: "\f340"; -} - -.fa-arrow-to-right { - --fa: "\f340"; -} - -.fa-arrow-up-to-line { - --fa: "\f341"; -} - -.fa-arrow-to-top { - --fa: "\f341"; -} - -.fa-arrow-up-from-line { - --fa: "\f342"; -} - -.fa-arrow-from-bottom { - --fa: "\f342"; -} - -.fa-arrow-right-from-line { - --fa: "\f343"; -} - -.fa-arrow-from-left { - --fa: "\f343"; -} - -.fa-arrow-left-from-line { - --fa: "\f344"; -} - -.fa-arrow-from-right { - --fa: "\f344"; -} - -.fa-arrow-down-from-line { - --fa: "\f345"; -} - -.fa-arrow-from-top { - --fa: "\f345"; -} - -.fa-up-from-line { - --fa: "\f346"; -} - -.fa-arrow-alt-from-bottom { - --fa: "\f346"; -} - -.fa-right-from-line { - --fa: "\f347"; -} - -.fa-arrow-alt-from-left { - --fa: "\f347"; -} - -.fa-left-from-line { - --fa: "\f348"; -} - -.fa-arrow-alt-from-right { - --fa: "\f348"; -} - -.fa-down-from-line { - --fa: "\f349"; -} - -.fa-arrow-alt-from-top { - --fa: "\f349"; -} - -.fa-down-to-line { - --fa: "\f34a"; -} - -.fa-arrow-alt-to-bottom { - --fa: "\f34a"; -} - -.fa-left-to-line { - --fa: "\f34b"; -} - -.fa-arrow-alt-to-left { - --fa: "\f34b"; -} - -.fa-right-to-line { - --fa: "\f34c"; -} - -.fa-arrow-alt-to-right { - --fa: "\f34c"; -} - -.fa-up-to-line { - --fa: "\f34d"; -} - -.fa-arrow-alt-to-top { - --fa: "\f34d"; -} - -.fa-alarm-clock { - --fa: "\f34e"; -} - -.fa-square-down { - --fa: "\f350"; -} - -.fa-arrow-alt-square-down { - --fa: "\f350"; -} - -.fa-square-left { - --fa: "\f351"; -} - -.fa-arrow-alt-square-left { - --fa: "\f351"; -} - -.fa-square-right { - --fa: "\f352"; -} - -.fa-arrow-alt-square-right { - --fa: "\f352"; -} - -.fa-square-up { - --fa: "\f353"; -} - -.fa-arrow-alt-square-up { - --fa: "\f353"; -} - -.fa-down { - --fa: "\f354"; -} - -.fa-arrow-alt-down { - --fa: "\f354"; -} - -.fa-left { - --fa: "\f355"; -} - -.fa-arrow-alt-left { - --fa: "\f355"; -} - -.fa-right { - --fa: "\f356"; -} - -.fa-arrow-alt-right { - --fa: "\f356"; -} - -.fa-up { - --fa: "\f357"; -} - -.fa-arrow-alt-up { - --fa: "\f357"; -} - -.fa-circle-down { - --fa: "\f358"; -} - -.fa-arrow-alt-circle-down { - --fa: "\f358"; -} - -.fa-circle-left { - --fa: "\f359"; -} - -.fa-arrow-alt-circle-left { - --fa: "\f359"; -} - -.fa-circle-right { - --fa: "\f35a"; -} - -.fa-arrow-alt-circle-right { - --fa: "\f35a"; -} - -.fa-circle-up { - --fa: "\f35b"; -} - -.fa-arrow-alt-circle-up { - --fa: "\f35b"; -} - -.fa-up-right-from-square { - --fa: "\f35d"; -} - -.fa-external-link-alt { - --fa: "\f35d"; -} - -.fa-square-up-right { - --fa: "\f360"; -} - -.fa-external-link-square-alt { - --fa: "\f360"; -} - -.fa-arrows-retweet { - --fa: "\f361"; -} - -.fa-retweet-alt { - --fa: "\f361"; -} - -.fa-right-left { - --fa: "\f362"; -} - -.fa-exchange-alt { - --fa: "\f362"; -} - -.fa-repeat { - --fa: "\f363"; -} - -.fa-arrows-repeat { - --fa: "\f364"; -} - -.fa-repeat-alt { - --fa: "\f364"; -} - -.fa-repeat-1 { - --fa: "\f365"; -} - -.fa-arrows-repeat-1 { - --fa: "\f366"; -} - -.fa-repeat-1-alt { - --fa: "\f366"; -} - -.fa-share-all { - --fa: "\f367"; -} - -.fa-battery-bolt { - --fa: "\f376"; -} - -.fa-battery-slash { - --fa: "\f377"; -} - -.fa-browser { - --fa: "\f37e"; -} - -.fa-code-commit { - --fa: "\f386"; -} - -.fa-code-merge { - --fa: "\f387"; -} - -.fa-credit-card-blank { - --fa: "\f389"; -} - -.fa-credit-card-front { - --fa: "\f38a"; -} - -.fa-desktop { - --fa: "\f390"; -} - -.fa-desktop-alt { - --fa: "\f390"; -} - -.fa-ellipsis-stroke { - --fa: "\f39b"; -} - -.fa-ellipsis-h-alt { - --fa: "\f39b"; -} - -.fa-ellipsis-stroke-vertical { - --fa: "\f39c"; -} - -.fa-ellipsis-v-alt { - --fa: "\f39c"; -} - -.fa-gem { - --fa: "\f3a5"; -} - -.fa-industry-windows { - --fa: "\f3b3"; -} - -.fa-industry-alt { - --fa: "\f3b3"; -} - -.fa-turn-down { - --fa: "\f3be"; -} - -.fa-level-down-alt { - --fa: "\f3be"; -} - -.fa-turn-up { - --fa: "\f3bf"; -} - -.fa-level-up-alt { - --fa: "\f3bf"; -} - -.fa-lock-open { - --fa: "\f3c1"; -} - -.fa-lock-keyhole-open { - --fa: "\f3c2"; -} - -.fa-lock-open-alt { - --fa: "\f3c2"; -} - -.fa-location-dot { - --fa: "\f3c5"; -} - -.fa-map-marker-alt { - --fa: "\f3c5"; -} - -.fa-microphone-lines { - --fa: "\f3c9"; -} - -.fa-microphone-alt { - --fa: "\f3c9"; -} - -.fa-mobile-screen-button { - --fa: "\f3cd"; -} - -.fa-mobile-alt { - --fa: "\f3cd"; -} - -.fa-mobile { - --fa: "\f3ce"; -} - -.fa-mobile-android { - --fa: "\f3ce"; -} - -.fa-mobile-phone { - --fa: "\f3ce"; -} - -.fa-mobile-screen { - --fa: "\f3cf"; -} - -.fa-mobile-android-alt { - --fa: "\f3cf"; -} - -.fa-money-bill-1 { - --fa: "\f3d1"; -} - -.fa-money-bill-alt { - --fa: "\f3d1"; -} - -.fa-phone-slash { - --fa: "\f3dd"; -} - -.fa-plane-engines { - --fa: "\f3de"; -} - -.fa-plane-alt { - --fa: "\f3de"; -} - -.fa-image-portrait { - --fa: "\f3e0"; -} - -.fa-portrait { - --fa: "\f3e0"; -} - -.fa-reply { - --fa: "\f3e5"; -} - -.fa-mail-reply { - --fa: "\f3e5"; -} - -.fa-shield-halved { - --fa: "\f3ed"; -} - -.fa-shield-alt { - --fa: "\f3ed"; -} - -.fa-square-sliders { - --fa: "\f3f0"; -} - -.fa-sliders-h-square { - --fa: "\f3f0"; -} - -.fa-sliders-up { - --fa: "\f3f1"; -} - -.fa-sliders-v { - --fa: "\f3f1"; -} - -.fa-square-sliders-vertical { - --fa: "\f3f2"; -} - -.fa-sliders-v-square { - --fa: "\f3f2"; -} - -.fa-spinner-third { - --fa: "\f3f4"; -} - -.fa-tablet-screen-button { - --fa: "\f3fa"; -} - -.fa-tablet-alt { - --fa: "\f3fa"; -} - -.fa-tablet { - --fa: "\f3fb"; -} - -.fa-tablet-android { - --fa: "\f3fb"; -} - -.fa-tablet-screen { - --fa: "\f3fc"; -} - -.fa-tablet-android-alt { - --fa: "\f3fc"; -} - -.fa-ticket-simple { - --fa: "\f3ff"; -} - -.fa-ticket-alt { - --fa: "\f3ff"; -} - -.fa-tree-deciduous { - --fa: "\f400"; -} - -.fa-tree-alt { - --fa: "\f400"; -} - -.fa-tv-retro { - --fa: "\f401"; -} - -.fa-window { - --fa: "\f40e"; -} - -.fa-window-flip { - --fa: "\f40f"; -} - -.fa-window-alt { - --fa: "\f40f"; -} - -.fa-rectangle-xmark { - --fa: "\f410"; -} - -.fa-rectangle-times { - --fa: "\f410"; -} - -.fa-times-rectangle { - --fa: "\f410"; -} - -.fa-window-close { - --fa: "\f410"; -} - -.fa-down-left-and-up-right-to-center { - --fa: "\f422"; -} - -.fa-compress-alt { - --fa: "\f422"; -} - -.fa-up-right-and-down-left-from-center { - --fa: "\f424"; -} - -.fa-expand-alt { - --fa: "\f424"; -} - -.fa-baseball-bat-ball { - --fa: "\f432"; -} - -.fa-baseball { - --fa: "\f433"; -} - -.fa-baseball-ball { - --fa: "\f433"; -} - -.fa-basketball { - --fa: "\f434"; -} - -.fa-basketball-ball { - --fa: "\f434"; -} - -.fa-basketball-hoop { - --fa: "\f435"; -} - -.fa-bowling-ball { - --fa: "\f436"; -} - -.fa-bowling-pins { - --fa: "\f437"; -} - -.fa-boxing-glove { - --fa: "\f438"; -} - -.fa-glove-boxing { - --fa: "\f438"; -} - -.fa-chess { - --fa: "\f439"; -} - -.fa-chess-bishop { - --fa: "\f43a"; -} - -.fa-chess-bishop-piece { - --fa: "\f43b"; -} - -.fa-chess-bishop-alt { - --fa: "\f43b"; -} - -.fa-chess-board { - --fa: "\f43c"; -} - -.fa-chess-clock { - --fa: "\f43d"; -} - -.fa-chess-clock-flip { - --fa: "\f43e"; -} - -.fa-chess-clock-alt { - --fa: "\f43e"; -} - -.fa-chess-king { - --fa: "\f43f"; -} - -.fa-chess-king-piece { - --fa: "\f440"; -} - -.fa-chess-king-alt { - --fa: "\f440"; -} - -.fa-chess-knight { - --fa: "\f441"; -} - -.fa-chess-knight-piece { - --fa: "\f442"; -} - -.fa-chess-knight-alt { - --fa: "\f442"; -} - -.fa-chess-pawn { - --fa: "\f443"; -} - -.fa-chess-pawn-piece { - --fa: "\f444"; -} - -.fa-chess-pawn-alt { - --fa: "\f444"; -} - -.fa-chess-queen { - --fa: "\f445"; -} - -.fa-chess-queen-piece { - --fa: "\f446"; -} - -.fa-chess-queen-alt { - --fa: "\f446"; -} - -.fa-chess-rook { - --fa: "\f447"; -} - -.fa-chess-rook-piece { - --fa: "\f448"; -} - -.fa-chess-rook-alt { - --fa: "\f448"; -} - -.fa-cricket-bat-ball { - --fa: "\f449"; -} - -.fa-cricket { - --fa: "\f449"; -} - -.fa-curling-stone { - --fa: "\f44a"; -} - -.fa-curling { - --fa: "\f44a"; -} - -.fa-dumbbell { - --fa: "\f44b"; -} - -.fa-field-hockey-stick-ball { - --fa: "\f44c"; -} - -.fa-field-hockey { - --fa: "\f44c"; -} - -.fa-football { - --fa: "\f44e"; -} - -.fa-football-ball { - --fa: "\f44e"; -} - -.fa-football-helmet { - --fa: "\f44f"; -} - -.fa-golf-ball-tee { - --fa: "\f450"; -} - -.fa-golf-ball { - --fa: "\f450"; -} - -.fa-golf-club { - --fa: "\f451"; -} - -.fa-hockey-puck { - --fa: "\f453"; -} - -.fa-hockey-sticks { - --fa: "\f454"; -} - -.fa-luchador-mask { - --fa: "\f455"; -} - -.fa-luchador { - --fa: "\f455"; -} - -.fa-mask-luchador { - --fa: "\f455"; -} - -.fa-flag-pennant { - --fa: "\f456"; -} - -.fa-pennant { - --fa: "\f456"; -} - -.fa-broom-ball { - --fa: "\f458"; -} - -.fa-quidditch { - --fa: "\f458"; -} - -.fa-quidditch-broom-ball { - --fa: "\f458"; -} - -.fa-racquet { - --fa: "\f45a"; -} - -.fa-shuttlecock { - --fa: "\f45b"; -} - -.fa-square-full { - --fa: "\f45c"; -} - -.fa-table-tennis-paddle-ball { - --fa: "\f45d"; -} - -.fa-ping-pong-paddle-ball { - --fa: "\f45d"; -} - -.fa-table-tennis { - --fa: "\f45d"; -} - -.fa-tennis-ball { - --fa: "\f45e"; -} - -.fa-volleyball { - --fa: "\f45f"; -} - -.fa-volleyball-ball { - --fa: "\f45f"; -} - -.fa-whistle { - --fa: "\f460"; -} - -.fa-hand-dots { - --fa: "\f461"; -} - -.fa-allergies { - --fa: "\f461"; -} - -.fa-bandage { - --fa: "\f462"; -} - -.fa-band-aid { - --fa: "\f462"; -} - -.fa-rectangle-barcode { - --fa: "\f463"; -} - -.fa-barcode-alt { - --fa: "\f463"; -} - -.fa-barcode-read { - --fa: "\f464"; -} - -.fa-barcode-scan { - --fa: "\f465"; -} - -.fa-box { - --fa: "\f466"; -} - -.fa-box-check { - --fa: "\f467"; -} - -.fa-boxes-stacked { - --fa: "\f468"; -} - -.fa-boxes { - --fa: "\f468"; -} - -.fa-boxes-alt { - --fa: "\f468"; -} - -.fa-briefcase-medical { - --fa: "\f469"; -} - -.fa-fire-flame-simple { - --fa: "\f46a"; -} - -.fa-burn { - --fa: "\f46a"; -} - -.fa-capsules { - --fa: "\f46b"; -} - -.fa-clipboard-check { - --fa: "\f46c"; -} - -.fa-clipboard-list { - --fa: "\f46d"; -} - -.fa-conveyor-belt { - --fa: "\f46e"; -} - -.fa-conveyor-belt-boxes { - --fa: "\f46f"; -} - -.fa-conveyor-belt-alt { - --fa: "\f46f"; -} - -.fa-person-dots-from-line { - --fa: "\f470"; -} - -.fa-diagnoses { - --fa: "\f470"; -} - -.fa-dna { - --fa: "\f471"; -} - -.fa-dolly { - --fa: "\f472"; -} - -.fa-dolly-box { - --fa: "\f472"; -} - -.fa-dolly-empty { - --fa: "\f473"; -} - -.fa-cart-flatbed { - --fa: "\f474"; -} - -.fa-dolly-flatbed { - --fa: "\f474"; -} - -.fa-cart-flatbed-boxes { - --fa: "\f475"; -} - -.fa-dolly-flatbed-alt { - --fa: "\f475"; -} - -.fa-cart-flatbed-empty { - --fa: "\f476"; -} - -.fa-dolly-flatbed-empty { - --fa: "\f476"; -} - -.fa-file-medical { - --fa: "\f477"; -} - -.fa-file-waveform { - --fa: "\f478"; -} - -.fa-file-medical-alt { - --fa: "\f478"; -} - -.fa-kit-medical { - --fa: "\f479"; -} - -.fa-first-aid { - --fa: "\f479"; -} - -.fa-forklift { - --fa: "\f47a"; -} - -.fa-hand-holding-box { - --fa: "\f47b"; -} - -.fa-hands-holding-diamond { - --fa: "\f47c"; -} - -.fa-hand-receiving { - --fa: "\f47c"; -} - -.fa-circle-h { - --fa: "\f47e"; -} - -.fa-hospital-symbol { - --fa: "\f47e"; -} - -.fa-id-card-clip { - --fa: "\f47f"; -} - -.fa-id-card-alt { - --fa: "\f47f"; -} - -.fa-shelves { - --fa: "\f480"; -} - -.fa-inventory { - --fa: "\f480"; -} - -.fa-notes-medical { - --fa: "\f481"; -} - -.fa-pallet { - --fa: "\f482"; -} - -.fa-pallet-boxes { - --fa: "\f483"; -} - -.fa-palette-boxes { - --fa: "\f483"; -} - -.fa-pallet-alt { - --fa: "\f483"; -} - -.fa-pills { - --fa: "\f484"; -} - -.fa-prescription-bottle { - --fa: "\f485"; -} - -.fa-prescription-bottle-medical { - --fa: "\f486"; -} - -.fa-prescription-bottle-alt { - --fa: "\f486"; -} - -.fa-bed-pulse { - --fa: "\f487"; -} - -.fa-procedures { - --fa: "\f487"; -} - -.fa-scanner-gun { - --fa: "\f488"; -} - -.fa-scanner { - --fa: "\f488"; -} - -.fa-scanner-keyboard { - --fa: "\f489"; -} - -.fa-scanner-touchscreen { - --fa: "\f48a"; -} - -.fa-truck-fast { - --fa: "\f48b"; -} - -.fa-shipping-fast { - --fa: "\f48b"; -} - -.fa-truck-clock { - --fa: "\f48c"; -} - -.fa-shipping-timed { - --fa: "\f48c"; -} - -.fa-smoking { - --fa: "\f48d"; -} - -.fa-syringe { - --fa: "\f48e"; -} - -.fa-tablet-rugged { - --fa: "\f48f"; -} - -.fa-tablets { - --fa: "\f490"; -} - -.fa-thermometer { - --fa: "\f491"; -} - -.fa-vial { - --fa: "\f492"; -} - -.fa-vials { - --fa: "\f493"; -} - -.fa-warehouse { - --fa: "\f494"; -} - -.fa-warehouse-full { - --fa: "\f495"; -} - -.fa-warehouse-alt { - --fa: "\f495"; -} - -.fa-weight-scale { - --fa: "\f496"; -} - -.fa-weight { - --fa: "\f496"; -} - -.fa-x-ray { - --fa: "\f497"; -} - -.fa-blanket { - --fa: "\f498"; -} - -.fa-book-heart { - --fa: "\f499"; -} - -.fa-box-taped { - --fa: "\f49a"; -} - -.fa-box-alt { - --fa: "\f49a"; -} - -.fa-square-fragile { - --fa: "\f49b"; -} - -.fa-box-fragile { - --fa: "\f49b"; -} - -.fa-square-wine-glass-crack { - --fa: "\f49b"; -} - -.fa-box-open-full { - --fa: "\f49c"; -} - -.fa-box-full { - --fa: "\f49c"; -} - -.fa-box-heart { - --fa: "\f49d"; -} - -.fa-box-open { - --fa: "\f49e"; -} - -.fa-square-this-way-up { - --fa: "\f49f"; -} - -.fa-box-up { - --fa: "\f49f"; -} - -.fa-box-dollar { - --fa: "\f4a0"; -} - -.fa-box-usd { - --fa: "\f4a0"; -} - -.fa-message-check { - --fa: "\f4a2"; -} - -.fa-comment-alt-check { - --fa: "\f4a2"; -} - -.fa-message-dots { - --fa: "\f4a3"; -} - -.fa-comment-alt-dots { - --fa: "\f4a3"; -} - -.fa-messaging { - --fa: "\f4a3"; -} - -.fa-message-pen { - --fa: "\f4a4"; -} - -.fa-comment-alt-edit { - --fa: "\f4a4"; -} - -.fa-message-edit { - --fa: "\f4a4"; -} - -.fa-message-exclamation { - --fa: "\f4a5"; -} - -.fa-comment-alt-exclamation { - --fa: "\f4a5"; -} - -.fa-message-lines { - --fa: "\f4a6"; -} - -.fa-comment-alt-lines { - --fa: "\f4a6"; -} - -.fa-message-minus { - --fa: "\f4a7"; -} - -.fa-comment-alt-minus { - --fa: "\f4a7"; -} - -.fa-message-plus { - --fa: "\f4a8"; -} - -.fa-comment-alt-plus { - --fa: "\f4a8"; -} - -.fa-message-slash { - --fa: "\f4a9"; -} - -.fa-comment-alt-slash { - --fa: "\f4a9"; -} - -.fa-message-smile { - --fa: "\f4aa"; -} - -.fa-comment-alt-smile { - --fa: "\f4aa"; -} - -.fa-message-xmark { - --fa: "\f4ab"; -} - -.fa-comment-alt-times { - --fa: "\f4ab"; -} - -.fa-message-times { - --fa: "\f4ab"; -} - -.fa-comment-check { - --fa: "\f4ac"; -} - -.fa-comment-dots { - --fa: "\f4ad"; -} - -.fa-commenting { - --fa: "\f4ad"; -} - -.fa-comment-pen { - --fa: "\f4ae"; -} - -.fa-comment-edit { - --fa: "\f4ae"; -} - -.fa-comment-exclamation { - --fa: "\f4af"; -} - -.fa-comment-lines { - --fa: "\f4b0"; -} - -.fa-comment-minus { - --fa: "\f4b1"; -} - -.fa-comment-plus { - --fa: "\f4b2"; -} - -.fa-comment-slash { - --fa: "\f4b3"; -} - -.fa-comment-smile { - --fa: "\f4b4"; -} - -.fa-comment-xmark { - --fa: "\f4b5"; -} - -.fa-comment-times { - --fa: "\f4b5"; -} - -.fa-messages { - --fa: "\f4b6"; -} - -.fa-comments-alt { - --fa: "\f4b6"; -} - -.fa-container-storage { - --fa: "\f4b7"; -} - -.fa-couch { - --fa: "\f4b8"; -} - -.fa-circle-dollar-to-slot { - --fa: "\f4b9"; -} - -.fa-donate { - --fa: "\f4b9"; -} - -.fa-dove { - --fa: "\f4ba"; -} - -.fa-wine-glass-crack { - --fa: "\f4bb"; -} - -.fa-fragile { - --fa: "\f4bb"; -} - -.fa-hand-heart { - --fa: "\f4bc"; -} - -.fa-hand-holding { - --fa: "\f4bd"; -} - -.fa-hand-holding-heart { - --fa: "\f4be"; -} - -.fa-hand-holding-seedling { - --fa: "\f4bf"; -} - -.fa-hand-holding-dollar { - --fa: "\f4c0"; -} - -.fa-hand-holding-usd { - --fa: "\f4c0"; -} - -.fa-hand-holding-droplet { - --fa: "\f4c1"; -} - -.fa-hand-holding-water { - --fa: "\f4c1"; -} - -.fa-hands-holding { - --fa: "\f4c2"; -} - -.fa-hands-holding-heart { - --fa: "\f4c3"; -} - -.fa-hands-heart { - --fa: "\f4c3"; -} - -.fa-handshake-angle { - --fa: "\f4c4"; -} - -.fa-hands-helping { - --fa: "\f4c4"; -} - -.fa-hands-holding-dollar { - --fa: "\f4c5"; -} - -.fa-hands-usd { - --fa: "\f4c5"; -} - -.fa-circle-heart { - --fa: "\f4c7"; -} - -.fa-heart-circle { - --fa: "\f4c7"; -} - -.fa-square-heart { - --fa: "\f4c8"; -} - -.fa-heart-square { - --fa: "\f4c8"; -} - -.fa-house-heart { - --fa: "\f4c9"; -} - -.fa-home-heart { - --fa: "\f4c9"; -} - -.fa-lamp { - --fa: "\f4ca"; -} - -.fa-leaf-heart { - --fa: "\f4cb"; -} - -.fa-loveseat { - --fa: "\f4cc"; -} - -.fa-couch-small { - --fa: "\f4cc"; -} - -.fa-parachute-box { - --fa: "\f4cd"; -} - -.fa-people-carry-box { - --fa: "\f4ce"; -} - -.fa-people-carry { - --fa: "\f4ce"; -} - -.fa-person-carry-box { - --fa: "\f4cf"; -} - -.fa-person-carry { - --fa: "\f4cf"; -} - -.fa-person-dolly { - --fa: "\f4d0"; -} - -.fa-person-dolly-empty { - --fa: "\f4d1"; -} - -.fa-phone-plus { - --fa: "\f4d2"; -} - -.fa-piggy-bank { - --fa: "\f4d3"; -} - -.fa-ramp-loading { - --fa: "\f4d4"; -} - -.fa-ribbon { - --fa: "\f4d6"; -} - -.fa-route { - --fa: "\f4d7"; -} - -.fa-seedling { - --fa: "\f4d8"; -} - -.fa-sprout { - --fa: "\f4d8"; -} - -.fa-sign-hanging { - --fa: "\f4d9"; -} - -.fa-sign { - --fa: "\f4d9"; -} - -.fa-face-smile-wink { - --fa: "\f4da"; -} - -.fa-smile-wink { - --fa: "\f4da"; -} - -.fa-tape { - --fa: "\f4db"; -} - -.fa-truck-container { - --fa: "\f4dc"; -} - -.fa-truck-ramp-couch { - --fa: "\f4dd"; -} - -.fa-truck-couch { - --fa: "\f4dd"; -} - -.fa-truck-ramp-box { - --fa: "\f4de"; -} - -.fa-truck-loading { - --fa: "\f4de"; -} - -.fa-truck-moving { - --fa: "\f4df"; -} - -.fa-truck-ramp { - --fa: "\f4e0"; -} - -.fa-video-plus { - --fa: "\f4e1"; -} - -.fa-video-slash { - --fa: "\f4e2"; -} - -.fa-wine-glass { - --fa: "\f4e3"; -} - -.fa-user-astronaut { - --fa: "\f4fb"; -} - -.fa-user-check { - --fa: "\f4fc"; -} - -.fa-user-clock { - --fa: "\f4fd"; -} - -.fa-user-gear { - --fa: "\f4fe"; -} - -.fa-user-cog { - --fa: "\f4fe"; -} - -.fa-user-pen { - --fa: "\f4ff"; -} - -.fa-user-edit { - --fa: "\f4ff"; -} - -.fa-user-group { - --fa: "\f500"; -} - -.fa-user-friends { - --fa: "\f500"; -} - -.fa-user-graduate { - --fa: "\f501"; -} - -.fa-user-lock { - --fa: "\f502"; -} - -.fa-user-minus { - --fa: "\f503"; -} - -.fa-user-ninja { - --fa: "\f504"; -} - -.fa-user-shield { - --fa: "\f505"; -} - -.fa-user-slash { - --fa: "\f506"; -} - -.fa-user-alt-slash { - --fa: "\f506"; -} - -.fa-user-large-slash { - --fa: "\f506"; -} - -.fa-user-tag { - --fa: "\f507"; -} - -.fa-user-tie { - --fa: "\f508"; -} - -.fa-users-gear { - --fa: "\f509"; -} - -.fa-users-cog { - --fa: "\f509"; -} - -.fa-scale-unbalanced { - --fa: "\f515"; -} - -.fa-balance-scale-left { - --fa: "\f515"; -} - -.fa-scale-unbalanced-flip { - --fa: "\f516"; -} - -.fa-balance-scale-right { - --fa: "\f516"; -} - -.fa-blender { - --fa: "\f517"; -} - -.fa-book-open { - --fa: "\f518"; -} - -.fa-tower-broadcast { - --fa: "\f519"; -} - -.fa-broadcast-tower { - --fa: "\f519"; -} - -.fa-broom { - --fa: "\f51a"; -} - -.fa-chalkboard { - --fa: "\f51b"; -} - -.fa-blackboard { - --fa: "\f51b"; -} - -.fa-chalkboard-user { - --fa: "\f51c"; -} - -.fa-chalkboard-teacher { - --fa: "\f51c"; -} - -.fa-church { - --fa: "\f51d"; -} - -.fa-coins { - --fa: "\f51e"; -} - -.fa-compact-disc { - --fa: "\f51f"; -} - -.fa-crow { - --fa: "\f520"; -} - -.fa-crown { - --fa: "\f521"; -} - -.fa-dice { - --fa: "\f522"; -} - -.fa-dice-five { - --fa: "\f523"; -} - -.fa-dice-four { - --fa: "\f524"; -} - -.fa-dice-one { - --fa: "\f525"; -} - -.fa-dice-six { - --fa: "\f526"; -} - -.fa-dice-three { - --fa: "\f527"; -} - -.fa-dice-two { - --fa: "\f528"; -} - -.fa-divide { - --fa: "\f529"; -} - -.fa-door-closed { - --fa: "\f52a"; -} - -.fa-door-open { - --fa: "\f52b"; -} - -.fa-feather { - --fa: "\f52d"; -} - -.fa-frog { - --fa: "\f52e"; -} - -.fa-gas-pump { - --fa: "\f52f"; -} - -.fa-glasses { - --fa: "\f530"; -} - -.fa-greater-than-equal { - --fa: "\f532"; -} - -.fa-helicopter { - --fa: "\f533"; -} - -.fa-infinity { - --fa: "\f534"; -} - -.fa-kiwi-bird { - --fa: "\f535"; -} - -.fa-less-than-equal { - --fa: "\f537"; -} - -.fa-memory { - --fa: "\f538"; -} - -.fa-microphone-lines-slash { - --fa: "\f539"; -} - -.fa-microphone-alt-slash { - --fa: "\f539"; -} - -.fa-money-bill-wave { - --fa: "\f53a"; -} - -.fa-money-bill-1-wave { - --fa: "\f53b"; -} - -.fa-money-bill-wave-alt { - --fa: "\f53b"; -} - -.fa-money-check { - --fa: "\f53c"; -} - -.fa-money-check-dollar { - --fa: "\f53d"; -} - -.fa-money-check-alt { - --fa: "\f53d"; -} - -.fa-not-equal { - --fa: "\f53e"; -} - -.fa-palette { - --fa: "\f53f"; -} - -.fa-square-parking { - --fa: "\f540"; -} - -.fa-parking { - --fa: "\f540"; -} - -.fa-diagram-project { - --fa: "\f542"; -} - -.fa-project-diagram { - --fa: "\f542"; -} - -.fa-receipt { - --fa: "\f543"; -} - -.fa-robot { - --fa: "\f544"; -} - -.fa-ruler { - --fa: "\f545"; -} - -.fa-ruler-combined { - --fa: "\f546"; -} - -.fa-ruler-horizontal { - --fa: "\f547"; -} - -.fa-ruler-vertical { - --fa: "\f548"; -} - -.fa-school { - --fa: "\f549"; -} - -.fa-screwdriver { - --fa: "\f54a"; -} - -.fa-shoe-prints { - --fa: "\f54b"; -} - -.fa-skull { - --fa: "\f54c"; -} - -.fa-ban-smoking { - --fa: "\f54d"; -} - -.fa-smoking-ban { - --fa: "\f54d"; -} - -.fa-store { - --fa: "\f54e"; -} - -.fa-shop { - --fa: "\f54f"; -} - -.fa-store-alt { - --fa: "\f54f"; -} - -.fa-bars-staggered { - --fa: "\f550"; -} - -.fa-reorder { - --fa: "\f550"; -} - -.fa-stream { - --fa: "\f550"; -} - -.fa-stroopwafel { - --fa: "\f551"; -} - -.fa-toolbox { - --fa: "\f552"; -} - -.fa-shirt { - --fa: "\f553"; -} - -.fa-t-shirt { - --fa: "\f553"; -} - -.fa-tshirt { - --fa: "\f553"; -} - -.fa-person-walking { - --fa: "\f554"; -} - -.fa-walking { - --fa: "\f554"; -} - -.fa-wallet { - --fa: "\f555"; -} - -.fa-face-angry { - --fa: "\f556"; -} - -.fa-angry { - --fa: "\f556"; -} - -.fa-archway { - --fa: "\f557"; -} - -.fa-book-atlas { - --fa: "\f558"; -} - -.fa-atlas { - --fa: "\f558"; -} - -.fa-award { - --fa: "\f559"; -} - -.fa-delete-left { - --fa: "\f55a"; -} - -.fa-backspace { - --fa: "\f55a"; -} - -.fa-bezier-curve { - --fa: "\f55b"; -} - -.fa-bong { - --fa: "\f55c"; -} - -.fa-brush { - --fa: "\f55d"; -} - -.fa-bus-simple { - --fa: "\f55e"; -} - -.fa-bus-alt { - --fa: "\f55e"; -} - -.fa-cannabis { - --fa: "\f55f"; -} - -.fa-check-double { - --fa: "\f560"; -} - -.fa-martini-glass-citrus { - --fa: "\f561"; -} - -.fa-cocktail { - --fa: "\f561"; -} - -.fa-bell-concierge { - --fa: "\f562"; -} - -.fa-concierge-bell { - --fa: "\f562"; -} - -.fa-cookie { - --fa: "\f563"; -} - -.fa-cookie-bite { - --fa: "\f564"; -} - -.fa-crop-simple { - --fa: "\f565"; -} - -.fa-crop-alt { - --fa: "\f565"; -} - -.fa-tachograph-digital { - --fa: "\f566"; -} - -.fa-digital-tachograph { - --fa: "\f566"; -} - -.fa-face-dizzy { - --fa: "\f567"; -} - -.fa-dizzy { - --fa: "\f567"; -} - -.fa-compass-drafting { - --fa: "\f568"; -} - -.fa-drafting-compass { - --fa: "\f568"; -} - -.fa-drum { - --fa: "\f569"; -} - -.fa-drum-steelpan { - --fa: "\f56a"; -} - -.fa-feather-pointed { - --fa: "\f56b"; -} - -.fa-feather-alt { - --fa: "\f56b"; -} - -.fa-file-contract { - --fa: "\f56c"; -} - -.fa-file-arrow-down { - --fa: "\f56d"; -} - -.fa-file-download { - --fa: "\f56d"; -} - -.fa-file-export { - --fa: "\f56e"; -} - -.fa-arrow-right-from-file { - --fa: "\f56e"; -} - -.fa-file-import { - --fa: "\f56f"; -} - -.fa-arrow-right-to-file { - --fa: "\f56f"; -} - -.fa-file-invoice { - --fa: "\f570"; -} - -.fa-file-invoice-dollar { - --fa: "\f571"; -} - -.fa-file-prescription { - --fa: "\f572"; -} - -.fa-file-signature { - --fa: "\f573"; -} - -.fa-file-arrow-up { - --fa: "\f574"; -} - -.fa-file-upload { - --fa: "\f574"; -} - -.fa-fill { - --fa: "\f575"; -} - -.fa-fill-drip { - --fa: "\f576"; -} - -.fa-fingerprint { - --fa: "\f577"; -} - -.fa-fish { - --fa: "\f578"; -} - -.fa-face-flushed { - --fa: "\f579"; -} - -.fa-flushed { - --fa: "\f579"; -} - -.fa-face-frown-open { - --fa: "\f57a"; -} - -.fa-frown-open { - --fa: "\f57a"; -} - -.fa-martini-glass { - --fa: "\f57b"; -} - -.fa-glass-martini-alt { - --fa: "\f57b"; -} - -.fa-earth-africa { - --fa: "\f57c"; -} - -.fa-globe-africa { - --fa: "\f57c"; -} - -.fa-earth-americas { - --fa: "\f57d"; -} - -.fa-earth { - --fa: "\f57d"; -} - -.fa-earth-america { - --fa: "\f57d"; -} - -.fa-globe-americas { - --fa: "\f57d"; -} - -.fa-earth-asia { - --fa: "\f57e"; -} - -.fa-globe-asia { - --fa: "\f57e"; -} - -.fa-face-grimace { - --fa: "\f57f"; -} - -.fa-grimace { - --fa: "\f57f"; -} - -.fa-face-grin { - --fa: "\f580"; -} - -.fa-grin { - --fa: "\f580"; -} - -.fa-face-grin-wide { - --fa: "\f581"; -} - -.fa-grin-alt { - --fa: "\f581"; -} - -.fa-face-grin-beam { - --fa: "\f582"; -} - -.fa-grin-beam { - --fa: "\f582"; -} - -.fa-face-grin-beam-sweat { - --fa: "\f583"; -} - -.fa-grin-beam-sweat { - --fa: "\f583"; -} - -.fa-face-grin-hearts { - --fa: "\f584"; -} - -.fa-grin-hearts { - --fa: "\f584"; -} - -.fa-face-grin-squint { - --fa: "\f585"; -} - -.fa-grin-squint { - --fa: "\f585"; -} - -.fa-face-grin-squint-tears { - --fa: "\f586"; -} - -.fa-grin-squint-tears { - --fa: "\f586"; -} - -.fa-face-grin-stars { - --fa: "\f587"; -} - -.fa-grin-stars { - --fa: "\f587"; -} - -.fa-face-grin-tears { - --fa: "\f588"; -} - -.fa-grin-tears { - --fa: "\f588"; -} - -.fa-face-grin-tongue { - --fa: "\f589"; -} - -.fa-grin-tongue { - --fa: "\f589"; -} - -.fa-face-grin-tongue-squint { - --fa: "\f58a"; -} - -.fa-grin-tongue-squint { - --fa: "\f58a"; -} - -.fa-face-grin-tongue-wink { - --fa: "\f58b"; -} - -.fa-grin-tongue-wink { - --fa: "\f58b"; -} - -.fa-face-grin-wink { - --fa: "\f58c"; -} - -.fa-grin-wink { - --fa: "\f58c"; -} - -.fa-grip { - --fa: "\f58d"; -} - -.fa-grid-horizontal { - --fa: "\f58d"; -} - -.fa-grip-horizontal { - --fa: "\f58d"; -} - -.fa-grip-vertical { - --fa: "\f58e"; -} - -.fa-grid-vertical { - --fa: "\f58e"; -} - -.fa-headset { - --fa: "\f590"; -} - -.fa-highlighter { - --fa: "\f591"; -} - -.fa-hot-tub-person { - --fa: "\f593"; -} - -.fa-hot-tub { - --fa: "\f593"; -} - -.fa-hotel { - --fa: "\f594"; -} - -.fa-joint { - --fa: "\f595"; -} - -.fa-face-kiss { - --fa: "\f596"; -} - -.fa-kiss { - --fa: "\f596"; -} - -.fa-face-kiss-beam { - --fa: "\f597"; -} - -.fa-kiss-beam { - --fa: "\f597"; -} - -.fa-face-kiss-wink-heart { - --fa: "\f598"; -} - -.fa-kiss-wink-heart { - --fa: "\f598"; -} - -.fa-face-laugh { - --fa: "\f599"; -} - -.fa-laugh { - --fa: "\f599"; -} - -.fa-face-laugh-beam { - --fa: "\f59a"; -} - -.fa-laugh-beam { - --fa: "\f59a"; -} - -.fa-face-laugh-squint { - --fa: "\f59b"; -} - -.fa-laugh-squint { - --fa: "\f59b"; -} - -.fa-face-laugh-wink { - --fa: "\f59c"; -} - -.fa-laugh-wink { - --fa: "\f59c"; -} - -.fa-cart-flatbed-suitcase { - --fa: "\f59d"; -} - -.fa-luggage-cart { - --fa: "\f59d"; -} - -.fa-map-location { - --fa: "\f59f"; -} - -.fa-map-marked { - --fa: "\f59f"; -} - -.fa-map-location-dot { - --fa: "\f5a0"; -} - -.fa-map-marked-alt { - --fa: "\f5a0"; -} - -.fa-marker { - --fa: "\f5a1"; -} - -.fa-medal { - --fa: "\f5a2"; -} - -.fa-face-meh-blank { - --fa: "\f5a4"; -} - -.fa-meh-blank { - --fa: "\f5a4"; -} - -.fa-face-rolling-eyes { - --fa: "\f5a5"; -} - -.fa-meh-rolling-eyes { - --fa: "\f5a5"; -} - -.fa-monument { - --fa: "\f5a6"; -} - -.fa-mortar-pestle { - --fa: "\f5a7"; -} - -.fa-paintbrush-fine { - --fa: "\f5a9"; -} - -.fa-paint-brush-alt { - --fa: "\f5a9"; -} - -.fa-paint-brush-fine { - --fa: "\f5a9"; -} - -.fa-paintbrush-alt { - --fa: "\f5a9"; -} - -.fa-paint-roller { - --fa: "\f5aa"; -} - -.fa-passport { - --fa: "\f5ab"; -} - -.fa-pen-fancy { - --fa: "\f5ac"; -} - -.fa-pen-nib { - --fa: "\f5ad"; -} - -.fa-pen-ruler { - --fa: "\f5ae"; -} - -.fa-pencil-ruler { - --fa: "\f5ae"; -} - -.fa-plane-arrival { - --fa: "\f5af"; -} - -.fa-plane-departure { - --fa: "\f5b0"; -} - -.fa-prescription { - --fa: "\f5b1"; -} - -.fa-face-sad-cry { - --fa: "\f5b3"; -} - -.fa-sad-cry { - --fa: "\f5b3"; -} - -.fa-face-sad-tear { - --fa: "\f5b4"; -} - -.fa-sad-tear { - --fa: "\f5b4"; -} - -.fa-van-shuttle { - --fa: "\f5b6"; -} - -.fa-shuttle-van { - --fa: "\f5b6"; -} - -.fa-signature { - --fa: "\f5b7"; -} - -.fa-face-smile-beam { - --fa: "\f5b8"; -} - -.fa-smile-beam { - --fa: "\f5b8"; -} - -.fa-face-smile-plus { - --fa: "\f5b9"; -} - -.fa-smile-plus { - --fa: "\f5b9"; -} - -.fa-solar-panel { - --fa: "\f5ba"; -} - -.fa-spa { - --fa: "\f5bb"; -} - -.fa-splotch { - --fa: "\f5bc"; -} - -.fa-spray-can { - --fa: "\f5bd"; -} - -.fa-stamp { - --fa: "\f5bf"; -} - -.fa-star-half-stroke { - --fa: "\f5c0"; -} - -.fa-star-half-alt { - --fa: "\f5c0"; -} - -.fa-suitcase-rolling { - --fa: "\f5c1"; -} - -.fa-face-surprise { - --fa: "\f5c2"; -} - -.fa-surprise { - --fa: "\f5c2"; -} - -.fa-swatchbook { - --fa: "\f5c3"; -} - -.fa-person-swimming { - --fa: "\f5c4"; -} - -.fa-swimmer { - --fa: "\f5c4"; -} - -.fa-water-ladder { - --fa: "\f5c5"; -} - -.fa-ladder-water { - --fa: "\f5c5"; -} - -.fa-swimming-pool { - --fa: "\f5c5"; -} - -.fa-droplet-slash { - --fa: "\f5c7"; -} - -.fa-tint-slash { - --fa: "\f5c7"; -} - -.fa-face-tired { - --fa: "\f5c8"; -} - -.fa-tired { - --fa: "\f5c8"; -} - -.fa-tooth { - --fa: "\f5c9"; -} - -.fa-umbrella-beach { - --fa: "\f5ca"; -} - -.fa-weight-hanging { - --fa: "\f5cd"; -} - -.fa-wine-glass-empty { - --fa: "\f5ce"; -} - -.fa-wine-glass-alt { - --fa: "\f5ce"; -} - -.fa-spray-can-sparkles { - --fa: "\f5d0"; -} - -.fa-air-freshener { - --fa: "\f5d0"; -} - -.fa-apple-whole { - --fa: "\f5d1"; -} - -.fa-apple-alt { - --fa: "\f5d1"; -} - -.fa-atom { - --fa: "\f5d2"; -} - -.fa-atom-simple { - --fa: "\f5d3"; -} - -.fa-atom-alt { - --fa: "\f5d3"; -} - -.fa-backpack { - --fa: "\f5d4"; -} - -.fa-bell-school { - --fa: "\f5d5"; -} - -.fa-bell-school-slash { - --fa: "\f5d6"; -} - -.fa-bone { - --fa: "\f5d7"; -} - -.fa-bone-break { - --fa: "\f5d8"; -} - -.fa-book-blank { - --fa: "\f5d9"; -} - -.fa-book-alt { - --fa: "\f5d9"; -} - -.fa-book-open-reader { - --fa: "\f5da"; -} - -.fa-book-reader { - --fa: "\f5da"; -} - -.fa-books { - --fa: "\f5db"; -} - -.fa-brain { - --fa: "\f5dc"; -} - -.fa-bus-school { - --fa: "\f5dd"; -} - -.fa-car-rear { - --fa: "\f5de"; -} - -.fa-car-alt { - --fa: "\f5de"; -} - -.fa-car-battery { - --fa: "\f5df"; -} - -.fa-battery-car { - --fa: "\f5df"; -} - -.fa-car-bump { - --fa: "\f5e0"; -} - -.fa-car-burst { - --fa: "\f5e1"; -} - -.fa-car-crash { - --fa: "\f5e1"; -} - -.fa-car-garage { - --fa: "\f5e2"; -} - -.fa-car-wrench { - --fa: "\f5e3"; -} - -.fa-car-mechanic { - --fa: "\f5e3"; -} - -.fa-car-side { - --fa: "\f5e4"; -} - -.fa-car-tilt { - --fa: "\f5e5"; -} - -.fa-car-wash { - --fa: "\f5e6"; -} - -.fa-charging-station { - --fa: "\f5e7"; -} - -.fa-clipboard-prescription { - --fa: "\f5e8"; -} - -.fa-compass-slash { - --fa: "\f5e9"; -} - -.fa-diploma { - --fa: "\f5ea"; -} - -.fa-scroll-ribbon { - --fa: "\f5ea"; -} - -.fa-diamond-turn-right { - --fa: "\f5eb"; -} - -.fa-directions { - --fa: "\f5eb"; -} - -.fa-do-not-enter { - --fa: "\f5ec"; -} - -.fa-draw-circle { - --fa: "\f5ed"; -} - -.fa-vector-circle { - --fa: "\f5ed"; -} - -.fa-draw-polygon { - --fa: "\f5ee"; -} - -.fa-vector-polygon { - --fa: "\f5ee"; -} - -.fa-draw-square { - --fa: "\f5ef"; -} - -.fa-vector-square { - --fa: "\f5ef"; -} - -.fa-ear { - --fa: "\f5f0"; -} - -.fa-engine-warning { - --fa: "\f5f2"; -} - -.fa-engine-exclamation { - --fa: "\f5f2"; -} - -.fa-file-certificate { - --fa: "\f5f3"; -} - -.fa-file-award { - --fa: "\f5f3"; -} - -.fa-gas-pump-slash { - --fa: "\f5f4"; -} - -.fa-glasses-round { - --fa: "\f5f5"; -} - -.fa-glasses-alt { - --fa: "\f5f5"; -} - -.fa-globe-stand { - --fa: "\f5f6"; -} - -.fa-wave-pulse { - --fa: "\f5f8"; -} - -.fa-heart-rate { - --fa: "\f5f8"; -} - -.fa-inhaler { - --fa: "\f5f9"; -} - -.fa-kidneys { - --fa: "\f5fb"; -} - -.fa-laptop-code { - --fa: "\f5fc"; -} - -.fa-layer-group { - --fa: "\f5fd"; -} - -.fa-layer-minus { - --fa: "\f5fe"; -} - -.fa-layer-group-minus { - --fa: "\f5fe"; -} - -.fa-layer-plus { - --fa: "\f5ff"; -} - -.fa-layer-group-plus { - --fa: "\f5ff"; -} - -.fa-lips { - --fa: "\f600"; -} - -.fa-location-crosshairs { - --fa: "\f601"; -} - -.fa-location { - --fa: "\f601"; -} - -.fa-circle-location-arrow { - --fa: "\f602"; -} - -.fa-location-circle { - --fa: "\f602"; -} - -.fa-location-crosshairs-slash { - --fa: "\f603"; -} - -.fa-location-slash { - --fa: "\f603"; -} - -.fa-lungs { - --fa: "\f604"; -} - -.fa-location-dot-slash { - --fa: "\f605"; -} - -.fa-map-marker-alt-slash { - --fa: "\f605"; -} - -.fa-location-check { - --fa: "\f606"; -} - -.fa-map-marker-check { - --fa: "\f606"; -} - -.fa-location-pen { - --fa: "\f607"; -} - -.fa-map-marker-edit { - --fa: "\f607"; -} - -.fa-location-exclamation { - --fa: "\f608"; -} - -.fa-map-marker-exclamation { - --fa: "\f608"; -} - -.fa-location-minus { - --fa: "\f609"; -} - -.fa-map-marker-minus { - --fa: "\f609"; -} - -.fa-location-plus { - --fa: "\f60a"; -} - -.fa-map-marker-plus { - --fa: "\f60a"; -} - -.fa-location-question { - --fa: "\f60b"; -} - -.fa-map-marker-question { - --fa: "\f60b"; -} - -.fa-location-pin-slash { - --fa: "\f60c"; -} - -.fa-map-marker-slash { - --fa: "\f60c"; -} - -.fa-location-smile { - --fa: "\f60d"; -} - -.fa-map-marker-smile { - --fa: "\f60d"; -} - -.fa-location-xmark { - --fa: "\f60e"; -} - -.fa-map-marker-times { - --fa: "\f60e"; -} - -.fa-map-marker-xmark { - --fa: "\f60e"; -} - -.fa-microscope { - --fa: "\f610"; -} - -.fa-monitor-waveform { - --fa: "\f611"; -} - -.fa-monitor-heart-rate { - --fa: "\f611"; -} - -.fa-oil-can { - --fa: "\f613"; -} - -.fa-oil-temperature { - --fa: "\f614"; -} - -.fa-oil-temp { - --fa: "\f614"; -} - -.fa-circle-parking { - --fa: "\f615"; -} - -.fa-parking-circle { - --fa: "\f615"; -} - -.fa-ban-parking { - --fa: "\f616"; -} - -.fa-parking-circle-slash { - --fa: "\f616"; -} - -.fa-square-parking-slash { - --fa: "\f617"; -} - -.fa-parking-slash { - --fa: "\f617"; -} - -.fa-pen-paintbrush { - --fa: "\f618"; -} - -.fa-pencil-paintbrush { - --fa: "\f618"; -} - -.fa-poop { - --fa: "\f619"; -} - -.fa-route-highway { - --fa: "\f61a"; -} - -.fa-route-interstate { - --fa: "\f61b"; -} - -.fa-ruler-triangle { - --fa: "\f61c"; -} - -.fa-scalpel { - --fa: "\f61d"; -} - -.fa-scalpel-line-dashed { - --fa: "\f61e"; -} - -.fa-scalpel-path { - --fa: "\f61e"; -} - -.fa-shapes { - --fa: "\f61f"; -} - -.fa-triangle-circle-square { - --fa: "\f61f"; -} - -.fa-skeleton { - --fa: "\f620"; -} - -.fa-star-of-life { - --fa: "\f621"; -} - -.fa-steering-wheel { - --fa: "\f622"; -} - -.fa-stomach { - --fa: "\f623"; -} - -.fa-gauge { - --fa: "\f624"; -} - -.fa-dashboard { - --fa: "\f624"; -} - -.fa-gauge-med { - --fa: "\f624"; -} - -.fa-tachometer-alt-average { - --fa: "\f624"; -} - -.fa-gauge-high { - --fa: "\f625"; -} - -.fa-tachometer-alt { - --fa: "\f625"; -} - -.fa-tachometer-alt-fast { - --fa: "\f625"; -} - -.fa-gauge-max { - --fa: "\f626"; -} - -.fa-tachometer-alt-fastest { - --fa: "\f626"; -} - -.fa-gauge-low { - --fa: "\f627"; -} - -.fa-tachometer-alt-slow { - --fa: "\f627"; -} - -.fa-gauge-min { - --fa: "\f628"; -} - -.fa-tachometer-alt-slowest { - --fa: "\f628"; -} - -.fa-gauge-simple { - --fa: "\f629"; -} - -.fa-gauge-simple-med { - --fa: "\f629"; -} - -.fa-tachometer-average { - --fa: "\f629"; -} - -.fa-gauge-simple-high { - --fa: "\f62a"; -} - -.fa-tachometer { - --fa: "\f62a"; -} - -.fa-tachometer-fast { - --fa: "\f62a"; -} - -.fa-gauge-simple-max { - --fa: "\f62b"; -} - -.fa-tachometer-fastest { - --fa: "\f62b"; -} - -.fa-gauge-simple-low { - --fa: "\f62c"; -} - -.fa-tachometer-slow { - --fa: "\f62c"; -} - -.fa-gauge-simple-min { - --fa: "\f62d"; -} - -.fa-tachometer-slowest { - --fa: "\f62d"; -} - -.fa-teeth { - --fa: "\f62e"; -} - -.fa-teeth-open { - --fa: "\f62f"; -} - -.fa-masks-theater { - --fa: "\f630"; -} - -.fa-theater-masks { - --fa: "\f630"; -} - -.fa-tire { - --fa: "\f631"; -} - -.fa-tire-flat { - --fa: "\f632"; -} - -.fa-tire-pressure-warning { - --fa: "\f633"; -} - -.fa-tire-rugged { - --fa: "\f634"; -} - -.fa-toothbrush { - --fa: "\f635"; -} - -.fa-traffic-cone { - --fa: "\f636"; -} - -.fa-traffic-light { - --fa: "\f637"; -} - -.fa-traffic-light-go { - --fa: "\f638"; -} - -.fa-traffic-light-slow { - --fa: "\f639"; -} - -.fa-traffic-light-stop { - --fa: "\f63a"; -} - -.fa-truck-monster { - --fa: "\f63b"; -} - -.fa-truck-pickup { - --fa: "\f63c"; -} - -.fa-screen-users { - --fa: "\f63d"; -} - -.fa-users-class { - --fa: "\f63d"; -} - -.fa-watch-fitness { - --fa: "\f63e"; -} - -.fa-abacus { - --fa: "\f640"; -} - -.fa-rectangle-ad { - --fa: "\f641"; -} - -.fa-ad { - --fa: "\f641"; -} - -.fa-chart-mixed { - --fa: "\f643"; -} - -.fa-analytics { - --fa: "\f643"; -} - -.fa-ankh { - --fa: "\f644"; -} - -.fa-badge-dollar { - --fa: "\f645"; -} - -.fa-badge-percent { - --fa: "\f646"; -} - -.fa-book-bible { - --fa: "\f647"; -} - -.fa-bible { - --fa: "\f647"; -} - -.fa-bullseye-arrow { - --fa: "\f648"; -} - -.fa-bullseye-pointer { - --fa: "\f649"; -} - -.fa-business-time { - --fa: "\f64a"; -} - -.fa-briefcase-clock { - --fa: "\f64a"; -} - -.fa-cabinet-filing { - --fa: "\f64b"; -} - -.fa-calculator-simple { - --fa: "\f64c"; -} - -.fa-calculator-alt { - --fa: "\f64c"; -} - -.fa-chart-line-down { - --fa: "\f64d"; -} - -.fa-chart-pie-simple { - --fa: "\f64e"; -} - -.fa-chart-pie-alt { - --fa: "\f64e"; -} - -.fa-city { - --fa: "\f64f"; -} - -.fa-message-dollar { - --fa: "\f650"; -} - -.fa-comment-alt-dollar { - --fa: "\f650"; -} - -.fa-comment-dollar { - --fa: "\f651"; -} - -.fa-messages-dollar { - --fa: "\f652"; -} - -.fa-comments-alt-dollar { - --fa: "\f652"; -} - -.fa-comments-dollar { - --fa: "\f653"; -} - -.fa-cross { - --fa: "\f654"; -} - -.fa-dharmachakra { - --fa: "\f655"; -} - -.fa-empty-set { - --fa: "\f656"; -} - -.fa-envelope-open-dollar { - --fa: "\f657"; -} - -.fa-envelope-open-text { - --fa: "\f658"; -} - -.fa-file-chart-column { - --fa: "\f659"; -} - -.fa-file-chart-line { - --fa: "\f659"; -} - -.fa-file-chart-pie { - --fa: "\f65a"; -} - -.fa-file-spreadsheet { - --fa: "\f65b"; -} - -.fa-file-user { - --fa: "\f65c"; -} - -.fa-folder-minus { - --fa: "\f65d"; -} - -.fa-folder-plus { - --fa: "\f65e"; -} - -.fa-folder-xmark { - --fa: "\f65f"; -} - -.fa-folder-times { - --fa: "\f65f"; -} - -.fa-folders { - --fa: "\f660"; -} - -.fa-function { - --fa: "\f661"; -} - -.fa-filter-circle-dollar { - --fa: "\f662"; -} - -.fa-funnel-dollar { - --fa: "\f662"; -} - -.fa-gift-card { - --fa: "\f663"; -} - -.fa-gopuram { - --fa: "\f664"; -} - -.fa-hamsa { - --fa: "\f665"; -} - -.fa-bahai { - --fa: "\f666"; -} - -.fa-haykal { - --fa: "\f666"; -} - -.fa-integral { - --fa: "\f667"; -} - -.fa-intersection { - --fa: "\f668"; -} - -.fa-jedi { - --fa: "\f669"; -} - -.fa-book-journal-whills { - --fa: "\f66a"; -} - -.fa-journal-whills { - --fa: "\f66a"; -} - -.fa-kaaba { - --fa: "\f66b"; -} - -.fa-keynote { - --fa: "\f66c"; -} - -.fa-khanda { - --fa: "\f66d"; -} - -.fa-lambda { - --fa: "\f66e"; -} - -.fa-landmark { - --fa: "\f66f"; -} - -.fa-lightbulb-dollar { - --fa: "\f670"; -} - -.fa-lightbulb-exclamation { - --fa: "\f671"; -} - -.fa-lightbulb-on { - --fa: "\f672"; -} - -.fa-lightbulb-slash { - --fa: "\f673"; -} - -.fa-envelopes-bulk { - --fa: "\f674"; -} - -.fa-mail-bulk { - --fa: "\f674"; -} - -.fa-megaphone { - --fa: "\f675"; -} - -.fa-menorah { - --fa: "\f676"; -} - -.fa-brain-arrow-curved-right { - --fa: "\f677"; -} - -.fa-mind-share { - --fa: "\f677"; -} - -.fa-mosque { - --fa: "\f678"; -} - -.fa-om { - --fa: "\f679"; -} - -.fa-omega { - --fa: "\f67a"; -} - -.fa-spaghetti-monster-flying { - --fa: "\f67b"; -} - -.fa-pastafarianism { - --fa: "\f67b"; -} - -.fa-peace { - --fa: "\f67c"; -} - -.fa-phone-office { - --fa: "\f67d"; -} - -.fa-pi { - --fa: "\f67e"; -} - -.fa-place-of-worship { - --fa: "\f67f"; -} - -.fa-podium { - --fa: "\f680"; -} - -.fa-square-poll-vertical { - --fa: "\f681"; -} - -.fa-poll { - --fa: "\f681"; -} - -.fa-square-poll-horizontal { - --fa: "\f682"; -} - -.fa-poll-h { - --fa: "\f682"; -} - -.fa-person-praying { - --fa: "\f683"; -} - -.fa-pray { - --fa: "\f683"; -} - -.fa-hands-praying { - --fa: "\f684"; -} - -.fa-praying-hands { - --fa: "\f684"; -} - -.fa-presentation-screen { - --fa: "\f685"; -} - -.fa-presentation { - --fa: "\f685"; -} - -.fa-print-slash { - --fa: "\f686"; -} - -.fa-book-quran { - --fa: "\f687"; -} - -.fa-quran { - --fa: "\f687"; -} - -.fa-magnifying-glass-dollar { - --fa: "\f688"; -} - -.fa-search-dollar { - --fa: "\f688"; -} - -.fa-magnifying-glass-location { - --fa: "\f689"; -} - -.fa-search-location { - --fa: "\f689"; -} - -.fa-shredder { - --fa: "\f68a"; -} - -.fa-sigma { - --fa: "\f68b"; -} - -.fa-signal-weak { - --fa: "\f68c"; -} - -.fa-signal-1 { - --fa: "\f68c"; -} - -.fa-signal-fair { - --fa: "\f68d"; -} - -.fa-signal-2 { - --fa: "\f68d"; -} - -.fa-signal-good { - --fa: "\f68e"; -} - -.fa-signal-3 { - --fa: "\f68e"; -} - -.fa-signal-strong { - --fa: "\f68f"; -} - -.fa-signal-4 { - --fa: "\f68f"; -} - -.fa-signal-bars { - --fa: "\f690"; -} - -.fa-signal-alt { - --fa: "\f690"; -} - -.fa-signal-alt-4 { - --fa: "\f690"; -} - -.fa-signal-bars-strong { - --fa: "\f690"; -} - -.fa-signal-bars-weak { - --fa: "\f691"; -} - -.fa-signal-alt-1 { - --fa: "\f691"; -} - -.fa-signal-bars-fair { - --fa: "\f692"; -} - -.fa-signal-alt-2 { - --fa: "\f692"; -} - -.fa-signal-bars-good { - --fa: "\f693"; -} - -.fa-signal-alt-3 { - --fa: "\f693"; -} - -.fa-signal-bars-slash { - --fa: "\f694"; -} - -.fa-signal-alt-slash { - --fa: "\f694"; -} - -.fa-signal-slash { - --fa: "\f695"; -} - -.fa-socks { - --fa: "\f696"; -} - -.fa-square-root { - --fa: "\f697"; -} - -.fa-square-root-variable { - --fa: "\f698"; -} - -.fa-square-root-alt { - --fa: "\f698"; -} - -.fa-star-and-crescent { - --fa: "\f699"; -} - -.fa-star-of-david { - --fa: "\f69a"; -} - -.fa-synagogue { - --fa: "\f69b"; -} - -.fa-tally { - --fa: "\f69c"; -} - -.fa-tally-5 { - --fa: "\f69c"; -} - -.fa-theta { - --fa: "\f69e"; -} - -.fa-scroll-torah { - --fa: "\f6a0"; -} - -.fa-torah { - --fa: "\f6a0"; -} - -.fa-torii-gate { - --fa: "\f6a1"; -} - -.fa-union { - --fa: "\f6a2"; -} - -.fa-chart-user { - --fa: "\f6a3"; -} - -.fa-user-chart { - --fa: "\f6a3"; -} - -.fa-user-crown { - --fa: "\f6a4"; -} - -.fa-user-group-crown { - --fa: "\f6a5"; -} - -.fa-users-crown { - --fa: "\f6a5"; -} - -.fa-value-absolute { - --fa: "\f6a6"; -} - -.fa-vihara { - --fa: "\f6a7"; -} - -.fa-volume { - --fa: "\f6a8"; -} - -.fa-volume-medium { - --fa: "\f6a8"; -} - -.fa-volume-xmark { - --fa: "\f6a9"; -} - -.fa-volume-mute { - --fa: "\f6a9"; -} - -.fa-volume-times { - --fa: "\f6a9"; -} - -.fa-wifi-weak { - --fa: "\f6aa"; -} - -.fa-wifi-1 { - --fa: "\f6aa"; -} - -.fa-wifi-fair { - --fa: "\f6ab"; -} - -.fa-wifi-2 { - --fa: "\f6ab"; -} - -.fa-wifi-slash { - --fa: "\f6ac"; -} - -.fa-yin-yang { - --fa: "\f6ad"; -} - -.fa-acorn { - --fa: "\f6ae"; -} - -.fa-alicorn { - --fa: "\f6b0"; -} - -.fa-crate-apple { - --fa: "\f6b1"; -} - -.fa-apple-crate { - --fa: "\f6b1"; -} - -.fa-axe { - --fa: "\f6b2"; -} - -.fa-axe-battle { - --fa: "\f6b3"; -} - -.fa-badger-honey { - --fa: "\f6b4"; -} - -.fa-bat { - --fa: "\f6b5"; -} - -.fa-blender-phone { - --fa: "\f6b6"; -} - -.fa-book-skull { - --fa: "\f6b7"; -} - -.fa-book-dead { - --fa: "\f6b7"; -} - -.fa-book-sparkles { - --fa: "\f6b8"; -} - -.fa-book-spells { - --fa: "\f6b8"; -} - -.fa-bow-arrow { - --fa: "\f6b9"; -} - -.fa-campfire { - --fa: "\f6ba"; -} - -.fa-campground { - --fa: "\f6bb"; -} - -.fa-candle-holder { - --fa: "\f6bc"; -} - -.fa-candy-corn { - --fa: "\f6bd"; -} - -.fa-cat { - --fa: "\f6be"; -} - -.fa-cauldron { - --fa: "\f6bf"; -} - -.fa-chair { - --fa: "\f6c0"; -} - -.fa-chair-office { - --fa: "\f6c1"; -} - -.fa-claw-marks { - --fa: "\f6c2"; -} - -.fa-cloud-moon { - --fa: "\f6c3"; -} - -.fa-cloud-sun { - --fa: "\f6c4"; -} - -.fa-cup-togo { - --fa: "\f6c5"; -} - -.fa-coffee-togo { - --fa: "\f6c5"; -} - -.fa-coffin { - --fa: "\f6c6"; -} - -.fa-corn { - --fa: "\f6c7"; -} - -.fa-cow { - --fa: "\f6c8"; -} - -.fa-dagger { - --fa: "\f6cb"; -} - -.fa-dice-d10 { - --fa: "\f6cd"; -} - -.fa-dice-d12 { - --fa: "\f6ce"; -} - -.fa-dice-d20 { - --fa: "\f6cf"; -} - -.fa-dice-d4 { - --fa: "\f6d0"; -} - -.fa-dice-d6 { - --fa: "\f6d1"; -} - -.fa-dice-d8 { - --fa: "\f6d2"; -} - -.fa-dog { - --fa: "\f6d3"; -} - -.fa-dog-leashed { - --fa: "\f6d4"; -} - -.fa-dragon { - --fa: "\f6d5"; -} - -.fa-drumstick { - --fa: "\f6d6"; -} - -.fa-drumstick-bite { - --fa: "\f6d7"; -} - -.fa-duck { - --fa: "\f6d8"; -} - -.fa-dungeon { - --fa: "\f6d9"; -} - -.fa-elephant { - --fa: "\f6da"; -} - -.fa-eye-evil { - --fa: "\f6db"; -} - -.fa-file-csv { - --fa: "\f6dd"; -} - -.fa-hand-fist { - --fa: "\f6de"; -} - -.fa-fist-raised { - --fa: "\f6de"; -} - -.fa-fire-flame { - --fa: "\f6df"; -} - -.fa-flame { - --fa: "\f6df"; -} - -.fa-flask-round-poison { - --fa: "\f6e0"; -} - -.fa-flask-poison { - --fa: "\f6e0"; -} - -.fa-flask-round-potion { - --fa: "\f6e1"; -} - -.fa-flask-potion { - --fa: "\f6e1"; -} - -.fa-ghost { - --fa: "\f6e2"; -} - -.fa-hammer { - --fa: "\f6e3"; -} - -.fa-hammer-war { - --fa: "\f6e4"; -} - -.fa-hand-holding-magic { - --fa: "\f6e5"; -} - -.fa-hanukiah { - --fa: "\f6e6"; -} - -.fa-hat-witch { - --fa: "\f6e7"; -} - -.fa-hat-wizard { - --fa: "\f6e8"; -} - -.fa-head-side { - --fa: "\f6e9"; -} - -.fa-head-side-goggles { - --fa: "\f6ea"; -} - -.fa-head-vr { - --fa: "\f6ea"; -} - -.fa-helmet-battle { - --fa: "\f6eb"; -} - -.fa-person-hiking { - --fa: "\f6ec"; -} - -.fa-hiking { - --fa: "\f6ec"; -} - -.fa-hippo { - --fa: "\f6ed"; -} - -.fa-hockey-mask { - --fa: "\f6ee"; -} - -.fa-hood-cloak { - --fa: "\f6ef"; -} - -.fa-horse { - --fa: "\f6f0"; -} - -.fa-house-chimney-crack { - --fa: "\f6f1"; -} - -.fa-house-damage { - --fa: "\f6f1"; -} - -.fa-hryvnia-sign { - --fa: "\f6f2"; -} - -.fa-hryvnia { - --fa: "\f6f2"; -} - -.fa-key-skeleton { - --fa: "\f6f3"; -} - -.fa-kite { - --fa: "\f6f4"; -} - -.fa-knife-kitchen { - --fa: "\f6f5"; -} - -.fa-leaf-maple { - --fa: "\f6f6"; -} - -.fa-leaf-oak { - --fa: "\f6f7"; -} - -.fa-mace { - --fa: "\f6f8"; -} - -.fa-mandolin { - --fa: "\f6f9"; -} - -.fa-mask { - --fa: "\f6fa"; -} - -.fa-monkey { - --fa: "\f6fb"; -} - -.fa-mountain { - --fa: "\f6fc"; -} - -.fa-mountains { - --fa: "\f6fd"; -} - -.fa-narwhal { - --fa: "\f6fe"; -} - -.fa-network-wired { - --fa: "\f6ff"; -} - -.fa-otter { - --fa: "\f700"; -} - -.fa-paw-simple { - --fa: "\f701"; -} - -.fa-paw-alt { - --fa: "\f701"; -} - -.fa-paw-claws { - --fa: "\f702"; -} - -.fa-pegasus { - --fa: "\f703"; -} - -.fa-pie { - --fa: "\f705"; -} - -.fa-pig { - --fa: "\f706"; -} - -.fa-pumpkin { - --fa: "\f707"; -} - -.fa-rabbit { - --fa: "\f708"; -} - -.fa-rabbit-running { - --fa: "\f709"; -} - -.fa-rabbit-fast { - --fa: "\f709"; -} - -.fa-ram { - --fa: "\f70a"; -} - -.fa-ring { - --fa: "\f70b"; -} - -.fa-person-running { - --fa: "\f70c"; -} - -.fa-running { - --fa: "\f70c"; -} - -.fa-scarecrow { - --fa: "\f70d"; -} - -.fa-scroll { - --fa: "\f70e"; -} - -.fa-scroll-old { - --fa: "\f70f"; -} - -.fa-scythe { - --fa: "\f710"; -} - -.fa-sheep { - --fa: "\f711"; -} - -.fa-shield-cross { - --fa: "\f712"; -} - -.fa-shovel { - --fa: "\f713"; -} - -.fa-skull-crossbones { - --fa: "\f714"; -} - -.fa-slash { - --fa: "\f715"; -} - -.fa-snake { - --fa: "\f716"; -} - -.fa-spider { - --fa: "\f717"; -} - -.fa-spider-black-widow { - --fa: "\f718"; -} - -.fa-spider-web { - --fa: "\f719"; -} - -.fa-squirrel { - --fa: "\f71a"; -} - -.fa-staff { - --fa: "\f71b"; -} - -.fa-sword { - --fa: "\f71c"; -} - -.fa-swords { - --fa: "\f71d"; -} - -.fa-toilet-paper { - --fa: "\f71e"; -} - -.fa-toilet-paper-alt { - --fa: "\f71e"; -} - -.fa-toilet-paper-blank { - --fa: "\f71e"; -} - -.fa-tombstone { - --fa: "\f720"; -} - -.fa-tombstone-blank { - --fa: "\f721"; -} - -.fa-tombstone-alt { - --fa: "\f721"; -} - -.fa-tractor { - --fa: "\f722"; -} - -.fa-treasure-chest { - --fa: "\f723"; -} - -.fa-trees { - --fa: "\f724"; -} - -.fa-turkey { - --fa: "\f725"; -} - -.fa-turtle { - --fa: "\f726"; -} - -.fa-unicorn { - --fa: "\f727"; -} - -.fa-user-injured { - --fa: "\f728"; -} - -.fa-vr-cardboard { - --fa: "\f729"; -} - -.fa-wand { - --fa: "\f72a"; -} - -.fa-wand-sparkles { - --fa: "\f72b"; -} - -.fa-whale { - --fa: "\f72c"; -} - -.fa-wheat { - --fa: "\f72d"; -} - -.fa-wind { - --fa: "\f72e"; -} - -.fa-wine-bottle { - --fa: "\f72f"; -} - -.fa-ballot { - --fa: "\f732"; -} - -.fa-ballot-check { - --fa: "\f733"; -} - -.fa-booth-curtain { - --fa: "\f734"; -} - -.fa-box-ballot { - --fa: "\f735"; -} - -.fa-calendar-star { - --fa: "\f736"; -} - -.fa-clipboard-list-check { - --fa: "\f737"; -} - -.fa-cloud-drizzle { - --fa: "\f738"; -} - -.fa-cloud-hail { - --fa: "\f739"; -} - -.fa-cloud-hail-mixed { - --fa: "\f73a"; -} - -.fa-cloud-meatball { - --fa: "\f73b"; -} - -.fa-cloud-moon-rain { - --fa: "\f73c"; -} - -.fa-cloud-rain { - --fa: "\f73d"; -} - -.fa-cloud-rainbow { - --fa: "\f73e"; -} - -.fa-cloud-showers { - --fa: "\f73f"; -} - -.fa-cloud-showers-heavy { - --fa: "\f740"; -} - -.fa-cloud-sleet { - --fa: "\f741"; -} - -.fa-cloud-snow { - --fa: "\f742"; -} - -.fa-cloud-sun-rain { - --fa: "\f743"; -} - -.fa-clouds { - --fa: "\f744"; -} - -.fa-clouds-moon { - --fa: "\f745"; -} - -.fa-clouds-sun { - --fa: "\f746"; -} - -.fa-democrat { - --fa: "\f747"; -} - -.fa-droplet-degree { - --fa: "\f748"; -} - -.fa-dewpoint { - --fa: "\f748"; -} - -.fa-eclipse { - --fa: "\f749"; -} - -.fa-moon-over-sun { - --fa: "\f74a"; -} - -.fa-eclipse-alt { - --fa: "\f74a"; -} - -.fa-fire-smoke { - --fa: "\f74b"; -} - -.fa-flag-swallowtail { - --fa: "\f74c"; -} - -.fa-flag-alt { - --fa: "\f74c"; -} - -.fa-flag-usa { - --fa: "\f74d"; -} - -.fa-cloud-fog { - --fa: "\f74e"; -} - -.fa-fog { - --fa: "\f74e"; -} - -.fa-house-water { - --fa: "\f74f"; -} - -.fa-house-flood { - --fa: "\f74f"; -} - -.fa-droplet-percent { - --fa: "\f750"; -} - -.fa-humidity { - --fa: "\f750"; -} - -.fa-hurricane { - --fa: "\f751"; -} - -.fa-landmark-dome { - --fa: "\f752"; -} - -.fa-landmark-alt { - --fa: "\f752"; -} - -.fa-meteor { - --fa: "\f753"; -} - -.fa-moon-cloud { - --fa: "\f754"; -} - -.fa-moon-stars { - --fa: "\f755"; -} - -.fa-person-booth { - --fa: "\f756"; -} - -.fa-person-sign { - --fa: "\f757"; -} - -.fa-podium-star { - --fa: "\f758"; -} - -.fa-poll-people { - --fa: "\f759"; -} - -.fa-poo-storm { - --fa: "\f75a"; -} - -.fa-poo-bolt { - --fa: "\f75a"; -} - -.fa-rainbow { - --fa: "\f75b"; -} - -.fa-raindrops { - --fa: "\f75c"; -} - -.fa-republican { - --fa: "\f75e"; -} - -.fa-smog { - --fa: "\f75f"; -} - -.fa-smoke { - --fa: "\f760"; -} - -.fa-snow-blowing { - --fa: "\f761"; -} - -.fa-stars { - --fa: "\f762"; -} - -.fa-sun-cloud { - --fa: "\f763"; -} - -.fa-sun-dust { - --fa: "\f764"; -} - -.fa-sun-haze { - --fa: "\f765"; -} - -.fa-sunrise { - --fa: "\f766"; -} - -.fa-sunset { - --fa: "\f767"; -} - -.fa-temperature-snow { - --fa: "\f768"; -} - -.fa-temperature-frigid { - --fa: "\f768"; -} - -.fa-temperature-high { - --fa: "\f769"; -} - -.fa-temperature-sun { - --fa: "\f76a"; -} - -.fa-temperature-hot { - --fa: "\f76a"; -} - -.fa-temperature-low { - --fa: "\f76b"; -} - -.fa-cloud-bolt { - --fa: "\f76c"; -} - -.fa-thunderstorm { - --fa: "\f76c"; -} - -.fa-cloud-bolt-moon { - --fa: "\f76d"; -} - -.fa-thunderstorm-moon { - --fa: "\f76d"; -} - -.fa-cloud-bolt-sun { - --fa: "\f76e"; -} - -.fa-thunderstorm-sun { - --fa: "\f76e"; -} - -.fa-tornado { - --fa: "\f76f"; -} - -.fa-volcano { - --fa: "\f770"; -} - -.fa-xmark-to-slot { - --fa: "\f771"; -} - -.fa-times-to-slot { - --fa: "\f771"; -} - -.fa-vote-nay { - --fa: "\f771"; -} - -.fa-check-to-slot { - --fa: "\f772"; -} - -.fa-vote-yea { - --fa: "\f772"; -} - -.fa-water { - --fa: "\f773"; -} - -.fa-water-arrow-down { - --fa: "\f774"; -} - -.fa-water-lower { - --fa: "\f774"; -} - -.fa-water-arrow-up { - --fa: "\f775"; -} - -.fa-water-rise { - --fa: "\f775"; -} - -.fa-wind-warning { - --fa: "\f776"; -} - -.fa-wind-circle-exclamation { - --fa: "\f776"; -} - -.fa-windsock { - --fa: "\f777"; -} - -.fa-angel { - --fa: "\f779"; -} - -.fa-baby { - --fa: "\f77c"; -} - -.fa-baby-carriage { - --fa: "\f77d"; -} - -.fa-carriage-baby { - --fa: "\f77d"; -} - -.fa-ball-pile { - --fa: "\f77e"; -} - -.fa-bells { - --fa: "\f77f"; -} - -.fa-biohazard { - --fa: "\f780"; -} - -.fa-blog { - --fa: "\f781"; -} - -.fa-boot { - --fa: "\f782"; -} - -.fa-calendar-day { - --fa: "\f783"; -} - -.fa-calendar-week { - --fa: "\f784"; -} - -.fa-candy-cane { - --fa: "\f786"; -} - -.fa-carrot { - --fa: "\f787"; -} - -.fa-cash-register { - --fa: "\f788"; -} - -.fa-chart-network { - --fa: "\f78a"; -} - -.fa-chimney { - --fa: "\f78b"; -} - -.fa-minimize { - --fa: "\f78c"; -} - -.fa-compress-arrows-alt { - --fa: "\f78c"; -} - -.fa-deer { - --fa: "\f78e"; -} - -.fa-deer-rudolph { - --fa: "\f78f"; -} - -.fa-dreidel { - --fa: "\f792"; -} - -.fa-dumpster { - --fa: "\f793"; -} - -.fa-dumpster-fire { - --fa: "\f794"; -} - -.fa-ear-muffs { - --fa: "\f795"; -} - -.fa-ethernet { - --fa: "\f796"; -} - -.fa-fireplace { - --fa: "\f79a"; -} - -.fa-snowman-head { - --fa: "\f79b"; -} - -.fa-frosty-head { - --fa: "\f79b"; -} - -.fa-gifts { - --fa: "\f79c"; -} - -.fa-gingerbread-man { - --fa: "\f79d"; -} - -.fa-champagne-glass { - --fa: "\f79e"; -} - -.fa-glass-champagne { - --fa: "\f79e"; -} - -.fa-champagne-glasses { - --fa: "\f79f"; -} - -.fa-glass-cheers { - --fa: "\f79f"; -} - -.fa-whiskey-glass { - --fa: "\f7a0"; -} - -.fa-glass-whiskey { - --fa: "\f7a0"; -} - -.fa-whiskey-glass-ice { - --fa: "\f7a1"; -} - -.fa-glass-whiskey-rocks { - --fa: "\f7a1"; -} - -.fa-earth-europe { - --fa: "\f7a2"; -} - -.fa-globe-europe { - --fa: "\f7a2"; -} - -.fa-globe-snow { - --fa: "\f7a3"; -} - -.fa-grip-lines { - --fa: "\f7a4"; -} - -.fa-grip-lines-vertical { - --fa: "\f7a5"; -} - -.fa-guitar { - --fa: "\f7a6"; -} - -.fa-hat-santa { - --fa: "\f7a7"; -} - -.fa-hat-winter { - --fa: "\f7a8"; -} - -.fa-heart-crack { - --fa: "\f7a9"; -} - -.fa-heart-broken { - --fa: "\f7a9"; -} - -.fa-holly-berry { - --fa: "\f7aa"; -} - -.fa-horse-head { - --fa: "\f7ab"; -} - -.fa-ice-skate { - --fa: "\f7ac"; -} - -.fa-icicles { - --fa: "\f7ad"; -} - -.fa-igloo { - --fa: "\f7ae"; -} - -.fa-lights-holiday { - --fa: "\f7b2"; -} - -.fa-mistletoe { - --fa: "\f7b4"; -} - -.fa-mitten { - --fa: "\f7b5"; -} - -.fa-mug-hot { - --fa: "\f7b6"; -} - -.fa-mug-marshmallows { - --fa: "\f7b7"; -} - -.fa-ornament { - --fa: "\f7b8"; -} - -.fa-radiation { - --fa: "\f7b9"; -} - -.fa-circle-radiation { - --fa: "\f7ba"; -} - -.fa-radiation-alt { - --fa: "\f7ba"; -} - -.fa-restroom { - --fa: "\f7bd"; -} - -.fa-rv { - --fa: "\f7be"; -} - -.fa-satellite { - --fa: "\f7bf"; -} - -.fa-satellite-dish { - --fa: "\f7c0"; -} - -.fa-scarf { - --fa: "\f7c1"; -} - -.fa-sd-card { - --fa: "\f7c2"; -} - -.fa-shovel-snow { - --fa: "\f7c3"; -} - -.fa-sim-card { - --fa: "\f7c4"; -} - -.fa-person-skating { - --fa: "\f7c5"; -} - -.fa-skating { - --fa: "\f7c5"; -} - -.fa-person-ski-jumping { - --fa: "\f7c7"; -} - -.fa-ski-jump { - --fa: "\f7c7"; -} - -.fa-person-ski-lift { - --fa: "\f7c8"; -} - -.fa-ski-lift { - --fa: "\f7c8"; -} - -.fa-person-skiing { - --fa: "\f7c9"; -} - -.fa-skiing { - --fa: "\f7c9"; -} - -.fa-person-skiing-nordic { - --fa: "\f7ca"; -} - -.fa-skiing-nordic { - --fa: "\f7ca"; -} - -.fa-person-sledding { - --fa: "\f7cb"; -} - -.fa-sledding { - --fa: "\f7cb"; -} - -.fa-sleigh { - --fa: "\f7cc"; -} - -.fa-comment-sms { - --fa: "\f7cd"; -} - -.fa-sms { - --fa: "\f7cd"; -} - -.fa-person-snowboarding { - --fa: "\f7ce"; -} - -.fa-snowboarding { - --fa: "\f7ce"; -} - -.fa-snowflakes { - --fa: "\f7cf"; -} - -.fa-snowman { - --fa: "\f7d0"; -} - -.fa-person-snowmobiling { - --fa: "\f7d1"; -} - -.fa-snowmobile { - --fa: "\f7d1"; -} - -.fa-snowplow { - --fa: "\f7d2"; -} - -.fa-star-christmas { - --fa: "\f7d4"; -} - -.fa-stocking { - --fa: "\f7d5"; -} - -.fa-tenge-sign { - --fa: "\f7d7"; -} - -.fa-tenge { - --fa: "\f7d7"; -} - -.fa-toilet { - --fa: "\f7d8"; -} - -.fa-screwdriver-wrench { - --fa: "\f7d9"; -} - -.fa-tools { - --fa: "\f7d9"; -} - -.fa-cable-car { - --fa: "\f7da"; -} - -.fa-tram { - --fa: "\f7da"; -} - -.fa-tree-christmas { - --fa: "\f7db"; -} - -.fa-tree-decorated { - --fa: "\f7dc"; -} - -.fa-tree-large { - --fa: "\f7dd"; -} - -.fa-truck-plow { - --fa: "\f7de"; -} - -.fa-wreath { - --fa: "\f7e2"; -} - -.fa-fire-flame-curved { - --fa: "\f7e4"; -} - -.fa-fire-alt { - --fa: "\f7e4"; -} - -.fa-bacon { - --fa: "\f7e5"; -} - -.fa-book-medical { - --fa: "\f7e6"; -} - -.fa-book-user { - --fa: "\f7e7"; -} - -.fa-books-medical { - --fa: "\f7e8"; -} - -.fa-brackets-square { - --fa: "\f7e9"; -} - -.fa-brackets { - --fa: "\f7e9"; -} - -.fa-brackets-curly { - --fa: "\f7ea"; -} - -.fa-bread-loaf { - --fa: "\f7eb"; -} - -.fa-bread-slice { - --fa: "\f7ec"; -} - -.fa-burrito { - --fa: "\f7ed"; -} - -.fa-chart-scatter { - --fa: "\f7ee"; -} - -.fa-cheese { - --fa: "\f7ef"; -} - -.fa-cheese-swiss { - --fa: "\f7f0"; -} - -.fa-burger-cheese { - --fa: "\f7f1"; -} - -.fa-cheeseburger { - --fa: "\f7f1"; -} - -.fa-house-chimney-medical { - --fa: "\f7f2"; -} - -.fa-clinic-medical { - --fa: "\f7f2"; -} - -.fa-clipboard-user { - --fa: "\f7f3"; -} - -.fa-message-medical { - --fa: "\f7f4"; -} - -.fa-comment-alt-medical { - --fa: "\f7f4"; -} - -.fa-comment-medical { - --fa: "\f7f5"; -} - -.fa-croissant { - --fa: "\f7f6"; -} - -.fa-crutch { - --fa: "\f7f7"; -} - -.fa-crutches { - --fa: "\f7f8"; -} - -.fa-ban-bug { - --fa: "\f7f9"; -} - -.fa-debug { - --fa: "\f7f9"; -} - -.fa-disease { - --fa: "\f7fa"; -} - -.fa-egg { - --fa: "\f7fb"; -} - -.fa-egg-fried { - --fa: "\f7fc"; -} - -.fa-files-medical { - --fa: "\f7fd"; -} - -.fa-fish-cooked { - --fa: "\f7fe"; -} - -.fa-flower { - --fa: "\f7ff"; -} - -.fa-flower-daffodil { - --fa: "\f800"; -} - -.fa-flower-tulip { - --fa: "\f801"; -} - -.fa-folder-tree { - --fa: "\f802"; -} - -.fa-french-fries { - --fa: "\f803"; -} - -.fa-glass { - --fa: "\f804"; -} - -.fa-burger { - --fa: "\f805"; -} - -.fa-hamburger { - --fa: "\f805"; -} - -.fa-hand-middle-finger { - --fa: "\f806"; -} - -.fa-helmet-safety { - --fa: "\f807"; -} - -.fa-hard-hat { - --fa: "\f807"; -} - -.fa-hat-hard { - --fa: "\f807"; -} - -.fa-head-side-brain { - --fa: "\f808"; -} - -.fa-head-side-medical { - --fa: "\f809"; -} - -.fa-hospital-user { - --fa: "\f80d"; -} - -.fa-hospitals { - --fa: "\f80e"; -} - -.fa-hotdog { - --fa: "\f80f"; -} - -.fa-ice-cream { - --fa: "\f810"; -} - -.fa-island-tropical { - --fa: "\f811"; -} - -.fa-island-tree-palm { - --fa: "\f811"; -} - -.fa-laptop-medical { - --fa: "\f812"; -} - -.fa-mailbox { - --fa: "\f813"; -} - -.fa-meat { - --fa: "\f814"; -} - -.fa-pager { - --fa: "\f815"; -} - -.fa-pepper-hot { - --fa: "\f816"; -} - -.fa-pizza { - --fa: "\f817"; -} - -.fa-pizza-slice { - --fa: "\f818"; -} - -.fa-popcorn { - --fa: "\f819"; -} - -.fa-print-magnifying-glass { - --fa: "\f81a"; -} - -.fa-print-search { - --fa: "\f81a"; -} - -.fa-rings-wedding { - --fa: "\f81b"; -} - -.fa-sack { - --fa: "\f81c"; -} - -.fa-sack-dollar { - --fa: "\f81d"; -} - -.fa-salad { - --fa: "\f81e"; -} - -.fa-bowl-salad { - --fa: "\f81e"; -} - -.fa-sandwich { - --fa: "\f81f"; -} - -.fa-sausage { - --fa: "\f820"; -} - -.fa-shish-kebab { - --fa: "\f821"; -} - -.fa-sickle { - --fa: "\f822"; -} - -.fa-bowl-hot { - --fa: "\f823"; -} - -.fa-soup { - --fa: "\f823"; -} - -.fa-steak { - --fa: "\f824"; -} - -.fa-stretcher { - --fa: "\f825"; -} - -.fa-taco { - --fa: "\f826"; -} - -.fa-book-tanakh { - --fa: "\f827"; -} - -.fa-tanakh { - --fa: "\f827"; -} - -.fa-bars-progress { - --fa: "\f828"; -} - -.fa-tasks-alt { - --fa: "\f828"; -} - -.fa-trash-arrow-up { - --fa: "\f829"; -} - -.fa-trash-restore { - --fa: "\f829"; -} - -.fa-trash-can-arrow-up { - --fa: "\f82a"; -} - -.fa-trash-restore-alt { - --fa: "\f82a"; -} - -.fa-tree-palm { - --fa: "\f82b"; -} - -.fa-user-helmet-safety { - --fa: "\f82c"; -} - -.fa-user-construction { - --fa: "\f82c"; -} - -.fa-user-hard-hat { - --fa: "\f82c"; -} - -.fa-user-headset { - --fa: "\f82d"; -} - -.fa-user-doctor-message { - --fa: "\f82e"; -} - -.fa-user-md-chat { - --fa: "\f82e"; -} - -.fa-user-nurse { - --fa: "\f82f"; -} - -.fa-users-medical { - --fa: "\f830"; -} - -.fa-walker { - --fa: "\f831"; -} - -.fa-camera-web { - --fa: "\f832"; -} - -.fa-webcam { - --fa: "\f832"; -} - -.fa-camera-web-slash { - --fa: "\f833"; -} - -.fa-webcam-slash { - --fa: "\f833"; -} - -.fa-wave-square { - --fa: "\f83e"; -} - -.fa-alarm-exclamation { - --fa: "\f843"; -} - -.fa-alarm-plus { - --fa: "\f844"; -} - -.fa-alarm-snooze { - --fa: "\f845"; -} - -.fa-align-slash { - --fa: "\f846"; -} - -.fa-bags-shopping { - --fa: "\f847"; -} - -.fa-bell-exclamation { - --fa: "\f848"; -} - -.fa-bell-plus { - --fa: "\f849"; -} - -.fa-person-biking { - --fa: "\f84a"; -} - -.fa-biking { - --fa: "\f84a"; -} - -.fa-person-biking-mountain { - --fa: "\f84b"; -} - -.fa-biking-mountain { - --fa: "\f84b"; -} - -.fa-border-all { - --fa: "\f84c"; -} - -.fa-border-bottom { - --fa: "\f84d"; -} - -.fa-border-inner { - --fa: "\f84e"; -} - -.fa-border-left { - --fa: "\f84f"; -} - -.fa-border-none { - --fa: "\f850"; -} - -.fa-border-outer { - --fa: "\f851"; -} - -.fa-border-right { - --fa: "\f852"; -} - -.fa-border-top-left { - --fa: "\f853"; -} - -.fa-border-style { - --fa: "\f853"; -} - -.fa-border-bottom-right { - --fa: "\f854"; -} - -.fa-border-style-alt { - --fa: "\f854"; -} - -.fa-border-top { - --fa: "\f855"; -} - -.fa-bring-forward { - --fa: "\f856"; -} - -.fa-bring-front { - --fa: "\f857"; -} - -.fa-burger-soda { - --fa: "\f858"; -} - -.fa-car-building { - --fa: "\f859"; -} - -.fa-car-bus { - --fa: "\f85a"; -} - -.fa-cars { - --fa: "\f85b"; -} - -.fa-coin { - --fa: "\f85c"; -} - -.fa-triangle-person-digging { - --fa: "\f85d"; -} - -.fa-construction { - --fa: "\f85d"; -} - -.fa-person-digging { - --fa: "\f85e"; -} - -.fa-digging { - --fa: "\f85e"; -} - -.fa-drone { - --fa: "\f85f"; -} - -.fa-drone-front { - --fa: "\f860"; -} - -.fa-drone-alt { - --fa: "\f860"; -} - -.fa-dryer { - --fa: "\f861"; -} - -.fa-dryer-heat { - --fa: "\f862"; -} - -.fa-dryer-alt { - --fa: "\f862"; -} - -.fa-fan { - --fa: "\f863"; -} - -.fa-farm { - --fa: "\f864"; -} - -.fa-barn-silo { - --fa: "\f864"; -} - -.fa-file-magnifying-glass { - --fa: "\f865"; -} - -.fa-file-search { - --fa: "\f865"; -} - -.fa-font-case { - --fa: "\f866"; -} - -.fa-game-board { - --fa: "\f867"; -} - -.fa-game-board-simple { - --fa: "\f868"; -} - -.fa-game-board-alt { - --fa: "\f868"; -} - -.fa-glass-citrus { - --fa: "\f869"; -} - -.fa-h4 { - --fa: "\f86a"; -} - -.fa-hat-chef { - --fa: "\f86b"; -} - -.fa-horizontal-rule { - --fa: "\f86c"; -} - -.fa-icons { - --fa: "\f86d"; -} - -.fa-heart-music-camera-bolt { - --fa: "\f86d"; -} - -.fa-symbols { - --fa: "\f86e"; -} - -.fa-icons-alt { - --fa: "\f86e"; -} - -.fa-kerning { - --fa: "\f86f"; -} - -.fa-line-columns { - --fa: "\f870"; -} - -.fa-line-height { - --fa: "\f871"; -} - -.fa-money-check-pen { - --fa: "\f872"; -} - -.fa-money-check-edit { - --fa: "\f872"; -} - -.fa-money-check-dollar-pen { - --fa: "\f873"; -} - -.fa-money-check-edit-alt { - --fa: "\f873"; -} - -.fa-mug { - --fa: "\f874"; -} - -.fa-mug-tea { - --fa: "\f875"; -} - -.fa-overline { - --fa: "\f876"; -} - -.fa-file-dashed-line { - --fa: "\f877"; -} - -.fa-page-break { - --fa: "\f877"; -} - -.fa-paragraph-left { - --fa: "\f878"; -} - -.fa-paragraph-rtl { - --fa: "\f878"; -} - -.fa-phone-flip { - --fa: "\f879"; -} - -.fa-phone-alt { - --fa: "\f879"; -} - -.fa-laptop-mobile { - --fa: "\f87a"; -} - -.fa-phone-laptop { - --fa: "\f87a"; -} - -.fa-square-phone-flip { - --fa: "\f87b"; -} - -.fa-phone-square-alt { - --fa: "\f87b"; -} - -.fa-photo-film { - --fa: "\f87c"; -} - -.fa-photo-video { - --fa: "\f87c"; -} - -.fa-text-slash { - --fa: "\f87d"; -} - -.fa-remove-format { - --fa: "\f87d"; -} - -.fa-send-back { - --fa: "\f87e"; -} - -.fa-send-backward { - --fa: "\f87f"; -} - -.fa-snooze { - --fa: "\f880"; -} - -.fa-zzz { - --fa: "\f880"; -} - -.fa-arrow-down-z-a { - --fa: "\f881"; -} - -.fa-sort-alpha-desc { - --fa: "\f881"; -} - -.fa-sort-alpha-down-alt { - --fa: "\f881"; -} - -.fa-arrow-up-z-a { - --fa: "\f882"; -} - -.fa-sort-alpha-up-alt { - --fa: "\f882"; -} - -.fa-arrow-down-arrow-up { - --fa: "\f883"; -} - -.fa-sort-alt { - --fa: "\f883"; -} - -.fa-arrow-down-short-wide { - --fa: "\f884"; -} - -.fa-sort-amount-desc { - --fa: "\f884"; -} - -.fa-sort-amount-down-alt { - --fa: "\f884"; -} - -.fa-arrow-up-short-wide { - --fa: "\f885"; -} - -.fa-sort-amount-up-alt { - --fa: "\f885"; -} - -.fa-arrow-down-9-1 { - --fa: "\f886"; -} - -.fa-sort-numeric-desc { - --fa: "\f886"; -} - -.fa-sort-numeric-down-alt { - --fa: "\f886"; -} - -.fa-arrow-up-9-1 { - --fa: "\f887"; -} - -.fa-sort-numeric-up-alt { - --fa: "\f887"; -} - -.fa-arrow-down-triangle-square { - --fa: "\f888"; -} - -.fa-sort-shapes-down { - --fa: "\f888"; -} - -.fa-arrow-down-square-triangle { - --fa: "\f889"; -} - -.fa-sort-shapes-down-alt { - --fa: "\f889"; -} - -.fa-arrow-up-triangle-square { - --fa: "\f88a"; -} - -.fa-sort-shapes-up { - --fa: "\f88a"; -} - -.fa-arrow-up-square-triangle { - --fa: "\f88b"; -} - -.fa-sort-shapes-up-alt { - --fa: "\f88b"; -} - -.fa-arrow-down-big-small { - --fa: "\f88c"; -} - -.fa-sort-size-down { - --fa: "\f88c"; -} - -.fa-arrow-down-small-big { - --fa: "\f88d"; -} - -.fa-sort-size-down-alt { - --fa: "\f88d"; -} - -.fa-arrow-up-big-small { - --fa: "\f88e"; -} - -.fa-sort-size-up { - --fa: "\f88e"; -} - -.fa-arrow-up-small-big { - --fa: "\f88f"; -} - -.fa-sort-size-up-alt { - --fa: "\f88f"; -} - -.fa-sparkles { - --fa: "\f890"; -} - -.fa-spell-check { - --fa: "\f891"; -} - -.fa-sunglasses { - --fa: "\f892"; -} - -.fa-text { - --fa: "\f893"; -} - -.fa-text-size { - --fa: "\f894"; -} - -.fa-trash-undo { - --fa: "\f895"; -} - -.fa-trash-arrow-turn-left { - --fa: "\f895"; -} - -.fa-trash-can-undo { - --fa: "\f896"; -} - -.fa-trash-can-arrow-turn-left { - --fa: "\f896"; -} - -.fa-trash-undo-alt { - --fa: "\f896"; -} - -.fa-voicemail { - --fa: "\f897"; -} - -.fa-washing-machine { - --fa: "\f898"; -} - -.fa-washer { - --fa: "\f898"; -} - -.fa-wave-sine { - --fa: "\f899"; -} - -.fa-wave-triangle { - --fa: "\f89a"; -} - -.fa-wind-turbine { - --fa: "\f89b"; -} - -.fa-border-center-h { - --fa: "\f89c"; -} - -.fa-border-center-v { - --fa: "\f89d"; -} - -.fa-album { - --fa: "\f89f"; -} - -.fa-album-collection { - --fa: "\f8a0"; -} - -.fa-amp-guitar { - --fa: "\f8a1"; -} - -.fa-badge-sheriff { - --fa: "\f8a2"; -} - -.fa-banjo { - --fa: "\f8a3"; -} - -.fa-cassette-betamax { - --fa: "\f8a4"; -} - -.fa-betamax { - --fa: "\f8a4"; -} - -.fa-boombox { - --fa: "\f8a5"; -} - -.fa-cactus { - --fa: "\f8a7"; -} - -.fa-camcorder { - --fa: "\f8a8"; -} - -.fa-video-handheld { - --fa: "\f8a8"; -} - -.fa-camera-movie { - --fa: "\f8a9"; -} - -.fa-camera-polaroid { - --fa: "\f8aa"; -} - -.fa-cassette-tape { - --fa: "\f8ab"; -} - -.fa-camera-cctv { - --fa: "\f8ac"; -} - -.fa-cctv { - --fa: "\f8ac"; -} - -.fa-clarinet { - --fa: "\f8ad"; -} - -.fa-cloud-music { - --fa: "\f8ae"; -} - -.fa-message-music { - --fa: "\f8af"; -} - -.fa-comment-alt-music { - --fa: "\f8af"; -} - -.fa-comment-music { - --fa: "\f8b0"; -} - -.fa-computer-classic { - --fa: "\f8b1"; -} - -.fa-computer-speaker { - --fa: "\f8b2"; -} - -.fa-cowbell { - --fa: "\f8b3"; -} - -.fa-cowbell-circle-plus { - --fa: "\f8b4"; -} - -.fa-cowbell-more { - --fa: "\f8b4"; -} - -.fa-disc-drive { - --fa: "\f8b5"; -} - -.fa-file-music { - --fa: "\f8b6"; -} - -.fa-film-canister { - --fa: "\f8b7"; -} - -.fa-film-cannister { - --fa: "\f8b7"; -} - -.fa-flashlight { - --fa: "\f8b8"; -} - -.fa-flute { - --fa: "\f8b9"; -} - -.fa-flux-capacitor { - --fa: "\f8ba"; -} - -.fa-game-console-handheld { - --fa: "\f8bb"; -} - -.fa-gramophone { - --fa: "\f8bd"; -} - -.fa-guitar-electric { - --fa: "\f8be"; -} - -.fa-guitars { - --fa: "\f8bf"; -} - -.fa-hat-cowboy { - --fa: "\f8c0"; -} - -.fa-hat-cowboy-side { - --fa: "\f8c1"; -} - -.fa-head-side-headphones { - --fa: "\f8c2"; -} - -.fa-horse-saddle { - --fa: "\f8c3"; -} - -.fa-image-polaroid { - --fa: "\f8c4"; -} - -.fa-joystick { - --fa: "\f8c5"; -} - -.fa-jug { - --fa: "\f8c6"; -} - -.fa-kazoo { - --fa: "\f8c7"; -} - -.fa-lasso { - --fa: "\f8c8"; -} - -.fa-list-music { - --fa: "\f8c9"; -} - -.fa-microphone-stand { - --fa: "\f8cb"; -} - -.fa-computer-mouse { - --fa: "\f8cc"; -} - -.fa-mouse { - --fa: "\f8cc"; -} - -.fa-computer-mouse-scrollwheel { - --fa: "\f8cd"; -} - -.fa-mouse-alt { - --fa: "\f8cd"; -} - -.fa-mp3-player { - --fa: "\f8ce"; -} - -.fa-music-note { - --fa: "\f8cf"; -} - -.fa-music-alt { - --fa: "\f8cf"; -} - -.fa-music-note-slash { - --fa: "\f8d0"; -} - -.fa-music-alt-slash { - --fa: "\f8d0"; -} - -.fa-music-slash { - --fa: "\f8d1"; -} - -.fa-phone-rotary { - --fa: "\f8d3"; -} - -.fa-piano { - --fa: "\f8d4"; -} - -.fa-piano-keyboard { - --fa: "\f8d5"; -} - -.fa-projector { - --fa: "\f8d6"; -} - -.fa-radio { - --fa: "\f8d7"; -} - -.fa-radio-tuner { - --fa: "\f8d8"; -} - -.fa-radio-alt { - --fa: "\f8d8"; -} - -.fa-record-vinyl { - --fa: "\f8d9"; -} - -.fa-router { - --fa: "\f8da"; -} - -.fa-saxophone-fire { - --fa: "\f8db"; -} - -.fa-sax-hot { - --fa: "\f8db"; -} - -.fa-saxophone { - --fa: "\f8dc"; -} - -.fa-signal-stream { - --fa: "\f8dd"; -} - -.fa-skull-cow { - --fa: "\f8de"; -} - -.fa-speaker { - --fa: "\f8df"; -} - -.fa-speakers { - --fa: "\f8e0"; -} - -.fa-triangle-instrument { - --fa: "\f8e2"; -} - -.fa-triangle-music { - --fa: "\f8e2"; -} - -.fa-trumpet { - --fa: "\f8e3"; -} - -.fa-turntable { - --fa: "\f8e4"; -} - -.fa-tv-music { - --fa: "\f8e6"; -} - -.fa-typewriter { - --fa: "\f8e7"; -} - -.fa-usb-drive { - --fa: "\f8e9"; -} - -.fa-user-cowboy { - --fa: "\f8ea"; -} - -.fa-user-music { - --fa: "\f8eb"; -} - -.fa-cassette-vhs { - --fa: "\f8ec"; -} - -.fa-vhs { - --fa: "\f8ec"; -} - -.fa-violin { - --fa: "\f8ed"; -} - -.fa-wagon-covered { - --fa: "\f8ee"; -} - -.fa-walkie-talkie { - --fa: "\f8ef"; -} - -.fa-watch-calculator { - --fa: "\f8f0"; -} - -.fa-waveform { - --fa: "\f8f1"; -} - -.fa-waveform-lines { - --fa: "\f8f2"; -} - -.fa-waveform-path { - --fa: "\f8f2"; -} - -.fa-scanner-image { - --fa: "\f8f3"; -} - -.fa-air-conditioner { - --fa: "\f8f4"; -} - -.fa-alien { - --fa: "\f8f5"; -} - -.fa-alien-8bit { - --fa: "\f8f6"; -} - -.fa-alien-monster { - --fa: "\f8f6"; -} - -.fa-bed-front { - --fa: "\f8f7"; -} - -.fa-bed-alt { - --fa: "\f8f7"; -} - -.fa-bed-bunk { - --fa: "\f8f8"; -} - -.fa-bed-empty { - --fa: "\f8f9"; -} - -.fa-bell-on { - --fa: "\f8fa"; -} - -.fa-blinds { - --fa: "\f8fb"; -} - -.fa-blinds-open { - --fa: "\f8fc"; -} - -.fa-blinds-raised { - --fa: "\f8fd"; -} - -.fa-camera-security { - --fa: "\f8fe"; -} - -.fa-camera-home { - --fa: "\f8fe"; -} - -.fa-caravan { - --fa: "\f8ff"; -} diff --git a/public/vendor/fontawesome/css/fontawesome.min.css b/public/vendor/fontawesome/css/fontawesome.min.css deleted file mode 100644 index 85923c8..0000000 --- a/public/vendor/fontawesome/css/fontawesome.min.css +++ /dev/null @@ -1,8 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -.fa,.fa-brands,.fa-chisel,.fa-classic,.fa-duotone,.fa-etch,.fa-jelly,.fa-jelly-duo,.fa-jelly-fill,.fa-light,.fa-notdog,.fa-notdog-duo,.fa-regular,.fa-semibold,.fa-sharp,.fa-sharp-duotone,.fa-slab,.fa-slab-press,.fa-solid,.fa-thin,.fa-thumbprint,.fa-utility,.fa-utility-duo,.fa-utility-fill,.fa-whiteboard,.fab,.facr,.fad,.fadl,.fadr,.fadt,.faes,.fajdr,.fajfr,.fajr,.fal,.fands,.fans,.far,.fas,.fasdl,.fasdr,.fasds,.fasdt,.fasl,.faslpr,.faslr,.fasr,.fass,.fast,.fat,.fatl,.faudsb,.faufsb,.fausb,.fawsb{--_fa-family:var(--fa-family,var(--fa-style-family,"Font Awesome 7 Pro"));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;display:var(--fa-display,inline-block);font-family:var(--_fa-family);font-feature-settings:normal;font-style:normal;font-synthesis:none;font-variant:normal;font-weight:var(--fa-style,900);line-height:1;text-align:center;text-rendering:auto;width:var(--fa-width,1.25em)}:is(.fas,.fass,.far,.fasr,.fal,.fasl,.fat,.fast,.fad,.fadr,.fadl,.fadt,.fasds,.fasdr,.fasdl,.fasdt,.fab,.faslr,.faslpr,.fawsb,.fatl,.fans,.fands,.faes,.fajr,.fajfr,.fajdr,.facr,.fausb,.faudsb,.faufsb,.fa-solid,.fa-semibold,.fa-regular,.fa-light,.fa-thin,.fa-brands,.fa-classic,.fa-duotone,.fa-sharp,.fa-sharp-duotone,.fa-chisel,.fa-etch,.fa-jelly,.fa-jelly-duo,.fa-jelly-fill,.fa-notdog,.fa-notdog-duo,.fa-slab,.fa-slab-press,.fa-thumbprint,.fa-utility,.fa-utility-duo,.fa-utility-fill,.fa-whiteboard,.fa):before{content:var(--fa)/""}@supports not (content:""/""){:is(.fas,.fass,.far,.fasr,.fal,.fasl,.fat,.fast,.fad,.fadr,.fadl,.fadt,.fasds,.fasdr,.fasdl,.fasdt,.fab,.faslr,.faslpr,.fawsb,.fatl,.fans,.fands,.faes,.fajr,.fajfr,.fajdr,.facr,.fausb,.faudsb,.faufsb,.fa-solid,.fa-semibold,.fa-regular,.fa-light,.fa-thin,.fa-brands,.fa-classic,.fa-duotone,.fa-sharp,.fa-sharp-duotone,.fa-chisel,.fa-etch,.fa-jelly,.fa-jelly-duo,.fa-jelly-fill,.fa-notdog,.fa-notdog-duo,.fa-slab,.fa-slab-press,.fa-thumbprint,.fa-utility,.fa-utility-duo,.fa-utility-fill,.fa-whiteboard,.fa):before{content:var(--fa)}}:is(.fad,.fa-duotone,.fadr,.fadl,.fadt,.fasds,.fa-sharp-duotone,.fasdr,.fasdl,.fasdt,.fatl,.fa-thumbprint,.fands,.fa-notdog-duo,.fajdr,.fa-jelly-duo,.faudsb,.fa-utility-duo):after{content:var(--fa);font-feature-settings:"ss01"}@supports not (content:""/""){:is(.fad,.fa-duotone,.fadr,.fadl,.fadt,.fasds,.fa-sharp-duotone,.fasdr,.fasdl,.fasdt,.fatl,.fa-thumbprint,.fands,.fa-notdog-duo,.fajdr,.fa-jelly-duo,.faudsb,.fa-utility-duo):after{content:var(--fa)}}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-2xs{font-size:.625em;line-height:.1em;vertical-align:.225em}.fa-xs{font-size:.75em;line-height:.08333em;vertical-align:.125em}.fa-sm{font-size:.875em;line-height:.07143em;vertical-align:.05357em}.fa-lg{font-size:1.25em;line-height:.05em;vertical-align:-.075em}.fa-xl{font-size:1.5em;line-height:.04167em;vertical-align:-.125em}.fa-2xl{font-size:2em;line-height:.03125em;vertical-align:-.1875em}.fa-width-auto{--fa-width:auto}.fa-fw,.fa-width-fixed{--fa-width:1.25em}.fa-ul{list-style-type:none;margin-inline-start:var(--fa-li-margin,2.5em);padding-inline-start:0}.fa-ul>li{position:relative}.fa-li{inset-inline-start:calc(var(--fa-li-width, 2em)*-1);position:absolute;text-align:center;width:var(--fa-li-width,2em);line-height:inherit}.fa-border{border-radius:var(--fa-border-radius,.1em);border:var(--fa-border-width,.0625em) var(--fa-border-style,solid) var(--fa-border-color,#eee);box-sizing:var(--fa-border-box-sizing,content-box);padding:var(--fa-border-padding,.1875em .25em)}.fa-pull-left,.fa-pull-start{float:inline-start;margin-inline-end:var(--fa-pull-margin,.3em)}.fa-pull-end,.fa-pull-right{float:inline-end;margin-inline-start:var(--fa-pull-margin,.3em)}.fa-beat{animation-name:fa-beat;animation-delay:var(--fa-animation-delay,0s);animation-direction:var(--fa-animation-direction,normal);animation-duration:var(--fa-animation-duration,1s);animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-bounce{animation-name:fa-bounce;animation-delay:var(--fa-animation-delay,0s);animation-direction:var(--fa-animation-direction,normal);animation-duration:var(--fa-animation-duration,1s);animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1))}.fa-fade{animation-name:fa-fade;animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-beat-fade,.fa-fade{animation-delay:var(--fa-animation-delay,0s);animation-direction:var(--fa-animation-direction,normal);animation-duration:var(--fa-animation-duration,1s)}.fa-beat-fade{animation-name:fa-beat-fade;animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-flip{animation-name:fa-flip;animation-delay:var(--fa-animation-delay,0s);animation-direction:var(--fa-animation-direction,normal);animation-duration:var(--fa-animation-duration,1s);animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-shake{animation-name:fa-shake;animation-duration:var(--fa-animation-duration,1s);animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,linear)}.fa-shake,.fa-spin{animation-delay:var(--fa-animation-delay,0s);animation-direction:var(--fa-animation-direction,normal)}.fa-spin{animation-name:fa-spin;animation-duration:var(--fa-animation-duration,2s);animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,linear)}.fa-spin-reverse{--fa-animation-direction:reverse}.fa-pulse,.fa-spin-pulse{animation-name:fa-spin;animation-direction:var(--fa-animation-direction,normal);animation-duration:var(--fa-animation-duration,1s);animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,steps(8))}@media (prefers-reduced-motion:reduce){.fa-beat,.fa-beat-fade,.fa-bounce,.fa-fade,.fa-flip,.fa-pulse,.fa-shake,.fa-spin,.fa-spin-pulse{animation:none!important;transition:none!important}}@keyframes fa-beat{0%,90%{transform:scale(1)}45%{transform:scale(var(--fa-beat-scale,1.25))}}@keyframes fa-bounce{0%{transform:scale(1) translateY(0)}10%{transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em))}64%{transform:scale(1) translateY(0)}to{transform:scale(1) translateY(0)}}@keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);transform:scale(1)}50%{opacity:1;transform:scale(var(--fa-beat-fade-scale,1.125))}}@keyframes fa-flip{50%{transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@keyframes fa-shake{0%{transform:rotate(-15deg)}4%{transform:rotate(15deg)}8%,24%{transform:rotate(-18deg)}12%,28%{transform:rotate(18deg)}16%{transform:rotate(-22deg)}20%{transform:rotate(22deg)}32%{transform:rotate(-12deg)}36%{transform:rotate(12deg)}40%,to{transform:rotate(0deg)}}@keyframes fa-spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}.fa-rotate-90{transform:rotate(90deg)}.fa-rotate-180{transform:rotate(180deg)}.fa-rotate-270{transform:rotate(270deg)}.fa-flip-horizontal{transform:scaleX(-1)}.fa-flip-vertical{transform:scaleY(-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{transform:scale(-1)}.fa-rotate-by{transform:rotate(var(--fa-rotate-angle,0))}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{--fa-width:100%;inset:0;position:absolute;text-align:center;width:var(--fa-width);z-index:var(--fa-stack-z-index,auto)}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:var(--fa-inverse,#fff)} - -.fa-0{--fa:"\30 "}.fa-1{--fa:"\31 "}.fa-2{--fa:"\32 "}.fa-3{--fa:"\33 "}.fa-4{--fa:"\34 "}.fa-5{--fa:"\35 "}.fa-6{--fa:"\36 "}.fa-7{--fa:"\37 "}.fa-8{--fa:"\38 "}.fa-9{--fa:"\39 "}.fa-exclamation{--fa:"\!"}.fa-ditto{--fa:'"'}.fa-hashtag{--fa:"\#"}.fa-dollar,.fa-dollar-sign,.fa-usd{--fa:"\$"}.fa-percent,.fa-percentage{--fa:"\%"}.fa-ampersand{--fa:"\&"}.fa-apostrophe{--fa:"'"}.fa-bracket-round,.fa-parenthesis{--fa:"\("}.fa-bracket-round-right{--fa:"\)"}.fa-asterisk{--fa:"\*"}.fa-add,.fa-plus{--fa:"\+"}.fa-comma{--fa:"\,"}.fa-hyphen{--fa:"\-"}.fa-period{--fa:"\."}.fa-slash-forward{--fa:"\/"}.fa-colon{--fa:"\:"}.fa-semicolon{--fa:"\;"}.fa-less-than{--fa:"\<"}.fa-equals{--fa:"\="}.fa-greater-than{--fa:"\>"}.fa-question{--fa:"\?"}.fa-at{--fa:"\@"}.fa-a{--fa:"A"}.fa-b{--fa:"B"}.fa-c{--fa:"C"}.fa-d{--fa:"D"}.fa-e{--fa:"E"}.fa-f{--fa:"F"}.fa-g{--fa:"G"}.fa-h{--fa:"H"}.fa-i{--fa:"I"}.fa-j{--fa:"J"}.fa-k{--fa:"K"}.fa-l{--fa:"L"}.fa-m{--fa:"M"}.fa-n{--fa:"N"}.fa-o{--fa:"O"}.fa-p{--fa:"P"}.fa-q{--fa:"Q"}.fa-r{--fa:"R"}.fa-s{--fa:"S"}.fa-t{--fa:"T"}.fa-u{--fa:"U"}.fa-v{--fa:"V"}.fa-w{--fa:"W"}.fa-x{--fa:"X"}.fa-y{--fa:"Y"}.fa-z{--fa:"Z"}.fa-bracket,.fa-bracket-left,.fa-bracket-square{--fa:"\["}.fa-slash-back{--fa:"\\"}.fa-bracket-square-right{--fa:"\]"}.fa-accent-grave{--fa:"\`"}.fa-bracket-curly,.fa-bracket-curly-left{--fa:"\{"}.fa-pipe{--fa:"\|"}.fa-bracket-curly-right{--fa:"\}"}.fa-tilde{--fa:"\~"}.fa-caravan-alt,.fa-caravan-simple{--fa:"\e000"}.fa-cat-space{--fa:"\e001"}.fa-coffee-pot{--fa:"\e002"}.fa-comet{--fa:"\e003"}.fa-fan-table{--fa:"\e004"}.fa-faucet{--fa:"\e005"}.fa-faucet-drip{--fa:"\e006"}.fa-galaxy{--fa:"\e008"}.fa-garage{--fa:"\e009"}.fa-garage-car{--fa:"\e00a"}.fa-garage-open{--fa:"\e00b"}.fa-heat{--fa:"\e00c"}.fa-house-chimney-window{--fa:"\e00d"}.fa-house-day{--fa:"\e00e"}.fa-house-leave,.fa-house-person-depart,.fa-house-person-leave{--fa:"\e00f"}.fa-house-night{--fa:"\e010"}.fa-house-person-arrive,.fa-house-person-return,.fa-house-return{--fa:"\e011"}.fa-house-signal{--fa:"\e012"}.fa-lamp-desk{--fa:"\e014"}.fa-lamp-floor{--fa:"\e015"}.fa-light-ceiling{--fa:"\e016"}.fa-light-switch{--fa:"\e017"}.fa-light-switch-off{--fa:"\e018"}.fa-light-switch-on{--fa:"\e019"}.fa-microwave{--fa:"\e01b"}.fa-outlet{--fa:"\e01c"}.fa-oven{--fa:"\e01d"}.fa-planet-moon{--fa:"\e01f"}.fa-planet-ringed{--fa:"\e020"}.fa-police-box{--fa:"\e021"}.fa-person-to-portal,.fa-portal-enter{--fa:"\e022"}.fa-person-from-portal,.fa-portal-exit{--fa:"\e023"}.fa-radar{--fa:"\e024"}.fa-raygun{--fa:"\e025"}.fa-refrigerator{--fa:"\e026"}.fa-rocket-launch{--fa:"\e027"}.fa-sensor{--fa:"\e028"}.fa-sensor-alert,.fa-sensor-triangle-exclamation{--fa:"\e029"}.fa-sensor-fire{--fa:"\e02a"}.fa-sensor-on{--fa:"\e02b"}.fa-sensor-cloud,.fa-sensor-smoke{--fa:"\e02c"}.fa-siren{--fa:"\e02d"}.fa-siren-on{--fa:"\e02e"}.fa-solar-system{--fa:"\e02f"}.fa-circle-sort,.fa-sort-circle{--fa:"\e030"}.fa-circle-sort-down,.fa-sort-circle-down{--fa:"\e031"}.fa-circle-sort-up,.fa-sort-circle-up{--fa:"\e032"}.fa-space-station-moon{--fa:"\e033"}.fa-space-station-moon-alt,.fa-space-station-moon-construction{--fa:"\e034"}.fa-sprinkler{--fa:"\e035"}.fa-star-shooting{--fa:"\e036"}.fa-starfighter{--fa:"\e037"}.fa-starfighter-alt,.fa-starfighter-twin-ion-engine{--fa:"\e038"}.fa-starship{--fa:"\e039"}.fa-starship-freighter{--fa:"\e03a"}.fa-sword-laser{--fa:"\e03b"}.fa-sword-laser-alt{--fa:"\e03c"}.fa-swords-laser{--fa:"\e03d"}.fa-telescope{--fa:"\e03e"}.fa-temperature-arrow-down,.fa-temperature-down{--fa:"\e03f"}.fa-temperature-arrow-up,.fa-temperature-up{--fa:"\e040"}.fa-trailer{--fa:"\e041"}.fa-transporter{--fa:"\e042"}.fa-transporter-1{--fa:"\e043"}.fa-transporter-2{--fa:"\e044"}.fa-transporter-3{--fa:"\e045"}.fa-transporter-empty{--fa:"\e046"}.fa-ufo{--fa:"\e047"}.fa-ufo-beam{--fa:"\e048"}.fa-user-alien{--fa:"\e04a"}.fa-user-robot{--fa:"\e04b"}.fa-user-visor{--fa:"\e04c"}.fa-vacuum{--fa:"\e04d"}.fa-vacuum-robot{--fa:"\e04e"}.fa-window-frame{--fa:"\e04f"}.fa-window-frame-open{--fa:"\e050"}.fa-coffin-cross{--fa:"\e051"}.fa-folder-arrow-down,.fa-folder-download{--fa:"\e053"}.fa-folder-arrow-up,.fa-folder-upload{--fa:"\e054"}.fa-user-unlock{--fa:"\e058"}.fa-bacteria{--fa:"\e059"}.fa-bacterium{--fa:"\e05a"}.fa-box-tissue{--fa:"\e05b"}.fa-hand-holding-medical{--fa:"\e05c"}.fa-hand-sparkles{--fa:"\e05d"}.fa-hands-bubbles,.fa-hands-wash{--fa:"\e05e"}.fa-handshake-alt-slash,.fa-handshake-simple-slash,.fa-handshake-slash{--fa:"\e060"}.fa-head-side-cough{--fa:"\e061"}.fa-head-side-cough-slash{--fa:"\e062"}.fa-head-side-mask{--fa:"\e063"}.fa-head-side-virus{--fa:"\e064"}.fa-house-chimney-user{--fa:"\e065"}.fa-house-laptop,.fa-laptop-house{--fa:"\e066"}.fa-lungs-virus{--fa:"\e067"}.fa-people-arrows,.fa-people-arrows-left-right{--fa:"\e068"}.fa-plane-slash{--fa:"\e069"}.fa-pump-medical{--fa:"\e06a"}.fa-pump-soap{--fa:"\e06b"}.fa-shield-virus{--fa:"\e06c"}.fa-sink{--fa:"\e06d"}.fa-soap{--fa:"\e06e"}.fa-stopwatch-20{--fa:"\e06f"}.fa-shop-slash,.fa-store-alt-slash{--fa:"\e070"}.fa-store-slash{--fa:"\e071"}.fa-toilet-paper-slash{--fa:"\e072"}.fa-users-slash{--fa:"\e073"}.fa-virus{--fa:"\e074"}.fa-virus-slash{--fa:"\e075"}.fa-viruses{--fa:"\e076"}.fa-vest{--fa:"\e085"}.fa-vest-patches{--fa:"\e086"}.fa-airplay{--fa:"\e089"}.fa-alt{--fa:"\e08a"}.fa-angle{--fa:"\e08c"}.fa-angle-90{--fa:"\e08d"}.fa-apple-core{--fa:"\e08f"}.fa-arrow-down-from-dotted-line{--fa:"\e090"}.fa-arrow-down-left{--fa:"\e091"}.fa-arrow-down-left-and-arrow-up-right-to-center{--fa:"\e092"}.fa-arrow-down-right{--fa:"\e093"}.fa-arrow-down-to-bracket{--fa:"\e094"}.fa-arrow-down-to-dotted-line{--fa:"\e095"}.fa-arrow-down-to-square{--fa:"\e096"}.fa-arrow-trend-down{--fa:"\e097"}.fa-arrow-trend-up{--fa:"\e098"}.fa-arrow-up-arrow-down,.fa-sort-up-down{--fa:"\e099"}.fa-arrow-up-from-bracket{--fa:"\e09a"}.fa-arrow-up-from-dotted-line{--fa:"\e09b"}.fa-arrow-up-from-square{--fa:"\e09c"}.fa-arrow-up-left{--fa:"\e09d"}.fa-arrow-up-left-from-circle{--fa:"\e09e"}.fa-arrow-up-right{--fa:"\e09f"}.fa-arrow-up-right-and-arrow-down-left-from-center{--fa:"\e0a0"}.fa-arrow-up-to-dotted-line{--fa:"\e0a1"}.fa-arrows-cross{--fa:"\e0a2"}.fa-arrows-from-dotted-line{--fa:"\e0a3"}.fa-arrows-from-line{--fa:"\e0a4"}.fa-arrows-minimize,.fa-compress-arrows{--fa:"\e0a5"}.fa-arrows-to-dotted-line{--fa:"\e0a6"}.fa-arrows-to-line{--fa:"\e0a7"}.fa-audio-description-slash{--fa:"\e0a8"}.fa-austral-sign{--fa:"\e0a9"}.fa-avocado{--fa:"\e0aa"}.fa-award-simple{--fa:"\e0ab"}.fa-baht-sign{--fa:"\e0ac"}.fa-bars-filter{--fa:"\e0ad"}.fa-bars-sort{--fa:"\e0ae"}.fa-basket-shopping-simple,.fa-shopping-basket-alt{--fa:"\e0af"}.fa-battery-exclamation{--fa:"\e0b0"}.fa-battery-1,.fa-battery-low{--fa:"\e0b1"}.fa-bee{--fa:"\e0b2"}.fa-beer-foam,.fa-beer-mug{--fa:"\e0b3"}.fa-bitcoin-sign{--fa:"\e0b4"}.fa-block-quote{--fa:"\e0b5"}.fa-bolt-auto{--fa:"\e0b6"}.fa-bolt-lightning{--fa:"\e0b7"}.fa-bolt-slash{--fa:"\e0b8"}.fa-book-arrow-right{--fa:"\e0b9"}.fa-book-arrow-up{--fa:"\e0ba"}.fa-book-bookmark{--fa:"\e0bb"}.fa-book-circle-arrow-right{--fa:"\e0bc"}.fa-book-circle-arrow-up{--fa:"\e0bd"}.fa-book-copy{--fa:"\e0be"}.fa-book-font{--fa:"\e0bf"}.fa-book-open-alt,.fa-book-open-cover{--fa:"\e0c0"}.fa-book-law,.fa-book-section{--fa:"\e0c1"}.fa-bookmark-slash{--fa:"\e0c2"}.fa-bowling-ball-pin{--fa:"\e0c3"}.fa-box-circle-check{--fa:"\e0c4"}.fa-brackets-round,.fa-parentheses{--fa:"\e0c5"}.fa-brain-circuit{--fa:"\e0c6"}.fa-brake-warning{--fa:"\e0c7"}.fa-briefcase-blank{--fa:"\e0c8"}.fa-brightness{--fa:"\e0c9"}.fa-brightness-low{--fa:"\e0ca"}.fa-browsers{--fa:"\e0cb"}.fa-buildings{--fa:"\e0cc"}.fa-burger-fries{--fa:"\e0cd"}.fa-burger-glass{--fa:"\e0ce"}.fa-calendar-arrow-down,.fa-calendar-download{--fa:"\e0d0"}.fa-calendar-arrow-up,.fa-calendar-upload{--fa:"\e0d1"}.fa-calendar-clock,.fa-calendar-time{--fa:"\e0d2"}.fa-calendar-heart{--fa:"\e0d3"}.fa-calendar-image{--fa:"\e0d4"}.fa-calendar-lines,.fa-calendar-note{--fa:"\e0d5"}.fa-calendar-range{--fa:"\e0d6"}.fa-calendars{--fa:"\e0d7"}.fa-camera-rotate{--fa:"\e0d8"}.fa-camera-slash{--fa:"\e0d9"}.fa-camera-viewfinder,.fa-screenshot{--fa:"\e0da"}.fa-cart-minus{--fa:"\e0db"}.fa-cart-shopping-fast{--fa:"\e0dc"}.fa-cart-xmark{--fa:"\e0dd"}.fa-castle{--fa:"\e0de"}.fa-cedi-sign{--fa:"\e0df"}.fa-chart-bullet{--fa:"\e0e1"}.fa-chart-candlestick{--fa:"\e0e2"}.fa-chart-column{--fa:"\e0e3"}.fa-chart-gantt{--fa:"\e0e4"}.fa-chart-line-up{--fa:"\e0e5"}.fa-chart-pyramid{--fa:"\e0e6"}.fa-chart-radar{--fa:"\e0e7"}.fa-chart-scatter-3d{--fa:"\e0e8"}.fa-chart-scatter-bubble{--fa:"\e0e9"}.fa-chart-tree-map{--fa:"\e0ea"}.fa-chart-waterfall{--fa:"\e0eb"}.fa-cherries{--fa:"\e0ec"}.fa-circle-0{--fa:"\e0ed"}.fa-circle-1{--fa:"\e0ee"}.fa-circle-2{--fa:"\e0ef"}.fa-circle-3{--fa:"\e0f0"}.fa-circle-4{--fa:"\e0f1"}.fa-circle-5{--fa:"\e0f2"}.fa-circle-6{--fa:"\e0f3"}.fa-circle-7{--fa:"\e0f4"}.fa-circle-8{--fa:"\e0f5"}.fa-circle-9{--fa:"\e0f6"}.fa-circle-a{--fa:"\e0f7"}.fa-circle-ampersand{--fa:"\e0f8"}.fa-circle-arrow-down-left{--fa:"\e0f9"}.fa-circle-arrow-down-right{--fa:"\e0fa"}.fa-circle-arrow-up-left{--fa:"\e0fb"}.fa-circle-arrow-up-right{--fa:"\e0fc"}.fa-circle-b{--fa:"\e0fd"}.fa-circle-bolt{--fa:"\e0fe"}.fa-book-circle,.fa-circle-book-open{--fa:"\e0ff"}.fa-bookmark-circle,.fa-circle-bookmark{--fa:"\e100"}.fa-circle-c{--fa:"\e101"}.fa-calendar-circle,.fa-circle-calendar{--fa:"\e102"}.fa-camera-circle,.fa-circle-camera{--fa:"\e103"}.fa-circle-d{--fa:"\e104"}.fa-circle-dashed{--fa:"\e105"}.fa-circle-divide{--fa:"\e106"}.fa-circle-down-left{--fa:"\e107"}.fa-circle-down-right{--fa:"\e108"}.fa-circle-e{--fa:"\e109"}.fa-circle-ellipsis{--fa:"\e10a"}.fa-circle-ellipsis-vertical{--fa:"\e10b"}.fa-circle-envelope,.fa-envelope-circle{--fa:"\e10c"}.fa-circle-exclamation-check{--fa:"\e10d"}.fa-circle-f{--fa:"\e10e"}.fa-circle-g{--fa:"\e10f"}.fa-circle-half{--fa:"\e110"}.fa-circle-i{--fa:"\e111"}.fa-circle-j{--fa:"\e112"}.fa-circle-k{--fa:"\e113"}.fa-circle-l{--fa:"\e114"}.fa-circle-m{--fa:"\e115"}.fa-circle-microphone,.fa-microphone-circle{--fa:"\e116"}.fa-circle-microphone-lines,.fa-microphone-circle-alt{--fa:"\e117"}.fa-circle-n{--fa:"\e118"}.fa-circle-o{--fa:"\e119"}.fa-circle-p{--fa:"\e11a"}.fa-circle-phone,.fa-phone-circle{--fa:"\e11b"}.fa-circle-phone-flip,.fa-phone-circle-alt{--fa:"\e11c"}.fa-circle-phone-hangup,.fa-phone-circle-down{--fa:"\e11d"}.fa-circle-q{--fa:"\e11e"}.fa-circle-quarter{--fa:"\e11f"}.fa-circle-r{--fa:"\e120"}.fa-circle-s{--fa:"\e121"}.fa-circle-small{--fa:"\e122"}.fa-circle-star,.fa-star-circle{--fa:"\e123"}.fa-circle-t{--fa:"\e124"}.fa-circle-three-quarters{--fa:"\e125"}.fa-circle-trash,.fa-trash-circle{--fa:"\e126"}.fa-circle-u{--fa:"\e127"}.fa-circle-up-left{--fa:"\e128"}.fa-circle-up-right{--fa:"\e129"}.fa-circle-v{--fa:"\e12a"}.fa-circle-video,.fa-video-circle{--fa:"\e12b"}.fa-circle-w{--fa:"\e12c"}.fa-circle-waveform-lines,.fa-waveform-circle{--fa:"\e12d"}.fa-circle-x{--fa:"\e12e"}.fa-circle-y{--fa:"\e12f"}.fa-circle-z{--fa:"\e130"}.fa-clapperboard{--fa:"\e131"}.fa-clapperboard-play{--fa:"\e132"}.fa-clipboard-medical{--fa:"\e133"}.fa-clock-desk{--fa:"\e134"}.fa-closed-captioning-slash{--fa:"\e135"}.fa-clothes-hanger{--fa:"\e136"}.fa-cloud-slash{--fa:"\e137"}.fa-cloud-word{--fa:"\e138"}.fa-clover{--fa:"\e139"}.fa-code-compare{--fa:"\e13a"}.fa-code-fork{--fa:"\e13b"}.fa-code-pull-request{--fa:"\e13c"}.fa-code-simple{--fa:"\e13d"}.fa-coffee-bean{--fa:"\e13e"}.fa-coffee-beans{--fa:"\e13f"}.fa-colon-sign{--fa:"\e140"}.fa-command{--fa:"\e142"}.fa-comment-arrow-down{--fa:"\e143"}.fa-comment-arrow-up{--fa:"\e144"}.fa-comment-arrow-up-right{--fa:"\e145"}.fa-comment-captions{--fa:"\e146"}.fa-comment-code{--fa:"\e147"}.fa-comment-image{--fa:"\e148"}.fa-comment-middle{--fa:"\e149"}.fa-comment-middle-top{--fa:"\e14a"}.fa-comment-question{--fa:"\e14b"}.fa-comment-quote{--fa:"\e14c"}.fa-comment-text{--fa:"\e14d"}.fa-comments-question{--fa:"\e14e"}.fa-comments-question-check{--fa:"\e14f"}.fa-conveyor-belt-empty{--fa:"\e150"}.fa-crate-empty{--fa:"\e151"}.fa-cruzeiro-sign{--fa:"\e152"}.fa-delete-right{--fa:"\e154"}.fa-desktop-arrow-down{--fa:"\e155"}.fa-diagram-lean-canvas{--fa:"\e156"}.fa-diagram-nested{--fa:"\e157"}.fa-diagram-sankey{--fa:"\e158"}.fa-diagram-venn{--fa:"\e15a"}.fa-dial,.fa-dial-med-high{--fa:"\e15b"}.fa-dial-high{--fa:"\e15c"}.fa-dial-low{--fa:"\e15d"}.fa-dial-max{--fa:"\e15e"}.fa-dial-med{--fa:"\e15f"}.fa-dial-med-low{--fa:"\e160"}.fa-dial-min{--fa:"\e161"}.fa-dial-off{--fa:"\e162"}.fa-display{--fa:"\e163"}.fa-display-arrow-down{--fa:"\e164"}.fa-desktop-code,.fa-display-code{--fa:"\e165"}.fa-desktop-medical,.fa-display-medical{--fa:"\e166"}.fa-dolphin{--fa:"\e168"}.fa-dong-sign{--fa:"\e169"}.fa-down-left{--fa:"\e16a"}.fa-down-right{--fa:"\e16b"}.fa-eggplant{--fa:"\e16c"}.fa-elevator{--fa:"\e16d"}.fa-engine{--fa:"\e16e"}.fa-envelope-badge,.fa-envelope-dot{--fa:"\e16f"}.fa-envelopes{--fa:"\e170"}.fa-escalator{--fa:"\e171"}.fa-eye-dropper-full{--fa:"\e172"}.fa-eye-dropper-half{--fa:"\e173"}.fa-ferris-wheel{--fa:"\e174"}.fa-file-binary{--fa:"\e175"}.fa-file-heart{--fa:"\e176"}.fa-file-plus-minus{--fa:"\e177"}.fa-files{--fa:"\e178"}.fa-film-slash{--fa:"\e179"}.fa-films{--fa:"\e17a"}.fa-filter-circle-xmark{--fa:"\e17b"}.fa-filter-list{--fa:"\e17c"}.fa-filter-slash{--fa:"\e17d"}.fa-filters{--fa:"\e17e"}.fa-fire-hydrant{--fa:"\e17f"}.fa-floppy-disk-circle-arrow-right,.fa-save-circle-arrow-right{--fa:"\e180"}.fa-floppy-disk-circle-xmark,.fa-floppy-disk-times,.fa-save-circle-xmark,.fa-save-times{--fa:"\e181"}.fa-floppy-disk-pen{--fa:"\e182"}.fa-floppy-disks{--fa:"\e183"}.fa-florin-sign{--fa:"\e184"}.fa-folder-closed{--fa:"\e185"}.fa-folder-bookmark{--fa:"\e186"}.fa-folder-cog,.fa-folder-gear{--fa:"\e187"}.fa-folder-grid{--fa:"\e188"}.fa-folder-heart{--fa:"\e189"}.fa-folder-image{--fa:"\e18a"}.fa-folder-magnifying-glass,.fa-folder-search{--fa:"\e18b"}.fa-folder-medical{--fa:"\e18c"}.fa-folder-music{--fa:"\e18d"}.fa-folder-user{--fa:"\e18e"}.fa-franc-sign{--fa:"\e18f"}.fa-gif{--fa:"\e190"}.fa-glass-empty{--fa:"\e191"}.fa-glass-half,.fa-glass-half-empty,.fa-glass-half-full{--fa:"\e192"}.fa-grate{--fa:"\e193"}.fa-grate-droplet{--fa:"\e194"}.fa-grid,.fa-grid-3{--fa:"\e195"}.fa-grid-2{--fa:"\e196"}.fa-grid-2-plus{--fa:"\e197"}.fa-grid-4{--fa:"\e198"}.fa-grid-5{--fa:"\e199"}.fa-guarani-sign{--fa:"\e19a"}.fa-gun{--fa:"\e19b"}.fa-gun-slash{--fa:"\e19c"}.fa-gun-squirt{--fa:"\e19d"}.fa-hand-back-point-down{--fa:"\e19e"}.fa-hand-back-point-left{--fa:"\e19f"}.fa-hand-back-point-ribbon{--fa:"\e1a0"}.fa-hand-back-point-right{--fa:"\e1a1"}.fa-hand-back-point-up{--fa:"\e1a2"}.fa-hand-fingers-crossed{--fa:"\e1a3"}.fa-hand-holding-skull{--fa:"\e1a4"}.fa-hand-love{--fa:"\e1a5"}.fa-hand-point-ribbon{--fa:"\e1a6"}.fa-hand-wave{--fa:"\e1a7"}.fa-hands-clapping{--fa:"\e1a8"}.fa-hand-horns{--fa:"\e1a9"}.fa-head-side-heart{--fa:"\e1aa"}.fa-heart-half{--fa:"\e1ab"}.fa-heart-half-alt,.fa-heart-half-stroke{--fa:"\e1ac"}.fa-hexagon-divide{--fa:"\e1ad"}.fa-high-definition,.fa-rectangle-hd{--fa:"\e1ae"}.fa-highlighter-line{--fa:"\e1af"}.fa-home-user,.fa-house-user{--fa:"\e1b0"}.fa-house-building{--fa:"\e1b1"}.fa-house-chimney-heart{--fa:"\e1b2"}.fa-house-tree{--fa:"\e1b3"}.fa-house-turret{--fa:"\e1b4"}.fa-image-landscape,.fa-landscape{--fa:"\e1b5"}.fa-image-polaroid-user{--fa:"\e1b6"}.fa-image-slash{--fa:"\e1b7"}.fa-image-user{--fa:"\e1b8"}.fa-images-user{--fa:"\e1b9"}.fa-inbox-full{--fa:"\e1ba"}.fa-inboxes{--fa:"\e1bb"}.fa-indian-rupee,.fa-indian-rupee-sign,.fa-inr{--fa:"\e1bc"}.fa-input-numeric{--fa:"\e1bd"}.fa-input-pipe{--fa:"\e1be"}.fa-input-text{--fa:"\e1bf"}.fa-keyboard-brightness{--fa:"\e1c0"}.fa-keyboard-brightness-low{--fa:"\e1c1"}.fa-keyboard-down{--fa:"\e1c2"}.fa-keyboard-left{--fa:"\e1c3"}.fa-kip-sign{--fa:"\e1c4"}.fa-lamp-street{--fa:"\e1c5"}.fa-laptop-arrow-down{--fa:"\e1c6"}.fa-laptop-slash{--fa:"\e1c7"}.fa-lari-sign{--fa:"\e1c8"}.fa-lasso-sparkles{--fa:"\e1c9"}.fa-lightbulb-exclamation-on{--fa:"\e1ca"}.fa-chain-horizontal,.fa-link-horizontal{--fa:"\e1cb"}.fa-chain-horizontal-slash,.fa-link-horizontal-slash{--fa:"\e1cc"}.fa-link-simple{--fa:"\e1cd"}.fa-link-simple-slash{--fa:"\e1ce"}.fa-list-dropdown{--fa:"\e1cf"}.fa-list-radio{--fa:"\e1d0"}.fa-list-timeline{--fa:"\e1d1"}.fa-list-tree{--fa:"\e1d2"}.fa-litecoin-sign{--fa:"\e1d3"}.fa-loader{--fa:"\e1d4"}.fa-manat-sign{--fa:"\e1d5"}.fa-manhole{--fa:"\e1d6"}.fa-mask-face{--fa:"\e1d7"}.fa-memo{--fa:"\e1d8"}.fa-memo-circle-check{--fa:"\e1d9"}.fa-memo-pad{--fa:"\e1da"}.fa-comment-alt-arrow-down,.fa-message-arrow-down{--fa:"\e1db"}.fa-comment-alt-arrow-up,.fa-message-arrow-up{--fa:"\e1dc"}.fa-message-arrow-up-right{--fa:"\e1dd"}.fa-comment-alt-captions,.fa-message-captions{--fa:"\e1de"}.fa-message-code{--fa:"\e1df"}.fa-comment-alt-image,.fa-message-image{--fa:"\e1e0"}.fa-comment-middle-alt,.fa-message-middle{--fa:"\e1e1"}.fa-comment-middle-top-alt,.fa-message-middle-top{--fa:"\e1e2"}.fa-message-question{--fa:"\e1e3"}.fa-comment-alt-quote,.fa-message-quote{--fa:"\e1e4"}.fa-message-sms{--fa:"\e1e5"}.fa-comment-alt-text,.fa-message-text{--fa:"\e1e6"}.fa-messages-question{--fa:"\e1e7"}.fa-meter{--fa:"\e1e8"}.fa-meter-bolt{--fa:"\e1e9"}.fa-meter-droplet{--fa:"\e1ea"}.fa-meter-fire{--fa:"\e1eb"}.fa-microchip-ai{--fa:"\e1ec"}.fa-mill-sign{--fa:"\e1ed"}.fa-mobile-iphone,.fa-mobile-notch{--fa:"\e1ee"}.fa-mobile-signal{--fa:"\e1ef"}.fa-mobile-signal-out{--fa:"\e1f0"}.fa-money-bill-simple{--fa:"\e1f1"}.fa-money-bill-simple-wave{--fa:"\e1f2"}.fa-money-bills{--fa:"\e1f3"}.fa-money-bills-alt,.fa-money-bills-simple{--fa:"\e1f4"}.fa-mug-tea-saucer{--fa:"\e1f5"}.fa-naira-sign{--fa:"\e1f6"}.fa-nfc{--fa:"\e1f7"}.fa-nfc-lock{--fa:"\e1f8"}.fa-nfc-magnifying-glass{--fa:"\e1f9"}.fa-nfc-pen{--fa:"\e1fa"}.fa-nfc-signal{--fa:"\e1fb"}.fa-nfc-slash{--fa:"\e1fc"}.fa-nfc-trash{--fa:"\e1fd"}.fa-notdef{--fa:"\e1fe"}.fa-note{--fa:"\e1ff"}.fa-note-medical{--fa:"\e200"}.fa-notebook{--fa:"\e201"}.fa-notes{--fa:"\e202"}.fa-octagon-divide{--fa:"\e203"}.fa-octagon-exclamation{--fa:"\e204"}.fa-oil-can-drip{--fa:"\e205"}.fa-paintbrush-pencil{--fa:"\e206"}.fa-pallet-box{--fa:"\e208"}.fa-panorama{--fa:"\e209"}.fa-paper-plane-alt,.fa-paper-plane-top,.fa-send{--fa:"\e20a"}.fa-peach{--fa:"\e20b"}.fa-pear{--fa:"\e20c"}.fa-pedestal{--fa:"\e20d"}.fa-pen-circle{--fa:"\e20e"}.fa-pen-alt-slash,.fa-pen-clip-slash{--fa:"\e20f"}.fa-pen-fancy-slash{--fa:"\e210"}.fa-pen-field{--fa:"\e211"}.fa-pen-line{--fa:"\e212"}.fa-pen-slash{--fa:"\e213"}.fa-pen-swirl{--fa:"\e214"}.fa-pencil-slash{--fa:"\e215"}.fa-people{--fa:"\e216"}.fa-people-dress{--fa:"\e217"}.fa-people-dress-simple{--fa:"\e218"}.fa-people-pants{--fa:"\e219"}.fa-people-pants-simple{--fa:"\e21a"}.fa-people-simple{--fa:"\e21b"}.fa-person-dress-simple{--fa:"\e21c"}.fa-person-pinball{--fa:"\e21d"}.fa-person-seat{--fa:"\e21e"}.fa-person-seat-reclined{--fa:"\e21f"}.fa-person-simple{--fa:"\e220"}.fa-peseta-sign{--fa:"\e221"}.fa-peso-sign{--fa:"\e222"}.fa-phone-arrow-down,.fa-phone-arrow-down-left,.fa-phone-incoming{--fa:"\e223"}.fa-phone-arrow-up,.fa-phone-arrow-up-right,.fa-phone-outgoing{--fa:"\e224"}.fa-phone-hangup{--fa:"\e225"}.fa-phone-missed{--fa:"\e226"}.fa-phone-xmark{--fa:"\e227"}.fa-photo-film-music{--fa:"\e228"}.fa-pinball{--fa:"\e229"}.fa-plane-prop{--fa:"\e22b"}.fa-plane-tail{--fa:"\e22c"}.fa-plane-up{--fa:"\e22d"}.fa-plane-up-slash{--fa:"\e22e"}.fa-play-pause{--fa:"\e22f"}.fa-puzzle-piece-alt,.fa-puzzle-piece-simple{--fa:"\e231"}.fa-quotes{--fa:"\e234"}.fa-pro,.fa-rectangle-pro{--fa:"\e235"}.fa-rectangle-terminal{--fa:"\e236"}.fa-rectangle-vertical-history{--fa:"\e237"}.fa-reel{--fa:"\e238"}.fa-reply-clock,.fa-reply-time{--fa:"\e239"}.fa-restroom-simple{--fa:"\e23a"}.fa-rhombus{--fa:"\e23b"}.fa-rotate-exclamation{--fa:"\e23c"}.fa-rupiah-sign{--fa:"\e23d"}.fa-screencast{--fa:"\e23e"}.fa-scribble{--fa:"\e23f"}.fa-sd-cards{--fa:"\e240"}.fa-seal{--fa:"\e241"}.fa-seal-exclamation{--fa:"\e242"}.fa-seal-question{--fa:"\e243"}.fa-seat-airline{--fa:"\e244"}.fa-shelves-empty{--fa:"\e246"}.fa-shield-exclamation{--fa:"\e247"}.fa-shield-keyhole{--fa:"\e248"}.fa-shield-minus{--fa:"\e249"}.fa-shield-plus{--fa:"\e24a"}.fa-shield-slash{--fa:"\e24b"}.fa-shield-times,.fa-shield-xmark{--fa:"\e24c"}.fa-shower-alt,.fa-shower-down{--fa:"\e24d"}.fa-sidebar{--fa:"\e24e"}.fa-sidebar-flip{--fa:"\e24f"}.fa-signal-stream-slash{--fa:"\e250"}.fa-sim-cards{--fa:"\e251"}.fa-slider{--fa:"\e252"}.fa-sliders-simple{--fa:"\e253"}.fa-split{--fa:"\e254"}.fa-square-0{--fa:"\e255"}.fa-square-1{--fa:"\e256"}.fa-square-2{--fa:"\e257"}.fa-square-3{--fa:"\e258"}.fa-square-4{--fa:"\e259"}.fa-square-5{--fa:"\e25a"}.fa-square-6{--fa:"\e25b"}.fa-square-7{--fa:"\e25c"}.fa-square-8{--fa:"\e25d"}.fa-square-9{--fa:"\e25e"}.fa-square-a{--fa:"\e25f"}.fa-square-ampersand{--fa:"\e260"}.fa-square-arrow-down-left{--fa:"\e261"}.fa-square-arrow-down-right{--fa:"\e262"}.fa-square-arrow-up-left{--fa:"\e263"}.fa-square-b{--fa:"\e264"}.fa-square-bolt{--fa:"\e265"}.fa-square-c{--fa:"\e266"}.fa-square-code{--fa:"\e267"}.fa-square-d{--fa:"\e268"}.fa-square-dashed{--fa:"\e269"}.fa-square-divide{--fa:"\e26a"}.fa-square-down-left{--fa:"\e26b"}.fa-square-down-right{--fa:"\e26c"}.fa-square-e{--fa:"\e26d"}.fa-square-ellipsis{--fa:"\e26e"}.fa-square-ellipsis-vertical{--fa:"\e26f"}.fa-square-f{--fa:"\e270"}.fa-square-g{--fa:"\e271"}.fa-square-i{--fa:"\e272"}.fa-square-j{--fa:"\e273"}.fa-square-k{--fa:"\e274"}.fa-square-l{--fa:"\e275"}.fa-square-m{--fa:"\e276"}.fa-square-n{--fa:"\e277"}.fa-square-o{--fa:"\e278"}.fa-square-p{--fa:"\e279"}.fa-phone-square-down,.fa-square-phone-hangup{--fa:"\e27a"}.fa-square-q{--fa:"\e27b"}.fa-square-r{--fa:"\e27c"}.fa-square-s{--fa:"\e27d"}.fa-square-small{--fa:"\e27e"}.fa-square-star{--fa:"\e27f"}.fa-square-t{--fa:"\e280"}.fa-square-u{--fa:"\e281"}.fa-square-up-left{--fa:"\e282"}.fa-square-user{--fa:"\e283"}.fa-square-v{--fa:"\e284"}.fa-square-w{--fa:"\e285"}.fa-square-x{--fa:"\e286"}.fa-square-y{--fa:"\e287"}.fa-square-z{--fa:"\e288"}.fa-stairs{--fa:"\e289"}.fa-rectangle-sd,.fa-standard-definition{--fa:"\e28a"}.fa-star-sharp{--fa:"\e28b"}.fa-star-sharp-half{--fa:"\e28c"}.fa-star-sharp-half-alt,.fa-star-sharp-half-stroke{--fa:"\e28d"}.fa-starfighter-alt-advanced,.fa-starfighter-twin-ion-engine-advanced{--fa:"\e28e"}.fa-sun-alt,.fa-sun-bright{--fa:"\e28f"}.fa-table-layout{--fa:"\e290"}.fa-table-pivot{--fa:"\e291"}.fa-rows,.fa-table-rows{--fa:"\e292"}.fa-table-tree{--fa:"\e293"}.fa-tally-1{--fa:"\e294"}.fa-tally-2{--fa:"\e295"}.fa-tally-3{--fa:"\e296"}.fa-tally-4{--fa:"\e297"}.fa-taxi-bus{--fa:"\e298"}.fa-temperature-list{--fa:"\e299"}.fa-ticket-airline,.fa-ticket-perforated-plane,.fa-ticket-plane{--fa:"\e29a"}.fa-tickets-airline,.fa-tickets-perforated-plane,.fa-tickets-plane{--fa:"\e29b"}.fa-timeline{--fa:"\e29c"}.fa-timeline-arrow{--fa:"\e29d"}.fa-timer{--fa:"\e29e"}.fa-toilet-paper-blank-under,.fa-toilet-paper-reverse,.fa-toilet-paper-reverse-alt,.fa-toilet-paper-under{--fa:"\e2a0"}.fa-toilet-paper-reverse-slash,.fa-toilet-paper-under-slash{--fa:"\e2a1"}.fa-tower-control{--fa:"\e2a2"}.fa-subway-tunnel,.fa-train-subway-tunnel{--fa:"\e2a3"}.fa-transformer-bolt{--fa:"\e2a4"}.fa-transporter-4{--fa:"\e2a5"}.fa-transporter-5{--fa:"\e2a6"}.fa-transporter-6{--fa:"\e2a7"}.fa-transporter-7{--fa:"\e2a8"}.fa-trash-can-check{--fa:"\e2a9"}.fa-trash-can-clock{--fa:"\e2aa"}.fa-trash-can-list{--fa:"\e2ab"}.fa-trash-can-plus{--fa:"\e2ac"}.fa-trash-alt-slash,.fa-trash-can-slash{--fa:"\e2ad"}.fa-trash-can-xmark{--fa:"\e2ae"}.fa-trash-check{--fa:"\e2af"}.fa-trash-clock{--fa:"\e2b0"}.fa-trash-list{--fa:"\e2b1"}.fa-trash-plus{--fa:"\e2b2"}.fa-trash-slash{--fa:"\e2b3"}.fa-trash-xmark{--fa:"\e2b4"}.fa-truck-container-empty{--fa:"\e2b5"}.fa-truck-flatbed{--fa:"\e2b6"}.fa-truck-front{--fa:"\e2b7"}.fa-truck-tow{--fa:"\e2b8"}.fa-teletype-answer,.fa-tty-answer{--fa:"\e2b9"}.fa-tugrik-sign{--fa:"\e2ba"}.fa-try,.fa-turkish-lira,.fa-turkish-lira-sign{--fa:"\e2bb"}.fa-umbrella-alt,.fa-umbrella-simple{--fa:"\e2bc"}.fa-up-left{--fa:"\e2bd"}.fa-up-right{--fa:"\e2be"}.fa-user-bounty-hunter{--fa:"\e2bf"}.fa-user-pilot{--fa:"\e2c0"}.fa-user-pilot-tie{--fa:"\e2c1"}.fa-user-shakespeare{--fa:"\e2c2"}.fa-utility-pole{--fa:"\e2c3"}.fa-utility-pole-double{--fa:"\e2c4"}.fa-vault{--fa:"\e2c5"}.fa-video-arrow-down-left{--fa:"\e2c8"}.fa-video-arrow-up-right{--fa:"\e2c9"}.fa-magic-wand-sparkles,.fa-wand-magic-sparkles{--fa:"\e2ca"}.fa-watch-apple{--fa:"\e2cb"}.fa-watch-smart{--fa:"\e2cc"}.fa-wheat-alt,.fa-wheat-awn{--fa:"\e2cd"}.fa-wheelchair-alt,.fa-wheelchair-move{--fa:"\e2ce"}.fa-wifi-exclamation{--fa:"\e2cf"}.fa-wrench-simple{--fa:"\e2d1"}.fa-robot-astromech{--fa:"\e2d2"}.fa-360-degrees{--fa:"\e2dc"}.fa-aperture{--fa:"\e2df"}.fa-arrow-turn-down-left{--fa:"\e2e1"}.fa-balloon{--fa:"\e2e3"}.fa-balloons{--fa:"\e2e4"}.fa-banana{--fa:"\e2e5"}.fa-bangladeshi-taka-sign{--fa:"\e2e6"}.fa-bench-tree{--fa:"\e2e7"}.fa-blueberries{--fa:"\e2e8"}.fa-bowl-chopsticks{--fa:"\e2e9"}.fa-bowl-chopsticks-noodles{--fa:"\e2ea"}.fa-bowl-rice{--fa:"\e2eb"}.fa-briefcase-arrow-right{--fa:"\e2f2"}.fa-citrus{--fa:"\e2f4"}.fa-citrus-slice{--fa:"\e2f5"}.fa-coconut{--fa:"\e2f6"}.fa-desktop-slash,.fa-display-slash{--fa:"\e2fa"}.fa-exploding-head,.fa-face-explode{--fa:"\e2fe"}.fa-face-viewfinder{--fa:"\e2ff"}.fa-family{--fa:"\e300"}.fa-family-dress{--fa:"\e301"}.fa-family-pants{--fa:"\e302"}.fa-fence{--fa:"\e303"}.fa-fish-bones{--fa:"\e304"}.fa-grapes{--fa:"\e306"}.fa-kiwi-fruit{--fa:"\e30c"}.fa-mango{--fa:"\e30f"}.fa-melon{--fa:"\e310"}.fa-melon-slice{--fa:"\e311"}.fa-money-from-bracket{--fa:"\e312"}.fa-money-simple-from-bracket{--fa:"\e313"}.fa-olive{--fa:"\e316"}.fa-olive-branch{--fa:"\e317"}.fa-option{--fa:"\e318"}.fa-party-bell{--fa:"\e31a"}.fa-party-horn{--fa:"\e31b"}.fa-peapod{--fa:"\e31c"}.fa-person-pregnant{--fa:"\e31e"}.fa-pineapple{--fa:"\e31f"}.fa-rectangle-code{--fa:"\e322"}.fa-rectangles-mixed{--fa:"\e323"}.fa-roller-coaster{--fa:"\e324"}.fa-square-quote{--fa:"\e329"}.fa-square-terminal{--fa:"\e32a"}.fa-strawberry{--fa:"\e32b"}.fa-table-picnic{--fa:"\e32d"}.fa-thought-bubble{--fa:"\e32e"}.fa-tick{--fa:"\e32f"}.fa-tomato{--fa:"\e330"}.fa-turn-down-left{--fa:"\e331"}.fa-user-police{--fa:"\e333"}.fa-user-police-tie{--fa:"\e334"}.fa-watermelon-slice{--fa:"\e337"}.fa-wheat-awn-slash{--fa:"\e338"}.fa-wheat-slash{--fa:"\e339"}.fa-badminton{--fa:"\e33a"}.fa-binary{--fa:"\e33b"}.fa-binary-circle-check{--fa:"\e33c"}.fa-binary-lock{--fa:"\e33d"}.fa-binary-slash{--fa:"\e33e"}.fa-boot-heeled{--fa:"\e33f"}.fa-car-bolt{--fa:"\e341"}.fa-car-circle-bolt{--fa:"\e342"}.fa-car-mirrors{--fa:"\e343"}.fa-car-side-bolt{--fa:"\e344"}.fa-clock-eight{--fa:"\e345"}.fa-clock-eight-thirty{--fa:"\e346"}.fa-clock-eleven{--fa:"\e347"}.fa-clock-eleven-thirty{--fa:"\e348"}.fa-clock-five{--fa:"\e349"}.fa-clock-five-thirty{--fa:"\e34a"}.fa-clock-four-thirty{--fa:"\e34b"}.fa-clock-nine{--fa:"\e34c"}.fa-clock-nine-thirty{--fa:"\e34d"}.fa-clock-one{--fa:"\e34e"}.fa-clock-one-thirty{--fa:"\e34f"}.fa-clock-seven{--fa:"\e350"}.fa-clock-seven-thirty{--fa:"\e351"}.fa-clock-six{--fa:"\e352"}.fa-clock-six-thirty{--fa:"\e353"}.fa-clock-ten{--fa:"\e354"}.fa-clock-ten-thirty{--fa:"\e355"}.fa-clock-three{--fa:"\e356"}.fa-clock-three-thirty{--fa:"\e357"}.fa-clock-twelve{--fa:"\e358"}.fa-clock-twelve-thirty{--fa:"\e359"}.fa-clock-two{--fa:"\e35a"}.fa-clock-two-thirty{--fa:"\e35b"}.fa-cloud-check{--fa:"\e35c"}.fa-cloud-minus{--fa:"\e35d"}.fa-cloud-plus{--fa:"\e35e"}.fa-cloud-xmark{--fa:"\e35f"}.fa-columns-3{--fa:"\e361"}.fa-crystal-ball{--fa:"\e362"}.fa-cup-straw{--fa:"\e363"}.fa-cup-straw-swoosh{--fa:"\e364"}.fa-distribute-spacing-horizontal{--fa:"\e365"}.fa-distribute-spacing-vertical{--fa:"\e366"}.fa-eyes{--fa:"\e367"}.fa-face-angry-horns{--fa:"\e368"}.fa-face-anguished{--fa:"\e369"}.fa-face-anxious-sweat{--fa:"\e36a"}.fa-face-astonished{--fa:"\e36b"}.fa-face-confounded{--fa:"\e36c"}.fa-face-confused{--fa:"\e36d"}.fa-face-cowboy-hat{--fa:"\e36e"}.fa-face-disappointed{--fa:"\e36f"}.fa-face-disguise{--fa:"\e370"}.fa-face-downcast-sweat{--fa:"\e371"}.fa-face-drooling{--fa:"\e372"}.fa-face-expressionless{--fa:"\e373"}.fa-face-eyes-xmarks{--fa:"\e374"}.fa-face-fearful{--fa:"\e375"}.fa-face-frown-slight{--fa:"\e376"}.fa-face-glasses{--fa:"\e377"}.fa-face-hand-over-mouth{--fa:"\e378"}.fa-face-hand-yawn{--fa:"\e379"}.fa-face-head-bandage{--fa:"\e37a"}.fa-face-hushed{--fa:"\e37b"}.fa-face-icicles{--fa:"\e37c"}.fa-face-kiss-closed-eyes{--fa:"\e37d"}.fa-face-lying{--fa:"\e37e"}.fa-face-mask{--fa:"\e37f"}.fa-face-monocle{--fa:"\e380"}.fa-face-nauseated{--fa:"\e381"}.fa-face-nose-steam{--fa:"\e382"}.fa-face-party{--fa:"\e383"}.fa-face-pensive{--fa:"\e384"}.fa-face-persevering{--fa:"\e385"}.fa-face-pleading{--fa:"\e386"}.fa-face-pouting{--fa:"\e387"}.fa-face-raised-eyebrow{--fa:"\e388"}.fa-face-relieved{--fa:"\e389"}.fa-face-sad-sweat{--fa:"\e38a"}.fa-face-scream{--fa:"\e38b"}.fa-face-shush{--fa:"\e38c"}.fa-face-sleeping{--fa:"\e38d"}.fa-face-sleepy{--fa:"\e38e"}.fa-face-smile-halo{--fa:"\e38f"}.fa-face-smile-hearts{--fa:"\e390"}.fa-face-smile-horns{--fa:"\e391"}.fa-face-smile-relaxed{--fa:"\e392"}.fa-face-smile-tear{--fa:"\e393"}.fa-face-smile-tongue{--fa:"\e394"}.fa-face-smile-upside-down{--fa:"\e395"}.fa-face-smiling-hands{--fa:"\e396"}.fa-face-smirking{--fa:"\e397"}.fa-face-sunglasses{--fa:"\e398"}.fa-face-swear{--fa:"\e399"}.fa-face-thermometer{--fa:"\e39a"}.fa-face-thinking{--fa:"\e39b"}.fa-face-tissue{--fa:"\e39c"}.fa-face-tongue-money{--fa:"\e39d"}.fa-face-tongue-sweat{--fa:"\e39e"}.fa-face-unamused{--fa:"\e39f"}.fa-face-vomit{--fa:"\e3a0"}.fa-face-weary{--fa:"\e3a1"}.fa-face-woozy{--fa:"\e3a2"}.fa-face-worried{--fa:"\e3a3"}.fa-face-zany{--fa:"\e3a4"}.fa-face-zipper{--fa:"\e3a5"}.fa-file-lock{--fa:"\e3a6"}.fa-file-slash{--fa:"\e3a7"}.fa-fishing-rod{--fa:"\e3a8"}.fa-flying-disc{--fa:"\e3a9"}.fa-gallery-thumbnails{--fa:"\e3aa"}.fa-goal-net{--fa:"\e3ab"}.fa-golf-flag-hole{--fa:"\e3ac"}.fa-grid-dividers{--fa:"\e3ad"}.fa-hockey-stick-puck{--fa:"\e3ae"}.fa-home-lg,.fa-house-chimney{--fa:"\e3af"}.fa-house-chimney-blank{--fa:"\e3b0"}.fa-house-crack{--fa:"\e3b1"}.fa-house-medical{--fa:"\e3b2"}.fa-house-window{--fa:"\e3b3"}.fa-key-skeleton-left-right{--fa:"\e3b4"}.fa-lacrosse-stick{--fa:"\e3b5"}.fa-lacrosse-stick-ball{--fa:"\e3b6"}.fa-mask-snorkel{--fa:"\e3b7"}.fa-message-bot{--fa:"\e3b8"}.fa-moped{--fa:"\e3b9"}.fa-nesting-dolls{--fa:"\e3ba"}.fa-objects-align-bottom{--fa:"\e3bb"}.fa-objects-align-center-horizontal{--fa:"\e3bc"}.fa-objects-align-center-vertical{--fa:"\e3bd"}.fa-objects-align-left{--fa:"\e3be"}.fa-objects-align-right{--fa:"\e3bf"}.fa-objects-align-top{--fa:"\e3c0"}.fa-objects-column{--fa:"\e3c1"}.fa-paperclip-vertical{--fa:"\e3c2"}.fa-pinata{--fa:"\e3c3"}.fa-pipe-smoking{--fa:"\e3c4"}.fa-pool-8-ball{--fa:"\e3c5"}.fa-rugby-ball{--fa:"\e3c6"}.fa-shirt-long-sleeve{--fa:"\e3c7"}.fa-shirt-running{--fa:"\e3c8"}.fa-shirt-tank-top{--fa:"\e3c9"}.fa-signature-lock{--fa:"\e3ca"}.fa-signature-slash{--fa:"\e3cb"}.fa-ski-boot{--fa:"\e3cc"}.fa-ski-boot-ski{--fa:"\e3cd"}.fa-slot-machine{--fa:"\e3ce"}.fa-teddy-bear{--fa:"\e3cf"}.fa-truck-bolt{--fa:"\e3d0"}.fa-uniform-martial-arts{--fa:"\e3d1"}.fa-user-chef{--fa:"\e3d2"}.fa-user-hair-buns{--fa:"\e3d3"}.fa-arrow-left-long-to-line{--fa:"\e3d4"}.fa-arrow-right-long-to-line{--fa:"\e3d5"}.fa-arrow-turn-down-right{--fa:"\e3d6"}.fa-bagel{--fa:"\e3d7"}.fa-baguette{--fa:"\e3d8"}.fa-blanket-fire{--fa:"\e3da"}.fa-block-brick,.fa-wall-brick{--fa:"\e3db"}.fa-block-brick-fire,.fa-firewall{--fa:"\e3dc"}.fa-block-question{--fa:"\e3dd"}.fa-bowl-scoop,.fa-bowl-shaved-ice{--fa:"\e3de"}.fa-bowl-scoops{--fa:"\e3df"}.fa-bowl-spoon{--fa:"\e3e0"}.fa-bread-slice-butter{--fa:"\e3e1"}.fa-broccoli{--fa:"\e3e2"}.fa-burger-lettuce{--fa:"\e3e3"}.fa-butter{--fa:"\e3e4"}.fa-cake-slice,.fa-shortcake{--fa:"\e3e5"}.fa-can-food{--fa:"\e3e6"}.fa-candy{--fa:"\e3e7"}.fa-candy-bar,.fa-chocolate-bar{--fa:"\e3e8"}.fa-card-club{--fa:"\e3e9"}.fa-card-diamond{--fa:"\e3ea"}.fa-card-heart{--fa:"\e3eb"}.fa-card-spade{--fa:"\e3ec"}.fa-cards{--fa:"\e3ed"}.fa-cart-arrow-up{--fa:"\e3ee"}.fa-cart-circle-arrow-down{--fa:"\e3ef"}.fa-cart-circle-arrow-up{--fa:"\e3f0"}.fa-cart-circle-check{--fa:"\e3f1"}.fa-cart-circle-exclamation{--fa:"\e3f2"}.fa-cart-circle-plus{--fa:"\e3f3"}.fa-cart-circle-xmark{--fa:"\e3f4"}.fa-cent-sign{--fa:"\e3f5"}.fa-chestnut{--fa:"\e3f6"}.fa-chopsticks{--fa:"\e3f7"}.fa-circle-quarters{--fa:"\e3f8"}.fa-code-pull-request-closed{--fa:"\e3f9"}.fa-code-pull-request-draft{--fa:"\e3fa"}.fa-coin-blank{--fa:"\e3fb"}.fa-coin-front{--fa:"\e3fc"}.fa-coin-vertical{--fa:"\e3fd"}.fa-corner{--fa:"\e3fe"}.fa-crab{--fa:"\e3ff"}.fa-creemee,.fa-soft-serve{--fa:"\e400"}.fa-cucumber{--fa:"\e401"}.fa-cupcake{--fa:"\e402"}.fa-custard{--fa:"\e403"}.fa-dash,.fa-minus-large{--fa:"\e404"}.fa-diamond-exclamation{--fa:"\e405"}.fa-donut,.fa-doughnut{--fa:"\e406"}.fa-down-from-dotted-line{--fa:"\e407"}.fa-down-to-dotted-line{--fa:"\e408"}.fa-face-awesome,.fa-gave-dandy{--fa:"\e409"}.fa-falafel{--fa:"\e40a"}.fa-flatbread{--fa:"\e40b"}.fa-flatbread-stuffed{--fa:"\e40c"}.fa-fondue-pot{--fa:"\e40d"}.fa-garlic{--fa:"\e40e"}.fa-grip-dots{--fa:"\e410"}.fa-grip-dots-vertical{--fa:"\e411"}.fa-h5{--fa:"\e412"}.fa-h6{--fa:"\e413"}.fa-hammer-crash{--fa:"\e414"}.fa-hashtag-lock{--fa:"\e415"}.fa-hexagon-check{--fa:"\e416"}.fa-hexagon-exclamation{--fa:"\e417"}.fa-honey-pot{--fa:"\e418"}.fa-hose{--fa:"\e419"}.fa-hose-reel{--fa:"\e41a"}.fa-hourglass-clock{--fa:"\e41b"}.fa-100,.fa-hundred-points{--fa:"\e41c"}.fa-leafy-green{--fa:"\e41d"}.fa-left-long-to-line{--fa:"\e41e"}.fa-light-emergency{--fa:"\e41f"}.fa-light-emergency-on{--fa:"\e420"}.fa-lobster{--fa:"\e421"}.fa-lock-a{--fa:"\e422"}.fa-lock-hashtag{--fa:"\e423"}.fa-lollipop,.fa-lollypop{--fa:"\e424"}.fa-mushroom{--fa:"\e425"}.fa-octagon-check{--fa:"\e426"}.fa-onion{--fa:"\e427"}.fa-page{--fa:"\e428"}.fa-file-caret-down,.fa-page-caret-down{--fa:"\e429"}.fa-file-caret-up,.fa-page-caret-up{--fa:"\e42a"}.fa-pan-food{--fa:"\e42b"}.fa-pan-frying{--fa:"\e42c"}.fa-pancakes{--fa:"\e42d"}.fa-panel-ews{--fa:"\e42e"}.fa-panel-fire{--fa:"\e42f"}.fa-peanut{--fa:"\e430"}.fa-peanuts{--fa:"\e431"}.fa-pepper{--fa:"\e432"}.fa-person-to-door{--fa:"\e433"}.fa-phone-intercom{--fa:"\e434"}.fa-pickleball{--fa:"\e435"}.fa-pipe-circle-check{--fa:"\e436"}.fa-pipe-collar{--fa:"\e437"}.fa-pipe-section{--fa:"\e438"}.fa-pipe-valve{--fa:"\e439"}.fa-plate-utensils{--fa:"\e43b"}.fa-plus-minus{--fa:"\e43c"}.fa-pompebled{--fa:"\e43d"}.fa-popsicle{--fa:"\e43e"}.fa-pot-food{--fa:"\e43f"}.fa-potato{--fa:"\e440"}.fa-pretzel{--fa:"\e441"}.fa-pump{--fa:"\e442"}.fa-puzzle{--fa:"\e443"}.fa-right-long-to-line{--fa:"\e444"}.fa-sailboat{--fa:"\e445"}.fa-salt-shaker{--fa:"\e446"}.fa-section{--fa:"\e447"}.fa-shrimp{--fa:"\e448"}.fa-shutters{--fa:"\e449"}.fa-sportsball{--fa:"\e44b"}.fa-sprinkler-ceiling{--fa:"\e44c"}.fa-square-a-lock{--fa:"\e44d"}.fa-square-quarters{--fa:"\e44e"}.fa-square-ring{--fa:"\e44f"}.fa-squid{--fa:"\e450"}.fa-tamale{--fa:"\e451"}.fa-tank-water{--fa:"\e452"}.fa-train-track{--fa:"\e453"}.fa-train-tunnel{--fa:"\e454"}.fa-turn-down-right{--fa:"\e455"}.fa-up-from-dotted-line{--fa:"\e456"}.fa-up-to-dotted-line{--fa:"\e457"}.fa-user-doctor-hair{--fa:"\e458"}.fa-user-doctor-hair-long{--fa:"\e459"}.fa-user-hair{--fa:"\e45a"}.fa-user-hair-long{--fa:"\e45b"}.fa-business-front,.fa-party-back,.fa-trian-balbot,.fa-user-hair-mullet{--fa:"\e45c"}.fa-user-nurse-hair{--fa:"\e45d"}.fa-user-nurse-hair-long{--fa:"\e45e"}.fa-user-tie-hair{--fa:"\e45f"}.fa-user-tie-hair-long{--fa:"\e460"}.fa-user-vneck{--fa:"\e461"}.fa-user-vneck-hair{--fa:"\e462"}.fa-user-vneck-hair-long{--fa:"\e463"}.fa-utensils-slash{--fa:"\e464"}.fa-vent-damper{--fa:"\e465"}.fa-waffle{--fa:"\e466"}.fa-00{--fa:"\e467"}.fa-apartment{--fa:"\e468"}.fa-bird{--fa:"\e469"}.fa-block{--fa:"\e46a"}.fa-bowl-soft-serve{--fa:"\e46b"}.fa-brazilian-real-sign{--fa:"\e46c"}.fa-cabin{--fa:"\e46d"}.fa-calendar-circle-exclamation{--fa:"\e46e"}.fa-calendar-circle-minus{--fa:"\e46f"}.fa-calendar-circle-plus{--fa:"\e470"}.fa-calendar-circle-user{--fa:"\e471"}.fa-calendar-lines-pen{--fa:"\e472"}.fa-chart-simple{--fa:"\e473"}.fa-chart-simple-horizontal{--fa:"\e474"}.fa-diagram-cells{--fa:"\e475"}.fa-diagram-next{--fa:"\e476"}.fa-diagram-predecessor{--fa:"\e477"}.fa-diagram-previous{--fa:"\e478"}.fa-diagram-subtask{--fa:"\e479"}.fa-diagram-successor{--fa:"\e47a"}.fa-earth-oceania,.fa-globe-oceania{--fa:"\e47b"}.fa-face-beam-hand-over-mouth{--fa:"\e47c"}.fa-face-clouds{--fa:"\e47d"}.fa-face-diagonal-mouth{--fa:"\e47e"}.fa-face-dotted{--fa:"\e47f"}.fa-face-exhaling{--fa:"\e480"}.fa-face-hand-peeking{--fa:"\e481"}.fa-face-holding-back-tears{--fa:"\e482"}.fa-face-melting{--fa:"\e483"}.fa-face-saluting{--fa:"\e484"}.fa-face-spiral-eyes{--fa:"\e485"}.fa-fort{--fa:"\e486"}.fa-home-blank,.fa-house-blank{--fa:"\e487"}.fa-square-kanban{--fa:"\e488"}.fa-square-list{--fa:"\e489"}.fa-nigiri,.fa-sushi{--fa:"\e48a"}.fa-maki-roll,.fa-makizushi,.fa-sushi-roll{--fa:"\e48b"}.fa-album-circle-plus{--fa:"\e48c"}.fa-album-circle-user{--fa:"\e48d"}.fa-album-collection-circle-plus{--fa:"\e48e"}.fa-album-collection-circle-user{--fa:"\e48f"}.fa-bug-slash{--fa:"\e490"}.fa-cloud-exclamation{--fa:"\e491"}.fa-cloud-question{--fa:"\e492"}.fa-file-circle-info{--fa:"\e493"}.fa-file-circle-plus{--fa:"\e494"}.fa-frame{--fa:"\e495"}.fa-gauge-circle-bolt{--fa:"\e496"}.fa-gauge-circle-minus{--fa:"\e497"}.fa-gauge-circle-plus{--fa:"\e498"}.fa-memo-circle-info{--fa:"\e49a"}.fa-object-exclude{--fa:"\e49c"}.fa-object-intersect{--fa:"\e49d"}.fa-object-subtract{--fa:"\e49e"}.fa-object-union{--fa:"\e49f"}.fa-pen-nib-slash{--fa:"\e4a1"}.fa-rectangle-history{--fa:"\e4a2"}.fa-rectangle-history-circle-plus{--fa:"\e4a3"}.fa-rectangle-history-circle-user{--fa:"\e4a4"}.fa-shop-lock{--fa:"\e4a5"}.fa-store-lock{--fa:"\e4a6"}.fa-user-robot-xmarks{--fa:"\e4a7"}.fa-virus-covid{--fa:"\e4a8"}.fa-virus-covid-slash{--fa:"\e4a9"}.fa-anchor-circle-check{--fa:"\e4aa"}.fa-anchor-circle-exclamation{--fa:"\e4ab"}.fa-anchor-circle-xmark{--fa:"\e4ac"}.fa-anchor-lock{--fa:"\e4ad"}.fa-arrow-down-to-arc{--fa:"\e4ae"}.fa-arrow-down-up-across-line{--fa:"\e4af"}.fa-arrow-down-up-lock{--fa:"\e4b0"}.fa-arrow-right-from-arc{--fa:"\e4b1"}.fa-arrow-right-to-arc{--fa:"\e4b2"}.fa-arrow-right-to-city{--fa:"\e4b3"}.fa-arrow-up-from-arc{--fa:"\e4b4"}.fa-arrow-up-from-ground-water{--fa:"\e4b5"}.fa-arrow-up-from-water-pump{--fa:"\e4b6"}.fa-arrow-up-right-dots{--fa:"\e4b7"}.fa-arrows-down-to-line{--fa:"\e4b8"}.fa-arrows-down-to-people{--fa:"\e4b9"}.fa-arrows-left-right-to-line{--fa:"\e4ba"}.fa-arrows-spin{--fa:"\e4bb"}.fa-arrows-split-up-and-left{--fa:"\e4bc"}.fa-arrows-to-circle{--fa:"\e4bd"}.fa-arrows-to-dot{--fa:"\e4be"}.fa-arrows-to-eye{--fa:"\e4bf"}.fa-arrows-turn-right{--fa:"\e4c0"}.fa-arrows-turn-to-dots{--fa:"\e4c1"}.fa-arrows-up-to-line{--fa:"\e4c2"}.fa-bore-hole{--fa:"\e4c3"}.fa-bottle-droplet{--fa:"\e4c4"}.fa-bottle-water{--fa:"\e4c5"}.fa-bowl-food{--fa:"\e4c6"}.fa-boxes-packing{--fa:"\e4c7"}.fa-bridge{--fa:"\e4c8"}.fa-bridge-circle-check{--fa:"\e4c9"}.fa-bridge-circle-exclamation{--fa:"\e4ca"}.fa-bridge-circle-xmark{--fa:"\e4cb"}.fa-bridge-lock{--fa:"\e4cc"}.fa-bridge-suspension{--fa:"\e4cd"}.fa-bridge-water{--fa:"\e4ce"}.fa-bucket{--fa:"\e4cf"}.fa-bugs{--fa:"\e4d0"}.fa-building-circle-arrow-right{--fa:"\e4d1"}.fa-building-circle-check{--fa:"\e4d2"}.fa-building-circle-exclamation{--fa:"\e4d3"}.fa-building-circle-xmark{--fa:"\e4d4"}.fa-building-flag{--fa:"\e4d5"}.fa-building-lock{--fa:"\e4d6"}.fa-building-ngo{--fa:"\e4d7"}.fa-building-shield{--fa:"\e4d8"}.fa-building-un{--fa:"\e4d9"}.fa-building-user{--fa:"\e4da"}.fa-building-wheat{--fa:"\e4db"}.fa-burst{--fa:"\e4dc"}.fa-car-on{--fa:"\e4dd"}.fa-car-tunnel{--fa:"\e4de"}.fa-cards-blank{--fa:"\e4df"}.fa-child-combatant,.fa-child-rifle{--fa:"\e4e0"}.fa-children{--fa:"\e4e1"}.fa-circle-nodes{--fa:"\e4e2"}.fa-clipboard-question{--fa:"\e4e3"}.fa-cloud-showers-water{--fa:"\e4e4"}.fa-computer{--fa:"\e4e5"}.fa-cubes-stacked{--fa:"\e4e6"}.fa-down-to-bracket{--fa:"\e4e7"}.fa-envelope-circle-check{--fa:"\e4e8"}.fa-explosion{--fa:"\e4e9"}.fa-ferry{--fa:"\e4ea"}.fa-file-circle-exclamation{--fa:"\e4eb"}.fa-file-circle-minus{--fa:"\e4ed"}.fa-file-circle-question{--fa:"\e4ef"}.fa-file-shield{--fa:"\e4f0"}.fa-fire-burner{--fa:"\e4f1"}.fa-fish-fins{--fa:"\e4f2"}.fa-flask-vial{--fa:"\e4f3"}.fa-glass-water{--fa:"\e4f4"}.fa-glass-water-droplet{--fa:"\e4f5"}.fa-group-arrows-rotate{--fa:"\e4f6"}.fa-hand-holding-hand{--fa:"\e4f7"}.fa-handcuffs{--fa:"\e4f8"}.fa-hands-bound{--fa:"\e4f9"}.fa-hands-holding-child{--fa:"\e4fa"}.fa-hands-holding-circle{--fa:"\e4fb"}.fa-heart-circle-bolt{--fa:"\e4fc"}.fa-heart-circle-check{--fa:"\e4fd"}.fa-heart-circle-exclamation{--fa:"\e4fe"}.fa-heart-circle-minus{--fa:"\e4ff"}.fa-heart-circle-plus{--fa:"\e500"}.fa-heart-circle-xmark{--fa:"\e501"}.fa-helicopter-symbol{--fa:"\e502"}.fa-helmet-un{--fa:"\e503"}.fa-hexagon-image{--fa:"\e504"}.fa-hexagon-vertical-nft,.fa-hexagon-vertical-nft-slanted{--fa:"\e505"}.fa-hill-avalanche{--fa:"\e507"}.fa-hill-rockslide{--fa:"\e508"}.fa-house-circle-check{--fa:"\e509"}.fa-house-circle-exclamation{--fa:"\e50a"}.fa-house-circle-xmark{--fa:"\e50b"}.fa-house-fire{--fa:"\e50c"}.fa-house-flag{--fa:"\e50d"}.fa-house-flood-water{--fa:"\e50e"}.fa-house-flood-water-circle-arrow-right{--fa:"\e50f"}.fa-house-lock{--fa:"\e510"}.fa-house-medical-circle-check{--fa:"\e511"}.fa-house-medical-circle-exclamation{--fa:"\e512"}.fa-house-medical-circle-xmark{--fa:"\e513"}.fa-house-medical-flag{--fa:"\e514"}.fa-house-tsunami{--fa:"\e515"}.fa-jar{--fa:"\e516"}.fa-jar-wheat{--fa:"\e517"}.fa-jet-fighter-up{--fa:"\e518"}.fa-jug-detergent{--fa:"\e519"}.fa-kitchen-set{--fa:"\e51a"}.fa-land-mine-on{--fa:"\e51b"}.fa-landmark-flag{--fa:"\e51c"}.fa-laptop-file{--fa:"\e51d"}.fa-lines-leaning{--fa:"\e51e"}.fa-location-pin-lock{--fa:"\e51f"}.fa-locust{--fa:"\e520"}.fa-magnifying-glass-arrow-right{--fa:"\e521"}.fa-magnifying-glass-chart{--fa:"\e522"}.fa-mars-and-venus-burst{--fa:"\e523"}.fa-mask-ventilator{--fa:"\e524"}.fa-mattress-pillow{--fa:"\e525"}.fa-merge{--fa:"\e526"}.fa-mobile-retro{--fa:"\e527"}.fa-money-bill-transfer{--fa:"\e528"}.fa-money-bill-trend-up{--fa:"\e529"}.fa-money-bill-wheat{--fa:"\e52a"}.fa-mosquito{--fa:"\e52b"}.fa-mosquito-net{--fa:"\e52c"}.fa-mound{--fa:"\e52d"}.fa-mountain-city{--fa:"\e52e"}.fa-mountain-sun{--fa:"\e52f"}.fa-nfc-symbol{--fa:"\e531"}.fa-oil-well{--fa:"\e532"}.fa-people-group{--fa:"\e533"}.fa-people-line{--fa:"\e534"}.fa-people-pulling{--fa:"\e535"}.fa-people-robbery{--fa:"\e536"}.fa-people-roof{--fa:"\e537"}.fa-person-arrow-down-to-line{--fa:"\e538"}.fa-person-arrow-up-from-line{--fa:"\e539"}.fa-person-breastfeeding{--fa:"\e53a"}.fa-person-burst{--fa:"\e53b"}.fa-person-cane{--fa:"\e53c"}.fa-person-chalkboard{--fa:"\e53d"}.fa-person-circle-check{--fa:"\e53e"}.fa-person-circle-exclamation{--fa:"\e53f"}.fa-person-circle-minus{--fa:"\e540"}.fa-person-circle-plus{--fa:"\e541"}.fa-person-circle-question{--fa:"\e542"}.fa-person-circle-xmark{--fa:"\e543"}.fa-person-dress-burst{--fa:"\e544"}.fa-person-drowning{--fa:"\e545"}.fa-person-falling{--fa:"\e546"}.fa-person-falling-burst{--fa:"\e547"}.fa-person-half-dress{--fa:"\e548"}.fa-person-harassing{--fa:"\e549"}.fa-person-military-pointing{--fa:"\e54a"}.fa-person-military-rifle{--fa:"\e54b"}.fa-person-military-to-person{--fa:"\e54c"}.fa-person-rays{--fa:"\e54d"}.fa-person-rifle{--fa:"\e54e"}.fa-person-shelter{--fa:"\e54f"}.fa-person-walking-arrow-loop-left{--fa:"\e551"}.fa-person-walking-arrow-right{--fa:"\e552"}.fa-person-walking-dashed-line-arrow-right{--fa:"\e553"}.fa-person-walking-luggage{--fa:"\e554"}.fa-plane-circle-check{--fa:"\e555"}.fa-plane-circle-exclamation{--fa:"\e556"}.fa-plane-circle-xmark{--fa:"\e557"}.fa-plane-lock{--fa:"\e558"}.fa-plate-wheat{--fa:"\e55a"}.fa-plug-circle-bolt{--fa:"\e55b"}.fa-plug-circle-check{--fa:"\e55c"}.fa-plug-circle-exclamation{--fa:"\e55d"}.fa-plug-circle-minus{--fa:"\e55e"}.fa-plug-circle-plus{--fa:"\e55f"}.fa-plug-circle-xmark{--fa:"\e560"}.fa-ranking-star{--fa:"\e561"}.fa-road-barrier{--fa:"\e562"}.fa-road-bridge{--fa:"\e563"}.fa-road-circle-check{--fa:"\e564"}.fa-road-circle-exclamation{--fa:"\e565"}.fa-road-circle-xmark{--fa:"\e566"}.fa-road-lock{--fa:"\e567"}.fa-road-spikes{--fa:"\e568"}.fa-rug{--fa:"\e569"}.fa-sack-xmark{--fa:"\e56a"}.fa-school-circle-check{--fa:"\e56b"}.fa-school-circle-exclamation{--fa:"\e56c"}.fa-school-circle-xmark{--fa:"\e56d"}.fa-school-flag{--fa:"\e56e"}.fa-school-lock{--fa:"\e56f"}.fa-sheet-plastic{--fa:"\e571"}.fa-shield-cat{--fa:"\e572"}.fa-shield-dog{--fa:"\e573"}.fa-shield-heart{--fa:"\e574"}.fa-shield-quartered{--fa:"\e575"}.fa-square-nfi{--fa:"\e576"}.fa-square-person-confined{--fa:"\e577"}.fa-square-virus{--fa:"\e578"}.fa-rod-asclepius,.fa-rod-snake,.fa-staff-aesculapius,.fa-staff-snake{--fa:"\e579"}.fa-sun-plant-wilt{--fa:"\e57a"}.fa-tarp{--fa:"\e57b"}.fa-tarp-droplet{--fa:"\e57c"}.fa-tent{--fa:"\e57d"}.fa-tent-arrow-down-to-line{--fa:"\e57e"}.fa-tent-arrow-left-right{--fa:"\e57f"}.fa-tent-arrow-turn-left{--fa:"\e580"}.fa-tent-arrows-down{--fa:"\e581"}.fa-tents{--fa:"\e582"}.fa-toilet-portable{--fa:"\e583"}.fa-toilets-portable{--fa:"\e584"}.fa-tower-cell{--fa:"\e585"}.fa-tower-observation{--fa:"\e586"}.fa-tree-city{--fa:"\e587"}.fa-trillium{--fa:"\e588"}.fa-trowel{--fa:"\e589"}.fa-trowel-bricks{--fa:"\e58a"}.fa-truck-arrow-right{--fa:"\e58b"}.fa-truck-droplet{--fa:"\e58c"}.fa-truck-field{--fa:"\e58d"}.fa-truck-field-un{--fa:"\e58e"}.fa-truck-plane{--fa:"\e58f"}.fa-up-from-bracket{--fa:"\e590"}.fa-users-between-lines{--fa:"\e591"}.fa-users-line{--fa:"\e592"}.fa-users-rays{--fa:"\e593"}.fa-users-rectangle{--fa:"\e594"}.fa-users-viewfinder{--fa:"\e595"}.fa-vial-circle-check{--fa:"\e596"}.fa-vial-virus{--fa:"\e597"}.fa-wheat-awn-circle-exclamation{--fa:"\e598"}.fa-worm{--fa:"\e599"}.fa-xmarks-lines{--fa:"\e59a"}.fa-xmark-large{--fa:"\e59b"}.fa-child-dress{--fa:"\e59c"}.fa-child-reaching{--fa:"\e59d"}.fa-plus-large{--fa:"\e59e"}.fa-crosshairs-simple{--fa:"\e59f"}.fa-file-circle-check{--fa:"\e5a0"}.fa-file-circle-xmark{--fa:"\e5a1"}.fa-gamepad-alt,.fa-gamepad-modern{--fa:"\e5a2"}.fa-grill{--fa:"\e5a3"}.fa-grill-fire{--fa:"\e5a4"}.fa-grill-hot{--fa:"\e5a5"}.fa-lightbulb-cfl{--fa:"\e5a6"}.fa-lightbulb-cfl-on{--fa:"\e5a7"}.fa-mouse-field{--fa:"\e5a8"}.fa-person-through-window{--fa:"\e5a9"}.fa-plant-wilt{--fa:"\e5aa"}.fa-ring-diamond{--fa:"\e5ab"}.fa-stapler{--fa:"\e5af"}.fa-toggle-large-off{--fa:"\e5b0"}.fa-toggle-large-on{--fa:"\e5b1"}.fa-toilet-paper-check{--fa:"\e5b2"}.fa-toilet-paper-xmark{--fa:"\e5b3"}.fa-train-tram{--fa:"\e5b4"}.fa-buoy{--fa:"\e5b5"}.fa-buoy-mooring{--fa:"\e5b6"}.fa-diamond-half{--fa:"\e5b7"}.fa-diamond-half-stroke{--fa:"\e5b8"}.fa-game-console-handheld-crank{--fa:"\e5b9"}.fa-interrobang{--fa:"\e5ba"}.fa-mailbox-flag-up{--fa:"\e5bb"}.fa-mustache{--fa:"\e5bc"}.fa-nose{--fa:"\e5bd"}.fa-phone-arrow-right{--fa:"\e5be"}.fa-pickaxe{--fa:"\e5bf"}.fa-prescription-bottle-pill{--fa:"\e5c0"}.fa-snowflake-droplets{--fa:"\e5c1"}.fa-square-dashed-circle-plus{--fa:"\e5c2"}.fa-tricycle{--fa:"\e5c3"}.fa-tricycle-adult{--fa:"\e5c4"}.fa-user-magnifying-glass{--fa:"\e5c5"}.fa-comment-heart{--fa:"\e5c8"}.fa-message-heart{--fa:"\e5c9"}.fa-pencil-mechanical{--fa:"\e5ca"}.fa-skeleton-ribs{--fa:"\e5cb"}.fa-billboard{--fa:"\e5cd"}.fa-circle-euro{--fa:"\e5ce"}.fa-circle-sterling{--fa:"\e5cf"}.fa-circle-yen{--fa:"\e5d0"}.fa-broom-wide{--fa:"\e5d1"}.fa-wreath-laurel{--fa:"\e5d2"}.fa-circle-quarter-stroke{--fa:"\e5d3"}.fa-circle-three-quarters-stroke{--fa:"\e5d4"}.fa-webhook{--fa:"\e5d5"}.fa-sparkle{--fa:"\e5d6"}.fa-chart-line-up-down{--fa:"\e5d7"}.fa-chart-mixed-up-circle-currency{--fa:"\e5d8"}.fa-chart-mixed-up-circle-dollar{--fa:"\e5d9"}.fa-grid-round{--fa:"\e5da"}.fa-grid-round-2{--fa:"\e5db"}.fa-grid-round-2-plus{--fa:"\e5dc"}.fa-grid-round-4{--fa:"\e5dd"}.fa-grid-round-5{--fa:"\e5de"}.fa-arrow-progress{--fa:"\e5df"}.fa-right-left-large{--fa:"\e5e1"}.fa-calendar-users{--fa:"\e5e2"}.fa-display-chart-up{--fa:"\e5e3"}.fa-display-chart-up-circle-currency{--fa:"\e5e5"}.fa-display-chart-up-circle-dollar{--fa:"\e5e6"}.fa-laptop-binary{--fa:"\e5e7"}.fa-gear-code{--fa:"\e5e8"}.fa-gear-complex{--fa:"\e5e9"}.fa-gear-complex-code{--fa:"\e5eb"}.fa-file-doc{--fa:"\e5ed"}.fa-file-zip{--fa:"\e5ee"}.fa-flask-gear{--fa:"\e5f1"}.fa-bag-seedling{--fa:"\e5f2"}.fa-bin-bottles{--fa:"\e5f5"}.fa-bin-bottles-recycle{--fa:"\e5f6"}.fa-bin-recycle{--fa:"\e5f7"}.fa-conveyor-belt-arm{--fa:"\e5f8"}.fa-jug-bottle{--fa:"\e5fb"}.fa-lightbulb-gear{--fa:"\e5fd"}.fa-dinosaur{--fa:"\e5fe"}.fa-person-running-fast{--fa:"\e5ff"}.fa-circles-overlap{--fa:"\e600"}.fa-cloud-binary{--fa:"\e601"}.fa-chf-sign{--fa:"\e602"}.fa-user-group-simple{--fa:"\e603"}.fa-chart-pie-simple-circle-currency{--fa:"\e604"}.fa-chart-pie-simple-circle-dollar{--fa:"\e605"}.fa-hat-beach{--fa:"\e606"}.fa-person-dress-fairy{--fa:"\e607"}.fa-person-fairy{--fa:"\e608"}.fa-swap{--fa:"\e609"}.fa-swap-arrows{--fa:"\e60a"}.fa-angles-up-down{--fa:"\e60d"}.fa-globe-pointer{--fa:"\e60e"}.fa-subtitles{--fa:"\e60f"}.fa-subtitles-slash{--fa:"\e610"}.fa-head-side-gear{--fa:"\e611"}.fa-lighthouse{--fa:"\e612"}.fa-raccoon{--fa:"\e613"}.fa-arrow-down-from-arc{--fa:"\e614"}.fa-arrow-left-from-arc{--fa:"\e615"}.fa-arrow-left-to-arc{--fa:"\e616"}.fa-arrow-up-to-arc{--fa:"\e617"}.fa-building-magnifying-glass{--fa:"\e61c"}.fa-building-memo{--fa:"\e61e"}.fa-hammer-brush{--fa:"\e620"}.fa-hand-holding-circle-dollar{--fa:"\e621"}.fa-landmark-magnifying-glass{--fa:"\e622"}.fa-sign-post{--fa:"\e624"}.fa-sign-posts{--fa:"\e625"}.fa-sign-posts-wrench{--fa:"\e626"}.fa-tent-double-peak{--fa:"\e627"}.fa-truck-utensils{--fa:"\e628"}.fa-t-rex{--fa:"\e629"}.fa-spinner-scale{--fa:"\e62a"}.fa-bell-ring{--fa:"\e62c"}.fa-arrows-rotate-reverse{--fa:"\e630"}.fa-rotate-reverse{--fa:"\e631"}.fa-arrow-turn-left{--fa:"\e632"}.fa-arrow-turn-left-down{--fa:"\e633"}.fa-arrow-turn-left-up{--fa:"\e634"}.fa-arrow-turn-right{--fa:"\e635"}.fa-turn-left{--fa:"\e636"}.fa-turn-left-down{--fa:"\e637"}.fa-turn-left-up{--fa:"\e638"}.fa-turn-right{--fa:"\e639"}.fa-location-arrow-up{--fa:"\e63a"}.fa-ticket-perforated{--fa:"\e63e"}.fa-tickets-perforated{--fa:"\e63f"}.fa-cannon{--fa:"\e642"}.fa-court-sport{--fa:"\e643"}.fa-file-eps{--fa:"\e644"}.fa-file-gif{--fa:"\e645"}.fa-file-jpg{--fa:"\e646"}.fa-file-mov{--fa:"\e647"}.fa-file-mp3{--fa:"\e648"}.fa-file-mp4{--fa:"\e649"}.fa-file-ppt{--fa:"\e64a"}.fa-file-svg{--fa:"\e64b"}.fa-file-vector{--fa:"\e64c"}.fa-file-xls{--fa:"\e64d"}.fa-folder-check{--fa:"\e64e"}.fa-chart-kanban{--fa:"\e64f"}.fa-bag-shopping-minus{--fa:"\e650"}.fa-bag-shopping-plus{--fa:"\e651"}.fa-basket-shopping-minus{--fa:"\e652"}.fa-basket-shopping-plus{--fa:"\e653"}.fa-file-xml{--fa:"\e654"}.fa-bulldozer{--fa:"\e655"}.fa-excavator{--fa:"\e656"}.fa-truck-ladder{--fa:"\e657"}.fa-tickets{--fa:"\e658"}.fa-tickets-simple{--fa:"\e659"}.fa-truck-fire{--fa:"\e65a"}.fa-wave{--fa:"\e65b"}.fa-waves-sine{--fa:"\e65d"}.fa-magnifying-glass-arrows-rotate{--fa:"\e65e"}.fa-magnifying-glass-music{--fa:"\e65f"}.fa-magnifying-glass-play{--fa:"\e660"}.fa-magnifying-glass-waveform{--fa:"\e661"}.fa-music-magnifying-glass{--fa:"\e662"}.fa-reflect-horizontal{--fa:"\e664"}.fa-reflect-vertical{--fa:"\e665"}.fa-file-png{--fa:"\e666"}.fa-arrow-down-from-bracket{--fa:"\e667"}.fa-arrow-left-from-bracket{--fa:"\e668"}.fa-arrow-left-to-bracket{--fa:"\e669"}.fa-arrow-up-to-bracket{--fa:"\e66a"}.fa-down-from-bracket{--fa:"\e66b"}.fa-left-from-bracket{--fa:"\e66c"}.fa-left-to-bracket{--fa:"\e66d"}.fa-up-to-bracket{--fa:"\e66e"}.fa-reflect-both{--fa:"\e66f"}.fa-file-cad{--fa:"\e672"}.fa-bottle-baby{--fa:"\e673"}.fa-table-cells-column-lock{--fa:"\e678"}.fa-table-cells-lock{--fa:"\e679"}.fa-table-cells-row-lock{--fa:"\e67a"}.fa-circle-wifi{--fa:"\e67d"}.fa-circle-wifi-circle-wifi,.fa-circle-wifi-group{--fa:"\e67e"}.fa-circle-gf{--fa:"\e67f"}.fa-ant{--fa:"\e680"}.fa-caduceus{--fa:"\e681"}.fa-web-awesome{--fa:"\e682"}.fa-globe-wifi{--fa:"\e685"}.fa-hydra{--fa:"\e686"}.fa-lightbulb-message{--fa:"\e687"}.fa-octopus{--fa:"\e688"}.fa-user-beard-bolt{--fa:"\e689"}.fa-user-hoodie{--fa:"\e68a"}.fa-diamonds-4{--fa:"\e68b"}.fa-thumb-tack-slash,.fa-thumbtack-slash{--fa:"\e68f"}.fa-table-cells-column-unlock{--fa:"\e690"}.fa-table-cells-row-unlock{--fa:"\e691"}.fa-table-cells-unlock{--fa:"\e692"}.fa-chart-diagram{--fa:"\e695"}.fa-comment-nodes{--fa:"\e696"}.fa-file-fragment{--fa:"\e697"}.fa-file-half-dashed{--fa:"\e698"}.fa-hexagon-nodes{--fa:"\e699"}.fa-hexagon-nodes-bolt{--fa:"\e69a"}.fa-square-binary{--fa:"\e69b"}.fa-car-people,.fa-carpool{--fa:"\e69c"}.fa-chart-sine{--fa:"\e69d"}.fa-chart-fft{--fa:"\e69e"}.fa-circles-overlap-3,.fa-pronoun{--fa:"\e6a1"}.fa-bar-progress{--fa:"\e6a4"}.fa-bar-progress-empty{--fa:"\e6a5"}.fa-bar-progress-full{--fa:"\e6a6"}.fa-bar-progress-half{--fa:"\e6a7"}.fa-bar-progress-quarter{--fa:"\e6a8"}.fa-bar-progress-three-quarters{--fa:"\e6a9"}.fa-grid-2-minus{--fa:"\e6aa"}.fa-grid-round-2-minus{--fa:"\e6ab"}.fa-table-cells-columns{--fa:"\e6ac"}.fa-table-cells-header{--fa:"\e6ad"}.fa-table-cells-header-lock{--fa:"\e6ae"}.fa-table-cells-header-unlock{--fa:"\e6af"}.fa-table-cells-rows{--fa:"\e6b0"}.fa-circle-equals{--fa:"\e6b1"}.fa-hexagon-equals{--fa:"\e6b2"}.fa-octagon-equals{--fa:"\e6b3"}.fa-rectangle-minus{--fa:"\e6b4"}.fa-rectangle-plus{--fa:"\e6b5"}.fa-square-equals{--fa:"\e6b6"}.fa-arrow-down-long-to-line{--fa:"\e6b7"}.fa-arrow-left-arrow-right{--fa:"\e6b8"}.fa-arrow-left-from-dotted-line{--fa:"\e6b9"}.fa-arrow-left-to-dotted-line{--fa:"\e6ba"}.fa-arrow-right-from-dotted-line{--fa:"\e6bb"}.fa-arrow-right-to-dotted-line{--fa:"\e6bc"}.fa-arrow-up-long-to-line{--fa:"\e6bd"}.fa-direction-left-right{--fa:"\e6be"}.fa-direction-up-down{--fa:"\e6bf"}.fa-down-long-to-line{--fa:"\e6c0"}.fa-down-up{--fa:"\e6c1"}.fa-left-from-dotted-line{--fa:"\e6c2"}.fa-left-to-dotted-line{--fa:"\e6c3"}.fa-right-from-dotted-line{--fa:"\e6c4"}.fa-right-to-dotted-line{--fa:"\e6c5"}.fa-up-long-to-line{--fa:"\e6c6"}.fa-barn{--fa:"\e6c7"}.fa-circle-house{--fa:"\e6c8"}.fa-garage-empty{--fa:"\e6c9"}.fa-house-unlock{--fa:"\e6ca"}.fa-school-unlock{--fa:"\e6cb"}.fa-stadium{--fa:"\e6cc"}.fa-tent-circus{--fa:"\e6cd"}.fa-ball-yarn{--fa:"\e6ce"}.fa-bra{--fa:"\e6cf"}.fa-briefs{--fa:"\e6d0"}.fa-dress{--fa:"\e6d1"}.fa-jeans{--fa:"\e6d2"}.fa-jeans-straight{--fa:"\e6d3"}.fa-panties{--fa:"\e6d4"}.fa-pants{--fa:"\e6d5"}.fa-pants-straight{--fa:"\e6d6"}.fa-shirt-jersey{--fa:"\e6d7"}.fa-shoe{--fa:"\e6d8"}.fa-shorts{--fa:"\e6d9"}.fa-sneaker{--fa:"\e6da"}.fa-circle-share-nodes{--fa:"\e6db"}.fa-comment-dot{--fa:"\e6dc"}.fa-comment-waveform{--fa:"\e6dd"}.fa-envelope-circle-user{--fa:"\e6de"}.fa-message-dot{--fa:"\e6df"}.fa-message-waveform{--fa:"\e6e0"}.fa-phone-connection{--fa:"\e6e1"}.fa-phone-waveform{--fa:"\e6e2"}.fa-postage-stamp{--fa:"\e6e3"}.fa-circle-florin{--fa:"\e6e4"}.fa-circle-ruble{--fa:"\e6e5"}.fa-square-chf{--fa:"\e6e6"}.fa-square-lira{--fa:"\e6e7"}.fa-norwegian-krone-sign{--fa:"\e6e8"}.fa-circle-renminbi{--fa:"\e6e9"}.fa-square-peseta{--fa:"\e6ea"}.fa-circle-brazilian-real{--fa:"\e6eb"}.fa-circle-won{--fa:"\e6ec"}.fa-square-cruzeiro{--fa:"\e6ed"}.fa-circle-currency{--fa:"\e6ee"}.fa-circle-hryvnia{--fa:"\e6ef"}.fa-square-cent{--fa:"\e6f0"}.fa-square-brazilian-real{--fa:"\e6f1"}.fa-square-bitcoin{--fa:"\e6f2"}.fa-circle-peruvian-soles{--fa:"\e6f3"}.fa-circle-litecoin{--fa:"\e6f4"}.fa-square-indian-rupee{--fa:"\e6f5"}.fa-circle-lira{--fa:"\e6f6"}.fa-square-litecoin{--fa:"\e6f7"}.fa-square-ruble{--fa:"\e6f8"}.fa-circle-malaysian-ringgit{--fa:"\e6f9"}.fa-malaysian-ringgit-sign{--fa:"\e6fa"}.fa-circle-manat{--fa:"\e6fb"}.fa-circle-colon{--fa:"\e6fc"}.fa-circle-kip{--fa:"\e6fd"}.fa-australian-dollar-sign{--fa:"\e6fe"}.fa-circle-peso{--fa:"\e6ff"}.fa-circle-polish-zloty{--fa:"\e700"}.fa-circle-bangladeshi-taka{--fa:"\e701"}.fa-circle-mill{--fa:"\e702"}.fa-circle-shekel{--fa:"\e703"}.fa-square-manat{--fa:"\e704"}.fa-peruvian-soles-sign{--fa:"\e705"}.fa-circle-rupiah{--fa:"\e706"}.fa-square-norwegian-krone{--fa:"\e707"}.fa-square-naira{--fa:"\e708"}.fa-square-won{--fa:"\e709"}.fa-square-mill{--fa:"\e70a"}.fa-polish-zloty-sign{--fa:"\e70b"}.fa-square-currency{--fa:"\e70c"}.fa-square-kip{--fa:"\e70d"}.fa-square-guarani{--fa:"\e70e"}.fa-square-dong{--fa:"\e70f"}.fa-square-hryvnia{--fa:"\e710"}.fa-circle-tugrik{--fa:"\e711"}.fa-square-rupiah{--fa:"\e712"}.fa-square-sterling{--fa:"\e713"}.fa-circle-rupee{--fa:"\e714"}.fa-square-rupee{--fa:"\e715"}.fa-square-peruvian-soles{--fa:"\e716"}.fa-square-florin{--fa:"\e717"}.fa-square-australian-dollar{--fa:"\e718"}.fa-square-baht{--fa:"\e719"}.fa-square-peso{--fa:"\e71a"}.fa-circle-austral{--fa:"\e71b"}.fa-square-swedish-krona{--fa:"\e71c"}.fa-circle-lari{--fa:"\e71d"}.fa-circleapore-dollar{--fa:"\e71e"}.fa-square-turkish-lira{--fa:"\e71f"}.fa-danish-krone-sign{--fa:"\e720"}.fa-circle-franc{--fa:"\e721"}.fa-circle-cruzeiro{--fa:"\e722"}.fa-circle-dong{--fa:"\e723"}.fa-square-yen{--fa:"\e724"}.fa-circle-tenge{--fa:"\e725"}.fa-square-austral{--fa:"\e726"}.fa-square-eurozone{--fa:"\e727"}.fa-square-tugrik{--fa:"\e728"}.fa-square-cedi{--fa:"\e729"}.fa-circle-cent{--fa:"\e72a"}.fa-currency-sign{--fa:"\e72b"}.fa-circle-chf{--fa:"\e72c"}.fa-circle-baht{--fa:"\e72d"}.fa-signapore-dollar-sign{--fa:"\e72e"}.fa-square-franc{--fa:"\e72f"}.fa-circle-australian-dollar{--fa:"\e730"}.fa-square-tenge{--fa:"\e731"}.fa-square-euro{--fa:"\e732"}.fa-squareapore-dollar{--fa:"\e733"}.fa-circle-indian-rupee{--fa:"\e734"}.fa-square-shekel{--fa:"\e735"}.fa-square-polish-zloty{--fa:"\e736"}.fa-circle-bitcoin{--fa:"\e737"}.fa-circle-norwegian-krone{--fa:"\e738"}.fa-circle-turkish-lira{--fa:"\e739"}.fa-square-colon{--fa:"\e73a"}.fa-circle-guarani{--fa:"\e73b"}.fa-renminbi-sign{--fa:"\e73c"}.fa-square-renminbi{--fa:"\e73d"}.fa-swedish-krona-sign{--fa:"\e73e"}.fa-square-lari{--fa:"\e73f"}.fa-eurozone-sign{--fa:"\e740"}.fa-circle-peseta{--fa:"\e741"}.fa-circle-cedi{--fa:"\e742"}.fa-circle-swedish-krona{--fa:"\e743"}.fa-square-bangladeshi-taka{--fa:"\e744"}.fa-circle-eurozone{--fa:"\e745"}.fa-circle-danish-krone{--fa:"\e746"}.fa-square-danish-krone{--fa:"\e747"}.fa-square-malaysian-ringgit{--fa:"\e748"}.fa-circle-naira{--fa:"\e749"}.fa-mobile-arrow-down{--fa:"\e74b"}.fa-clone-plus{--fa:"\e74c"}.fa-paintbrush-fine-slash{--fa:"\e74d"}.fa-paintbrush-slash{--fa:"\e74e"}.fa-pencil-line{--fa:"\e74f"}.fa-slider-circle{--fa:"\e750"}.fa-thumbtack-angle{--fa:"\e751"}.fa-thumbtack-angle-slash{--fa:"\e752"}.fa-book-open-lines{--fa:"\e753"}.fa-book-spine{--fa:"\e754"}.fa-bookmark-plus{--fa:"\e755"}.fa-clipboard-clock{--fa:"\e756"}.fa-clipboard-exclamation{--fa:"\e757"}.fa-file-ban{--fa:"\e758"}.fa-notes-sticky{--fa:"\e759"}.fa-capsule{--fa:"\e75a"}.fa-ear-circle-checkmark{--fa:"\e75b"}.fa-ear-triangle-exclamation{--fa:"\e75c"}.fa-ear-waveform{--fa:"\e75d"}.fa-head-side-circuit{--fa:"\e75e"}.fa-head-side-speak{--fa:"\e75f"}.fa-microphone-signal-meter{--fa:"\e760"}.fa-spine{--fa:"\e761"}.fa-vial-vertical{--fa:"\e762"}.fa-bin{--fa:"\e763"}.fa-seat{--fa:"\e764"}.fa-seats{--fa:"\e765"}.fa-camera-circle-ellipsis{--fa:"\e766"}.fa-camera-clock{--fa:"\e767"}.fa-camera-shutter{--fa:"\e768"}.fa-film-music{--fa:"\e769"}.fa-film-stack{--fa:"\e76b"}.fa-image-circle-arrow-down{--fa:"\e76c"}.fa-image-circle-check{--fa:"\e76d"}.fa-image-circle-plus{--fa:"\e76e"}.fa-image-circle-xmark{--fa:"\e76f"}.fa-image-music{--fa:"\e770"}.fa-image-stack{--fa:"\e771"}.fa-rectangle-4k{--fa:"\e772"}.fa-rectangle-hdr,.fa-rectangle-high-dynamic-range{--fa:"\e773"}.fa-rectangle-video-on-demand{--fa:"\e774"}.fa-user-viewfinder{--fa:"\e775"}.fa-video-down-to-line{--fa:"\e776"}.fa-video-question{--fa:"\e777"}.fa-gas-pump-left{--fa:"\e778"}.fa-gas-pump-right{--fa:"\e779"}.fa-location-arrow-slash{--fa:"\e77a"}.fa-airplay-audio{--fa:"\e77b"}.fa-headphones-slash{--fa:"\e77c"}.fa-microphone-circle-plus{--fa:"\e77d"}.fa-microphone-circle-xmark{--fa:"\e77e"}.fa-open-captioning{--fa:"\e77f"}.fa-play-flip{--fa:"\e780"}.fa-square-microphone{--fa:"\e781"}.fa-trombone{--fa:"\e782"}.fa-person-arms-raised{--fa:"\e783"}.fa-person-basketball{--fa:"\e784"}.fa-person-carry-empty{--fa:"\e785"}.fa-person-golfing{--fa:"\e786"}.fa-person-limbs-wide{--fa:"\e787"}.fa-person-seat-window{--fa:"\e788"}.fa-person-soccer{--fa:"\e789"}.fa-person-swimming-pool{--fa:"\e78a"}.fa-person-swimming-water{--fa:"\e78b"}.fa-person-water-arms-raised{--fa:"\e78c"}.fa-person-waving{--fa:"\e78d"}.fa-heart-slash{--fa:"\e78e"}.fa-hearts{--fa:"\e78f"}.fa-pentagon{--fa:"\e790"}.fa-rectangle-tall{--fa:"\e791"}.fa-square-half{--fa:"\e792"}.fa-square-half-stroke{--fa:"\e793"}.fa-box-arrow-down{--fa:"\e794"}.fa-box-arrow-down-arrow-up{--fa:"\e795"}.fa-box-arrow-down-magnifying-glass{--fa:"\e796"}.fa-box-isometric{--fa:"\e797"}.fa-box-isometric-tape{--fa:"\e798"}.fa-qrcode-read{--fa:"\e799"}.fa-shop-24{--fa:"\e79a"}.fa-store-24{--fa:"\e79b"}.fa-face-shaking{--fa:"\e79c"}.fa-face-shaking-horizontal{--fa:"\e79d"}.fa-face-shaking-vertical{--fa:"\e79e"}.fa-circle-user-circle-check{--fa:"\e79f"}.fa-circle-user-circle-exclamation{--fa:"\e7a0"}.fa-circle-user-circle-minus{--fa:"\e7a1"}.fa-circle-user-circle-moon{--fa:"\e7a2"}.fa-circle-user-circle-plus{--fa:"\e7a3"}.fa-circle-user-circle-question{--fa:"\e7a4"}.fa-circle-user-circle-user{--fa:"\e7a5"}.fa-circle-user-circle-xmark{--fa:"\e7a6"}.fa-circle-user-clock{--fa:"\e7a7"}.fa-user-beard{--fa:"\e7a8"}.fa-user-chef-hair-long{--fa:"\e7a9"}.fa-user-circle-minus{--fa:"\e7aa"}.fa-user-circle-plus{--fa:"\e7ab"}.fa-user-dashed{--fa:"\e7ac"}.fa-user-doctor-hair-mullet{--fa:"\e7ad"}.fa-user-hat-tie{--fa:"\e7ae"}.fa-user-hat-tie-magnifying-glass{--fa:"\e7af"}.fa-user-key{--fa:"\e7b0"}.fa-user-message{--fa:"\e7b1"}.fa-user-microphone{--fa:"\e7b2"}.fa-user-pilot-hair-long{--fa:"\e7b3"}.fa-user-pilot-tie-hair-long{--fa:"\e7b4"}.fa-user-police-hair-long{--fa:"\e7b5"}.fa-user-police-tie-hair-long{--fa:"\e7b6"}.fa-user-question{--fa:"\e7b7"}.fa-user-sith{--fa:"\e7b8"}.fa-user-tie-hair-mullet{--fa:"\e7b9"}.fa-user-vneck-hair-mullet{--fa:"\e7ba"}.fa-plane-flying{--fa:"\e7bb"}.fa-plane-landing-gear{--fa:"\e7bc"}.fa-rocket-vertical{--fa:"\e7bd"}.fa-seat-airline-window{--fa:"\e7be"}.fa-shuttle-space-vertical{--fa:"\e7bf"}.fa-car-key{--fa:"\e7c0"}.fa-car-siren{--fa:"\e7c1"}.fa-car-siren-on{--fa:"\e7c2"}.fa-scooter{--fa:"\e7c3"}.fa-snowmobile-blank{--fa:"\e7c4"}.fa-stair-car{--fa:"\e7c5"}.fa-truck-suv{--fa:"\e7c6"}.fa-unicycle{--fa:"\e7c7"}.fa-van{--fa:"\e7c8"}.fa-moon-star{--fa:"\e7c9"}.fa-rainbow-half{--fa:"\e7ca"}.fa-temperature-slash{--fa:"\e7cb"}.fa-dialpad{--fa:"\e7cc"}.fa-computer-mouse-button-left{--fa:"\e7cd"}.fa-computer-mouse-button-right{--fa:"\e7ce"}.fa-dot{--fa:"\e7d1"}.fa-folder-arrow-left{--fa:"\e7d2"}.fa-folder-arrow-right{--fa:"\e7d3"}.fa-wireless{--fa:"\e7df"}.fa-circle-moon{--fa:"\e7e0"}.fa-person-meditating{--fa:"\e7e1"}.fa-baseball-bat{--fa:"\e7e5"}.fa-hockey-stick{--fa:"\e7e6"}.fa-arrow-u-turn-down-left{--fa:"\e7e7"}.fa-arrow-u-turn-down-right{--fa:"\e7e8"}.fa-arrow-u-turn-left-down{--fa:"\e7e9"}.fa-arrow-u-turn-left-up{--fa:"\e7ea"}.fa-arrow-u-turn-right-down{--fa:"\e7eb"}.fa-arrow-u-turn-right-up{--fa:"\e7ec"}.fa-arrow-u-turn-up-left{--fa:"\e7ed"}.fa-arrow-u-turn-up-right{--fa:"\e7ee"}.fa-u-turn-down-left{--fa:"\e7ef"}.fa-u-turn-down-right{--fa:"\e7f0"}.fa-u-turn,.fa-u-turn-left-down{--fa:"\e7f1"}.fa-u-turn-left-up{--fa:"\e7f2"}.fa-u-turn-right-down{--fa:"\e7f3"}.fa-u-turn-right-up{--fa:"\e7f4"}.fa-u-turn-up-left{--fa:"\e7f5"}.fa-u-turn-up-right{--fa:"\e7f6"}.fa-triple-chevrons-down{--fa:"\e7f7"}.fa-triple-chevrons-left{--fa:"\e7f8"}.fa-triple-chevrons-right{--fa:"\e7f9"}.fa-triple-chevrons-up{--fa:"\e7fa"}.fa-file-aiff{--fa:"\e7fb"}.fa-file-odf{--fa:"\e7fc"}.fa-file-tex{--fa:"\e7fd"}.fa-file-wav{--fa:"\e7fe"}.fa-droplet-plus{--fa:"\e800"}.fa-hand-holding-star{--fa:"\e801"}.fa-transmission{--fa:"\e802"}.fa-alarm-minus{--fa:"\e803"}.fa-file-brackets-curly{--fa:"\e804"}.fa-file-midi{--fa:"\e805"}.fa-midi{--fa:"\e806"}.fa-non-binary{--fa:"\e807"}.fa-rectangle-beta{--fa:"\e808"}.fa-shield-user{--fa:"\e809"}.fa-spiral{--fa:"\e80a"}.fa-picture-in-picture{--fa:"\e80b"}.fa-circle-half-horizontal{--fa:"\e80c"}.fa-circle-half-stroke-horizontal{--fa:"\e80d"}.fa-square-half-horizontal{--fa:"\e80e"}.fa-square-half-stroke-horizontal{--fa:"\e80f"}.fa-ship-large{--fa:"\e810"}.fa-butterfly{--fa:"\e811"}.fa-mobile-rotate{--fa:"\e813"}.fa-mobile-rotate-reverse{--fa:"\e814"}.fa-mobile-slash{--fa:"\e815"}.fa-mobile-vibrate{--fa:"\e816"}.fa-mobile-vibrate-slash{--fa:"\e817"}.fa-almost-equal-to{--fa:"\e818"}.fa-sneaker-running{--fa:"\e819"}.fa-horseshoe{--fa:"\e81a"}.fa-single-quote-left{--fa:"\e81b"}.fa-single-quote-right{--fa:"\e81c"}.fa-bus-side{--fa:"\e81d"}.fa-bus-stop{--fa:"\e81e"}.fa-train-stop{--fa:"\e81f"}.fa-heptagon,.fa-septagon{--fa:"\e820"}.fa-mailbox-open-empty{--fa:"\e821"}.fa-mailbox-open-letter{--fa:"\e823"}.fa-lychee{--fa:"\e824"}.fa-tank-recovery{--fa:"\e825"}.fa-transducer{--fa:"\e826"}.fa-box-arrow-up{--fa:"\e827"}.fa-box-magnifying-glass{--fa:"\e828"}.fa-envelope-certificate,.fa-envelope-ribbon{--fa:"\e829"}.fa-water-temp,.fa-water-temperature{--fa:"\e82a"}.fa-aeropress{--fa:"\e82b"}.fa-caret-large-down{--fa:"\e82c"}.fa-caret-large-left{--fa:"\e82d"}.fa-caret-large-right{--fa:"\e82e"}.fa-caret-large-up{--fa:"\e82f"}.fa-chemex{--fa:"\e830"}.fa-hand-shaka{--fa:"\e831"}.fa-kettlebell{--fa:"\e832"}.fa-foot-wing{--fa:"\e834"}.fa-pump-impeller{--fa:"\e835"}.fa-arrow-rotate-left-10{--fa:"\e836"}.fa-arrow-rotate-right-10{--fa:"\e837"}.fa-glass-martini,.fa-martini-glass-empty{--fa:"\f000"}.fa-music{--fa:"\f001"}.fa-magnifying-glass,.fa-search{--fa:"\f002"}.fa-heart{--fa:"\f004"}.fa-star{--fa:"\f005"}.fa-user,.fa-user-alt,.fa-user-large{--fa:"\f007"}.fa-film,.fa-film-alt,.fa-film-simple{--fa:"\f008"}.fa-table-cells-large,.fa-th-large{--fa:"\f009"}.fa-table-cells,.fa-th{--fa:"\f00a"}.fa-table-list,.fa-th-list{--fa:"\f00b"}.fa-check{--fa:"\f00c"}.fa-close,.fa-multiply,.fa-remove,.fa-times,.fa-xmark{--fa:"\f00d"}.fa-magnifying-glass-plus,.fa-search-plus{--fa:"\f00e"}.fa-magnifying-glass-minus,.fa-search-minus{--fa:"\f010"}.fa-power-off{--fa:"\f011"}.fa-signal,.fa-signal-5,.fa-signal-perfect{--fa:"\f012"}.fa-cog,.fa-gear{--fa:"\f013"}.fa-home,.fa-home-alt,.fa-home-lg-alt,.fa-house{--fa:"\f015"}.fa-clock,.fa-clock-four{--fa:"\f017"}.fa-road{--fa:"\f018"}.fa-download{--fa:"\f019"}.fa-inbox{--fa:"\f01c"}.fa-arrow-right-rotate,.fa-arrow-rotate-forward,.fa-arrow-rotate-right,.fa-redo{--fa:"\f01e"}.fa-arrows-rotate,.fa-refresh,.fa-sync{--fa:"\f021"}.fa-list-alt,.fa-rectangle-list{--fa:"\f022"}.fa-lock{--fa:"\f023"}.fa-flag{--fa:"\f024"}.fa-headphones,.fa-headphones-alt,.fa-headphones-simple{--fa:"\f025"}.fa-volume-off{--fa:"\f026"}.fa-volume-down,.fa-volume-low{--fa:"\f027"}.fa-volume-high,.fa-volume-up{--fa:"\f028"}.fa-qrcode{--fa:"\f029"}.fa-barcode{--fa:"\f02a"}.fa-tag{--fa:"\f02b"}.fa-tags{--fa:"\f02c"}.fa-book{--fa:"\f02d"}.fa-bookmark{--fa:"\f02e"}.fa-print{--fa:"\f02f"}.fa-camera,.fa-camera-alt{--fa:"\f030"}.fa-font{--fa:"\f031"}.fa-bold{--fa:"\f032"}.fa-italic{--fa:"\f033"}.fa-text-height{--fa:"\f034"}.fa-text-width{--fa:"\f035"}.fa-align-left{--fa:"\f036"}.fa-align-center{--fa:"\f037"}.fa-align-right{--fa:"\f038"}.fa-align-justify{--fa:"\f039"}.fa-list,.fa-list-squares{--fa:"\f03a"}.fa-dedent,.fa-outdent{--fa:"\f03b"}.fa-indent{--fa:"\f03c"}.fa-video,.fa-video-camera{--fa:"\f03d"}.fa-image{--fa:"\f03e"}.fa-location-pin,.fa-map-marker{--fa:"\f041"}.fa-adjust,.fa-circle-half-stroke{--fa:"\f042"}.fa-droplet,.fa-tint{--fa:"\f043"}.fa-edit,.fa-pen-to-square{--fa:"\f044"}.fa-arrows,.fa-arrows-up-down-left-right{--fa:"\f047"}.fa-backward-step,.fa-step-backward{--fa:"\f048"}.fa-backward-fast,.fa-fast-backward{--fa:"\f049"}.fa-backward{--fa:"\f04a"}.fa-play{--fa:"\f04b"}.fa-pause{--fa:"\f04c"}.fa-stop{--fa:"\f04d"}.fa-forward{--fa:"\f04e"}.fa-fast-forward,.fa-forward-fast{--fa:"\f050"}.fa-forward-step,.fa-step-forward{--fa:"\f051"}.fa-eject{--fa:"\f052"}.fa-chevron-left{--fa:"\f053"}.fa-chevron-right{--fa:"\f054"}.fa-circle-plus,.fa-plus-circle{--fa:"\f055"}.fa-circle-minus,.fa-minus-circle{--fa:"\f056"}.fa-circle-xmark,.fa-times-circle,.fa-xmark-circle{--fa:"\f057"}.fa-check-circle,.fa-circle-check{--fa:"\f058"}.fa-circle-question,.fa-question-circle{--fa:"\f059"}.fa-circle-info,.fa-info-circle{--fa:"\f05a"}.fa-crosshairs{--fa:"\f05b"}.fa-ban,.fa-cancel{--fa:"\f05e"}.fa-arrow-left{--fa:"\f060"}.fa-arrow-right{--fa:"\f061"}.fa-arrow-up{--fa:"\f062"}.fa-arrow-down{--fa:"\f063"}.fa-mail-forward,.fa-share{--fa:"\f064"}.fa-expand{--fa:"\f065"}.fa-compress{--fa:"\f066"}.fa-minus,.fa-subtract{--fa:"\f068"}.fa-circle-exclamation,.fa-exclamation-circle{--fa:"\f06a"}.fa-gift{--fa:"\f06b"}.fa-leaf{--fa:"\f06c"}.fa-fire{--fa:"\f06d"}.fa-eye{--fa:"\f06e"}.fa-eye-slash{--fa:"\f070"}.fa-exclamation-triangle,.fa-triangle-exclamation,.fa-warning{--fa:"\f071"}.fa-plane{--fa:"\f072"}.fa-calendar-alt,.fa-calendar-days{--fa:"\f073"}.fa-random,.fa-shuffle{--fa:"\f074"}.fa-comment{--fa:"\f075"}.fa-magnet{--fa:"\f076"}.fa-chevron-up{--fa:"\f077"}.fa-chevron-down{--fa:"\f078"}.fa-retweet{--fa:"\f079"}.fa-cart-shopping,.fa-shopping-cart{--fa:"\f07a"}.fa-folder,.fa-folder-blank{--fa:"\f07b"}.fa-folder-open{--fa:"\f07c"}.fa-arrows-up-down,.fa-arrows-v{--fa:"\f07d"}.fa-arrows-h,.fa-arrows-left-right{--fa:"\f07e"}.fa-bar-chart,.fa-chart-bar{--fa:"\f080"}.fa-camera-retro{--fa:"\f083"}.fa-key{--fa:"\f084"}.fa-cogs,.fa-gears{--fa:"\f085"}.fa-comments{--fa:"\f086"}.fa-star-half{--fa:"\f089"}.fa-arrow-right-from-bracket,.fa-sign-out{--fa:"\f08b"}.fa-thumb-tack,.fa-thumbtack{--fa:"\f08d"}.fa-arrow-up-right-from-square,.fa-external-link{--fa:"\f08e"}.fa-arrow-right-to-bracket,.fa-sign-in{--fa:"\f090"}.fa-trophy{--fa:"\f091"}.fa-upload{--fa:"\f093"}.fa-lemon{--fa:"\f094"}.fa-phone{--fa:"\f095"}.fa-phone-square,.fa-square-phone{--fa:"\f098"}.fa-unlock{--fa:"\f09c"}.fa-credit-card,.fa-credit-card-alt{--fa:"\f09d"}.fa-feed,.fa-rss{--fa:"\f09e"}.fa-hard-drive,.fa-hdd{--fa:"\f0a0"}.fa-bullhorn{--fa:"\f0a1"}.fa-certificate{--fa:"\f0a3"}.fa-hand-point-right{--fa:"\f0a4"}.fa-hand-point-left{--fa:"\f0a5"}.fa-hand-point-up{--fa:"\f0a6"}.fa-hand-point-down{--fa:"\f0a7"}.fa-arrow-circle-left,.fa-circle-arrow-left{--fa:"\f0a8"}.fa-arrow-circle-right,.fa-circle-arrow-right{--fa:"\f0a9"}.fa-arrow-circle-up,.fa-circle-arrow-up{--fa:"\f0aa"}.fa-arrow-circle-down,.fa-circle-arrow-down{--fa:"\f0ab"}.fa-globe{--fa:"\f0ac"}.fa-wrench{--fa:"\f0ad"}.fa-list-check,.fa-tasks{--fa:"\f0ae"}.fa-filter{--fa:"\f0b0"}.fa-briefcase{--fa:"\f0b1"}.fa-arrows-alt,.fa-up-down-left-right{--fa:"\f0b2"}.fa-users{--fa:"\f0c0"}.fa-chain,.fa-link{--fa:"\f0c1"}.fa-cloud{--fa:"\f0c2"}.fa-flask{--fa:"\f0c3"}.fa-cut,.fa-scissors{--fa:"\f0c4"}.fa-copy{--fa:"\f0c5"}.fa-paperclip{--fa:"\f0c6"}.fa-floppy-disk,.fa-save{--fa:"\f0c7"}.fa-square{--fa:"\f0c8"}.fa-bars,.fa-navicon{--fa:"\f0c9"}.fa-list-dots,.fa-list-ul{--fa:"\f0ca"}.fa-list-1-2,.fa-list-numeric,.fa-list-ol{--fa:"\f0cb"}.fa-strikethrough{--fa:"\f0cc"}.fa-underline{--fa:"\f0cd"}.fa-table{--fa:"\f0ce"}.fa-magic,.fa-wand-magic{--fa:"\f0d0"}.fa-truck{--fa:"\f0d1"}.fa-money-bill{--fa:"\f0d6"}.fa-caret-down{--fa:"\f0d7"}.fa-caret-up{--fa:"\f0d8"}.fa-caret-left{--fa:"\f0d9"}.fa-caret-right{--fa:"\f0da"}.fa-columns,.fa-table-columns{--fa:"\f0db"}.fa-sort,.fa-unsorted{--fa:"\f0dc"}.fa-sort-desc,.fa-sort-down{--fa:"\f0dd"}.fa-sort-asc,.fa-sort-up{--fa:"\f0de"}.fa-envelope{--fa:"\f0e0"}.fa-arrow-left-rotate,.fa-arrow-rotate-back,.fa-arrow-rotate-backward,.fa-arrow-rotate-left,.fa-undo{--fa:"\f0e2"}.fa-gavel,.fa-legal{--fa:"\f0e3"}.fa-bolt,.fa-zap{--fa:"\f0e7"}.fa-sitemap{--fa:"\f0e8"}.fa-umbrella{--fa:"\f0e9"}.fa-file-clipboard,.fa-paste{--fa:"\f0ea"}.fa-lightbulb{--fa:"\f0eb"}.fa-arrow-right-arrow-left,.fa-exchange{--fa:"\f0ec"}.fa-cloud-arrow-down,.fa-cloud-download,.fa-cloud-download-alt{--fa:"\f0ed"}.fa-cloud-arrow-up,.fa-cloud-upload,.fa-cloud-upload-alt{--fa:"\f0ee"}.fa-user-doctor,.fa-user-md{--fa:"\f0f0"}.fa-stethoscope{--fa:"\f0f1"}.fa-suitcase{--fa:"\f0f2"}.fa-bell{--fa:"\f0f3"}.fa-coffee,.fa-mug-saucer{--fa:"\f0f4"}.fa-hospital,.fa-hospital-alt,.fa-hospital-wide{--fa:"\f0f8"}.fa-ambulance,.fa-truck-medical{--fa:"\f0f9"}.fa-medkit,.fa-suitcase-medical{--fa:"\f0fa"}.fa-fighter-jet,.fa-jet-fighter{--fa:"\f0fb"}.fa-beer,.fa-beer-mug-empty{--fa:"\f0fc"}.fa-h-square,.fa-square-h{--fa:"\f0fd"}.fa-plus-square,.fa-square-plus{--fa:"\f0fe"}.fa-angle-double-left,.fa-angles-left{--fa:"\f100"}.fa-angle-double-right,.fa-angles-right{--fa:"\f101"}.fa-angle-double-up,.fa-angles-up{--fa:"\f102"}.fa-angle-double-down,.fa-angles-down{--fa:"\f103"}.fa-angle-left{--fa:"\f104"}.fa-angle-right{--fa:"\f105"}.fa-angle-up{--fa:"\f106"}.fa-angle-down{--fa:"\f107"}.fa-laptop{--fa:"\f109"}.fa-tablet-button{--fa:"\f10a"}.fa-mobile-button{--fa:"\f10b"}.fa-quote-left,.fa-quote-left-alt{--fa:"\f10d"}.fa-quote-right,.fa-quote-right-alt{--fa:"\f10e"}.fa-spinner{--fa:"\f110"}.fa-circle{--fa:"\f111"}.fa-face-smile,.fa-smile{--fa:"\f118"}.fa-face-frown,.fa-frown{--fa:"\f119"}.fa-face-meh,.fa-meh{--fa:"\f11a"}.fa-gamepad{--fa:"\f11b"}.fa-keyboard{--fa:"\f11c"}.fa-flag-checkered{--fa:"\f11e"}.fa-terminal{--fa:"\f120"}.fa-code{--fa:"\f121"}.fa-mail-reply-all,.fa-reply-all{--fa:"\f122"}.fa-location-arrow{--fa:"\f124"}.fa-crop{--fa:"\f125"}.fa-code-branch{--fa:"\f126"}.fa-chain-broken,.fa-chain-slash,.fa-link-slash,.fa-unlink{--fa:"\f127"}.fa-info{--fa:"\f129"}.fa-superscript{--fa:"\f12b"}.fa-subscript{--fa:"\f12c"}.fa-eraser{--fa:"\f12d"}.fa-puzzle-piece{--fa:"\f12e"}.fa-microphone{--fa:"\f130"}.fa-microphone-slash{--fa:"\f131"}.fa-shield,.fa-shield-blank{--fa:"\f132"}.fa-calendar{--fa:"\f133"}.fa-fire-extinguisher{--fa:"\f134"}.fa-rocket{--fa:"\f135"}.fa-chevron-circle-left,.fa-circle-chevron-left{--fa:"\f137"}.fa-chevron-circle-right,.fa-circle-chevron-right{--fa:"\f138"}.fa-chevron-circle-up,.fa-circle-chevron-up{--fa:"\f139"}.fa-chevron-circle-down,.fa-circle-chevron-down{--fa:"\f13a"}.fa-anchor{--fa:"\f13d"}.fa-unlock-alt,.fa-unlock-keyhole{--fa:"\f13e"}.fa-bullseye{--fa:"\f140"}.fa-ellipsis,.fa-ellipsis-h{--fa:"\f141"}.fa-ellipsis-v,.fa-ellipsis-vertical{--fa:"\f142"}.fa-rss-square,.fa-square-rss{--fa:"\f143"}.fa-circle-play,.fa-play-circle{--fa:"\f144"}.fa-ticket{--fa:"\f145"}.fa-minus-square,.fa-square-minus{--fa:"\f146"}.fa-arrow-turn-up,.fa-level-up{--fa:"\f148"}.fa-arrow-turn-down,.fa-level-down{--fa:"\f149"}.fa-check-square,.fa-square-check{--fa:"\f14a"}.fa-pen-square,.fa-pencil-square,.fa-square-pen{--fa:"\f14b"}.fa-external-link-square,.fa-square-arrow-up-right{--fa:"\f14c"}.fa-share-from-square,.fa-share-square{--fa:"\f14d"}.fa-compass{--fa:"\f14e"}.fa-caret-square-down,.fa-square-caret-down{--fa:"\f150"}.fa-caret-square-up,.fa-square-caret-up{--fa:"\f151"}.fa-caret-square-right,.fa-square-caret-right{--fa:"\f152"}.fa-eur,.fa-euro,.fa-euro-sign{--fa:"\f153"}.fa-gbp,.fa-pound-sign,.fa-sterling-sign{--fa:"\f154"}.fa-rupee,.fa-rupee-sign{--fa:"\f156"}.fa-cny,.fa-jpy,.fa-rmb,.fa-yen,.fa-yen-sign{--fa:"\f157"}.fa-rouble,.fa-rub,.fa-ruble,.fa-ruble-sign{--fa:"\f158"}.fa-krw,.fa-won,.fa-won-sign{--fa:"\f159"}.fa-file{--fa:"\f15b"}.fa-file-alt,.fa-file-lines,.fa-file-text{--fa:"\f15c"}.fa-arrow-down-a-z,.fa-sort-alpha-asc,.fa-sort-alpha-down{--fa:"\f15d"}.fa-arrow-up-a-z,.fa-sort-alpha-up{--fa:"\f15e"}.fa-arrow-down-wide-short,.fa-sort-amount-asc,.fa-sort-amount-down{--fa:"\f160"}.fa-arrow-up-wide-short,.fa-sort-amount-up{--fa:"\f161"}.fa-arrow-down-1-9,.fa-sort-numeric-asc,.fa-sort-numeric-down{--fa:"\f162"}.fa-arrow-up-1-9,.fa-sort-numeric-up{--fa:"\f163"}.fa-thumbs-up{--fa:"\f164"}.fa-thumbs-down{--fa:"\f165"}.fa-arrow-down-long,.fa-long-arrow-down{--fa:"\f175"}.fa-arrow-up-long,.fa-long-arrow-up{--fa:"\f176"}.fa-arrow-left-long,.fa-long-arrow-left{--fa:"\f177"}.fa-arrow-right-long,.fa-long-arrow-right{--fa:"\f178"}.fa-female,.fa-person-dress{--fa:"\f182"}.fa-male,.fa-person{--fa:"\f183"}.fa-sun{--fa:"\f185"}.fa-moon{--fa:"\f186"}.fa-archive,.fa-box-archive{--fa:"\f187"}.fa-bug{--fa:"\f188"}.fa-caret-square-left,.fa-square-caret-left{--fa:"\f191"}.fa-circle-dot,.fa-dot-circle{--fa:"\f192"}.fa-wheelchair{--fa:"\f193"}.fa-lira-sign{--fa:"\f195"}.fa-shuttle-space,.fa-space-shuttle{--fa:"\f197"}.fa-envelope-square,.fa-square-envelope{--fa:"\f199"}.fa-bank,.fa-building-columns,.fa-institution,.fa-museum,.fa-university{--fa:"\f19c"}.fa-graduation-cap,.fa-mortar-board{--fa:"\f19d"}.fa-language{--fa:"\f1ab"}.fa-fax{--fa:"\f1ac"}.fa-building{--fa:"\f1ad"}.fa-child{--fa:"\f1ae"}.fa-paw{--fa:"\f1b0"}.fa-cube{--fa:"\f1b2"}.fa-cubes{--fa:"\f1b3"}.fa-recycle{--fa:"\f1b8"}.fa-automobile,.fa-car{--fa:"\f1b9"}.fa-cab,.fa-taxi{--fa:"\f1ba"}.fa-tree{--fa:"\f1bb"}.fa-database{--fa:"\f1c0"}.fa-file-pdf{--fa:"\f1c1"}.fa-file-word{--fa:"\f1c2"}.fa-file-excel{--fa:"\f1c3"}.fa-file-powerpoint{--fa:"\f1c4"}.fa-file-image{--fa:"\f1c5"}.fa-file-archive,.fa-file-zipper{--fa:"\f1c6"}.fa-file-audio{--fa:"\f1c7"}.fa-file-video{--fa:"\f1c8"}.fa-file-code{--fa:"\f1c9"}.fa-life-ring{--fa:"\f1cd"}.fa-circle-notch{--fa:"\f1ce"}.fa-paper-plane{--fa:"\f1d8"}.fa-clock-rotate-left,.fa-history{--fa:"\f1da"}.fa-header,.fa-heading{--fa:"\f1dc"}.fa-paragraph{--fa:"\f1dd"}.fa-sliders,.fa-sliders-h{--fa:"\f1de"}.fa-share-alt,.fa-share-nodes{--fa:"\f1e0"}.fa-share-alt-square,.fa-square-share-nodes{--fa:"\f1e1"}.fa-bomb{--fa:"\f1e2"}.fa-futbol,.fa-futbol-ball,.fa-soccer-ball{--fa:"\f1e3"}.fa-teletype,.fa-tty{--fa:"\f1e4"}.fa-binoculars{--fa:"\f1e5"}.fa-plug{--fa:"\f1e6"}.fa-newspaper{--fa:"\f1ea"}.fa-wifi,.fa-wifi-3,.fa-wifi-strong{--fa:"\f1eb"}.fa-calculator{--fa:"\f1ec"}.fa-bell-slash{--fa:"\f1f6"}.fa-trash{--fa:"\f1f8"}.fa-copyright{--fa:"\f1f9"}.fa-eye-dropper,.fa-eye-dropper-empty,.fa-eyedropper{--fa:"\f1fb"}.fa-paint-brush,.fa-paintbrush{--fa:"\f1fc"}.fa-birthday-cake,.fa-cake,.fa-cake-candles{--fa:"\f1fd"}.fa-area-chart,.fa-chart-area{--fa:"\f1fe"}.fa-chart-pie,.fa-pie-chart{--fa:"\f200"}.fa-chart-line,.fa-line-chart{--fa:"\f201"}.fa-toggle-off{--fa:"\f204"}.fa-toggle-on{--fa:"\f205"}.fa-bicycle{--fa:"\f206"}.fa-bus{--fa:"\f207"}.fa-closed-captioning{--fa:"\f20a"}.fa-ils,.fa-shekel,.fa-shekel-sign,.fa-sheqel,.fa-sheqel-sign{--fa:"\f20b"}.fa-cart-plus{--fa:"\f217"}.fa-cart-arrow-down{--fa:"\f218"}.fa-diamond{--fa:"\f219"}.fa-ship{--fa:"\f21a"}.fa-user-secret{--fa:"\f21b"}.fa-motorcycle{--fa:"\f21c"}.fa-street-view{--fa:"\f21d"}.fa-heart-pulse,.fa-heartbeat{--fa:"\f21e"}.fa-venus{--fa:"\f221"}.fa-mars{--fa:"\f222"}.fa-mercury{--fa:"\f223"}.fa-mars-and-venus{--fa:"\f224"}.fa-transgender,.fa-transgender-alt{--fa:"\f225"}.fa-venus-double{--fa:"\f226"}.fa-mars-double{--fa:"\f227"}.fa-venus-mars{--fa:"\f228"}.fa-mars-stroke{--fa:"\f229"}.fa-mars-stroke-up,.fa-mars-stroke-v{--fa:"\f22a"}.fa-mars-stroke-h,.fa-mars-stroke-right{--fa:"\f22b"}.fa-neuter{--fa:"\f22c"}.fa-genderless{--fa:"\f22d"}.fa-server{--fa:"\f233"}.fa-user-plus{--fa:"\f234"}.fa-user-times,.fa-user-xmark{--fa:"\f235"}.fa-bed{--fa:"\f236"}.fa-train{--fa:"\f238"}.fa-subway,.fa-train-subway{--fa:"\f239"}.fa-battery,.fa-battery-5,.fa-battery-full{--fa:"\f240"}.fa-battery-4,.fa-battery-three-quarters{--fa:"\f241"}.fa-battery-3,.fa-battery-half{--fa:"\f242"}.fa-battery-2,.fa-battery-quarter{--fa:"\f243"}.fa-battery-0,.fa-battery-empty{--fa:"\f244"}.fa-arrow-pointer,.fa-mouse-pointer{--fa:"\f245"}.fa-i-cursor{--fa:"\f246"}.fa-object-group{--fa:"\f247"}.fa-object-ungroup{--fa:"\f248"}.fa-note-sticky,.fa-sticky-note{--fa:"\f249"}.fa-clone{--fa:"\f24d"}.fa-balance-scale,.fa-scale-balanced{--fa:"\f24e"}.fa-hourglass-1,.fa-hourglass-start{--fa:"\f251"}.fa-hourglass-2,.fa-hourglass-half{--fa:"\f252"}.fa-hourglass-3,.fa-hourglass-end{--fa:"\f253"}.fa-hourglass,.fa-hourglass-empty{--fa:"\f254"}.fa-hand-back-fist,.fa-hand-rock{--fa:"\f255"}.fa-hand,.fa-hand-paper{--fa:"\f256"}.fa-hand-scissors{--fa:"\f257"}.fa-hand-lizard{--fa:"\f258"}.fa-hand-spock{--fa:"\f259"}.fa-hand-pointer{--fa:"\f25a"}.fa-hand-peace{--fa:"\f25b"}.fa-trademark{--fa:"\f25c"}.fa-registered{--fa:"\f25d"}.fa-television,.fa-tv,.fa-tv-alt{--fa:"\f26c"}.fa-calendar-plus{--fa:"\f271"}.fa-calendar-minus{--fa:"\f272"}.fa-calendar-times,.fa-calendar-xmark{--fa:"\f273"}.fa-calendar-check{--fa:"\f274"}.fa-industry{--fa:"\f275"}.fa-map-pin{--fa:"\f276"}.fa-map-signs,.fa-signs-post{--fa:"\f277"}.fa-map{--fa:"\f279"}.fa-comment-alt,.fa-message{--fa:"\f27a"}.fa-circle-pause,.fa-pause-circle{--fa:"\f28b"}.fa-circle-stop,.fa-stop-circle{--fa:"\f28d"}.fa-bag-shopping,.fa-shopping-bag{--fa:"\f290"}.fa-basket-shopping,.fa-shopping-basket{--fa:"\f291"}.fa-bluetooth{--fa:"\f293"}.fa-universal-access{--fa:"\f29a"}.fa-blind,.fa-person-walking-with-cane{--fa:"\f29d"}.fa-audio-description{--fa:"\f29e"}.fa-phone-volume,.fa-volume-control-phone{--fa:"\f2a0"}.fa-braille{--fa:"\f2a1"}.fa-assistive-listening-systems,.fa-ear-listen{--fa:"\f2a2"}.fa-american-sign-language-interpreting,.fa-asl-interpreting,.fa-hands-american-sign-language-interpreting,.fa-hands-asl-interpreting{--fa:"\f2a3"}.fa-deaf,.fa-deafness,.fa-ear-deaf,.fa-hard-of-hearing{--fa:"\f2a4"}.fa-hands,.fa-sign-language,.fa-signing{--fa:"\f2a7"}.fa-eye-low-vision,.fa-low-vision{--fa:"\f2a8"}.fa-font-awesome,.fa-font-awesome-flag,.fa-font-awesome-logo-full{--fa:"\f2b4"}.fa-handshake,.fa-handshake-alt,.fa-handshake-simple{--fa:"\f2b5"}.fa-envelope-open{--fa:"\f2b6"}.fa-address-book,.fa-contact-book{--fa:"\f2b9"}.fa-address-card,.fa-contact-card,.fa-vcard{--fa:"\f2bb"}.fa-circle-user,.fa-user-circle{--fa:"\f2bd"}.fa-id-badge{--fa:"\f2c1"}.fa-drivers-license,.fa-id-card{--fa:"\f2c2"}.fa-temperature-4,.fa-temperature-full,.fa-thermometer-4,.fa-thermometer-full{--fa:"\f2c7"}.fa-temperature-3,.fa-temperature-three-quarters,.fa-thermometer-3,.fa-thermometer-three-quarters{--fa:"\f2c8"}.fa-temperature-2,.fa-temperature-half,.fa-thermometer-2,.fa-thermometer-half{--fa:"\f2c9"}.fa-temperature-1,.fa-temperature-quarter,.fa-thermometer-1,.fa-thermometer-quarter{--fa:"\f2ca"}.fa-temperature-0,.fa-temperature-empty,.fa-thermometer-0,.fa-thermometer-empty{--fa:"\f2cb"}.fa-shower{--fa:"\f2cc"}.fa-bath,.fa-bathtub{--fa:"\f2cd"}.fa-podcast{--fa:"\f2ce"}.fa-window-maximize{--fa:"\f2d0"}.fa-window-minimize{--fa:"\f2d1"}.fa-window-restore{--fa:"\f2d2"}.fa-square-xmark,.fa-times-square,.fa-xmark-square{--fa:"\f2d3"}.fa-microchip{--fa:"\f2db"}.fa-snowflake{--fa:"\f2dc"}.fa-watch{--fa:"\f2e1"}.fa-volume-slash{--fa:"\f2e2"}.fa-fork,.fa-utensil-fork{--fa:"\f2e3"}.fa-knife,.fa-utensil-knife{--fa:"\f2e4"}.fa-spoon,.fa-utensil-spoon{--fa:"\f2e5"}.fa-fork-knife,.fa-utensils-alt{--fa:"\f2e6"}.fa-cutlery,.fa-utensils{--fa:"\f2e7"}.fa-circle-dollar,.fa-dollar-circle,.fa-usd-circle{--fa:"\f2e8"}.fa-dollar-square,.fa-square-dollar,.fa-usd-square{--fa:"\f2e9"}.fa-rotate-back,.fa-rotate-backward,.fa-rotate-left,.fa-undo-alt{--fa:"\f2ea"}.fa-trophy-alt,.fa-trophy-star{--fa:"\f2eb"}.fa-triangle{--fa:"\f2ec"}.fa-trash-alt,.fa-trash-can{--fa:"\f2ed"}.fa-hexagon-xmark,.fa-times-hexagon,.fa-xmark-hexagon{--fa:"\f2ee"}.fa-octagon-xmark,.fa-times-octagon,.fa-xmark-octagon{--fa:"\f2f0"}.fa-rotate,.fa-sync-alt{--fa:"\f2f1"}.fa-stopwatch{--fa:"\f2f2"}.fa-star-exclamation{--fa:"\f2f3"}.fa-spade{--fa:"\f2f4"}.fa-right-from-bracket,.fa-sign-out-alt{--fa:"\f2f5"}.fa-right-to-bracket,.fa-sign-in-alt{--fa:"\f2f6"}.fa-shield-check{--fa:"\f2f7"}.fa-scrubber{--fa:"\f2f8"}.fa-redo-alt,.fa-rotate-forward,.fa-rotate-right{--fa:"\f2f9"}.fa-rectangle,.fa-rectangle-landscape{--fa:"\f2fa"}.fa-rectangle-portrait,.fa-rectangle-vertical{--fa:"\f2fb"}.fa-rectangle-wide{--fa:"\f2fc"}.fa-question-square,.fa-square-question{--fa:"\f2fd"}.fa-poo{--fa:"\f2fe"}.fa-hexagon-plus,.fa-plus-hexagon{--fa:"\f300"}.fa-octagon-plus,.fa-plus-octagon{--fa:"\f301"}.fa-images{--fa:"\f302"}.fa-pencil,.fa-pencil-alt{--fa:"\f303"}.fa-pen{--fa:"\f304"}.fa-pen-alt,.fa-pen-clip{--fa:"\f305"}.fa-octagon{--fa:"\f306"}.fa-hexagon-minus,.fa-minus-hexagon{--fa:"\f307"}.fa-minus-octagon,.fa-octagon-minus{--fa:"\f308"}.fa-down-long,.fa-long-arrow-alt-down{--fa:"\f309"}.fa-left-long,.fa-long-arrow-alt-left{--fa:"\f30a"}.fa-long-arrow-alt-right,.fa-right-long{--fa:"\f30b"}.fa-long-arrow-alt-up,.fa-up-long{--fa:"\f30c"}.fa-lock-alt,.fa-lock-keyhole{--fa:"\f30d"}.fa-jack-o-lantern{--fa:"\f30e"}.fa-info-square,.fa-square-info{--fa:"\f30f"}.fa-inbox-arrow-down,.fa-inbox-in{--fa:"\f310"}.fa-inbox-arrow-up,.fa-inbox-out{--fa:"\f311"}.fa-hexagon{--fa:"\f312"}.fa-h1{--fa:"\f313"}.fa-h2{--fa:"\f314"}.fa-h3{--fa:"\f315"}.fa-file-check{--fa:"\f316"}.fa-file-times,.fa-file-xmark{--fa:"\f317"}.fa-file-minus{--fa:"\f318"}.fa-file-plus{--fa:"\f319"}.fa-file-exclamation{--fa:"\f31a"}.fa-file-edit,.fa-file-pen{--fa:"\f31c"}.fa-arrows-maximize,.fa-expand-arrows{--fa:"\f31d"}.fa-expand-arrows-alt,.fa-maximize{--fa:"\f31e"}.fa-expand-wide{--fa:"\f320"}.fa-exclamation-square,.fa-square-exclamation{--fa:"\f321"}.fa-chevron-double-down,.fa-chevrons-down{--fa:"\f322"}.fa-chevron-double-left,.fa-chevrons-left{--fa:"\f323"}.fa-chevron-double-right,.fa-chevrons-right{--fa:"\f324"}.fa-chevron-double-up,.fa-chevrons-up{--fa:"\f325"}.fa-compress-wide{--fa:"\f326"}.fa-club{--fa:"\f327"}.fa-clipboard{--fa:"\f328"}.fa-chevron-square-down,.fa-square-chevron-down{--fa:"\f329"}.fa-chevron-square-left,.fa-square-chevron-left{--fa:"\f32a"}.fa-chevron-square-right,.fa-square-chevron-right{--fa:"\f32b"}.fa-chevron-square-up,.fa-square-chevron-up{--fa:"\f32c"}.fa-caret-circle-down,.fa-circle-caret-down{--fa:"\f32d"}.fa-caret-circle-left,.fa-circle-caret-left{--fa:"\f32e"}.fa-caret-circle-right,.fa-circle-caret-right{--fa:"\f330"}.fa-caret-circle-up,.fa-circle-caret-up{--fa:"\f331"}.fa-calendar-edit,.fa-calendar-pen{--fa:"\f333"}.fa-calendar-exclamation{--fa:"\f334"}.fa-badge{--fa:"\f335"}.fa-badge-check{--fa:"\f336"}.fa-arrows-alt-h,.fa-left-right{--fa:"\f337"}.fa-arrows-alt-v,.fa-up-down{--fa:"\f338"}.fa-arrow-square-down,.fa-square-arrow-down{--fa:"\f339"}.fa-arrow-square-left,.fa-square-arrow-left{--fa:"\f33a"}.fa-arrow-square-right,.fa-square-arrow-right{--fa:"\f33b"}.fa-arrow-square-up,.fa-square-arrow-up{--fa:"\f33c"}.fa-arrow-down-to-line,.fa-arrow-to-bottom{--fa:"\f33d"}.fa-arrow-left-to-line,.fa-arrow-to-left{--fa:"\f33e"}.fa-arrow-right-to-line,.fa-arrow-to-right{--fa:"\f340"}.fa-arrow-to-top,.fa-arrow-up-to-line{--fa:"\f341"}.fa-arrow-from-bottom,.fa-arrow-up-from-line{--fa:"\f342"}.fa-arrow-from-left,.fa-arrow-right-from-line{--fa:"\f343"}.fa-arrow-from-right,.fa-arrow-left-from-line{--fa:"\f344"}.fa-arrow-down-from-line,.fa-arrow-from-top{--fa:"\f345"}.fa-arrow-alt-from-bottom,.fa-up-from-line{--fa:"\f346"}.fa-arrow-alt-from-left,.fa-right-from-line{--fa:"\f347"}.fa-arrow-alt-from-right,.fa-left-from-line{--fa:"\f348"}.fa-arrow-alt-from-top,.fa-down-from-line{--fa:"\f349"}.fa-arrow-alt-to-bottom,.fa-down-to-line{--fa:"\f34a"}.fa-arrow-alt-to-left,.fa-left-to-line{--fa:"\f34b"}.fa-arrow-alt-to-right,.fa-right-to-line{--fa:"\f34c"}.fa-arrow-alt-to-top,.fa-up-to-line{--fa:"\f34d"}.fa-alarm-clock{--fa:"\f34e"}.fa-arrow-alt-square-down,.fa-square-down{--fa:"\f350"}.fa-arrow-alt-square-left,.fa-square-left{--fa:"\f351"}.fa-arrow-alt-square-right,.fa-square-right{--fa:"\f352"}.fa-arrow-alt-square-up,.fa-square-up{--fa:"\f353"}.fa-arrow-alt-down,.fa-down{--fa:"\f354"}.fa-arrow-alt-left,.fa-left{--fa:"\f355"}.fa-arrow-alt-right,.fa-right{--fa:"\f356"}.fa-arrow-alt-up,.fa-up{--fa:"\f357"}.fa-arrow-alt-circle-down,.fa-circle-down{--fa:"\f358"}.fa-arrow-alt-circle-left,.fa-circle-left{--fa:"\f359"}.fa-arrow-alt-circle-right,.fa-circle-right{--fa:"\f35a"}.fa-arrow-alt-circle-up,.fa-circle-up{--fa:"\f35b"}.fa-external-link-alt,.fa-up-right-from-square{--fa:"\f35d"}.fa-external-link-square-alt,.fa-square-up-right{--fa:"\f360"}.fa-arrows-retweet,.fa-retweet-alt{--fa:"\f361"}.fa-exchange-alt,.fa-right-left{--fa:"\f362"}.fa-repeat{--fa:"\f363"}.fa-arrows-repeat,.fa-repeat-alt{--fa:"\f364"}.fa-repeat-1{--fa:"\f365"}.fa-arrows-repeat-1,.fa-repeat-1-alt{--fa:"\f366"}.fa-share-all{--fa:"\f367"}.fa-battery-bolt{--fa:"\f376"}.fa-battery-slash{--fa:"\f377"}.fa-browser{--fa:"\f37e"}.fa-code-commit{--fa:"\f386"}.fa-code-merge{--fa:"\f387"}.fa-credit-card-blank{--fa:"\f389"}.fa-credit-card-front{--fa:"\f38a"}.fa-desktop,.fa-desktop-alt{--fa:"\f390"}.fa-ellipsis-h-alt,.fa-ellipsis-stroke{--fa:"\f39b"}.fa-ellipsis-stroke-vertical,.fa-ellipsis-v-alt{--fa:"\f39c"}.fa-gem{--fa:"\f3a5"}.fa-industry-alt,.fa-industry-windows{--fa:"\f3b3"}.fa-level-down-alt,.fa-turn-down{--fa:"\f3be"}.fa-level-up-alt,.fa-turn-up{--fa:"\f3bf"}.fa-lock-open{--fa:"\f3c1"}.fa-lock-keyhole-open,.fa-lock-open-alt{--fa:"\f3c2"}.fa-location-dot,.fa-map-marker-alt{--fa:"\f3c5"}.fa-microphone-alt,.fa-microphone-lines{--fa:"\f3c9"}.fa-mobile-alt,.fa-mobile-screen-button{--fa:"\f3cd"}.fa-mobile,.fa-mobile-android,.fa-mobile-phone{--fa:"\f3ce"}.fa-mobile-android-alt,.fa-mobile-screen{--fa:"\f3cf"}.fa-money-bill-1,.fa-money-bill-alt{--fa:"\f3d1"}.fa-phone-slash{--fa:"\f3dd"}.fa-plane-alt,.fa-plane-engines{--fa:"\f3de"}.fa-image-portrait,.fa-portrait{--fa:"\f3e0"}.fa-mail-reply,.fa-reply{--fa:"\f3e5"}.fa-shield-alt,.fa-shield-halved{--fa:"\f3ed"}.fa-sliders-h-square,.fa-square-sliders{--fa:"\f3f0"}.fa-sliders-up,.fa-sliders-v{--fa:"\f3f1"}.fa-sliders-v-square,.fa-square-sliders-vertical{--fa:"\f3f2"}.fa-spinner-third{--fa:"\f3f4"}.fa-tablet-alt,.fa-tablet-screen-button{--fa:"\f3fa"}.fa-tablet,.fa-tablet-android{--fa:"\f3fb"}.fa-tablet-android-alt,.fa-tablet-screen{--fa:"\f3fc"}.fa-ticket-alt,.fa-ticket-simple{--fa:"\f3ff"}.fa-tree-alt,.fa-tree-deciduous{--fa:"\f400"}.fa-tv-retro{--fa:"\f401"}.fa-window{--fa:"\f40e"}.fa-window-alt,.fa-window-flip{--fa:"\f40f"}.fa-rectangle-times,.fa-rectangle-xmark,.fa-times-rectangle,.fa-window-close{--fa:"\f410"}.fa-compress-alt,.fa-down-left-and-up-right-to-center{--fa:"\f422"}.fa-expand-alt,.fa-up-right-and-down-left-from-center{--fa:"\f424"}.fa-baseball-bat-ball{--fa:"\f432"}.fa-baseball,.fa-baseball-ball{--fa:"\f433"}.fa-basketball,.fa-basketball-ball{--fa:"\f434"}.fa-basketball-hoop{--fa:"\f435"}.fa-bowling-ball{--fa:"\f436"}.fa-bowling-pins{--fa:"\f437"}.fa-boxing-glove,.fa-glove-boxing{--fa:"\f438"}.fa-chess{--fa:"\f439"}.fa-chess-bishop{--fa:"\f43a"}.fa-chess-bishop-alt,.fa-chess-bishop-piece{--fa:"\f43b"}.fa-chess-board{--fa:"\f43c"}.fa-chess-clock{--fa:"\f43d"}.fa-chess-clock-alt,.fa-chess-clock-flip{--fa:"\f43e"}.fa-chess-king{--fa:"\f43f"}.fa-chess-king-alt,.fa-chess-king-piece{--fa:"\f440"}.fa-chess-knight{--fa:"\f441"}.fa-chess-knight-alt,.fa-chess-knight-piece{--fa:"\f442"}.fa-chess-pawn{--fa:"\f443"}.fa-chess-pawn-alt,.fa-chess-pawn-piece{--fa:"\f444"}.fa-chess-queen{--fa:"\f445"}.fa-chess-queen-alt,.fa-chess-queen-piece{--fa:"\f446"}.fa-chess-rook{--fa:"\f447"}.fa-chess-rook-alt,.fa-chess-rook-piece{--fa:"\f448"}.fa-cricket,.fa-cricket-bat-ball{--fa:"\f449"}.fa-curling,.fa-curling-stone{--fa:"\f44a"}.fa-dumbbell{--fa:"\f44b"}.fa-field-hockey,.fa-field-hockey-stick-ball{--fa:"\f44c"}.fa-football,.fa-football-ball{--fa:"\f44e"}.fa-football-helmet{--fa:"\f44f"}.fa-golf-ball,.fa-golf-ball-tee{--fa:"\f450"}.fa-golf-club{--fa:"\f451"}.fa-hockey-puck{--fa:"\f453"}.fa-hockey-sticks{--fa:"\f454"}.fa-luchador,.fa-luchador-mask,.fa-mask-luchador{--fa:"\f455"}.fa-flag-pennant,.fa-pennant{--fa:"\f456"}.fa-broom-ball,.fa-quidditch,.fa-quidditch-broom-ball{--fa:"\f458"}.fa-racquet{--fa:"\f45a"}.fa-shuttlecock{--fa:"\f45b"}.fa-square-full{--fa:"\f45c"}.fa-ping-pong-paddle-ball,.fa-table-tennis,.fa-table-tennis-paddle-ball{--fa:"\f45d"}.fa-tennis-ball{--fa:"\f45e"}.fa-volleyball,.fa-volleyball-ball{--fa:"\f45f"}.fa-whistle{--fa:"\f460"}.fa-allergies,.fa-hand-dots{--fa:"\f461"}.fa-band-aid,.fa-bandage{--fa:"\f462"}.fa-barcode-alt,.fa-rectangle-barcode{--fa:"\f463"}.fa-barcode-read{--fa:"\f464"}.fa-barcode-scan{--fa:"\f465"}.fa-box{--fa:"\f466"}.fa-box-check{--fa:"\f467"}.fa-boxes,.fa-boxes-alt,.fa-boxes-stacked{--fa:"\f468"}.fa-briefcase-medical{--fa:"\f469"}.fa-burn,.fa-fire-flame-simple{--fa:"\f46a"}.fa-capsules{--fa:"\f46b"}.fa-clipboard-check{--fa:"\f46c"}.fa-clipboard-list{--fa:"\f46d"}.fa-conveyor-belt{--fa:"\f46e"}.fa-conveyor-belt-alt,.fa-conveyor-belt-boxes{--fa:"\f46f"}.fa-diagnoses,.fa-person-dots-from-line{--fa:"\f470"}.fa-dna{--fa:"\f471"}.fa-dolly,.fa-dolly-box{--fa:"\f472"}.fa-dolly-empty{--fa:"\f473"}.fa-cart-flatbed,.fa-dolly-flatbed{--fa:"\f474"}.fa-cart-flatbed-boxes,.fa-dolly-flatbed-alt{--fa:"\f475"}.fa-cart-flatbed-empty,.fa-dolly-flatbed-empty{--fa:"\f476"}.fa-file-medical{--fa:"\f477"}.fa-file-medical-alt,.fa-file-waveform{--fa:"\f478"}.fa-first-aid,.fa-kit-medical{--fa:"\f479"}.fa-forklift{--fa:"\f47a"}.fa-hand-holding-box{--fa:"\f47b"}.fa-hand-receiving,.fa-hands-holding-diamond{--fa:"\f47c"}.fa-circle-h,.fa-hospital-symbol{--fa:"\f47e"}.fa-id-card-alt,.fa-id-card-clip{--fa:"\f47f"}.fa-inventory,.fa-shelves{--fa:"\f480"}.fa-notes-medical{--fa:"\f481"}.fa-pallet{--fa:"\f482"}.fa-palette-boxes,.fa-pallet-alt,.fa-pallet-boxes{--fa:"\f483"}.fa-pills{--fa:"\f484"}.fa-prescription-bottle{--fa:"\f485"}.fa-prescription-bottle-alt,.fa-prescription-bottle-medical{--fa:"\f486"}.fa-bed-pulse,.fa-procedures{--fa:"\f487"}.fa-scanner,.fa-scanner-gun{--fa:"\f488"}.fa-scanner-keyboard{--fa:"\f489"}.fa-scanner-touchscreen{--fa:"\f48a"}.fa-shipping-fast,.fa-truck-fast{--fa:"\f48b"}.fa-shipping-timed,.fa-truck-clock{--fa:"\f48c"}.fa-smoking{--fa:"\f48d"}.fa-syringe{--fa:"\f48e"}.fa-tablet-rugged{--fa:"\f48f"}.fa-tablets{--fa:"\f490"}.fa-thermometer{--fa:"\f491"}.fa-vial{--fa:"\f492"}.fa-vials{--fa:"\f493"}.fa-warehouse{--fa:"\f494"}.fa-warehouse-alt,.fa-warehouse-full{--fa:"\f495"}.fa-weight,.fa-weight-scale{--fa:"\f496"}.fa-x-ray{--fa:"\f497"}.fa-blanket{--fa:"\f498"}.fa-book-heart{--fa:"\f499"}.fa-box-alt,.fa-box-taped{--fa:"\f49a"}.fa-box-fragile,.fa-square-fragile,.fa-square-wine-glass-crack{--fa:"\f49b"}.fa-box-full,.fa-box-open-full{--fa:"\f49c"}.fa-box-heart{--fa:"\f49d"}.fa-box-open{--fa:"\f49e"}.fa-box-up,.fa-square-this-way-up{--fa:"\f49f"}.fa-box-dollar,.fa-box-usd{--fa:"\f4a0"}.fa-comment-alt-check,.fa-message-check{--fa:"\f4a2"}.fa-comment-alt-dots,.fa-message-dots,.fa-messaging{--fa:"\f4a3"}.fa-comment-alt-edit,.fa-message-edit,.fa-message-pen{--fa:"\f4a4"}.fa-comment-alt-exclamation,.fa-message-exclamation{--fa:"\f4a5"}.fa-comment-alt-lines,.fa-message-lines{--fa:"\f4a6"}.fa-comment-alt-minus,.fa-message-minus{--fa:"\f4a7"}.fa-comment-alt-plus,.fa-message-plus{--fa:"\f4a8"}.fa-comment-alt-slash,.fa-message-slash{--fa:"\f4a9"}.fa-comment-alt-smile,.fa-message-smile{--fa:"\f4aa"}.fa-comment-alt-times,.fa-message-times,.fa-message-xmark{--fa:"\f4ab"}.fa-comment-check{--fa:"\f4ac"}.fa-comment-dots,.fa-commenting{--fa:"\f4ad"}.fa-comment-edit,.fa-comment-pen{--fa:"\f4ae"}.fa-comment-exclamation{--fa:"\f4af"}.fa-comment-lines{--fa:"\f4b0"}.fa-comment-minus{--fa:"\f4b1"}.fa-comment-plus{--fa:"\f4b2"}.fa-comment-slash{--fa:"\f4b3"}.fa-comment-smile{--fa:"\f4b4"}.fa-comment-times,.fa-comment-xmark{--fa:"\f4b5"}.fa-comments-alt,.fa-messages{--fa:"\f4b6"}.fa-container-storage{--fa:"\f4b7"}.fa-couch{--fa:"\f4b8"}.fa-circle-dollar-to-slot,.fa-donate{--fa:"\f4b9"}.fa-dove{--fa:"\f4ba"}.fa-fragile,.fa-wine-glass-crack{--fa:"\f4bb"}.fa-hand-heart{--fa:"\f4bc"}.fa-hand-holding{--fa:"\f4bd"}.fa-hand-holding-heart{--fa:"\f4be"}.fa-hand-holding-seedling{--fa:"\f4bf"}.fa-hand-holding-dollar,.fa-hand-holding-usd{--fa:"\f4c0"}.fa-hand-holding-droplet,.fa-hand-holding-water{--fa:"\f4c1"}.fa-hands-holding{--fa:"\f4c2"}.fa-hands-heart,.fa-hands-holding-heart{--fa:"\f4c3"}.fa-hands-helping,.fa-handshake-angle{--fa:"\f4c4"}.fa-hands-holding-dollar,.fa-hands-usd{--fa:"\f4c5"}.fa-circle-heart,.fa-heart-circle{--fa:"\f4c7"}.fa-heart-square,.fa-square-heart{--fa:"\f4c8"}.fa-home-heart,.fa-house-heart{--fa:"\f4c9"}.fa-lamp{--fa:"\f4ca"}.fa-leaf-heart{--fa:"\f4cb"}.fa-couch-small,.fa-loveseat{--fa:"\f4cc"}.fa-parachute-box{--fa:"\f4cd"}.fa-people-carry,.fa-people-carry-box{--fa:"\f4ce"}.fa-person-carry,.fa-person-carry-box{--fa:"\f4cf"}.fa-person-dolly{--fa:"\f4d0"}.fa-person-dolly-empty{--fa:"\f4d1"}.fa-phone-plus{--fa:"\f4d2"}.fa-piggy-bank{--fa:"\f4d3"}.fa-ramp-loading{--fa:"\f4d4"}.fa-ribbon{--fa:"\f4d6"}.fa-route{--fa:"\f4d7"}.fa-seedling,.fa-sprout{--fa:"\f4d8"}.fa-sign,.fa-sign-hanging{--fa:"\f4d9"}.fa-face-smile-wink,.fa-smile-wink{--fa:"\f4da"}.fa-tape{--fa:"\f4db"}.fa-truck-container{--fa:"\f4dc"}.fa-truck-couch,.fa-truck-ramp-couch{--fa:"\f4dd"}.fa-truck-loading,.fa-truck-ramp-box{--fa:"\f4de"}.fa-truck-moving{--fa:"\f4df"}.fa-truck-ramp{--fa:"\f4e0"}.fa-video-plus{--fa:"\f4e1"}.fa-video-slash{--fa:"\f4e2"}.fa-wine-glass{--fa:"\f4e3"}.fa-user-astronaut{--fa:"\f4fb"}.fa-user-check{--fa:"\f4fc"}.fa-user-clock{--fa:"\f4fd"}.fa-user-cog,.fa-user-gear{--fa:"\f4fe"}.fa-user-edit,.fa-user-pen{--fa:"\f4ff"}.fa-user-friends,.fa-user-group{--fa:"\f500"}.fa-user-graduate{--fa:"\f501"}.fa-user-lock{--fa:"\f502"}.fa-user-minus{--fa:"\f503"}.fa-user-ninja{--fa:"\f504"}.fa-user-shield{--fa:"\f505"}.fa-user-alt-slash,.fa-user-large-slash,.fa-user-slash{--fa:"\f506"}.fa-user-tag{--fa:"\f507"}.fa-user-tie{--fa:"\f508"}.fa-users-cog,.fa-users-gear{--fa:"\f509"}.fa-balance-scale-left,.fa-scale-unbalanced{--fa:"\f515"}.fa-balance-scale-right,.fa-scale-unbalanced-flip{--fa:"\f516"}.fa-blender{--fa:"\f517"}.fa-book-open{--fa:"\f518"}.fa-broadcast-tower,.fa-tower-broadcast{--fa:"\f519"}.fa-broom{--fa:"\f51a"}.fa-blackboard,.fa-chalkboard{--fa:"\f51b"}.fa-chalkboard-teacher,.fa-chalkboard-user{--fa:"\f51c"}.fa-church{--fa:"\f51d"}.fa-coins{--fa:"\f51e"}.fa-compact-disc{--fa:"\f51f"}.fa-crow{--fa:"\f520"}.fa-crown{--fa:"\f521"}.fa-dice{--fa:"\f522"}.fa-dice-five{--fa:"\f523"}.fa-dice-four{--fa:"\f524"}.fa-dice-one{--fa:"\f525"}.fa-dice-six{--fa:"\f526"}.fa-dice-three{--fa:"\f527"}.fa-dice-two{--fa:"\f528"}.fa-divide{--fa:"\f529"}.fa-door-closed{--fa:"\f52a"}.fa-door-open{--fa:"\f52b"}.fa-feather{--fa:"\f52d"}.fa-frog{--fa:"\f52e"}.fa-gas-pump{--fa:"\f52f"}.fa-glasses{--fa:"\f530"}.fa-greater-than-equal{--fa:"\f532"}.fa-helicopter{--fa:"\f533"}.fa-infinity{--fa:"\f534"}.fa-kiwi-bird{--fa:"\f535"}.fa-less-than-equal{--fa:"\f537"}.fa-memory{--fa:"\f538"}.fa-microphone-alt-slash,.fa-microphone-lines-slash{--fa:"\f539"}.fa-money-bill-wave{--fa:"\f53a"}.fa-money-bill-1-wave,.fa-money-bill-wave-alt{--fa:"\f53b"}.fa-money-check{--fa:"\f53c"}.fa-money-check-alt,.fa-money-check-dollar{--fa:"\f53d"}.fa-not-equal{--fa:"\f53e"}.fa-palette{--fa:"\f53f"}.fa-parking,.fa-square-parking{--fa:"\f540"}.fa-diagram-project,.fa-project-diagram{--fa:"\f542"}.fa-receipt{--fa:"\f543"}.fa-robot{--fa:"\f544"}.fa-ruler{--fa:"\f545"}.fa-ruler-combined{--fa:"\f546"}.fa-ruler-horizontal{--fa:"\f547"}.fa-ruler-vertical{--fa:"\f548"}.fa-school{--fa:"\f549"}.fa-screwdriver{--fa:"\f54a"}.fa-shoe-prints{--fa:"\f54b"}.fa-skull{--fa:"\f54c"}.fa-ban-smoking,.fa-smoking-ban{--fa:"\f54d"}.fa-store{--fa:"\f54e"}.fa-shop,.fa-store-alt{--fa:"\f54f"}.fa-bars-staggered,.fa-reorder,.fa-stream{--fa:"\f550"}.fa-stroopwafel{--fa:"\f551"}.fa-toolbox{--fa:"\f552"}.fa-shirt,.fa-t-shirt,.fa-tshirt{--fa:"\f553"}.fa-person-walking,.fa-walking{--fa:"\f554"}.fa-wallet{--fa:"\f555"}.fa-angry,.fa-face-angry{--fa:"\f556"}.fa-archway{--fa:"\f557"}.fa-atlas,.fa-book-atlas{--fa:"\f558"}.fa-award{--fa:"\f559"}.fa-backspace,.fa-delete-left{--fa:"\f55a"}.fa-bezier-curve{--fa:"\f55b"}.fa-bong{--fa:"\f55c"}.fa-brush{--fa:"\f55d"}.fa-bus-alt,.fa-bus-simple{--fa:"\f55e"}.fa-cannabis{--fa:"\f55f"}.fa-check-double{--fa:"\f560"}.fa-cocktail,.fa-martini-glass-citrus{--fa:"\f561"}.fa-bell-concierge,.fa-concierge-bell{--fa:"\f562"}.fa-cookie{--fa:"\f563"}.fa-cookie-bite{--fa:"\f564"}.fa-crop-alt,.fa-crop-simple{--fa:"\f565"}.fa-digital-tachograph,.fa-tachograph-digital{--fa:"\f566"}.fa-dizzy,.fa-face-dizzy{--fa:"\f567"}.fa-compass-drafting,.fa-drafting-compass{--fa:"\f568"}.fa-drum{--fa:"\f569"}.fa-drum-steelpan{--fa:"\f56a"}.fa-feather-alt,.fa-feather-pointed{--fa:"\f56b"}.fa-file-contract{--fa:"\f56c"}.fa-file-arrow-down,.fa-file-download{--fa:"\f56d"}.fa-arrow-right-from-file,.fa-file-export{--fa:"\f56e"}.fa-arrow-right-to-file,.fa-file-import{--fa:"\f56f"}.fa-file-invoice{--fa:"\f570"}.fa-file-invoice-dollar{--fa:"\f571"}.fa-file-prescription{--fa:"\f572"}.fa-file-signature{--fa:"\f573"}.fa-file-arrow-up,.fa-file-upload{--fa:"\f574"}.fa-fill{--fa:"\f575"}.fa-fill-drip{--fa:"\f576"}.fa-fingerprint{--fa:"\f577"}.fa-fish{--fa:"\f578"}.fa-face-flushed,.fa-flushed{--fa:"\f579"}.fa-face-frown-open,.fa-frown-open{--fa:"\f57a"}.fa-glass-martini-alt,.fa-martini-glass{--fa:"\f57b"}.fa-earth-africa,.fa-globe-africa{--fa:"\f57c"}.fa-earth,.fa-earth-america,.fa-earth-americas,.fa-globe-americas{--fa:"\f57d"}.fa-earth-asia,.fa-globe-asia{--fa:"\f57e"}.fa-face-grimace,.fa-grimace{--fa:"\f57f"}.fa-face-grin,.fa-grin{--fa:"\f580"}.fa-face-grin-wide,.fa-grin-alt{--fa:"\f581"}.fa-face-grin-beam,.fa-grin-beam{--fa:"\f582"}.fa-face-grin-beam-sweat,.fa-grin-beam-sweat{--fa:"\f583"}.fa-face-grin-hearts,.fa-grin-hearts{--fa:"\f584"}.fa-face-grin-squint,.fa-grin-squint{--fa:"\f585"}.fa-face-grin-squint-tears,.fa-grin-squint-tears{--fa:"\f586"}.fa-face-grin-stars,.fa-grin-stars{--fa:"\f587"}.fa-face-grin-tears,.fa-grin-tears{--fa:"\f588"}.fa-face-grin-tongue,.fa-grin-tongue{--fa:"\f589"}.fa-face-grin-tongue-squint,.fa-grin-tongue-squint{--fa:"\f58a"}.fa-face-grin-tongue-wink,.fa-grin-tongue-wink{--fa:"\f58b"}.fa-face-grin-wink,.fa-grin-wink{--fa:"\f58c"}.fa-grid-horizontal,.fa-grip,.fa-grip-horizontal{--fa:"\f58d"}.fa-grid-vertical,.fa-grip-vertical{--fa:"\f58e"}.fa-headset{--fa:"\f590"}.fa-highlighter{--fa:"\f591"}.fa-hot-tub,.fa-hot-tub-person{--fa:"\f593"}.fa-hotel{--fa:"\f594"}.fa-joint{--fa:"\f595"}.fa-face-kiss,.fa-kiss{--fa:"\f596"}.fa-face-kiss-beam,.fa-kiss-beam{--fa:"\f597"}.fa-face-kiss-wink-heart,.fa-kiss-wink-heart{--fa:"\f598"}.fa-face-laugh,.fa-laugh{--fa:"\f599"}.fa-face-laugh-beam,.fa-laugh-beam{--fa:"\f59a"}.fa-face-laugh-squint,.fa-laugh-squint{--fa:"\f59b"}.fa-face-laugh-wink,.fa-laugh-wink{--fa:"\f59c"}.fa-cart-flatbed-suitcase,.fa-luggage-cart{--fa:"\f59d"}.fa-map-location,.fa-map-marked{--fa:"\f59f"}.fa-map-location-dot,.fa-map-marked-alt{--fa:"\f5a0"}.fa-marker{--fa:"\f5a1"}.fa-medal{--fa:"\f5a2"}.fa-face-meh-blank,.fa-meh-blank{--fa:"\f5a4"}.fa-face-rolling-eyes,.fa-meh-rolling-eyes{--fa:"\f5a5"}.fa-monument{--fa:"\f5a6"}.fa-mortar-pestle{--fa:"\f5a7"}.fa-paint-brush-alt,.fa-paint-brush-fine,.fa-paintbrush-alt,.fa-paintbrush-fine{--fa:"\f5a9"}.fa-paint-roller{--fa:"\f5aa"}.fa-passport{--fa:"\f5ab"}.fa-pen-fancy{--fa:"\f5ac"}.fa-pen-nib{--fa:"\f5ad"}.fa-pen-ruler,.fa-pencil-ruler{--fa:"\f5ae"}.fa-plane-arrival{--fa:"\f5af"}.fa-plane-departure{--fa:"\f5b0"}.fa-prescription{--fa:"\f5b1"}.fa-face-sad-cry,.fa-sad-cry{--fa:"\f5b3"}.fa-face-sad-tear,.fa-sad-tear{--fa:"\f5b4"}.fa-shuttle-van,.fa-van-shuttle{--fa:"\f5b6"}.fa-signature{--fa:"\f5b7"}.fa-face-smile-beam,.fa-smile-beam{--fa:"\f5b8"}.fa-face-smile-plus,.fa-smile-plus{--fa:"\f5b9"}.fa-solar-panel{--fa:"\f5ba"}.fa-spa{--fa:"\f5bb"}.fa-splotch{--fa:"\f5bc"}.fa-spray-can{--fa:"\f5bd"}.fa-stamp{--fa:"\f5bf"}.fa-star-half-alt,.fa-star-half-stroke{--fa:"\f5c0"}.fa-suitcase-rolling{--fa:"\f5c1"}.fa-face-surprise,.fa-surprise{--fa:"\f5c2"}.fa-swatchbook{--fa:"\f5c3"}.fa-person-swimming,.fa-swimmer{--fa:"\f5c4"}.fa-ladder-water,.fa-swimming-pool,.fa-water-ladder{--fa:"\f5c5"}.fa-droplet-slash,.fa-tint-slash{--fa:"\f5c7"}.fa-face-tired,.fa-tired{--fa:"\f5c8"}.fa-tooth{--fa:"\f5c9"}.fa-umbrella-beach{--fa:"\f5ca"}.fa-weight-hanging{--fa:"\f5cd"}.fa-wine-glass-alt,.fa-wine-glass-empty{--fa:"\f5ce"}.fa-air-freshener,.fa-spray-can-sparkles{--fa:"\f5d0"}.fa-apple-alt,.fa-apple-whole{--fa:"\f5d1"}.fa-atom{--fa:"\f5d2"}.fa-atom-alt,.fa-atom-simple{--fa:"\f5d3"}.fa-backpack{--fa:"\f5d4"}.fa-bell-school{--fa:"\f5d5"}.fa-bell-school-slash{--fa:"\f5d6"}.fa-bone{--fa:"\f5d7"}.fa-bone-break{--fa:"\f5d8"}.fa-book-alt,.fa-book-blank{--fa:"\f5d9"}.fa-book-open-reader,.fa-book-reader{--fa:"\f5da"}.fa-books{--fa:"\f5db"}.fa-brain{--fa:"\f5dc"}.fa-bus-school{--fa:"\f5dd"}.fa-car-alt,.fa-car-rear{--fa:"\f5de"}.fa-battery-car,.fa-car-battery{--fa:"\f5df"}.fa-car-bump{--fa:"\f5e0"}.fa-car-burst,.fa-car-crash{--fa:"\f5e1"}.fa-car-garage{--fa:"\f5e2"}.fa-car-mechanic,.fa-car-wrench{--fa:"\f5e3"}.fa-car-side{--fa:"\f5e4"}.fa-car-tilt{--fa:"\f5e5"}.fa-car-wash{--fa:"\f5e6"}.fa-charging-station{--fa:"\f5e7"}.fa-clipboard-prescription{--fa:"\f5e8"}.fa-compass-slash{--fa:"\f5e9"}.fa-diploma,.fa-scroll-ribbon{--fa:"\f5ea"}.fa-diamond-turn-right,.fa-directions{--fa:"\f5eb"}.fa-do-not-enter{--fa:"\f5ec"}.fa-draw-circle,.fa-vector-circle{--fa:"\f5ed"}.fa-draw-polygon,.fa-vector-polygon{--fa:"\f5ee"}.fa-draw-square,.fa-vector-square{--fa:"\f5ef"}.fa-ear{--fa:"\f5f0"}.fa-engine-exclamation,.fa-engine-warning{--fa:"\f5f2"}.fa-file-award,.fa-file-certificate{--fa:"\f5f3"}.fa-gas-pump-slash{--fa:"\f5f4"}.fa-glasses-alt,.fa-glasses-round{--fa:"\f5f5"}.fa-globe-stand{--fa:"\f5f6"}.fa-heart-rate,.fa-wave-pulse{--fa:"\f5f8"}.fa-inhaler{--fa:"\f5f9"}.fa-kidneys{--fa:"\f5fb"}.fa-laptop-code{--fa:"\f5fc"}.fa-layer-group{--fa:"\f5fd"}.fa-layer-group-minus,.fa-layer-minus{--fa:"\f5fe"}.fa-layer-group-plus,.fa-layer-plus{--fa:"\f5ff"}.fa-lips{--fa:"\f600"}.fa-location,.fa-location-crosshairs{--fa:"\f601"}.fa-circle-location-arrow,.fa-location-circle{--fa:"\f602"}.fa-location-crosshairs-slash,.fa-location-slash{--fa:"\f603"}.fa-lungs{--fa:"\f604"}.fa-location-dot-slash,.fa-map-marker-alt-slash{--fa:"\f605"}.fa-location-check,.fa-map-marker-check{--fa:"\f606"}.fa-location-pen,.fa-map-marker-edit{--fa:"\f607"}.fa-location-exclamation,.fa-map-marker-exclamation{--fa:"\f608"}.fa-location-minus,.fa-map-marker-minus{--fa:"\f609"}.fa-location-plus,.fa-map-marker-plus{--fa:"\f60a"}.fa-location-question,.fa-map-marker-question{--fa:"\f60b"}.fa-location-pin-slash,.fa-map-marker-slash{--fa:"\f60c"}.fa-location-smile,.fa-map-marker-smile{--fa:"\f60d"}.fa-location-xmark,.fa-map-marker-times,.fa-map-marker-xmark{--fa:"\f60e"}.fa-microscope{--fa:"\f610"}.fa-monitor-heart-rate,.fa-monitor-waveform{--fa:"\f611"}.fa-oil-can{--fa:"\f613"}.fa-oil-temp,.fa-oil-temperature{--fa:"\f614"}.fa-circle-parking,.fa-parking-circle{--fa:"\f615"}.fa-ban-parking,.fa-parking-circle-slash{--fa:"\f616"}.fa-parking-slash,.fa-square-parking-slash{--fa:"\f617"}.fa-pen-paintbrush,.fa-pencil-paintbrush{--fa:"\f618"}.fa-poop{--fa:"\f619"}.fa-route-highway{--fa:"\f61a"}.fa-route-interstate{--fa:"\f61b"}.fa-ruler-triangle{--fa:"\f61c"}.fa-scalpel{--fa:"\f61d"}.fa-scalpel-line-dashed,.fa-scalpel-path{--fa:"\f61e"}.fa-shapes,.fa-triangle-circle-square{--fa:"\f61f"}.fa-skeleton{--fa:"\f620"}.fa-star-of-life{--fa:"\f621"}.fa-steering-wheel{--fa:"\f622"}.fa-stomach{--fa:"\f623"}.fa-dashboard,.fa-gauge,.fa-gauge-med,.fa-tachometer-alt-average{--fa:"\f624"}.fa-gauge-high,.fa-tachometer-alt,.fa-tachometer-alt-fast{--fa:"\f625"}.fa-gauge-max,.fa-tachometer-alt-fastest{--fa:"\f626"}.fa-gauge-low,.fa-tachometer-alt-slow{--fa:"\f627"}.fa-gauge-min,.fa-tachometer-alt-slowest{--fa:"\f628"}.fa-gauge-simple,.fa-gauge-simple-med,.fa-tachometer-average{--fa:"\f629"}.fa-gauge-simple-high,.fa-tachometer,.fa-tachometer-fast{--fa:"\f62a"}.fa-gauge-simple-max,.fa-tachometer-fastest{--fa:"\f62b"}.fa-gauge-simple-low,.fa-tachometer-slow{--fa:"\f62c"}.fa-gauge-simple-min,.fa-tachometer-slowest{--fa:"\f62d"}.fa-teeth{--fa:"\f62e"}.fa-teeth-open{--fa:"\f62f"}.fa-masks-theater,.fa-theater-masks{--fa:"\f630"}.fa-tire{--fa:"\f631"}.fa-tire-flat{--fa:"\f632"}.fa-tire-pressure-warning{--fa:"\f633"}.fa-tire-rugged{--fa:"\f634"}.fa-toothbrush{--fa:"\f635"}.fa-traffic-cone{--fa:"\f636"}.fa-traffic-light{--fa:"\f637"}.fa-traffic-light-go{--fa:"\f638"}.fa-traffic-light-slow{--fa:"\f639"}.fa-traffic-light-stop{--fa:"\f63a"}.fa-truck-monster{--fa:"\f63b"}.fa-truck-pickup{--fa:"\f63c"}.fa-screen-users,.fa-users-class{--fa:"\f63d"}.fa-watch-fitness{--fa:"\f63e"}.fa-abacus{--fa:"\f640"}.fa-ad,.fa-rectangle-ad{--fa:"\f641"}.fa-analytics,.fa-chart-mixed{--fa:"\f643"}.fa-ankh{--fa:"\f644"}.fa-badge-dollar{--fa:"\f645"}.fa-badge-percent{--fa:"\f646"}.fa-bible,.fa-book-bible{--fa:"\f647"}.fa-bullseye-arrow{--fa:"\f648"}.fa-bullseye-pointer{--fa:"\f649"}.fa-briefcase-clock,.fa-business-time{--fa:"\f64a"}.fa-cabinet-filing{--fa:"\f64b"}.fa-calculator-alt,.fa-calculator-simple{--fa:"\f64c"}.fa-chart-line-down{--fa:"\f64d"}.fa-chart-pie-alt,.fa-chart-pie-simple{--fa:"\f64e"}.fa-city{--fa:"\f64f"}.fa-comment-alt-dollar,.fa-message-dollar{--fa:"\f650"}.fa-comment-dollar{--fa:"\f651"}.fa-comments-alt-dollar,.fa-messages-dollar{--fa:"\f652"}.fa-comments-dollar{--fa:"\f653"}.fa-cross{--fa:"\f654"}.fa-dharmachakra{--fa:"\f655"}.fa-empty-set{--fa:"\f656"}.fa-envelope-open-dollar{--fa:"\f657"}.fa-envelope-open-text{--fa:"\f658"}.fa-file-chart-column,.fa-file-chart-line{--fa:"\f659"}.fa-file-chart-pie{--fa:"\f65a"}.fa-file-spreadsheet{--fa:"\f65b"}.fa-file-user{--fa:"\f65c"}.fa-folder-minus{--fa:"\f65d"}.fa-folder-plus{--fa:"\f65e"}.fa-folder-times,.fa-folder-xmark{--fa:"\f65f"}.fa-folders{--fa:"\f660"}.fa-function{--fa:"\f661"}.fa-filter-circle-dollar,.fa-funnel-dollar{--fa:"\f662"}.fa-gift-card{--fa:"\f663"}.fa-gopuram{--fa:"\f664"}.fa-hamsa{--fa:"\f665"}.fa-bahai,.fa-haykal{--fa:"\f666"}.fa-integral{--fa:"\f667"}.fa-intersection{--fa:"\f668"}.fa-jedi{--fa:"\f669"}.fa-book-journal-whills,.fa-journal-whills{--fa:"\f66a"}.fa-kaaba{--fa:"\f66b"}.fa-keynote{--fa:"\f66c"}.fa-khanda{--fa:"\f66d"}.fa-lambda{--fa:"\f66e"}.fa-landmark{--fa:"\f66f"}.fa-lightbulb-dollar{--fa:"\f670"}.fa-lightbulb-exclamation{--fa:"\f671"}.fa-lightbulb-on{--fa:"\f672"}.fa-lightbulb-slash{--fa:"\f673"}.fa-envelopes-bulk,.fa-mail-bulk{--fa:"\f674"}.fa-megaphone{--fa:"\f675"}.fa-menorah{--fa:"\f676"}.fa-brain-arrow-curved-right,.fa-mind-share{--fa:"\f677"}.fa-mosque{--fa:"\f678"}.fa-om{--fa:"\f679"}.fa-omega{--fa:"\f67a"}.fa-pastafarianism,.fa-spaghetti-monster-flying{--fa:"\f67b"}.fa-peace{--fa:"\f67c"}.fa-phone-office{--fa:"\f67d"}.fa-pi{--fa:"\f67e"}.fa-place-of-worship{--fa:"\f67f"}.fa-podium{--fa:"\f680"}.fa-poll,.fa-square-poll-vertical{--fa:"\f681"}.fa-poll-h,.fa-square-poll-horizontal{--fa:"\f682"}.fa-person-praying,.fa-pray{--fa:"\f683"}.fa-hands-praying,.fa-praying-hands{--fa:"\f684"}.fa-presentation,.fa-presentation-screen{--fa:"\f685"}.fa-print-slash{--fa:"\f686"}.fa-book-quran,.fa-quran{--fa:"\f687"}.fa-magnifying-glass-dollar,.fa-search-dollar{--fa:"\f688"}.fa-magnifying-glass-location,.fa-search-location{--fa:"\f689"}.fa-shredder{--fa:"\f68a"}.fa-sigma{--fa:"\f68b"}.fa-signal-1,.fa-signal-weak{--fa:"\f68c"}.fa-signal-2,.fa-signal-fair{--fa:"\f68d"}.fa-signal-3,.fa-signal-good{--fa:"\f68e"}.fa-signal-4,.fa-signal-strong{--fa:"\f68f"}.fa-signal-alt,.fa-signal-alt-4,.fa-signal-bars,.fa-signal-bars-strong{--fa:"\f690"}.fa-signal-alt-1,.fa-signal-bars-weak{--fa:"\f691"}.fa-signal-alt-2,.fa-signal-bars-fair{--fa:"\f692"}.fa-signal-alt-3,.fa-signal-bars-good{--fa:"\f693"}.fa-signal-alt-slash,.fa-signal-bars-slash{--fa:"\f694"}.fa-signal-slash{--fa:"\f695"}.fa-socks{--fa:"\f696"}.fa-square-root{--fa:"\f697"}.fa-square-root-alt,.fa-square-root-variable{--fa:"\f698"}.fa-star-and-crescent{--fa:"\f699"}.fa-star-of-david{--fa:"\f69a"}.fa-synagogue{--fa:"\f69b"}.fa-tally,.fa-tally-5{--fa:"\f69c"}.fa-theta{--fa:"\f69e"}.fa-scroll-torah,.fa-torah{--fa:"\f6a0"}.fa-torii-gate{--fa:"\f6a1"}.fa-union{--fa:"\f6a2"}.fa-chart-user,.fa-user-chart{--fa:"\f6a3"}.fa-user-crown{--fa:"\f6a4"}.fa-user-group-crown,.fa-users-crown{--fa:"\f6a5"}.fa-value-absolute{--fa:"\f6a6"}.fa-vihara{--fa:"\f6a7"}.fa-volume,.fa-volume-medium{--fa:"\f6a8"}.fa-volume-mute,.fa-volume-times,.fa-volume-xmark{--fa:"\f6a9"}.fa-wifi-1,.fa-wifi-weak{--fa:"\f6aa"}.fa-wifi-2,.fa-wifi-fair{--fa:"\f6ab"}.fa-wifi-slash{--fa:"\f6ac"}.fa-yin-yang{--fa:"\f6ad"}.fa-acorn{--fa:"\f6ae"}.fa-alicorn{--fa:"\f6b0"}.fa-crate-apple{--fa:"\f6b1"}.fa-apple-crate{--fa:"\f6b1"}.fa-axe{--fa:"\f6b2"}.fa-axe-battle{--fa:"\f6b3"}.fa-badger-honey{--fa:"\f6b4"}.fa-bat{--fa:"\f6b5"}.fa-blender-phone{--fa:"\f6b6"}.fa-book-dead,.fa-book-skull{--fa:"\f6b7"}.fa-book-sparkles,.fa-book-spells{--fa:"\f6b8"}.fa-bow-arrow{--fa:"\f6b9"}.fa-campfire{--fa:"\f6ba"}.fa-campground{--fa:"\f6bb"}.fa-candle-holder{--fa:"\f6bc"}.fa-candy-corn{--fa:"\f6bd"}.fa-cat{--fa:"\f6be"}.fa-cauldron{--fa:"\f6bf"}.fa-chair{--fa:"\f6c0"}.fa-chair-office{--fa:"\f6c1"}.fa-claw-marks{--fa:"\f6c2"}.fa-cloud-moon{--fa:"\f6c3"}.fa-cloud-sun{--fa:"\f6c4"}.fa-coffee-togo,.fa-cup-togo{--fa:"\f6c5"}.fa-coffin{--fa:"\f6c6"}.fa-corn{--fa:"\f6c7"}.fa-cow{--fa:"\f6c8"}.fa-dagger{--fa:"\f6cb"}.fa-dice-d10{--fa:"\f6cd"}.fa-dice-d12{--fa:"\f6ce"}.fa-dice-d20{--fa:"\f6cf"}.fa-dice-d4{--fa:"\f6d0"}.fa-dice-d6{--fa:"\f6d1"}.fa-dice-d8{--fa:"\f6d2"}.fa-dog{--fa:"\f6d3"}.fa-dog-leashed{--fa:"\f6d4"}.fa-dragon{--fa:"\f6d5"}.fa-drumstick{--fa:"\f6d6"}.fa-drumstick-bite{--fa:"\f6d7"}.fa-duck{--fa:"\f6d8"}.fa-dungeon{--fa:"\f6d9"}.fa-elephant{--fa:"\f6da"}.fa-eye-evil{--fa:"\f6db"}.fa-file-csv{--fa:"\f6dd"}.fa-fist-raised,.fa-hand-fist{--fa:"\f6de"}.fa-fire-flame,.fa-flame{--fa:"\f6df"}.fa-flask-poison,.fa-flask-round-poison{--fa:"\f6e0"}.fa-flask-potion,.fa-flask-round-potion{--fa:"\f6e1"}.fa-ghost{--fa:"\f6e2"}.fa-hammer{--fa:"\f6e3"}.fa-hammer-war{--fa:"\f6e4"}.fa-hand-holding-magic{--fa:"\f6e5"}.fa-hanukiah{--fa:"\f6e6"}.fa-hat-witch{--fa:"\f6e7"}.fa-hat-wizard{--fa:"\f6e8"}.fa-head-side{--fa:"\f6e9"}.fa-head-side-goggles,.fa-head-vr{--fa:"\f6ea"}.fa-helmet-battle{--fa:"\f6eb"}.fa-hiking,.fa-person-hiking{--fa:"\f6ec"}.fa-hippo{--fa:"\f6ed"}.fa-hockey-mask{--fa:"\f6ee"}.fa-hood-cloak{--fa:"\f6ef"}.fa-horse{--fa:"\f6f0"}.fa-house-chimney-crack,.fa-house-damage{--fa:"\f6f1"}.fa-hryvnia,.fa-hryvnia-sign{--fa:"\f6f2"}.fa-key-skeleton{--fa:"\f6f3"}.fa-kite{--fa:"\f6f4"}.fa-knife-kitchen{--fa:"\f6f5"}.fa-leaf-maple{--fa:"\f6f6"}.fa-leaf-oak{--fa:"\f6f7"}.fa-mace{--fa:"\f6f8"}.fa-mandolin{--fa:"\f6f9"}.fa-mask{--fa:"\f6fa"}.fa-monkey{--fa:"\f6fb"}.fa-mountain{--fa:"\f6fc"}.fa-mountains{--fa:"\f6fd"}.fa-narwhal{--fa:"\f6fe"}.fa-network-wired{--fa:"\f6ff"}.fa-otter{--fa:"\f700"}.fa-paw-alt,.fa-paw-simple{--fa:"\f701"}.fa-paw-claws{--fa:"\f702"}.fa-pegasus{--fa:"\f703"}.fa-pie{--fa:"\f705"}.fa-pig{--fa:"\f706"}.fa-pumpkin{--fa:"\f707"}.fa-rabbit{--fa:"\f708"}.fa-rabbit-fast,.fa-rabbit-running{--fa:"\f709"}.fa-ram{--fa:"\f70a"}.fa-ring{--fa:"\f70b"}.fa-person-running,.fa-running{--fa:"\f70c"}.fa-scarecrow{--fa:"\f70d"}.fa-scroll{--fa:"\f70e"}.fa-scroll-old{--fa:"\f70f"}.fa-scythe{--fa:"\f710"}.fa-sheep{--fa:"\f711"}.fa-shield-cross{--fa:"\f712"}.fa-shovel{--fa:"\f713"}.fa-skull-crossbones{--fa:"\f714"}.fa-slash{--fa:"\f715"}.fa-snake{--fa:"\f716"}.fa-spider{--fa:"\f717"}.fa-spider-black-widow{--fa:"\f718"}.fa-spider-web{--fa:"\f719"}.fa-squirrel{--fa:"\f71a"}.fa-staff{--fa:"\f71b"}.fa-sword{--fa:"\f71c"}.fa-swords{--fa:"\f71d"}.fa-toilet-paper,.fa-toilet-paper-alt,.fa-toilet-paper-blank{--fa:"\f71e"}.fa-tombstone{--fa:"\f720"}.fa-tombstone-alt,.fa-tombstone-blank{--fa:"\f721"}.fa-tractor{--fa:"\f722"}.fa-treasure-chest{--fa:"\f723"}.fa-trees{--fa:"\f724"}.fa-turkey{--fa:"\f725"}.fa-turtle{--fa:"\f726"}.fa-unicorn{--fa:"\f727"}.fa-user-injured{--fa:"\f728"}.fa-vr-cardboard{--fa:"\f729"}.fa-wand{--fa:"\f72a"}.fa-wand-sparkles{--fa:"\f72b"}.fa-whale{--fa:"\f72c"}.fa-wheat{--fa:"\f72d"}.fa-wind{--fa:"\f72e"}.fa-wine-bottle{--fa:"\f72f"}.fa-ballot{--fa:"\f732"}.fa-ballot-check{--fa:"\f733"}.fa-booth-curtain{--fa:"\f734"}.fa-box-ballot{--fa:"\f735"}.fa-calendar-star{--fa:"\f736"}.fa-clipboard-list-check{--fa:"\f737"}.fa-cloud-drizzle{--fa:"\f738"}.fa-cloud-hail{--fa:"\f739"}.fa-cloud-hail-mixed{--fa:"\f73a"}.fa-cloud-meatball{--fa:"\f73b"}.fa-cloud-moon-rain{--fa:"\f73c"}.fa-cloud-rain{--fa:"\f73d"}.fa-cloud-rainbow{--fa:"\f73e"}.fa-cloud-showers{--fa:"\f73f"}.fa-cloud-showers-heavy{--fa:"\f740"}.fa-cloud-sleet{--fa:"\f741"}.fa-cloud-snow{--fa:"\f742"}.fa-cloud-sun-rain{--fa:"\f743"}.fa-clouds{--fa:"\f744"}.fa-clouds-moon{--fa:"\f745"}.fa-clouds-sun{--fa:"\f746"}.fa-democrat{--fa:"\f747"}.fa-dewpoint,.fa-droplet-degree{--fa:"\f748"}.fa-eclipse{--fa:"\f749"}.fa-eclipse-alt,.fa-moon-over-sun{--fa:"\f74a"}.fa-fire-smoke{--fa:"\f74b"}.fa-flag-alt,.fa-flag-swallowtail{--fa:"\f74c"}.fa-flag-usa{--fa:"\f74d"}.fa-cloud-fog,.fa-fog{--fa:"\f74e"}.fa-house-flood,.fa-house-water{--fa:"\f74f"}.fa-droplet-percent,.fa-humidity{--fa:"\f750"}.fa-hurricane{--fa:"\f751"}.fa-landmark-alt,.fa-landmark-dome{--fa:"\f752"}.fa-meteor{--fa:"\f753"}.fa-moon-cloud{--fa:"\f754"}.fa-moon-stars{--fa:"\f755"}.fa-person-booth{--fa:"\f756"}.fa-person-sign{--fa:"\f757"}.fa-podium-star{--fa:"\f758"}.fa-poll-people{--fa:"\f759"}.fa-poo-bolt,.fa-poo-storm{--fa:"\f75a"}.fa-rainbow{--fa:"\f75b"}.fa-raindrops{--fa:"\f75c"}.fa-republican{--fa:"\f75e"}.fa-smog{--fa:"\f75f"}.fa-smoke{--fa:"\f760"}.fa-snow-blowing{--fa:"\f761"}.fa-stars{--fa:"\f762"}.fa-sun-cloud{--fa:"\f763"}.fa-sun-dust{--fa:"\f764"}.fa-sun-haze{--fa:"\f765"}.fa-sunrise{--fa:"\f766"}.fa-sunset{--fa:"\f767"}.fa-temperature-frigid,.fa-temperature-snow{--fa:"\f768"}.fa-temperature-high{--fa:"\f769"}.fa-temperature-hot,.fa-temperature-sun{--fa:"\f76a"}.fa-temperature-low{--fa:"\f76b"}.fa-cloud-bolt,.fa-thunderstorm{--fa:"\f76c"}.fa-cloud-bolt-moon,.fa-thunderstorm-moon{--fa:"\f76d"}.fa-cloud-bolt-sun,.fa-thunderstorm-sun{--fa:"\f76e"}.fa-tornado{--fa:"\f76f"}.fa-volcano{--fa:"\f770"}.fa-times-to-slot,.fa-vote-nay,.fa-xmark-to-slot{--fa:"\f771"}.fa-check-to-slot,.fa-vote-yea{--fa:"\f772"}.fa-water{--fa:"\f773"}.fa-water-arrow-down,.fa-water-lower{--fa:"\f774"}.fa-water-arrow-up,.fa-water-rise{--fa:"\f775"}.fa-wind-circle-exclamation,.fa-wind-warning{--fa:"\f776"}.fa-windsock{--fa:"\f777"}.fa-angel{--fa:"\f779"}.fa-baby{--fa:"\f77c"}.fa-baby-carriage,.fa-carriage-baby{--fa:"\f77d"}.fa-ball-pile{--fa:"\f77e"}.fa-bells{--fa:"\f77f"}.fa-biohazard{--fa:"\f780"}.fa-blog{--fa:"\f781"}.fa-boot{--fa:"\f782"}.fa-calendar-day{--fa:"\f783"}.fa-calendar-week{--fa:"\f784"}.fa-candy-cane{--fa:"\f786"}.fa-carrot{--fa:"\f787"}.fa-cash-register{--fa:"\f788"}.fa-chart-network{--fa:"\f78a"}.fa-chimney{--fa:"\f78b"}.fa-compress-arrows-alt,.fa-minimize{--fa:"\f78c"}.fa-deer{--fa:"\f78e"}.fa-deer-rudolph{--fa:"\f78f"}.fa-dreidel{--fa:"\f792"}.fa-dumpster{--fa:"\f793"}.fa-dumpster-fire{--fa:"\f794"}.fa-ear-muffs{--fa:"\f795"}.fa-ethernet{--fa:"\f796"}.fa-fireplace{--fa:"\f79a"}.fa-frosty-head,.fa-snowman-head{--fa:"\f79b"}.fa-gifts{--fa:"\f79c"}.fa-gingerbread-man{--fa:"\f79d"}.fa-champagne-glass,.fa-glass-champagne{--fa:"\f79e"}.fa-champagne-glasses,.fa-glass-cheers{--fa:"\f79f"}.fa-glass-whiskey,.fa-whiskey-glass{--fa:"\f7a0"}.fa-glass-whiskey-rocks,.fa-whiskey-glass-ice{--fa:"\f7a1"}.fa-earth-europe,.fa-globe-europe{--fa:"\f7a2"}.fa-globe-snow{--fa:"\f7a3"}.fa-grip-lines{--fa:"\f7a4"}.fa-grip-lines-vertical{--fa:"\f7a5"}.fa-guitar{--fa:"\f7a6"}.fa-hat-santa{--fa:"\f7a7"}.fa-hat-winter{--fa:"\f7a8"}.fa-heart-broken,.fa-heart-crack{--fa:"\f7a9"}.fa-holly-berry{--fa:"\f7aa"}.fa-horse-head{--fa:"\f7ab"}.fa-ice-skate{--fa:"\f7ac"}.fa-icicles{--fa:"\f7ad"}.fa-igloo{--fa:"\f7ae"}.fa-lights-holiday{--fa:"\f7b2"}.fa-mistletoe{--fa:"\f7b4"}.fa-mitten{--fa:"\f7b5"}.fa-mug-hot{--fa:"\f7b6"}.fa-mug-marshmallows{--fa:"\f7b7"}.fa-ornament{--fa:"\f7b8"}.fa-radiation{--fa:"\f7b9"}.fa-circle-radiation,.fa-radiation-alt{--fa:"\f7ba"}.fa-restroom{--fa:"\f7bd"}.fa-rv{--fa:"\f7be"}.fa-satellite{--fa:"\f7bf"}.fa-satellite-dish{--fa:"\f7c0"}.fa-scarf{--fa:"\f7c1"}.fa-sd-card{--fa:"\f7c2"}.fa-shovel-snow{--fa:"\f7c3"}.fa-sim-card{--fa:"\f7c4"}.fa-person-skating,.fa-skating{--fa:"\f7c5"}.fa-person-ski-jumping,.fa-ski-jump{--fa:"\f7c7"}.fa-person-ski-lift,.fa-ski-lift{--fa:"\f7c8"}.fa-person-skiing,.fa-skiing{--fa:"\f7c9"}.fa-person-skiing-nordic,.fa-skiing-nordic{--fa:"\f7ca"}.fa-person-sledding,.fa-sledding{--fa:"\f7cb"}.fa-sleigh{--fa:"\f7cc"}.fa-comment-sms,.fa-sms{--fa:"\f7cd"}.fa-person-snowboarding,.fa-snowboarding{--fa:"\f7ce"}.fa-snowflakes{--fa:"\f7cf"}.fa-snowman{--fa:"\f7d0"}.fa-person-snowmobiling,.fa-snowmobile{--fa:"\f7d1"}.fa-snowplow{--fa:"\f7d2"}.fa-star-christmas{--fa:"\f7d4"}.fa-stocking{--fa:"\f7d5"}.fa-tenge,.fa-tenge-sign{--fa:"\f7d7"}.fa-toilet{--fa:"\f7d8"}.fa-screwdriver-wrench,.fa-tools{--fa:"\f7d9"}.fa-cable-car,.fa-tram{--fa:"\f7da"}.fa-tree-christmas{--fa:"\f7db"}.fa-tree-decorated{--fa:"\f7dc"}.fa-tree-large{--fa:"\f7dd"}.fa-truck-plow{--fa:"\f7de"}.fa-wreath{--fa:"\f7e2"}.fa-fire-alt,.fa-fire-flame-curved{--fa:"\f7e4"}.fa-bacon{--fa:"\f7e5"}.fa-book-medical{--fa:"\f7e6"}.fa-book-user{--fa:"\f7e7"}.fa-books-medical{--fa:"\f7e8"}.fa-brackets,.fa-brackets-square{--fa:"\f7e9"}.fa-brackets-curly{--fa:"\f7ea"}.fa-bread-loaf{--fa:"\f7eb"}.fa-bread-slice{--fa:"\f7ec"}.fa-burrito{--fa:"\f7ed"}.fa-chart-scatter{--fa:"\f7ee"}.fa-cheese{--fa:"\f7ef"}.fa-cheese-swiss{--fa:"\f7f0"}.fa-burger-cheese,.fa-cheeseburger{--fa:"\f7f1"}.fa-clinic-medical,.fa-house-chimney-medical{--fa:"\f7f2"}.fa-clipboard-user{--fa:"\f7f3"}.fa-comment-alt-medical,.fa-message-medical{--fa:"\f7f4"}.fa-comment-medical{--fa:"\f7f5"}.fa-croissant{--fa:"\f7f6"}.fa-crutch{--fa:"\f7f7"}.fa-crutches{--fa:"\f7f8"}.fa-ban-bug,.fa-debug{--fa:"\f7f9"}.fa-disease{--fa:"\f7fa"}.fa-egg{--fa:"\f7fb"}.fa-egg-fried{--fa:"\f7fc"}.fa-files-medical{--fa:"\f7fd"}.fa-fish-cooked{--fa:"\f7fe"}.fa-flower{--fa:"\f7ff"}.fa-flower-daffodil{--fa:"\f800"}.fa-flower-tulip{--fa:"\f801"}.fa-folder-tree{--fa:"\f802"}.fa-french-fries{--fa:"\f803"}.fa-glass{--fa:"\f804"}.fa-burger,.fa-hamburger{--fa:"\f805"}.fa-hand-middle-finger{--fa:"\f806"}.fa-hard-hat,.fa-hat-hard,.fa-helmet-safety{--fa:"\f807"}.fa-head-side-brain{--fa:"\f808"}.fa-head-side-medical{--fa:"\f809"}.fa-hospital-user{--fa:"\f80d"}.fa-hospitals{--fa:"\f80e"}.fa-hotdog{--fa:"\f80f"}.fa-ice-cream{--fa:"\f810"}.fa-island-tree-palm,.fa-island-tropical{--fa:"\f811"}.fa-laptop-medical{--fa:"\f812"}.fa-mailbox{--fa:"\f813"}.fa-meat{--fa:"\f814"}.fa-pager{--fa:"\f815"}.fa-pepper-hot{--fa:"\f816"}.fa-pizza{--fa:"\f817"}.fa-pizza-slice{--fa:"\f818"}.fa-popcorn{--fa:"\f819"}.fa-print-magnifying-glass,.fa-print-search{--fa:"\f81a"}.fa-rings-wedding{--fa:"\f81b"}.fa-sack{--fa:"\f81c"}.fa-sack-dollar{--fa:"\f81d"}.fa-bowl-salad,.fa-salad{--fa:"\f81e"}.fa-sandwich{--fa:"\f81f"}.fa-sausage{--fa:"\f820"}.fa-shish-kebab{--fa:"\f821"}.fa-sickle{--fa:"\f822"}.fa-bowl-hot,.fa-soup{--fa:"\f823"}.fa-steak{--fa:"\f824"}.fa-stretcher{--fa:"\f825"}.fa-taco{--fa:"\f826"}.fa-book-tanakh,.fa-tanakh{--fa:"\f827"}.fa-bars-progress,.fa-tasks-alt{--fa:"\f828"}.fa-trash-arrow-up,.fa-trash-restore{--fa:"\f829"}.fa-trash-can-arrow-up,.fa-trash-restore-alt{--fa:"\f82a"}.fa-tree-palm{--fa:"\f82b"}.fa-user-construction,.fa-user-hard-hat,.fa-user-helmet-safety{--fa:"\f82c"}.fa-user-headset{--fa:"\f82d"}.fa-user-doctor-message,.fa-user-md-chat{--fa:"\f82e"}.fa-user-nurse{--fa:"\f82f"}.fa-users-medical{--fa:"\f830"}.fa-walker{--fa:"\f831"}.fa-camera-web,.fa-webcam{--fa:"\f832"}.fa-camera-web-slash,.fa-webcam-slash{--fa:"\f833"}.fa-wave-square{--fa:"\f83e"}.fa-alarm-exclamation{--fa:"\f843"}.fa-alarm-plus{--fa:"\f844"}.fa-alarm-snooze{--fa:"\f845"}.fa-align-slash{--fa:"\f846"}.fa-bags-shopping{--fa:"\f847"}.fa-bell-exclamation{--fa:"\f848"}.fa-bell-plus{--fa:"\f849"}.fa-biking,.fa-person-biking{--fa:"\f84a"}.fa-biking-mountain,.fa-person-biking-mountain{--fa:"\f84b"}.fa-border-all{--fa:"\f84c"}.fa-border-bottom{--fa:"\f84d"}.fa-border-inner{--fa:"\f84e"}.fa-border-left{--fa:"\f84f"}.fa-border-none{--fa:"\f850"}.fa-border-outer{--fa:"\f851"}.fa-border-right{--fa:"\f852"}.fa-border-style,.fa-border-top-left{--fa:"\f853"}.fa-border-bottom-right,.fa-border-style-alt{--fa:"\f854"}.fa-border-top{--fa:"\f855"}.fa-bring-forward{--fa:"\f856"}.fa-bring-front{--fa:"\f857"}.fa-burger-soda{--fa:"\f858"}.fa-car-building{--fa:"\f859"}.fa-car-bus{--fa:"\f85a"}.fa-cars{--fa:"\f85b"}.fa-coin{--fa:"\f85c"}.fa-construction,.fa-triangle-person-digging{--fa:"\f85d"}.fa-digging,.fa-person-digging{--fa:"\f85e"}.fa-drone{--fa:"\f85f"}.fa-drone-alt,.fa-drone-front{--fa:"\f860"}.fa-dryer{--fa:"\f861"}.fa-dryer-alt,.fa-dryer-heat{--fa:"\f862"}.fa-fan{--fa:"\f863"}.fa-barn-silo,.fa-farm{--fa:"\f864"}.fa-file-magnifying-glass,.fa-file-search{--fa:"\f865"}.fa-font-case{--fa:"\f866"}.fa-game-board{--fa:"\f867"}.fa-game-board-alt,.fa-game-board-simple{--fa:"\f868"}.fa-glass-citrus{--fa:"\f869"}.fa-h4{--fa:"\f86a"}.fa-hat-chef{--fa:"\f86b"}.fa-horizontal-rule{--fa:"\f86c"}.fa-heart-music-camera-bolt,.fa-icons{--fa:"\f86d"}.fa-icons-alt,.fa-symbols{--fa:"\f86e"}.fa-kerning{--fa:"\f86f"}.fa-line-columns{--fa:"\f870"}.fa-line-height{--fa:"\f871"}.fa-money-check-edit,.fa-money-check-pen{--fa:"\f872"}.fa-money-check-dollar-pen,.fa-money-check-edit-alt{--fa:"\f873"}.fa-mug{--fa:"\f874"}.fa-mug-tea{--fa:"\f875"}.fa-overline{--fa:"\f876"}.fa-file-dashed-line,.fa-page-break{--fa:"\f877"}.fa-paragraph-left,.fa-paragraph-rtl{--fa:"\f878"}.fa-phone-alt,.fa-phone-flip{--fa:"\f879"}.fa-laptop-mobile,.fa-phone-laptop{--fa:"\f87a"}.fa-phone-square-alt,.fa-square-phone-flip{--fa:"\f87b"}.fa-photo-film,.fa-photo-video{--fa:"\f87c"}.fa-remove-format,.fa-text-slash{--fa:"\f87d"}.fa-send-back{--fa:"\f87e"}.fa-send-backward{--fa:"\f87f"}.fa-snooze,.fa-zzz{--fa:"\f880"}.fa-arrow-down-z-a,.fa-sort-alpha-desc,.fa-sort-alpha-down-alt{--fa:"\f881"}.fa-arrow-up-z-a,.fa-sort-alpha-up-alt{--fa:"\f882"}.fa-arrow-down-arrow-up,.fa-sort-alt{--fa:"\f883"}.fa-arrow-down-short-wide,.fa-sort-amount-desc,.fa-sort-amount-down-alt{--fa:"\f884"}.fa-arrow-up-short-wide,.fa-sort-amount-up-alt{--fa:"\f885"}.fa-arrow-down-9-1,.fa-sort-numeric-desc,.fa-sort-numeric-down-alt{--fa:"\f886"}.fa-arrow-up-9-1,.fa-sort-numeric-up-alt{--fa:"\f887"}.fa-arrow-down-triangle-square,.fa-sort-shapes-down{--fa:"\f888"}.fa-arrow-down-square-triangle,.fa-sort-shapes-down-alt{--fa:"\f889"}.fa-arrow-up-triangle-square,.fa-sort-shapes-up{--fa:"\f88a"}.fa-arrow-up-square-triangle,.fa-sort-shapes-up-alt{--fa:"\f88b"}.fa-arrow-down-big-small,.fa-sort-size-down{--fa:"\f88c"}.fa-arrow-down-small-big,.fa-sort-size-down-alt{--fa:"\f88d"}.fa-arrow-up-big-small,.fa-sort-size-up{--fa:"\f88e"}.fa-arrow-up-small-big,.fa-sort-size-up-alt{--fa:"\f88f"}.fa-sparkles{--fa:"\f890"}.fa-spell-check{--fa:"\f891"}.fa-sunglasses{--fa:"\f892"}.fa-text{--fa:"\f893"}.fa-text-size{--fa:"\f894"}.fa-trash-arrow-turn-left,.fa-trash-undo{--fa:"\f895"}.fa-trash-can-arrow-turn-left,.fa-trash-can-undo,.fa-trash-undo-alt{--fa:"\f896"}.fa-voicemail{--fa:"\f897"}.fa-washer,.fa-washing-machine{--fa:"\f898"}.fa-wave-sine{--fa:"\f899"}.fa-wave-triangle{--fa:"\f89a"}.fa-wind-turbine{--fa:"\f89b"}.fa-border-center-h{--fa:"\f89c"}.fa-border-center-v{--fa:"\f89d"}.fa-album{--fa:"\f89f"}.fa-album-collection{--fa:"\f8a0"}.fa-amp-guitar{--fa:"\f8a1"}.fa-badge-sheriff{--fa:"\f8a2"}.fa-banjo{--fa:"\f8a3"}.fa-betamax,.fa-cassette-betamax{--fa:"\f8a4"}.fa-boombox{--fa:"\f8a5"}.fa-cactus{--fa:"\f8a7"}.fa-camcorder,.fa-video-handheld{--fa:"\f8a8"}.fa-camera-movie{--fa:"\f8a9"}.fa-camera-polaroid{--fa:"\f8aa"}.fa-cassette-tape{--fa:"\f8ab"}.fa-camera-cctv,.fa-cctv{--fa:"\f8ac"}.fa-clarinet{--fa:"\f8ad"}.fa-cloud-music{--fa:"\f8ae"}.fa-comment-alt-music,.fa-message-music{--fa:"\f8af"}.fa-comment-music{--fa:"\f8b0"}.fa-computer-classic{--fa:"\f8b1"}.fa-computer-speaker{--fa:"\f8b2"}.fa-cowbell{--fa:"\f8b3"}.fa-cowbell-circle-plus,.fa-cowbell-more{--fa:"\f8b4"}.fa-disc-drive{--fa:"\f8b5"}.fa-file-music{--fa:"\f8b6"}.fa-film-canister,.fa-film-cannister{--fa:"\f8b7"}.fa-flashlight{--fa:"\f8b8"}.fa-flute{--fa:"\f8b9"}.fa-flux-capacitor{--fa:"\f8ba"}.fa-game-console-handheld{--fa:"\f8bb"}.fa-gramophone{--fa:"\f8bd"}.fa-guitar-electric{--fa:"\f8be"}.fa-guitars{--fa:"\f8bf"}.fa-hat-cowboy{--fa:"\f8c0"}.fa-hat-cowboy-side{--fa:"\f8c1"}.fa-head-side-headphones{--fa:"\f8c2"}.fa-horse-saddle{--fa:"\f8c3"}.fa-image-polaroid{--fa:"\f8c4"}.fa-joystick{--fa:"\f8c5"}.fa-jug{--fa:"\f8c6"}.fa-kazoo{--fa:"\f8c7"}.fa-lasso{--fa:"\f8c8"}.fa-list-music{--fa:"\f8c9"}.fa-microphone-stand{--fa:"\f8cb"}.fa-computer-mouse,.fa-mouse{--fa:"\f8cc"}.fa-computer-mouse-scrollwheel,.fa-mouse-alt{--fa:"\f8cd"}.fa-mp3-player{--fa:"\f8ce"}.fa-music-alt,.fa-music-note{--fa:"\f8cf"}.fa-music-alt-slash,.fa-music-note-slash{--fa:"\f8d0"}.fa-music-slash{--fa:"\f8d1"}.fa-phone-rotary{--fa:"\f8d3"}.fa-piano{--fa:"\f8d4"}.fa-piano-keyboard{--fa:"\f8d5"}.fa-projector{--fa:"\f8d6"}.fa-radio{--fa:"\f8d7"}.fa-radio-alt,.fa-radio-tuner{--fa:"\f8d8"}.fa-record-vinyl{--fa:"\f8d9"}.fa-router{--fa:"\f8da"}.fa-sax-hot,.fa-saxophone-fire{--fa:"\f8db"}.fa-saxophone{--fa:"\f8dc"}.fa-signal-stream{--fa:"\f8dd"}.fa-skull-cow{--fa:"\f8de"}.fa-speaker{--fa:"\f8df"}.fa-speakers{--fa:"\f8e0"}.fa-triangle-instrument,.fa-triangle-music{--fa:"\f8e2"}.fa-trumpet{--fa:"\f8e3"}.fa-turntable{--fa:"\f8e4"}.fa-tv-music{--fa:"\f8e6"}.fa-typewriter{--fa:"\f8e7"}.fa-usb-drive{--fa:"\f8e9"}.fa-user-cowboy{--fa:"\f8ea"}.fa-user-music{--fa:"\f8eb"}.fa-cassette-vhs,.fa-vhs{--fa:"\f8ec"}.fa-violin{--fa:"\f8ed"}.fa-wagon-covered{--fa:"\f8ee"}.fa-walkie-talkie{--fa:"\f8ef"}.fa-watch-calculator{--fa:"\f8f0"}.fa-waveform{--fa:"\f8f1"}.fa-waveform-lines,.fa-waveform-path{--fa:"\f8f2"}.fa-scanner-image{--fa:"\f8f3"}.fa-air-conditioner{--fa:"\f8f4"}.fa-alien{--fa:"\f8f5"}.fa-alien-8bit,.fa-alien-monster{--fa:"\f8f6"}.fa-bed-alt,.fa-bed-front{--fa:"\f8f7"}.fa-bed-bunk{--fa:"\f8f8"}.fa-bed-empty{--fa:"\f8f9"}.fa-bell-on{--fa:"\f8fa"}.fa-blinds{--fa:"\f8fb"}.fa-blinds-open{--fa:"\f8fc"}.fa-blinds-raised{--fa:"\f8fd"}.fa-camera-home,.fa-camera-security{--fa:"\f8fe"}.fa-caravan{--fa:"\f8ff"} diff --git a/public/vendor/fontawesome/css/jelly-duo-regular.css b/public/vendor/fontawesome/css/jelly-duo-regular.css deleted file mode 100644 index 895ad7c..0000000 --- a/public/vendor/fontawesome/css/jelly-duo-regular.css +++ /dev/null @@ -1,71 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-jelly-duo: "Font Awesome 7 Jelly Duo"; - --fa-font-jelly-duo-regular: normal 400 1em/1 var(--fa-family-jelly-duo); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-jelly-duo: var(--fa-family-jelly-duo); -} - -@font-face { - font-family: "Font Awesome 7 Jelly Duo"; - font-style: normal; - font-weight: 400; - font-display: block; - src: url("../webfonts/fa-jelly-duo-regular-400.woff2"); -} -.fajdr { - --fa-family: var(--fa-family-jelly-duo); - --fa-style: 400; - position: relative; - letter-spacing: normal; -} - -.fa-jelly-duo { - --fa-family: var(--fa-family-jelly-duo); - position: relative; - letter-spacing: normal; -} - -.fa-regular { - --fa-style: 400; -} - -.fajdr::before, -.fa-jelly-duo::before { - position: absolute; - color: var(--fa-primary-color, currentColor); - opacity: var(--fa-primary-opacity, 1); -} - -.fajdr::after, -.fa-jelly-duo::after { - color: var(--fa-secondary-color, currentColor); - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .fajdr::before, -.fa-swap-opacity .fa-jelly-duo::before, -.fa-swap-opacity.fajdr::before, -.fa-swap-opacity.fa-jelly-duo::before { - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .fajdr::after, -.fa-swap-opacity .fa-jelly-duo::after, -.fa-swap-opacity.fajdr::after, -.fa-swap-opacity.fa-jelly-duo::after { - opacity: var(--fa-primary-opacity, 1); -} - -.fa-li.fajdr, -.fa-li.fa-jelly-duo, -.fa-stack-1x.fajdr, -.fa-stack-1x.fa-jelly-duo, -.fa-stack-2x.fajdr, -.fa-stack-2x.fa-jelly-duo { - position: absolute; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/jelly-duo-regular.min.css b/public/vendor/fontawesome/css/jelly-duo-regular.min.css deleted file mode 100644 index fe482cf..0000000 --- a/public/vendor/fontawesome/css/jelly-duo-regular.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-jelly-duo:"Font Awesome 7 Jelly Duo";--fa-font-jelly-duo-regular:normal 400 1em/1 var(--fa-family-jelly-duo);--fa-style-family-jelly-duo:var(--fa-family-jelly-duo)}@font-face{font-family:"Font Awesome 7 Jelly Duo";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-jelly-duo-regular-400.woff2)}.fajdr{--fa-style:400}.fa-jelly-duo,.fajdr{--fa-family:var(--fa-family-jelly-duo);position:relative;letter-spacing:normal}.fa-regular{--fa-style:400}.fa-jelly-duo:before,.fajdr:before{position:absolute;color:var(--fa-primary-color,currentColor);opacity:var(--fa-primary-opacity,1)}.fa-jelly-duo:after,.fajdr:after{color:var(--fa-secondary-color,currentColor)}.fa-jelly-duo:after,.fa-swap-opacity.fa-jelly-duo:before,.fa-swap-opacity .fa-jelly-duo:before,.fa-swap-opacity.fajdr:before,.fa-swap-opacity .fajdr:before,.fajdr:after{opacity:var(--fa-secondary-opacity,.4)}.fa-swap-opacity.fa-jelly-duo:after,.fa-swap-opacity .fa-jelly-duo:after,.fa-swap-opacity.fajdr:after,.fa-swap-opacity .fajdr:after{opacity:var(--fa-primary-opacity,1)}.fa-li.fa-jelly-duo,.fa-li.fajdr,.fa-stack-1x.fa-jelly-duo,.fa-stack-1x.fajdr,.fa-stack-2x.fa-jelly-duo,.fa-stack-2x.fajdr{position:absolute} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/jelly-fill-regular.css b/public/vendor/fontawesome/css/jelly-fill-regular.css deleted file mode 100644 index 01e5d64..0000000 --- a/public/vendor/fontawesome/css/jelly-fill-regular.css +++ /dev/null @@ -1,31 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-jelly-fill: "Font Awesome 7 Jelly Fill"; - --fa-font-jelly-fill-regular: normal 400 1em/1 var(--fa-family-jelly-fill); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-jelly-fill: var(--fa-family-jelly-fill); -} - -@font-face { - font-family: "Font Awesome 7 Jelly Fill"; - font-style: normal; - font-weight: 400; - font-display: block; - src: url("../webfonts/fa-jelly-fill-regular-400.woff2"); -} -.fajfr { - --fa-family: var(--fa-family-jelly-fill); - --fa-style: 400; -} - -.fa-jelly-fill { - --fa-family: var(--fa-family-jelly-fill); -} - -.fa-regular { - --fa-style: 400; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/jelly-fill-regular.min.css b/public/vendor/fontawesome/css/jelly-fill-regular.min.css deleted file mode 100644 index 9aac59b..0000000 --- a/public/vendor/fontawesome/css/jelly-fill-regular.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-jelly-fill:"Font Awesome 7 Jelly Fill";--fa-font-jelly-fill-regular:normal 400 1em/1 var(--fa-family-jelly-fill);--fa-style-family-jelly-fill:var(--fa-family-jelly-fill)}@font-face{font-family:"Font Awesome 7 Jelly Fill";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-jelly-fill-regular-400.woff2)}.fajfr{--fa-style:400}.fa-jelly-fill,.fajfr{--fa-family:var(--fa-family-jelly-fill)}.fa-regular{--fa-style:400} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/jelly-regular.css b/public/vendor/fontawesome/css/jelly-regular.css deleted file mode 100644 index 23c4792..0000000 --- a/public/vendor/fontawesome/css/jelly-regular.css +++ /dev/null @@ -1,31 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-jelly: "Font Awesome 7 Jelly"; - --fa-font-jelly-regular: normal 400 1em/1 var(--fa-family-jelly); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-jelly: var(--fa-family-jelly); -} - -@font-face { - font-family: "Font Awesome 7 Jelly"; - font-style: normal; - font-weight: 400; - font-display: block; - src: url("../webfonts/fa-jelly-regular-400.woff2"); -} -.fajr { - --fa-family: var(--fa-family-jelly); - --fa-style: 400; -} - -.fa-jelly { - --fa-family: var(--fa-family-jelly); -} - -.fa-regular { - --fa-style: 400; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/jelly-regular.min.css b/public/vendor/fontawesome/css/jelly-regular.min.css deleted file mode 100644 index b2f643a..0000000 --- a/public/vendor/fontawesome/css/jelly-regular.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-jelly:"Font Awesome 7 Jelly";--fa-font-jelly-regular:normal 400 1em/1 var(--fa-family-jelly);--fa-style-family-jelly:var(--fa-family-jelly)}@font-face{font-family:"Font Awesome 7 Jelly";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-jelly-regular-400.woff2)}.fajr{--fa-style:400}.fa-jelly,.fajr{--fa-family:var(--fa-family-jelly)}.fa-regular{--fa-style:400} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/light.css b/public/vendor/fontawesome/css/light.css deleted file mode 100644 index 7b7615d..0000000 --- a/public/vendor/fontawesome/css/light.css +++ /dev/null @@ -1,31 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-classic: "Font Awesome 7 Pro"; - --fa-font-light: normal 300 1em/1 var(--fa-family-classic); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-classic: var(--fa-family-classic); -} - -@font-face { - font-family: "Font Awesome 7 Pro"; - font-style: normal; - font-weight: 300; - font-display: block; - src: url("../webfonts/fa-light-300.woff2"); -} -.fal { - --fa-family: var(--fa-family-classic); - --fa-style: 300; -} - -.fa-classic { - --fa-family: var(--fa-family-classic); -} - -.fa-light { - --fa-style: 300; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/light.min.css b/public/vendor/fontawesome/css/light.min.css deleted file mode 100644 index ca5fbf1..0000000 --- a/public/vendor/fontawesome/css/light.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-classic:"Font Awesome 7 Pro";--fa-font-light:normal 300 1em/1 var(--fa-family-classic);--fa-style-family-classic:var(--fa-family-classic)}@font-face{font-family:"Font Awesome 7 Pro";font-style:normal;font-weight:300;font-display:block;src:url(../webfonts/fa-light-300.woff2)}.fal{--fa-style:300}.fa-classic,.fal{--fa-family:var(--fa-family-classic)}.fa-light{--fa-style:300} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/notdog-duo-solid.css b/public/vendor/fontawesome/css/notdog-duo-solid.css deleted file mode 100644 index 2fe540e..0000000 --- a/public/vendor/fontawesome/css/notdog-duo-solid.css +++ /dev/null @@ -1,71 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-notdog-duo: "Font Awesome 7 Notdog Duo"; - --fa-font-notdog-duo-solid: normal 900 1em/1 var(--fa-family-notdog-duo); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-notdog-duo: var(--fa-family-notdog-duo); -} - -@font-face { - font-family: "Font Awesome 7 Notdog Duo"; - font-style: normal; - font-weight: 900; - font-display: block; - src: url("../webfonts/fa-notdog-duo-solid-900.woff2"); -} -.fands { - --fa-family: var(--fa-family-notdog-duo); - --fa-style: 900; - position: relative; - letter-spacing: normal; -} - -.fa-notdog-duo { - --fa-family: var(--fa-family-notdog-duo); - position: relative; - letter-spacing: normal; -} - -.fa-solid { - --fa-style: 900; -} - -.fands::before, -.fa-notdog-duo::before { - position: absolute; - color: var(--fa-primary-color, currentColor); - opacity: var(--fa-primary-opacity, 1); -} - -.fands::after, -.fa-notdog-duo::after { - color: var(--fa-secondary-color, currentColor); - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .fands::before, -.fa-swap-opacity .fa-notdog-duo::before, -.fa-swap-opacity.fands::before, -.fa-swap-opacity.fa-notdog-duo::before { - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .fands::after, -.fa-swap-opacity .fa-notdog-duo::after, -.fa-swap-opacity.fands::after, -.fa-swap-opacity.fa-notdog-duo::after { - opacity: var(--fa-primary-opacity, 1); -} - -.fa-li.fands, -.fa-li.fa-notdog-duo, -.fa-stack-1x.fands, -.fa-stack-1x.fa-notdog-duo, -.fa-stack-2x.fands, -.fa-stack-2x.fa-notdog-duo { - position: absolute; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/notdog-duo-solid.min.css b/public/vendor/fontawesome/css/notdog-duo-solid.min.css deleted file mode 100644 index 6db43c1..0000000 --- a/public/vendor/fontawesome/css/notdog-duo-solid.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-notdog-duo:"Font Awesome 7 Notdog Duo";--fa-font-notdog-duo-solid:normal 900 1em/1 var(--fa-family-notdog-duo);--fa-style-family-notdog-duo:var(--fa-family-notdog-duo)}@font-face{font-family:"Font Awesome 7 Notdog Duo";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-notdog-duo-solid-900.woff2)}.fands{--fa-style:900}.fa-notdog-duo,.fands{--fa-family:var(--fa-family-notdog-duo);position:relative;letter-spacing:normal}.fa-solid{--fa-style:900}.fa-notdog-duo:before,.fands:before{position:absolute;color:var(--fa-primary-color,currentColor);opacity:var(--fa-primary-opacity,1)}.fa-notdog-duo:after,.fands:after{color:var(--fa-secondary-color,currentColor)}.fa-notdog-duo:after,.fa-swap-opacity.fa-notdog-duo:before,.fa-swap-opacity .fa-notdog-duo:before,.fa-swap-opacity.fands:before,.fa-swap-opacity .fands:before,.fands:after{opacity:var(--fa-secondary-opacity,.4)}.fa-swap-opacity.fa-notdog-duo:after,.fa-swap-opacity .fa-notdog-duo:after,.fa-swap-opacity.fands:after,.fa-swap-opacity .fands:after{opacity:var(--fa-primary-opacity,1)}.fa-li.fa-notdog-duo,.fa-li.fands,.fa-stack-1x.fa-notdog-duo,.fa-stack-1x.fands,.fa-stack-2x.fa-notdog-duo,.fa-stack-2x.fands{position:absolute} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/notdog-solid.css b/public/vendor/fontawesome/css/notdog-solid.css deleted file mode 100644 index 04ffe14..0000000 --- a/public/vendor/fontawesome/css/notdog-solid.css +++ /dev/null @@ -1,31 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-notdog: "Font Awesome 7 Notdog"; - --fa-font-notdog-solid: normal 900 1em/1 var(--fa-family-notdog); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-notdog: var(--fa-family-notdog); -} - -@font-face { - font-family: "Font Awesome 7 Notdog"; - font-style: normal; - font-weight: 900; - font-display: block; - src: url("../webfonts/fa-notdog-solid-900.woff2"); -} -.fans { - --fa-family: var(--fa-family-notdog); - --fa-style: 900; -} - -.fa-notdog { - --fa-family: var(--fa-family-notdog); -} - -.fa-solid { - --fa-style: 900; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/notdog-solid.min.css b/public/vendor/fontawesome/css/notdog-solid.min.css deleted file mode 100644 index a4115e9..0000000 --- a/public/vendor/fontawesome/css/notdog-solid.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-notdog:"Font Awesome 7 Notdog";--fa-font-notdog-solid:normal 900 1em/1 var(--fa-family-notdog);--fa-style-family-notdog:var(--fa-family-notdog)}@font-face{font-family:"Font Awesome 7 Notdog";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-notdog-solid-900.woff2)}.fans{--fa-style:900}.fa-notdog,.fans{--fa-family:var(--fa-family-notdog)}.fa-solid{--fa-style:900} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/regular.css b/public/vendor/fontawesome/css/regular.css deleted file mode 100644 index 0bbe54e..0000000 --- a/public/vendor/fontawesome/css/regular.css +++ /dev/null @@ -1,31 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-classic: "Font Awesome 7 Pro"; - --fa-font-regular: normal 400 1em/1 var(--fa-family-classic); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-classic: var(--fa-family-classic); -} - -@font-face { - font-family: "Font Awesome 7 Pro"; - font-style: normal; - font-weight: 400; - font-display: block; - src: url("../webfonts/fa-regular-400.woff2"); -} -.far { - --fa-family: var(--fa-family-classic); - --fa-style: 400; -} - -.fa-classic { - --fa-family: var(--fa-family-classic); -} - -.fa-regular { - --fa-style: 400; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/regular.min.css b/public/vendor/fontawesome/css/regular.min.css deleted file mode 100644 index 1530da4..0000000 --- a/public/vendor/fontawesome/css/regular.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-classic:"Font Awesome 7 Pro";--fa-font-regular:normal 400 1em/1 var(--fa-family-classic);--fa-style-family-classic:var(--fa-family-classic)}@font-face{font-family:"Font Awesome 7 Pro";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-regular-400.woff2)}.far{--fa-style:400}.fa-classic,.far{--fa-family:var(--fa-family-classic)}.fa-regular{--fa-style:400} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/sharp-duotone-light.css b/public/vendor/fontawesome/css/sharp-duotone-light.css deleted file mode 100644 index f36953d..0000000 --- a/public/vendor/fontawesome/css/sharp-duotone-light.css +++ /dev/null @@ -1,71 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-sharp-duotone: "Font Awesome 7 Sharp Duotone"; - --fa-font-sharp-duotone-light: normal 300 1em/1 var(--fa-family-sharp-duotone); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-sharp-duotone: var(--fa-family-sharp-duotone); -} - -@font-face { - font-family: "Font Awesome 7 Sharp Duotone"; - font-style: normal; - font-weight: 300; - font-display: block; - src: url("../webfonts/fa-sharp-duotone-light-300.woff2"); -} -.fasdl { - --fa-family: var(--fa-family-sharp-duotone); - --fa-style: 300; - position: relative; - letter-spacing: normal; -} - -.fa-sharp-duotone { - --fa-family: var(--fa-family-sharp-duotone); - position: relative; - letter-spacing: normal; -} - -.fa-light { - --fa-style: 300; -} - -.fasdl::before, -.fa-sharp-duotone::before { - position: absolute; - color: var(--fa-primary-color, currentColor); - opacity: var(--fa-primary-opacity, 1); -} - -.fasdl::after, -.fa-sharp-duotone::after { - color: var(--fa-secondary-color, currentColor); - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .fasdl::before, -.fa-swap-opacity .fa-sharp-duotone::before, -.fa-swap-opacity.fasdl::before, -.fa-swap-opacity.fa-sharp-duotone::before { - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .fasdl::after, -.fa-swap-opacity .fa-sharp-duotone::after, -.fa-swap-opacity.fasdl::after, -.fa-swap-opacity.fa-sharp-duotone::after { - opacity: var(--fa-primary-opacity, 1); -} - -.fa-li.fasdl, -.fa-li.fa-sharp-duotone, -.fa-stack-1x.fasdl, -.fa-stack-1x.fa-sharp-duotone, -.fa-stack-2x.fasdl, -.fa-stack-2x.fa-sharp-duotone { - position: absolute; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/sharp-duotone-light.min.css b/public/vendor/fontawesome/css/sharp-duotone-light.min.css deleted file mode 100644 index cf21435..0000000 --- a/public/vendor/fontawesome/css/sharp-duotone-light.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-sharp-duotone:"Font Awesome 7 Sharp Duotone";--fa-font-sharp-duotone-light:normal 300 1em/1 var(--fa-family-sharp-duotone);--fa-style-family-sharp-duotone:var(--fa-family-sharp-duotone)}@font-face{font-family:"Font Awesome 7 Sharp Duotone";font-style:normal;font-weight:300;font-display:block;src:url(../webfonts/fa-sharp-duotone-light-300.woff2)}.fasdl{--fa-style:300}.fa-sharp-duotone,.fasdl{--fa-family:var(--fa-family-sharp-duotone);position:relative;letter-spacing:normal}.fa-light{--fa-style:300}.fa-sharp-duotone:before,.fasdl:before{position:absolute;color:var(--fa-primary-color,currentColor);opacity:var(--fa-primary-opacity,1)}.fa-sharp-duotone:after,.fasdl:after{color:var(--fa-secondary-color,currentColor)}.fa-sharp-duotone:after,.fa-swap-opacity.fa-sharp-duotone:before,.fa-swap-opacity .fa-sharp-duotone:before,.fa-swap-opacity.fasdl:before,.fa-swap-opacity .fasdl:before,.fasdl:after{opacity:var(--fa-secondary-opacity,.4)}.fa-swap-opacity.fa-sharp-duotone:after,.fa-swap-opacity .fa-sharp-duotone:after,.fa-swap-opacity.fasdl:after,.fa-swap-opacity .fasdl:after{opacity:var(--fa-primary-opacity,1)}.fa-li.fa-sharp-duotone,.fa-li.fasdl,.fa-stack-1x.fa-sharp-duotone,.fa-stack-1x.fasdl,.fa-stack-2x.fa-sharp-duotone,.fa-stack-2x.fasdl{position:absolute} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/sharp-duotone-regular.css b/public/vendor/fontawesome/css/sharp-duotone-regular.css deleted file mode 100644 index 0ada211..0000000 --- a/public/vendor/fontawesome/css/sharp-duotone-regular.css +++ /dev/null @@ -1,71 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-sharp-duotone: "Font Awesome 7 Sharp Duotone"; - --fa-font-sharp-duotone-regular: normal 400 1em/1 var(--fa-family-sharp-duotone); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-sharp-duotone: var(--fa-family-sharp-duotone); -} - -@font-face { - font-family: "Font Awesome 7 Sharp Duotone"; - font-style: normal; - font-weight: 400; - font-display: block; - src: url("../webfonts/fa-sharp-duotone-regular-400.woff2"); -} -.fasdr { - --fa-family: var(--fa-family-sharp-duotone); - --fa-style: 400; - position: relative; - letter-spacing: normal; -} - -.fa-sharp-duotone { - --fa-family: var(--fa-family-sharp-duotone); - position: relative; - letter-spacing: normal; -} - -.fa-regular { - --fa-style: 400; -} - -.fasdr::before, -.fa-sharp-duotone::before { - position: absolute; - color: var(--fa-primary-color, currentColor); - opacity: var(--fa-primary-opacity, 1); -} - -.fasdr::after, -.fa-sharp-duotone::after { - color: var(--fa-secondary-color, currentColor); - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .fasdr::before, -.fa-swap-opacity .fa-sharp-duotone::before, -.fa-swap-opacity.fasdr::before, -.fa-swap-opacity.fa-sharp-duotone::before { - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .fasdr::after, -.fa-swap-opacity .fa-sharp-duotone::after, -.fa-swap-opacity.fasdr::after, -.fa-swap-opacity.fa-sharp-duotone::after { - opacity: var(--fa-primary-opacity, 1); -} - -.fa-li.fasdr, -.fa-li.fa-sharp-duotone, -.fa-stack-1x.fasdr, -.fa-stack-1x.fa-sharp-duotone, -.fa-stack-2x.fasdr, -.fa-stack-2x.fa-sharp-duotone { - position: absolute; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/sharp-duotone-regular.min.css b/public/vendor/fontawesome/css/sharp-duotone-regular.min.css deleted file mode 100644 index 343c2b3..0000000 --- a/public/vendor/fontawesome/css/sharp-duotone-regular.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-sharp-duotone:"Font Awesome 7 Sharp Duotone";--fa-font-sharp-duotone-regular:normal 400 1em/1 var(--fa-family-sharp-duotone);--fa-style-family-sharp-duotone:var(--fa-family-sharp-duotone)}@font-face{font-family:"Font Awesome 7 Sharp Duotone";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-sharp-duotone-regular-400.woff2)}.fasdr{--fa-style:400}.fa-sharp-duotone,.fasdr{--fa-family:var(--fa-family-sharp-duotone);position:relative;letter-spacing:normal}.fa-regular{--fa-style:400}.fa-sharp-duotone:before,.fasdr:before{position:absolute;color:var(--fa-primary-color,currentColor);opacity:var(--fa-primary-opacity,1)}.fa-sharp-duotone:after,.fasdr:after{color:var(--fa-secondary-color,currentColor)}.fa-sharp-duotone:after,.fa-swap-opacity.fa-sharp-duotone:before,.fa-swap-opacity .fa-sharp-duotone:before,.fa-swap-opacity.fasdr:before,.fa-swap-opacity .fasdr:before,.fasdr:after{opacity:var(--fa-secondary-opacity,.4)}.fa-swap-opacity.fa-sharp-duotone:after,.fa-swap-opacity .fa-sharp-duotone:after,.fa-swap-opacity.fasdr:after,.fa-swap-opacity .fasdr:after{opacity:var(--fa-primary-opacity,1)}.fa-li.fa-sharp-duotone,.fa-li.fasdr,.fa-stack-1x.fa-sharp-duotone,.fa-stack-1x.fasdr,.fa-stack-2x.fa-sharp-duotone,.fa-stack-2x.fasdr{position:absolute} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/sharp-duotone-solid.css b/public/vendor/fontawesome/css/sharp-duotone-solid.css deleted file mode 100644 index 275b3ef..0000000 --- a/public/vendor/fontawesome/css/sharp-duotone-solid.css +++ /dev/null @@ -1,71 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-sharp-duotone: "Font Awesome 7 Sharp Duotone"; - --fa-font-sharp-duotone-solid: normal 900 1em/1 var(--fa-family-sharp-duotone); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-sharp-duotone: var(--fa-family-sharp-duotone); -} - -@font-face { - font-family: "Font Awesome 7 Sharp Duotone"; - font-style: normal; - font-weight: 900; - font-display: block; - src: url("../webfonts/fa-sharp-duotone-solid-900.woff2"); -} -.fasds { - --fa-family: var(--fa-family-sharp-duotone); - --fa-style: 900; - position: relative; - letter-spacing: normal; -} - -.fa-sharp-duotone { - --fa-family: var(--fa-family-sharp-duotone); - position: relative; - letter-spacing: normal; -} - -.fa-solid { - --fa-style: 900; -} - -.fasds::before, -.fa-sharp-duotone::before { - position: absolute; - color: var(--fa-primary-color, currentColor); - opacity: var(--fa-primary-opacity, 1); -} - -.fasds::after, -.fa-sharp-duotone::after { - color: var(--fa-secondary-color, currentColor); - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .fasds::before, -.fa-swap-opacity .fa-sharp-duotone::before, -.fa-swap-opacity.fasds::before, -.fa-swap-opacity.fa-sharp-duotone::before { - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .fasds::after, -.fa-swap-opacity .fa-sharp-duotone::after, -.fa-swap-opacity.fasds::after, -.fa-swap-opacity.fa-sharp-duotone::after { - opacity: var(--fa-primary-opacity, 1); -} - -.fa-li.fasds, -.fa-li.fa-sharp-duotone, -.fa-stack-1x.fasds, -.fa-stack-1x.fa-sharp-duotone, -.fa-stack-2x.fasds, -.fa-stack-2x.fa-sharp-duotone { - position: absolute; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/sharp-duotone-solid.min.css b/public/vendor/fontawesome/css/sharp-duotone-solid.min.css deleted file mode 100644 index 70869db..0000000 --- a/public/vendor/fontawesome/css/sharp-duotone-solid.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-sharp-duotone:"Font Awesome 7 Sharp Duotone";--fa-font-sharp-duotone-solid:normal 900 1em/1 var(--fa-family-sharp-duotone);--fa-style-family-sharp-duotone:var(--fa-family-sharp-duotone)}@font-face{font-family:"Font Awesome 7 Sharp Duotone";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-sharp-duotone-solid-900.woff2)}.fasds{--fa-style:900}.fa-sharp-duotone,.fasds{--fa-family:var(--fa-family-sharp-duotone);position:relative;letter-spacing:normal}.fa-solid{--fa-style:900}.fa-sharp-duotone:before,.fasds:before{position:absolute;color:var(--fa-primary-color,currentColor);opacity:var(--fa-primary-opacity,1)}.fa-sharp-duotone:after,.fasds:after{color:var(--fa-secondary-color,currentColor)}.fa-sharp-duotone:after,.fa-swap-opacity.fa-sharp-duotone:before,.fa-swap-opacity .fa-sharp-duotone:before,.fa-swap-opacity.fasds:before,.fa-swap-opacity .fasds:before,.fasds:after{opacity:var(--fa-secondary-opacity,.4)}.fa-swap-opacity.fa-sharp-duotone:after,.fa-swap-opacity .fa-sharp-duotone:after,.fa-swap-opacity.fasds:after,.fa-swap-opacity .fasds:after{opacity:var(--fa-primary-opacity,1)}.fa-li.fa-sharp-duotone,.fa-li.fasds,.fa-stack-1x.fa-sharp-duotone,.fa-stack-1x.fasds,.fa-stack-2x.fa-sharp-duotone,.fa-stack-2x.fasds{position:absolute} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/sharp-duotone-thin.css b/public/vendor/fontawesome/css/sharp-duotone-thin.css deleted file mode 100644 index e065b37..0000000 --- a/public/vendor/fontawesome/css/sharp-duotone-thin.css +++ /dev/null @@ -1,71 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-sharp-duotone: "Font Awesome 7 Sharp Duotone"; - --fa-font-sharp-duotone-thin: normal 100 1em/1 var(--fa-family-sharp-duotone); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-sharp-duotone: var(--fa-family-sharp-duotone); -} - -@font-face { - font-family: "Font Awesome 7 Sharp Duotone"; - font-style: normal; - font-weight: 100; - font-display: block; - src: url("../webfonts/fa-sharp-duotone-thin-100.woff2"); -} -.fasdt { - --fa-family: var(--fa-family-sharp-duotone); - --fa-style: 100; - position: relative; - letter-spacing: normal; -} - -.fa-sharp-duotone { - --fa-family: var(--fa-family-sharp-duotone); - position: relative; - letter-spacing: normal; -} - -.fa-thin { - --fa-style: 100; -} - -.fasdt::before, -.fa-sharp-duotone::before { - position: absolute; - color: var(--fa-primary-color, currentColor); - opacity: var(--fa-primary-opacity, 1); -} - -.fasdt::after, -.fa-sharp-duotone::after { - color: var(--fa-secondary-color, currentColor); - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .fasdt::before, -.fa-swap-opacity .fa-sharp-duotone::before, -.fa-swap-opacity.fasdt::before, -.fa-swap-opacity.fa-sharp-duotone::before { - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .fasdt::after, -.fa-swap-opacity .fa-sharp-duotone::after, -.fa-swap-opacity.fasdt::after, -.fa-swap-opacity.fa-sharp-duotone::after { - opacity: var(--fa-primary-opacity, 1); -} - -.fa-li.fasdt, -.fa-li.fa-sharp-duotone, -.fa-stack-1x.fasdt, -.fa-stack-1x.fa-sharp-duotone, -.fa-stack-2x.fasdt, -.fa-stack-2x.fa-sharp-duotone { - position: absolute; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/sharp-duotone-thin.min.css b/public/vendor/fontawesome/css/sharp-duotone-thin.min.css deleted file mode 100644 index 63cee2d..0000000 --- a/public/vendor/fontawesome/css/sharp-duotone-thin.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-sharp-duotone:"Font Awesome 7 Sharp Duotone";--fa-font-sharp-duotone-thin:normal 100 1em/1 var(--fa-family-sharp-duotone);--fa-style-family-sharp-duotone:var(--fa-family-sharp-duotone)}@font-face{font-family:"Font Awesome 7 Sharp Duotone";font-style:normal;font-weight:100;font-display:block;src:url(../webfonts/fa-sharp-duotone-thin-100.woff2)}.fasdt{--fa-style:100}.fa-sharp-duotone,.fasdt{--fa-family:var(--fa-family-sharp-duotone);position:relative;letter-spacing:normal}.fa-thin{--fa-style:100}.fa-sharp-duotone:before,.fasdt:before{position:absolute;color:var(--fa-primary-color,currentColor);opacity:var(--fa-primary-opacity,1)}.fa-sharp-duotone:after,.fasdt:after{color:var(--fa-secondary-color,currentColor)}.fa-sharp-duotone:after,.fa-swap-opacity.fa-sharp-duotone:before,.fa-swap-opacity .fa-sharp-duotone:before,.fa-swap-opacity.fasdt:before,.fa-swap-opacity .fasdt:before,.fasdt:after{opacity:var(--fa-secondary-opacity,.4)}.fa-swap-opacity.fa-sharp-duotone:after,.fa-swap-opacity .fa-sharp-duotone:after,.fa-swap-opacity.fasdt:after,.fa-swap-opacity .fasdt:after{opacity:var(--fa-primary-opacity,1)}.fa-li.fa-sharp-duotone,.fa-li.fasdt,.fa-stack-1x.fa-sharp-duotone,.fa-stack-1x.fasdt,.fa-stack-2x.fa-sharp-duotone,.fa-stack-2x.fasdt{position:absolute} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/sharp-light.css b/public/vendor/fontawesome/css/sharp-light.css deleted file mode 100644 index 653b43d..0000000 --- a/public/vendor/fontawesome/css/sharp-light.css +++ /dev/null @@ -1,31 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-sharp: "Font Awesome 7 Sharp"; - --fa-font-sharp-light: normal 300 1em/1 var(--fa-family-sharp); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-sharp: var(--fa-family-sharp); -} - -@font-face { - font-family: "Font Awesome 7 Sharp"; - font-style: normal; - font-weight: 300; - font-display: block; - src: url("../webfonts/fa-sharp-light-300.woff2"); -} -.fasl { - --fa-family: var(--fa-family-sharp); - --fa-style: 300; -} - -.fa-sharp { - --fa-family: var(--fa-family-sharp); -} - -.fa-light { - --fa-style: 300; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/sharp-light.min.css b/public/vendor/fontawesome/css/sharp-light.min.css deleted file mode 100644 index 050980a..0000000 --- a/public/vendor/fontawesome/css/sharp-light.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-sharp:"Font Awesome 7 Sharp";--fa-font-sharp-light:normal 300 1em/1 var(--fa-family-sharp);--fa-style-family-sharp:var(--fa-family-sharp)}@font-face{font-family:"Font Awesome 7 Sharp";font-style:normal;font-weight:300;font-display:block;src:url(../webfonts/fa-sharp-light-300.woff2)}.fasl{--fa-style:300}.fa-sharp,.fasl{--fa-family:var(--fa-family-sharp)}.fa-light{--fa-style:300} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/sharp-regular.css b/public/vendor/fontawesome/css/sharp-regular.css deleted file mode 100644 index edb8764..0000000 --- a/public/vendor/fontawesome/css/sharp-regular.css +++ /dev/null @@ -1,31 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-sharp: "Font Awesome 7 Sharp"; - --fa-font-sharp-regular: normal 400 1em/1 var(--fa-family-sharp); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-sharp: var(--fa-family-sharp); -} - -@font-face { - font-family: "Font Awesome 7 Sharp"; - font-style: normal; - font-weight: 400; - font-display: block; - src: url("../webfonts/fa-sharp-regular-400.woff2"); -} -.fasr { - --fa-family: var(--fa-family-sharp); - --fa-style: 400; -} - -.fa-sharp { - --fa-family: var(--fa-family-sharp); -} - -.fa-regular { - --fa-style: 400; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/sharp-regular.min.css b/public/vendor/fontawesome/css/sharp-regular.min.css deleted file mode 100644 index 824913f..0000000 --- a/public/vendor/fontawesome/css/sharp-regular.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-sharp:"Font Awesome 7 Sharp";--fa-font-sharp-regular:normal 400 1em/1 var(--fa-family-sharp);--fa-style-family-sharp:var(--fa-family-sharp)}@font-face{font-family:"Font Awesome 7 Sharp";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-sharp-regular-400.woff2)}.fasr{--fa-style:400}.fa-sharp,.fasr{--fa-family:var(--fa-family-sharp)}.fa-regular{--fa-style:400} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/sharp-solid.css b/public/vendor/fontawesome/css/sharp-solid.css deleted file mode 100644 index b9edc26..0000000 --- a/public/vendor/fontawesome/css/sharp-solid.css +++ /dev/null @@ -1,31 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-sharp: "Font Awesome 7 Sharp"; - --fa-font-sharp-solid: normal 900 1em/1 var(--fa-family-sharp); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-sharp: var(--fa-family-sharp); -} - -@font-face { - font-family: "Font Awesome 7 Sharp"; - font-style: normal; - font-weight: 900; - font-display: block; - src: url("../webfonts/fa-sharp-solid-900.woff2"); -} -.fass { - --fa-family: var(--fa-family-sharp); - --fa-style: 900; -} - -.fa-sharp { - --fa-family: var(--fa-family-sharp); -} - -.fa-solid { - --fa-style: 900; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/sharp-solid.min.css b/public/vendor/fontawesome/css/sharp-solid.min.css deleted file mode 100644 index 9f308c1..0000000 --- a/public/vendor/fontawesome/css/sharp-solid.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-sharp:"Font Awesome 7 Sharp";--fa-font-sharp-solid:normal 900 1em/1 var(--fa-family-sharp);--fa-style-family-sharp:var(--fa-family-sharp)}@font-face{font-family:"Font Awesome 7 Sharp";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-sharp-solid-900.woff2)}.fass{--fa-style:900}.fa-sharp,.fass{--fa-family:var(--fa-family-sharp)}.fa-solid{--fa-style:900} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/sharp-thin.css b/public/vendor/fontawesome/css/sharp-thin.css deleted file mode 100644 index 341b4b8..0000000 --- a/public/vendor/fontawesome/css/sharp-thin.css +++ /dev/null @@ -1,31 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-sharp: "Font Awesome 7 Sharp"; - --fa-font-sharp-thin: normal 100 1em/1 var(--fa-family-sharp); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-sharp: var(--fa-family-sharp); -} - -@font-face { - font-family: "Font Awesome 7 Sharp"; - font-style: normal; - font-weight: 100; - font-display: block; - src: url("../webfonts/fa-sharp-thin-100.woff2"); -} -.fast { - --fa-family: var(--fa-family-sharp); - --fa-style: 100; -} - -.fa-sharp { - --fa-family: var(--fa-family-sharp); -} - -.fa-thin { - --fa-style: 100; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/sharp-thin.min.css b/public/vendor/fontawesome/css/sharp-thin.min.css deleted file mode 100644 index 4b179c9..0000000 --- a/public/vendor/fontawesome/css/sharp-thin.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-sharp:"Font Awesome 7 Sharp";--fa-font-sharp-thin:normal 100 1em/1 var(--fa-family-sharp);--fa-style-family-sharp:var(--fa-family-sharp)}@font-face{font-family:"Font Awesome 7 Sharp";font-style:normal;font-weight:100;font-display:block;src:url(../webfonts/fa-sharp-thin-100.woff2)}.fast{--fa-style:100}.fa-sharp,.fast{--fa-family:var(--fa-family-sharp)}.fa-thin{--fa-style:100} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/slab-press-regular.css b/public/vendor/fontawesome/css/slab-press-regular.css deleted file mode 100644 index fa0ceb0..0000000 --- a/public/vendor/fontawesome/css/slab-press-regular.css +++ /dev/null @@ -1,31 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-slab-press: "Font Awesome 7 Slab Press"; - --fa-font-slab-press-regular: normal 400 1em/1 var(--fa-family-slab-press); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-slab-press: var(--fa-family-slab-press); -} - -@font-face { - font-family: "Font Awesome 7 Slab Press"; - font-style: normal; - font-weight: 400; - font-display: block; - src: url("../webfonts/fa-slab-press-regular-400.woff2"); -} -.faslpr { - --fa-family: var(--fa-family-slab-press); - --fa-style: 400; -} - -.fa-slab-press { - --fa-family: var(--fa-family-slab-press); -} - -.fa-regular { - --fa-style: 400; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/slab-press-regular.min.css b/public/vendor/fontawesome/css/slab-press-regular.min.css deleted file mode 100644 index 13a2616..0000000 --- a/public/vendor/fontawesome/css/slab-press-regular.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-slab-press:"Font Awesome 7 Slab Press";--fa-font-slab-press-regular:normal 400 1em/1 var(--fa-family-slab-press);--fa-style-family-slab-press:var(--fa-family-slab-press)}@font-face{font-family:"Font Awesome 7 Slab Press";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-slab-press-regular-400.woff2)}.faslpr{--fa-style:400}.fa-slab-press,.faslpr{--fa-family:var(--fa-family-slab-press)}.fa-regular{--fa-style:400} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/slab-regular.css b/public/vendor/fontawesome/css/slab-regular.css deleted file mode 100644 index 8086f62..0000000 --- a/public/vendor/fontawesome/css/slab-regular.css +++ /dev/null @@ -1,31 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-slab: "Font Awesome 7 Slab"; - --fa-font-slab-regular: normal 400 1em/1 var(--fa-family-slab); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-slab: var(--fa-family-slab); -} - -@font-face { - font-family: "Font Awesome 7 Slab"; - font-style: normal; - font-weight: 400; - font-display: block; - src: url("../webfonts/fa-slab-regular-400.woff2"); -} -.faslr { - --fa-family: var(--fa-family-slab); - --fa-style: 400; -} - -.fa-slab { - --fa-family: var(--fa-family-slab); -} - -.fa-regular { - --fa-style: 400; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/slab-regular.min.css b/public/vendor/fontawesome/css/slab-regular.min.css deleted file mode 100644 index e5bd374..0000000 --- a/public/vendor/fontawesome/css/slab-regular.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-slab:"Font Awesome 7 Slab";--fa-font-slab-regular:normal 400 1em/1 var(--fa-family-slab);--fa-style-family-slab:var(--fa-family-slab)}@font-face{font-family:"Font Awesome 7 Slab";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-slab-regular-400.woff2)}.faslr{--fa-style:400}.fa-slab,.faslr{--fa-family:var(--fa-family-slab)}.fa-regular{--fa-style:400} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/solid.css b/public/vendor/fontawesome/css/solid.css deleted file mode 100644 index b948558..0000000 --- a/public/vendor/fontawesome/css/solid.css +++ /dev/null @@ -1,31 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-classic: "Font Awesome 7 Pro"; - --fa-font-solid: normal 900 1em/1 var(--fa-family-classic); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-classic: var(--fa-family-classic); -} - -@font-face { - font-family: "Font Awesome 7 Pro"; - font-style: normal; - font-weight: 900; - font-display: block; - src: url("../webfonts/fa-solid-900.woff2"); -} -.fas { - --fa-family: var(--fa-family-classic); - --fa-style: 900; -} - -.fa-classic { - --fa-family: var(--fa-family-classic); -} - -.fa-solid { - --fa-style: 900; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/solid.min.css b/public/vendor/fontawesome/css/solid.min.css deleted file mode 100644 index 34ce1b1..0000000 --- a/public/vendor/fontawesome/css/solid.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-classic:"Font Awesome 7 Pro";--fa-font-solid:normal 900 1em/1 var(--fa-family-classic);--fa-style-family-classic:var(--fa-family-classic)}@font-face{font-family:"Font Awesome 7 Pro";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.woff2)}.fas{--fa-style:900}.fa-classic,.fas{--fa-family:var(--fa-family-classic)}.fa-solid{--fa-style:900} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/svg-with-js.css b/public/vendor/fontawesome/css/svg-with-js.css deleted file mode 100644 index 9591903..0000000 --- a/public/vendor/fontawesome/css/svg-with-js.css +++ /dev/null @@ -1,556 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-font-solid: normal 900 1em/1 "Font Awesome 7 Pro"; - --fa-font-regular: normal 400 1em/1 "Font Awesome 7 Pro"; - --fa-font-light: normal 300 1em/1 "Font Awesome 7 Pro"; - --fa-font-thin: normal 100 1em/1 "Font Awesome 7 Pro"; - --fa-font-duotone: normal 900 1em/1 "Font Awesome 7 Duotone"; - --fa-font-duotone-regular: normal 400 1em/1 "Font Awesome 7 Duotone"; - --fa-font-duotone-light: normal 300 1em/1 "Font Awesome 7 Duotone"; - --fa-font-duotone-thin: normal 100 1em/1 "Font Awesome 7 Duotone"; - --fa-font-brands: normal 400 1em/1 "Font Awesome 7 Brands"; - --fa-font-sharp-solid: normal 900 1em/1 "Font Awesome 7 Sharp"; - --fa-font-sharp-regular: normal 400 1em/1 "Font Awesome 7 Sharp"; - --fa-font-sharp-light: normal 300 1em/1 "Font Awesome 7 Sharp"; - --fa-font-sharp-thin: normal 100 1em/1 "Font Awesome 7 Sharp"; - --fa-font-sharp-duotone-solid: normal 900 1em/1 "Font Awesome 7 Sharp Duotone"; - --fa-font-sharp-duotone-regular: normal 400 1em/1 "Font Awesome 7 Sharp Duotone"; - --fa-font-sharp-duotone-light: normal 300 1em/1 "Font Awesome 7 Sharp Duotone"; - --fa-font-sharp-duotone-thin: normal 100 1em/1 "Font Awesome 7 Sharp Duotone"; - --fa-font-slab-regular: normal 400 1em/1 "Font Awesome 7 Slab"; - --fa-font-slab-press-regular: normal 400 1em/1 "Font Awesome 7 Slab Press"; - --fa-font-whiteboard-semibold: normal 600 1em/1 "Font Awesome 7 Whiteboard"; - --fa-font-thumbprint-light: normal 300 1em/1 "Font Awesome 7 Thumbprint"; - --fa-font-notdog-solid: normal 900 1em/1 "Font Awesome 7 Notdog"; - --fa-font-notdog-duo-solid: normal 900 1em/1 "Font Awesome 7 Notdog Duo"; - --fa-font-etch-solid: normal 900 1em/1 "Font Awesome 7 Etch"; - --fa-font-jelly-regular: normal 400 1em/1 "Font Awesome 7 Jelly"; - --fa-font-jelly-fill-regular: normal 400 1em/1 "Font Awesome 7 Jelly Fill"; - --fa-font-jelly-duo-regular: normal 400 1em/1 "Font Awesome 7 Jelly Duo"; - --fa-font-chisel-regular: normal 400 1em/1 "Font Awesome 7 Chisel"; - --fa-font-utility-semibold: normal 600 1em/1 "Font Awesome 7 Utility"; - --fa-font-utility-duo-semibold: normal 600 1em/1 "Font Awesome 7 Utility Duo"; - --fa-font-utility-fill-semibold: normal 600 1em/1 "Font Awesome 7 Utility Fill"; -} - -.svg-inline--fa { - box-sizing: content-box; - display: var(--fa-display, inline-block); - height: 1em; - overflow: visible; - vertical-align: -0.125em; - width: var(--fa-width, 1.25em); -} -.svg-inline--fa.fa-2xs { - vertical-align: 0.1em; -} -.svg-inline--fa.fa-xs { - vertical-align: 0em; -} -.svg-inline--fa.fa-sm { - vertical-align: -0.0714285714em; -} -.svg-inline--fa.fa-lg { - vertical-align: -0.2em; -} -.svg-inline--fa.fa-xl { - vertical-align: -0.25em; -} -.svg-inline--fa.fa-2xl { - vertical-align: -0.3125em; -} -.svg-inline--fa.fa-pull-left, -.svg-inline--fa .fa-pull-start { - float: inline-start; - margin-inline-end: var(--fa-pull-margin, 0.3em); -} -.svg-inline--fa.fa-pull-right, -.svg-inline--fa .fa-pull-end { - float: inline-end; - margin-inline-start: var(--fa-pull-margin, 0.3em); -} -.svg-inline--fa.fa-li { - width: var(--fa-li-width, 2em); - inset-inline-start: calc(-1 * var(--fa-li-width, 2em)); - inset-block-start: 0.25em; /* syncing vertical alignment with Web Font rendering */ -} - -.fa-layers-counter, .fa-layers-text { - display: inline-block; - position: absolute; - text-align: center; -} - -.fa-layers { - display: inline-block; - height: 1em; - position: relative; - text-align: center; - vertical-align: -0.125em; - width: var(--fa-width, 1.25em); -} -.fa-layers .svg-inline--fa { - inset: 0; - margin: auto; - position: absolute; - transform-origin: center center; -} - -.fa-layers-text { - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - transform-origin: center center; -} - -.fa-layers-counter { - background-color: var(--fa-counter-background-color, #ff253a); - border-radius: var(--fa-counter-border-radius, 1em); - box-sizing: border-box; - color: var(--fa-inverse, #fff); - line-height: var(--fa-counter-line-height, 1); - max-width: var(--fa-counter-max-width, 5em); - min-width: var(--fa-counter-min-width, 1.5em); - overflow: hidden; - padding: var(--fa-counter-padding, 0.25em 0.5em); - right: var(--fa-right, 0); - text-overflow: ellipsis; - top: var(--fa-top, 0); - transform: scale(var(--fa-counter-scale, 0.25)); - transform-origin: top right; -} - -.fa-layers-bottom-right { - bottom: var(--fa-bottom, 0); - right: var(--fa-right, 0); - top: auto; - transform: scale(var(--fa-layers-scale, 0.25)); - transform-origin: bottom right; -} - -.fa-layers-bottom-left { - bottom: var(--fa-bottom, 0); - left: var(--fa-left, 0); - right: auto; - top: auto; - transform: scale(var(--fa-layers-scale, 0.25)); - transform-origin: bottom left; -} - -.fa-layers-top-right { - top: var(--fa-top, 0); - right: var(--fa-right, 0); - transform: scale(var(--fa-layers-scale, 0.25)); - transform-origin: top right; -} - -.fa-layers-top-left { - left: var(--fa-left, 0); - right: auto; - top: var(--fa-top, 0); - transform: scale(var(--fa-layers-scale, 0.25)); - transform-origin: top left; -} - -.fa-1x { - font-size: 1em; -} - -.fa-2x { - font-size: 2em; -} - -.fa-3x { - font-size: 3em; -} - -.fa-4x { - font-size: 4em; -} - -.fa-5x { - font-size: 5em; -} - -.fa-6x { - font-size: 6em; -} - -.fa-7x { - font-size: 7em; -} - -.fa-8x { - font-size: 8em; -} - -.fa-9x { - font-size: 9em; -} - -.fa-10x { - font-size: 10em; -} - -.fa-2xs { - font-size: calc(10 / 16 * 1em); /* converts a 10px size into an em-based value that's relative to the scale's 16px base */ - line-height: calc(1 / 10 * 1em); /* sets the line-height of the icon back to that of it's parent */ - vertical-align: calc((6 / 10 - 0.375) * 1em); /* vertically centers the icon taking into account the surrounding text's descender */ -} - -.fa-xs { - font-size: calc(12 / 16 * 1em); /* converts a 12px size into an em-based value that's relative to the scale's 16px base */ - line-height: calc(1 / 12 * 1em); /* sets the line-height of the icon back to that of it's parent */ - vertical-align: calc((6 / 12 - 0.375) * 1em); /* vertically centers the icon taking into account the surrounding text's descender */ -} - -.fa-sm { - font-size: calc(14 / 16 * 1em); /* converts a 14px size into an em-based value that's relative to the scale's 16px base */ - line-height: calc(1 / 14 * 1em); /* sets the line-height of the icon back to that of it's parent */ - vertical-align: calc((6 / 14 - 0.375) * 1em); /* vertically centers the icon taking into account the surrounding text's descender */ -} - -.fa-lg { - font-size: calc(20 / 16 * 1em); /* converts a 20px size into an em-based value that's relative to the scale's 16px base */ - line-height: calc(1 / 20 * 1em); /* sets the line-height of the icon back to that of it's parent */ - vertical-align: calc((6 / 20 - 0.375) * 1em); /* vertically centers the icon taking into account the surrounding text's descender */ -} - -.fa-xl { - font-size: calc(24 / 16 * 1em); /* converts a 24px size into an em-based value that's relative to the scale's 16px base */ - line-height: calc(1 / 24 * 1em); /* sets the line-height of the icon back to that of it's parent */ - vertical-align: calc((6 / 24 - 0.375) * 1em); /* vertically centers the icon taking into account the surrounding text's descender */ -} - -.fa-2xl { - font-size: calc(32 / 16 * 1em); /* converts a 32px size into an em-based value that's relative to the scale's 16px base */ - line-height: calc(1 / 32 * 1em); /* sets the line-height of the icon back to that of it's parent */ - vertical-align: calc((6 / 32 - 0.375) * 1em); /* vertically centers the icon taking into account the surrounding text's descender */ -} - -.fa-width-auto { - --fa-width: auto; -} - -.fa-fw, -.fa-width-fixed { - --fa-width: 1.25em; -} - -.fa-ul { - list-style-type: none; - margin-inline-start: var(--fa-li-margin, 2.5em); - padding-inline-start: 0; -} -.fa-ul > li { - position: relative; -} - -.fa-li { - inset-inline-start: calc(-1 * var(--fa-li-width, 2em)); - position: absolute; - text-align: center; - width: var(--fa-li-width, 2em); - line-height: inherit; -} - -/* Heads Up: Bordered Icons will not be supported in the future! - - This feature will be deprecated in the next major release of Font Awesome (v8)! - - You may continue to use it in this version *v7), but it will not be supported in Font Awesome v8. -*/ -/* Notes: -* --@{v.$css-prefix}-border-width = 1/16 by default (to render as ~1px based on a 16px default font-size) -* --@{v.$css-prefix}-border-padding = - ** 3/16 for vertical padding (to give ~2px of vertical whitespace around an icon considering it's vertical alignment) - ** 4/16 for horizontal padding (to give ~4px of horizontal whitespace around an icon) -*/ -.fa-border { - border-color: var(--fa-border-color, #eee); - border-radius: var(--fa-border-radius, 0.1em); - border-style: var(--fa-border-style, solid); - border-width: var(--fa-border-width, 0.0625em); - box-sizing: var(--fa-border-box-sizing, content-box); - padding: var(--fa-border-padding, 0.1875em 0.25em); -} - -.fa-pull-left, -.fa-pull-start { - float: inline-start; - margin-inline-end: var(--fa-pull-margin, 0.3em); -} - -.fa-pull-right, -.fa-pull-end { - float: inline-end; - margin-inline-start: var(--fa-pull-margin, 0.3em); -} - -.fa-beat { - animation-name: fa-beat; - animation-delay: var(--fa-animation-delay, 0s); - animation-direction: var(--fa-animation-direction, normal); - animation-duration: var(--fa-animation-duration, 1s); - animation-iteration-count: var(--fa-animation-iteration-count, infinite); - animation-timing-function: var(--fa-animation-timing, ease-in-out); -} - -.fa-bounce { - animation-name: fa-bounce; - animation-delay: var(--fa-animation-delay, 0s); - animation-direction: var(--fa-animation-direction, normal); - animation-duration: var(--fa-animation-duration, 1s); - animation-iteration-count: var(--fa-animation-iteration-count, infinite); - animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.28, 0.84, 0.42, 1)); -} - -.fa-fade { - animation-name: fa-fade; - animation-delay: var(--fa-animation-delay, 0s); - animation-direction: var(--fa-animation-direction, normal); - animation-duration: var(--fa-animation-duration, 1s); - animation-iteration-count: var(--fa-animation-iteration-count, infinite); - animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1)); -} - -.fa-beat-fade { - animation-name: fa-beat-fade; - animation-delay: var(--fa-animation-delay, 0s); - animation-direction: var(--fa-animation-direction, normal); - animation-duration: var(--fa-animation-duration, 1s); - animation-iteration-count: var(--fa-animation-iteration-count, infinite); - animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1)); -} - -.fa-flip { - animation-name: fa-flip; - animation-delay: var(--fa-animation-delay, 0s); - animation-direction: var(--fa-animation-direction, normal); - animation-duration: var(--fa-animation-duration, 1s); - animation-iteration-count: var(--fa-animation-iteration-count, infinite); - animation-timing-function: var(--fa-animation-timing, ease-in-out); -} - -.fa-shake { - animation-name: fa-shake; - animation-delay: var(--fa-animation-delay, 0s); - animation-direction: var(--fa-animation-direction, normal); - animation-duration: var(--fa-animation-duration, 1s); - animation-iteration-count: var(--fa-animation-iteration-count, infinite); - animation-timing-function: var(--fa-animation-timing, linear); -} - -.fa-spin { - animation-name: fa-spin; - animation-delay: var(--fa-animation-delay, 0s); - animation-direction: var(--fa-animation-direction, normal); - animation-duration: var(--fa-animation-duration, 2s); - animation-iteration-count: var(--fa-animation-iteration-count, infinite); - animation-timing-function: var(--fa-animation-timing, linear); -} - -.fa-spin-reverse { - --fa-animation-direction: reverse; -} - -.fa-pulse, -.fa-spin-pulse { - animation-name: fa-spin; - animation-direction: var(--fa-animation-direction, normal); - animation-duration: var(--fa-animation-duration, 1s); - animation-iteration-count: var(--fa-animation-iteration-count, infinite); - animation-timing-function: var(--fa-animation-timing, steps(8)); -} - -@media (prefers-reduced-motion: reduce) { - .fa-beat, - .fa-bounce, - .fa-fade, - .fa-beat-fade, - .fa-flip, - .fa-pulse, - .fa-shake, - .fa-spin, - .fa-spin-pulse { - animation: none !important; - transition: none !important; - } -} -@keyframes fa-beat { - 0%, 90% { - transform: scale(1); - } - 45% { - transform: scale(var(--fa-beat-scale, 1.25)); - } -} -@keyframes fa-bounce { - 0% { - transform: scale(1, 1) translateY(0); - } - 10% { - transform: scale(var(--fa-bounce-start-scale-x, 1.1), var(--fa-bounce-start-scale-y, 0.9)) translateY(0); - } - 30% { - transform: scale(var(--fa-bounce-jump-scale-x, 0.9), var(--fa-bounce-jump-scale-y, 1.1)) translateY(var(--fa-bounce-height, -0.5em)); - } - 50% { - transform: scale(var(--fa-bounce-land-scale-x, 1.05), var(--fa-bounce-land-scale-y, 0.95)) translateY(0); - } - 57% { - transform: scale(1, 1) translateY(var(--fa-bounce-rebound, -0.125em)); - } - 64% { - transform: scale(1, 1) translateY(0); - } - 100% { - transform: scale(1, 1) translateY(0); - } -} -@keyframes fa-fade { - 50% { - opacity: var(--fa-fade-opacity, 0.4); - } -} -@keyframes fa-beat-fade { - 0%, 100% { - opacity: var(--fa-beat-fade-opacity, 0.4); - transform: scale(1); - } - 50% { - opacity: 1; - transform: scale(var(--fa-beat-fade-scale, 1.125)); - } -} -@keyframes fa-flip { - 50% { - transform: rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), var(--fa-flip-angle, -180deg)); - } -} -@keyframes fa-shake { - 0% { - transform: rotate(-15deg); - } - 4% { - transform: rotate(15deg); - } - 8%, 24% { - transform: rotate(-18deg); - } - 12%, 28% { - transform: rotate(18deg); - } - 16% { - transform: rotate(-22deg); - } - 20% { - transform: rotate(22deg); - } - 32% { - transform: rotate(-12deg); - } - 36% { - transform: rotate(12deg); - } - 40%, 100% { - transform: rotate(0deg); - } -} -@keyframes fa-spin { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } -} -.fa-rotate-90 { - transform: rotate(90deg); -} - -.fa-rotate-180 { - transform: rotate(180deg); -} - -.fa-rotate-270 { - transform: rotate(270deg); -} - -.fa-flip-horizontal { - transform: scale(-1, 1); -} - -.fa-flip-vertical { - transform: scale(1, -1); -} - -.fa-flip-both, -.fa-flip-horizontal.fa-flip-vertical { - transform: scale(-1, -1); -} - -.fa-rotate-by { - transform: rotate(var(--fa-rotate-angle, 0)); -} - -.svg-inline--fa .fa-primary { - fill: var(--fa-primary-color, currentColor); - opacity: var(--fa-primary-opacity, 1); -} - -.svg-inline--fa .fa-secondary { - fill: var(--fa-secondary-color, currentColor); - opacity: var(--fa-secondary-opacity, 0.4); -} - -.svg-inline--fa.fa-swap-opacity .fa-primary { - opacity: var(--fa-secondary-opacity, 0.4); -} - -.svg-inline--fa.fa-swap-opacity .fa-secondary { - opacity: var(--fa-primary-opacity, 1); -} - -.svg-inline--fa mask .fa-primary, -.svg-inline--fa mask .fa-secondary { - fill: black; -} - -.svg-inline--fa.fa-inverse { - fill: var(--fa-inverse, #fff); -} - -.fa-stack { - display: inline-block; - height: 2em; - line-height: 2em; - position: relative; - vertical-align: middle; - width: 2.5em; -} - -.fa-inverse { - color: var(--fa-inverse, #fff); -} - -.svg-inline--fa.fa-stack-1x { - --fa-width: 1.25em; - height: 1em; - width: var(--fa-width); -} -.svg-inline--fa.fa-stack-2x { - --fa-width: 2.5em; - height: 2em; - width: var(--fa-width); -} - -.fa-stack-1x, -.fa-stack-2x { - inset: 0; - margin: auto; - position: absolute; - z-index: var(--fa-stack-z-index, auto); -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/svg-with-js.min.css b/public/vendor/fontawesome/css/svg-with-js.min.css deleted file mode 100644 index c8cc184..0000000 --- a/public/vendor/fontawesome/css/svg-with-js.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-font-solid:normal 900 1em/1 "Font Awesome 7 Pro";--fa-font-regular:normal 400 1em/1 "Font Awesome 7 Pro";--fa-font-light:normal 300 1em/1 "Font Awesome 7 Pro";--fa-font-thin:normal 100 1em/1 "Font Awesome 7 Pro";--fa-font-duotone:normal 900 1em/1 "Font Awesome 7 Duotone";--fa-font-duotone-regular:normal 400 1em/1 "Font Awesome 7 Duotone";--fa-font-duotone-light:normal 300 1em/1 "Font Awesome 7 Duotone";--fa-font-duotone-thin:normal 100 1em/1 "Font Awesome 7 Duotone";--fa-font-brands:normal 400 1em/1 "Font Awesome 7 Brands";--fa-font-sharp-solid:normal 900 1em/1 "Font Awesome 7 Sharp";--fa-font-sharp-regular:normal 400 1em/1 "Font Awesome 7 Sharp";--fa-font-sharp-light:normal 300 1em/1 "Font Awesome 7 Sharp";--fa-font-sharp-thin:normal 100 1em/1 "Font Awesome 7 Sharp";--fa-font-sharp-duotone-solid:normal 900 1em/1 "Font Awesome 7 Sharp Duotone";--fa-font-sharp-duotone-regular:normal 400 1em/1 "Font Awesome 7 Sharp Duotone";--fa-font-sharp-duotone-light:normal 300 1em/1 "Font Awesome 7 Sharp Duotone";--fa-font-sharp-duotone-thin:normal 100 1em/1 "Font Awesome 7 Sharp Duotone";--fa-font-slab-regular:normal 400 1em/1 "Font Awesome 7 Slab";--fa-font-slab-press-regular:normal 400 1em/1 "Font Awesome 7 Slab Press";--fa-font-whiteboard-semibold:normal 600 1em/1 "Font Awesome 7 Whiteboard";--fa-font-thumbprint-light:normal 300 1em/1 "Font Awesome 7 Thumbprint";--fa-font-notdog-solid:normal 900 1em/1 "Font Awesome 7 Notdog";--fa-font-notdog-duo-solid:normal 900 1em/1 "Font Awesome 7 Notdog Duo";--fa-font-etch-solid:normal 900 1em/1 "Font Awesome 7 Etch";--fa-font-jelly-regular:normal 400 1em/1 "Font Awesome 7 Jelly";--fa-font-jelly-fill-regular:normal 400 1em/1 "Font Awesome 7 Jelly Fill";--fa-font-jelly-duo-regular:normal 400 1em/1 "Font Awesome 7 Jelly Duo";--fa-font-chisel-regular:normal 400 1em/1 "Font Awesome 7 Chisel";--fa-font-utility-semibold:normal 600 1em/1 "Font Awesome 7 Utility";--fa-font-utility-duo-semibold:normal 600 1em/1 "Font Awesome 7 Utility Duo";--fa-font-utility-fill-semibold:normal 600 1em/1 "Font Awesome 7 Utility Fill"}.svg-inline--fa{box-sizing:content-box;display:var(--fa-display,inline-block);height:1em;overflow:visible;vertical-align:-.125em;width:var(--fa-width,1.25em)}.svg-inline--fa.fa-2xs{vertical-align:.1em}.svg-inline--fa.fa-xs{vertical-align:0}.svg-inline--fa.fa-sm{vertical-align:-.0714285714em}.svg-inline--fa.fa-lg{vertical-align:-.2em}.svg-inline--fa.fa-xl{vertical-align:-.25em}.svg-inline--fa.fa-2xl{vertical-align:-.3125em}.svg-inline--fa.fa-pull-left,.svg-inline--fa .fa-pull-start{float:inline-start;margin-inline-end:var(--fa-pull-margin,.3em)}.svg-inline--fa .fa-pull-end,.svg-inline--fa.fa-pull-right{float:inline-end;margin-inline-start:var(--fa-pull-margin,.3em)}.svg-inline--fa.fa-li{width:var(--fa-li-width,2em);inset-inline-start:calc(var(--fa-li-width, 2em)*-1);inset-block-start:.25em}.fa-layers-counter,.fa-layers-text{display:inline-block;position:absolute;text-align:center}.fa-layers{display:inline-block;height:1em;position:relative;text-align:center;vertical-align:-.125em;width:var(--fa-width,1.25em)}.fa-layers .svg-inline--fa{inset:0;margin:auto;position:absolute;transform-origin:center center}.fa-layers-text{left:50%;top:50%;transform:translate(-50%,-50%);transform-origin:center center}.fa-layers-counter{background-color:var(--fa-counter-background-color,#ff253a);border-radius:var(--fa-counter-border-radius,1em);box-sizing:border-box;color:var(--fa-inverse,#fff);line-height:var(--fa-counter-line-height,1);max-width:var(--fa-counter-max-width,5em);min-width:var(--fa-counter-min-width,1.5em);overflow:hidden;padding:var(--fa-counter-padding,.25em .5em);right:var(--fa-right,0);text-overflow:ellipsis;top:var(--fa-top,0);transform:scale(var(--fa-counter-scale,.25));transform-origin:top right}.fa-layers-bottom-right{bottom:var(--fa-bottom,0);right:var(--fa-right,0);top:auto;transform:scale(var(--fa-layers-scale,.25));transform-origin:bottom right}.fa-layers-bottom-left{bottom:var(--fa-bottom,0);left:var(--fa-left,0);right:auto;top:auto;transform:scale(var(--fa-layers-scale,.25));transform-origin:bottom left}.fa-layers-top-right{top:var(--fa-top,0);right:var(--fa-right,0);transform:scale(var(--fa-layers-scale,.25));transform-origin:top right}.fa-layers-top-left{left:var(--fa-left,0);right:auto;top:var(--fa-top,0);transform:scale(var(--fa-layers-scale,.25));transform-origin:top left}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-2xs{font-size:.625em;line-height:.1em;vertical-align:.225em}.fa-xs{font-size:.75em;line-height:.08333em;vertical-align:.125em}.fa-sm{font-size:.875em;line-height:.07143em;vertical-align:.05357em}.fa-lg{font-size:1.25em;line-height:.05em;vertical-align:-.075em}.fa-xl{font-size:1.5em;line-height:.04167em;vertical-align:-.125em}.fa-2xl{font-size:2em;line-height:.03125em;vertical-align:-.1875em}.fa-width-auto{--fa-width:auto}.fa-fw,.fa-width-fixed{--fa-width:1.25em}.fa-ul{list-style-type:none;margin-inline-start:var(--fa-li-margin,2.5em);padding-inline-start:0}.fa-ul>li{position:relative}.fa-li{inset-inline-start:calc(var(--fa-li-width, 2em)*-1);position:absolute;text-align:center;width:var(--fa-li-width,2em);line-height:inherit}.fa-border{border-radius:var(--fa-border-radius,.1em);border:var(--fa-border-width,.0625em) var(--fa-border-style,solid) var(--fa-border-color,#eee);box-sizing:var(--fa-border-box-sizing,content-box);padding:var(--fa-border-padding,.1875em .25em)}.fa-pull-left,.fa-pull-start{float:inline-start;margin-inline-end:var(--fa-pull-margin,.3em)}.fa-pull-end,.fa-pull-right{float:inline-end;margin-inline-start:var(--fa-pull-margin,.3em)}.fa-beat{animation-name:fa-beat;animation-delay:var(--fa-animation-delay,0s);animation-direction:var(--fa-animation-direction,normal);animation-duration:var(--fa-animation-duration,1s);animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-bounce{animation-name:fa-bounce;animation-delay:var(--fa-animation-delay,0s);animation-direction:var(--fa-animation-direction,normal);animation-duration:var(--fa-animation-duration,1s);animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1))}.fa-fade{animation-name:fa-fade;animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-beat-fade,.fa-fade{animation-delay:var(--fa-animation-delay,0s);animation-direction:var(--fa-animation-direction,normal);animation-duration:var(--fa-animation-duration,1s)}.fa-beat-fade{animation-name:fa-beat-fade;animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-flip{animation-name:fa-flip;animation-delay:var(--fa-animation-delay,0s);animation-direction:var(--fa-animation-direction,normal);animation-duration:var(--fa-animation-duration,1s);animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-shake{animation-name:fa-shake;animation-duration:var(--fa-animation-duration,1s);animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,linear)}.fa-shake,.fa-spin{animation-delay:var(--fa-animation-delay,0s);animation-direction:var(--fa-animation-direction,normal)}.fa-spin{animation-name:fa-spin;animation-duration:var(--fa-animation-duration,2s);animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,linear)}.fa-spin-reverse{--fa-animation-direction:reverse}.fa-pulse,.fa-spin-pulse{animation-name:fa-spin;animation-direction:var(--fa-animation-direction,normal);animation-duration:var(--fa-animation-duration,1s);animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,steps(8))}@media (prefers-reduced-motion:reduce){.fa-beat,.fa-beat-fade,.fa-bounce,.fa-fade,.fa-flip,.fa-pulse,.fa-shake,.fa-spin,.fa-spin-pulse{animation:none!important;transition:none!important}}@keyframes fa-beat{0%,90%{transform:scale(1)}45%{transform:scale(var(--fa-beat-scale,1.25))}}@keyframes fa-bounce{0%{transform:scale(1) translateY(0)}10%{transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em))}64%{transform:scale(1) translateY(0)}to{transform:scale(1) translateY(0)}}@keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);transform:scale(1)}50%{opacity:1;transform:scale(var(--fa-beat-fade-scale,1.125))}}@keyframes fa-flip{50%{transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@keyframes fa-shake{0%{transform:rotate(-15deg)}4%{transform:rotate(15deg)}8%,24%{transform:rotate(-18deg)}12%,28%{transform:rotate(18deg)}16%{transform:rotate(-22deg)}20%{transform:rotate(22deg)}32%{transform:rotate(-12deg)}36%{transform:rotate(12deg)}40%,to{transform:rotate(0deg)}}@keyframes fa-spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}.fa-rotate-90{transform:rotate(90deg)}.fa-rotate-180{transform:rotate(180deg)}.fa-rotate-270{transform:rotate(270deg)}.fa-flip-horizontal{transform:scaleX(-1)}.fa-flip-vertical{transform:scaleY(-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{transform:scale(-1)}.fa-rotate-by{transform:rotate(var(--fa-rotate-angle,0))}.svg-inline--fa .fa-primary{fill:var(--fa-primary-color,currentColor);opacity:var(--fa-primary-opacity,1)}.svg-inline--fa .fa-secondary{fill:var(--fa-secondary-color,currentColor)}.svg-inline--fa .fa-secondary,.svg-inline--fa.fa-swap-opacity .fa-primary{opacity:var(--fa-secondary-opacity,.4)}.svg-inline--fa.fa-swap-opacity .fa-secondary{opacity:var(--fa-primary-opacity,1)}.svg-inline--fa mask .fa-primary,.svg-inline--fa mask .fa-secondary{fill:#000}.svg-inline--fa.fa-inverse{fill:var(--fa-inverse,#fff)}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-inverse{color:var(--fa-inverse,#fff)}.svg-inline--fa.fa-stack-1x{--fa-width:1.25em;height:1em;width:var(--fa-width)}.svg-inline--fa.fa-stack-2x{--fa-width:2.5em;height:2em;width:var(--fa-width)}.fa-stack-1x,.fa-stack-2x{inset:0;margin:auto;position:absolute;z-index:var(--fa-stack-z-index,auto)} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/svg.css b/public/vendor/fontawesome/css/svg.css deleted file mode 100644 index 0e4005c..0000000 --- a/public/vendor/fontawesome/css/svg.css +++ /dev/null @@ -1,182 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-font-solid: normal 900 1em/1 "Font Awesome 7 Pro"; - --fa-font-regular: normal 400 1em/1 "Font Awesome 7 Pro"; - --fa-font-light: normal 300 1em/1 "Font Awesome 7 Pro"; - --fa-font-thin: normal 100 1em/1 "Font Awesome 7 Pro"; - --fa-font-duotone: normal 900 1em/1 "Font Awesome 7 Duotone"; - --fa-font-duotone-regular: normal 400 1em/1 "Font Awesome 7 Duotone"; - --fa-font-duotone-light: normal 300 1em/1 "Font Awesome 7 Duotone"; - --fa-font-duotone-thin: normal 100 1em/1 "Font Awesome 7 Duotone"; - --fa-font-brands: normal 400 1em/1 "Font Awesome 7 Brands"; - --fa-font-sharp-solid: normal 900 1em/1 "Font Awesome 7 Sharp"; - --fa-font-sharp-regular: normal 400 1em/1 "Font Awesome 7 Sharp"; - --fa-font-sharp-light: normal 300 1em/1 "Font Awesome 7 Sharp"; - --fa-font-sharp-thin: normal 100 1em/1 "Font Awesome 7 Sharp"; - --fa-font-sharp-duotone-solid: normal 900 1em/1 "Font Awesome 7 Sharp Duotone"; - --fa-font-sharp-duotone-regular: normal 400 1em/1 "Font Awesome 7 Sharp Duotone"; - --fa-font-sharp-duotone-light: normal 300 1em/1 "Font Awesome 7 Sharp Duotone"; - --fa-font-sharp-duotone-thin: normal 100 1em/1 "Font Awesome 7 Sharp Duotone"; - --fa-font-slab-regular: normal 400 1em/1 "Font Awesome 7 Slab"; - --fa-font-slab-press-regular: normal 400 1em/1 "Font Awesome 7 Slab Press"; - --fa-font-whiteboard-semibold: normal 600 1em/1 "Font Awesome 7 Whiteboard"; - --fa-font-thumbprint-light: normal 300 1em/1 "Font Awesome 7 Thumbprint"; - --fa-font-notdog-solid: normal 900 1em/1 "Font Awesome 7 Notdog"; - --fa-font-notdog-duo-solid: normal 900 1em/1 "Font Awesome 7 Notdog Duo"; - --fa-font-etch-solid: normal 900 1em/1 "Font Awesome 7 Etch"; - --fa-font-jelly-regular: normal 400 1em/1 "Font Awesome 7 Jelly"; - --fa-font-jelly-fill-regular: normal 400 1em/1 "Font Awesome 7 Jelly Fill"; - --fa-font-jelly-duo-regular: normal 400 1em/1 "Font Awesome 7 Jelly Duo"; - --fa-font-chisel-regular: normal 400 1em/1 "Font Awesome 7 Chisel"; - --fa-font-utility-semibold: normal 600 1em/1 "Font Awesome 7 Utility"; - --fa-font-utility-duo-semibold: normal 600 1em/1 "Font Awesome 7 Utility Duo"; - --fa-font-utility-fill-semibold: normal 600 1em/1 "Font Awesome 7 Utility Fill"; -} - -.svg-inline--fa { - box-sizing: content-box; - display: var(--fa-display, inline-block); - height: 1em; - overflow: visible; - vertical-align: -0.125em; - width: var(--fa-width, 1.25em); -} -.svg-inline--fa.fa-2xs { - vertical-align: 0.1em; -} -.svg-inline--fa.fa-xs { - vertical-align: 0em; -} -.svg-inline--fa.fa-sm { - vertical-align: -0.0714285714em; -} -.svg-inline--fa.fa-lg { - vertical-align: -0.2em; -} -.svg-inline--fa.fa-xl { - vertical-align: -0.25em; -} -.svg-inline--fa.fa-2xl { - vertical-align: -0.3125em; -} -.svg-inline--fa.fa-li { - width: var(--fa-li-width, 2em); - inset-inline-start: calc(-1 * var(--fa-li-width, 2em)); - inset-block-start: 0.25em; /* syncing vertical alignment with Web Font rendering */ -} - -.fa-layers-counter, .fa-layers-text { - display: inline-block; - position: absolute; - text-align: center; -} - -.fa-layers { - display: inline-block; - height: 1em; - position: relative; - text-align: center; - vertical-align: -0.125em; - width: var(--fa-width, 1.25em); -} -.fa-layers .svg-inline--fa { - inset: 0; - margin: auto; - position: absolute; - transform-origin: center center; -} - -.fa-layers-text { - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - transform-origin: center center; -} - -.fa-layers-counter { - background-color: var(--fa-counter-background-color, #ff253a); - border-radius: var(--fa-counter-border-radius, 1em); - box-sizing: border-box; - color: var(--fa-inverse, #fff); - line-height: var(--fa-counter-line-height, 1); - max-width: var(--fa-counter-max-width, 5em); - min-width: var(--fa-counter-min-width, 1.5em); - overflow: hidden; - padding: var(--fa-counter-padding, 0.25em 0.5em); - right: var(--fa-right, 0); - text-overflow: ellipsis; - top: var(--fa-top, 0); - transform: scale(var(--fa-counter-scale, 0.25)); - transform-origin: top right; -} - -.fa-layers-bottom-right { - bottom: var(--fa-bottom, 0); - right: var(--fa-right, 0); - top: auto; - transform: scale(var(--fa-layers-scale, 0.25)); - transform-origin: bottom right; -} - -.fa-layers-bottom-left { - bottom: var(--fa-bottom, 0); - left: var(--fa-left, 0); - right: auto; - top: auto; - transform: scale(var(--fa-layers-scale, 0.25)); - transform-origin: bottom left; -} - -.fa-layers-top-right { - top: var(--fa-top, 0); - right: var(--fa-right, 0); - transform: scale(var(--fa-layers-scale, 0.25)); - transform-origin: top right; -} - -.fa-layers-top-left { - left: var(--fa-left, 0); - right: auto; - top: var(--fa-top, 0); - transform: scale(var(--fa-layers-scale, 0.25)); - transform-origin: top left; -} - -.svg-inline--fa .fa-primary { - fill: var(--fa-primary-color, currentColor); - opacity: var(--fa-primary-opacity, 1); -} - -.svg-inline--fa .fa-secondary { - fill: var(--fa-secondary-color, currentColor); - opacity: var(--fa-secondary-opacity, 0.4); -} - -.svg-inline--fa.fa-swap-opacity .fa-primary { - opacity: var(--fa-secondary-opacity, 0.4); -} - -.svg-inline--fa.fa-swap-opacity .fa-secondary { - opacity: var(--fa-primary-opacity, 1); -} - -.svg-inline--fa mask .fa-primary, -.svg-inline--fa mask .fa-secondary { - fill: black; -} - -.svg-inline--fa.fa-inverse { - fill: var(--fa-inverse, #fff); -} - -.fa-stack-1x, -.fa-stack-2x { - inset: 0; - margin: auto; - position: absolute; - z-index: var(--fa-stack-z-index, auto); -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/svg.min.css b/public/vendor/fontawesome/css/svg.min.css deleted file mode 100644 index 7cd5a5b..0000000 --- a/public/vendor/fontawesome/css/svg.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-font-solid:normal 900 1em/1 "Font Awesome 7 Pro";--fa-font-regular:normal 400 1em/1 "Font Awesome 7 Pro";--fa-font-light:normal 300 1em/1 "Font Awesome 7 Pro";--fa-font-thin:normal 100 1em/1 "Font Awesome 7 Pro";--fa-font-duotone:normal 900 1em/1 "Font Awesome 7 Duotone";--fa-font-duotone-regular:normal 400 1em/1 "Font Awesome 7 Duotone";--fa-font-duotone-light:normal 300 1em/1 "Font Awesome 7 Duotone";--fa-font-duotone-thin:normal 100 1em/1 "Font Awesome 7 Duotone";--fa-font-brands:normal 400 1em/1 "Font Awesome 7 Brands";--fa-font-sharp-solid:normal 900 1em/1 "Font Awesome 7 Sharp";--fa-font-sharp-regular:normal 400 1em/1 "Font Awesome 7 Sharp";--fa-font-sharp-light:normal 300 1em/1 "Font Awesome 7 Sharp";--fa-font-sharp-thin:normal 100 1em/1 "Font Awesome 7 Sharp";--fa-font-sharp-duotone-solid:normal 900 1em/1 "Font Awesome 7 Sharp Duotone";--fa-font-sharp-duotone-regular:normal 400 1em/1 "Font Awesome 7 Sharp Duotone";--fa-font-sharp-duotone-light:normal 300 1em/1 "Font Awesome 7 Sharp Duotone";--fa-font-sharp-duotone-thin:normal 100 1em/1 "Font Awesome 7 Sharp Duotone";--fa-font-slab-regular:normal 400 1em/1 "Font Awesome 7 Slab";--fa-font-slab-press-regular:normal 400 1em/1 "Font Awesome 7 Slab Press";--fa-font-whiteboard-semibold:normal 600 1em/1 "Font Awesome 7 Whiteboard";--fa-font-thumbprint-light:normal 300 1em/1 "Font Awesome 7 Thumbprint";--fa-font-notdog-solid:normal 900 1em/1 "Font Awesome 7 Notdog";--fa-font-notdog-duo-solid:normal 900 1em/1 "Font Awesome 7 Notdog Duo";--fa-font-etch-solid:normal 900 1em/1 "Font Awesome 7 Etch";--fa-font-jelly-regular:normal 400 1em/1 "Font Awesome 7 Jelly";--fa-font-jelly-fill-regular:normal 400 1em/1 "Font Awesome 7 Jelly Fill";--fa-font-jelly-duo-regular:normal 400 1em/1 "Font Awesome 7 Jelly Duo";--fa-font-chisel-regular:normal 400 1em/1 "Font Awesome 7 Chisel";--fa-font-utility-semibold:normal 600 1em/1 "Font Awesome 7 Utility";--fa-font-utility-duo-semibold:normal 600 1em/1 "Font Awesome 7 Utility Duo";--fa-font-utility-fill-semibold:normal 600 1em/1 "Font Awesome 7 Utility Fill"}.svg-inline--fa{box-sizing:content-box;display:var(--fa-display,inline-block);height:1em;overflow:visible;vertical-align:-.125em;width:var(--fa-width,1.25em)}.svg-inline--fa.fa-2xs{vertical-align:.1em}.svg-inline--fa.fa-xs{vertical-align:0}.svg-inline--fa.fa-sm{vertical-align:-.0714285714em}.svg-inline--fa.fa-lg{vertical-align:-.2em}.svg-inline--fa.fa-xl{vertical-align:-.25em}.svg-inline--fa.fa-2xl{vertical-align:-.3125em}.svg-inline--fa.fa-li{width:var(--fa-li-width,2em);inset-inline-start:calc(var(--fa-li-width, 2em)*-1);inset-block-start:.25em}.fa-layers-counter,.fa-layers-text{display:inline-block;position:absolute;text-align:center}.fa-layers{display:inline-block;height:1em;position:relative;text-align:center;vertical-align:-.125em;width:var(--fa-width,1.25em)}.fa-layers .svg-inline--fa{inset:0;margin:auto;position:absolute;transform-origin:center center}.fa-layers-text{left:50%;top:50%;transform:translate(-50%,-50%);transform-origin:center center}.fa-layers-counter{background-color:var(--fa-counter-background-color,#ff253a);border-radius:var(--fa-counter-border-radius,1em);box-sizing:border-box;color:var(--fa-inverse,#fff);line-height:var(--fa-counter-line-height,1);max-width:var(--fa-counter-max-width,5em);min-width:var(--fa-counter-min-width,1.5em);overflow:hidden;padding:var(--fa-counter-padding,.25em .5em);right:var(--fa-right,0);text-overflow:ellipsis;top:var(--fa-top,0);transform:scale(var(--fa-counter-scale,.25));transform-origin:top right}.fa-layers-bottom-right{bottom:var(--fa-bottom,0);right:var(--fa-right,0);top:auto;transform:scale(var(--fa-layers-scale,.25));transform-origin:bottom right}.fa-layers-bottom-left{bottom:var(--fa-bottom,0);left:var(--fa-left,0);right:auto;top:auto;transform:scale(var(--fa-layers-scale,.25));transform-origin:bottom left}.fa-layers-top-right{top:var(--fa-top,0);right:var(--fa-right,0);transform:scale(var(--fa-layers-scale,.25));transform-origin:top right}.fa-layers-top-left{left:var(--fa-left,0);right:auto;top:var(--fa-top,0);transform:scale(var(--fa-layers-scale,.25));transform-origin:top left}.svg-inline--fa .fa-primary{fill:var(--fa-primary-color,currentColor);opacity:var(--fa-primary-opacity,1)}.svg-inline--fa .fa-secondary{fill:var(--fa-secondary-color,currentColor)}.svg-inline--fa .fa-secondary,.svg-inline--fa.fa-swap-opacity .fa-primary{opacity:var(--fa-secondary-opacity,.4)}.svg-inline--fa.fa-swap-opacity .fa-secondary{opacity:var(--fa-primary-opacity,1)}.svg-inline--fa mask .fa-primary,.svg-inline--fa mask .fa-secondary{fill:#000}.svg-inline--fa.fa-inverse{fill:var(--fa-inverse,#fff)}.fa-stack-1x,.fa-stack-2x{inset:0;margin:auto;position:absolute;z-index:var(--fa-stack-z-index,auto)} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/thin.css b/public/vendor/fontawesome/css/thin.css deleted file mode 100644 index cedfa25..0000000 --- a/public/vendor/fontawesome/css/thin.css +++ /dev/null @@ -1,31 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-classic: "Font Awesome 7 Pro"; - --fa-font-thin: normal 100 1em/1 var(--fa-family-classic); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-classic: var(--fa-family-classic); -} - -@font-face { - font-family: "Font Awesome 7 Pro"; - font-style: normal; - font-weight: 100; - font-display: block; - src: url("../webfonts/fa-thin-100.woff2"); -} -.fat { - --fa-family: var(--fa-family-classic); - --fa-style: 100; -} - -.fa-classic { - --fa-family: var(--fa-family-classic); -} - -.fa-thin { - --fa-style: 100; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/thin.min.css b/public/vendor/fontawesome/css/thin.min.css deleted file mode 100644 index c31ff6f..0000000 --- a/public/vendor/fontawesome/css/thin.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-classic:"Font Awesome 7 Pro";--fa-font-thin:normal 100 1em/1 var(--fa-family-classic);--fa-style-family-classic:var(--fa-family-classic)}@font-face{font-family:"Font Awesome 7 Pro";font-style:normal;font-weight:100;font-display:block;src:url(../webfonts/fa-thin-100.woff2)}.fat{--fa-style:100}.fa-classic,.fat{--fa-family:var(--fa-family-classic)}.fa-thin{--fa-style:100} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/thumbprint-light.css b/public/vendor/fontawesome/css/thumbprint-light.css deleted file mode 100644 index 97cb358..0000000 --- a/public/vendor/fontawesome/css/thumbprint-light.css +++ /dev/null @@ -1,71 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-thumbprint: "Font Awesome 7 Thumbprint"; - --fa-font-thumbprint-light: normal 300 1em/1 var(--fa-family-thumbprint); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-thumbprint: var(--fa-family-thumbprint); -} - -@font-face { - font-family: "Font Awesome 7 Thumbprint"; - font-style: normal; - font-weight: 300; - font-display: block; - src: url("../webfonts/fa-thumbprint-light-300.woff2"); -} -.fatl { - --fa-family: var(--fa-family-thumbprint); - --fa-style: 300; - position: relative; - letter-spacing: normal; -} - -.fa-thumbprint { - --fa-family: var(--fa-family-thumbprint); - position: relative; - letter-spacing: normal; -} - -.fa-light { - --fa-style: 300; -} - -.fatl::before, -.fa-thumbprint::before { - position: absolute; - color: var(--fa-primary-color, currentColor); - opacity: var(--fa-primary-opacity, 1); -} - -.fatl::after, -.fa-thumbprint::after { - color: var(--fa-secondary-color, currentColor); - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .fatl::before, -.fa-swap-opacity .fa-thumbprint::before, -.fa-swap-opacity.fatl::before, -.fa-swap-opacity.fa-thumbprint::before { - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .fatl::after, -.fa-swap-opacity .fa-thumbprint::after, -.fa-swap-opacity.fatl::after, -.fa-swap-opacity.fa-thumbprint::after { - opacity: var(--fa-primary-opacity, 1); -} - -.fa-li.fatl, -.fa-li.fa-thumbprint, -.fa-stack-1x.fatl, -.fa-stack-1x.fa-thumbprint, -.fa-stack-2x.fatl, -.fa-stack-2x.fa-thumbprint { - position: absolute; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/thumbprint-light.min.css b/public/vendor/fontawesome/css/thumbprint-light.min.css deleted file mode 100644 index 54a95d3..0000000 --- a/public/vendor/fontawesome/css/thumbprint-light.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-thumbprint:"Font Awesome 7 Thumbprint";--fa-font-thumbprint-light:normal 300 1em/1 var(--fa-family-thumbprint);--fa-style-family-thumbprint:var(--fa-family-thumbprint)}@font-face{font-family:"Font Awesome 7 Thumbprint";font-style:normal;font-weight:300;font-display:block;src:url(../webfonts/fa-thumbprint-light-300.woff2)}.fatl{--fa-style:300}.fa-thumbprint,.fatl{--fa-family:var(--fa-family-thumbprint);position:relative;letter-spacing:normal}.fa-light{--fa-style:300}.fa-thumbprint:before,.fatl:before{position:absolute;color:var(--fa-primary-color,currentColor);opacity:var(--fa-primary-opacity,1)}.fa-thumbprint:after,.fatl:after{color:var(--fa-secondary-color,currentColor)}.fa-swap-opacity.fa-thumbprint:before,.fa-swap-opacity .fa-thumbprint:before,.fa-swap-opacity.fatl:before,.fa-swap-opacity .fatl:before,.fa-thumbprint:after,.fatl:after{opacity:var(--fa-secondary-opacity,.4)}.fa-swap-opacity.fa-thumbprint:after,.fa-swap-opacity .fa-thumbprint:after,.fa-swap-opacity.fatl:after,.fa-swap-opacity .fatl:after{opacity:var(--fa-primary-opacity,1)}.fa-li.fa-thumbprint,.fa-li.fatl,.fa-stack-1x.fa-thumbprint,.fa-stack-1x.fatl,.fa-stack-2x.fa-thumbprint,.fa-stack-2x.fatl{position:absolute} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/utility-duo-semibold.css b/public/vendor/fontawesome/css/utility-duo-semibold.css deleted file mode 100644 index f9e02fd..0000000 --- a/public/vendor/fontawesome/css/utility-duo-semibold.css +++ /dev/null @@ -1,71 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-utility-duo: "Font Awesome 7 Utility Duo"; - --fa-font-utility-duo-semibold: normal 600 1em/1 var(--fa-family-utility-duo); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-utility-duo: var(--fa-family-utility-duo); -} - -@font-face { - font-family: "Font Awesome 7 Utility Duo"; - font-style: normal; - font-weight: 600; - font-display: block; - src: url("../webfonts/fa-utility-duo-semibold-600.woff2"); -} -.faudsb { - --fa-family: var(--fa-family-utility-duo); - --fa-style: 600; - position: relative; - letter-spacing: normal; -} - -.fa-utility-duo { - --fa-family: var(--fa-family-utility-duo); - position: relative; - letter-spacing: normal; -} - -.fa-semibold { - --fa-style: 600; -} - -.faudsb::before, -.fa-utility-duo::before { - position: absolute; - color: var(--fa-primary-color, currentColor); - opacity: var(--fa-primary-opacity, 1); -} - -.faudsb::after, -.fa-utility-duo::after { - color: var(--fa-secondary-color, currentColor); - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .faudsb::before, -.fa-swap-opacity .fa-utility-duo::before, -.fa-swap-opacity.faudsb::before, -.fa-swap-opacity.fa-utility-duo::before { - opacity: var(--fa-secondary-opacity, 0.4); -} - -.fa-swap-opacity .faudsb::after, -.fa-swap-opacity .fa-utility-duo::after, -.fa-swap-opacity.faudsb::after, -.fa-swap-opacity.fa-utility-duo::after { - opacity: var(--fa-primary-opacity, 1); -} - -.fa-li.faudsb, -.fa-li.fa-utility-duo, -.fa-stack-1x.faudsb, -.fa-stack-1x.fa-utility-duo, -.fa-stack-2x.faudsb, -.fa-stack-2x.fa-utility-duo { - position: absolute; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/utility-duo-semibold.min.css b/public/vendor/fontawesome/css/utility-duo-semibold.min.css deleted file mode 100644 index 1b7cda8..0000000 --- a/public/vendor/fontawesome/css/utility-duo-semibold.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-utility-duo:"Font Awesome 7 Utility Duo";--fa-font-utility-duo-semibold:normal 600 1em/1 var(--fa-family-utility-duo);--fa-style-family-utility-duo:var(--fa-family-utility-duo)}@font-face{font-family:"Font Awesome 7 Utility Duo";font-style:normal;font-weight:600;font-display:block;src:url(../webfonts/fa-utility-duo-semibold-600.woff2)}.faudsb{--fa-style:600}.fa-utility-duo,.faudsb{--fa-family:var(--fa-family-utility-duo);position:relative;letter-spacing:normal}.fa-semibold{--fa-style:600}.fa-utility-duo:before,.faudsb:before{position:absolute;color:var(--fa-primary-color,currentColor);opacity:var(--fa-primary-opacity,1)}.fa-utility-duo:after,.faudsb:after{color:var(--fa-secondary-color,currentColor)}.fa-swap-opacity.fa-utility-duo:before,.fa-swap-opacity .fa-utility-duo:before,.fa-swap-opacity.faudsb:before,.fa-swap-opacity .faudsb:before,.fa-utility-duo:after,.faudsb:after{opacity:var(--fa-secondary-opacity,.4)}.fa-swap-opacity.fa-utility-duo:after,.fa-swap-opacity .fa-utility-duo:after,.fa-swap-opacity.faudsb:after,.fa-swap-opacity .faudsb:after{opacity:var(--fa-primary-opacity,1)}.fa-li.fa-utility-duo,.fa-li.faudsb,.fa-stack-1x.fa-utility-duo,.fa-stack-1x.faudsb,.fa-stack-2x.fa-utility-duo,.fa-stack-2x.faudsb{position:absolute} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/utility-fill-semibold.css b/public/vendor/fontawesome/css/utility-fill-semibold.css deleted file mode 100644 index 67722d9..0000000 --- a/public/vendor/fontawesome/css/utility-fill-semibold.css +++ /dev/null @@ -1,31 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-utility-fill: "Font Awesome 7 Utility Fill"; - --fa-font-utility-fill-semibold: normal 600 1em/1 var(--fa-family-utility-fill); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-utility-fill: var(--fa-family-utility-fill); -} - -@font-face { - font-family: "Font Awesome 7 Utility Fill"; - font-style: normal; - font-weight: 600; - font-display: block; - src: url("../webfonts/fa-utility-fill-semibold-600.woff2"); -} -.faufsb { - --fa-family: var(--fa-family-utility-fill); - --fa-style: 600; -} - -.fa-utility-fill { - --fa-family: var(--fa-family-utility-fill); -} - -.fa-semibold { - --fa-style: 600; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/utility-fill-semibold.min.css b/public/vendor/fontawesome/css/utility-fill-semibold.min.css deleted file mode 100644 index 5317938..0000000 --- a/public/vendor/fontawesome/css/utility-fill-semibold.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-utility-fill:"Font Awesome 7 Utility Fill";--fa-font-utility-fill-semibold:normal 600 1em/1 var(--fa-family-utility-fill);--fa-style-family-utility-fill:var(--fa-family-utility-fill)}@font-face{font-family:"Font Awesome 7 Utility Fill";font-style:normal;font-weight:600;font-display:block;src:url(../webfonts/fa-utility-fill-semibold-600.woff2)}.faufsb{--fa-style:600}.fa-utility-fill,.faufsb{--fa-family:var(--fa-family-utility-fill)}.fa-semibold{--fa-style:600} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/utility-semibold.css b/public/vendor/fontawesome/css/utility-semibold.css deleted file mode 100644 index 04ed1b8..0000000 --- a/public/vendor/fontawesome/css/utility-semibold.css +++ /dev/null @@ -1,31 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-utility: "Font Awesome 7 Utility"; - --fa-font-utility-semibold: normal 600 1em/1 var(--fa-family-utility); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-utility: var(--fa-family-utility); -} - -@font-face { - font-family: "Font Awesome 7 Utility"; - font-style: normal; - font-weight: 600; - font-display: block; - src: url("../webfonts/fa-utility-semibold-600.woff2"); -} -.fausb { - --fa-family: var(--fa-family-utility); - --fa-style: 600; -} - -.fa-utility { - --fa-family: var(--fa-family-utility); -} - -.fa-semibold { - --fa-style: 600; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/utility-semibold.min.css b/public/vendor/fontawesome/css/utility-semibold.min.css deleted file mode 100644 index 69907dd..0000000 --- a/public/vendor/fontawesome/css/utility-semibold.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-utility:"Font Awesome 7 Utility";--fa-font-utility-semibold:normal 600 1em/1 var(--fa-family-utility);--fa-style-family-utility:var(--fa-family-utility)}@font-face{font-family:"Font Awesome 7 Utility";font-style:normal;font-weight:600;font-display:block;src:url(../webfonts/fa-utility-semibold-600.woff2)}.fausb{--fa-style:600}.fa-utility,.fausb{--fa-family:var(--fa-family-utility)}.fa-semibold{--fa-style:600} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/v4-font-face.css b/public/vendor/fontawesome/css/v4-font-face.css deleted file mode 100644 index 72b0402..0000000 --- a/public/vendor/fontawesome/css/v4-font-face.css +++ /dev/null @@ -1,27 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -@font-face { - font-family: "FontAwesome"; - font-display: block; - src: url("../webfonts/fa-solid-900.woff2") format("woff2"); -} -@font-face { - font-family: "FontAwesome"; - font-display: block; - src: url("../webfonts/fa-brands-400.woff2") format("woff2"); -} -@font-face { - font-family: "FontAwesome"; - font-display: block; - src: url("../webfonts/fa-regular-400.woff2") format("woff2"); - unicode-range: U+F003, U+F006, U+F014, U+F016-F017, U+F01A-F01B, U+F01D, U+F022, U+F03E, U+F044, U+F046, U+F05C-F05D, U+F06E, U+F070, U+F087-F088, U+F08A, U+F094, U+F096-F097, U+F09D, U+F0A0, U+F0A2, U+F0A4-F0A7, U+F0C5, U+F0C7, U+F0E5-F0E6, U+F0EB, U+F0F6-F0F8, U+F10C, U+F114-F115, U+F118-F11A, U+F11C-F11D, U+F133, U+F147, U+F14E, U+F150-F152, U+F185-F186, U+F18E, U+F190-F192, U+F196, U+F1C1-F1C9, U+F1D9, U+F1DB, U+F1E3, U+F1EA, U+F1F7, U+F1F9, U+F20A, U+F247-F248, U+F24A, U+F24D, U+F255-F25B, U+F25D, U+F271-F274, U+F278, U+F27B, U+F28C, U+F28E, U+F29C, U+F2B5, U+F2B7, U+F2BA, U+F2BC, U+F2BE, U+F2C0-F2C1, U+F2C3, U+F2D0, U+F2D2, U+F2D4, U+F2DC; -} -@font-face { - font-family: "FontAwesome"; - font-display: block; - src: url("../webfonts/fa-v4compatibility.woff2") format("woff2"); - unicode-range: U+F041, U+F047, U+F065-F066, U+F07D-F07E, U+F080, U+F08B, U+F08E, U+F090, U+F09A, U+F0AC, U+F0AE, U+F0B2, U+F0D0, U+F0D6, U+F0E4, U+F0EC, U+F10A-F10B, U+F123, U+F13E, U+F148-F149, U+F14C, U+F156, U+F15E, U+F160-F161, U+F163, U+F175-F178, U+F195, U+F1F8, U+F219, U+F27A; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/v4-font-face.min.css b/public/vendor/fontawesome/css/v4-font-face.min.css deleted file mode 100644 index 680bc6f..0000000 --- a/public/vendor/fontawesome/css/v4-font-face.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-brands-400.woff2) format("woff2")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2");unicode-range:u+f003,u+f006,u+f014,u+f016-f017,u+f01a-f01b,u+f01d,u+f022,u+f03e,u+f044,u+f046,u+f05c-f05d,u+f06e,u+f070,u+f087-f088,u+f08a,u+f094,u+f096-f097,u+f09d,u+f0a0,u+f0a2,u+f0a4-f0a7,u+f0c5,u+f0c7,u+f0e5-f0e6,u+f0eb,u+f0f6-f0f8,u+f10c,u+f114-f115,u+f118-f11a,u+f11c-f11d,u+f133,u+f147,u+f14e,u+f150-f152,u+f185-f186,u+f18e,u+f190-f192,u+f196,u+f1c1-f1c9,u+f1d9,u+f1db,u+f1e3,u+f1ea,u+f1f7,u+f1f9,u+f20a,u+f247-f248,u+f24a,u+f24d,u+f255-f25b,u+f25d,u+f271-f274,u+f278,u+f27b,u+f28c,u+f28e,u+f29c,u+f2b5,u+f2b7,u+f2ba,u+f2bc,u+f2be,u+f2c0-f2c1,u+f2c3,u+f2d0,u+f2d2,u+f2d4,u+f2dc}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-v4compatibility.woff2) format("woff2");unicode-range:u+f041,u+f047,u+f065-f066,u+f07d-f07e,u+f080,u+f08b,u+f08e,u+f090,u+f09a,u+f0ac,u+f0ae,u+f0b2,u+f0d0,u+f0d6,u+f0e4,u+f0ec,u+f10a-f10b,u+f123,u+f13e,u+f148-f149,u+f14c,u+f156,u+f15e,u+f160-f161,u+f163,u+f175-f178,u+f195,u+f1f8,u+f219,u+f27a} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/v4-shims.css b/public/vendor/fontawesome/css/v4-shims.css deleted file mode 100644 index 49bd689..0000000 --- a/public/vendor/fontawesome/css/v4-shims.css +++ /dev/null @@ -1,2818 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -.fa.fa-glass { - --fa: "\f000"; -} - -.fa.fa-envelope-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-envelope-o { - --fa: "\f0e0"; -} - -.fa.fa-star-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-star-o { - --fa: "\f005"; -} - -.fa.fa-remove { - --fa: "\f00d"; -} - -.fa.fa-close { - --fa: "\f00d"; -} - -.fa.fa-gear { - --fa: "\f013"; -} - -.fa.fa-trash-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-trash-o { - --fa: "\f2ed"; -} - -.fa.fa-home { - --fa: "\f015"; -} - -.fa.fa-file-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-file-o { - --fa: "\f15b"; -} - -.fa.fa-clock-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-clock-o { - --fa: "\f017"; -} - -.fa.fa-arrow-circle-o-down { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-arrow-circle-o-down { - --fa: "\f358"; -} - -.fa.fa-arrow-circle-o-up { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-arrow-circle-o-up { - --fa: "\f35b"; -} - -.fa.fa-play-circle-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-play-circle-o { - --fa: "\f144"; -} - -.fa.fa-repeat { - --fa: "\f01e"; -} - -.fa.fa-rotate-right { - --fa: "\f01e"; -} - -.fa.fa-refresh { - --fa: "\f021"; -} - -.fa.fa-list-alt { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-list-alt { - --fa: "\f022"; -} - -.fa.fa-dedent { - --fa: "\f03b"; -} - -.fa.fa-video-camera { - --fa: "\f03d"; -} - -.fa.fa-picture-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-picture-o { - --fa: "\f03e"; -} - -.fa.fa-photo { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-photo { - --fa: "\f03e"; -} - -.fa.fa-image { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-image { - --fa: "\f03e"; -} - -.fa.fa-map-marker { - --fa: "\f3c5"; -} - -.fa.fa-pencil-square-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-pencil-square-o { - --fa: "\f044"; -} - -.fa.fa-edit { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-edit { - --fa: "\f044"; -} - -.fa.fa-share-square-o { - --fa: "\f14d"; -} - -.fa.fa-check-square-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-check-square-o { - --fa: "\f14a"; -} - -.fa.fa-arrows { - --fa: "\f0b2"; -} - -.fa.fa-times-circle-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-times-circle-o { - --fa: "\f057"; -} - -.fa.fa-check-circle-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-check-circle-o { - --fa: "\f058"; -} - -.fa.fa-mail-forward { - --fa: "\f064"; -} - -.fa.fa-expand { - --fa: "\f424"; -} - -.fa.fa-compress { - --fa: "\f422"; -} - -.fa.fa-eye { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-eye-slash { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-warning { - --fa: "\f071"; -} - -.fa.fa-calendar { - --fa: "\f073"; -} - -.fa.fa-arrows-v { - --fa: "\f338"; -} - -.fa.fa-arrows-h { - --fa: "\f337"; -} - -.fa.fa-bar-chart { - --fa: "\e0e3"; -} - -.fa.fa-bar-chart-o { - --fa: "\e0e3"; -} - -.fa.fa-twitter-square { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-twitter-square { - --fa: "\f081"; -} - -.fa.fa-facebook-square { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-facebook-square { - --fa: "\f082"; -} - -.fa.fa-gears { - --fa: "\f085"; -} - -.fa.fa-thumbs-o-up { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-thumbs-o-up { - --fa: "\f164"; -} - -.fa.fa-thumbs-o-down { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-thumbs-o-down { - --fa: "\f165"; -} - -.fa.fa-heart-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-heart-o { - --fa: "\f004"; -} - -.fa.fa-sign-out { - --fa: "\f2f5"; -} - -.fa.fa-linkedin-square { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-linkedin-square { - --fa: "\f08c"; -} - -.fa.fa-thumb-tack { - --fa: "\f08d"; -} - -.fa.fa-external-link { - --fa: "\f35d"; -} - -.fa.fa-sign-in { - --fa: "\f2f6"; -} - -.fa.fa-github-square { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-github-square { - --fa: "\f092"; -} - -.fa.fa-lemon-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-lemon-o { - --fa: "\f094"; -} - -.fa.fa-square-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-square-o { - --fa: "\f0c8"; -} - -.fa.fa-bookmark-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-bookmark-o { - --fa: "\f02e"; -} - -.fa.fa-twitter { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-facebook { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-facebook { - --fa: "\f39e"; -} - -.fa.fa-facebook-f { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-facebook-f { - --fa: "\f39e"; -} - -.fa.fa-github { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-credit-card { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-feed { - --fa: "\f09e"; -} - -.fa.fa-hdd-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-hdd-o { - --fa: "\f0a0"; -} - -.fa.fa-hand-o-right { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-hand-o-right { - --fa: "\f0a4"; -} - -.fa.fa-hand-o-left { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-hand-o-left { - --fa: "\f0a5"; -} - -.fa.fa-hand-o-up { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-hand-o-up { - --fa: "\f0a6"; -} - -.fa.fa-hand-o-down { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-hand-o-down { - --fa: "\f0a7"; -} - -.fa.fa-globe { - --fa: "\f57d"; -} - -.fa.fa-tasks { - --fa: "\f828"; -} - -.fa.fa-arrows-alt { - --fa: "\f31e"; -} - -.fa.fa-group { - --fa: "\f0c0"; -} - -.fa.fa-chain { - --fa: "\f0c1"; -} - -.fa.fa-cut { - --fa: "\f0c4"; -} - -.fa.fa-files-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-files-o { - --fa: "\f0c5"; -} - -.fa.fa-floppy-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-floppy-o { - --fa: "\f0c7"; -} - -.fa.fa-save { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-save { - --fa: "\f0c7"; -} - -.fa.fa-navicon { - --fa: "\f0c9"; -} - -.fa.fa-reorder { - --fa: "\f0c9"; -} - -.fa.fa-magic { - --fa: "\e2ca"; -} - -.fa.fa-pinterest { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-pinterest-square { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-pinterest-square { - --fa: "\f0d3"; -} - -.fa.fa-google-plus-square { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-google-plus-square { - --fa: "\f0d4"; -} - -.fa.fa-google-plus { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-google-plus { - --fa: "\f0d5"; -} - -.fa.fa-money { - --fa: "\f3d1"; -} - -.fa.fa-unsorted { - --fa: "\f0dc"; -} - -.fa.fa-sort-desc { - --fa: "\f0dd"; -} - -.fa.fa-sort-asc { - --fa: "\f0de"; -} - -.fa.fa-linkedin { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-linkedin { - --fa: "\f0e1"; -} - -.fa.fa-rotate-left { - --fa: "\f0e2"; -} - -.fa.fa-legal { - --fa: "\f0e3"; -} - -.fa.fa-tachometer { - --fa: "\f625"; -} - -.fa.fa-dashboard { - --fa: "\f625"; -} - -.fa.fa-comment-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-comment-o { - --fa: "\f075"; -} - -.fa.fa-comments-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-comments-o { - --fa: "\f086"; -} - -.fa.fa-flash { - --fa: "\f0e7"; -} - -.fa.fa-clipboard { - --fa: "\f0ea"; -} - -.fa.fa-lightbulb-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-lightbulb-o { - --fa: "\f0eb"; -} - -.fa.fa-exchange { - --fa: "\f362"; -} - -.fa.fa-cloud-download { - --fa: "\f0ed"; -} - -.fa.fa-cloud-upload { - --fa: "\f0ee"; -} - -.fa.fa-bell-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-bell-o { - --fa: "\f0f3"; -} - -.fa.fa-cutlery { - --fa: "\f2e7"; -} - -.fa.fa-file-text-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-file-text-o { - --fa: "\f15c"; -} - -.fa.fa-building-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-building-o { - --fa: "\f1ad"; -} - -.fa.fa-hospital-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-hospital-o { - --fa: "\f0f8"; -} - -.fa.fa-tablet { - --fa: "\f3fa"; -} - -.fa.fa-mobile { - --fa: "\f3cd"; -} - -.fa.fa-mobile-phone { - --fa: "\f3cd"; -} - -.fa.fa-circle-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-circle-o { - --fa: "\f111"; -} - -.fa.fa-mail-reply { - --fa: "\f3e5"; -} - -.fa.fa-github-alt { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-folder-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-folder-o { - --fa: "\f07b"; -} - -.fa.fa-folder-open-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-folder-open-o { - --fa: "\f07c"; -} - -.fa.fa-smile-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-smile-o { - --fa: "\f118"; -} - -.fa.fa-frown-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-frown-o { - --fa: "\f119"; -} - -.fa.fa-meh-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-meh-o { - --fa: "\f11a"; -} - -.fa.fa-keyboard-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-keyboard-o { - --fa: "\f11c"; -} - -.fa.fa-flag-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-flag-o { - --fa: "\f024"; -} - -.fa.fa-mail-reply-all { - --fa: "\f122"; -} - -.fa.fa-star-half-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-star-half-o { - --fa: "\f5c0"; -} - -.fa.fa-star-half-empty { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-star-half-empty { - --fa: "\f5c0"; -} - -.fa.fa-star-half-full { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-star-half-full { - --fa: "\f5c0"; -} - -.fa.fa-code-fork { - --fa: "\f126"; -} - -.fa.fa-chain-broken { - --fa: "\f127"; -} - -.fa.fa-unlink { - --fa: "\f127"; -} - -.fa.fa-calendar-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-calendar-o { - --fa: "\f133"; -} - -.fa.fa-maxcdn { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-html5 { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-css3 { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-unlock-alt { - --fa: "\f09c"; -} - -.fa.fa-minus-square-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-minus-square-o { - --fa: "\f146"; -} - -.fa.fa-level-up { - --fa: "\f3bf"; -} - -.fa.fa-level-down { - --fa: "\f3be"; -} - -.fa.fa-pencil-square { - --fa: "\f14b"; -} - -.fa.fa-external-link-square { - --fa: "\f360"; -} - -.fa.fa-compass { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-caret-square-o-down { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-caret-square-o-down { - --fa: "\f150"; -} - -.fa.fa-toggle-down { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-toggle-down { - --fa: "\f150"; -} - -.fa.fa-caret-square-o-up { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-caret-square-o-up { - --fa: "\f151"; -} - -.fa.fa-toggle-up { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-toggle-up { - --fa: "\f151"; -} - -.fa.fa-caret-square-o-right { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-caret-square-o-right { - --fa: "\f152"; -} - -.fa.fa-toggle-right { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-toggle-right { - --fa: "\f152"; -} - -.fa.fa-eur { - --fa: "\f153"; -} - -.fa.fa-euro { - --fa: "\f153"; -} - -.fa.fa-gbp { - --fa: "\f154"; -} - -.fa.fa-usd { - --fa: "\$"; -} - -.fa.fa-dollar { - --fa: "\$"; -} - -.fa.fa-inr { - --fa: "\e1bc"; -} - -.fa.fa-rupee { - --fa: "\e1bc"; -} - -.fa.fa-jpy { - --fa: "\f157"; -} - -.fa.fa-cny { - --fa: "\f157"; -} - -.fa.fa-rmb { - --fa: "\f157"; -} - -.fa.fa-yen { - --fa: "\f157"; -} - -.fa.fa-rub { - --fa: "\f158"; -} - -.fa.fa-ruble { - --fa: "\f158"; -} - -.fa.fa-rouble { - --fa: "\f158"; -} - -.fa.fa-krw { - --fa: "\f159"; -} - -.fa.fa-won { - --fa: "\f159"; -} - -.fa.fa-btc { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-bitcoin { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-bitcoin { - --fa: "\f15a"; -} - -.fa.fa-file-text { - --fa: "\f15c"; -} - -.fa.fa-sort-alpha-asc { - --fa: "\f15d"; -} - -.fa.fa-sort-alpha-desc { - --fa: "\f881"; -} - -.fa.fa-sort-amount-asc { - --fa: "\f884"; -} - -.fa.fa-sort-amount-desc { - --fa: "\f160"; -} - -.fa.fa-sort-numeric-asc { - --fa: "\f162"; -} - -.fa.fa-sort-numeric-desc { - --fa: "\f886"; -} - -.fa.fa-youtube-square { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-youtube-square { - --fa: "\f431"; -} - -.fa.fa-youtube { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-xing { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-xing-square { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-xing-square { - --fa: "\f169"; -} - -.fa.fa-youtube-play { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-youtube-play { - --fa: "\f167"; -} - -.fa.fa-dropbox { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-stack-overflow { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-instagram { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-flickr { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-adn { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-bitbucket { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-bitbucket-square { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-bitbucket-square { - --fa: "\f171"; -} - -.fa.fa-tumblr { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-tumblr-square { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-tumblr-square { - --fa: "\f174"; -} - -.fa.fa-long-arrow-down { - --fa: "\f309"; -} - -.fa.fa-long-arrow-up { - --fa: "\f30c"; -} - -.fa.fa-long-arrow-left { - --fa: "\f30a"; -} - -.fa.fa-long-arrow-right { - --fa: "\f30b"; -} - -.fa.fa-apple { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-windows { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-android { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-linux { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-dribbble { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-skype { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-foursquare { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-trello { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-gratipay { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-gittip { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-gittip { - --fa: "\f184"; -} - -.fa.fa-sun-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-sun-o { - --fa: "\f185"; -} - -.fa.fa-moon-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-moon-o { - --fa: "\f186"; -} - -.fa.fa-vk { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-weibo { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-renren { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-pagelines { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-stack-exchange { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-arrow-circle-o-right { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-arrow-circle-o-right { - --fa: "\f35a"; -} - -.fa.fa-arrow-circle-o-left { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-arrow-circle-o-left { - --fa: "\f359"; -} - -.fa.fa-caret-square-o-left { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-caret-square-o-left { - --fa: "\f191"; -} - -.fa.fa-toggle-left { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-toggle-left { - --fa: "\f191"; -} - -.fa.fa-dot-circle-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-dot-circle-o { - --fa: "\f192"; -} - -.fa.fa-vimeo-square { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-vimeo-square { - --fa: "\f194"; -} - -.fa.fa-try { - --fa: "\e2bb"; -} - -.fa.fa-turkish-lira { - --fa: "\e2bb"; -} - -.fa.fa-plus-square-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-plus-square-o { - --fa: "\f0fe"; -} - -.fa.fa-slack { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-wordpress { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-openid { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-institution { - --fa: "\f19c"; -} - -.fa.fa-bank { - --fa: "\f19c"; -} - -.fa.fa-mortar-board { - --fa: "\f19d"; -} - -.fa.fa-yahoo { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-google { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-reddit { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-reddit-square { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-reddit-square { - --fa: "\f1a2"; -} - -.fa.fa-stumbleupon-circle { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-stumbleupon { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-delicious { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-digg { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-pied-piper-pp { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-pied-piper-alt { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-drupal { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-joomla { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-behance { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-behance-square { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-behance-square { - --fa: "\f1b5"; -} - -.fa.fa-steam { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-steam-square { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-steam-square { - --fa: "\f1b7"; -} - -.fa.fa-automobile { - --fa: "\f1b9"; -} - -.fa.fa-cab { - --fa: "\f1ba"; -} - -.fa.fa-spotify { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-deviantart { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-soundcloud { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-file-pdf-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-file-pdf-o { - --fa: "\f1c1"; -} - -.fa.fa-file-word-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-file-word-o { - --fa: "\f1c2"; -} - -.fa.fa-file-excel-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-file-excel-o { - --fa: "\f1c3"; -} - -.fa.fa-file-powerpoint-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-file-powerpoint-o { - --fa: "\f1c4"; -} - -.fa.fa-file-image-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-file-image-o { - --fa: "\f1c5"; -} - -.fa.fa-file-photo-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-file-photo-o { - --fa: "\f1c5"; -} - -.fa.fa-file-picture-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-file-picture-o { - --fa: "\f1c5"; -} - -.fa.fa-file-archive-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-file-archive-o { - --fa: "\f1c6"; -} - -.fa.fa-file-zip-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-file-zip-o { - --fa: "\f1c6"; -} - -.fa.fa-file-audio-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-file-audio-o { - --fa: "\f1c7"; -} - -.fa.fa-file-sound-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-file-sound-o { - --fa: "\f1c7"; -} - -.fa.fa-file-video-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-file-video-o { - --fa: "\f1c8"; -} - -.fa.fa-file-movie-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-file-movie-o { - --fa: "\f1c8"; -} - -.fa.fa-file-code-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-file-code-o { - --fa: "\f1c9"; -} - -.fa.fa-vine { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-codepen { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-jsfiddle { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-life-bouy { - --fa: "\f1cd"; -} - -.fa.fa-life-buoy { - --fa: "\f1cd"; -} - -.fa.fa-life-saver { - --fa: "\f1cd"; -} - -.fa.fa-support { - --fa: "\f1cd"; -} - -.fa.fa-circle-o-notch { - --fa: "\f1ce"; -} - -.fa.fa-rebel { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-ra { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-ra { - --fa: "\f1d0"; -} - -.fa.fa-resistance { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-resistance { - --fa: "\f1d0"; -} - -.fa.fa-empire { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-ge { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-ge { - --fa: "\f1d1"; -} - -.fa.fa-git-square { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-git-square { - --fa: "\f1d2"; -} - -.fa.fa-git { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-hacker-news { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-y-combinator-square { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-y-combinator-square { - --fa: "\f1d4"; -} - -.fa.fa-yc-square { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-yc-square { - --fa: "\f1d4"; -} - -.fa.fa-tencent-weibo { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-qq { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-weixin { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-wechat { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-wechat { - --fa: "\f1d7"; -} - -.fa.fa-send { - --fa: "\f1d8"; -} - -.fa.fa-paper-plane-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-paper-plane-o { - --fa: "\f1d8"; -} - -.fa.fa-send-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-send-o { - --fa: "\f1d8"; -} - -.fa.fa-circle-thin { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-circle-thin { - --fa: "\f111"; -} - -.fa.fa-header { - --fa: "\f1dc"; -} - -.fa.fa-futbol-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-futbol-o { - --fa: "\f1e3"; -} - -.fa.fa-soccer-ball-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-soccer-ball-o { - --fa: "\f1e3"; -} - -.fa.fa-slideshare { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-twitch { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-yelp { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-newspaper-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-newspaper-o { - --fa: "\f1ea"; -} - -.fa.fa-paypal { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-google-wallet { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-cc-visa { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-cc-mastercard { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-cc-discover { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-cc-amex { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-cc-paypal { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-cc-stripe { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-bell-slash-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-bell-slash-o { - --fa: "\f1f6"; -} - -.fa.fa-trash { - --fa: "\f2ed"; -} - -.fa.fa-copyright { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-eyedropper { - --fa: "\f1fb"; -} - -.fa.fa-area-chart { - --fa: "\f1fe"; -} - -.fa.fa-pie-chart { - --fa: "\f200"; -} - -.fa.fa-line-chart { - --fa: "\f201"; -} - -.fa.fa-lastfm { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-lastfm-square { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-lastfm-square { - --fa: "\f203"; -} - -.fa.fa-ioxhost { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-angellist { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-cc { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-cc { - --fa: "\f20a"; -} - -.fa.fa-ils { - --fa: "\f20b"; -} - -.fa.fa-shekel { - --fa: "\f20b"; -} - -.fa.fa-sheqel { - --fa: "\f20b"; -} - -.fa.fa-buysellads { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-connectdevelop { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-dashcube { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-forumbee { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-leanpub { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-sellsy { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-shirtsinbulk { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-simplybuilt { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-skyatlas { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-diamond { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-diamond { - --fa: "\f3a5"; -} - -.fa.fa-transgender { - --fa: "\f224"; -} - -.fa.fa-intersex { - --fa: "\f224"; -} - -.fa.fa-transgender-alt { - --fa: "\f225"; -} - -.fa.fa-facebook-official { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-facebook-official { - --fa: "\f09a"; -} - -.fa.fa-pinterest-p { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-whatsapp { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-hotel { - --fa: "\f236"; -} - -.fa.fa-viacoin { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-medium { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-y-combinator { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-yc { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-yc { - --fa: "\f23b"; -} - -.fa.fa-optin-monster { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-opencart { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-expeditedssl { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-battery-4 { - --fa: "\f240"; -} - -.fa.fa-battery { - --fa: "\f240"; -} - -.fa.fa-battery-3 { - --fa: "\f241"; -} - -.fa.fa-battery-2 { - --fa: "\f242"; -} - -.fa.fa-battery-1 { - --fa: "\f243"; -} - -.fa.fa-battery-0 { - --fa: "\f244"; -} - -.fa.fa-object-group { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-object-ungroup { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-sticky-note-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-sticky-note-o { - --fa: "\f249"; -} - -.fa.fa-cc-jcb { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-cc-diners-club { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-clone { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-hourglass-o { - --fa: "\f254"; -} - -.fa.fa-hourglass-1 { - --fa: "\f251"; -} - -.fa.fa-hourglass-2 { - --fa: "\f252"; -} - -.fa.fa-hourglass-3 { - --fa: "\f253"; -} - -.fa.fa-hand-rock-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-hand-rock-o { - --fa: "\f255"; -} - -.fa.fa-hand-grab-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-hand-grab-o { - --fa: "\f255"; -} - -.fa.fa-hand-paper-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-hand-paper-o { - --fa: "\f256"; -} - -.fa.fa-hand-stop-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-hand-stop-o { - --fa: "\f256"; -} - -.fa.fa-hand-scissors-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-hand-scissors-o { - --fa: "\f257"; -} - -.fa.fa-hand-lizard-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-hand-lizard-o { - --fa: "\f258"; -} - -.fa.fa-hand-spock-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-hand-spock-o { - --fa: "\f259"; -} - -.fa.fa-hand-pointer-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-hand-pointer-o { - --fa: "\f25a"; -} - -.fa.fa-hand-peace-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-hand-peace-o { - --fa: "\f25b"; -} - -.fa.fa-registered { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-creative-commons { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-gg { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-gg-circle { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-odnoklassniki { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-odnoklassniki-square { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-odnoklassniki-square { - --fa: "\f264"; -} - -.fa.fa-get-pocket { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-wikipedia-w { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-safari { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-chrome { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-firefox { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-opera { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-internet-explorer { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-television { - --fa: "\f26c"; -} - -.fa.fa-contao { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-500px { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-amazon { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-calendar-plus-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-calendar-plus-o { - --fa: "\f271"; -} - -.fa.fa-calendar-minus-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-calendar-minus-o { - --fa: "\f272"; -} - -.fa.fa-calendar-times-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-calendar-times-o { - --fa: "\f273"; -} - -.fa.fa-calendar-check-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-calendar-check-o { - --fa: "\f274"; -} - -.fa.fa-map-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-map-o { - --fa: "\f279"; -} - -.fa.fa-commenting { - --fa: "\f4ad"; -} - -.fa.fa-commenting-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-commenting-o { - --fa: "\f4ad"; -} - -.fa.fa-houzz { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-vimeo { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-vimeo { - --fa: "\f27d"; -} - -.fa.fa-black-tie { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-fonticons { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-reddit-alien { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-edge { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-credit-card-alt { - --fa: "\f09d"; -} - -.fa.fa-codiepie { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-modx { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-fort-awesome { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-usb { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-product-hunt { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-mixcloud { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-scribd { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-pause-circle-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-pause-circle-o { - --fa: "\f28b"; -} - -.fa.fa-stop-circle-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-stop-circle-o { - --fa: "\f28d"; -} - -.fa.fa-bluetooth { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-bluetooth-b { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-gitlab { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-wpbeginner { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-wpforms { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-envira { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-wheelchair-alt { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-wheelchair-alt { - --fa: "\f368"; -} - -.fa.fa-question-circle-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-question-circle-o { - --fa: "\f059"; -} - -.fa.fa-volume-control-phone { - --fa: "\f2a0"; -} - -.fa.fa-asl-interpreting { - --fa: "\f2a3"; -} - -.fa.fa-deafness { - --fa: "\f2a4"; -} - -.fa.fa-hard-of-hearing { - --fa: "\f2a4"; -} - -.fa.fa-glide { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-glide-g { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-signing { - --fa: "\f2a7"; -} - -.fa.fa-viadeo { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-viadeo-square { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-viadeo-square { - --fa: "\f2aa"; -} - -.fa.fa-snapchat { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-snapchat-ghost { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-snapchat-ghost { - --fa: "\f2ab"; -} - -.fa.fa-snapchat-square { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-snapchat-square { - --fa: "\f2ad"; -} - -.fa.fa-pied-piper { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-first-order { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-yoast { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-themeisle { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-google-plus-official { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-google-plus-official { - --fa: "\f2b3"; -} - -.fa.fa-google-plus-circle { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-google-plus-circle { - --fa: "\f2b3"; -} - -.fa.fa-font-awesome { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-fa { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-fa { - --fa: "\f2b4"; -} - -.fa.fa-handshake-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-handshake-o { - --fa: "\f2b5"; -} - -.fa.fa-envelope-open-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-envelope-open-o { - --fa: "\f2b6"; -} - -.fa.fa-linode { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-address-book-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-address-book-o { - --fa: "\f2b9"; -} - -.fa.fa-vcard { - --fa: "\f2bb"; -} - -.fa.fa-address-card-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-address-card-o { - --fa: "\f2bb"; -} - -.fa.fa-vcard-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-vcard-o { - --fa: "\f2bb"; -} - -.fa.fa-user-circle-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-user-circle-o { - --fa: "\f2bd"; -} - -.fa.fa-user-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-user-o { - --fa: "\f007"; -} - -.fa.fa-id-badge { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-drivers-license { - --fa: "\f2c2"; -} - -.fa.fa-id-card-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-id-card-o { - --fa: "\f2c2"; -} - -.fa.fa-drivers-license-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-drivers-license-o { - --fa: "\f2c2"; -} - -.fa.fa-quora { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-free-code-camp { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-telegram { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-thermometer-4 { - --fa: "\f2c7"; -} - -.fa.fa-thermometer { - --fa: "\f2c7"; -} - -.fa.fa-thermometer-3 { - --fa: "\f2c8"; -} - -.fa.fa-thermometer-2 { - --fa: "\f2c9"; -} - -.fa.fa-thermometer-1 { - --fa: "\f2ca"; -} - -.fa.fa-thermometer-0 { - --fa: "\f2cb"; -} - -.fa.fa-bathtub { - --fa: "\f2cd"; -} - -.fa.fa-s15 { - --fa: "\f2cd"; -} - -.fa.fa-window-maximize { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-window-restore { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-times-rectangle { - --fa: "\f410"; -} - -.fa.fa-window-close-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-window-close-o { - --fa: "\f410"; -} - -.fa.fa-times-rectangle-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-times-rectangle-o { - --fa: "\f410"; -} - -.fa.fa-bandcamp { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-grav { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-etsy { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-imdb { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-ravelry { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-eercast { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-eercast { - --fa: "\f2da"; -} - -.fa.fa-snowflake-o { - font-family: "Font Awesome 7 Pro"; - font-weight: 400; -} - -.fa.fa-snowflake-o { - --fa: "\f2dc"; -} - -.fa.fa-superpowers { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-wpexplorer { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} - -.fa.fa-meetup { - font-family: "Font Awesome 7 Brands"; - font-weight: 400; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/v4-shims.min.css b/public/vendor/fontawesome/css/v4-shims.min.css deleted file mode 100644 index da4ca52..0000000 --- a/public/vendor/fontawesome/css/v4-shims.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -.fa.fa-glass{--fa:"\f000"}.fa.fa-envelope-o{--fa:"\f0e0"}.fa.fa-envelope-o,.fa.fa-star-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-star-o{--fa:"\f005"}.fa.fa-close,.fa.fa-remove{--fa:"\f00d"}.fa.fa-gear{--fa:"\f013"}.fa.fa-trash-o{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f2ed"}.fa.fa-home{--fa:"\f015"}.fa.fa-file-o{--fa:"\f15b"}.fa.fa-clock-o,.fa.fa-file-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-clock-o{--fa:"\f017"}.fa.fa-arrow-circle-o-down{--fa:"\f358"}.fa.fa-arrow-circle-o-down,.fa.fa-arrow-circle-o-up{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-arrow-circle-o-up{--fa:"\f35b"}.fa.fa-play-circle-o{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f144"}.fa.fa-repeat,.fa.fa-rotate-right{--fa:"\f01e"}.fa.fa-refresh{--fa:"\f021"}.fa.fa-list-alt{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f022"}.fa.fa-dedent{--fa:"\f03b"}.fa.fa-video-camera{--fa:"\f03d"}.fa.fa-picture-o{--fa:"\f03e"}.fa.fa-photo,.fa.fa-picture-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-photo{--fa:"\f03e"}.fa.fa-image{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f03e"}.fa.fa-map-marker{--fa:"\f3c5"}.fa.fa-pencil-square-o{--fa:"\f044"}.fa.fa-edit,.fa.fa-pencil-square-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-edit{--fa:"\f044"}.fa.fa-share-square-o{--fa:"\f14d"}.fa.fa-check-square-o{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f14a"}.fa.fa-arrows{--fa:"\f0b2"}.fa.fa-times-circle-o{--fa:"\f057"}.fa.fa-check-circle-o,.fa.fa-times-circle-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-check-circle-o{--fa:"\f058"}.fa.fa-mail-forward{--fa:"\f064"}.fa.fa-expand{--fa:"\f424"}.fa.fa-compress{--fa:"\f422"}.fa.fa-eye,.fa.fa-eye-slash{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-warning{--fa:"\f071"}.fa.fa-calendar{--fa:"\f073"}.fa.fa-arrows-v{--fa:"\f338"}.fa.fa-arrows-h{--fa:"\f337"}.fa.fa-bar-chart,.fa.fa-bar-chart-o{--fa:"\e0e3"}.fa.fa-twitter-square{--fa:"\f081"}.fa.fa-facebook-square,.fa.fa-twitter-square{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-facebook-square{--fa:"\f082"}.fa.fa-gears{--fa:"\f085"}.fa.fa-thumbs-o-up{--fa:"\f164"}.fa.fa-thumbs-o-down,.fa.fa-thumbs-o-up{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-thumbs-o-down{--fa:"\f165"}.fa.fa-heart-o{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f004"}.fa.fa-sign-out{--fa:"\f2f5"}.fa.fa-linkedin-square{font-family:"Font Awesome 7 Brands";font-weight:400;--fa:"\f08c"}.fa.fa-thumb-tack{--fa:"\f08d"}.fa.fa-external-link{--fa:"\f35d"}.fa.fa-sign-in{--fa:"\f2f6"}.fa.fa-github-square{font-family:"Font Awesome 7 Brands";font-weight:400;--fa:"\f092"}.fa.fa-lemon-o{--fa:"\f094"}.fa.fa-lemon-o,.fa.fa-square-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-square-o{--fa:"\f0c8"}.fa.fa-bookmark-o{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f02e"}.fa.fa-facebook,.fa.fa-twitter{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-facebook{--fa:"\f39e"}.fa.fa-facebook-f{--fa:"\f39e"}.fa.fa-facebook-f,.fa.fa-github{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-credit-card{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-feed{--fa:"\f09e"}.fa.fa-hdd-o{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f0a0"}.fa.fa-hand-o-right{--fa:"\f0a4"}.fa.fa-hand-o-left,.fa.fa-hand-o-right{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-hand-o-left{--fa:"\f0a5"}.fa.fa-hand-o-up{--fa:"\f0a6"}.fa.fa-hand-o-down,.fa.fa-hand-o-up{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-hand-o-down{--fa:"\f0a7"}.fa.fa-globe{--fa:"\f57d"}.fa.fa-tasks{--fa:"\f828"}.fa.fa-arrows-alt{--fa:"\f31e"}.fa.fa-group{--fa:"\f0c0"}.fa.fa-chain{--fa:"\f0c1"}.fa.fa-cut{--fa:"\f0c4"}.fa.fa-files-o{--fa:"\f0c5"}.fa.fa-files-o,.fa.fa-floppy-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-floppy-o{--fa:"\f0c7"}.fa.fa-save{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f0c7"}.fa.fa-navicon,.fa.fa-reorder{--fa:"\f0c9"}.fa.fa-magic{--fa:"\e2ca"}.fa.fa-pinterest,.fa.fa-pinterest-square{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-pinterest-square{--fa:"\f0d3"}.fa.fa-google-plus-square{--fa:"\f0d4"}.fa.fa-google-plus,.fa.fa-google-plus-square{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-google-plus{--fa:"\f0d5"}.fa.fa-money{--fa:"\f3d1"}.fa.fa-unsorted{--fa:"\f0dc"}.fa.fa-sort-desc{--fa:"\f0dd"}.fa.fa-sort-asc{--fa:"\f0de"}.fa.fa-linkedin{font-family:"Font Awesome 7 Brands";font-weight:400;--fa:"\f0e1"}.fa.fa-rotate-left{--fa:"\f0e2"}.fa.fa-legal{--fa:"\f0e3"}.fa.fa-dashboard,.fa.fa-tachometer{--fa:"\f625"}.fa.fa-comment-o{--fa:"\f075"}.fa.fa-comment-o,.fa.fa-comments-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-comments-o{--fa:"\f086"}.fa.fa-flash{--fa:"\f0e7"}.fa.fa-clipboard{--fa:"\f0ea"}.fa.fa-lightbulb-o{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f0eb"}.fa.fa-exchange{--fa:"\f362"}.fa.fa-cloud-download{--fa:"\f0ed"}.fa.fa-cloud-upload{--fa:"\f0ee"}.fa.fa-bell-o{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f0f3"}.fa.fa-cutlery{--fa:"\f2e7"}.fa.fa-file-text-o{--fa:"\f15c"}.fa.fa-building-o,.fa.fa-file-text-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-building-o{--fa:"\f1ad"}.fa.fa-hospital-o{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f0f8"}.fa.fa-tablet{--fa:"\f3fa"}.fa.fa-mobile,.fa.fa-mobile-phone{--fa:"\f3cd"}.fa.fa-circle-o{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f111"}.fa.fa-mail-reply{--fa:"\f3e5"}.fa.fa-github-alt{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-folder-o{--fa:"\f07b"}.fa.fa-folder-o,.fa.fa-folder-open-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-folder-open-o{--fa:"\f07c"}.fa.fa-smile-o{--fa:"\f118"}.fa.fa-frown-o,.fa.fa-smile-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-frown-o{--fa:"\f119"}.fa.fa-meh-o{--fa:"\f11a"}.fa.fa-keyboard-o,.fa.fa-meh-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-keyboard-o{--fa:"\f11c"}.fa.fa-flag-o{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f024"}.fa.fa-mail-reply-all{--fa:"\f122"}.fa.fa-star-half-o{--fa:"\f5c0"}.fa.fa-star-half-empty,.fa.fa-star-half-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-star-half-empty{--fa:"\f5c0"}.fa.fa-star-half-full{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f5c0"}.fa.fa-code-fork{--fa:"\f126"}.fa.fa-chain-broken,.fa.fa-unlink{--fa:"\f127"}.fa.fa-calendar-o{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f133"}.fa.fa-css3,.fa.fa-html5,.fa.fa-maxcdn{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-unlock-alt{--fa:"\f09c"}.fa.fa-minus-square-o{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f146"}.fa.fa-level-up{--fa:"\f3bf"}.fa.fa-level-down{--fa:"\f3be"}.fa.fa-pencil-square{--fa:"\f14b"}.fa.fa-external-link-square{--fa:"\f360"}.fa.fa-compass{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-caret-square-o-down{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f150"}.fa.fa-toggle-down{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f150"}.fa.fa-caret-square-o-up{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f151"}.fa.fa-toggle-up{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f151"}.fa.fa-caret-square-o-right{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f152"}.fa.fa-toggle-right{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f152"}.fa.fa-eur,.fa.fa-euro{--fa:"\f153"}.fa.fa-gbp{--fa:"\f154"}.fa.fa-dollar,.fa.fa-usd{--fa:"\$"}.fa.fa-inr,.fa.fa-rupee{--fa:"\e1bc"}.fa.fa-cny,.fa.fa-jpy,.fa.fa-rmb,.fa.fa-yen{--fa:"\f157"}.fa.fa-rouble,.fa.fa-rub,.fa.fa-ruble{--fa:"\f158"}.fa.fa-krw,.fa.fa-won{--fa:"\f159"}.fa.fa-bitcoin,.fa.fa-btc{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-bitcoin{--fa:"\f15a"}.fa.fa-file-text{--fa:"\f15c"}.fa.fa-sort-alpha-asc{--fa:"\f15d"}.fa.fa-sort-alpha-desc{--fa:"\f881"}.fa.fa-sort-amount-asc{--fa:"\f884"}.fa.fa-sort-amount-desc{--fa:"\f160"}.fa.fa-sort-numeric-asc{--fa:"\f162"}.fa.fa-sort-numeric-desc{--fa:"\f886"}.fa.fa-youtube-square{--fa:"\f431"}.fa.fa-xing,.fa.fa-xing-square,.fa.fa-youtube,.fa.fa-youtube-square{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-xing-square{--fa:"\f169"}.fa.fa-youtube-play{--fa:"\f167"}.fa.fa-adn,.fa.fa-bitbucket,.fa.fa-bitbucket-square,.fa.fa-dropbox,.fa.fa-flickr,.fa.fa-instagram,.fa.fa-stack-overflow,.fa.fa-youtube-play{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-bitbucket-square{--fa:"\f171"}.fa.fa-tumblr,.fa.fa-tumblr-square{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-tumblr-square{--fa:"\f174"}.fa.fa-long-arrow-down{--fa:"\f309"}.fa.fa-long-arrow-up{--fa:"\f30c"}.fa.fa-long-arrow-left{--fa:"\f30a"}.fa.fa-long-arrow-right{--fa:"\f30b"}.fa.fa-android,.fa.fa-apple,.fa.fa-dribbble,.fa.fa-foursquare,.fa.fa-gittip,.fa.fa-gratipay,.fa.fa-linux,.fa.fa-skype,.fa.fa-trello,.fa.fa-windows{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-gittip{--fa:"\f184"}.fa.fa-sun-o{--fa:"\f185"}.fa.fa-moon-o,.fa.fa-sun-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-moon-o{--fa:"\f186"}.fa.fa-pagelines,.fa.fa-renren,.fa.fa-stack-exchange,.fa.fa-vk,.fa.fa-weibo{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-arrow-circle-o-right{--fa:"\f35a"}.fa.fa-arrow-circle-o-left,.fa.fa-arrow-circle-o-right{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-arrow-circle-o-left{--fa:"\f359"}.fa.fa-caret-square-o-left{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f191"}.fa.fa-toggle-left{--fa:"\f191"}.fa.fa-dot-circle-o,.fa.fa-toggle-left{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-dot-circle-o{--fa:"\f192"}.fa.fa-vimeo-square{font-family:"Font Awesome 7 Brands";font-weight:400;--fa:"\f194"}.fa.fa-try,.fa.fa-turkish-lira{--fa:"\e2bb"}.fa.fa-plus-square-o{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f0fe"}.fa.fa-openid,.fa.fa-slack,.fa.fa-wordpress{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-bank,.fa.fa-institution{--fa:"\f19c"}.fa.fa-mortar-board{--fa:"\f19d"}.fa.fa-google,.fa.fa-reddit,.fa.fa-reddit-square,.fa.fa-yahoo{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-reddit-square{--fa:"\f1a2"}.fa.fa-behance,.fa.fa-behance-square,.fa.fa-delicious,.fa.fa-digg,.fa.fa-drupal,.fa.fa-joomla,.fa.fa-pied-piper-alt,.fa.fa-pied-piper-pp,.fa.fa-stumbleupon,.fa.fa-stumbleupon-circle{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-behance-square{--fa:"\f1b5"}.fa.fa-steam,.fa.fa-steam-square{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-steam-square{--fa:"\f1b7"}.fa.fa-automobile{--fa:"\f1b9"}.fa.fa-cab{--fa:"\f1ba"}.fa.fa-deviantart,.fa.fa-soundcloud,.fa.fa-spotify{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-file-pdf-o{--fa:"\f1c1"}.fa.fa-file-pdf-o,.fa.fa-file-word-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-file-word-o{--fa:"\f1c2"}.fa.fa-file-excel-o{--fa:"\f1c3"}.fa.fa-file-excel-o,.fa.fa-file-powerpoint-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-file-powerpoint-o{--fa:"\f1c4"}.fa.fa-file-image-o{--fa:"\f1c5"}.fa.fa-file-image-o,.fa.fa-file-photo-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-file-photo-o{--fa:"\f1c5"}.fa.fa-file-picture-o{--fa:"\f1c5"}.fa.fa-file-archive-o,.fa.fa-file-picture-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-file-archive-o{--fa:"\f1c6"}.fa.fa-file-zip-o{--fa:"\f1c6"}.fa.fa-file-audio-o,.fa.fa-file-zip-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-file-audio-o{--fa:"\f1c7"}.fa.fa-file-sound-o{--fa:"\f1c7"}.fa.fa-file-sound-o,.fa.fa-file-video-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-file-video-o{--fa:"\f1c8"}.fa.fa-file-movie-o{--fa:"\f1c8"}.fa.fa-file-code-o,.fa.fa-file-movie-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-file-code-o{--fa:"\f1c9"}.fa.fa-codepen,.fa.fa-jsfiddle,.fa.fa-vine{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-life-bouy,.fa.fa-life-buoy,.fa.fa-life-saver,.fa.fa-support{--fa:"\f1cd"}.fa.fa-circle-o-notch{--fa:"\f1ce"}.fa.fa-ra,.fa.fa-rebel{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-ra{--fa:"\f1d0"}.fa.fa-resistance{--fa:"\f1d0"}.fa.fa-empire,.fa.fa-ge,.fa.fa-resistance{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-ge{--fa:"\f1d1"}.fa.fa-git-square{--fa:"\f1d2"}.fa.fa-git,.fa.fa-git-square,.fa.fa-hacker-news,.fa.fa-y-combinator-square{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-y-combinator-square{--fa:"\f1d4"}.fa.fa-yc-square{--fa:"\f1d4"}.fa.fa-qq,.fa.fa-tencent-weibo,.fa.fa-wechat,.fa.fa-weixin,.fa.fa-yc-square{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-wechat{--fa:"\f1d7"}.fa.fa-send{--fa:"\f1d8"}.fa.fa-paper-plane-o{--fa:"\f1d8"}.fa.fa-paper-plane-o,.fa.fa-send-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-send-o{--fa:"\f1d8"}.fa.fa-circle-thin{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f111"}.fa.fa-header{--fa:"\f1dc"}.fa.fa-futbol-o{--fa:"\f1e3"}.fa.fa-futbol-o,.fa.fa-soccer-ball-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-soccer-ball-o{--fa:"\f1e3"}.fa.fa-slideshare,.fa.fa-twitch,.fa.fa-yelp{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-newspaper-o{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f1ea"}.fa.fa-cc-amex,.fa.fa-cc-discover,.fa.fa-cc-mastercard,.fa.fa-cc-paypal,.fa.fa-cc-stripe,.fa.fa-cc-visa,.fa.fa-google-wallet,.fa.fa-paypal{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-bell-slash-o{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f1f6"}.fa.fa-trash{--fa:"\f2ed"}.fa.fa-copyright{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-eyedropper{--fa:"\f1fb"}.fa.fa-area-chart{--fa:"\f1fe"}.fa.fa-pie-chart{--fa:"\f200"}.fa.fa-line-chart{--fa:"\f201"}.fa.fa-lastfm,.fa.fa-lastfm-square{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-lastfm-square{--fa:"\f203"}.fa.fa-angellist,.fa.fa-ioxhost{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-cc{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f20a"}.fa.fa-ils,.fa.fa-shekel,.fa.fa-sheqel{--fa:"\f20b"}.fa.fa-buysellads,.fa.fa-connectdevelop,.fa.fa-dashcube,.fa.fa-forumbee,.fa.fa-leanpub,.fa.fa-sellsy,.fa.fa-shirtsinbulk,.fa.fa-simplybuilt,.fa.fa-skyatlas{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-diamond{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f3a5"}.fa.fa-intersex,.fa.fa-transgender{--fa:"\f224"}.fa.fa-transgender-alt{--fa:"\f225"}.fa.fa-facebook-official{--fa:"\f09a"}.fa.fa-facebook-official,.fa.fa-pinterest-p,.fa.fa-whatsapp{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-hotel{--fa:"\f236"}.fa.fa-medium,.fa.fa-viacoin,.fa.fa-y-combinator,.fa.fa-yc{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-yc{--fa:"\f23b"}.fa.fa-expeditedssl,.fa.fa-opencart,.fa.fa-optin-monster{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-battery,.fa.fa-battery-4{--fa:"\f240"}.fa.fa-battery-3{--fa:"\f241"}.fa.fa-battery-2{--fa:"\f242"}.fa.fa-battery-1{--fa:"\f243"}.fa.fa-battery-0{--fa:"\f244"}.fa.fa-object-group,.fa.fa-object-ungroup,.fa.fa-sticky-note-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-sticky-note-o{--fa:"\f249"}.fa.fa-cc-diners-club,.fa.fa-cc-jcb{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-clone{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-hourglass-o{--fa:"\f254"}.fa.fa-hourglass-1{--fa:"\f251"}.fa.fa-hourglass-2{--fa:"\f252"}.fa.fa-hourglass-3{--fa:"\f253"}.fa.fa-hand-rock-o{--fa:"\f255"}.fa.fa-hand-grab-o,.fa.fa-hand-rock-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-hand-grab-o{--fa:"\f255"}.fa.fa-hand-paper-o{--fa:"\f256"}.fa.fa-hand-paper-o,.fa.fa-hand-stop-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-hand-stop-o{--fa:"\f256"}.fa.fa-hand-scissors-o{--fa:"\f257"}.fa.fa-hand-lizard-o,.fa.fa-hand-scissors-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-hand-lizard-o{--fa:"\f258"}.fa.fa-hand-spock-o{--fa:"\f259"}.fa.fa-hand-pointer-o,.fa.fa-hand-spock-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-hand-pointer-o{--fa:"\f25a"}.fa.fa-hand-peace-o{--fa:"\f25b"}.fa.fa-hand-peace-o,.fa.fa-registered{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-creative-commons,.fa.fa-gg,.fa.fa-gg-circle,.fa.fa-odnoklassniki,.fa.fa-odnoklassniki-square{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-odnoklassniki-square{--fa:"\f264"}.fa.fa-chrome,.fa.fa-firefox,.fa.fa-get-pocket,.fa.fa-internet-explorer,.fa.fa-opera,.fa.fa-safari,.fa.fa-wikipedia-w{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-television{--fa:"\f26c"}.fa.fa-500px,.fa.fa-amazon,.fa.fa-contao{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-calendar-plus-o{--fa:"\f271"}.fa.fa-calendar-minus-o,.fa.fa-calendar-plus-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-calendar-minus-o{--fa:"\f272"}.fa.fa-calendar-times-o{--fa:"\f273"}.fa.fa-calendar-check-o,.fa.fa-calendar-times-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-calendar-check-o{--fa:"\f274"}.fa.fa-map-o{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f279"}.fa.fa-commenting{--fa:"\f4ad"}.fa.fa-commenting-o{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f4ad"}.fa.fa-houzz,.fa.fa-vimeo{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-vimeo{--fa:"\f27d"}.fa.fa-black-tie,.fa.fa-edge,.fa.fa-fonticons,.fa.fa-reddit-alien{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-credit-card-alt{--fa:"\f09d"}.fa.fa-codiepie,.fa.fa-fort-awesome,.fa.fa-mixcloud,.fa.fa-modx,.fa.fa-product-hunt,.fa.fa-scribd,.fa.fa-usb{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-pause-circle-o{--fa:"\f28b"}.fa.fa-pause-circle-o,.fa.fa-stop-circle-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-stop-circle-o{--fa:"\f28d"}.fa.fa-bluetooth,.fa.fa-bluetooth-b,.fa.fa-envira,.fa.fa-gitlab,.fa.fa-wheelchair-alt,.fa.fa-wpbeginner,.fa.fa-wpforms{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-wheelchair-alt{--fa:"\f368"}.fa.fa-question-circle-o{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f059"}.fa.fa-volume-control-phone{--fa:"\f2a0"}.fa.fa-asl-interpreting{--fa:"\f2a3"}.fa.fa-deafness,.fa.fa-hard-of-hearing{--fa:"\f2a4"}.fa.fa-glide,.fa.fa-glide-g{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-signing{--fa:"\f2a7"}.fa.fa-viadeo,.fa.fa-viadeo-square{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-viadeo-square{--fa:"\f2aa"}.fa.fa-snapchat,.fa.fa-snapchat-ghost{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-snapchat-ghost{--fa:"\f2ab"}.fa.fa-snapchat-square{--fa:"\f2ad"}.fa.fa-first-order,.fa.fa-google-plus-official,.fa.fa-pied-piper,.fa.fa-snapchat-square,.fa.fa-themeisle,.fa.fa-yoast{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-google-plus-official{--fa:"\f2b3"}.fa.fa-google-plus-circle{--fa:"\f2b3"}.fa.fa-fa,.fa.fa-font-awesome,.fa.fa-google-plus-circle{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-fa{--fa:"\f2b4"}.fa.fa-handshake-o{--fa:"\f2b5"}.fa.fa-envelope-open-o,.fa.fa-handshake-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-envelope-open-o{--fa:"\f2b6"}.fa.fa-linode{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-address-book-o{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f2b9"}.fa.fa-vcard{--fa:"\f2bb"}.fa.fa-address-card-o{--fa:"\f2bb"}.fa.fa-address-card-o,.fa.fa-vcard-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-vcard-o{--fa:"\f2bb"}.fa.fa-user-circle-o{--fa:"\f2bd"}.fa.fa-user-circle-o,.fa.fa-user-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-user-o{--fa:"\f007"}.fa.fa-id-badge{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-drivers-license{--fa:"\f2c2"}.fa.fa-id-card-o{--fa:"\f2c2"}.fa.fa-drivers-license-o,.fa.fa-id-card-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-drivers-license-o{--fa:"\f2c2"}.fa.fa-free-code-camp,.fa.fa-quora,.fa.fa-telegram{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-thermometer,.fa.fa-thermometer-4{--fa:"\f2c7"}.fa.fa-thermometer-3{--fa:"\f2c8"}.fa.fa-thermometer-2{--fa:"\f2c9"}.fa.fa-thermometer-1{--fa:"\f2ca"}.fa.fa-thermometer-0{--fa:"\f2cb"}.fa.fa-bathtub,.fa.fa-s15{--fa:"\f2cd"}.fa.fa-window-maximize,.fa.fa-window-restore{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-times-rectangle{--fa:"\f410"}.fa.fa-window-close-o{--fa:"\f410"}.fa.fa-times-rectangle-o,.fa.fa-window-close-o{font-family:"Font Awesome 7 Pro";font-weight:400}.fa.fa-times-rectangle-o{--fa:"\f410"}.fa.fa-bandcamp,.fa.fa-eercast,.fa.fa-etsy,.fa.fa-grav,.fa.fa-imdb,.fa.fa-ravelry{font-family:"Font Awesome 7 Brands";font-weight:400}.fa.fa-eercast{--fa:"\f2da"}.fa.fa-snowflake-o{font-family:"Font Awesome 7 Pro";font-weight:400;--fa:"\f2dc"}.fa.fa-meetup,.fa.fa-superpowers,.fa.fa-wpexplorer{font-family:"Font Awesome 7 Brands";font-weight:400} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/v5-font-face.css b/public/vendor/fontawesome/css/v5-font-face.css deleted file mode 100644 index eeae0ee..0000000 --- a/public/vendor/fontawesome/css/v5-font-face.css +++ /dev/null @@ -1,35 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -@font-face { - font-family: "Font Awesome 5 Brands"; - font-display: block; - font-weight: 400; - src: url("../webfonts/fa-brands-400.woff2") format("woff2"); -} -@font-face { - font-family: "Font Awesome 5 Pro"; - font-display: block; - font-weight: 900; - src: url("../webfonts/fa-solid-900.woff2") format("woff2"); -} -@font-face { - font-family: "Font Awesome 5 Pro"; - font-display: block; - font-weight: 400; - src: url("../webfonts/fa-regular-400.woff2") format("woff2"); -} -@font-face { - font-family: "Font Awesome 5 Pro"; - font-display: block; - font-weight: 300; - src: url("../webfonts/fa-light-300.woff2") format("woff2"); -} -@font-face { - font-family: "Font Awesome 5 Duotone"; - font-display: block; - font-weight: 900; - src: url("../webfonts/fa-duotone-900.woff2") format("woff2"); -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/v5-font-face.min.css b/public/vendor/fontawesome/css/v5-font-face.min.css deleted file mode 100644 index bcf927b..0000000 --- a/public/vendor/fontawesome/css/v5-font-face.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -@font-face{font-family:"Font Awesome 5 Brands";font-display:block;font-weight:400;src:url(../webfonts/fa-brands-400.woff2) format("woff2")}@font-face{font-family:"Font Awesome 5 Pro";font-display:block;font-weight:900;src:url(../webfonts/fa-solid-900.woff2) format("woff2")}@font-face{font-family:"Font Awesome 5 Pro";font-display:block;font-weight:400;src:url(../webfonts/fa-regular-400.woff2) format("woff2")}@font-face{font-family:"Font Awesome 5 Pro";font-display:block;font-weight:300;src:url(../webfonts/fa-light-300.woff2) format("woff2")}@font-face{font-family:"Font Awesome 5 Duotone";font-display:block;font-weight:900;src:url(../webfonts/fa-duotone-900.woff2) format("woff2")} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/whiteboard-semibold.css b/public/vendor/fontawesome/css/whiteboard-semibold.css deleted file mode 100644 index 62452bd..0000000 --- a/public/vendor/fontawesome/css/whiteboard-semibold.css +++ /dev/null @@ -1,31 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:root, :host { - --fa-family-whiteboard: "Font Awesome 7 Whiteboard"; - --fa-font-whiteboard-semibold: normal 600 1em/1 var(--fa-family-whiteboard); - /* deprecated: this older custom property will be removed next major release */ - --fa-style-family-whiteboard: var(--fa-family-whiteboard); -} - -@font-face { - font-family: "Font Awesome 7 Whiteboard"; - font-style: normal; - font-weight: 600; - font-display: block; - src: url("../webfonts/fa-whiteboard-semibold-600.woff2"); -} -.fawsb { - --fa-family: var(--fa-family-whiteboard); - --fa-style: 600; -} - -.fa-whiteboard { - --fa-family: var(--fa-family-whiteboard); -} - -.fa-semibold { - --fa-style: 600; -} \ No newline at end of file diff --git a/public/vendor/fontawesome/css/whiteboard-semibold.min.css b/public/vendor/fontawesome/css/whiteboard-semibold.min.css deleted file mode 100644 index 1474ff4..0000000 --- a/public/vendor/fontawesome/css/whiteboard-semibold.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Font Awesome Pro 7.1.0 by @fontawesome - https://fontawesome.com - * License - https://fontawesome.com/license (Commercial License) - * Copyright 2025 Fonticons, Inc. - */ -:host,:root{--fa-family-whiteboard:"Font Awesome 7 Whiteboard";--fa-font-whiteboard-semibold:normal 600 1em/1 var(--fa-family-whiteboard);--fa-style-family-whiteboard:var(--fa-family-whiteboard)}@font-face{font-family:"Font Awesome 7 Whiteboard";font-style:normal;font-weight:600;font-display:block;src:url(../webfonts/fa-whiteboard-semibold-600.woff2)}.fawsb{--fa-style:600}.fa-whiteboard,.fawsb{--fa-family:var(--fa-family-whiteboard)}.fa-semibold{--fa-style:600} \ No newline at end of file diff --git a/public/vendor/fontawesome/webfonts/fa-brands-400.woff2 b/public/vendor/fontawesome/webfonts/fa-brands-400.woff2 deleted file mode 100644 index 750d377..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-brands-400.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-chisel-regular-400.woff2 b/public/vendor/fontawesome/webfonts/fa-chisel-regular-400.woff2 deleted file mode 100644 index bf17e09..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-chisel-regular-400.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-duotone-900.woff2 b/public/vendor/fontawesome/webfonts/fa-duotone-900.woff2 deleted file mode 100644 index 676eee5..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-duotone-900.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-duotone-light-300.woff2 b/public/vendor/fontawesome/webfonts/fa-duotone-light-300.woff2 deleted file mode 100644 index d70a1e3..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-duotone-light-300.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-duotone-regular-400.woff2 b/public/vendor/fontawesome/webfonts/fa-duotone-regular-400.woff2 deleted file mode 100644 index d0207e4..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-duotone-regular-400.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-duotone-thin-100.woff2 b/public/vendor/fontawesome/webfonts/fa-duotone-thin-100.woff2 deleted file mode 100644 index dae6bbd..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-duotone-thin-100.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-etch-solid-900.woff2 b/public/vendor/fontawesome/webfonts/fa-etch-solid-900.woff2 deleted file mode 100644 index 84e1748..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-etch-solid-900.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-jelly-duo-regular-400.woff2 b/public/vendor/fontawesome/webfonts/fa-jelly-duo-regular-400.woff2 deleted file mode 100644 index 954becc..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-jelly-duo-regular-400.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-jelly-fill-regular-400.woff2 b/public/vendor/fontawesome/webfonts/fa-jelly-fill-regular-400.woff2 deleted file mode 100644 index 9e66b6c..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-jelly-fill-regular-400.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-jelly-regular-400.woff2 b/public/vendor/fontawesome/webfonts/fa-jelly-regular-400.woff2 deleted file mode 100644 index efcde53..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-jelly-regular-400.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-light-300.woff2 b/public/vendor/fontawesome/webfonts/fa-light-300.woff2 deleted file mode 100644 index 361c02e..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-light-300.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-notdog-duo-solid-900.woff2 b/public/vendor/fontawesome/webfonts/fa-notdog-duo-solid-900.woff2 deleted file mode 100644 index cfbc09a..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-notdog-duo-solid-900.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-notdog-solid-900.woff2 b/public/vendor/fontawesome/webfonts/fa-notdog-solid-900.woff2 deleted file mode 100644 index d6b92ed..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-notdog-solid-900.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-regular-400.woff2 b/public/vendor/fontawesome/webfonts/fa-regular-400.woff2 deleted file mode 100644 index 5f9abe6..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-regular-400.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-sharp-duotone-light-300.woff2 b/public/vendor/fontawesome/webfonts/fa-sharp-duotone-light-300.woff2 deleted file mode 100644 index 3d7a139..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-sharp-duotone-light-300.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-sharp-duotone-regular-400.woff2 b/public/vendor/fontawesome/webfonts/fa-sharp-duotone-regular-400.woff2 deleted file mode 100644 index dd3f333..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-sharp-duotone-regular-400.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-sharp-duotone-solid-900.woff2 b/public/vendor/fontawesome/webfonts/fa-sharp-duotone-solid-900.woff2 deleted file mode 100644 index 6ce72da..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-sharp-duotone-solid-900.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-sharp-duotone-thin-100.woff2 b/public/vendor/fontawesome/webfonts/fa-sharp-duotone-thin-100.woff2 deleted file mode 100644 index a8e88b2..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-sharp-duotone-thin-100.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-sharp-light-300.woff2 b/public/vendor/fontawesome/webfonts/fa-sharp-light-300.woff2 deleted file mode 100644 index 447380b..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-sharp-light-300.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-sharp-regular-400.woff2 b/public/vendor/fontawesome/webfonts/fa-sharp-regular-400.woff2 deleted file mode 100644 index 18ea13b..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-sharp-regular-400.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-sharp-solid-900.woff2 b/public/vendor/fontawesome/webfonts/fa-sharp-solid-900.woff2 deleted file mode 100644 index 56de541..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-sharp-solid-900.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-sharp-thin-100.woff2 b/public/vendor/fontawesome/webfonts/fa-sharp-thin-100.woff2 deleted file mode 100644 index 68d57f4..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-sharp-thin-100.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-slab-press-regular-400.woff2 b/public/vendor/fontawesome/webfonts/fa-slab-press-regular-400.woff2 deleted file mode 100644 index 4f11cf8..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-slab-press-regular-400.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-slab-regular-400.woff2 b/public/vendor/fontawesome/webfonts/fa-slab-regular-400.woff2 deleted file mode 100644 index 1a6bea8..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-slab-regular-400.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-solid-900.woff2 b/public/vendor/fontawesome/webfonts/fa-solid-900.woff2 deleted file mode 100644 index ab0e2f2..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-solid-900.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-thin-100.woff2 b/public/vendor/fontawesome/webfonts/fa-thin-100.woff2 deleted file mode 100644 index 7f429e8..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-thin-100.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-thumbprint-light-300.woff2 b/public/vendor/fontawesome/webfonts/fa-thumbprint-light-300.woff2 deleted file mode 100644 index c84e54e..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-thumbprint-light-300.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-utility-duo-semibold-600.woff2 b/public/vendor/fontawesome/webfonts/fa-utility-duo-semibold-600.woff2 deleted file mode 100644 index 944e7c8..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-utility-duo-semibold-600.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-utility-fill-semibold-600.woff2 b/public/vendor/fontawesome/webfonts/fa-utility-fill-semibold-600.woff2 deleted file mode 100644 index 49b5c2e..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-utility-fill-semibold-600.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-utility-semibold-600.woff2 b/public/vendor/fontawesome/webfonts/fa-utility-semibold-600.woff2 deleted file mode 100644 index c9c8fd0..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-utility-semibold-600.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-v4compatibility.woff2 b/public/vendor/fontawesome/webfonts/fa-v4compatibility.woff2 deleted file mode 100644 index 2d174ba..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-v4compatibility.woff2 and /dev/null differ diff --git a/public/vendor/fontawesome/webfonts/fa-whiteboard-semibold-600.woff2 b/public/vendor/fontawesome/webfonts/fa-whiteboard-semibold-600.woff2 deleted file mode 100644 index 9a0d0ca..0000000 Binary files a/public/vendor/fontawesome/webfonts/fa-whiteboard-semibold-600.woff2 and /dev/null differ diff --git a/resources/css/admin.css b/resources/css/admin.css deleted file mode 100644 index 860e626..0000000 --- a/resources/css/admin.css +++ /dev/null @@ -1,5 +0,0 @@ -@import "tailwindcss"; - -/* Include admin views for class detection */ -@source "../../packages/core-admin"; -@source "../../packages/core-php/src/Core/Front"; diff --git a/resources/css/app.css b/resources/css/app.css deleted file mode 100644 index 7d2b507..0000000 --- a/resources/css/app.css +++ /dev/null @@ -1,5 +0,0 @@ -@import "tailwindcss"; - -/* Include app views for class detection */ -@source "../../app"; -@source "../../packages"; diff --git a/resources/js/app.js b/resources/js/app.js deleted file mode 100644 index ee78076..0000000 --- a/resources/js/app.js +++ /dev/null @@ -1 +0,0 @@ -// Core PHP Framework diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js deleted file mode 100644 index 5f1390b..0000000 --- a/resources/js/bootstrap.js +++ /dev/null @@ -1,4 +0,0 @@ -import axios from 'axios'; -window.axios = axios; - -window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php deleted file mode 100644 index b7355d7..0000000 --- a/resources/views/welcome.blade.php +++ /dev/null @@ -1,277 +0,0 @@ - - - - - - - {{ config('app.name', 'Laravel') }} - - - - - - - @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) - @vite(['resources/css/app.css', 'resources/js/app.js']) - @else - - @endif - - -
- @if (Route::has('login')) - - @endif -
-
-
-
-

Let's get started

-

Laravel has an incredibly rich ecosystem.
We suggest starting with the following.

- - -
-
- {{-- Laravel Logo --}} - - - - - - - - - - - {{-- Light Mode 12 SVG --}} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {{-- Dark Mode 12 SVG --}} - -
-
-
-
- - @if (Route::has('login')) - - @endif - - diff --git a/routes/api.php b/routes/api.php deleted file mode 100644 index 6dcf813..0000000 --- a/routes/api.php +++ /dev/null @@ -1,3 +0,0 @@ - $lastException?->getMessage() ?? 'Unknown error', ])); diff --git a/packages/core-php/src/Core/Cdn/Services/CdnUrlBuilder.php b/src/Core/Cdn/Services/CdnUrlBuilder.php similarity index 99% rename from packages/core-php/src/Core/Cdn/Services/CdnUrlBuilder.php rename to src/Core/Cdn/Services/CdnUrlBuilder.php index f3948da..3b6285e 100644 --- a/packages/core-php/src/Core/Cdn/Services/CdnUrlBuilder.php +++ b/src/Core/Cdn/Services/CdnUrlBuilder.php @@ -115,7 +115,7 @@ class CdnUrlBuilder * * @param string $path Path relative to storage root * @param int|Carbon|null $expiry Expiry time in seconds, or Carbon instance. - * Defaults to config('cdn.signed_url_expiry', 3600) + * Defaults to config('cdn.signed_url_expiry', 3600) * @param string|null $token Optional token override (uses config if null) * @return string|null Signed URL or null if token not configured */ diff --git a/packages/core-php/src/Core/Cdn/Services/FluxCdnService.php b/src/Core/Cdn/Services/FluxCdnService.php similarity index 99% rename from packages/core-php/src/Core/Cdn/Services/FluxCdnService.php rename to src/Core/Cdn/Services/FluxCdnService.php index 97880ea..7f80550 100644 --- a/packages/core-php/src/Core/Cdn/Services/FluxCdnService.php +++ b/src/Core/Cdn/Services/FluxCdnService.php @@ -1,4 +1,5 @@ urlBuilder = $urlBuilder ?? new CdnUrlBuilder; } + /** * Get the Flux scripts tag with CDN awareness. * @@ -70,6 +72,7 @@ class FluxCdnService * Get the Flux editor scripts tag with CDN awareness. * * @return string HTML script tag for Flux editor + * * @throws \Exception When Flux Pro is not available */ public function editorScripts(): string @@ -95,6 +98,7 @@ class FluxCdnService * Get the Flux editor styles tag with CDN awareness. * * @return string HTML link tag for Flux editor styles + * * @throws \Exception When Flux Pro is not available */ public function editorStyles(): string diff --git a/packages/core-php/src/Core/Cdn/Services/StorageOffload.php b/src/Core/Cdn/Services/StorageOffload.php similarity index 99% rename from packages/core-php/src/Core/Cdn/Services/StorageOffload.php rename to src/Core/Cdn/Services/StorageOffload.php index 949f73b..5914fbe 100644 --- a/packages/core-php/src/Core/Cdn/Services/StorageOffload.php +++ b/src/Core/Cdn/Services/StorageOffload.php @@ -1,4 +1,5 @@ attributes['value'] = json_encode(self::ENCRYPTED_PREFIX . $encrypted); + $this->attributes['value'] = json_encode(self::ENCRYPTED_PREFIX.$encrypted); } else { // Store as regular JSON $this->attributes['value'] = json_encode($value); diff --git a/packages/core-php/src/Core/Config/Models/ConfigVersion.php b/src/Core/Config/Models/ConfigVersion.php similarity index 98% rename from packages/core-php/src/Core/Config/Models/ConfigVersion.php rename to src/Core/Config/Models/ConfigVersion.php index 48087b8..b97871d 100644 --- a/packages/core-php/src/Core/Config/Models/ConfigVersion.php +++ b/src/Core/Config/Models/ConfigVersion.php @@ -1,4 +1,5 @@ setupEnvironment()) { $progressBar->finish(); $this->newLine(); + return self::FAILURE; } $progressBar->advance(); @@ -184,6 +186,7 @@ class InstallCommand extends Command { if ($this->isDryRun) { $this->info(" [WOULD] {$description}"); + return null; } @@ -479,9 +482,6 @@ class InstallCommand extends Command * * This command has no option values that need completion hints, * but implements the method for consistency with other commands. - * - * @param \Symfony\Component\Console\Completion\CompletionInput $input - * @param \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions */ public function complete( \Symfony\Component\Console\Completion\CompletionInput $input, diff --git a/packages/core-php/src/Core/Console/Commands/MakeModCommand.php b/src/Core/Console/Commands/MakeModCommand.php similarity index 97% rename from packages/core-php/src/Core/Console/Commands/MakeModCommand.php rename to src/Core/Console/Commands/MakeModCommand.php index b654914..a069743 100644 --- a/packages/core-php/src/Core/Console/Commands/MakeModCommand.php +++ b/src/Core/Console/Commands/MakeModCommand.php @@ -1,4 +1,5 @@ option('api') || $this->option('all')) { - $methods[] = <<routes(fn () => require __DIR__.'/Routes/api.php'); + $event->routes(fn () => require __DIR__.'/Routes/api.php'); } } PHP; @@ -505,9 +506,6 @@ BLADE; /** * Get shell completion suggestions for arguments. - * - * @param \Symfony\Component\Console\Completion\CompletionInput $input - * @param \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions */ public function complete( \Symfony\Component\Console\Completion\CompletionInput $input, diff --git a/packages/core-php/src/Core/Console/Commands/MakePlugCommand.php b/src/Core/Console/Commands/MakePlugCommand.php similarity index 99% rename from packages/core-php/src/Core/Console/Commands/MakePlugCommand.php rename to src/Core/Console/Commands/MakePlugCommand.php index 52ccd3b..0d187f5 100644 --- a/packages/core-php/src/Core/Console/Commands/MakePlugCommand.php +++ b/src/Core/Console/Commands/MakePlugCommand.php @@ -1,4 +1,5 @@ option('admin') || $this->option('all')) { - $methods[] = <<routes(fn () => require __DIR__.'/Routes/admin.php'); + $event->routes(fn () => require __DIR__.'/Routes/admin.php'); } } PHP; } if ($this->option('api') || $this->option('all')) { - $methods[] = <<routes(fn () => require __DIR__.'/Routes/api.php'); + $event->routes(fn () => require __DIR__.'/Routes/api.php'); } } PHP; @@ -571,9 +572,6 @@ BLADE; /** * Get shell completion suggestions for arguments and options. - * - * @param \Symfony\Component\Console\Completion\CompletionInput $input - * @param \Symfony\Component\Console\Completion\CompletionSuggestions $suggestions */ public function complete( \Symfony\Component\Console\Completion\CompletionInput $input, diff --git a/packages/core-php/src/Core/Console/Commands/NewProjectCommand.php b/src/Core/Console/Commands/NewProjectCommand.php similarity index 99% rename from packages/core-php/src/Core/Console/Commands/NewProjectCommand.php rename to src/Core/Console/Commands/NewProjectCommand.php index 73d49c9..2005d2c 100644 --- a/packages/core-php/src/Core/Console/Commands/NewProjectCommand.php +++ b/src/Core/Console/Commands/NewProjectCommand.php @@ -1,4 +1,5 @@ $profile['event'] === $eventClass + fn ($profile) => $profile['event'] === $eventClass ); } /** * Get profiles for a specific handler. * - * @param string $handlerClass Handler class name + * @param string $handlerClass Handler class name * @return array $profile['handler'] === $handlerClass + fn ($profile) => $profile['handler'] === $handlerClass ); } @@ -371,14 +372,14 @@ class ListenerProfiler { return array_filter( self::$profiles, - fn($profile) => $profile['is_slow'] + fn ($profile) => $profile['is_slow'] ); } /** * Get the N slowest listeners by total duration. * - * @param int $limit Maximum number of results + * @param int $limit Maximum number of results * @return array $b['duration_ms'] <=> $a['duration_ms']); + uasort($profiles, fn ($a, $b) => $b['duration_ms'] <=> $a['duration_ms']); return array_slice($profiles, 0, $limit, true); } @@ -403,7 +404,7 @@ class ListenerProfiler /** * Get the N highest memory-consuming listeners. * - * @param int $limit Maximum number of results + * @param int $limit Maximum number of results * @return array $b['memory_delta_bytes'] <=> $a['memory_delta_bytes']); + uasort($profiles, fn ($a, $b) => $b['memory_delta_bytes'] <=> $a['memory_delta_bytes']); return array_slice($profiles, 0, $limit, true); } @@ -525,6 +526,7 @@ class ListenerProfiler private static function makeContextKey(string $eventClass, string $handlerClass, string $method): string { $uniqueId = bin2hex(random_bytes(8)); + return "{$eventClass}|{$handlerClass}|{$method}|{$uniqueId}"; } @@ -536,6 +538,7 @@ class ListenerProfiler private static function parseContextKey(string $contextKey): array { $parts = explode('|', $contextKey); + return [$parts[0] ?? '', $parts[1] ?? '', $parts[2] ?? '']; } diff --git a/packages/core-php/src/Core/Events/MailSending.php b/src/Core/Events/MailSending.php similarity index 98% rename from packages/core-php/src/Core/Events/MailSending.php rename to src/Core/Events/MailSending.php index aa8950c..968faf7 100644 --- a/packages/core-php/src/Core/Events/MailSending.php +++ b/src/Core/Events/MailSending.php @@ -1,4 +1,5 @@ mailable(WelcomeEmail::class); * } * ``` - * - * @package Core\Events */ class MailSending extends LifecycleEvent { diff --git a/packages/core-php/src/Core/Events/McpToolsRegistering.php b/src/Core/Events/McpToolsRegistering.php similarity index 98% rename from packages/core-php/src/Core/Events/McpToolsRegistering.php rename to src/Core/Events/McpToolsRegistering.php index a6255e9..057ea9d 100644 --- a/packages/core-php/src/Core/Events/McpToolsRegistering.php +++ b/src/Core/Events/McpToolsRegistering.php @@ -1,4 +1,5 @@ processor('video', VideoProcessor::class); * } * ``` - * - * @package Core\Events */ class MediaRequested extends LifecycleEvent { diff --git a/packages/core-php/src/Core/Events/QueueWorkerBooting.php b/src/Core/Events/QueueWorkerBooting.php similarity index 98% rename from packages/core-php/src/Core/Events/QueueWorkerBooting.php rename to src/Core/Events/QueueWorkerBooting.php index 66bf3b3..d1aadc7 100644 --- a/packages/core-php/src/Core/Events/QueueWorkerBooting.php +++ b/src/Core/Events/QueueWorkerBooting.php @@ -1,4 +1,5 @@ searchable(Article::class); * } * ``` - * - * @package Core\Events */ class SearchRequested extends LifecycleEvent { diff --git a/packages/core-php/src/Core/Events/WebRoutesRegistering.php b/src/Core/Events/WebRoutesRegistering.php similarity index 98% rename from packages/core-php/src/Core/Events/WebRoutesRegistering.php rename to src/Core/Events/WebRoutesRegistering.php index d6a7086..eebfcef 100644 --- a/packages/core-php/src/Core/Events/WebRoutesRegistering.php +++ b/src/Core/Events/WebRoutesRegistering.php @@ -1,4 +1,5 @@ entitlements = $entitlements; } - $this->iconValidator = $iconValidator ?? new IconValidator(); + $this->iconValidator = $iconValidator ?? new IconValidator; $this->cacheTtl = (int) config('core.admin_menu.cache_ttl', self::DEFAULT_CACHE_TTL); $this->cachingEnabled = (bool) config('core.admin_menu.cache_enabled', true); $this->validateIcons = (bool) config('core.admin_menu.validate_icons', true); @@ -183,7 +184,6 @@ class AdminMenuRegistry * Get static menu items, using cache if enabled. * * @param object|null $workspace Workspace model instance - * @param bool $isAdmin * @param object|null $user User model instance * @return array> */ @@ -204,7 +204,6 @@ class AdminMenuRegistry * Get dynamic menu items from dynamic providers. * * @param object|null $workspace Workspace model instance - * @param bool $isAdmin * @param object|null $user User model instance * @return array> */ @@ -276,10 +275,7 @@ class AdminMenuRegistry /** * Build the final menu structure from collected items. * - * @param array $allItems * @param object|null $workspace Workspace model instance - * @param bool $isAdmin - * @return array */ protected function buildMenuStructure(array $allItems, ?object $workspace, bool $isAdmin): array { @@ -376,17 +372,15 @@ class AdminMenuRegistry * Build the cache key for menu items. * * @param object|null $workspace Workspace model instance - * @param bool $isAdmin * @param object|null $user User model instance - * @return string */ protected function buildCacheKey(?object $workspace, bool $isAdmin, ?object $user): string { $parts = [ self::CACHE_PREFIX, - 'w' . ($workspace?->id ?? 'null'), - 'a' . ($isAdmin ? '1' : '0'), - 'u' . ($user?->id ?? 'null'), + 'w'.($workspace?->id ?? 'null'), + 'a'.($isAdmin ? '1' : '0'), + 'u'.($user?->id ?? 'null'), ]; // Add dynamic cache key modifiers @@ -404,7 +398,6 @@ class AdminMenuRegistry * Collect items from all providers, filtering by entitlements and permissions. * * @param object|null $workspace Workspace model instance - * @param bool $isAdmin * @param object|null $user User model instance * @return array> */ @@ -462,7 +455,6 @@ class AdminMenuRegistry * @param object|null $user User model instance * @param array $permissions * @param object|null $workspace Workspace model instance - * @return bool */ protected function checkPermissions(?object $user, array $permissions, ?object $workspace): bool { @@ -516,7 +508,7 @@ class AdminMenuRegistry // We can't easily clear pattern-based cache keys with all drivers, // so we rely on TTL expiration for non-tagged caches if (method_exists(Cache::getStore(), 'tags')) { - Cache::tags([self::CACHE_PREFIX, 'workspace:' . $workspace->id])->flush(); + Cache::tags([self::CACHE_PREFIX, 'workspace:'.$workspace->id])->flush(); } } @@ -528,7 +520,7 @@ class AdminMenuRegistry public function invalidateUserCache(object $user): void { if (method_exists(Cache::getStore(), 'tags')) { - Cache::tags([self::CACHE_PREFIX, 'user:' . $user->id])->flush(); + Cache::tags([self::CACHE_PREFIX, 'user:'.$user->id])->flush(); } } diff --git a/packages/core-php/src/Core/Front/Admin/AdminTagCompiler.php b/src/Core/Front/Admin/AdminTagCompiler.php similarity index 99% rename from packages/core-php/src/Core/Front/Admin/AdminTagCompiler.php rename to src/Core/Front/Admin/AdminTagCompiler.php index b0c34f2..aad43b2 100644 --- a/packages/core-php/src/Core/Front/Admin/AdminTagCompiler.php +++ b/src/Core/Front/Admin/AdminTagCompiler.php @@ -1,4 +1,5 @@ build(); * ``` * - * @package Core\Front\Admin\Support * * @see AdminMenuProvider For menu provider interface * @see MenuItemGroup For grouping utilities @@ -173,7 +173,6 @@ class MenuItemBuilder * Create a new menu item builder (static factory). * * @param string $label The menu item display text - * @return static */ public static function make(string $label): static { @@ -187,7 +186,6 @@ class MenuItemBuilder * * @param string $label The child item label * @param string $href The child item URL - * @return static */ public static function child(string $label, string $href): static { @@ -692,8 +690,6 @@ class MenuItemBuilder /** * Build the lazy-evaluated item closure. - * - * @return \Closure */ protected function buildItemClosure(): \Closure { @@ -775,8 +771,6 @@ class MenuItemBuilder /** * Build a child item array (without registration wrapper). - * - * @return array */ public function buildChildItem(): array { @@ -823,8 +817,6 @@ class MenuItemBuilder /** * Get the label. - * - * @return string */ public function getLabel(): string { @@ -833,8 +825,6 @@ class MenuItemBuilder /** * Get the group. - * - * @return string */ public function getGroup(): string { @@ -843,8 +833,6 @@ class MenuItemBuilder /** * Get the priority. - * - * @return int */ public function getPriority(): int { diff --git a/packages/core-php/src/Core/Front/Admin/Support/MenuItemGroup.php b/src/Core/Front/Admin/Support/MenuItemGroup.php similarity index 97% rename from packages/core-php/src/Core/Front/Admin/Support/MenuItemGroup.php rename to src/Core/Front/Admin/Support/MenuItemGroup.php index e94bf2c..78583ae 100644 --- a/packages/core-php/src/Core/Front/Admin/Support/MenuItemGroup.php +++ b/src/Core/Front/Admin/Support/MenuItemGroup.php @@ -1,4 +1,5 @@ path . $slot; + return $this->path.$slot; } /** @@ -75,6 +81,7 @@ class Layout implements Htmlable, Renderable foreach ($items as $item) { $this->header[] = $item; } + return $this; } @@ -86,6 +93,7 @@ class Layout implements Htmlable, Renderable foreach ($items as $item) { $this->left[] = $item; } + return $this; } @@ -97,6 +105,7 @@ class Layout implements Htmlable, Renderable foreach ($items as $item) { $this->content[] = $item; } + return $this; } @@ -108,6 +117,7 @@ class Layout implements Htmlable, Renderable foreach ($items as $item) { $this->right[] = $item; } + return $this; } @@ -119,17 +129,37 @@ class Layout implements Htmlable, Renderable foreach ($items as $item) { $this->footer[] = $item; } + return $this; } /** * Alias methods for readability (variadic) */ - public function addHeader(mixed ...$items): static { return $this->h(...$items); } - public function addLeft(mixed ...$items): static { return $this->l(...$items); } - public function addContent(mixed ...$items): static { return $this->c(...$items); } - public function addRight(mixed ...$items): static { return $this->r(...$items); } - public function addFooter(mixed ...$items): static { return $this->f(...$items); } + public function addHeader(mixed ...$items): static + { + return $this->h(...$items); + } + + public function addLeft(mixed ...$items): static + { + return $this->l(...$items); + } + + public function addContent(mixed ...$items): static + { + return $this->c(...$items); + } + + public function addRight(mixed ...$items): static + { + return $this->r(...$items); + } + + public function addFooter(mixed ...$items): static + { + return $this->f(...$items); + } /** * Set HTML attributes on the layout container @@ -137,6 +167,7 @@ class Layout implements Htmlable, Renderable public function attributes(array $attributes): static { $this->attributes = array_merge($this->attributes, $attributes); + return $this; } @@ -146,7 +177,8 @@ class Layout implements Htmlable, Renderable public function class(string $class): static { $existing = $this->attributes['class'] ?? ''; - $this->attributes['class'] = trim($existing . ' ' . $class); + $this->attributes['class'] = trim($existing.' '.$class); + return $this; } @@ -165,10 +197,11 @@ class Layout implements Htmlable, Renderable { $html = ''; foreach ($items as $index => $item) { - $itemId = $this->slotId($slot) . '-' . $index; + $itemId = $this->slotId($slot).'-'.$index; $resolved = $this->resolveItem($item, $slot); - $html .= '
' . $resolved . '
'; + $html .= '
'.$resolved.'
'; } + return $html; } @@ -183,7 +216,8 @@ class Layout implements Htmlable, Renderable // Nested Layout - inject the path context if ($content instanceof Layout) { - $content->path = $this->slotId($slot) . '-'; + $content->path = $this->slotId($slot).'-'; + return $content->render(); } @@ -212,14 +246,14 @@ class Layout implements Htmlable, Renderable protected function buildAttributes(): string { $attrs = $this->attributes; - $attrs['class'] = trim('hlcrf-layout ' . ($attrs['class'] ?? '')); + $attrs['class'] = trim('hlcrf-layout '.($attrs['class'] ?? '')); $parts = []; foreach ($attrs as $key => $value) { if ($value === true) { $parts[] = $key; } elseif ($value !== false && $value !== null) { - $parts[] = $key . '="' . e($value) . '"'; + $parts[] = $key.'="'.e($value).'"'; } } @@ -232,40 +266,40 @@ class Layout implements Htmlable, Renderable public function render(): string { $layoutId = $this->path ? rtrim($this->path, '-') : 'root'; - $html = '
buildAttributes() . ' data-layout="' . e($layoutId) . '">'; + $html = '
buildAttributes().' data-layout="'.e($layoutId).'">'; // Header - if ($this->has('H') && !empty($this->header)) { + if ($this->has('H') && ! empty($this->header)) { $id = $this->slotId('H'); - $html .= '
' . $this->renderSlot($this->header, 'H') . '
'; + $html .= '
'.$this->renderSlot($this->header, 'H').'
'; } // Body (L, C, R) if ($this->has('L') || $this->has('C') || $this->has('R')) { $html .= '
'; - if ($this->has('L') && !empty($this->left)) { + if ($this->has('L') && ! empty($this->left)) { $id = $this->slotId('L'); - $html .= ''; + $html .= ''; } if ($this->has('C')) { $id = $this->slotId('C'); - $html .= '
' . $this->renderSlot($this->content, 'C') . '
'; + $html .= '
'.$this->renderSlot($this->content, 'C').'
'; } - if ($this->has('R') && !empty($this->right)) { + if ($this->has('R') && ! empty($this->right)) { $id = $this->slotId('R'); - $html .= ''; + $html .= ''; } $html .= '
'; } // Footer - if ($this->has('F') && !empty($this->footer)) { + if ($this->has('F') && ! empty($this->footer)) { $id = $this->slotId('F'); - $html .= '
' . $this->renderSlot($this->footer, 'F') . '
'; + $html .= '
'.$this->renderSlot($this->footer, 'F').'
'; } $html .= '
'; diff --git a/packages/core-php/src/Core/Front/Components/NavList.php b/src/Core/Front/Components/NavList.php similarity index 99% rename from packages/core-php/src/Core/Front/Components/NavList.php rename to src/Core/Front/Components/NavList.php index e73ad40..39c79c9 100644 --- a/packages/core-php/src/Core/Front/Components/NavList.php +++ b/src/Core/Front/Components/NavList.php @@ -1,4 +1,5 @@ getNonce() . '"'; + return 'nonce="'.$this->getNonce().'"'; } /** diff --git a/packages/core-php/src/Core/Headers/DetectDevice.php b/src/Core/Headers/DetectDevice.php similarity index 99% rename from packages/core-php/src/Core/Headers/DetectDevice.php rename to src/Core/Headers/DetectDevice.php index 0ac2f97..79efcf7 100644 --- a/packages/core-php/src/Core/Headers/DetectDevice.php +++ b/src/Core/Headers/DetectDevice.php @@ -1,4 +1,5 @@ headersEnabled ? 'true' : 'false'), + 'SECURITY_HEADERS_ENABLED='.($this->headersEnabled ? 'true' : 'false'), '', '# HSTS', - 'SECURITY_HSTS_ENABLED=' . ($this->hstsEnabled ? 'true' : 'false'), - 'SECURITY_HSTS_MAX_AGE=' . $this->hstsMaxAge, - 'SECURITY_HSTS_INCLUDE_SUBDOMAINS=' . ($this->hstsIncludeSubdomains ? 'true' : 'false'), - 'SECURITY_HSTS_PRELOAD=' . ($this->hstsPreload ? 'true' : 'false'), + 'SECURITY_HSTS_ENABLED='.($this->hstsEnabled ? 'true' : 'false'), + 'SECURITY_HSTS_MAX_AGE='.$this->hstsMaxAge, + 'SECURITY_HSTS_INCLUDE_SUBDOMAINS='.($this->hstsIncludeSubdomains ? 'true' : 'false'), + 'SECURITY_HSTS_PRELOAD='.($this->hstsPreload ? 'true' : 'false'), '', '# CSP', - 'SECURITY_CSP_ENABLED=' . ($this->cspEnabled ? 'true' : 'false'), - 'SECURITY_CSP_REPORT_ONLY=' . ($this->cspReportOnly ? 'true' : 'false'), - 'SECURITY_CSP_NONCE_ENABLED=' . ($this->cspNonceEnabled ? 'true' : 'false'), + 'SECURITY_CSP_ENABLED='.($this->cspEnabled ? 'true' : 'false'), + 'SECURITY_CSP_REPORT_ONLY='.($this->cspReportOnly ? 'true' : 'false'), + 'SECURITY_CSP_NONCE_ENABLED='.($this->cspNonceEnabled ? 'true' : 'false'), ]; if ($this->cspReportUri) { - $lines[] = 'SECURITY_CSP_REPORT_URI=' . $this->cspReportUri; + $lines[] = 'SECURITY_CSP_REPORT_URI='.$this->cspReportUri; } $lines = array_merge($lines, [ '', '# External Services', - 'SECURITY_CSP_JSDELIVR=' . ($this->jsdelivrEnabled ? 'true' : 'false'), - 'SECURITY_CSP_UNPKG=' . ($this->unpkgEnabled ? 'true' : 'false'), - 'SECURITY_CSP_GOOGLE_ANALYTICS=' . ($this->googleAnalyticsEnabled ? 'true' : 'false'), - 'SECURITY_CSP_FACEBOOK=' . ($this->facebookEnabled ? 'true' : 'false'), + 'SECURITY_CSP_JSDELIVR='.($this->jsdelivrEnabled ? 'true' : 'false'), + 'SECURITY_CSP_UNPKG='.($this->unpkgEnabled ? 'true' : 'false'), + 'SECURITY_CSP_GOOGLE_ANALYTICS='.($this->googleAnalyticsEnabled ? 'true' : 'false'), + 'SECURITY_CSP_FACEBOOK='.($this->facebookEnabled ? 'true' : 'false'), '', '# Other Headers', - 'SECURITY_X_FRAME_OPTIONS=' . $this->xFrameOptions, - 'SECURITY_REFERRER_POLICY=' . $this->referrerPolicy, + 'SECURITY_X_FRAME_OPTIONS='.$this->xFrameOptions, + 'SECURITY_REFERRER_POLICY='.$this->referrerPolicy, ]); return implode("\n", $lines); @@ -367,7 +381,7 @@ class HeaderConfigurationManager extends Component $parts = []; foreach ($directives as $directive => $sources) { - $parts[] = $directive . ' ' . implode(' ', $sources); + $parts[] = $directive.' '.implode(' ', $sources); } return implode('; ', $parts); diff --git a/packages/core-php/src/Core/Headers/SecurityHeaders.php b/src/Core/Headers/SecurityHeaders.php similarity index 99% rename from packages/core-php/src/Core/Headers/SecurityHeaders.php rename to src/Core/Headers/SecurityHeaders.php index 7cb8851..bdb686f 100644 --- a/packages/core-php/src/Core/Headers/SecurityHeaders.php +++ b/src/Core/Headers/SecurityHeaders.php @@ -1,4 +1,5 @@ nonceService = $nonceService ?? App::make(CspNonceService::class); diff --git a/packages/core-php/src/Core/Headers/Testing/HeaderAssertions.php b/src/Core/Headers/Testing/HeaderAssertions.php similarity index 99% rename from packages/core-php/src/Core/Headers/Testing/HeaderAssertions.php rename to src/Core/Headers/Testing/HeaderAssertions.php index 31adce5..e413665 100644 --- a/packages/core-php/src/Core/Headers/Testing/HeaderAssertions.php +++ b/src/Core/Headers/Testing/HeaderAssertions.php @@ -1,4 +1,5 @@ filter($_GET ?? []); $_POST = $sanitiser->filter($_POST ?? []); diff --git a/packages/core-php/src/Core/Input/Sanitiser.php b/src/Core/Input/Sanitiser.php similarity index 86% rename from packages/core-php/src/Core/Input/Sanitiser.php rename to src/Core/Input/Sanitiser.php index 8fe50cc..d96f30d 100644 --- a/packages/core-php/src/Core/Input/Sanitiser.php +++ b/src/Core/Input/Sanitiser.php @@ -1,4 +1,5 @@ $schema Per-field filter rules - * @param LoggerInterface|null $logger Optional PSR-3 logger for audit logging - * @param bool $auditEnabled Whether to enable audit logging (requires logger) - * @param bool $normalizeUnicode Whether to normalize Unicode to NFC form - * @param int $maxLength Global maximum input length (0 = unlimited) - * @param string $allowedHtmlTags Global allowed HTML tags (empty = strip all) + * @param array $schema Per-field filter rules + * @param LoggerInterface|null $logger Optional PSR-3 logger for audit logging + * @param bool $auditEnabled Whether to enable audit logging (requires logger) + * @param bool $normalizeUnicode Whether to normalize Unicode to NFC form + * @param int $maxLength Global maximum input length (0 = unlimited) + * @param string $allowedHtmlTags Global allowed HTML tags (empty = strip all) */ public function __construct( array $schema = [], @@ -217,8 +224,7 @@ class Sanitiser * ], * ] * - * @param array $schema - * @return static + * @param array $schema */ public function withSchema(array $schema): static { @@ -231,9 +237,7 @@ class Sanitiser /** * Set the logger for audit logging. * - * @param LoggerInterface $logger - * @param bool $enabled Whether to enable audit logging - * @return static + * @param bool $enabled Whether to enable audit logging */ public function withLogger(LoggerInterface $logger, bool $enabled = true): static { @@ -246,9 +250,6 @@ class Sanitiser /** * Enable or disable Unicode NFC normalization. - * - * @param bool $enabled - * @return static */ public function withNormalization(bool $enabled): static { @@ -264,8 +265,7 @@ class Sanitiser * Pass a string of allowed tags (e.g., '


') or * use one of the predefined constants (SAFE_HTML_TAGS, BASIC_HTML_TAGS). * - * @param string $allowedTags Allowed HTML tags in strip_tags format - * @return static + * @param string $allowedTags Allowed HTML tags in strip_tags format */ public function allowHtml(string $allowedTags = self::SAFE_HTML_TAGS): static { @@ -280,8 +280,6 @@ class Sanitiser * * Allows common formatting tags: p, br, strong, em, a, ul, ol, li, * headings, blockquote, code, and pre. - * - * @return static */ public function richText(): static { @@ -292,8 +290,6 @@ class Sanitiser * Enable basic HTML mode with minimal formatting tags. * * Allows only: p, br, strong, em. - * - * @return static */ public function basicHtml(): static { @@ -306,8 +302,7 @@ class Sanitiser * Inputs exceeding this length will be truncated. * Set to 0 for unlimited length. * - * @param int $maxLength Maximum length in characters (0 = unlimited) - * @return static + * @param int $maxLength Maximum length in characters (0 = unlimited) */ public function maxLength(int $maxLength): static { @@ -323,8 +318,7 @@ class Sanitiser * Sanitises using FILTER_SANITIZE_EMAIL and lowercases the result. * Use for fields that should contain valid email addresses. * - * @param string ...$fields Field names to apply the preset to (empty = all fields) - * @return static + * @param string ...$fields Field names to apply the preset to (empty = all fields) */ public function email(string ...$fields): static { @@ -337,8 +331,7 @@ class Sanitiser * Sanitises using FILTER_SANITIZE_URL. * Use for fields that should contain valid URLs. * - * @param string ...$fields Field names to apply the preset to (empty = all fields) - * @return static + * @param string ...$fields Field names to apply the preset to (empty = all fields) */ public function url(string ...$fields): static { @@ -351,8 +344,7 @@ class Sanitiser * Keeps only digits, plus signs, hyphens, parentheses, and spaces. * Use for fields that should contain phone numbers. * - * @param string ...$fields Field names to apply the preset to (empty = all fields) - * @return static + * @param string ...$fields Field names to apply the preset to (empty = all fields) */ public function phone(string ...$fields): static { @@ -365,8 +357,7 @@ class Sanitiser * Keeps only alphabetic characters (including Unicode letters). * Use for fields like names that should only contain letters. * - * @param string ...$fields Field names to apply the preset to (empty = all fields) - * @return static + * @param string ...$fields Field names to apply the preset to (empty = all fields) */ public function alpha(string ...$fields): static { @@ -379,8 +370,7 @@ class Sanitiser * Keeps only alphanumeric characters (including Unicode). * Use for usernames, codes, or similar fields. * - * @param string ...$fields Field names to apply the preset to (empty = all fields) - * @return static + * @param string ...$fields Field names to apply the preset to (empty = all fields) */ public function alphanumeric(string ...$fields): static { @@ -393,8 +383,7 @@ class Sanitiser * Keeps only digits, decimal points, and minus signs. * Use for numeric input fields. * - * @param string ...$fields Field names to apply the preset to (empty = all fields) - * @return static + * @param string ...$fields Field names to apply the preset to (empty = all fields) */ public function numeric(string ...$fields): static { @@ -407,8 +396,7 @@ class Sanitiser * Lowercases and keeps only lowercase letters, numbers, and hyphens. * Use for URL slugs, identifiers, or similar fields. * - * @param string ...$fields Field names to apply the preset to (empty = all fields) - * @return static + * @param string ...$fields Field names to apply the preset to (empty = all fields) */ public function slug(string ...$fields): static { @@ -418,8 +406,8 @@ class Sanitiser /** * Register a custom preset. * - * @param string $name Preset name - * @param array{pattern?: string, filter?: int, transform?: callable(string): string} $definition + * @param string $name Preset name + * @param array{pattern?: string, filter?: int, transform?: callable(string): string} $definition */ public static function registerPreset(string $name, array $definition): void { @@ -439,9 +427,7 @@ class Sanitiser /** * Apply a preset to specified fields or globally. * - * @param string $presetName - * @param array $fields - * @return static + * @param array $fields */ protected function applyPresetToFields(string $presetName, array $fields): static { @@ -477,7 +463,6 @@ class Sanitiser * the transformed value. Multiple hooks are executed in order. * * @param callable(string, string): string $callback - * @return static */ public function beforeFilter(callable $callback): static { @@ -494,7 +479,6 @@ class Sanitiser * the transformed value. Multiple hooks are executed in order. * * @param callable(string, string): string $callback - * @return static */ public function afterFilter(callable $callback): static { @@ -515,13 +499,12 @@ class Sanitiser * @param string $field Field name to transform * @param callable(string): string $callback * @param string $stage When to run: 'before' or 'after' - * @return static */ public function transformField(string $field, callable $callback, string $stage = 'after'): static { $clone = clone $this; - if (!isset($clone->fieldHooks[$field])) { + if (! isset($clone->fieldHooks[$field])) { $clone->fieldHooks[$field] = ['before' => [], 'after' => []]; } @@ -536,7 +519,6 @@ class Sanitiser * * @param callable(string): string $callback * @param string ...$fields Field names to apply the hook to - * @return static */ public function beforeFilterFields(callable $callback, string ...$fields): static { @@ -554,7 +536,6 @@ class Sanitiser * * @param callable(string): string $callback * @param string ...$fields Field names to apply the hook to - * @return static */ public function afterFilterFields(callable $callback, string ...$fields): static { @@ -572,7 +553,6 @@ class Sanitiser * * @param string $value The value to transform * @param string $fieldName The field name - * @return string */ protected function applyBeforeHooks(string $value, string $fieldName): string { @@ -596,7 +576,6 @@ class Sanitiser * * @param string $value The value to transform * @param string $fieldName The field name - * @return string */ protected function applyAfterHooks(string $value, string $fieldName): string { @@ -620,9 +599,9 @@ class Sanitiser */ public function hasTransformationHooks(): bool { - return !empty($this->beforeHooks) - || !empty($this->afterHooks) - || !empty($this->fieldHooks); + return ! empty($this->beforeHooks) + || ! empty($this->afterHooks) + || ! empty($this->fieldHooks); } /** @@ -665,16 +644,14 @@ class Sanitiser /** * Recursively filter array values. * - * @param array $input - * @param string $path Current path for nested arrays (for logging) - * @return array + * @param string $path Current path for nested arrays (for logging) */ protected function filterRecursive(array $input, string $path = ''): array { $result = []; foreach ($input as $key => $value) { - $currentPath = $path === '' ? (string) $key : $path . '.' . $key; + $currentPath = $path === '' ? (string) $key : $path.'.'.$key; if (is_array($value)) { // Recursively filter nested arrays @@ -705,10 +682,8 @@ class Sanitiser * 8. After hooks (global, then field-specific) * 9. Audit logging (if enabled and value changed) * - * @param string $value - * @param string $path Full path for logging - * @param string $fieldName Top-level field name for schema lookup - * @return string + * @param string $path Full path for logging + * @param string $fieldName Top-level field name for schema lookup */ protected function filterString(string $value, string $path, string $fieldName): string { @@ -724,7 +699,7 @@ class Sanitiser // Step 1: Unicode NFC normalization (unless skipped) $skipNormalize = $effectiveSchema['skip_normalize'] ?? false; - if ($this->normalizeUnicode && !$skipNormalize && $this->isNormalizerAvailable()) { + if ($this->normalizeUnicode && ! $skipNormalize && $this->isNormalizerAvailable()) { $normalized = Normalizer::normalize($value, Normalizer::FORM_C); if ($normalized !== false) { $value = $normalized; @@ -733,7 +708,7 @@ class Sanitiser // Step 2: Strip control characters (unless skipped) $skipControlStrip = $effectiveSchema['skip_control_strip'] ?? false; - if (!$skipControlStrip) { + if (! $skipControlStrip) { $value = filter_var($value, FILTER_UNSAFE_RAW, FILTER_FLAG_STRIP_LOW) ?? ''; } @@ -778,15 +753,14 @@ class Sanitiser /** * Apply a preset to a value. * - * @param string $value The value to transform - * @param array $schema The effective schema containing preset configuration - * @return string + * @param string $value The value to transform + * @param array $schema The effective schema containing preset configuration */ protected function applyPreset(string $value, array $schema): string { $presetName = $schema['preset'] ?? null; - if ($presetName === null || !isset(self::$presets[$presetName])) { + if ($presetName === null || ! isset(self::$presets[$presetName])) { return $value; } @@ -821,9 +795,8 @@ class Sanitiser /** * Filter HTML from value based on configuration. * - * @param string $value The value to filter - * @param array $effectiveSchema Effective schema (merged global + field) - * @return string + * @param string $value The value to filter + * @param array $effectiveSchema Effective schema (merged global + field) */ protected function filterHtml(string $value, array $effectiveSchema): string { @@ -855,9 +828,8 @@ class Sanitiser /** * Enforce maximum length on value. * - * @param string $value The value to truncate - * @param array $effectiveSchema Effective schema (merged global + field) - * @return string + * @param string $value The value to truncate + * @param array $effectiveSchema Effective schema (merged global + field) */ protected function enforceMaxLength(string $value, array $effectiveSchema): string { @@ -885,19 +857,19 @@ class Sanitiser /** * Log when content is modified during sanitisation. * - * @param string $path Field path - * @param string $original Original value - * @param string $sanitised Sanitised value + * @param string $path Field path + * @param string $original Original value + * @param string $sanitised Sanitised value */ protected function logSanitisation(string $path, string $original, string $sanitised): void { // Truncate long values for logging $maxLength = 100; $originalTruncated = mb_strlen($original) > $maxLength - ? mb_substr($original, 0, $maxLength) . '...' + ? mb_substr($original, 0, $maxLength).'...' : $original; $sanitisedTruncated = mb_strlen($sanitised) > $maxLength - ? mb_substr($sanitised, 0, $maxLength) . '...' + ? mb_substr($sanitised, 0, $maxLength).'...' : $sanitised; // Convert control characters to visible representation for logging @@ -915,9 +887,6 @@ class Sanitiser /** * Convert control characters to visible Unicode representation. - * - * @param string $value - * @return string */ protected function makeControlCharsVisible(string $value): string { @@ -931,8 +900,6 @@ class Sanitiser /** * Check if the Normalizer class is available. - * - * @return bool */ protected function isNormalizerAvailable(): bool { diff --git a/packages/core-php/src/Core/Input/Tests/Unit/InputFilteringTest.php b/src/Core/Input/Tests/Unit/InputFilteringTest.php similarity index 99% rename from packages/core-php/src/Core/Input/Tests/Unit/InputFilteringTest.php rename to src/Core/Input/Tests/Unit/InputFilteringTest.php index c30ca2f..645868a 100644 --- a/packages/core-php/src/Core/Input/Tests/Unit/InputFilteringTest.php +++ b/src/Core/Input/Tests/Unit/InputFilteringTest.php @@ -1,4 +1,5 @@ line(" ".sprintf('%.0f%%', $similarity * 100).' match ('.$category.')'); $this->line(" Source: {$entry->getSource()}"); $this->line(" Target: {$entry->getTarget()}"); - $this->line(" Confidence: ".sprintf('%.0f%%', $confidence * 100).", Quality: ".sprintf('%.0f%%', $entry->getQuality() * 100).''); + $this->line(' Confidence: '.sprintf('%.0f%%', $confidence * 100).', Quality: '.sprintf('%.0f%%', $entry->getQuality() * 100).''); $this->newLine(); } diff --git a/packages/core-php/src/Core/Lang/Coverage/TranslationCoverage.php b/src/Core/Lang/Coverage/TranslationCoverage.php similarity index 98% rename from packages/core-php/src/Core/Lang/Coverage/TranslationCoverage.php rename to src/Core/Lang/Coverage/TranslationCoverage.php index f314a52..d4cb426 100644 --- a/packages/core-php/src/Core/Lang/Coverage/TranslationCoverage.php +++ b/src/Core/Lang/Coverage/TranslationCoverage.php @@ -1,4 +1,5 @@ $locales Locales to scan * @param string|null $namespace Optional namespace filter - * @return array>> [locale => [key => [file]]] + * @return array>> [locale => [key => [file]]] */ public function loadDefinedKeys(string $langPath, array $locales, ?string $namespace = null): array { @@ -345,7 +343,7 @@ class TranslationCoverage */ protected function generateReport(Collection $usedKeys, array $definedKeys, array $locales): TranslationCoverageReport { - $report = new TranslationCoverageReport(); + $report = new TranslationCoverageReport; // Get all used key names $usedKeyNames = $usedKeys->keys()->all(); diff --git a/packages/core-php/src/Core/Lang/Coverage/TranslationCoverageReport.php b/src/Core/Lang/Coverage/TranslationCoverageReport.php similarity index 99% rename from packages/core-php/src/Core/Lang/Coverage/TranslationCoverageReport.php rename to src/Core/Lang/Coverage/TranslationCoverageReport.php index 85aa6c7..2f40dfb 100644 --- a/packages/core-php/src/Core/Lang/Coverage/TranslationCoverageReport.php +++ b/src/Core/Lang/Coverage/TranslationCoverageReport.php @@ -1,4 +1,5 @@ $entries + * @param iterable $entries * @return int Number of entries stored */ public function storeBatch(iterable $entries): int; @@ -40,9 +41,9 @@ interface TranslationMemoryRepository /** * Find an exact match for the source text. * - * @param string $source Source text to find - * @param string $sourceLocale Source locale - * @param string $targetLocale Target locale + * @param string $source Source text to find + * @param string $sourceLocale Source locale + * @param string $targetLocale Target locale * @return TranslationMemoryEntry|null The matching entry or null */ public function findExact(string $source, string $sourceLocale, string $targetLocale): ?TranslationMemoryEntry; @@ -50,16 +51,15 @@ interface TranslationMemoryRepository /** * Find entries by ID. * - * @param string $id Entry ID - * @return TranslationMemoryEntry|null + * @param string $id Entry ID */ public function findById(string $id): ?TranslationMemoryEntry; /** * Find all entries for a locale pair. * - * @param string $sourceLocale Source locale - * @param string $targetLocale Target locale + * @param string $sourceLocale Source locale + * @param string $targetLocale Target locale * @return Collection */ public function findByLocalePair(string $sourceLocale, string $targetLocale): Collection; @@ -67,10 +67,10 @@ interface TranslationMemoryRepository /** * Search entries containing the given text. * - * @param string $query Search query - * @param string|null $sourceLocale Optional source locale filter - * @param string|null $targetLocale Optional target locale filter - * @param int $limit Maximum results to return + * @param string $query Search query + * @param string|null $sourceLocale Optional source locale filter + * @param string|null $targetLocale Optional target locale filter + * @param int $limit Maximum results to return * @return Collection */ public function search(string $query, ?string $sourceLocale = null, ?string $targetLocale = null, int $limit = 50): Collection; @@ -85,7 +85,7 @@ interface TranslationMemoryRepository /** * Delete an entry by ID. * - * @param string $id Entry ID + * @param string $id Entry ID * @return bool True if deleted */ public function delete(string $id): bool; @@ -93,8 +93,8 @@ interface TranslationMemoryRepository /** * Delete all entries for a locale pair. * - * @param string $sourceLocale Source locale - * @param string $targetLocale Target locale + * @param string $sourceLocale Source locale + * @param string $targetLocale Target locale * @return int Number of entries deleted */ public function deleteByLocalePair(string $sourceLocale, string $targetLocale): int; @@ -109,9 +109,8 @@ interface TranslationMemoryRepository /** * Get the total count of entries. * - * @param string|null $sourceLocale Optional source locale filter - * @param string|null $targetLocale Optional target locale filter - * @return int + * @param string|null $sourceLocale Optional source locale filter + * @param string|null $targetLocale Optional target locale filter */ public function count(?string $sourceLocale = null, ?string $targetLocale = null): int; @@ -139,7 +138,7 @@ interface TranslationMemoryRepository /** * Increment the usage count for an entry. * - * @param string $id Entry ID + * @param string $id Entry ID * @return bool True if incremented */ public function incrementUsage(string $id): bool; @@ -147,8 +146,8 @@ interface TranslationMemoryRepository /** * Update an entry's quality score. * - * @param string $id Entry ID - * @param float $quality New quality score + * @param string $id Entry ID + * @param float $quality New quality score * @return bool True if updated */ public function updateQuality(string $id, float $quality): bool; diff --git a/packages/core-php/src/Core/Lang/TranslationMemory/FuzzyMatcher.php b/src/Core/Lang/TranslationMemory/FuzzyMatcher.php similarity index 93% rename from packages/core-php/src/Core/Lang/TranslationMemory/FuzzyMatcher.php rename to src/Core/Lang/TranslationMemory/FuzzyMatcher.php index 1efdf7c..0f01f27 100644 --- a/packages/core-php/src/Core/Lang/TranslationMemory/FuzzyMatcher.php +++ b/src/Core/Lang/TranslationMemory/FuzzyMatcher.php @@ -1,4 +1,5 @@ */ public function findSimilar( @@ -115,10 +116,10 @@ class FuzzyMatcher /** * Get the best match for a source text. * - * @param string $source Source text to match - * @param string $sourceLocale Source locale - * @param string $targetLocale Target locale - * @param float|null $minSimilarity Minimum similarity threshold + * @param string $source Source text to match + * @param string $sourceLocale Source locale + * @param string $targetLocale Target locale + * @param float|null $minSimilarity Minimum similarity threshold * @return array{entry: TranslationMemoryEntry, similarity: float, confidence: float}|null */ public function getBestMatch( @@ -338,9 +339,8 @@ class FuzzyMatcher /** * Calculate Levenshtein distance for arrays (word-level). * - * @param array $a - * @param array $b - * @return int + * @param array $a + * @param array $b */ protected function arrayLevenshtein(array $a, array $b): int { @@ -382,10 +382,10 @@ class FuzzyMatcher /** * Suggest translations for multiple source texts. * - * @param array $sources Source texts to match - * @param string $sourceLocale Source locale - * @param string $targetLocale Target locale - * @param float|null $minSimilarity Minimum similarity threshold + * @param array $sources Source texts to match + * @param string $sourceLocale Source locale + * @param string $targetLocale Target locale + * @param float|null $minSimilarity Minimum similarity threshold * @return array */ public function suggestBatch( diff --git a/packages/core-php/src/Core/Lang/TranslationMemory/JsonTranslationMemoryRepository.php b/src/Core/Lang/TranslationMemory/JsonTranslationMemoryRepository.php similarity index 99% rename from packages/core-php/src/Core/Lang/TranslationMemory/JsonTranslationMemoryRepository.php rename to src/Core/Lang/TranslationMemory/JsonTranslationMemoryRepository.php index b583952..e38fb9a 100644 --- a/packages/core-php/src/Core/Lang/TranslationMemory/JsonTranslationMemoryRepository.php +++ b/src/Core/Lang/TranslationMemory/JsonTranslationMemoryRepository.php @@ -1,4 +1,5 @@ $entries Entries to export - * @param string $sourceLocale Primary source locale - * @param array $options Export options + * @param string $filePath Output file path + * @param Collection $entries Entries to export + * @param string $sourceLocale Primary source locale + * @param array $options Export options * @return array{ * success: bool, * exported: int, @@ -207,9 +208,9 @@ class TmxExporter /** * Convert entries to TMX XML string. * - * @param Collection $entries - * @param string $sourceLocale Primary source locale - * @param array $options Export options + * @param Collection $entries + * @param string $sourceLocale Primary source locale + * @param array $options Export options * @return string TMX XML content */ protected function entriesToTmx(Collection $entries, string $sourceLocale, array $options): string @@ -234,7 +235,7 @@ class TmxExporter $header->setAttribute('adminlang', 'en'); $header->setAttribute('srclang', $this->formatLocale($sourceLocale)); $header->setAttribute('datatype', 'plaintext'); - $header->setAttribute('creationdate', (new DateTimeImmutable())->format('Ymd\THis\Z')); + $header->setAttribute('creationdate', (new DateTimeImmutable)->format('Ymd\THis\Z')); if (! empty($creator)) { $header->setAttribute('creationid', $creator); @@ -260,7 +261,7 @@ class TmxExporter /** * Group entries by source text for multi-language TUs. * - * @param Collection $entries + * @param Collection $entries * @return array> */ protected function groupEntriesBySource(Collection $entries): array @@ -294,12 +295,6 @@ class TmxExporter /** * Create a translation unit element. - * - * @param DOMDocument $dom - * @param string $sourceKey - * @param array $data - * @param bool $includeMetadata - * @return DOMElement */ protected function createTu(DOMDocument $dom, string $sourceKey, array $data, bool $includeMetadata): DOMElement { @@ -349,8 +344,8 @@ class TmxExporter /** * Get export statistics for a locale pair. * - * @param string $sourceLocale Source locale - * @param string $targetLocale Target locale + * @param string $sourceLocale Source locale + * @param string $targetLocale Target locale * @return array{ * total_entries: int, * high_quality: int, diff --git a/packages/core-php/src/Core/Lang/TranslationMemory/TmxImporter.php b/src/Core/Lang/TranslationMemory/TmxImporter.php similarity index 95% rename from packages/core-php/src/Core/Lang/TranslationMemory/TmxImporter.php rename to src/Core/Lang/TranslationMemory/TmxImporter.php index 80ed2b0..4df396c 100644 --- a/packages/core-php/src/Core/Lang/TranslationMemory/TmxImporter.php +++ b/src/Core/Lang/TranslationMemory/TmxImporter.php @@ -1,4 +1,5 @@ loadXML($content)) { $errors = libxml_get_errors(); @@ -190,12 +191,12 @@ class TmxImporter /** * Parse a translation unit (tu) element. * - * @param DOMElement $tu The tu element - * @param DOMXPath $xpath XPath instance - * @param string|null $sourceLocaleFilter Optional source locale filter - * @param string|null $targetLocaleFilter Optional target locale filter - * @param float $defaultQuality Default quality score - * @param array $additionalMetadata Additional metadata + * @param DOMElement $tu The tu element + * @param DOMXPath $xpath XPath instance + * @param string|null $sourceLocaleFilter Optional source locale filter + * @param string|null $targetLocaleFilter Optional target locale filter + * @param float $defaultQuality Default quality score + * @param array $additionalMetadata Additional metadata * @return array{entries: array, locales: array}|null */ protected function parseTu( @@ -431,7 +432,7 @@ class TmxImporter /** * Validate a TMX file without importing. * - * @param string $filePath Path to the TMX file + * @param string $filePath Path to the TMX file * @return array{ * valid: bool, * version: string|null, @@ -459,7 +460,7 @@ class TmxImporter $content = File::get($filePath); libxml_use_internal_errors(true); - $dom = new DOMDocument(); + $dom = new DOMDocument; if (! $dom->loadXML($content)) { $errors = libxml_get_errors(); diff --git a/packages/core-php/src/Core/Lang/TranslationMemory/TranslationMemory.php b/src/Core/Lang/TranslationMemory/TranslationMemory.php similarity index 79% rename from packages/core-php/src/Core/Lang/TranslationMemory/TranslationMemory.php rename to src/Core/Lang/TranslationMemory/TranslationMemory.php index a4c630e..3297c42 100644 --- a/packages/core-php/src/Core/Lang/TranslationMemory/TranslationMemory.php +++ b/src/Core/Lang/TranslationMemory/TranslationMemory.php @@ -1,4 +1,5 @@ $metadata Additional metadata + * @param string $source Source text + * @param string $target Translated text + * @param string $sourceLocale Source locale + * @param string $targetLocale Target locale + * @param float $quality Quality score (0.0-1.0) + * @param array $metadata Additional metadata * @return TranslationMemoryEntry The stored entry */ public function store( @@ -120,9 +121,9 @@ class TranslationMemory /** * Store multiple translations at once. * - * @param array $translations - * @param string|null $defaultSourceLocale Default source locale - * @param string|null $defaultTargetLocale Default target locale + * @param array $translations + * @param string|null $defaultSourceLocale Default source locale + * @param string|null $defaultTargetLocale Default target locale * @return int Number of entries stored */ public function storeBatch( @@ -153,9 +154,9 @@ class TranslationMemory /** * Get an exact match translation. * - * @param string $source Source text to translate - * @param string $sourceLocale Source locale - * @param string $targetLocale Target locale + * @param string $source Source text to translate + * @param string $sourceLocale Source locale + * @param string $targetLocale Target locale * @return string|null The translation or null if not found */ public function get(string $source, string $sourceLocale, string $targetLocale): ?string @@ -175,10 +176,9 @@ class TranslationMemory /** * Get an exact match entry with full metadata. * - * @param string $source Source text - * @param string $sourceLocale Source locale - * @param string $targetLocale Target locale - * @return TranslationMemoryEntry|null + * @param string $source Source text + * @param string $sourceLocale Source locale + * @param string $targetLocale Target locale */ public function getEntry(string $source, string $sourceLocale, string $targetLocale): ?TranslationMemoryEntry { @@ -190,11 +190,11 @@ class TranslationMemory * * Returns similar translations when an exact match is not found. * - * @param string $source Source text to translate - * @param string $sourceLocale Source locale - * @param string $targetLocale Target locale - * @param float|null $minSimilarity Minimum similarity threshold (0.0-1.0) - * @param int|null $maxResults Maximum number of suggestions + * @param string $source Source text to translate + * @param string $sourceLocale Source locale + * @param string $targetLocale Target locale + * @param float|null $minSimilarity Minimum similarity threshold (0.0-1.0) + * @param int|null $maxResults Maximum number of suggestions * @return Collection */ public function suggest( @@ -218,10 +218,10 @@ class TranslationMemory * * First checks for an exact match, then falls back to fuzzy matching. * - * @param string $source Source text to translate - * @param string $sourceLocale Source locale - * @param string $targetLocale Target locale - * @param float|null $minSimilarity Minimum similarity for fuzzy match + * @param string $source Source text to translate + * @param string $sourceLocale Source locale + * @param string $targetLocale Target locale + * @param float|null $minSimilarity Minimum similarity for fuzzy match * @return array{translation: string, similarity: float, confidence: float, is_exact: bool}|null */ public function getBestMatch( @@ -254,10 +254,10 @@ class TranslationMemory * * Returns the best available translation or the original text. * - * @param string $source Source text to translate - * @param string $sourceLocale Source locale - * @param string $targetLocale Target locale - * @param float|null $minSimilarity Minimum similarity threshold + * @param string $source Source text to translate + * @param string $sourceLocale Source locale + * @param string $targetLocale Target locale + * @param float|null $minSimilarity Minimum similarity threshold * @return array{text: string, matched: bool, similarity: float|null} */ public function translate( @@ -286,10 +286,10 @@ class TranslationMemory /** * Update the quality score for a translation. * - * @param string $source Source text - * @param string $sourceLocale Source locale - * @param string $targetLocale Target locale - * @param float $quality New quality score (0.0-1.0) + * @param string $source Source text + * @param string $sourceLocale Source locale + * @param string $targetLocale Target locale + * @param float $quality New quality score (0.0-1.0) * @return bool True if updated */ public function updateQuality( @@ -310,9 +310,9 @@ class TranslationMemory /** * Delete a translation from memory. * - * @param string $source Source text - * @param string $sourceLocale Source locale - * @param string $targetLocale Target locale + * @param string $source Source text + * @param string $sourceLocale Source locale + * @param string $targetLocale Target locale * @return bool True if deleted */ public function delete(string $source, string $sourceLocale, string $targetLocale): bool @@ -329,10 +329,9 @@ class TranslationMemory /** * Check if a translation exists. * - * @param string $source Source text - * @param string $sourceLocale Source locale - * @param string $targetLocale Target locale - * @return bool + * @param string $source Source text + * @param string $sourceLocale Source locale + * @param string $targetLocale Target locale */ public function has(string $source, string $sourceLocale, string $targetLocale): bool { @@ -342,10 +341,10 @@ class TranslationMemory /** * Search the translation memory. * - * @param string $query Search query - * @param string|null $sourceLocale Optional source locale filter - * @param string|null $targetLocale Optional target locale filter - * @param int $limit Maximum results + * @param string $query Search query + * @param string|null $sourceLocale Optional source locale filter + * @param string|null $targetLocale Optional target locale filter + * @param int $limit Maximum results * @return Collection */ public function search( @@ -360,8 +359,8 @@ class TranslationMemory /** * Get all translations for a locale pair. * - * @param string $sourceLocale Source locale - * @param string $targetLocale Target locale + * @param string $sourceLocale Source locale + * @param string $targetLocale Target locale * @return Collection */ public function getByLocalePair(string $sourceLocale, string $targetLocale): Collection @@ -372,8 +371,8 @@ class TranslationMemory /** * Get translations needing review (low quality). * - * @param string|null $sourceLocale Optional source locale filter - * @param string|null $targetLocale Optional target locale filter + * @param string|null $sourceLocale Optional source locale filter + * @param string|null $targetLocale Optional target locale filter * @return Collection */ public function getNeedsReview(?string $sourceLocale = null, ?string $targetLocale = null): Collection @@ -388,7 +387,7 @@ class TranslationMemory /** * Import translations from a TMX file. * - * @param string $filePath Path to the TMX file + * @param string $filePath Path to the TMX file * @param array{ * source_locale?: string, * target_locale?: string, @@ -411,9 +410,8 @@ class TranslationMemory /** * Import translations from TMX content. * - * @param string $content TMX content - * @param array $options Import options - * @return array + * @param string $content TMX content + * @param array $options Import options */ public function importTmxContent(string $content, array $options = []): array { @@ -423,9 +421,9 @@ class TranslationMemory /** * Export translations to a TMX file. * - * @param string $filePath Output file path - * @param string $sourceLocale Source locale - * @param string $targetLocale Target locale + * @param string $filePath Output file path + * @param string $sourceLocale Source locale + * @param string $targetLocale Target locale * @param array{ * include_metadata?: bool, * min_quality?: float, @@ -450,9 +448,8 @@ class TranslationMemory /** * Export all translations to a TMX file. * - * @param string $filePath Output file path - * @param array $options Export options - * @return array + * @param string $filePath Output file path + * @param array $options Export options */ public function exportAllTmx(string $filePath, array $options = []): array { @@ -462,9 +459,9 @@ class TranslationMemory /** * Get TMX content for a locale pair. * - * @param string $sourceLocale Source locale - * @param string $targetLocale Target locale - * @param array $options Export options + * @param string $sourceLocale Source locale + * @param string $targetLocale Target locale + * @param array $options Export options * @return string TMX content */ public function toTmx(string $sourceLocale, string $targetLocale, array $options = []): string @@ -475,7 +472,7 @@ class TranslationMemory /** * Validate a TMX file. * - * @param string $filePath Path to the TMX file + * @param string $filePath Path to the TMX file * @return array{ * valid: bool, * version: string|null, @@ -519,9 +516,8 @@ class TranslationMemory /** * Get the count of translations. * - * @param string|null $sourceLocale Optional source locale filter - * @param string|null $targetLocale Optional target locale filter - * @return int + * @param string|null $sourceLocale Optional source locale filter + * @param string|null $targetLocale Optional target locale filter */ public function count(?string $sourceLocale = null, ?string $targetLocale = null): int { @@ -531,8 +527,8 @@ class TranslationMemory /** * Clear all translations for a locale pair. * - * @param string $sourceLocale Source locale - * @param string $targetLocale Target locale + * @param string $sourceLocale Source locale + * @param string $targetLocale Target locale * @return int Number of entries deleted */ public function clearLocalePair(string $sourceLocale, string $targetLocale): int @@ -552,8 +548,6 @@ class TranslationMemory /** * Get the underlying repository. - * - * @return TranslationMemoryRepository */ public function getRepository(): TranslationMemoryRepository { @@ -562,8 +556,6 @@ class TranslationMemory /** * Get the fuzzy matcher. - * - * @return FuzzyMatcher */ public function getFuzzyMatcher(): FuzzyMatcher { @@ -573,8 +565,8 @@ class TranslationMemory /** * Calculate similarity between two strings. * - * @param string $a First string - * @param string $b Second string + * @param string $a First string + * @param string $b Second string * @return float Similarity score (0.0-1.0) */ public function calculateSimilarity(string $a, string $b): float diff --git a/packages/core-php/src/Core/Lang/TranslationMemory/TranslationMemoryEntry.php b/src/Core/Lang/TranslationMemory/TranslationMemoryEntry.php similarity index 88% rename from packages/core-php/src/Core/Lang/TranslationMemory/TranslationMemoryEntry.php rename to src/Core/Lang/TranslationMemory/TranslationMemoryEntry.php index be8fa08..2f74dd4 100644 --- a/packages/core-php/src/Core/Lang/TranslationMemory/TranslationMemoryEntry.php +++ b/src/Core/Lang/TranslationMemory/TranslationMemoryEntry.php @@ -1,4 +1,5 @@ $metadata Additional metadata (context, domain, etc.) - * @param int $usageCount How many times this translation has been used + * @param string $id Unique identifier for this entry + * @param string $sourceLocale Source language locale (e.g., 'en_GB') + * @param string $targetLocale Target language locale (e.g., 'de_DE') + * @param string $source Source text to translate + * @param string $target Translated text + * @param float $quality Quality/confidence score (0.0-1.0) + * @param DateTimeImmutable|null $createdAt When the entry was created + * @param DateTimeImmutable|null $updatedAt When the entry was last updated + * @param array $metadata Additional metadata (context, domain, etc.) + * @param int $usageCount How many times this translation has been used */ public function __construct( protected string $id, @@ -57,15 +58,15 @@ class TranslationMemoryEntry implements Arrayable, JsonSerializable protected array $metadata = [], protected int $usageCount = 0, ) { - $this->createdAt ??= new DateTimeImmutable(); - $this->updatedAt ??= new DateTimeImmutable(); + $this->createdAt ??= new DateTimeImmutable; + $this->updatedAt ??= new DateTimeImmutable; $this->quality = max(0.0, min(1.0, $quality)); } /** * Create an entry from an array. * - * @param array $data + * @param array $data */ public static function fromArray(array $data): static { @@ -194,7 +195,7 @@ class TranslationMemoryEntry implements Arrayable, JsonSerializable target: $target, quality: $this->quality, createdAt: $this->createdAt, - updatedAt: new DateTimeImmutable(), + updatedAt: new DateTimeImmutable, metadata: $this->metadata, usageCount: $this->usageCount, ); @@ -213,7 +214,7 @@ class TranslationMemoryEntry implements Arrayable, JsonSerializable target: $this->target, quality: $quality, createdAt: $this->createdAt, - updatedAt: new DateTimeImmutable(), + updatedAt: new DateTimeImmutable, metadata: $this->metadata, usageCount: $this->usageCount, ); @@ -222,7 +223,7 @@ class TranslationMemoryEntry implements Arrayable, JsonSerializable /** * Create a new instance with additional metadata. * - * @param array $metadata + * @param array $metadata */ public function withMetadata(array $metadata): static { @@ -234,7 +235,7 @@ class TranslationMemoryEntry implements Arrayable, JsonSerializable target: $this->target, quality: $this->quality, createdAt: $this->createdAt, - updatedAt: new DateTimeImmutable(), + updatedAt: new DateTimeImmutable, metadata: array_merge($this->metadata, $metadata), usageCount: $this->usageCount, ); diff --git a/packages/core-php/src/Core/Lang/en_GB/core.php b/src/Core/Lang/en_GB/core.php similarity index 99% rename from packages/core-php/src/Core/Lang/en_GB/core.php rename to src/Core/Lang/en_GB/core.php index 26b2a2a..a1fbe22 100644 --- a/packages/core-php/src/Core/Lang/en_GB/core.php +++ b/src/Core/Lang/en_GB/core.php @@ -1,4 +1,5 @@ $context Additional context - * @return static */ public static function started(string $filepath, string $engine, array $context = []): static { @@ -120,7 +118,6 @@ class ConversionProgress * @param int $percent Progress percentage (0-100) * @param string|null $message Optional status message * @param array $context Additional context - * @return static */ public static function processing( string $filepath, @@ -146,7 +143,6 @@ class ConversionProgress * @param string $engine Conversion engine name * @param string|null $outputPath Output file path * @param array $context Additional context - * @return static */ public static function completed( string $filepath, @@ -176,7 +172,6 @@ class ConversionProgress * @param string $engine Conversion engine name * @param string $error Error message * @param array $context Additional context - * @return static */ public static function failed( string $filepath, @@ -198,8 +193,6 @@ class ConversionProgress /** * Check if the conversion has started. - * - * @return bool */ public function isStarted(): bool { @@ -208,8 +201,6 @@ class ConversionProgress /** * Check if the conversion is processing. - * - * @return bool */ public function isProcessing(): bool { @@ -218,8 +209,6 @@ class ConversionProgress /** * Check if the conversion is completed. - * - * @return bool */ public function isCompleted(): bool { @@ -228,8 +217,6 @@ class ConversionProgress /** * Check if the conversion has failed. - * - * @return bool */ public function isFailed(): bool { @@ -238,8 +225,6 @@ class ConversionProgress /** * Check if this is a terminal state (completed or failed). - * - * @return bool */ public function isTerminal(): bool { @@ -248,8 +233,6 @@ class ConversionProgress /** * Get the output path from context if available. - * - * @return string|null */ public function getOutputPath(): ?string { @@ -258,8 +241,6 @@ class ConversionProgress /** * Get the error message from context if available. - * - * @return string|null */ public function getError(): ?string { diff --git a/packages/core-php/src/Core/Media/Image/ExifStripper.php b/src/Core/Media/Image/ExifStripper.php similarity index 99% rename from packages/core-php/src/Core/Media/Image/ExifStripper.php rename to src/Core/Media/Image/ExifStripper.php index 6528c20..99094ba 100644 --- a/packages/core-php/src/Core/Media/Image/ExifStripper.php +++ b/src/Core/Media/Image/ExifStripper.php @@ -1,4 +1,5 @@ readImageBlob($content); $imagick->stripImage(); diff --git a/packages/core-php/src/Core/Media/Image/ImageOptimization.php b/src/Core/Media/Image/ImageOptimization.php similarity index 99% rename from packages/core-php/src/Core/Media/Image/ImageOptimization.php rename to src/Core/Media/Image/ImageOptimization.php index 4c6b4da..1e9cdff 100644 --- a/packages/core-php/src/Core/Media/Image/ImageOptimization.php +++ b/src/Core/Media/Image/ImageOptimization.php @@ -1,4 +1,5 @@ conversionClass(); + $conversion = new $this->conversionClass; // Apply configuration if (isset($this->conversionConfig['filepath'])) { @@ -136,7 +136,7 @@ class ProcessMediaConversion implements ShouldQueue // Try to get engine name from the conversion class if (class_exists($this->conversionClass)) { try { - $tempConversion = new $this->conversionClass(); + $tempConversion = new $this->conversionClass; $engineName = $tempConversion->getEngineName(); } catch (\Throwable) { $engineName = class_basename($this->conversionClass); diff --git a/packages/core-php/src/Core/Media/Routes/web.php b/src/Core/Media/Routes/web.php similarity index 100% rename from packages/core-php/src/Core/Media/Routes/web.php rename to src/Core/Media/Routes/web.php diff --git a/packages/core-php/src/Core/Media/Support/ConversionProgressReporter.php b/src/Core/Media/Support/ConversionProgressReporter.php similarity index 96% rename from packages/core-php/src/Core/Media/Support/ConversionProgressReporter.php rename to src/Core/Media/Support/ConversionProgressReporter.php index 5acca53..3cd8eee 100644 --- a/packages/core-php/src/Core/Media/Support/ConversionProgressReporter.php +++ b/src/Core/Media/Support/ConversionProgressReporter.php @@ -1,4 +1,5 @@ execute(); * ``` - * - * @package Core\Media\Support */ class ConversionProgressReporter { @@ -179,7 +178,6 @@ class ConversionProgressReporter * Report that the conversion has started. * * @param string|null $message Optional message - * @return void */ public function start(?string $message = null): void { @@ -195,7 +193,6 @@ class ConversionProgressReporter * * @param int $percent Progress percentage (0-100) * @param string|null $message Optional status message - * @return void */ public function progress(int $percent, ?string $message = null): void { @@ -219,7 +216,6 @@ class ConversionProgressReporter * @param int $current Current item number * @param int $total Total items * @param string|null $message Optional status message - * @return void */ public function progressItems(int $current, int $total, ?string $message = null): void { @@ -236,7 +232,6 @@ class ConversionProgressReporter * * @param float $ratio Progress ratio (0.0 to 1.0) * @param string|null $message Optional status message - * @return void */ public function progressRatio(float $ratio, ?string $message = null): void { @@ -249,7 +244,6 @@ class ConversionProgressReporter * * @param string|null $outputPath Output file path * @param string|null $message Optional message - * @return void */ public function complete(?string $outputPath = null, ?string $message = null): void { @@ -265,7 +259,6 @@ class ConversionProgressReporter * * @param string $error Error message * @param \Throwable|null $exception Optional exception - * @return void */ public function fail(string $error, ?\Throwable $exception = null): void { @@ -285,7 +278,6 @@ class ConversionProgressReporter * Dispatch the event if events are enabled. * * @param ConversionProgress $event Event instance - * @return void */ protected function dispatch(ConversionProgress $event): void { @@ -300,7 +292,6 @@ class ConversionProgressReporter * @param int $percent Progress percentage * @param string $stage Progress stage * @param string|null $message Status message - * @return void */ protected function invokeCallback(int $percent, string $stage, ?string $message): void { @@ -311,8 +302,6 @@ class ConversionProgressReporter /** * Get the current progress percentage. - * - * @return int */ public function getCurrentPercent(): int { @@ -321,8 +310,6 @@ class ConversionProgressReporter /** * Get the file path. - * - * @return string */ public function getFilepath(): string { @@ -331,8 +318,6 @@ class ConversionProgressReporter /** * Get the engine name. - * - * @return string */ public function getEngine(): string { diff --git a/packages/core-php/src/Core/Media/Support/ImageResizer.php b/src/Core/Media/Support/ImageResizer.php similarity index 99% rename from packages/core-php/src/Core/Media/Support/ImageResizer.php rename to src/Core/Media/Support/ImageResizer.php index 66a7dda..a2c5295 100644 --- a/packages/core-php/src/Core/Media/Support/ImageResizer.php +++ b/src/Core/Media/Support/ImageResizer.php @@ -1,4 +1,5 @@ addPaths([base_path('plugins/custom-module')]); * ``` * - * @package Core * * @see ModuleScanner For the discovery mechanism * @see LazyModuleListener For the lazy-loading wrapper diff --git a/packages/core-php/src/Core/ModuleScanner.php b/src/Core/ModuleScanner.php similarity index 97% rename from packages/core-php/src/Core/ModuleScanner.php rename to src/Core/ModuleScanner.php index f12d140..4542b5d 100644 --- a/packages/core-php/src/Core/ModuleScanner.php +++ b/src/Core/ModuleScanner.php @@ -1,4 +1,5 @@ [ModuleClass => ['method' => 'name', 'priority' => 0]]] * ``` * - * @package Core * * @see ModuleRegistry For registering discovered listeners with Laravel's event system * @see LazyModuleListener For the lazy-loading listener wrapper @@ -144,7 +144,7 @@ class ModuleScanner * Normalize listener declarations to consistent format. * * @param array $listens Raw listener declarations - * @return array Normalized declarations + * @return array Normalized declarations */ private function normalizeListens(array $listens): array { @@ -179,7 +179,7 @@ class ModuleScanner * * @param string $file Absolute path to the Boot.php file * @param string $basePath Base directory path (e.g., app_path('Mod')) - * @return string|null Fully qualified class name, or null if path doesn't match expected structure + * @return string|null Fully qualified class name, or null if path doesn't match expected structure */ private function classFromFile(string $file, string $basePath): ?string { diff --git a/packages/core-php/src/Core/Pro.php b/src/Core/Pro.php similarity index 99% rename from packages/core-php/src/Core/Pro.php rename to src/Core/Pro.php index 1cff9d4..062ea1c 100644 --- a/packages/core-php/src/Core/Pro.php +++ b/src/Core/Pro.php @@ -1,4 +1,5 @@ * diff --git a/packages/core-php/src/Core/README.md b/src/Core/README.md similarity index 100% rename from packages/core-php/src/Core/README.md rename to src/Core/README.md diff --git a/packages/core-php/src/Core/RELEASE-BLOCKERS.md b/src/Core/RELEASE-BLOCKERS.md similarity index 96% rename from packages/core-php/src/Core/RELEASE-BLOCKERS.md rename to src/Core/RELEASE-BLOCKERS.md index aaa9a40..ca31cd7 100644 --- a/packages/core-php/src/Core/RELEASE-BLOCKERS.md +++ b/src/Core/RELEASE-BLOCKERS.md @@ -298,12 +298,12 @@ Update to generic project contact or maintainer email. ### Before Release -- [ ] Create CONTRIBUTING.md with contributor guidelines -- [ ] Create SECURITY.md with security reporting procedures -- [ ] Update composer.json author email +- [x] Create CONTRIBUTING.md with contributor guidelines +- [x] Create SECURITY.md with security reporting procedures +- [x] Update composer.json author email (support@host.uk.com) - [ ] Test installation on fresh Laravel project -- [ ] Verify all dependencies in composer.json -- [ ] Run PHPStan and code style checks +- [x] Verify all dependencies in composer.json +- [x] Run PHPStan and code style checks --- diff --git a/packages/core-php/src/Core/Search/Analytics/SearchAnalytics.php b/src/Core/Search/Analytics/SearchAnalytics.php similarity index 99% rename from packages/core-php/src/Core/Search/Analytics/SearchAnalytics.php rename to src/Core/Search/Analytics/SearchAnalytics.php index 47536cb..03a248c 100644 --- a/packages/core-php/src/Core/Search/Analytics/SearchAnalytics.php +++ b/src/Core/Search/Analytics/SearchAnalytics.php @@ -1,4 +1,5 @@ snippet($longText, 'search term', 50); * // Returns: '...text around search term with context...' * ``` - * - * @package Core\Search\Support */ class SearchHighlighter { @@ -103,7 +102,6 @@ class SearchHighlighter * Set the wrapper tag for highlighting. * * @param string $tag HTML tag name (e.g., 'mark', 'span', 'strong') - * @return static */ public function tag(string $tag): static { @@ -116,7 +114,6 @@ class SearchHighlighter * Set the CSS class for the highlight wrapper. * * @param string $class CSS class name(s) - * @return static */ public function class(string $class): static { @@ -129,7 +126,6 @@ class SearchHighlighter * Set whether to escape HTML in the input text. * * @param bool $escape Whether to escape HTML entities - * @return static */ public function escapeHtml(bool $escape): static { @@ -142,7 +138,6 @@ class SearchHighlighter * Set the minimum word length to highlight. * * @param int $length Minimum characters for a word to be highlighted - * @return static */ public function minWordLength(int $length): static { @@ -315,7 +310,7 @@ class SearchHighlighter $escaped = array_map(fn (string $word) => preg_quote($word, '/'), $words); // Use word boundary where possible, case-insensitive - return '/(' . implode('|', $escaped) . ')/iu'; + return '/('.implode('|', $escaped).')/iu'; } /** @@ -326,7 +321,7 @@ class SearchHighlighter */ protected function wrapMatch(string $match): string { - $classAttr = $this->class ? ' class="' . htmlspecialchars($this->class, ENT_QUOTES, 'UTF-8') . '"' : ''; + $classAttr = $this->class ? ' class="'.htmlspecialchars($this->class, ENT_QUOTES, 'UTF-8').'"' : ''; return "<{$this->tag}{$classAttr}>{$match}tag}>"; } @@ -392,7 +387,7 @@ class SearchHighlighter $prefix = $start > 0 ? '...' : ''; $suffix = $end < $textLength ? '...' : ''; - return $prefix . trim($context) . $suffix; + return $prefix.trim($context).$suffix; } /** @@ -416,18 +411,16 @@ class SearchHighlighter $truncated = mb_substr($truncated, 0, $lastSpace); } - $result = trim($truncated) . '...'; + $result = trim($truncated).'...'; return $this->escapeHtml ? htmlspecialchars($result, ENT_QUOTES, 'UTF-8') : $result; } /** * Create a new highlighter with default configuration. - * - * @return static */ public static function make(): static { - return new static(); + return new static; } } diff --git a/packages/core-php/src/Core/Search/Unified.php b/src/Core/Search/Unified.php similarity index 99% rename from packages/core-php/src/Core/Search/Unified.php rename to src/Core/Search/Unified.php index 17c2add..c76550b 100644 --- a/packages/core-php/src/Core/Search/Unified.php +++ b/src/Core/Search/Unified.php @@ -1,4 +1,5 @@ $metadataRecords - * @return int Number of records created + * @return int Number of records created */ public function recordScores(Collection $metadataRecords): int { @@ -154,7 +155,7 @@ class SeoScoreTrend * * Useful for periodic batch recording (e.g., daily cron job). * - * @return int Number of records created + * @return int Number of records created */ public function recordAllScores(): int { @@ -522,7 +523,7 @@ class SeoScoreTrend * Prune old history records. * * @param int|null $days Days to retain (null uses config) - * @return int Number of records deleted + * @return int Number of records deleted */ public function prune(?int $days = null): int { diff --git a/packages/core-php/src/Core/Seo/Boot.php b/src/Core/Seo/Boot.php similarity index 99% rename from packages/core-php/src/Core/Seo/Boot.php rename to src/Core/Seo/Boot.php index 14866ab..875b930 100644 --- a/packages/core-php/src/Core/Seo/Boot.php +++ b/src/Core/Seo/Boot.php @@ -1,4 +1,5 @@ $schemaResult) { $this->newLine(); - $this->components->info("Schema ".($index + 1)); + $this->components->info('Schema '.($index + 1)); $this->displaySchemaResult($schemaResult); } diff --git a/packages/core-php/src/Core/Seo/Controllers/OgImageController.php b/src/Core/Seo/Controllers/OgImageController.php similarity index 99% rename from packages/core-php/src/Core/Seo/Controllers/OgImageController.php rename to src/Core/Seo/Controllers/OgImageController.php index 93f89c5..d387924 100644 --- a/packages/core-php/src/Core/Seo/Controllers/OgImageController.php +++ b/src/Core/Seo/Controllers/OgImageController.php @@ -1,4 +1,5 @@ List of eligible rich result features + * @return array List of eligible rich result features */ public function getRichResultsEligibility(): array { diff --git a/packages/core-php/src/Core/Seo/Services/SchemaBuilderService.php b/src/Core/Seo/Services/SchemaBuilderService.php similarity index 99% rename from packages/core-php/src/Core/Seo/Services/SchemaBuilderService.php rename to src/Core/Seo/Services/SchemaBuilderService.php index 098c468..e711ee5 100644 --- a/packages/core-php/src/Core/Seo/Services/SchemaBuilderService.php +++ b/src/Core/Seo/Services/SchemaBuilderService.php @@ -1,4 +1,5 @@ $schema - * @return array List of eligible rich result features + * @return array List of eligible rich result features */ public function checkRichResultsEligibility(array $schema): array { diff --git a/packages/core-php/src/Core/Service/Concerns/HasServiceVersion.php b/src/Core/Service/Concerns/HasServiceVersion.php similarity index 99% rename from packages/core-php/src/Core/Service/Concerns/HasServiceVersion.php rename to src/Core/Service/Concerns/HasServiceVersion.php index 0dae4c9..7f061e0 100644 --- a/packages/core-php/src/Core/Service/Concerns/HasServiceVersion.php +++ b/src/Core/Service/Concerns/HasServiceVersion.php @@ -1,4 +1,5 @@ isValidColor($definition['color'])) { throw new \InvalidArgumentException( "Service '{$serviceClass}' color '{$definition['color']}' is invalid. ". - "Color must be a valid hex color (e.g., #3B82F6)" + 'Color must be a valid hex color (e.g., #3B82F6)' ); } } @@ -374,7 +372,6 @@ class ServiceDiscovery /** * Validate dependencies for a specific service. * - * @param string $code * @param class-string $class * @return array */ @@ -700,7 +697,7 @@ class ServiceDiscovery { if (in_array($code, $resolving)) { throw new ServiceDependencyException( - "Circular dependency detected: ".implode(' -> ', [...$resolving, $code]) + 'Circular dependency detected: '.implode(' -> ', [...$resolving, $code]) ); } diff --git a/packages/core-php/src/Core/Service/ServiceVersion.php b/src/Core/Service/ServiceVersion.php similarity index 99% rename from packages/core-php/src/Core/Service/ServiceVersion.php rename to src/Core/Service/ServiceVersion.php index 02bc0e9..59a2b4d 100644 --- a/packages/core-php/src/Core/Service/ServiceVersion.php +++ b/src/Core/Service/ServiceVersion.php @@ -1,4 +1,5 @@ app->singleton(CacheWarmer::class, function () { - return new CacheWarmer(); + return new CacheWarmer; }); // Register StorageMetrics as singleton $this->app->singleton(StorageMetrics::class, function () { - return new StorageMetrics(); + return new StorageMetrics; }); // Register TieredCacheStore as singleton $this->app->singleton(TieredCacheStore::class, function () { - return new TieredCacheStore(); + return new TieredCacheStore; }); } diff --git a/packages/core-php/src/Core/Storage/CacheWarmer.php b/src/Core/Storage/CacheWarmer.php similarity index 99% rename from packages/core-php/src/Core/Storage/CacheWarmer.php rename to src/Core/Storage/CacheWarmer.php index e49f2d8..f6302cc 100644 --- a/packages/core-php/src/Core/Storage/CacheWarmer.php +++ b/src/Core/Storage/CacheWarmer.php @@ -1,4 +1,5 @@ items; - uasort($sortedItems, fn($a, $b) => $a['priority'] <=> $b['priority']); + uasort($sortedItems, fn ($a, $b) => $a['priority'] <=> $b['priority']); // Warm regular items foreach ($sortedItems as $key => $item) { @@ -225,8 +226,8 @@ class CacheWarmer $this->log('info', 'Cache warming completed', [ 'total_duration' => round($totalDuration, 3), 'items_warmed' => count($this->lastResults), - 'successes' => count(array_filter($this->lastResults, fn($r) => $r['status'] === 'success')), - 'failures' => count(array_filter($this->lastResults, fn($r) => $r['status'] === 'failed')), + 'successes' => count(array_filter($this->lastResults, fn ($r) => $r['status'] === 'success')), + 'failures' => count(array_filter($this->lastResults, fn ($r) => $r['status'] === 'failed')), ]); return $this->lastResults; diff --git a/packages/core-php/src/Core/Storage/CircuitBreaker.php b/src/Core/Storage/CircuitBreaker.php similarity index 99% rename from packages/core-php/src/Core/Storage/CircuitBreaker.php rename to src/Core/Storage/CircuitBreaker.php index e71d4de..65adb6c 100644 --- a/packages/core-php/src/Core/Storage/CircuitBreaker.php +++ b/src/Core/Storage/CircuitBreaker.php @@ -1,4 +1,5 @@ hit rate percentage. * Useful for monitoring dashboards and alerting. * - * @return array Map of driver name to hit rate percentage + * @return array Map of driver name to hit rate percentage */ public function getAllHitRates(): array { @@ -328,7 +329,7 @@ class StorageMetrics * @param string $driver The cache driver name * @param float $threshold Minimum acceptable hit rate percentage (default: 50.0) * @param int $minSamples Minimum samples required before alerting (default: 100) - * @return bool True if hit rate is below threshold and we have enough samples + * @return bool True if hit rate is below threshold and we have enough samples */ public function isHitRateLow(string $driver, float $threshold = 50.0, int $minSamples = 100): bool { diff --git a/packages/core-php/src/Core/Storage/TierConfiguration.php b/src/Core/Storage/TierConfiguration.php similarity index 99% rename from packages/core-php/src/Core/Storage/TierConfiguration.php rename to src/Core/Storage/TierConfiguration.php index cd79696..ff64e35 100644 --- a/packages/core-php/src/Core/Storage/TierConfiguration.php +++ b/src/Core/Storage/TierConfiguration.php @@ -1,4 +1,5 @@ $a->priority <=> $b->priority); + usort($tiers, fn (TierConfiguration $a, TierConfiguration $b) => $a->priority <=> $b->priority); // Filter to enabled tiers only - $this->tiers = array_values(array_filter($tiers, fn(TierConfiguration $t) => $t->enabled)); + $this->tiers = array_values(array_filter($tiers, fn (TierConfiguration $t) => $t->enabled)); } /** @@ -159,7 +160,7 @@ class TieredCacheStore implements Store if (! empty($configTiers)) { return array_map( - fn(array $tier) => TierConfiguration::fromArray($tier), + fn (array $tier) => TierConfiguration::fromArray($tier), $configTiers ); } @@ -179,7 +180,6 @@ class TieredCacheStore implements Store * optionally promotes the value to faster tiers. * * @param string|array $key - * @return mixed */ public function get($key): mixed { @@ -228,14 +228,11 @@ class TieredCacheStore implements Store /** * Retrieve multiple items from the cache by key. - * - * @param array $keys - * @return array */ public function many(array $keys): array { $results = []; - $prefixedKeys = array_map(fn($k) => $this->prefix.$k, $keys); + $prefixedKeys = array_map(fn ($k) => $this->prefix.$k, $keys); $keyMap = array_combine($prefixedKeys, $keys); if (! $this->enabled || empty($this->tiers)) { @@ -299,7 +296,6 @@ class TieredCacheStore implements Store * @param string $key * @param mixed $value * @param int $seconds Base TTL (each tier may adjust) - * @return bool */ public function put($key, $value, $seconds): bool { @@ -334,9 +330,7 @@ class TieredCacheStore implements Store /** * Store multiple items in the cache for a given number of seconds. * - * @param array $values * @param int $seconds - * @return bool */ public function putMany(array $values, $seconds): bool { @@ -373,7 +367,6 @@ class TieredCacheStore implements Store * * @param string $key * @param mixed $value - * @return int|bool */ public function increment($key, $value = 1): int|bool { @@ -408,7 +401,6 @@ class TieredCacheStore implements Store * * @param string $key * @param mixed $value - * @return int|bool */ public function decrement($key, $value = 1): int|bool { @@ -443,7 +435,6 @@ class TieredCacheStore implements Store * * @param string $key * @param mixed $value - * @return bool */ public function forever($key, $value): bool { @@ -477,7 +468,6 @@ class TieredCacheStore implements Store * Removes from all tiers. * * @param string $key - * @return bool */ public function forget($key): bool { @@ -510,8 +500,6 @@ class TieredCacheStore implements Store * Remove all items from the cache. * * Flushes all tiers. - * - * @return bool */ public function flush(): bool { @@ -539,8 +527,6 @@ class TieredCacheStore implements Store /** * Get the cache key prefix. - * - * @return string */ public function getPrefix(): string { @@ -552,10 +538,8 @@ class TieredCacheStore implements Store * * Implements the "cache-aside" pattern with automatic promotion. * - * @param string $key * @param int $ttl TTL in seconds * @param Closure $callback Callback to generate value if missing - * @return mixed */ public function remember(string $key, int $ttl, Closure $callback): mixed { @@ -574,10 +558,6 @@ class TieredCacheStore implements Store /** * Get or set the value of an item forever. - * - * @param string $key - * @param Closure $callback - * @return mixed */ public function rememberForever(string $key, Closure $callback): mixed { @@ -633,7 +613,7 @@ class TieredCacheStore implements Store public function addTier(TierConfiguration $tier): static { $this->tiers[] = $tier; - usort($this->tiers, fn(TierConfiguration $a, TierConfiguration $b) => $a->priority <=> $b->priority); + usort($this->tiers, fn (TierConfiguration $a, TierConfiguration $b) => $a->priority <=> $b->priority); return $this; } @@ -645,7 +625,7 @@ class TieredCacheStore implements Store { $this->tiers = array_values(array_filter( $this->tiers, - fn(TierConfiguration $t) => $t->name !== $name + fn (TierConfiguration $t) => $t->name !== $name )); unset($this->stores[$name]); diff --git a/packages/core-php/src/Core/TODO.md b/src/Core/TODO.md similarity index 100% rename from packages/core-php/src/Core/TODO.md rename to src/Core/TODO.md diff --git a/packages/core-php/src/Core/Tests/Feature/AdminComponentsTest.php b/src/Core/Tests/Feature/AdminComponentsTest.php similarity index 99% rename from packages/core-php/src/Core/Tests/Feature/AdminComponentsTest.php rename to src/Core/Tests/Feature/AdminComponentsTest.php index e89c05d..c5346b6 100644 --- a/packages/core-php/src/Core/Tests/Feature/AdminComponentsTest.php +++ b/src/Core/Tests/Feature/AdminComponentsTest.php @@ -1,4 +1,5 @@ not->toBeEmpty('No admin routes found - is the app bootstrapped?'); // Log discovered routes for visibility - dump('Discovered ' . count($routes) . ' admin routes'); + dump('Discovered '.count($routes).' admin routes'); }); it('all admin routes respond without server errors', function () { @@ -51,13 +52,14 @@ describe('Admin Route Discovery', function () { } try { - $response = $this->actingAs($this->hadesUser)->get('/' . $uri); + $response = $this->actingAs($this->hadesUser)->get('/'.$uri); } catch (\Throwable $e) { $failures[] = [ 'uri' => $uri, 'status' => 500, - 'error' => get_class($e) . ': ' . $e->getMessage(), + 'error' => get_class($e).': '.$e->getMessage(), ]; + continue; } @@ -108,7 +110,7 @@ describe('Admin Route Discovery', function () { continue; } - $response = $this->actingAs($this->hadesUser)->get('/' . $uri); + $response = $this->actingAs($this->hadesUser)->get('/'.$uri); expect($response->status()) ->not->toBe(500, "Route /{$uri} returned 500 error"); @@ -128,7 +130,7 @@ describe('Admin Route Security', function () { continue; } - $response = $this->get('/' . $uri); + $response = $this->get('/'.$uri); // Should redirect to login (302) or return 401/403 expect($response->status()) @@ -156,7 +158,7 @@ describe('Admin Route Security', function () { continue; } - $response = $this->actingAs($regularUser)->get('/' . $uri); + $response = $this->actingAs($regularUser)->get('/'.$uri); // Should be 403 Forbidden for admin-only routes expect($response->status()) @@ -211,7 +213,7 @@ describe('Hub Route Architecture', function () { $hasExplicitStack = in_array('admin.domain', $middleware) && in_array('auth', $middleware); if (! $hasAdminGroup && ! $hasExplicitStack) { - $missing[] = $uri . ' (' . implode(', ', $middleware) . ')'; + $missing[] = $uri.' ('.implode(', ', $middleware).')'; } } @@ -286,7 +288,7 @@ describe('Hub Route Architecture', function () { continue; } - $response = $this->actingAs($regularUser)->get('/' . $uri); + $response = $this->actingAs($regularUser)->get('/'.$uri); // Should NOT be 403 for regular authenticated users if ($response->status() === 403) { diff --git a/packages/core-php/src/Core/Tests/Feature/BladeViewCompilationTest.php b/src/Core/Tests/Feature/BladeViewCompilationTest.php similarity index 99% rename from packages/core-php/src/Core/Tests/Feature/BladeViewCompilationTest.php rename to src/Core/Tests/Feature/BladeViewCompilationTest.php index c1535b2..083e94a 100644 --- a/packages/core-php/src/Core/Tests/Feature/BladeViewCompilationTest.php +++ b/src/Core/Tests/Feature/BladeViewCompilationTest.php @@ -1,4 +1,5 @@ toBeEmpty( - 'Missing components: ' . implode(', ', $missing) + 'Missing components: '.implode(', ', $missing) ); }); @@ -215,7 +216,7 @@ describe('Core Component Library', function () { $bladeFiles = File::allFiles($basePath); foreach ($bladeFiles as $file) { - if (!str_contains($file->getFilename(), '.blade.php')) { + if (! str_contains($file->getFilename(), '.blade.php')) { continue; } @@ -223,13 +224,13 @@ describe('Core Component Library', function () { $compiler = app(BladeCompiler::class); $compiler->compile($file->getPathname()); } catch (Throwable $e) { - $relativePath = str_replace($basePath . '/', '', $file->getPathname()); + $relativePath = str_replace($basePath.'/', '', $file->getPathname()); $errors[] = "{$relativePath}: {$e->getMessage()}"; } } expect($errors)->toBeEmpty( - "Components failed to compile:\n" . implode("\n", $errors) + "Components failed to compile:\n".implode("\n", $errors) ); }); @@ -240,12 +241,12 @@ describe('Core Component Library', function () { $bladeFiles = File::allFiles($basePath); foreach ($bladeFiles as $file) { - if (!str_contains($file->getFilename(), '.blade.php')) { + if (! str_contains($file->getFilename(), '.blade.php')) { continue; } $content = File::get($file->getPathname()); - $relativePath = str_replace($basePath . '/', '', $file->getPathname()); + $relativePath = str_replace($basePath.'/', '', $file->getPathname()); // Skip directories with different patterns $skipPrefixes = ['layout', 'forms/', 'examples/', 'errors/', 'components/', 'web/']; @@ -268,14 +269,14 @@ describe('Core Component Library', function () { || str_contains($content, '$attributes->merge') || str_contains($content, ':$attributes'); - if (!$hasForwarding) { + if (! $hasForwarding) { $missingAttributeForwarding[] = $relativePath; } } } expect($missingAttributeForwarding)->toBeEmpty( - "Components missing attribute forwarding:\n" . implode("\n", $missingAttributeForwarding) + "Components missing attribute forwarding:\n".implode("\n", $missingAttributeForwarding) ); }); @@ -286,12 +287,12 @@ describe('Core Component Library', function () { $bladeFiles = File::allFiles($basePath); foreach ($bladeFiles as $file) { - if (!str_contains($file->getFilename(), '.blade.php')) { + if (! str_contains($file->getFilename(), '.blade.php')) { continue; } $content = File::get($file->getPathname()); - $relativePath = str_replace($basePath . '/', '', $file->getPathname()); + $relativePath = str_replace($basePath.'/', '', $file->getPathname()); // Skip directories with different patterns $skipPrefixes = ['layout', 'forms/', 'examples/', 'errors/', 'components/', 'web/']; @@ -308,14 +309,14 @@ describe('Core Component Library', function () { // If it has opening and closing flux tags, should have {{ $slot }} if (preg_match('/.*<\/flux:/s', $content)) { - if (!str_contains($content, '{{ $slot }}')) { + if (! str_contains($content, '{{ $slot }}')) { $missingSlotForwarding[] = $relativePath; } } } expect($missingSlotForwarding)->toBeEmpty( - "Components missing {{ \$slot }} forwarding:\n" . implode("\n", $missingSlotForwarding) + "Components missing {{ \$slot }} forwarding:\n".implode("\n", $missingSlotForwarding) ); }); }); @@ -778,13 +779,13 @@ describe('Component Count Verification', function () { $missing = []; foreach ($categories as $file => $category) { - if (!File::exists($basePath . '/' . $file)) { + if (! File::exists($basePath.'/'.$file)) { $missing[] = "{$category} ({$file})"; } } expect($missing)->toBeEmpty( - 'Missing category coverage: ' . implode(', ', $missing) + 'Missing category coverage: '.implode(', ', $missing) ); }); }); @@ -884,7 +885,7 @@ describe('Comprehensive Core=Flux Parity', function () { } expect($failures)->toBeEmpty( - 'Components not matching flux: ' . implode(', ', $failures) + 'Components not matching flux: '.implode(', ', $failures) ); }); @@ -892,8 +893,7 @@ describe('Comprehensive Core=Flux Parity', function () { $components = [ '' => '', '' => '', - 'A' => - 'A', + 'A' => 'A', '' => '', '' => '', ]; @@ -908,18 +908,15 @@ describe('Comprehensive Core=Flux Parity', function () { } expect($failures)->toBeEmpty( - 'Form components not matching flux: ' . implode(', ', $failures) + 'Form components not matching flux: '.implode(', ', $failures) ); }); it('navigation components match flux', function () { $components = [ - 'OpenItem' => - 'OpenItem', - 'OneTwo' => - 'OneTwo', - 'Home' => - 'Home', + 'OpenItem' => 'OpenItem', + 'OneTwo' => 'OneTwo', + 'Home' => 'Home', ]; $failures = []; @@ -932,7 +929,7 @@ describe('Comprehensive Core=Flux Parity', function () { } expect($failures)->toBeEmpty( - 'Navigation components not matching flux: ' . implode(', ', $failures) + 'Navigation components not matching flux: '.implode(', ', $failures) ); }); }); diff --git a/packages/core-php/src/Core/Tests/Feature/DatabaseMigrationTest.php b/src/Core/Tests/Feature/DatabaseMigrationTest.php similarity index 99% rename from packages/core-php/src/Core/Tests/Feature/DatabaseMigrationTest.php rename to src/Core/Tests/Feature/DatabaseMigrationTest.php index 93dcf5b..5022764 100644 --- a/packages/core-php/src/Core/Tests/Feature/DatabaseMigrationTest.php +++ b/src/Core/Tests/Feature/DatabaseMigrationTest.php @@ -1,4 +1,5 @@ $this->workspace, 'features' => $this->getFeatures(), ])->layout('service::layouts.service', [ - 'title' => 'Features - ' . ($this->workspace['name'] ?? $appName), + 'title' => 'Features - '.($this->workspace['name'] ?? $appName), 'workspace' => $this->workspace, ]); } diff --git a/packages/core-php/src/Website/Service/View/Landing.php b/src/Website/Service/View/Landing.php similarity index 100% rename from packages/core-php/src/Website/Service/View/Landing.php rename to src/Website/Service/View/Landing.php diff --git a/packages/core-php/stubs/Mod/Example/Boot.php.stub b/stubs/Mod/Example/Boot.php.stub similarity index 100% rename from packages/core-php/stubs/Mod/Example/Boot.php.stub rename to stubs/Mod/Example/Boot.php.stub diff --git a/packages/core-php/stubs/Mod/Example/Routes/admin.php.stub b/stubs/Mod/Example/Routes/admin.php.stub similarity index 100% rename from packages/core-php/stubs/Mod/Example/Routes/admin.php.stub rename to stubs/Mod/Example/Routes/admin.php.stub diff --git a/packages/core-php/stubs/Mod/Example/Routes/api.php.stub b/stubs/Mod/Example/Routes/api.php.stub similarity index 100% rename from packages/core-php/stubs/Mod/Example/Routes/api.php.stub rename to stubs/Mod/Example/Routes/api.php.stub diff --git a/packages/core-php/stubs/Mod/Example/Routes/web.php.stub b/stubs/Mod/Example/Routes/web.php.stub similarity index 100% rename from packages/core-php/stubs/Mod/Example/Routes/web.php.stub rename to stubs/Mod/Example/Routes/web.php.stub diff --git a/packages/core-php/stubs/Mod/Example/config.php.stub b/stubs/Mod/Example/config.php.stub similarity index 100% rename from packages/core-php/stubs/Mod/Example/config.php.stub rename to stubs/Mod/Example/config.php.stub diff --git a/packages/core-php/stubs/Plug/Example/Boot.php.stub b/stubs/Plug/Example/Boot.php.stub similarity index 100% rename from packages/core-php/stubs/Plug/Example/Boot.php.stub rename to stubs/Plug/Example/Boot.php.stub diff --git a/packages/core-php/stubs/Website/Example/Boot.php.stub b/stubs/Website/Example/Boot.php.stub similarity index 100% rename from packages/core-php/stubs/Website/Example/Boot.php.stub rename to stubs/Website/Example/Boot.php.stub diff --git a/packages/core-php/tests/Feature/ActivityLogServiceTest.php b/tests/Feature/ActivityLogServiceTest.php similarity index 100% rename from packages/core-php/tests/Feature/ActivityLogServiceTest.php rename to tests/Feature/ActivityLogServiceTest.php diff --git a/packages/core-php/tests/Feature/AdminMenuRegistryTest.php b/tests/Feature/AdminMenuRegistryTest.php similarity index 98% rename from packages/core-php/tests/Feature/AdminMenuRegistryTest.php rename to tests/Feature/AdminMenuRegistryTest.php index 8f044fc..858ded9 100644 --- a/packages/core-php/tests/Feature/AdminMenuRegistryTest.php +++ b/tests/Feature/AdminMenuRegistryTest.php @@ -7,8 +7,6 @@ namespace Core\Tests\Feature; use Core\Front\Admin\AdminMenuRegistry; use Core\Front\Admin\Concerns\HasMenuPermissions; use Core\Front\Admin\Contracts\AdminMenuProvider; -use Core\Mod\Tenant\Models\User; -use Core\Mod\Tenant\Models\Workspace; use Core\Mod\Tenant\Services\EntitlementService; use Core\Tests\TestCase; use Mockery; @@ -271,7 +269,8 @@ class AdminMenuRegistryTest extends TestCase protected function createMockProvider(array $items): AdminMenuProvider { - return new class($items) implements AdminMenuProvider { + return new class($items) implements AdminMenuProvider + { use HasMenuPermissions; public function __construct(private array $items) {} diff --git a/packages/core-php/tests/Feature/EventAuditLogTest.php b/tests/Feature/EventAuditLogTest.php similarity index 100% rename from packages/core-php/tests/Feature/EventAuditLogTest.php rename to tests/Feature/EventAuditLogTest.php diff --git a/packages/core-php/tests/Feature/InputTest.php b/tests/Feature/InputTest.php similarity index 100% rename from packages/core-php/tests/Feature/InputTest.php rename to tests/Feature/InputTest.php diff --git a/packages/core-php/tests/Feature/LazyModuleListenerTest.php b/tests/Feature/LazyModuleListenerTest.php similarity index 100% rename from packages/core-php/tests/Feature/LazyModuleListenerTest.php rename to tests/Feature/LazyModuleListenerTest.php diff --git a/packages/core-php/tests/Feature/LifecycleEventProviderTest.php b/tests/Feature/LifecycleEventProviderTest.php similarity index 100% rename from packages/core-php/tests/Feature/LifecycleEventProviderTest.php rename to tests/Feature/LifecycleEventProviderTest.php diff --git a/packages/core-php/tests/Feature/LifecycleEventsTest.php b/tests/Feature/LifecycleEventsTest.php similarity index 100% rename from packages/core-php/tests/Feature/LifecycleEventsTest.php rename to tests/Feature/LifecycleEventsTest.php diff --git a/packages/core-php/tests/Feature/LogsActivityTraitTest.php b/tests/Feature/LogsActivityTraitTest.php similarity index 100% rename from packages/core-php/tests/Feature/LogsActivityTraitTest.php rename to tests/Feature/LogsActivityTraitTest.php diff --git a/packages/core-php/tests/Feature/ModuleRegistryTest.php b/tests/Feature/ModuleRegistryTest.php similarity index 100% rename from packages/core-php/tests/Feature/ModuleRegistryTest.php rename to tests/Feature/ModuleRegistryTest.php diff --git a/packages/core-php/tests/Feature/ModuleScannerTest.php b/tests/Feature/ModuleScannerTest.php similarity index 99% rename from packages/core-php/tests/Feature/ModuleScannerTest.php rename to tests/Feature/ModuleScannerTest.php index 1f830ce..3a0584a 100644 --- a/packages/core-php/tests/Feature/ModuleScannerTest.php +++ b/tests/Feature/ModuleScannerTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Core\Tests\Feature; -use Core\Events\ApiRoutesRegistering; use Core\Events\ConsoleBooting; use Core\Events\FrameworkBooted; use Core\Events\WebRoutesRegistering; @@ -131,7 +130,8 @@ PHP); public function test_extract_listens_returns_empty_for_class_without_property(): void { - $class = new class { + $class = new class + { public function handle(): void {} }; diff --git a/packages/core-php/tests/Feature/ProTest.php b/tests/Feature/ProTest.php similarity index 100% rename from packages/core-php/tests/Feature/ProTest.php rename to tests/Feature/ProTest.php diff --git a/packages/core-php/tests/Feature/SanitiserTest.php b/tests/Feature/SanitiserTest.php similarity index 98% rename from packages/core-php/tests/Feature/SanitiserTest.php rename to tests/Feature/SanitiserTest.php index ab6ebbb..4d46d1b 100644 --- a/packages/core-php/tests/Feature/SanitiserTest.php +++ b/tests/Feature/SanitiserTest.php @@ -215,7 +215,7 @@ class SanitiserTest extends TestCase public function test_unicode_normalization_is_enabled_by_default(): void { - if (!class_exists(Normalizer::class)) { + if (! class_exists(Normalizer::class)) { $this->markTestSkipped('intl extension not available'); } @@ -235,7 +235,7 @@ class SanitiserTest extends TestCase public function test_with_normalization_false_disables_nfc(): void { - if (!class_exists(Normalizer::class)) { + if (! class_exists(Normalizer::class)) { $this->markTestSkipped('intl extension not available'); } @@ -253,7 +253,7 @@ class SanitiserTest extends TestCase public function test_schema_can_skip_normalization_per_field(): void { - if (!class_exists(Normalizer::class)) { + if (! class_exists(Normalizer::class)) { $this->markTestSkipped('intl extension not available'); } diff --git a/packages/core-php/tests/Feature/SeederDiscoveryTest.php b/tests/Feature/SeederDiscoveryTest.php similarity index 100% rename from packages/core-php/tests/Feature/SeederDiscoveryTest.php rename to tests/Feature/SeederDiscoveryTest.php diff --git a/packages/core-php/tests/Feature/SeederRegistryTest.php b/tests/Feature/SeederRegistryTest.php similarity index 100% rename from packages/core-php/tests/Feature/SeederRegistryTest.php rename to tests/Feature/SeederRegistryTest.php diff --git a/packages/core-php/tests/Fixtures/Core/TestCore/Boot.php b/tests/Fixtures/Core/TestCore/Boot.php similarity index 100% rename from packages/core-php/tests/Fixtures/Core/TestCore/Boot.php rename to tests/Fixtures/Core/TestCore/Boot.php diff --git a/packages/core-php/tests/Fixtures/Custom/TestCustom/Boot.php b/tests/Fixtures/Custom/TestCustom/Boot.php similarity index 100% rename from packages/core-php/tests/Fixtures/Custom/TestCustom/Boot.php rename to tests/Fixtures/Custom/TestCustom/Boot.php diff --git a/packages/core-php/tests/Fixtures/Mcp/TestHandler.php b/tests/Fixtures/Mcp/TestHandler.php similarity index 100% rename from packages/core-php/tests/Fixtures/Mcp/TestHandler.php rename to tests/Fixtures/Mcp/TestHandler.php diff --git a/packages/core-php/tests/Fixtures/Mod/Alpha/Database/Seeders/AlphaSeeder.php b/tests/Fixtures/Mod/Alpha/Database/Seeders/AlphaSeeder.php similarity index 100% rename from packages/core-php/tests/Fixtures/Mod/Alpha/Database/Seeders/AlphaSeeder.php rename to tests/Fixtures/Mod/Alpha/Database/Seeders/AlphaSeeder.php diff --git a/packages/core-php/tests/Fixtures/Mod/Beta/Database/Seeders/BetaSeeder.php b/tests/Fixtures/Mod/Beta/Database/Seeders/BetaSeeder.php similarity index 100% rename from packages/core-php/tests/Fixtures/Mod/Beta/Database/Seeders/BetaSeeder.php rename to tests/Fixtures/Mod/Beta/Database/Seeders/BetaSeeder.php diff --git a/packages/core-php/tests/Fixtures/Mod/Circular/Database/Seeders/CircularASeeder.php b/tests/Fixtures/Mod/Circular/Database/Seeders/CircularASeeder.php similarity index 100% rename from packages/core-php/tests/Fixtures/Mod/Circular/Database/Seeders/CircularASeeder.php rename to tests/Fixtures/Mod/Circular/Database/Seeders/CircularASeeder.php diff --git a/packages/core-php/tests/Fixtures/Mod/Circular/Database/Seeders/CircularBSeeder.php b/tests/Fixtures/Mod/Circular/Database/Seeders/CircularBSeeder.php similarity index 100% rename from packages/core-php/tests/Fixtures/Mod/Circular/Database/Seeders/CircularBSeeder.php rename to tests/Fixtures/Mod/Circular/Database/Seeders/CircularBSeeder.php diff --git a/packages/core-php/tests/Fixtures/Mod/Example/Boot.php b/tests/Fixtures/Mod/Example/Boot.php similarity index 100% rename from packages/core-php/tests/Fixtures/Mod/Example/Boot.php rename to tests/Fixtures/Mod/Example/Boot.php diff --git a/packages/core-php/tests/Fixtures/Mod/Gamma/Database/Seeders/DeltaSeeder.php b/tests/Fixtures/Mod/Gamma/Database/Seeders/DeltaSeeder.php similarity index 100% rename from packages/core-php/tests/Fixtures/Mod/Gamma/Database/Seeders/DeltaSeeder.php rename to tests/Fixtures/Mod/Gamma/Database/Seeders/DeltaSeeder.php diff --git a/packages/core-php/tests/Fixtures/Mod/Gamma/Database/Seeders/GammaSeeder.php b/tests/Fixtures/Mod/Gamma/Database/Seeders/GammaSeeder.php similarity index 100% rename from packages/core-php/tests/Fixtures/Mod/Gamma/Database/Seeders/GammaSeeder.php rename to tests/Fixtures/Mod/Gamma/Database/Seeders/GammaSeeder.php diff --git a/packages/core-php/tests/Fixtures/Mod/HighPriority/Boot.php b/tests/Fixtures/Mod/HighPriority/Boot.php similarity index 100% rename from packages/core-php/tests/Fixtures/Mod/HighPriority/Boot.php rename to tests/Fixtures/Mod/HighPriority/Boot.php diff --git a/packages/core-php/tests/Fixtures/Mod/NoListens/Boot.php b/tests/Fixtures/Mod/NoListens/Boot.php similarity index 100% rename from packages/core-php/tests/Fixtures/Mod/NoListens/Boot.php rename to tests/Fixtures/Mod/NoListens/Boot.php diff --git a/packages/core-php/tests/Fixtures/Mod/NonArrayListens/Boot.php b/tests/Fixtures/Mod/NonArrayListens/Boot.php similarity index 100% rename from packages/core-php/tests/Fixtures/Mod/NonArrayListens/Boot.php rename to tests/Fixtures/Mod/NonArrayListens/Boot.php diff --git a/packages/core-php/tests/Fixtures/Mod/NonStaticListens/Boot.php b/tests/Fixtures/Mod/NonStaticListens/Boot.php similarity index 100% rename from packages/core-php/tests/Fixtures/Mod/NonStaticListens/Boot.php rename to tests/Fixtures/Mod/NonStaticListens/Boot.php diff --git a/packages/core-php/tests/Fixtures/Mod/PrivateListens/Boot.php b/tests/Fixtures/Mod/PrivateListens/Boot.php similarity index 100% rename from packages/core-php/tests/Fixtures/Mod/PrivateListens/Boot.php rename to tests/Fixtures/Mod/PrivateListens/Boot.php diff --git a/packages/core-php/tests/Fixtures/Mod/ServiceProviderModule/Boot.php b/tests/Fixtures/Mod/ServiceProviderModule/Boot.php similarity index 100% rename from packages/core-php/tests/Fixtures/Mod/ServiceProviderModule/Boot.php rename to tests/Fixtures/Mod/ServiceProviderModule/Boot.php diff --git a/packages/core-php/tests/Fixtures/Plug/TestPlugin/Boot.php b/tests/Fixtures/Plug/TestPlugin/Boot.php similarity index 100% rename from packages/core-php/tests/Fixtures/Plug/TestPlugin/Boot.php rename to tests/Fixtures/Plug/TestPlugin/Boot.php diff --git a/packages/core-php/tests/Fixtures/Website/TestSite/Boot.php b/tests/Fixtures/Website/TestSite/Boot.php similarity index 100% rename from packages/core-php/tests/Fixtures/Website/TestSite/Boot.php rename to tests/Fixtures/Website/TestSite/Boot.php diff --git a/tests/TestCase.php b/tests/TestCase.php index 434903c..6f1c046 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,19 +2,28 @@ declare(strict_types=1); -namespace Tests; +namespace Core\Tests; -use Illuminate\Foundation\Testing\TestCase as BaseTestCase; +use Core\LifecycleEventProvider; +use Orchestra\Testbench\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase { - /** - * Automatically load migrations from packages. - */ - protected function defineDatabaseMigrations(): void + protected function getPackageProviders($app): array { - // Load core-php migrations - $this->loadMigrationsFrom(__DIR__.'/../packages/core-php/src/Mod/Tenant/Migrations'); - $this->loadMigrationsFrom(__DIR__.'/../packages/core-php/src/Mod/Social/Migrations'); + return [ + LifecycleEventProvider::class, + ]; + } + + protected function defineEnvironment($app): void + { + // Override the default scan paths to use test fixtures + $app['config']->set('app.path', $this->getFixturePath()); + } + + protected function getFixturePath(string $path = ''): string + { + return __DIR__.'/Fixtures'.($path ? "/{$path}" : ''); } } diff --git a/vite.config.js b/vite.config.js deleted file mode 100644 index 1b2b742..0000000 --- a/vite.config.js +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from 'vite'; -import laravel from 'laravel-vite-plugin'; -import tailwindcss from '@tailwindcss/vite'; - -export default defineConfig({ - plugins: [ - tailwindcss(), - laravel({ - input: ['resources/css/app.css', 'resources/css/admin.css', 'resources/js/app.js'], - refresh: true, - }), - ], -});