From 71c0805bfd94d3ac7ec25ec7299dcdd384a5ca65 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 26 Jan 2026 20:56:28 +0000 Subject: [PATCH] monorepo sepration --- README.md | 201 +- TODO.md | 227 ++ changelog/2026/jan/features.md | 70 + composer.json | 71 +- .../views/components/forms/button.blade.php | 82 + .../views/components/forms/checkbox.blade.php | 88 + .../components/forms/form-group.blade.php | 50 + .../views/components/forms/input.blade.php | 77 + .../views/components/forms/select.blade.php | 108 + .../views/components/forms/textarea.blade.php | 87 + .../views/components/forms/toggle.blade.php | 104 + src/Boot.php | 88 + src/Forms/Concerns/HasAuthorizationProps.php | 101 + src/Forms/View/Components/Button.php | 135 ++ src/Forms/View/Components/Checkbox.php | 89 + src/Forms/View/Components/FormGroup.php | 88 + src/Forms/View/Components/Input.php | 99 + src/Forms/View/Components/Select.php | 146 ++ src/Forms/View/Components/Textarea.php | 104 + src/Forms/View/Components/Toggle.php | 127 ++ src/Mod/Hub/Boot.php | 268 +++ src/Mod/Hub/Controllers/TeapotController.php | 158 ++ .../Hub/Database/Seeders/ServiceSeeder.php | 110 + src/Mod/Hub/Lang/en_GB/hub.php | 1034 +++++++++ ...1_11_000001_create_honeypot_hits_table.php | 35 + ..._000001_create_platform_services_table.php | 49 + ...add_website_class_to_platform_services.php | 35 + src/Mod/Hub/Models/HoneypotHit.php | 206 ++ src/Mod/Hub/Models/Service.php | 149 ++ src/Mod/Hub/Tests/Feature/HubRoutesTest.php | 255 +++ .../Tests/Feature/WorkspaceSwitcherTest.php | 198 ++ src/Mod/Hub/Tests/UseCase/DashboardBasic.php | 53 + src/Search/Concerns/HasSearchProvider.php | 49 + src/Search/Contracts/SearchProvider.php | 120 + .../Providers/AdminPageSearchProvider.php | 216 ++ src/Search/SearchProviderRegistry.php | 305 +++ src/Search/SearchResult.php | 104 + .../Tests/SearchProviderRegistryTest.php | 237 ++ src/Search/Tests/SearchResultTest.php | 165 ++ src/Website/Hub/Boot.php | 195 ++ src/Website/Hub/Routes/admin.php | 74 + .../View/Blade/admin/account-usage.blade.php | 691 ++++++ .../View/Blade/admin/activity-log.blade.php | 19 + .../View/Blade/admin/ai-services.blade.php | 316 +++ .../Hub/View/Blade/admin/analytics.blade.php | 62 + .../View/Blade/admin/boost-purchase.blade.php | 90 + .../admin/components/developer-bar.blade.php | 505 +++++ .../Blade/admin/components/header.blade.php | 183 ++ .../Blade/admin/components/sidebar.blade.php | 4 + .../Hub/View/Blade/admin/console.blade.php | 132 ++ .../View/Blade/admin/content-editor.blade.php | 654 ++++++ .../Blade/admin/content-manager.blade.php | 161 ++ .../admin/content-manager/calendar.blade.php | 100 + .../admin/content-manager/dashboard.blade.php | 240 ++ .../admin/content-manager/kanban.blade.php | 58 + .../admin/content-manager/list.blade.php | 176 ++ .../admin/content-manager/webhooks.blade.php | 165 ++ .../Hub/View/Blade/admin/content.blade.php | 298 +++ .../Hub/View/Blade/admin/dashboard.blade.php | 96 + .../Hub/View/Blade/admin/databases.blade.php | 233 ++ .../View/Blade/admin/deployments.blade.php | 160 ++ .../Hub/View/Blade/admin/dev/cache.blade.php | 148 ++ .../Hub/View/Blade/admin/dev/logs.blade.php | 112 + .../Hub/View/Blade/admin/dev/routes.blade.php | 111 + .../admin/entitlement/dashboard.blade.php | 452 ++++ .../entitlement/feature-manager.blade.php | 77 + .../entitlement/package-manager.blade.php | 101 + .../View/Blade/admin/global-search.blade.php | 211 ++ .../Hub/View/Blade/admin/honeypot.blade.php | 180 ++ .../View/Blade/admin/layouts/app.blade.php | 126 ++ .../View/Blade/admin/platform-user.blade.php | 706 ++++++ .../Hub/View/Blade/admin/platform.blade.php | 278 +++ .../Hub/View/Blade/admin/profile.blade.php | 175 ++ .../View/Blade/admin/prompt-manager.blade.php | 242 ++ .../Blade/admin/service-manager.blade.php | 79 + .../View/Blade/admin/services-admin.blade.php | 1900 ++++++++++++++++ .../Hub/View/Blade/admin/settings.blade.php | 390 ++++ .../View/Blade/admin/site-settings.blade.php | 253 +++ .../Hub/View/Blade/admin/sites.blade.php | 72 + .../Blade/admin/usage-dashboard.blade.php | 209 ++ .../Blade/admin/waitlist-manager.blade.php | 40 + .../Blade/admin/workspace-switcher.blade.php | 58 + .../admin/wp-connector-settings.blade.php | 150 ++ .../Hub/View/Modal/Admin/AIServices.php | 179 ++ .../Hub/View/Modal/Admin/AccountUsage.php | 339 +++ .../Hub/View/Modal/Admin/ActivityLog.php | 181 ++ .../Hub/View/Modal/Admin/Analytics.php | 69 + .../Hub/View/Modal/Admin/BoostPurchase.php | 77 + src/Website/Hub/View/Modal/Admin/Console.php | 53 + src/Website/Hub/View/Modal/Admin/Content.php | 295 +++ .../Hub/View/Modal/Admin/ContentEditor.php | 843 +++++++ .../Hub/View/Modal/Admin/ContentManager.php | 520 +++++ .../Hub/View/Modal/Admin/Dashboard.php | 22 + .../Hub/View/Modal/Admin/Databases.php | 219 ++ .../Hub/View/Modal/Admin/Deployments.php | 274 +++ .../Modal/Admin/Entitlement/Dashboard.php | 534 +++++ .../Admin/Entitlement/FeatureManager.php | 259 +++ .../Admin/Entitlement/PackageManager.php | 306 +++ .../Hub/View/Modal/Admin/GlobalSearch.php | 257 +++ src/Website/Hub/View/Modal/Admin/Honeypot.php | 84 + src/Website/Hub/View/Modal/Admin/Platform.php | 162 ++ .../Hub/View/Modal/Admin/PlatformUser.php | 697 ++++++ src/Website/Hub/View/Modal/Admin/Profile.php | 128 ++ .../Hub/View/Modal/Admin/PromptManager.php | 335 +++ .../Hub/View/Modal/Admin/ServiceManager.php | 244 ++ .../Hub/View/Modal/Admin/ServicesAdmin.php | 1973 +++++++++++++++++ src/Website/Hub/View/Modal/Admin/Settings.php | 247 +++ .../Hub/View/Modal/Admin/SiteSettings.php | 297 +++ src/Website/Hub/View/Modal/Admin/Sites.php | 282 +++ .../Hub/View/Modal/Admin/UsageDashboard.php | 41 + .../Hub/View/Modal/Admin/WaitlistManager.php | 330 +++ .../View/Modal/Admin/WorkspaceSwitcher.php | 75 + .../View/Modal/Admin/WpConnectorSettings.php | 136 ++ 113 files changed, 25853 insertions(+), 175 deletions(-) create mode 100644 TODO.md create mode 100644 changelog/2026/jan/features.md create mode 100644 resources/views/components/forms/button.blade.php create mode 100644 resources/views/components/forms/checkbox.blade.php create mode 100644 resources/views/components/forms/form-group.blade.php create mode 100644 resources/views/components/forms/input.blade.php create mode 100644 resources/views/components/forms/select.blade.php create mode 100644 resources/views/components/forms/textarea.blade.php create mode 100644 resources/views/components/forms/toggle.blade.php create mode 100644 src/Boot.php create mode 100644 src/Forms/Concerns/HasAuthorizationProps.php create mode 100644 src/Forms/View/Components/Button.php create mode 100644 src/Forms/View/Components/Checkbox.php create mode 100644 src/Forms/View/Components/FormGroup.php create mode 100644 src/Forms/View/Components/Input.php create mode 100644 src/Forms/View/Components/Select.php create mode 100644 src/Forms/View/Components/Textarea.php create mode 100644 src/Forms/View/Components/Toggle.php create mode 100644 src/Mod/Hub/Boot.php create mode 100644 src/Mod/Hub/Controllers/TeapotController.php create mode 100644 src/Mod/Hub/Database/Seeders/ServiceSeeder.php create mode 100644 src/Mod/Hub/Lang/en_GB/hub.php create mode 100644 src/Mod/Hub/Migrations/2026_01_11_000001_create_honeypot_hits_table.php create mode 100644 src/Mod/Hub/Migrations/2026_01_20_000001_create_platform_services_table.php create mode 100644 src/Mod/Hub/Migrations/2026_01_20_000002_add_website_class_to_platform_services.php create mode 100644 src/Mod/Hub/Models/HoneypotHit.php create mode 100644 src/Mod/Hub/Models/Service.php create mode 100644 src/Mod/Hub/Tests/Feature/HubRoutesTest.php create mode 100644 src/Mod/Hub/Tests/Feature/WorkspaceSwitcherTest.php create mode 100644 src/Mod/Hub/Tests/UseCase/DashboardBasic.php create mode 100644 src/Search/Concerns/HasSearchProvider.php create mode 100644 src/Search/Contracts/SearchProvider.php create mode 100644 src/Search/Providers/AdminPageSearchProvider.php create mode 100644 src/Search/SearchProviderRegistry.php create mode 100644 src/Search/SearchResult.php create mode 100644 src/Search/Tests/SearchProviderRegistryTest.php create mode 100644 src/Search/Tests/SearchResultTest.php create mode 100644 src/Website/Hub/Boot.php create mode 100644 src/Website/Hub/Routes/admin.php create mode 100644 src/Website/Hub/View/Blade/admin/account-usage.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/activity-log.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/ai-services.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/analytics.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/boost-purchase.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/components/developer-bar.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/components/header.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/components/sidebar.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/console.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/content-editor.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/content-manager.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/content-manager/calendar.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/content-manager/dashboard.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/content-manager/kanban.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/content-manager/list.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/content-manager/webhooks.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/content.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/dashboard.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/databases.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/deployments.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/dev/cache.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/dev/logs.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/dev/routes.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/entitlement/dashboard.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/entitlement/feature-manager.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/entitlement/package-manager.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/global-search.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/honeypot.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/layouts/app.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/platform-user.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/platform.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/profile.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/prompt-manager.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/service-manager.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/services-admin.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/settings.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/site-settings.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/sites.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/usage-dashboard.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/waitlist-manager.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/workspace-switcher.blade.php create mode 100644 src/Website/Hub/View/Blade/admin/wp-connector-settings.blade.php create mode 100644 src/Website/Hub/View/Modal/Admin/AIServices.php create mode 100644 src/Website/Hub/View/Modal/Admin/AccountUsage.php create mode 100644 src/Website/Hub/View/Modal/Admin/ActivityLog.php create mode 100644 src/Website/Hub/View/Modal/Admin/Analytics.php create mode 100644 src/Website/Hub/View/Modal/Admin/BoostPurchase.php create mode 100644 src/Website/Hub/View/Modal/Admin/Console.php create mode 100644 src/Website/Hub/View/Modal/Admin/Content.php create mode 100644 src/Website/Hub/View/Modal/Admin/ContentEditor.php create mode 100644 src/Website/Hub/View/Modal/Admin/ContentManager.php create mode 100644 src/Website/Hub/View/Modal/Admin/Dashboard.php create mode 100644 src/Website/Hub/View/Modal/Admin/Databases.php create mode 100644 src/Website/Hub/View/Modal/Admin/Deployments.php create mode 100644 src/Website/Hub/View/Modal/Admin/Entitlement/Dashboard.php create mode 100644 src/Website/Hub/View/Modal/Admin/Entitlement/FeatureManager.php create mode 100644 src/Website/Hub/View/Modal/Admin/Entitlement/PackageManager.php create mode 100644 src/Website/Hub/View/Modal/Admin/GlobalSearch.php create mode 100644 src/Website/Hub/View/Modal/Admin/Honeypot.php create mode 100644 src/Website/Hub/View/Modal/Admin/Platform.php create mode 100644 src/Website/Hub/View/Modal/Admin/PlatformUser.php create mode 100644 src/Website/Hub/View/Modal/Admin/Profile.php create mode 100644 src/Website/Hub/View/Modal/Admin/PromptManager.php create mode 100644 src/Website/Hub/View/Modal/Admin/ServiceManager.php create mode 100644 src/Website/Hub/View/Modal/Admin/ServicesAdmin.php create mode 100644 src/Website/Hub/View/Modal/Admin/Settings.php create mode 100644 src/Website/Hub/View/Modal/Admin/SiteSettings.php create mode 100644 src/Website/Hub/View/Modal/Admin/Sites.php create mode 100644 src/Website/Hub/View/Modal/Admin/UsageDashboard.php create mode 100644 src/Website/Hub/View/Modal/Admin/WaitlistManager.php create mode 100644 src/Website/Hub/View/Modal/Admin/WorkspaceSwitcher.php create mode 100644 src/Website/Hub/View/Modal/Admin/WpConnectorSettings.php diff --git a/README.md b/README.md index 5db97d9..01ebaa8 100644 --- a/README.md +++ b/README.md @@ -1,138 +1,113 @@ -# Core PHP Framework Project +# Core Admin Package -[![CI](https://github.com/host-uk/core-template/actions/workflows/ci.yml/badge.svg)](https://github.com/host-uk/core-template/actions/workflows/ci.yml) -[![codecov](https://codecov.io/gh/host-uk/core-template/graph/badge.svg)](https://codecov.io/gh/host-uk/core-template) -[![PHP Version](https://img.shields.io/packagist/php-v/host-uk/core-template)](https://packagist.org/packages/host-uk/core-template) -[![Laravel](https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel)](https://laravel.com) -[![License](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE) - -A modular monolith Laravel application built with Core PHP Framework. - -## Features - -- **Core Framework** - Event-driven module system with lazy loading -- **Admin Panel** - Livewire-powered admin interface with Flux UI -- **REST API** - Scoped API keys, rate limiting, webhooks, OpenAPI docs -- **MCP Tools** - Model Context Protocol for AI agent integration - -## Requirements - -- PHP 8.2+ -- Composer 2.x -- SQLite (default) or MySQL/PostgreSQL -- Node.js 18+ (for frontend assets) +Admin panel components, Livewire modals, and service management interface for the Core PHP Framework. ## Installation ```bash -# Clone or create from template -git clone https://github.com/host-uk/core-template.git my-project -cd my-project - -# Install dependencies -composer install -npm install - -# Configure environment -cp .env.example .env -php artisan key:generate - -# Set up database -touch database/database.sqlite -php artisan migrate - -# Start development server -php artisan serve +composer require host-uk/core-admin ``` -Visit: http://localhost:8000 +## Features -## Project Structure - -``` -app/ -├── Console/ # Artisan commands -├── Http/ # Controllers & Middleware -├── Models/ # Eloquent models -├── Mod/ # Your custom modules -└── Providers/ # Service providers - -config/ -└── core.php # Core framework configuration - -routes/ -├── web.php # Public web routes -├── api.php # REST API routes -└── console.php # Artisan commands -``` - -## Creating Modules - -```bash -# Create a new module with all features -php artisan make:mod Blog --all - -# Create module with specific features -php artisan make:mod Shop --web --api --admin -``` - -Modules follow the event-driven pattern: +### Admin Menu System +Declarative menu registration with automatic permission checking: ```php - 'onWebRoutes', - ApiRoutesRegistering::class => 'onApiRoutes', - AdminPanelBooting::class => 'onAdminPanel', - ]; - - public function onWebRoutes(WebRoutesRegistering $event): void + public function registerMenu(AdminMenuRegistry $registry): void { - $event->routes(fn() => require __DIR__.'/Routes/web.php'); - $event->views('blog', __DIR__.'/Views'); + $registry->addItem('products', [ + 'label' => 'Products', + 'icon' => 'cube', + 'route' => 'admin.products.index', + 'permission' => 'products.view', + ]); } } ``` -## Core Packages +### Livewire Modals +Full-page Livewire components for admin interfaces: -| Package | Description | -|---------|-------------| -| `host-uk/core` | Core framework components | -| `host-uk/core-admin` | Admin panel & Livewire modals | -| `host-uk/core-api` | REST API with scopes & webhooks | -| `host-uk/core-mcp` | Model Context Protocol tools | +```php +use Livewire\Component; +use Livewire\Attributes\Title; -## Flux Pro (Optional) - -This template uses the free Flux UI components. If you have a Flux Pro license: - -```bash -# Configure authentication -composer config http-basic.composer.fluxui.dev your-email your-license-key - -# Add the repository -composer config repositories.flux-pro composer https://composer.fluxui.dev - -# Install Flux Pro -composer require livewire/flux-pro +#[Title('Product Manager')] +class ProductManager extends Component +{ + public function render(): View + { + return view('admin.products.manager') + ->layout('hub::admin.layouts.app'); + } +} ``` -## Documentation +### Form Components +Reusable form components with authorization: -- [Core PHP Framework](https://github.com/host-uk/core-php) -- [Getting Started Guide](https://host-uk.github.io/core-php/guide/) -- [Architecture](https://host-uk.github.io/core-php/architecture/) +- `` - 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 (European Union Public Licence) +EUPL-1.2 - See [LICENSE](../../LICENSE) for details. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..ef70508 --- /dev/null +++ b/TODO.md @@ -0,0 +1,227 @@ +# 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/changelog/2026/jan/features.md b/changelog/2026/jan/features.md new file mode 100644 index 0000000..96025c9 --- /dev/null +++ b/changelog/2026/jan/features.md @@ -0,0 +1,70 @@ +# 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/composer.json b/composer.json index 5cdb126..7a2be27 100644 --- a/composer.json +++ b/composer.json @@ -1,76 +1,23 @@ { - "name": "host-uk/core-template", - "type": "project", - "description": "Core PHP Framework - Project Template", - "keywords": ["laravel", "core-php", "modular", "framework", "template"], + "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", - "laravel/framework": "^12.0", - "laravel/tinker": "^2.10", - "livewire/flux": "^2.0", - "livewire/livewire": "^3.0", - "host-uk/core": "dev-main", - "host-uk/core-admin": "dev-main", - "host-uk/core-api": "dev-main", - "host-uk/core-mcp": "dev-main" - }, - "require-dev": { - "fakerphp/faker": "^1.23", - "laravel/pail": "^1.2", - "laravel/pint": "^1.18", - "laravel/sail": "^1.41", - "mockery/mockery": "^1.6", - "nunomaduro/collision": "^8.6", - "pestphp/pest": "^3.0", - "pestphp/pest-plugin-laravel": "^3.0" + "host-uk/core": "@dev" }, "autoload": { "psr-4": { - "App\\": "app/", - "Database\\Factories\\": "database/factories/", - "Database\\Seeders\\": "database/seeders/" + "Core\\Admin\\": "src/", + "Website\\Hub\\": "src/Website/Hub/" } }, - "autoload-dev": { - "psr-4": { - "Tests\\": "tests/" - } - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/host-uk/core-php.git" - } - ], - "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" - ] - }, "extra": { "laravel": { - "dont-discover": [] - } - }, - "config": { - "optimize-autoloader": true, - "preferred-install": "dist", - "sort-packages": true, - "allow-plugins": { - "php-http/discovery": true + "providers": [ + "Core\\Admin\\Boot" + ] } }, "minimum-stability": "stable", diff --git a/resources/views/components/forms/button.blade.php b/resources/views/components/forms/button.blade.php new file mode 100644 index 0000000..67fe9fa --- /dev/null +++ b/resources/views/components/forms/button.blade.php @@ -0,0 +1,82 @@ +{{-- + 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/resources/views/components/forms/checkbox.blade.php b/resources/views/components/forms/checkbox.blade.php new file mode 100644 index 0000000..bfb6036 --- /dev/null +++ b/resources/views/components/forms/checkbox.blade.php @@ -0,0 +1,88 @@ +{{-- + 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/resources/views/components/forms/form-group.blade.php b/resources/views/components/forms/form-group.blade.php new file mode 100644 index 0000000..fea442b --- /dev/null +++ b/resources/views/components/forms/form-group.blade.php @@ -0,0 +1,50 @@ +{{-- + 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/resources/views/components/forms/input.blade.php b/resources/views/components/forms/input.blade.php new file mode 100644 index 0000000..b3c9804 --- /dev/null +++ b/resources/views/components/forms/input.blade.php @@ -0,0 +1,77 @@ +{{-- + 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/resources/views/components/forms/select.blade.php b/resources/views/components/forms/select.blade.php new file mode 100644 index 0000000..a0b741d --- /dev/null +++ b/resources/views/components/forms/select.blade.php @@ -0,0 +1,108 @@ +{{-- + 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/resources/views/components/forms/textarea.blade.php b/resources/views/components/forms/textarea.blade.php new file mode 100644 index 0000000..0549fea --- /dev/null +++ b/resources/views/components/forms/textarea.blade.php @@ -0,0 +1,87 @@ +{{-- + 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/resources/views/components/forms/toggle.blade.php b/resources/views/components/forms/toggle.blade.php new file mode 100644 index 0000000..ed843a8 --- /dev/null +++ b/resources/views/components/forms/toggle.blade.php @@ -0,0 +1,104 @@ +{{-- + 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/src/Boot.php b/src/Boot.php new file mode 100644 index 0000000..53b1ea2 --- /dev/null +++ b/src/Boot.php @@ -0,0 +1,88 @@ +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/src/Forms/Concerns/HasAuthorizationProps.php b/src/Forms/Concerns/HasAuthorizationProps.php new file mode 100644 index 0000000..dff78c2 --- /dev/null +++ b/src/Forms/Concerns/HasAuthorizationProps.php @@ -0,0 +1,101 @@ + + * 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/src/Forms/View/Components/Button.php b/src/Forms/View/Components/Button.php new file mode 100644 index 0000000..a11ff8f --- /dev/null +++ b/src/Forms/View/Components/Button.php @@ -0,0 +1,135 @@ + + * 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/src/Forms/View/Components/Checkbox.php b/src/Forms/View/Components/Checkbox.php new file mode 100644 index 0000000..a9d8181 --- /dev/null +++ b/src/Forms/View/Components/Checkbox.php @@ -0,0 +1,89 @@ + + * ``` + */ +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/src/Forms/View/Components/FormGroup.php b/src/Forms/View/Components/FormGroup.php new file mode 100644 index 0000000..9e47675 --- /dev/null +++ b/src/Forms/View/Components/FormGroup.php @@ -0,0 +1,88 @@ + + * + *
+ * ``` + */ +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/src/Forms/View/Components/Input.php b/src/Forms/View/Components/Input.php new file mode 100644 index 0000000..e9e3a45 --- /dev/null +++ b/src/Forms/View/Components/Input.php @@ -0,0 +1,99 @@ + + * ``` + */ +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/src/Forms/View/Components/Select.php b/src/Forms/View/Components/Select.php new file mode 100644 index 0000000..4dba7eb --- /dev/null +++ b/src/Forms/View/Components/Select.php @@ -0,0 +1,146 @@ + 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/src/Forms/View/Components/Textarea.php b/src/Forms/View/Components/Textarea.php new file mode 100644 index 0000000..b4eb6df --- /dev/null +++ b/src/Forms/View/Components/Textarea.php @@ -0,0 +1,104 @@ + + * ``` + */ +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/src/Forms/View/Components/Toggle.php b/src/Forms/View/Components/Toggle.php new file mode 100644 index 0000000..4530d30 --- /dev/null +++ b/src/Forms/View/Components/Toggle.php @@ -0,0 +1,127 @@ + + * ``` + */ +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/src/Mod/Hub/Boot.php b/src/Mod/Hub/Boot.php new file mode 100644 index 0000000..5f09f3c --- /dev/null +++ b/src/Mod/Hub/Boot.php @@ -0,0 +1,268 @@ + + */ + 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/src/Mod/Hub/Controllers/TeapotController.php b/src/Mod/Hub/Controllers/TeapotController.php new file mode 100644 index 0000000..d50113b --- /dev/null +++ b/src/Mod/Hub/Controllers/TeapotController.php @@ -0,0 +1,158 @@ +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/src/Mod/Hub/Database/Seeders/ServiceSeeder.php b/src/Mod/Hub/Database/Seeders/ServiceSeeder.php new file mode 100644 index 0000000..07554be --- /dev/null +++ b/src/Mod/Hub/Database/Seeders/ServiceSeeder.php @@ -0,0 +1,110 @@ +> + */ + 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/src/Mod/Hub/Lang/en_GB/hub.php b/src/Mod/Hub/Lang/en_GB/hub.php new file mode 100644 index 0000000..fd3278c --- /dev/null +++ b/src/Mod/Hub/Lang/en_GB/hub.php @@ -0,0 +1,1034 @@ + [ + '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/src/Mod/Hub/Migrations/2026_01_11_000001_create_honeypot_hits_table.php b/src/Mod/Hub/Migrations/2026_01_11_000001_create_honeypot_hits_table.php new file mode 100644 index 0000000..75b5bce --- /dev/null +++ b/src/Mod/Hub/Migrations/2026_01_11_000001_create_honeypot_hits_table.php @@ -0,0 +1,35 @@ +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/src/Mod/Hub/Migrations/2026_01_20_000001_create_platform_services_table.php b/src/Mod/Hub/Migrations/2026_01_20_000001_create_platform_services_table.php new file mode 100644 index 0000000..b2a9146 --- /dev/null +++ b/src/Mod/Hub/Migrations/2026_01_20_000001_create_platform_services_table.php @@ -0,0 +1,49 @@ +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/src/Mod/Hub/Migrations/2026_01_20_000002_add_website_class_to_platform_services.php b/src/Mod/Hub/Migrations/2026_01_20_000002_add_website_class_to_platform_services.php new file mode 100644 index 0000000..c55aabe --- /dev/null +++ b/src/Mod/Hub/Migrations/2026_01_20_000002_add_website_class_to_platform_services.php @@ -0,0 +1,35 @@ +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/src/Mod/Hub/Models/HoneypotHit.php b/src/Mod/Hub/Models/HoneypotHit.php new file mode 100644 index 0000000..5373e89 --- /dev/null +++ b/src/Mod/Hub/Models/HoneypotHit.php @@ -0,0 +1,206 @@ + '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/src/Mod/Hub/Models/Service.php b/src/Mod/Hub/Models/Service.php new file mode 100644 index 0000000..edf4884 --- /dev/null +++ b/src/Mod/Hub/Models/Service.php @@ -0,0 +1,149 @@ + '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/src/Mod/Hub/Tests/Feature/HubRoutesTest.php b/src/Mod/Hub/Tests/Feature/HubRoutesTest.php new file mode 100644 index 0000000..0c5ea59 --- /dev/null +++ b/src/Mod/Hub/Tests/Feature/HubRoutesTest.php @@ -0,0 +1,255 @@ +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/src/Mod/Hub/Tests/Feature/WorkspaceSwitcherTest.php b/src/Mod/Hub/Tests/Feature/WorkspaceSwitcherTest.php new file mode 100644 index 0000000..8c75be6 --- /dev/null +++ b/src/Mod/Hub/Tests/Feature/WorkspaceSwitcherTest.php @@ -0,0 +1,198 @@ +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/src/Mod/Hub/Tests/UseCase/DashboardBasic.php b/src/Mod/Hub/Tests/UseCase/DashboardBasic.php new file mode 100644 index 0000000..d7021f3 --- /dev/null +++ b/src/Mod/Hub/Tests/UseCase/DashboardBasic.php @@ -0,0 +1,53 @@ +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/src/Search/Concerns/HasSearchProvider.php b/src/Search/Concerns/HasSearchProvider.php new file mode 100644 index 0000000..77db755 --- /dev/null +++ b/src/Search/Concerns/HasSearchProvider.php @@ -0,0 +1,49 @@ + '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/src/Search/Providers/AdminPageSearchProvider.php b/src/Search/Providers/AdminPageSearchProvider.php new file mode 100644 index 0000000..8d92e86 --- /dev/null +++ b/src/Search/Providers/AdminPageSearchProvider.php @@ -0,0 +1,216 @@ + + */ + 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/src/Search/SearchProviderRegistry.php b/src/Search/SearchProviderRegistry.php new file mode 100644 index 0000000..c5fa718 --- /dev/null +++ b/src/Search/SearchProviderRegistry.php @@ -0,0 +1,305 @@ +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/src/Search/SearchResult.php b/src/Search/SearchResult.php new file mode 100644 index 0000000..7035317 --- /dev/null +++ b/src/Search/SearchResult.php @@ -0,0 +1,104 @@ +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/src/Search/Tests/SearchProviderRegistryTest.php b/src/Search/Tests/SearchProviderRegistryTest.php new file mode 100644 index 0000000..55d7663 --- /dev/null +++ b/src/Search/Tests/SearchProviderRegistryTest.php @@ -0,0 +1,237 @@ +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/src/Search/Tests/SearchResultTest.php b/src/Search/Tests/SearchResultTest.php new file mode 100644 index 0000000..8a085ae --- /dev/null +++ b/src/Search/Tests/SearchResultTest.php @@ -0,0 +1,165 @@ + '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/src/Website/Hub/Boot.php b/src/Website/Hub/Boot.php new file mode 100644 index 0000000..0b74e4c --- /dev/null +++ b/src/Website/Hub/Boot.php @@ -0,0 +1,195 @@ + + */ + 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/src/Website/Hub/Routes/admin.php b/src/Website/Hub/Routes/admin.php new file mode 100644 index 0000000..5b615bd --- /dev/null +++ b/src/Website/Hub/Routes/admin.php @@ -0,0 +1,74 @@ +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/src/Website/Hub/View/Blade/admin/account-usage.blade.php b/src/Website/Hub/View/Blade/admin/account-usage.blade.php new file mode 100644 index 0000000..5aafc3a --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/account-usage.blade.php @@ -0,0 +1,691 @@ +
+ + + {{-- 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/src/Website/Hub/View/Blade/admin/activity-log.blade.php b/src/Website/Hub/View/Blade/admin/activity-log.blade.php new file mode 100644 index 0000000..8126a68 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/activity-log.blade.php @@ -0,0 +1,19 @@ + + + + @if(count($this->logNames) > 0) + + @endif + @if(count($this->events) > 0) + + @endif + + + + + diff --git a/src/Website/Hub/View/Blade/admin/ai-services.blade.php b/src/Website/Hub/View/Blade/admin/ai-services.blade.php new file mode 100644 index 0000000..9908080 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/ai-services.blade.php @@ -0,0 +1,316 @@ +
+ +
+
+

{{ __('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/src/Website/Hub/View/Blade/admin/analytics.blade.php b/src/Website/Hub/View/Blade/admin/analytics.blade.php new file mode 100644 index 0000000..3be8f2b --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/analytics.blade.php @@ -0,0 +1,62 @@ +
+ +
+
+

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/src/Website/Hub/View/Blade/admin/boost-purchase.blade.php b/src/Website/Hub/View/Blade/admin/boost-purchase.blade.php new file mode 100644 index 0000000..e6fb1ea --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/boost-purchase.blade.php @@ -0,0 +1,90 @@ +
+ +
+

{{ __('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/src/Website/Hub/View/Blade/admin/components/developer-bar.blade.php b/src/Website/Hub/View/Blade/admin/components/developer-bar.blade.php new file mode 100644 index 0000000..2542254 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/components/developer-bar.blade.php @@ -0,0 +1,505 @@ +@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/src/Website/Hub/View/Blade/admin/components/header.blade.php b/src/Website/Hub/View/Blade/admin/components/header.blade.php new file mode 100644 index 0000000..f71d30a --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/components/header.blade.php @@ -0,0 +1,183 @@ +
+
+
+ + +
+ + + + + + + +
+ + +
+ + + + + +
+ + +
+ + + + + +
+ + + @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/src/Website/Hub/View/Blade/admin/components/sidebar.blade.php b/src/Website/Hub/View/Blade/admin/components/sidebar.blade.php new file mode 100644 index 0000000..73d7db6 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/components/sidebar.blade.php @@ -0,0 +1,4 @@ + + + + diff --git a/src/Website/Hub/View/Blade/admin/console.blade.php b/src/Website/Hub/View/Blade/admin/console.blade.php new file mode 100644 index 0000000..efb0237 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/console.blade.php @@ -0,0 +1,132 @@ +
+ +
+
+

{{ __('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/src/Website/Hub/View/Blade/admin/content-editor.blade.php b/src/Website/Hub/View/Blade/admin/content-editor.blade.php new file mode 100644 index 0000000..7f6d9c8 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/content-editor.blade.php @@ -0,0 +1,654 @@ +
+ {{-- 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/src/Website/Hub/View/Blade/admin/content-manager.blade.php b/src/Website/Hub/View/Blade/admin/content-manager.blade.php new file mode 100644 index 0000000..11302d9 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/content-manager.blade.php @@ -0,0 +1,161 @@ +
+ +
+
+
+ {{ __('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/src/Website/Hub/View/Blade/admin/content-manager/calendar.blade.php b/src/Website/Hub/View/Blade/admin/content-manager/calendar.blade.php new file mode 100644 index 0000000..a88fc7e --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/content-manager/calendar.blade.php @@ -0,0 +1,100 @@ + + + @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/src/Website/Hub/View/Blade/admin/content-manager/dashboard.blade.php b/src/Website/Hub/View/Blade/admin/content-manager/dashboard.blade.php new file mode 100644 index 0000000..6083edf --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/content-manager/dashboard.blade.php @@ -0,0 +1,240 @@ + +
+ +
+
+ +
+
+ {{ $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/src/Website/Hub/View/Blade/admin/content-manager/kanban.blade.php b/src/Website/Hub/View/Blade/admin/content-manager/kanban.blade.php new file mode 100644 index 0000000..9a9c9e0 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/content-manager/kanban.blade.php @@ -0,0 +1,58 @@ + + + @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/src/Website/Hub/View/Blade/admin/content-manager/list.blade.php b/src/Website/Hub/View/Blade/admin/content-manager/list.blade.php new file mode 100644 index 0000000..9429d05 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/content-manager/list.blade.php @@ -0,0 +1,176 @@ + + +
+
+ + + + + + {{ __('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/src/Website/Hub/View/Blade/admin/content-manager/webhooks.blade.php b/src/Website/Hub/View/Blade/admin/content-manager/webhooks.blade.php new file mode 100644 index 0000000..052e9cf --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/content-manager/webhooks.blade.php @@ -0,0 +1,165 @@ + +
+ +
+
+ +
+
+ {{ $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/src/Website/Hub/View/Blade/admin/content.blade.php b/src/Website/Hub/View/Blade/admin/content.blade.php new file mode 100644 index 0000000..6c67741 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/content.blade.php @@ -0,0 +1,298 @@ +
+ +
+
+
+

{{ __('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/src/Website/Hub/View/Blade/admin/dashboard.blade.php b/src/Website/Hub/View/Blade/admin/dashboard.blade.php new file mode 100644 index 0000000..66d2440 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/dashboard.blade.php @@ -0,0 +1,96 @@ +
+ {{-- 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/src/Website/Hub/View/Blade/admin/databases.blade.php b/src/Website/Hub/View/Blade/admin/databases.blade.php new file mode 100644 index 0000000..45be416 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/databases.blade.php @@ -0,0 +1,233 @@ +
+ + 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/src/Website/Hub/View/Blade/admin/deployments.blade.php b/src/Website/Hub/View/Blade/admin/deployments.blade.php new file mode 100644 index 0000000..3ce3458 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/deployments.blade.php @@ -0,0 +1,160 @@ +
+ 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/src/Website/Hub/View/Blade/admin/dev/cache.blade.php b/src/Website/Hub/View/Blade/admin/dev/cache.blade.php new file mode 100644 index 0000000..225131e --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/dev/cache.blade.php @@ -0,0 +1,148 @@ +
+ {{-- 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/src/Website/Hub/View/Blade/admin/dev/logs.blade.php b/src/Website/Hub/View/Blade/admin/dev/logs.blade.php new file mode 100644 index 0000000..c9f8222 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/dev/logs.blade.php @@ -0,0 +1,112 @@ +
+ {{-- 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/src/Website/Hub/View/Blade/admin/dev/routes.blade.php b/src/Website/Hub/View/Blade/admin/dev/routes.blade.php new file mode 100644 index 0000000..95ff9d2 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/dev/routes.blade.php @@ -0,0 +1,111 @@ +
+ {{-- 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/src/Website/Hub/View/Blade/admin/entitlement/dashboard.blade.php b/src/Website/Hub/View/Blade/admin/entitlement/dashboard.blade.php new file mode 100644 index 0000000..2ac53c0 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/entitlement/dashboard.blade.php @@ -0,0 +1,452 @@ +
+ {{-- 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/src/Website/Hub/View/Blade/admin/entitlement/feature-manager.blade.php b/src/Website/Hub/View/Blade/admin/entitlement/feature-manager.blade.php new file mode 100644 index 0000000..ad5c7ef --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/entitlement/feature-manager.blade.php @@ -0,0 +1,77 @@ + + + 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/src/Website/Hub/View/Blade/admin/entitlement/package-manager.blade.php b/src/Website/Hub/View/Blade/admin/entitlement/package-manager.blade.php new file mode 100644 index 0000000..69a31c0 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/entitlement/package-manager.blade.php @@ -0,0 +1,101 @@ + + + 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/src/Website/Hub/View/Blade/admin/global-search.blade.php b/src/Website/Hub/View/Blade/admin/global-search.blade.php new file mode 100644 index 0000000..d6200f2 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/global-search.blade.php @@ -0,0 +1,211 @@ +{{-- +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/src/Website/Hub/View/Blade/admin/honeypot.blade.php b/src/Website/Hub/View/Blade/admin/honeypot.blade.php new file mode 100644 index 0000000..a68ceaf --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/honeypot.blade.php @@ -0,0 +1,180 @@ +
+ {{-- 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/src/Website/Hub/View/Blade/admin/layouts/app.blade.php b/src/Website/Hub/View/Blade/admin/layouts/app.blade.php new file mode 100644 index 0000000..e8cded6 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/layouts/app.blade.php @@ -0,0 +1,126 @@ +@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/src/Website/Hub/View/Blade/admin/platform-user.blade.php b/src/Website/Hub/View/Blade/admin/platform-user.blade.php new file mode 100644 index 0000000..d572c7c --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/platform-user.blade.php @@ -0,0 +1,706 @@ +
+ {{-- 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/src/Website/Hub/View/Blade/admin/platform.blade.php b/src/Website/Hub/View/Blade/admin/platform.blade.php new file mode 100644 index 0000000..f4387fc --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/platform.blade.php @@ -0,0 +1,278 @@ +
+ +
+
+

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/src/Website/Hub/View/Blade/admin/profile.blade.php b/src/Website/Hub/View/Blade/admin/profile.blade.php new file mode 100644 index 0000000..b3b7126 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/profile.blade.php @@ -0,0 +1,175 @@ +
+ +
+ +
+ +
+ +
+
+ {{ $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/src/Website/Hub/View/Blade/admin/prompt-manager.blade.php b/src/Website/Hub/View/Blade/admin/prompt-manager.blade.php new file mode 100644 index 0000000..3dcacee --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/prompt-manager.blade.php @@ -0,0 +1,242 @@ + + + {{ __('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/src/Website/Hub/View/Blade/admin/service-manager.blade.php b/src/Website/Hub/View/Blade/admin/service-manager.blade.php new file mode 100644 index 0000000..a516ca5 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/service-manager.blade.php @@ -0,0 +1,79 @@ + + + + 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/src/Website/Hub/View/Blade/admin/services-admin.blade.php b/src/Website/Hub/View/Blade/admin/services-admin.blade.php new file mode 100644 index 0000000..4a57c57 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/services-admin.blade.php @@ -0,0 +1,1900 @@ +@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/src/Website/Hub/View/Blade/admin/settings.blade.php b/src/Website/Hub/View/Blade/admin/settings.blade.php new file mode 100644 index 0000000..aeff5bc --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/settings.blade.php @@ -0,0 +1,390 @@ +
+ + + {{-- 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/src/Website/Hub/View/Blade/admin/site-settings.blade.php b/src/Website/Hub/View/Blade/admin/site-settings.blade.php new file mode 100644 index 0000000..460cbb1 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/site-settings.blade.php @@ -0,0 +1,253 @@ +@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/src/Website/Hub/View/Blade/admin/sites.blade.php b/src/Website/Hub/View/Blade/admin/sites.blade.php new file mode 100644 index 0000000..306c3f8 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/sites.blade.php @@ -0,0 +1,72 @@ + + + {{ __('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/src/Website/Hub/View/Blade/admin/usage-dashboard.blade.php b/src/Website/Hub/View/Blade/admin/usage-dashboard.blade.php new file mode 100644 index 0000000..d735104 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/usage-dashboard.blade.php @@ -0,0 +1,209 @@ +
+ +
+

{{ __('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/src/Website/Hub/View/Blade/admin/waitlist-manager.blade.php b/src/Website/Hub/View/Blade/admin/waitlist-manager.blade.php new file mode 100644 index 0000000..57ba181 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/waitlist-manager.blade.php @@ -0,0 +1,40 @@ + + + Export CSV + @if (count($selected) > 0) + + Invite Selected ({{ count($selected) }}) + + @endif + + + + + {{-- Stats Cards --}} + + + + + + + + + + + +
+ +
+
+ + +
diff --git a/src/Website/Hub/View/Blade/admin/workspace-switcher.blade.php b/src/Website/Hub/View/Blade/admin/workspace-switcher.blade.php new file mode 100644 index 0000000..d71efd0 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/workspace-switcher.blade.php @@ -0,0 +1,58 @@ +
+ + + + +
+
+

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

+
+
+ @foreach($workspaces as $slug => $workspace) + + @endforeach +
+
+
+ + {{ $current['domain'] }} +
+
+
+
diff --git a/src/Website/Hub/View/Blade/admin/wp-connector-settings.blade.php b/src/Website/Hub/View/Blade/admin/wp-connector-settings.blade.php new file mode 100644 index 0000000..9848f66 --- /dev/null +++ b/src/Website/Hub/View/Blade/admin/wp-connector-settings.blade.php @@ -0,0 +1,150 @@ +
+ + +
+ +
+ 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/src/Website/Hub/View/Modal/Admin/AIServices.php b/src/Website/Hub/View/Modal/Admin/AIServices.php new file mode 100644 index 0000000..ddd404a --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/AIServices.php @@ -0,0 +1,179 @@ + '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/src/Website/Hub/View/Modal/Admin/AccountUsage.php b/src/Website/Hub/View/Modal/Admin/AccountUsage.php new file mode 100644 index 0000000..f1b17ed --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/AccountUsage.php @@ -0,0 +1,339 @@ + '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/src/Website/Hub/View/Modal/Admin/ActivityLog.php b/src/Website/Hub/View/Modal/Admin/ActivityLog.php new file mode 100644 index 0000000..bb90ef7 --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/ActivityLog.php @@ -0,0 +1,181 @@ +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/src/Website/Hub/View/Modal/Admin/Analytics.php b/src/Website/Hub/View/Modal/Admin/Analytics.php new file mode 100644 index 0000000..ec7e96b --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/Analytics.php @@ -0,0 +1,69 @@ +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/src/Website/Hub/View/Modal/Admin/BoostPurchase.php b/src/Website/Hub/View/Modal/Admin/BoostPurchase.php new file mode 100644 index 0000000..0f7943b --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/BoostPurchase.php @@ -0,0 +1,77 @@ +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/src/Website/Hub/View/Modal/Admin/Console.php b/src/Website/Hub/View/Modal/Admin/Console.php new file mode 100644 index 0000000..156bc1e --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/Console.php @@ -0,0 +1,53 @@ +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/src/Website/Hub/View/Modal/Admin/Content.php b/src/Website/Hub/View/Modal/Admin/Content.php new file mode 100644 index 0000000..fa709b6 --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/Content.php @@ -0,0 +1,295 @@ +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/src/Website/Hub/View/Modal/Admin/ContentEditor.php b/src/Website/Hub/View/Modal/Admin/ContentEditor.php new file mode 100644 index 0000000..01af915 --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/ContentEditor.php @@ -0,0 +1,843 @@ + '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/src/Website/Hub/View/Modal/Admin/ContentManager.php b/src/Website/Hub/View/Modal/Admin/ContentManager.php new file mode 100644 index 0000000..df9fa98 --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/ContentManager.php @@ -0,0 +1,520 @@ +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/src/Website/Hub/View/Modal/Admin/Dashboard.php b/src/Website/Hub/View/Modal/Admin/Dashboard.php new file mode 100644 index 0000000..f71aa4e --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/Dashboard.php @@ -0,0 +1,22 @@ +layout('hub::admin.layouts.app', ['title' => 'Dashboard']); + } +} diff --git a/src/Website/Hub/View/Modal/Admin/Databases.php b/src/Website/Hub/View/Modal/Admin/Databases.php new file mode 100644 index 0000000..d1e4012 --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/Databases.php @@ -0,0 +1,219 @@ +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/src/Website/Hub/View/Modal/Admin/Deployments.php b/src/Website/Hub/View/Modal/Admin/Deployments.php new file mode 100644 index 0000000..659c29e --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/Deployments.php @@ -0,0 +1,274 @@ +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/src/Website/Hub/View/Modal/Admin/Entitlement/Dashboard.php b/src/Website/Hub/View/Modal/Admin/Entitlement/Dashboard.php new file mode 100644 index 0000000..324ea8e --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/Entitlement/Dashboard.php @@ -0,0 +1,534 @@ +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/src/Website/Hub/View/Modal/Admin/Entitlement/FeatureManager.php b/src/Website/Hub/View/Modal/Admin/Entitlement/FeatureManager.php new file mode 100644 index 0000000..a94e87a --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/Entitlement/FeatureManager.php @@ -0,0 +1,259 @@ +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/src/Website/Hub/View/Modal/Admin/Entitlement/PackageManager.php b/src/Website/Hub/View/Modal/Admin/Entitlement/PackageManager.php new file mode 100644 index 0000000..60bd27a --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/Entitlement/PackageManager.php @@ -0,0 +1,306 @@ +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/src/Website/Hub/View/Modal/Admin/GlobalSearch.php b/src/Website/Hub/View/Modal/Admin/GlobalSearch.php new file mode 100644 index 0000000..b418cab --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/GlobalSearch.php @@ -0,0 +1,257 @@ +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/src/Website/Hub/View/Modal/Admin/Honeypot.php b/src/Website/Hub/View/Modal/Admin/Honeypot.php new file mode 100644 index 0000000..1616587 --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/Honeypot.php @@ -0,0 +1,84 @@ + ['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/src/Website/Hub/View/Modal/Admin/Platform.php b/src/Website/Hub/View/Modal/Admin/Platform.php new file mode 100644 index 0000000..d44ff9f --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/Platform.php @@ -0,0 +1,162 @@ + ['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/src/Website/Hub/View/Modal/Admin/PlatformUser.php b/src/Website/Hub/View/Modal/Admin/PlatformUser.php new file mode 100644 index 0000000..94587f0 --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/PlatformUser.php @@ -0,0 +1,697 @@ +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/src/Website/Hub/View/Modal/Admin/Profile.php b/src/Website/Hub/View/Modal/Admin/Profile.php new file mode 100644 index 0000000..eba7aa4 --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/Profile.php @@ -0,0 +1,128 @@ +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/src/Website/Hub/View/Modal/Admin/PromptManager.php b/src/Website/Hub/View/Modal/Admin/PromptManager.php new file mode 100644 index 0000000..714c18c --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/PromptManager.php @@ -0,0 +1,335 @@ +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/src/Website/Hub/View/Modal/Admin/ServiceManager.php b/src/Website/Hub/View/Modal/Admin/ServiceManager.php new file mode 100644 index 0000000..08e24e4 --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/ServiceManager.php @@ -0,0 +1,244 @@ +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/src/Website/Hub/View/Modal/Admin/ServicesAdmin.php b/src/Website/Hub/View/Modal/Admin/ServicesAdmin.php new file mode 100644 index 0000000..244457e --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/ServicesAdmin.php @@ -0,0 +1,1973 @@ +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/src/Website/Hub/View/Modal/Admin/Settings.php b/src/Website/Hub/View/Modal/Admin/Settings.php new file mode 100644 index 0000000..95fde1d --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/Settings.php @@ -0,0 +1,247 @@ +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/src/Website/Hub/View/Modal/Admin/SiteSettings.php b/src/Website/Hub/View/Modal/Admin/SiteSettings.php new file mode 100644 index 0000000..4502db2 --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/SiteSettings.php @@ -0,0 +1,297 @@ +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/src/Website/Hub/View/Modal/Admin/Sites.php b/src/Website/Hub/View/Modal/Admin/Sites.php new file mode 100644 index 0000000..e6f2a5f --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/Sites.php @@ -0,0 +1,282 @@ +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/src/Website/Hub/View/Modal/Admin/UsageDashboard.php b/src/Website/Hub/View/Modal/Admin/UsageDashboard.php new file mode 100644 index 0000000..169cccb --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/UsageDashboard.php @@ -0,0 +1,41 @@ +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/src/Website/Hub/View/Modal/Admin/WaitlistManager.php b/src/Website/Hub/View/Modal/Admin/WaitlistManager.php new file mode 100644 index 0000000..1d68f98 --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/WaitlistManager.php @@ -0,0 +1,330 @@ +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/src/Website/Hub/View/Modal/Admin/WorkspaceSwitcher.php b/src/Website/Hub/View/Modal/Admin/WorkspaceSwitcher.php new file mode 100644 index 0000000..d5d2d53 --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/WorkspaceSwitcher.php @@ -0,0 +1,75 @@ +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/src/Website/Hub/View/Modal/Admin/WpConnectorSettings.php b/src/Website/Hub/View/Modal/Admin/WpConnectorSettings.php new file mode 100644 index 0000000..a1fb540 --- /dev/null +++ b/src/Website/Hub/View/Modal/Admin/WpConnectorSettings.php @@ -0,0 +1,136 @@ +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'); + } +}