[
  {
    "path": ".github/workflows/docker-build.yml",
    "content": "name: Build and Push Docker Image\n\non:\n  push:\n    branches:\n      - main\n      - dev\n    paths:\n      - 'backend/**'\n      - '.github/workflows/docker-build.yml'\n  workflow_dispatch:\n\nenv:\n  REGISTRY: ghcr.io\n  IMAGE_NAME: ${{ github.repository }}\n\njobs:\n  build-and-push:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n        with:\n          driver: docker-container\n\n      - name: Log in to the Container registry\n        uses: docker/login-action@v3\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Extract metadata (tags, labels) for Docker\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n          tags: |\n            # For main branch\n            type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}\n            type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' }}\n            # For dev branch\n            type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }}\n            # Common tags\n            type=ref,event=branch\n            type=sha,format=short\n            type=raw,value=${{ github.ref_name }}-${{ github.sha }},enable=${{ github.ref != 'refs/heads/main' }}\n\n      - name: Get git tag\n        id: git_tag\n        run: |\n          GIT_TAG=$(git describe --tags --exact-match 2>/dev/null || echo \"\")\n          echo \"tag=${GIT_TAG}\" >> $GITHUB_OUTPUT\n          echo \"Found tag: ${GIT_TAG}\"\n\n      - name: Build and push Docker image\n        uses: docker/build-push-action@v5\n        with:\n          context: ./backend\n          platforms: linux/amd64,linux/arm64\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          build-args: |\n            GIT_TAG=${{ steps.git_tag.outputs.tag }}\n            GIT_COMMIT=${{ github.sha }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max "
  },
  {
    "path": ".github/workflows/tests.yml",
    "content": "name: Tests\n\non:\n  push:\n    branches:\n      - main\n      - dev\n    paths:\n      - 'backend/**'\n      - '.github/workflows/tests.yml'\n  pull_request:\n    branches:\n      - main\n      - dev\n    paths:\n      - 'backend/**'\n  workflow_dispatch:\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Setup PHP\n        uses: shivammathur/setup-php@v2\n        with:\n          php-version: '8.4'\n          extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, opentelemetry, protobuf\n          coverage: xdebug\n\n      - name: Copy .env\n        working-directory: backend\n        run: php -r \"file_exists('.env') || copy('.env.example', '.env');\"\n\n      - name: Get Composer Cache Directory\n        working-directory: backend\n        id: composer-cache\n        run: echo \"dir=$(composer config cache-files-dir)\" >> $GITHUB_OUTPUT\n\n      - name: Cache Composer dependencies\n        uses: actions/cache@v3\n        with:\n          path: ${{ steps.composer-cache.outputs.dir }}\n          key: ${{ runner.os }}-composer-${{ hashFiles('backend/composer.lock') }}\n          restore-keys: ${{ runner.os }}-composer-\n\n      - name: Install Dependencies\n        working-directory: backend\n        run: |\n          composer self-update\n          composer install --prefer-dist --no-progress --no-scripts\n          composer dump-autoload\n\n      - name: Generate key\n        working-directory: backend\n        run: php artisan key:generate\n\n      - name: Directory Permissions\n        working-directory: backend\n        run: chmod -R 777 storage bootstrap/cache\n\n      - name: Execute tests (via Pest)\n        working-directory: backend\n        run: vendor/bin/pest --coverage-text --coverage-clover=coverage.xml\n\n      - name: Upload coverage to Codecov\n        uses: codecov/codecov-action@v5\n        with:\n          token: ${{ secrets.CODECOV_TOKEN }}\n          file: backend/coverage.xml\n          fail_ci_if_error: true "
  },
  {
    "path": ".gitignore",
    "content": ".env\ndatabase.sqlite\n.claude/"
  },
  {
    "path": "CLAUDE.md",
    "content": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Project Overview\n\nSpacepad is a privacy-focused room display application that shows real-time room availability, synced with calendars from Google, Microsoft, and CalDAV providers. The project consists of:\n\n- **Frontend (Flutter app)**: Cross-platform mobile app for room displays\n- **Backend (Laravel API)**: RESTful API handling authentication, calendar integration, and webhook processing\n\n## Architecture\n\n### Flutter App (`/app/`)\n- **MVC Pattern**: Controllers handle business logic, Services manage API communication\n- **State Management**: GetX for dependency injection and state management\n- **Main Components**:\n  - `controllers/`: Business logic controllers (DashboardController, DisplayController, LoginController)\n  - `services/`: API communication services (ApiService, AuthService, EventService, DisplayService)\n  - `models/`: Data models (EventModel, DisplayModel, DeviceModel, UserModel)\n  - `pages/`: UI screens (LoginPage, DashboardPage, DisplayPage, SplashPage)\n  - `components/`: Reusable UI components (ActionButton, EventLine, Spinner, Toast)\n\n### Laravel Backend (`/backend/`)\n- **Clean Architecture**: Controllers, Services, Models, and Data classes\n- **Authentication**: Laravel Sanctum for API authentication\n- **Calendar Integration**: Google Calendar API, Microsoft Graph API, CalDAV\n- **Main Components**:\n  - `app/Http/Controllers/API/`: API controllers for mobile app\n  - `app/Services/`: Business logic services (EventService, GoogleService, OutlookService, CalDAVService)\n  - `app/Models/`: Eloquent models (User, Display, Event, GoogleAccount, OutlookAccount)\n  - `app/Data/`: Data transfer objects using Spatie Laravel Data\n\n## Common Development Commands\n\n### Flutter App\n```bash\n# Navigate to app directory\ncd app\n\n# Install dependencies\nflutter pub get\n\n# Run the app in development\nflutter run\n\n# Build for Android\nflutter build apk\n\n# Build for iOS\nflutter build ios\n\n# Run tests\nflutter test\n\n# Generate launcher icons\nflutter pub run flutter_launcher_icons:main\n```\n\n### Laravel Backend\n```bash\n# Navigate to backend directory\ncd backend\n\n# Install PHP dependencies\ncomposer install\n\n# Install Node.js dependencies\nnpm install\n\n# Run development server (with queue, logs, and vite)\ncomposer dev\n\n# Run individual services\nphp artisan serve                    # Web server\nphp artisan queue:listen --tries=1  # Queue worker\nphp artisan pail --timeout=0        # Log viewer\nnpm run dev                         # Vite asset bundler\n\n# Database operations\nphp artisan migrate                  # Run migrations\nphp artisan db:seed                 # Seed database\nphp artisan migrate:fresh --seed    # Fresh migration with seeding\n\n# Clear caches\nphp artisan config:clear\nphp artisan cache:clear\nphp artisan route:clear\n\n# Run tests\nphp artisan test\n./vendor/bin/pest\n\n# Code formatting\n./vendor/bin/pint\n```\n\n## Key Architecture Patterns\n\n### Flutter App Patterns\n- **GetX Controllers**: Handle state management and business logic\n- **Service Layer**: Abstracts API calls and external dependencies\n- **Repository Pattern**: Services act as repositories for data access\n- **Translations**: Internationalization support with GetX translations\n\n### Laravel Backend Patterns\n- **API Resources**: Transform model data for API responses\n- **Service Classes**: Encapsulate business logic and external API interactions\n- **Data Classes**: Type-safe data transfer objects\n- **Middleware**: Authentication and request processing\n- **Webhooks**: Handle real-time calendar updates from external providers\n\n## Environment Configuration\n\n### Flutter App\n- Uses `.env` file for environment variables\n- Key variables: API endpoints, environment settings\n\n### Laravel Backend\n- Uses `.env` file for configuration\n- Key variables: Database, cache, queue, calendar API credentials, webhook URLs\n\n## Testing\n\n### Flutter\n- Widget tests in `/app/test/`\n- Run with `flutter test`\n\n### Laravel\n- Feature and Unit tests in `/backend/tests/`\n- Uses Pest PHP testing framework\n- Run with `php artisan test` or `./vendor/bin/pest`\n\n## External Integrations\n\n### Calendar Providers\n- **Google Calendar**: Uses Google Calendar API v3\n- **Microsoft 365**: Uses Microsoft Graph API\n- **CalDAV**: Generic CalDAV protocol support\n\n### Licensing\n- **LemonSqueezy**: Handles subscription billing for Pro features\n- **License validation**: Cloud-based instance validation system\n\n## Development Notes\n\n- **PHP Requirements**: Backend requires PHP 8.4+ (composer.json specifies ^8.4)\n- **Flutter Version**: Uses Flutter 3.29.0+ with Dart 3.7.0+\n- **Database**: SQLite for local development, supports other databases for production\n- **Queue System**: Laravel queues for background job processing\n- **Real-time Updates**: Webhook handlers for calendar change notifications\n- **Cross-platform**: Flutter app supports iOS and Android\n- **Internationalization**: Support for English, Dutch, French, Spanish, and German\n\n## Security Considerations\n\n- API authentication via Laravel Sanctum tokens\n- Device-specific authentication and display assignment\n- User activity tracking and session management\n- Webhook signature validation for external calendar providers\n- Environment-based configuration for sensitive data"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Spacepad\n\nThank you for your interest in contributing to Spacepad! This document provides guidelines and instructions for contributing to our project.\n\n## How to Contribute\n\n### Reporting Bugs\n\n- Check if the bug has already been reported in the [Issues](https://github.com/magweter/spacepad/issues) section\n- If not, create a new issue with a clear title and description\n- Include as much relevant information as possible (steps to reproduce, expected behavior, actual behavior, screenshots, etc.)\n- Use the bug report template if available\n\n### Suggesting Enhancements\n\n- Check if the enhancement has already been suggested in the [Issues](https://github.com/magweter/spacepad/issues) section\n- If not, create a new issue with a clear title and description\n- Explain why this enhancement would be useful to most users\n- Use the feature request template if available\n\n### Pull Requests\n\n1. Fork the repository\n2. Create a new branch for your feature or bugfix (`git checkout -b feature/amazing-feature`)\n3. Make your changes\n4. Run tests to ensure your changes don't break existing functionality\n5. Commit your changes (`git commit -m 'Add some amazing feature'`)\n6. Push to the branch (`git push origin feature/amazing-feature`)\n7. Open a Pull Request\n\n### Development Setup\n\n1. Clone your fork of the repository\n2. Install dependencies:\n   ```bash\n   composer install\n   pnpm install\n   ```\n3. Set up your environment:\n   ```bash\n   cp .env.example .env\n   php artisan key:generate\n   ```\n4. Start the development server:\n   ```bash\n   docker-compose up -d\n   ```\n\n### Coding Standards\n\n- Follow the [PSR-12](https://www.php-fig.org/psr/psr-12/) coding style guide for PHP\n- Always use import statements instead of inline fully qualified class names - See [Coding Standards](backend/docs/CODING_STANDARDS.md) for details\n- Use ESLint and Prettier for JavaScript/TypeScript\n- Write meaningful commit messages\n- Add comments for complex logic\n- Update documentation as needed\n\n### Testing\n\n- Write tests for new features\n- Ensure all tests pass before submitting a pull request\n- Run the test suite:\n  ```bash\n  php artisan test\n  ```\n\n## Documentation\n\n- Update the README.md if needed\n- Add inline documentation for complex functions\n- Update API documentation if you change endpoints\n- Add examples for new features\n\n## Release Process\n\n1. Update version numbers in relevant files\n2. Update the CHANGELOG.md\n3. Create a new release on GitHub\n4. Tag the release with the version number\n\n## Questions?\n\nIf you have any questions, please open an issue or contact the maintainers.\n\nThank you for contributing to Spacepad! "
  },
  {
    "path": "LICENSE.md",
    "content": "Spacepad Community License (Sustainable Use License)\n----------------------------------------------------\n\nCopyright (c) 2025 Spacepad.io\n\nPermission is hereby granted, free of charge, to any individual or organization (the \"User\") to use, copy, modify, and self-host this software (the “Software”) under the following conditions:\n\n1. **Permitted Use**\n   - You may use and modify the Software for **personal**, **educational**, or **non-commercial personal** purposes.\n   - You may also use the Software in a **limited commercial** or **non-commercial organizational** setting under the following condition:\n     - The deployment includes **no more than 1 active room display** at any time.\n     - The deployment is **self-hosted**.\n   - Use beyond this limit requires a paid Pro license, regardless of commercial or non-commercial status.\n\n2. **Non-Commercial Organizations (e.g. nonprofits, schools)**\n   - Organizations using the Software non-commercially (such as nonprofits, educational institutions, or NGOs) **must obtain a Pro license** for use beyond 1 display.\n   - Eligible non-commercial organizations are entitled to a **50% discount** on licensing fees.\n\n3. **Commercial Use Restrictions**\n   - Use of the Software with **more than 1 display**, or to access premium features, requires a valid **Spacepad Pro License**.\n   - You may not offer the Software as a hosted service (SaaS) or embed it in commercial products without written permission.\n\n4. **Attribution**\n   - You may remove or modify Spacepad branding only with a valid Pro license.\n   - Attribution in the form of a visible link or credit is appreciated but not required for personal use.\n\n5. **No Warranty**\n   - This Software is provided \"as is\", without warranty of any kind.\n\n6. **Termination**\n   - This license is automatically terminated if these terms are violated.\n   - Continued use beyond these terms requires a Pro license or separate written agreement.\n\nTo purchase a license or request a nonprofit discount, visit: https://spacepad.io/pricing  \n\nContact: support@spacepad.io"
  },
  {
    "path": "LICENSE_PRO.md",
    "content": "Spacepad Pro License (Commercial Use)\n--------------------------------------\n\nCopyright (c) 2025 Spacepad.io\n\nThis license grants you (the \"Licensee\") the right to use Spacepad in commercial deployments beyond what is permitted in the Community License.\n\n1. **Scope of License**\n   - You may deploy Spacepad in a commercial environment with the amount of displays specified by your purchased plan. For details of these plans see [pricing](https://spacepad.io/pricing).\n   - You may use all included Pro features.\n\n2. **Conditions**\n   - This license is **non-transferable** and limited to your organization or client.\n   - You may not sublicense, sell, or resell Spacepad or host it as a service to third parties without a separate agreement.\n\n3. **Delivery and Activation**\n   - A valid license key may be required to unlock Pro features.\n   - Use of license keys is subject to monitoring and rate-limiting.\n\n4. **Support**\n   - Commercial licenses include email support and update access during the active term of the license.\n\n5. **Term**\n   - Your license is valid for the subscription period purchased (monthly or yearly).\n   - Renewal is required to continue using Pro features after expiration.\n\n6. **Termination**\n   - This license is revoked if the terms are violated.\n   - License keys may be deactivated in the event of abuse, fraud, or breach of terms.\n\nFor questions or volume licensing, contact: support@spacepad.io"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\" style=\"margin-top: 120px\">\n  <h1 align=\"center\">Spacepad</h3>\n\n  <p align=\"center\">Simple room displays for every workplace. Display room availability in real-time, <br>synced with your rooms and calendars — ideal for tablets outside meeting spaces or home-offices. <br>Suitable for both small offices and larger deployments.\n    <br />\n    <br />\n    <a href=\"https://spacepad.io\">Website</a>\n    ·\n    <a href=\"https://github.com/magweter/spacepad/issues\">Report Issue</a>\n    ·\n    <a href=\"https://github.com/magweter/spacepad/discussions\">Suggest Feature</a>\n  </p>\n</p>\n\n![Product Overview](art/overview.png)\n\n## Our Mission\n\nWe’re building focused, fun tools for modern offices — tools that just work, without enterprise BS.\nSpacepad strives to be the perfect all-encompassing room display solution for SMB's.\n<br><br>\n✅ Simple: Easy to deploy and use<br>\n🔐 Privacy-first: Self hosted and open source auditable<br>\n💸 Fair and sustainable: We offer paid features to keep development active<br>\n❤️ Designed with care: Beautiful on tablets, easy on the eye<br>\n\n## Features\n\nSpacepad offers a comprehensive suite of features to make managing and viewing room availability effortless.\n\n### Core Features\n- **Real-time room availability** - Events sync instantly and display current room status\n- **Multi-room overview boards** - Create beautiful dashboards showing multiple rooms at once with customizable layouts (card, table, or grid view)\n- **On-device room booking** - Book rooms directly from the display with preset durations (15/30/60 min) or custom time slots\n- **Room check-in** - Check in to reserved meetings with configurable grace periods\n- **Event cancellation** - Cancel current meetings directly from the display\n- **Full day schedule** - View all upcoming events for the day on each display\n\n### Calendar Integrations\n- **Google Calendar** - Full integration with Google Workspace calendars\n- **Microsoft 365** - Seamless sync with Outlook calendars via Microsoft Graph API\n- **CalDAV** - Support for any CalDAV-compatible provider (Nextcloud, iCloud, etc.)\n- **Real-time webhooks** - Instant updates when calendar events change\n\n### Customization & Branding\n- **Custom themes** - Dark, light, or system theme support\n- **Custom logos** - Upload your organization's logo to displays and boards\n- **Font selection** - Choose from multiple Google Fonts (Inter, Roboto, Open Sans, Lato, Poppins, Montserrat)\n- **Multi-language support** - Available in English, Dutch, French, German, Spanish, and Swedish\n- **Privacy controls** - Hide meeting titles for privacy-sensitive environments\n- **Display settings** - Configure what information to show (booker name, next event, transitioning status)\n\n### Workspace & Collaboration\n- **Workspaces** - Organize displays, calendars, and boards by workspace\n- **Team collaboration** - Share workspaces with team members with role-based access\n- **Workspace-scoped resources** - All displays, calendars, and boards are organized per workspace\n\n### Deployment Options\n- **Cloud Hosted** - Get started in minutes with zero maintenance\n- **Self Hosted** - Full control over your data with Docker deployment\n- **Cross-platform** - Native iOS and Android apps built with Flutter\n\n> [!TIP]\n> The product is developing rapidly and we're happily accepting feedback and suggestions. Have a look at our [roadmap](#roadmap) on the implementation of new features or open a new [discussion](https://github.com/magweter/spacepad/discussions) to share ideas.\n\n## 🔧 Get Started\n\n### ☁️ Cloud Hosted (Easiest)\n\nLooking to get started quickly? Get started in minutes using our cloud.\n\n1. Visit [spacepad.io](https://spacepad.io)\n2. Create a free account\n3. Set up your first display — the first one is free forever\n4. Add more displays at $6/month each\n\nGreat for fast deployments with zero maintenance.\n\n### 🏗️ Self Hosted\n\nSelf hosting Spacepad is the perfect solution for businesses or enthousiasts who want control over their data.\n\nAs we believe in open source and personal tinkering, we want to support these communities.\n\n🙎‍♂️ If you’re a hobbyist or home user, enjoy Spacepad self hosted without limits — completely for free.\n\n🏢 If you're a business using Spacepad, we ask you to purchase a self-hosted license. We offer simple, sustainable and affordable flat-tiered pricing. Have a look at [Spacepad Pricing](https://spacepad.io/pricing).\n\nFor full setup instructions, see [Setup Guide](docs/SETUP.md).\n\n## 🛠 Licensing\n\nSpacepad is dual-licensed:\n\n- 🧑‍💻 **Community License** ([LICENSE.md](LICENSE.md))  \n  For personal use and self-hosted commercial use with up to 1 display.\n\n- 🏢 **Pro License** ([LICENSE_PRO.md](LICENSE_PRO.md))  \n  Required for commercial use with multiple displays or Pro features.\n\nPurchasing a license helps support continued development.\n\n## 🤝 Contributing\n\nWe love open source and welcome your contributions! See [CONTRIBUTING.md](CONTRIBUTING.md) to get started.\n\n## 📅 Roadmap Highlights\n\n- [x] Microsoft 365 / Outlook integration\n- [x] Self-hosted deployment with Docker\n- [x] Google Workspace / Google Calendar integration\n- [x] CalDAV support (Nextcloud, iCloud, etc.)\n- [x] On-device room booking with custom time slots\n- [x] Room check-in and release functionality\n- [x] Full day event schedule display\n- [x] Custom themes, logos, and fonts\n- [x] Multi-room overview boards (Pro feature)\n- [x] Workspace management and collaboration\n- [x] Multi-language support (6 languages)\n- [x] Privacy controls for meeting titles\n- [x] Multiple board view modes (card, table, grid)\n\nFeature requests? We're all ears! Please open a new [discussion](https://github.com/magweter/spacepad/discussions).\n"
  },
  {
    "path": "app/.gitignore",
    "content": "# Miscellaneous\n*.class\n*.log\n*.pyc\n*.swp\n.DS_Store\n.atom/\n.build/\n.buildlog/\n.history\n.svn/\n.swiftpm/\nmigrate_working_dir/\n.env\n\n# IntelliJ related\n*.iml\n*.ipr\n*.iws\n.idea/\n\n# The .vscode folder contains launch configuration and tasks you configure in\n# VS Code which you may wish to be included in version control, so this line\n# is commented out by default.\n#.vscode/\n\n# Flutter/Dart/Pub related\n**/doc/api/\n**/ios/Flutter/.last_build_id\n.dart_tool/\n.flutter-plugins\n.flutter-plugins-dependencies\n.pub-cache/\n.pub/\n/build/\n\n# Symbolication related\napp.*.symbols\n\n# Obfuscation related\napp.*.map.json\n\n# Android Studio will place build artifacts here\n/android/app/debug\n/android/app/profile\n/android/app/release\n\n# FVM Version Cache\n.fvm/"
  },
  {
    "path": "app/.metadata",
    "content": "# This file tracks properties of this Flutter project.\n# Used by Flutter tool to assess capabilities and perform upgrades etc.\n#\n# This file should be version controlled and should not be manually edited.\n\nversion:\n  revision: \"2d17299f20f3eb164ef21bc80b8079ba293e5985\"\n  channel: \"master\"\n\nproject_type: app\n\n# Tracks metadata for the flutter migrate command\nmigration:\n  platforms:\n    - platform: root\n      create_revision: 2d17299f20f3eb164ef21bc80b8079ba293e5985\n      base_revision: 2d17299f20f3eb164ef21bc80b8079ba293e5985\n    - platform: android\n      create_revision: 2d17299f20f3eb164ef21bc80b8079ba293e5985\n      base_revision: 2d17299f20f3eb164ef21bc80b8079ba293e5985\n    - platform: ios\n      create_revision: 2d17299f20f3eb164ef21bc80b8079ba293e5985\n      base_revision: 2d17299f20f3eb164ef21bc80b8079ba293e5985\n\n  # User provided section\n\n  # List of Local paths (relative to this file) that should be\n  # ignored by the migrate tool.\n  #\n  # Files that are not part of the templates will be ignored by default.\n  unmanaged_files:\n    - 'lib/main.dart'\n    - 'ios/Runner.xcodeproj/project.pbxproj'\n"
  },
  {
    "path": "app/README.md",
    "content": "# spacepad\n\nA simple and fun meeting room occupancy display.\n\n## Getting Started\n\nThis project is a starting point for a Flutter application.\n\nA few resources to get you started if this is your first Flutter project:\n\n- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)\n- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)\n\nFor help getting started with Flutter development, view the\n[online documentation](https://docs.flutter.dev/), which offers tutorials,\nsamples, guidance on mobile development, and a full API reference.\n"
  },
  {
    "path": "app/analysis_options.yaml",
    "content": "# This file configures the analyzer, which statically analyzes Dart code to\n# check for errors, warnings, and lints.\n#\n# The issues identified by the analyzer are surfaced in the UI of Dart-enabled\n# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be\n# invoked from the command line by running `flutter analyze`.\n\n# The following line activates a set of recommended lints for Flutter apps,\n# packages, and plugins designed to encourage good coding practices.\ninclude: package:flutter_lints/flutter.yaml\n\nlinter:\n  # The lint rules applied to this project can be customized in the\n  # section below to disable rules from the `package:flutter_lints/flutter.yaml`\n  # included above or to enable additional rules. A list of all available lints\n  # and their documentation is published at https://dart.dev/lints.\n  #\n  # Instead of disabling a lint rule for the entire project in the\n  # section below, it can also be suppressed for a single line of code\n  # or a specific dart file by using the `// ignore: name_of_lint` and\n  # `// ignore_for_file: name_of_lint` syntax on the line or in the file\n  # producing the lint.\n  rules:\n    # avoid_print: false  # Uncomment to disable the `avoid_print` rule\n    # prefer_single_quotes: true  # Uncomment to enable the `prefer_single_quotes` rule\n\n# Additional information about this file can be found at\n# https://dart.dev/guides/language/analysis-options\n"
  },
  {
    "path": "app/android/.gitignore",
    "content": "gradle-wrapper.jar\n/.gradle\n/captures/\n/gradlew\n/gradlew.bat\n/local.properties\nGeneratedPluginRegistrant.java\n\n# Remember to never publicly share your keystore.\n# See https://flutter.dev/to/reference-keystore\nkey.properties\n**/*.keystore\n**/*.jks\n"
  },
  {
    "path": "app/android/app/.gitignore",
    "content": ".cxx/"
  },
  {
    "path": "app/android/app/build.gradle.kts",
    "content": "import java.util.Properties\nimport java.io.FileInputStream\n\nplugins {\n    id(\"com.android.application\")\n    id(\"kotlin-android\")\n    // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.\n    id(\"dev.flutter.flutter-gradle-plugin\")\n}\n\n// Load keystore properties from key.properties\nval keystoreProperties = Properties()\nval keystorePropertiesFile = rootProject.file(\"key.properties\")\nif (keystorePropertiesFile.exists()) {\n    keystoreProperties.load(FileInputStream(keystorePropertiesFile))\n}\n\nandroid {\n    namespace = \"com.magweter.spacepad\"\n    compileSdk = flutter.compileSdkVersion\n    ndkVersion = \"27.0.12077973\"\n\n    compileOptions {\n        sourceCompatibility = JavaVersion.VERSION_1_8\n        targetCompatibility = JavaVersion.VERSION_1_8\n    }\n\n    kotlinOptions {\n        jvmTarget = JavaVersion.VERSION_1_8.toString()\n    }\n\n    defaultConfig {\n        applicationId = \"com.magweter.spacepad\"\n        minSdk = flutter.minSdkVersion\n        targetSdk = flutter.targetSdkVersion\n        versionCode = flutter.versionCode\n        versionName = flutter.versionName\n    }\n\n    signingConfigs {\n        create(\"release\") {\n            keyAlias = keystoreProperties[\"keyAlias\"] as String\n            keyPassword = keystoreProperties[\"keyPassword\"] as String\n            storeFile = file(keystoreProperties[\"storeFile\"] as String)\n            storePassword = keystoreProperties[\"storePassword\"] as String\n        }\n    }\n\n    buildTypes {\n        getByName(\"release\") {\n            isMinifyEnabled = true\n            isShrinkResources = true\n            signingConfig = signingConfigs.getByName(\"release\")\n            proguardFiles(\n                getDefaultProguardFile(\"proguard-android-optimize.txt\"),\n                \"proguard-rules.pro\"\n            )\n        }\n    }\n}\n\nflutter {\n    source = \"../..\"\n}"
  },
  {
    "path": "app/android/app/src/debug/AndroidManifest.xml",
    "content": "<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <!-- The INTERNET permission is required for development. Specifically,\n         the Flutter tool needs it to communicate with the running application\n         to allow setting breakpoints, to provide hot reload, etc.\n    -->\n    <uses-permission android:name=\"android.permission.INTERNET\"/>\n</manifest>\n"
  },
  {
    "path": "app/android/app/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <application\n        android:label=\"Spacepad\"\n        android:name=\"${applicationName}\"\n        android:icon=\"@mipmap/launcher_icon\">\n        <activity\n            android:name=\".MainActivity\"\n            android:exported=\"true\"\n            android:launchMode=\"singleTop\"\n            android:taskAffinity=\"\"\n            android:theme=\"@style/LaunchTheme\"\n            android:configChanges=\"orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode\"\n            android:hardwareAccelerated=\"true\"\n            android:windowSoftInputMode=\"adjustResize\">\n            <!-- Specifies an Android theme to apply to this Activity as soon as\n                 the Android process has started. This theme is visible to the user\n                 while the Flutter UI initializes. After that, this theme continues\n                 to determine the Window background behind the Flutter UI. -->\n            <meta-data\n              android:name=\"io.flutter.embedding.android.NormalTheme\"\n              android:resource=\"@style/NormalTheme\"\n              />\n            <intent-filter>\n                <action android:name=\"android.intent.action.MAIN\"/>\n                <category android:name=\"android.intent.category.LAUNCHER\"/>\n            </intent-filter>\n        </activity>\n        <!-- Don't delete the meta-data below.\n             This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->\n        <meta-data\n            android:name=\"flutterEmbedding\"\n            android:value=\"2\" />\n    </application>\n    <uses-permission android:name=\"android.permission.INTERNET\" />\n    <!-- Required to query activities that can process text, see:\n         https://developer.android.com/training/package-visibility and\n         https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.\n\n         In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->\n    <queries>\n        <intent>\n            <action android:name=\"android.intent.action.PROCESS_TEXT\"/>\n            <data android:mimeType=\"text/plain\"/>\n        </intent>\n    </queries>\n</manifest>\n"
  },
  {
    "path": "app/android/app/src/main/kotlin/com/magweter/spacepad/MainActivity.kt",
    "content": "package com.magweter.spacepad\n\nimport io.flutter.embedding.android.FlutterActivity\n\nclass MainActivity : FlutterActivity()\n"
  },
  {
    "path": "app/android/app/src/main/res/drawable/launch_background.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!-- Modify this file to customize your launch splash screen -->\n<layer-list xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <item android:drawable=\"@android:color/white\" />\n\n    <!-- You can insert your own image assets here -->\n    <!-- <item>\n        <bitmap\n            android:gravity=\"center\"\n            android:src=\"@mipmap/launch_image\" />\n    </item> -->\n</layer-list>\n"
  },
  {
    "path": "app/android/app/src/main/res/drawable-v21/launch_background.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!-- Modify this file to customize your launch splash screen -->\n<layer-list xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <item android:drawable=\"?android:colorBackground\" />\n\n    <!-- You can insert your own image assets here -->\n    <!-- <item>\n        <bitmap\n            android:gravity=\"center\"\n            android:src=\"@mipmap/launch_image\" />\n    </item> -->\n</layer-list>\n"
  },
  {
    "path": "app/android/app/src/main/res/values/styles.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n    <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->\n    <style name=\"LaunchTheme\" parent=\"@android:style/Theme.Light.NoTitleBar\">\n        <!-- Show a splash screen on the activity. Automatically removed when\n             the Flutter engine draws its first frame -->\n        <item name=\"android:windowBackground\">@drawable/launch_background</item>\n    </style>\n    <!-- Theme applied to the Android Window as soon as the process has started.\n         This theme determines the color of the Android Window while your\n         Flutter UI initializes, as well as behind your Flutter UI while its\n         running.\n\n         This Theme is only used starting with V2 of Flutter's Android embedding. -->\n    <style name=\"NormalTheme\" parent=\"@android:style/Theme.Light.NoTitleBar\">\n        <item name=\"android:windowBackground\">?android:colorBackground</item>\n    </style>\n</resources>\n"
  },
  {
    "path": "app/android/app/src/main/res/values-night/styles.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n    <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->\n    <style name=\"LaunchTheme\" parent=\"@android:style/Theme.Black.NoTitleBar\">\n        <!-- Show a splash screen on the activity. Automatically removed when\n             the Flutter engine draws its first frame -->\n        <item name=\"android:windowBackground\">@drawable/launch_background</item>\n    </style>\n    <!-- Theme applied to the Android Window as soon as the process has started.\n         This theme determines the color of the Android Window while your\n         Flutter UI initializes, as well as behind your Flutter UI while its\n         running.\n\n         This Theme is only used starting with V2 of Flutter's Android embedding. -->\n    <style name=\"NormalTheme\" parent=\"@android:style/Theme.Black.NoTitleBar\">\n        <item name=\"android:windowBackground\">?android:colorBackground</item>\n    </style>\n</resources>\n"
  },
  {
    "path": "app/android/app/src/profile/AndroidManifest.xml",
    "content": "<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <!-- The INTERNET permission is required for development. Specifically,\n         the Flutter tool needs it to communicate with the running application\n         to allow setting breakpoints, to provide hot reload, etc.\n    -->\n    <uses-permission android:name=\"android.permission.INTERNET\"/>\n</manifest>\n"
  },
  {
    "path": "app/android/build.gradle.kts",
    "content": "allprojects {\n    repositories {\n        google()\n        mavenCentral()\n    }\n}\n\nval newBuildDir: Directory = rootProject.layout.buildDirectory.dir(\"../../build\").get()\nrootProject.layout.buildDirectory.value(newBuildDir)\n\nsubprojects {\n    val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)\n    project.layout.buildDirectory.value(newSubprojectBuildDir)\n}\nsubprojects {\n    project.evaluationDependsOn(\":app\")\n}\n\nsubprojects {\n    plugins.withType<com.android.build.gradle.BasePlugin> {\n        extensions.findByType<com.android.build.gradle.BaseExtension>()?.apply {\n            if (namespace == null) {\n                namespace = group.toString()\n            }\n        }\n    }\n}\n\ntasks.register<Delete>(\"clean\") {\n    delete(rootProject.layout.buildDirectory)\n}\n"
  },
  {
    "path": "app/android/gradle/wrapper/gradle-wrapper.properties",
    "content": "distributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\nzipStoreBase=GRADLE_USER_HOME\nzipStorePath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-8.10.2-all.zip\n"
  },
  {
    "path": "app/android/gradle.properties",
    "content": "org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError\nandroid.useAndroidX=true\nandroid.enableJetifier=true\n"
  },
  {
    "path": "app/android/settings.gradle.kts",
    "content": "pluginManagement {\n    val flutterSdkPath = run {\n        val properties = java.util.Properties()\n        file(\"local.properties\").inputStream().use { properties.load(it) }\n        val flutterSdkPath = properties.getProperty(\"flutter.sdk\")\n        require(flutterSdkPath != null) { \"flutter.sdk not set in local.properties\" }\n        flutterSdkPath\n    }\n\n    includeBuild(\"$flutterSdkPath/packages/flutter_tools/gradle\")\n\n    repositories {\n        google()\n        mavenCentral()\n        gradlePluginPortal()\n    }\n}\n\nplugins {\n    id(\"dev.flutter.flutter-plugin-loader\") version \"1.0.0\"\n    id(\"com.android.application\") version \"8.7.0\" apply false\n    id(\"org.jetbrains.kotlin.android\") version \"2.2.21\" apply false\n}\n\ninclude(\":app\")\n"
  },
  {
    "path": "app/ios/.gitignore",
    "content": "**/dgph\n*.mode1v3\n*.mode2v3\n*.moved-aside\n*.pbxuser\n*.perspectivev3\n**/*sync/\n.sconsign.dblite\n.tags*\n**/.vagrant/\n**/DerivedData/\nIcon?\n**/Pods/\n**/.symlinks/\nprofile\nxcuserdata\n**/.generated/\nFlutter/App.framework\nFlutter/Flutter.framework\nFlutter/Flutter.podspec\nFlutter/Generated.xcconfig\nFlutter/ephemeral/\nFlutter/app.flx\nFlutter/app.zip\nFlutter/flutter_assets/\nFlutter/flutter_export_environment.sh\nServiceDefinitions.json\nRunner/GeneratedPluginRegistrant.*\n\n# Exceptions to above rules.\n!default.mode1v3\n!default.mode2v3\n!default.pbxuser\n!default.perspectivev3\n"
  },
  {
    "path": "app/ios/Flutter/AppFrameworkInfo.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n  <key>CFBundleDevelopmentRegion</key>\n  <string>en</string>\n  <key>CFBundleExecutable</key>\n  <string>App</string>\n  <key>CFBundleIdentifier</key>\n  <string>io.flutter.flutter.app</string>\n  <key>CFBundleInfoDictionaryVersion</key>\n  <string>6.0</string>\n  <key>CFBundleName</key>\n  <string>App</string>\n  <key>CFBundlePackageType</key>\n  <string>FMWK</string>\n  <key>CFBundleShortVersionString</key>\n  <string>1.0</string>\n  <key>CFBundleSignature</key>\n  <string>????</string>\n  <key>CFBundleVersion</key>\n  <string>1.0</string>\n  <key>MinimumOSVersion</key>\n  <string>12.0</string>\n</dict>\n</plist>\n"
  },
  {
    "path": "app/ios/Flutter/Debug.xcconfig",
    "content": "#include? \"Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig\"\n#include \"Generated.xcconfig\"\n"
  },
  {
    "path": "app/ios/Flutter/Release.xcconfig",
    "content": "#include? \"Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig\"\n#include \"Generated.xcconfig\"\n"
  },
  {
    "path": "app/ios/Podfile",
    "content": "# Uncomment this line to define a global platform for your project\n# platform :ios, '12.0'\n\n# CocoaPods analytics sends network stats synchronously affecting flutter build latency.\nENV['COCOAPODS_DISABLE_STATS'] = 'true'\n\nproject 'Runner', {\n  'Debug' => :debug,\n  'Profile' => :release,\n  'Release' => :release,\n}\n\ndef flutter_root\n  generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)\n  unless File.exist?(generated_xcode_build_settings_path)\n    raise \"#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first\"\n  end\n\n  File.foreach(generated_xcode_build_settings_path) do |line|\n    matches = line.match(/FLUTTER_ROOT\\=(.*)/)\n    return matches[1].strip if matches\n  end\n  raise \"FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get\"\nend\n\nrequire File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)\n\nflutter_ios_podfile_setup\n\ntarget 'Runner' do\n  use_frameworks!\n\n  flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))\n  target 'RunnerTests' do\n    inherit! :search_paths\n  end\nend\n\npost_install do |installer|\n  installer.pods_project.targets.each do |target|\n    flutter_additional_ios_build_settings(target)\n  end\nend\n"
  },
  {
    "path": "app/ios/Runner/AppDelegate.swift",
    "content": "import Flutter\nimport UIKit\n\n@main\n@objc class AppDelegate: FlutterAppDelegate {\n  override func application(\n    _ application: UIApplication,\n    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?\n  ) -> Bool {\n    GeneratedPluginRegistrant.register(with: self)\n    return super.application(application, didFinishLaunchingWithOptions: launchOptions)\n  }\n}\n"
  },
  {
    "path": "app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json",
    "content": "{\"images\":[{\"size\":\"20x20\",\"idiom\":\"iphone\",\"filename\":\"Icon-App-20x20@2x.png\",\"scale\":\"2x\"},{\"size\":\"20x20\",\"idiom\":\"iphone\",\"filename\":\"Icon-App-20x20@3x.png\",\"scale\":\"3x\"},{\"size\":\"29x29\",\"idiom\":\"iphone\",\"filename\":\"Icon-App-29x29@1x.png\",\"scale\":\"1x\"},{\"size\":\"29x29\",\"idiom\":\"iphone\",\"filename\":\"Icon-App-29x29@2x.png\",\"scale\":\"2x\"},{\"size\":\"29x29\",\"idiom\":\"iphone\",\"filename\":\"Icon-App-29x29@3x.png\",\"scale\":\"3x\"},{\"size\":\"40x40\",\"idiom\":\"iphone\",\"filename\":\"Icon-App-40x40@2x.png\",\"scale\":\"2x\"},{\"size\":\"40x40\",\"idiom\":\"iphone\",\"filename\":\"Icon-App-40x40@3x.png\",\"scale\":\"3x\"},{\"size\":\"57x57\",\"idiom\":\"iphone\",\"filename\":\"Icon-App-57x57@1x.png\",\"scale\":\"1x\"},{\"size\":\"57x57\",\"idiom\":\"iphone\",\"filename\":\"Icon-App-57x57@2x.png\",\"scale\":\"2x\"},{\"size\":\"60x60\",\"idiom\":\"iphone\",\"filename\":\"Icon-App-60x60@2x.png\",\"scale\":\"2x\"},{\"size\":\"60x60\",\"idiom\":\"iphone\",\"filename\":\"Icon-App-60x60@3x.png\",\"scale\":\"3x\"},{\"size\":\"20x20\",\"idiom\":\"ipad\",\"filename\":\"Icon-App-20x20@1x.png\",\"scale\":\"1x\"},{\"size\":\"20x20\",\"idiom\":\"ipad\",\"filename\":\"Icon-App-20x20@2x.png\",\"scale\":\"2x\"},{\"size\":\"29x29\",\"idiom\":\"ipad\",\"filename\":\"Icon-App-29x29@1x.png\",\"scale\":\"1x\"},{\"size\":\"29x29\",\"idiom\":\"ipad\",\"filename\":\"Icon-App-29x29@2x.png\",\"scale\":\"2x\"},{\"size\":\"40x40\",\"idiom\":\"ipad\",\"filename\":\"Icon-App-40x40@1x.png\",\"scale\":\"1x\"},{\"size\":\"40x40\",\"idiom\":\"ipad\",\"filename\":\"Icon-App-40x40@2x.png\",\"scale\":\"2x\"},{\"size\":\"50x50\",\"idiom\":\"ipad\",\"filename\":\"Icon-App-50x50@1x.png\",\"scale\":\"1x\"},{\"size\":\"50x50\",\"idiom\":\"ipad\",\"filename\":\"Icon-App-50x50@2x.png\",\"scale\":\"2x\"},{\"size\":\"72x72\",\"idiom\":\"ipad\",\"filename\":\"Icon-App-72x72@1x.png\",\"scale\":\"1x\"},{\"size\":\"72x72\",\"idiom\":\"ipad\",\"filename\":\"Icon-App-72x72@2x.png\",\"scale\":\"2x\"},{\"size\":\"76x76\",\"idiom\":\"ipad\",\"filename\":\"Icon-App-76x76@1x.png\",\"scale\":\"1x\"},{\"size\":\"76x76\",\"idiom\":\"ipad\",\"filename\":\"Icon-App-76x76@2x.png\",\"scale\":\"2x\"},{\"size\":\"83.5x83.5\",\"idiom\":\"ipad\",\"filename\":\"Icon-App-83.5x83.5@2x.png\",\"scale\":\"2x\"},{\"size\":\"1024x1024\",\"idiom\":\"ios-marketing\",\"filename\":\"Icon-App-1024x1024@1x.png\",\"scale\":\"1x\"}],\"info\":{\"version\":1,\"author\":\"xcode\"}}"
  },
  {
    "path": "app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"idiom\" : \"universal\",\n      \"filename\" : \"LaunchImage.png\",\n      \"scale\" : \"1x\"\n    },\n    {\n      \"idiom\" : \"universal\",\n      \"filename\" : \"LaunchImage@2x.png\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"idiom\" : \"universal\",\n      \"filename\" : \"LaunchImage@3x.png\",\n      \"scale\" : \"3x\"\n    }\n  ],\n  \"info\" : {\n    \"version\" : 1,\n    \"author\" : \"xcode\"\n  }\n}\n"
  },
  {
    "path": "app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md",
    "content": "# Launch Screen Assets\n\nYou can customize the launch screen with your own desired assets by replacing the image files in this directory.\n\nYou can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images."
  },
  {
    "path": "app/ios/Runner/Base.lproj/LaunchScreen.storyboard",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n<document type=\"com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB\" version=\"3.0\" toolsVersion=\"12121\" systemVersion=\"16G29\" targetRuntime=\"iOS.CocoaTouch\" propertyAccessControl=\"none\" useAutolayout=\"YES\" launchScreen=\"YES\" colorMatched=\"YES\" initialViewController=\"01J-lp-oVM\">\n    <dependencies>\n        <deployment identifier=\"iOS\"/>\n        <plugIn identifier=\"com.apple.InterfaceBuilder.IBCocoaTouchPlugin\" version=\"12089\"/>\n    </dependencies>\n    <scenes>\n        <!--View Controller-->\n        <scene sceneID=\"EHf-IW-A2E\">\n            <objects>\n                <viewController id=\"01J-lp-oVM\" sceneMemberID=\"viewController\">\n                    <layoutGuides>\n                        <viewControllerLayoutGuide type=\"top\" id=\"Ydg-fD-yQy\"/>\n                        <viewControllerLayoutGuide type=\"bottom\" id=\"xbc-2k-c8Z\"/>\n                    </layoutGuides>\n                    <view key=\"view\" contentMode=\"scaleToFill\" id=\"Ze5-6b-2t3\">\n                        <autoresizingMask key=\"autoresizingMask\" widthSizable=\"YES\" heightSizable=\"YES\"/>\n                        <subviews>\n                            <imageView opaque=\"NO\" clipsSubviews=\"YES\" multipleTouchEnabled=\"YES\" contentMode=\"center\" image=\"LaunchImage\" translatesAutoresizingMaskIntoConstraints=\"NO\" id=\"YRO-k0-Ey4\">\n                            </imageView>\n                        </subviews>\n                        <color key=\"backgroundColor\" red=\"1\" green=\"1\" blue=\"1\" alpha=\"1\" colorSpace=\"custom\" customColorSpace=\"sRGB\"/>\n                        <constraints>\n                            <constraint firstItem=\"YRO-k0-Ey4\" firstAttribute=\"centerX\" secondItem=\"Ze5-6b-2t3\" secondAttribute=\"centerX\" id=\"1a2-6s-vTC\"/>\n                            <constraint firstItem=\"YRO-k0-Ey4\" firstAttribute=\"centerY\" secondItem=\"Ze5-6b-2t3\" secondAttribute=\"centerY\" id=\"4X2-HB-R7a\"/>\n                        </constraints>\n                    </view>\n                </viewController>\n                <placeholder placeholderIdentifier=\"IBFirstResponder\" id=\"iYj-Kq-Ea1\" userLabel=\"First Responder\" sceneMemberID=\"firstResponder\"/>\n            </objects>\n            <point key=\"canvasLocation\" x=\"53\" y=\"375\"/>\n        </scene>\n    </scenes>\n    <resources>\n        <image name=\"LaunchImage\" width=\"168\" height=\"185\"/>\n    </resources>\n</document>\n"
  },
  {
    "path": "app/ios/Runner/Base.lproj/Main.storyboard",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n<document type=\"com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB\" version=\"3.0\" toolsVersion=\"10117\" systemVersion=\"15F34\" targetRuntime=\"iOS.CocoaTouch\" propertyAccessControl=\"none\" useAutolayout=\"YES\" useTraitCollections=\"YES\" initialViewController=\"BYZ-38-t0r\">\n    <dependencies>\n        <deployment identifier=\"iOS\"/>\n        <plugIn identifier=\"com.apple.InterfaceBuilder.IBCocoaTouchPlugin\" version=\"10085\"/>\n    </dependencies>\n    <scenes>\n        <!--Flutter View Controller-->\n        <scene sceneID=\"tne-QT-ifu\">\n            <objects>\n                <viewController id=\"BYZ-38-t0r\" customClass=\"FlutterViewController\" sceneMemberID=\"viewController\">\n                    <layoutGuides>\n                        <viewControllerLayoutGuide type=\"top\" id=\"y3c-jy-aDJ\"/>\n                        <viewControllerLayoutGuide type=\"bottom\" id=\"wfy-db-euE\"/>\n                    </layoutGuides>\n                    <view key=\"view\" contentMode=\"scaleToFill\" id=\"8bC-Xf-vdC\">\n                        <rect key=\"frame\" x=\"0.0\" y=\"0.0\" width=\"600\" height=\"600\"/>\n                        <autoresizingMask key=\"autoresizingMask\" widthSizable=\"YES\" heightSizable=\"YES\"/>\n                        <color key=\"backgroundColor\" white=\"1\" alpha=\"1\" colorSpace=\"custom\" customColorSpace=\"calibratedWhite\"/>\n                    </view>\n                </viewController>\n                <placeholder placeholderIdentifier=\"IBFirstResponder\" id=\"dkx-z0-nzr\" sceneMemberID=\"firstResponder\"/>\n            </objects>\n        </scene>\n    </scenes>\n</document>\n"
  },
  {
    "path": "app/ios/Runner/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>CFBundleDevelopmentRegion</key>\n\t<string>$(DEVELOPMENT_LANGUAGE)</string>\n\t<key>CFBundleDisplayName</key>\n\t<string>Spacepad</string>\n\t<key>CFBundleExecutable</key>\n\t<string>$(EXECUTABLE_NAME)</string>\n\t<key>CFBundleIdentifier</key>\n\t<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>\n\t<key>CFBundleInfoDictionaryVersion</key>\n\t<string>6.0</string>\n\t<key>CFBundleName</key>\n\t<string>spacepad</string>\n\t<key>CFBundlePackageType</key>\n\t<string>APPL</string>\n\t<key>CFBundleShortVersionString</key>\n\t<string>$(FLUTTER_BUILD_NAME)</string>\n\t<key>CFBundleSignature</key>\n\t<string>????</string>\n\t<key>CFBundleVersion</key>\n\t<string>$(FLUTTER_BUILD_NUMBER)</string>\n\t<key>LSRequiresIPhoneOS</key>\n\t<true/>\n\t<key>UILaunchStoryboardName</key>\n\t<string>LaunchScreen</string>\n\t<key>UIMainStoryboardFile</key>\n\t<string>Main</string>\n\t<key>UISupportedInterfaceOrientations</key>\n\t<array>\n\t\t<string>UIInterfaceOrientationPortrait</string>\n\t\t<string>UIInterfaceOrientationLandscapeLeft</string>\n\t\t<string>UIInterfaceOrientationLandscapeRight</string>\n\t</array>\n\t<key>UISupportedInterfaceOrientations~ipad</key>\n\t<array>\n\t\t<string>UIInterfaceOrientationPortrait</string>\n\t\t<string>UIInterfaceOrientationPortraitUpsideDown</string>\n\t\t<string>UIInterfaceOrientationLandscapeLeft</string>\n\t\t<string>UIInterfaceOrientationLandscapeRight</string>\n\t</array>\n\t<key>CADisableMinimumFrameDurationOnPhone</key>\n\t<true/>\n\t<key>UIApplicationSupportsIndirectInputEvents</key>\n\t<true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "app/ios/Runner/Runner-Bridging-Header.h",
    "content": "#import \"GeneratedPluginRegistrant.h\"\n"
  },
  {
    "path": "app/ios/Runner.xcodeproj/project.pbxproj",
    "content": "// !$*UTF8*$!\n{\n\tarchiveVersion = 1;\n\tclasses = {\n\t};\n\tobjectVersion = 54;\n\tobjects = {\n\n/* Begin PBXBuildFile section */\n\t\t04CBFBCE232CB3029E7ED08A /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EA6696D39F3FA1106D16702B /* Pods_Runner.framework */; };\n\t\t1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };\n\t\t331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };\n\t\t3670718E77EE3226CF664312 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 03B40A5A39F58BC328EECDE1 /* Pods_RunnerTests.framework */; };\n\t\t3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };\n\t\t74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };\n\t\t97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };\n\t\t97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };\n\t\t97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };\n/* End PBXBuildFile section */\n\n/* Begin PBXContainerItemProxy section */\n\t\t331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {\n\t\t\tisa = PBXContainerItemProxy;\n\t\t\tcontainerPortal = 97C146E61CF9000F007C117D /* Project object */;\n\t\t\tproxyType = 1;\n\t\t\tremoteGlobalIDString = 97C146ED1CF9000F007C117D;\n\t\t\tremoteInfo = Runner;\n\t\t};\n/* End PBXContainerItemProxy section */\n\n/* Begin PBXCopyFilesBuildPhase section */\n\t\t9705A1C41CF9048500538489 /* Embed Frameworks */ = {\n\t\t\tisa = PBXCopyFilesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tdstPath = \"\";\n\t\t\tdstSubfolderSpec = 10;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tname = \"Embed Frameworks\";\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXCopyFilesBuildPhase section */\n\n/* Begin PBXFileReference section */\n\t\t03B40A5A39F58BC328EECDE1 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\t0861BD94EAEC750534439CD7 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = \"Pods-Runner.debug.xcconfig\"; path = \"Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig\"; sourceTree = \"<group>\"; };\n\t\t1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = \"<group>\"; };\n\t\t1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = \"<group>\"; };\n\t\t288B2822D826A92AAEFB3A7A /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = \"Pods-Runner.profile.xcconfig\"; path = \"Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig\"; sourceTree = \"<group>\"; };\n\t\t331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = \"<group>\"; };\n\t\t331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\t3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = \"<group>\"; };\n\t\t62CD59CF29A95104CB65D9E7 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = \"Pods-Runner.release.xcconfig\"; path = \"Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig\"; sourceTree = \"<group>\"; };\n\t\t74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"Runner-Bridging-Header.h\"; sourceTree = \"<group>\"; };\n\t\t74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = \"<group>\"; };\n\t\t7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = \"<group>\"; };\n\t\t8993A6110D12561E9348200F /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = \"Pods-RunnerTests.release.xcconfig\"; path = \"Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig\"; sourceTree = \"<group>\"; };\n\t\t9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = \"<group>\"; };\n\t\t9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = \"<group>\"; };\n\t\t97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\t97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = \"<group>\"; };\n\t\t97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = \"<group>\"; };\n\t\t97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = \"<group>\"; };\n\t\t97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = \"<group>\"; };\n\t\tEA6696D39F3FA1106D16702B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\tEDAA5666919238FE907B7EE2 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = \"Pods-RunnerTests.debug.xcconfig\"; path = \"Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig\"; sourceTree = \"<group>\"; };\n\t\tFE7BEC1B12808A29558CCC47 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = \"Pods-RunnerTests.profile.xcconfig\"; path = \"Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig\"; sourceTree = \"<group>\"; };\n/* End PBXFileReference section */\n\n/* Begin PBXFrameworksBuildPhase section */\n\t\t64E37CD377E15AE198178E4F /* Frameworks */ = {\n\t\t\tisa = PBXFrameworksBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t3670718E77EE3226CF664312 /* Pods_RunnerTests.framework in Frameworks */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\t97C146EB1CF9000F007C117D /* Frameworks */ = {\n\t\t\tisa = PBXFrameworksBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t04CBFBCE232CB3029E7ED08A /* Pods_Runner.framework in Frameworks */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXFrameworksBuildPhase section */\n\n/* Begin PBXGroup section */\n\t\t331C8082294A63A400263BE5 /* RunnerTests */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t331C807B294A618700263BE5 /* RunnerTests.swift */,\n\t\t\t);\n\t\t\tpath = RunnerTests;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t9740EEB11CF90186004384FC /* Flutter */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,\n\t\t\t\t9740EEB21CF90195004384FC /* Debug.xcconfig */,\n\t\t\t\t7AFA3C8E1D35360C0083082E /* Release.xcconfig */,\n\t\t\t\t9740EEB31CF90195004384FC /* Generated.xcconfig */,\n\t\t\t);\n\t\t\tname = Flutter;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t97C146E51CF9000F007C117D = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t9740EEB11CF90186004384FC /* Flutter */,\n\t\t\t\t97C146F01CF9000F007C117D /* Runner */,\n\t\t\t\t97C146EF1CF9000F007C117D /* Products */,\n\t\t\t\t331C8082294A63A400263BE5 /* RunnerTests */,\n\t\t\t\tC0DBB1C66027CD5924273FC7 /* Pods */,\n\t\t\t\t9C087B018664F9F7D6AF9B81 /* Frameworks */,\n\t\t\t);\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t97C146EF1CF9000F007C117D /* Products */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t97C146EE1CF9000F007C117D /* Runner.app */,\n\t\t\t\t331C8081294A63A400263BE5 /* RunnerTests.xctest */,\n\t\t\t);\n\t\t\tname = Products;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t97C146F01CF9000F007C117D /* Runner */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t97C146FA1CF9000F007C117D /* Main.storyboard */,\n\t\t\t\t97C146FD1CF9000F007C117D /* Assets.xcassets */,\n\t\t\t\t97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,\n\t\t\t\t97C147021CF9000F007C117D /* Info.plist */,\n\t\t\t\t1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,\n\t\t\t\t1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,\n\t\t\t\t74858FAE1ED2DC5600515810 /* AppDelegate.swift */,\n\t\t\t\t74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,\n\t\t\t);\n\t\t\tpath = Runner;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t9C087B018664F9F7D6AF9B81 /* Frameworks */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tEA6696D39F3FA1106D16702B /* Pods_Runner.framework */,\n\t\t\t\t03B40A5A39F58BC328EECDE1 /* Pods_RunnerTests.framework */,\n\t\t\t);\n\t\t\tname = Frameworks;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tC0DBB1C66027CD5924273FC7 /* Pods */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t0861BD94EAEC750534439CD7 /* Pods-Runner.debug.xcconfig */,\n\t\t\t\t62CD59CF29A95104CB65D9E7 /* Pods-Runner.release.xcconfig */,\n\t\t\t\t288B2822D826A92AAEFB3A7A /* Pods-Runner.profile.xcconfig */,\n\t\t\t\tEDAA5666919238FE907B7EE2 /* Pods-RunnerTests.debug.xcconfig */,\n\t\t\t\t8993A6110D12561E9348200F /* Pods-RunnerTests.release.xcconfig */,\n\t\t\t\tFE7BEC1B12808A29558CCC47 /* Pods-RunnerTests.profile.xcconfig */,\n\t\t\t);\n\t\t\tpath = Pods;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n/* End PBXGroup section */\n\n/* Begin PBXNativeTarget section */\n\t\t331C8080294A63A400263BE5 /* RunnerTests */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget \"RunnerTests\" */;\n\t\t\tbuildPhases = (\n\t\t\t\t0FCF9A8EB03F9B4634484BD6 /* [CP] Check Pods Manifest.lock */,\n\t\t\t\t331C807D294A63A400263BE5 /* Sources */,\n\t\t\t\t331C807F294A63A400263BE5 /* Resources */,\n\t\t\t\t64E37CD377E15AE198178E4F /* Frameworks */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t\t331C8086294A63A400263BE5 /* PBXTargetDependency */,\n\t\t\t);\n\t\t\tname = RunnerTests;\n\t\t\tproductName = RunnerTests;\n\t\t\tproductReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;\n\t\t\tproductType = \"com.apple.product-type.bundle.unit-test\";\n\t\t};\n\t\t97C146ED1CF9000F007C117D /* Runner */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget \"Runner\" */;\n\t\t\tbuildPhases = (\n\t\t\t\tCE1F428B58F0533B9F926766 /* [CP] Check Pods Manifest.lock */,\n\t\t\t\t9740EEB61CF901F6004384FC /* Run Script */,\n\t\t\t\t97C146EA1CF9000F007C117D /* Sources */,\n\t\t\t\t97C146EB1CF9000F007C117D /* Frameworks */,\n\t\t\t\t97C146EC1CF9000F007C117D /* Resources */,\n\t\t\t\t9705A1C41CF9048500538489 /* Embed Frameworks */,\n\t\t\t\t3B06AD1E1E4923F5004D2608 /* Thin Binary */,\n\t\t\t\tAAA2B55502893C358B6E22B5 /* [CP] Embed Pods Frameworks */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t);\n\t\t\tname = Runner;\n\t\t\tproductName = Runner;\n\t\t\tproductReference = 97C146EE1CF9000F007C117D /* Runner.app */;\n\t\t\tproductType = \"com.apple.product-type.application\";\n\t\t};\n/* End PBXNativeTarget section */\n\n/* Begin PBXProject section */\n\t\t97C146E61CF9000F007C117D /* Project object */ = {\n\t\t\tisa = PBXProject;\n\t\t\tattributes = {\n\t\t\t\tBuildIndependentTargetsInParallel = YES;\n\t\t\t\tLastUpgradeCheck = 1510;\n\t\t\t\tORGANIZATIONNAME = \"\";\n\t\t\t\tTargetAttributes = {\n\t\t\t\t\t331C8080294A63A400263BE5 = {\n\t\t\t\t\t\tCreatedOnToolsVersion = 14.0;\n\t\t\t\t\t\tTestTargetID = 97C146ED1CF9000F007C117D;\n\t\t\t\t\t};\n\t\t\t\t\t97C146ED1CF9000F007C117D = {\n\t\t\t\t\t\tCreatedOnToolsVersion = 7.3.1;\n\t\t\t\t\t\tLastSwiftMigration = 1100;\n\t\t\t\t\t};\n\t\t\t\t};\n\t\t\t};\n\t\t\tbuildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject \"Runner\" */;\n\t\t\tcompatibilityVersion = \"Xcode 9.3\";\n\t\t\tdevelopmentRegion = en;\n\t\t\thasScannedForEncodings = 0;\n\t\t\tknownRegions = (\n\t\t\t\ten,\n\t\t\t\tBase,\n\t\t\t);\n\t\t\tmainGroup = 97C146E51CF9000F007C117D;\n\t\t\tproductRefGroup = 97C146EF1CF9000F007C117D /* Products */;\n\t\t\tprojectDirPath = \"\";\n\t\t\tprojectRoot = \"\";\n\t\t\ttargets = (\n\t\t\t\t97C146ED1CF9000F007C117D /* Runner */,\n\t\t\t\t331C8080294A63A400263BE5 /* RunnerTests */,\n\t\t\t);\n\t\t};\n/* End PBXProject section */\n\n/* Begin PBXResourcesBuildPhase section */\n\t\t331C807F294A63A400263BE5 /* Resources */ = {\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\t97C146EC1CF9000F007C117D /* Resources */ = {\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,\n\t\t\t\t3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,\n\t\t\t\t97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,\n\t\t\t\t97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXResourcesBuildPhase section */\n\n/* Begin PBXShellScriptBuildPhase section */\n\t\t0FCF9A8EB03F9B4634484BD6 /* [CP] Check Pods Manifest.lock */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tinputFileListPaths = (\n\t\t\t);\n\t\t\tinputPaths = (\n\t\t\t\t\"${PODS_PODFILE_DIR_PATH}/Podfile.lock\",\n\t\t\t\t\"${PODS_ROOT}/Manifest.lock\",\n\t\t\t);\n\t\t\tname = \"[CP] Check Pods Manifest.lock\";\n\t\t\toutputFileListPaths = (\n\t\t\t);\n\t\t\toutputPaths = (\n\t\t\t\t\"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt\",\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t\tshellPath = /bin/sh;\n\t\t\tshellScript = \"diff \\\"${PODS_PODFILE_DIR_PATH}/Podfile.lock\\\" \\\"${PODS_ROOT}/Manifest.lock\\\" > /dev/null\\nif [ $? != 0 ] ; then\\n    # print error to STDERR\\n    echo \\\"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\\\" >&2\\n    exit 1\\nfi\\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\\necho \\\"SUCCESS\\\" > \\\"${SCRIPT_OUTPUT_FILE_0}\\\"\\n\";\n\t\t\tshowEnvVarsInLog = 0;\n\t\t};\n\t\t3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\talwaysOutOfDate = 1;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tinputPaths = (\n\t\t\t\t\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\",\n\t\t\t);\n\t\t\tname = \"Thin Binary\";\n\t\t\toutputPaths = (\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t\tshellPath = /bin/sh;\n\t\t\tshellScript = \"/bin/sh \\\"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\\\" embed_and_thin\";\n\t\t};\n\t\t9740EEB61CF901F6004384FC /* Run Script */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\talwaysOutOfDate = 1;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tinputPaths = (\n\t\t\t);\n\t\t\tname = \"Run Script\";\n\t\t\toutputPaths = (\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t\tshellPath = /bin/sh;\n\t\t\tshellScript = \"/bin/sh \\\"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\\\" build\";\n\t\t};\n\t\tAAA2B55502893C358B6E22B5 /* [CP] Embed Pods Frameworks */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tinputFileListPaths = (\n\t\t\t\t\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist\",\n\t\t\t);\n\t\t\tname = \"[CP] Embed Pods Frameworks\";\n\t\t\toutputFileListPaths = (\n\t\t\t\t\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist\",\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t\tshellPath = /bin/sh;\n\t\t\tshellScript = \"\\\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\\\"\\n\";\n\t\t\tshowEnvVarsInLog = 0;\n\t\t};\n\t\tCE1F428B58F0533B9F926766 /* [CP] Check Pods Manifest.lock */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tinputFileListPaths = (\n\t\t\t);\n\t\t\tinputPaths = (\n\t\t\t\t\"${PODS_PODFILE_DIR_PATH}/Podfile.lock\",\n\t\t\t\t\"${PODS_ROOT}/Manifest.lock\",\n\t\t\t);\n\t\t\tname = \"[CP] Check Pods Manifest.lock\";\n\t\t\toutputFileListPaths = (\n\t\t\t);\n\t\t\toutputPaths = (\n\t\t\t\t\"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt\",\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t\tshellPath = /bin/sh;\n\t\t\tshellScript = \"diff \\\"${PODS_PODFILE_DIR_PATH}/Podfile.lock\\\" \\\"${PODS_ROOT}/Manifest.lock\\\" > /dev/null\\nif [ $? != 0 ] ; then\\n    # print error to STDERR\\n    echo \\\"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\\\" >&2\\n    exit 1\\nfi\\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\\necho \\\"SUCCESS\\\" > \\\"${SCRIPT_OUTPUT_FILE_0}\\\"\\n\";\n\t\t\tshowEnvVarsInLog = 0;\n\t\t};\n/* End PBXShellScriptBuildPhase section */\n\n/* Begin PBXSourcesBuildPhase section */\n\t\t331C807D294A63A400263BE5 /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\t97C146EA1CF9000F007C117D /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,\n\t\t\t\t1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXSourcesBuildPhase section */\n\n/* Begin PBXTargetDependency section */\n\t\t331C8086294A63A400263BE5 /* PBXTargetDependency */ = {\n\t\t\tisa = PBXTargetDependency;\n\t\t\ttarget = 97C146ED1CF9000F007C117D /* Runner */;\n\t\t\ttargetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;\n\t\t};\n/* End PBXTargetDependency section */\n\n/* Begin PBXVariantGroup section */\n\t\t97C146FA1CF9000F007C117D /* Main.storyboard */ = {\n\t\t\tisa = PBXVariantGroup;\n\t\t\tchildren = (\n\t\t\t\t97C146FB1CF9000F007C117D /* Base */,\n\t\t\t);\n\t\t\tname = Main.storyboard;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {\n\t\t\tisa = PBXVariantGroup;\n\t\t\tchildren = (\n\t\t\t\t97C147001CF9000F007C117D /* Base */,\n\t\t\t);\n\t\t\tname = LaunchScreen.storyboard;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n/* End PBXVariantGroup section */\n\n/* Begin XCBuildConfiguration section */\n\t\t249021D3217E4FDB00AE95B9 /* Profile */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;\n\t\t\t\tCLANG_ANALYZER_NONNULL = YES;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++0x\";\n\t\t\t\tCLANG_CXX_LIBRARY = \"libc++\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_COMMA = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_STRICT_PROTOTYPES = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = YES;\n\t\t\t\tCLANG_WARN__DUPLICATE_METHOD_MATCH = YES;\n\t\t\t\t\"CODE_SIGN_IDENTITY[sdk=iphoneos*]\" = \"iPhone Developer\";\n\t\t\t\tCOPY_PHASE_STRIP = NO;\n\t\t\t\tDEBUG_INFORMATION_FORMAT = \"dwarf-with-dsym\";\n\t\t\t\tENABLE_NS_ASSERTIONS = NO;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tENABLE_USER_SCRIPT_SANDBOXING = NO;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu99;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNDECLARED_SELECTOR = YES;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 12.0;\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = NO;\n\t\t\t\tSDKROOT = iphoneos;\n\t\t\t\tSUPPORTED_PLATFORMS = iphoneos;\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t\tVALIDATE_PRODUCT = YES;\n\t\t\t};\n\t\t\tname = Profile;\n\t\t};\n\t\t249021D4217E4FDB00AE95B9 /* Profile */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCURRENT_PROJECT_VERSION = \"$(FLUTTER_BUILD_NUMBER)\";\n\t\t\t\tDEVELOPMENT_TEAM = DGZY9K7USV;\n\t\t\t\tENABLE_BITCODE = NO;\n\t\t\t\tINFOPLIST_FILE = Runner/Info.plist;\n\t\t\t\tINFOPLIST_KEY_CFBundleDisplayName = Spacepad;\n\t\t\t\tINFOPLIST_KEY_LSApplicationCategoryType = \"public.app-category.business\";\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t);\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.magweter.spacepad;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSWIFT_OBJC_BRIDGING_HEADER = \"Runner/Runner-Bridging-Header.h\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tVERSIONING_SYSTEM = \"apple-generic\";\n\t\t\t};\n\t\t\tname = Profile;\n\t\t};\n\t\t331C8088294A63A400263BE5 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = EDAA5666919238FE907B7EE2 /* Pods-RunnerTests.debug.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tBUNDLE_LOADER = \"$(TEST_HOST)\";\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCURRENT_PROJECT_VERSION = 1;\n\t\t\t\tGENERATE_INFOPLIST_FILE = YES;\n\t\t\t\tMARKETING_VERSION = 1.0;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.magweter.spacepad.RunnerTests;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;\n\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-Onone\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tTEST_HOST = \"$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t331C8089294A63A400263BE5 /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = 8993A6110D12561E9348200F /* Pods-RunnerTests.release.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tBUNDLE_LOADER = \"$(TEST_HOST)\";\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCURRENT_PROJECT_VERSION = 1;\n\t\t\t\tGENERATE_INFOPLIST_FILE = YES;\n\t\t\t\tMARKETING_VERSION = 1.0;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.magweter.spacepad.RunnerTests;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tTEST_HOST = \"$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner\";\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\t331C808A294A63A400263BE5 /* Profile */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = FE7BEC1B12808A29558CCC47 /* Pods-RunnerTests.profile.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tBUNDLE_LOADER = \"$(TEST_HOST)\";\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCURRENT_PROJECT_VERSION = 1;\n\t\t\t\tGENERATE_INFOPLIST_FILE = YES;\n\t\t\t\tMARKETING_VERSION = 1.0;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.magweter.spacepad.RunnerTests;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tTEST_HOST = \"$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner\";\n\t\t\t};\n\t\t\tname = Profile;\n\t\t};\n\t\t97C147031CF9000F007C117D /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;\n\t\t\t\tCLANG_ANALYZER_NONNULL = YES;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++0x\";\n\t\t\t\tCLANG_CXX_LIBRARY = \"libc++\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_COMMA = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_STRICT_PROTOTYPES = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = YES;\n\t\t\t\tCLANG_WARN__DUPLICATE_METHOD_MATCH = YES;\n\t\t\t\t\"CODE_SIGN_IDENTITY[sdk=iphoneos*]\" = \"iPhone Developer\";\n\t\t\t\tCOPY_PHASE_STRIP = NO;\n\t\t\t\tDEBUG_INFORMATION_FORMAT = dwarf;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tENABLE_TESTABILITY = YES;\n\t\t\t\tENABLE_USER_SCRIPT_SANDBOXING = NO;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu99;\n\t\t\t\tGCC_DYNAMIC_NO_PIC = NO;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_OPTIMIZATION_LEVEL = 0;\n\t\t\t\tGCC_PREPROCESSOR_DEFINITIONS = (\n\t\t\t\t\t\"DEBUG=1\",\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t);\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNDECLARED_SELECTOR = YES;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 12.0;\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = YES;\n\t\t\t\tONLY_ACTIVE_ARCH = YES;\n\t\t\t\tSDKROOT = iphoneos;\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t97C147041CF9000F007C117D /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;\n\t\t\t\tCLANG_ANALYZER_NONNULL = YES;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++0x\";\n\t\t\t\tCLANG_CXX_LIBRARY = \"libc++\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_COMMA = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_STRICT_PROTOTYPES = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = YES;\n\t\t\t\tCLANG_WARN__DUPLICATE_METHOD_MATCH = YES;\n\t\t\t\t\"CODE_SIGN_IDENTITY[sdk=iphoneos*]\" = \"iPhone Developer\";\n\t\t\t\tCOPY_PHASE_STRIP = NO;\n\t\t\t\tDEBUG_INFORMATION_FORMAT = \"dwarf-with-dsym\";\n\t\t\t\tENABLE_NS_ASSERTIONS = NO;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tENABLE_USER_SCRIPT_SANDBOXING = NO;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu99;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNDECLARED_SELECTOR = YES;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 12.0;\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = NO;\n\t\t\t\tSDKROOT = iphoneos;\n\t\t\t\tSUPPORTED_PLATFORMS = iphoneos;\n\t\t\t\tSWIFT_COMPILATION_MODE = wholemodule;\n\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-O\";\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t\tVALIDATE_PRODUCT = YES;\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\t97C147061CF9000F007C117D /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCURRENT_PROJECT_VERSION = \"$(FLUTTER_BUILD_NUMBER)\";\n\t\t\t\tDEVELOPMENT_TEAM = DGZY9K7USV;\n\t\t\t\tENABLE_BITCODE = NO;\n\t\t\t\tINFOPLIST_FILE = Runner/Info.plist;\n\t\t\t\tINFOPLIST_KEY_CFBundleDisplayName = Spacepad;\n\t\t\t\tINFOPLIST_KEY_LSApplicationCategoryType = \"public.app-category.business\";\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t);\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.magweter.spacepad;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSWIFT_OBJC_BRIDGING_HEADER = \"Runner/Runner-Bridging-Header.h\";\n\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-Onone\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tVERSIONING_SYSTEM = \"apple-generic\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t97C147071CF9000F007C117D /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCURRENT_PROJECT_VERSION = \"$(FLUTTER_BUILD_NUMBER)\";\n\t\t\t\tDEVELOPMENT_TEAM = DGZY9K7USV;\n\t\t\t\tENABLE_BITCODE = NO;\n\t\t\t\tINFOPLIST_FILE = Runner/Info.plist;\n\t\t\t\tINFOPLIST_KEY_CFBundleDisplayName = Spacepad;\n\t\t\t\tINFOPLIST_KEY_LSApplicationCategoryType = \"public.app-category.business\";\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t);\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.magweter.spacepad;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSWIFT_OBJC_BRIDGING_HEADER = \"Runner/Runner-Bridging-Header.h\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tVERSIONING_SYSTEM = \"apple-generic\";\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n/* End XCBuildConfiguration section */\n\n/* Begin XCConfigurationList section */\n\t\t331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget \"RunnerTests\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t331C8088294A63A400263BE5 /* Debug */,\n\t\t\t\t331C8089294A63A400263BE5 /* Release */,\n\t\t\t\t331C808A294A63A400263BE5 /* Profile */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\t97C146E91CF9000F007C117D /* Build configuration list for PBXProject \"Runner\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t97C147031CF9000F007C117D /* Debug */,\n\t\t\t\t97C147041CF9000F007C117D /* Release */,\n\t\t\t\t249021D3217E4FDB00AE95B9 /* Profile */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\t97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget \"Runner\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t97C147061CF9000F007C117D /* Debug */,\n\t\t\t\t97C147071CF9000F007C117D /* Release */,\n\t\t\t\t249021D4217E4FDB00AE95B9 /* Profile */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n/* End XCConfigurationList section */\n\t};\n\trootObject = 97C146E61CF9000F007C117D /* Project object */;\n}\n"
  },
  {
    "path": "app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Workspace\n   version = \"1.0\">\n   <FileRef\n      location = \"self:\">\n   </FileRef>\n</Workspace>\n"
  },
  {
    "path": "app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>IDEDidComputeMac32BitWarning</key>\n\t<true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>PreviewsEnabled</key>\n\t<false/>\n</dict>\n</plist>\n"
  },
  {
    "path": "app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n   LastUpgradeVersion = \"1510\"\n   version = \"1.3\">\n   <BuildAction\n      parallelizeBuildables = \"YES\"\n      buildImplicitDependencies = \"YES\">\n      <BuildActionEntries>\n         <BuildActionEntry\n            buildForTesting = \"YES\"\n            buildForRunning = \"YES\"\n            buildForProfiling = \"YES\"\n            buildForArchiving = \"YES\"\n            buildForAnalyzing = \"YES\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"97C146ED1CF9000F007C117D\"\n               BuildableName = \"Runner.app\"\n               BlueprintName = \"Runner\"\n               ReferencedContainer = \"container:Runner.xcodeproj\">\n            </BuildableReference>\n         </BuildActionEntry>\n      </BuildActionEntries>\n   </BuildAction>\n   <TestAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\">\n      <MacroExpansion>\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"97C146ED1CF9000F007C117D\"\n            BuildableName = \"Runner.app\"\n            BlueprintName = \"Runner\"\n            ReferencedContainer = \"container:Runner.xcodeproj\">\n         </BuildableReference>\n      </MacroExpansion>\n      <Testables>\n         <TestableReference\n            skipped = \"NO\"\n            parallelizable = \"YES\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"331C8080294A63A400263BE5\"\n               BuildableName = \"RunnerTests.xctest\"\n               BlueprintName = \"RunnerTests\"\n               ReferencedContainer = \"container:Runner.xcodeproj\">\n            </BuildableReference>\n         </TestableReference>\n      </Testables>\n   </TestAction>\n   <LaunchAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      launchStyle = \"0\"\n      useCustomWorkingDirectory = \"NO\"\n      ignoresPersistentStateOnLaunch = \"NO\"\n      debugDocumentVersioning = \"YES\"\n      debugServiceExtension = \"internal\"\n      enableGPUValidationMode = \"1\"\n      allowLocationSimulation = \"YES\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"97C146ED1CF9000F007C117D\"\n            BuildableName = \"Runner.app\"\n            BlueprintName = \"Runner\"\n            ReferencedContainer = \"container:Runner.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n   </LaunchAction>\n   <ProfileAction\n      buildConfiguration = \"Profile\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      savedToolIdentifier = \"\"\n      useCustomWorkingDirectory = \"NO\"\n      debugDocumentVersioning = \"YES\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"97C146ED1CF9000F007C117D\"\n            BuildableName = \"Runner.app\"\n            BlueprintName = \"Runner\"\n            ReferencedContainer = \"container:Runner.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n   </ProfileAction>\n   <AnalyzeAction\n      buildConfiguration = \"Debug\">\n   </AnalyzeAction>\n   <ArchiveAction\n      buildConfiguration = \"Release\"\n      revealArchiveInOrganizer = \"YES\">\n   </ArchiveAction>\n</Scheme>\n"
  },
  {
    "path": "app/ios/Runner.xcworkspace/contents.xcworkspacedata",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Workspace\n   version = \"1.0\">\n   <FileRef\n      location = \"group:Runner.xcodeproj\">\n   </FileRef>\n   <FileRef\n      location = \"group:Pods/Pods.xcodeproj\">\n   </FileRef>\n</Workspace>\n"
  },
  {
    "path": "app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>IDEDidComputeMac32BitWarning</key>\n\t<true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>PreviewsEnabled</key>\n\t<false/>\n</dict>\n</plist>\n"
  },
  {
    "path": "app/ios/RunnerTests/RunnerTests.swift",
    "content": "import Flutter\nimport UIKit\nimport XCTest\n\nclass RunnerTests: XCTestCase {\n\n  func testExample() {\n    // If you add code to the Runner application, consider adding tests here.\n    // See https://developer.apple.com/documentation/xctest for more information about using XCTest.\n  }\n\n}\n"
  },
  {
    "path": "app/lib/components/action_button.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:get/get.dart';\nimport 'package:tailwind_components/tailwind_components.dart';\nimport 'package:spacepad/components/frosted_panel.dart';\n\nclass ActionButton extends StatelessWidget {\n  final String text;\n  final VoidCallback? onPressed;\n  final Color? borderColor;\n  final Color? textColor;\n  final bool isPhone;\n  final double cornerRadius;\n  final bool disabled;\n  final bool isLoading;\n\n  const ActionButton({\n    super.key,\n    required this.text,\n    required this.onPressed,\n    required this.isPhone,\n    required this.cornerRadius,\n    this.borderColor,\n    this.textColor,\n    this.disabled = false,\n    this.isLoading = false,\n  });\n\n  @override\n  Widget build(BuildContext context) {\n    final Color effectiveBorderColor = borderColor ?? TWColors.gray_500.withAlpha(160);\n    final bool isDisabled = disabled || isLoading;\n    return Opacity(\n      opacity: isDisabled ? 0.5 : 1.0,\n      child: Container(\n        margin: EdgeInsets.only(top: isPhone ? 10 : 20, bottom: isPhone ? 10 : 20),\n        decoration: BoxDecoration(\n          borderRadius: BorderRadius.circular(cornerRadius),\n        ),\n        child: FrostedPanel(\n          borderRadius: cornerRadius,\n          blurIntensity: 18,\n          child: Material(\n            color: Colors.transparent,\n            child: InkWell(\n              borderRadius: BorderRadius.circular(cornerRadius),\n              onTap: isDisabled ? null : onPressed,\n              child: Stack(\n                children: [\n                  Padding(\n                    padding: EdgeInsets.symmetric(\n                      vertical: isPhone ? 12 : 16,\n                      horizontal: isPhone ? 20 : 28,\n                    ),\n                    child: SizedBox(\n                      height: isPhone ? 22 : 26, // Fixed height to match text line height\n                      child: Stack(\n                        alignment: Alignment.center,\n                        children: [\n                          // Keep text in layout to maintain button width, but make it invisible when loading\n                          Opacity(\n                            opacity: isLoading ? 0 : 1,\n                            child: Text(\n                              text.tr,\n                              style: TextStyle(\n                                color: textColor ?? TWColors.white,\n                                fontSize: isPhone ? 16 : 20,\n                                fontWeight: FontWeight.w700,\n                                height: 1.0, // Ensure consistent line height\n                              ),\n                              textAlign: TextAlign.center,\n                            ),\n                          ),\n                          // Show loading indicator on top when loading\n                          if (isLoading)\n                            SizedBox(\n                              width: isPhone ? 20 : 24,\n                              height: isPhone ? 20 : 24,\n                              child: CircularProgressIndicator(\n                                strokeWidth: 2,\n                                valueColor: AlwaysStoppedAnimation<Color>(\n                                  textColor ?? TWColors.white,\n                                ),\n                              ),\n                            ),\n                        ],\n                      ),\n                    ),\n                  ),\n                  if (disabled && !isLoading)\n                    Positioned.fill(\n                      child: CustomPaint(\n                        painter: _DiagonalStrikethroughPainter(\n                          color: effectiveBorderColor,\n                        ),\n                      ),\n                    ),\n                ],\n              ),\n            ),\n          ),\n        ),\n      ),\n    );\n  }\n}\n\nclass _DiagonalStrikethroughPainter extends CustomPainter {\n  final Color color;\n  static const double borderWidth = 2;\n  _DiagonalStrikethroughPainter({required this.color});\n\n  @override\n  void paint(Canvas canvas, Size size) {\n    final paint = Paint()\n      ..color = color\n      ..strokeWidth = borderWidth;\n    // Draw from the middle of the border on each corner\n    canvas.drawLine(\n      Offset(1, size.height - 1),\n      Offset(size.width - 1, 1),\n      paint,\n    );\n  }\n\n  @override\n  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;\n}\n"
  },
  {
    "path": "app/lib/components/action_panel.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:get/get.dart';\nimport 'package:spacepad/components/action_button.dart';\nimport 'package:tailwind_components/tailwind_components.dart';\n\nclass ActionPanel extends StatelessWidget {\n  final dynamic controller;\n  final bool isPhone;\n  final double cornerRadius;\n\n  const ActionPanel({\n    super.key,\n    required this.controller,\n    required this.isPhone,\n    required this.cornerRadius,\n  });\n\n  @override\n  Widget build(BuildContext context) {\n    final isPortrait = MediaQuery.of(context).orientation == Orientation.portrait;\n    \n    return SpaceRow(\n      mainAxisSize: MainAxisSize.min,\n      mainAxisAlignment: MainAxisAlignment.start,\n      crossAxisAlignment: CrossAxisAlignment.start,\n      children: [\n        // Show booking options when not reserved\n        Obx(() {\n          final showBooking = !controller.isReserved && controller.bookingEnabled;\n          final showCheckIn = controller.isCheckInActive && controller.checkInEnabled;\n          \n          if (showBooking) {\n            final isBooking = controller.isBooking.value;\n            final bookingDuration = controller.bookingDuration.value;\n            \n            // If both booking and check-in are visible, combine them\n            if (showCheckIn && !controller.showBookingOptions.value) {\n              // Show \"book_now\" button and check-in button together\n              return SpaceRow(\n                mainAxisSize: MainAxisSize.min,\n                spaceBetween: isPhone ? 12 : 16,\n                mainAxisAlignment: MainAxisAlignment.start,\n                children: [\n                  ActionButton(\n                    text: 'book_now',\n                    onPressed: isBooking ? null : () => controller.toggleBookingOptions(),\n                    isPhone: isPhone,\n                    cornerRadius: cornerRadius,\n                    isLoading: isBooking && bookingDuration == null,\n                  ),\n                  ActionButton(\n                    text: 'check_in',\n                    onPressed: () => controller.checkIn(),\n                    isPhone: isPhone,\n                    cornerRadius: cornerRadius,\n                  ),\n                ],\n              );\n            }\n            \n            return controller.showBookingOptions.value ?\n          (isPortrait ? \n            // Portrait: Horizontally scrollable container wrapped in Flexible\n            Flexible(\n              child: SingleChildScrollView(\n                scrollDirection: Axis.horizontal,\n                child: Row(\n                  mainAxisSize: MainAxisSize.min,\n                  children: [\n                    // Show all options, but disable and strikethrough if not available\n                    for (var min in [15, 30, 60])\n                      Padding(\n                        padding: EdgeInsets.only(right: min == 60 ? 0 : (isPhone ? 12 : 16)),\n                        child: ActionButton(\n                          text: '$min min',\n                          onPressed: (controller.availableBookingDurations.contains(min) && !isBooking)\n                            ? () => controller.bookRoom(min)\n                            : null,\n                          isPhone: isPhone,\n                          cornerRadius: cornerRadius,\n                          disabled: !controller.availableBookingDurations.contains(min) || (isBooking && bookingDuration != min),\n                          isLoading: isBooking && bookingDuration == min, // Only show loading on the clicked button\n                        ),\n                      ),\n                    if (controller.hasCustomBooking) ...[\n                      SizedBox(width: isPhone ? 12 : 16),\n                      ActionButton(\n                        text: 'custom',\n                        onPressed: isBooking ? null : () => controller.showCustomBookingModal(context, isPhone, cornerRadius),\n                        isPhone: isPhone,\n                        cornerRadius: cornerRadius,\n                        disabled: isBooking,\n                        isLoading: isBooking && bookingDuration == null, // Show loading if custom booking is in progress\n                      ),\n                    ],\n                    SizedBox(width: isPhone ? 16 : 24),\n                    ActionButton(\n                      text: 'cancel',\n                      onPressed: isBooking ? null : () => controller.hideBookingOptions(),\n                      isPhone: isPhone,\n                      cornerRadius: cornerRadius,\n                      disabled: isBooking,\n                    ),\n                  ],\n                ),\n              ),\n            ) :\n            // Landscape: Keep buttons in a single row\n            Row(\n              mainAxisSize: MainAxisSize.min,\n              children: [\n                // Show all options, but disable and strikethrough if not available\n                for (var min in [15, 30, 60])\n                  Padding(\n                    padding: EdgeInsets.only(right: min == 60 ? 0 : (isPhone ? 12 : 16)),\n                    child: ActionButton(\n                      text: '$min min',\n                      onPressed: (controller.availableBookingDurations.contains(min) && !isBooking)\n                        ? () => controller.bookRoom(min)\n                        : null,\n                      isPhone: isPhone,\n                      cornerRadius: cornerRadius,\n                      disabled: !controller.availableBookingDurations.contains(min) || (isBooking && bookingDuration != min),\n                      isLoading: isBooking && bookingDuration == min, // Only show loading on the clicked button\n                    ),\n                  ),\n                if (controller.hasCustomBooking) ...[\n                  SizedBox(width: isPhone ? 12 : 16),\n                  ActionButton(\n                    text: 'custom',\n                    onPressed: isBooking ? null : () => controller.showCustomBookingModal(context, isPhone, cornerRadius),\n                    isPhone: isPhone,\n                    cornerRadius: cornerRadius,\n                    disabled: isBooking,\n                    isLoading: isBooking && bookingDuration == null, // Show loading if custom booking is in progress\n                  ),\n                ],\n                SizedBox(width: isPhone ? 16 : 24),\n                ActionButton(\n                  text: 'cancel',\n                  onPressed: isBooking ? null : () => controller.hideBookingOptions(),\n                  isPhone: isPhone,\n                  cornerRadius: cornerRadius,\n                  disabled: isBooking,\n                ),\n              ],\n            )\n          ) :\n          ActionButton(\n            text: 'book_now',\n            onPressed: isBooking ? null : () => controller.toggleBookingOptions(),\n            isPhone: isPhone,\n            cornerRadius: cornerRadius,\n            isLoading: isBooking && bookingDuration == null, // Only show loading if no specific duration button was clicked\n          );\n          }\n          return SizedBox.shrink();\n        }),\n        // Show cancel button and custom booking when reserved (meeting is active)\n        Obx(() {\n          if (controller.isReserved && !controller.isCheckInActive && controller.bookingEnabled) {\n            final isBooking = controller.isBooking.value;\n            return SpaceRow(\n              mainAxisSize: MainAxisSize.min,\n              spaceBetween: isPhone ? 12 : 16,\n              mainAxisAlignment: MainAxisAlignment.start,\n              children: [\n                if (controller.canCancelCurrentEvent)\n                  ActionButton(\n                    text: 'cancel_event',\n                    onPressed: controller.isCancelling.value ? null : () => controller.cancelCurrentEvent(),\n                    textColor: Colors.white,\n                    isPhone: isPhone,\n                    cornerRadius: cornerRadius,\n                    isLoading: controller.isCancelling.value,\n                  ),\n                if (controller.hasCustomBooking)\n                  ActionButton(\n                    text: 'reserve',\n                    onPressed: isBooking ? null : () => controller.showCustomBookingModal(context, isPhone, cornerRadius),\n                    isPhone: isPhone,\n                    cornerRadius: cornerRadius,\n                    disabled: isBooking,\n                    isLoading: isBooking && controller.bookingDuration.value == null,\n                  ),\n              ],\n            );\n          }\n          return SizedBox.shrink();\n        }),\n        Obx(() {\n          // Show check-in button separately when booking is not enabled or reserved\n          // Hide check-in button when booking options are expanded\n          final showBooking = !controller.isReserved && controller.bookingEnabled;\n          final showCheckIn = controller.isCheckInActive && controller.checkInEnabled;\n          \n          // Don't show check-in button when booking options are expanded\n          if (showBooking && controller.showBookingOptions.value) {\n            return SizedBox.shrink();\n          }\n          \n          if (showCheckIn && (controller.isReserved || !controller.bookingEnabled)) {\n            return ActionButton(\n              text: 'check_in',\n              onPressed: () => controller.checkIn(),\n              isPhone: isPhone,\n              cornerRadius: cornerRadius,\n            );\n          }\n          return SizedBox.shrink();\n        }),\n      ]\n    );\n  }\n} "
  },
  {
    "path": "app/lib/components/admin_actions.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:get/get.dart';\n\nclass AdminActions extends StatelessWidget {\n  final dynamic controller;\n  final bool isPhone;\n\n  const AdminActions({\n    super.key,\n    required this.controller,\n    required this.isPhone,\n  });\n\n  @override\n  Widget build(BuildContext context) {\n    return Row(\n      mainAxisSize: MainAxisSize.min,\n      children: [\n        // Refresh button\n        Obx(() {\n          final iconSize = isPhone ? 20.0 : 28.0;\n          return Opacity(\n            opacity: 0.6,\n            child: SizedBox(\n              width: 24,\n              height: iconSize,\n              child: IconButton(\n                icon: controller.isRefreshing.value\n                    ? SizedBox(\n                        width: iconSize - 8,\n                        height: iconSize - 8,\n                        child: CircularProgressIndicator(\n                          strokeWidth: 2,\n                          valueColor: AlwaysStoppedAnimation<Color>(Colors.white),\n                        ),\n                      )\n                    : Icon(Icons.refresh, size: iconSize, color: Colors.white),\n                onPressed: controller.isRefreshing.value ? null : () {\n                  controller.refreshDisplayData();\n                },\n                tooltip: 'refresh_data'.tr,\n                padding: EdgeInsets.zero,\n                alignment: Alignment.center,\n              ),\n            ),\n          );\n        }),\n        SizedBox(width: 15),\n        // Logout/Switch room button\n        Opacity(\n          opacity: 0.6,\n          child: SizedBox(\n            width: 24,\n            height: isPhone ? 20 : 28,\n            child: IconButton(\n              icon: const Icon(Icons.logout, size: 24, color: Colors.white),\n              onPressed: () {\n                controller.switchRoom();\n              },\n              tooltip: 'switch_room'.tr,\n              padding: EdgeInsets.zero,\n              alignment: Alignment.center,\n            ),\n          ),\n        ),\n      ],\n    );\n  }\n}\n\n"
  },
  {
    "path": "app/lib/components/authenticated_background.dart",
    "content": "import 'dart:async';\nimport 'package:flutter/material.dart';\nimport 'package:http/http.dart' as http;\nimport 'package:spacepad/services/auth_service.dart';\nimport 'package:get/get.dart';\n\nclass AuthenticatedBackground extends StatefulWidget {\n  final String? imageUrl;\n  final Widget child;\n  final BorderRadius? borderRadius;\n\n  const AuthenticatedBackground({\n    Key? key,\n    this.imageUrl,\n    required this.child,\n    this.borderRadius,\n  }) : super(key: key);\n\n  @override\n  State<AuthenticatedBackground> createState() => _AuthenticatedBackgroundState();\n}\n\nclass _AuthenticatedBackgroundState extends State<AuthenticatedBackground> {\n  ImageProvider? _imageProvider;\n  bool _hasError = false;\n\n  @override\n  void initState() {\n    super.initState();\n    if (widget.imageUrl != null) {\n      _loadImage();\n    }\n  }\n\n  @override\n  void didUpdateWidget(AuthenticatedBackground oldWidget) {\n    super.didUpdateWidget(oldWidget);\n    if (oldWidget.imageUrl != widget.imageUrl) {\n      if (widget.imageUrl != null) {\n        _loadImage();\n      } else {\n        setState(() {\n          _hasError = false;\n          _imageProvider = null;\n        });\n      }\n    }\n  }\n\n  Future<void> _loadImage() async {\n    if (!mounted || widget.imageUrl == null) return;\n\n    setState(() {\n      _hasError = false;\n    });\n\n    try {\n      // Get authentication headers\n      final headers = <String, String>{\n        'Accept': 'application/json',\n        'Accept-Language': Get.locale?.languageCode ?? 'en',\n      };\n\n      if (AuthService.instance.getAuthToken() != null) {\n        headers['Authorization'] = 'Bearer ${AuthService.instance.getAuthToken()}';\n      }\n\n      // Make authenticated request with timeout\n      final response = await http.get(\n        Uri.parse(widget.imageUrl!),\n        headers: headers,\n      ).timeout(\n        const Duration(seconds: 15),\n        onTimeout: () {\n          throw TimeoutException('Image load timeout after 15 seconds');\n        },\n      );\n\n      if (response.statusCode == 200) {\n        // Create image provider from bytes\n        _imageProvider = MemoryImage(response.bodyBytes);\n        \n        if (mounted) {\n          setState(() {\n            _hasError = false;\n          });\n        }\n      } else {\n        throw Exception('Failed to load image: ${response.statusCode}');\n      }\n    } on TimeoutException {\n      if (mounted) {\n        setState(() {\n          _hasError = true;\n        });\n      }\n      return;\n    } catch (e) {\n      if (mounted) {\n        setState(() {\n          _hasError = true;\n        });\n      }\n    }\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return Container(\n      decoration: BoxDecoration(\n        borderRadius: widget.borderRadius,\n        color: Colors.black,\n        image: _imageProvider != null && !_hasError\n            ? DecorationImage(\n                image: _imageProvider!,\n                fit: BoxFit.cover,\n                colorFilter: ColorFilter.mode(\n                  Colors.black.withValues(alpha: 0.3),\n                  BlendMode.srcOver,\n                ),\n              )\n            : null,\n      ),\n      child: widget.child,\n    );\n  }\n}\n"
  },
  {
    "path": "app/lib/components/authenticated_image.dart",
    "content": "import 'dart:async';\nimport 'package:flutter/material.dart';\nimport 'package:http/http.dart' as http;\nimport 'package:spacepad/services/auth_service.dart';\nimport 'package:get/get.dart';\n\nclass AuthenticatedImage extends StatefulWidget {\n  final String imageUrl;\n  final double? width;\n  final double? height;\n  final BoxFit fit;\n  final Widget? placeholder;\n  final Widget? errorWidget;\n\n  const AuthenticatedImage({\n    Key? key,\n    required this.imageUrl,\n    this.width,\n    this.height,\n    this.fit = BoxFit.cover,\n    this.placeholder,\n    this.errorWidget,\n  }) : super(key: key);\n\n  @override\n  State<AuthenticatedImage> createState() => _AuthenticatedImageState();\n}\n\nclass _AuthenticatedImageState extends State<AuthenticatedImage> {\n  ImageProvider? _imageProvider;\n  bool _isLoading = true;\n  bool _hasError = false;\n\n  @override\n  void initState() {\n    super.initState();\n    _loadImage();\n  }\n\n  @override\n  void didUpdateWidget(AuthenticatedImage oldWidget) {\n    super.didUpdateWidget(oldWidget);\n    if (oldWidget.imageUrl != widget.imageUrl) {\n      _loadImage();\n    }\n  }\n\n  Future<void> _loadImage() async {\n    if (!mounted) return;\n\n    setState(() {\n      _isLoading = true;\n      _hasError = false;\n    });\n\n    try {\n      // Get authentication headers\n      final headers = <String, String>{\n        'Accept': 'application/json',\n        'Accept-Language': Get.locale?.languageCode ?? 'en',\n      };\n\n      if (AuthService.instance.getAuthToken() != null) {\n        headers['Authorization'] = 'Bearer ${AuthService.instance.getAuthToken()}';\n      }\n\n      // Make authenticated request with timeout\n      final response = await http.get(\n        Uri.parse(widget.imageUrl),\n        headers: headers,\n      ).timeout(\n        const Duration(seconds: 10),\n        onTimeout: () {\n          throw TimeoutException('Image load timeout after 10 seconds', const Duration(seconds: 10));\n        },\n      );\n\n      if (response.statusCode == 200) {\n        // Create image provider from bytes\n        _imageProvider = MemoryImage(response.bodyBytes);\n        \n        if (mounted) {\n          setState(() {\n            _isLoading = false;\n            _hasError = false;\n          });\n        }\n      } else {\n        throw Exception('Failed to load image: ${response.statusCode}');\n      }\n    } on TimeoutException {\n      if (mounted) {\n        setState(() {\n          _isLoading = false;\n          _hasError = true;\n        });\n      }\n    } catch (e) {\n      if (mounted) {\n        setState(() {\n          _isLoading = false;\n          _hasError = true;\n        });\n      }\n    }\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    if (_isLoading) {\n      return widget.placeholder ?? \n        Container(\n          width: widget.width,\n          height: widget.height,\n          child: Center(\n            child: CircularProgressIndicator(\n              strokeWidth: 2,\n              valueColor: AlwaysStoppedAnimation<Color>(Colors.grey),\n            ),\n          ),\n        );\n    }\n\n    if (_hasError || _imageProvider == null) {\n      return widget.errorWidget ?? SizedBox.shrink();\n    }\n\n    return Image(\n      image: _imageProvider!,\n      width: widget.width,\n      height: widget.height,\n      fit: widget.fit,\n    );\n  }\n}\n"
  },
  {
    "path": "app/lib/components/calendar_modal.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:spacepad/models/event_model.dart';\nimport 'package:spacepad/theme.dart';\nimport 'package:get/get.dart';\nimport 'package:spacepad/date_format_helper.dart';\nimport 'package:tailwind_components/tailwind_components.dart';\nimport 'package:spacepad/components/frosted_panel.dart';\n\nclass CalendarModal extends StatelessWidget {\n  final List<EventModel> events;\n  final DateTime selectedDate;\n\n  const CalendarModal({\n    super.key,\n    required this.events,\n    required this.selectedDate,\n  });\n\n  @override\n  Widget build(BuildContext context) {\n    return Dialog(\n      backgroundColor: Colors.transparent,\n      insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),\n      child: Center(\n        child: SizedBox(\n          width: 800, // Make modal narrower\n          child: FrostedPanel(\n            borderRadius: 20,\n            blurIntensity: 18,\n            padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),\n            child: Column(\n                  mainAxisSize: MainAxisSize.min,\n                  children: [\n                    // Title and close icon\n                    Padding(\n                      padding: const EdgeInsets.fromLTRB(26, 20, 26, 20),\n                      child: Row(\n                        children: [\n                          Expanded(\n                            child: Text(\n                              'todays_schedule'.tr,\n                              style: TextStyle(\n                                color: AppTheme.platinum,\n                                fontSize: 22,\n                                fontWeight: FontWeight.bold,\n                              ),\n                            ),\n                          ),\n                          IconButton(\n                            onPressed: () => Navigator.of(context).pop(),\n                            icon: Icon(\n                              Icons.close,\n                              color: AppTheme.platinum,\n                              size: 28,\n                            ),\n                            splashRadius: 22,\n                          ),\n                        ],\n                      ),\n                    ),\n                    // Removed date header\n                    // Events list\n                    Flexible(\n                      child: Padding(\n                        padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),\n                        child: events.isEmpty\n                            ? SizedBox(\n                                height: 200,\n                                child: Center(\n                                  child: Text(\n                                    'no_events_today'.tr,\n                                    style: TextStyle(\n                                      color: AppTheme.platinum,\n                                      fontSize: 16,\n                                    ),\n                                  ),\n                                ),\n                              )\n                            : ListView.builder(\n                                shrinkWrap: true,\n                                itemCount: events.length,\n                                itemBuilder: (context, index) {\n                                  final event = events[index];\n                                  return Container(\n                                    margin: const EdgeInsets.only(bottom: 18),\n                                    decoration: BoxDecoration(\n                                      color: TWColors.gray_800,\n                                      borderRadius: BorderRadius.circular(14),\n                                      boxShadow: [\n                                        BoxShadow(\n                                          color: Colors.black\n                                              .withAlpha((0.1 * 255).toInt()),\n                                          blurRadius: 8,\n                                          offset: const Offset(0, 2),\n                                        ),\n                                      ],\n                                    ),\n                                    child: Padding(\n                                      padding: const EdgeInsets.symmetric(\n                                          horizontal: 18, vertical: 16),\n                                      child: Column(\n                                        crossAxisAlignment:\n                                            CrossAxisAlignment.start,\n                                        children: [\n                                          Row(\n                                            children: [\n                                              Icon(\n                                                Icons.schedule,\n                                                color: AppTheme.orange,\n                                                size: 18,\n                                              ),\n                                              const SizedBox(width: 8),\n                                              Text(\n                                                '${formatTime(context, event.start)} - ${formatTime(context, event.end)}',\n                                                style: TextStyle(\n                                                  color: AppTheme.platinum,\n                                                  fontSize: 15,\n                                                  fontWeight: FontWeight.w600,\n                                                ),\n                                              ),\n                                            ],\n                                          ),\n                                          const SizedBox(height: 8),\n                                          Text(\n                                            event.summary,\n                                            style: TextStyle(\n                                              color: Colors.white,\n                                              fontSize: 17,\n                                              fontWeight: FontWeight.bold,\n                                            ),\n                                          ),\n                                          if ((event.location ?? '')\n                                              .trim()\n                                              .isNotEmpty) ...[\n                                            const SizedBox(height: 4),\n                                            Text(\n                                              event.location!,\n                                              style: TextStyle(\n                                                color: AppTheme.platinum,\n                                                fontSize: 13,\n                                                fontWeight: FontWeight.w400,\n                                              ),\n                                              overflow: TextOverflow.ellipsis,\n                                            ),\n                                          ],\n                                        ],\n                                      ),\n                                    ),\n                                  );\n                                },\n                              ),\n                      ),\n                    ),\n                  ],\n                ),\n              ),\n            ),\n          ),\n        );\n  }\n}\n"
  },
  {
    "path": "app/lib/components/custom_booking_modal.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:get/get.dart';\nimport 'package:spacepad/components/frosted_panel.dart';\nimport 'package:spacepad/components/solid_button.dart';\nimport 'package:spacepad/date_format_helper.dart';\nimport 'package:spacepad/models/event_model.dart';\nimport 'package:spacepad/theme.dart';\nimport 'package:tailwind_components/tailwind_components.dart';\n\nclass CustomBookingModal extends StatefulWidget {\n  final dynamic controller;\n  final bool isPhone;\n  final double cornerRadius;\n\n  const CustomBookingModal({\n    super.key,\n    required this.controller,\n    required this.isPhone,\n    required this.cornerRadius,\n  });\n\n  @override\n  State<CustomBookingModal> createState() => _CustomBookingModalState();\n}\n\nclass _CustomBookingModalState extends State<CustomBookingModal> {\n  late TextEditingController _titleController;\n  late DateTime _startTime;\n  late DateTime _endTime;\n  DateTime? _nextMeetingStart;\n\n  @override\n  void initState() {\n    super.initState();\n    final now = DateTime.now();\n    \n    // Find the first available time slot\n    // If there's a current event, start time should be when it ends\n    // Otherwise, start from now\n    final currentEvent = widget.controller.currentEvent;\n    if (currentEvent != null && currentEvent.end.isAfter(now)) {\n      _startTime = currentEvent.end;\n    } else {\n      _startTime = now;\n    }\n    \n    // Calculate default end time: 1 hour from start, or until next meeting if available\n    final upcomingEvents = widget.controller.upcomingEvents as List<EventModel>;\n    if (upcomingEvents.isNotEmpty) {\n      // Find the next meeting that starts after our start time\n      final nextMeetingAfterStart = upcomingEvents.where((event) => event.start.isAfter(_startTime)).firstOrNull;\n      if (nextMeetingAfterStart != null) {\n        _nextMeetingStart = nextMeetingAfterStart.start;\n        final oneHourFromStart = _startTime.add(const Duration(hours: 1));\n        _endTime = _nextMeetingStart!.isBefore(oneHourFromStart)\n            ? _nextMeetingStart!\n            : oneHourFromStart;\n      } else {\n        // No meetings after start time, use 1 hour default\n        _nextMeetingStart = null;\n        _endTime = _startTime.add(const Duration(hours: 1));\n      }\n    } else {\n      _nextMeetingStart = null;\n      _endTime = _startTime.add(const Duration(hours: 1));\n    }\n    \n    // Ensure end time is strictly after start time\n    _endTime = _endTime.isBefore(_startTime) || _endTime.isAtSameMomentAs(_startTime)\n        ? _startTime.add(const Duration(minutes: 1))\n        : _endTime;\n    \n    _titleController = TextEditingController(text: 'reserved'.tr);\n  }\n\n  @override\n  void dispose() {\n    _titleController.dispose();\n    super.dispose();\n  }\n\n  Future<void> _selectStartTime() async {\n    final now = DateTime.now();\n    // Ensure initial time is not in the past\n    final initialTime = _startTime.isBefore(now) \n        ? TimeOfDay.fromDateTime(now) \n        : TimeOfDay.fromDateTime(_startTime);\n    \n    final TimeOfDay? picked = await showTimePicker(\n      context: context,\n      initialTime: initialTime,\n    );\n    if (picked != null) {\n      final selectedDateTime = DateTime(\n        now.year,\n        now.month,\n        now.day,\n        picked.hour,\n        picked.minute,\n      );\n      \n      // Prevent selecting time in the past\n      final validStartTime = selectedDateTime.isBefore(now) ? now : selectedDateTime;\n      \n      setState(() {\n        _startTime = validStartTime;\n        // Ensure end time is after start time\n        if (_endTime.isBefore(_startTime) || _endTime.isAtSameMomentAs(_startTime)) {\n          _endTime = _startTime.add(const Duration(hours: 1));\n        }\n      });\n    }\n  }\n\n  Future<void> _selectEndTime() async {\n    final now = DateTime.now();\n    // Ensure initial time is not in the past and is after start time\n    final minEndTime = _startTime.add(const Duration(minutes: 1));\n    final initialEndTime = _endTime.isBefore(minEndTime) \n        ? TimeOfDay.fromDateTime(minEndTime) \n        : TimeOfDay.fromDateTime(_endTime);\n    \n    final TimeOfDay? picked = await showTimePicker(\n      context: context,\n      initialTime: initialEndTime,\n    );\n    if (picked != null) {\n      final selectedDateTime = DateTime(\n        now.year,\n        now.month,\n        now.day,\n        picked.hour,\n        picked.minute,\n      );\n      \n      // Prevent selecting time in the past or before start time\n      final minValidTime = minEndTime.isAfter(now) ? minEndTime : now.add(const Duration(minutes: 1));\n      final validEndTime = selectedDateTime.isBefore(minValidTime) \n          ? minValidTime \n          : (selectedDateTime.isAfter(_startTime) ? selectedDateTime : minValidTime);\n      \n      setState(() {\n        _endTime = validEndTime;\n      });\n    }\n  }\n\n  void _setStartTimeToNow() {\n    setState(() {\n      final now = DateTime.now();\n      // Clamp start time to now if in the past\n      _startTime = now;\n      \n      // Ensure end time is strictly after start time\n      final oneHourFromStart = _startTime.add(const Duration(hours: 1));\n      if (_nextMeetingStart != null && _nextMeetingStart!.isBefore(oneHourFromStart)) {\n        _endTime = _nextMeetingStart!.isAfter(_startTime) \n            ? _nextMeetingStart! \n            : _startTime.add(const Duration(minutes: 1));\n      } else {\n        _endTime = _endTime.isBefore(_startTime) || _endTime.isAtSameMomentAs(_startTime)\n            ? oneHourFromStart\n            : _endTime;\n      }\n      \n      // Final check: ensure end time is strictly after start time\n      _endTime = _endTime.isBefore(_startTime) || _endTime.isAtSameMomentAs(_startTime)\n          ? _startTime.add(const Duration(minutes: 1))\n          : _endTime;\n    });\n  }\n\n  void _setEndTimeToMax() {\n    if (_nextMeetingStart != null) {\n      setState(() {\n        final now = DateTime.now();\n        // Clamp start time to now if in the past\n        _startTime = _startTime.isBefore(now) ? now : _startTime;\n        \n        // Set end time to next meeting start, but ensure it's strictly after start time\n        final oneHourFromStart = _startTime.add(const Duration(hours: 1));\n        _endTime = (_nextMeetingStart != null && _nextMeetingStart!.isBefore(oneHourFromStart))\n            ? _nextMeetingStart!\n            : oneHourFromStart;\n        \n        // Ensure end time is strictly after start time\n        _endTime = _endTime.isBefore(_startTime) || _endTime.isAtSameMomentAs(_startTime)\n            ? _startTime.add(const Duration(minutes: 1))\n            : _endTime;\n      });\n    }\n  }\n\n  void _bookCustom() {\n    if (_titleController.text.trim().isEmpty) {\n      return;\n    }\n    \n    // Clamp and normalize times before sending to backend\n    final now = DateTime.now();\n    \n    // Clamp start time to now if in the past\n    final clampedStartTime = _startTime.isBefore(now) ? now : _startTime;\n    \n    // Preserve next meeting clipping behavior if applicable\n    final oneHourFromStart = clampedStartTime.add(const Duration(hours: 1));\n    final endTimeWithClipping = (_nextMeetingStart != null && _nextMeetingStart!.isBefore(oneHourFromStart))\n        ? _nextMeetingStart!\n        : oneHourFromStart;\n    \n    // Ensure end time is strictly after start time (at least 1 minute after)\n    // Use max of endTimeWithClipping or minimum valid end time\n    final minValidEndTime = clampedStartTime.add(const Duration(minutes: 1));\n    final finalEndTime = endTimeWithClipping.isBefore(minValidEndTime) || endTimeWithClipping.isAtSameMomentAs(minValidEndTime)\n        ? minValidEndTime\n        : endTimeWithClipping;\n    \n    widget.controller.bookCustom(\n      _titleController.text.trim(),\n      clampedStartTime,\n      finalEndTime,\n    );\n    \n    Navigator.of(context).pop();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    \n    return Dialog(\n      backgroundColor: Colors.transparent,\n      insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),\n      child: Center(\n        child: SizedBox(\n          width: 800,\n          child: FrostedPanel(\n            borderRadius: 20,\n            blurIntensity: 18,\n            padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),\n            child: Column(\n              mainAxisSize: MainAxisSize.min,\n              children: [\n                // Title and close button\n                Padding(\n                  padding: const EdgeInsets.fromLTRB(26, 20, 26, 20),\n                  child: Row(\n                    children: [\n                      Expanded(\n                        child: Text(\n                          'custom_booking'.tr,\n                          style: TextStyle(\n                            color: AppTheme.platinum,\n                            fontSize: 22,\n                            fontWeight: FontWeight.bold,\n                          ),\n                        ),\n                      ),\n                      IconButton(\n                        onPressed: () => Navigator.of(context).pop(),\n                        icon: Icon(\n                          Icons.close,\n                          color: AppTheme.platinum,\n                          size: 28,\n                        ),\n                        splashRadius: 22,\n                      ),\n                    ],\n                  ),\n                ),\n                // Meeting title input\n                Padding(\n                  padding: const EdgeInsets.fromLTRB(26, 0, 26, 20),\n                  child: SpaceCol(\n                    spaceBetween: 8,\n                    children: [\n                      Text(\n                        'meeting_title'.tr,\n                        style: TextStyle(\n                          color: AppTheme.platinum,\n                          fontSize: 15,\n                          fontWeight: FontWeight.w600,\n                        ),\n                      ),\n                      TextField(\n                        controller: _titleController,\n                        style: TextStyle(\n                          color: Colors.white,\n                          fontSize: 17,\n                        ),\n                        decoration: InputDecoration(\n                          enabledBorder: OutlineInputBorder(\n                            borderSide: BorderSide(color: TWColors.gray_500),\n                            borderRadius: BorderRadius.circular(8),\n                          ),\n                          focusedBorder: OutlineInputBorder(\n                            borderSide: BorderSide(color: Colors.white),\n                            borderRadius: BorderRadius.circular(8),\n                          ),\n                          filled: true,\n                          fillColor: TWColors.gray_800.withOpacity(0.5),\n                        ),\n                      ),\n                    ],\n                  ),\n                ),\n                \n                // Start and End time side by side\n                Padding(\n                  padding: const EdgeInsets.fromLTRB(26, 0, 26, 20),\n                  child: Row(\n                    children: [\n                      // Start time\n                      Expanded(\n                        child: SpaceCol(\n                          spaceBetween: 8,\n                          children: [\n                            Text(\n                              'start_time'.tr,\n                              style: TextStyle(\n                                color: AppTheme.platinum,\n                                fontSize: 15,\n                                fontWeight: FontWeight.w600,\n                              ),\n                            ),\n                            Row(\n                              children: [\n                                Expanded(\n                                  child: GestureDetector(\n                                    onTap: _selectStartTime,\n                                    child: Container(\n                                      padding: EdgeInsets.symmetric(\n                                        horizontal: 12,\n                                        vertical: 12,\n                                      ),\n                                      decoration: BoxDecoration(\n                                        color: TWColors.gray_800.withOpacity(0.5),\n                                        borderRadius: BorderRadius.circular(8),\n                                        border: Border.all(color: TWColors.gray_500),\n                                      ),\n                                      child: Text(\n                                        formatTime(context, _startTime),\n                                        style: TextStyle(\n                                          color: Colors.white,\n                                          fontSize: 15,\n                                          fontWeight: FontWeight.w600,\n                                        ),\n                                      ),\n                                    ),\n                                  ),\n                                ),\n                                SizedBox(width: 8),\n                                SolidButton(\n                                  text: 'now',\n                                  onPressed: _setStartTimeToNow,\n                                  fontSize: 15,\n                                ),\n                              ],\n                            ),\n                          ],\n                        ),\n                      ),\n                      SizedBox(width: 16),\n                      // End time\n                      Expanded(\n                        child: SpaceCol(\n                          spaceBetween: 8,\n                          children: [\n                            Text(\n                              'end_time'.tr,\n                              style: TextStyle(\n                                color: AppTheme.platinum,\n                                fontSize: 15,\n                                fontWeight: FontWeight.w600,\n                              ),\n                            ),\n                            Row(\n                              children: [\n                                Expanded(\n                                  child: GestureDetector(\n                                    onTap: _selectEndTime,\n                                    child: Container(\n                                      padding: EdgeInsets.symmetric(\n                                        horizontal: 12,\n                                        vertical: 12,\n                                      ),\n                                      decoration: BoxDecoration(\n                                        color: TWColors.gray_800.withAlpha(128),\n                                        borderRadius: BorderRadius.circular(8),\n                                        border: Border.all(color: TWColors.gray_500),\n                                      ),\n                                      child: Text(\n                                        formatTime(context, _endTime),\n                                        style: TextStyle(\n                                          color: Colors.white,\n                                          fontSize: 15,\n                                          fontWeight: FontWeight.w600,\n                                        ),\n                                      ),\n                                    ),\n                                  ),\n                                ),\n                                SizedBox(width: 8),\n                                SolidButton(\n                                  text: 'max',\n                                  onPressed: _nextMeetingStart != null ? _setEndTimeToMax : null,\n                                  fontSize: 15,\n                                ),\n                              ],\n                            ),\n                          ],\n                        ),\n                      ),\n                    ],\n                  ),\n                ),\n                \n                // Action buttons\n                Padding(\n                  padding: const EdgeInsets.fromLTRB(26, 16, 26, 20),\n                  child: Row(\n                    mainAxisAlignment: MainAxisAlignment.end,\n                    children: [\n                      SolidButton(\n                        text: 'close',\n                        onPressed: () => Navigator.of(context).pop(),\n                        fontSize: 17,\n                        padding: EdgeInsets.symmetric(horizontal: 20, vertical: 12),\n                      ),\n                      SizedBox(width: 12),\n                      SolidButton(\n                        text: 'book',\n                        onPressed: _bookCustom,\n                        fontSize: 17,\n                        padding: EdgeInsets.symmetric(horizontal: 20, vertical: 12),\n                      ),\n                    ],\n                  ),\n                ),\n              ],\n            ),\n          ),\n        ),\n      ),\n    );\n  }\n}\n\n"
  },
  {
    "path": "app/lib/components/event_line.dart",
    "content": "import 'package:get/get.dart';\nimport 'package:spacepad/models/event_model.dart';\nimport 'package:flutter/material.dart';\nimport 'package:spacepad/date_format_helper.dart';\nimport 'package:tailwind_components/tailwind_components.dart';\n\nclass EventLine extends StatelessWidget {\n  const EventLine({super.key, required this.event});\n\n  final EventModel event;\n\n  bool _isPhone(BuildContext context) {\n    final shortestSide = MediaQuery.of(context).size.shortestSide;\n    return shortestSide < 600;\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final isPhone = _isPhone(context);\n\n    return SizedBox(\n      width: double.infinity,\n      child: SpaceRow(\n        spaceBetween: isPhone ? 5 : 10,\n        mainAxisSize: MainAxisSize.max,\n        crossAxisAlignment: CrossAxisAlignment.center,\n        children: [\n          Text(\n            '${'next'.tr}:',\n            style: TextStyle(\n              fontSize: isPhone ? 16 : 18,\n              fontWeight: FontWeight.bold,\n              color: Colors.white\n            )\n          ),\n          Expanded(\n            child: Text(\n              'next_event_title'.trParams({\n                'start': formatTime(context, event.start),\n                'end': formatTime(context, event.end),\n                'summary': event.summary,\n              }),\n              style: TextStyle(\n                fontSize: isPhone ? 16 : 18,\n                fontWeight: FontWeight.w400,\n                color: Colors.white\n              ),\n              overflow: TextOverflow.ellipsis,\n              maxLines: 1,\n            ),\n          ),\n        ],\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "app/lib/components/frosted_panel.dart",
    "content": "import 'dart:ui';\nimport 'package:flutter/material.dart';\nimport 'package:tailwind_components/tailwind_components.dart';\n\nclass FrostedPanel extends StatelessWidget {\n  final Widget child;\n  final double borderRadius;\n  final double blurIntensity;\n  final Color backgroundColor;\n  final EdgeInsetsGeometry? padding;\n\n  const FrostedPanel({\n    super.key,\n    required this.child,\n    this.borderRadius = 20,\n    this.blurIntensity = 18,\n    this.backgroundColor = const Color(0x14FFFFFF), // Colors.white.withAlpha((0.08 * 255).toInt())\n    this.padding,\n  });\n\n  /// Creates a frosted panel with gray background (for use with background images)\n  factory FrostedPanel.gray({\n    required Widget child,\n    double borderRadius = 20,\n    bool hasBackgroundImage = false,\n    EdgeInsetsGeometry? padding,\n  }) {\n    return FrostedPanel(\n      borderRadius: borderRadius,\n      blurIntensity: 0, // No blur for gray panels\n      backgroundColor: hasBackgroundImage \n          ? TWColors.black.withValues(alpha: 0.8)\n          : TWColors.black.withValues(alpha: 0.1),\n      padding: padding,\n      child: child,\n    );\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final panel = Container(\n      decoration: BoxDecoration(\n        color: Colors.white.withAlpha((0.1 * 255).toInt()),\n        borderRadius: BorderRadius.circular(borderRadius),\n      ),\n      padding: padding,\n      child: child,\n    );\n\n    // Only apply backdrop filter if blur intensity is greater than 0\n    if (blurIntensity > 0) {\n      return ClipRRect(\n        borderRadius: BorderRadius.circular(borderRadius),\n        child: BackdropFilter(\n          filter: ImageFilter.blur(sigmaX: blurIntensity, sigmaY: blurIntensity),\n          child: panel,\n        ),\n      );\n    }\n\n    return ClipRRect(\n      borderRadius: BorderRadius.circular(borderRadius),\n      child: panel,\n    );\n  }\n}\n\n"
  },
  {
    "path": "app/lib/components/solid_button.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:get/get.dart';\nimport 'package:tailwind_components/tailwind_components.dart';\n\nclass SolidButton extends StatelessWidget {\n  final String text;\n  final VoidCallback? onPressed;\n  final double? fontSize;\n  final EdgeInsets? padding;\n  final double borderRadius;\n\n  const SolidButton({\n    super.key,\n    required this.text,\n    this.onPressed,\n    this.fontSize,\n    this.padding,\n    this.borderRadius = 8,\n  });\n\n  @override\n  Widget build(BuildContext context) {\n    return Opacity(\n      opacity: onPressed != null ? 1.0 : 0.5,\n      child: GestureDetector(\n        onTap: onPressed,\n        child: Container(\n          padding: padding ?? EdgeInsets.symmetric(horizontal: 12, vertical: 12),\n          decoration: BoxDecoration(\n            color: TWColors.gray_800.withAlpha(128),\n            borderRadius: BorderRadius.circular(borderRadius),\n            border: Border.all(color: TWColors.gray_500),\n          ),\n          child: Text(\n            text.tr,\n            style: TextStyle(\n              color: Colors.white,\n              fontSize: fontSize ?? 15,\n              fontWeight: FontWeight.w600,\n            ),\n          ),\n        ),\n      ),\n    );\n  }\n}\n\n"
  },
  {
    "path": "app/lib/components/spinner.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:spacepad/theme.dart';\n\nclass Spinner extends StatelessWidget {\n  final double size;\n  final EdgeInsets? padding;\n  final double? thickness;\n  final Color? color;\n\n  const Spinner({super.key, required this.size, this.padding, this.thickness, this.color = AppTheme.oxford});\n\n  @override\n  Widget build(BuildContext context) {\n    return Container(\n      height: size,\n      width: size,\n      margin: padding,\n      child: CircularProgressIndicator(strokeWidth: thickness ?? 3, color: color),\n    );\n  }\n}\n"
  },
  {
    "path": "app/lib/components/toast.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:get/get.dart';\nimport 'package:heroicons/heroicons.dart';\n\nclass Toast {\n  Toast._();\n\n  static void showSuccess(String message) {\n    _showSnackBar(message, HeroIcons.check, Colors.green);\n  }\n\n  static void showError(String message) {\n    _showSnackBar(message, HeroIcons.exclamationCircle, Colors.red);\n  }\n\n  static void _showSnackBar(String message, HeroIcons icon, Color color) async {\n    /// Small delay to make sure widget tree is built.\n    await Future.delayed(const Duration(milliseconds: 100));\n\n    Get.showSnackbar(\n        GetSnackBar(\n          messageText: Row(\n            crossAxisAlignment: CrossAxisAlignment.center,\n            children: [\n              Expanded(child: Padding(\n                padding: const EdgeInsets.only(top: 3),\n                child: Text(message, style: const TextStyle(\n                  color: Colors.black,\n                  fontSize: 14,\n                  height: 1.2,\n                  fontWeight: FontWeight.w600,\n                )),\n              )),\n\n              const SizedBox(width: 2),\n\n              IconButton(\n                onPressed: () => Get.closeCurrentSnackbar(),\n                icon: const HeroIcon(HeroIcons.xMark, size: 20),\n              )\n            ],\n          ),\n          margin: const EdgeInsets.symmetric(horizontal: 35, vertical: 10),\n          padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 7),\n          backgroundColor: Colors.white,\n          boxShadows: const [\n            BoxShadow(\n              color: Colors.black12,\n              spreadRadius: 1,\n              blurRadius: 2,\n              offset: Offset(0, 0), // changes position of shadow\n            ),\n          ],\n          icon: Padding(\n              padding: const EdgeInsets.only(left: 12, right: 8),\n              child: HeroIcon(icon, color: color, style: HeroIconStyle.solid, size: 26)\n          ),\n          borderRadius: 10,\n          duration: const Duration(seconds: 4),\n          animationDuration: const Duration(milliseconds: 350),\n          forwardAnimationCurve: Curves.fastEaseInToSlowEaseOut,\n          reverseAnimationCurve: Curves.fastOutSlowIn,\n          borderWidth: 1,\n          borderColor: Colors.grey[300],\n        )\n    );\n  }\n}"
  },
  {
    "path": "app/lib/controllers/dashboard_controller.dart",
    "content": "import 'dart:async';\n\nimport 'package:get/get.dart';\nimport 'package:spacepad/components/toast.dart';\nimport 'package:spacepad/models/event_model.dart';\nimport 'package:spacepad/models/display_data_model.dart';\nimport 'package:spacepad/models/event_status.dart';\nimport 'package:spacepad/services/display_service.dart';\nimport 'package:spacepad/services/auth_service.dart';\nimport 'package:spacepad/pages/display_page.dart';\nimport 'package:spacepad/models/device_model.dart';\nimport 'package:spacepad/models/display_model.dart';\nimport 'package:spacepad/models/display_settings_model.dart';\nimport 'package:spacepad/services/font_service.dart';\nimport 'package:flutter/material.dart';\nimport 'package:spacepad/components/custom_booking_modal.dart';\n\nclass DashboardController extends GetxController {\n  final RxBool loading = RxBool(true);\n  final RxList<EventModel> events = RxList();\n  final Rx<DateTime> time = Rx<DateTime>(DateTime.now());\n  final RxString displayId = RxString('');\n\n  // Global variables for device, display, and settings\n  DeviceModel? globalCurrentDevice;\n  DisplayModel? globalDisplay;\n  final Rx<DisplaySettingsModel?> globalSettings = Rx<DisplaySettingsModel?>(null);\n  \n  // Reactive font family for UI updates\n  final RxString currentFontFamily = RxString('Inter');\n  \n  Timer? _clock;\n  Timer? _dataTimer;\n  \n  // Track refresh state to prevent spamming\n  final RxBool isRefreshing = RxBool(false);\n  DateTime? _lastRefreshTime;\n  static const int _refreshCooldownSeconds = 3;\n\n  @override\n  void onInit() async {\n    super.onInit();\n\n    updateTime();\n    \n    // Check if display ID is set, redirect to display page if not\n    final displayIdResult = AuthService.instance.getCurrentDisplayId();\n    if (displayIdResult == null) {\n      Get.offAll(() => const DisplayPage());\n      return;\n    } else {\n      displayId.value = displayIdResult;\n    }\n\n    initializeTimers();\n    await fetchDisplayData();\n    \n    // Preload fonts for better performance\n    await FontService.instance.preloadFonts();\n\n    loading.value = false;\n  }\n\n  void initializeTimers() {\n    final int millisecondsToNextSecond = DateTime.now().millisecond;\n\n    // Start a timer that aligns with the next second for data refresh (every 60 seconds)\n    Future.delayed(Duration(milliseconds: millisecondsToNextSecond), () {\n      _dataTimer = Timer.periodic(const Duration(seconds: 60), (timer) => fetchDisplayData());\n    });\n\n    _clock = Timer.periodic(const Duration(seconds: 1), (timer) => updateTime());\n  }\n\n  void updateTime() {\n    time.value = DateTime.now();\n  }\n\n  String get roomName {\n    return globalDisplay?.name ?? 'meeting_room'.tr;\n  }\n\n  String get title {\n    if (isReserved) {\n      return currentEvent!.summary;\n    }\n    if (isCheckInActive) {\n      return globalSettings.value?.textCheckin ?? 'check_in_now'.tr;\n    }\n    if (isTransitioning && !isReserved) {\n      return globalSettings.value?.textTransitioning ?? 'to_be_reserved'.tr;\n    }\n    return globalSettings.value?.textAvailable ?? 'available'.tr;\n  }\n\n  /// Returns the start and end DateTime of the current event, or null if not reserved.\n  Map<String, DateTime>? get meetingInfoTimes {\n    if (!isReserved) {\n      return null;\n    }\n    return {\n      'start': currentEvent!.start,\n      'end': currentEvent!.end,\n    };\n  }\n\n  String get subtitle {\n    if (isReserved && !isCheckInActive) {\n      final currentEventEnd = currentEvent!.end;\n      final totalMinutesLeft = currentEventEnd.difference(DateTime.now()).inMinutes;\n      final hoursLeft = (totalMinutesLeft / 60).floor();\n      final minutesLeft = (totalMinutesLeft - (hoursLeft * 60)).floor() + 1;\n\n      return totalMinutesLeft < 60 ?\n        'x_minutes_left'.trParams({'minutes': minutesLeft.toString()}) :\n        'x_hours_x_minutes_left'.trParams({'hours': hoursLeft.toString(), 'minutes': minutesLeft.toString()});\n    }\n\n    if (isCheckInActive && currentEvent != null) {\n      final totalMinutesLeft = currentEvent!.start.add(Duration(minutes: checkInGracePeriod)).difference(DateTime.now()).inMinutes;\n      final hoursLeft = (totalMinutesLeft / 60).floor();\n      final minutesLeft = (totalMinutesLeft - (hoursLeft * 60)).floor() + 1;\n\n      return 'check_in_within_x_minutes'.trParams({'minutes': minutesLeft.toString()});\n    }\n\n    if (isCheckInActive && upcomingEvents.isNotEmpty) {\n      final upcomingMeeting = upcomingEvents.first;\n      final totalMinutesLeft = upcomingMeeting.start.difference(DateTime.now()).inMinutes;\n      final hoursLeft = (totalMinutesLeft / 60).floor();\n      final minutesLeft = (totalMinutesLeft - (hoursLeft * 60)).floor() + 1;\n\n      return 'x_starts_in_x_minutes'.trParams({'meeting': upcomingMeeting.summary, 'minutes': minutesLeft.toString()});\n    }\n\n    if (upcomingEvents.isNotEmpty) {\n      final upcomingStart = upcomingEvents.first.start;\n      final totalMinutesLeft = upcomingStart.difference(DateTime.now()).inMinutes;\n      final hoursLeft = (totalMinutesLeft / 60).floor();\n      final minutesLeft = (totalMinutesLeft - (hoursLeft * 60)).floor() + 1;\n\n      return totalMinutesLeft < 60 ?\n        'for_x_minutes'.trParams({'minutes': minutesLeft.toString()}) :\n        'for_x_hours_x_minutes'.trParams({'hours': hoursLeft.toString(), 'minutes': minutesLeft.toString()});\n    }\n\n    return 'till_end_of_day'.tr;\n  }\n\n  bool get isReserved {\n    return currentEvent != null;\n  }\n\n  bool get isTransitioning {\n    if (checkInEnabled) {\n      return false;\n    }\n\n    if (isReserved) {\n      final currentEventEnd = currentEvent!.end;\n      final minutesLeft = currentEventEnd.difference(DateTime.now()).inMinutes;\n\n      return minutesLeft < 10;\n    }\n\n    if (upcomingEvents.isNotEmpty) {\n      final upcomingStart = upcomingEvents.first.start;\n      final minutesLeft = upcomingStart.difference(DateTime.now()).inMinutes;\n\n      return minutesLeft < 10;\n    }\n\n    return false;\n  }\n\n  // Returns true if there is an event with checkInRequired and we are within its check-in window (before/after start)\n  bool get isCheckInActive {\n    if (!checkInEnabled) {\n      return false;\n    }\n\n    return checkInEvent != null;\n  }\n\n  EventModel? get checkInEvent {\n    final now = DateTime.now();\n    return events.firstWhereOrNull((e) {\n      if (e.checkInRequired != true) return false;\n\n      final start = e.start;\n      final windowStart = start.subtract(Duration(minutes: checkInMinutes));\n      final windowEnd = start.add(Duration(minutes: checkInGracePeriod));\n\n      return now.isAfter(windowStart) && now.isBefore(windowEnd);\n    });\n  }\n\n  EventModel? get currentEvent {\n    DateTime now = DateTime.now();\n    return events.where((e) => now.isAfter(e.start) && now.isBefore(e.end)).firstOrNull;\n  }\n\n  List<EventModel> get upcomingEvents {\n    List<EventModel> nextEvents = events.where((e) => e.start.isAfter(DateTime.now())).toList();\n\n    nextEvents.sort((a, b) => a.start.compareTo(b.start));\n\n    return nextEvents;\n  }\n\n  Future<void> fetchDisplayData() async {\n    try {\n      DisplayDataModel displayData = await DisplayService.instance.getDisplayData(displayId.value);\n      \n      // Update global device, display, and settings\n      if (AuthService.instance.currentDevice.value != null) {\n        globalCurrentDevice = AuthService.instance.currentDevice.value;\n        globalCurrentDevice!.display = displayData.display;\n        globalDisplay = globalCurrentDevice!.display;\n        globalSettings.value = globalDisplay?.settings;\n        \n        // Update reactive font family to trigger UI rebuild\n        final newFontFamily = globalSettings.value?.fontFamily ?? 'Inter';\n        if (currentFontFamily.value != newFontFamily) {\n          currentFontFamily.value = newFontFamily;\n          \n          // Reload the font when settings change\n          await FontService.instance.reloadFont(newFontFamily);\n        }\n\n        AuthService.instance.currentDevice.refresh();\n      }\n\n      // Update events\n      events.value = displayData.events\n          .where((e) => e.status != EventStatus.cancelled)\n          .map((e) {\n            e.summary = getDisplayableSummary(e);\n            return e;\n          })\n          .toList();\n    } catch (e) {\n      Toast.showError('could_not_load_data'.tr);\n    }\n  }\n\n  void switchRoom() {\n    _clock?.cancel();\n    _dataTimer?.cancel();\n    \n    Get.offAll(() => const DisplayPage());\n  }\n\n  // Manually refresh display data with cooldown to prevent spamming\n  Future<void> refreshDisplayData() async {\n    // Check if we're already refreshing\n    if (isRefreshing.value) {\n      return;\n    }\n    \n    // Check cooldown period\n    if (_lastRefreshTime != null) {\n      final secondsSinceLastRefresh = DateTime.now().difference(_lastRefreshTime!).inSeconds;\n      if (secondsSinceLastRefresh < _refreshCooldownSeconds) {\n        return;\n      }\n    }\n    \n    isRefreshing.value = true;\n    _lastRefreshTime = DateTime.now();\n    \n    try {\n      await fetchDisplayData();\n      Toast.showSuccess('display_data_refreshed'.tr);\n    } finally {\n      isRefreshing.value = false;\n    }\n  }\n\n  Future<void> bookRoom(int duration) async {\n    if (isBooking.value) return; // Prevent multiple simultaneous bookings\n    \n    try {\n      isBooking.value = true;\n      bookingDuration.value = duration; // Track which button was clicked\n      final summary = 'reserved'.tr;\n      await DisplayService.instance.book(displayId.value, duration, summary: summary);\n      await fetchDisplayData();\n      Toast.showSuccess('room_booked'.tr);\n      \n      // Cancel the booking options timer since user took action\n      _bookingOptionsTimer?.cancel();\n      showBookingOptions.value = false;\n    } catch (e) {\n      Toast.showError('could_not_book_room'.tr);\n    } finally {\n      isBooking.value = false;\n      bookingDuration.value = null; // Clear the tracked duration\n    }\n  }\n\n  void showCustomBookingModal(BuildContext context, bool isPhone, double cornerRadius) {\n    showDialog(\n      context: context,\n      builder: (context) => CustomBookingModal(\n        controller: this,\n        isPhone: isPhone,\n        cornerRadius: cornerRadius,\n      ),\n    );\n  }\n\n  Future<void> bookCustom(String title, DateTime startTime, DateTime endTime) async {\n    isBooking.value = true;\n    try {\n      await DisplayService.instance.bookCustom(displayId.value, title, startTime, endTime);\n      await fetchDisplayData();\n      Toast.showSuccess('room_booked'.tr);\n      \n      // Cancel the booking options timer since user took action\n      _bookingOptionsTimer?.cancel();\n      showBookingOptions.value = false;\n    } catch (e) {\n      Toast.showError('could_not_book_room'.tr);\n    } finally {\n      isBooking.value = false;\n      bookingDuration.value = null; // Clear the tracked duration\n    }\n  }\n\n\n  Future<void> cancelCurrentEvent() async {\n    if (isCancelling.value) return; // Prevent multiple simultaneous cancellations\n    \n    try {\n      isCancelling.value = true;\n      if (currentEvent != null) {\n        await DisplayService.instance.cancelEvent(displayId.value, currentEvent!.id);\n        await fetchDisplayData();\n        Toast.showSuccess('event_cancelled'.tr);\n      }\n    } catch (e) {\n      Toast.showError('could_not_cancel_event'.tr);\n    } finally {\n      isCancelling.value = false;\n    }\n  }\n\n  // Check if booking should be displayed based on display settings\n  bool get bookingEnabled {\n    return globalSettings.value?.bookingEnabled ?? false;\n  }\n\n  // Check if custom booking is available (server capability)\n  bool get hasCustomBooking {\n    return globalSettings.value?.hasCustomBooking ?? false;\n  }\n\n  // Check if current event can be cancelled based on cancel permission setting\n  bool get canCancelCurrentEvent {\n    // Early return: cannot cancel if there's no current event\n    if (currentEvent == null) {\n      return false;\n    }\n    \n    final cancelPermission = globalSettings.value?.cancelPermission ?? 'all';\n    \n    if (cancelPermission == 'none') {\n      return false;\n    }\n    \n    if (cancelPermission == 'tablet_only') {\n      // Only allow cancelling if the event was booked via tablet\n      // currentEvent is guaranteed to be non-null at this point\n      return currentEvent!.isTabletBooking;\n    }\n    \n    // Default: 'all' - allow cancelling any event\n    // currentEvent is guaranteed to be non-null at this point\n    return true;\n  }\n\n  // Get border width based on border thickness setting\n  double getBorderWidth() {\n    final borderThickness = globalSettings.value?.borderThickness ?? 'medium';\n    switch (borderThickness) {\n      case 'small':\n        return 1.33;\n      case 'large':\n        return 2.67;\n      case 'medium':\n      default:\n        return 2.0;\n    }\n  }\n\n  bool get calendarEnabled {\n    return globalSettings.value?.calendarEnabled ?? false;\n  }\n\n  // Track if booking options are shown\n  final RxBool showBookingOptions = RxBool(false);\n  \n  // Loading states for actions\n  final RxBool isBooking = RxBool(false);\n  final Rx<int?> bookingDuration = Rx<int?>(null); // Track which duration button was clicked\n  final RxBool isCancelling = RxBool(false);\n  \n  // Timer for booking options timeout\n  Timer? _bookingOptionsTimer;\n\n  // Track if admin actions are temporarily visible\n  final RxBool showAdminActionsTemporarily = RxBool(false);\n  \n  // Timer for admin actions timeout\n  Timer? _adminActionsTimer;\n  \n  // Timer for long press detection (3 seconds)\n  Timer? _longPressTimer;\n\n  // Show booking options with 30-second timeout\n  void toggleBookingOptions() {\n    showBookingOptions.value = true;\n    \n    // Cancel any existing timer\n    _bookingOptionsTimer?.cancel();\n    \n    // Set a 30-second timeout to automatically hide booking options\n    _bookingOptionsTimer = Timer(const Duration(seconds: 30), () {\n      showBookingOptions.value = false;\n    });\n  }\n\n  // Hide booking options\n  void hideBookingOptions() {\n    showBookingOptions.value = false;\n    _bookingOptionsTimer?.cancel();\n  }\n  // Start long press timer (3 seconds)\n  void startLongPressTimer() {\n    // Cancel any existing timer\n    _longPressTimer?.cancel();\n    \n    // Set a 3-second timer to trigger reveal\n    _longPressTimer = Timer(const Duration(seconds: 3), () {\n      revealAdminActionsTemporarily();\n    });\n  }\n\n  // Cancel long press timer\n  void cancelLongPressTimer() {\n    _longPressTimer?.cancel();\n  }\n\n  // Show admin actions temporarily (30 seconds)\n  void revealAdminActionsTemporarily() {\n    showAdminActionsTemporarily.value = true;\n    \n    // Show notification with duration\n    Toast.showSuccess('admin_actions_enabled'.trParams({'seconds': '30'}));\n    \n    // Cancel any existing timer\n    _adminActionsTimer?.cancel();\n    \n    // Set a 30-second timeout to automatically hide admin actions\n    _adminActionsTimer = Timer(const Duration(seconds: 30), () {\n      showAdminActionsTemporarily.value = false;\n    });\n  }\n\n  int get checkInGracePeriod {\n    return globalSettings.value?.checkInGracePeriod ?? 5;\n  }\n\n  bool get checkInEnabled {\n    return globalSettings.value?.checkInEnabled ?? false;\n  }\n\n  int get checkInMinutes {\n    return globalSettings.value?.checkInMinutes ?? 15;\n  }\n\n  void checkIn() async {\n    try {\n      await DisplayService.instance.checkInToEvent(displayId.value, checkInEvent!.id);\n      await fetchDisplayData();\n      Toast.showSuccess('checked_in'.tr);\n    } catch (e) {\n      Toast.showError('could_not_check_in'.tr);\n    }\n  }\n\n  List<int> get availableBookingDurations {\n    final base = [15, 30, 60];\n    if (isCheckInActive) {\n      return base.where((min) => min <= checkInGracePeriod).toList();\n    }\n    if (upcomingEvents.isNotEmpty) {\n      final nextEvent = upcomingEvents.first;\n      final minutesUntilNext = nextEvent.start.difference(DateTime.now()).inMinutes;\n      return base.where((min) => min <= minutesUntilNext).toList();\n    }\n    return base;\n  }\n\n  /// Returns the summary to display for an event, respecting showMeetingTitle\n  String getDisplayableSummary(EventModel event) {\n    if (globalSettings.value?.showMeetingTitle == false) {\n      return getReservedText();\n    }\n    return event.summary;\n  }\n\n  String getReservedText() {\n    return globalSettings.value?.textReserved ?? 'reserved'.tr;\n  }\n\n  @override\n  void dispose() {\n    _clock?.cancel();\n    _dataTimer?.cancel();\n    _bookingOptionsTimer?.cancel();\n    _adminActionsTimer?.cancel();\n    _longPressTimer?.cancel();\n\n    super.dispose();\n  }\n}"
  },
  {
    "path": "app/lib/controllers/display_controller.dart",
    "content": "import 'package:get/get.dart';\nimport 'package:spacepad/models/display_model.dart';\nimport 'package:spacepad/components/toast.dart';\nimport 'package:spacepad/services/device_service.dart';\nimport 'package:spacepad/services/display_service.dart';\nimport 'package:spacepad/services/auth_service.dart';\n\nclass DisplayController extends GetxController {\n  final RxBool loading = RxBool(false);\n  final RxList<DisplayModel> displays = RxList();\n  final Rx<DisplayModel?> selectedDisplay = Rx(null);\n\n  @override\n  void onInit() {\n    super.onInit();\n\n    getDisplays();\n  }\n\n  void onSelect(val) {\n    selectedDisplay.value = val;\n  }\n\n  bool get submitActive {\n    return selectedDisplay.value != null;\n  }\n\n  Future<void> getDisplays() async {\n    if (loading.value) return;\n\n    loading.value = true;\n\n    try {\n      displays.value = await DisplayService.instance.getDisplays();\n    } catch (e) {\n      Toast.showError('could_not_load_displays'.tr);\n    }\n\n    loading.value = false;\n  }\n\n  Future<void> submit() async {\n    if (loading.value) return;\n\n    loading.value = true;\n\n    try {\n      await DeviceService.instance.changeDisplay(selectedDisplay.value!.id);\n\n      // Save the selected display ID to local storage\n      await AuthService.instance.setCurrentDisplayId(selectedDisplay.value!.id);\n\n      await AuthService.instance.verify();\n    } catch (e) {\n      Toast.showError('check_connection'.tr);\n    }\n\n    loading.value = false;\n  }\n}"
  },
  {
    "path": "app/lib/controllers/login_controller.dart",
    "content": "import 'dart:io';\nimport 'dart:core';\n\nimport 'package:device_info_plus/device_info_plus.dart';\nimport 'package:flutter_udid/flutter_udid.dart';\nimport 'package:get/get.dart';\nimport 'package:spacepad/services/auth_service.dart';\nimport 'package:spacepad/components/toast.dart';\nimport 'package:spacepad/services/server_service.dart';\nimport 'package:spacepad/services/api_service.dart';\n\nclass LoginController extends GetxController {\n  final AuthService _authService = AuthService.instance;\n  final ServerService _serverService = ServerService();\n  final RxBool loading = false.obs;\n  final RxBool isSelfHosted = false.obs;\n  final RxString url = ''.obs;\n  final RxString code = ''.obs;\n  final RxBool submitActive = false.obs;\n  final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();\n\n  void toggleSelfHosted(bool value) {\n    isSelfHosted.value = value;\n    _updateSubmitActive();\n  }\n\n  void urlChanged(String value) {\n    url.value = value;\n    _updateSubmitActive();\n  }\n\n  void codeChanged(String value) {\n    code.value = value;\n    _updateSubmitActive();\n  }\n\n  void _updateSubmitActive() {\n    if (isSelfHosted.value) {\n      submitActive.value = url.value.isNotEmpty && code.value.length == 6;\n    } else {\n      submitActive.value = code.value.length == 6;\n    }\n  }\n\n  bool _isValidUrl(String url) {\n    try {\n      final uri = Uri.parse(url);\n      return uri.isAbsolute && (uri.scheme == 'http' || uri.scheme == 'https');\n    } catch (e) {\n      return false;\n    }\n  }\n\n  Future<String?> getDeviceId() async {\n    return await FlutterUdid.udid;\n  }\n\n  Future<String?> getDeviceName() async {\n    if (Platform.isAndroid) {\n      AndroidDeviceInfo androidInfo = await deviceInfoPlugin.androidInfo;\n      return androidInfo.model;\n    }\n\n    if (Platform.isIOS) {\n      IosDeviceInfo iosInfo = await deviceInfoPlugin.iosInfo;\n      return iosInfo.utsname.machine;\n    }\n\n    return null;\n  }\n\n  Future<void> submit() async {\n    if (loading.value) return;\n\n    loading.value = true;\n    try {\n      if (isSelfHosted.value) {\n        if (!_isValidUrl(url.value)) {\n          Toast.showError('invalid_url'.tr);\n          return;\n        }\n\n        var trimmedUrl = url.value.endsWith('/') ? url.value.substring(0, url.value.length - 1) : url.value;\n        if (!await _serverService.isServerReachable(trimmedUrl)) {\n          Toast.showError('server_unreachable'.tr);\n          return;\n        }\n\n        // Set the custom base URL for the API service\n        await ApiService.setBaseUrl(trimmedUrl);\n      } else {\n        await ApiService.resetToServerBaseUrl();\n      }\n\n      final deviceId = await getDeviceId() ?? 'Unknown device';\n      final deviceName = await getDeviceName() ?? 'Unknown model';\n      await _authService.login(code.value, deviceId, deviceName);\n    } catch (e) {\n      Toast.showError('login_failed'.tr);\n    } finally {\n      loading.value = false;\n    }\n  }\n}"
  },
  {
    "path": "app/lib/date_format_helper.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:intl/intl.dart';\n\nString formatTime(BuildContext context, DateTime time) {\n  final use24Hour = MediaQuery.of(context).alwaysUse24HourFormat;\n  return use24Hour\n      ? DateFormat.Hm().format(time)   // 24-hour format\n      : DateFormat.jm().format(time);  // 12-hour format\n} "
  },
  {
    "path": "app/lib/exceptions/api_exception.dart",
    "content": "import 'dart:convert';\nimport 'package:http/http.dart';\n\nclass ApiException implements Exception {\n  final int code;\n  final String? message;\n  final Map? errors;\n\n  ApiException({required this.code, this.message, this.errors});\n\n  static ApiException fromResponse(Response response) {\n    return ApiException(\n        code: response.statusCode,\n        message: jsonDecode(response.body)['message'],\n        errors: _mapErrors(jsonDecode(response.body)['errors'])\n    );\n  }\n\n  static Map? _mapErrors(Map? errors) {\n    return errors?.map((key, value) {\n      return MapEntry(key, value?.isNotEmpty ? value.first : '');\n    });\n  }\n\n  @override\n  String toString() => 'ApiException: $code - $message';\n}"
  },
  {
    "path": "app/lib/main.dart",
    "content": "import 'dart:async';\nimport 'package:flutter/material.dart';\nimport 'package:flutter/services.dart';\nimport 'package:flutter_localizations/flutter_localizations.dart';\nimport 'package:get/get.dart';\nimport 'package:flutter_dotenv/flutter_dotenv.dart';\nimport 'package:spacepad/pages/login_page.dart';\nimport 'package:spacepad/pages/splash_page.dart';\nimport 'package:spacepad/theme.dart';\nimport 'package:spacepad/services/auth_service.dart';\nimport 'package:spacepad/translations/translations.dart';\nimport 'package:wakelock_plus/wakelock_plus.dart';\nimport 'package:timezone/data/latest.dart' as tz;\n\n// Supported locales list\nconst List<Locale> supportedLocales = [\n  Locale('en'),\n  Locale('nl'),\n  Locale('fr'),\n  Locale('es'),\n  Locale('de'),\n  Locale('sv'),\n];\n\n// Helper function to validate if a locale is exactly supported\nbool isLocaleSupported(Locale locale) {\n  return supportedLocales.any((supportedLocale) => \n    supportedLocale.languageCode == locale.languageCode &&\n    supportedLocale.countryCode == locale.countryCode);\n}\n\n// Helper function to get the best matching locale\nLocale getBestMatchingLocale(Locale? requestedLocale) {\n  if (requestedLocale == null) {\n    return const Locale('en');\n  }\n  \n  // First try exact match (both language and country code match)\n  for (final supportedLocale in supportedLocales) {\n    if (supportedLocale.languageCode == requestedLocale.languageCode &&\n        supportedLocale.countryCode == requestedLocale.countryCode) {\n      return supportedLocale;\n    }\n  }\n  \n  // Try to find a locale with the same language code (return supported locale, not original)\n  for (final supportedLocale in supportedLocales) {\n    if (supportedLocale.languageCode == requestedLocale.languageCode) {\n      return supportedLocale;\n    }\n  }\n  \n  // Fallback to English\n  return const Locale('en');\n}\n\nFuture<void> main() async {\n  WidgetsFlutterBinding.ensureInitialized();\n\n  await dotenv.load(fileName: \".env\");\n\n  await AuthService.instance.initialise();\n\n  tz.initializeTimeZones();\n\n  WakelockPlus.enable();\n\n  SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);\n\n  // Set a valid locale based on device locale or fallback to English\n  final deviceLocale = Get.deviceLocale;\n  final validLocale = getBestMatchingLocale(deviceLocale);\n  \n  // Debug information (remove in production)\n  if (deviceLocale != null) {\n    print('Device locale: ${deviceLocale.languageCode}_${deviceLocale.countryCode}');\n    print('Selected locale: ${validLocale.languageCode}');\n    print('Is supported: ${isLocaleSupported(deviceLocale)}');\n  }\n  \n  Get.updateLocale(validLocale);\n\n  runApp(const App());\n}\n\nclass App extends StatelessWidget {\n  const App({super.key});\n\n  @override\n  Widget build(BuildContext context) {\n    // Resolve locale consistently using the same logic as in main()\n    final resolvedLocale = getBestMatchingLocale(Get.locale);\n    \n    return GetMaterialApp(\n      themeMode: ThemeMode.light,\n      theme: AppTheme.data,\n      initialRoute: '/',\n      transitionDuration: Duration.zero,\n      translations: AppTranslations(),\n      locale: resolvedLocale,\n      fallbackLocale: const Locale('en'),\n      supportedLocales: supportedLocales,\n      localizationsDelegates: const [\n        GlobalMaterialLocalizations.delegate,\n        GlobalWidgetsLocalizations.delegate,\n        GlobalCupertinoLocalizations.delegate,\n      ],\n      debugShowCheckedModeBanner: false,\n      getPages: [\n        GetPage(name: '/', page: () {\n          if (AuthService.instance.getAuthToken() != null) {\n            return const SplashPage();\n          }\n\n          return const LoginPage();\n        })\n      ],\n    );\n  }\n}"
  },
  {
    "path": "app/lib/models/device_model.dart",
    "content": "import 'package:spacepad/models/user_model.dart';\nimport 'package:spacepad/models/display_model.dart';\n\nclass DeviceModel {\n  final String id;\n  final String name;\n  UserModel? user;\n  DisplayModel? display;\n\n  DeviceModel({required this.id, required this.name, required this.user, this.display});\n\n  factory DeviceModel.fromJson(Map data) {\n    return DeviceModel(\n        id: data['id'],\n        name: data['name'],\n        user: data['user'] != null ? UserModel.fromJson(data['user']) : null,\n        display: data['display'] != null ? DisplayModel.fromJson(data['display']) : null,\n    );\n  }\n}"
  },
  {
    "path": "app/lib/models/display_data_model.dart",
    "content": "import 'display_model.dart';\nimport 'event_model.dart';\n\nclass DisplayDataModel {\n  DisplayModel? display;\n  List<EventModel> events;\n\n  DisplayDataModel({\n    required this.display,\n    required this.events,\n  });\n\n  factory DisplayDataModel.fromJson(Map<String, dynamic> data) {\n    return DisplayDataModel(\n      display: DisplayModel.fromJson(data['display']),\n      events: (data['events'] as List)\n          .map((e) => EventModel.fromJson(e))\n          .toList(),\n    );\n  }\n\n  factory DisplayDataModel.fromEventsJson(List data) {\n    return DisplayDataModel(\n      display: null,\n      events: data\n          .map((e) => EventModel.fromJson(e))\n          .toList(),\n    );\n  }\n\n  Map<String, dynamic> toJson() {\n    return {\n      'display': display?.toJson(),\n      'events': events.map((e) => e.toJson()).toList(),\n    };\n  }\n}"
  },
  {
    "path": "app/lib/models/display_model.dart",
    "content": "import 'display_settings_model.dart';\n\nclass DisplayModel {\n  String id;\n  String name;\n  DisplaySettingsModel settings;\n\n  DisplayModel({\n    required this.id,\n    required this.name,\n    required this.settings,\n  });\n\n  factory DisplayModel.fromJson(Map data) {\n    return DisplayModel(\n      id: data['id'],\n      name: data['name'],\n      settings: DisplaySettingsModel.fromJson(data['settings'] ?? {}),\n    );\n  }\n\n  Map<String, dynamic> toJson() {\n    return {\n      'id': id,\n      'name': name,\n      'settings': settings.toJson(),\n    };\n  }\n}"
  },
  {
    "path": "app/lib/models/display_settings_model.dart",
    "content": "class DisplaySettingsModel {\n  bool checkInEnabled;\n  bool bookingEnabled;\n  bool hasCustomBooking;\n  int checkInGracePeriod;\n  int checkInMinutes;\n  bool calendarEnabled;\n  bool hideAdminActions;\n  String? textAvailable;\n  String? textTransitioning;\n  String? textReserved;\n  String? textCheckin;\n  bool showMeetingTitle;\n  String? logoUrl;\n  String? backgroundImageUrl;\n  String fontFamily;\n  String cancelPermission;\n  String borderThickness;\n\n  DisplaySettingsModel({\n    required this.checkInEnabled,\n    required this.bookingEnabled,\n    required this.hasCustomBooking,\n    required this.checkInGracePeriod,\n    required this.checkInMinutes,\n    required this.calendarEnabled,\n    required this.hideAdminActions,\n    this.textAvailable,\n    this.textTransitioning,\n    this.textReserved,\n    this.textCheckin,\n    required this.showMeetingTitle,\n    this.logoUrl,\n    this.backgroundImageUrl,\n    required this.fontFamily,\n    this.cancelPermission = 'all',\n    this.borderThickness = 'medium',\n  });\n\n  factory DisplaySettingsModel.fromJson(Map data) {\n    return DisplaySettingsModel(\n      checkInEnabled: data['check_in_enabled'] ?? false,\n      bookingEnabled: data['booking_enabled'] ?? false,\n      hasCustomBooking: data['has_custom_booking'] ?? false,\n      checkInGracePeriod: data['check_in_grace_period'] ?? 5,\n      checkInMinutes: data['check_in_minutes'] ?? 15,\n      calendarEnabled: data['calendar_enabled'] ?? false,\n      hideAdminActions: data['hide_admin_actions'] ?? false,\n      textAvailable: data['text_available'],\n      textTransitioning: data['text_transitioning'],\n      textReserved: data['text_reserved'],\n      textCheckin: data['text_checkin'],\n      showMeetingTitle: data['show_meeting_title'] ?? true,\n      logoUrl: data['logo_url'],\n      backgroundImageUrl: data['background_image_url'],\n      fontFamily: data['font_family'] ?? 'Inter',\n      cancelPermission: data['cancel_permission'] ?? 'all',\n      borderThickness: data['border_thickness'] ?? 'medium',\n    );\n  }\n\n  Map<String, dynamic>? toJson() {\n    return {\n      'check_in_enabled': checkInEnabled,\n      'booking_enabled': bookingEnabled,\n      'has_custom_booking': hasCustomBooking,\n      'check_in_grace_period': checkInGracePeriod,\n      'check_in_minutes': checkInMinutes,\n      'calendar_enabled': calendarEnabled,\n      'hide_admin_actions': hideAdminActions,\n      'text_available': textAvailable,\n      'text_transitioning': textTransitioning,\n      'text_reserved': textReserved,\n      'text_checkin': textCheckin,\n      'show_meeting_title': showMeetingTitle,\n      'logo_url': logoUrl,\n      'background_image_url': backgroundImageUrl,\n      'font_family': fontFamily,\n    };\n  }\n} "
  },
  {
    "path": "app/lib/models/event_model.dart",
    "content": "import 'event_status.dart';\n\nclass EventModel {\n  String id;\n  EventStatus status;\n  String summary;\n  String? location;\n  String? description;\n  DateTime start;\n  DateTime end;\n  String? timezone;\n  bool isCheckedIn;\n  bool checkInRequired;\n  String? source;\n  bool isTabletBooking;\n\n  EventModel({\n    required this.id,\n    required this.status,\n    required this.summary,\n    this.location,\n    this.description,\n    required this.start,\n    required this.end,\n    this.timezone,\n    this.isCheckedIn = false,\n    this.checkInRequired = false,\n    this.source,\n    this.isTabletBooking = false,\n  });\n\n  factory EventModel.fromJson(Map<String, dynamic> data) {\n    return EventModel(\n      id: data['id'],\n      status: eventStatusFromString(data['status']),\n      summary: data['summary'],\n      location: data['location'],\n      description: data['description'],\n      start: DateTime.parse(data['start']).toLocal(),\n      end: DateTime.parse(data['end']).toLocal(),\n      timezone: data['timezone'],\n      isCheckedIn: data['checkedInAt'] != null,\n      checkInRequired: data['checkInRequired'] ?? false,\n      source: data['source'],\n      isTabletBooking: data['isTabletBooking'] ?? false,\n    );\n  }\n\n  Map<String, dynamic> toJson() {\n    return {\n      'id': id,\n      'status': eventStatusToString(status),\n      'summary': summary,\n      'location': location,\n      'description': description,\n      'start': start.toIso8601String(),\n      'end': end.toIso8601String(),\n      'timezone': timezone,\n      'isCheckedIn': isCheckedIn,\n      'checkInRequired': checkInRequired,\n    };\n  }\n}\n"
  },
  {
    "path": "app/lib/models/event_status.dart",
    "content": "enum EventStatus { confirmed, tentative, cancelled }\n\nEventStatus eventStatusFromString(String? value) {\n  switch (value) {\n    case 'tentative':\n      return EventStatus.tentative;\n    case 'cancelled':\n      return EventStatus.cancelled;\n    case 'confirmed':\n    default:\n      return EventStatus.confirmed;\n  }\n}\n\nString eventStatusToString(EventStatus status) {\n  switch (status) {\n    case EventStatus.tentative:\n      return 'tentative';\n    case EventStatus.cancelled:\n      return 'cancelled';\n    case EventStatus.confirmed:\n    default:\n      return 'confirmed';\n  }\n} "
  },
  {
    "path": "app/lib/models/user_model.dart",
    "content": "class UserModel {\n  String id;\n  String name;\n  String email;\n\n  UserModel({\n    required this.id,\n    required this.name,\n    required this.email,\n  });\n\n  factory UserModel.fromJson(Map data) {\n   return UserModel(\n      id: data['id'],\n      name: data['name'],\n      email: data['email'],\n    );\n  }\n\n  Map<String, dynamic> toJson() {\n    return {\n      'id': id,\n      'name': name,\n      'email': email,\n    };\n  }\n}"
  },
  {
    "path": "app/lib/pages/dashboard_page.dart",
    "content": "import 'package:flutter/foundation.dart';\nimport 'package:flutter/material.dart';\nimport 'package:flutter/cupertino.dart';\nimport 'package:flutter/services.dart';\nimport 'package:spacepad/components/event_line.dart';\nimport 'package:spacepad/components/spinner.dart';\nimport 'package:spacepad/controllers/dashboard_controller.dart';\nimport 'package:spacepad/date_format_helper.dart';\nimport 'package:spacepad/models/event_model.dart';\nimport 'package:get/get.dart';\nimport 'package:spacepad/theme.dart';\nimport 'package:tailwind_components/tailwind_components.dart';\nimport 'dart:math' show max;\nimport 'package:spacepad/components/action_panel.dart';\nimport 'package:spacepad/components/calendar_modal.dart';\nimport 'package:spacepad/components/authenticated_image.dart';\nimport 'package:spacepad/components/authenticated_background.dart';\nimport 'package:spacepad/services/font_service.dart';\nimport 'package:spacepad/components/frosted_panel.dart';\nimport 'package:spacepad/components/admin_actions.dart';\n\nclass DashboardPage extends StatefulWidget {\n  const DashboardPage({super.key});\n\n  @override\n  State<DashboardPage> createState() => _DashboardPageState();\n}\n\nclass _DashboardPageState extends State<DashboardPage> {\n  bool _isPhone(BuildContext context) {\n    final shortestSide = MediaQuery.of(context).size.shortestSide;\n    // Consider devices with shortestSide < 600 as phones only\n    return shortestSide < 600;\n  }\n\n  bool _isPortrait(BuildContext context) {\n    final size = MediaQuery.of(context).size;\n    return size.height > size.width;\n  }\n\n  double _getCornerRadius(BuildContext context) {\n    // Get the top padding which includes the notch area\n    final topPadding = MediaQuery.of(context).padding.top;\n    // The corner radius is typically around 40-50% of the top padding\n    // We'll use 45% as a good middle ground\n    final cornerRadius = max(topPadding * 0.45, 10.0);\n    return cornerRadius;\n  }\n\n  double _getContainerPadding(BuildContext context, DashboardController controller) {\n    final size = MediaQuery.of(context).size;\n    final shortestSide = size.shortestSide;\n    final isPortrait = size.height > size.width;\n    \n    // Base padding on shortest side, increase for portrait\n    final basePadding = shortestSide * 0.02; // 2% of shortest side\n    final portraitMultiplier = isPortrait ? 1.2 : 1.1;\n    \n    // Adjust padding based on border thickness setting\n    // Border thickness affects the visual border created by padding\n    final borderThickness = controller.getBorderWidth();\n    final borderMultiplier = borderThickness / 2.0; // Normalize to 2.0 (medium) as baseline\n    \n    return basePadding * portraitMultiplier * borderMultiplier;\n  }\n\n  EdgeInsets _getInnerPadding(BuildContext context) {\n    final size = MediaQuery.of(context).size;\n    final shortestSide = size.shortestSide;\n    final isPortrait = size.height > size.width;\n    \n    // Base padding on shortest side, increase for portrait\n    final horizontalBase = shortestSide * 0.033; // ~3.3% of shortest side\n    final verticalBase = shortestSide * 0.025; // ~2.5% of shortest side\n    final portraitMultiplier = isPortrait ? 1.2 : 1.4;\n    \n    return EdgeInsets.fromLTRB(\n      horizontalBase * portraitMultiplier,\n      verticalBase * portraitMultiplier,\n      horizontalBase * portraitMultiplier,\n      verticalBase * portraitMultiplier,\n    );\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    DashboardController controller = Get.put(DashboardController());\n    final isPhone = _isPhone(context);\n    final isPortrait = _isPortrait(context);\n    final cornerRadius = _getCornerRadius(context);\n\n    if (kDebugMode) print('isPhone: $isPhone');\n    if (kDebugMode) print('isPortrait: $isPortrait');\n    if (kDebugMode) print('cornerRadius: $cornerRadius');\n\n    return Scaffold(\n      backgroundColor: AppTheme.black,\n      body: Obx(() => controller.loading.value ?\n          Center(\n            child: Spinner(size: 40, thickness: 4, color: AppTheme.platinum),\n          ) :\n          Container(\n            height: double.infinity,\n            width: double.infinity,\n            color: controller.isTransitioning || controller.isCheckInActive ?\n              TWColors.amber_500 :\n              (controller.isReserved ? TWColors.rose_600 : TWColors.green_600),\n            padding: EdgeInsets.all(_getContainerPadding(context, controller)),\n                child: AuthenticatedBackground(\n                  imageUrl: controller.globalSettings.value?.backgroundImageUrl,\n                  borderRadius: BorderRadius.circular(cornerRadius),\n                child: Padding(\n                  padding: _getInnerPadding(context),\n                  child: Stack(\n                    children: [\n                      Align(\n                        alignment: Alignment.topLeft,\n                        child: Obx(() => Text(\n                          formatTime(context, controller.time.value),\n                          style: FontService.instance.getTextStyle(\n                            fontFamily: controller.currentFontFamily.value,\n                            fontSize: isPhone ? 20 : 28,\n                            fontWeight: FontWeight.w500,\n                            color: TWColors.white,\n                          )\n                        ))\n                      ),\n                      Align(\n                        alignment: Alignment.topRight,\n                        child: Obx(() {\n                          final hideAdminActions = controller.globalSettings.value?.hideAdminActions ?? false;\n                          final showTemporarily = controller.showAdminActionsTemporarily.value;\n                          final shouldShowAdminActions = !hideAdminActions || showTemporarily;\n                          return Row(\n                            mainAxisSize: MainAxisSize.min,\n                            crossAxisAlignment: CrossAxisAlignment.center,\n                            children: [\n                              // Admin actions component (refresh and logout buttons)\n                              if (shouldShowAdminActions) AdminActions(\n                                controller: controller,\n                                isPhone: isPhone,\n                              ),\n                              if (shouldShowAdminActions) SizedBox(width: 15),\n                              GestureDetector(\n                                onLongPressStart: (details) {\n                                  if (hideAdminActions) {\n                                    controller.startLongPressTimer();\n                                  }\n                                },\n                                onLongPressEnd: (details) {\n                                  if (hideAdminActions) {\n                                    controller.cancelLongPressTimer();\n                                  }\n                                },\n                                child: Text(\n                                  controller.roomName,\n                                  style: FontService.instance.getTextStyle(\n                                    fontFamily: controller.currentFontFamily.value,\n                                    fontSize: isPhone ? 20 : 28,\n                                    fontWeight: FontWeight.w500,\n                                    color: TWColors.white,\n                                  )\n                                ),\n                              ),\n                            ],\n                          );\n                        }),\n                      ),\n\n                      SpaceCol(\n                        spaceBetween: _getContainerPadding(context, controller) * 1.75, // Proportional to container padding\n                        mainAxisSize: MainAxisSize.max,\n                        mainAxisAlignment: MainAxisAlignment.center,\n                        crossAxisAlignment: CrossAxisAlignment.start,\n                        children: [\n                          SpaceCol(\n                            spaceBetween: controller.meetingInfoTimes != null ? (isPhone ? 5 : 10) : 0,\n                            children: [\n                              Obx(() {\n                                final logoUrl = controller.globalSettings.value?.logoUrl;\n                                if (logoUrl != null) {\n                                  return Container(\n                                    margin: EdgeInsets.only(bottom: isPhone ? 20 : 10),\n                                    child: AuthenticatedImage(\n                                      imageUrl: logoUrl,\n                                      height: isPhone ? 24 : 36,\n                                      fit: BoxFit.contain,\n                                      placeholder: Container(\n                                        height: isPhone ? 24 : 36,\n                                        child: Center(\n                                          child: CircularProgressIndicator(\n                                            strokeWidth: 2,\n                                            valueColor: AlwaysStoppedAnimation<Color>(TWColors.gray_300),\n                                          ),\n                                        ),\n                                      ),\n                                      errorWidget: SizedBox.shrink(), // Hide logo if it fails to load\n                                    ),\n                                  );\n                                }\n                                return SizedBox.shrink();\n                              }),\n                              Obx(() => Text(\n                                controller.title,\n                                style: FontService.instance.getTextStyle(\n                                  fontFamily: controller.currentFontFamily.value,\n                                  fontSize: isPhone ? 30 : 50,\n                                  fontWeight: FontWeight.w700,\n                                  color: Colors.white,\n                                )\n                              )),\n                              SpaceRow(\n                                spaceBetween: isPhone ? 10 : 20,\n                                children: [\n                                  if (controller.meetingInfoTimes != null) FrostedPanel(\n                                    borderRadius: cornerRadius,\n                                    blurIntensity: 18,\n                                    padding: EdgeInsets.fromLTRB(\n                                      isPhone ? 10 : 15,\n                                      isPhone ? 5 : 8,\n                                      isPhone ? 10 : 15,\n                                      isPhone ? 5 : 8,\n                                    ),\n                                    child: Obx(() => Text(\n                                      'meeting_info_title'.trParams({\n                                        'start': formatTime(context, controller.meetingInfoTimes?['start'] ?? DateTime.now()),\n                                        'end': formatTime(context, controller.meetingInfoTimes?['end'] ?? DateTime.now()),\n                                      }),\n                                      style: FontService.instance.getTextStyle(\n                                        fontFamily: controller.currentFontFamily.value,\n                                        fontSize: isPhone ? 24 : 32,\n                                        fontWeight: FontWeight.w400,\n                                        color: TWColors.white,\n                                      )\n                                    )),\n                                  ),\n                                  Flexible(\n                                    child: Obx(() => Text(\n                                      controller.subtitle,\n                                      style: FontService.instance.getTextStyle(\n                                        fontFamily: controller.currentFontFamily.value,\n                                        fontSize: isPhone ? 28 : 36,\n                                        fontWeight: FontWeight.w400,\n                                        color: TWColors.gray_300,\n                                      ),\n                                      softWrap: true,\n                                      overflow: TextOverflow.visible,\n                                    )),\n                                  ),\n                                ]\n                              ),\n                              if (controller.meetingInfoTimes == null) SizedBox(height: isPhone ? 5 : 10),\n                              if (controller.bookingEnabled || controller.checkInEnabled) ActionPanel(\n                                controller: controller,\n                                isPhone: isPhone,\n                                cornerRadius: cornerRadius,\n                              ),\n                            ],\n                          ),\n                        ],\n                      ),\n\n                      // Fixed Action Bar at Bottom\n                      Align(\n                        alignment: Alignment.bottomCenter,\n                        child: FrostedPanel(\n                          borderRadius: cornerRadius,\n                          blurIntensity: 18,\n                          padding: EdgeInsets.all(isPhone ? 12 : 20),\n                          child: SpaceRow(\n                            spaceBetween: isPhone ? 10 : 20,\n                            mainAxisAlignment: MainAxisAlignment.spaceBetween,\n                            children: [\n                              // Upcoming Events Section\n                              Expanded(\n                                child: controller.upcomingEvents.isNotEmpty\n                                  ? SpaceCol(\n                                      spaceBetween: isPhone ? 8 : 12,\n                                      children: [\n                                        for (EventModel event in controller.upcomingEvents.take(1)) EventLine(event: event),\n                                      ],\n                                    )\n                                  : Text(\n                                      'no_upcoming_events'.tr,\n                                      style: TextStyle(\n                                        color: TWColors.white,\n                                        fontSize: isPhone ? 16 : 18,\n                                        fontWeight: FontWeight.w500,\n                                      ),\n                                    ),\n                              ),\n                              \n                              // Action Buttons Section\n                              if (controller.calendarEnabled)\n                                Material(\n                                  color: Colors.transparent,\n                                  child: InkWell(\n                                    hoverColor: Colors.transparent,\n                                    splashColor: Colors.transparent,\n                                    highlightColor: Colors.transparent,\n                                    borderRadius: BorderRadius.circular(8),\n                                    onTap: () {\n                                      showDialog(\n                                        context: context,\n                                        builder: (context) => CalendarModal(\n                                          events: controller.events,\n                                          selectedDate: DateTime.now(),\n                                        ),\n                                      );\n                                    },\n                                    child: Padding(\n                                      padding: EdgeInsets.symmetric(horizontal: 4, vertical: 4),\n                                      child: Row(\n                                        mainAxisSize: MainAxisSize.min,\n                                        children: [\n                                          Icon(\n                                            Icons.calendar_today_outlined,\n                                            size: 24,\n                                            color: Colors.white,\n                                          ),\n                                          SizedBox(width: 12),\n                                          Text(\n                                            'view_schedule'.tr,\n                                            style: TextStyle(\n                                              color: Colors.white,\n                                              fontSize: isPhone ? 16 : 18,\n                                              fontWeight: FontWeight.w500,\n                                            ),\n                                          ),\n                                        ],\n                                      ),\n                                    ),\n                                  ),\n                                ),\n                              ],\n                            ),\n                          ),\n                        ),\n                    ],\n                  ),\n                ),\n              )\n          ),\n      ),\n    );\n  }\n}"
  },
  {
    "path": "app/lib/pages/display_page.dart",
    "content": "import 'package:dropdown_button2/dropdown_button2.dart';\nimport 'package:flutter/material.dart';\nimport 'package:get/get.dart';\nimport 'package:spacepad/components/spinner.dart';\nimport 'package:spacepad/controllers/display_controller.dart';\nimport 'package:spacepad/models/display_model.dart';\nimport 'package:spacepad/services/auth_service.dart';\nimport 'package:spacepad/theme.dart';\nimport 'package:tailwind_components/tailwind_components.dart';\n\nclass DisplayPage extends StatelessWidget {\n  const DisplayPage({super.key});\n\n  @override\n  Widget build(BuildContext context) {\n    DisplayController controller = Get.put(DisplayController());\n\n    return Scaffold(\n      resizeToAvoidBottomInset: true,\n      body: SingleChildScrollView(\n        child: SafeArea(\n          child: Stack(\n            children: [\n              // Logout button at top right\n              Positioned(\n                top: 0,\n                right: 0,\n                child: Padding(\n                  padding: const EdgeInsets.all(16.0),\n                  child: Container(\n                    decoration: BoxDecoration(\n                      color: TWColors.red_500.withValues(alpha: 0.1),\n                      borderRadius: BorderRadius.circular(8),\n                      border: Border.all(color: TWColors.red_500.withValues(alpha: 0.3)),\n                    ),\n                    child: TextButton(\n                      onPressed: () {\n                        AuthService.instance.signOut();\n                      },\n                      child: Text(\n                        'logout'.tr,\n                        style: const TextStyle(\n                          color: TWColors.red_500,\n                          fontSize: 14,\n                          fontWeight: FontWeight.w500,\n                        ),\n                      ),\n                    ),\n                  ),\n                ),\n              ),\n              Container(\n              padding: const EdgeInsets.fromLTRB(20, 20, 20, 60),\n              alignment: Alignment.center,\n              height: MediaQuery.sizeOf(context).height,\n              child: Column(\n                  mainAxisAlignment: MainAxisAlignment.center,\n                  children: [\n                    Column(\n                      children: [\n                        Padding(\n                          padding: const EdgeInsets.only(right: 10),\n                          child: Text('choose_display'.tr, style: const TextStyle(\n                              fontSize: 20,\n                              fontWeight: FontWeight.w600,\n                              height: 1.2\n                          )),\n                        ),\n\n                        const SizedBox(height: 15),\n\n                        SizedBox(\n                          width: 350,\n                          child: Text('choose_room_display'.tr, textAlign: TextAlign.center),\n                        ),\n                      ],\n                    ),\n\n                    const SizedBox(height: 40),\n\n                    SizedBox(\n                      width: 400,\n                      child: Obx(() =>\n                        DropdownButtonFormField2<DisplayModel>(\n                          isExpanded: true,\n                          decoration: InputDecoration(\n                            contentPadding: const EdgeInsets.symmetric(vertical: 16),\n                            border: OutlineInputBorder(\n                              borderRadius: BorderRadius.circular(10),\n                              borderSide: const BorderSide(color: AppTheme.oxford),\n                            ),\n                            enabledBorder: OutlineInputBorder(\n                              borderRadius: BorderRadius.circular(10),\n                              borderSide: const BorderSide(color: AppTheme.oxford),\n                            ),\n                            focusedBorder: OutlineInputBorder(\n                              borderRadius: BorderRadius.circular(10),\n                              borderSide: const BorderSide(color: AppTheme.oxford),\n                            ),\n                          ),\n                          hint: Text(\n                            'select_display'.tr,\n                            style: const TextStyle(fontSize: 14),\n                          ),\n                          items: controller.displays\n                              .map((item) => DropdownMenuItem<DisplayModel>(\n                                  value: item,\n                                  child: Text(\n                                    item.name,\n                                    style: const TextStyle(\n                                      fontSize: 14,\n                                    ),\n                                  ),\n                                ))\n                              .toList(),\n                          validator: (value) {\n                            if (value == null) {\n                              return 'please_select_display'.tr;\n                            }\n                            return null;\n                          },\n                          onChanged: (value) {\n                            controller.onSelect(value);\n                          },\n                          buttonStyleData: ButtonStyleData(\n                            padding: EdgeInsets.only(right: 8),\n                            decoration: BoxDecoration(\n                              borderRadius: BorderRadius.circular(10),\n                            ),\n                          ),\n                          iconStyleData: const IconStyleData(\n                            icon: Icon(\n                              Icons.arrow_drop_down,\n                              color: Colors.black45,\n                            ),\n                            iconSize: 24,\n                          ),\n                          dropdownStyleData: DropdownStyleData(\n                            decoration: BoxDecoration(\n                              borderRadius: BorderRadius.circular(10),\n                            ),\n                          ),\n                          menuItemStyleData: const MenuItemStyleData(\n                            padding: EdgeInsets.symmetric(horizontal: 16),\n                          ),\n                        ),\n                      ),\n                    ),\n\n                    const SizedBox(height: 60),\n\n                    SizedBox(\n                      width: 400,\n                      child: Obx(() => ElevatedButton(\n                        onPressed: controller.submitActive ? controller.submit : null,\n                        child: controller.loading.value ? const Spinner(size: 20) : Text('continue'.tr),\n                      )),\n                    ),\n                  ]\n              ),\n              ),\n            ],\n          ),\n        )\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "app/lib/pages/login_page.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:get/get.dart';\nimport 'package:spacepad/components/spinner.dart';\nimport 'package:spacepad/controllers/login_controller.dart';\nimport 'package:pinput/pinput.dart';\nimport 'package:spacepad/theme.dart';\nimport 'package:tailwind_components/tailwind_components.dart';\n\nclass LoginPage extends StatelessWidget {\n  const LoginPage({super.key});\n\n  @override\n  Widget build(BuildContext context) {\n    LoginController controller = Get.put(LoginController());\n\n    final defaultPinTheme = PinTheme(\n      width: 60,\n      height: 60,\n      textStyle: const TextStyle(\n        fontSize: 22,\n        color: Color.fromRGBO(30, 60, 87, 1),\n      ),\n      decoration: BoxDecoration(\n        borderRadius: BorderRadius.circular(10),\n        border: Border.all(color: AppTheme.oxford),\n      ),\n    );\n\n    return Scaffold(\n      resizeToAvoidBottomInset: true,\n      body: SingleChildScrollView(\n        child: SafeArea(\n          child: Container(\n              padding: const EdgeInsets.fromLTRB(20, 20, 20, 60),\n              alignment: Alignment.center,\n              height: MediaQuery.sizeOf(context).height,\n              child: Column(\n                  mainAxisAlignment: MainAxisAlignment.center,\n                  children: [\n                    Column(\n                      children: [\n                        ClipRRect(\n                          borderRadius: BorderRadius.circular(8),\n                          child: Image.asset('assets/logo.png', width: 80),\n                        ),\n\n                        const SizedBox(height: 40),\n\n                        Padding(\n                          padding: const EdgeInsets.only(right: 10),\n                          child: Text('introduction_title'.tr, style: const TextStyle(\n                              fontSize: 22,\n                              fontWeight: FontWeight.w600,\n                              height: 1.2\n                          )),\n                        ),\n\n                        const SizedBox(height: 10),\n\n                        SizedBox(\n                          width: 350,\n                          child: Text('introduction_text'.tr, textAlign: TextAlign.center),\n                        ),\n                      ],\n                    ),\n\n                    const SizedBox(height: 30),\n\n                    // Self-hosting button group\n                    SizedBox(\n                      width: 400,\n                      child: Obx(() => Container(\n                        decoration: BoxDecoration(\n                          borderRadius: BorderRadius.circular(10),\n                          border: Border.all(color: AppTheme.oxford),\n                        ),\n                        child: Row(\n                          children: [\n                            Expanded(\n                              child: InkWell(\n                                onTap: () => controller.toggleSelfHosted(false),\n                                child: Container(\n                                  decoration: BoxDecoration(\n                                    color: !controller.isSelfHosted.value ? AppTheme.oxford : Colors.white,\n                                    borderRadius: const BorderRadius.only(\n                                      topLeft: Radius.circular(9),\n                                      bottomLeft: Radius.circular(9),\n                                    ),\n                                  ),\n                                  padding: const EdgeInsets.symmetric(vertical: 12),\n                                  child: Center(\n                                    child: Text(\n                                      'cloud_hosted'.tr,\n                                      style: TextStyle(\n                                        fontSize: 14,\n                                        fontWeight: FontWeight.w500,\n                                        color: !controller.isSelfHosted.value ? Colors.white : AppTheme.oxford,\n                                      ),\n                                    ),\n                                  ),\n                                ),\n                              ),\n                            ),\n                            Container(\n                              width: 1,\n                              color: AppTheme.oxford,\n                            ),\n                            Expanded(\n                              child: InkWell(\n                                onTap: () => controller.toggleSelfHosted(true),\n                                child: Container(\n                                  decoration: BoxDecoration(\n                                    color: controller.isSelfHosted.value ? AppTheme.oxford : Colors.white,\n                                    borderRadius: const BorderRadius.only(\n                                      topRight: Radius.circular(9),\n                                      bottomRight: Radius.circular(9),\n                                    ),\n                                  ),\n                                  padding: const EdgeInsets.symmetric(vertical: 12),\n                                  child: Center(\n                                    child: Text(\n                                      'self_hosted'.tr,\n                                      style: TextStyle(\n                                        fontSize: 14,\n                                        fontWeight: FontWeight.w500,\n                                        color: controller.isSelfHosted.value ? Colors.white : AppTheme.oxford,\n                                      ),\n                                    ),\n                                  ),\n                                ),\n                              ),\n                            ),\n                          ],\n                        ),\n                      )),\n                    ),\n\n                    const SizedBox(height: 15),\n\n                    // Self-hosted URL input\n                    Obx(() => controller.isSelfHosted.value\n                        ? SizedBox(\n                            width: 400,\n                            child: TextField(\n                              decoration: InputDecoration(\n                                labelText: 'self_hosted_url'.tr,\n                                hintText: 'url_hint'.tr,\n                                border: OutlineInputBorder(\n                                  borderRadius: BorderRadius.circular(10),\n                                  borderSide: const BorderSide(color: AppTheme.oxford),\n                                ),\n                                enabledBorder: OutlineInputBorder(\n                                  borderRadius: BorderRadius.circular(10),\n                                  borderSide: const BorderSide(color: AppTheme.oxford),\n                                ),\n                                focusedBorder: OutlineInputBorder(\n                                  borderRadius: BorderRadius.circular(10),\n                                  borderSide: const BorderSide(color: AppTheme.oxford),\n                                ),\n                              ),\n                              onChanged: controller.urlChanged,\n                            ),\n                          )\n                        : const SizedBox.shrink()),\n\n                    const SizedBox(height: 20),\n\n                    SizedBox(\n                      width: 400,\n                      child: Text('enter_connect_code'.tr, textAlign: TextAlign.start),\n                    ),\n\n                    const SizedBox(height: 15),\n\n                    SizedBox(\n                      width: 400,\n                      child: Directionality(\n                        textDirection: TextDirection.ltr,\n                        child: Pinput(\n                          length: 6,\n                          onChanged: controller.codeChanged,\n                          defaultPinTheme: defaultPinTheme,\n                          mainAxisAlignment: MainAxisAlignment.center,\n                          separatorBuilder: (index) => const SizedBox(width: 8),\n                          hapticFeedbackType: HapticFeedbackType.lightImpact,\n                        ),\n                      ),\n                    ),\n\n                    const SizedBox(height: 30),\n\n                    SizedBox(\n                      width: 400,\n                      child: Obx(() => ElevatedButton(\n                        onPressed: controller.submitActive.value ? controller.submit : null,\n                        child: controller.loading.value ? const Spinner(size: 20) : Text('connect_to_account'.tr),\n                      )),\n                    ),\n\n                    const SizedBox(height: 40),\n\n                    // Connect code explanation\n                    SizedBox(\n                      width: 350,\n                      child: Text(\n                        'connect_code_explanation'.tr,\n                        textAlign: TextAlign.center,\n                        style: const TextStyle(\n                          fontSize: 12,\n                          color: Colors.grey,\n                        ),\n                      ),\n                    ),\n                  ]\n              ),\n          ),\n        )\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "app/lib/pages/splash_page.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:spacepad/components/spinner.dart';\nimport 'package:spacepad/services/auth_service.dart';\nimport 'package:spacepad/theme.dart';\n\nclass SplashPage extends StatefulWidget {\n  const SplashPage({super.key});\n\n  @override\n  State<SplashPage> createState() => _SplashPageState();\n}\n\nclass _SplashPageState extends State<SplashPage> {\n  @override\n  void initState() {\n    AuthService.instance.verify();\n\n    super.initState();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return const Scaffold(\n      body: Center(\n        child: Spinner(size: 40, thickness: 4, color: AppTheme.platinum),\n      ),\n    );\n  }\n}"
  },
  {
    "path": "app/lib/services/api_service.dart",
    "content": "import 'dart:convert';\nimport 'package:flutter/foundation.dart';\nimport 'package:flutter_dotenv/flutter_dotenv.dart';\nimport 'package:http/http.dart';\nimport 'package:get/get.dart' as GetX;\n\nimport 'package:http/http.dart' as http;\nimport 'package:spacepad/exceptions/api_exception.dart';\nimport 'package:spacepad/services/auth_service.dart';\n\nimport 'package:shared_preferences/shared_preferences.dart';\n\nclass ApiService {\n  ApiService._();\n\n  static Future<bool> setBaseUrl(String apiUrl) async {\n    var sharedPrefs = await SharedPreferences.getInstance();\n    return sharedPrefs.setString('api_url', apiUrl);\n  }\n\n  static Future<bool> resetToServerBaseUrl() async {\n    var apiUrl = dotenv.env['API_URL'] ?? 'https://app.spacepad.io';\n    return await setBaseUrl(apiUrl);\n  }\n\n  static Future<String> getBaseUrl() async {\n    var sharedPrefs = await SharedPreferences.getInstance();\n    var apiUrl = sharedPrefs.getString('api_url');\n    return '$apiUrl/api/';\n  }\n\n  static Future get(String endpoint) async {\n    var baseUrl = await getBaseUrl();\n    if (kDebugMode) print('GET: $baseUrl$endpoint');\n\n    try {\n      Response response = await http.get(Uri.parse('$baseUrl$endpoint'), headers: _getHeaders());\n\n      if (response.statusCode == 200) {\n        return jsonDecode(response.body);\n      }\n\n      throw ApiException.fromResponse(response);\n    } on ApiException catch (e) {\n      if (kDebugMode) print('${e.code}: ${e.message}');\n\n      if (e.code == 401) {\n        AuthService.instance.signOut();\n        return;\n      }\n\n      rethrow;\n    }\n  }\n\n  static Future post(String endpoint, Map body) async {\n    var baseUrl = await getBaseUrl();\n    if (kDebugMode) print('POST: $baseUrl$endpoint');\n\n    try {\n      Response response = await http.post(\n          Uri.parse('$baseUrl$endpoint'),\n          headers: _getHeaders(),\n          body: jsonEncode(body)\n      );\n\n      if ([200, 201, 202, 204].contains(response.statusCode)) {\n        return jsonDecode(response.body);\n      }\n\n      throw ApiException.fromResponse(response);\n    } on ApiException catch (e) {\n      if (kDebugMode) print('${e.code}: ${e.message}');\n      rethrow;\n    }\n  }\n\n  static Future put(String endpoint, Map body) async {\n    var baseUrl = await getBaseUrl();\n    if (kDebugMode) print('PUT: $baseUrl$endpoint');\n\n    try {\n      Response response = await http.put(\n          Uri.parse('$baseUrl$endpoint'),\n          headers: _getHeaders(),\n          body: jsonEncode(body)\n      );\n\n      if ([200, 201, 202, 204].contains(response.statusCode)) {\n        return jsonDecode(response.body);\n      }\n\n      throw ApiException.fromResponse(response);\n    } on ApiException catch (e) {\n      if (kDebugMode) print('${e.code}: ${e.message}');\n      rethrow;\n    }\n  }\n\n  static Future delete(String endpoint, [Map? body]) async {\n    var baseUrl = await getBaseUrl();\n    if (kDebugMode) print('DELETE: $baseUrl$endpoint');\n\n    try {\n      Response response = await http.delete(\n          Uri.parse('$baseUrl$endpoint'),\n          headers: _getHeaders(),\n          body: jsonEncode(body)\n      );\n\n      if (response.statusCode == 204) {\n        return;\n      }\n\n      if ([200, 201, 202].contains(response.statusCode)) {\n        return jsonDecode(response.body);\n      }\n\n      throw ApiException.fromResponse(response);\n    } on ApiException catch (e) {\n      if (kDebugMode) print('${e.code}: ${e.message}');\n      rethrow;\n    }\n  }\n\n  static Map<String, String>? _getHeaders() {\n    Map<String, String> headers = {\n      'Content-Type' : 'application/json',\n      'Accept' : 'application/json',\n      'Accept-Language' : GetX.Get.locale?.languageCode ?? 'en'\n    };\n\n    if (AuthService.instance.getAuthToken() != null) {\n      headers['Authorization'] = 'Bearer ${AuthService.instance.getAuthToken()}';\n    }\n\n    return headers;\n  }\n}"
  },
  {
    "path": "app/lib/services/auth_service.dart",
    "content": "import 'package:flutter/foundation.dart';\nimport 'package:get/get.dart';\nimport 'package:shared_preferences/shared_preferences.dart';\nimport 'package:spacepad/models/device_model.dart';\nimport 'package:spacepad/pages/dashboard_page.dart';\nimport 'package:spacepad/pages/display_page.dart';\nimport 'package:spacepad/pages/login_page.dart';\nimport 'package:spacepad/services/api_service.dart';\n\nclass AuthService {\n  AuthService._();\n  static final AuthService instance = AuthService._();\n\n  late SharedPreferences _sharedPrefs;\n  Rxn<DeviceModel> currentDevice = Rxn<DeviceModel>();\n\n  Future<void> initialise() async {\n    _sharedPrefs = await SharedPreferences.getInstance();\n  }\n\n  Future<void> setBaseUrl(String url) async {\n    await ApiService.setBaseUrl(url);\n  }\n\n  Future<void> login(String code, String uid, String name) async {\n    Map result = await ApiService.post('auth/login', {\n      \"code\" : code,\n      \"uid\" : uid,\n      \"name\" : name,\n    });\n\n    Map data = result['data'];\n\n    await setAuthToken(data['token']);\n    currentDevice.value = DeviceModel.fromJson(data['device']);\n\n    await Get.offAll(const DisplayPage());\n  }\n\n  Future<void> verify() async {\n    try {\n      Map result = await ApiService.get('devices/me');\n\n      Map data = result['data'];\n\n      currentDevice.value = DeviceModel.fromJson(data);\n\n      final displayId = getCurrentDisplayId();\n      await Get.offAll(() => displayId != null ?\n        const DashboardPage() :\n        const DisplayPage()\n      );\n    } catch(e) {\n      if (kDebugMode) print(e);\n    }\n\n    signOut();\n  }\n\n  Future<void> changeDisplay(Map body) async {\n    Map result = await ApiService.put('devices/display', body);\n\n    Map data = result['data'];\n\n    currentDevice.value = DeviceModel.fromJson(data);\n  }\n\n  Future<void> signOut() async {\n    currentDevice.value = null;\n\n    await deleteAuthToken();\n    await removeCurrentDisplayId();\n\n    await Get.offAll(() => const LoginPage());\n  }\n\n  String? getAuthToken() {\n    return _sharedPrefs.getString('token');\n  }\n\n  Future<bool> setAuthToken(String token) {\n    return _sharedPrefs.setString('token', token);\n  }\n\n  Future<bool> deleteAuthToken() {\n    return _sharedPrefs.remove('token');\n  }\n\n  String? getCurrentDisplayId() {\n    return _sharedPrefs.getString('display_id');\n  }\n\n  Future<bool> setCurrentDisplayId(String displayId) {\n    return _sharedPrefs.setString('display_id', displayId);\n  }\n\n  Future<bool> removeCurrentDisplayId() {\n    return _sharedPrefs.remove('display_id');\n  }\n}"
  },
  {
    "path": "app/lib/services/device_service.dart",
    "content": "import 'package:spacepad/services/api_service.dart';\n\nclass DeviceService {\n  DeviceService._();\n  static final DeviceService instance = DeviceService._();\n\n  Future<void> changeDisplay(String displayId) async {\n    await ApiService.put('devices/display', {\n      \"display_id\" : displayId,\n    });\n  }\n}"
  },
  {
    "path": "app/lib/services/display_service.dart",
    "content": "import 'package:spacepad/models/display_model.dart';\nimport 'package:spacepad/models/display_data_model.dart';\nimport 'package:spacepad/services/api_service.dart';\n\nclass DisplayService {\n  DisplayService._();\n  static final DisplayService instance = DisplayService._();\n\n  Future<List<DisplayModel>> getDisplays() async {\n    Map body = await ApiService.get('displays');\n\n    List data = body['data'] as List;\n\n    return data.map((e) => DisplayModel.fromJson(e)).toList();\n  }\n\n  Future<void> book(String displayId, int duration, {String? summary}) async {\n    await ApiService.post('displays/$displayId/book', {\n      'duration': duration,\n      if (summary != null) 'summary': summary,\n    });\n  }\n\n  Future<void> bookCustom(String displayId, String title, DateTime startTime, DateTime endTime) async {\n    // Convert local DateTime to UTC before sending to backend\n    await ApiService.post('displays/$displayId/book', {\n      'start': startTime.toUtc().toIso8601String(),\n      'end': endTime.toUtc().toIso8601String(),\n      'summary': title,\n    });\n  }\n\n  Future<DisplayDataModel> getDisplayData(String displayId) async {\n    try {\n      return await _getDisplayDataNew(displayId);\n    } catch (e) {\n      if (_isRouteNotFoundError(e)) {\n        return _getDisplayDataOld(displayId);\n      }\n\n      rethrow;\n    }\n  }\n\n  Future<DisplayDataModel> _getDisplayDataNew(String displayId) async {\n    Map body = await ApiService.get('displays/$displayId/data');\n    Map<String, dynamic> data = Map<String, dynamic>.from(body['data']);\n    return DisplayDataModel.fromJson(data);\n  }\n\n  Future<DisplayDataModel> _getDisplayDataOld(String displayId) async {\n    Map body = await ApiService.get('events');\n    List data = body['data'] as List;\n    return DisplayDataModel.fromEventsJson(data);\n  }\n\n  bool _isRouteNotFoundError(dynamic e) {\n    // Check if the error is a 404 or similar\n    return e.toString().contains('404');\n  }\n\n  Future<void> cancelEvent(String displayId, String eventId) async {\n    await ApiService.delete('displays/$displayId/events/$eventId');\n  }\n\n  Future<void> checkInToEvent(String displayId, String eventId) async {\n    await ApiService.post('displays/$displayId/events/$eventId/check-in', {});\n  }\n}"
  },
  {
    "path": "app/lib/services/font_service.dart",
    "content": "import 'package:google_fonts/google_fonts.dart';\nimport 'package:flutter/material.dart';\n\nclass FontService {\n  FontService._();\n  static final FontService instance = FontService._();\n\n  // Available fonts\n  static const List<String> availableFonts = [\n    'Inter',\n    'Roboto',\n    'Open Sans',\n    'Lato',\n    'Poppins',\n    'Montserrat',\n  ];\n\n  // Font family mapping\n  static const Map<String, String> fontFamilyMapping = {\n    'Inter': 'Inter',\n    'Roboto': 'Roboto',\n    'Open Sans': 'OpenSans',\n    'Lato': 'Lato',\n    'Poppins': 'Poppins',\n    'Montserrat': 'Montserrat',\n  };\n\n  /// Get TextStyle for a specific font family\n  TextStyle getTextStyle({\n    required String fontFamily,\n    double? fontSize,\n    FontWeight? fontWeight,\n    Color? color,\n    double? letterSpacing,\n    double? height,\n  }) {\n    final googleFontFamily = fontFamilyMapping[fontFamily] ?? 'Inter';\n    \n    switch (googleFontFamily) {\n      case 'Inter':\n        return GoogleFonts.inter(\n          fontSize: fontSize,\n          fontWeight: fontWeight,\n          color: color,\n          letterSpacing: letterSpacing,\n          height: height,\n        );\n      case 'Roboto':\n        return GoogleFonts.roboto(\n          fontSize: fontSize,\n          fontWeight: fontWeight,\n          color: color,\n          letterSpacing: letterSpacing,\n          height: height,\n        );\n      case 'OpenSans':\n        return GoogleFonts.openSans(\n          fontSize: fontSize,\n          fontWeight: fontWeight,\n          color: color,\n          letterSpacing: letterSpacing,\n          height: height,\n        );\n      case 'Lato':\n        return GoogleFonts.lato(\n          fontSize: fontSize,\n          fontWeight: fontWeight,\n          color: color,\n          letterSpacing: letterSpacing,\n          height: height,\n        );\n      case 'Poppins':\n        return GoogleFonts.poppins(\n          fontSize: fontSize,\n          fontWeight: fontWeight,\n          color: color,\n          letterSpacing: letterSpacing,\n          height: height,\n        );\n      case 'Montserrat':\n        return GoogleFonts.montserrat(\n          fontSize: fontSize,\n          fontWeight: fontWeight,\n          color: color,\n          letterSpacing: letterSpacing,\n          height: height,\n        );\n      default:\n        return GoogleFonts.inter(\n          fontSize: fontSize,\n          fontWeight: fontWeight,\n          color: color,\n          letterSpacing: letterSpacing,\n          height: height,\n        );\n    }\n  }\n\n  /// Preload fonts to avoid loading delays\n  Future<void> preloadFonts() async {\n    for (final fontFamily in availableFonts) {\n      final googleFontFamily = fontFamilyMapping[fontFamily] ?? 'Inter';\n      \n      try {\n        switch (googleFontFamily) {\n          case 'Inter':\n            await GoogleFonts.pendingFonts([GoogleFonts.inter()]);\n            break;\n          case 'Roboto':\n            await GoogleFonts.pendingFonts([GoogleFonts.roboto()]);\n            break;\n          case 'OpenSans':\n            await GoogleFonts.pendingFonts([GoogleFonts.openSans()]);\n            break;\n          case 'Lato':\n            await GoogleFonts.pendingFonts([GoogleFonts.lato()]);\n            break;\n          case 'Poppins':\n            await GoogleFonts.pendingFonts([GoogleFonts.poppins()]);\n            break;\n          case 'Montserrat':\n            await GoogleFonts.pendingFonts([GoogleFonts.montserrat()]);\n            break;\n        }\n      } catch (e) {\n        // Font loading failed, continue with others\n        print('Failed to load font $fontFamily: $e');\n      }\n    }\n  }\n\n  /// Force reload a specific font\n  Future<void> reloadFont(String fontFamily) async {\n    final googleFontFamily = fontFamilyMapping[fontFamily] ?? 'Inter';\n    \n    try {\n      print('FontService: Reloading font: $fontFamily (mapped to: $googleFontFamily)');\n      \n      switch (googleFontFamily) {\n        case 'Inter':\n          await GoogleFonts.pendingFonts([GoogleFonts.inter()]);\n          break;\n        case 'Roboto':\n          await GoogleFonts.pendingFonts([GoogleFonts.roboto()]);\n          break;\n        case 'OpenSans':\n          await GoogleFonts.pendingFonts([GoogleFonts.openSans()]);\n          break;\n        case 'Lato':\n          await GoogleFonts.pendingFonts([GoogleFonts.lato()]);\n          break;\n        case 'Poppins':\n          await GoogleFonts.pendingFonts([GoogleFonts.poppins()]);\n          break;\n        case 'Montserrat':\n          await GoogleFonts.pendingFonts([GoogleFonts.montserrat()]);\n          break;\n      }\n    } catch (e) {\n      print('Failed to reload font $fontFamily: $e');\n    }\n  }\n\n  /// Get font display name for UI\n  String getFontDisplayName(String fontFamily) {\n    return fontFamily;\n  }\n\n  /// Validate if font is available\n  bool isFontAvailable(String fontFamily) {\n    return availableFonts.contains(fontFamily);\n  }\n}\n"
  },
  {
    "path": "app/lib/services/server_service.dart",
    "content": "import 'package:http/http.dart' as http;\n\nclass ServerService {\n  static final ServerService _instance = ServerService._internal();\n  factory ServerService() => _instance;\n  ServerService._internal();\n\n  /// Checks if a server is reachable by making a GET request to its health endpoint\n  /// Returns true if the server responds with a 200 status code within 5 seconds\n  Future<bool> isServerReachable(String url) async {\n    try {\n      final response = await http.get(\n        Uri.parse('$url/health'),\n        headers: {'Accept': 'application/json'},\n      ).timeout(const Duration(seconds: 5));\n      \n      return response.statusCode == 200;\n    } catch (e) {\n      return false;\n    }\n  }\n} "
  },
  {
    "path": "app/lib/theme.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter/services.dart';\nimport 'package:google_fonts/google_fonts.dart';\n\nclass AppTheme {\n  AppTheme._();\n\n  static const Color black = Color(0xFF000000);\n  static const Color oxford = Color(0xFF14213D);\n  static const Color orange = Color(0xFFFCA311);\n  static const Color platinum = Color(0xFFE5E5E5);\n  static const Color white = Color(0xFFFFFFFF);\n\n  static ThemeData get data {\n    return ThemeData(\n        textTheme: GoogleFonts.interTextTheme(),\n        scaffoldBackgroundColor: Colors.white,\n        colorScheme: ColorScheme.fromSeed(seedColor: oxford),\n        inputDecorationTheme: InputDecorationTheme(\n          hintStyle: const TextStyle(fontSize: 14.5),\n          filled: true,\n          fillColor: Colors.white,\n          isDense: true,\n          isCollapsed: true,\n          contentPadding: const EdgeInsets.only(\n              top: 14,\n              bottom: 10,\n              left: 20,\n              right: 20\n          ),\n          outlineBorder: const BorderSide(color: oxford, width: 1),\n          border: OutlineInputBorder(\n            borderSide: const BorderSide(color: oxford, width: 1),\n            borderRadius: BorderRadius.circular(99),\n          ),\n          errorBorder: OutlineInputBorder(\n            borderSide: const BorderSide(color: oxford, width: 1),\n            borderRadius: BorderRadius.circular(99),\n          ),\n          focusedBorder: OutlineInputBorder(\n            borderSide: const BorderSide(color: oxford, width: 1),\n            borderRadius: BorderRadius.circular(99),\n          ),\n          focusedErrorBorder: OutlineInputBorder(\n            borderSide: const BorderSide(color: oxford, width: 1),\n            borderRadius: BorderRadius.circular(99),\n          ),\n          disabledBorder: OutlineInputBorder(\n            borderSide: const BorderSide(color: oxford, width: 1),\n            borderRadius: BorderRadius.circular(99),\n          ),\n          enabledBorder: OutlineInputBorder(\n            borderSide: const BorderSide(color: oxford, width: 1),\n            borderRadius: BorderRadius.circular(99),\n          ),\n        ),\n        dividerColor: Colors.grey.shade300,\n        dividerTheme: DividerThemeData(\n          color: Colors.grey.shade300,\n          thickness: .5,\n        ),\n        elevatedButtonTheme: ElevatedButtonThemeData(\n          style: ElevatedButton.styleFrom(\n            foregroundColor: Colors.black,\n            backgroundColor: orange,\n            textStyle: const TextStyle(\n              fontSize: 15,\n              fontWeight: FontWeight.w500,\n            ),\n            elevation: 0,\n            shadowColor: Colors.transparent,\n            padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15)\n          ),\n        ),\n        outlinedButtonTheme: OutlinedButtonThemeData(\n          style: OutlinedButton.styleFrom(\n            foregroundColor: Colors.black,\n            textStyle: const TextStyle(\n              fontSize: 15,\n              fontWeight: FontWeight.w500,\n            ),\n            elevation: 0,\n            shadowColor: Colors.transparent,\n            padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15),\n            side: const BorderSide(color: oxford, width: 1.5), // Border color and width\n          ),\n        ),\n        appBarTheme: const AppBarTheme(\n          systemOverlayStyle: SystemUiOverlayStyle.light,\n          foregroundColor: Colors.white,\n          titleTextStyle: TextStyle(\n            fontWeight: FontWeight.w600,\n            fontSize: 24,\n            height: 0.5\n          )\n        ),\n        textButtonTheme: TextButtonThemeData(\n            style: TextButton.styleFrom(\n              foregroundColor: Colors.black,\n              textStyle: const TextStyle(\n                fontSize: 14,\n                fontWeight: FontWeight.w400,\n              ),\n            )\n        ),\n        floatingActionButtonTheme: FloatingActionButtonThemeData(\n          extendedPadding: const EdgeInsets.symmetric(horizontal: 25, vertical: 0),\n          shape: RoundedRectangleBorder(\n              borderRadius: BorderRadius.circular(100)\n          ),\n        ),\n    );\n  }\n}"
  },
  {
    "path": "app/lib/translations/translations.dart",
    "content": "import 'package:get/get.dart';\n\nclass AppTranslations extends Translations {\n  @override\n  Map<String, Map<String, String>> get keys => {\n    'en_US': {\n      'introduction_title': 'Welcome to Spacepad!',\n      'introduction_text': 'Get started by connecting your device with your room.',\n      'connect_to_account': 'Connect to your account',\n      'enter_connect_code': 'Please enter the connect code',\n      'choose_display': 'Choose display',\n      'choose_room_display': 'What room would you like to display on this device?',\n      'select_display': 'Select a display',\n      'please_select_display': 'Please select a display.',\n      'continue': 'Continue',\n      'next': 'Next',\n      'next_event_title': '@start - @end, @summary',\n      'meeting_room': 'Meeting room',\n      'meeting_info_title': '@start - @end',\n      'available': 'All yours!',\n      'to_be_reserved': 'Make it quick!',\n      'till_end_of_day': 'Till end of day',\n      'x_minutes_left': '@minutes min left',\n      'x_hours_x_minutes_left': '@hours h @minutes min left',\n      'for_x_minutes': 'for @minutes min',\n      'for_x_hours_x_minutes': 'for @hours h @minutes min',\n      'could_not_load_events': 'Could not load events',\n      'could_not_load_displays': 'Could not load displays',\n      'could_not_load_data': 'Could not load data',\n      'book_now': 'Book now',\n      'custom': 'Custom',\n      'cancel': 'Cancel',\n      'close': 'Close',\n      'room_booked': 'Room booked!',\n      'could_not_book_room': 'Could not book room',\n      'logout': 'Log out',\n      'switch_room': 'Switch room',\n      'cancel_event': 'Cancel',\n      'event_cancelled': 'Event cancelled',\n      'could_not_cancel_event': 'Could not cancel event',\n      'url_hint': 'https://your-instance.com',\n      'check_connection': 'An unexpected error arisen. Please check if you have an internet connection',\n      'code_incorrect': 'Your code is incorrect. Please refresh your dashboard to acquire the most recent code',\n      'self_hosted': 'Self-hosted',\n      'cloud_hosted': 'Cloud-hosted',\n      'self_hosted_url': 'Self-hosted instance URL',\n      'connect_code_explanation': 'To get a connect code, you need to:\\n1. Sign up at app.spacepad.io/register, or\\n2. Set up your own cloud-hosted environment, read: spacepad.io\\nThen go to your dashboard to get the connect code.',\n      'invalid_url': 'Please enter a valid URL (e.g., https://your-instance.com)',\n      'server_unreachable': 'Could not connect to the server. Please check if the URL is correct and the server is running.',\n      'login_failed': 'Your connect code is incorrect. Please try again.',\n      'reserved': 'Reserved',\n      'reserve': 'Reserve',\n      'check_in_now': 'Check-in for meeting',\n      'check_in': 'Check-in',\n      'x_starts_in_x_minutes': '@meeting starts in @minutes min',\n      'check_in_within_x_minutes': 'Check in within @minutes min',\n      'checked_in': 'Successfully checked in!',\n      'could_not_check_in': 'Could not check in',\n      'view_schedule': 'View schedule',\n      'todays_schedule': 'Today\\'s schedule',\n      'no_upcoming_events': 'No upcoming events',\n      'no_events_today': 'No events scheduled for today',\n      'meeting': 'Meeting',\n      'admin_actions_enabled': 'Admin actions enabled for @seconds seconds',\n      'refresh_data': 'Refresh data',\n      'display_data_refreshed': 'Display data successfully refreshed. The display should now use the most recent settings and an updated view of the events',\n      'refresh_cooldown': 'Please wait @seconds seconds before refreshing again',\n      'custom_booking': 'Custom booking',\n      'meeting_title': 'Meeting title',\n      'start_time': 'Start time',\n      'end_time': 'End time',\n      'now': 'Now',\n      'max': 'Max',\n      'book': 'Book'\n    },\n    'nl_NL': {\n      'introduction_title': 'Welkom bij Spacepad!',\n      'introduction_text': 'Begin met het verbinden van je scherm met een ruimte.',\n      'connect_to_account': 'Verbind met je account',\n      'enter_connect_code': 'Voer de connect code in',\n      'choose_display': 'Kies scherm',\n      'choose_room_display': 'Welke ruimte wil je weergeven op dit apparaat?',\n      'select_display': 'Selecteer een scherm',\n      'please_select_display': 'Selecteer a.u.b. een scherm.',\n      'continue': 'Ga door',\n      'next': 'Volgende',\n      'next_event_title': '@start - @end uur, @summary',\n      'meeting_room': 'Meetingruimte',\n      'meeting_info_title': '@start - @end uur',\n      'available': 'Helemaal van jou!',\n      'to_be_reserved': 'Houd het kort!',\n      'till_end_of_day': 'Tot het einde van de dag',\n      'x_minutes_left': 'nog @minutes min',\n      'x_hours_x_minutes_left': 'nog @hours uur @minutes min',\n      'for_x_minutes': 'voor @minutes min',\n      'for_x_hours_x_minutes': 'voor @hours uur @minutes min',\n      'could_not_load_events': 'Het laden van afspraken is niet gelukt',\n      'could_not_load_displays': 'Het laden van schermen is niet gelukt',\n      'could_not_load_data': 'Het laden van gegevens is niet gelukt',\n      'book_now': 'Nu boeken',\n      'custom': 'Aangepast',\n      'cancel': 'Annuleren',\n      'close': 'Sluiten',\n      'room_booked': 'Ruimte geboekt!',\n      'could_not_book_room': 'Kon ruimte niet boeken',\n      'logout': 'Uitloggen',\n      'switch_room': 'Wissel ruimte',\n      'cancel_event': 'Annuleren',\n      'event_cancelled': 'Afspraak geannuleerd',\n      'could_not_cancel_event': 'Kon afspraak niet annuleren',\n      'url_hint': 'https://jouw-instance.nl',\n      'check_connection': 'Er is een onbekende fout opgetreden. Check of je een actieve internetverbinding hebt.',\n      'code_incorrect': 'Je code is incorrect. Refresh je dashboard om de nieuwste code op te halen',\n      'self_hosted': 'Eigen hosting',\n      'cloud_hosted': 'Cloud hosting',\n      'self_hosted_url': 'URL van eigen instance',\n      'connect_code_explanation': 'Om een connect code te krijgen moet je:\\n1. Je registreren op app.spacepad.io/register, of\\n2. Je eigen cloud-hosted omgeving opzetten, zie: spacepad.io\\nGa daarna naar je dashboard om de connect code te krijgen.',\n      'invalid_url': 'Voer een geldige URL in (bijvoorbeeld https://jouw-instance.nl)',\n      'server_unreachable': 'Kon geen verbinding maken met de server. Controleer of de URL correct is en de server draait.',\n      'login_failed': 'Je connect code is incorrect. Probeer het opnieuw.',\n      'reserved': 'Gereserveerd',\n      'reserve': 'Reserveer',\n      'check_in_now': 'Check in voor meeting',\n      'check_in': 'Inchecken',\n      'x_starts_in_x_minutes': '@meeting start in @minutes min',\n      'check_in_within_x_minutes': 'Check in binnen @minutes min',\n      'checked_in': 'Succesvol ingecheckt!',\n      'could_not_check_in': 'Kon niet inchecken',\n      'view_schedule': 'Bekijk dagplanning',\n      'todays_schedule': 'Dagplanning',\n      'no_upcoming_events': 'Geen toekomstige afspraken',\n      'no_events_today': 'Geen afspraken gepland voor vandaag',\n      'meeting': 'Vergadering',\n      'admin_actions_enabled': 'Beheeracties ingeschakeld voor @seconds seconden',\n      'refresh_data': 'Gegevens vernieuwen',\n      'display_data_refreshed': 'Schermgegevens succesvol vernieuwd. Het scherm zou nu de meest recente instellingen en een bijgewerkt overzicht van de gebeurtenissen moeten gebruiken',\n      'refresh_cooldown': 'Wacht a.u.b. @seconds seconden voordat u opnieuw vernieuwt',\n      'custom_booking': 'Aangepaste boeking',\n      'meeting_title': 'Vergaderingstitel',\n      'start_time': 'Starttijd',\n      'end_time': 'Eindtijd',\n      'now': 'Nu',\n      'max': 'Max',\n      'book': 'Boek'\n    },\n    'es_ES': {\n      'introduction_title': '¡Bienvenido a Spacepad!',\n      'introduction_text': 'Comienza conectando tu dispositivo con tu sala.',\n      'connect_to_account': 'Conéctate a tu cuenta',\n      'enter_connect_code': 'Por favor, introduce el código de conexión',\n      'choose_display': 'Elegir pantalla',\n      'choose_room_display': '¿Qué sala te gustaría mostrar en este dispositivo?',\n      'select_display': 'Seleccionar una pantalla',\n      'please_select_display': 'Por favor, selecciona una pantalla.',\n      'continue': 'Continuar',\n      'next': 'Siguiente',\n      'next_event_title': '@start - @end, @summary',\n      'meeting_room': 'Sala de reuniones',\n      'meeting_info_title': '@start - @end',\n      'available': 'Todo tuyo!',\n      'to_be_reserved': 'Hazlo rápido!',\n      'till_end_of_day': 'Hasta el final del día',\n      'x_minutes_left': 'quedan @minutes min',\n      'x_hours_x_minutes_left': 'quedan @hours h @minutes min',\n      'for_x_minutes': 'por @minutes min',\n      'for_x_hours_x_minutes': 'por @hours h @minutes min',\n      'could_not_load_events': 'No se pudieron cargar los eventos',\n      'could_not_load_displays': 'No se pudieron cargar las pantallas',\n      'could_not_load_data': 'No se pudieron cargar los datos',\n      'book_now': 'Reservar ahora',\n      'custom': 'Personalizado',\n      'cancel': 'Cancelar',\n      'close': 'Cerrar',\n      'room_booked': '¡Sala reservada!',\n      'could_not_book_room': 'No se pudo reservar la sala',\n      'logout': 'Cerrar sesión',\n      'switch_room': 'Cambiar sala',\n      'cancel_event': 'Cancelar',\n      'event_cancelled': 'Evento cancelado',\n      'could_not_cancel_event': 'No se pudo cancelar el evento',\n      'url_hint': 'https://tu-instancia.com',\n      'check_connection': 'Ha surgido un error inesperado. Por favor, verifica tu conexión a internet',\n      'code_incorrect': 'Tu código es incorrecto. Por favor, actualiza tu panel para obtener el código más reciente',\n      'self_hosted': 'Autoalojado',\n      'cloud_hosted': 'Alojado en la nube',\n      'self_hosted_url': 'URL de la instancia autoalojada',\n      'connect_code_explanation': 'Para obtener un código de conexión, necesitas:\\n1. Registrarte en app.spacepad.io/register, o\\n2. Configurar tu propio entorno en la nube, lee: spacepad.io\\nLuego ve a tu panel para obtener el código de conexión.',\n      'invalid_url': 'Por favor, introduce una URL válida (por ejemplo, https://tu-instancia.com)',\n      'server_unreachable': 'No se pudo conectar con el servidor. Por favor, verifica que la URL sea correcta y que el servidor esté en funcionamiento.',\n      'login_failed': 'Tu código de conexión es incorrecto. Por favor, inténtalo de nuevo.',\n      'reserved': 'Reservado',\n      'reserve': 'Reservar',\n      'check_in_now': 'Check in para la reunión',\n      'check_in': 'Registrar',\n      'x_starts_in_x_minutes': '@meeting comienza en @minutes min',\n      'check_in_within_x_minutes': 'Registrar en @minutes min',\n      'checked_in': '¡Registrado!',\n      'could_not_check_in': 'No se pudo registrar',\n      'view_schedule': 'Ver horario',\n      'todays_schedule': 'Horario de hoy',\n      'no_upcoming_events': 'No hay eventos próximos',\n      'no_events_today': 'No hay eventos programados para hoy',\n      'meeting': 'Reunión',\n      'admin_actions_enabled': 'Acciones de administrador habilitadas por @seconds segundos',\n      'refresh_data': 'Actualizar datos',\n      'display_data_refreshed': 'Datos de la pantalla actualizados correctamente. La pantalla ahora debería usar la configuración más reciente y una vista actualizada de los eventos',\n      'refresh_cooldown': 'Espere @seconds segundos antes de actualizar nuevamente',\n      'custom_booking': 'Reserva personalizada',\n      'meeting_title': 'Título de la reunión',\n      'start_time': 'Hora de inicio',\n      'end_time': 'Hora de finalización',\n      'now': 'Ahora',\n      'max': 'Máx',\n      'book': 'Reservar'\n    },\n    'fr_FR': {\n      'introduction_title': 'Bienvenue sur Spacepad !',\n      'introduction_text': 'Commencez par connecter votre appareil à votre salle.',\n      'connect_to_account': 'Connectez-vous à votre compte',\n      'enter_connect_code': 'Veuillez entrer le code de connexion',\n      'choose_display': 'Choisir un écran',\n      'choose_room_display': 'Quelle salle souhaitez-vous afficher sur cet appareil ?',\n      'select_display': 'Sélectionner un écran',\n      'please_select_display': 'Veuillez sélectionner un écran.',\n      'continue': 'Continuer',\n      'next': 'Suivant',\n      'next_event_title': '@start - @end, @summary',\n      'meeting_room': 'Salle de réunion',\n      'meeting_info_title': '@start - @end',\n      'available': 'À vous!',\n      'to_be_reserved': 'Faites vite!',\n      'till_end_of_day': 'Jusqu\\'à la fin de la journée',\n      'x_minutes_left': 'il reste @minutes min',\n      'x_hours_x_minutes_left': 'il reste @hours h @minutes min',\n      'for_x_minutes': 'pour @minutes min',\n      'for_x_hours_x_minutes': 'pour @hours h @minutes min',\n      'could_not_load_events': 'Impossible de charger les événements',\n      'could_not_load_displays': 'Impossible de charger les écrans',\n      'could_not_load_data': 'Impossible de charger les données',\n      'book_now': 'Réserver maintenant',\n      'custom': 'Personnalisé',\n      'cancel': 'Annuler',\n      'close': 'Fermer',\n      'room_booked': 'Salle réservée !',\n      'could_not_book_room': 'Impossible de réserver la salle',\n      'logout': 'Se déconnecter',\n      'switch_room': 'Changer de salle',\n      'cancel_event': 'Annuler',\n      'event_cancelled': 'Événement annulé',\n      'could_not_cancel_event': 'Impossible d\\'annuler l\\'événement',\n      'url_hint': 'https://votre-instance.com',\n      'check_connection': 'Une erreur inattendue est survenue. Veuillez vérifier votre connexion internet',\n      'code_incorrect': 'Votre code est incorrect. Veuillez actualiser votre tableau de bord pour obtenir le code le plus récent',\n      'self_hosted': 'Auto-hébergé',\n      'cloud_hosted': 'Hébergé sur le cloud',\n      'self_hosted_url': 'URL de l\\'instance auto-hébergée',\n      'connect_code_explanation': 'Pour obtenir un code de connexion, vous devez :\\n1. Vous inscrire sur app.spacepad.io/register, ou\\n2. Configurer votre propre environnement cloud, voir : spacepad.io\\nEnsuite, allez sur votre tableau de bord pour obtenir le code de connexion.',\n      'invalid_url': 'Veuillez entrer une URL valide (par exemple, https://votre-instance.com)',\n      'server_unreachable': 'Impossible de se connecter au serveur. Veuillez vérifier que l\\'URL est correcte et que le serveur fonctionne.',\n      'login_failed': 'Votre code de connexion est incorrect. Veuillez réessayer.',\n      'reserved': 'Réservé',\n      'reserve': 'Réserver',\n      'check_in_now': 'Enregistrez-vous pour la réunion',\n      'check_in': 'Enregistrer',\n      'x_starts_in_x_minutes': '@meeting commence dans @minutes min',\n      'check_in_within_x_minutes': 'Enregistrez-vous dans @minutes min',\n      'checked_in': 'Enregistré !',\n      'could_not_check_in': 'Impossible de s\\'enregistrer',\n      'view_schedule': 'Voir l\\'horaire',\n      'todays_schedule': 'Horaire d\\'aujourd\\'hui',\n      'no_upcoming_events': 'Aucun événement à venir',\n      'no_events_today': 'Aucun événement programmé pour aujourd\\'hui',\n      'meeting': 'Réunion',\n      'admin_actions_enabled': 'Actions d\\'administration activées pendant @seconds secondes',\n      'refresh_data': 'Actualiser les données',\n      'display_data_refreshed': 'Données de l\\'écran actualisées avec succès. L\\'écran devrait maintenant utiliser les paramètres les plus récents et une vue mise à jour des événements',\n      'refresh_cooldown': 'Veuillez attendre @seconds secondes avant d\\'actualiser à nouveau',\n      'custom_booking': 'Réservation personnalisée',\n      'meeting_title': 'Titre de la réunion',\n      'start_time': 'Heure de début',\n      'end_time': 'Heure de fin',\n      'now': 'Maintenant',\n      'max': 'Max',\n      'book': 'Réserver'\n    },\n    'de_DE': {\n      'introduction_title': 'Willkommen bei Spacepad!',\n      'introduction_text': 'Beginnen sie, indem sie ihr gerät mit ihrem raum verbinden.',\n      'connect_to_account': 'Mit ihrem konto verbinden',\n      'enter_connect_code': 'Bitte geben sie den verbindungscode ein',\n      'choose_display': 'Anzeige auswählen',\n      'choose_room_display': 'Welchen raum möchten sie auf diesem gerät anzeigen?',\n      'select_display': 'Anzeige auswählen',\n      'please_select_display': 'Bitte wählen sie eine anzeige aus.',\n      'continue': 'Weiter',\n      'next': 'Nächste',\n      'next_event_title': '@start - @end, @summary',\n      'meeting_room': 'Besprechungsraum',\n      'meeting_info_title': '@start - @end',\n      'available': 'Ganz für sie!',\n      'to_be_reserved': 'Bitte beeilen!',\n      'till_end_of_day': 'Bis zum ende des tages',\n      'x_minutes_left': 'noch @minutes min.',\n      'x_hours_x_minutes_left': 'noch @hours std. @minutes min.',\n      'for_x_minutes': 'für @minutes min.',\n      'for_x_hours_x_minutes': 'für @hours std. @minutes min.',\n      'could_not_load_events': 'Ereignisse konnten nicht geladen werden',\n      'could_not_load_displays': 'Anzeigen konnten nicht geladen werden',\n      'could_not_load_data': 'Daten konnten nicht geladen werden',\n      'book_now': 'Jetzt buchen',\n      'custom': 'Benutzerdefiniert',\n      'cancel': 'Abbrechen',\n      'close': 'Schließen',\n      'room_booked': 'Raum gebucht!',\n      'could_not_book_room': 'Raum konnte nicht gebucht werden',\n      'logout': 'Abmelden',\n      'switch_room': 'Raum wechseln',\n      'cancel_event': 'Stornieren',\n      'event_cancelled': 'Termin storniert',\n      'could_not_cancel_event': 'Termin konnte nicht storniert werden',\n      'url_hint': 'https://ihre-instanz.com',\n      'check_connection': 'Ein unerwarteter fehler ist aufgetreten. Bitte überprüfen sie ihre internetverbindung',\n      'code_incorrect': 'Ihr code ist falsch. Bitte aktualisieren sie ihre dashboard, um den neuesten code zu erhalten',\n      'self_hosted': 'Selbst gehostet',\n      'cloud_hosted': 'Cloud-gehostet',\n      'self_hosted_url': 'URL der selbst gehosteten instanz',\n      'connect_code_explanation': 'Um einen verbindungscode zu erhalten, müssen sie:\\n1. Sich registrieren unter app.spacepad.io/register, oder\\n2. Ihre eigene cloud-umgebung einrichten, siehe: spacepad.io\\nGehen sie dann zu ihrem dashboard, um den verbindungscode zu erhalten.',\n      'invalid_url': 'Bitte geben sie eine gültige URL ein (z.B. https://ihre-instanz.com)',\n      'server_unreachable': 'Verbindung zum server konnte nicht hergestellt werden. Bitte überprüfen sie, ob die URL korrekt ist und der server läuft.',\n      'login_failed': 'Ihr verbindungscode ist falsch. Bitte versuchen sie es erneut.',\n      'reserved': 'Reserviert',\n      'reserve': 'Reservieren',\n      'check_in_now': 'Check-in für meeting',\n      'check_in': 'Einchecken',\n      'x_starts_in_x_minutes': '@meeting startet in @minutes min',\n      'check_in_within_x_minutes': 'Check-in in @minutes min',\n      'checked_in': 'Erfolgreich eingecheckt!',\n      'could_not_check_in': 'Konnte nicht einchecken',\n      'view_schedule': 'Zeitplan anzeigen',\n      'todays_schedule': 'Heutiger zeitplan',\n      'no_upcoming_events': 'Keine anstehenden termine',\n      'no_events_today': 'Keine termine für heute geplant',\n      'meeting': 'Besprechung',\n      'admin_actions_enabled': 'Administratoraktionen für @seconds Sekunden aktiviert',\n      'refresh_data': 'Daten aktualisieren',\n      'display_data_refreshed': 'Anzeigedaten erfolgreich aktualisiert. Die Anzeige sollte nun die neuesten Einstellungen und eine aktualisierte Ansicht der Ereignisse verwenden',\n      'refresh_cooldown': 'Bitte warten Sie @seconds Sekunden, bevor Sie erneut aktualisieren',\n      'custom_booking': 'Benutzerdefinierte Buchung',\n      'meeting_title': 'Besprechungstitel',\n      'start_time': 'Startzeit',\n      'end_time': 'Endzeit',\n      'now': 'Jetzt',\n      'max': 'Max',\n      'book': 'Buchen'\n    },\n    'sv_SE': {\n      'introduction_title': 'Välkommen till Spacepad!',\n      'introduction_text': 'Kom igång genom att ansluta din enhet med ditt rum.',\n      'connect_to_account': 'Anslut till ditt konto',\n      'enter_connect_code': 'Vänligen ange anslutningskoden',\n      'choose_display': 'Välj skärm',\n      'choose_room_display': 'Vilket rum skulle du vilja visa på denna enhet?',\n      'select_display': 'Välj en skärm',\n      'please_select_display': 'Vänligen välj en skärm.',\n      'continue': 'Fortsätt',\n      'next': 'Nästa',\n      'next_event_title': '@start - @end, @summary',\n      'meeting_room': 'Mötesrum',\n      'meeting_info_title': '@start - @end',\n      'available': 'Helt ditt!',\n      'to_be_reserved': 'Gör det snabbt!',\n      'till_end_of_day': 'Till slutet av dagen',\n      'x_minutes_left': '@minutes min kvar',\n      'x_hours_x_minutes_left': '@hours tim @minutes min kvar',\n      'for_x_minutes': 'i @minutes min',\n      'for_x_hours_x_minutes': 'i @hours tim @minutes min',\n      'could_not_load_events': 'Kunde inte ladda händelser',\n      'could_not_load_displays': 'Kunde inte ladda skärmar',\n      'could_not_load_data': 'Kunde inte ladda data',\n      'book_now': 'Boka nu',\n      'custom': 'Anpassad',\n      'cancel': 'Avbryt',\n      'close': 'Stäng',\n      'room_booked': 'Rum bokat!',\n      'could_not_book_room': 'Kunde inte boka rum',\n      'logout': 'Logga ut',\n      'switch_room': 'Byt rum',\n      'cancel_event': 'Avbryt',\n      'event_cancelled': 'Händelse avbruten',\n      'could_not_cancel_event': 'Kunde inte avbryta händelse',\n      'url_hint': 'https://din-instans.se',\n      'check_connection': 'Ett oväntat fel uppstod. Vänligen kontrollera att du har en internetanslutning',\n      'code_incorrect': 'Din kod är felaktig. Vänligen uppdatera din dashboard för att få den senaste koden',\n      'self_hosted': 'Självhostad',\n      'cloud_hosted': 'Molnhostad',\n      'self_hosted_url': 'URL för självhostad instans',\n      'connect_code_explanation': 'För att få en anslutningskod behöver du:\\n1. Registrera dig på app.spacepad.io/register, eller\\n2. Sätt upp din egen molnhostade miljö, läs: spacepad.io\\nGå sedan till din dashboard för att få anslutningskoden.',\n      'invalid_url': 'Vänligen ange en giltig URL (t.ex. https://din-instans.se)',\n      'server_unreachable': 'Kunde inte ansluta till servern. Vänligen kontrollera att URL:en är korrekt och att servern körs.',\n      'login_failed': 'Din anslutningskod är felaktig. Vänligen försök igen.',\n      'reserved': 'Reserverad',\n      'reserve': 'Reservera',\n      'check_in_now': 'Checka in för möte',\n      'check_in': 'Checka in',\n      'x_starts_in_x_minutes': '@meeting börjar om @minutes min',\n      'check_in_within_x_minutes': 'Checka in inom @minutes min',\n      'checked_in': 'Framgångsrikt incheckad!',\n      'could_not_check_in': 'Kunde inte checka in',\n      'view_schedule': 'Visa schema',\n      'todays_schedule': 'Dagens schema',\n      'no_upcoming_events': 'Inga kommande händelser',\n      'no_events_today': 'Inga händelser schemalagda för idag',\n      'meeting': 'Möte',\n      'admin_actions_enabled': 'Administratörsåtgärder aktiverade i @seconds sekunder',\n      'refresh_data': 'Uppdatera data',\n      'display_data_refreshed': 'Skärmdata uppdaterades framgångsrikt. Skärmen bör nu använda de senaste inställningarna och en uppdaterad vy över händelserna',\n      'refresh_cooldown': 'Vänta @seconds sekunder innan du uppdaterar igen',\n      'custom_booking': 'Anpassad bokning',\n      'meeting_title': 'Mötetitel',\n      'start_time': 'Starttid',\n      'end_time': 'Sluttid',\n      'now': 'Nu',\n      'max': 'Max',\n      'book': 'Boka'\n    },\n  };\n}"
  },
  {
    "path": "app/pubspec.yaml",
    "content": "name: spacepad\ndescription: \"A simple and privacy-focused meeting room display.\"\n# The following line prevents the package from being accidentally published to\n# pub.dev using `flutter pub publish`. This is preferred for private packages.\npublish_to: 'none' # Remove this line if you wish to publish to pub.dev\n\n# The following defines the version and build number for your application.\n# A version number is three numbers separated by dots, like 1.2.43\n# followed by an optional build number separated by a +.\n# Both the version and the builder number may be overridden in flutter\n# build by specifying --build-name and --build-number, respectively.\n# In Android, build-name is used as versionName while build-number used as versionCode.\n# Read more about Android versioning at https://developer.android.com/studio/publish/versioning\n# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.\n# Read more about iOS versioning at\n# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html\n# In Windows, build-name is used as the major, minor, and patch parts\n# of the product and file versions while build-number is used as the build suffix.\nversion: 1.4.0+14\n\nenvironment:\n  sdk: ^3.6.0\n\n# Dependencies specify other packages that your package needs in order to work.\n# To automatically upgrade your package dependencies to the latest versions\n# consider running `flutter pub upgrade --major-versions`. Alternatively,\n# dependencies can be manually updated by changing the version numbers below to\n# the latest version available on pub.dev. To see which dependencies have newer\n# versions available, run `flutter pub outdated`.\ndependencies:\n  flutter:\n    sdk: flutter\n\n  flutter_localizations:\n    sdk: flutter\n\n  # The following adds the Cupertino Icons font to your application.\n  # Use with the CupertinoIcons class for iOS style icons.\n  cupertino_icons: ^1.0.8\n  intl: ^0.19.0\n  tailwind_components: ^0.0.50\n  get: ^4.6.6\n  http: ^1.2.2\n  shared_preferences: ^2.3.5\n  flutter_dotenv: ^5.2.1\n  pinput: ^5.0.0\n  heroicons: ^0.11.0\n  device_info_plus: ^11.2.0\n  flutter_svg: ^2.0.17\n  wakelock_plus: ^1.2.10\n  google_fonts: ^6.2.1\n  dropdown_button2: ^2.3.9\n  marquee: ^2.3.0\n  timezone: ^0.10.0\n  flutter_udid: ^4.0.0\n  calendar_view: ^1.4.0\n\ndev_dependencies:\n  flutter_test:\n    sdk: flutter\n\n  # The \"flutter_lints\" package below contains a set of recommended lints to\n  # encourage good coding practices. The lint set provided by the package is\n  # activated in the `analysis_options.yaml` file located at the root of your\n  # package. See that file for information about deactivating specific lint\n  # rules and activating additional ones.\n  flutter_lints: ^5.0.0\n  flutter_launcher_icons: ^0.14.2\n\n# For information on the generic Dart part of this file, see the\n# following page: https://dart.dev/tools/pub/pubspec\n\n# The following section is specific to Flutter packages.\nflutter:\n\n  # The following line ensures that the Material Icons font is\n  # included with your application, so that you can use the icons in\n  # the material Icons class.\n  uses-material-design: true\n\n  # To add assets to your application, add an assets section, like this:\n  assets:\n    - .env\n    - assets/\n    - assets/fonts/\n\nflutter_launcher_icons:\n  android: \"launcher_icon\"\n  ios: true\n  image_path: \"assets/appicon.jpg\""
  },
  {
    "path": "app/test/widget_test.dart",
    "content": "// This is a basic Flutter widget test.\n//\n// To perform an interaction with a widget in your test, use the WidgetTester\n// utility in the flutter_test package. For example, you can send tap and scroll\n// gestures. You can also use WidgetTester to find child widgets in the widget\n// tree, read text, and verify that the values of widget properties are correct.\n\nimport 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\n\nimport 'package:spacepad/main.dart';\n\nvoid main() {\n  testWidgets('Counter increments smoke test', (WidgetTester tester) async {\n    // Build our app and trigger a frame.\n    await tester.pumpWidget(const App());\n\n    // Verify that our counter starts at 0.\n    expect(find.text('0'), findsOneWidget);\n    expect(find.text('1'), findsNothing);\n\n    // Tap the '+' icon and trigger a frame.\n    await tester.tap(find.byIcon(Icons.add));\n    await tester.pump();\n\n    // Verify that our counter has incremented.\n    expect(find.text('0'), findsNothing);\n    expect(find.text('1'), findsOneWidget);\n  });\n}\n"
  },
  {
    "path": "backend/.editorconfig",
    "content": "root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\nindent_size = 4\nindent_style = space\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n\n[*.md]\ntrim_trailing_whitespace = false\n\n[*.{yml,yaml}]\nindent_size = 2\n\n[docker-compose.yml]\nindent_size = 4\n"
  },
  {
    "path": "backend/.gitattributes",
    "content": "* text=auto eol=lf\n\n*.blade.php diff=html\n*.css diff=css\n*.html diff=html\n*.md diff=markdown\n*.php diff=php\n\n/.github export-ignore\nCHANGELOG.md export-ignore\n.styleci.yml export-ignore\n"
  },
  {
    "path": "backend/.gitignore",
    "content": "/.phpunit.cache\n/node_modules\n/public/build\n/public/hot\n/public/storage\n/storage/*.key\n/storage/pail\n/vendor\n.env\n.env.backup\n.env.production\n.phpactor.json\n.phpunit.result.cache\nHomestead.json\nHomestead.yaml\nauth.json\nnpm-debug.log\nyarn-error.log\n/.fleet\n/.idea\n/.nova\n/.vscode\n/.zed\n"
  },
  {
    "path": "backend/Dockerfile",
    "content": "FROM composer:lts AS php_builder\n\nWORKDIR /app\n\nCOPY composer.json composer.lock ./\n\nCOPY ./ /app\n\nRUN composer install --no-cache --no-dev --no-scripts --no-autoloader --ansi --no-interaction --ignore-platform-reqs \\\n    && composer dump-autoload -o --ignore-platform-reqs --no-scripts\n\n# Node.js builder stage\nFROM node:20-alpine AS node_builder\n\nWORKDIR /app\n\n# Install pnpm\nRUN corepack enable && corepack prepare pnpm@latest --activate\n\nCOPY package.json pnpm-lock.yaml ./\nCOPY vite.config.js ./\nCOPY resources ./resources\n\nRUN pnpm install && pnpm run build\n\n# Set our base image\nFROM serversideup/php:8.4-unit AS base\n\n# Switch to root before installing our PHP extensions\nUSER root\nRUN install-php-extensions opentelemetry protobuf\n\nFROM base AS production\n\n# Add build arguments for git information\nARG GIT_TAG\nARG GIT_COMMIT\n\n# Set environment variable with tag or commit hash\nENV SPACEPAD_VERSION=${GIT_TAG:-${GIT_COMMIT}}\n\nCOPY --chown=www-data:www-data . /var/www/html\nCOPY --chown=www-data:www-data --from=php_builder /app/vendor /var/www/html/vendor\nCOPY --chown=www-data:www-data --from=node_builder /app/public/build /var/www/html/public/build\n\nUSER www-data\n\nRUN mkdir -p storage && touch storage/database.sqlite\n"
  },
  {
    "path": "backend/FARO_SETUP.md",
    "content": "# Grafana Faro Frontend Monitoring Setup\n\nThis document explains how to configure Grafana Faro Real User Monitoring (RUM) for the Spacepad frontend.\n\n## Overview\n\nGrafana Faro collects frontend telemetry data including:\n- **Web Vitals** (LCP, FID, CLS, FCP, TTFB)\n- **Page load timing** (DOMContentLoaded, Load, etc.)\n- **User interactions** (clicks, form submissions)\n- **All fetch/XHR requests** with full trace context\n- **Frontend → Backend trace correlation**\n- **Long tasks** (performance monitoring)\n- **Errors and exceptions** (sent to Loki)\n- **Console logs** (errors/warnings sent to Loki)\n- **Session tracking**\n\n## Configuration\n\n### 1. Enable Faro\n\nAdd to your `.env` file:\n\n```env\nFARO_ENABLED=true\nFARO_COLLECTOR_URL=http://localhost:12347/collect\nFARO_API_KEY=faro-secret-key\nFARO_APP_NAME=spacepad\nFARO_APP_VERSION=1.0.0\nFARO_APP_ENV=local\n```\n\n### 2. Grafana Alloy Configuration\n\nEnsure Grafana Alloy is running with a FARO receiver configured. The receiver should:\n- Listen on port `12347` at `/collect` endpoint\n- Use the same `api_key` as configured in `FARO_API_KEY`\n- Forward logs to Loki\n- Forward traces to Tempo\n- Forward metrics to Prometheus\n\nExample Alloy configuration:\n\n```river\nfaro.receiver \"faro_receiver\" {\n    server {\n        listen_address           = \"0.0.0.0\"\n        listen_port              = 12347\n        cors_allowed_origins     = [\"*\"]  // Allow all origins for development\n        api_key                  = \"faro-secret-key\"  // Must match FARO_API_KEY\n        max_allowed_payload_size = \"10MiB\"\n\n        rate_limiting {\n            rate = 100\n        }\n    }\n\n    sourcemaps { }\n\n    output {\n        logs   = [loki.process.faro_logs.receiver]\n        traces = [otelcol.processor.batch.batch_processor.input]\n    }\n}\n```\n\n### 3. Docker Environment\n\nIf running in Docker, use `host.docker.internal` to reach Grafana Alloy on the host:\n\n```env\nFARO_COLLECTOR_URL=http://host.docker.internal:12347/collect\n```\n\n## How It Works\n\n1. **Frontend Application** → Sends RUM data via FARO SDK → **Grafana Alloy** (port 12347 `/collect`)\n2. **Grafana Alloy** → Processes FARO data → Forwards to:\n   - **Prometheus** (metrics)\n   - **Loki** (logs)\n   - **Tempo** (traces)\n\n## Features\n\nThe Faro integration automatically captures:\n\n- ✅ **Web Vitals** (LCP, FID, CLS, FCP, TTFB)\n- ✅ **Page load timing** (DOMContentLoaded, Load, etc.)\n- ✅ **User interactions** (clicks, form submissions)\n- ✅ **All fetch/XHR requests** with full trace context\n- ✅ **Frontend → Backend trace correlation**\n- ✅ **Long tasks** (performance monitoring)\n- ✅ **Errors and exceptions** (sent to Loki)\n- ✅ **Console logs** (errors/warnings sent to Loki)\n- ✅ **Session tracking**\n\n## Viewing Data\n\n### Grafana Dashboard\n\nImport the Grafana Faro Frontend Monitoring dashboard (ID: `17766`):\n\n1. Open Grafana at http://localhost:3000\n2. Go to Dashboards → Import\n3. Enter dashboard ID: `17766`\n4. Select Prometheus as the datasource\n5. Click \"Import\"\n\n### Prometheus Queries\n\nQuery FARO metrics in Prometheus:\n\n```promql\n# Frontend errors\nfaro_errors_total\n\n# Page load metrics\nfaro_page_load_duration_seconds\n\n# Web Vitals\nfaro_web_vitals_lcp_seconds\nfaro_web_vitals_fid_seconds\nfaro_web_vitals_cls\n```\n\n### Loki Logs\n\nSearch for frontend logs in Loki:\n\n```logql\n{service_name=\"spacepad\"} |= \"error\"\n```\n\n### Tempo Traces\n\nView frontend traces in Tempo:\n- Search for traces from `spacepad` service\n- Filter by route or operation\n- View trace details and spans\n\n## Troubleshooting\n\n### Faro Not Initializing\n\n1. **Check browser console** for initialization errors\n2. **Verify configuration** in `.env` file\n3. **Check network tab** for requests to `/collect` endpoint\n4. **Verify CORS** settings in Grafana Alloy config\n\n### No Data in Grafana\n\n1. **Check Grafana Alloy logs:**\n   ```bash\n   docker logs grafana-alloy\n   ```\n\n2. **Verify API key matches:**\n   - `FARO_API_KEY` in `.env` must match `api_key` in Alloy config\n\n3. **Check Prometheus targets:**\n   - Visit http://localhost:9090/targets\n   - Verify `grafana-alloy` target is UP\n\n4. **Verify CORS:**\n   - Ensure `cors_allowed_origins` in Alloy config includes your frontend origin\n\n### CORS Errors\n\nIf you see CORS errors in the browser console:\n\n1. Add your frontend origin to `cors_allowed_origins` in Alloy config\n2. For development: `cors_allowed_origins = [\"*\"]`\n3. For production: `cors_allowed_origins = [\"https://yourdomain.com\"]`\n\n## Security\n\n**Important:** The default API key `faro-secret-key` is for development only. In production:\n\n1. Generate a secure random API key\n2. Update `FARO_API_KEY` in `.env`\n3. Update `api_key` in Grafana Alloy config\n4. Consider using environment variables or secrets management\n\n## Advanced Configuration\n\n### Custom Instrumentations\n\nTo add custom instrumentations, modify `resources/views/components/scripts/faro.blade.php`:\n\n```javascript\nimport { TracingInstrumentation } from '@grafana/faro-web-tracing';\n\nconst faroInstance = initializeFaro({\n    // ... existing config\n    instrumentations: [\n        ...getWebInstrumentations(),\n        new TracingInstrumentation(),\n    ],\n});\n```\n\n### Disable Specific Features\n\nYou can disable specific features via environment variables:\n\n```env\nFARO_PERFORMANCE_ENABLED=false\nFARO_ERRORS_ENABLED=false\nFARO_CONSOLE_ENABLED=false\nFARO_INTERACTIONS_ENABLED=false\nFARO_SESSION_TRACKING=false\n```\n\n## References\n\n- [Grafana Faro Documentation](https://github.com/grafana/faro-web-sdk)\n- [Grafana Faro Quick Start](https://github.com/grafana/faro-web-sdk/blob/main/docs/sources/tutorials/quick-start-browser.md)\n- [Grafana Alloy FARO Receiver](https://grafana.com/docs/alloy/latest/reference/components/faro.receiver/)\n\n"
  },
  {
    "path": "backend/README.md",
    "content": "<p align=\"center\"><a href=\"https://laravel.com\" target=\"_blank\"><img src=\"https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg\" width=\"400\" alt=\"Laravel Logo\"></a></p>\n\n<p align=\"center\">\n<a href=\"https://github.com/laravel/framework/actions\"><img src=\"https://github.com/laravel/framework/workflows/tests/badge.svg\" alt=\"Build Status\"></a>\n<a href=\"https://packagist.org/packages/laravel/framework\"><img src=\"https://img.shields.io/packagist/dt/laravel/framework\" alt=\"Total Downloads\"></a>\n<a href=\"https://packagist.org/packages/laravel/framework\"><img src=\"https://img.shields.io/packagist/v/laravel/framework\" alt=\"Latest Stable Version\"></a>\n<a href=\"https://packagist.org/packages/laravel/framework\"><img src=\"https://img.shields.io/packagist/l/laravel/framework\" alt=\"License\"></a>\n</p>\n\n## About Laravel\n\nLaravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:\n\n- [Simple, fast routing engine](https://laravel.com/docs/routing).\n- [Powerful dependency injection container](https://laravel.com/docs/container).\n- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.\n- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).\n- Database agnostic [schema migrations](https://laravel.com/docs/migrations).\n- [Robust background job processing](https://laravel.com/docs/queues).\n- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).\n\nLaravel is accessible, powerful, and provides tools required for large, robust applications.\n\n## Learning Laravel\n\nLaravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.\n\nYou may also try the [Laravel Bootcamp](https://bootcamp.laravel.com), where you will be guided through building a modern Laravel application from scratch.\n\nIf you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.\n\n## Laravel Sponsors\n\nWe would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).\n\n### Premium Partners\n\n- **[Vehikl](https://vehikl.com/)**\n- **[Tighten Co.](https://tighten.co)**\n- **[WebReinvent](https://webreinvent.com/)**\n- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**\n- **[64 Robots](https://64robots.com)**\n- **[Curotec](https://www.curotec.com/services/technologies/laravel/)**\n- **[Cyber-Duck](https://cyber-duck.co.uk)**\n- **[DevSquad](https://devsquad.com/hire-laravel-developers)**\n- **[Jump24](https://jump24.co.uk)**\n- **[Redberry](https://redberry.international/laravel/)**\n- **[Active Logic](https://activelogic.com)**\n- **[byte5](https://byte5.de)**\n- **[OP.GG](https://op.gg)**\n\n## Contributing\n\nThank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).\n\n## Code of Conduct\n\nIn order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).\n\n## Security Vulnerabilities\n\nIf you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.\n\n## License\n\nThe Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).\n"
  },
  {
    "path": "backend/WORKSPACE_SETUP.md",
    "content": "# Workspace System Documentation\n\n## Overview\n\nThe workspace system allows multiple users to collaborate on managing displays, devices, calendars, and rooms. Each user automatically gets their own workspace, and Pro users can invite colleagues to join their workspace.\n\n## Architecture\n\n### Models\n\n1. **Workspace** - Represents a team/workspace\n   - Has an `owner` (User)\n   - Has many `members` (Users with roles)\n   - Contains displays, devices, calendars, rooms\n\n2. **WorkspaceMember** - Pivot table linking users to workspaces\n   - Roles: `owner`, `admin`, `member`\n   - `owner` role is implicit for the workspace owner\n\n### Relationships\n\n- **User** → **Workspace** (one-to-many: owned workspaces)\n- **User** ↔ **Workspace** (many-to-many: member workspaces)\n- **Workspace** → **Display** (one-to-many)\n- **Workspace** → **Device** (one-to-many)\n- **Workspace** → **Calendar** (one-to-many)\n- **Workspace** → **Room** (one-to-many)\n\n## Migration Strategy\n\n1. **Existing Users**: Each user automatically gets a workspace created with their name\n2. **Existing Data**: All displays, devices, calendars, and rooms are migrated to the user's workspace\n3. **Backward Compatibility**: The `user_id` field is kept for backward compatibility\n\n## Permissions\n\n### Workspace Roles\n\n- **Owner**: Full control (can delete workspace, manage all members)\n- **Admin**: Can manage members and workspace settings\n- **Member**: Can view and use workspace resources\n\n### Display Access\n\n- Users can access displays they own directly (`user_id`)\n- Users can access displays in workspaces they're members of (`workspace_id`)\n- Device authentication checks workspace membership\n\n## Usage\n\n### Adding a Colleague\n\n1. Navigate to workspace settings (requires Pro)\n2. Enter colleague's email address\n3. Select role (admin or member)\n4. Colleague receives access to all workspace resources\n\n### Managing Members\n\n- **Add Member**: Only owners/admins can add members\n- **Update Role**: Change member role between admin/member\n- **Remove Member**: Remove access from workspace\n\n## API Changes\n\n### DisplayController\n\n- `index()` now returns displays from user's workspace(s)\n- Access checks include workspace membership\n\n### DisplayService\n\n- `validateDisplayPermission()` checks workspace membership\n- Pro features check workspace owner's Pro status\n\n## Frontend Changes Needed\n\n1. **Workspace Management UI**\n   - List workspaces\n   - View workspace members\n   - Add/remove members\n   - Update member roles\n\n2. **Display Creation**\n   - Automatically assign to user's primary workspace\n   - Allow selecting workspace (if user has multiple)\n\n3. **Device Connection**\n   - Connect code should work with workspace\n   - Devices inherit workspace from user\n\n## Migration Commands\n\nRun migrations in order:\n\n```bash\nphp artisan migrate\n```\n\nThe migration `2025_12_30_000003_create_workspaces_for_existing_users.php` will:\n1. Create a workspace for each existing user\n2. Migrate all user's displays, devices, calendars, and rooms to their workspace\n3. Add the user as an owner member\n\n## Notes\n\n- Pro subscription is required to add team members\n- Workspace owner cannot be removed\n- All existing functionality remains backward compatible\n- `user_id` fields are kept for direct ownership tracking\n\n"
  },
  {
    "path": "backend/app/Console/Commands/CheckMarketingTriggers.php",
    "content": "<?php\n\nnamespace App\\Console\\Commands;\n\nuse App\\Events\\TrialExpiredOrCancelled;\nuse App\\Events\\UserActivatedAfter24h;\nuse App\\Events\\UserInactive;\nuse App\\Events\\UserNotActivatedAfter24h;\nuse App\\Events\\UserPassive;\nuse App\\Models\\User;\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Support\\Facades\\Cache;\n\nclass CheckMarketingTriggers extends Command\n{\n    /**\n     * The name and signature of the console command.\n     *\n     * @var string\n     */\n    protected $signature = 'app:check-marketing-triggers';\n\n    /**\n     * The console command description.\n     *\n     * @var string\n     */\n    protected $description = 'Check user conditions and fire marketing email trigger events';\n\n    /**\n     * Execute the console command.\n     */\n    public function handle(): int\n    {\n        $this->info('Checking marketing triggers...');\n\n        // Check users not activated after 24h\n        $this->checkUsersNotActivatedAfter24h();\n\n        // Check users activated after 24h\n        $this->checkUsersActivatedAfter24h();\n\n        // Check passive users (14 days no activity)\n        $this->checkPassiveUsers();\n\n        // Check inactive users (30 days no activity)\n        $this->checkInactiveUsers();\n\n        // Check trial expired or cancelled\n        $this->checkTrialExpiredOrCancelled();\n\n        $this->info('Marketing triggers check completed.');\n\n        return self::SUCCESS;\n    }\n\n    /**\n     * Check users registered 24h ago but haven't created a display\n     */\n    private function checkUsersNotActivatedAfter24h(): void\n    {\n        $users = User::whereNull('deleted_at')\n            ->where('created_at', '<=', now()->subHours(24))\n            ->where('created_at', '>', now()->subHours(25))\n            ->whereDoesntHave('displays')\n            ->get();\n\n        foreach ($users as $user) {\n            $cacheKey = \"marketing:user_not_activated_24h:{$user->id}\";\n            if (!Cache::has($cacheKey)) {\n                event(new UserNotActivatedAfter24h($user));\n                Cache::put($cacheKey, true, now()->addDays(7)); // Prevent duplicate events for 7 days\n                $this->line(\"Fired UserNotActivatedAfter24h for user {$user->email}\");\n            }\n        }\n    }\n\n    /**\n     * Check users who created their first display 24h ago\n     */\n    private function checkUsersActivatedAfter24h(): void\n    {\n        // Get users whose first display was created 24h ago\n        $users = User::whereNull('deleted_at')\n            ->where('created_at', '<=', now()->subHours(24))\n            ->where('created_at', '>', now()->subHours(25))\n            ->whereHas('displays')\n            ->get();\n\n        foreach ($users as $user) {\n            $cacheKey = \"marketing:user_activated_24h:{$user->id}\";\n            if (!Cache::has($cacheKey)) {\n                event(new UserActivatedAfter24h($user));\n                Cache::put($cacheKey, true, now()->addDays(7)); // Prevent duplicate events for 7 days\n                $this->line(\"Fired UserActivatedAfter24h for user {$user->email}\");\n            }\n        }\n    }\n\n    /**\n     * Check users with no activity for 14 days\n     * Activity includes: user activity, device activity\n     */\n    private function checkPassiveUsers(): void\n    {\n        $cutoffDate = now()->subDays(14);\n        $previousCutoffDate = now()->subDays(15);\n\n        $users = User::whereNull('deleted_at')\n            ->where(function ($query) use ($cutoffDate, $previousCutoffDate) {\n                // User's last activity is within the window (or null)\n                $query->where(function ($q) use ($cutoffDate, $previousCutoffDate) {\n                    $q->whereNotNull('last_activity_at')\n                        ->where('last_activity_at', '<=', $cutoffDate)\n                        ->where('last_activity_at', '>', $previousCutoffDate);\n                });\n            })\n            // And no devices with recent activity\n            ->whereDoesntHave('devices', function ($q) use ($cutoffDate) {\n                $q->whereNotNull('last_activity_at')\n                    ->where('last_activity_at', '>', $cutoffDate);\n            })\n            ->get();\n\n        foreach ($users as $user) {\n            $cacheKey = \"marketing:user_passive:{$user->id}\";\n            if (!Cache::has($cacheKey)) {\n                event(new UserPassive($user));\n                Cache::put($cacheKey, true, now()->addDays(7)); // Prevent duplicate events for 7 days\n                $this->line(\"Fired UserPassive for user {$user->email}\");\n            }\n        }\n    }\n\n    /**\n     * Check users with no activity for 30 days\n     * Activity includes: user activity, device activity\n     */\n    private function checkInactiveUsers(): void\n    {\n        $cutoffDate = now()->subDays(30);\n        $previousCutoffDate = now()->subDays(31);\n\n        $users = User::whereNull('deleted_at')\n            ->where(function ($query) use ($cutoffDate, $previousCutoffDate) {\n                // User's last activity is within the window (or null)\n                $query->where(function ($q) use ($cutoffDate, $previousCutoffDate) {\n                    $q->whereNotNull('last_activity_at')\n                        ->where('last_activity_at', '<=', $cutoffDate)\n                        ->where('last_activity_at', '>', $previousCutoffDate);\n                });\n            })\n            // And no devices with recent activity\n            ->whereDoesntHave('devices', function ($q) use ($cutoffDate) {\n                $q->whereNotNull('last_activity_at')\n                    ->where('last_activity_at', '>', $cutoffDate);\n            })\n            ->get();\n\n        foreach ($users as $user) {\n            $cacheKey = \"marketing:user_inactive:{$user->id}\";\n            if (!Cache::has($cacheKey)) {\n                event(new UserInactive($user));\n                Cache::put($cacheKey, true, now()->addDays(7)); // Prevent duplicate events for 7 days\n                $this->line(\"Fired UserInactive for user {$user->email}\");\n            }\n        }\n    }\n\n    /**\n     * Check users with expired or cancelled trials\n     */\n    private function checkTrialExpiredOrCancelled(): void\n    {\n        if (config('settings.is_self_hosted')) {\n            return; // Skip for self-hosted instances\n        }\n\n        // Get users whose subscriptions ended in the last 24 hours\n        $users = User::whereNull('deleted_at')\n            ->where('is_unlimited', false)\n            ->whereHas('subscriptions', function ($query) {\n                // Subscription ended in the last 24 hours\n                $query->where('ends_at', '<=', now())\n                    ->where('ends_at', '>', now()->subDay());\n            })\n            ->whereDoesntHave('subscriptions', function ($query) {\n                // And they don't have any active subscriptions\n                $query->where(function ($q) {\n                    $q->whereNull('ends_at')\n                        ->orWhere('ends_at', '>', now());\n                });\n            })\n            ->get();\n\n        foreach ($users as $user) {\n            $cacheKey = \"marketing:trial_expired:{$user->id}\";\n            if (!Cache::has($cacheKey)) {\n                event(new TrialExpiredOrCancelled($user));\n                Cache::put($cacheKey, true, now()->addDays(7)); // Prevent duplicate events for 7 days\n                $this->line(\"Fired TrialExpiredOrCancelled for user {$user->email}\");\n            }\n        }\n    }\n}\n\n"
  },
  {
    "path": "backend/app/Console/Commands/CleanupExpiredEvents.php",
    "content": "<?php\n\nnamespace App\\Console\\Commands;\n\nuse App\\Models\\Display;\nuse App\\Models\\Event;\nuse Illuminate\\Console\\Command;\n\nclass CleanupExpiredEvents extends Command\n{\n    /**\n     * The name and signature of the console command.\n     *\n     * @var string\n     */\n    protected $signature = 'app:cleanup-expired-events {--display= : Specific display ID to cleanup} {--dry-run : Show what would be deleted without actually deleting}';\n\n    /**\n     * The console command description.\n     *\n     * @var string\n     */\n    protected $description = 'Cleanup events that have ended before the display timeframe (events from previous days)';\n\n    /**\n     * Execute the console command.\n     */\n    public function handle(): void\n    {\n        $displayId = $this->option('display');\n        $dryRun = $this->option('dry-run');\n\n        if ($displayId) {\n            $displays = Display::where('id', $displayId)->get();\n            if ($displays->isEmpty()) {\n                $this->error(\"Display with ID {$displayId} not found.\");\n                return;\n            }\n        } else {\n            $displays = Display::all();\n        }\n\n        $totalDeleted = 0;\n\n        foreach ($displays as $display) {\n            $deletedCount = $this->cleanupEventsForDisplay($display, $dryRun);\n            $totalDeleted += $deletedCount;\n            \n            if ($deletedCount > 0) {\n                $action = $dryRun ? 'would delete' : 'deleted';\n                $this->info(\"Display '{$display->name}' (ID: {$display->id}): {$action} {$deletedCount} expired events\");\n            }\n        }\n\n        $action = $dryRun ? 'would be deleted' : 'deleted';\n        $this->info(\"Total: {$totalDeleted} events {$action}\");\n    }\n\n    /**\n     * Cleanup expired events for a specific display\n     */\n    private function cleanupEventsForDisplay(Display $display, bool $dryRun = false): int\n    {\n        $startTime = $display->getStartTime();\n\n        $query = Event::where('display_id', $display->id)\n            ->where('end', '<', $startTime);\n\n        if ($dryRun) {\n            return $query->count();\n        }\n\n        $deletedCount = $query->count();\n        $query->delete();\n\n        if ($deletedCount > 0) {\n            logger()->info(\"Cleaned up {$deletedCount} expired events for display {$display->id} that ended before {$startTime->toDateTimeString()}\");\n        }\n\n        return $deletedCount;\n    }\n}"
  },
  {
    "path": "backend/app/Console/Commands/RenewEventSubscriptions.php",
    "content": "<?php\n\nnamespace App\\Console\\Commands;\n\nuse App\\Enums\\DisplayStatus;\nuse App\\Models\\Display;\nuse App\\Models\\EventSubscription;\nuse App\\Models\\OutlookAccount;\nuse App\\Models\\GoogleAccount;\nuse App\\Services\\OutlookService;\nuse App\\Services\\GoogleService;\nuse Illuminate\\Console\\Command;\nuse App\\Enums\\AccountStatus;\nuse Illuminate\\Support\\Str;\n\nclass RenewEventSubscriptions extends Command\n{\n    /**\n     * The name and signature of the console command.\n     *\n     * @var string\n     */\n    protected $signature = 'app:renew-subscriptions';\n\n    /**\n     * The console command description.\n     *\n     * @var string\n     */\n    protected $description = 'Renew all expired event subscriptions';\n\n    /**\n     * Execute the console command.\n     * @throws \\Exception\n     */\n    public function handle(OutlookService $outlookService, GoogleService $googleService): void\n    {\n        $expiredSubscriptions = EventSubscription::with(['display.calendar', 'display.calendar.room'])\n            ->where(function ($query) {\n                $query->whereHas('outlookAccount', function ($query) {\n                    $query->where('status', AccountStatus::CONNECTED);\n                })->orWhereHas('googleAccount', function ($query) {\n                    $query->where('status', AccountStatus::CONNECTED);\n                });\n            })\n            ->expired()\n            ->get();\n\n        logger()->info('Renewing ' . $expiredSubscriptions->count() . ' expired subscriptions');\n        foreach ($expiredSubscriptions as $expiredSubscription) {\n            $display = $expiredSubscription->display;\n\n            // Renew Outlook event subscription\n            if ($expiredSubscription->outlookAccount) {\n                $this->renewOutlookEventSubscription($expiredSubscription->outlookAccount, $display, $expiredSubscription, $outlookService);\n            }\n\n            // Renew Google event subscription\n            if ($expiredSubscription->googleAccount) {\n                $this->renewGoogleEventSubscription($expiredSubscription->googleAccount, $display, $expiredSubscription, $googleService);\n            }\n        }\n\n        $newDisplays = Display::with(['calendar.room', 'calendar.outlookAccount', 'calendar.googleAccount'])\n            ->whereIn('status', [DisplayStatus::READY, DisplayStatus::ACTIVE])\n            ->doesntHave('eventSubscriptions')\n            ->get();\n\n        logger()->info('Creating ' . $newDisplays->count() . ' new subscriptions');\n        foreach ($newDisplays as $newDisplay) {\n            $calendar = $newDisplay->calendar;\n\n            // Create new Outlook event subscription\n            if ($calendar->outlookAccount) {\n                $this->createOutlookEventSubscription($calendar->outlookAccount, $newDisplay, $outlookService);\n            }\n\n            // Create new Google event subscription\n            if ($calendar->googleAccount) {\n                $this->createGoogleEventSubscription($calendar->googleAccount, $newDisplay, $googleService);\n            }\n        }\n    }\n\n    /**\n     * @param OutlookAccount $outlookAccount\n     * @param Display $display\n     * @param EventSubscription $eventSubscription\n     * @param OutlookService $outlookService\n     */\n    private function renewOutlookEventSubscription(OutlookAccount $outlookAccount, Display $display, EventSubscription $eventSubscription, OutlookService $outlookService): void\n    {\n        try {\n            $outlookService->deleteEventSubscription($outlookAccount, $eventSubscription, false);\n        } catch (\\Exception $e) {\n            $outlookAccount->update(['status' => AccountStatus::ERROR]);\n            $display->update(['status' => DisplayStatus::ERROR]);\n            report('Error deleting Outlook subscription for display ' . $display->id . ': ' . $e->getMessage());\n            return;\n        }\n\n        $this->createOutlookEventSubscription($outlookAccount, $display, $outlookService);\n    }\n\n    /**\n     * @param GoogleAccount $googleAccount\n     * @param Display $display\n     * @param EventSubscription $eventSubscription\n     * @param GoogleService $googleService\n     */\n    private function renewGoogleEventSubscription(GoogleAccount $googleAccount, Display $display, EventSubscription $eventSubscription, GoogleService $googleService): void\n    {\n        try {\n            $googleService->deleteEventSubscription($googleAccount, $eventSubscription, false);\n        } catch (\\Exception $e) {\n            $googleAccount->update(['status' => AccountStatus::ERROR]);\n            $display->update(['status' => DisplayStatus::ERROR]);\n            report('Error deleting Google subscription for display ' . $display->id . ': ' . $e->getMessage());\n            return;\n        }\n\n        $this->createGoogleEventSubscription($googleAccount, $display, $googleService);\n    }\n\n    /**\n     * @param OutlookAccount $outlookAccount\n     * @param Display $display\n     * @param OutlookService $outlookService\n     * @return void\n     */\n    private function createOutlookEventSubscription(OutlookAccount $outlookAccount, Display $display, OutlookService $outlookService): void\n    {\n        try {\n            $calendar = $display->calendar;\n\n            if ($calendar->room) {\n                $outlookService->createEventSubscriptionByUser($outlookAccount, $display, $calendar->calendar_id);\n                return;\n            }\n\n            $outlookService->createEventSubscriptionByCalendar($outlookAccount, $display, $calendar->calendar_id);\n        } catch (\\Exception $e) {\n            $outlookAccount->update(['status' => AccountStatus::ERROR]);\n            $display->update(['status' => DisplayStatus::ERROR]);\n            report('Error creating Outlook subscription for display ' . $display->id . ': ' . $e->getMessage());\n        }\n    }\n\n    /**\n     * @param GoogleAccount $googleAccount\n     * @param Display $display\n     * @param GoogleService $googleService\n     * @return void\n     */\n    private function createGoogleEventSubscription(GoogleAccount $googleAccount, Display $display, GoogleService $googleService): void\n    {\n        try {\n            $calendar = $display->calendar;\n\n            // Prevent resources and groups from creating a push notification, as it is not supported by Google (pushNotSupportedForRequestedResource)\n            if ($calendar->room || Str::contains($calendar->calendar_id, ['group.calendar.google.com', 'resource.calendar.google.com'])) {\n                return;\n            }\n\n            $googleService->createEventSubscription($googleAccount, $display, $calendar->calendar_id);\n        } catch (\\Exception $e) {\n            $googleAccount->update(['status' => AccountStatus::ERROR]);\n            $display->update(['status' => DisplayStatus::ERROR]);\n            report('Error creating Google subscription for display ' . $display->id . ': ' . $e->getMessage());\n        }\n    }\n}\n"
  },
  {
    "path": "backend/app/Console/Commands/SendHeartbeat.php",
    "content": "<?php\n\nnamespace App\\Console\\Commands;\n\nuse App\\Services\\InstanceService;\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Support\\Facades\\Http;\n\nclass SendHeartbeat extends Command\n{\n    protected $signature = 'spacepad:heartbeat';\n    protected $description = 'Send a heartbeat to the Spacepad server';\n\n    /**\n     * @throws ConnectionException\n     */\n    public function handle(InstanceService $instanceService): int\n    {\n        $data = $instanceService->getInstanceData();\n\n        $response = Http::acceptJson()->post(config('settings.license_server') . '/api/v1/instances/heartbeat', $data);\n        if ($response->successful()) {\n            $this->info('Heartbeat sent successfully');\n            return self::SUCCESS;\n        }\n\n        $this->error('Failed to send heartbeat: ' . $response->body());\n        return self::FAILURE;\n    }\n}\n"
  },
  {
    "path": "backend/app/Console/Commands/TriggerRegistrationWebhookForMissingNames.php",
    "content": "<?php\n\nnamespace App\\Console\\Commands;\n\nuse App\\Events\\UserRegistered;\nuse App\\Models\\User;\nuse Illuminate\\Console\\Command;\n\nclass TriggerRegistrationWebhookForMissingNames extends Command\n{\n    /**\n     * The name and signature of the console command.\n     *\n     * @var string\n     */\n    protected $signature = 'app:trigger-registration-webhook-missing-names';\n\n    /**\n     * The console command description.\n     *\n     * @var string\n     */\n    protected $description = 'Trigger registration webhook for one user without first_name or last_name (oldest first)';\n\n    /**\n     * Execute the console command.\n     */\n    public function handle(): int\n    {\n        $user = User::whereNull('deleted_at')\n            ->where(function ($query) {\n                $query->whereNull('first_name')\n                    ->orWhereNull('last_name');\n            })\n            ->orderBy('created_at', 'asc')\n            ->first();\n\n        if (!$user) {\n            $this->info('No users found without first_name or last_name.');\n            return self::SUCCESS;\n        }\n\n        $this->info(\"Triggering registration webhook for user: {$user->email} (ID: {$user->id})\");\n\n        event(new UserRegistered($user));\n\n        $this->info('Registration webhook triggered successfully.');\n\n        return self::SUCCESS;\n    }\n}\n\n"
  },
  {
    "path": "backend/app/Console/Commands/UpdateLemonSqueezySubscriptions.php",
    "content": "<?php\n\nnamespace App\\Console\\Commands;\n\nuse App\\Enums\\DisplayStatus;\nuse App\\Models\\User;\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\nclass UpdateLemonSqueezySubscriptions extends Command\n{\n    /**\n     * The name and signature of the console command.\n     *\n     * @var string\n     */\n    protected $signature = 'app:update-lemonsqueezy-subscriptions';\n\n    /**\n     * The console command description.\n     *\n     * @var string\n     */\n    protected $description = 'Update Lemon Squeezy subscriptions using both quantity-based and usage-based billing methods';\n\n    /**\n     * Execute the console command.\n     */\n    public function handle(): int\n    {\n        if (config('settings.is_self_hosted')) {\n            $this->info('Skipping subscription update - this is a self-hosted instance');\n            return self::SUCCESS;\n        }\n\n        $this->info('Starting Lemon Squeezy subscription updates...');\n\n        // Get all users with active subscriptions\n        $usersWithSubscriptions = User::where(function ($query) {\n            $query->where('is_unlimited', true)\n                  ->orWhereHas('subscriptions', function ($subQuery) {\n                      $subQuery->where('ends_at', null) // Active subscription\n                               ->orWhere('ends_at', '>', now()); // Not expired\n                  });\n        })->get();\n\n        $this->info(\"Found {$usersWithSubscriptions->count()} users with active subscriptions\");\n\n        $successCount = 0;\n        $errorCount = 0;\n\n        foreach ($usersWithSubscriptions as $user) {\n            try {\n                $totalUsage = $this->getTotalUsageCount($user);\n                \n                if ($user->is_unlimited) {\n                    $this->line(\"Skipping unlimited user {$user->email} with {$totalUsage} total usage units\");\n                    $successCount++;\n                } else {\n                    // Try both quantity-based and usage-based billing methods\n                    $this->updateQuantityBasedBilling($user, $totalUsage);\n                    $this->updateUsageBasedBilling($user, $totalUsage);\n                    $successCount++;\n                    $this->info(\"Updated subscription for user {$user->email} with {$totalUsage} total usage units (displays + boards*2)\");\n                }\n            } catch (\\Exception $e) {\n                $errorCount++;\n                $this->error(\"Failed to update subscription for user {$user->email}: {$e->getMessage()}\");\n                Log::error('Subscription update failed', [\n                    'user_id' => $user->id,\n                    'user_email' => $user->email,\n                    'error' => $e->getMessage(),\n                    'trace' => $e->getTraceAsString()\n                ]);\n            }\n        }\n\n        $this->info(\"Subscription updates completed: {$successCount} successful, {$errorCount} errors\");\n        \n        return $errorCount === 0 ? self::SUCCESS : self::FAILURE;\n    }\n\n    /**\n     * Get the count of active displays for a user\n     */\n    private function getActiveDisplayCount(User $user): int\n    {\n        return $user->displays()\n            ->whereIn('status', [DisplayStatus::READY, DisplayStatus::ACTIVE])\n            ->count();\n    }\n\n    /**\n     * Get the total usage count for a user across all their workspaces\n     * Displays count as 1x, Boards count as 2x\n     */\n    private function getTotalUsageCount(User $user): int\n    {\n        $totalUsage = 0;\n        \n        foreach ($user->workspaces as $workspace) {\n            $totalUsage += $workspace->getTotalUsageCount();\n        }\n        \n        return $totalUsage;\n    }\n\n    /**\n     * Update subscription using quantity-based billing\n     */\n    private function updateQuantityBasedBilling(User $user, int $displayCount): void\n    {\n        // Skip unlimited users as they don't need quantity updates\n        if ($user->is_unlimited) {\n            return;\n        }\n\n        // Get the user's active subscription\n        $subscription = $user->subscriptions()\n            ->where(function($query) {\n                $query->whereNull('ends_at')\n                      ->orWhere('ends_at', '>', now());\n            })\n            ->first();\n\n        if (!$subscription) {\n            return; // No subscription found, skip silently\n        }\n\n        $apiKey = config('lemon-squeezy.api_key');\n        if (!$apiKey) {\n            return; // No API key, skip silently\n        }\n\n        try {\n            // Get subscription details from Lemon Squeezy API to find subscription items\n            $subscriptionResponse = Http::withToken($apiKey)\n                ->withHeaders([\n                    'Accept' => 'application/vnd.api+json',\n                ])\n                ->get('https://api.lemonsqueezy.com/v1/subscriptions/' . $subscription->lemon_squeezy_id);\n\n            if (!$subscriptionResponse->successful()) {\n                return; // Failed to fetch subscription, skip silently\n            }\n\n            $subscriptionData = $subscriptionResponse->json();\n            \n            // Get subscription items from the response (handle different response structures)\n            $subscriptionItems = $this->getSubscriptionItems($subscriptionData, $apiKey, $subscription->lemon_squeezy_id);\n\n            if (empty($subscriptionItems)) {\n                return; // No subscription items found, skip silently\n            }\n\n            // Find the first subscription item (assuming single item per subscription)\n            $subscriptionItem = $subscriptionItems[0];\n            $subscriptionItemId = $this->getSubscriptionItemId($subscriptionItem);\n\n            if (!$subscriptionItemId) {\n                return; // Could not get subscription item ID, skip silently\n            }\n\n            // Update subscription item quantity using quantity-based billing\n            $response = Http::withToken($apiKey)\n                ->withHeaders([\n                    'Accept' => 'application/vnd.api+json',\n                    'Content-Type' => 'application/vnd.api+json',\n                ])\n                ->patch(\"https://api.lemonsqueezy.com/v1/subscription-items/{$subscriptionItemId}\", [\n                    'data' => [\n                        'type' => 'subscription-items',\n                        'id' => $subscriptionItemId,\n                        'attributes' => [\n                            'quantity' => $displayCount\n                        ]\n                    ]\n                ]);\n\n            if ($response->successful()) {\n                Log::info('Quantity-based billing updated successfully', [\n                    'user_id' => $user->id,\n                    'user_email' => $user->email,\n                    'subscription_item_id' => $subscriptionItemId,\n                    'display_count' => $displayCount\n                ]);\n            }\n\n        } catch (\\Exception $e) {\n            // Log but don't throw - let the usage-based billing method try\n            Log::debug('Quantity-based billing update failed', [\n                'user_id' => $user->id,\n                'error' => $e->getMessage()\n            ]);\n        }\n    }\n\n    /**\n     * Update subscription using usage-based billing\n     */\n    private function updateUsageBasedBilling(User $user, int $displayCount): void\n    {\n        // Skip unlimited users as they don't need usage reporting\n        if ($user->is_unlimited) {\n            return;\n        }\n\n        // Get the user's active subscription\n        $subscription = $user->subscriptions()\n            ->where(function($query) {\n                $query->where('ends_at', null)\n                      ->orWhere('ends_at', '>', now());\n            })\n            ->first();\n\n        if (!$subscription) {\n            return; // No subscription found, skip silently\n        }\n\n        $apiKey = config('lemon-squeezy.api_key');\n        if (!$apiKey) {\n            return; // No API key, skip silently\n        }\n\n        try {\n            // Get subscription details from Lemon Squeezy API to find subscription items\n            $subscriptionResponse = Http::withToken($apiKey)\n                ->withHeaders([\n                    'Accept' => 'application/vnd.api+json',\n                ])\n                ->get('https://api.lemonsqueezy.com/v1/subscriptions/' . $subscription->lemon_squeezy_id);\n\n            if (!$subscriptionResponse->successful()) {\n                return; // Failed to fetch subscription, skip silently\n            }\n\n            $subscriptionData = $subscriptionResponse->json();\n            \n            // Get subscription items from the response (handle different response structures)\n            $subscriptionItems = $this->getSubscriptionItems($subscriptionData, $apiKey, $subscription->lemon_squeezy_id);\n\n            if (empty($subscriptionItems)) {\n                return; // No subscription items found, skip silently\n            }\n\n            // Find the first subscription item (assuming single item per subscription)\n            $subscriptionItem = $subscriptionItems[0];\n            $subscriptionItemId = $this->getSubscriptionItemId($subscriptionItem);\n\n            if (!$subscriptionItemId) {\n                return; // Could not get subscription item ID, skip silently\n            }\n\n            // Report usage to Lemon Squeezy using the usage-records API endpoint\n            $response = Http::withToken($apiKey)\n                ->withHeaders([\n                    'Accept' => 'application/vnd.api+json',\n                    'Content-Type' => 'application/vnd.api+json',\n                ])\n                ->post('https://api.lemonsqueezy.com/v1/usage-records', [\n                    'data' => [\n                        'type' => 'usage-records',\n                        'attributes' => [\n                            'quantity' => $displayCount,\n                            'action' => 'set', // Set the usage count for the current period\n                        ],\n                        'relationships' => [\n                            'subscription-item' => [\n                                'data' => [\n                                    'type' => 'subscription-items',\n                                    'id' => $subscriptionItemId\n                                ]\n                            ]\n                        ]\n                    ]\n                ]);\n\n            if ($response->successful()) {\n                Log::info('Usage-based billing updated successfully', [\n                    'user_id' => $user->id,\n                    'user_email' => $user->email,\n                    'subscription_item_id' => $subscriptionItemId,\n                    'display_count' => $displayCount\n                ]);\n            }\n\n        } catch (\\Exception $e) {\n            // Log but don't throw - let the quantity-based billing method try\n            Log::debug('Usage-based billing update failed', [\n                'user_id' => $user->id,\n                'error' => $e->getMessage()\n            ]);\n        }\n    }\n\n    /**\n     * Extract subscription items from Lemon Squeezy API response\n     */\n    private function getSubscriptionItems(array $subscriptionData, string $apiKey, string $subscriptionId): array\n    {\n        $subscriptionItems = [];\n        \n        // Check if subscription_items is in the attributes\n        if (isset($subscriptionData['data']['attributes']['subscription_items'])) {\n            $subscriptionItems = $subscriptionData['data']['attributes']['subscription_items'];\n        }\n        // Check if subscription_items is in the relationships\n        elseif (isset($subscriptionData['data']['relationships']['subscription_items']['data'])) {\n            $subscriptionItems = $subscriptionData['data']['relationships']['subscription_items']['data'];\n        }\n        // Check if subscription_items is in the included data\n        elseif (isset($subscriptionData['included'])) {\n            $subscriptionItems = collect($subscriptionData['included'])\n                ->filter(fn($item) => $item['type'] === 'subscription-items')\n                ->toArray();\n        }\n        else {\n            // Try to fetch subscription items directly\n            $subscriptionItemsResponse = Http::withToken($apiKey)\n                ->withHeaders([\n                    'Accept' => 'application/vnd.api+json',\n                ])\n                ->get('https://api.lemonsqueezy.com/v1/subscription-items?filter[subscription_id]=' . $subscriptionId);\n\n            if ($subscriptionItemsResponse->successful()) {\n                $subscriptionItemsData = $subscriptionItemsResponse->json();\n                \n                if (isset($subscriptionItemsData['data']) && !empty($subscriptionItemsData['data'])) {\n                    $subscriptionItems = $subscriptionItemsData['data'];\n                }\n            }\n        }\n\n        return $subscriptionItems;\n    }\n\n    /**\n     * Extract subscription item ID from Lemon Squeezy API response\n     */\n    private function getSubscriptionItemId(array $subscriptionItem): ?string\n    {\n        // Handle different response structures\n        if (isset($subscriptionItem['id'])) {\n            return $subscriptionItem['id'];\n        } elseif (isset($subscriptionItem['attributes']['id'])) {\n            return $subscriptionItem['attributes']['id'];\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "backend/app/Console/Commands/ValidateLicense.php",
    "content": "<?php\n\nnamespace App\\Console\\Commands;\n\nuse App\\Data\\LicenseData;\nuse App\\Services\\InstanceService;\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Support\\Facades\\Http;\n\nclass ValidateLicense extends Command\n{\n    protected $signature = 'spacepad:validate';\n    protected $description = 'Send a validate request to the Spacepad server';\n\n    /**\n     * @throws ConnectionException\n     */\n    public function handle(InstanceService $instanceService): int\n    {\n        $data = $instanceService->getInstanceData();\n\n        $response = Http::acceptJson()->post(config('settings.license_server') . '/api/v1/instances/validate', $data);\n        if ($response->successful()) {\n            $this->info('Validation successfully');\n\n            $licenseData = LicenseData::from($response->json()['data']);\n            $instanceService->updateLicense($licenseData);\n\n            return self::SUCCESS;\n        }\n\n        $this->error('Failed to validate: ' . $response->body());\n        return self::FAILURE;\n    }\n}\n"
  },
  {
    "path": "backend/app/Data/CalendarWebhookData.php",
    "content": "<?php\n\nnamespace App\\Data;\n\nuse Spatie\\LaravelData\\Data;\n\nclass CalendarWebhookData extends Data\n{\n    public function __construct(\n        public string $id,\n        public string $name,\n        public ?string $googleAccountId,\n        public ?string $outlookAccountId,\n        public ?string $caldavAccountId,\n        public array &$providers = []\n    ) {\n        if ($googleAccountId) {\n            $providers[] = 'Google';\n        }\n        if ($outlookAccountId) {\n            $providers[] = 'Outlook';\n        }\n        if ($caldavAccountId) {\n            $providers[] = 'CalDAV';\n        }\n    }\n\n    public function excludeProperties(): array\n    {\n        return [\n            'googleAccountId',\n            'outlookAccountId',\n            'caldavAccountId',\n        ];\n    }\n}\n"
  },
  {
    "path": "backend/app/Data/DisplayWebhookData.php",
    "content": "<?php\n\nnamespace App\\Data;\n\nuse Spatie\\LaravelData\\Data;\n\nclass DisplayWebhookData extends Data\n{\n    public function __construct(\n        public string $id,\n        public string $name,\n    ) {\n    }\n}\n"
  },
  {
    "path": "backend/app/Data/InstanceData.php",
    "content": "<?php\n\nnamespace App\\Data;\n\nuse Illuminate\\Support\\Carbon;\nuse Spatie\\LaravelData\\Data;\nuse Spatie\\LaravelData\\Attributes\\MapName;\n\nclass InstanceData extends Data\n{\n    public function __construct(\n        #[MapName('instance_key')]\n        public string $instanceKey,\n\n        // License & activation\n        #[MapName('license_key')]\n        public ?string $licenseKey,\n        #[MapName('license_valid')]\n        public ?bool $licenseValid,\n        #[MapName('license_expires_at')]\n        public ?Carbon $licenseExpiresAt,\n\n        // Tampering\n        #[MapName('is_self_hosted')]\n        public bool $isSelfHosted,\n\n        // Usage\n        #[MapName('displays_count')]\n        public int $displaysCount,\n        #[MapName('rooms_count')]\n        public int $roomsCount,\n        #[MapName('boards_count')]\n        public ?int $boardsCount = null,\n\n        // Telemetry\n        public string $version,\n\n        public array $users = [],\n    ) {}\n}\n"
  },
  {
    "path": "backend/app/Data/LicenseData.php",
    "content": "<?php\n\nnamespace App\\Data;\n\nuse App\\Models\\Instance;\nuse Spatie\\LaravelData\\Data;\nuse Carbon\\Carbon;\n\nclass LicenseData extends Data\n{\n    public function __construct(\n        public ?string $licenseKey,\n        public ?bool $valid = false,\n        public ?Carbon $expiresAt = null,\n    ) {}\n\n    public static function fromModel(Instance $instance): self\n    {\n        return new self(\n            licenseKey: $instance->license_key,\n            valid: $instance->license_valid,\n            expiresAt: $instance->license_expires_at,\n        );\n    }\n}\n"
  },
  {
    "path": "backend/app/Data/OrderWebhookData.php",
    "content": "<?php\n\nnamespace App\\Data;\n\nuse Spatie\\LaravelData\\Data;\n\nclass OrderWebhookData extends Data\n{\n    public function __construct(\n        public string $id,\n        public string $total,\n        public string $status,\n    ) {\n    }\n}\n"
  },
  {
    "path": "backend/app/Data/PermissionResult.php",
    "content": "<?php\n\nnamespace App\\Data;\n\nclass PermissionResult\n{\n    public readonly bool $permitted;\n    public readonly ?string $message;\n    public readonly ?int $code;\n\n    public function __construct(bool $permitted, ?string $message = null, ?int $code = null)\n    {\n        $this->permitted = $permitted;\n        $this->message = $message;\n        $this->code = $code;\n    }\n} "
  },
  {
    "path": "backend/app/Data/UserData.php",
    "content": "<?php\n\nnamespace App\\Data;\n\nuse Spatie\\LaravelData\\Data;\nuse Spatie\\LaravelData\\Attributes\\MapName;\nuse Carbon\\Carbon;\n\nclass UserData extends Data\n{\n    public function __construct(\n        public string $email,\n        #[MapName('usage_type')]\n        public ?string $usageType,\n        #[MapName('is_unlimited')]\n        public bool $isUnlimited,\n        #[MapName('terms_accepted_at')]\n        public ?string $termsAcceptedAt,\n    ) {}\n}\n"
  },
  {
    "path": "backend/app/Data/UserWebhookData.php",
    "content": "<?php\n\nnamespace App\\Data;\n\nuse Illuminate\\Support\\Carbon;\nuse Spatie\\LaravelData\\Data;\n\nclass UserWebhookData extends Data\n{\n    public function __construct(\n        public string $id,\n        public string $name,\n        public ?string $firstName,\n        public ?string $lastName,\n        public string $email,\n        public string $status,\n        public ?Carbon $emailVerifiedAt,\n        public ?string $microsoftId,\n        public ?string $googleId,\n        public ?bool $isBillingExempt,\n        public ?bool $isUnlimited,\n        public ?Carbon $lastActivityAt,\n        public Carbon $createdAt,\n        public Carbon $updatedAt,\n        public array &$providers = []\n    ) {\n        if ($emailVerifiedAt) {\n            $providers[] = 'Email';\n        }\n        if ($microsoftId) {\n            $providers[] = 'Microsoft';\n        }\n        if ($googleId) {\n            $providers[] = 'Google';\n        }\n    }\n\n    public function excludeProperties(): array\n    {\n        return [\n            'microsoftId',\n            'googleId',\n        ];\n    }\n}\n"
  },
  {
    "path": "backend/app/Enums/AccountStatus.php",
    "content": "<?php\n\nnamespace App\\Enums;\n\nenum AccountStatus: string\n{\n    case CONNECTED = 'connected';\n    case ERROR = 'error';\n\n    public function label(): string\n    {\n        return match($this) {\n            self::CONNECTED => 'Connected',\n            self::ERROR => 'Error - needs re-authentication',\n        };\n    }\n\n    public function color(): string\n    {\n        return match($this) {\n            self::CONNECTED => 'green',\n            self::ERROR => 'red',\n        };\n    }\n} "
  },
  {
    "path": "backend/app/Enums/DisplayStatus.php",
    "content": "<?php\n\nnamespace App\\Enums;\n\nenum DisplayStatus: string\n{\n    case READY = 'ready';\n    case ACTIVE = 'active';\n    case DEACTIVATED = 'deactivated';\n    case ERROR = 'error';\n\n    public function label(): string\n    {\n        return match($this) {\n            self::READY => 'Ready',\n            self::ACTIVE => 'Active',\n            self::DEACTIVATED => 'Deactivated',\n            self::ERROR => 'Error - try recreating',\n        };\n    }\n\n    public function color(): string\n    {\n        return match($this) {\n            self::READY => 'blue',\n            self::ACTIVE => 'green',\n            self::DEACTIVATED => 'gray',\n            self::ERROR => 'red',\n        };\n    }\n}\n"
  },
  {
    "path": "backend/app/Enums/EventSource.php",
    "content": "<?php\n\nnamespace App\\Enums;\n\nenum EventSource\n{\n    const GOOGLE = 'google';\n    const OUTLOOK = 'outlook';\n    const CALDAV = 'caldav';\n    const CUSTOM = 'custom';\n}\n"
  },
  {
    "path": "backend/app/Enums/EventStatus.php",
    "content": "<?php\n\nnamespace App\\Enums;\n\nenum EventStatus: string\n{\n    case CONFIRMED = 'confirmed';\n    case TENTATIVE = 'tentative';\n    case CANCELLED = 'cancelled';\n}\n"
  },
  {
    "path": "backend/app/Enums/GoogleBookingMethod.php",
    "content": "<?php\n\nnamespace App\\Enums;\n\nenum GoogleBookingMethod: string\n{\n    case SERVICE_ACCOUNT = 'service_account';\n    case USER_ACCOUNT = 'user_account';\n}\n\n"
  },
  {
    "path": "backend/app/Enums/OAuthDriver.php",
    "content": "<?php\n\nnamespace App\\Enums;\n\nenum OAuthDriver\n{\n    const MICROSOFT = 'microsoft';\n    const GOOGLE = 'google';\n}\n"
  },
  {
    "path": "backend/app/Enums/PermissionType.php",
    "content": "<?php\n\nnamespace App\\Enums;\n\nenum PermissionType: string\n{\n    case READ = 'read';\n    case WRITE = 'write';\n\n    public function label(): string\n    {\n        return match($this) {\n            self::READ => 'Read Only',\n            self::WRITE => 'Read & Write',\n        };\n    }\n\n    public function description(): string\n    {\n        return match($this) {\n            self::READ => 'View calendar events and room availability. Cannot create or modify events.',\n            self::WRITE => 'View calendar events and create new bookings. Required for ad-hoc room bookings when users book rooms directly from the tablet display.',\n        };\n    }\n}\n\n"
  },
  {
    "path": "backend/app/Enums/Plan.php",
    "content": "<?php\n\nnamespace App\\Enums;\n\nenum Plan\n{\n    const WAITLIST = 'waitlist';\n    const FREE = 'free';\n    const FAIR = 'fair';\n    const SUPPORTER = 'supporter';\n}\n"
  },
  {
    "path": "backend/app/Enums/Provider.php",
    "content": "<?php\n\nnamespace App\\Enums;\n\nenum Provider\n{\n    const GOOGLE = 'google';\n    const OUTLOOK = 'outlook';\n    const CALDAV = 'caldav';\n}\n"
  },
  {
    "path": "backend/app/Enums/UsageType.php",
    "content": "<?php\n\nnamespace App\\Enums;\n\nenum UsageType: string\n{\n    case BUSINESS = 'business';\n    case PERSONAL = 'personal';\n\n    public function label(): string\n    {\n        return match($this) {\n            self::BUSINESS => 'Business',\n            self::PERSONAL => 'Personal / Community',\n        };\n    }\n}\n"
  },
  {
    "path": "backend/app/Enums/UserStatus.php",
    "content": "<?php\n\nnamespace App\\Enums;\n\nenum UserStatus\n{\n    const ONBOARDING = 'onboarding';\n    const ACTIVE = 'active';\n    const BLOCKED = 'blocked';\n}\n"
  },
  {
    "path": "backend/app/Enums/WorkspaceRole.php",
    "content": "<?php\n\nnamespace App\\Enums;\n\nenum WorkspaceRole: string\n{\n    case OWNER = 'owner';\n    case ADMIN = 'admin';\n    case MEMBER = 'member';\n\n    public function label(): string\n    {\n        return match($this) {\n            self::OWNER => 'Owner',\n            self::ADMIN => 'Admin',\n            self::MEMBER => 'Member',\n        };\n    }\n\n    /**\n     * Check if this role can manage the workspace\n     */\n    public function canManage(): bool\n    {\n        return in_array($this, [self::OWNER, self::ADMIN]);\n    }\n}\n\n"
  },
  {
    "path": "backend/app/Events/TrialExpiredOrCancelled.php",
    "content": "<?php\n\nnamespace App\\Events;\n\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Bus\\Dispatchable;\nuse Illuminate\\Queue\\SerializesModels;\n\nclass TrialExpiredOrCancelled\n{\n    use Dispatchable, SerializesModels;\n\n    /**\n     * Create a new event instance.\n     */\n    public function __construct(public User $user)\n    {\n        //\n    }\n}\n\n"
  },
  {
    "path": "backend/app/Events/UserActivatedAfter24h.php",
    "content": "<?php\n\nnamespace App\\Events;\n\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Bus\\Dispatchable;\nuse Illuminate\\Queue\\SerializesModels;\n\nclass UserActivatedAfter24h\n{\n    use Dispatchable, SerializesModels;\n\n    /**\n     * Create a new event instance.\n     */\n    public function __construct(public User $user)\n    {\n        //\n    }\n}\n\n"
  },
  {
    "path": "backend/app/Events/UserInactive.php",
    "content": "<?php\n\nnamespace App\\Events;\n\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Bus\\Dispatchable;\nuse Illuminate\\Queue\\SerializesModels;\n\nclass UserInactive\n{\n    use Dispatchable, SerializesModels;\n\n    /**\n     * Create a new event instance.\n     */\n    public function __construct(public User $user)\n    {\n        //\n    }\n}\n\n"
  },
  {
    "path": "backend/app/Events/UserNotActivatedAfter24h.php",
    "content": "<?php\n\nnamespace App\\Events;\n\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Bus\\Dispatchable;\nuse Illuminate\\Queue\\SerializesModels;\n\nclass UserNotActivatedAfter24h\n{\n    use Dispatchable, SerializesModels;\n\n    /**\n     * Create a new event instance.\n     */\n    public function __construct(public User $user)\n    {\n        //\n    }\n}\n\n"
  },
  {
    "path": "backend/app/Events/UserOnboarded.php",
    "content": "<?php\n\nnamespace App\\Events;\n\nuse App\\Models\\Display;\nuse App\\Models\\User;\nuse Illuminate\\Queue\\SerializesModels;\n\nclass UserOnboarded\n{\n    use SerializesModels;\n\n    /**\n     * Create a new event instance.\n     */\n    public function __construct(public User $user, public Display $display)\n    {\n        //\n    }\n}\n"
  },
  {
    "path": "backend/app/Events/UserPassive.php",
    "content": "<?php\n\nnamespace App\\Events;\n\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Bus\\Dispatchable;\nuse Illuminate\\Queue\\SerializesModels;\n\nclass UserPassive\n{\n    use Dispatchable, SerializesModels;\n\n    /**\n     * Create a new event instance.\n     */\n    public function __construct(public User $user)\n    {\n        //\n    }\n}\n\n"
  },
  {
    "path": "backend/app/Events/UserRegistered.php",
    "content": "<?php\n\nnamespace App\\Events;\n\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Bus\\Dispatchable;\nuse Illuminate\\Queue\\SerializesModels;\n\nclass UserRegistered\n{\n    use Dispatchable, SerializesModels;\n\n    /**\n     * Create a new event instance.\n     */\n    public function __construct(public User $user)\n    {\n        //\n    }\n}\n"
  },
  {
    "path": "backend/app/Exceptions/Handler.php",
    "content": "<?php\n\nnamespace App\\Exceptions;\n\nuse Illuminate\\Foundation\\Exceptions\\Handler as ExceptionHandler;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Response;\nuse Illuminate\\Validation\\ValidationException;\nuse Illuminate\\Auth\\AuthenticationException;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\nuse Symfony\\Component\\HttpKernel\\Exception\\HttpExceptionInterface;\nuse Throwable;\n\nclass Handler extends ExceptionHandler\n{\n    /**\n     * The list of the inputs that are never flashed to the session on validation exceptions.\n     *\n     * @var array<int, string>\n     */\n    protected $dontFlash = [\n        'current_password',\n        'password',\n        'password_confirmation',\n    ];\n\n    /**\n     * Register the exception handling callbacks for the application.\n     */\n    public function register(): void\n    {\n        //\n    }\n\n    /**\n     * Render an exception into an HTTP response.\n     * @throws Throwable\n     */\n    public function render($request, Throwable $e): Response|JsonResponse|\\Symfony\\Component\\HttpFoundation\\Response\n    {\n        // Log exceptions with context (skip 4xx noise; avoid leaking details in prod)\n        if (\n            $this->shouldReport($e) &&\n            !($e instanceof NotFoundHttpException) &&\n            !($e instanceof ValidationException) &&\n            !($e instanceof HttpExceptionInterface && $e->getStatusCode() < 500)\n        ) {\n            $logLevel = $e instanceof AuthenticationException ? 'warning' : 'error';\n\n            $context = [\n                'exception' => get_class($e),\n                'route' => $request->route()?->getName(),\n                'path' => $request->path(),\n                'method' => $request->method(),\n                'user_id' => auth()->id(),\n            ];\n\n            if (config('app.debug')) {\n                $context += [\n                    'message' => $e->getMessage(),\n                    'code' => $e->getCode(),\n                    'file' => $e->getFile(),\n                    'line' => $e->getLine(),\n                    'ip' => $request->ip(),\n                    'user_agent' => substr($request->userAgent() ?? '', 0, 200),\n                    'trace' => substr($e->getTraceAsString(), 0, 1000),\n                ];\n            }\n\n            logger()->{$logLevel}('Unhandled exception', $context);\n        }\n\n        if ($request->expectsJson()) {\n            $status = 500;\n            $message = 'Server Error';\n            $errors = config('app.debug') ? $e->getMessage() : null;\n\n            if ($e instanceof ValidationException) {\n                $status = 422;\n                $message = 'Validation Error';\n                $errors = $e->errors();\n            }\n\n            if ($e instanceof AuthenticationException) {\n                $status = 401;\n                $message = 'Unauthenticated';\n            }\n\n            if ($e instanceof NotFoundHttpException) {\n                $status = 404;\n                $message = 'Resource not found';\n            }\n\n            return response()->json([\n                'success' => false,\n                'message' => $message,\n                'errors' => $errors,\n            ], $status);\n        }\n\n        return parent::render($request, $e);\n    }\n}\n"
  },
  {
    "path": "backend/app/Helpers/DisplaySettings.php",
    "content": "<?php\n\nnamespace App\\Helpers;\n\nuse App\\Models\\Display;\nuse App\\Models\\DisplaySetting;\n\nclass DisplaySettings\n{\n    public static function getSetting(Display $display, string $key, mixed $default = null): mixed\n    {\n        // If settings relationship is already loaded, use it to avoid N+1 queries\n        if ($display->relationLoaded('settings')) {\n            $setting = $display->settings->firstWhere('key', $key);\n            return $setting?->value ?? $default;\n        }\n\n        // Fallback to querying if relationship is not loaded (backward compatibility)\n        $setting = DisplaySetting::where('display_id', $display->id)\n            ->where('key', $key)\n            ->first();\n\n        return $setting?->value ?? $default;\n    }\n\n    public static function setSetting(Display $display, string $key, mixed $value, string $type = 'string'): bool\n    {\n        try {\n            DisplaySetting::updateOrCreate(\n                [\n                    'display_id' => $display->id,\n                    'key' => $key,\n                ],\n                [\n                    'value' => $value,\n                    'type' => $type,\n                ]\n            );\n            return true;\n        } catch (\\Exception $e) {\n            report($e);\n            return false;\n        }\n    }\n\n    public static function deleteSetting(Display $display, string $key): bool\n    {\n        try {\n            return DisplaySetting::where('display_id', $display->id)\n                ->where('key', $key)\n                ->delete() > 0;\n        } catch (\\Exception $e) {\n            report($e);\n            return false;\n        }\n    }\n\n    public static function getAllSettings(Display $display): array\n    {\n        // If settings relationship is already loaded, use it to avoid N+1 queries\n        if ($display->relationLoaded('settings')) {\n            return $display->settings->mapWithKeys(function ($setting) {\n                return [$setting->key => $setting->value];\n            })->toArray();\n        }\n\n        // Fallback to querying if relationship is not loaded (backward compatibility)\n        return DisplaySetting::where('display_id', $display->id)\n            ->get()\n            ->mapWithKeys(function ($setting) {\n                return [$setting->key => $setting->value];\n            })\n            ->toArray();\n    }\n\n    // Convenience methods for common settings\n    public static function isCheckInEnabled(Display $display): bool\n    {\n        return self::getSetting($display, 'check_in_enabled', false);\n    }\n\n    public static function setCheckInEnabled(Display $display, bool $enabled): bool\n    {\n        return self::setSetting($display, 'check_in_enabled', $enabled, 'boolean');\n    }\n\n    public static function isBookingEnabled(Display $display): bool\n    {\n        return self::getSetting($display, 'booking_enabled', false);\n    }\n\n    public static function setBookingEnabled(Display $display, bool $enabled): bool\n    {\n        return self::setSetting($display, 'booking_enabled', $enabled, 'boolean');\n    }\n\n    // Logo settings\n    public static function getLogo(Display $display): ?string\n    {\n        return self::getSetting($display, 'logo');\n    }\n\n    public static function setLogo(Display $display, string $logoPath): bool\n    {\n        return self::setSetting($display, 'logo', $logoPath, 'string');\n    }\n\n    public static function removeLogo(Display $display): bool\n    {\n        return self::deleteSetting($display, 'logo');\n    }\n\n    // Background image settings\n    public static function getBackgroundImage(Display $display): ?string\n    {\n        return self::getSetting($display, 'background_image');\n    }\n\n    public static function setBackgroundImage(Display $display, string $backgroundPath): bool\n    {\n        return self::setSetting($display, 'background_image', $backgroundPath, 'string');\n    }\n\n    public static function removeBackgroundImage(Display $display): bool\n    {\n        return self::deleteSetting($display, 'background_image');\n    }\n\n    // Font family settings\n    public static function getFontFamily(Display $display): string\n    {\n        return self::getSetting($display, 'font_family', 'Inter');\n    }\n\n    public static function setFontFamily(Display $display, string $fontFamily): bool\n    {\n        return self::setSetting($display, 'font_family', $fontFamily, 'string');\n    }\n\n    public static function getCheckInMinutes(Display $display): int\n    {\n        return self::getSetting($display, 'check_in_minutes', 15);\n    }\n\n    public static function setCheckInMinutes(Display $display, int $minutes): bool\n    {\n        return self::setSetting($display, 'check_in_minutes', $minutes, 'integer');\n    }\n\n    public static function getCheckInGracePeriod(Display $display): int\n    {\n        return self::getSetting($display, 'check_in_grace_period', 5);\n    }\n\n    public static function setCheckInGracePeriod(Display $display, int $minutes): bool\n    {\n        return self::setSetting($display, 'check_in_grace_period', $minutes, 'integer');\n    }\n\n    public static function isCalendarEnabled(Display $display): bool\n    {\n        return self::getSetting($display, 'calendar_enabled', false);\n    }\n\n    public static function setCalendarEnabled(Display $display, bool $enabled): bool\n    {\n        return self::setSetting($display, 'calendar_enabled', $enabled, 'boolean');\n    }\n\n    // Customizable display state texts (shorter keys)\n    public static function getAvailableText(Display $display): ?string\n    {\n        return self::getSetting($display, 'text_available');\n    }\n    public static function setAvailableText(Display $display, string $text): bool\n    {\n        return self::setSetting($display, 'text_available', $text, 'string');\n    }\n\n    public static function getTransitioningText(Display $display): ?string\n    {\n        return self::getSetting($display, 'text_transitioning');\n    }\n    public static function setTransitioningText(Display $display, string $text): bool\n    {\n        return self::setSetting($display, 'text_transitioning', $text, 'string');\n    }\n\n    public static function getReservedText(Display $display): ?string\n    {\n        return self::getSetting($display, 'text_reserved');\n    }\n    public static function setReservedText(Display $display, string $text): bool\n    {\n        return self::setSetting($display, 'text_reserved', $text, 'string');\n    }\n\n    public static function getCheckInText(Display $display): ?string\n    {\n        return self::getSetting($display, 'text_checkin');\n    }\n    public static function setCheckInText(Display $display, string $text): bool\n    {\n        return self::setSetting($display, 'text_checkin', $text, 'string');\n    }\n\n    // Toggle for showing meeting title\n    public static function getShowMeetingTitle(Display $display): bool\n    {\n        return self::getSetting($display, 'show_meeting_title', true);\n    }\n    public static function setShowMeetingTitle(Display $display, bool $show): bool\n    {\n        return self::setSetting($display, 'show_meeting_title', $show, 'boolean');\n    }\n\n    // Admin actions visibility\n    public static function isAdminActionsHidden(Display $display): bool\n    {\n        return self::getSetting($display, 'hide_admin_actions', false);\n    }\n\n    public static function setAdminActionsHidden(Display $display, bool $hidden): bool\n    {\n        return self::setSetting($display, 'hide_admin_actions', $hidden, 'boolean');\n    }\n\n    // Cancel permission settings\n    // Values: 'all' (default), 'tablet_only', 'none'\n    public static function getCancelPermission(Display $display): string\n    {\n        return self::getSetting($display, 'cancel_permission', 'all');\n    }\n\n    public static function setCancelPermission(Display $display, string $permission): bool\n    {\n        if (!in_array($permission, ['all', 'tablet_only', 'none'])) {\n            return false;\n        }\n        return self::setSetting($display, 'cancel_permission', $permission, 'string');\n    }\n\n    // Border thickness settings\n    // Values: 'small', 'medium' (default), 'large'\n    public static function getBorderThickness(Display $display): string\n    {\n        return self::getSetting($display, 'border_thickness', 'medium');\n    }\n\n    public static function setBorderThickness(Display $display, string $thickness): bool\n    {\n        if (!in_array($thickness, ['small', 'medium', 'large'])) {\n            return false;\n        }\n        return self::setSetting($display, 'border_thickness', $thickness, 'string');\n    }\n}\n"
  },
  {
    "path": "backend/app/Helpers/Settings.php",
    "content": "<?php\n\nnamespace App\\Helpers;\n\nuse App\\Models\\Setting;\n\nclass Settings\n{\n    public static function getSetting(string $key, mixed $default = null): mixed\n    {\n        $setting = Setting::where('key', $key)->first();\n        return $setting?->value ?? $default;\n    }\n\n    public static function setSetting(string $key, mixed $value, string $type = 'string'): bool\n    {\n        try {\n            Setting::updateOrCreate(\n                ['key' => $key],\n                [\n                    'value' => $value,\n                    'type' => $type,\n                ]\n            );\n            return true;\n        } catch (\\Exception $e) {\n            report($e);\n            return false;\n        }\n    }\n\n    public static function deleteSetting(string $key): bool\n    {\n        try {\n            return Setting::where('key', $key)->delete() > 0;\n        } catch (\\Exception $e) {\n            report($e);\n            return false;\n        }\n    }\n\n    public static function getAllSettings(): array\n    {\n        return Setting::all()->mapWithKeys(function ($setting) {\n            return [$setting->key => $setting->value];\n        })->toArray();\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/API/ApiController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers\\API;\n\nuse App\\Http\\Controllers\\Controller;\nuse Illuminate\\Http\\JsonResponse;\n\nclass ApiController extends Controller\n{\n    protected function success(string $message = 'Success', mixed $data = null, int $code = 200): JsonResponse\n    {\n        return response()->json([\n            'success' => true,\n            'message' => $message,\n            'data' => $data,\n        ], $code);\n    }\n\n    protected function error(string $message = 'Error', mixed $errors = null, int $code = 400): JsonResponse\n    {\n        // Log API errors for observability (skip 404s and auth errors to avoid noise)\n        if ($code >= 500 || ($code >= 400 && $code < 404)) {\n            logger()->warning('API error response', [\n                'message' => $message,\n                'code' => $code,\n                'errors' => $errors,\n                'route' => request()->route()?->getName(),\n                'path' => request()->path(),\n                'method' => request()->method(),\n                'ip' => request()->ip(),\n                'user_id' => auth()->id(),\n            ]);\n        }\n\n        return response()->json([\n            'success' => false,\n            'message' => $message,\n            'errors' => $errors,\n        ], $code);\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/API/Auth/AuthController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers\\API\\Auth;\n\nuse App\\Http\\Controllers\\API\\ApiController;\nuse App\\Http\\Requests\\API\\Auth\\LoginRequest;\nuse App\\Http\\Resources\\API\\DeviceResource;\nuse App\\Models\\Device;\nuse App\\Models\\User;\nuse App\\Services\\OutlookService;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Validation\\ValidationException;\nuse Psr\\Container\\ContainerExceptionInterface;\nuse Psr\\Container\\NotFoundExceptionInterface;\n\nclass AuthController extends ApiController\n{\n    public function __construct(protected OutlookService $outlookService)\n    {\n    }\n\n    /**\n     * @throws ContainerExceptionInterface\n     * @throws NotFoundExceptionInterface\n     * @throws ValidationException\n     */\n    public function login(LoginRequest $request): JsonResponse\n    {\n        $code = $request->validated()['code'];\n        $uid = $request->validated()['uid'];\n        $name = $request->validated()['name'] ?? 'Unknown';\n        \n        // Atomically retrieve and invalidate the connect code\n        $connectedUserId = User::pullConnectCode($code);\n\n        // Check if the code is a valid connect code and user exists\n        if ($connectedUserId !== null) {\n            $user = User::find($connectedUserId);\n            \n            // Verify user exists before proceeding\n            if (!$user) {\n                logger()->warning('Device authentication failed - user not found', [\n                    'user_id' => $connectedUserId,\n                    'code_prefix' => substr($code, 0, 3) . '...',\n                    'device_uid' => substr($uid, 0, 8) . '...',\n                    'ip' => $request->ip(),\n                ]);\n\n                return $this->error(\n                    message: 'Code is incorrect.',\n                    errors: [\n                        'code' => [\n                            'incorrect',\n                        ]\n                    ]\n                );\n            }\n\n            $workspace = $user->primaryWorkspace();\n\n            $device = Device::firstOrCreate([\n                'user_id' => $connectedUserId,\n                'uid' => $uid,\n            ],[\n                'user_id' => $connectedUserId,\n                'workspace_id' => $workspace?->id,\n                'uid' => $uid,\n                'name' => $name,\n            ]);\n\n            // Update device name and workspace_id if device already existed\n            $updateData = ['name' => $name];\n            if ($device->workspace_id === null && $workspace) {\n                $updateData['workspace_id'] = $workspace->id;\n            }\n            $device->update($updateData);\n\n            logger()->info('Device authentication successful', [\n                'user_id' => $connectedUserId,\n                'device_id' => $device->id,\n                'device_uid' => substr($uid, 0, 8) . '...',\n                'device_name' => $name,\n                'ip' => $request->ip(),\n                'user_agent' => substr($request->userAgent() ?? '', 0, 100),\n            ]);\n\n            return $this->success(\n                data: [\n                    'token' => $device->createToken('device-token')->plainTextToken,\n                    'device' => DeviceResource::make($device),\n                ]\n            );\n        }\n\n        logger()->warning('Device authentication failed - invalid connect code', [\n            'code_prefix' => substr($code, 0, 3) . '...',\n            'device_uid' => substr($uid, 0, 8) . '...',\n            'ip' => $request->ip(),\n            'user_agent' => substr($request->userAgent() ?? '', 0, 100),\n        ]);\n\n        return $this->error(\n            message: 'Code is incorrect.',\n            errors: [\n                'code' => [\n                    'incorrect',\n                ]\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/API/Cloud/InstanceController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers\\API\\Cloud;\n\nuse App\\Data\\LicenseData;\nuse App\\Http\\Controllers\\API\\ApiController;\nuse App\\Http\\Requests\\API\\InstanceHeartbeatRequest;\nuse App\\Http\\Requests\\API\\ValidateInstanceRequest;\nuse App\\Infrastructure\\Cloud\\LicenseService;\nuse App\\Models\\Instance;\nuse App\\Services\\InstanceService;\nuse Illuminate\\Http\\JsonResponse;\nuse LemonSqueezy\\Laravel\\Exceptions\\LemonSqueezyApiError;\nuse LemonSqueezy\\Laravel\\Exceptions\\LicenseKeyNotFound;\n\nclass InstanceController extends ApiController\n{\n    public function __construct(\n        protected InstanceService $instanceService\n    ) {}\n\n    /**\n     * Pseudonymize an IP address using HMAC-SHA256 with APP_KEY.\n     * This allows consistent pseudonymization for audit purposes while protecting PII.\n     * Returns first 16 characters of the hash for readability.\n     *\n     * @param string $ip\n     * @return string\n     */\n    private function pseudonymizeIp(string $ip): string\n    {\n        $key = config('app.key');\n        $hash = hash_hmac('sha256', $ip, $key);\n        return substr($hash, 0, 16);\n    }\n\n    public function heartbeat(InstanceHeartbeatRequest $request): JsonResponse\n    {\n        // Security: Log instance heartbeat for audit trail\n        logger()->info('Instance heartbeat received', [\n            'instance_key' => substr($request['instance_key'], 0, 8) . '...', // Log partial key only\n            'ip_hash' => $this->pseudonymizeIp(request()->ip()),\n            'version' => $request['version'],\n        ]);\n\n        // First, try to find an existing instance with the same instance_key\n        $existingInstance = Instance::query()\n            ->where('instance_key', $request['instance_key'])\n            ->latest()\n            ->first();\n        \n        // Second, try to find an existing instance with the same user data by comparing JSON strings directly\n        // Direct JSON comparison works for both SQLite (TEXT) and MySQL (JSON type)\n        // Always convert users to JSON string for comparison, regardless of input type\n        $usersValue = $request['users'];\n        $usersJson = is_string($usersValue) \n            ? $usersValue \n            : json_encode($usersValue, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);\n        \n        // Ensure it's always a string (not array) for database comparison\n        $usersJson = (string) $usersJson;\n        \n        $existingInstance = $existingInstance ?? Instance::query()\n            ->whereRaw('users = ?', [$usersJson])\n            ->latest()\n            ->first();\n\n        $instanceData = [\n            'instance_key' => $request['instance_key'],\n            'license_key' => $request['license_key'],\n            'license_valid' => $request['license_valid'],\n            'license_expires_at' => $request['license_expires_at'],\n            'is_self_hosted' => $request['is_self_hosted'],\n            'displays_count' => $request['displays_count'],\n            'rooms_count' => $request['rooms_count'],\n            'boards_count' => $request['boards_count'] ?? null,\n            'users' => $request['users'],\n            'version' => $request['version'],\n            'last_heartbeat_at' => now(),\n        ];\n\n        // If found, update that instance instead of creating a new one\n        if ($existingInstance !== null) {\n            $existingInstance->update($instanceData);\n        } else {\n            // No existing instance, create a new instance\n            Instance::create($instanceData);\n        }\n\n        return $this->success(\n            message: 'Heartbeat received'\n        );\n    }\n\n    public function validateInstance(ValidateInstanceRequest $request): JsonResponse\n    {\n        // Security: Log instance validation for audit trail\n        logger()->info('Instance validation received', [\n            'instance_key' => substr($request['instance_key'], 0, 8) . '...', // Log partial key only\n            'ip_hash' => $this->pseudonymizeIp(request()->ip()),\n        ]);\n\n        // Fetch current instance and update last validated at timestamp\n        $instance = Instance::updateOrCreate(\n            ['instance_key' => $request['instance_key']],\n            [\n                'instance_key' => $request['instance_key'],\n                'last_validated_at' => now(),\n            ]\n        );\n\n        // Return current instance data to sync license data\n        return $this->success(\n            message: 'Instance successfully validated',\n            data: LicenseData::fromModel($instance)\n        );\n    }\n\n    public function activate(ValidateInstanceRequest $request): JsonResponse\n    {\n        // Security: Log instance activation for audit trail\n        logger()->info('Instance activation attempt', [\n            'instance_key' => substr($request['instance_key'], 0, 8) . '...', // Log partial key only\n            'ip_hash' => $this->pseudonymizeIp(request()->ip()),\n            'has_license_key' => !empty($request['license_key']),\n        ]);\n\n        $instance = Instance::updateOrCreate(\n            ['instance_key' => $request['instance_key']],\n            [\n                'instance_key' => $request['instance_key'],\n                'last_heartbeat_at' => now(),\n            ]\n        );\n\n        try {\n            LicenseService::activateLicense([\n                'license_key' => $request['license_key'],\n                'instance_name' => $instance->id,\n            ]);\n\n            // Update instance with license key\n            $instance->update([\n                'license_key' => $request['license_key'],\n                'license_valid' => true,\n            ]);\n\n            return $this->success(\n                message: 'Instance activated successfully',\n                data: LicenseData::fromModel($instance)\n            );\n        } catch (LicenseKeyNotFound|LemonSqueezyApiError $e) {\n            return $this->error(\n                message: 'License key not found',\n                code: 404\n            );\n        } catch (\\Exception $e) {\n            report($e);\n            return $this->error(\n                message: 'Instance could not be activated',\n                code: 500\n            );\n        }\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/API/DeviceController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers\\API;\n\nuse App\\Enums\\DisplayStatus;\nuse App\\Http\\Requests\\API\\ChangeDisplayRequest;\nuse App\\Http\\Resources\\API\\DeviceResource;\nuse App\\Models\\Device;\nuse App\\Models\\Display;\nuse App\\Models\\User;\nuse Illuminate\\Http\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass DeviceController extends ApiController\n{\n    public function me(): JsonResponse\n    {\n        /** @var Device $device */\n        $device = auth()->user();\n        \n        // Eager load display with settings to avoid N+1 queries\n        $device->load('display.settings');\n        \n        return $this->success(\n            data: DeviceResource::make($device)\n        );\n    }\n\n    public function changeDisplay(ChangeDisplayRequest $request): JsonResponse\n    {\n        /** @var Device $device */\n        $device = auth()->user();\n        $data = $request->validated();\n\n        if (!$device->user_id) {\n            return $this->error(\n                message: 'Device is not associated with a user',\n                code: Response::HTTP_BAD_REQUEST\n            );\n        }\n\n        $user = User::with('workspaces')->find($device->user_id);\n        if (!$user) {\n            return $this->error(\n                message: 'User not found',\n                code: Response::HTTP_NOT_FOUND\n            );\n        }\n\n        // Get all workspace IDs the user is a member of\n        $workspaceIds = $user->workspaces->pluck('id');\n        if ($workspaceIds->isEmpty()) {\n            return $this->error(\n                message: 'User is not a member of any workspace',\n                code: Response::HTTP_BAD_REQUEST\n            );\n        }\n\n        // Find display in any of the user's workspaces\n        $display = Display::query()\n            ->whereIn('workspace_id', $workspaceIds)\n            ->find($data['display_id']);\n\n        if (! $display) {\n            return $this->error(\n                message: 'Display could not be found',\n                code: Response::HTTP_NOT_FOUND\n            );\n        }\n\n        $device->update(['display_id' => $display->id]);\n        $display->update(['status' => DisplayStatus::ACTIVE]);\n\n        return $this->success(\n            message: 'Successfully changed display.'\n        );\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/API/DisplayController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers\\API;\n\nuse App\\Enums\\DisplayStatus;\nuse App\\Http\\Requests\\API\\BookEventRequest;\nuse App\\Http\\Resources\\API\\DisplayDataResource;\nuse App\\Http\\Resources\\API\\DisplayResource;\nuse App\\Http\\Resources\\API\\EventResource;\nuse App\\Models\\Device;\nuse App\\Models\\Display;\nuse App\\Models\\User;\nuse App\\Services\\DisplayService;\nuse App\\Services\\EventService;\nuse App\\Services\\ImageService;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Support\\Arr;\nuse Illuminate\\Support\\Carbon;\n\nclass DisplayController extends ApiController\n{\n    public function __construct(\n        protected EventService $eventService,\n        protected DisplayService $displayService,\n        protected ImageService $imageService,\n    ) {\n    }\n\n    public function index(): JsonResponse\n    {\n        /** @var Device $device */\n        $device = auth()->user();\n\n        if (!$device->user_id) {\n            return $this->success(data: []);\n        }\n\n        $user = User::find($device->user_id);\n        if (!$user) {\n            return $this->success(data: []);\n        }\n\n        // Get displays from all workspaces the user is a member of\n        $workspaceIds = $user->workspaces->pluck('id');\n        if ($workspaceIds->isEmpty()) {\n            return $this->success(data: []);\n        }\n\n        $displays = Display::query()\n            ->whereIn('workspace_id', $workspaceIds)\n            ->whereIn('status', [DisplayStatus::READY, DisplayStatus::ACTIVE])\n            ->with('settings')\n            ->get();\n\n        logger()->info('Display list requested', [\n            'user_id' => $device->user_id,\n            'device_id' => $device->id,\n            'workspace_ids' => $workspaceIds->toArray(),\n            'display_count' => $displays->count(),\n            'ip' => request()->ip(),\n        ]);\n\n        return $this->success(data: DisplayResource::collection($displays));\n    }\n\n    public function getData(string $displayId): JsonResponse\n    {\n        /** @var Device $device */\n        $device = auth()->user();\n\n        $permission = $this->displayService->validateDisplayPermission($displayId, $device->id);\n        if (! $permission->permitted) {\n            logger()->warning('Display data access denied', [\n                'user_id' => $device->user_id,\n                'device_id' => $device->id,\n                'display_id' => $displayId,\n                'reason' => $permission->message,\n                'ip' => request()->ip(),\n            ]);\n            return $this->error(message: $permission->message, code: $permission->code);\n        }\n\n        try {\n            $startTime = microtime(true);\n            $display = $this->displayService->getDisplay($displayId);\n            $events = $this->eventService->getEventsForDisplay($displayId);\n            $duration = round((microtime(true) - $startTime) * 1000, 2);\n\n            logger()->info('Display data retrieved successfully', [\n                'user_id' => $device->user_id,\n                'device_id' => $device->id,\n                'display_id' => $displayId,\n                'display_name' => $display->name ?? 'Unknown',\n                'event_count' => count($events),\n                'duration_ms' => $duration,\n                'ip' => request()->ip(),\n            ]);\n\n            return $this->success(data: DisplayDataResource::make([\n                'display' => $display,\n                'events' => $events,\n            ]));\n        } catch (\\Exception $e) {\n            logger()->error('Failed to fetch display data', [\n                'user_id' => $device->user_id,\n                'device_id' => $device->id,\n                'display_id' => $displayId,\n                'error' => $e->getMessage(),\n                'trace' => substr($e->getTraceAsString(), 0, 500),\n                'ip' => request()->ip(),\n            ]);\n            report($e);\n            return $this->error(message: 'Something went wrong while fetching display data. Please try again later.', code: 500);\n        }\n    }\n\n    /**\n     * Book a room for a given duration (Pro feature).\n     */\n    public function book(BookEventRequest $request, string $displayId): JsonResponse\n    {\n        /** @var Device $device */\n        $device = auth()->user();\n\n        $permission = $this->displayService->validateDisplayPermission($displayId, $device->id, ['pro' => true, 'booking' => true]);\n        if (! $permission->permitted) {\n            return $this->error(message: $permission->message, code: $permission->code);\n        }\n\n        try {\n            $data = $request->validated();\n            \n            // Parse start and end times if provided, otherwise use duration\n            $start = isset($data['start']) ? Carbon::parse($data['start'])->utc() : null;\n            $end = isset($data['end']) ? Carbon::parse($data['end'])->utc() : null;\n            $duration = isset($data['duration']) ? (int) $data['duration'] : null;\n            \n            logger()->info('Room booking requested', [\n                'user_id' => $device->user_id,\n                'device_id' => $device->id,\n                'display_id' => $displayId,\n                'start' => $start?->toIso8601String(),\n                'end' => $end?->toIso8601String(),\n                'duration' => $duration,\n                'summary' => Arr::get($data, 'summary', __('Reserved')),\n                'ip' => request()->ip(),\n            ]);\n            \n            $event = $this->eventService->bookRoom(\n                displayId: $displayId,\n                userId: $device->user_id,\n                summary: Arr::get($data, 'summary', __('Reserved')),\n                duration: $duration,\n                start: $start,\n                end: $end\n            );\n            \n            logger()->info('Room booked successfully', [\n                'user_id' => $device->user_id,\n                'device_id' => $device->id,\n                'display_id' => $displayId,\n                'event_id' => $event->id ?? null,\n                'ip' => request()->ip(),\n            ]);\n            \n            return $this->success(data: new EventResource($event), code: 201);\n        } catch (\\Exception $e) {\n            logger()->error('Room booking failed', [\n                'user_id' => $device->user_id,\n                'device_id' => $device->id,\n                'display_id' => $displayId,\n                'error' => $e->getMessage(),\n                'error_code' => $e->getCode(),\n                'ip' => request()->ip(),\n            ]);\n            report($e);\n            $status = $e->getCode() === 403 ? 403 : 400;\n            return $this->error(message: 'Room could not be booked. There may be conflicting events during this time period. Please try a different time or duration.', code: $status);\n        }\n    }\n\n    /**\n     * Check in to an event (Pro feature).\n     */\n    public function checkIn(string $displayId, string $eventId): JsonResponse\n    {\n        /** @var Device $device */\n        $device = auth()->user();\n\n        $permission = $this->displayService->validateDisplayPermission($displayId, $device->id, ['pro' => true]);\n        if (! $permission->permitted) {\n            return $this->error(message: $permission->message, code: $permission->code);\n        }\n\n        try {\n            logger()->info('Event check-in requested', [\n                'user_id' => $device->user_id,\n                'device_id' => $device->id,\n                'display_id' => $displayId,\n                'event_id' => $eventId,\n                'ip' => request()->ip(),\n            ]);\n\n            $this->eventService->checkInToEvent($eventId, $displayId);\n            \n            logger()->info('Event check-in successful', [\n                'user_id' => $device->user_id,\n                'device_id' => $device->id,\n                'display_id' => $displayId,\n                'event_id' => $eventId,\n                'ip' => request()->ip(),\n            ]);\n\n            return $this->success(message: 'Checked in successfully');\n        } catch (\\Exception $e) {\n            logger()->error('Event check-in failed', [\n                'user_id' => $device->user_id,\n                'device_id' => $device->id,\n                'display_id' => $displayId,\n                'event_id' => $eventId,\n                'error' => $e->getMessage(),\n                'error_code' => $e->getCode(),\n                'ip' => request()->ip(),\n            ]);\n            $status = $e->getCode() === 403 ? 403 : 400;\n            return $this->error(message: 'Could not check in to event. Please try again later.', code: $status);\n        }\n    }\n\n    /**\n     * Cancel an event (Pro feature).\n     */\n    public function cancel(string $displayId, string $eventId): JsonResponse\n    {\n        /** @var Device $device */\n        $device = auth()->user();\n\n        $permission = $this->displayService->validateDisplayPermission($displayId, $device->id, ['pro' => true]);\n        if (! $permission->permitted) {\n            return $this->error(message: $permission->message, code: $permission->code);\n        }\n\n        try {\n            logger()->info('Event cancellation requested', [\n                'user_id' => $device->user_id,\n                'device_id' => $device->id,\n                'display_id' => $displayId,\n                'event_id' => $eventId,\n                'ip' => request()->ip(),\n            ]);\n\n            $this->eventService->cancelEvent($eventId, $displayId);\n            \n            logger()->info('Event cancelled successfully', [\n                'user_id' => $device->user_id,\n                'device_id' => $device->id,\n                'display_id' => $displayId,\n                'event_id' => $eventId,\n                'ip' => request()->ip(),\n            ]);\n\n            return $this->success(message: 'Event cancelled successfully');\n        } catch (\\Exception $e) {\n            logger()->error('Event cancellation failed', [\n                'user_id' => $device->user_id,\n                'device_id' => $device->id,\n                'display_id' => $displayId,\n                'event_id' => $eventId,\n                'error' => $e->getMessage(),\n                'error_code' => $e->getCode(),\n                'ip' => request()->ip(),\n            ]);\n            $status = $e->getCode() === 403 ? 403 : 400;\n            return $this->error(message: 'Event could not be cancelled. Please try again later.', code: $status);\n        }\n    }\n\n    /**\n     * Serve display images (logo or background) for mobile app\n     */\n    public function serveImage(string $displayId, string $type)\n    {\n        /** @var Device $device */\n        $device = auth()->user();\n\n        // Validate that the device has access to this display\n        $permission = $this->displayService->validateDisplayPermission($displayId, $device->id);\n        if (!$permission->permitted) {\n            abort(403, 'Access denied');\n        }\n\n        try {\n            $display = $this->displayService->getDisplay($displayId);\n            return $this->imageService->serveImage($display, $type);\n        } catch (\\Exception $e) {\n            abort(404, 'Image not found');\n        }\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/API/EventController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers\\API;\n\nuse App\\Http\\Resources\\API\\EventResource;\nuse App\\Models\\Device;\nuse App\\Services\\DisplayService;\nuse App\\Services\\EventService;\nuse Exception;\nuse Illuminate\\Http\\JsonResponse;\n\nclass EventController extends ApiController\n{\n    public function __construct(\n        protected EventService $eventService,\n        protected DisplayService $displayService,\n    ) {\n    }\n\n    /**\n     * @throws Exception\n     */\n    public function index(): JsonResponse\n    {\n        /** @var Device $device */\n        $device = auth()->user();\n\n        $permission = $this->displayService->validateDisplayPermission($device->display_id, $device->id);\n        if (! $permission->permitted) {\n            return $this->error(message: $permission->message, code: $permission->code);\n        }\n\n        try {\n            $events = $this->eventService->getEventsForDisplay($device->display_id);\n            return $this->success(data: EventResource::collection($events));\n        } catch (\\Exception $e) {\n            return $this->error(message: $e->getMessage(), code: 500);\n        }\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/AdminController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers;\n\nuse App\\Enums\\DisplayStatus;\nuse App\\Models\\Board;\nuse App\\Models\\Display;\nuse App\\Models\\Instance;\nuse App\\Models\\User;\nuse App\\Models\\Workspace;\nuse App\\Models\\WorkspaceMember;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Http\\RedirectResponse;\nuse Illuminate\\Support\\Facades\\Auth;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Facades\\Http;\n\nclass AdminController extends Controller\n{\n    /**\n     * Check if the current request is authorized for admin access\n     */\n    private function checkAdminAccess(): void\n    {\n        $user = Auth::user();\n        \n        // Prevent access if impersonating\n        if (session()->get('impersonating')) {\n            abort(403, 'Cannot access admin panel while impersonating. Please stop impersonating first.');\n        }\n        \n        // Check if current user is admin\n        if (!$user || !$user->isAdmin() || config('settings.is_self_hosted')) {\n            abort(403);\n        }\n    }\n\n    public function index()\n    {\n        $this->checkAdminAccess();\n\n        $activeDisplays = Display::where('status', DisplayStatus::ACTIVE)->count();\n        $totalDisplays = Display::count();\n        $totalBoards = Board::count();\n        $totalInstances = Instance::count();\n        $sevenDaysAgo = now()->subDays(7);\n\n        // Active self-hosted instances in the last 7 days, sorted by registration order\n        $activeInstances = Instance::where('is_self_hosted', true)\n            ->where('last_heartbeat_at', '>=', $sevenDaysAgo)\n            ->orderBy('created_at', 'asc')\n            ->get()\n            ->map(function($instance) {\n                $instance->is_paid = (bool) $instance->license_valid;\n                return $instance;\n            });\n\n        // Active cloud-hosted displays: users with at least one display active in the last 7 days, sorted by registration order\n        $activeDisplays = User::query()\n            ->whereHas('displays', function($q) use ($sevenDaysAgo) {\n                $q->where('last_sync_at', '>=', $sevenDaysAgo);\n            })\n            ->withCount(['displays' => function($q) use ($sevenDaysAgo) {\n                $q->where('last_sync_at', '>=', $sevenDaysAgo);\n            }])\n            ->withCount('boards')\n            ->with(['displays' => function($q) use ($sevenDaysAgo) {\n                $q->where('last_sync_at', '>=', $sevenDaysAgo)->orderByDesc('last_sync_at');\n            }])\n            ->orderBy('created_at', 'asc')\n            ->get()\n            ->map(function($user) {\n                $user->last_display_activity = $user->displays->max('last_sync_at');\n                $user->is_paid = $user->hasPro();\n                return $user;\n            })\n            ->values();\n\n        // Paying cloud-hosted users: users with Pro subscription (is_unlimited or active subscription)\n        $totalMRR = 0;\n        $forecastedMRR = 0;\n        $payingUsers = User::query()\n            ->where(function($query) {\n                $query->where('is_unlimited', true)\n                    ->orWhereHas('subscriptions', function($subQuery) {\n                        $subQuery->where(function($q) {\n                            $q->whereNull('ends_at') // Active subscription\n                              ->orWhere('ends_at', '>', now()); // Not expired\n                        });\n                    });\n            })\n            ->withCount('displays')\n            ->withCount('boards')\n            ->with(['subscriptions' => function($query) {\n                $query->where(function($q) {\n                    $q->whereNull('ends_at')\n                      ->orWhere('ends_at', '>', now());\n                })->orderByDesc('created_at');\n            }])\n            ->orderBy('created_at', 'asc')\n            ->get()\n            ->map(function($user) use (&$totalMRR, &$forecastedMRR) {\n                $user->subscription_status = $user->is_unlimited \n                    ? 'Unlimited' \n                    : ($user->subscriptions->isNotEmpty() ? 'Subscribed' : 'None');\n                $user->subscription_ends_at = $user->subscriptions->first()?->ends_at;\n                \n                // Fetch subscription price and status from Lemon Squeezy API\n                $user->price = 0;\n                $user->mrr = 0;\n                $user->lemon_squeezy_status = null;\n                \n                if (!$user->is_unlimited && $user->subscriptions->isNotEmpty()) {\n                    $subscription = $user->subscriptions->first();\n                    $subscriptionData = $this->getSubscriptionData($subscription->lemon_squeezy_id, $user->displays_count);\n                    \n                    if ($subscriptionData) {\n                        $user->lemon_squeezy_status = $subscriptionData['status'] ?? null;\n                        $user->price = $subscriptionData['price'] ?? 0;\n                        $user->mrr = $user->price * $user->displays_count;\n                        \n                        // Add to forecasted MRR (all statuses)\n                        $forecastedMRR += $user->mrr;\n                        \n                        // Only add to total MRR if status is 'active'\n                        if ($subscriptionData['status'] === 'active') {\n                            $totalMRR += $user->mrr;\n                        }\n                    }\n                }\n                \n                return $user;\n            });\n\n        // All users for the users overview tab (paginated for performance)\n        $search = request()->get('search');\n        $allUsersQuery = User::query()\n            ->withCount('displays')\n            ->withCount('boards')\n            ->with(['subscriptions' => function($query) {\n                $query->where(function($q) {\n                    $q->whereNull('ends_at')\n                      ->orWhere('ends_at', '>', now());\n                });\n            }]);\n        \n        if ($search) {\n            $allUsersQuery->where(function($query) use ($search) {\n                $query->where('name', 'like', \"%{$search}%\")\n                      ->orWhere('email', 'like', \"%{$search}%\");\n            });\n        }\n        \n        $allUsers = $allUsersQuery\n            ->orderBy('created_at', 'desc')\n            ->paginate(50)\n            ->withQueryString();\n\n        return view('pages.admin', [\n            'activeInstances' => $activeInstances,\n            'activeDisplays' => $activeDisplays,\n            'payingUsers' => $payingUsers,\n            'allUsers' => $allUsers,\n            'activeDisplaysCount' => $activeDisplays->count(),\n            'totalDisplays' => $totalDisplays,\n            'totalBoards' => $totalBoards,\n            'activeInstancesCount' => $activeInstances->count(),\n            'totalInstances' => $totalInstances,\n            'payingUsersCount' => $payingUsers->count(),\n            'totalMRR' => $totalMRR,\n            'forecastedMRR' => $forecastedMRR,\n        ]);\n    }\n\n    /**\n     * Get subscription data (status, price, MRR) from Lemon Squeezy API\n     * Returns array with 'status', 'price', and 'mrr' keys\n     */\n    private function getSubscriptionData(string $subscriptionId, int $displaysCount = 0): ?array\n    {\n        $apiKey = config('lemon-squeezy.api_key');\n        if (!$apiKey) {\n            return null;\n        }\n\n        try {\n            // Cache key for subscription data\n            $subscriptionCacheKey = \"lemonsqueezy:subscription:{$subscriptionId}\";\n            \n            // Fetch subscription to get status (cached for 1 hour)\n            $subscriptionData = Cache::remember($subscriptionCacheKey, now()->addHour(), function () use ($apiKey, $subscriptionId) {\n                $subscriptionResponse = Http::withToken($apiKey)\n                    ->withHeaders([\n                        'Accept' => 'application/vnd.api+json',\n                    ])\n                    ->get(\"https://api.lemonsqueezy.com/v1/subscriptions/{$subscriptionId}\");\n\n                if ($subscriptionResponse->successful()) {\n                    return $subscriptionResponse->json();\n                }\n                \n                return null;\n            });\n\n            if (!$subscriptionData || !isset($subscriptionData['data']['attributes'])) {\n                return null;\n            }\n\n            $subscriptionAttributes = $subscriptionData['data']['attributes'];\n            $status = $subscriptionAttributes['status'] ?? null;\n            \n            // Get price using existing method\n            $price = $this->getSubscriptionPrice($subscriptionId, $displaysCount);\n            \n            if ($price === null) {\n                return null;\n            }\n\n            // Calculate MRR (price is already calculated with quantity for usage-based)\n            $mrr = $price;\n\n            return [\n                'status' => $status,\n                'price' => $price,\n                'mrr' => $mrr,\n            ];\n        } catch (\\Exception $e) {\n            return null;\n        }\n    }\n\n    /**\n     * Get subscription price from Lemon Squeezy API\n     * Returns monthly recurring revenue (MRR) - converts yearly to monthly if needed\n     * Handles both usage-based and non-usage-based subscriptions\n     * For usage-based subscriptions, multiplies unit price by quantity (displays count)\n     */\n    private function getSubscriptionPrice(string $subscriptionId, int $displaysCount = 0): ?float\n    {\n        $apiKey = config('lemon-squeezy.api_key');\n        if (!$apiKey) {\n            return null;\n        }\n\n        try {\n            // Cache key for subscription items\n            $itemsCacheKey = \"lemonsqueezy:subscription-items:{$subscriptionId}\";\n            \n            // Fetch subscription items to get pricing (cached for 1 hour)\n            $itemsData = Cache::remember($itemsCacheKey, now()->addHour(), function () use ($apiKey, $subscriptionId) {\n                $itemsResponse = Http::withToken($apiKey)\n                    ->withHeaders([\n                        'Accept' => 'application/vnd.api+json',\n                    ])\n                    ->get(\"https://api.lemonsqueezy.com/v1/subscription-items?filter[subscription_id]={$subscriptionId}\");\n\n                if ($itemsResponse->successful()) {\n                    return $itemsResponse->json();\n                }\n                \n                return null;\n            });\n\n            if (!$itemsData || !isset($itemsData['data']) || empty($itemsData['data'])) {\n                return null;\n            }\n\n            $item = $itemsData['data'][0];\n            $itemAttributes = $item['attributes'] ?? [];\n            $priceId = $itemAttributes['price_id'] ?? null;\n            $isUsageBased = $itemAttributes['is_usage_based'] ?? false;\n            $quantity = $itemAttributes['quantity'] ?? $displaysCount; // Use subscription item quantity or fallback to displays count\n            \n            if (!$priceId) {\n                return null;\n            }\n\n            // Cache key for price details\n            $priceCacheKey = \"lemonsqueezy:price:{$priceId}\";\n            \n            // Fetch price details using price_id (cached for 24 hours)\n            $priceData = Cache::remember($priceCacheKey, now()->addHours(24), function () use ($apiKey, $priceId) {\n                $priceResponse = Http::withToken($apiKey)\n                    ->withHeaders([\n                        'Accept' => 'application/vnd.api+json',\n                    ])\n                    ->get(\"https://api.lemonsqueezy.com/v1/prices/{$priceId}\");\n\n                if ($priceResponse->successful()) {\n                    return $priceResponse->json();\n                }\n                \n                return null;\n            });\n\n            if (!$priceData || !isset($priceData['data']['attributes'])) {\n                return null;\n            }\n\n            $priceAttributes = $priceData['data']['attributes'];\n            \n            // Handle usage-based subscriptions differently\n            if ($isUsageBased) {\n                // For usage-based subscriptions, calculate MRR as quantity × unit_price\n                // unit_price is often null for usage-based, so we use unit_price_decimal\n                if (isset($priceAttributes['unit_price_decimal']) && $priceAttributes['unit_price_decimal'] !== null) {\n                    $unitPrice = (float) ($priceAttributes['unit_price_decimal'] / 100);\n                    // Multiply by quantity (number of displays) to get total MRR\n                    return $unitPrice * max(1, $quantity); // Ensure at least 1\n                }\n                \n                // Fallback: check for unit_price if available\n                if (isset($priceAttributes['unit_price']) && $priceAttributes['unit_price'] !== null) {\n                    $unitPrice = (float) ($priceAttributes['unit_price'] / 100);\n                    // Multiply by quantity (number of displays) to get total MRR\n                    return $unitPrice * max(1, $quantity); // Ensure at least 1\n                }\n                \n                // Fallback: check for setup_price or other price fields\n                if (isset($priceAttributes['setup_price']) && $priceAttributes['setup_price'] !== null) {\n                    $price = (float) ($priceAttributes['setup_price'] / 100);\n                    return $price;\n                }\n            } else {\n                // For non-usage-based subscriptions, get unit_price (fixed monthly price)\n                // Try unit_price_decimal first (more precise)\n                if (isset($priceAttributes['unit_price_decimal']) && $priceAttributes['unit_price_decimal'] !== null) {\n                    $price = (float) ($priceAttributes['unit_price_decimal'] / 100);\n                    \n                    // Check billing interval from price attributes\n                    $interval = strtolower($priceAttributes['renewal_interval_unit'] ?? '');\n                    $isYearly = $interval === 'year';\n                    \n                    // Convert yearly to monthly MRR\n                    if ($isYearly) {\n                        return $price / 12;\n                    }\n                    \n                    return $price;\n                }\n                \n                // Fallback to unit_price\n                if (isset($priceAttributes['unit_price']) && $priceAttributes['unit_price'] !== null) {\n                    $price = (float) ($priceAttributes['unit_price'] / 100);\n                    \n                    // Check billing interval from price attributes\n                    $interval = strtolower($priceAttributes['renewal_interval_unit'] ?? '');\n                    $isYearly = $interval === 'year';\n                    \n                    // Convert yearly to monthly MRR\n                    if ($isYearly) {\n                        return $price / 12;\n                    }\n                    \n                    return $price;\n                }\n            }\n\n            return null;\n        } catch (\\Exception $e) {\n            return null;\n        }\n    }\n\n    /**\n     * Show user details page\n     */\n    public function showUser(User $user)\n    {\n        $this->checkAdminAccess();\n        \n        $admin = Auth::user();\n\n        // Load user relationships for display\n        $user->load([\n            'outlookAccounts',\n            'googleAccounts',\n            'caldavAccounts',\n            'displays',\n            'devices',\n            'workspaces',\n            'subscriptions' => function($query) {\n                $query->where(function($q) {\n                    $q->whereNull('ends_at')\n                      ->orWhere('ends_at', '>', now());\n                })->orderByDesc('created_at');\n            },\n        ]);\n\n        // Get subscription info\n        $subscriptionInfo = null;\n        if (!$user->is_unlimited && $user->subscriptions->isNotEmpty()) {\n            $subscription = $user->subscriptions->first();\n            $subscriptionData = $this->getSubscriptionData($subscription->lemon_squeezy_id, $user->displays->count());\n            if ($subscriptionData) {\n                $subscriptionInfo = [\n                    'status' => $subscriptionData['status'] ?? null,\n                    'price' => $subscriptionData['price'] ?? 0,\n                    'mrr' => ($subscriptionData['price'] ?? 0) * $user->displays->count(),\n                    'ends_at' => $subscription->ends_at,\n                ];\n            }\n        }\n\n        return view('pages.admin.user', [\n            'user' => $user,\n            'subscriptionInfo' => $subscriptionInfo,\n        ]);\n    }\n\n    /**\n     * Delete a user account and all associated data\n     */\n    public function deleteUser(Request $request, User $user): RedirectResponse\n    {\n        $this->checkAdminAccess();\n        \n        $admin = Auth::user();\n\n        // Prevent deleting yourself\n        if ($user->id === $admin->id) {\n            return redirect()->route('admin.index')\n                ->with('error', 'You cannot delete your own account.');\n        }\n\n        // Confirm deletion\n        $request->validate([\n            'confirm_email' => ['required', 'email'],\n        ]);\n\n        if ($request->input('confirm_email') !== $user->email) {\n            return back()->withErrors(['confirm_email' => 'Email confirmation does not match.']);\n        }\n\n        DB::transaction(function () use ($user, $admin) {\n            // Delete all user's personal access tokens\n            $user->tokens()->delete();\n\n            // Delete displays and their related data first (before calendars/accounts)\n            if ($user->displays) {\n                foreach ($user->displays as $display) {\n                    // Delete event subscriptions\n                    $display->eventSubscriptions()->delete();\n                    // Delete display settings\n                    $display->settings()->delete();\n                    // Delete events associated with this display\n                    $display->events()->delete();\n                    // Delete devices associated with this display\n                    $display->devices()->delete();\n                    $display->delete();\n                }\n            }\n\n            // Delete devices (standalone devices not linked to displays)\n            $user->devices()->delete();\n\n            // Delete rooms\n            $user->rooms()->delete();\n\n            // Delete Outlook accounts and their calendars/events\n            if ($user->outlookAccounts) {\n                foreach ($user->outlookAccounts as $account) {\n                    if ($account->calendars) {\n                        foreach ($account->calendars as $calendar) {\n                            $calendar->events()->delete();\n                            $calendar->delete();\n                        }\n                    }\n                    $account->delete();\n                }\n            }\n\n            // Delete Google accounts and their calendars/events\n            if ($user->googleAccounts) {\n                foreach ($user->googleAccounts as $account) {\n                    if ($account->calendars) {\n                        foreach ($account->calendars as $calendar) {\n                            $calendar->events()->delete();\n                            $calendar->delete();\n                        }\n                    }\n                    $account->delete();\n                }\n            }\n\n            // Delete CalDAV accounts and their calendars/events\n            if ($user->caldavAccounts) {\n                foreach ($user->caldavAccounts as $account) {\n                    if ($account->calendars) {\n                        foreach ($account->calendars as $calendar) {\n                            $calendar->events()->delete();\n                            $calendar->delete();\n                        }\n                    }\n                    $account->delete();\n                }\n            }\n\n            // Delete any remaining calendars directly linked to user (shouldn't happen, but safety check)\n            // Note: Calendars are linked through accounts, not directly to users, so this is unlikely\n            // Events are deleted through calendars above\n\n            // Handle workspaces\n            $ownedWorkspaces = $user->ownedWorkspaces()->get();\n            foreach ($ownedWorkspaces as $workspace) {\n                // Get other members (excluding the user being deleted)\n                $otherMembers = $workspace->members()->where('user_id', '!=', $user->id)->get();\n                \n                if ($otherMembers->isNotEmpty()) {\n                    // Find first admin or first member to transfer ownership\n                    $newOwner = $otherMembers->first(function ($member) {\n                        return $member->pivot->role === \\App\\Enums\\WorkspaceRole::ADMIN->value;\n                    }) ?? $otherMembers->first();\n                    \n                    if ($newOwner) {\n                        // Transfer ownership\n                        WorkspaceMember::where('workspace_id', $workspace->id)\n                            ->where('user_id', $newOwner->id)\n                            ->update(['role' => \\App\\Enums\\WorkspaceRole::OWNER]);\n                    }\n                } else {\n                    // No other members, delete the workspace and all its data\n                    foreach ($workspace->displays as $display) {\n                        $display->eventSubscriptions()->delete();\n                        $display->settings()->delete();\n                        $display->events()->delete();\n                        $display->devices()->delete();\n                        $display->delete();\n                    }\n                    $workspace->devices()->delete();\n                    foreach ($workspace->calendars as $calendar) {\n                        $calendar->events()->delete();\n                        $calendar->delete();\n                    }\n                    $workspace->rooms()->delete();\n                    WorkspaceMember::where('workspace_id', $workspace->id)->delete();\n                    $workspace->delete();\n                }\n            }\n\n            // Delete workspace memberships (user's membership in workspaces they don't own)\n            WorkspaceMember::where('user_id', $user->id)->delete();\n\n            // Note: Instances are system-wide (for self-hosted tracking), not user-specific\n            // No need to delete instances when deleting a user\n\n            // Cancel LemonSqueezy subscriptions (if any)\n            // Note: This doesn't actually cancel them in LemonSqueezy, just removes the local reference\n            // You might want to add API call to cancel subscriptions\n            if (method_exists($user, 'subscriptions')) {\n                $user->subscriptions()->delete();\n            }\n\n            // Finally, delete the user\n            $user->delete();\n\n            logger()->info('User account deleted by admin', [\n                'deleted_user_id' => $user->id,\n                'deleted_user_email' => $user->email,\n                'deleted_by_admin_id' => $admin->id,\n                'deleted_by_admin_email' => $admin->email,\n            ]);\n        });\n\n        return redirect()->route('admin.index')\n            ->with('success', \"User account {$user->email} and all associated data have been permanently deleted.\");\n    }\n\n    /**\n     * Impersonate a user\n     */\n    public function impersonate(User $user): RedirectResponse\n    {\n        $this->checkAdminAccess();\n        \n        $admin = Auth::user();\n\n        // Prevent impersonating yourself\n        if ($admin->id === $user->id) {\n            return redirect()->route('admin.index')\n                ->with('error', 'You cannot impersonate yourself.');\n        }\n\n        // Store original admin ID in session\n        session()->put('impersonating', true);\n        session()->put('impersonator_id', $admin->id);\n\n        // Clear any workspace selection from admin session - let impersonated user's workspace be selected\n        session()->forget('selected_workspace_id');\n\n        // Log in as the target user\n        Auth::login($user);\n\n        // Regenerate session and CSRF token to prevent session fixation\n        session()->regenerate();\n        session()->regenerateToken();\n\n        logger()->info('Admin started impersonating user', [\n            'admin_id' => $admin->id,\n            'admin_email' => $admin->email,\n            'impersonated_user_id' => $user->id,\n            'impersonated_user_email' => $user->email,\n        ]);\n\n        return redirect()->route('dashboard')\n            ->with('success', \"You are now impersonating {$user->email}\");\n    }\n\n    /**\n     * Stop impersonating and return to admin account\n     */\n    public function stopImpersonating(): RedirectResponse\n    {\n        $impersonatorId = session()->get('impersonator_id');\n        \n        if (!$impersonatorId) {\n            return redirect()->route('dashboard');\n        }\n\n        $impersonator = User::find($impersonatorId);\n        if (!$impersonator || !$impersonator->isAdmin()) {\n            session()->forget(['impersonating', 'impersonator_id']);\n            return redirect()->route('dashboard');\n        }\n\n        $impersonatedUser = Auth::user();\n\n        // Clear impersonation session\n        session()->forget(['impersonating', 'impersonator_id']);\n\n        // Log back in as admin\n        Auth::login($impersonator);\n\n        // Regenerate session and CSRF token to prevent session fixation\n        session()->regenerate();\n        session()->regenerateToken();\n\n        logger()->info('Admin stopped impersonating user', [\n            'admin_id' => $impersonator->id,\n            'admin_email' => $impersonator->email,\n            'impersonated_user_id' => $impersonatedUser->id,\n            'impersonated_user_email' => $impersonatedUser->email,\n        ]);\n\n        return redirect()->route('admin.index')\n            ->with('success', 'Stopped impersonating user.');\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/Auth/AuthController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers\\Auth;\n\nuse App\\Http\\Controllers\\Controller;\nuse App\\Models\\User;\nuse Illuminate\\Support\\Facades\\Hash;\n\nabstract class AuthController extends Controller\n{\n    protected function issueToken(string $tokenName): string\n    {\n        /** @var User $user */\n        $user = auth()->user();\n\n        return $user->createToken($tokenName)->plainTextToken;\n    }\n\n    protected function createUser(\n        string $name,\n        string $email,\n        string $password = null\n    ): User {\n        $attributes = [\n            'name' => $name,\n            'email' => $email,\n            'password' => $password ? Hash::make($password) : null,\n        ];\n\n        return User::factory()->unverified()->create($attributes);\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/Auth/GoogleController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers\\Auth;\n\nuse App\\Enums\\OAuthDriver;\nuse App\\Http\\Requests\\Auth\\OAuth2TokenRequest;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\RedirectResponse;\n\nclass GoogleController extends SocialAuthController\n{\n    protected string $driver = OAuthDriver::GOOGLE;\n\n    public function token(OAuth2TokenRequest $oauthTokenRequest): RedirectResponse\n    {\n        return parent::token($oauthTokenRequest);\n    }\n\n    public function redirect(): mixed\n    {\n        return parent::redirect();\n    }\n\n    public function callback(): RedirectResponse\n    {\n        return parent::callback();\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/Auth/LoginController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers\\Auth;\n\nuse App\\Events\\UserRegistered;\nuse App\\Http\\Controllers\\Controller;\nuse App\\Http\\Requests\\Auth\\LoginRequest;\nuse App\\Models\\User;\nuse App\\Notifications\\MagicLoginNotification;\nuse Illuminate\\Http\\RedirectResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\Auth;\nuse Illuminate\\Support\\Str;\nuse Illuminate\\Validation\\ValidationException;\nuse Illuminate\\View\\View;\nuse MagicLink\\Actions\\LoginAction;\nuse MagicLink\\MagicLink;\n\nclass LoginController extends Controller\n{\n    /**\n     * Display the login view.\n     */\n    public function create(): View\n    {\n        return view('auth.login');\n    }\n\n    /**\n     * Handle an incoming authentication request.\n     *\n     *\n     * @throws ValidationException\n     */\n    public function store(LoginRequest $request): RedirectResponse\n    {\n        if (config('settings.disable_email_login')) {\n            return redirect()->back()->withErrors(['email' => 'Email login is disabled.']);\n        }\n\n        $data = $request->validated();\n\n        if (! User::isAllowedLogin($data['email'])) {\n            return redirect()->back()->withErrors(['email' => 'Your organization or email is not allowed to log in.']);\n        }\n\n        $user = User::where('email', $data['email'])->first();\n        if (!$user) {\n            $user = User::factory()->unverified()->create([\n                'name' => Str::before($data['email'], '@'),\n                'email' => $data['email']\n            ]);\n        }\n\n        $loginUrl = MagicLink::create(new LoginAction($user))->url;\n        $user->notify(new MagicLoginNotification($loginUrl));\n\n        return redirect()\n            ->back()\n            ->with('success', 'Check your e-mail. You should receive an e-mail with a login link shortly.');\n    }\n\n    /**\n     * Destroy an authenticated session.\n     */\n    public function destroy(Request $request): RedirectResponse\n    {\n        auth()->logout();\n\n        $request->session()->invalidate();\n        $request->session()->regenerateToken();\n\n        return redirect()->intended('/');\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/Auth/MicrosoftController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers\\Auth;\n\nuse App\\Enums\\OAuthDriver;\nuse App\\Http\\Requests\\Auth\\OAuth2TokenRequest;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\RedirectResponse;\n\nclass MicrosoftController extends SocialAuthController\n{\n    protected string $driver = OAuthDriver::MICROSOFT;\n\n    public function token(OAuth2TokenRequest $oauthTokenRequest): RedirectResponse\n    {\n        return parent::token($oauthTokenRequest);\n    }\n\n    public function redirect(): mixed\n    {\n        return parent::redirect();\n    }\n\n    public function callback(): RedirectResponse\n    {\n        return parent::callback();\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/Auth/RegisterController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers\\Auth;\n\nuse App\\Events\\UserRegistered;\nuse App\\Http\\Controllers\\Controller;\nuse App\\Http\\Requests\\Auth\\RegisterRequest;\nuse App\\Models\\User;\nuse App\\Notifications\\MagicLoginNotification;\nuse Illuminate\\Http\\RedirectResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\Auth;\nuse Illuminate\\Support\\Str;\nuse Illuminate\\Validation\\ValidationException;\nuse Illuminate\\View\\View;\nuse MagicLink\\Actions\\LoginAction;\nuse MagicLink\\MagicLink;\nuse Spatie\\GoogleTagManager\\GoogleTagManagerFacade as GoogleTagManager;\n\nclass RegisterController extends Controller\n{\n    /**\n     * Display the login view.\n     */\n    public function create(): View\n    {\n        return view('auth.register');\n    }\n\n    /**\n     * Handle an incoming authentication request.\n     *\n     *\n     * @throws ValidationException\n     */\n    public function store(RegisterRequest $request): RedirectResponse\n    {\n        if (config('settings.disable_email_login')) {\n            return redirect()->back()->withErrors(['email' => 'Email registration is disabled.']);\n        }\n\n        $data = $request->validated();\n\n        if (! User::isAllowedLogin($data['email'])) {\n            return redirect()->back()->withErrors(['email' => 'Your organization or email is not allowed to register.']);\n        }\n\n        $user = User::where('email', $data['email'])->first();\n        if (!$user) {\n            $user = User::factory()->unverified()->create([\n                'name' => $data['name'],\n                'email' => $data['email'],\n                'terms_accepted_at' => ! config('settings.is_self_hosted') ? now() : null,\n            ]);\n\n            GoogleTagManager::flashPush([\n                'event' => 'sign_up',\n            ]);\n        }\n\n        $loginUrl = MagicLink::create(new LoginAction($user))->url;\n        $user->notify(new MagicLoginNotification($loginUrl));\n\n        return redirect()\n            ->back()\n            ->with('registered', true);\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/Auth/SocialAuthController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers\\Auth;\n\nuse App\\Events\\UserRegistered;\nuse App\\Http\\Requests\\Auth\\OAuth2TokenRequest;\nuse App\\Models\\User;\nuse Illuminate\\Http\\RedirectResponse;\nuse Illuminate\\Validation\\ValidationException;\nuse Laravel\\Socialite\\Facades\\Socialite;\nuse Illuminate\\Support\\Str;\nuse Spatie\\GoogleTagManager\\GoogleTagManagerFacade as GoogleTagManager;\n\nabstract class SocialAuthController extends AuthController\n{\n    protected string $driver;\n\n    public function redirect(): mixed\n    {\n        try {\n            return Socialite::driver($this->driver)->stateless()->redirect();\n        } catch (\\Exception $e) {\n            report($e);\n            logger()->error('Social redirect failed', [\n                'provider' => $this->driver,\n                'error' => $e->getMessage()\n            ]);\n            return redirect()->route('login')->with('error', 'Redirecting to the provider failed. Please try again.');\n        }\n    }\n\n    public function callback(): RedirectResponse\n    {\n        try {\n            $socialUser = Socialite::driver($this->driver)->stateless()->user();\n            if (! User::isAllowedLogin($socialUser->getEmail())) {\n                return redirect()\n                    ->route('login')\n                    ->with('error', 'Your organization or email is not allowed to log in.');\n            }\n\n            $user = $this->findOrCreateUser($socialUser);\n\n            return $this->authenticateUser($user);\n        } catch (\\Exception $e) {\n            report($e);\n            logger()->error('Social authentication failed', [\n                'provider' => $this->driver,\n                'error' => $e->getMessage()\n            ]);\n            return redirect()\n                ->route('login')\n                ->with('error', 'Authentication with ' . Str::ucfirst($this->driver) . ' failed. Please try again.');\n        }\n    }\n\n    /**\n     * @throws \\Throwable\n     */\n    public function token(OAuth2TokenRequest $oauthTokenRequest): RedirectResponse\n    {\n        $socialUser = $this->getSocialUserFromToken($oauthTokenRequest);\n\n        $this->validateSocialUser($socialUser);\n        if (! User::isAllowedLogin($socialUser->getEmail())) {\n            return redirect()\n                ->route('login')\n                ->with('error', 'Your organization or email is not allowed to log in.');\n        }\n\n        $user = $this->findOrCreateUser($socialUser);\n\n        return $this->authenticateUser($user);\n    }\n\n    private function getSocialUserFromToken(OAuth2TokenRequest $oauthTokenRequest): mixed\n    {\n        $socialUser = null;\n        $socialiteDriver = Socialite::driver($this->driver);\n\n        try {\n            $token = $oauthTokenRequest->token;\n            $socialUser = $socialiteDriver->userFromToken($token);\n            if (empty($socialUser->getName()) && ! empty($oauthTokenRequest->full_name)) {\n                $socialUser->name = $oauthTokenRequest->full_name;\n            }\n        } catch (\\Exception $e) {\n            report($e);\n            logger()->error('Something went wrong during OAuth2 authentication', [\n                'provider' => $this->driver,\n                'exception' => $e,\n            ]);\n        }\n\n        return $socialUser;\n    }\n\n    /**\n     * @throws \\Throwable\n     */\n    private function validateSocialUser($socialUser): void\n    {\n        if (empty($socialUser) ||\n            empty($socialUser->getId()) ||\n            empty($socialUser->getName()) ||\n            empty($socialUser->getEmail())) {\n            logger()->error('One or more required properties were empty during OAuth2 authentication', [\n                'provider' => $this->driver,\n                'user' => $socialUser,\n            ]);\n\n            throw_if(empty($socialUser), ValidationException::withMessages(['token' => ['required']]));\n            throw_if(empty($socialUser->getId()), ValidationException::withMessages(['id' => ['required']]));\n            throw_if(empty($socialUser->getName()), ValidationException::withMessages(['name' => ['required']]));\n            throw_if(empty($socialUser->getEmail()), ValidationException::withMessages(['email' => ['required']]));\n        }\n    }\n\n    protected function findOrCreateUser(mixed $socialUser): User\n    {\n        // first try to lookup the user by token\n        $user = User::where($this->driver.'_id', $socialUser->getId())->first();\n        if (empty($user)) {\n            // getting here means there is no user connected to this social provider\n            // check if this user has logged in using another provider or via email\n            $user = User::whereEmail($socialUser->getEmail())->first();\n\n            // if there still is no match, create a new user\n            if (empty($user)) {\n                $user = $this->createUser($socialUser->getName(), $socialUser->getEmail());\n\n                GoogleTagManager::flashPush([\n                    'event' => 'sign_up',\n                ]);\n                event(new UserRegistered($user));\n            }\n\n            // connect user to the social provider\n            $user->update([$this->driver.'_id' => $socialUser->getId()]);\n        }\n\n        return $user;\n    }\n\n    protected function authenticateUser(User $user): RedirectResponse\n    {\n        auth()->login($user);\n\n        return redirect()->route('dashboard');\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/BoardController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers;\n\nuse App\\Enums\\DisplayStatus;\nuse App\\Enums\\EventStatus;\nuse App\\Helpers\\DisplaySettings;\nuse App\\Http\\Requests\\CreateBoardRequest;\nuse App\\Http\\Requests\\UpdateBoardRequest;\nuse App\\Models\\Display;\nuse App\\Models\\Board;\nuse App\\Services\\EventService;\nuse App\\Services\\ImageService;\nuse Illuminate\\Contracts\\Foundation\\Application;\nuse Illuminate\\Contracts\\View\\Factory;\nuse Illuminate\\Contracts\\View\\View;\nuse Illuminate\\Http\\RedirectResponse;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Lang;\n\nclass BoardController extends Controller\n{\n    public function __construct(\n        protected EventService $eventService,\n        protected ImageService $imageService\n    ) {\n    }\n\n    /**\n     * Display a listing of boards for the current workspace\n     */\n    public function index(): View|Factory|Application\n    {\n        $user = auth()->user();\n        \n        // Check Pro access\n        if (!$user->hasProForCurrentWorkspace()) {\n            abort(403, 'Boards is a Pro feature. Please upgrade to access this feature.');\n        }\n        \n        $selectedWorkspace = $user->getSelectedWorkspace();\n        \n        if (!$selectedWorkspace) {\n            abort(404, 'No workspace found');\n        }\n        \n        $boards = Board::where('workspace_id', $selectedWorkspace->id)\n            ->with(['user', 'displays'])\n            ->orderBy('name')\n            ->get();\n        \n        return view('pages.boards.index', [\n            'boards' => $boards,\n            'workspace' => $selectedWorkspace,\n        ]);\n    }\n\n    /**\n     * Show the form for creating a new board\n     */\n    public function create(): View|Factory|Application\n    {\n        $user = auth()->user();\n        \n        // Check Pro access\n        if (!$user->hasProForCurrentWorkspace()) {\n            abort(403, 'Boards is a Pro feature. Please upgrade to access this feature.');\n        }\n        \n        $this->authorize('create', Board::class);\n        \n        $selectedWorkspace = $user->getSelectedWorkspace();\n        \n        if (!$selectedWorkspace) {\n            abort(404, 'No workspace found');\n        }\n        \n        // Get all active displays from the workspace\n        $displays = Display::where('workspace_id', $selectedWorkspace->id)\n            ->whereIn('status', [DisplayStatus::READY, DisplayStatus::ACTIVE])\n            ->orderBy('name')\n            ->get();\n        \n        return view('pages.boards.form', [\n            'board' => null,\n            'displays' => $displays,\n            'workspace' => $selectedWorkspace,\n        ]);\n    }\n\n    /**\n     * Store a newly created board\n     */\n    public function store(CreateBoardRequest $request): RedirectResponse\n    {\n        $user = auth()->user();\n        \n        // Check Pro access\n        if (!$user->hasProForCurrentWorkspace()) {\n            return redirect()->back()->with('error', 'Boards is a Pro feature. Please upgrade to access this feature.');\n        }\n        \n        $this->authorize('create', Board::class);\n        \n        $validated = $request->validated();\n        $selectedWorkspace = $user->getSelectedWorkspace();\n        \n        if (!$selectedWorkspace || $selectedWorkspace->id !== $validated['workspace_id']) {\n            return redirect()->back()->with('error', 'Invalid workspace selected.');\n        }\n        \n        // Verify user has access to this workspace\n        if (!$selectedWorkspace->hasMember($user)) {\n            return redirect()->back()->with('error', 'You do not have access to this workspace.');\n        }\n        \n        // Create the board first\n        $board = Board::create([\n            'workspace_id' => $validated['workspace_id'],\n            'user_id' => $user->id,\n            'name' => $validated['name'],\n            'title' => $validated['title'] ?? null,\n            'subtitle' => $validated['subtitle'] ?? null,\n            'show_all_displays' => $validated['show_all_displays'],\n            'theme' => $validated['theme'] ?? 'dark',\n            'show_title' => $validated['show_title'] ?? true,\n            'show_booker' => $validated['show_booker'] ?? true,\n            'show_next_event' => $validated['show_next_event'] ?? true,\n            'show_transitioning' => $validated['show_transitioning'] ?? true,\n            'transitioning_minutes' => $validated['transitioning_minutes'] ?? 10,\n            'font_family' => $validated['font_family'] ?? 'Inter',\n            'language' => $validated['language'] ?? 'en',\n            'view_mode' => $validated['view_mode'] ?? 'card',\n            'show_meeting_title' => $validated['show_meeting_title'] ?? true,\n        ]);\n        \n        // Handle logo upload after board is created\n        if ($request->hasFile('logo')) {\n            $logoPath = $this->imageService->storeBoardLogoFile($request->file('logo'), $board);\n            $board->update(['logo' => $logoPath]);\n        }\n        \n        // Sync displays if not showing all\n        if (!$validated['show_all_displays']) {\n            if (isset($validated['display_ids']) && is_array($validated['display_ids']) && count($validated['display_ids']) > 0) {\n                // Verify all display IDs belong to the workspace\n                $displayIds = Display::where('workspace_id', $selectedWorkspace->id)\n                    ->whereIn('id', $validated['display_ids'])\n                    ->pluck('id')\n                    ->toArray();\n                \n                $board->displays()->sync($displayIds);\n            } else {\n                // No displays selected, clear associations\n                $board->displays()->detach();\n            }\n        } else {\n            // Clear all display associations if showing all\n            $board->displays()->detach();\n        }\n        \n        return redirect(route('dashboard') . '?tab=boards')\n            ->with('success', 'Board created successfully.');\n    }\n\n    /**\n     * Display the specified board (the actual board view)\n     */\n    public function show(Board $board): View|Factory|Application\n    {\n        $user = auth()->user();\n        \n        // Check Pro access\n        if (!$user->hasProForCurrentWorkspace()) {\n            abort(403, 'Boards is a Pro feature. Please upgrade to access this feature.');\n        }\n        \n        $this->authorize('view', $board);\n        \n        // Store board in a way that getDisplayStatusData can access it\n        $this->currentBoard = $board;\n        \n        // Get displays to show\n        $displays = $board->getDisplaysToShow();\n        \n        // Fetch events and determine status for each display\n        $displayData = $this->getDisplayStatusData($displays, $board);\n        \n        return view('pages.boards.show', [\n            'board' => $board,\n            'displays' => $displayData,\n            'workspace' => $board->workspace,\n        ]);\n    }\n    \n    private function getTransitioningMinutes($currentEvent, $nextEvent, ?Board $board = null): ?int\n    {\n        $transitioningMinutes = $board ? ($board->transitioning_minutes ?? 10) : 10;\n        $now = now();\n        \n        // If current event is ending soon\n        if ($currentEvent) {\n            $minutesLeft = $now->diffInMinutes($currentEvent->end, false);\n            if ($minutesLeft < $transitioningMinutes && $minutesLeft > 0) {\n                return $minutesLeft;\n            }\n        }\n        \n        // If next event is starting soon\n        if ($nextEvent) {\n            $minutesUntil = $now->diffInMinutes($nextEvent->start, false);\n            if ($minutesUntil < $transitioningMinutes && $minutesUntil > 0) {\n                return $minutesUntil;\n            }\n        }\n        \n        return null;\n    }\n\n    /**\n     * Show the form for editing the specified board\n     */\n    public function edit(Board $board): View|Factory|Application\n    {\n        $user = auth()->user();\n        \n        // Check Pro access\n        if (!$user->hasProForCurrentWorkspace()) {\n            abort(403, 'Boards is a Pro feature. Please upgrade to access this feature.');\n        }\n        \n        $this->authorize('update', $board);\n        \n        // Get all active displays from the workspace\n        $displays = Display::where('workspace_id', $board->workspace_id)\n            ->whereIn('status', [DisplayStatus::READY, DisplayStatus::ACTIVE])\n            ->orderBy('name')\n            ->get();\n        \n        return view('pages.boards.form', [\n            'board' => $board,\n            'displays' => $displays,\n            'workspace' => $board->workspace,\n        ]);\n    }\n\n    /**\n     * Update the specified board\n     */\n    public function update(UpdateBoardRequest $request, Board $board): RedirectResponse\n    {\n        $user = auth()->user();\n        \n        // Check Pro access\n        if (!$user->hasProForCurrentWorkspace()) {\n            return redirect()->back()->with('error', 'Boards is a Pro feature. Please upgrade to access this feature.');\n        }\n        \n        $this->authorize('update', $board);\n        \n        $validated = $request->validated();\n        \n        // Verify workspace matches\n        if ($board->workspace_id !== $validated['workspace_id']) {\n            return redirect()->back()->with('error', 'Invalid workspace selected.');\n        }\n        \n        // Handle logo upload/removal\n        $logoPath = $board->logo;\n        if ($request->boolean('remove_logo')) {\n            $this->imageService->removeBoardLogoFile($board);\n            $logoPath = null;\n        } elseif ($request->hasFile('logo')) {\n            // Remove old logo if exists\n            $this->imageService->removeBoardLogoFile($board);\n            // Store new logo\n            $logoPath = $this->imageService->storeBoardLogoFile($request->file('logo'), $board);\n        }\n        \n        // Update the board\n        $board->update([\n            'name' => $validated['name'],\n            'title' => $validated['title'] ?? null,\n            'subtitle' => $validated['subtitle'] ?? null,\n            'show_all_displays' => $validated['show_all_displays'],\n            'theme' => $validated['theme'] ?? 'dark',\n            'logo' => $logoPath,\n            'show_title' => $validated['show_title'] ?? true,\n            'show_booker' => $validated['show_booker'] ?? true,\n            'show_next_event' => $validated['show_next_event'] ?? true,\n            'show_transitioning' => $validated['show_transitioning'] ?? true,\n            'transitioning_minutes' => $validated['transitioning_minutes'] ?? 10,\n            'font_family' => $validated['font_family'] ?? 'Inter',\n            'language' => $validated['language'] ?? 'en',\n            'view_mode' => $validated['view_mode'] ?? 'card',\n            'show_meeting_title' => $validated['show_meeting_title'] ?? true,\n        ]);\n        \n        // Sync displays if not showing all\n        if (!$validated['show_all_displays']) {\n            if (isset($validated['display_ids']) && is_array($validated['display_ids']) && count($validated['display_ids']) > 0) {\n                // Verify all display IDs belong to the workspace\n                $displayIds = Display::where('workspace_id', $board->workspace_id)\n                    ->whereIn('id', $validated['display_ids'])\n                    ->pluck('id')\n                    ->toArray();\n                \n                $board->displays()->sync($displayIds);\n            } else {\n                // No displays selected, clear associations\n                $board->displays()->detach();\n            }\n        } else {\n            // Clear all display associations if showing all\n            $board->displays()->detach();\n        }\n        \n        return redirect(route('dashboard') . '?tab=boards')\n            ->with('success', 'Board updated successfully.');\n    }\n\n    /**\n     * Remove the specified board\n     */\n    public function destroy(Board $board): RedirectResponse\n    {\n        $user = auth()->user();\n        \n        // Check Pro access\n        if (!$user->hasProForCurrentWorkspace()) {\n            return redirect()->back()->with('error', 'Boards is a Pro feature. Please upgrade to access this feature.');\n        }\n        \n        $this->authorize('delete', $board);\n        \n        // Remove logo file if exists\n        $this->imageService->removeBoardLogoFile($board);\n        \n        $board->delete();\n        \n        return redirect(route('dashboard') . '?tab=boards')\n            ->with('success', 'Board deleted successfully.');\n    }\n\n    /**\n     * Serve board logo image\n     */\n    public function serveLogo(Board $board)\n    {\n        $this->authorize('view', $board);\n        return $this->imageService->serveBoardLogo($board);\n    }\n\n    /**\n     * Get display status data for a collection of displays\n     * Extracted from FlightboardController for reusability\n     */\n    private function getDisplayStatusData(Collection $displays, ?Board $board = null): Collection\n    {\n        return $displays->map(function ($display) use ($board) {\n            try {\n                $events = $this->eventService->getEventsForDisplay($display->id)\n                    ->where('status', '!=', EventStatus::CANCELLED);\n                \n                $now = now();\n                $currentEvent = $events->first(function ($event) use ($now) {\n                    return $event->start <= $now && $event->end > $now;\n                });\n                \n                $upcomingEvents = $events->filter(function ($event) use ($now) {\n                    return $event->start > $now;\n                })->sortBy('start');\n                \n                $nextEvent = $upcomingEvents->first();\n                \n                // Get board settings\n                $showTransitioning = $board ? ($board->show_transitioning ?? true) : true;\n                \n                // Get board language for translations\n                $boardLanguage = $board ? ($board->language ?? 'en') : 'en';\n                \n                // Determine status\n                $status = 'available'; // green\n                $statusText = Lang::get('boards.available', [], $boardLanguage);\n                \n                if ($currentEvent) {\n                    $status = 'busy'; // red\n                    $statusText = Lang::get('boards.busy', [], $boardLanguage);\n                } elseif ($showTransitioning && $this->isTransitioning($display, $currentEvent, $nextEvent, $board)) {\n                    $status = 'transitioning'; // amber\n                    $statusText = Lang::get('boards.transitioning', [], $boardLanguage);\n                }\n                \n                // Check for check-in active\n                $checkInEnabled = DisplaySettings::isCheckInEnabled($display);\n                $checkInEvent = null;\n                if ($checkInEnabled) {\n                    $checkInMinutes = DisplaySettings::getCheckInMinutes($display);\n                    $checkInGracePeriod = DisplaySettings::getCheckInGracePeriod($display);\n                    \n                    $checkInEvent = $events->first(function ($event) use ($now, $checkInMinutes, $checkInGracePeriod) {\n                        if (!$event->checkInRequired()) {\n                            return false;\n                        }\n                        $windowStart = $event->start->copy()->subMinutes($checkInMinutes);\n                        $windowEnd = $event->start->copy()->addMinutes($checkInGracePeriod);\n                        return $now->isAfter($windowStart) && $now->isBefore($windowEnd);\n                    });\n                    \n                    if ($checkInEvent) {\n                        $status = 'transitioning';\n                        $statusText = Lang::get('boards.check_in', [], $boardLanguage);\n                    }\n                }\n                \n                // Get board settings for meeting title privacy\n                $showMeetingTitle = $board ? ($board->show_meeting_title ?? true) : DisplaySettings::getShowMeetingTitle($display);\n                \n                // Helper function to truncate summary\n                $truncateSummary = function($text) {\n                    if (mb_strlen($text) > 40) {\n                        return mb_substr($text, 0, 40) . '...';\n                    }\n                    return $text;\n                };\n                \n                return [\n                    'display' => $display,\n                    'status' => $status,\n                    'statusText' => $statusText,\n                    'currentEvent' => $currentEvent ? [\n                        'summary' => $truncateSummary($showMeetingTitle \n                            ? $currentEvent->summary \n                            : (DisplaySettings::getReservedText($display) ?? 'Reserved')),\n                        'start' => $currentEvent->start,\n                        'end' => $currentEvent->end,\n                        'organizer' => $currentEvent->user?->name ?? 'Unknown',\n                    ] : null,\n                    'nextEvent' => $nextEvent ? [\n                        'summary' => $truncateSummary($showMeetingTitle \n                            ? $nextEvent->summary \n                            : (DisplaySettings::getReservedText($display) ?? 'Reserved')),\n                        'start' => $nextEvent->start,\n                        'end' => $nextEvent->end,\n                        'organizer' => $nextEvent->user?->name ?? 'Unknown',\n                    ] : null,\n                    'transitioningMinutes' => $this->getTransitioningMinutes($currentEvent, $nextEvent, $board),\n                ];\n            } catch (\\Exception $e) {\n                logger()->error('Failed to fetch events for display in board', [\n                    'display_id' => $display->id,\n                    'error' => $e->getMessage(),\n                ]);\n                \n                // Get board language for translations\n                $boardLanguage = $board ? ($board->language ?? 'en') : 'en';\n                \n                return [\n                    'display' => $display,\n                    'status' => 'error',\n                    'statusText' => Lang::get('boards.error', [], $boardLanguage),\n                    'currentEvent' => null,\n                    'nextEvent' => null,\n                ];\n            }\n        });\n    }\n    \n    /**\n     * Check if display is in transitioning state\n     */\n    private function isTransitioning($display, $currentEvent, $nextEvent, ?Board $board = null): bool\n    {\n        $checkInEnabled = DisplaySettings::isCheckInEnabled($display);\n        if ($checkInEnabled) {\n            return false; // Check-in logic handled separately\n        }\n        \n        $transitioningMinutes = $board ? ($board->transitioning_minutes ?? 10) : 10;\n        $now = now();\n        \n        // Current event ending within configured minutes\n        if ($currentEvent) {\n            $minutesLeft = $now->diffInMinutes($currentEvent->end, false);\n            if ($minutesLeft < $transitioningMinutes && $minutesLeft > 0) {\n                return true;\n            }\n        }\n        \n        // Next event starting within configured minutes\n        if ($nextEvent) {\n            $minutesUntil = $now->diffInMinutes($nextEvent->start, false);\n            if ($minutesUntil < $transitioningMinutes && $minutesUntil > 0) {\n                return true;\n            }\n        }\n        \n        return false;\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/CalDAVAccountsController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers;\n\nuse App\\Models\\CalDAVAccount;\nuse Illuminate\\Http\\RedirectResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\View\\View;\nuse App\\Services\\CalDAVService;\nuse App\\Enums\\PermissionType;\n\nclass CalDAVAccountsController extends Controller\n{\n    public function __construct(protected CalDAVService $caldavService)\n    {\n    }\n\n    public function create(): View\n    {\n        return view('pages.caldav-accounts.create');\n    }\n\n    public function store(Request $request): RedirectResponse\n    {\n        $validated = $request->validate([\n            'url' => 'required|url',\n            'username' => 'required|string',\n            'password' => 'required|string',\n        ]);\n\n        // Test connection before creating account\n        $connectionTest = $this->caldavService->checkConnection(\n            $validated['url'],\n            $validated['username'],\n            $validated['password']\n        );\n\n        if (!$connectionTest['success']) {\n            return back()->withErrors([\n                'connection' => $connectionTest['message']\n            ])->withInput();\n        }\n\n        // Get selected workspace (from session or default to primary)\n        $selectedWorkspace = auth()->user()->getSelectedWorkspace();\n        $workspaceId = $selectedWorkspace?->id;\n\n        // Create the CalDAV account\n        $account = CalDAVAccount::create([\n            'user_id' => auth()->id(),\n            'workspace_id' => $workspaceId,\n            'name' => parse_url($validated['url'], PHP_URL_HOST),\n            'email' => $validated['username'],\n            'url' => $validated['url'],\n            'username' => $validated['username'],\n            'password' => $validated['password'],\n            'permission_type' => PermissionType::WRITE,\n        ]);\n\n        return redirect()\n            ->route('dashboard')\n            ->with('status', 'CalDAV account has been connected successfully.');\n    }\n\n    public function delete(CalDAVAccount $caldavAccount): RedirectResponse\n    {\n        $caldavAccount->delete();\n\n        return redirect()\n            ->route('dashboard')\n            ->with('status', 'CalDAV account has been removed successfully.');\n    }\n}"
  },
  {
    "path": "backend/app/Http/Controllers/CalendarController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers;\n\nuse App\\Services\\GoogleService;\nuse App\\Services\\OutlookService;\nuse App\\Services\\CalDAVService;\nuse Google\\Service\\Exception as GoogleException;\nuse Illuminate\\Contracts\\Foundation\\Application;\nuse Illuminate\\Contracts\\View\\Factory;\nuse Illuminate\\Contracts\\View\\View;\nuse Illuminate\\Http\\Client\\ConnectionException;\n\nclass CalendarController extends Controller\n{\n    public function __construct(\n        protected OutlookService $outlookService,\n        protected GoogleService $googleService,\n        protected CalDAVService $caldavService\n    ) {\n    }\n\n    public function google(string $id): View|Factory|Application\n    {\n        try {\n            $account = auth()->user()->googleAccounts()->findOrFail($id);\n            $calendars = $this->googleService->fetchCalendars($account);\n\n            return view('components.calendars.picker', [\n                'calendars' => collect($calendars)->map(function ($calendar) {\n                    return [\n                        'id' => $calendar->getId(),\n                        'name' => $calendar->getSummary(),\n                    ];\n                })->toArray()\n            ]);\n        } catch (GoogleException $e) {\n            logger()->error('Google API error: ' . $e->getMessage());\n\n            // Check for insufficient permissions error\n            if (str_contains($e->getMessage(), 'insufficientPermissions') ||\n                str_contains($e->getMessage(), 'ACCESS_TOKEN_SCOPE_INSUFFICIENT')) {\n                return view('components.calendars.picker', [\n                    'calendars' => [],\n                    'error' => 'Insufficient permissions to access Google Calendar. Please ensure you have granted all required permissions during authentication.'\n                ]);\n            }\n\n            return view('components.calendars.picker', [\n                'calendars' => [],\n                'error' => 'Could not fetch calendars from Google. Please check your permissions and try again.'\n            ]);\n        } catch (\\Exception $e) {\n            logger()->error('Google calendars fetch error: ' . $e->getMessage());\n            return view('components.calendars.picker', [\n                'calendars' => [],\n                'error' => 'Could not fetch calendars from Google. Please try again later.'\n            ]);\n        }\n    }\n\n    public function outlook(string $id): View|Factory|Application\n    {\n        try {\n            $account = auth()->user()->outlookAccounts()->findOrFail($id);\n            $calendars = $this->outlookService->fetchCalendars($account);\n\n            return view('components.calendars.picker', [\n                'calendars' => collect($calendars)->map(function (array $calendar) {\n                    return [\n                        'id' => $calendar['id'],\n                        'name' => $calendar['name']\n                    ];\n                })->toArray()\n            ]);\n        } catch (ConnectionException $e) {\n            logger()->error('Outlook API connection error: ' . $e->getMessage());\n            return view('components.calendars.picker', [\n                'calendars' => [],\n                'error' => 'Could not connect to Outlook. Please try again later.'\n            ]);\n        } catch (\\Exception $e) {\n            logger()->error('Outlook calendars fetch error: ' . $e->getMessage());\n            return view('components.calendars.picker', [\n                'calendars' => [],\n                'error' => 'Could not fetch calendars from Outlook. Please check your permissions and try again.'\n            ]);\n        }\n    }\n\n    public function caldav(string $id): View|Factory|Application\n    {\n        try {\n            $account = auth()->user()->caldavAccounts()->findOrFail($id);\n            $calendars = app(CalDAVService::class)->fetchCalendars($account);\n            return view('components.calendars.picker', [\n                'calendars' => collect($calendars)->map(function ($calendar) {\n                    return [\n                        'id' => $calendar['id'],\n                        'name' => $calendar['name']\n                    ];\n                })->toArray()\n            ]);\n        } catch (\\Exception $e) {\n            logger()->error('CalDAV calendars fetch error: ' . $e->getMessage());\n            return view('components.calendars.picker', [\n                'calendars' => [],\n                'error' => 'Could not fetch calendars from CalDAV server. Please check your connection and try again.'\n            ]);\n        }\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/Controller.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers;\n\nuse Illuminate\\Foundation\\Auth\\Access\\AuthorizesRequests;\nuse Illuminate\\Foundation\\Validation\\ValidatesRequests;\nuse Illuminate\\Routing\\Controller as BaseController;\n\nclass Controller extends BaseController\n{\n    use AuthorizesRequests, ValidatesRequests;\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/DashboardController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers;\n\nuse App\\Services\\OutlookService;\nuse Illuminate\\Contracts\\Foundation\\Application;\nuse Illuminate\\Contracts\\View\\Factory;\nuse Illuminate\\Contracts\\View\\View;\nuse App\\Services\\InstanceService;\nuse App\\Models\\Display;\nuse App\\Models\\Calendar;\nuse App\\Models\\OutlookAccount;\nuse App\\Models\\GoogleAccount;\nuse App\\Models\\CalDAVAccount;\nuse App\\Models\\Board;\n\nclass DashboardController extends Controller\n{\n    public function __construct(protected OutlookService $outlookService)\n    {\n    }\n\n    /**\n     * @return Application|Factory|View\n     * @throws \\Exception\n     */\n    public function __invoke(): View|Factory|Application\n    {\n        $user = auth()->user();\n        \n        // Load workspaces with pivot data (role) - this includes all workspaces user is a member of\n        $workspaces = $user->workspaces()->withPivot('role')->get();\n        \n        // Get selected workspace (from session or default to primary)\n        $selectedWorkspace = $user->getSelectedWorkspace();\n        \n        // Get connect code from workspace owner (or current user if no workspace selected)\n        $connectCode = null;\n        if ($selectedWorkspace) {\n            $workspaceOwner = $selectedWorkspace->owners()->first();\n            if ($workspaceOwner) {\n                $connectCode = $workspaceOwner->getConnectCode();\n            }\n        }\n        // Fallback to current user's connect code if no workspace or owner found\n        if (!$connectCode) {\n            $connectCode = $user->getConnectCode();\n        }\n        \n        // Get displays from selected workspace only\n        if ($selectedWorkspace) {\n            $displays = Display::where('workspace_id', $selectedWorkspace->id)\n                ->with(['workspace', 'calendar.outlookAccount', 'calendar.googleAccount', 'calendar.caldavAccount'])\n                ->get();\n            \n            // Get boards for the selected workspace\n            $boards = Board::where('workspace_id', $selectedWorkspace->id)\n                ->with(['user', 'displays'])\n                ->orderBy('name')\n                ->get();\n            \n            // Get accounts for the selected workspace\n            $outlookAccounts = OutlookAccount::where('workspace_id', $selectedWorkspace->id)\n                ->get();\n            $googleAccounts = GoogleAccount::where('workspace_id', $selectedWorkspace->id)\n                ->get();\n            $caldavAccounts = CalDAVAccount::where('workspace_id', $selectedWorkspace->id)\n                ->get();\n        } else {\n            $displays = collect();\n            $boards = collect();\n            $outlookAccounts = collect();\n            $googleAccounts = collect();\n            $caldavAccounts = collect();\n        }\n\n        logger()->info('Dashboard page accessed', [\n            'user_id' => $user->id,\n            'outlook_accounts_count' => $outlookAccounts->count(),\n            'google_accounts_count' => $googleAccounts->count(),\n            'caldav_accounts_count' => $caldavAccounts->count(),\n            'displays_count' => $displays->count(),\n            'workspaces_count' => $workspaces->count(),\n            'selected_workspace_id' => $selectedWorkspace?->id,\n            'ip' => request()->ip(),\n            'user_agent' => substr(request()->userAgent() ?? '', 0, 100),\n        ]);\n\n        $isSelfHosted = config('settings.is_self_hosted');\n        \n        return view('pages.dashboard', [\n            'outlookAccounts' => $outlookAccounts,\n            'googleAccounts' => $googleAccounts,\n            'caldavAccounts' => $caldavAccounts,\n            'displays' => $displays,\n            'boards' => $boards,\n            'workspaces' => $workspaces,\n            'selectedWorkspace' => $selectedWorkspace,\n            'connectCode' => $connectCode,\n            'primaryWorkspace' => $user->primaryWorkspace(),\n            'version' => config('settings.version', 'dev'),\n            'appEnv' => config('app.env', 'production'),\n            'appUrl' => config('app.url'),\n            'isSelfHosted' => $isSelfHosted,\n        ]);\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/DisplayController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers;\n\nuse App\\Enums\\Provider;\nuse App\\Enums\\DisplayStatus;\nuse App\\Events\\UserOnboarded;\nuse App\\Http\\Requests\\CreateDisplayRequest;\nuse App\\Models\\Calendar;\nuse App\\Models\\Display;\nuse App\\Models\\OutlookAccount;\nuse App\\Models\\Room;\nuse App\\Models\\CalDAVAccount;\nuse App\\Services\\OutlookService;\nuse App\\Services\\GoogleService;\nuse App\\Services\\CalDAVService;\nuse Exception;\nuse Illuminate\\Contracts\\View\\Factory;\nuse Illuminate\\Contracts\\View\\View;\nuse Illuminate\\Foundation\\Application;\nuse Illuminate\\Http\\RedirectResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\DB;\nuse App\\Models\\GoogleAccount;\n\nclass DisplayController extends Controller\n{\n    public function __construct(\n        protected OutlookService $outlookService,\n        protected GoogleService $googleService,\n        protected CalDAVService $caldavService\n    ) {\n    }\n\n    public function create(): View\n    {\n        $user = auth()->user();\n        $workspaces = $user->workspaces()->withPivot('role')->get();\n        $selectedWorkspace = $user->getSelectedWorkspace();\n\n        // Filter accounts to show all accounts for the selected workspace (from any workspace member)\n        if ($selectedWorkspace) {\n            $outlookAccounts = OutlookAccount::where('workspace_id', $selectedWorkspace->id)->get();\n            $googleAccounts = GoogleAccount::where('workspace_id', $selectedWorkspace->id)->get();\n            $caldavAccounts = CalDAVAccount::where('workspace_id', $selectedWorkspace->id)->get();\n        } else {\n            // Fallback to user's own accounts if no workspace selected\n            $outlookAccounts = $user->outlookAccounts;\n            $googleAccounts = $user->googleAccounts;\n            $caldavAccounts = $user->caldavAccounts;\n        }\n\n        return view('pages.displays.create', [\n            'outlookAccounts' => $outlookAccounts,\n            'googleAccounts' => $googleAccounts,\n            'caldavAccounts' => $caldavAccounts,\n            'workspaces' => $workspaces,\n            'defaultWorkspace' => $selectedWorkspace ?? $user->primaryWorkspace(),\n        ]);\n    }\n\n    /**\n     * @throws Exception\n     */\n    public function store(CreateDisplayRequest $request): RedirectResponse\n    {\n        $validatedData = $request->validated();\n\n        $provider = $validatedData['provider'];\n        $accountId = $validatedData['account'];\n\n        // Check on access to create multiple displays (workspace-aware Pro check)\n        if (auth()->user()->shouldUpgradeForCurrentWorkspace()) {\n            return redirect()->back()->with('error', 'You require an active Pro license to create multiple displays.');\n        }\n\n        // Validate the existence of the appropriate account based on provider\n        match ($provider) {\n            'outlook' => OutlookAccount::findOrFail($accountId),\n            'google' => GoogleAccount::findOrFail($accountId),\n            'caldav' => CalDAVAccount::findOrFail($accountId),\n            default => throw new \\InvalidArgumentException('Invalid provider')\n        };\n\n        $user = auth()->user();\n        \n        // Get workspace from request, session (selected workspace), or default to primary\n        $workspaceId = $validatedData['workspace_id'] \n            ?? session()->get('selected_workspace_id')\n            ?? $user->primaryWorkspace()?->id;\n        \n        if (!$workspaceId) {\n            return redirect()->back()->with('error', 'No workspace found. Please contact support.');\n        }\n        \n        // Verify user has access to this workspace\n        $workspace = $user->workspaces()->find($workspaceId);\n        if (!$workspace) {\n            return redirect()->back()->with('error', 'You do not have access to this workspace.');\n        }\n        \n        // Check if user can create displays in this workspace (owner/admin)\n        if (!$workspace->canBeManagedBy($user)) {\n            return redirect()->back()->with('error', 'You do not have permission to create displays in this workspace.');\n        }\n\n        $display = DB::transaction(function () use ($validatedData, $workspace) {\n            // Handle room or calendar selection\n            $calendar = $this->createCalendar($validatedData, $workspace);\n\n            return Display::create([\n                'user_id' => auth()->id(),\n                'workspace_id' => $workspace->id,\n                'name' => $validatedData['name'],\n                'display_name' => $validatedData['displayName'],\n                'status' => DisplayStatus::READY,\n                'calendar_id' => $calendar->id,\n            ]);\n        });\n\n        if ($display) {\n            event(new UserOnboarded($request->user(), $display));\n        }\n\n        return redirect()->route('dashboard')->with($display ? 'success' : 'error', $display ?\n            'Display created! Now enter the connect code in the app on your tablet to connect it to the display.' :\n            'Display could not be created. Please try again later.'\n        );\n    }\n\n    public function updateStatus(Request $request, Display $display): RedirectResponse\n    {\n        $this->authorize('update', $display);\n\n        $data = $request->validate([\n            'status' => 'required|in:active,deactivated'\n        ]);\n\n        $display->update(['status' => $data['status']]);\n\n        return redirect()\n            ->route('dashboard')\n            ->with('status', 'Display status has been changed.');\n    }\n\n    public function delete(Display $display): RedirectResponse\n    {\n        $this->authorize('delete', $display);\n\n        // Get the calendar associated with the display\n        $calendar = $display->calendar;\n\n        // Delete the display\n        $display->eventSubscriptions()->delete();\n        $display->delete();\n\n        // Delete the calendar if it has no displays\n        if ($calendar && $calendar->displays()->count() === 0) {\n            $calendar->delete();\n        }\n\n        return redirect()\n            ->route('dashboard')\n            ->with('status', 'Display has successfully been deleted.');\n    }\n\n    private function createCalendar(array $validatedData, $workspace): Calendar\n    {\n        $provider = $validatedData['provider'];\n        $accountId = $validatedData['account'];\n        $userId = auth()->id();\n\n        if (isset($validatedData['room'])) {\n            $roomData = explode(',', $validatedData['room']);\n            $calendarId = $roomData[0];\n            $calendarName = $this->extractCalendarName($roomData[1] ?? '');\n\n            $calendar = Calendar::firstOrCreate([\n                'calendar_id' => $calendarId,\n                'workspace_id' => $workspace->id,\n            ], [\n                'calendar_id' => $calendarId,\n                'user_id' => $userId,\n                'workspace_id' => $workspace->id,\n                \"{$provider}_account_id\" => $accountId,\n                'name' => $calendarName,\n            ]);\n\n            Room::firstOrCreate([\n                'email_address' => $calendarId,\n                'workspace_id' => $workspace->id,\n            ], [\n                'email_address' => $calendarId,\n                'user_id' => $userId,\n                'workspace_id' => $workspace->id,\n                'calendar_id' => $calendar->id,\n                'name' => $calendarName,\n            ]);\n\n            return $calendar;\n        }\n\n        $calendarData = explode(',', $validatedData['calendar']);\n        $calendarName = $this->extractCalendarName($calendarData[1] ?? '');\n        \n        $calendar = Calendar::firstOrCreate([\n            'calendar_id' => $calendarData[0],\n            'workspace_id' => $workspace->id,\n        ], [\n            'user_id' => $userId,\n            'workspace_id' => $workspace->id,\n            \"{$provider}_account_id\" => $accountId,\n            'calendar_id' => $calendarData[0],\n            'name' => $calendarName,\n        ]);\n\n        return $calendar;\n    }\n\n    /**\n     * Extract calendar name from a value that might be a URL or a plain name.\n     * Truncates to 255 characters to fit the database column.\n     */\n    private function extractCalendarName(string $value): string\n    {\n        // If empty, return a default name\n        if (empty($value)) {\n            return 'Calendar';\n        }\n\n        // Truncate to 255 characters to fit the database column\n        return mb_substr($value, 0, 255);\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/DisplaySettingsController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers;\n\nuse App\\Helpers\\DisplaySettings;\nuse App\\Models\\Display;\nuse App\\Services\\ImageService;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Http\\RedirectResponse;\nuse Illuminate\\Contracts\\View\\View;\nuse App\\Http\\Requests\\UpdateDisplayCustomizationRequest;\n\nclass DisplaySettingsController extends Controller\n{\n    public function __construct(\n        protected ImageService $imageService\n    ) {\n    }\n    public function index(Display $display): View\n    {\n        $this->authorize('update', $display);\n\n        // Check if user has Pro access for the display's workspace\n        if (!$display->workspace_id || !auth()->user()->hasProForWorkspace($display->workspace)) {\n            return redirect()->route('dashboard')->with('error', 'Display settings are only available for Pro users.');\n        }\n\n        return view('pages.displays.settings', [\n            'display' => $display->load('calendar')\n        ]);\n    }\n\n    public function update(Request $request, Display $display): RedirectResponse\n    {\n        $this->authorize('update', $display);\n\n        // Check if user has Pro access for the display's workspace\n        if (!$display->workspace_id || !auth()->user()->hasProForWorkspace($display->workspace)) {\n            return redirect()->route('dashboard')->with('error', 'Display settings are only available for Pro users.');\n        }\n\n        $request->validate([\n            'check_in_enabled' => 'boolean',\n            'booking_enabled' => 'boolean',\n            'calendar_enabled' => 'boolean',\n            'hide_admin_actions' => 'boolean',\n            'check_in_minutes' => 'nullable|integer|min:1|max:60',\n            'check_in_grace_period' => 'nullable|integer|min:1|max:30',\n            'cancel_permission' => 'nullable|in:all,tablet_only,none',\n            'border_thickness' => 'nullable|in:small,medium,large',\n        ]);\n\n        $updated = true;\n\n        $updated = $updated && DisplaySettings::setCheckInEnabled(\n            $display,\n            $request->boolean('check_in_enabled')\n        );\n\n        $updated = $updated && DisplaySettings::setBookingEnabled(\n            $display,\n            $request->boolean('booking_enabled')\n        );\n\n        $updated = $updated && DisplaySettings::setCalendarEnabled(\n            $display,\n            $request->boolean('calendar_enabled')\n        );\n\n        $updated = $updated && DisplaySettings::setAdminActionsHidden(\n            $display,\n            $request->boolean('hide_admin_actions')\n        );\n\n        // Only allow updating grace period if check-in is enabled (either in request or already enabled)\n        $checkInEnabled = $request->has('check_in_enabled')\n            ? $request->boolean('check_in_enabled')\n            : $display->isCheckInEnabled();\n        if ($checkInEnabled && $request->has('check_in_grace_period')) {\n            $updated = $updated && DisplaySettings::setCheckInGracePeriod(\n                $display,\n                (int) $request->input('check_in_grace_period')\n            );\n        }\n\n        if ($checkInEnabled && $request->has('check_in_minutes')) {\n            $updated = $updated && DisplaySettings::setCheckInMinutes(\n                $display,\n                (int) $request->input('check_in_minutes')\n            );\n        }\n\n        // Handle cancel permission\n        if ($request->has('cancel_permission')) {\n            $updated = $updated && DisplaySettings::setCancelPermission(\n                $display,\n                $request->input('cancel_permission')\n            );\n        }\n\n        // Handle border thickness\n        if ($request->has('border_thickness')) {\n            $updated = $updated && DisplaySettings::setBorderThickness(\n                $display,\n                $request->input('border_thickness')\n            );\n        }\n\n        if (!$updated) {\n            return back()->withErrors(['error' => 'Failed to update settings']);\n        }\n\n        // Touch the display to update its updated_at timestamp\n        $display->touch();\n\n        return redirect()->route('dashboard')->with('success', 'Display settings updated successfully');\n    }\n\n    public function customization(Display $display): View\n    {\n        $this->authorize('update', $display);\n\n        // Check if user has Pro access for the display's workspace\n        if (!$display->workspace_id || !auth()->user()->hasProForWorkspace($display->workspace)) {\n            return redirect()->route('dashboard')->with('error', 'Display customization is only available for Pro users.');\n        }\n\n        return view('pages.displays.customization', [\n            'display' => $display->load('calendar')\n        ]);\n    }\n\n    public function updateCustomization(UpdateDisplayCustomizationRequest $request, Display $display): RedirectResponse\n    {\n        $this->authorize('update', $display);\n\n        // Check if user has Pro access for the display's workspace\n        if (!$display->workspace_id || !auth()->user()->hasProForWorkspace($display->workspace)) {\n            return redirect()->route('dashboard')->with('error', 'Display customization is only available for Pro users.');\n        }\n\n        $updated = true;\n        // Handle text_available\n        if (filled($request->input('text_available'))) {\n            $updated = $updated && DisplaySettings::setAvailableText($display, $request->input('text_available'));\n        } else {\n            DisplaySettings::deleteSetting($display, 'text_available');\n        }\n\n        // Handle text_transitioning\n        if (filled($request->input('text_transitioning'))) {\n            $updated = $updated && DisplaySettings::setTransitioningText($display, $request->input('text_transitioning'));\n        } else {\n            DisplaySettings::deleteSetting($display, 'text_transitioning');\n        }\n\n        // Handle text_reserved\n        if (filled($request->input('text_reserved'))) {\n            $updated = $updated && DisplaySettings::setReservedText($display, $request->input('text_reserved'));\n        } else {\n            DisplaySettings::deleteSetting($display, 'text_reserved');\n        }\n\n        // Handle text_checkin\n        if (filled($request->input('text_checkin'))) {\n            $updated = $updated && DisplaySettings::setCheckInText($display, $request->input('text_checkin'));\n        } else {\n            DisplaySettings::deleteSetting($display, 'text_checkin');\n        }\n\n        // Handle show_meeting_title (always set, default to false if not present)\n        $updated = $updated && DisplaySettings::setShowMeetingTitle($display, $request->boolean('show_meeting_title'));\n\n        // Handle font_family\n        if ($request->has('font_family')) {\n            $updated = $updated && DisplaySettings::setFontFamily($display, $request->input('font_family'));\n        }\n\n        // Handle logo upload/removal\n        if ($request->boolean('remove_logo')) {\n            $this->imageService->removeLogoFile($display);\n            $updated = $updated && DisplaySettings::removeLogo($display);\n        } elseif ($request->hasFile('logo')) {\n            $logoPath = $this->imageService->storeLogoFile($request->file('logo'), $display);\n            if ($logoPath) {\n                $this->imageService->removeLogoFile($display); // Remove old logo if exists\n                $updated = $updated && DisplaySettings::setLogo($display, $logoPath);\n            } else {\n                $updated = false;\n            }\n        }\n\n        // Handle background image upload/removal/default selection\n        if ($request->boolean('remove_background_image')) {\n            $this->imageService->removeBackgroundImageFile($display);\n            $updated = $updated && DisplaySettings::removeBackgroundImage($display);\n        } elseif ($request->hasFile('background_image')) {\n            // Custom uploaded background\n            $backgroundPath = $this->imageService->storeBackgroundImageFile($request->file('background_image'), $display);\n            if ($backgroundPath) {\n                $this->imageService->removeBackgroundImageFile($display); // Remove old background if exists\n                $updated = $updated && DisplaySettings::setBackgroundImage($display, $backgroundPath);\n            } else {\n                $updated = false;\n            }\n        } elseif ($request->filled('default_background')) {\n            // Default background selected\n            $defaultKey = $request->input('default_background');\n            if (isset(\\App\\Services\\ImageService::DEFAULT_BACKGROUNDS[$defaultKey])) {\n                // Remove old custom uploaded background if exists\n                $currentBackground = DisplaySettings::getBackgroundImage($display);\n                if ($currentBackground && !isset(\\App\\Services\\ImageService::DEFAULT_BACKGROUNDS[$currentBackground])) {\n                    $this->imageService->removeBackgroundImageFile($display);\n                }\n                // Store the default background key\n                $updated = $updated && DisplaySettings::setBackgroundImage($display, $defaultKey);\n            }\n        }\n\n        if (!$updated) {\n            return back()->withErrors(['error' => 'Failed to update customization settings']);\n        }\n\n        // Touch the display to update its updated_at timestamp for cache busting\n        $display->touch();\n\n        return redirect()->route('displays.customization', $display)->with('success', 'Customization settings updated successfully. Changes may take up to 1 minute to appear on your display.');\n    }\n\n\n    /**\n     * Serve display images (logo or background)\n     */\n    public function serveImage(Display $display, string $type)\n    {\n        // Use the policy to check access for both User and Device models\n        $this->authorize('view', $display);\n        \n        return $this->imageService->serveImage($display, $type);\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/GoogleAccountsController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers;\n\nuse App\\Enums\\PermissionType;\nuse App\\Enums\\GoogleBookingMethod;\nuse App\\Models\\GoogleAccount;\nuse App\\Services\\GoogleService;\nuse Google\\Service\\Exception;\nuse Illuminate\\Http\\RedirectResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\Crypt;\nuse Illuminate\\Support\\Facades\\Storage;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\Enum;\n\nclass GoogleAccountsController extends Controller\n{\n    protected GoogleService $googleService;\n\n    public function __construct(GoogleService $googleService)\n    {\n        $this->googleService = $googleService;\n    }\n\n    public function setBookingMethod(Request $request): RedirectResponse\n    {\n        $request->validate([\n            'google_account_id' => [\n                'required',\n                Rule::exists('google_accounts', 'id')->where('user_id', auth()->id()),\n            ],\n            'booking_method' => ['required', Rule::in(['service_account', 'user_account'])],\n        ]);\n\n        $googleAccount = GoogleAccount::where('id', $request->google_account_id)\n            ->where('user_id', auth()->id())\n            ->firstOrFail();\n\n        $googleAccount->update([\n            'booking_method' => GoogleBookingMethod::from($request->booking_method),\n        ]);\n\n        // If service account method selected, redirect to upload service account file\n        if ($request->booking_method === 'service_account') {\n            return redirect()->route('dashboard')\n                ->with('open-service-account-modal', $googleAccount->id)\n                ->with('success', 'Booking method set. Please upload your service account file.');\n        }\n\n        return redirect()->route('dashboard')->with('success', 'Booking method has been set successfully.');\n    }\n\n    public function auth(Request $request): RedirectResponse\n    {\n        $request->validate([\n            'permission_type' => ['required', new Enum(PermissionType::class)],\n        ]);\n\n        $permissionType = PermissionType::from($request->permission_type);\n\n        // Store permission type in session\n        session(['google_permission_type' => $request->permission_type]);\n\n        // Proceed to OAuth - booking method will be set after connection\n        return redirect($this->googleService->getAuthUrl($permissionType));\n    }\n\n    /**\n     * Handle service account file upload for workspace accounts.\n     *\n     * @param Request $request\n     * @return RedirectResponse\n     */\n    public function uploadServiceAccount(Request $request): RedirectResponse\n    {\n        $request->validate([\n            'google_account_id' => [\n                'required',\n                Rule::exists('google_accounts', 'id')->where('user_id', auth()->id()),\n            ],\n            'service_account_file' => [\n                'required',\n                'file',\n                'mimes:json',\n                'max:50', // Max 50KB\n                function ($attribute, $value, $fail) {\n                    $content = file_get_contents($value->getRealPath());\n                    $json = json_decode($content, true);\n                    \n                    if (!$json || !isset($json['type']) || $json['type'] !== 'service_account') {\n                        $fail('The file must be a valid Google Service Account JSON file.');\n                    }\n                    \n                    $required = ['private_key', 'client_email', 'project_id'];\n                    foreach ($required as $field) {\n                        if (!isset($json[$field])) {\n                            $fail(\"The service account file is missing required field: {$field}\");\n                        }\n                    }\n                },\n            ],\n        ]);\n\n        $googleAccount = GoogleAccount::where('id', $request->google_account_id)\n            ->where('user_id', auth()->id())\n            ->firstOrFail();\n\n        // Ensure it's a workspace account\n        if (! $googleAccount->isBusiness()) {\n            return redirect()->route('dashboard')->with('error', 'Service account is only required for Google Workspace accounts.');\n        }\n\n        // Store file in user-specific directory\n        $userDir = 'google-service-accounts/' . auth()->id();\n        $fileName = 'google-account-' . $googleAccount->id . '-' . time() . '.json';\n        $filePath = $userDir . '/' . $fileName;\n        \n        // Delete old file if exists\n        if ($googleAccount->service_account_file_path && Storage::exists($googleAccount->service_account_file_path)) {\n            Storage::delete($googleAccount->service_account_file_path);\n        }\n\n        // Read file content and encrypt it before storing\n        $fileContent = file_get_contents($request->file('service_account_file')->getRealPath());\n        $encryptedContent = Crypt::encryptString($fileContent);\n        \n        // Store encrypted file - explicitly construct path to ensure correct value is stored\n        if (! Storage::put($filePath, $encryptedContent)) {\n            return redirect()->route('dashboard')->with('error', 'Failed to save service account file. Please try again.');\n        }\n\n        // Update account with file path and upgrade to WRITE permission\n        $googleAccount->update([\n            'service_account_file_path' => $filePath,\n            'permission_type' => PermissionType::WRITE,\n        ]);\n\n        return redirect()->route('dashboard')->with('success', 'Service account file uploaded successfully. Your account has been upgraded to Read & Write. You can now book rooms directly.');\n    }\n\n    /**\n     * @throws \\Exception\n     */\n    public function callback(): RedirectResponse\n    {\n        if (request()->has('error')) {\n            return redirect()->route('dashboard')->with('error', 'Failed to connect to Google. Please try again.');\n        }\n\n        $authCode = request('code');\n        $permissionType = PermissionType::from(session('google_permission_type', PermissionType::READ->value));\n\n        // Clear the session values after retrieving them\n        session()->forget('google_permission_type');\n        session()->forget('google_booking_method');\n\n        // Don't set booking_method initially - will be set based on account type\n        $googleAccount = $this->googleService->authenticateGoogleAccount($authCode, $permissionType, null);\n\n        // If write permission, automatically detect account type and set booking method\n        if ($permissionType === PermissionType::WRITE) {\n            // Refresh account to get hosted_domain\n            $googleAccount->refresh();\n            \n            // If personal account, automatically set to USER_ACCOUNT\n            if (!$googleAccount->isBusiness()) {\n                $googleAccount->update([\n                    'booking_method' => GoogleBookingMethod::USER_ACCOUNT,\n                ]);\n                return redirect()->route('dashboard')\n                    ->with('success', 'Google account \"' . $googleAccount->email . '\" has been connected successfully.');\n            }\n            \n            // If workspace account, show booking method selection modal\n            return redirect()->route('dashboard')\n                ->with('success', 'Google account \"' . $googleAccount->email . '\" has been connected successfully.')\n                ->with('open-google-booking-method-modal', $googleAccount->id);\n        }\n\n        return redirect()->route('dashboard')->with('success', 'Google account \"' . $googleAccount->email . '\" has been connected successfully.');\n    }\n\n    public function delete(GoogleAccount $googleAccount): RedirectResponse\n    {\n        if ($googleAccount->calendars()->exists()) {\n            return redirect()->route('dashboard')->with('error', 'Cannot disconnect this account because it is used by one or more displays.');\n        }\n\n        // Delete service account file if it exists\n        if ($googleAccount->service_account_file_path && Storage::exists($googleAccount->service_account_file_path)) {\n            Storage::delete($googleAccount->service_account_file_path);\n        }\n\n        $googleAccount->delete();\n\n        return redirect()->route('dashboard')->with('status', 'Google account has been removed successfully.');\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/GoogleWebhookController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers;\n\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Http\\Response;\nuse Illuminate\\Support\\Arr;\nuse App\\Services\\GoogleService;\nuse App\\Models\\EventSubscription;\n\nclass GoogleWebhookController extends Controller\n{\n    public function __construct(protected GoogleService $googleService)\n    {\n    }\n\n    /**\n     * Handle incoming notifications from Google Calendar API.\n     *\n     * @param Request $request\n     * @return Response\n     */\n    public function handleNotification(Request $request): Response\n    {\n        $subscriptionId = $request->header('X-Goog-Channel-ID');\n        \n        // Security: Require subscription ID header\n        if (empty($subscriptionId)) {\n            logger()->warning('Google webhook received without subscription ID', [\n                'ip' => $request->ip(),\n                'headers' => $request->headers->all(),\n            ]);\n            return response('Invalid request', 400);\n        }\n\n        logger()->info(\"Received Google webhook for channel $subscriptionId\", [\n            'ip' => $request->ip(),\n        ]);\n\n        // Find the corresponding subscription in the database\n        // Security: Only process if subscription exists (prevents cache clearing attacks)\n        $subscription = EventSubscription::with('display')\n            ->where('subscription_id', $subscriptionId)\n            ->first();\n\n        if (!$subscription) {\n            logger()->warning('Google webhook received for unknown subscription', [\n                'subscriptionId' => $subscriptionId,\n                'ip' => $request->ip(),\n            ]);\n            // Return 200 to prevent subscription enumeration, but don't process\n            return response('Notification processed', 200);\n        }\n\n        $newSyncTimestamp = now();\n\n        // Clear events cache for display\n        cache()->forget($subscription->display->getEventsCacheKey());\n\n        // Set new point to sync from\n        $subscription->display->updateLastEventAt($newSyncTimestamp);\n\n        return response('Notification processed', 200);\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/LicenseController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers;\n\nuse App\\Data\\LicenseData;\nuse App\\Http\\Requests\\ActivateLicenseRequest;\nuse App\\Services\\InstanceService;\nuse Illuminate\\Support\\Facades\\Http;\n\nclass LicenseController extends Controller\n{\n    public function __construct(\n        protected InstanceService $instanceService\n    ) {}\n\n    public function validateLicense(ActivateLicenseRequest $request)\n    {\n        try {\n            // Get instance data\n            $instanceData = $this->instanceService->getInstanceData();\n\n            // Send validation request to the license server\n            $response = Http::acceptJson()->post(config('settings.license_server') . '/api/v1/instances/activate', [\n                'instance_key' => $instanceData->instanceKey,\n                'license_key' => $request['license_key'],\n            ]);\n\n            if ($response->notFound()) {\n                return back()->withErrors([\n                    'license_key' => 'License key was not found.',\n                ]);\n            }\n\n            if ($response->failed()) {\n                return back()->withErrors([\n                    'license_key' => 'Failed to validate license key. Please try again later.',\n                ]);\n            }\n\n            $licenseData = LicenseData::from($response->json()['data']);\n            if (! $licenseData->valid) {\n                return back()->withErrors([\n                    'license_key' => 'License key was invalid or has been used before.',\n                ]);\n            }\n\n            $activated = $this->instanceService->updateLicense($licenseData);\n            if (! $activated) {\n                return back()->withErrors([\n                    'license_key' => 'Instance could not be activated.',\n                ]);\n            }\n\n            return back()->with('success', 'Thank you for supporting Spacepad! Your license key was validated successfully. Enjoy using the Pro features.');\n        } catch (\\Exception $e) {\n            report($e);\n            return back()->withErrors([\n                'license_key' => 'An error occurred while validating the license key. Please try again later.',\n            ]);\n        }\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/OnboardingController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers;\n\nuse App\\Enums\\Plan;\nuse App\\Enums\\DisplayStatus;\nuse App\\Events\\UserRegistered;\nuse App\\Services\\OutlookService;\nuse Illuminate\\Contracts\\Foundation\\Application;\nuse Illuminate\\Contracts\\View\\Factory;\nuse Illuminate\\Contracts\\View\\View;\nuse Illuminate\\Http\\RedirectResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Validation\\Rule;\nuse App\\Models\\User;\nuse Illuminate\\Support\\Facades\\Auth;\n\nclass OnboardingController extends Controller\n{\n    public function __construct(protected OutlookService $outlookService)\n    {\n    }\n\n    /**\n     * @return Application|Factory|View\n     * @throws \\Exception\n     */\n    public function index(): View|RedirectResponse\n    {\n        $user = auth()->user();\n        $isSelfHosted = config('settings.is_self_hosted');\n\n        // Register email verified if not a social auth user and publish the registered event\n        if (! $user->hasVerifiedEmail() && ! $user->microsoft_id && ! $user->google_id) {\n            $user->update(['email_verified_at' => now()]);\n            event(new UserRegistered($user));\n        }\n\n        return view('pages.onboarding', [\n            'hasUsageType' => $user->usage_type !== null,\n            'hasAcceptedTerms' => ! $isSelfHosted || $user->terms_accepted_at !== null,\n            'hasAnyAccount' => $user->hasAnyAccount(),\n        ]);\n    }\n\n    public function updateUsageType(Request $request): RedirectResponse\n    {\n        $request->validate([\n            'usage_type' => 'required|in:business,personal',\n        ]);\n\n        auth()->user()->update([\n            'usage_type' => $request->usage_type,\n        ]);\n\n        return redirect()->route('dashboard');\n    }\n\n    public function acceptTerms(): RedirectResponse\n    {\n        auth()->user()->update([\n            'terms_accepted_at' => now(),\n        ]);\n\n        return redirect()->route('dashboard');\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/OutlookAccountsController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers;\n\nuse App\\Enums\\PermissionType;\nuse App\\Models\\OutlookAccount;\nuse App\\Services\\OutlookService;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\RedirectResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Validation\\Rules\\Enum;\n\nclass OutlookAccountsController extends Controller\n{\n    protected OutlookService $outlookService;\n\n    public function __construct(OutlookService $outlookService)\n    {\n        $this->outlookService = $outlookService;\n    }\n\n    public function auth(Request $request): RedirectResponse\n    {\n        $request->validate([\n            'permission_type' => ['required', new Enum(PermissionType::class)],\n        ]);\n\n        // Store permission type in session before redirecting to OAuth\n        session(['outlook_permission_type' => $request->permission_type]);\n\n        $permissionType = PermissionType::from($request->permission_type);\n        return redirect($this->outlookService->getAuthUrl($permissionType));\n    }\n\n    /**\n     * @throws \\Exception\n     */\n    public function callback(): RedirectResponse\n    {\n        if (request()->has('error')) {\n            return redirect()->route('dashboard')->with('error', 'Failed to connect to Outlook. Please try again.');\n        }\n\n        $authCode = request('code');\n        $permissionType = PermissionType::from(session('outlook_permission_type', PermissionType::READ->value));\n\n        // Clear the session value after retrieving it\n        session()->forget('outlook_permission_type');\n\n        $outlookAccount = $this->outlookService->authenticateOutlookAccount($authCode, $permissionType);\n\n        return redirect()->route('dashboard')->with('success', 'Microsoft account \"' . $outlookAccount->email . '\" has been connected successfully.');\n    }\n\n    public function delete(OutlookAccount $outlookAccount): RedirectResponse\n    {\n        if ($outlookAccount->calendars()->exists()) {\n            return redirect()->route('dashboard')->with('error', 'Cannot disconnect this account because it is used by one or more displays.');\n        }\n\n        $outlookAccount->delete();\n\n        return redirect()->route('dashboard')->with('status', 'Outlook account has been removed successfully.');\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/OutlookWebhookController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers;\n\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Http\\Response;\nuse Illuminate\\Support\\Arr;\nuse App\\Services\\OutlookService;\nuse App\\Models\\EventSubscription;\n\nclass OutlookWebhookController extends Controller\n{\n    public function __construct(protected OutlookService $outlookService)\n    {\n    }\n\n    /**\n     * Handle incoming notifications from Microsoft Graph.\n     *\n     * @param Request $request\n     * @return Response\n     * @throws \\Exception\n     */\n    public function handleNotification(Request $request): Response\n    {\n        // Handle webhook validation request (Microsoft Graph requires this)\n        if ($request->has('validationToken')) {\n            return response($request->validationToken, 200)\n                ->header('Content-Type', 'text/plain');\n        }\n\n        logger()->info('Received Outlook webhook', [\n            'ip' => $request->ip(),\n            'notification_count' => count($request->input('value', [])),\n        ]);\n\n        $newSyncTimestamp = now();\n        $notifications = $request->input('value', []);\n        \n        // Security: Limit number of notifications per request to prevent DoS\n        if (count($notifications) > 100) {\n            logger()->warning('Outlook webhook received too many notifications', [\n                'count' => count($notifications),\n                'ip' => $request->ip(),\n            ]);\n            return response('Too many notifications', 400);\n        }\n\n        foreach ($notifications as $notification) {\n            $subscriptionId = Arr::get($notification, 'subscriptionId');\n            $resource = Arr::get($notification, 'resource');\n            $resourceId = Arr::get($notification, 'resourceData.id');\n\n            // Check for required fields\n            if (!$resource || !$resourceId) {\n                logger()->warning('Resource or ResourceData was missing from request body', [\n                    'subscriptionId' => $subscriptionId,\n                ]);\n                continue;\n            }\n\n            // Security: Only process if subscription exists (prevents cache clearing attacks)\n            $subscription = EventSubscription::with('display')\n                ->where('subscription_id', $subscriptionId)\n                ->first();\n\n            if (!$subscription) {\n                logger()->warning('Outlook webhook received for unknown subscription', [\n                    'subscriptionId' => $subscriptionId,\n                    'ip' => $request->ip(),\n                ]);\n                // Continue to next notification (don't reveal which subscriptions exist)\n                continue;\n            }\n\n            // Clear events cache for display\n            cache()->forget($subscription->display->getEventsCacheKey());\n\n            // Set new point to sync from\n            $subscription->display->updateLastEventAt($newSyncTimestamp);\n        }\n\n        return response('Notification processed', 200);\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/RoomController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers;\n\nuse App\\Enums\\Provider;\nuse App\\Services\\OutlookService;\nuse App\\Services\\GoogleService;\nuse Google\\Service\\Exception as GoogleException;\nuse Illuminate\\Contracts\\Foundation\\Application;\nuse Illuminate\\Contracts\\View\\Factory;\nuse Illuminate\\Contracts\\View\\View;\nuse Illuminate\\Http\\Client\\ConnectionException;\n\nclass RoomController extends Controller\n{\n    public function __construct(\n        protected OutlookService $outlookService,\n        protected GoogleService $googleService\n    ) {\n    }\n\n    public function outlook(string $id): View|Factory|Application\n    {\n        try {\n            $account = auth()->user()->outlookAccounts()->findOrFail($id);\n            $rooms = $this->outlookService->fetchRooms($account);\n\n            return view('components.rooms.picker', [\n                'rooms' => collect($rooms)->map(function (array $room) {\n                    return [\n                        'emailAddress' => $room['emailAddress'],\n                        'name' => $room['displayName']\n                    ];\n                })->toArray(),\n                'type' => Provider::OUTLOOK,\n            ]);\n        } catch (ConnectionException $e) {\n            logger()->error('Outlook API connection error: ' . $e->getMessage());\n            return view('components.rooms.picker', [\n                'rooms' => [],\n                'type' => Provider::OUTLOOK,\n                'error' => 'Could not connect to Outlook. Please try again later.'\n            ]);\n        } catch (\\Exception $e) {\n            logger()->error('Outlook rooms fetch error: ' . $e->getMessage());\n            return view('components.rooms.picker', [\n                'rooms' => [],\n                'type' => Provider::OUTLOOK,\n                'error' => 'Could not fetch rooms from Outlook. Please check your permissions and try again.'\n            ]);\n        }\n    }\n\n    public function google(string $id): View|Factory|Application\n    {\n        try {\n            $account = auth()->user()->googleAccounts()->findOrFail($id);\n            $rooms = $this->googleService->fetchRooms($account);\n\n            return view('components.rooms.picker', [\n                'rooms' => collect($rooms)->map(function ($room) {\n                    return [\n                        'emailAddress' => $room->getResourceEmail(),\n                        'name' => $room->getResourceName(),\n                    ];\n                })->toArray(),\n                'type' => Provider::GOOGLE,\n            ]);\n        } catch (GoogleException $e) {\n            logger()->error('Google API error: ' . $e->getMessage());\n\n            // Check for insufficient permissions error\n            if (str_contains($e->getMessage(), 'insufficientPermissions') ||\n                str_contains($e->getMessage(), 'ACCESS_TOKEN_SCOPE_INSUFFICIENT')) {\n                return view('components.rooms.picker', [\n                    'rooms' => [],\n                    'type' => Provider::GOOGLE,\n                    'error' => 'Insufficient permissions to access Google Calendar. Please ensure you have granted all required permissions during authentication.'\n                ]);\n            }\n\n            return view('components.rooms.picker', [\n                'rooms' => [],\n                'type' => Provider::GOOGLE,\n                'error' => 'Could not fetch rooms from Google. Please check your permissions and try again.'\n            ]);\n        } catch (\\Exception $e) {\n            logger()->error('Google rooms fetch error: ' . $e->getMessage());\n            return view('components.rooms.picker', [\n                'rooms' => [],\n                'type' => Provider::GOOGLE,\n                'error' => 'Could not fetch rooms from Google. Please try again later.'\n            ]);\n        }\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/UsageController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers;\n\nuse Illuminate\\Contracts\\View\\Factory;\nuse Illuminate\\Contracts\\View\\View;\nuse Illuminate\\Foundation\\Application;\n\nclass UsageController extends Controller\n{\n    /**\n     * Display the usage page\n     */\n    public function index(): View|Factory|Application\n    {\n        $user = auth()->user();\n        $selectedWorkspace = $user->getSelectedWorkspace();\n        \n        if (!$selectedWorkspace) {\n            abort(404, 'No workspace found');\n        }\n        \n        $usageBreakdown = $selectedWorkspace->getUsageBreakdown();\n        \n        return view('pages.usage.index', [\n            'workspace' => $selectedWorkspace,\n            'usageBreakdown' => $usageBreakdown,\n        ]);\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Controllers/WorkspaceController.php",
    "content": "<?php\n\nnamespace App\\Http\\Controllers;\n\nuse App\\Models\\Workspace;\nuse Illuminate\\Http\\RedirectResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\Auth;\n\nclass WorkspaceController extends Controller\n{\n    /**\n     * Switch to a different workspace\n     * \n     * Note: This works for all users (including non-Pro users) who are members of the workspace.\n     * Workspace access is based on membership, not Pro status.\n     * Also works during impersonation - uses the impersonated user's workspace memberships.\n     */\n    public function switch(Request $request): RedirectResponse\n    {\n        $request->validate([\n            'workspace_id' => 'required|string|exists:workspaces,id',\n        ]);\n        \n        $user = Auth::user();\n        $workspaceId = $request->input('workspace_id');\n        \n        // Validate user has access to this workspace (checks membership, not Pro status)\n        // This works for both regular users and impersonated users\n        $workspace = $user->workspaces()->find($workspaceId);\n        if (!$workspace) {\n            abort(403, 'You do not have access to this workspace.');\n        }\n        \n        // Store selected workspace in session\n        // This persists during impersonation since we're using the impersonated user's session\n        session()->put('selected_workspace_id', $workspace->id);\n        \n        logger()->info('User switched workspace', [\n            'user_id' => $user->id,\n            'workspace_id' => $workspace->id,\n            'workspace_name' => $workspace->name,\n            'is_impersonating' => session()->has('impersonating'),\n        ]);\n        \n        return redirect()->route('dashboard')->with('success', \"Switched to workspace: {$workspace->name}\");\n    }\n}\n\n"
  },
  {
    "path": "backend/app/Http/Middleware/CheckUserActive.php",
    "content": "<?php\n\nnamespace App\\Http\\Middleware;\n\nuse App\\Enums\\UserStatus;\nuse Closure;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\Auth;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass CheckUserActive\n{\n    /**\n     * Handle an incoming request.\n     *\n     * @param  \\Closure(\\Illuminate\\Http\\Request): (\\Symfony\\Component\\HttpFoundation\\Response)  $next\n     */\n    public function handle(Request $request, Closure $next): Response\n    {\n        if (! auth()->user()->isOnboarded()) {\n            return redirect()->route('onboarding');\n        }\n\n        return $next($request);\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Middleware/CheckUserOnboarding.php",
    "content": "<?php\n\nnamespace App\\Http\\Middleware;\n\nuse App\\Enums\\UserStatus;\nuse Closure;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\Auth;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass CheckUserOnboarding\n{\n    /**\n     * Handle an incoming request.\n     *\n     * @param  \\Closure(\\Illuminate\\Http\\Request): (\\Symfony\\Component\\HttpFoundation\\Response)  $next\n     */\n    public function handle(Request $request, Closure $next): Response\n    {\n        if (auth()->user()->isOnboarded()) {\n            return redirect()->route('dashboard');\n        }\n\n        return $next($request);\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Middleware/UpdateLastActivity.php",
    "content": "<?php\n\nnamespace App\\Http\\Middleware;\n\nuse Closure;\nuse App\\Models\\Device;\nuse App\\Models\\User;\nuse Illuminate\\Http\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass UpdateLastActivity\n{\n    /**\n     * Handle an incoming request.\n     *\n     * @param  \\Closure(\\Illuminate\\Http\\Request): (\\Symfony\\Component\\HttpFoundation\\Response)  $next\n     */\n    public function handle(Request $request, Closure $next): Response\n    {\n        if (auth()->check()) {\n            /** @var Device|User $user */\n            $user = auth()->user();\n            $user->updateLastActivity();\n        }\n\n        return $next($request);\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Requests/API/Auth/LoginRequest.php",
    "content": "<?php\n\nnamespace App\\Http\\Requests\\API\\Auth;\n\nuse Illuminate\\Auth\\Events\\Lockout;\nuse Illuminate\\Foundation\\Http\\FormRequest;\nuse Illuminate\\Support\\Facades\\Auth;\nuse Illuminate\\Support\\Facades\\RateLimiter;\nuse Illuminate\\Support\\Str;\nuse Illuminate\\Validation\\ValidationException;\n\nclass LoginRequest extends FormRequest\n{\n    /**\n     * Determine if the user is authorized to make this request.\n     *\n     * @return bool\n     */\n    public function authorize(): bool\n    {\n        return true;\n    }\n\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array\n     */\n    public function rules(): array\n    {\n        return [\n            'code' => 'required|string',\n            'uid' => 'required|string',\n            'name' => 'required|string',\n        ];\n    }\n\n    /**\n     * Attempt to authenticate the request's credentials.\n     *\n     * @return void\n     *\n     * @throws ValidationException\n     */\n    public function authenticate()\n    {\n        $this->ensureIsNotRateLimited();\n    }\n\n    /**\n     * Ensure the login request is not rate limited.\n     *\n     * @return void\n     *\n     * @throws ValidationException\n     */\n    public function ensureIsNotRateLimited()\n    {\n        if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {\n            return;\n        }\n\n        event(new Lockout($this));\n\n        $seconds = RateLimiter::availableIn($this->throttleKey());\n\n        throw ValidationException::withMessages([\n            'code' => trans('auth.throttle', [\n                'seconds' => $seconds,\n                'minutes' => ceil($seconds / 60),\n            ]),\n        ]);\n    }\n\n    /**\n     * Get the rate limiting throttle key for the request.\n     *\n     * @return string\n     */\n    public function throttleKey()\n    {\n        return Str::lower($this->input('code')).'|'.$this->ip();\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Requests/API/BookEventRequest.php",
    "content": "<?php\n\nnamespace App\\Http\\Requests\\API;\n\nuse Illuminate\\Foundation\\Http\\FormRequest;\n\nclass BookEventRequest extends FormRequest\n{\n    public function authorize(): bool\n    {\n        return true;\n    }\n\n    public function rules(): array\n    {\n        return [\n            'duration' => 'required_without:start|in:15,30,45,60',\n            'start' => 'required_without:duration|date',\n            'end' => 'required_with:start|date|after:start',\n            'summary' => 'nullable|string|max:255',\n        ];\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Requests/API/ChangeDisplayRequest.php",
    "content": "<?php\n\nnamespace App\\Http\\Requests\\API;\n\nuse Illuminate\\Auth\\Events\\Lockout;\nuse Illuminate\\Foundation\\Http\\FormRequest;\nuse Illuminate\\Support\\Facades\\Auth;\nuse Illuminate\\Support\\Facades\\RateLimiter;\nuse Illuminate\\Support\\Str;\nuse Illuminate\\Validation\\ValidationException;\n\nclass ChangeDisplayRequest extends FormRequest\n{\n    /**\n     * Determine if the user is authorized to make this request.\n     *\n     * @return bool\n     */\n    public function authorize(): bool\n    {\n        return true;\n    }\n\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array\n     */\n    public function rules(): array\n    {\n        return [\n            'display_id' => 'required|string|exists:displays,id',\n        ];\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Requests/API/InstanceHeartbeatRequest.php",
    "content": "<?php\n\nnamespace App\\Http\\Requests\\API;\n\nuse Illuminate\\Foundation\\Http\\FormRequest;\n\nclass InstanceHeartbeatRequest extends FormRequest\n{\n    public function authorize(): bool\n    {\n        return true;\n    }\n\n    public function rules(): array\n    {\n        return [\n            'instance_key' => ['required', 'string'],\n            'license_key' => ['nullable', 'string'],\n            'license_valid' => ['nullable', 'boolean'],\n            'license_expires_at' => ['nullable', 'date'],\n            'is_self_hosted' => ['required', 'boolean'],\n            'displays_count' => ['required', 'integer', 'min:0'],\n            'rooms_count' => ['required', 'integer', 'min:0'],\n            'boards_count' => ['nullable', 'integer', 'min:0'],\n            'version' => ['required', 'string'],\n            'users' => ['required', 'array'],\n            'users.*.email' => ['required', 'email'],\n            'users.*.usage_type' => ['nullable', 'string'],\n            'users.*.is_unlimited' => ['nullable', 'boolean'],\n            'users.*.terms_accepted_at' => ['nullable', 'date'],\n        ];\n    }\n\n    public function messages(): array\n    {\n        return [\n            'instance_key.required' => 'The instance key is required.',\n            'license_key.required' => 'The license key is required.',\n            'license_valid.boolean' => 'The license valid flag must be a boolean.',\n            'license_expires_at.date' => 'The license expiration date must be a valid date.',\n            'is_self_hosted.required' => 'The self-hosted flag is required.',\n            'displays_count.required' => 'The displays count is required.',\n            'displays_count.integer' => 'The displays count must be an integer.',\n            'displays_count.min' => 'The displays count cannot be negative.',\n            'rooms_count.required' => 'The rooms count is required.',\n            'rooms_count.integer' => 'The rooms count must be an integer.',\n            'rooms_count.min' => 'The rooms count cannot be negative.',\n            'version.required' => 'The version is required.',\n            'users.required' => 'The users array is required.',\n            'users.*.email.required' => 'Each user must have an email address.',\n            'users.*.email.email' => 'Each user must have a valid email address.',\n            'users.*.usage_type.string' => 'The usage type must be a string.',\n            'users.*.is_unlimited.boolean' => 'The unlimited flag must be a boolean.',\n            'users.*.terms_accepted_at.date' => 'The terms accepted date must be a valid date.',\n        ];\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Requests/API/ValidateInstanceRequest.php",
    "content": "<?php\n\nnamespace App\\Http\\Requests\\API;\n\nuse Illuminate\\Foundation\\Http\\FormRequest;\nuse App\\Enums\\Provider;\n\nclass ValidateInstanceRequest extends FormRequest\n{\n    public function authorize(): bool\n    {\n        return true;\n    }\n\n    public function rules(): array\n    {\n        return [\n            'instance_key' => ['required', 'string'],\n            'license_key' => ['required', 'string'],\n        ];\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Requests/ActivateLicenseRequest.php",
    "content": "<?php\n\nnamespace App\\Http\\Requests;\n\nuse Illuminate\\Foundation\\Http\\FormRequest;\nuse App\\Enums\\Provider;\n\nclass ActivateLicenseRequest extends FormRequest\n{\n    public function authorize(): bool\n    {\n        return true;\n    }\n\n    public function rules(): array\n    {\n        return [\n            'license_key' => ['required', 'string'],\n        ];\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Requests/Auth/LoginRequest.php",
    "content": "<?php\n\nnamespace App\\Http\\Requests\\Auth;\n\nuse Illuminate\\Auth\\Events\\Lockout;\nuse Illuminate\\Foundation\\Http\\FormRequest;\nuse Illuminate\\Support\\Facades\\Auth;\nuse Illuminate\\Support\\Facades\\RateLimiter;\nuse Illuminate\\Support\\Str;\nuse Illuminate\\Validation\\ValidationException;\n\nclass LoginRequest extends FormRequest\n{\n    /**\n     * Determine if the user is authorized to make this request.\n     *\n     * @return bool\n     */\n    public function authorize(): bool\n    {\n        return true;\n    }\n\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array\n     */\n    public function rules(): array\n    {\n        return [\n            'email' => 'required|string|email',\n            'g-recaptcha-response' => config('recaptchav3.sitekey') ? 'required|recaptchav3:login,0.5' : 'nullable'\n        ];\n    }\n\n    /**\n     * Attempt to authenticate the request's credentials.\n     *\n     * @return void\n     *\n     * @throws ValidationException\n     */\n    public function authenticate()\n    {\n        $this->ensureIsNotRateLimited();\n\n        if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {\n            RateLimiter::hit($this->throttleKey());\n\n            throw ValidationException::withMessages([\n                'email' => __('auth.failed'),\n            ]);\n        }\n\n        RateLimiter::clear($this->throttleKey());\n    }\n\n    /**\n     * Ensure the login request is not rate limited.\n     *\n     * @return void\n     *\n     * @throws ValidationException\n     */\n    public function ensureIsNotRateLimited()\n    {\n        if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {\n            return;\n        }\n\n        event(new Lockout($this));\n\n        $seconds = RateLimiter::availableIn($this->throttleKey());\n\n        throw ValidationException::withMessages([\n            'email' => trans('auth.throttle', [\n                'seconds' => $seconds,\n                'minutes' => ceil($seconds / 60),\n            ]),\n        ]);\n    }\n\n    /**\n     * Get the rate limiting throttle key for the request.\n     *\n     * @return string\n     */\n    public function throttleKey()\n    {\n        return Str::lower($this->input('email')).'|'.$this->ip();\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Requests/Auth/OAuth2TokenRequest.php",
    "content": "<?php\n\nnamespace App\\Http\\Requests\\Auth;\n\nuse Illuminate\\Foundation\\Http\\FormRequest;\n\nclass OAuth2TokenRequest extends FormRequest\n{\n    /**\n     * Determine if the user is authorized to make this request.\n     */\n    public function authorize(): bool\n    {\n        return true;\n    }\n\n    /**\n     * Get the validation rules that apply to the request.\n     */\n    public function rules(): array\n    {\n        return [\n            'token' => 'required|string',\n            'full_name' => 'sometimes|string|max:255',\n        ];\n    }\n\n    /**\n     * Get custom messages for validator errors.\n     */\n    public function messages(): array\n    {\n        return [\n            'token.required' => 'required',\n            'token.string' => 'string',\n            'full_name.string' => 'string',\n            'full_name.max' => 'max::max',\n        ];\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Requests/Auth/RegisterRequest.php",
    "content": "<?php\n\nnamespace App\\Http\\Requests\\Auth;\n\nuse Illuminate\\Auth\\Events\\Lockout;\nuse Illuminate\\Foundation\\Http\\FormRequest;\nuse Illuminate\\Support\\Facades\\Auth;\nuse Illuminate\\Support\\Facades\\RateLimiter;\nuse Illuminate\\Support\\Str;\nuse Illuminate\\Validation\\ValidationException;\n\nclass RegisterRequest extends FormRequest\n{\n    /**\n     * Determine if the user is authorized to make this request.\n     *\n     * @return bool\n     */\n    public function authorize(): bool\n    {\n        return true;\n    }\n\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array\n     */\n    public function rules(): array\n    {\n        return [\n            'name' => 'required|string',\n            'email' => 'required|string|email',\n            'g-recaptcha-response' => config('recaptchav3.sitekey') ? 'required|recaptchav3:register,0.5' : 'nullable'\n        ];\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Requests/CreateBoardRequest.php",
    "content": "<?php\n\nnamespace App\\Http\\Requests;\n\nuse Illuminate\\Foundation\\Http\\FormRequest;\nuse Illuminate\\Validation\\Rule;\n\nclass CreateBoardRequest extends FormRequest\n{\n    /**\n     * Determine if the user is authorized to make this request.\n     *\n     * @return bool\n     */\n    public function authorize(): bool\n    {\n        return auth()->check();\n    }\n\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array\n     */\n    public function rules(): array\n    {\n        return [\n            'name' => 'required|string|max:255',\n            'title' => 'nullable|string|max:255',\n            'subtitle' => 'nullable|string|max:255',\n            'workspace_id' => 'required|string|exists:workspaces,id',\n            'show_all_displays' => 'required|boolean',\n            'theme' => 'nullable|string|in:dark,light,system',\n            'logo' => 'nullable|image|mimes:jpeg,png,jpg,gif,svg|max:2048',\n            'remove_logo' => 'nullable|boolean',\n            'show_title' => 'nullable|boolean',\n            'show_booker' => 'nullable|boolean',\n            'show_next_event' => 'nullable|boolean',\n            'show_transitioning' => 'nullable|boolean',\n            'transitioning_minutes' => 'nullable|integer|min:1|max:60',\n            'font_family' => 'nullable|string|in:Inter,Roboto,Open Sans,Lato,Poppins,Montserrat',\n            'language' => 'nullable|string|in:en,nl,fr,de,es,sv',\n            'view_mode' => 'nullable|string|in:card,table,grid',\n            'show_meeting_title' => 'nullable|boolean',\n            'display_ids' => [\n                'nullable',\n                'array',\n                function ($attribute, $value, $fail) {\n                    $showAll = filter_var(request()->input('show_all_displays'), FILTER_VALIDATE_BOOLEAN);\n                    if (!$showAll && (empty($value) || !is_array($value) || count($value) === 0)) {\n                        $fail('Please select at least one display when not showing all displays.');\n                    }\n                },\n            ],\n            'display_ids.*' => [\n                Rule::exists('displays', 'id')->where('workspace_id', $this->input('workspace_id')),\n            ],\n        ];\n    }\n\n    public function messages(): array\n    {\n        return [\n            'display_ids.required_if' => 'Please select at least one display when not showing all displays.',\n        ];\n    }\n\n    protected function prepareForValidation(): void\n    {\n        // Convert string \"1\" or \"0\" to boolean\n        if ($this->has('show_all_displays')) {\n            $this->merge([\n                'show_all_displays' => filter_var($this->show_all_displays, FILTER_VALIDATE_BOOLEAN),\n            ]);\n        }\n\n        // Convert checkbox values to boolean (default to true if not present for new boards)\n        $this->merge([\n            'show_title' => $this->has('show_title') ? filter_var($this->show_title, FILTER_VALIDATE_BOOLEAN) : true,\n            'show_booker' => $this->has('show_booker') ? filter_var($this->show_booker, FILTER_VALIDATE_BOOLEAN) : true,\n            'show_next_event' => $this->has('show_next_event') ? filter_var($this->show_next_event, FILTER_VALIDATE_BOOLEAN) : true,\n            'show_transitioning' => $this->has('show_transitioning') ? filter_var($this->show_transitioning, FILTER_VALIDATE_BOOLEAN) : true,\n            'transitioning_minutes' => $this->has('transitioning_minutes') ? (int) $this->transitioning_minutes : 10,\n            'show_meeting_title' => $this->has('show_meeting_title') ? filter_var($this->show_meeting_title, FILTER_VALIDATE_BOOLEAN) : true,\n        ]);\n\n        // Ensure display_ids is an array\n        if ($this->has('display_ids') && !is_array($this->display_ids)) {\n            $this->merge([\n                'display_ids' => $this->display_ids ? [$this->display_ids] : [],\n            ]);\n        }\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Requests/CreateDisplayRequest.php",
    "content": "<?php\n\nnamespace App\\Http\\Requests;\n\nuse Illuminate\\Auth\\Events\\Lockout;\nuse Illuminate\\Foundation\\Http\\FormRequest;\nuse Illuminate\\Support\\Facades\\Auth;\nuse Illuminate\\Support\\Facades\\RateLimiter;\nuse Illuminate\\Support\\Str;\nuse Illuminate\\Validation\\ValidationException;\n\nclass CreateDisplayRequest extends FormRequest\n{\n    /**\n     * Determine if the user is authorized to make this request.\n     *\n     * @return bool\n     */\n    public function authorize(): bool\n    {\n        return auth()->check();\n    }\n\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array\n     */\n    public function rules(): array\n    {\n        return [\n            'name' => 'required|string',\n            'displayName' => 'required|string',\n            'account' => 'required|string',\n            'provider' => 'required|string|in:outlook,google,caldav',\n            'room' => 'required_without:calendar|string',\n            'calendar' => 'required_without:room|string',\n            'workspace_id' => 'nullable|string|exists:workspaces,id',\n        ];\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Requests/UpdateBoardRequest.php",
    "content": "<?php\n\nnamespace App\\Http\\Requests;\n\nuse Illuminate\\Foundation\\Http\\FormRequest;\nuse Illuminate\\Validation\\Rule;\n\nclass UpdateBoardRequest extends FormRequest\n{\n    /**\n     * Determine if the user is authorized to make this request.\n     *\n     * @return bool\n     */\n    public function authorize(): bool\n    {\n        return auth()->check();\n    }\n\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array\n     */\n    public function rules(): array\n    {\n        return [\n            'name' => 'required|string|max:255',\n            'title' => 'nullable|string|max:255',\n            'subtitle' => 'nullable|string|max:255',\n            'workspace_id' => 'required|string|exists:workspaces,id',\n            'show_all_displays' => 'required|boolean',\n            'theme' => 'nullable|string|in:dark,light,system',\n            'logo' => 'nullable|image|mimes:jpeg,png,jpg,gif,svg|max:2048',\n            'remove_logo' => 'nullable|boolean',\n            'show_title' => 'nullable|boolean',\n            'show_booker' => 'nullable|boolean',\n            'show_next_event' => 'nullable|boolean',\n            'show_transitioning' => 'nullable|boolean',\n            'transitioning_minutes' => 'nullable|integer|min:1|max:60',\n            'font_family' => 'nullable|string|in:Inter,Roboto,Open Sans,Lato,Poppins,Montserrat',\n            'language' => 'nullable|string|in:en,nl,fr,de,es,sv',\n            'view_mode' => 'nullable|string|in:card,table,grid',\n            'show_meeting_title' => 'nullable|boolean',\n            'display_ids' => [\n                'nullable',\n                'array',\n                function ($attribute, $value, $fail) {\n                    $showAll = filter_var(request()->input('show_all_displays'), FILTER_VALIDATE_BOOLEAN);\n                    if (!$showAll && (empty($value) || !is_array($value) || count($value) === 0)) {\n                        $fail('Please select at least one display when not showing all displays.');\n                    }\n                },\n            ],\n            'display_ids.*' => [\n                Rule::exists('displays', 'id')->where(function ($query) {\n                    $workspaceId = $this->input('workspace_id') ?? $this->route('board')?->workspace_id;\n                    $query->where('workspace_id', $workspaceId);\n                }),\n            ],\n        ];\n    }\n\n    public function messages(): array\n    {\n        return [\n            'display_ids.required_if' => 'Please select at least one display when not showing all displays.',\n        ];\n    }\n\n    protected function prepareForValidation(): void\n    {\n        // Convert string \"1\" or \"0\" to boolean\n        if ($this->has('show_all_displays')) {\n            $this->merge([\n                'show_all_displays' => filter_var($this->show_all_displays, FILTER_VALIDATE_BOOLEAN),\n            ]);\n        }\n\n        // Retrieve the route-bound board model\n        $board = $this->route('board');\n\n        // Convert checkbox values to boolean (if checkbox is unchecked, it won't be in request, so default to false)\n        $this->merge([\n            'show_title' => $this->has('show_title') && filter_var($this->show_title, FILTER_VALIDATE_BOOLEAN),\n            'show_booker' => $this->has('show_booker') && filter_var($this->show_booker, FILTER_VALIDATE_BOOLEAN),\n            'show_next_event' => $this->has('show_next_event') && filter_var($this->show_next_event, FILTER_VALIDATE_BOOLEAN),\n            'show_transitioning' => $this->has('show_transitioning') && filter_var($this->show_transitioning, FILTER_VALIDATE_BOOLEAN),\n            'transitioning_minutes' => $this->has('transitioning_minutes') ? (int) $this->transitioning_minutes : ($board?->transitioning_minutes ?? 10),\n            'show_meeting_title' => $this->has('show_meeting_title') && filter_var($this->show_meeting_title, FILTER_VALIDATE_BOOLEAN),\n        ]);\n\n        // Ensure display_ids is an array\n        if ($this->has('display_ids') && !is_array($this->display_ids)) {\n            $this->merge([\n                'display_ids' => $this->display_ids ? [$this->display_ids] : [],\n            ]);\n        }\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Requests/UpdateDisplayCustomizationRequest.php",
    "content": "<?php\n\nnamespace App\\Http\\Requests;\n\nuse Illuminate\\Foundation\\Http\\FormRequest;\n\nclass UpdateDisplayCustomizationRequest extends FormRequest\n{\n    public function authorize(): bool\n    {\n        // Authorization is handled in the controller\n        return true;\n    }\n\n    public function rules(): array\n    {\n        return [\n            'text_available' => 'nullable|string|max:64',\n            'text_transitioning' => 'nullable|string|max:64',\n            'text_reserved' => 'nullable|string|max:64',\n            'text_checkin' => 'nullable|string|max:64',\n            'show_meeting_title' => 'boolean',\n            'logo' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048',\n            'background_image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048',\n            'default_background' => 'nullable|string|in:default_1,default_2,default_3,default_4,default_5,default_6,default_7,default_8',\n            'remove_logo' => 'boolean',\n            'remove_background_image' => 'boolean',\n            'font_family' => 'nullable|string|in:Inter,Roboto,Open Sans,Lato,Poppins,Montserrat',\n        ];\n    }\n} "
  },
  {
    "path": "backend/app/Http/Resources/API/DeviceResource.php",
    "content": "<?php\n\nnamespace App\\Http\\Resources\\API;\n\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Http\\Resources\\Json\\JsonResource;\n\nclass DeviceResource extends JsonResource\n{\n    /**\n     * Transform the resource into an array.\n     *\n     * @param  Request  $request\n     */\n    public function toArray($request): array\n    {\n        return [\n            'id' => $this->id,\n            'name' => $this->name,\n            'user' => UserResource::make($this->user),\n            'display' => DisplayResource::make($this->display)\n        ];\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Resources/API/DisplayDataResource.php",
    "content": "<?php\n\nnamespace App\\Http\\Resources\\API;\n\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Http\\Resources\\Json\\JsonResource;\n\nclass DisplayDataResource extends JsonResource\n{\n    /**\n     * Transform the resource into an array.\n     *\n     * @param  Request  $request\n     */\n    public function toArray($request): array\n    {\n        return [\n            'display' => DisplayResource::make($this['display']),\n            'events' => EventResource::collection($this['events']),\n        ];\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Resources/API/DisplayResource.php",
    "content": "<?php\n\nnamespace App\\Http\\Resources\\API;\n\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Http\\Resources\\Json\\JsonResource;\n\nclass DisplayResource extends JsonResource\n{\n    /**\n     * Transform the resource into an array.\n     *\n     * @param  Request  $request\n     */\n    public function toArray($request): array\n    {\n        return [\n            'id' => $this->id,\n            'name' => $this->display_name,\n            'settings' => DisplaySettingsResource::make($this),\n        ];\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Resources/API/DisplaySettingsResource.php",
    "content": "<?php\n\nnamespace App\\Http\\Resources\\API;\n\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Http\\Resources\\Json\\JsonResource;\n\nclass DisplaySettingsResource extends JsonResource\n{\n    /**\n     * Transform the resource into an array.\n     *\n     * @param  Request  $request\n     */\n    public function toArray($request): array\n    {\n        return [\n            'check_in_enabled' => $this->isCheckInEnabled(),\n            'booking_enabled' => $this->isBookingEnabled(),\n            'calendar_enabled' => $this->isCalendarEnabled(),\n            'hide_admin_actions' => $this->isAdminActionsHidden(),\n            'check_in_minutes' => $this->getCheckInMinutes(),\n            'check_in_grace_period' => $this->getCheckInGracePeriod(),\n            'text_available' => $this->getAvailableText(),\n            'text_transitioning' => $this->getTransitioningText(),\n            'text_reserved' => $this->getReservedText(),\n            'text_checkin' => $this->getCheckInText(),\n            'show_meeting_title' => $this->getShowMeetingTitle(),\n            'logo_url' => $this->getLogoUrl(),\n            'background_image_url' => $this->getBackgroundImageUrl(),\n            'font_family' => $this->getFontFamily(),\n            'cancel_permission' => $this->getCancelPermission(),\n            'border_thickness' => $this->getBorderThickness(),\n\n            // Feature flags\n            'has_custom_booking' => $this->hasCustomBooking(),\n        ];\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Resources/API/EventResource.php",
    "content": "<?php\n\nnamespace App\\Http\\Resources\\API;\n\nuse App\\Models\\Event;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Http\\Resources\\Json\\JsonResource;\nuse Illuminate\\Support\\Carbon;\n\nclass EventResource extends JsonResource\n{\n    /**\n     * Transform the resource into an array.\n     *\n     * @param  Request  $request\n     */\n    public function toArray($request): array\n    {\n        $timezone = $this['timezone'] ?? config('app.timezone');\n        // Convert from UTC (database) to the event's timezone\n        // setTimezone() properly converts the time, unlike shiftTimezone() which only changes the label\n        return [\n            'id' => $this['id'],\n            'status' => $this['status'],\n            'summary' => $this['summary'],\n            'location' => $this['location'],\n            'description' => $this['description'],\n            'start' => $this['start']->setTimezone($timezone)->toAtomString(),\n            'end' => $this['end']->setTimezone($timezone)->toAtomString(),\n            'checkedInAt' => $this['checked_in_at']?->toAtomString(),\n            'timezone' => $this['timezone'],\n            'checkInRequired' => $this->checkInRequired(),\n            'source' => $this['source'] ?? null,\n            'isTabletBooking' => $this->isTabletBooking(),\n        ];\n    }\n}\n"
  },
  {
    "path": "backend/app/Http/Resources/API/UserResource.php",
    "content": "<?php\n\nnamespace App\\Http\\Resources\\API;\n\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Http\\Resources\\Json\\JsonResource;\n\nclass UserResource extends JsonResource\n{\n    /**\n     * Transform the resource into an array.\n     *\n     * @param  Request  $request\n     */\n    public function toArray($request): array\n    {\n        return [\n            'id' => $this->id,\n            'name' => $this->name,\n            'email' => $this->email,\n        ];\n    }\n}\n"
  },
  {
    "path": "backend/app/Infrastructure/Cloud/LicenseService.php",
    "content": "<?php\n\nnamespace App\\Infrastructure\\Cloud;\n\nuse Exception;\nuse Illuminate\\Http\\Client\\Response;\nuse Illuminate\\Support\\Facades\\Http;\nuse LemonSqueezy\\Laravel\\Exceptions\\LemonSqueezyApiError;\nuse LemonSqueezy\\Laravel\\Exceptions\\LicenseKeyNotFound;\nuse LemonSqueezy\\Laravel\\Exceptions\\MalformedDataError;\n\nclass LicenseService\n{\n    public const VERSION = '1.8.5';\n\n    public const API = 'https://api.lemonsqueezy.com/v1';\n\n    /**\n     * Perform a Lemon Squeezy API call.\n     *\n     * @throws Exception\n     * @throws LemonSqueezyApiError\n     */\n    public static function api(string $method, string $uri, array $payload = []): Response\n    {\n        if (empty($apiKey = config('lemon-squeezy.api_key'))) {\n            throw new Exception('Lemon Squeezy API key not set.');\n        }\n\n        /** @var Response $response */\n        $response = Http::withToken($apiKey)\n            ->withUserAgent('LemonSqueezy\\Laravel/' . static::VERSION)\n            ->accept('application/vnd.api+json')\n            ->contentType('application/vnd.api+json')\n            ->$method(static::API . \"/{$uri}\", $payload);\n\n        return $response;\n    }\n\n    /**\n     * @throws LemonSqueezyApiError\n     * @throws LicenseKeyNotFound|MalformedDataError\n     */\n    public static function getLicenseKey(string $id): array\n    {\n        $response = static::api('GET', \"license-keys/$id\");\n        if ($response->notFound()) {\n            throw new LicenseKeyNotFound();\n        }\n\n        if ($response->failed()) {\n            throw new LemonSqueezyApiError($response['error'], (int) $response['error']);\n        }\n\n        return $response->json();\n    }\n\n    /**\n     * @throws LemonSqueezyApiError\n     * @throws LicenseKeyNotFound|MalformedDataError\n     */\n    public static function activateLicense(array $payload = []): array\n    {\n        $response = static::api('POST', 'licenses/activate', $payload);\n        if ($response->notFound()) {\n            throw new LicenseKeyNotFound();\n        }\n\n        if ($response->failed()) {\n            throw new LemonSqueezyApiError($response['error'], (int) $response['error']);\n        }\n\n        return $response->json();\n    }\n}\n"
  },
  {
    "path": "backend/app/Listeners/ActivateUser.php",
    "content": "<?php\n\nnamespace App\\Listeners;\n\nuse App\\Enums\\UserStatus;\nuse App\\Events\\UserOnboarded;\n\nclass ActivateUser\n{\n    /**\n     * Handle the event.\n     */\n    public function handle(UserOnboarded $event): void\n    {\n        $event->user->update(['status' => UserStatus::ACTIVE]);\n    }\n}\n"
  },
  {
    "path": "backend/app/Listeners/SendOnboardingCompleteNotification.php",
    "content": "<?php\n\nnamespace App\\Listeners;\n\nuse App\\Data\\CalendarWebhookData;\nuse App\\Data\\DisplayWebhookData;\nuse App\\Data\\UserWebhookData;\nuse App\\Events\\UserOnboarded;\nuse Illuminate\\Support\\Facades\\Http;\n\nclass SendOnboardingCompleteNotification\n{\n    /**\n     * Handle the event.\n     */\n    public function handle(UserOnboarded $event): void\n    {\n        $webhookUrl = config('settings.onboarding_complete_webhook_url');\n        if (!$webhookUrl) {\n            return;\n        }\n\n        Http::post($webhookUrl, [\n            'event' => 'onboarding_complete',\n            'user' => UserWebhookData::from($event->user),\n            'display' => DisplayWebhookData::from($event->display),\n            'calendar' => CalendarWebhookData::from($event->display->calendar),\n        ]);\n    }\n}\n"
  },
  {
    "path": "backend/app/Listeners/SendOrderCreatedNotification.php",
    "content": "<?php\n\nnamespace App\\Listeners;\n\nuse App\\Data\\OrderWebhookData;\nuse App\\Data\\UserWebhookData;\nuse Illuminate\\Support\\Facades\\Http;\nuse LemonSqueezy\\Laravel\\Events\\OrderCreated;\n\nclass SendOrderCreatedNotification\n{\n    /**\n     * Handle the event.\n     */\n    public function handle(OrderCreated $event): void\n    {\n        $webhookUrl = config('settings.order_created_webhook_url');\n        if (!$webhookUrl) {\n            return;\n        }\n\n        Http::post($webhookUrl, [\n            'event' => 'order_created',\n            'user' => UserWebhookData::from($event->billable),\n            'order' => OrderWebhookData::from($event->order),\n        ]);\n    }\n}\n"
  },
  {
    "path": "backend/app/Listeners/SendRegistrationNotification.php",
    "content": "<?php\n\nnamespace App\\Listeners;\n\nuse App\\Data\\UserWebhookData;\nuse App\\Events\\UserRegistered;\nuse Illuminate\\Support\\Facades\\Http;\n\nclass SendRegistrationNotification\n{\n    /**\n     * Handle the event.\n     */\n    public function handle(UserRegistered $event): void\n    {\n        $webhookUrl = config('settings.registration_webhook_url');\n        if (!$webhookUrl) {\n            return;\n        }\n\n        Http::post($webhookUrl, [\n            'event' => 'registration',\n            'user' => UserWebhookData::from($event->user),\n        ]);\n    }\n}\n"
  },
  {
    "path": "backend/app/Listeners/SendTrialExpiredOrCancelledNotification.php",
    "content": "<?php\n\nnamespace App\\Listeners;\n\nuse App\\Data\\UserWebhookData;\nuse App\\Events\\TrialExpiredOrCancelled;\nuse Illuminate\\Support\\Facades\\Http;\n\nclass SendTrialExpiredOrCancelledNotification\n{\n    /**\n     * Handle the event.\n     */\n    public function handle(TrialExpiredOrCancelled $event): void\n    {\n        $webhookUrl = config('settings.trial_expired_or_cancelled_webhook_url');\n        if (!$webhookUrl) {\n            return;\n        }\n\n        Http::post($webhookUrl, [\n            'event' => 'trial_expired_or_cancelled',\n            'user' => UserWebhookData::from($event->user),\n        ]);\n    }\n}\n\n"
  },
  {
    "path": "backend/app/Listeners/SendUserActivatedAfter24hNotification.php",
    "content": "<?php\n\nnamespace App\\Listeners;\n\nuse App\\Data\\UserWebhookData;\nuse App\\Events\\UserActivatedAfter24h;\nuse Illuminate\\Support\\Facades\\Http;\n\nclass SendUserActivatedAfter24hNotification\n{\n    /**\n     * Handle the event.\n     */\n    public function handle(UserActivatedAfter24h $event): void\n    {\n        $webhookUrl = config('settings.user_activated_after_24h_webhook_url');\n        if (!$webhookUrl) {\n            return;\n        }\n\n        Http::post($webhookUrl, [\n            'event' => 'user_activated_after_24h',\n            'user' => UserWebhookData::from($event->user),\n        ]);\n    }\n}\n\n"
  },
  {
    "path": "backend/app/Listeners/SendUserInactiveNotification.php",
    "content": "<?php\n\nnamespace App\\Listeners;\n\nuse App\\Data\\UserWebhookData;\nuse App\\Events\\UserInactive;\nuse Illuminate\\Support\\Facades\\Http;\n\nclass SendUserInactiveNotification\n{\n    /**\n     * Handle the event.\n     */\n    public function handle(UserInactive $event): void\n    {\n        $webhookUrl = config('settings.user_inactive_webhook_url');\n        if (!$webhookUrl) {\n            return;\n        }\n\n        Http::post($webhookUrl, [\n            'event' => 'user_inactive',\n            'user' => UserWebhookData::from($event->user),\n        ]);\n    }\n}\n\n"
  },
  {
    "path": "backend/app/Listeners/SendUserNotActivatedAfter24hNotification.php",
    "content": "<?php\n\nnamespace App\\Listeners;\n\nuse App\\Data\\UserWebhookData;\nuse App\\Events\\UserNotActivatedAfter24h;\nuse Illuminate\\Support\\Facades\\Http;\n\nclass SendUserNotActivatedAfter24hNotification\n{\n    /**\n     * Handle the event.\n     */\n    public function handle(UserNotActivatedAfter24h $event): void\n    {\n        $webhookUrl = config('settings.user_not_activated_after_24h_webhook_url');\n        if (!$webhookUrl) {\n            return;\n        }\n\n        Http::post($webhookUrl, [\n            'event' => 'user_not_activated_after_24h',\n            'user' => UserWebhookData::from($event->user),\n        ]);\n    }\n}\n\n"
  },
  {
    "path": "backend/app/Listeners/SendUserPassiveNotification.php",
    "content": "<?php\n\nnamespace App\\Listeners;\n\nuse App\\Data\\UserWebhookData;\nuse App\\Events\\UserPassive;\nuse Illuminate\\Support\\Facades\\Http;\n\nclass SendUserPassiveNotification\n{\n    /**\n     * Handle the event.\n     */\n    public function handle(UserPassive $event): void\n    {\n        $webhookUrl = config('settings.user_passive_webhook_url');\n        if (!$webhookUrl) {\n            return;\n        }\n\n        Http::post($webhookUrl, [\n            'event' => 'user_passive',\n            'user' => UserWebhookData::from($event->user),\n        ]);\n    }\n}\n\n"
  },
  {
    "path": "backend/app/Models/Board.php",
    "content": "<?php\n\nnamespace App\\Models;\n\nuse App\\Traits\\HasUlid;\nuse App\\Enums\\DisplayStatus;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany;\n\nclass Board extends Model\n{\n    use HasFactory;\n    use HasUlid;\n\n    protected $fillable = [\n        'workspace_id',\n        'user_id',\n        'name',\n        'title',\n        'subtitle',\n        'show_all_displays',\n        'theme',\n        'logo',\n        'show_title',\n        'show_booker',\n        'show_next_event',\n        'show_transitioning',\n        'transitioning_minutes',\n        'font_family',\n        'language',\n        'view_mode',\n        'show_meeting_title',\n    ];\n\n    protected $casts = [\n        'show_all_displays' => 'boolean',\n        'show_title' => 'boolean',\n        'show_booker' => 'boolean',\n        'show_next_event' => 'boolean',\n        'show_transitioning' => 'boolean',\n        'transitioning_minutes' => 'integer',\n        'show_meeting_title' => 'boolean',\n    ];\n\n    public function workspace(): BelongsTo\n    {\n        return $this->belongsTo(Workspace::class, 'workspace_id');\n    }\n\n    public function user(): BelongsTo\n    {\n        return $this->belongsTo(User::class, 'user_id');\n    }\n\n    public function displays(): BelongsToMany\n    {\n        return $this->belongsToMany(Display::class, 'board_displays')\n            ->withTimestamps();\n    }\n\n    /**\n     * Get the query builder for displays that should be shown on this board\n     * If show_all_displays is true, returns query for all active displays from the workspace\n     * Otherwise, returns query for selected displays from the pivot table\n     */\n    public function getDisplaysToShowQuery()\n    {\n        if ($this->show_all_displays) {\n            return Display::where('workspace_id', $this->workspace_id)\n                ->whereIn('status', [DisplayStatus::READY, DisplayStatus::ACTIVE]);\n        }\n\n        return $this->displays()\n            ->where('workspace_id', $this->workspace_id)\n            ->whereIn('status', [DisplayStatus::READY, DisplayStatus::ACTIVE]);\n    }\n\n    /**\n     * Get the displays that should be shown on this board\n     * If show_all_displays is true, returns all active displays from the workspace\n     * Otherwise, returns only the selected displays from the pivot table\n     */\n    public function getDisplaysToShow()\n    {\n        return $this->getDisplaysToShowQuery()\n            ->with(['settings', 'user'])\n            ->orderBy('name')\n            ->get();\n    }\n\n    /**\n     * Check if a display is included in this board\n     */\n    public function hasDisplay(Display $display): bool\n    {\n        if ($this->show_all_displays) {\n            return $display->workspace_id === $this->workspace_id;\n        }\n\n        return $this->displays()\n            ->where('workspace_id', $this->workspace_id)\n            ->where('displays.id', $display->id)\n            ->exists();\n    }\n\n    /**\n     * Get the count of displays shown on this board\n     */\n    public function getDisplayCountAttribute(): int\n    {\n        return $this->getDisplaysToShowQuery()->count();\n    }\n}\n"
  },
  {
    "path": "backend/app/Models/CalDAVAccount.php",
    "content": "<?php\n\nnamespace App\\Models;\n\nuse App\\Enums\\AccountStatus;\nuse App\\Enums\\PermissionType;\nuse App\\Traits\\HasUlid;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse App\\Services\\CalDAVService;\n\nclass CalDAVAccount extends Model\n{\n    use HasFactory;\n    use HasUlid;\n\n    protected $table = 'caldav_accounts';\n\n    protected $fillable = [\n        'name',\n        'email',\n        'avatar',\n        'status',\n        'permission_type',\n        'user_id',\n        'workspace_id',\n        'url',\n        'username',\n        'password',\n    ];\n\n    protected $hidden = [\n        'password',\n    ];\n\n    protected $casts = [\n        'status' => AccountStatus::class,\n        'permission_type' => PermissionType::class,\n        'password' => 'encrypted',\n    ];\n\n    public function user(): BelongsTo\n    {\n        return $this->belongsTo(User::class);\n    }\n\n    public function calendars(): HasMany\n    {\n        return $this->hasMany(Calendar::class, 'caldav_account_id');\n    }\n\n    public function workspace(): BelongsTo\n    {\n        return $this->belongsTo(Workspace::class);\n    }\n}\n"
  },
  {
    "path": "backend/app/Models/Calendar.php",
    "content": "<?php\n\nnamespace App\\Models;\n\nuse App\\Enums\\Provider;\nuse App\\Traits\\HasUlid;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasOne;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\n\nclass Calendar extends Model\n{\n    use HasFactory;\n    use HasUlid;\n\n    protected $fillable = [\n        'user_id',\n        'workspace_id',\n        'outlook_account_id',\n        'google_account_id',\n        'caldav_account_id',\n        'calendar_id',\n        'name',\n        'is_primary',\n    ];\n\n    public function outlookAccount(): ?BelongsTo\n    {\n        return $this->belongsTo(OutlookAccount::class, 'outlook_account_id');\n    }\n\n    public function googleAccount(): ?BelongsTo\n    {\n        return $this->belongsTo(GoogleAccount::class, 'google_account_id');\n    }\n\n    public function caldavAccount(): ?BelongsTo\n    {\n        return $this->belongsTo(CalDAVAccount::class, 'caldav_account_id');\n    }\n\n    public function room(): HasOne\n    {\n        return $this->hasOne(Room::class);\n    }\n\n    public function displays(): HasMany\n    {\n        return $this->hasMany(Display::class);\n    }\n\n    public function events(): HasMany\n    {\n        return $this->hasMany(Event::class);\n    }\n\n    public function workspace(): BelongsTo\n    {\n        return $this->belongsTo(Workspace::class, 'workspace_id');\n    }\n}\n"
  },
  {
    "path": "backend/app/Models/Device.php",
    "content": "<?php\n\nnamespace App\\Models;\n\nuse App\\Traits\\HasUlid;\nuse App\\Traits\\HasLastActivity;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\nuse Laravel\\Sanctum\\HasApiTokens;\nuse Illuminate\\Contracts\\Auth\\Authenticatable;\nuse Illuminate\\Auth\\Authenticatable as AuthenticatableTrait;\n\nclass Device extends Model implements Authenticatable\n{\n    use HasApiTokens;\n    use HasFactory;\n    use HasUlid;\n    use HasLastActivity;\n    use AuthenticatableTrait;\n\n    protected $fillable = [\n        'user_id',\n        'workspace_id',\n        'display_id',\n        'name',\n        'uid',\n        'last_activity_at'\n    ];\n\n    protected $casts = [\n        'last_activity_at' => 'datetime',\n    ];\n\n    public function display(): BelongsTo\n    {\n        return $this->belongsTo(Display::class, 'display_id');\n    }\n\n    public function user(): BelongsTo\n    {\n        return $this->belongsTo(User::class, 'user_id');\n    }\n\n    public function workspace(): BelongsTo\n    {\n        return $this->belongsTo(Workspace::class, 'workspace_id');\n    }\n}\n"
  },
  {
    "path": "backend/app/Models/Display.php",
    "content": "<?php\n\nnamespace App\\Models;\n\nuse App\\Traits\\HasUlid;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany;\nuse App\\Enums\\DisplayStatus;\nuse App\\Helpers\\DisplaySettings;\n\nclass Display extends Model\n{\n    use HasFactory;\n    use HasUlid;\n\n    protected $fillable = [\n        'user_id',\n        'workspace_id',\n        'name',\n        'display_name',\n        'calendar_id',\n        'status',\n        'last_sync_at',\n        'last_event_at'\n    ];\n\n    protected $casts = [\n        'last_sync_at' => 'datetime',\n        'last_event_at' => 'datetime',\n        'status' => DisplayStatus::class,\n    ];\n\n    public function calendar(): BelongsTo\n    {\n        return $this->belongsTo(Calendar::class, 'calendar_id');\n    }\n\n    public function user(): BelongsTo\n    {\n        return $this->belongsTo(User::class, 'user_id');\n    }\n\n    public function workspace(): BelongsTo\n    {\n        return $this->belongsTo(Workspace::class, 'workspace_id');\n    }\n\n    public function eventSubscriptions(): HasMany\n    {\n        return $this->hasMany(EventSubscription::class);\n    }\n\n    public function events(): HasMany\n    {\n        return $this->hasMany(Event::class);\n    }\n\n    public function devices(): HasMany\n    {\n        return $this->hasMany(Device::class);\n    }\n\n    public function settings(): HasMany\n    {\n        return $this->hasMany(DisplaySetting::class);\n    }\n\n    public function boards(): BelongsToMany\n    {\n        return $this->belongsToMany(Board::class, 'board_displays')\n            ->withTimestamps();\n    }\n\n    public function getStartTime(): Carbon\n    {\n        return now()->startOfDay();\n    }\n\n    public function getEndTime(): Carbon\n    {\n        return now()->endOfDay();\n    }\n\n    public function getEventsCacheKey(): string\n    {\n        return self::getEventsCacheKeyForDisplay($this->id);\n    }\n\n    public static function getEventsCacheKeyForDisplay(string $displayId): string\n    {\n        return \"display:$displayId:events\";\n    }\n\n    public function isDeactivated(): bool\n    {\n        return $this->status === DisplayStatus::DEACTIVATED;\n    }\n\n    public function updateLastEventAt(Carbon|null $date = null): void\n    {\n        $this->update(['last_event_at' => $date ?? now()]);\n    }\n\n    public function updateLastSyncAt(Carbon|null $date = null): void\n    {\n        $this->update(['last_sync_at' => $date ?? now()]);\n    }\n\n    // Display settings convenience methods\n    public function isCheckInEnabled(): bool\n    {\n        return DisplaySettings::isCheckInEnabled($this);\n    }\n\n    public function isBookingEnabled(): bool\n    {\n        return DisplaySettings::isBookingEnabled($this);\n    }\n\n    public function hasCustomBooking(): bool\n    {\n        // Check if booking is enabled in settings\n        if (! DisplaySettings::isBookingEnabled($this)) {\n            return false;\n        }\n\n        return true;\n    }\n\n    public function setCheckInEnabled(bool $enabled): bool\n    {\n        return DisplaySettings::setCheckInEnabled($this, $enabled);\n    }\n\n    public function setBookingEnabled(bool $enabled): bool\n    {\n        return DisplaySettings::setBookingEnabled($this, $enabled);\n    }\n\n    public function getCheckInMinutes(): int\n    {\n        return DisplaySettings::getCheckInMinutes($this);\n    }\n\n    public function setCheckInMinutes(int $minutes): bool\n    {\n        return DisplaySettings::setCheckInMinutes($this, $minutes);\n    }\n\n    public function getCheckInGracePeriod(): int\n    {\n        return DisplaySettings::getCheckInGracePeriod($this);\n    }\n\n    public function setCheckInGracePeriod(int $minutes): bool\n    {\n        return DisplaySettings::setCheckInGracePeriod($this, $minutes);\n    }\n\n    public function isCalendarEnabled(): bool\n    {\n        return DisplaySettings::isCalendarEnabled($this);\n    }\n\n    public function setCalendarEnabled(bool $enabled): bool\n    {\n        return DisplaySettings::setCalendarEnabled($this, $enabled);\n    }\n\n    public function getAvailableText(): ?string\n    {\n        return DisplaySettings::getAvailableText($this);\n    }\n\n    public function getTransitioningText(): ?string\n    {\n        return DisplaySettings::getTransitioningText($this);\n    }\n\n    public function getReservedText(): ?string\n    {\n        return DisplaySettings::getReservedText($this);\n    }\n\n    public function getCheckInText(): ?string\n    {\n        return DisplaySettings::getCheckInText($this);\n    }\n\n    public function getLogoUrl(): ?string\n    {\n        return app(\\App\\Services\\ImageService::class)->getLogoUrl($this);\n    }\n\n    public function getBackgroundImageUrl(): ?string\n    {\n        return app(\\App\\Services\\ImageService::class)->getBackgroundImageUrl($this);\n    }\n\n    public function getShowMeetingTitle(): bool\n    {\n        return DisplaySettings::getShowMeetingTitle($this);\n    }\n\n    public function getFontFamily(): string\n    {\n        return DisplaySettings::getFontFamily($this);\n    }\n\n    public function isAdminActionsHidden(): bool\n    {\n        return DisplaySettings::isAdminActionsHidden($this);\n    }\n\n    public function getCancelPermission(): string\n    {\n        return DisplaySettings::getCancelPermission($this);\n    }\n\n    public function getBorderThickness(): string\n    {\n        return DisplaySettings::getBorderThickness($this);\n    }\n}\n"
  },
  {
    "path": "backend/app/Models/DisplaySetting.php",
    "content": "<?php\n\nnamespace App\\Models;\n\nuse App\\Traits\\HasUlid;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\nuse Illuminate\\Support\\Facades\\Crypt;\n\nclass DisplaySetting extends Model\n{\n    use HasUlid;\n\n    protected $fillable = [\n        'display_id',\n        'key',\n        'value',\n        'type',\n    ];\n\n    protected $casts = [\n        'value' => 'encrypted',\n    ];\n\n    public function display(): BelongsTo\n    {\n        return $this->belongsTo(Display::class);\n    }\n\n    public function getValueAttribute($value)\n    {\n        if (!$value) {\n            return null;\n        }\n\n        $decrypted = Crypt::decryptString($value);\n\n        return match ($this->type) {\n            'boolean' => filter_var($decrypted, FILTER_VALIDATE_BOOLEAN),\n            'integer' => (int) $decrypted,\n            'float' => (float) $decrypted,\n            'array' => json_decode($decrypted, true),\n            'object' => json_decode($decrypted),\n            default => $decrypted,\n        };\n    }\n\n    public function setValueAttribute($value)\n    {\n        if ($value === null) {\n            $this->attributes['value'] = null;\n            return;\n        }\n\n        $this->attributes['value'] = Crypt::encryptString(\n            is_array($value) || is_object($value) ? json_encode($value) : (string) $value\n        );\n    }\n} "
  },
  {
    "path": "backend/app/Models/Event.php",
    "content": "<?php\n\nnamespace App\\Models;\n\nuse App\\Enums\\EventSource;\nuse App\\Traits\\HasUlid;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\n\nclass Event extends Model\n{\n    use HasUlid, HasFactory;\n\n    protected $fillable = [\n        'display_id',\n        'user_id',\n        'calendar_id',\n        'external_id',\n        'status',\n        'source',\n        'start',\n        'end',\n        'summary',\n        'description',\n        'location',\n        'timezone',\n        'checked_in_at',\n    ];\n\n    protected $casts = [\n        'start' => 'datetime',\n        'end' => 'datetime',\n        'checked_in_at' => 'datetime',\n    ];\n\n    public function display(): BelongsTo\n    {\n        return $this->belongsTo(Display::class);\n    }\n\n    public function user(): BelongsTo\n    {\n        return $this->belongsTo(User::class);\n    }\n\n    public function calendar(): BelongsTo\n    {\n        return $this->belongsTo(Calendar::class);\n    }\n\n    /**\n     * Check if this is a custom (user-created) event\n     */\n    public function isCustomEvent(): bool\n    {\n        return $this->source === EventSource::CUSTOM;\n    }\n\n    /**\n     * Check if this event was booked via the tablet\n     * Tablet bookings have calendar_id set (even if they exist in external calendars)\n     * Synced events from external calendars don't have calendar_id set\n     */\n    public function isTabletBooking(): bool\n    {\n        // If it's a custom event (no external calendar), it's definitely a tablet booking\n        if ($this->isCustomEvent()) {\n            return true;\n        }\n\n        // If it has external_id AND calendar_id, it was created via tablet and synced to external calendar\n        // Synced events from external calendars don't have calendar_id set\n        return $this->external_id !== null && $this->calendar_id !== null;\n    }\n\n    /**\n     * Check if event is currently active\n     */\n    public function isActive(): bool\n    {\n        $now = now();\n        return $this->start <= $now && $this->end > $now;\n    }\n\n    /**\n     * Check if event is upcoming (starts within next hour)\n     */\n    public function isUpcoming(): bool\n    {\n        $now = now();\n        $nextHour = $now->copy()->addHour();\n        return $this->start > $now && $this->start <= $nextHour;\n    }\n\n    /**\n     * Check in to this event\n     */\n    public function checkIn(): void\n    {\n        $this->update([\n            'checked_in_at' => now(),\n        ]);\n    }\n\n    /**\n     * Get unique identifier for external events\n     */\n    public function getUniqueKey(): string\n    {\n        return $this->external_id ?? $this->id;\n    }\n\n    /**\n     * Should the app require check-in for this event?\n     */\n    public function checkInRequired(): bool\n    {\n        // Never require check-in for custom events\n        if ($this->isCustomEvent()) {\n            return false;\n        }\n\n        // Only require if event is upcoming, and not checked in\n        return ! $this->checked_in_at;\n    }\n}\n"
  },
  {
    "path": "backend/app/Models/EventSubscription.php",
    "content": "<?php\n\nnamespace App\\Models;\n\nuse App\\Enums\\Provider;\nuse App\\Traits\\HasUlid;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\n\nclass EventSubscription extends Model\n{\n    use HasFactory;\n    use HasUlid;\n\n    protected $fillable = [\n        'subscription_id',\n        'resource',\n        'expiration',\n        'notification_url',\n        'user_id',\n        'display_id',\n        'outlook_account_id',\n        'google_account_id',\n    ];\n\n    public function scopeExpired(Builder $query)\n    {\n        return $query->where('expiration', '<=', now()->toAtomString());\n    }\n\n    public function outlookAccount(): BelongsTo\n    {\n        return $this->belongsTo(OutlookAccount::class, 'outlook_account_id');\n    }\n\n    public function googleAccount(): BelongsTo\n    {\n        return $this->belongsTo(GoogleAccount::class, 'google_account_id');\n    }\n\n    public function display(): BelongsTo\n    {\n        return $this->belongsTo(Display::class, 'display_id');\n    }\n}\n"
  },
  {
    "path": "backend/app/Models/GoogleAccount.php",
    "content": "<?php\n\nnamespace App\\Models;\n\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse App\\Services\\GoogleService;\nuse App\\Traits\\HasUlid;\nuse App\\Enums\\AccountStatus;\nuse App\\Enums\\PermissionType;\nuse App\\Enums\\GoogleBookingMethod;\n\nclass GoogleAccount extends Model\n{\n    use HasFactory;\n    use HasUlid;\n\n    protected $fillable = [\n        'name',\n        'email',\n        'avatar',\n        'hosted_domain',\n        'status',\n        'permission_type',\n        'service_account_file_path',\n        'booking_method',\n        'user_id',\n        'workspace_id',\n        'google_id',\n        'token',\n        'refresh_token',\n        'token_expires_at',\n    ];\n\n    protected $hidden = [\n        'token',\n        'refresh_token',\n    ];\n\n    protected $casts = [\n        'token_expires_at' => 'datetime',\n        'status' => AccountStatus::class,\n        'permission_type' => PermissionType::class,\n        'booking_method' => GoogleBookingMethod::class,\n        'token' => 'encrypted',\n        'refresh_token' => 'encrypted',\n    ];\n\n    public function user(): BelongsTo\n    {\n        return $this->belongsTo(User::class);\n    }\n\n    public function calendars(): HasMany\n    {\n        return $this->hasMany(Calendar::class, 'google_account_id');\n    }\n\n    public function isBusiness(): bool\n    {\n        return !empty($this->hosted_domain);\n    }\n\n    public function workspace(): BelongsTo\n    {\n        return $this->belongsTo(Workspace::class);\n    }\n}\n"
  },
  {
    "path": "backend/app/Models/Instance.php",
    "content": "<?php\n\nnamespace App\\Models;\n\nuse App\\Traits\\HasUlid;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\nuse LemonSqueezy\\Laravel\\Billable;\n\nclass Instance extends Model\n{\n    use HasFactory, HasUlid, Billable;\n\n    protected $fillable = [\n        'instance_key',\n        'license_key',\n        'license_valid',\n        'license_expires_at',\n        'is_self_hosted',\n        'displays_count',\n        'rooms_count',\n        'boards_count',\n        'users',\n        'version',\n        'last_validated_at',\n        'last_heartbeat_at',\n    ];\n\n    protected $casts = [\n        'license_valid' => 'boolean',\n        'is_self_hosted' => 'boolean',\n        'users' => 'array',\n        'license_expires_at' => 'datetime',\n        'last_validated_at' => 'datetime',\n        'last_heartbeat_at' => 'datetime',\n    ];\n\n    public function user(): BelongsTo\n    {\n        return $this->belongsTo(User::class);\n    }\n}\n"
  },
  {
    "path": "backend/app/Models/OutlookAccount.php",
    "content": "<?php\n\nnamespace App\\Models;\n\nuse App\\Services\\OutlookService;\nuse App\\Traits\\HasUlid;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse App\\Enums\\AccountStatus;\nuse App\\Enums\\PermissionType;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\n\nclass OutlookAccount extends Model\n{\n    use HasFactory;\n    use HasUlid;\n\n    protected $fillable = [\n        'name',\n        'email',\n        'avatar',\n        'tenant_id',\n        'status',\n        'permission_type',\n        'user_id',\n        'workspace_id',\n        'outlook_id',\n        'token',\n        'refresh_token',\n        'token_expires_at',\n    ];\n\n    protected $hidden = [\n        'token',\n        'refresh_token',\n    ];\n\n    protected $casts = [\n        'token_expires_at' => 'datetime',\n        'status' => AccountStatus::class,\n        'permission_type' => PermissionType::class,\n        'token' => 'encrypted',\n        'refresh_token' => 'encrypted',\n    ];\n\n    public function isBusiness(): bool\n    {\n        return !empty($this->tenant_id);\n    }\n\n    public function calendars(): HasMany\n    {\n        return $this->hasMany(Calendar::class, 'outlook_account_id');\n    }\n\n    public function workspace(): BelongsTo\n    {\n        return $this->belongsTo(Workspace::class);\n    }\n}\n"
  },
  {
    "path": "backend/app/Models/PersonalAccessToken.php",
    "content": "<?php\n\nnamespace App\\Models;\n\nuse App\\Traits\\HasUlid;\nuse Laravel\\Sanctum\\PersonalAccessToken as SanctumPersonalAccessToken;\n\nclass PersonalAccessToken extends SanctumPersonalAccessToken\n{\n    use HasUlid;\n}\n"
  },
  {
    "path": "backend/app/Models/Room.php",
    "content": "<?php\n\nnamespace App\\Models;\n\nuse App\\Enums\\Plan;\nuse App\\Traits\\HasUlid;\nuse Carbon\\Carbon;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\n\nclass Room extends Model\n{\n    use HasFactory;\n    use HasUlid;\n\n    protected $fillable = [\n        'user_id',\n        'workspace_id',\n        'calendar_id',\n        'name',\n        'email_address',\n    ];\n\n    public function calendar(): BelongsTo\n    {\n        return $this->belongsTo(Calendar::class, 'calendar_id');\n    }\n\n    public function user(): BelongsTo\n    {\n        return $this->belongsTo(User::class, 'user_id');\n    }\n\n    public function workspace(): BelongsTo\n    {\n        return $this->belongsTo(Workspace::class, 'workspace_id');\n    }\n}\n"
  },
  {
    "path": "backend/app/Models/Setting.php",
    "content": "<?php\n\nnamespace App\\Models;\n\nuse App\\Traits\\HasUlid;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Support\\Facades\\Crypt;\n\nclass Setting extends Model\n{\n    use HasUlid;\n\n    protected $fillable = [\n        'key',\n        'value',\n        'type',\n    ];\n\n    protected $casts = [\n        'value' => 'encrypted',\n    ];\n\n    public function getValueAttribute($value)\n    {\n        if (!$value) {\n            return null;\n        }\n\n        $decrypted = Crypt::decryptString($value);\n\n        return match ($this->type) {\n            'boolean' => filter_var($decrypted, FILTER_VALIDATE_BOOLEAN),\n            'integer' => (int) $decrypted,\n            'float' => (float) $decrypted,\n            'array' => json_decode($decrypted, true),\n            'object' => json_decode($decrypted),\n            default => $decrypted,\n        };\n    }\n\n    public function setValueAttribute($value)\n    {\n        if ($value === null) {\n            $this->attributes['value'] = null;\n            return;\n        }\n\n        $this->attributes['value'] = Crypt::encryptString(\n            is_array($value) || is_object($value) ? json_encode($value) : (string) $value\n        );\n    }\n} "
  },
  {
    "path": "backend/app/Models/User.php",
    "content": "<?php\n\nnamespace App\\Models;\n\nuse App\\Enums\\Plan;\nuse App\\Enums\\UsageType;\nuse App\\Enums\\WorkspaceRole;\nuse App\\Traits\\HasUlid;\nuse App\\Traits\\HasLastActivity;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany;\nuse Illuminate\\Foundation\\Auth\\User as Authenticatable;\nuse Illuminate\\Notifications\\Notifiable;\nuse Laravel\\Sanctum\\HasApiTokens;\nuse LemonSqueezy\\Laravel\\Billable;\nuse LemonSqueezy\\Laravel\\Checkout;\nuse App\\Services\\InstanceService;\n\nclass User extends Authenticatable\n{\n    use HasApiTokens, HasFactory, Notifiable, HasUlid, HasLastActivity, Billable;\n\n    /**\n     * Boot the model.\n     */\n    protected static function boot()\n    {\n        parent::boot();\n\n        // Auto-create workspace when user is created\n        static::created(function ($user) {\n            // Only create if user doesn't already have a workspace\n            if (!$user->workspaces()->exists()) {\n                $workspace = Workspace::create([\n                    'name' => $user->name . \"'s Workspace\",\n                ]);\n\n                // Add user as owner member (use WorkspaceMember::create to generate ULID)\n                WorkspaceMember::create([\n                    'workspace_id' => $workspace->id,\n                    'user_id' => $user->id,\n                    'role' => WorkspaceRole::OWNER,\n                ]);\n            }\n        });\n    }\n\n    /**\n     * The attributes that are mass assignable.\n     *\n     * @var array<int, string>\n     */\n    protected $fillable = [\n        'name',\n        'first_name',\n        'last_name',\n        'email',\n        'password',\n        'microsoft_id',\n        'google_id',\n        'status',\n        'usage_type',\n        'email_verified_at',\n        'last_activity_at',\n        'is_unlimited',\n        'terms_accepted_at',\n        'is_admin',\n    ];\n\n    /**\n     * The attributes that should be hidden for serialization.\n     *\n     * @var array<int, string>\n     */\n    protected $hidden = [\n        'password',\n        'remember_token',\n    ];\n\n    /**\n     * The attributes that should be cast.\n     *\n     * @var array<string, string>\n     */\n    protected $casts = [\n        'email_verified_at' => 'datetime',\n        'password' => 'hashed',\n        'last_activity_at' => 'datetime',\n        'is_unlimited' => 'boolean',\n        'usage_type' => UsageType::class,\n        'terms_accepted_at' => 'datetime',\n        'is_admin' => 'boolean',\n    ];\n\n    public function outlookAccounts(): HasMany\n    {\n        return $this->hasMany(OutlookAccount::class);\n    }\n\n    public function googleAccounts(): HasMany\n    {\n        return $this->hasMany(GoogleAccount::class);\n    }\n\n    public function caldavAccounts(): HasMany\n    {\n        return $this->hasMany(CalDAVAccount::class);\n    }\n\n    public function displays(): HasMany\n    {\n        return $this->hasMany(Display::class);\n    }\n\n    public function devices(): HasMany\n    {\n        return $this->hasMany(Device::class);\n    }\n\n    public function rooms(): HasMany\n    {\n        return $this->hasMany(Room::class);\n    }\n\n    public function boards(): HasMany\n    {\n        return $this->hasMany(Board::class);\n    }\n\n    /**\n     * Get workspaces owned by this user (where user has 'owner' role)\n     */\n    public function ownedWorkspaces()\n    {\n        return $this->workspaces()->wherePivot('role', WorkspaceRole::OWNER->value);\n    }\n\n    /**\n     * Get workspaces this user is a member of\n     */\n    public function workspaces(): BelongsToMany\n    {\n        return $this->belongsToMany(Workspace::class, 'workspace_members')\n            ->withPivot('role')\n            ->withTimestamps();\n    }\n\n    /**\n     * Get the primary workspace for this user (first workspace where user is owner)\n     */\n    public function primaryWorkspace(): ?Workspace\n    {\n        return $this->ownedWorkspaces()->first() ?? $this->workspaces()->first();\n    }\n\n    /**\n     * Get all workspaces this user has access to\n     */\n    public function accessibleWorkspaces()\n    {\n        return $this->workspaces()->get();\n    }\n\n    public function hasAnyDisplay(): bool\n    {\n        return $this->displays()->count() > 0;\n    }\n\n    public function hasAnyAccount(): bool\n    {\n        return $this->outlookAccounts()->count() > 0 || $this->googleAccounts()->count() > 0 || $this->caldavAccounts()->count() > 0;\n    }\n\n    /**\n     * Get or generate a connect code for this user\n     * \n     * @return string 6-digit connect code\n     */\n    public function getConnectCode(): string\n    {\n        $connectCode = cache()->get(\"user:$this->id:connect-code\");\n        if (!$connectCode) {\n            $expiresAt = now()->addMinutes(30);\n            do {\n                $connectCode = mt_rand(100000, 999999);\n            } while (cache()->has(\"connect-code:$connectCode\"));\n\n            cache()->put(\"user:$this->id:connect-code\", $connectCode, $expiresAt);\n            cache()->put(\"connect-code:$connectCode\", $this->id, $expiresAt);\n        }\n\n        return $connectCode;\n    }\n\n    /**\n     * Retrieve and invalidate a connect code atomically\n     * This ensures the code can only be used once\n     * \n     * @param string $code The 6-digit connect code\n     * @return string|null The user ID associated with the code, or null if invalid/already used\n     */\n    public static function pullConnectCode(string $code): ?string\n    {\n        // Atomically retrieve and remove the connect code from cache\n        $userId = cache()->pull(\"connect-code:$code\");\n        \n        // If code was valid, also remove the reverse mapping\n        if ($userId !== null) {\n            cache()->forget(\"user:$userId:connect-code\");\n        }\n        \n        return $userId;\n    }\n\n    public function isOnboarded(): bool\n    {\n        // Check if user has accounts OR if any workspace they're a member of has accounts\n        $hasAccounts = $this->hasAnyAccount();\n        \n        if (!$hasAccounts) {\n            // Check if any workspace the user is a member of has accounts\n            $workspaceIds = $this->workspaces()->pluck('workspaces.id')->toArray();\n            if (!empty($workspaceIds)) {\n                $workspaceAccountCount = OutlookAccount::whereIn('workspace_id', $workspaceIds)->count()\n                    + GoogleAccount::whereIn('workspace_id', $workspaceIds)->count()\n                    + CalDAVAccount::whereIn('workspace_id', $workspaceIds)->count();\n                \n                if ($workspaceAccountCount > 0) {\n                    $hasAccounts = true;\n                }\n            }\n        }\n\n        if (config('settings.is_self_hosted')) {\n            return $this->usage_type && $this->terms_accepted_at && $hasAccounts;\n        }\n\n        return $this->usage_type && $hasAccounts;\n    }\n\n    public function hasPro(): bool\n    {\n        if (config('settings.is_self_hosted')) {\n            return $this->usage_type === UsageType::PERSONAL || InstanceService::hasValidLicense();\n        }\n\n        return $this->is_unlimited || $this->subscribed();\n    }\n\n    /**\n     * Check if the user has Pro for the current workspace context.\n     * Returns true if the user has Pro OR if the selected workspace has Pro (any owner has Pro).\n     */\n    public function hasProForCurrentWorkspace(): bool\n    {\n        // If user has Pro, they have Pro everywhere\n        if ($this->hasPro()) {\n            return true;\n        }\n\n        // Check if the selected workspace has Pro (any owner has Pro)\n        $selectedWorkspace = $this->getSelectedWorkspace();\n        if ($selectedWorkspace && $selectedWorkspace->hasPro()) {\n            return true;\n        }\n\n        return false;\n    }\n\n    /**\n     * Check if the user has Pro for a specific workspace.\n     * Returns true if the user has Pro OR if the workspace has Pro (any owner has Pro).\n     */\n    public function hasProForWorkspace(Workspace $workspace): bool\n    {\n        // If user has Pro, they have Pro everywhere\n        if ($this->hasPro()) {\n            return true;\n        }\n\n        // Check if the workspace has Pro (any owner has Pro)\n        return $workspace->hasPro();\n    }\n\n    /**\n     * Check if the user should be treated as a business user\n     */\n    public function isBusinessUser(): bool\n    {\n        return $this->usage_type === UsageType::BUSINESS;\n    }\n\n    /**\n     * Check if the user should be treated as a personal user\n     */\n    public function isPersonalUser(): bool\n    {\n        return $this->usage_type === UsageType::PERSONAL;\n    }\n\n    /**\n     * Check if the user should upgrade to Pro\n     */\n    public function shouldUpgrade(): bool\n    {\n        // Self Hosted: If the user is a personal user, use a soft limit\n        if (config('settings.is_self_hosted') && $this->isPersonalUser()) {\n            return false;\n        }\n\n        // Cloud Hosted: If the user is a business user and doesn't have Pro, they should upgrade\n        return ! $this->hasPro() && $this->hasAnyDisplay();\n    }\n\n    /**\n     * Check if the user should upgrade to Pro for the current workspace context.\n     * Returns false if the user has Pro OR if the selected workspace has Pro.\n     */\n    public function shouldUpgradeForCurrentWorkspace(): bool\n    {\n        // If user has Pro for current workspace, no upgrade needed\n        if ($this->hasProForCurrentWorkspace()) {\n            return false;\n        }\n\n        // Self Hosted: If the user is a personal user, use a soft limit\n        if (config('settings.is_self_hosted') && $this->isPersonalUser()) {\n            return false;\n        }\n\n        // Get the current workspace to scope the display check\n        $selectedWorkspace = $this->getSelectedWorkspace();\n        if (!$selectedWorkspace) {\n            // No workspace context, no upgrade needed\n            return false;\n        }\n\n        // Cloud Hosted: Check if the user has any displays in the current workspace\n        return $this->displays()->where('workspace_id', $selectedWorkspace->id)->exists();\n    }\n\n    public function getCheckoutUrl(?string $redirectUrl = null): ?Checkout\n    {\n        $redirectUrl ??= route('dashboard');\n\n        if (config('settings.is_self_hosted')) {\n            return null;\n        }\n\n        $cacheKey = \"user:{$this->id}:checkout-url:{$redirectUrl}\";\n\n        return cache()->remember($cacheKey, now()->addHour(), function () use ($redirectUrl) {\n            return auth()->user()->subscribe(config('settings.cloud_hosted_pro_plan_id'))->redirectTo($redirectUrl);\n        });\n    }\n\n    /**\n     * Check if the given email is allowed based on config('settings.allowed_logins')\n     */\n    public static function isAllowedLogin(string $email): bool\n    {\n        $allowed = config('settings.allowed_logins', []);\n        if (empty($allowed)) {\n            return true; // No restrictions set\n        }\n\n        $email = strtolower(trim($email));\n        $domain = substr(strrchr($email, '@'), 1);\n        foreach ($allowed as $allowedEntry) {\n            $allowedEntry = strtolower($allowedEntry);\n            if ($allowedEntry === $email || $allowedEntry === $domain) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    /**\n     * Check if the user is an admin\n     */\n    public function isAdmin(): bool\n    {\n        return (bool) $this->is_admin;\n    }\n\n    /**\n     * Get the currently selected workspace (from session) or default to primary workspace\n     * \n     * Note: This works for all users (including non-Pro users) who are members of workspaces.\n     * Workspace access is based on membership, not Pro status.\n     */\n    public function getSelectedWorkspace(): ?Workspace\n    {\n        $selectedWorkspaceId = session()->get('selected_workspace_id');\n        \n        if ($selectedWorkspaceId) {\n            // Validate user has access to the selected workspace (checks membership, not Pro status)\n            $workspace = $this->workspaces()->find($selectedWorkspaceId);\n            if ($workspace) {\n                return $workspace;\n            }\n            // If selected workspace is invalid or user no longer has access, clear it from session\n            session()->forget('selected_workspace_id');\n        }\n        \n        // Default to primary workspace (first owned workspace, or first workspace user is a member of)\n        return $this->primaryWorkspace();\n    }\n}\n"
  },
  {
    "path": "backend/app/Models/Workspace.php",
    "content": "<?php\n\nnamespace App\\Models;\n\nuse App\\Enums\\DisplayStatus;\nuse App\\Enums\\WorkspaceRole;\nuse App\\Traits\\HasUlid;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany;\n\nclass Workspace extends Model\n{\n    use HasFactory, HasUlid;\n\n    protected $fillable = [\n        'name',\n    ];\n\n    /**\n     * Get all members of the workspace\n     */\n    public function members(): BelongsToMany\n    {\n        return $this->belongsToMany(User::class, 'workspace_members')\n            ->withPivot('role')\n            ->withTimestamps();\n    }\n\n    /**\n     * Get all displays in this workspace\n     */\n    public function displays(): HasMany\n    {\n        return $this->hasMany(Display::class);\n    }\n\n    /**\n     * Get all devices in this workspace\n     */\n    public function devices(): HasMany\n    {\n        return $this->hasMany(Device::class);\n    }\n\n    /**\n     * Get all calendars in this workspace\n     */\n    public function calendars(): HasMany\n    {\n        return $this->hasMany(Calendar::class);\n    }\n\n    /**\n     * Get all rooms in this workspace\n     */\n    public function rooms(): HasMany\n    {\n        return $this->hasMany(Room::class);\n    }\n\n    /**\n     * Get all boards in this workspace\n     */\n    public function boards(): HasMany\n    {\n        return $this->hasMany(Board::class);\n    }\n\n    /**\n     * Check if a user is a member of this workspace\n     */\n    public function hasMember(User $user): bool\n    {\n        return $this->members()->where('user_id', $user->id)->exists();\n    }\n\n    /**\n     * Get the owner(s) of the workspace (members with 'owner' role)\n     */\n    public function owners()\n    {\n        return $this->members()->wherePivot('role', WorkspaceRole::OWNER->value);\n    }\n\n    /**\n     * Check if a user is the owner of this workspace\n     */\n    public function isOwnedBy(User $user): bool\n    {\n        return $this->members()->where('user_id', $user->id)->wherePivot('role', WorkspaceRole::OWNER->value)->exists();\n    }\n\n    /**\n     * Check if a user can manage this workspace (owner or admin)\n     */\n    public function canBeManagedBy(User $user): bool\n    {\n        $member = $this->members()->where('user_id', $user->id)->first();\n        if (!$member) {\n            return false;\n        }\n        \n        $role = $member->pivot->role instanceof WorkspaceRole \n            ? $member->pivot->role \n            : WorkspaceRole::from($member->pivot->role);\n            \n        return $role->canManage();\n    }\n\n    /**\n     * Get the role of a user in this workspace\n     */\n    public function getUserRole(User $user): ?WorkspaceRole\n    {\n        $member = $this->members()->where('user_id', $user->id)->first();\n        if (!$member) {\n            return null;\n        }\n        \n        $role = $member->pivot->role;\n        return $role instanceof WorkspaceRole ? $role : WorkspaceRole::from($role);\n    }\n\n    /**\n     * Check if this workspace has Pro (any owner has Pro)\n     */\n    public function hasPro(): bool\n    {\n        // Only eager load subscriptions in cloud-hosted mode\n        $ownersQuery = $this->owners();\n        if (!config('settings.is_self_hosted')) {\n            $ownersQuery = $ownersQuery->with('subscriptions');\n        }\n        \n        $owners = $ownersQuery->get();\n        foreach ($owners as $owner) {\n            if ($owner->hasPro()) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    /**\n     * Get the total usage count for billing purposes\n     * Displays count as 1x, Boards count as 2x\n     */\n    public function getTotalUsageCount(): int\n    {\n        $displayCount = $this->displays()\n            ->count();\n        \n        $boardCount = $this->boards()->count();\n        \n        return $displayCount + ($boardCount * 2);\n    }\n\n    /**\n     * Get breakdown of usage for display\n     */\n    public function getUsageBreakdown(): array\n    {\n        $displayCount = $this->displays()\n            ->count();\n        \n        $boardCount = $this->boards()->count();\n        $boardUsage = $boardCount * 2;\n        $totalUsage = $displayCount + $boardUsage;\n        \n        return [\n            'displays' => $displayCount,\n            'boards' => $boardCount,\n            'board_usage' => $boardUsage,\n            'total' => $totalUsage,\n        ];\n    }\n}\n\n"
  },
  {
    "path": "backend/app/Models/WorkspaceMember.php",
    "content": "<?php\n\nnamespace App\\Models;\n\nuse App\\Enums\\WorkspaceRole;\nuse App\\Traits\\HasUlid;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\n\nclass WorkspaceMember extends Model\n{\n    use HasFactory, HasUlid;\n\n    protected $table = 'workspace_members';\n\n    protected $fillable = [\n        'workspace_id',\n        'user_id',\n        'role',\n    ];\n\n    protected $casts = [\n        'role' => WorkspaceRole::class,\n    ];\n\n    /**\n     * Get the workspace this member belongs to\n     */\n    public function workspace(): BelongsTo\n    {\n        return $this->belongsTo(Workspace::class);\n    }\n\n    /**\n     * Get the user member\n     */\n    public function user(): BelongsTo\n    {\n        return $this->belongsTo(User::class);\n    }\n}\n\n"
  },
  {
    "path": "backend/app/Notifications/MagicLoginNotification.php",
    "content": "<?php\n\nnamespace App\\Notifications;\n\nuse Illuminate\\Bus\\Queueable;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Illuminate\\Notifications\\Messages\\MailMessage;\nuse Illuminate\\Notifications\\Notification;\n\nclass MagicLoginNotification extends Notification\n{\n    use Queueable;\n\n    /**\n     * Create a new notification instance.\n     */\n    public function __construct(public string $loginUrl)\n    {\n        //\n    }\n\n    /**\n     * Get the notification's delivery channels.\n     *\n     * @return array<int, string>\n     */\n    public function via(object $notifiable): array\n    {\n        return ['mail'];\n    }\n\n    /**\n     * Get the mail representation of the notification.\n     */\n    public function toMail(object $notifiable): MailMessage\n    {\n        return (new MailMessage)\n            ->subject('🔐 Your Login Link - ' . config('app.name'))\n            ->greeting('Hello! 👋')\n            ->line('We\\'ve generated a secure login link just for you.')\n            ->line('Click the button below to access your account:')\n            ->action('Log in to ' . config('app.name'), $this->loginUrl)\n            ->line('This link will expire in 15 minutes for your security.')\n            ->line('If you didn\\'t request this login link, you can safely ignore this email.')\n            ->salutation('Best regards,');\n    }\n\n    /**\n     * Get the array representation of the notification.\n     *\n     * @return array<string, mixed>\n     */\n    public function toArray(object $notifiable): array\n    {\n        return [\n            //\n        ];\n    }\n}\n"
  },
  {
    "path": "backend/app/Observers/EventObserver.php",
    "content": "<?php\n\nnamespace App\\Observers;\n\nuse App\\Models\\Display;\nuse App\\Models\\Event;\n\nclass EventObserver\n{\n    /**\n     * Handle the Event \"created\" event.\n     */\n    public function created(Event $event): void\n    {\n        $this->clearDisplayCache($event);\n    }\n\n    /**\n     * Handle the Event \"updated\" event.\n     */\n    public function updated(Event $event): void\n    {\n        $this->clearDisplayCache($event);\n    }\n\n    /**\n     * Handle the Event \"deleted\" event.\n     */\n    public function deleted(Event $event): void\n    {\n        $this->clearDisplayCache($event);\n    }\n\n    /**\n     * Clear the cache for the display's events if display is attached.\n     */\n    protected function clearDisplayCache(Event $event): void\n    {\n        if ($event->display_id) {\n            cache()->forget(Display::getEventsCacheKeyForDisplay($event->display_id));\n        }\n    }\n}\n"
  },
  {
    "path": "backend/app/Policies/BoardPolicy.php",
    "content": "<?php\n\nnamespace App\\Policies;\n\nuse App\\Models\\Board;\nuse App\\Models\\User;\nuse Illuminate\\Auth\\Access\\HandlesAuthorization;\n\nclass BoardPolicy\n{\n    use HandlesAuthorization;\n\n    /**\n     * Determine whether the user can create boards.\n     */\n    public function create(User $user): bool\n    {\n        // User must have Pro and be a member of a workspace\n        return $user->hasProForCurrentWorkspace() && $user->getSelectedWorkspace() !== null;\n    }\n\n    /**\n     * Determine whether the user can view the board.\n     */\n    public function view(User $user, Board $board): bool\n    {\n        // User must be a member of the workspace\n        return $board->workspace && $board->workspace->hasMember($user);\n    }\n\n    /**\n     * Determine whether the user can update the board.\n     */\n    public function update(User $user, Board $board): bool\n    {\n        // User must be able to manage the workspace (owner/admin)\n        if (!$board->workspace_id) {\n            return false;\n        }\n\n        $workspace = $board->workspace;\n        return $workspace && $workspace->canBeManagedBy($user);\n    }\n\n    /**\n     * Determine whether the user can delete the board.\n     */\n    public function delete(User $user, Board $board): bool\n    {\n        // User must be able to manage the workspace (owner/admin)\n        if (!$board->workspace_id) {\n            return false;\n        }\n\n        $workspace = $board->workspace;\n        return $workspace && $workspace->canBeManagedBy($user);\n    }\n}\n"
  },
  {
    "path": "backend/app/Policies/DisplayPolicy.php",
    "content": "<?php\n\nnamespace App\\Policies;\n\nuse App\\Models\\Display;\nuse App\\Models\\User;\nuse App\\Models\\Device;\nuse Illuminate\\Auth\\Access\\HandlesAuthorization;\n\nclass DisplayPolicy\n{\n    use HandlesAuthorization;\n\n    /**\n     * Determine whether the user can create displays.\n     */\n    public function create(User $user): bool\n    {\n        return $user->isOnboarded();\n    }\n\n    /**\n     * Determine whether the user can update the display.\n     */\n    public function update(User $user, Display $display): bool\n    {\n        if (!$display->workspace_id) {\n            return false;\n        }\n\n        $workspace = $display->workspace;\n        return $workspace && $workspace->canBeManagedBy($user);\n    }\n\n    /**\n     * Determine whether the user can delete the display.\n     */\n    public function delete(User $user, Display $display): bool\n    {\n        if (!$display->workspace_id) {\n            return false;\n        }\n\n        $workspace = $display->workspace;\n        return $workspace && $workspace->canBeManagedBy($user);\n    }\n\n    /**\n     * Determine whether the user can view the display.\n     */\n    public function view($user, Display $display): bool\n    {\n        // Handle User model\n        if ($user instanceof User) {\n            if (!$display->workspace_id) {\n                return false;\n            }\n\n            $workspace = $display->workspace;\n            return $workspace && $workspace->hasMember($user);\n        }\n        \n        // Handle Device model\n        if ($user instanceof Device) {\n            return $user->display_id === $display->id;\n        }\n        \n        return false;\n    }\n}\n"
  },
  {
    "path": "backend/app/Providers/AppServiceProvider.php",
    "content": "<?php\n\nnamespace App\\Providers;\n\nuse App\\Models\\PersonalAccessToken;\nuse Illuminate\\Support\\Facades\\Event;\nuse Illuminate\\Support\\ServiceProvider;\nuse Laravel\\Sanctum\\Sanctum;\nuse LemonSqueezy\\Laravel\\LemonSqueezy;\nuse SocialiteProviders\\Manager\\SocialiteWasCalled;\nuse SocialiteProviders\\Microsoft\\Provider;\nuse App\\Models\\Event as EventModel;\nuse App\\Observers\\EventObserver;\n\nclass AppServiceProvider extends ServiceProvider\n{\n    /**\n     * Register any application services.\n     */\n    public function register(): void\n    {\n        // Don't ignore migrations in test environment - tests need the tables\n        if (config('settings.is_self_hosted') && !app()->environment('testing')) {\n            LemonSqueezy::ignoreMigrations();\n        }\n    }\n\n    /**\n     * Bootstrap any application services.\n     */\n    public function boot(): void\n    {\n        Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class);\n\n        EventModel::observe(EventObserver::class);\n\n        Event::listen(function (SocialiteWasCalled $event) {\n            $event->extendSocialite('microsoft', Provider::class);\n        });\n    }\n}\n"
  },
  {
    "path": "backend/app/Providers/AuthServiceProvider.php",
    "content": "<?php\n\nnamespace App\\Providers;\n\nuse App\\Models\\Display;\nuse App\\Models\\Board;\nuse App\\Policies\\DisplayPolicy;\nuse App\\Policies\\BoardPolicy;\nuse Illuminate\\Foundation\\Support\\Providers\\AuthServiceProvider as ServiceProvider;\n\nclass AuthServiceProvider extends ServiceProvider\n{\n    /**\n     * The model to policy mappings for the application.\n     *\n     * @var array<class-string, class-string>\n     */\n    protected $policies = [\n        Display::class => DisplayPolicy::class,\n        Board::class => BoardPolicy::class,\n    ];\n\n    /**\n     * Register any authentication / authorization services.\n     */\n    public function boot(): void\n    {\n        $this->registerPolicies();\n    }\n} "
  },
  {
    "path": "backend/app/Services/CalDAVService.php",
    "content": "<?php\n\nnamespace App\\Services;\n\nuse App\\Models\\CalDAVAccount;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Str;\nuse Sabre\\DAV\\Client;\nuse Sabre\\DAV\\Xml\\Property\\ResourceType;\nuse Sabre\\HTTP\\ClientException;\nuse Sabre\\VObject\\Reader;\nuse Sabre\\VObject\\Component\\VCalendar;\nuse Sabre\\VObject\\Component\\VEvent;\n\nclass CalDAVService\n{\n    private Client $client;\n\n    public function __construct()\n    {\n        $this->client = new Client([\n            'baseUri' => '',\n            'userName' => '',\n            'password' => '',\n        ]);\n    }\n\n    private function configureClient(CalDAVAccount $account): void\n    {\n        $this->client = new Client([\n            'baseUri' => rtrim($account->url, '/'),\n            'userName' => $account->username,\n            'password' => $account->password,\n        ]);\n    }\n\n    public function fetchCalendars(CalDAVAccount $account): array\n    {\n        $this->configureClient($account);\n\n        try {\n            $response = $this->client->propFind(\"$account->url/calendars/$account->username/\", [\n                '{DAV:}resourcetype',\n                '{DAV:}displayname',\n                '{urn:ietf:params:xml:ns:caldav}calendar-description',\n            ], 1);\n\n            $calendars = [];\n            foreach ($response as $path => $properties) {\n                if (!isset($properties['{DAV:}resourcetype'])) {\n                    continue;\n                }\n\n                $resourceType = $properties['{DAV:}resourcetype'];\n                if (!$resourceType instanceof ResourceType ||\n                    !$resourceType->is('{urn:ietf:params:xml:ns:caldav}calendar')) {\n                    continue;\n                }\n\n                $calendars[] = [\n                    'id' => $path,\n                    'name' => $properties['{DAV:}displayname'] ?? basename($path),\n                    'description' => $properties['{urn:ietf:params:xml:ns:caldav}calendar-description'] ?? '',\n                ];\n            }\n\n            return $calendars;\n        } catch (\\Exception $e) {\n            throw new \\Exception(\"Failed to fetch calendars: \" . $e->getMessage());\n        }\n    }\n\n    public function fetchEvents(\n        CalDAVAccount $caldavAccount,\n        string $calendarId,\n        Carbon $startDateTime,\n        Carbon $endDateTime\n    ): array {\n        $this->configureClient($caldavAccount);\n\n        $query = <<<XML\n<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n<C:calendar-query xmlns:D=\"DAV:\" xmlns:C=\"urn:ietf:params:xml:ns:caldav\">\n    <D:prop>\n        <D:getetag/>\n        <C:calendar-data/>\n    </D:prop>\n    <C:filter>\n        <C:comp-filter name=\"VCALENDAR\">\n            <C:comp-filter name=\"VEVENT\">\n                <C:time-range start=\"{$startDateTime->format('Ymd\\THis\\Z')}\" end=\"{$endDateTime->format('Ymd\\THis\\Z')}\"/>\n            </C:comp-filter>\n        </C:comp-filter>\n    </C:filter>\n</C:calendar-query>\nXML;\n\n        try {\n            $response = $this->client->request('REPORT', $calendarId, $query, [\n                'Depth' => 1,\n                'Content-Type' => 'application/xml; charset=utf-8',\n            ]);\n\n            if ($response['statusCode'] !== 207) {\n                throw new \\Exception(\"Unexpected status code {$response['statusCode']}\");\n            }\n\n            // Parse multi-status response with calendar-data entries\n            $body = $response['body'];\n            $events = [];\n\n            preg_match_all('/<(?:cal|C):calendar-data[^>]*>(.*?)<\\/(?:cal|C):calendar-data>/s', $body, $matches);\n\n            foreach ($matches[1] as $icalData) {\n                $vcalendar = Reader::read($icalData);\n                foreach ($vcalendar->select('VEVENT') as $vevent) {\n                    $start = $vevent->DTSTART->getDateTime();\n                    $end = $vevent->DTEND->getDateTime();\n\n                    $events[] = [\n                        'id' => (string) $vevent->UID,\n                        'summary' => (string) $vevent->SUMMARY,\n                        'description' => (string) $vevent->DESCRIPTION,\n                        'location' => (string) $vevent->LOCATION,\n                        'start' => $start->format('Y-m-d\\TH:i:sP'),\n                        'end' => $end->format('Y-m-d\\TH:i:sP'),\n                        'timezone' => $start->getTimezone()->getName() ?? $end->getTimezone()->getName() ?? 'UTC',\n                        'isAllDay' => $vevent->DTSTART->hasTime() === false,\n                    ];\n                }\n            }\n\n            return $events;\n        } catch (\\Exception $e) {\n            throw new \\Exception(\"CalDAV request failed: \" . $e->getMessage());\n        }\n    }\n\n    /**\n     * Create an event in CalDAV calendar.\n     *\n     * @param CalDAVAccount $caldavAccount\n     * @param string $calendarId\n     * @param string $summary\n     * @param Carbon $start\n     * @param Carbon $end\n     * @return string|null Event UID\n     * @throws \\Exception\n     */\n    public function createEvent(\n        CalDAVAccount $caldavAccount,\n        string $calendarId,\n        string $summary,\n        Carbon $start,\n        Carbon $end\n    ): ?string {\n        $this->configureClient($caldavAccount);\n\n        // Create VCalendar with VEvent\n        $vcalendar = new VCalendar();\n        $vevent = $vcalendar->createComponent('VEVENT');\n        \n        $uid = Str::uuid()->toString();\n        $vevent->UID = $uid;\n        $vevent->SUMMARY = $summary;\n        \n        // Set DTSTART and DTEND - VObject handles DateTime objects directly\n        $vevent->DTSTART = $start;\n        $vevent->DTEND = $end;\n        $vevent->DTSTAMP = now();\n        \n        $vcalendar->add($vevent);\n\n        // Generate event filename\n        $filename = $uid . '.ics';\n\n        try {\n            // PUT the event to the calendar - trim trailing slash from calendarId to avoid double slashes\n            $path = rtrim($calendarId, '/') . '/' . $filename;\n            $response = $this->client->request('PUT', $path, $vcalendar->serialize(), [\n                'Content-Type' => 'text/calendar; charset=utf-8',\n            ]);\n\n            if ($response['statusCode'] >= 200 && $response['statusCode'] < 300) {\n                return $uid;\n            }\n\n            throw new \\Exception(\"Failed to create CalDAV event: Status code {$response['statusCode']}\");\n        } catch (\\Exception $e) {\n            throw new \\Exception('Failed to create CalDAV event: ' . $e->getMessage());\n        }\n    }\n\n    /**\n     * Delete an event from CalDAV calendar.\n     *\n     * @param CalDAVAccount $caldavAccount\n     * @param string $calendarId\n     * @param string $eventId\n     * @return void\n     * @throws \\Exception\n     */\n    public function deleteEvent(\n        CalDAVAccount $caldavAccount,\n        string $calendarId,\n        string $eventId\n    ): void {\n        $this->configureClient($caldavAccount);\n\n        // Generate event filename (assuming .ics extension)\n        $filename = $eventId . '.ics';\n\n        try {\n            // DELETE the event from the calendar - trim trailing slash from calendarId to avoid double slashes\n            $path = rtrim($calendarId, '/') . '/' . $filename;\n            $response = $this->client->request('DELETE', $path);\n\n            if ($response['statusCode'] < 200 || $response['statusCode'] >= 300) {\n                throw new \\Exception(\"Failed to delete CalDAV event: Status code {$response['statusCode']}\");\n            }\n        } catch (\\Exception $e) {\n            throw new \\Exception('Failed to delete CalDAV event: ' . $e->getMessage());\n        }\n    }\n\n    /**\n     * Check if the CalDAV server is accessible and credentials are valid\n     *\n     * @param string $url The CalDAV server URL\n     * @param string $username The username for authentication\n     * @param string $password The password for authentication\n     * @return array{success: bool, message: string} Connection test result\n     */\n    public function checkConnection(string $url, string $username, string $password): array\n    {\n        try {\n            $settings = [\n                'baseUri' => rtrim($url, '/'),\n                'userName' => $username,\n                'password' => $password,\n            ];\n\n            $client = new Client($settings);\n\n            // Try to fetch the principal URL to verify connection\n            $response = $client->propFind('', [\n                '{DAV:}current-user-principal'\n            ], 0);\n\n            if (isset($response['{DAV:}current-user-principal'])) {\n                return [\n                    'success' => true,\n                    'message' => 'Successfully connected to CalDAV server'\n                ];\n            }\n\n            return [\n                'success' => false,\n                'message' => 'Failed to connect to CalDAV server: Could not find principal URL'\n            ];\n\n        } catch (\\Exception $e) {\n            return [\n                'success' => false,\n                'message' => 'Failed to connect to CalDAV server: ' . $e->getMessage()\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "backend/app/Services/DisplayService.php",
    "content": "<?php\n\nnamespace App\\Services;\n\nuse App\\Data\\PermissionResult;\nuse App\\Models\\Device;\nuse App\\Models\\Display;\nuse App\\Models\\User;\n\nclass DisplayService\n{\n    public function getDisplay(string $displayId)\n    {\n        return Display::query()->with('settings')->findOrFail($displayId);\n    }\n\n    /**\n     * Validate if a display is permitted to perform actions.\n     *\n     * @param string|null $displayId\n     * @param string $deviceId\n     * @param array $options ['pro' => true, 'booking' => true]\n     * @return PermissionResult\n     */\n    public function validateDisplayPermission(?string $displayId, string $deviceId, array $options = []): PermissionResult\n    {\n        $device = Device::with('user.workspaces')->find($deviceId);\n        \n        if (!$device || !$device->user_id) {\n            return new PermissionResult(false, 'Device not found', 404);\n        }\n\n        $user = $device->user;\n        if (!$user) {\n            return new PermissionResult(false, 'User not found', 404);\n        }\n\n        if (!$displayId) {\n            return new PermissionResult(false, 'Display not found', 404);\n        }\n\n        // Get all workspace IDs the user is a member of\n        $workspaceIds = $user->workspaces->pluck('id');\n        if ($workspaceIds->isEmpty()) {\n            return new PermissionResult(false, 'User is not a member of any workspace', 403);\n        }\n\n        // Find display in any of the user's workspaces\n        $display = Display::with('workspace.members')\n            ->whereIn('workspace_id', $workspaceIds)\n            ->find($displayId);\n\n        if (!$display) {\n            return new PermissionResult(false, 'Display not found', 404);\n        }\n        \n        if ($display->isDeactivated()) {\n            return new PermissionResult(false, 'Display is deactivated', 400);\n        }\n        \n        // Pro feature check: check if any workspace owner has Pro\n        if (!empty($options['pro'])) {\n            if (!$display->workspace->hasPro()) {\n                return new PermissionResult(false, 'This is a Pro feature. Please upgrade to Pro to use this feature.', 403);\n            }\n        }\n        \n        if (!empty($options['booking']) && !$display->isBookingEnabled()) {\n            return new PermissionResult(false, 'Booking is not enabled for this display', 403);\n        }\n\n        // Add more checks as needed\n        return new PermissionResult(true);\n    }\n}\n"
  },
  {
    "path": "backend/app/Services/EventService.php",
    "content": "<?php\n\nnamespace App\\Services;\n\nuse App\\Enums\\EventStatus;\nuse App\\Enums\\EventSource;\nuse App\\Enums\\PermissionType;\nuse App\\Helpers\\DisplaySettings;\nuse App\\Models\\Display;\nuse App\\Models\\Event;\nuse App\\Models\\Calendar;\nuse Exception;\nuse Google\\Service\\Calendar\\Event as GoogleEvent;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Arr;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Str;\n\nclass EventService\n{\n    public function __construct(\n        protected OutlookService $outlookService,\n        protected GoogleService $googleService,\n        protected CalDAVService $caldavService,\n    ) {\n    }\n\n    /**\n     * Fetch events for a display, including remote sync if needed.\n     * @throws Exception\n     */\n    public function getEventsForDisplay($display): Collection\n    {\n        $display = Display::query()->withCount('eventSubscriptions')->findOrFail($display);\n\n        // Update last sync timestamp\n        $display->updateLastSyncAt();\n\n        // Release rooms that have not been checked in\n        $this->processExpiredCheckIns($display);\n\n        // Cache events if caching is enabled and the display has an event subscription\n        $cachingEnabled = config('services.events.cache_enabled') && $display->event_subscriptions_count > 0;\n        if ($cachingEnabled) {\n            $events = cache()->remember(\n                key: $display->getEventsCacheKey(),\n                ttl: now()->addMinutes(15),\n                callback: fn() => $this->getAllEvents($display)\n            );\n        } else {\n            $events = $this->getAllEvents($display);\n        }\n\n        return $events;\n    }\n\n    /**\n     * Book a room for a given duration. Handles all business logic.\n     * If the connected account has write permissions, creates the event via API.\n     * Otherwise, creates a custom event locally.\n     * Throws exception if not allowed.\n     */\n    public function bookRoom(string $displayId, string $userId, string $summary, ?int $duration = null, ?Carbon $start = null, ?Carbon $end = null): Event\n    {\n        // Normalize summary: trim and replace empty with default\n        $summary = trim($summary);\n        if (empty($summary)) {\n            $summary = __('Reserved');\n        }\n\n        // Validate duration if provided\n        if ($duration !== null) {\n            if (!is_int($duration) || $duration <= 0) {\n                throw new Exception('Duration must be a positive integer greater than 0');\n            }\n            $start = now();\n            $end = $start->copy()->addMinutes($duration);\n        } else {\n            // Validate that both start and end are provided\n            if ($start === null || $end === null) {\n                throw new Exception('Either duration or both start and end times must be provided');\n            }\n            // Validate that start is before end\n            if (!$start->lt($end)) {\n                throw new Exception('Start time must be before end time');\n            }\n        }\n\n        // Check for any conflicting events (both custom and external)\n        if ($this->hasConflictingEvents($displayId, $start, $end)) {\n            throw new Exception('Cannot book room: there are conflicting events during this time period');\n        }\n\n        $display = Display::query()\n            ->with(['calendar.outlookAccount', 'calendar.googleAccount', 'calendar.caldavAccount', 'calendar.room'])\n            ->findOrFail($displayId);\n        $calendar = $display->calendar;\n\n        // Check if we have write permissions and can create via API\n        if ($calendar) {\n            $hasWritePermissions = false;\n            $account = null;\n\n            // Check Outlook account\n            if ($calendar->outlook_account_id && $calendar->outlookAccount) {\n                $account = $calendar->outlookAccount;\n                $hasWritePermissions = $account->permission_type === PermissionType::WRITE;\n            }\n            // Check Google account\n            elseif ($calendar->google_account_id && $calendar->googleAccount) {\n                $account = $calendar->googleAccount;\n                $hasWritePermissions = $account->permission_type === PermissionType::WRITE;\n            }\n            // Check CalDAV account\n            elseif ($calendar->caldav_account_id && $calendar->caldavAccount) {\n                $account = $calendar->caldavAccount;\n                $hasWritePermissions = $account->permission_type === PermissionType::WRITE;\n            }\n\n            // If we have write permissions, create event via API\n            if ($hasWritePermissions && $account) {\n                try {\n                    $externalEventId = null;\n\n                    // Create event via Outlook API\n                    if ($calendar->outlook_account_id) {\n                        $eventData = $this->outlookService->createEvent(\n                            $calendar->outlookAccount,\n                            $calendar,\n                            $summary,\n                            $start,\n                            $end\n                        );\n                        $externalEventId = $eventData['id'] ?? null;\n                    }\n                    // Create event via Google API\n                    elseif ($calendar->google_account_id) {\n                        $googleEvent = $this->googleService->createEvent(\n                            $calendar->googleAccount,\n                            $calendar,\n                            $summary,\n                            $start,\n                            $end\n                        );\n                        $externalEventId = $googleEvent?->getId();\n                    }\n                    // Create event via CalDAV API\n                    elseif ($calendar->caldav_account_id) {\n                        $externalEventId = $this->caldavService->createEvent(\n                            $calendar->caldavAccount,\n                            $calendar->calendar_id,\n                            $summary,\n                            $start,\n                            $end\n                        );\n                    }\n\n                    // Validate that external event ID was returned\n                    // If API creation succeeded but no ID was returned, we can't track/cancel the event\n                    if (!is_string($externalEventId) || $externalEventId === '') {\n                        throw new Exception('External event was created but no external ID was returned. Cannot track or cancel this event.');\n                    }\n\n                    // Clear cache to force refetch\n                    Cache::forget($display->getEventsCacheKey());\n\n                    // Create event in database immediately with external_id (optimistic approach)\n                    // Wrap in transaction to ensure atomicity - if DB write fails, we know the external event exists but isn't tracked\n                    // Note: source is set to the calendar system (GOOGLE/OUTLOOK/CALDAV) so cancellation knows which API to use\n                    // calendar_id is set to identify this as a tablet booking (isTabletBooking() checks for calendar_id)\n                    $event = DB::transaction(function () use ($displayId, $userId, $calendar, $externalEventId, $start, $end, $summary) {\n                        return Event::create([\n                            'display_id' => $displayId,\n                            'user_id' => $userId,\n                            'calendar_id' => $calendar->id, // Set calendar_id to mark as tablet booking\n                            'external_id' => $externalEventId,\n                            'status' => EventStatus::CONFIRMED,\n                            'source' => $calendar->google_account_id ? EventSource::GOOGLE : ($calendar->outlook_account_id ? EventSource::OUTLOOK : EventSource::CALDAV),\n                            'start' => $start,\n                            'end' => $end,\n                            'summary' => $summary,\n                            'timezone' => config('app.timezone', 'UTC'),\n                        ]);\n                    });\n\n                    // Wait for Google Calendar API to reflect the change (with retry logic)\n                    // This ensures the event appears in API queries\n                    if ($calendar->google_account_id) {\n                        $this->waitForEventInApi($calendar, $externalEventId, $start, $end, true);\n                    }\n\n                    // Sync to update event details from API (times, summary, etc. might differ slightly)\n                    $this->syncAllExternalEventsForDisplay($display);\n\n                    // Refresh event from database after sync\n                    $event->refresh();\n\n                    return $event;\n                } catch (\\Exception $e) {\n                    // If API creation fails or external ID is missing, throw exception\n                    // Don't silently fall back to custom event - this would create duplicates\n                    logger()->error('Failed to create external event or track it in database', [\n                        'error' => $e->getMessage(),\n                        'display_id' => $displayId,\n                        'start' => $start->toIso8601String(),\n                        'end' => $end->toIso8601String(),\n                    ]);\n                    throw $e;\n                }\n            }\n        }\n\n        // Fall back to creating a custom event (no write permissions or API creation failed)\n        return Event::create([\n            'display_id' => $displayId,\n            'user_id' => $userId,\n            'status' => EventStatus::CONFIRMED,\n            'source' => EventSource::CUSTOM,\n            'start' => $start,\n            'end' => $end,\n            'summary' => $summary,\n            'timezone' => config('app.timezone', 'UTC'),\n        ]);\n    }\n\n    /**\n     * Cancel an event. If the event was created via API and account has write permissions,\n     * deletes it from the external calendar. Otherwise, marks it as cancelled.\n     */\n    public function cancelEvent(string $eventId, string $displayId): void\n    {\n        $event = Event::query()\n            ->where('display_id', $displayId)\n            ->with(['display.calendar.outlookAccount', 'display.calendar.googleAccount', 'display.calendar.caldavAccount', 'display.calendar.room', 'display.settings'])\n            ->find($eventId);\n\n        if (!$event) {\n            throw new Exception('Event not found or not accessible');\n        }\n\n        $display = $event->display;\n        $calendar = $display->calendar;\n\n        // Check cancel permission setting\n        $cancelPermission = DisplaySettings::getCancelPermission($display);\n        if ($cancelPermission === 'none') {\n            throw new Exception('Cancelling events is not allowed on this display');\n        }\n        \n        if ($cancelPermission === 'tablet_only' && !$event->isTabletBooking()) {\n            throw new Exception('Only events booked via this tablet can be cancelled');\n        }\n\n        // If event has external_id and calendar has write permissions, delete via API\n        if ($event->external_id && $calendar) {\n            $hasWritePermissions = false;\n            $account = null;\n\n            // Check Outlook account\n            if ($calendar->outlook_account_id && $calendar->outlookAccount) {\n                $account = $calendar->outlookAccount;\n                $hasWritePermissions = $account->permission_type === PermissionType::WRITE;\n            }\n            // Check Google account\n            elseif ($calendar->google_account_id && $calendar->googleAccount) {\n                $account = $calendar->googleAccount;\n                $hasWritePermissions = $account->permission_type === PermissionType::WRITE;\n            }\n            // Check CalDAV account\n            elseif ($calendar->caldav_account_id && $calendar->caldavAccount) {\n                $account = $calendar->caldavAccount;\n                $hasWritePermissions = $account->permission_type === PermissionType::WRITE;\n            }\n\n            // If we have write permissions, delete event via API\n            if ($hasWritePermissions && $account) {\n                try {\n                    // Delete event via Outlook API\n                    if ($calendar->outlook_account_id) {\n                        $this->outlookService->deleteEvent(\n                            $calendar->outlookAccount,\n                            $calendar,\n                            $event->external_id\n                        );\n                    }\n                    // Delete event via Google API\n                    elseif ($calendar->google_account_id) {\n                        $this->googleService->deleteEvent(\n                            $calendar->googleAccount,\n                            $calendar,\n                            $event->external_id\n                        );\n                    }\n                    // Delete event via CalDAV API\n                    elseif ($calendar->caldav_account_id) {\n                        $this->caldavService->deleteEvent(\n                            $calendar->caldavAccount,\n                            $calendar->calendar_id,\n                            $event->external_id\n                        );\n                    }\n\n                    // Clear cache to force refetch\n                    Cache::forget($display->getEventsCacheKey());\n\n                    // Store external_id before deletion\n                    $externalEventId = $event->external_id;\n                    $eventStart = $event->start;\n                    $eventEnd = $event->end;\n\n                    // Wait for Google Calendar API to reflect the deletion (with retry logic)\n                    // This ensures the event is removed from API queries before we delete from DB\n                    if ($calendar->google_account_id) {\n                        $this->waitForEventInApi($calendar, $externalEventId, $eventStart, $eventEnd, false);\n                    }\n\n                    // Delete the event from database (it's been removed from external calendar)\n                    $event->update(['status' => EventStatus::CANCELLED]);\n\n                    // Sync to ensure everything is in sync\n                    $this->syncAllExternalEventsForDisplay($display);\n\n                    return;\n                } catch (\\Exception $e) {\n                    // If API deletion fails, fall back to marking as cancelled\n                    logger()->warning('Failed to delete event via API, marking as cancelled', [\n                        'error' => $e->getMessage(),\n                        'event_id' => $eventId,\n                        'display_id' => $displayId,\n                    ]);\n                }\n            }\n        }\n\n        // For custom events (no external_id), delete them directly since they don't exist externally\n        if ($event->isCustomEvent()) {\n            $event->delete();\n            Cache::forget($display->getEventsCacheKey());\n            return;\n        }\n\n        // Fall back to marking as cancelled (for external events if API deletion failed)\n        $event->update(['status' => EventStatus::CANCELLED]);\n    }\n\n    /**\n     * @throws Exception\n     */\n    private function getAllEvents(Display $display): Collection\n    {\n        // Make sure external events are up to date\n        $this->syncAllExternalEventsForDisplay($display);\n\n        // Then query all events\n        return Event::query()\n            ->where('display_id', $display->id)\n            ->where('start', '>=', $display->getStartTime())\n            ->where('start', '<', $display->getEndTime())\n            ->orderBy('start')\n            ->get();\n    }\n\n    /**\n     * @throws Exception\n     */\n    private function syncAllExternalEventsForDisplay(Display $display): void\n    {\n        $calendar = $display->calendar()\n            ->with(['googleAccount', 'outlookAccount', 'caldavAccount', 'room'])\n            ->first();\n\n        // Handle Google integration\n        if ($calendar->google_account_id) {\n            $googleEvents = $this->fetchGoogleEvents($calendar, $display);\n            $this->syncExternalEvents($display, EventSource::GOOGLE, $googleEvents);\n        }\n\n        // Handle Outlook integration\n        if ($calendar->outlook_account_id) {\n            $outlookEvents = $this->fetchOutlookEvents($calendar, $display);\n            $this->syncExternalEvents($display, EventSource::OUTLOOK, $outlookEvents);\n        }\n\n        // Handle CalDAV integration\n        if ($calendar->caldav_account_id) {\n            $caldavEvents = $this->fetchCalDAVEvents($calendar, $display);\n            $this->syncExternalEvents($display, EventSource::CALDAV, $caldavEvents);\n        }\n    }\n\n    /**\n     * @param Calendar $calendar\n     * @param Display $display\n     * @return Collection\n     * @throws Exception\n     */\n    private function fetchOutlookEvents(Calendar $calendar, Display $display): Collection\n    {\n        $events = [];\n\n        // Fetch events by user (room)\n        if ($calendar->room) {\n            $events = $this->outlookService->fetchEventsByUser(\n                outlookAccount: $calendar->outlookAccount,\n                emailAddress: $calendar->calendar_id,\n                startDateTime: $display->getStartTime(),\n                endDateTime: $display->getEndTime(),\n            );\n        }\n\n        // Fetch events by calendar\n        if (! $calendar->room) {\n            $events = $this->outlookService->fetchEventsByCalendar(\n                outlookAccount: $calendar->outlookAccount,\n                calendarId: $calendar->calendar_id,\n                startDateTime: $display->getStartTime(),\n                endDateTime: $display->getEndTime(),\n            );\n        }\n\n        return collect($events)->map(fn($e) => $this->sanitizeOutlookEvent($e));\n    }\n\n    /**\n     * @param Calendar $calendar\n     * @param Display $display\n     * @return Collection\n     * @throws \\Exception\n     */\n    private function fetchGoogleEvents(Calendar $calendar, Display $display): Collection\n    {\n        $events = $this->googleService->fetchEvents(\n            googleAccount: $calendar->googleAccount,\n            calendarId: $calendar->calendar_id,\n            startDateTime: $display->getStartTime(),\n            endDateTime: $display->getEndTime(),\n        );\n\n        // Get room email if this calendar has a room\n        $roomEmail = $calendar->room?->email_address;\n\n        // Filter out cancelled events and events where the room declined as attendee\n        return collect($events)\n            ->filter(function ($event) use ($roomEmail) {\n                // Filter out cancelled events\n                if ($event->getStatus() === 'cancelled') {\n                    return false;\n                }\n\n                // If this calendar has a room, check if the room declined the event\n                if ($roomEmail && $event->getAttendees()) {\n                    foreach ($event->getAttendees() as $attendee) {\n                        // Check if this attendee is the room and if it declined\n                        if (strtolower($attendee->getEmail()) === strtolower($roomEmail)) {\n                            $responseStatus = $attendee->getResponseStatus();\n                            // Filter out events where the room declined\n                            if ($responseStatus === 'declined') {\n                                return false;\n                            }\n                        }\n                    }\n                }\n\n                return true;\n            })\n            ->map(fn($e) => $this->sanitizeGoogleEvent($e));\n    }\n\n    /**\n     * @param Calendar $calendar\n     * @param Display $display\n     * @return Collection\n     * @throws Exception\n     */\n    private function fetchCalDAVEvents(Calendar $calendar, Display $display): Collection\n    {\n        $events = $this->caldavService->fetchEvents(\n            caldavAccount: $calendar->caldavAccount,\n            calendarId: $calendar->calendar_id,\n            startDateTime: $display->getStartTime(),\n            endDateTime: $display->getEndTime(),\n        );\n\n        return collect($events)->map(fn($e) => $this->sanitizeCalDAVEvent($e));\n    }\n\n    /**\n     * @param array $outlookEvent\n     * @return array\n     */\n    public function sanitizeOutlookEvent(array $outlookEvent): array\n    {\n        $summary = $this->cleanSubject($outlookEvent['subject']);\n\n        $description = $this->cleanBody(\n            Arr::has($outlookEvent, 'body') && is_array($outlookEvent['body']) ?\n                $outlookEvent['body']['content'] :\n                $outlookEvent['bodyPreview']\n        );\n\n        // Get location if available\n        $location = $outlookEvent['location']['displayName'] ?? '';\n\n        // Handle all-day event\n        $isAllDay = $outlookEvent['isAllDay'] ?? false;\n\n        // Extract date for all-day events, or dateTime with timeZone for regular events\n        $start = $isAllDay ? ['dateTime' => explode('T', $outlookEvent['start']['dateTime'])[0]]\n            : ['dateTime' => $outlookEvent['start']['dateTime'], 'timeZone' => $outlookEvent['start']['timeZone']];\n\n        $end = $isAllDay ? ['dateTime' => explode('T', $outlookEvent['end']['dateTime'])[0]]\n            : ['dateTime' => $outlookEvent['end']['dateTime'], 'timeZone' => $outlookEvent['end']['timeZone']];\n\n        return [\n            'id' => $outlookEvent['id'],\n            'summary' => $summary,\n            'location' => $location,\n            'description' => $description,\n            'start' => $start['dateTime'],\n            'end' => $end['dateTime'],\n            'timezone' => $outlookEvent['start']['timeZone'] ?? $outlookEvent['end']['timeZone'] ?? 'UTC',\n            'isAllDay' => $isAllDay\n        ];\n    }\n\n    /**\n     * @param GoogleEvent $googleEvent\n     * @return array\n     */\n    public function sanitizeGoogleEvent(GoogleEvent $googleEvent): array\n    {\n        $start = $googleEvent->getStart();\n        $end = $googleEvent->getEnd();\n\n        // Handle all-day event - Google Calendar uses 'date' field for all-day events\n        $isAllDay = $start->getDate() !== null;\n\n        return [\n            'id' => $googleEvent->getId(),\n            'summary' => $this->cleanSubject($googleEvent->getSummary()),\n            'location' => $googleEvent->getLocation(),\n            'description' => $googleEvent->getDescription(),\n            'start' => $isAllDay ? $start->getDate() : $start->getDateTime(),\n            'end' => $isAllDay ? $end->getDate() : $end->getDateTime(),\n            'timezone' => $start->getTimeZone() ?? $end->getTimeZone() ?? 'UTC',\n            'isAllDay' => $isAllDay\n        ];\n    }\n\n    /**\n     * @param array $caldavEvent\n     * @return array\n     */\n    public function sanitizeCalDAVEvent(array $caldavEvent): array\n    {\n        return [\n            'id' => $caldavEvent['id'],\n            'summary' => $this->cleanSubject($caldavEvent['summary']),\n            'location' => $caldavEvent['location'],\n            'description' => $this->cleanBody($caldavEvent['description']),\n            'start' => $caldavEvent['start'],\n            'end' => $caldavEvent['end'],\n            'timezone' => $caldavEvent['timezone'],\n            'isAllDay' => $caldavEvent['isAllDay']\n        ];\n    }\n\n    private function cleanSubject(?string $subject): string\n    {\n        // Ensure variable is set\n        $subject ??= \"\";\n\n        return trim($subject); // Basic cleanup, can be expanded if necessary\n    }\n\n    private function cleanBody(?string $body): string\n    {\n        // Ensure variable is set\n        $body ??= \"\";\n\n        // Replace newlines and carriage returns as in JS version\n        $body = str_replace(\"\\r\", \"\\n\", $body);\n        return str_replace(\"\\n\", ' ', $body);\n    }\n\n    /**\n     * Safely truncate description to prevent database errors.\n     * MEDIUMTEXT can hold up to 16MB, but we'll limit to 10MB for safety.\n     */\n    private function truncateDescription(?string $description): string\n    {\n        if ($description === null || $description === '') {\n            return '';\n        }\n\n        // MEDIUMTEXT limit is 16,777,215 bytes, but we'll use 10MB (10,485,760 bytes) as a safe limit\n        $maxLength = 10 * 1024 * 1024; // 10MB in bytes\n\n        if (strlen($description) > $maxLength) {\n            // Truncate and add ellipsis\n            return substr($description, 0, $maxLength - 3) . '...';\n        }\n\n        return $description;\n    }\n\n    /**\n     * Check if there are any conflicting events for a display in a given time range.\n     *\n     * @param string $displayId\n     * @param Carbon $start\n     * @param Carbon $end\n     * @return bool\n     */\n    public function hasConflictingEvents(string $displayId, Carbon $start, Carbon $end): bool\n    {\n        return Event::query()\n            ->where('display_id', $displayId)\n            ->where('status', '!=', EventStatus::CANCELLED)\n            ->where(function ($q) use ($start, $end) {\n                // Check if an existing event starts within the new booking period (exclusive of end time)\n                $q->where('start', '>=', $start)->where('start', '<', $end)\n                  // Check if an existing event ends within the new booking period (exclusive of start time)\n                  ->orWhere(function ($q2) use ($start, $end) {\n                      $q2->where('end', '>', $start)->where('end', '<=', $end);\n                  })\n                  // Check if an existing event completely contains the new booking (exclusive boundaries)\n                  ->orWhere(function ($q2) use ($start, $end) {\n                      $q2->where('start', '<', $start)->where('end', '>', $end);\n                  });\n            })\n            ->exists();\n    }\n\n    /**\n     * Sync external events to the database for a display and source.\n     *\n     * @param Display $display\n     * @param string $source\n     * @param Collection $externalEvents\n     */\n    public function syncExternalEvents(Display $display, string $source, Collection $externalEvents): void\n    {\n        $existing = Event::query()\n            ->where('display_id', $display->id)\n            ->where('source', $source)\n            ->get()\n            ->keyBy('external_id');\n\n        $seenIds = [];\n\n        $externalEvents = $externalEvents->filter(fn ($event) => ! $event['isAllDay']);\n        \n        foreach ($externalEvents as $ext) {\n            $externalId = $ext['id'];\n            $seenIds[] = $externalId;\n\n            $event = $existing->get($externalId);\n            \n            // If event doesn't exist, create it\n            if (!$event) {\n                $event = new Event();\n                $event->id = (string) Str::ulid(); // Manually generate ULID since saveQuietly() disables creating event\n                $event->display_id = $display->id;\n                $event->user_id = $display->user_id;\n                $event->source = $source;\n                $event->external_id = $externalId;\n                $event->status = EventStatus::CONFIRMED;\n            } else {\n                // If event exists but is cancelled, don't reactivate it\n                // (it was likely just cancelled and Google API hasn't updated yet)\n                if ($event->status === EventStatus::CANCELLED) {\n                    continue;\n                }\n            }\n\n            // Parse datetime strings and convert to UTC for storage\n            // Carbon will automatically parse the timezone from the string and convert to UTC\n            $event->start = Carbon::parse($ext['start'])->utc();\n            $event->end = Carbon::parse($ext['end'])->utc();\n            $event->summary = $ext['summary'];\n            $event->description = $this->truncateDescription($ext['description'] ?? null);\n            $event->location = $ext['location'];\n            $event->timezone = $ext['timezone'];\n            // Ensure status is confirmed when syncing (unless it was cancelled)\n            if ($event->status !== EventStatus::CANCELLED) {\n                $event->status = EventStatus::CONFIRMED;\n            }\n\n            // Save without firing events to prevent N+1 queries (cache cleared once at end)\n            $event->saveQuietly();\n        }\n\n        // Delete events that no longer exist externally\n        Event::query()\n            ->where('display_id', $display->id)\n            ->where('source', $source)\n            ->whereNotIn('external_id', $seenIds)\n            ->delete();\n\n        // Clear cache once at the end instead of for each event\n        Cache::forget($display->getEventsCacheKey());\n    }\n\n    public function checkInToEvent(string $eventId, string $displayId): void\n    {\n        $event = Event::query()\n            ->where('display_id', $displayId)\n            ->find($eventId);\n\n        if (!$event) {\n            throw new Exception('Event not found or not accessible');\n        }\n\n        // Only allow check-in if not already checked in\n        if ($event->checked_in_at) {\n            throw new Exception('Already checked in');\n        }\n\n        $event->checkIn();\n    }\n\n    /**\n     * Wait for an event to appear or disappear in Google Calendar API.\n     * Retries with exponential backoff to handle Google's eventual consistency.\n     *\n     * @param Calendar $calendar\n     * @param string $externalEventId\n     * @param Carbon $start\n     * @param Carbon $end\n     * @param bool $shouldExist True if waiting for event to appear, false if waiting for it to disappear\n     * @return void\n     */\n    private function waitForEventInApi(Calendar $calendar, string $externalEventId, Carbon $start, Carbon $end, bool $shouldExist): void\n    {\n        if (!$calendar->google_account_id || !$calendar->googleAccount) {\n            return; // Only wait for Google Calendar API\n        }\n\n        $maxAttempts = 5;\n        $baseDelay = 0.5; // Start with 500ms\n\n        for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {\n            try {\n                // Fetch events from Google Calendar API\n                $googleEvents = $this->googleService->fetchEvents(\n                    $calendar->googleAccount,\n                    $calendar->calendar_id,\n                    $start->copy()->subHours(1), // Fetch slightly wider range\n                    $end->copy()->addHours(1)\n                );\n\n                // Check if event exists in the API response\n                $eventExists = false;\n                foreach ($googleEvents as $googleEvent) {\n                    if ($googleEvent->getId() === $externalEventId) {\n                        $eventExists = true;\n                        break;\n                    }\n                }\n\n                // If event state matches what we expect, we're done\n                if ($eventExists === $shouldExist) {\n                    return;\n                }\n\n                // If this is the last attempt, log a warning but continue\n                if ($attempt === $maxAttempts) {\n                    logger()->warning('Event state in Google API did not match expected state after retries', [\n                        'external_event_id' => $externalEventId,\n                        'expected_exists' => $shouldExist,\n                        'actual_exists' => $eventExists,\n                        'attempts' => $maxAttempts,\n                    ]);\n                    return;\n                }\n\n                // Exponential backoff: wait before retrying\n                $delay = $baseDelay * pow(2, $attempt - 1);\n                usleep((int)($delay * 1000000)); // Convert to microseconds\n\n            } catch (\\Exception $e) {\n                // If API call fails, log and continue (don't block the operation)\n                logger()->warning('Error checking event in Google API during wait', [\n                    'error' => $e->getMessage(),\n                    'external_event_id' => $externalEventId,\n                    'attempt' => $attempt,\n                ]);\n\n                // On last attempt, give up\n                if ($attempt === $maxAttempts) {\n                    return;\n                }\n\n                // Wait before retrying\n                $delay = $baseDelay * pow(2, $attempt - 1);\n                usleep((int)($delay * 1000000));\n            }\n        }\n    }\n\n    private function processExpiredCheckIns(Display $display): void\n    {\n        if (! $display->isCheckInEnabled()) {\n            return;\n        }\n\n        $gracePeriod = $display->getCheckInGracePeriod();\n        $events = Event::query()\n            ->select('id')\n            ->where('display_id', $display->id)\n            ->whereNull('checked_in_at')\n            ->where('start', '<', now()->subMinutes($gracePeriod))\n            ->where('status', '!=', EventStatus::CANCELLED)\n            ->get();\n\n        if ($events->isNotEmpty()) {\n            $events->each->update(['status' => EventStatus::CANCELLED]);\n        }\n    }\n}\n"
  },
  {
    "path": "backend/app/Services/GoogleService.php",
    "content": "<?php\n\nnamespace App\\Services;\n\nuse App\\Enums\\AccountStatus;\nuse App\\Models\\GoogleAccount;\nuse App\\Models\\Display;\nuse App\\Models\\EventSubscription;\nuse App\\Models\\Calendar;\nuse Exception;\nuse Google\\Client;\nuse Google\\Service\\Calendar\\Channel;\nuse Google\\Service\\Oauth2;\nuse Google\\Service\\Calendar as GoogleCalendar;\nuse Google\\Service\\Directory;\nuse Google\\Service\\Calendar\\Event as GoogleEvent;\nuse Illuminate\\Support\\Arr;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\Crypt;\nuse Illuminate\\Support\\Facades\\Storage;\nuse App\\Enums\\PermissionType;\nuse App\\Enums\\GoogleBookingMethod;\n\nclass GoogleService\n{\n    private Client $client;\n\n    public function __construct()\n    {\n        $this->client = new Client();\n        $this->client->setClientId(config('services.google.client_id'));\n        $this->client->setClientSecret(config('services.google.client_secret'));\n        $this->client->setRedirectUri(config('services.google.calendar_redirect'));\n        $this->client->setAccessType('offline');\n        $this->client->setPrompt('consent');\n    }\n\n    /**\n     * Handle Google OAuth callback and store tokens in the database.\n     *\n     * @param string $authCode\n     * @param PermissionType $permissionType\n     * @param GoogleBookingMethod $bookingMethod\n     * @return GoogleAccount\n     * @throws Exception\n     */\n    public function authenticateGoogleAccount(string $authCode, PermissionType $permissionType = PermissionType::READ, ?GoogleBookingMethod $bookingMethod = null): GoogleAccount\n    {\n        $accessToken = $this->client->fetchAccessTokenWithAuthCode($authCode);\n        if (Arr::exists($accessToken, 'error')) {\n            throw new Exception('Error authenticating with Google: ' . Arr::get($accessToken, 'error'));\n        }\n\n        logger()->info('Received Google access token:', $accessToken);\n\n        $this->client->setAccessToken($accessToken['access_token']);\n\n        // Get the authenticated user's profile and save tokens\n        $googleService = new Oauth2($this->client);\n        $googleUserInfo = $googleService->userinfo->get();\n\n        // Get selected workspace (from session or default to primary)\n        $selectedWorkspace = auth()->user()->getSelectedWorkspace();\n        $workspaceId = $selectedWorkspace?->id;\n\n        // Save the user's Google account and tokens in the database\n        return GoogleAccount::updateOrCreate(\n            [\n                'user_id' => auth()->id(),\n                'google_id' => $googleUserInfo->id,\n                'workspace_id' => $workspaceId,\n            ],\n            [\n                'user_id' => auth()->id(),\n                'workspace_id' => $workspaceId,\n                'email' => $googleUserInfo->email,\n                'name' => $googleUserInfo->name,\n                'avatar' => $googleUserInfo->picture,\n                'hosted_domain' => $googleUserInfo->hd,\n                'token' => $accessToken['access_token'],\n                'refresh_token' => $accessToken['refresh_token'] ?? null,\n                'token_expires_at' => now()->addSeconds($accessToken['expires_in']),\n                'status' => AccountStatus::CONNECTED,\n                'permission_type' => $permissionType,\n                'booking_method' => $bookingMethod,\n            ]\n        );\n    }\n\n\n    /**\n     * Determine if a Google account is personal or business\n     */\n    public function isGoogleBusiness(GoogleAccount $account): bool\n    {\n        $this->ensureAuthenticated($account);\n\n        try {\n            $googleService = new Oauth2($this->client);\n            $googleUserInfo = $googleService->userinfo->get();\n\n            // Check if it's a Gmail account\n            $isGmail = str_ends_with(strtolower($googleUserInfo->email), '@gmail.com') ||\n                str_ends_with(strtolower($googleUserInfo->email), '@googlemail.com');\n\n            // If it's not Gmail and has a hosted domain, it's a business account\n            return !$isGmail && isset($googleUserInfo->hd);\n        } catch (\\Exception $e) {\n            logger()->error('Error checking Google account type', [\n                'error' => $e->getMessage(),\n                'trace' => $e->getTraceAsString(),\n            ]);\n            return false;\n        }\n    }\n\n    /**\n     * Generate Google OAuth URL for authentication.\n     *\n     * @param PermissionType $permissionType\n     * @return string\n     */\n    public function getAuthUrl(PermissionType $permissionType = PermissionType::READ): string\n    {\n        $scopes = [\n            Oauth2::USERINFO_EMAIL,\n            Oauth2::USERINFO_PROFILE,\n            Directory::ADMIN_DIRECTORY_RESOURCE_CALENDAR_READONLY,\n        ];\n\n        if ($permissionType === PermissionType::WRITE) {\n            $scopes[] = GoogleCalendar::CALENDAR_EVENTS;\n            $scopes[] = GoogleCalendar::CALENDAR_READONLY;\n        } else {\n            $scopes[] = GoogleCalendar::CALENDAR_EVENTS_READONLY;\n            $scopes[] = GoogleCalendar::CALENDAR_READONLY;\n        }\n\n        $this->client->setScopes($scopes);\n\n        return $this->client->createAuthUrl();\n    }\n\n    private function ensureAuthenticated(GoogleAccount $account): void\n    {\n        if (!$account->token) {\n            throw new Exception('Google account has no token.');\n        }\n\n        // Set the access token for API requests\n        $this->client->setAccessToken($account->token);\n\n        // Refresh the token if expired\n        if ($this->client->isAccessTokenExpired()) {\n            $this->refreshToken($account);\n        }\n    }\n\n    private function refreshToken(GoogleAccount $account): void\n    {\n        $this->client->setAccessToken($account->token);\n\n        $tokenData = $this->client->fetchAccessTokenWithRefreshToken($account->refresh_token);\n        if (Arr::exists($tokenData, 'error')) {\n            throw new Exception('Error authenticating with Google: ' . Arr::get($tokenData, 'error'));\n        }\n\n        $account->update([\n            'token' => $tokenData['access_token'],\n            'refresh_token' => $tokenData['refresh_token'] ?? $account->refresh_token,\n            'token_expires_at' => now()->addSeconds($tokenData['expires_in']),\n        ]);\n    }\n\n    public function fetchCalendars(GoogleAccount $account): array\n    {\n        $this->ensureAuthenticated($account);\n\n        $service = new GoogleCalendar($this->client);\n        $calendarList = $service->calendarList->listCalendarList();\n\n        return $calendarList->getItems();\n    }\n\n    public function fetchRooms(GoogleAccount $account): array\n    {\n        $this->ensureAuthenticated($account);\n\n        $service = new Directory($this->client);\n        $customerId = 'my_customer'; // Default customer ID for the current domain\n\n        $results = $service->resources_calendars->listResourcesCalendars($customerId);\n        return $results->getItems();\n    }\n\n    /**\n     * @throws Exception\n     */\n    public function fetchEvents(\n        GoogleAccount $googleAccount,\n        string $calendarId,\n        Carbon $startDateTime,\n        Carbon $endDateTime\n    ): array {\n        $this->ensureAuthenticated($googleAccount);\n\n        $calendarService = new GoogleCalendar($this->client);\n        $events = $calendarService->events->listEvents($calendarId, [\n            'timeMin' => $startDateTime->toRfc3339String(),\n            'timeMax' => $endDateTime->toRfc3339String(),\n            'maxResults' => 100,\n            'singleEvents' => true,\n            'showDeleted' => false,\n            'orderBy' => 'startTime'\n        ]);\n\n        return $events->getItems();\n    }\n\n    /**\n     * Create an event in Google Calendar.\n     *\n     * @param GoogleAccount $googleAccount\n     * @param Calendar $calendar\n     * @param string $summary\n     * @param Carbon $start\n     * @param Carbon $end\n     * @return GoogleEvent|null\n     * @throws Exception\n     */\n    public function createEvent(\n        GoogleAccount $googleAccount,\n        Calendar $calendar,\n        string $summary,\n        Carbon $start,\n        Carbon $end\n    ): ?GoogleEvent {\n        $event = new GoogleEvent();\n        $event->setSummary($summary);\n\n        $startDateTime = new \\Google\\Service\\Calendar\\EventDateTime();\n        $startDateTime->setDateTime($start->toRfc3339String());\n        $startDateTime->setTimeZone($start->timezone->getName());\n        $event->setStart($startDateTime);\n\n        $endDateTime = new \\Google\\Service\\Calendar\\EventDateTime();\n        $endDateTime->setDateTime($end->toRfc3339String());\n        $endDateTime->setTimeZone($end->timezone->getName());\n        $event->setEnd($endDateTime);\n\n        // Get booking method, defaulting to USER_ACCOUNT if null\n        $bookingMethod = $googleAccount->booking_method ?? GoogleBookingMethod::USER_ACCOUNT;\n\n        // For workspace accounts with room resources and service account booking method, write directly to room calendar\n        if ($calendar->room && $googleAccount->isBusiness() && \n            $bookingMethod === GoogleBookingMethod::SERVICE_ACCOUNT && \n            $googleAccount->service_account_file_path) {\n            return $this->createRoomEventWithServiceAccount($googleAccount, $calendar, $event);\n        }\n\n        // Fall back to user OAuth method (current account booking method or personal accounts)\n        $this->ensureAuthenticated($googleAccount);\n        $calendarService = new GoogleCalendar($this->client);\n\n        $calendarId = $calendar->room ? 'primary' : $calendar->calendar_id;\n\n        // For room resources with current account method, create event on user's primary calendar and add room as attendee\n        // Room resource calendars are read-only and cannot be written to directly without service account\n        if ($calendar->room) {\n            $attendee = new \\Google\\Service\\Calendar\\EventAttendee();\n            $attendee->setEmail($calendar->calendar_id);\n            $event->setAttendees([$attendee]);\n        }\n\n        try {\n            $createdEvent = $calendarService->events->insert($calendarId, $event, [\n                'sendUpdates' => 'none'\n            ]);\n            return $createdEvent;\n        } catch (\\Exception $e) {\n            throw new Exception('Failed to create Google event: ' . $e->getMessage());\n        }\n    }\n\n    /**\n     * Delete an event from Google Calendar.\n     *\n     * @param GoogleAccount $googleAccount\n     * @param Calendar $calendar\n     * @param string $eventId\n     * @return void\n     * @throws Exception\n     */\n    public function deleteEvent(\n        GoogleAccount $googleAccount,\n        Calendar $calendar,\n        string $eventId\n    ): void {\n        // Get booking method, defaulting to USER_ACCOUNT if null\n        $bookingMethod = $googleAccount->booking_method ?? GoogleBookingMethod::USER_ACCOUNT;\n\n        // For workspace accounts with room resources and service account booking method, delete directly from room calendar\n        if ($calendar->room && $googleAccount->isBusiness() && \n            $bookingMethod === GoogleBookingMethod::SERVICE_ACCOUNT && \n            $googleAccount->service_account_file_path) {\n            $this->deleteRoomEventWithServiceAccount($googleAccount, $calendar, $eventId);\n            return;\n        }\n\n        // Fall back to user OAuth method (current account booking method or personal accounts)\n        $this->ensureAuthenticated($googleAccount);\n        $calendarService = new GoogleCalendar($this->client);\n\n        try {\n            if ($calendar->room) {\n                $this->deleteRoomEvent($calendarService, $calendar, $eventId);\n            } else {\n                $calendarService->events->delete($calendar->calendar_id, $eventId, [\n                    'sendUpdates' => 'none'\n                ]);\n            }\n        } catch (\\Exception $e) {\n            throw new Exception('Failed to delete Google event: ' . $e->getMessage());\n        }\n    }\n\n    /**\n     * Delete an event for a room resource.\n     * For room resources, events are created on the user's primary calendar,\n     * but the eventId we receive is from the room's calendar (from fetchEvents).\n     *\n     * @param GoogleCalendar $calendarService\n     * @param Calendar $calendar\n     * @param string $eventId\n     * @return void\n     * @throws Exception\n     */\n    private function deleteRoomEvent(\n        GoogleCalendar $calendarService,\n        Calendar $calendar,\n        string $eventId\n    ): void {\n        // Try deleting from primary calendar first (where we created it)\n        try {\n            $calendarService->events->delete('primary', $eventId, [\n                'sendUpdates' => 'none'\n            ]);\n        } catch (\\Exception $e) {\n            // If that fails, try deleting from the room calendar\n            // The event might have a different ID on the room calendar\n            $calendarService->events->delete($calendar->calendar_id, $eventId, [\n                'sendUpdates' => 'none'\n            ]);\n        }\n    }\n\n    /**\n     * Create a webhook subscription for Google Calendar events.\n     *\n     * @param GoogleAccount $googleAccount\n     * @param Display $display\n     * @param string $calendarId\n     * @return EventSubscription|null\n     * @throws Exception\n     */\n    public function createEventSubscription(\n        GoogleAccount $googleAccount,\n        Display $display,\n        string $calendarId\n    ): ?EventSubscription {\n        $this->ensureAuthenticated($googleAccount);\n\n        $calendarService = new GoogleCalendar($this->client);\n\n        try {\n            $channel = new Channel();\n            $channel->setId(str()->uuid());\n            $channel->setType('web_hook');\n            $channel->setAddress(config('services.google.webhook_url'));\n            $channel->setExpiration(now()->addDays(3)->getTimestampMs());\n\n            $response = $calendarService->events->watch($calendarId, $channel);\n\n            if (!$response->getId()) {\n                logger()->error('Creating Google subscription failed - no subscription ID returned', [\n                    'response' => $response,\n                ]);\n                // This is likely a user error (invalid calendar, permissions, etc.)\n                throw new Exception(\"Failed to create Google subscription: No subscription ID returned\");\n            }\n\n            // Create the subscription record in the database\n            $eventSubscription = EventSubscription::create([\n                'subscription_id' => $response->getId(),\n                'resource' => $calendarId,\n                'expiration' => Carbon::createFromTimestampMs($response->getExpiration()),\n                'notification_url' => config('services.google.webhook_url'),\n                'display_id' => $display->id,\n                'google_account_id' => $googleAccount->id,\n            ]);\n\n            // Log the creation for debugging\n            logger()->info('Google subscription created', ['subscription' => $response]);\n\n            return $eventSubscription;\n        } catch (Exception $e) {\n            // Re-throw if it's already a user error exception we just created\n            if (str_contains($e->getMessage(), 'Failed to create Google subscription')) {\n                throw $e;\n            }\n            \n            // Check if this is a Google API exception with HTTP status code\n            $statusCode = $e->getCode();\n            $isUserError = $statusCode >= 400 && $statusCode < 500;\n            \n            // Check exception class name for Google API exceptions\n            $exceptionClass = get_class($e);\n            \n            logger()->error('Error creating Google subscription', [\n                'error' => $e->getMessage(),\n                'calendarId' => $calendarId,\n                'status_code' => $statusCode,\n                'is_user_error' => $isUserError,\n                'exception_type' => $exceptionClass,\n            ]);\n            \n            // Throw exception for user errors (4xx) so the command can handle it\n            // Return null for server errors (5xx) or connection errors to avoid marking display as error\n            if ($isUserError) {\n                throw new Exception(\"Failed to create Google subscription: HTTP {$statusCode} - \" . $e->getMessage());\n            }\n            \n            // For connection errors, timeouts, etc., don't throw - these are transient\n            return null;\n        }\n    }\n\n    /**\n     * Delete a webhook subscription for Google Calendar events.\n     *\n     * @param GoogleAccount $googleAccount\n     * @param EventSubscription $eventSubscription\n     * @param bool $useApi\n     * @return void\n     * @throws Exception\n     */\n    public function deleteEventSubscription(\n        GoogleAccount $googleAccount,\n        EventSubscription $eventSubscription,\n        bool $useApi = true\n    ): void {\n        if ($useApi) {\n            $this->ensureAuthenticated($googleAccount);\n\n            try {\n                $calendarService = new GoogleCalendar($this->client);\n                $channel = new Channel();\n                $channel->setId($eventSubscription->subscription_id);\n                $channel->setResourceId($eventSubscription->resource);\n\n                $calendarService->channels->stop($channel);\n            } catch (Exception $e) {\n                report($e);\n                logger()->error('Error stopping Google subscription', [\n                    'error' => $e->getMessage(),\n                    'subscriptionId' => $eventSubscription->subscription_id\n                ]);\n            }\n        }\n\n        // Delete the subscription record from the database\n        $eventSubscription->delete();\n\n        // Log the deletion for debugging\n        logger()->info('Google subscription deleted', ['subscriptionId' => $eventSubscription->id]);\n    }\n\n    /**\n     * Create a Google Calendar client authenticated with service account.\n     *\n     * @param GoogleAccount $googleAccount\n     * @return Client\n     * @throws Exception\n     */\n    private function getServiceAccountClient(GoogleAccount $googleAccount): Client\n    {\n        if (!$googleAccount->service_account_file_path) {\n            throw new Exception('Service account file path not set for Google account.');\n        }\n\n        if (!Storage::exists($googleAccount->service_account_file_path)) {\n            throw new Exception('Service account file not found: ' . $googleAccount->service_account_file_path);\n        }\n\n        // Read and decrypt the encrypted service account file\n        $encryptedContent = Storage::get($googleAccount->service_account_file_path);\n        $decryptedContent = Crypt::decryptString($encryptedContent);\n        \n        // Parse the JSON content\n        $serviceAccountData = json_decode($decryptedContent, true);\n        if (!$serviceAccountData) {\n            throw new Exception('Invalid service account JSON file.');\n        }\n\n        $client = new Client();\n        // setAuthConfig() can accept either a file path or an array\n        // Using array avoids creating temporary files with sensitive data\n        $client->setAuthConfig($serviceAccountData);\n        \n        $scopes = [\n            GoogleCalendar::CALENDAR_READONLY,\n            GoogleCalendar::CALENDAR_EVENTS,\n        ];\n        \n        $client->setScopes($scopes);\n        \n        // For domain-wide delegation, impersonate the user who owns the Google account\n        // This allows the service account to access resources on behalf of the user\n        if ($googleAccount->email) {\n            $client->setSubject($googleAccount->email);\n        }\n\n        return $client;\n    }\n\n    /**\n     * Create an event directly on a room resource calendar using service account.\n     * This allows booking rooms without using a user's calendar for workspace accounts.\n     *\n     * @param GoogleAccount $googleAccount\n     * @param Calendar $calendar\n     * @param GoogleEvent $event\n     * @return GoogleEvent\n     * @throws Exception\n     */\n    private function createRoomEventWithServiceAccount(\n        GoogleAccount $googleAccount,\n        Calendar $calendar,\n        GoogleEvent $event\n    ): GoogleEvent {\n        $client = $this->getServiceAccountClient($googleAccount);\n        $calendarService = new GoogleCalendar($client);\n\n        try {\n            // With service account and proper permissions, we can write directly to room calendars\n            $createdEvent = $calendarService->events->insert($calendar->calendar_id, $event, [\n                'sendUpdates' => 'none'\n            ]);\n            return $createdEvent;\n        } catch (\\Exception $e) {\n            throw new Exception('Failed to create Google room event with service account: ' . $e->getMessage());\n        }\n    }\n\n    /**\n     * Delete an event directly from a room resource calendar using service account.\n     *\n     * @param GoogleAccount $googleAccount\n     * @param Calendar $calendar\n     * @param string $eventId\n     * @return void\n     * @throws Exception\n     */\n    private function deleteRoomEventWithServiceAccount(\n        GoogleAccount $googleAccount,\n        Calendar $calendar,\n        string $eventId\n    ): void {\n        $client = $this->getServiceAccountClient($googleAccount);\n        $calendarService = new GoogleCalendar($client);\n\n        try {\n            // With service account and proper permissions, we can delete directly from room calendars\n            $calendarService->events->delete($calendar->calendar_id, $eventId, [\n                'sendUpdates' => 'none'\n            ]);\n        } catch (\\Exception $e) {\n            throw new Exception('Failed to delete Google room event with service account: ' . $e->getMessage());\n        }\n    }\n}\n"
  },
  {
    "path": "backend/app/Services/ImageService.php",
    "content": "<?php\n\nnamespace App\\Services;\n\nuse App\\Models\\Display;\nuse App\\Helpers\\DisplaySettings;\nuse Illuminate\\Support\\Facades\\Storage;\n\nclass ImageService\n{\n    /**\n     * Available default background images\n     */\n    public const DEFAULT_BACKGROUNDS = [\n        'default_1' => 'images/backgrounds/default_1.jpg',\n        'default_2' => 'images/backgrounds/default_2.jpg',\n        'default_3' => 'images/backgrounds/default_3.jpg',\n        'default_4' => 'images/backgrounds/default_4.jpg',\n        'default_5' => 'images/backgrounds/default_5.jpg',\n        'default_6' => 'images/backgrounds/default_6.jpg',\n        'default_7' => 'images/backgrounds/default_7.jpg',\n        'default_8' => 'images/backgrounds/default_8.jpg',\n    ];\n\n    /**\n     * Get all available default backgrounds\n     */\n    public function getDefaultBackgrounds(): array\n    {\n        return array_map(function ($path, $key) {\n            return [\n                'key' => $key,\n                'url' => asset($path),\n                'path' => $path,\n            ];\n        }, self::DEFAULT_BACKGROUNDS, array_keys(self::DEFAULT_BACKGROUNDS));\n    }\n    /**\n     * Get the logo URL for a display\n     */\n    public function getLogoUrl(Display $display): ?string\n    {\n        $logo = DisplaySettings::getLogo($display);\n        if (!$logo) {\n            return null;\n        }\n\n        // Add version parameter based on when logo was last updated\n        $version = $this->getImageVersion($display, 'logo');\n        return url('api/displays/' . $display->id . '/images/logo') . '?v=' . $version;\n    }\n\n    /**\n     * Get the background image URL for a display\n     */\n    public function getBackgroundImageUrl(Display $display): ?string\n    {\n        $background = DisplaySettings::getBackgroundImage($display);\n        if (!$background) {\n            return null;\n        }\n\n        // Check if it's a default background - if so, return the direct asset URL\n        if (isset(self::DEFAULT_BACKGROUNDS[$background])) {\n            return asset(self::DEFAULT_BACKGROUNDS[$background]);\n        }\n\n        // Add version parameter based on when background was last updated\n        $version = $this->getImageVersion($display, 'background');\n        return url('api/displays/' . $display->id . '/images/background') . '?v=' . $version;\n    }\n\n    /**\n     * Get image version based on file modification time or fallback to display updated_at\n     */\n    private function getImageVersion(Display $display, string $type): string\n    {\n        $imagePath = $type === 'logo'\n            ? DisplaySettings::getLogo($display)\n            : DisplaySettings::getBackgroundImage($display);\n\n        if ($imagePath && Storage::disk('public')->exists($imagePath)) {\n            // Use file modification time as version\n            return (string) Storage::disk('public')->lastModified($imagePath);\n        }\n\n        // Fallback to display updated_at timestamp\n        return $display->updated_at->timestamp;\n    }\n\n    /**\n     * Serve a display image (logo or background)\n     */\n    public function serveImage(Display $display, string $type)\n    {\n        if ($type === 'logo') {\n            $imagePath = DisplaySettings::getLogo($display);\n        } elseif ($type === 'background') {\n            $imagePath = DisplaySettings::getBackgroundImage($display);\n\n            // Check if it's a default background\n            if ($imagePath && isset(self::DEFAULT_BACKGROUNDS[$imagePath])) {\n                $publicPath = public_path(self::DEFAULT_BACKGROUNDS[$imagePath]);\n                if (file_exists($publicPath)) {\n                    return response()->file($publicPath);\n                }\n            }\n        } else {\n            abort(404, 'Invalid image type');\n        }\n\n        if (!$imagePath || !Storage::disk('public')->exists($imagePath)) {\n            abort(404, 'Image not found');\n        }\n\n        return response()->file(Storage::disk('public')->path($imagePath));\n    }\n\n    /**\n     * Store a logo file and return the path\n     */\n    public function storeLogoFile($file, Display $display): ?string\n    {\n        try {\n            $filename = 'logo_' . $display->id . '_' . time() . '.' . $file->getClientOriginalExtension();\n            $path = $file->storeAs('displays/logos', $filename, 'public');\n            return $path;\n        } catch (\\Exception $e) {\n            return null;\n        }\n    }\n\n    /**\n     * Store a background image file and return the path\n     */\n    public function storeBackgroundImageFile($file, Display $display): ?string\n    {\n        try {\n            $filename = 'background_' . $display->id . '_' . time() . '.' . $file->getClientOriginalExtension();\n            $path = $file->storeAs('displays/backgrounds', $filename, 'public');\n            return $path;\n        } catch (\\Exception $e) {\n            return null;\n        }\n    }\n\n    /**\n     * Remove logo file from storage\n     */\n    public function removeLogoFile(Display $display): void\n    {\n        $currentLogo = DisplaySettings::getLogo($display);\n        if ($currentLogo && Storage::disk('public')->exists($currentLogo)) {\n            Storage::disk('public')->delete($currentLogo);\n        }\n    }\n\n    /**\n     * Remove background image file from storage\n     */\n    public function removeBackgroundImageFile(Display $display): void\n    {\n        $currentBackground = DisplaySettings::getBackgroundImage($display);\n        if ($currentBackground && Storage::disk('public')->exists($currentBackground)) {\n            Storage::disk('public')->delete($currentBackground);\n        }\n    }\n\n    /**\n     * Get the logo URL for a board\n     */\n    public function getBoardLogoUrl(\\App\\Models\\Board $board): ?string\n    {\n        if (!$board->logo) {\n            return null;\n        }\n\n        // Add version parameter based on when logo was last updated\n        $version = $board->updated_at->timestamp;\n        return url('boards/' . $board->id . '/images/logo') . '?v=' . $version;\n    }\n\n    /**\n     * Store a logo file for a board and return the path\n     */\n    public function storeBoardLogoFile($file, \\App\\Models\\Board $board): ?string\n    {\n        try {\n            $filename = 'logo_' . $board->id . '_' . time() . '.' . $file->getClientOriginalExtension();\n            $path = $file->storeAs('boards/logos', $filename, 'public');\n            return $path;\n        } catch (\\Exception $e) {\n            return null;\n        }\n    }\n\n    /**\n     * Remove logo file from storage for a board\n     */\n    public function removeBoardLogoFile(\\App\\Models\\Board $board): void\n    {\n        if ($board->logo && Storage::disk('public')->exists($board->logo)) {\n            Storage::disk('public')->delete($board->logo);\n        }\n    }\n\n    /**\n     * Serve board logo image\n     */\n    public function serveBoardLogo(\\App\\Models\\Board $board)\n    {\n        if (!$board->logo || !Storage::disk('public')->exists($board->logo)) {\n            abort(404, 'Logo not found');\n        }\n\n        return response()->file(Storage::disk('public')->path($board->logo));\n    }\n}\n"
  },
  {
    "path": "backend/app/Services/InstanceService.php",
    "content": "<?php\n\nnamespace App\\Services;\n\nuse App\\Data\\LicenseData;\nuse App\\Data\\UserData;\nuse App\\Models\\Board;\nuse App\\Models\\Display;\nuse App\\Models\\Room;\nuse App\\Models\\User;\nuse App\\Data\\InstanceData;\nuse App\\Helpers\\Settings;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Str;\n\nclass InstanceService\n{\n    private const SETTING_PREFIX = 'instance_data';\n\n    public static function hasValidLicense(): bool\n    {\n        $licenseKey = self::getInstanceVariable('license_key');\n        $licenseValid = self::getInstanceVariable('license_valid');\n        if (! $licenseKey || ! $licenseValid) {\n            return false;\n        }\n\n        $expiresAt = self::getInstanceVariable('license_expires_at');\n        if ($expiresAt && Carbon::parse($expiresAt)->isPast()) {\n            return false;\n        }\n\n        return true;\n    }\n\n    public static function hasLicense(): bool\n    {\n        return self::getInstanceVariable('license_key') !== null;\n    }\n\n    public static function updateLicense(LicenseData $data): bool\n    {\n        $updatedLicense = self::storeInstanceVariable('license_key', $data->licenseKey) &&\n            self::storeInstanceVariable('license_valid', $data->valid);\n\n        if ($data->expiresAt && ! self::storeInstanceVariable('license_expires_at', $data->expiresAt->toDateTimeString())) {\n            return false;\n        }\n\n        return $updatedLicense;\n    }\n\n    public static function storeInstanceVariable(string $key, ?string $value): bool\n    {\n        try {\n            $key = self::getSettingKey($key);\n\n            if (is_null($value)) {\n                return Settings::deleteSetting($key);\n            }\n\n            return Settings::setSetting($key, $value);\n        } catch (\\Exception $e) {\n            report($e);\n            return false;\n        }\n    }\n\n    public static function getInstanceVariable(string $key, mixed $default = null): mixed\n    {\n        try {\n            return Settings::getSetting(self::getSettingKey($key), $default);\n        } catch (\\Exception $e) {\n            report($e);\n            return $default;\n        }\n    }\n\n    private static function getInstanceKey(): string\n    {\n        $instanceKey = self::getInstanceVariable('instance_key');\n\n        // Generate and set a new key when non existant\n        if (is_null($instanceKey)) {\n            $instanceKey = self::generateInstanceKey();\n            self::storeInstanceVariable('instance_key', $instanceKey);\n        }\n\n        return $instanceKey;\n    }\n\n    private static function generateInstanceKey(): string\n    {\n        return sha1(Str::ulid()->toString());\n    }\n\n    private static function getSettingKey(string $key): string\n    {\n        return self::SETTING_PREFIX . '_' . Str::snake($key);\n    }\n\n    public static function getInstanceData(): InstanceData\n    {\n        $instanceKey = self::getInstanceKey();\n\n        $users = User::all()->map(function ($user) {\n            return new UserData(\n                email: $user->email,\n                usageType: $user->usage_type?->value,\n                isUnlimited: $user->is_unlimited,\n                termsAcceptedAt: $user->terms_accepted_at,\n            );\n        });\n\n        $version = config('settings.version');\n        $licenseExpiresAt = self::getInstanceVariable('license_expires_at');\n        return new InstanceData(\n            instanceKey: $instanceKey,\n            licenseKey: self::getInstanceVariable('license_key'),\n            licenseValid: self::getInstanceVariable('license_valid'),\n            licenseExpiresAt: $licenseExpiresAt ? Carbon::parse($licenseExpiresAt) : null,\n            isSelfHosted: config('settings.is_self_hosted'),\n            displaysCount: Display::count(),\n            roomsCount: Room::count(),\n            boardsCount: Board::count(),\n            version: ! empty($version) ? $version : 'unknown',\n            users: $users->toArray()\n        );\n    }\n}\n"
  },
  {
    "path": "backend/app/Services/OutlookService.php",
    "content": "<?php\n\nnamespace App\\Services;\n\nuse App\\Enums\\AccountStatus;\nuse App\\Enums\\PermissionType;\nuse App\\Models\\Calendar;\nuse App\\Models\\Display;\nuse App\\Models\\EventSubscription;\nuse App\\Models\\OutlookAccount;\nuse Exception;\nuse Illuminate\\Support\\Arr;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\Http;\n\nclass OutlookService\n{\n    const OAUTH_SCOPES_READ = 'openid email profile offline_access User.Read Calendars.Read.Shared Place.Read.All';\n    const OAUTH_SCOPES_WRITE = 'openid email profile offline_access User.Read Calendars.ReadWrite.Shared Calendars.Read.Shared Place.Read.All';\n    protected mixed $clientId;\n    protected mixed $clientSecret;\n    protected mixed $redirectUri;\n    protected mixed $tenantId;\n\n    public function __construct()\n    {\n        $this->clientId = config('services.azure_ad.client_id');\n        $this->clientSecret = config('services.azure_ad.client_secret');\n        $this->redirectUri = config('services.azure_ad.redirect');\n        $this->tenantId = config('services.azure_ad.tenant_id');\n    }\n\n    /**\n     * Get the access token for Google Calendar API\n     * @throws \\Exception\n     */\n    private function ensureAuthenticated(&$outlookAccount): void\n    {\n        if (now()->lte($outlookAccount->token_expires_at)) {\n            return;\n        }\n\n        // Set the access token for API requests\n        $this->refreshToken($outlookAccount);\n    }\n\n    /**\n     * Generate Outlook OAuth URL for authentication.\n     *\n     * @param PermissionType $permissionType 'read' or 'write', or PermissionType enum\n     * @return string\n     */\n    public function getAuthUrl(PermissionType $permissionType = PermissionType::READ): string\n    {\n        $oauthEndpoint = \"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/authorize\";\n\n        $scopes = $permissionType === PermissionType::WRITE ? self::OAUTH_SCOPES_WRITE : self::OAUTH_SCOPES_READ;\n\n        $params = [\n            'client_id' => $this->clientId,\n            'response_type' => 'code',\n            'redirect_uri' => $this->redirectUri,\n            'response_mode' => 'query',\n            'scope' => $scopes,\n            'state' => csrf_token(),\n        ];\n\n        return $oauthEndpoint . '?' . http_build_query($params);\n    }\n\n    /**\n     * Handle Outlook OAuth callback and store tokens in the database.\n     *\n     * @param string $authCode\n     * @param string|PermissionType $permissionType 'read' or 'write', or PermissionType enum\n     * @return OutlookAccount\n     * @throws \\Exception\n     */\n    public function authenticateOutlookAccount(string $authCode, string|PermissionType $permissionType = PermissionType::READ): OutlookAccount\n    {\n        $oauthTokenEndpoint = \"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token\";\n\n        // Convert string to enum if needed\n        if (is_string($permissionType)) {\n            $permissionType = PermissionType::from($permissionType);\n        }\n\n        $scopes = $permissionType === PermissionType::WRITE ? self::OAUTH_SCOPES_WRITE : self::OAUTH_SCOPES_READ;\n\n        // Exchange authorization code for tokens\n        $response = Http::asForm()->post($oauthTokenEndpoint, [\n            'client_id' => $this->clientId,\n            'scope' => $scopes,\n            'code' => $authCode,\n            'redirect_uri' => $this->redirectUri,\n            'grant_type' => 'authorization_code',\n            'client_secret' => $this->clientSecret,\n        ]);\n\n        $tokenData = $response->json();\n        if (Arr::exists($tokenData, 'error')) {\n            throw new Exception('Error authenticating with Outlook: ' . Arr::get($tokenData, 'error.message'));\n        }\n\n        // Get the current user information\n        $response = Http::acceptJson()\n            ->withToken($tokenData['access_token'])\n            ->get('https://graph.microsoft.com/v1.0/me');\n\n        $user = $response->json();\n\n        $tenantId = $this->getTenantId($tokenData['access_token']);\n\n        // Get selected workspace (from session or default to primary)\n        $selectedWorkspace = auth()->user()->getSelectedWorkspace();\n        $workspaceId = $selectedWorkspace?->id;\n\n        // Save the Outlook account and tokens\n        return OutlookAccount::updateOrCreate(\n            [\n                'user_id' => auth()->id(),\n                'outlook_id' => $user['id'],\n                'workspace_id' => $workspaceId,\n            ],\n            [\n                'user_id' => auth()->id(),\n                'workspace_id' => $workspaceId,\n                'email' => $user['mail'] ?? $user['userPrincipalName'],\n                'name' => $user['displayName'],\n                'tenant_id' => $tenantId,\n                'permission_type' => $permissionType->value,\n                'token' => $tokenData['access_token'],\n                'refresh_token' => $tokenData['refresh_token'] ?? null,\n                'token_expires_at' => now()->addSeconds($tokenData['expires_in']),\n                'status' => AccountStatus::CONNECTED,\n            ]\n        );\n    }\n\n    public function getTenantId(string $token): ?string\n    {\n        try {\n            $response = Http::withToken($token)\n                ->get('https://graph.microsoft.com/v1.0/organization');\n\n            if (!$response->successful()) {\n                logger()->error('Failed to fetch Microsoft user info', [\n                    'status' => $response->status(),\n                    'response' => $response->json(),\n                ]);\n                return null;\n            }\n\n            $data = Arr::get($response->json(), 'value') ?? [];\n            return Arr::get($data, '0.id');\n        } catch (\\Exception $e) {\n            report($e);\n            return null;\n        }\n    }\n\n    /**\n     * Refresh Outlook access token.\n     *\n     * @param OutlookAccount $outlookAccount\n     * @return void\n     * @throws \\Exception\n     */\n    protected function refreshToken(OutlookAccount &$outlookAccount): void\n    {\n        $oauthTokenEndpoint = \"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token\";\n\n        $scopes = $outlookAccount->permission_type === PermissionType::WRITE ? self::OAUTH_SCOPES_WRITE : self::OAUTH_SCOPES_READ;\n\n        $response = Http::asForm()->post($oauthTokenEndpoint, [\n            'client_id' => $this->clientId,\n            'scope' => $scopes,\n            'refresh_token' => $outlookAccount->refresh_token,\n            'grant_type' => 'refresh_token',\n            'client_secret' => $this->clientSecret,\n        ]);\n\n        $tokenData = $response->json();\n\n        if (Arr::exists($tokenData, 'error')) {\n            $outlookAccount->update([\n                'status' => AccountStatus::ERROR,\n            ]);\n            throw new Exception('Error refreshing Outlook token: ' . Arr::get($tokenData, 'error.message'));\n        }\n\n        $outlookAccount->update([\n            'token' => $tokenData['access_token'],\n            'token_expires_at' => now()->addSeconds($tokenData['expires_in'])->subSeconds(5),\n            'refresh_token' => $tokenData['refresh_token'] ?? $outlookAccount->refresh_token,\n        ]);\n    }\n\n    /**\n     * Fetch calendar events from Outlook account.\n     *\n     * @param OutlookAccount $outlookAccount\n     * @param string $emailAddress\n     * @param Carbon $startDateTime\n     * @param Carbon $endDateTime\n     * @return mixed\n     * @throws \\Exception\n     */\n    public function fetchEventsByUser(\n        OutlookAccount $outlookAccount,\n        string $emailAddress,\n        Carbon $startDateTime,\n        Carbon $endDateTime,\n    ): array {\n        $this->ensureAuthenticated($outlookAccount);\n\n        $params = [\n            'startDateTime' => $startDateTime->toIso8601String(),\n            'endDateTime' => $endDateTime->toIso8601String(),\n            '$select' => 'id,lastModifiedDateTime,subject,body,bodyPreview,isAllDay,location,start,end',\n            '$orderby' => 'createdDateTime',\n            '$top' => 100\n        ];\n\n        $response = Http::withToken($outlookAccount->token)\n            ->get(\"https://graph.microsoft.com/v1.0/users/$emailAddress/calendarview\", $params);\n\n        return Arr::get($response->json(), 'value') ?? [];\n    }\n\n    /**\n     * Fetch calendar events from Outlook account.\n     *\n     * @param OutlookAccount $outlookAccount\n     * @param string $calendarId\n     * @param Carbon $startDateTime\n     * @param Carbon $endDateTime\n     * @return mixed\n     * @throws \\Exception\n     */\n    public function fetchEventsByCalendar(\n        OutlookAccount $outlookAccount,\n        string $calendarId,\n        Carbon $startDateTime,\n        Carbon $endDateTime,\n    ): array {\n        $this->ensureAuthenticated($outlookAccount);\n\n        $params = [\n            'startDateTime' => $startDateTime->toIso8601String(),\n            'endDateTime' => $endDateTime->toIso8601String(),\n            '$select' => 'id,lastModifiedDateTime,subject,body,bodyPreview,isAllDay,location,start,end',\n            '$orderby' => 'createdDateTime',\n            '$top' => 100\n        ];\n\n        $response = Http::withToken($outlookAccount->token)\n            ->get(\"https://graph.microsoft.com/v1.0/me/calendars/$calendarId/calendarview\", $params);\n\n        return Arr::get($response->json(), 'value') ?? [];\n    }\n\n    /**\n     * Fetch calendars from the authenticated user's Outlook account.\n     *\n     * @param OutlookAccount $outlookAccount\n     * @return mixed\n     * @throws \\Exception\n     */\n    public function fetchCalendars(OutlookAccount $outlookAccount): mixed\n    {\n        $this->ensureAuthenticated($outlookAccount);\n\n        // Get the current user information\n        $response = Http::acceptJson()->withHeaders([\n            'Authorization' => 'Bearer ' . $outlookAccount->token,\n        ])->get('https://graph.microsoft.com/v1.0/me/calendars');\n\n        return Arr::get($response->json(), 'value');\n    }\n\n    /**\n     * Fetch rooms from the authenticated user's Outlook account.\n     *\n     * @param OutlookAccount $outlookAccount\n     * @return mixed\n     * @throws \\Exception\n     */\n    public function fetchRooms(OutlookAccount $outlookAccount): mixed\n    {\n        $this->ensureAuthenticated($outlookAccount);\n\n        // Get the current user information\n        $response = Http::acceptJson()->withHeaders([\n            'Authorization' => 'Bearer ' . $outlookAccount->token,\n        ])->get('https://graph.microsoft.com/v1.0/places/microsoft.graph.room');\n\n        return Arr::get($response->json(), 'value');\n    }\n\n    /**\n     * Create an event in Outlook calendar.\n     *\n     * @param OutlookAccount $outlookAccount\n     * @param Calendar $calendar\n     * @param string $summary\n     * @param Carbon $start\n     * @param Carbon $end\n     * @return array|null\n     * @throws \\Exception\n     */\n    public function createEvent(\n        OutlookAccount $outlookAccount,\n        Calendar $calendar,\n        string $summary,\n        Carbon $start,\n        Carbon $end\n    ): ?array {\n        $this->ensureAuthenticated($outlookAccount);\n\n        $eventData = [\n            'subject' => $summary,\n            'start' => [\n                'dateTime' => $start->toIso8601String(),\n                'timeZone' => $start->timezone->getName(),\n            ],\n            'end' => [\n                'dateTime' => $end->toIso8601String(),\n                'timeZone' => $end->timezone->getName(),\n            ],\n        ];\n\n        // Determine the endpoint based on whether it's a room or calendar\n        if ($calendar->room) {\n            // For rooms, use the user's calendar\n            $endpoint = \"https://graph.microsoft.com/v1.0/users/{$calendar->calendar_id}/calendar/events\";\n        } elseif ($calendar->is_primary) {\n            // For primary calendar, use /me/calendar/events (without calendar ID)\n            $endpoint = \"https://graph.microsoft.com/v1.0/me/calendar/events\";\n        } else {\n            // For other calendars, use the calendar ID\n            $endpoint = \"https://graph.microsoft.com/v1.0/me/calendars/{$calendar->calendar_id}/events\";\n        }\n\n        $response = Http::acceptJson()\n            ->withHeaders([\n                'Authorization' => 'Bearer ' . $outlookAccount->token,\n            ])\n            ->post($endpoint, $eventData);\n\n        if (!$response->successful()) {\n            throw new Exception('Failed to create Outlook event: ' . $response->body());\n        }\n\n        return $response->json();\n    }\n\n    /**\n     * Delete an event from Outlook calendar.\n     *\n     * @param OutlookAccount $outlookAccount\n     * @param Calendar $calendar\n     * @param string $eventId\n     * @return void\n     * @throws \\Exception\n     */\n    public function deleteEvent(\n        OutlookAccount $outlookAccount,\n        Calendar $calendar,\n        string $eventId\n    ): void {\n        $this->ensureAuthenticated($outlookAccount);\n\n        // Determine the endpoint based on whether it's a room or calendar\n        if ($calendar->room) {\n            // For rooms, use the user's calendar\n            $endpoint = \"https://graph.microsoft.com/v1.0/users/{$calendar->calendar_id}/calendar/events/{$eventId}\";\n        } elseif ($calendar->is_primary) {\n            // For primary calendar, use /me/calendar/events (without calendar ID)\n            $endpoint = \"https://graph.microsoft.com/v1.0/me/calendar/events/{$eventId}\";\n        } else {\n            // For other calendars, use the calendar ID\n            $endpoint = \"https://graph.microsoft.com/v1.0/me/calendars/{$calendar->calendar_id}/events/{$eventId}\";\n        }\n\n        $response = Http::acceptJson()\n            ->withHeaders([\n                'Authorization' => 'Bearer ' . $outlookAccount->token,\n            ])\n            ->delete($endpoint);\n\n        if (!$response->successful()) {\n            throw new Exception('Failed to delete Outlook event: ' . $response->body());\n        }\n    }\n\n    /**\n     * Create an event subscription for Outlook calendar events.\n     *\n     * @param OutlookAccount $outlookAccount\n     * @param Display $display\n     * @param string $emailAddress\n     * @return EventSubscription|null\n     * @throws \\Exception\n     */\n    public function createEventSubscriptionByUser(\n        OutlookAccount $outlookAccount,\n        Display $display,\n        string $emailAddress\n    ): ?EventSubscription {\n        // Try the standard path first\n        try {\n            return $this->createEventSubscription($outlookAccount, $display, \"/users/$emailAddress/events\");\n        } catch (\\Exception $e) {\n            // If it fails with a resource invalid error, try with /calendar/ path as backup\n            if (str_contains($e->getMessage(), 'Resource') && str_contains($e->getMessage(), 'invalid')) {\n                logger()->warning('Subscription failed with /events path, trying /calendar/events as backup', [\n                    'email' => $emailAddress,\n                    'display_id' => $display->id,\n                    'error' => $e->getMessage(),\n                ]);\n                return $this->createEventSubscription($outlookAccount, $display, \"/users/$emailAddress/calendar/events\");\n            }\n            // Re-throw if it's not a resource invalid error\n            throw $e;\n        }\n    }\n\n    /**\n     * Create an event subscription for Outlook calendar events.\n     *\n     * @param OutlookAccount $outlookAccount\n     * @param Display $display\n     * @param string $calendarId\n     * @return EventSubscription|null\n     * @throws \\Exception\n     */\n    public function createEventSubscriptionByCalendar(\n        OutlookAccount $outlookAccount,\n        Display $display,\n        string $calendarId\n    ): ?EventSubscription {\n        return $this->createEventSubscription($outlookAccount, $display, \"/me/calendars/$calendarId/events\");\n    }\n\n    /**\n     * Create an event subscription for Outlook calendar events.\n     *\n     * @param OutlookAccount $outlookAccount\n     * @param Display $display\n     * @param string $resource\n     * @return EventSubscription|null\n     * @throws \\Exception\n     */\n    private function createEventSubscription(\n        OutlookAccount $outlookAccount,\n        Display $display,\n        string $resource\n    ): ?EventSubscription {\n        $this->ensureAuthenticated($outlookAccount);\n\n        $data = [\n            'resource' => $resource,\n            'changeType' => 'created,updated,deleted',\n            'notificationUrl' => config('services.azure_ad.webhook_url'),\n            'expirationDateTime' => now()->addHours(3)->toISOString(),\n            'includeResourceData' => \"false\",\n        ];\n\n        logger()->info('Creating subscription', [\n            'data' => $data\n        ]);\n\n        try {\n            // Create a subscription with Microsoft Graph\n            $response = Http::withToken($outlookAccount->token)\n                ->post(\"https://graph.microsoft.com/v1.0/subscriptions\", $data);\n\n            $responseBody = $response->json();\n            if (\n                $response->failed() ||\n                !Arr::has($responseBody, ['id', 'resource', 'expirationDateTime', 'notificationUrl'])\n            ) {\n                $statusCode = $response->status();\n                $isUserError = $statusCode >= 400 && $statusCode < 500;\n                \n                logger()->error('Creating outlook subscription failed', [\n                    'statuscode' => $statusCode,\n                    'response' => $responseBody,\n                    'is_user_error' => $isUserError,\n                ]);\n                \n                // Throw exception for user errors (4xx) so the command can handle it\n                // Return null for server errors (5xx) to avoid marking display as error\n                if ($isUserError) {\n                    throw new Exception(\"Failed to create Outlook subscription: HTTP {$statusCode} - \" . ($responseBody['error']['message'] ?? $responseBody['message'] ?? 'Unknown error'));\n                }\n                \n                return null;\n            }\n        } catch (Exception $e) {\n            // Re-throw if it's already a user error exception we just created\n            if (str_contains($e->getMessage(), 'Failed to create Outlook subscription')) {\n                throw $e;\n            }\n            // For connection errors, timeouts, etc., don't throw - these are transient\n            logger()->error('Error creating outlook subscription - connection/timeout error', [\n                'error' => $e->getMessage(),\n                'exception_type' => get_class($e),\n            ]);\n            return null;\n        }\n\n        // Create the subscription record in the database\n        $eventSubscription = EventSubscription::create([\n            'subscription_id' => $responseBody['id'],\n            'resource' => $responseBody['resource'],\n            'expiration' => Carbon::parse($responseBody['expirationDateTime']),\n            'notification_url' => $data['notificationUrl'],\n            'display_id' => $display->id,\n            'outlook_account_id' => $outlookAccount->id,\n        ]);\n\n        // Log the creation for debugging\n        logger()->info('Outlook subscription created', ['subscription' => $responseBody]);\n\n        return $eventSubscription;\n    }\n\n    /**\n     * Delete an event subscription in Outlook.\n     *\n     * @param OutlookAccount $outlookAccount\n     * @param EventSubscription $eventSubscription\n     * @param bool $useApi\n     * @return void\n     * @throws \\Exception\n     */\n    public function deleteEventSubscription(\n        OutlookAccount $outlookAccount,\n        EventSubscription $eventSubscription,\n        bool $useApi = true\n    ): void {\n        // Delete the subscription on Microsoft Graph\n        if ($useApi) {\n            $this->ensureAuthenticated($outlookAccount);\n\n            Http::withToken($outlookAccount->token)\n                ->delete(\"https://graph.microsoft.com/v1.0/subscriptions/{$eventSubscription->subscription_id}\");\n        }\n\n        // Delete the subscription record from the database\n        $eventSubscription->delete();\n\n        // Log the deletion for debugging\n        logger()->info('Outlook subscription deleted', ['subscriptionId' => $eventSubscription->id]);\n    }\n}\n"
  },
  {
    "path": "backend/app/Traits/HasLastActivity.php",
    "content": "<?php\n\nnamespace App\\Traits;\n\ntrait HasLastActivity\n{\n    /**\n     * Update the model's last activity timestamp\n     */\n    public function updateLastActivity(): void\n    {\n        $this->update(['last_activity_at' => now()]);\n    }\n} "
  },
  {
    "path": "backend/app/Traits/HasUlid.php",
    "content": "<?php\n\nnamespace App\\Traits;\n\nuse Illuminate\\Support\\Str;\n\ntrait HasUlid\n{\n    public static function bootHasUlid(): void\n    {\n        static::creating(function ($model) {\n            $model->{$model->getKeyName()} = $model->{$model->getKeyName()} ?: (string) Str::ulid();\n        });\n    }\n\n    public function getIncrementing(): bool\n    {\n        return false;\n    }\n\n    public function getKeyType(): string\n    {\n        return 'string';\n    }\n\n    public function getCasts(): array\n    {\n        return array_merge([$this->getKeyName() => $this->getKeyType()], $this->casts);\n    }\n}\n"
  },
  {
    "path": "backend/app/Traits/RespondsWithApiResponse.php",
    "content": "<?php\n\nnamespace App\\Traits;\n\nuse App\\Data\\ApiResponse;\nuse Illuminate\\Http\\JsonResponse;\n\ntrait RespondsWithApiResponse\n{\n    protected function respond(ApiResponse $response): JsonResponse\n    {\n        return response()->json($response, $response->status);\n    }\n} "
  },
  {
    "path": "backend/artisan",
    "content": "#!/usr/bin/env php\n<?php\n\nuse Symfony\\Component\\Console\\Input\\ArgvInput;\n\ndefine('LARAVEL_START', microtime(true));\n\n// Configure OpenTelemetry before autoloader (prevents warnings when extension not installed)\nrequire __DIR__.'/bootstrap/opentelemetry.php';\n\n// Register the Composer autoloader...\nrequire __DIR__.'/vendor/autoload.php';\n\n// Bootstrap Laravel and handle the command...\n$status = (require_once __DIR__.'/bootstrap/app.php')\n    ->handleCommand(new ArgvInput);\n\nexit($status);\n"
  },
  {
    "path": "backend/bootstrap/app.php",
    "content": "<?php\n\nuse App\\Http\\Middleware\\CheckUserActive;\nuse App\\Http\\Middleware\\CheckUserOnboarding;\nuse App\\Http\\Middleware\\UpdateLastActivity;\nuse Illuminate\\Foundation\\Application;\nuse Illuminate\\Foundation\\Configuration\\Exceptions;\nuse Illuminate\\Foundation\\Configuration\\Middleware;\nuse Sentry\\Laravel\\Integration;\nuse Spatie\\GoogleTagManager\\GoogleTagManagerMiddleware;\n\nreturn Application::configure(basePath: dirname(__DIR__))\n    ->withRouting(\n        web: __DIR__.'/../routes/web.php',\n        api: __DIR__.'/../routes/api.php',\n        commands: __DIR__.'/../routes/console.php',\n        channels: __DIR__.'/../routes/channels.php',\n        health: '/health',\n    )\n    ->withMiddleware(function (Middleware $middleware) {\n        $middleware->trustProxies(at: '*');\n        $middleware->alias([\n            'user.update-last-activity' => UpdateLastActivity::class,\n            'user.active' => CheckUserActive::class,\n            'user.onboarding' => CheckUserOnboarding::class,\n            'gtm' => GoogleTagManagerMiddleware::class,\n        ]);\n        $middleware->validateCsrfTokens(except: [\n            'lemon-squeezy/*',\n        ]);\n    })\n    ->withExceptions(function (Exceptions $exceptions) {\n        Integration::handles($exceptions);\n    })->create();\n"
  },
  {
    "path": "backend/bootstrap/cache/.gitignore",
    "content": "*\n!.gitignore\n"
  },
  {
    "path": "backend/bootstrap/opentelemetry.php",
    "content": "<?php\n\n/**\n * Disable OpenTelemetry Laravel instrumentation if extension is not loaded.\n * This prevents warnings in dev/CI environments where the extension isn't installed.\n * \n * This file should be included before the Composer autoloader is loaded.\n */\nif (!extension_loaded('opentelemetry') && !isset($_ENV['OTEL_PHP_DISABLED_INSTRUMENTATIONS'])) {\n    $_ENV['OTEL_PHP_DISABLED_INSTRUMENTATIONS'] = 'laravel';\n    putenv('OTEL_PHP_DISABLED_INSTRUMENTATIONS=laravel');\n}\n\n"
  },
  {
    "path": "backend/bootstrap/providers.php",
    "content": "<?php\n\nreturn [\n    App\\Providers\\AppServiceProvider::class,\n    App\\Providers\\AuthServiceProvider::class,\n    \\SocialiteProviders\\Manager\\ServiceProvider::class,\n];\n"
  },
  {
    "path": "backend/composer.json",
    "content": "{\n    \"$schema\": \"https://getcomposer.org/schema.json\",\n    \"name\": \"laravel/laravel\",\n    \"type\": \"project\",\n    \"description\": \"The skeleton application for the Laravel framework.\",\n    \"keywords\": [\"laravel\", \"framework\"],\n    \"license\": \"MIT\",\n    \"require\": {\n        \"php\": \"^8.4\",\n        \"cesargb/laravel-magiclink\": \"^2.22\",\n        \"doctrine/dbal\": \"^4.2\",\n        \"google/apiclient\": \"^2.18\",\n        \"guzzlehttp/guzzle\": \"^7.9\",\n        \"josiasmontag/laravel-recaptchav3\": \"^1.0\",\n        \"laravel/framework\": \"^12.0\",\n        \"laravel/sanctum\": \"^4.0\",\n        \"laravel/socialite\": \"^5.16\",\n        \"laravel/tinker\": \"^2.9\",\n        \"lemonsqueezy/laravel\": \"^1.8\",\n        \"microsoft/microsoft-graph\": \"^2.23\",\n        \"open-telemetry/exporter-otlp\": \"^1.3\",\n        \"open-telemetry/opentelemetry-auto-laravel\": \"*\",\n        \"open-telemetry/opentelemetry-logger-monolog\": \"^1.0\",\n        \"open-telemetry/sdk\": \"^1.9\",\n        \"predis/predis\": \"^3.0\",\n        \"sabre/dav\": \"^4.7\",\n        \"sabre/vobject\": \"^4.5\",\n        \"sentry/sentry-laravel\": \"^4.13\",\n        \"socialiteproviders/microsoft\": \"^4.6\",\n        \"spatie/laravel-data\": \"^4.15\",\n        \"spatie/laravel-googletagmanager\": \"*\",\n        \"fakerphp/faker\": \"^1.23\"\n    },\n    \"require-dev\": {\n        \"laravel/pail\": \"^1.1\",\n        \"laravel/pint\": \"^1.13\",\n        \"laravel/sail\": \"^1.26\",\n        \"mockery/mockery\": \"^1.6\",\n        \"nunomaduro/collision\": \"^8.1\",\n        \"pestphp/pest\": \"^3.7\",\n        \"pestphp/pest-plugin-laravel\": \"^3.0\"\n    },\n    \"autoload\": {\n        \"psr-4\": {\n            \"App\\\\\": \"app/\",\n            \"Database\\\\Factories\\\\\": \"database/factories/\",\n            \"Database\\\\Seeders\\\\\": \"database/seeders/\"\n        }\n    },\n    \"autoload-dev\": {\n        \"psr-4\": {\n            \"Tests\\\\\": \"tests/\"\n        }\n    },\n    \"scripts\": {\n        \"post-autoload-dump\": [\n            \"Illuminate\\\\Foundation\\\\ComposerScripts::postAutoloadDump\",\n            \"@php artisan package:discover --ansi\"\n        ],\n        \"post-update-cmd\": [\n            \"@php artisan vendor:publish --tag=laravel-assets --ansi --force\"\n        ],\n        \"post-root-package-install\": [\n            \"@php -r \\\"file_exists('.env') || copy('.env.example', '.env');\\\"\"\n        ],\n        \"post-create-project-cmd\": [\n            \"@php artisan key:generate --ansi\",\n            \"@php -r \\\"file_exists('database/database.sqlite') || touch('database/database.sqlite');\\\"\",\n            \"@php artisan migrate --graceful --ansi\"\n        ],\n        \"dev\": [\n            \"Composer\\\\Config::disableProcessTimeout\",\n            \"npx concurrently -c \\\"#93c5fd,#c4b5fd,#fb7185,#fdba74\\\" \\\"php artisan serve\\\" \\\"php artisan queue:listen --tries=1\\\" \\\"php artisan pail --timeout=0\\\" \\\"npm run dev\\\" --names=server,queue,logs,vite\"\n        ]\n    },\n    \"extra\": {\n        \"laravel\": {\n            \"dont-discover\": []\n        }\n    },\n    \"config\": {\n        \"optimize-autoloader\": true,\n        \"preferred-install\": \"dist\",\n        \"sort-packages\": true,\n        \"platform\": {\n            \"ext-opentelemetry\": \"1.0.0\"\n        },\n        \"allow-plugins\": {\n            \"pestphp/pest-plugin\": true,\n            \"php-http/discovery\": true,\n            \"tbachert/spi\": true\n        }\n    },\n    \"minimum-stability\": \"stable\",\n    \"prefer-stable\": true\n}\n"
  },
  {
    "path": "backend/config/app.php",
    "content": "<?php\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Application Name\n    |--------------------------------------------------------------------------\n    |\n    | This value is the name of your application, which will be used when the\n    | framework needs to place the application's name in a notification or\n    | other UI elements where an application name needs to be displayed.\n    |\n    */\n\n    'name' => env('APP_NAME', 'Laravel'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Application Environment\n    |--------------------------------------------------------------------------\n    |\n    | This value determines the \"environment\" your application is currently\n    | running in. This may determine how you prefer to configure various\n    | services the application utilizes. Set this in your \".env\" file.\n    |\n    */\n\n    'env' => env('APP_ENV', 'production'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Application Debug Mode\n    |--------------------------------------------------------------------------\n    |\n    | When your application is in debug mode, detailed error messages with\n    | stack traces will be shown on every error that occurs within your\n    | application. If disabled, a simple generic error page is shown.\n    |\n    */\n\n    'debug' => (bool) env('APP_DEBUG', false),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Application URL\n    |--------------------------------------------------------------------------\n    |\n    | This URL is used by the console to properly generate URLs when using\n    | the Artisan command line tool. You should set this to the root of\n    | the application so that it's available within Artisan commands.\n    |\n    */\n\n    'url' => env('APP_URL', 'http://localhost'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Application Timezone\n    |--------------------------------------------------------------------------\n    |\n    | Here you may specify the default timezone for your application, which\n    | will be used by the PHP date and date-time functions. The timezone\n    | is set to \"UTC\" by default as it is suitable for most use cases.\n    |\n    */\n\n    'timezone' => env('APP_TIMEZONE', 'UTC'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Application Locale Configuration\n    |--------------------------------------------------------------------------\n    |\n    | The application locale determines the default locale that will be used\n    | by Laravel's translation / localization methods. This option can be\n    | set to any locale for which you plan to have translation strings.\n    |\n    */\n\n    'locale' => env('APP_LOCALE', 'en'),\n\n    'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),\n\n    'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Encryption Key\n    |--------------------------------------------------------------------------\n    |\n    | This key is utilized by Laravel's encryption services and should be set\n    | to a random, 32 character string to ensure that all encrypted values\n    | are secure. You should do this prior to deploying the application.\n    |\n    */\n\n    'cipher' => 'AES-256-CBC',\n\n    'key' => env('APP_KEY'),\n\n    'previous_keys' => [\n        ...array_filter(\n            explode(',', env('APP_PREVIOUS_KEYS', ''))\n        ),\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Maintenance Mode Driver\n    |--------------------------------------------------------------------------\n    |\n    | These configuration options determine the driver used to determine and\n    | manage Laravel's \"maintenance mode\" status. The \"cache\" driver will\n    | allow maintenance mode to be controlled across multiple machines.\n    |\n    | Supported drivers: \"file\", \"cache\"\n    |\n    */\n\n    'maintenance' => [\n        'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),\n        'store' => env('APP_MAINTENANCE_STORE', 'database'),\n    ],\n\n];\n"
  },
  {
    "path": "backend/config/auth.php",
    "content": "<?php\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Authentication Defaults\n    |--------------------------------------------------------------------------\n    |\n    | This option defines the default authentication \"guard\" and password\n    | reset \"broker\" for your application. You may change these values\n    | as required, but they're a perfect start for most applications.\n    |\n    */\n\n    'defaults' => [\n        'guard' => env('AUTH_GUARD', 'web'),\n        'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Authentication Guards\n    |--------------------------------------------------------------------------\n    |\n    | Next, you may define every authentication guard for your application.\n    | Of course, a great default configuration has been defined for you\n    | which utilizes session storage plus the Eloquent user provider.\n    |\n    | All authentication guards have a user provider, which defines how the\n    | users are actually retrieved out of your database or other storage\n    | system used by the application. Typically, Eloquent is utilized.\n    |\n    | Supported: \"session\"\n    |\n    */\n\n    'guards' => [\n        'web' => [\n            'driver' => 'session',\n            'provider' => 'users',\n        ],\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | User Providers\n    |--------------------------------------------------------------------------\n    |\n    | All authentication guards have a user provider, which defines how the\n    | users are actually retrieved out of your database or other storage\n    | system used by the application. Typically, Eloquent is utilized.\n    |\n    | If you have multiple user tables or models you may configure multiple\n    | providers to represent the model / table. These providers may then\n    | be assigned to any extra authentication guards you have defined.\n    |\n    | Supported: \"database\", \"eloquent\"\n    |\n    */\n\n    'providers' => [\n        'users' => [\n            'driver' => 'eloquent',\n            'model' => env('AUTH_MODEL', App\\Models\\User::class),\n        ],\n\n        // 'users' => [\n        //     'driver' => 'database',\n        //     'table' => 'users',\n        // ],\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Resetting Passwords\n    |--------------------------------------------------------------------------\n    |\n    | These configuration options specify the behavior of Laravel's password\n    | reset functionality, including the table utilized for token storage\n    | and the user provider that is invoked to actually retrieve users.\n    |\n    | The expiry time is the number of minutes that each reset token will be\n    | considered valid. This security feature keeps tokens short-lived so\n    | they have less time to be guessed. You may change this as needed.\n    |\n    | The throttle setting is the number of seconds a user must wait before\n    | generating more password reset tokens. This prevents the user from\n    | quickly generating a very large amount of password reset tokens.\n    |\n    */\n\n    'passwords' => [\n        'users' => [\n            'provider' => 'users',\n            'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),\n            'expire' => 60,\n            'throttle' => 60,\n        ],\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Password Confirmation Timeout\n    |--------------------------------------------------------------------------\n    |\n    | Here you may define the amount of seconds before a password confirmation\n    | window expires and users are asked to re-enter their password via the\n    | confirmation screen. By default, the timeout lasts for three hours.\n    |\n    */\n\n    'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),\n\n];\n"
  },
  {
    "path": "backend/config/broadcasting.php",
    "content": "<?php\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Default Broadcaster\n    |--------------------------------------------------------------------------\n    |\n    | This option controls the default broadcaster that will be used by the\n    | framework when an event needs to be broadcast. You may set this to\n    | any of the connections defined in the \"connections\" array below.\n    |\n    | Supported: \"reverb\", \"pusher\", \"ably\", \"redis\", \"log\", \"null\"\n    |\n    */\n\n    'default' => env('BROADCAST_CONNECTION', 'null'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Broadcast Connections\n    |--------------------------------------------------------------------------\n    |\n    | Here you may define all of the broadcast connections that will be used\n    | to broadcast events to other systems or over WebSockets. Samples of\n    | each available type of connection are provided inside this array.\n    |\n    */\n\n    'connections' => [\n\n        'redis' => [\n            'driver' => 'redis',\n            'connection' => 'default',\n        ],\n\n        'pusher' => [\n            'driver' => 'pusher',\n            'key' => env('PUSHER_APP_KEY'),\n            'secret' => env('PUSHER_APP_SECRET'),\n            'app_id' => env('PUSHER_APP_ID'),\n            'options' => [\n                'cluster' => env('PUSHER_APP_CLUSTER'),\n                'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',\n                'port' => env('PUSHER_PORT', 443),\n                'scheme' => env('PUSHER_SCHEME', 'https'),\n                'encrypted' => true,\n                'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',\n            ],\n            'client_options' => [\n                // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html\n            ],\n        ],\n\n        'ably' => [\n            'driver' => 'ably',\n            'key' => env('ABLY_KEY'),\n        ],\n\n        'log' => [\n            'driver' => 'log',\n        ],\n\n        'null' => [\n            'driver' => 'null',\n        ],\n\n    ],\n\n];\n"
  },
  {
    "path": "backend/config/cache.php",
    "content": "<?php\n\nuse Illuminate\\Support\\Str;\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Default Cache Store\n    |--------------------------------------------------------------------------\n    |\n    | This option controls the default cache store that will be used by the\n    | framework. This connection is utilized if another isn't explicitly\n    | specified when running a cache operation inside the application.\n    |\n    */\n\n    'default' => env('CACHE_STORE', 'database'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Cache Stores\n    |--------------------------------------------------------------------------\n    |\n    | Here you may define all of the cache \"stores\" for your application as\n    | well as their drivers. You may even define multiple stores for the\n    | same cache driver to group types of items stored in your caches.\n    |\n    | Supported drivers: \"array\", \"database\", \"file\", \"memcached\",\n    |                    \"redis\", \"dynamodb\", \"octane\", \"null\"\n    |\n    */\n\n    'stores' => [\n\n        'array' => [\n            'driver' => 'array',\n            'serialize' => false,\n        ],\n\n        'database' => [\n            'driver' => 'database',\n            'connection' => env('DB_CACHE_CONNECTION'),\n            'table' => env('DB_CACHE_TABLE', 'cache'),\n            'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),\n            'lock_table' => env('DB_CACHE_LOCK_TABLE'),\n        ],\n\n        'file' => [\n            'driver' => 'file',\n            'path' => storage_path('framework/cache/data'),\n            'lock_path' => storage_path('framework/cache/data'),\n        ],\n\n        'memcached' => [\n            'driver' => 'memcached',\n            'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),\n            'sasl' => [\n                env('MEMCACHED_USERNAME'),\n                env('MEMCACHED_PASSWORD'),\n            ],\n            'options' => [\n                // Memcached::OPT_CONNECT_TIMEOUT => 2000,\n            ],\n            'servers' => [\n                [\n                    'host' => env('MEMCACHED_HOST', '127.0.0.1'),\n                    'port' => env('MEMCACHED_PORT', 11211),\n                    'weight' => 100,\n                ],\n            ],\n        ],\n\n        'redis' => [\n            'driver' => 'redis',\n            'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),\n            'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),\n        ],\n\n        'dynamodb' => [\n            'driver' => 'dynamodb',\n            'key' => env('AWS_ACCESS_KEY_ID'),\n            'secret' => env('AWS_SECRET_ACCESS_KEY'),\n            'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n            'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),\n            'endpoint' => env('DYNAMODB_ENDPOINT'),\n        ],\n\n        'octane' => [\n            'driver' => 'octane',\n        ],\n\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Cache Key Prefix\n    |--------------------------------------------------------------------------\n    |\n    | When utilizing the APC, database, memcached, Redis, and DynamoDB cache\n    | stores, there might be other applications using the same cache. For\n    | that reason, you may prefix every cache key to avoid collisions.\n    |\n    */\n\n    'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'),\n\n];\n"
  },
  {
    "path": "backend/config/database.php",
    "content": "<?php\n\nuse Illuminate\\Support\\Str;\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Default Database Connection Name\n    |--------------------------------------------------------------------------\n    |\n    | Here you may specify which of the database connections below you wish\n    | to use as your default connection for database operations. This is\n    | the connection which will be utilized unless another connection\n    | is explicitly specified when you execute a query / statement.\n    |\n    */\n\n    'default' => env('DB_CONNECTION', 'sqlite'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Database Connections\n    |--------------------------------------------------------------------------\n    |\n    | Below are all of the database connections defined for your application.\n    | An example configuration is provided for each database system which\n    | is supported by Laravel. You're free to add / remove connections.\n    |\n    */\n\n    'connections' => [\n\n        'sqlite' => [\n            'driver' => 'sqlite',\n            'url' => env('DB_URL'),\n            'database' => env('DB_DATABASE', storage_path('database.sqlite')),\n            'prefix' => '',\n            'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),\n            'busy_timeout' => null,\n            'journal_mode' => null,\n            'synchronous' => null,\n        ],\n\n        'mysql' => [\n            'driver' => 'mysql',\n            'url' => env('DB_URL'),\n            'host' => env('DB_HOST', '127.0.0.1'),\n            'port' => env('DB_PORT', '3306'),\n            'database' => env('DB_DATABASE', 'laravel'),\n            'username' => env('DB_USERNAME', 'root'),\n            'password' => env('DB_PASSWORD', ''),\n            'unix_socket' => env('DB_SOCKET', ''),\n            'charset' => env('DB_CHARSET', 'utf8mb4'),\n            'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),\n            'prefix' => '',\n            'prefix_indexes' => true,\n            'strict' => true,\n            'engine' => null,\n            'options' => extension_loaded('pdo_mysql') ? array_filter([\n                Pdo\\Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),\n            ]) : [],\n        ],\n\n        'mariadb' => [\n            'driver' => 'mariadb',\n            'url' => env('DB_URL'),\n            'host' => env('DB_HOST', '127.0.0.1'),\n            'port' => env('DB_PORT', '3306'),\n            'database' => env('DB_DATABASE', 'laravel'),\n            'username' => env('DB_USERNAME', 'root'),\n            'password' => env('DB_PASSWORD', ''),\n            'unix_socket' => env('DB_SOCKET', ''),\n            'charset' => env('DB_CHARSET', 'utf8mb4'),\n            'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),\n            'prefix' => '',\n            'prefix_indexes' => true,\n            'strict' => true,\n            'engine' => null,\n            'options' => extension_loaded('pdo_mysql') ? array_filter([\n                Pdo\\Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),\n            ]) : [],\n        ],\n\n        'pgsql' => [\n            'driver' => 'pgsql',\n            'url' => env('DB_URL'),\n            'host' => env('DB_HOST', '127.0.0.1'),\n            'port' => env('DB_PORT', '5432'),\n            'database' => env('DB_DATABASE', 'laravel'),\n            'username' => env('DB_USERNAME', 'root'),\n            'password' => env('DB_PASSWORD', ''),\n            'charset' => env('DB_CHARSET', 'utf8'),\n            'prefix' => '',\n            'prefix_indexes' => true,\n            'search_path' => 'public',\n            'sslmode' => 'prefer',\n        ],\n\n        'sqlsrv' => [\n            'driver' => 'sqlsrv',\n            'url' => env('DB_URL'),\n            'host' => env('DB_HOST', 'localhost'),\n            'port' => env('DB_PORT', '1433'),\n            'database' => env('DB_DATABASE', 'laravel'),\n            'username' => env('DB_USERNAME', 'root'),\n            'password' => env('DB_PASSWORD', ''),\n            'charset' => env('DB_CHARSET', 'utf8'),\n            'prefix' => '',\n            'prefix_indexes' => true,\n            // 'encrypt' => env('DB_ENCRYPT', 'yes'),\n            // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),\n        ],\n\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Migration Repository Table\n    |--------------------------------------------------------------------------\n    |\n    | This table keeps track of all the migrations that have already run for\n    | your application. Using this information, we can determine which of\n    | the migrations on disk haven't actually been run on the database.\n    |\n    */\n\n    'migrations' => [\n        'table' => 'migrations',\n        'update_date_on_publish' => true,\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Redis Databases\n    |--------------------------------------------------------------------------\n    |\n    | Redis is an open source, fast, and advanced key-value store that also\n    | provides a richer body of commands than a typical key-value system\n    | such as Memcached. You may define your connection settings here.\n    |\n    */\n\n    'redis' => [\n\n        'client' => env('REDIS_CLIENT', 'phpredis'),\n\n        'options' => [\n            'cluster' => env('REDIS_CLUSTER', 'redis'),\n            'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),\n        ],\n\n        'default' => [\n            'url' => env('REDIS_URL'),\n            'host' => env('REDIS_HOST', '127.0.0.1'),\n            'username' => env('REDIS_USERNAME'),\n            'password' => env('REDIS_PASSWORD'),\n            'port' => env('REDIS_PORT', '6379'),\n            'database' => env('REDIS_DB', '0'),\n        ],\n\n        'cache' => [\n            'url' => env('REDIS_URL'),\n            'host' => env('REDIS_HOST', '127.0.0.1'),\n            'username' => env('REDIS_USERNAME'),\n            'password' => env('REDIS_PASSWORD'),\n            'port' => env('REDIS_PORT', '6379'),\n            'database' => env('REDIS_CACHE_DB', '1'),\n        ],\n\n    ],\n\n];\n"
  },
  {
    "path": "backend/config/faro.php",
    "content": "<?php\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Grafana Faro Configuration\n    |--------------------------------------------------------------------------\n    |\n    | Configuration for Grafana Faro Real User Monitoring (RUM).\n    | Faro collects frontend telemetry data (errors, performance, user interactions)\n    | and sends it to Grafana Alloy for processing.\n    |\n    */\n\n    'enabled' => env('FARO_ENABLED', false),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Faro Collector Endpoint\n    |--------------------------------------------------------------------------\n    |\n    | The URL where Grafana Alloy FARO receiver is listening.\n    | Default: http://localhost:12347/collect\n    |\n    | In Docker environments, use host.docker.internal to reach the host.\n    | In production, use your actual Grafana Alloy endpoint.\n    |\n    */\n\n    'collector_url' => env('FARO_COLLECTOR_URL', 'http://localhost:12347/collect'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | API Key\n    |--------------------------------------------------------------------------\n    |\n    | The API key that must match the api_key configured in Grafana Alloy.\n    | Default: faro-secret-key (change this in production!)\n    |\n    */\n\n    'api_key' => env('FARO_API_KEY', 'faro-secret-key'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Application Information\n    |--------------------------------------------------------------------------\n    |\n    | Application metadata sent with Faro telemetry.\n    |\n    */\n\n    'app' => [\n        'name' => env('FARO_APP_NAME', env('APP_NAME', 'spacepad')),\n        'version' => env('FARO_APP_VERSION', env('APP_VERSION', '1.0.0')),\n        'environment' => env('FARO_APP_ENV', env('APP_ENV', 'local')),\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Session Tracking\n    |--------------------------------------------------------------------------\n    |\n    | Enable session tracking and user identification.\n    |\n    */\n\n    'session_tracking' => env('FARO_SESSION_TRACKING', true),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Performance Monitoring\n    |--------------------------------------------------------------------------\n    |\n    | Enable Web Vitals and performance metrics collection.\n    |\n    */\n\n    'performance' => [\n        'enabled' => env('FARO_PERFORMANCE_ENABLED', true),\n        'observe_long_tasks' => env('FARO_OBSERVE_LONG_TASKS', true),\n        'observe_resources' => env('FARO_OBSERVE_RESOURCES', true),\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Error Tracking\n    |--------------------------------------------------------------------------\n    |\n    | Enable automatic error and exception tracking.\n    |\n    */\n\n    'errors' => [\n        'enabled' => env('FARO_ERRORS_ENABLED', true),\n        'capture_unhandled_rejections' => env('FARO_CAPTURE_UNHANDLED_REJECTIONS', true),\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Console Logs\n    |--------------------------------------------------------------------------\n    |\n    | Enable capturing console logs (errors and warnings).\n    |\n    */\n\n    'console' => [\n        'enabled' => env('FARO_CONSOLE_ENABLED', true),\n        'levels' => env('FARO_CONSOLE_LEVELS', 'error,warn'), // Comma-separated: error, warn, info, debug\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | User Interactions\n    |--------------------------------------------------------------------------\n    |\n    | Enable tracking user interactions (clicks, form submissions).\n    |\n    */\n\n    'interactions' => [\n        'enabled' => env('FARO_INTERACTIONS_ENABLED', true),\n    ],\n\n];\n\n"
  },
  {
    "path": "backend/config/filesystems.php",
    "content": "<?php\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Default Filesystem Disk\n    |--------------------------------------------------------------------------\n    |\n    | Here you may specify the default filesystem disk that should be used\n    | by the framework. The \"local\" disk, as well as a variety of cloud\n    | based disks are available to your application for file storage.\n    |\n    */\n\n    'default' => env('FILESYSTEM_DISK', 'local'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Filesystem Disks\n    |--------------------------------------------------------------------------\n    |\n    | Below you may configure as many filesystem disks as necessary, and you\n    | may even configure multiple disks for the same driver. Examples for\n    | most supported storage drivers are configured here for reference.\n    |\n    | Supported drivers: \"local\", \"ftp\", \"sftp\", \"s3\"\n    |\n    */\n\n    'disks' => [\n\n        'local' => [\n            'driver' => 'local',\n            'root' => storage_path('app/private'),\n            'serve' => true,\n            'throw' => false,\n        ],\n\n        'public' => [\n            'driver' => 'local',\n            'root' => storage_path('app/public'),\n            'url' => env('APP_URL').'/storage',\n            'visibility' => 'public',\n            'throw' => false,\n        ],\n\n        's3' => [\n            'driver' => 's3',\n            'key' => env('AWS_ACCESS_KEY_ID'),\n            'secret' => env('AWS_SECRET_ACCESS_KEY'),\n            'region' => env('AWS_DEFAULT_REGION'),\n            'bucket' => env('AWS_BUCKET'),\n            'url' => env('AWS_URL'),\n            'endpoint' => env('AWS_ENDPOINT'),\n            'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),\n            'throw' => false,\n        ],\n\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Symbolic Links\n    |--------------------------------------------------------------------------\n    |\n    | Here you may configure the symbolic links that will be created when the\n    | `storage:link` Artisan command is executed. The array keys should be\n    | the locations of the links and the values should be their targets.\n    |\n    */\n\n    'links' => [\n        public_path('storage') => storage_path('app/public'),\n    ],\n\n];\n"
  },
  {
    "path": "backend/config/googletagmanager.php",
    "content": "<?php\n\nreturn [\n\n    // The Google Tag Manager id, e.g. GTM-XXXXXXX\n    'id' => env('GTM_ID', ''),\n\n    // Enable or disable script rendering. Useful for local development.\n    'enabled' => env('GTM_ENABLED', false),\n\n    // Script domain; keep default unless using a Server-Side GTM custom domain\n    'domain' => env('GTM_DOMAIN', 'www.googletagmanager.com'),\n\n    // Session key for flashed data layer values\n    'sessionKey' => '_googleTagManager',\n];\n\n\n"
  },
  {
    "path": "backend/config/lemon-squeezy.php",
    "content": "<?php\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Lemon Squeezy API Key\n    |--------------------------------------------------------------------------\n    |\n    | The Lemon Squeezy API key is used to authenticate with the Lemon Squeezy\n    | API. You can find your API key in the Lemon Squeezy dashboard. You can\n    | find your API key in the Lemon Squeezy dashboard under the \"API\" section.\n    |\n    */\n\n    'api_key' => env('LEMON_SQUEEZY_API_KEY'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Lemon Squeezy Signing Secret\n    |--------------------------------------------------------------------------\n    |\n    | The Lemon Squeezy signing secret is used to verify that the webhook\n    | requests are coming from Lemon Squeezy. You can find your signing\n    | secret in the Lemon Squeezy dashboard under the \"Webhooks\" section.\n    |\n    */\n\n    'signing_secret' => env('LEMON_SQUEEZY_SIGNING_SECRET'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Lemon Squeezy Url Path\n    |--------------------------------------------------------------------------\n    |\n    | This is the base URI where routes from Lemon Squeezy will be served\n    | from. The URL built into Lemon Squeezy is used by default; however,\n    | you can modify this path as you see fit for your application.\n    |\n    */\n\n    'path' => env('LEMON_SQUEEZY_PATH', 'lemon-squeezy'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Lemon Squeezy Store\n    |--------------------------------------------------------------------------\n    |\n    | This is the ID of your Lemon Squeezy store. You can find your store\n    | ID in the Lemon Squeezy dashboard. The entered value should be the\n    | part after the # sign.\n    |\n    */\n\n    'store' => env('LEMON_SQUEEZY_STORE'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Default Redirect URL\n    |--------------------------------------------------------------------------\n    |\n    | This is the default redirect URL that will be used when a customer\n    | is redirected back to your application after completing a purchase\n    | from a checkout session in your Lemon Squeezy store.\n    |\n    */\n\n    'redirect_url' => null,\n\n    /*\n    |--------------------------------------------------------------------------\n    | Currency Locale\n    |--------------------------------------------------------------------------\n    |\n    | This is the default locale in which your money values are formatted in\n    | for display. To utilize other locales besides the default en locale\n    | verify you have the \"intl\" PHP extension installed on the system.\n    |\n    */\n\n    'currency_locale' => env('LEMON_SQUEEZY_CURRENCY_LOCALE', 'en'),\n\n];\n"
  },
  {
    "path": "backend/config/logging.php",
    "content": "<?php\n\nuse Monolog\\Handler\\NullHandler;\nuse Monolog\\Handler\\StreamHandler;\nuse Monolog\\Handler\\SyslogUdpHandler;\nuse Monolog\\Processor\\PsrLogMessageProcessor;\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Default Log Channel\n    |--------------------------------------------------------------------------\n    |\n    | This option defines the default log channel that is utilized to write\n    | messages to your logs. The value provided here should match one of\n    | the channels present in the list of \"channels\" configured below.\n    |\n    */\n\n    'default' => env('LOG_CHANNEL', 'stderr'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Deprecations Log Channel\n    |--------------------------------------------------------------------------\n    |\n    | This option controls the log channel that should be used to log warnings\n    | regarding deprecated PHP and library features. This allows you to get\n    | your application ready for upcoming major versions of dependencies.\n    |\n    */\n\n    'deprecations' => [\n        'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),\n        'trace' => env('LOG_DEPRECATIONS_TRACE', false),\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Log Channels\n    |--------------------------------------------------------------------------\n    |\n    | Here you may configure the log channels for your application. Laravel\n    | utilizes the Monolog PHP logging library, which includes a variety\n    | of powerful log handlers and formatters that you're free to use.\n    |\n    | Available drivers: \"single\", \"daily\", \"slack\", \"syslog\",\n    |                    \"errorlog\", \"monolog\", \"custom\", \"stack\"\n    |\n    */\n\n    'channels' => [\n\n        'stack' => [\n            'driver' => 'stack',\n            'channels' => explode(',', env('LOG_STACK', 'single')),\n            'ignore_exceptions' => false,\n        ],\n\n        'single' => [\n            'driver' => 'single',\n            'path' => storage_path('logs/laravel.log'),\n            'level' => env('LOG_LEVEL', 'debug'),\n            'replace_placeholders' => true,\n        ],\n\n        'daily' => [\n            'driver' => 'daily',\n            'path' => storage_path('logs/laravel.log'),\n            'level' => env('LOG_LEVEL', 'debug'),\n            'days' => env('LOG_DAILY_DAYS', 14),\n            'replace_placeholders' => true,\n        ],\n\n        'slack' => [\n            'driver' => 'slack',\n            'url' => env('LOG_SLACK_WEBHOOK_URL'),\n            'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),\n            'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),\n            'level' => env('LOG_LEVEL', 'critical'),\n            'replace_placeholders' => true,\n        ],\n\n        'papertrail' => [\n            'driver' => 'monolog',\n            'level' => env('LOG_LEVEL', 'debug'),\n            'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),\n            'handler_with' => [\n                'host' => env('PAPERTRAIL_URL'),\n                'port' => env('PAPERTRAIL_PORT'),\n                'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),\n            ],\n            'processors' => [PsrLogMessageProcessor::class],\n        ],\n\n        'stderr' => [\n            'driver' => 'monolog',\n            'level' => env('LOG_LEVEL', 'debug'),\n            'handler' => StreamHandler::class,\n            'formatter' => env('LOG_STDERR_FORMATTER'),\n            'with' => [\n                'stream' => 'php://stderr',\n            ],\n            'processors' => [PsrLogMessageProcessor::class],\n        ],\n\n        'syslog' => [\n            'driver' => 'syslog',\n            'level' => env('LOG_LEVEL', 'debug'),\n            'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),\n            'replace_placeholders' => true,\n        ],\n\n        'errorlog' => [\n            'driver' => 'errorlog',\n            'level' => env('LOG_LEVEL', 'debug'),\n            'replace_placeholders' => true,\n        ],\n\n        'null' => [\n            'driver' => 'monolog',\n            'handler' => NullHandler::class,\n        ],\n\n        'emergency' => [\n            'path' => storage_path('logs/laravel.log'),\n        ],\n\n    ],\n\n];\n"
  },
  {
    "path": "backend/config/magiclink.php",
    "content": "<?php\n\nreturn [\n\n    'token'           => [\n        /*\n        |--------------------------------------------------------------------------\n        | Token size\n        |--------------------------------------------------------------------------\n        |\n        | Here you may specify the length of token to verify the identify.\n        | Max value is 255 characters, it will be used if bigger value is set.\n        |\n        */\n        'length' => 64,\n    ],\n\n    'url' => [\n        /*\n        |--------------------------------------------------------------------------\n        | Path to Validate Token and Auto Auth\n        |--------------------------------------------------------------------------\n        |\n        | Here you may specify the name of the path you'd like to use so that\n        | the verify token and auth in system.\n        |\n        */\n        'validate_path' => 'magiclink',\n        /*\n        |--------------------------------------------------------------------------\n        | Path default to redirect\n        |--------------------------------------------------------------------------\n        |\n        | Here you may specify the name of the path you'd like to use so that\n        | the redirect when verify correct token.\n        |\n        */\n        'redirect_default' => '/',\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Response when token is invalid\n    |--------------------------------------------------------------------------\n    |\n    | Here you may specify the class with method __invoke to get the response\n    | when token is invalid\n    |\n    */\n    'invalid_response' => [\n        'class' => MagicLink\\Responses\\Response::class,\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Disable default route\n    |--------------------------------------------------------------------------\n    |\n    | If you wish use your custom controller, you can invalidate the\n    | default route of magic link, mark this configuration as true,\n    | and add your custom route with the middleware:\n    | MagicLink\\Middlewares\\MagiclinkMiddleware\n    |\n    */\n    'disable_default_route' => false,\n\n    'access_code' => [\n        'view' => 'magiclink::ask-for-access-code-form',\n    ]\n];\n"
  },
  {
    "path": "backend/config/mail.php",
    "content": "<?php\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Default Mailer\n    |--------------------------------------------------------------------------\n    |\n    | This option controls the default mailer that is used to send all email\n    | messages unless another mailer is explicitly specified when sending\n    | the message. All additional mailers can be configured within the\n    | \"mailers\" array. Examples of each type of mailer are provided.\n    |\n    */\n\n    'default' => env('MAIL_MAILER', 'log'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Mailer Configurations\n    |--------------------------------------------------------------------------\n    |\n    | Here you may configure all of the mailers used by your application plus\n    | their respective settings. Several examples have been configured for\n    | you and you are free to add your own as your application requires.\n    |\n    | Laravel supports a variety of mail \"transport\" drivers that can be used\n    | when delivering an email. You may specify which one you're using for\n    | your mailers below. You may also add additional mailers if needed.\n    |\n    | Supported: \"smtp\", \"sendmail\", \"mailgun\", \"ses\", \"ses-v2\",\n    |            \"postmark\", \"resend\", \"log\", \"array\",\n    |            \"failover\", \"roundrobin\"\n    |\n    */\n\n    'mailers' => [\n\n        'smtp' => [\n            'transport' => 'smtp',\n            'scheme' => env('MAIL_SCHEME'),\n            'url' => env('MAIL_URL'),\n            'host' => env('MAIL_HOST', '127.0.0.1'),\n            'port' => env('MAIL_PORT', 2525),\n            'username' => env('MAIL_USERNAME'),\n            'password' => env('MAIL_PASSWORD'),\n            'timeout' => null,\n            'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url(env('APP_URL', 'http://localhost'), PHP_URL_HOST)),\n        ],\n\n        'ses' => [\n            'transport' => 'ses',\n        ],\n\n        'postmark' => [\n            'transport' => 'postmark',\n            // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),\n            // 'client' => [\n            //     'timeout' => 5,\n            // ],\n        ],\n\n        'resend' => [\n            'transport' => 'resend',\n        ],\n\n        'sendmail' => [\n            'transport' => 'sendmail',\n            'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),\n        ],\n\n        'log' => [\n            'transport' => 'log',\n            'channel' => env('MAIL_LOG_CHANNEL'),\n        ],\n\n        'array' => [\n            'transport' => 'array',\n        ],\n\n        'failover' => [\n            'transport' => 'failover',\n            'mailers' => [\n                'smtp',\n                'log',\n            ],\n        ],\n\n        'roundrobin' => [\n            'transport' => 'roundrobin',\n            'mailers' => [\n                'ses',\n                'postmark',\n            ],\n        ],\n\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Global \"From\" Address\n    |--------------------------------------------------------------------------\n    |\n    | You may wish for all emails sent by your application to be sent from\n    | the same address. Here you may specify a name and address that is\n    | used globally for all emails that are sent by your application.\n    |\n    */\n\n    'from' => [\n        'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),\n        'name' => env('MAIL_FROM_NAME', 'Example'),\n    ],\n\n];\n"
  },
  {
    "path": "backend/config/queue.php",
    "content": "<?php\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Default Queue Connection Name\n    |--------------------------------------------------------------------------\n    |\n    | Laravel's queue supports a variety of backends via a single, unified\n    | API, giving you convenient access to each backend using identical\n    | syntax for each. The default queue connection is defined below.\n    |\n    */\n\n    'default' => env('QUEUE_CONNECTION', 'sync'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Queue Connections\n    |--------------------------------------------------------------------------\n    |\n    | Here you may configure the connection options for every queue backend\n    | used by your application. An example configuration is provided for\n    | each backend supported by Laravel. You're also free to add more.\n    |\n    | Drivers: \"sync\", \"database\", \"beanstalkd\", \"sqs\", \"redis\", \"null\"\n    |\n    */\n\n    'connections' => [\n\n        'sync' => [\n            'driver' => 'sync',\n        ],\n\n        'database' => [\n            'driver' => 'database',\n            'connection' => env('DB_QUEUE_CONNECTION'),\n            'table' => env('DB_QUEUE_TABLE', 'jobs'),\n            'queue' => env('DB_QUEUE', 'default'),\n            'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),\n            'after_commit' => false,\n        ],\n\n        'beanstalkd' => [\n            'driver' => 'beanstalkd',\n            'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),\n            'queue' => env('BEANSTALKD_QUEUE', 'default'),\n            'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),\n            'block_for' => 0,\n            'after_commit' => false,\n        ],\n\n        'sqs' => [\n            'driver' => 'sqs',\n            'key' => env('AWS_ACCESS_KEY_ID'),\n            'secret' => env('AWS_SECRET_ACCESS_KEY'),\n            'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),\n            'queue' => env('SQS_QUEUE', 'default'),\n            'suffix' => env('SQS_SUFFIX'),\n            'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n            'after_commit' => false,\n        ],\n\n        'redis' => [\n            'driver' => 'redis',\n            'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),\n            'queue' => env('REDIS_QUEUE', 'default'),\n            'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),\n            'block_for' => null,\n            'after_commit' => false,\n        ],\n\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Job Batching\n    |--------------------------------------------------------------------------\n    |\n    | The following options configure the database and table that store job\n    | batching information. These options can be updated to any database\n    | connection and table which has been defined by your application.\n    |\n    */\n\n    'batching' => [\n        'database' => env('DB_CONNECTION', 'sqlite'),\n        'table' => 'job_batches',\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Failed Queue Jobs\n    |--------------------------------------------------------------------------\n    |\n    | These options configure the behavior of failed queue job logging so you\n    | can control how and where failed jobs are stored. Laravel ships with\n    | support for storing failed jobs in a simple file or in a database.\n    |\n    | Supported drivers: \"database-uuids\", \"dynamodb\", \"file\", \"null\"\n    |\n    */\n\n    'failed' => [\n        'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),\n        'database' => env('DB_CONNECTION', 'sqlite'),\n        'table' => 'failed_jobs',\n    ],\n\n];\n"
  },
  {
    "path": "backend/config/recaptchav3.php",
    "content": "<?php\nreturn [\n    'origin' => env('RECAPTCHAV3_ORIGIN', 'https://www.google.com/recaptcha'),\n    'sitekey' => env('RECAPTCHAV3_SITEKEY'),\n    'secret' => env('RECAPTCHAV3_SECRET'),\n    'locale' => env('RECAPTCHAV3_LOCALE')\n];\n"
  },
  {
    "path": "backend/config/sanctum.php",
    "content": "<?php\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Stateful Domains\n    |--------------------------------------------------------------------------\n    |\n    | Requests from the following domains / hosts will receive stateful API\n    | authentication cookies. Typically, these should include your local\n    | and production domains which access your API via a frontend SPA.\n    |\n    */\n\n    'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(\n        '%s%s%s',\n        'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',\n        env('APP_URL') ? ','.parse_url(env('APP_URL'), PHP_URL_HOST) : '',\n        env('FRONTEND_URL') ? ','.parse_url(env('FRONTEND_URL'), PHP_URL_HOST) : ''\n    ))),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Sanctum Guards\n    |--------------------------------------------------------------------------\n    |\n    | This array contains the authentication guards that will be checked when\n    | Sanctum is trying to authenticate a request. If none of these guards\n    | are able to authenticate the request, Sanctum will use the bearer\n    | token that's present on an incoming request for authentication.\n    |\n    */\n\n    'guard' => ['web'],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Expiration Minutes\n    |--------------------------------------------------------------------------\n    |\n    | This value controls the number of minutes until an issued token will be\n    | considered expired. This will override any values set in the token's\n    | \"expires_at\" attribute, but first-party sessions are not affected.\n    |\n    */\n\n    'expiration' => null,\n\n    /*\n    |--------------------------------------------------------------------------\n    | Sanctum Middleware\n    |--------------------------------------------------------------------------\n    |\n    | When authenticating your first-party SPA with Sanctum you may need to\n    | customize some of the middleware Sanctum uses while processing the\n    | request. You may change the middleware listed below as required.\n    |\n    */\n\n    'middleware' => [\n        'verify_csrf_token' => App\\Http\\Middleware\\VerifyCsrfToken::class,\n        'encrypt_cookies' => App\\Http\\Middleware\\EncryptCookies::class,\n    ],\n\n];\n"
  },
  {
    "path": "backend/config/sentry.php",
    "content": "<?php\n\n/**\n * Sentry Laravel SDK configuration file.\n *\n * @see https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/\n */\nreturn [\n\n    // @see https://docs.sentry.io/product/sentry-basics/dsn-explainer/\n    'dsn' => env('SENTRY_LARAVEL_DSN', env('SENTRY_DSN')),\n\n    // @see https://spotlightjs.com/\n    // 'spotlight' => env('SENTRY_SPOTLIGHT', false),\n\n    // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#logger\n    // 'logger' => Sentry\\Logger\\DebugFileLogger::class, // By default this will log to `storage_path('logs/sentry.log')`\n\n    // The release version of your application\n    // Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty=\"%h\" -n1 HEAD'))\n    'release' => env('SENTRY_RELEASE'),\n\n    // When left empty or `null` the Laravel environment will be used (usually discovered from `APP_ENV` in your `.env`)\n    'environment' => env('SENTRY_ENVIRONMENT'),\n\n    // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#sample-rate\n    'sample_rate' => env('SENTRY_SAMPLE_RATE') === null ? 1.0 : (float) env('SENTRY_SAMPLE_RATE'),\n\n    // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#traces-sample-rate\n    'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_TRACES_SAMPLE_RATE'),\n\n    // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#profiles-sample-rate\n    'profiles_sample_rate' => env('SENTRY_PROFILES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_PROFILES_SAMPLE_RATE'),\n\n    // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#send-default-pii\n    'send_default_pii' => env('SENTRY_SEND_DEFAULT_PII', false),\n\n    // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore-exceptions\n    // 'ignore_exceptions' => [],\n\n    // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore-transactions\n    'ignore_transactions' => [\n        // Ignore Laravel's default health URL\n        '/up',\n    ],\n\n    // Breadcrumb specific configuration\n    'breadcrumbs' => [\n        // Capture Laravel logs as breadcrumbs\n        'logs' => env('SENTRY_BREADCRUMBS_LOGS_ENABLED', true),\n\n        // Capture Laravel cache events (hits, writes etc.) as breadcrumbs\n        'cache' => env('SENTRY_BREADCRUMBS_CACHE_ENABLED', true),\n\n        // Capture Livewire components like routes as breadcrumbs\n        'livewire' => env('SENTRY_BREADCRUMBS_LIVEWIRE_ENABLED', true),\n\n        // Capture SQL queries as breadcrumbs\n        'sql_queries' => env('SENTRY_BREADCRUMBS_SQL_QUERIES_ENABLED', true),\n\n        // Capture SQL query bindings (parameters) in SQL query breadcrumbs\n        'sql_bindings' => env('SENTRY_BREADCRUMBS_SQL_BINDINGS_ENABLED', false),\n\n        // Capture queue job information as breadcrumbs\n        'queue_info' => env('SENTRY_BREADCRUMBS_QUEUE_INFO_ENABLED', true),\n\n        // Capture command information as breadcrumbs\n        'command_info' => env('SENTRY_BREADCRUMBS_COMMAND_JOBS_ENABLED', true),\n\n        // Capture HTTP client request information as breadcrumbs\n        'http_client_requests' => env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true),\n\n        // Capture send notifications as breadcrumbs\n        'notifications' => env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true),\n    ],\n\n    // Performance monitoring specific configuration\n    'tracing' => [\n        // Trace queue jobs as their own transactions (this enables tracing for queue jobs)\n        'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', true),\n\n        // Capture queue jobs as spans when executed on the sync driver\n        'queue_jobs' => env('SENTRY_TRACE_QUEUE_JOBS_ENABLED', true),\n\n        // Capture SQL queries as spans\n        'sql_queries' => env('SENTRY_TRACE_SQL_QUERIES_ENABLED', true),\n\n        // Capture SQL query bindings (parameters) in SQL query spans\n        'sql_bindings' => env('SENTRY_TRACE_SQL_BINDINGS_ENABLED', false),\n\n        // Capture where the SQL query originated from on the SQL query spans\n        'sql_origin' => env('SENTRY_TRACE_SQL_ORIGIN_ENABLED', true),\n\n        // Define a threshold in milliseconds for SQL queries to resolve their origin\n        'sql_origin_threshold_ms' => env('SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS', 100),\n\n        // Capture views rendered as spans\n        'views' => env('SENTRY_TRACE_VIEWS_ENABLED', true),\n\n        // Capture Livewire components as spans\n        'livewire' => env('SENTRY_TRACE_LIVEWIRE_ENABLED', true),\n\n        // Capture HTTP client requests as spans\n        'http_client_requests' => env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ENABLED', true),\n\n        // Capture Laravel cache events (hits, writes etc.) as spans\n        'cache' => env('SENTRY_TRACE_CACHE_ENABLED', true),\n\n        // Capture Redis operations as spans (this enables Redis events in Laravel)\n        'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false),\n\n        // Capture where the Redis command originated from on the Redis command spans\n        'redis_origin' => env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true),\n\n        // Capture send notifications as spans\n        'notifications' => env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true),\n\n        // Enable tracing for requests without a matching route (404's)\n        'missing_routes' => env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false),\n\n        // Configures if the performance trace should continue after the response has been sent to the user until the application terminates\n        // This is required to capture any spans that are created after the response has been sent like queue jobs dispatched using `dispatch(...)->afterResponse()` for example\n        'continue_after_response' => env('SENTRY_TRACE_CONTINUE_AFTER_RESPONSE', true),\n\n        // Enable the tracing integrations supplied by Sentry (recommended)\n        'default_integrations' => env('SENTRY_TRACE_DEFAULT_INTEGRATIONS_ENABLED', true),\n    ],\n\n];\n"
  },
  {
    "path": "backend/config/services.php",
    "content": "<?php\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Third Party Services\n    |--------------------------------------------------------------------------\n    |\n    | This file is for storing the credentials for third party services such\n    | as Mailgun, Postmark, AWS and more. This file provides the de facto\n    | location for this type of information, allowing packages to have\n    | a conventional file to locate the various service credentials.\n    |\n    */\n\n    'postmark' => [\n        'token' => env('POSTMARK_TOKEN'),\n    ],\n\n    'ses' => [\n        'key' => env('AWS_ACCESS_KEY_ID'),\n        'secret' => env('AWS_SECRET_ACCESS_KEY'),\n        'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n    ],\n\n    'resend' => [\n        'key' => env('RESEND_KEY'),\n    ],\n\n    'slack' => [\n        'notifications' => [\n            'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n            'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n        ],\n    ],\n\n    'outlook' => [\n        'client_id' => env('OUTLOOK_CLIENT_ID'),\n        'client_secret' => env('OUTLOOK_CLIENT_SECRET'),\n        'redirect' => env('OUTLOOK_REDIRECT_URI'),\n    ],\n\n    'google' => [\n        'enabled' => env('GOOGLE_CLIENT_ID') !== null,\n        'client_id' => env('GOOGLE_CLIENT_ID'),\n        'client_secret' => env('GOOGLE_CLIENT_SECRET'),\n        'redirect' => env('GOOGLE_REDIRECT_URI', 'https://'.env('DOMAIN').'/auth/google/callback'),\n        'calendar_redirect' => env('GOOGLE_CALENDAR_REDIRECT_URI', 'https://'.env('DOMAIN').'/google-accounts/callback'),\n        'webhook_url' => env('GOOGLE_WEBHOOK_URL', 'https://'.env('DOMAIN').'/api/webhook/google'),\n    ],\n\n    'azure_ad' => [\n        'enabled' => env('AZURE_AD_CLIENT_ID') !== null,\n        'client_id' => env('AZURE_AD_CLIENT_ID'),\n        'client_secret' => env('AZURE_AD_CLIENT_SECRET'),\n        'redirect' => env('AZURE_AD_REDIRECT_URI', 'https://'.env('DOMAIN').'/outlook-accounts/callback'),\n        'tenant_id' => env('AZURE_AD_TENANT_ID', 'common'),\n        'webhook_url' => env('OUTLOOK_WEBHOOK_URL', 'https://'.env('DOMAIN').'/api/webhook/outlook')\n    ],\n\n    'microsoft' => [\n        'enabled' => env('MICROSOFT_CLIENT_ID', env('AZURE_AD_CLIENT_ID')) !== null,\n        'client_id' => env('MICROSOFT_CLIENT_ID', env('AZURE_AD_CLIENT_ID')),\n        'client_secret' => env('MICROSOFT_CLIENT_SECRET', env('AZURE_AD_CLIENT_SECRET')),\n        'redirect' => env('MICROSOFT_REDIRECT_URI', 'https://'.env('DOMAIN').'/auth/microsoft/callback'),\n        'proxy' => env('PROXY')  // Optional, will be used for all requests\n    ],\n\n    'caldav' => [\n        'enabled' => env('CALDAV_ENABLED', true),\n        'default_timezone' => env('CALDAV_DEFAULT_TIMEZONE', 'UTC'),\n    ],\n\n    'events' => [\n        'cache_enabled' => env('EVENTS_CACHE_ENABLED', true),\n    ],\n\n    'clarity' => [\n        'tag_code' => env('CLARITY_TAG_CODE'),\n    ],\n\n    'google_conversion' => [\n        'send_to' => env('GOOGLE_CONVERSION_SEND_TO'),\n        'value' => env('GOOGLE_CONVERSION_VALUE', 1.0),\n        'currency' => env('GOOGLE_CONVERSION_CURRENCY', 'EUR'),\n    ],\n\n];\n"
  },
  {
    "path": "backend/config/session.php",
    "content": "<?php\n\nuse Illuminate\\Support\\Str;\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Default Session Driver\n    |--------------------------------------------------------------------------\n    |\n    | This option determines the default session driver that is utilized for\n    | incoming requests. Laravel supports a variety of storage options to\n    | persist session data. Database storage is a great default choice.\n    |\n    | Supported: \"file\", \"cookie\", \"database\", \"apc\",\n    |            \"memcached\", \"redis\", \"dynamodb\", \"array\"\n    |\n    */\n\n    'driver' => env('SESSION_DRIVER', 'database'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Session Lifetime\n    |--------------------------------------------------------------------------\n    |\n    | Here you may specify the number of minutes that you wish the session\n    | to be allowed to remain idle before it expires. If you want them\n    | to expire immediately when the browser is closed then you may\n    | indicate that via the expire_on_close configuration option.\n    |\n    */\n\n    'lifetime' => env('SESSION_LIFETIME', 120),\n\n    'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Session Encryption\n    |--------------------------------------------------------------------------\n    |\n    | This option allows you to easily specify that all of your session data\n    | should be encrypted before it's stored. All encryption is performed\n    | automatically by Laravel and you may use the session like normal.\n    |\n    */\n\n    'encrypt' => env('SESSION_ENCRYPT', false),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Session File Location\n    |--------------------------------------------------------------------------\n    |\n    | When utilizing the \"file\" session driver, the session files are placed\n    | on disk. The default storage location is defined here; however, you\n    | are free to provide another location where they should be stored.\n    |\n    */\n\n    'files' => storage_path('framework/sessions'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Session Database Connection\n    |--------------------------------------------------------------------------\n    |\n    | When using the \"database\" or \"redis\" session drivers, you may specify a\n    | connection that should be used to manage these sessions. This should\n    | correspond to a connection in your database configuration options.\n    |\n    */\n\n    'connection' => env('SESSION_CONNECTION'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Session Database Table\n    |--------------------------------------------------------------------------\n    |\n    | When using the \"database\" session driver, you may specify the table to\n    | be used to store sessions. Of course, a sensible default is defined\n    | for you; however, you're welcome to change this to another table.\n    |\n    */\n\n    'table' => env('SESSION_TABLE', 'sessions'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Session Cache Store\n    |--------------------------------------------------------------------------\n    |\n    | When using one of the framework's cache driven session backends, you may\n    | define the cache store which should be used to store the session data\n    | between requests. This must match one of your defined cache stores.\n    |\n    | Affects: \"apc\", \"dynamodb\", \"memcached\", \"redis\"\n    |\n    */\n\n    'store' => env('SESSION_STORE'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Session Sweeping Lottery\n    |--------------------------------------------------------------------------\n    |\n    | Some session drivers must manually sweep their storage location to get\n    | rid of old sessions from storage. Here are the chances that it will\n    | happen on a given request. By default, the odds are 2 out of 100.\n    |\n    */\n\n    'lottery' => [2, 100],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Session Cookie Name\n    |--------------------------------------------------------------------------\n    |\n    | Here you may change the name of the session cookie that is created by\n    | the framework. Typically, you should not need to change this value\n    | since doing so does not grant a meaningful security improvement.\n    |\n    */\n\n    'cookie' => env(\n        'SESSION_COOKIE',\n        Str::slug(env('APP_NAME', 'laravel'), '_').'_session'\n    ),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Session Cookie Path\n    |--------------------------------------------------------------------------\n    |\n    | The session cookie path determines the path for which the cookie will\n    | be regarded as available. Typically, this will be the root path of\n    | your application, but you're free to change this when necessary.\n    |\n    */\n\n    'path' => env('SESSION_PATH', '/'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Session Cookie Domain\n    |--------------------------------------------------------------------------\n    |\n    | This value determines the domain and subdomains the session cookie is\n    | available to. By default, the cookie will be available to the root\n    | domain and all subdomains. Typically, this shouldn't be changed.\n    |\n    */\n\n    'domain' => env('SESSION_DOMAIN'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | HTTPS Only Cookies\n    |--------------------------------------------------------------------------\n    |\n    | By setting this option to true, session cookies will only be sent back\n    | to the server if the browser has a HTTPS connection. This will keep\n    | the cookie from being sent to you when it can't be done securely.\n    |\n    */\n\n    'secure' => env('SESSION_SECURE_COOKIE'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | HTTP Access Only\n    |--------------------------------------------------------------------------\n    |\n    | Setting this value to true will prevent JavaScript from accessing the\n    | value of the cookie and the cookie will only be accessible through\n    | the HTTP protocol. It's unlikely you should disable this option.\n    |\n    */\n\n    'http_only' => env('SESSION_HTTP_ONLY', true),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Same-Site Cookies\n    |--------------------------------------------------------------------------\n    |\n    | This option determines how your cookies behave when cross-site requests\n    | take place, and can be used to mitigate CSRF attacks. By default, we\n    | will set this value to \"lax\" to permit secure cross-site requests.\n    |\n    | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value\n    |\n    | Supported: \"lax\", \"strict\", \"none\", null\n    |\n    */\n\n    'same_site' => env('SESSION_SAME_SITE', 'lax'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Partitioned Cookies\n    |--------------------------------------------------------------------------\n    |\n    | Setting this value to true will tie the cookie to the top-level site for\n    | a cross-site context. Partitioned cookies are accepted by the browser\n    | when flagged \"secure\" and the Same-Site attribute is set to \"none\".\n    |\n    */\n\n    'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),\n\n];\n"
  },
  {
    "path": "backend/config/settings.php",
    "content": "<?php\n\nreturn [\n\n    'is_self_hosted' => env('SELF_HOSTED', true),\n    'registration_webhook_url' => env('REGISTRATION_WEBHOOK_URL'),\n    'onboarding_complete_webhook_url' => env('ONBOARDING_COMPLETE_WEBHOOK_URL'),\n    'order_created_webhook_url' => env('ORDER_CREATED_WEBHOOK_URL'),\n    'user_not_activated_after_24h_webhook_url' => env('USER_NOT_ACTIVATED_AFTER_24H_WEBHOOK_URL'),\n    'user_activated_after_24h_webhook_url' => env('USER_ACTIVATED_AFTER_24H_WEBHOOK_URL'),\n    'trial_expired_or_cancelled_webhook_url' => env('TRIAL_EXPIRED_OR_CANCELLED_WEBHOOK_URL'),\n    'user_passive_webhook_url' => env('USER_PASSIVE_WEBHOOK_URL'),\n    'user_inactive_webhook_url' => env('USER_INACTIVE_WEBHOOK_URL'),\n\n    'license_server' => env('LICENSE_SERVER', 'https://app.spacepad.io'),\n\n    'cloud_hosted_pro_plan_id' => env('CLOUD_HOSTED_PRO_PLAN_ID'),\n\n    'version' => env('SPACEPAD_VERSION'),\n\n    'disable_email_login' => env('DISABLE_EMAIL_LOGIN', false),\n\n    'allowed_logins' => array_filter(array_map('trim', explode(',', env('ALLOWED_LOGINS', '')))), // Comma-separated list of allowed domains or emails\n\n];\n"
  },
  {
    "path": "backend/config/wave.php",
    "content": "<?php\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Resume Lifetime\n    |--------------------------------------------------------------------------\n    |\n    | Define how long (in seconds) you wish an event stream to persist so it\n    | can be resumed after a reconnect. The connection automatically\n    | re-establishes with every closed response.\n    |\n    | * Requires a cache driver to be configured.\n    |\n    */\n    'resume_lifetime' => 60,\n\n    /*\n    |--------------------------------------------------------------------------\n    | Reconnection Time\n    |--------------------------------------------------------------------------\n    |\n    | This value determines how long (in milliseconds) to wait before\n    | attempting a reconnect to the server after a connection has been lost.\n    | By default, the client attempts to reconnect immediately. For more\n    | information, please refer to the Mozilla developer's guide on event\n    | stream format.\n    | https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format\n    |\n    */\n    'retry' => null,\n\n    /*\n    |--------------------------------------------------------------------------\n    | Ping\n    |--------------------------------------------------------------------------\n    |\n    | A ping event is automatically sent on every SSE connection request if the\n    | last event occurred before the set `frequency` value (in seconds). This\n    | ensures the connection remains persistent.\n    |\n    | By setting the `eager_env` option, a ping event will be sent with each\n    | request. This is useful for development or for applications that do not\n    | frequently expect events. The `eager_env` option can be set as an `array` or `null`.\n    |\n    | For manual control of the ping event with the `sse:ping` command, you can\n    | disable this option.\n    |\n    */\n    'ping' => [\n        'enable' => true,\n        'frequency' => 30,\n        'eager_env' => 'local', // null or array\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Routes Path\n    |--------------------------------------------------------------------------\n    |\n    | This path is used to register the necessary routes for establishing the\n    | Wave connection, storing presence channel users, and handling simple whisper events.\n    |\n    */\n    'path' => 'wave',\n\n    /*\n     |--------------------------------------------------------------------------\n     | Route Middleware\n     |--------------------------------------------------------------------------\n     |\n     | Define which middleware Wave should assign to the routes that it registers.\n     | You may modify these middleware as needed. However, the default value is\n     | typically sufficient.\n     |\n     */\n    'middleware' => [\n        'web',\n    ],\n\n    /*\n     |--------------------------------------------------------------------------\n     | Auth & Guard\n     |--------------------------------------------------------------------------\n     |\n     | Define the default authentication middleware and guard type for\n     | authenticating users for presence channels and whisper events.\n     |\n     */\n    'auth_middleware' => 'auth',\n\n    'guard' => 'web',\n\n];\n"
  },
  {
    "path": "backend/database/.gitignore",
    "content": "*.sqlite*\n"
  },
  {
    "path": "backend/database/factories/BoardFactory.php",
    "content": "<?php\n\nnamespace Database\\Factories;\n\nuse App\\Models\\Board;\nuse App\\Models\\User;\nuse App\\Models\\Workspace;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\n\n/**\n * @extends \\Illuminate\\Database\\Eloquent\\Factories\\Factory<\\App\\Models\\Board>\n */\nclass BoardFactory extends Factory\n{\n    /**\n     * The name of the factory's corresponding model.\n     *\n     * @var string\n     */\n    protected $model = Board::class;\n\n    /**\n     * Define the model's default state.\n     *\n     * @return array<string, mixed>\n     */\n    public function definition(): array\n    {\n        return [\n            'workspace_id' => Workspace::factory(),\n            'user_id' => User::factory(),\n            'name' => $this->faker->words(3, true),\n            'title' => null,\n            'subtitle' => null,\n            'show_all_displays' => true,\n            'theme' => 'dark',\n            'logo' => null,\n            'show_title' => true,\n            'show_booker' => true,\n            'show_next_event' => true,\n            'show_transitioning' => true,\n            'transitioning_minutes' => 10,\n            'font_family' => 'Inter',\n            'language' => 'en',\n            'view_mode' => 'card',\n            'show_meeting_title' => true,\n        ];\n    }\n}\n"
  },
  {
    "path": "backend/database/factories/CalDAVAccountFactory.php",
    "content": "<?php\n\nnamespace Database\\Factories;\n\nuse App\\Enums\\AccountStatus;\nuse App\\Models\\CalDAVAccount;\nuse App\\Models\\User;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\n\n/**\n * @extends \\Illuminate\\Database\\Eloquent\\Factories\\Factory<\\App\\Models\\CalDAVAccount>\n */\nclass CalDAVAccountFactory extends Factory\n{\n    /**\n     * The name of the factory's corresponding model.\n     *\n     * @var string\n     */\n    protected $model = CalDAVAccount::class;\n\n    /**\n     * Define the model's default state.\n     *\n     * @return array<string, mixed>\n     */\n    public function definition(): array\n    {\n        return [\n            'user_id' => User::factory(),\n            'name' => $this->faker->name(),\n            'email' => $this->faker->unique()->safeEmail(),\n            'avatar' => $this->faker->imageUrl(),\n            'status' => AccountStatus::CONNECTED,\n            'url' => $this->faker->url(),\n            'username' => $this->faker->userName(),\n            'password' => $this->faker->password(),\n        ];\n    }\n}\n"
  },
  {
    "path": "backend/database/factories/CalendarFactory.php",
    "content": "<?php\n\nnamespace Database\\Factories;\n\nuse App\\Models\\Calendar;\nuse App\\Models\\User;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\n\n/**\n * @extends \\Illuminate\\Database\\Eloquent\\Factories\\Factory<\\App\\Models\\Calendar>\n */\nclass CalendarFactory extends Factory\n{\n    /**\n     * The name of the factory's corresponding model.\n     *\n     * @var string\n     */\n    protected $model = Calendar::class;\n\n    /**\n     * Define the model's default state.\n     *\n     * @return array<string, mixed>\n     */\n    public function definition(): array\n    {\n        return [\n            'user_id' => User::factory(),\n            'calendar_id' => $this->faker->uuid(),\n            'name' => $this->faker->word(),\n            'is_primary' => false,\n        ];\n    }\n\n    /**\n     * Indicate that the calendar is primary.\n     */\n    public function primary(): static\n    {\n        return $this->state(fn (array $attributes) => [\n            'is_primary' => true,\n        ]);\n    }\n\n    /**\n     * Indicate that the calendar belongs to an Outlook account.\n     */\n    public function outlook(): static\n    {\n        return $this->state(fn (array $attributes) => [\n            'outlook_account_id' => OutlookAccount::factory(),\n        ]);\n    }\n\n    /**\n     * Indicate that the calendar belongs to a Google account.\n     */\n    public function google(): static\n    {\n        return $this->state(fn (array $attributes) => [\n            'google_account_id' => GoogleAccount::factory(),\n        ]);\n    }\n\n    /**\n     * Indicate that the calendar belongs to a CalDAV account.\n     */\n    public function caldav(): static\n    {\n        return $this->state(fn (array $attributes) => [\n            'caldav_account_id' => CalDAVAccount::factory(),\n        ]);\n    }\n} "
  },
  {
    "path": "backend/database/factories/DeviceFactory.php",
    "content": "<?php\n\nnamespace Database\\Factories;\n\nuse App\\Models\\Device;\nuse App\\Models\\User;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\nuse Illuminate\\Support\\Str;\n\n/**\n * @extends \\Illuminate\\Database\\Eloquent\\Factories\\Factory<\\App\\Models\\Device>\n */\nclass DeviceFactory extends Factory\n{\n    /**\n     * The name of the factory's corresponding model.\n     *\n     * @var string\n     */\n    protected $model = Device::class;\n\n    /**\n     * Define the model's default state.\n     *\n     * @return array<string, mixed>\n     */\n    public function definition(): array\n    {\n        return [\n            'user_id' => User::factory(),\n            'name' => $this->faker->word(),\n            'uid' => Str::random(32),\n        ];\n    }\n} "
  },
  {
    "path": "backend/database/factories/DisplayFactory.php",
    "content": "<?php\n\nnamespace Database\\Factories;\n\nuse App\\Enums\\DisplayStatus;\nuse App\\Models\\Calendar;\nuse App\\Models\\Display;\nuse App\\Models\\User;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\n\n/**\n * @extends \\Illuminate\\Database\\Eloquent\\Factories\\Factory<\\App\\Models\\Display>\n */\nclass DisplayFactory extends Factory\n{\n    /**\n     * The name of the factory's corresponding model.\n     *\n     * @var string\n     */\n    protected $model = Display::class;\n\n    /**\n     * Define the model's default state.\n     *\n     * @return array<string, mixed>\n     */\n    public function definition(): array\n    {\n        return [\n            'user_id' => User::factory(),\n            'calendar_id' => Calendar::factory(),\n            'name' => $this->faker->word(),\n            'display_name' => $this->faker->word(),\n            'status' => DisplayStatus::READY,\n        ];\n    }\n\n    /**\n     * Indicate that the display is active.\n     */\n    public function active(): static\n    {\n        return $this->state(fn (array $attributes) => [\n            'status' => DisplayStatus::ACTIVE,\n        ]);\n    }\n\n    /**\n     * Indicate that the display is deactivated.\n     */\n    public function deactivated(): static\n    {\n        return $this->state(fn (array $attributes) => [\n            'status' => DisplayStatus::DEACTIVATED,\n        ]);\n    }\n} "
  },
  {
    "path": "backend/database/factories/EventSubscriptionFactory.php",
    "content": "<?php\n\nnamespace Database\\Factories;\n\nuse App\\Models\\EventSubscription;\nuse App\\Models\\GoogleAccount;\nuse App\\Models\\OutlookAccount;\nuse App\\Models\\Display;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\n\nclass EventSubscriptionFactory extends Factory\n{\n    protected $model = EventSubscription::class;\n\n    public function definition(): array\n    {\n        return [\n            'subscription_id' => $this->faker->uuid,\n            'resource' => 'me/events',\n            'expiration' => now()->addDays(3),\n            'notification_url' => config('services.azure_ad.webhook_url'),\n            'display_id' => Display::factory(),\n            'outlook_account_id' => null,\n            'google_account_id' => null,\n        ];\n    }\n\n    public function outlook(OutlookAccount $account): self\n    {\n        return $this->state(function (array $attributes) use ($account) {\n            return [\n                'outlook_account_id' => $account->id,\n                'google_account_id' => null,\n            ];\n        });\n    }\n\n    public function google(GoogleAccount $account): self\n    {\n        return $this->state(function (array $attributes) use ($account) {\n            return [\n                'outlook_account_id' => null,\n                'google_account_id' => $account->id,\n            ];\n        });\n    }\n} "
  },
  {
    "path": "backend/database/factories/GoogleAccountFactory.php",
    "content": "<?php\n\nnamespace Database\\Factories;\n\nuse App\\Enums\\AccountStatus;\nuse App\\Models\\GoogleAccount;\nuse App\\Models\\User;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\n\n/**\n * @extends \\Illuminate\\Database\\Eloquent\\Factories\\Factory<\\App\\Models\\GoogleAccount>\n */\nclass GoogleAccountFactory extends Factory\n{\n    /**\n     * The name of the factory's corresponding model.\n     *\n     * @var string\n     */\n    protected $model = GoogleAccount::class;\n\n    /**\n     * Define the model's default state.\n     *\n     * @return array<string, mixed>\n     */\n    public function definition(): array\n    {\n        return [\n            'user_id' => User::factory(),\n            'name' => $this->faker->name(),\n            'email' => $this->faker->unique()->safeEmail(),\n            'avatar' => $this->faker->imageUrl(),\n            'hosted_domain' => null,\n            'status' => AccountStatus::CONNECTED,\n            'google_id' => $this->faker->uuid(),\n            'token' => $this->faker->uuid(),\n            'refresh_token' => $this->faker->uuid(),\n            'token_expires_at' => now()->addHour(),\n        ];\n    }\n\n    /**\n     * Indicate that the account is a business account.\n     */\n    public function business(): static\n    {\n        return $this->state(fn (array $attributes) => [\n            'hosted_domain' => $this->faker->domainName(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "backend/database/factories/InstanceFactory.php",
    "content": "<?php\n\nnamespace Database\\Factories;\n\nuse App\\Models\\Instance;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\n\n/**\n * @extends \\Illuminate\\Database\\Eloquent\\Factories\\Factory<\\App\\Models\\Instance>\n */\nclass InstanceFactory extends Factory\n{\n    /**\n     * The name of the factory's corresponding model.\n     *\n     * @var string\n     */\n    protected $model = Instance::class;\n\n    /**\n     * Define the model's default state.\n     *\n     * @return array<string, mixed>\n     */\n    public function definition(): array\n    {\n        return [\n            'instance_key' => $this->faker->sha1(),\n            'license_key' => null,\n            'license_valid' => false,\n            'license_expires_at' => null,\n            'is_self_hosted' => true,\n            'displays_count' => $this->faker->numberBetween(0, 10),\n            'rooms_count' => $this->faker->numberBetween(0, 5),\n            'boards_count' => null,\n            'users' => [\n                [\n                    'email' => $this->faker->safeEmail(),\n                    'usage_type' => 'personal',\n                ],\n            ],\n            'version' => '1.0.0',\n            'last_validated_at' => now(),\n            'last_heartbeat_at' => now(),\n        ];\n    }\n}\n"
  },
  {
    "path": "backend/database/factories/OutlookAccountFactory.php",
    "content": "<?php\n\nnamespace Database\\Factories;\n\nuse App\\Enums\\AccountStatus;\nuse App\\Models\\OutlookAccount;\nuse App\\Models\\User;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\n\n/**\n * @extends \\Illuminate\\Database\\Eloquent\\Factories\\Factory<\\App\\Models\\OutlookAccount>\n */\nclass OutlookAccountFactory extends Factory\n{\n    /**\n     * The name of the factory's corresponding model.\n     *\n     * @var string\n     */\n    protected $model = OutlookAccount::class;\n\n    /**\n     * Define the model's default state.\n     *\n     * @return array<string, mixed>\n     */\n    public function definition(): array\n    {\n        return [\n            'user_id' => User::factory(),\n            'name' => $this->faker->name(),\n            'email' => $this->faker->unique()->safeEmail(),\n            'avatar' => $this->faker->imageUrl(),\n            'tenant_id' => $this->faker->uuid(),\n            'status' => AccountStatus::CONNECTED,\n            'outlook_id' => $this->faker->uuid(),\n            'token' => $this->faker->uuid(),\n            'refresh_token' => $this->faker->uuid(),\n            'token_expires_at' => now()->addHour(),\n        ];\n    }\n\n    /**\n     * Indicate that the account is a business account.\n     */\n    public function business(): static\n    {\n        return $this->state(fn (array $attributes) => [\n            'tenant_id' => $this->faker->uuid(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "backend/database/factories/RoomFactory.php",
    "content": "<?php\n\nnamespace Database\\Factories;\n\nuse App\\Models\\Room;\nuse App\\Models\\User;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\n\n/**\n * @extends \\Illuminate\\Database\\Eloquent\\Factories\\Factory<\\App\\Models\\Room>\n */\nclass RoomFactory extends Factory\n{\n    /**\n     * The name of the factory's corresponding model.\n     *\n     * @var string\n     */\n    protected $model = Room::class;\n\n    /**\n     * Define the model's default state.\n     *\n     * @return array<string, mixed>\n     */\n    public function definition(): array\n    {\n        return [\n            'user_id' => User::factory(),\n            'name' => $this->faker->word(),\n            'email_address' => $this->faker->unique()->safeEmail(),\n            'calendar_id' => null,\n        ];\n    }\n} "
  },
  {
    "path": "backend/database/factories/UserFactory.php",
    "content": "<?php\n\nnamespace Database\\Factories;\n\nuse App\\Enums\\UsageType;\nuse App\\Enums\\UserStatus;\nuse App\\Models\\OutlookAccount;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\nuse Illuminate\\Support\\Facades\\Hash;\nuse Illuminate\\Support\\Str;\n\n/**\n * @extends \\Illuminate\\Database\\Eloquent\\Factories\\Factory<\\App\\Models\\User>\n */\nclass UserFactory extends Factory\n{\n    /**\n     * The current password being used by the factory.\n     */\n    protected static ?string $password;\n\n    /**\n     * Define the model's default state.\n     *\n     * @return array<string, mixed>\n     */\n    public function definition(): array\n    {\n        return [\n            'name' => $this->faker->name(),\n            'email' => $this->faker->unique()->safeEmail(),\n            'email_verified_at' => now(),\n            'password' => static::$password ??= Hash::make('password'),\n            'remember_token' => Str::random(10),\n            'status' => UserStatus::ONBOARDING,\n            'is_unlimited' => false,\n            'terms_accepted_at' => null,\n        ];\n    }\n\n    /**\n     * Indicate that the model's email address should be unverified.\n     */\n    public function unverified(): static\n    {\n        return $this->state(fn (array $attributes) => [\n            'email_verified_at' => null,\n        ]);\n    }\n\n    /**\n     * Indicate that the user is active and has an Outlook account.\n     */\n    public function active(): static\n    {\n        return $this->state(fn (array $attributes) => [\n            'status' => UserStatus::ACTIVE,\n            'usage_type' => UsageType::PERSONAL,\n            'terms_accepted_at' => now(),\n        ])->afterCreating(function ($user) {\n            OutlookAccount::factory()->create(['user_id' => $user->id]);\n        });\n    }\n}\n"
  },
  {
    "path": "backend/database/factories/WorkspaceFactory.php",
    "content": "<?php\n\nnamespace Database\\Factories;\n\nuse App\\Models\\Workspace;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\n\n/**\n * @extends \\Illuminate\\Database\\Eloquent\\Factories\\Factory<\\App\\Models\\Workspace>\n */\nclass WorkspaceFactory extends Factory\n{\n    /**\n     * The name of the factory's corresponding model.\n     *\n     * @var string\n     */\n    protected $model = Workspace::class;\n\n    /**\n     * Define the model's default state.\n     *\n     * @return array<string, mixed>\n     */\n    public function definition(): array\n    {\n        return [\n            'name' => $this->faker->company() . ' Workspace',\n        ];\n    }\n}\n"
  },
  {
    "path": "backend/database/migrations/2014_10_12_000000_create_users_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('users', function (Blueprint $table) {\n            $table->ulid('id')->primary();\n            $table->string('name');\n            $table->string('email')->unique();\n            $table->timestamp('email_verified_at')->nullable();\n            $table->string('password')->nullable();\n            $table->string('microsoft_id')->nullable();\n            $table->string('status')->nullable();\n            $table->rememberToken();\n            $table->timestamps();\n            $table->softDeletes();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('users');\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2014_10_12_100000_create_password_reset_tokens_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('password_reset_tokens', function (Blueprint $table) {\n            $table->string('email')->primary();\n            $table->string('token');\n            $table->timestamp('created_at')->nullable();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('password_reset_tokens');\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2017_07_06_000000_create_table_magic_links.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nclass CreateTableMagicLinks extends Migration\n{\n    /**\n     * Run the migrations.\n     *\n     * @return void\n     */\n    public function up()\n    {\n        Schema::create(config('magiclink.magiclink_table', 'magic_links'), function (Blueprint $table) {\n            $table->uuid('id')->primary();\n            $table->string('token', 255);\n            $table->text('action');\n            $table->unsignedTinyInteger('num_visits')->default(0);\n            $table->unsignedTinyInteger('max_visits')->nullable();\n            $table->timestamp('available_at')->nullable();\n            $table->timestamps();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     *\n     * @return void\n     */\n    public function down()\n    {\n        Schema::dropIfExists(config('magiclink.magiclink_table', 'magic_links'));\n    }\n}\n"
  },
  {
    "path": "backend/database/migrations/2019_08_19_000000_create_failed_jobs_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('failed_jobs', function (Blueprint $table) {\n            $table->id();\n            $table->string('uuid')->unique();\n            $table->text('connection');\n            $table->text('queue');\n            $table->longText('payload');\n            $table->longText('exception');\n            $table->timestamp('failed_at')->useCurrent();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('failed_jobs');\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('personal_access_tokens', function (Blueprint $table) {\n            $table->ulid('id')->primary();\n            $table->ulidMorphs('tokenable');\n            $table->string('name');\n            $table->string('token', 64)->unique();\n            $table->text('abilities')->nullable();\n            $table->timestamp('last_used_at')->nullable();\n            $table->timestamp('expires_at')->nullable();\n            $table->timestamps();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('personal_access_tokens');\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2021_03_06_211907_add_access_code_to_magic_links_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nclass AddAccessCodeToMagicLinksTable extends Migration\n{\n    /**\n     * Run the migrations.\n     *\n     * @return void\n     */\n    public function up()\n    {\n        Schema::table('magic_links', function (Blueprint $table) {\n            $table->string('access_code')->nullable();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     *\n     * @return void\n     */\n    public function down()\n    {\n        if (Schema::hasColumn('magic_links', 'access_code')) {\n            Schema::table('magic_links', function (Blueprint $table) {\n                $table->dropColumn('access_code');\n            });\n        }\n    }\n}\n"
  },
  {
    "path": "backend/database/migrations/2024_03_19_000000_add_usage_type_to_users_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('users', function (Blueprint $table) {\n            $table->string('usage_type')->nullable()->after('status');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('users', function (Blueprint $table) {\n            $table->dropColumn('usage_type');\n        });\n    }\n}; "
  },
  {
    "path": "backend/database/migrations/2024_10_08_193424_create_outlook_accounts_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('outlook_accounts', function (Blueprint $table) {\n            $table->ulid('id')->primary();\n            $table->foreignUlid('user_id')->constrained()->onDelete('cascade'); // Link to users table\n            $table->string('outlook_id')->unique(); // Unique ID from Microsoft (Outlook)\n            $table->string('email')->unique(); // The user's Outlook email\n            $table->string('name')->nullable(); // Optional: The user's display name\n            $table->text('avatar')->nullable(); // Optional: The user's avatar image\n            $table->text('token'); // OAuth access token\n            $table->text('refresh_token')->nullable(); // Optional: OAuth refresh token\n            $table->timestamp('token_expires_at')->nullable(); // Expiry time for the token\n            $table->timestamps(); // Laravel default: created_at and updated_at\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('outlook_accounts');\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2024_10_08_193455_create_calendars_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('calendars', function (Blueprint $table) {\n            $table->ulid('id')->primary();\n            $table->foreignUlid('user_id')->constrained()->onDelete('cascade'); // Link to the user who owns the calendar\n            $table->foreignUlid('outlook_account_id')->nullable()->constrained()->onDelete('cascade'); // Link to OutlookAccount\n            $table->string('calendar_id')->unique(); // External calendar ID\n            $table->string('name'); // Name of the calendar (e.g., \"Work\", \"Personal\")\n            $table->boolean('is_primary')->default(false); // Whether it's the user's primary calendar\n            $table->timestamps(); // Laravel default: created_at and updated_at\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('calendars');\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2024_10_12_203020_create_displays_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('displays', function (Blueprint $table) {\n            $table->ulid('id')->primary();\n            $table->foreignUlid('user_id')->constrained()->onDelete('cascade'); // Link to the user performing the sync\n            $table->foreignUlid('calendar_id')->constrained('calendars')->onDelete('cascade'); // Link to the calendar being synced\n            $table->string('name');\n            $table->string('display_name');\n            $table->string('status')->nullable();\n            $table->timestamp('last_sync_at', 6)->nullable();\n            $table->timestamp('last_event_at', 6)->nullable();\n            $table->timestamps(); // Laravel default: created_at and updated_at\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('displays');\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2024_10_17_212003_create_event_subscriptions_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('event_subscriptions', function (Blueprint $table) {\n            $table->ulid('id')->primary();\n            $table->string('subscription_id')->unique();  // Unique ID of the subscription from Microsoft Graph\n            $table->string('resource');                   // The resource the subscription is for (e.g., 'me/events')\n            $table->timestamp('expiration')->nullable();  // Expiration time of the subscription\n            $table->string('notification_url');           // URL where the notifications will be sent\n            $table->foreignUlid('display_id')->constrained()->onDelete('cascade');\n            $table->foreignUlid('outlook_account_id')->constrained()->onDelete('cascade');\n            $table->timestamps();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('event_subscriptions');\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2025_01_12_122905_create_devices_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('devices', function (Blueprint $table) {\n            $table->ulid('id')->primary();\n            $table->foreignUlid('user_id')->constrained()->onDelete('cascade'); // Link to the user performing the sync\n            $table->foreignUlid('display_id')->nullable()->constrained()->onDelete('cascade'); // Link to the calendar being synced\n            $table->string('name');\n            $table->timestamps(); // Laravel default: created_at and updated_at\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('devices');\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2025_01_12_190259_create_rooms_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('rooms', function (Blueprint $table) {\n            $table->ulid('id')->primary();\n            $table->foreignUlid('user_id')->constrained()->onDelete('cascade');\n            $table->foreignUlid('calendar_id')->constrained()->onDelete('cascade');\n            $table->string('name');\n            $table->string('email_address');\n            $table->timestamps();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('rooms');\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2025_05_04_204354_remove_unique_from_outlook_accounts.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('outlook_accounts', function (Blueprint $table) {\n            $table->dropUnique(['outlook_id']);\n            $table->dropUnique(['email']);\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('outlook_accounts', function (Blueprint $table) {\n            $table->unique('outlook_id');\n            $table->unique('email');\n        });\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2025_05_07_181029_create_sessions_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('sessions', function (Blueprint $table) {\n            $table->string('id')->primary();\n            $table->foreignId('user_id')->nullable()->index();\n            $table->string('ip_address', 45)->nullable();\n            $table->text('user_agent')->nullable();\n            $table->longText('payload');\n            $table->integer('last_activity')->index();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('sessions');\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2025_05_07_181034_create_cache_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('cache', function (Blueprint $table) {\n            $table->string('key')->primary();\n            $table->mediumText('value');\n            $table->integer('expiration');\n        });\n\n        Schema::create('cache_locks', function (Blueprint $table) {\n            $table->string('key')->primary();\n            $table->string('owner');\n            $table->integer('expiration');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('cache');\n        Schema::dropIfExists('cache_locks');\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2025_05_17_130507_create_google_accounts_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    public function up(): void\n    {\n        Schema::create('google_accounts', function (Blueprint $table) {\n            $table->ulid('id')->primary();\n            $table->foreignUlid('user_id')->constrained()->onDelete('cascade'); // Link to users table\n            $table->string('google_id')->unique();\n            $table->string('name');\n            $table->string('email')->unique();\n            $table->string('avatar')->nullable();\n            $table->text('token');\n            $table->text('refresh_token');\n            $table->timestamp('token_expires_at');\n            $table->timestamps();\n        });\n    }\n\n    public function down(): void\n    {\n        Schema::dropIfExists('google_accounts');\n    }\n}; "
  },
  {
    "path": "backend/database/migrations/2025_05_17_153857_add_google_account_id_to_calendars_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('calendars', function (Blueprint $table) {\n            $table->foreignUlid('google_account_id')->nullable()->after('outlook_account_id')->constrained()->onDelete('cascade'); // Link to GoogleAccount\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('calendars', function (Blueprint $table) {\n            $table->dropForeign(['outlook_account_id']);\n            $table->dropColumn('outlook_account_id');\n        });\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2025_05_18_010101_remove_unique_from_google_accounts.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('google_accounts', function (Blueprint $table) {\n            $table->dropUnique(['google_id']);\n            $table->dropUnique(['email']);\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('google_accounts', function (Blueprint $table) {\n            $table->unique('google_id');\n            $table->unique('email');\n        });\n    }\n}; "
  },
  {
    "path": "backend/database/migrations/2025_05_18_010201_remove_unique_from_calendars.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('calendars', function (Blueprint $table) {\n            $table->dropUnique(['calendar_id']);\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('calendars', function (Blueprint $table) {\n            $table->unique('calendar_id');\n        });\n    }\n}; "
  },
  {
    "path": "backend/database/migrations/2025_05_18_114502_add_status_to_accounts.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\nuse App\\Enums\\AccountStatus;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('google_accounts', function (Blueprint $table) {\n            $table->string('status')->default(AccountStatus::CONNECTED)->after('email');\n        });\n\n        Schema::table('outlook_accounts', function (Blueprint $table) {\n            $table->string('status')->default(AccountStatus::CONNECTED)->after('email');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('google_accounts', function (Blueprint $table) {\n            $table->dropColumn('status');\n        });\n\n        Schema::table('outlook_accounts', function (Blueprint $table) {\n            $table->dropColumn('status');\n        });\n    }\n}; "
  },
  {
    "path": "backend/database/migrations/2025_05_21_000000_add_google_account_id_to_event_subscriptions.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('event_subscriptions', function (Blueprint $table) {\n            $table->foreignUlid('google_account_id')->nullable()->after('outlook_account_id')\n                ->constrained()->onDelete('cascade');\n        });\n        Schema::table('event_subscriptions', function (Blueprint $table) {\n            $table->foreignUlid('outlook_account_id')->nullable(true)->change();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('event_subscriptions', function (Blueprint $table) {\n            $table->dropForeign(['google_account_id']);\n            $table->dropColumn('google_account_id');\n        });\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2025_05_23_000000_create_caldav_accounts_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\nuse App\\Enums\\AccountStatus;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('caldav_accounts', function (Blueprint $table) {\n            $table->ulid('id')->primary();\n            $table->foreignUlid('user_id')->constrained()->onDelete('cascade');\n            $table->string('name');\n            $table->string('email');\n            $table->string('avatar')->nullable();\n            $table->string('status')->default(AccountStatus::CONNECTED);\n            $table->string('url');\n            $table->string('username');\n            $table->string('password');\n            $table->timestamps();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('caldav_accounts');\n    }\n}; "
  },
  {
    "path": "backend/database/migrations/2025_05_23_000001_add_caldav_account_id_to_calendars_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('calendars', function (Blueprint $table) {\n            $table->foreignUlid('caldav_account_id')->nullable()->after('google_account_id')->constrained()->onDelete('cascade');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('calendars', function (Blueprint $table) {\n            $table->dropForeign(['caldav_account_id']);\n            $table->dropColumn('caldav_account_id');\n        });\n    }\n}; "
  },
  {
    "path": "backend/database/migrations/2025_05_23_201433_add_google_id_to_users_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('users', function (Blueprint $table) {\n            $table->string('google_id')->after('microsoft_id')->nullable();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('users', function (Blueprint $table) {\n            $table->dropColumn('google_id');\n        });\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2025_05_27_203928_add_last_activity_at_to_users_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('users', function (Blueprint $table) {\n            $table->timestamp('last_activity_at')->nullable();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('users', function (Blueprint $table) {\n            $table->dropColumn('last_activity_at');\n        });\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2025_05_27_204843_add_last_activity_at_to_devices_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('devices', function (Blueprint $table) {\n            $table->timestamp('last_activity_at')->nullable();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('devices', function (Blueprint $table) {\n            $table->dropColumn('last_activity_at');\n        });\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2025_05_28_193657_add_is_billing_exempt_to_users_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('users', function (Blueprint $table) {\n            $table->boolean('is_billing_exempt')->default(false);\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('users', function (Blueprint $table) {\n            $table->dropColumn('is_billing_exempt');\n        });\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2025_05_28_194845_add_is_unlimited_to_users_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('users', function (Blueprint $table) {\n            $table->boolean('is_unlimited')->default(false);\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('users', function (Blueprint $table) {\n            $table->dropColumn('is_unlimited');\n        });\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2025_06_08_000001_add_terms_accepted_at_to_users_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    public function up(): void\n    {\n        Schema::table('users', function (Blueprint $table) {\n            $table->timestamp('terms_accepted_at')->nullable();\n        });\n    }\n\n    public function down(): void\n    {\n        Schema::table('users', function (Blueprint $table) {\n            $table->dropColumn('terms_accepted_at');\n        });\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2025_06_09_115819_drop_is_billing_exempt_from_users_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('users', function (Blueprint $table) {\n            $table->dropColumn('is_billing_exempt');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('users', function (Blueprint $table) {\n            $table->boolean('is_billing_exempt')->default(false);\n        });\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2025_06_09_122516_add_hosted_domain_to_google_accounts_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('google_accounts', function (Blueprint $table) {\n            $table->string('hosted_domain')->after('avatar')->nullable();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('google_accounts', function (Blueprint $table) {\n            $table->dropColumn('hosted_domain');\n        });\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2025_06_09_122702_add_tenant_id_to_outlook_accounts_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('outlook_accounts', function (Blueprint $table) {\n            $table->string('tenant_id')->after('avatar')->nullable();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('outlook_accounts', function (Blueprint $table) {\n            $table->dropColumn('tenant_id');\n        });\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2025_06_09_125231_add_uid_to_devices_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('devices', function (Blueprint $table) {\n            $table->string('uid')->after('name')->nullable();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('devices', function (Blueprint $table) {\n            $table->dropColumn('uid');\n        });\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2025_06_09_150001_create_instances_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    public function up(): void\n    {\n        Schema::create('instances', function (Blueprint $table) {\n            $table->ulid('id');\n            $table->string('instance_key')->unique();\n            $table->string('license_key')->nullable();\n            $table->boolean('license_valid')->nullable();\n            $table->timestamp('license_expires_at')->nullable();\n            $table->boolean('is_self_hosted')->nullable();\n            $table->integer('displays_count')->nullable();\n            $table->integer('rooms_count')->nullable();\n            $table->json('users')->nullable();\n            $table->string('version')->nullable();\n            $table->timestamp('last_validated_at')->nullable();\n            $table->timestamp('last_heartbeat_at')->nullable();\n            $table->timestamps();\n        });\n    }\n\n    public function down(): void\n    {\n        Schema::dropIfExists('instances');\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2025_06_15_000000_create_settings_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    public function up(): void\n    {\n        Schema::create('settings', function (Blueprint $table) {\n            $table->ulid('id')->primary();\n            $table->string('key')->unique();\n            $table->text('value');\n            $table->string('type')->default('string');\n            $table->timestamps();\n        });\n    }\n\n    public function down(): void\n    {\n        Schema::dropIfExists('settings');\n    }\n}; "
  },
  {
    "path": "backend/database/migrations/2025_06_15_120000_change_billable_id_to_ulid_on_lemonsqueezy_tables.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration {\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        if (config('settings.is_self_hosted')) {\n            return;\n        }\n\n        $connection = Schema::getConnection();\n        $driver = $connection->getDriverName();\n\n        if ($driver === 'sqlite') {\n            // Customers table\n            Schema::table('lemon_squeezy_customers', function (Blueprint $table) {\n                $table->dropUnique('lemon_squeezy_customers_billable_id_billable_type_unique');\n            });\n            Schema::table('lemon_squeezy_customers', function (Blueprint $table) {\n                $table->dropColumn('billable_id');\n            });\n            Schema::table('lemon_squeezy_customers', function (Blueprint $table) {\n                $table->ulid('billable_id')->after('id');\n            });\n\n            // Subscriptions table\n            Schema::table('lemon_squeezy_subscriptions', function (Blueprint $table) {\n                $table->dropColumn('billable_id');\n            });\n            Schema::table('lemon_squeezy_subscriptions', function (Blueprint $table) {\n                $table->ulid('billable_id')->after('id');\n            });\n\n            // Orders table\n            Schema::table('lemon_squeezy_orders', function (Blueprint $table) {\n                $table->dropColumn('billable_id');\n            });\n            Schema::table('lemon_squeezy_orders', function (Blueprint $table) {\n                $table->ulid('billable_id')->after('id');\n            });\n        } else {\n            // Customers table\n            Schema::table('lemon_squeezy_customers', function (Blueprint $table) {\n                $table->ulid('billable_id')->change();\n            });\n            // Subscriptions table\n            Schema::table('lemon_squeezy_subscriptions', function (Blueprint $table) {\n                $table->ulid('billable_id')->change();\n            });\n            // Orders table\n            Schema::table('lemon_squeezy_orders', function (Blueprint $table) {\n                $table->ulid('billable_id')->change();\n            });\n        }\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        if (config('settings.is_self_hosted')) {\n            return;\n        }\n\n        // Customers table\n        Schema::table('lemon_squeezy_customers', function (Blueprint $table) {\n            $table->unsignedBigInteger('billable_id')->change();\n        });\n\n        // Subscriptions table\n        Schema::table('lemon_squeezy_subscriptions', function (Blueprint $table) {\n            $table->unsignedBigInteger('billable_id')->change();\n        });\n\n        // Orders table\n        Schema::table('lemon_squeezy_orders', function (Blueprint $table) {\n            $table->unsignedBigInteger('billable_id')->change();\n        });\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2025_06_16_000000_create_events_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    public function up(): void\n    {\n        Schema::create('events', function (Blueprint $table) {\n            $table->ulid('id')->primary();\n            $table->foreignUlid('display_id')->constrained()->onDelete('cascade');\n            $table->foreignUlid('user_id')->constrained()->onDelete('cascade');\n            $table->foreignUlid('calendar_id')->nullable()->constrained()->onDelete('cascade');\n            $table->string('status');\n            $table->dateTime('start');\n            $table->dateTime('end');\n            $table->text('summary')->nullable();\n            $table->string('location')->nullable();\n            $table->text('description')->nullable();\n            $table->string('timezone');\n            $table->string('source');\n            $table->string('external_id')->nullable();\n\n            // Check-in functionality\n            $table->timestamp('checked_in_at')->nullable();\n\n            // Audit logging\n            $table->timestamps();\n\n            // Indexes for performance\n            $table->index(['display_id', 'start', 'end']);\n            $table->index(['external_id', 'source']);\n            $table->index(['calendar_id', 'start']);\n        });\n    }\n\n    public function down(): void\n    {\n        Schema::dropIfExists('events');\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2025_07_05_000000_create_display_settings_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('display_settings', function (Blueprint $table) {\n            $table->ulid('id')->primary();\n            $table->foreignUlid('display_id')->constrained()->onDelete('cascade');\n            $table->string('key');\n            $table->text('value');\n            $table->string('type')->default('string');\n            $table->timestamps();\n            \n            // Ensure unique settings per display\n            $table->unique(['display_id', 'key']);\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('display_settings');\n    }\n}; "
  },
  {
    "path": "backend/database/migrations/2025_07_05_000001_alter_avatar_column_on_google_accounts_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration {\n    public function up(): void\n    {\n        Schema::table('google_accounts', function (Blueprint $table) {\n            $table->text('avatar')->change();\n        });\n    }\n\n    public function down(): void\n    {\n        Schema::table('google_accounts', function (Blueprint $table) {\n            $table->string('avatar', 255)->change();\n        });\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2025_07_27_000000_add_is_admin_to_users_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('users', function (Blueprint $table) {\n            $table->boolean('is_admin')->default(false)->nullable()->after('is_unlimited');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('users', function (Blueprint $table) {\n            $table->dropColumn('is_admin');\n        });\n    }\n}; "
  },
  {
    "path": "backend/database/migrations/2025_11_28_000000_add_permission_type_to_outlook_accounts_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\nuse App\\Enums\\PermissionType;\n\nreturn new class extends Migration {\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('outlook_accounts', function (Blueprint $table) {\n            $table->string('permission_type')->default(PermissionType::READ)->after('status');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('outlook_accounts', function (Blueprint $table) {\n            $table->dropColumn('permission_type');\n        });\n    }\n};\n\n"
  },
  {
    "path": "backend/database/migrations/2025_11_28_000001_add_permission_type_to_google_accounts_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\nuse App\\Enums\\PermissionType;\n\nreturn new class extends Migration {\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('google_accounts', function (Blueprint $table) {\n            $table->string('permission_type')->default(PermissionType::READ)->after('status');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('google_accounts', function (Blueprint $table) {\n            $table->dropColumn('permission_type');\n        });\n    }\n};\n\n"
  },
  {
    "path": "backend/database/migrations/2025_11_28_000002_add_permission_type_to_caldav_accounts_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\nuse App\\Enums\\PermissionType;\n\nreturn new class extends Migration {\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('caldav_accounts', function (Blueprint $table) {\n            $table->string('permission_type')->default(PermissionType::WRITE)->after('status');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('caldav_accounts', function (Blueprint $table) {\n            $table->dropColumn('permission_type');\n        });\n    }\n};\n\n"
  },
  {
    "path": "backend/database/migrations/2025_12_03_000000_add_service_account_file_path_to_google_accounts_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration {\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('google_accounts', function (Blueprint $table) {\n            $table->string('service_account_file_path')->nullable()->after('permission_type');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('google_accounts', function (Blueprint $table) {\n            $table->dropColumn('service_account_file_path');\n        });\n    }\n};\n\n"
  },
  {
    "path": "backend/database/migrations/2025_12_04_000000_add_booking_method_to_google_accounts_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration {\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('google_accounts', function (Blueprint $table) {\n            $table->string('booking_method')->nullable()->after('permission_type');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('google_accounts', function (Blueprint $table) {\n            $table->dropColumn('booking_method');\n        });\n    }\n};\n\n"
  },
  {
    "path": "backend/database/migrations/2025_12_05_000000_encrypt_existing_tokens_in_google_and_outlook_accounts.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Facades\\Crypt;\n\nreturn new class extends Migration {\n    /**\n     * Check if a value is already encrypted by Laravel's encryption.\n     * Laravel encrypted values are base64-encoded JSON with specific structure.\n     */\n    private function isEncrypted(string $value): bool\n    {\n        try {\n            // Try to decrypt - if it succeeds, it's already encrypted\n            Crypt::decryptString($value);\n            return true;\n        } catch (\\Illuminate\\Contracts\\Encryption\\DecryptException $e) {\n            // Decryption failed, so it's not encrypted\n            return false;\n        } catch (\\Exception $e) {\n            // Other exceptions (like invalid base64) mean it's not encrypted\n            return false;\n        }\n    }\n\n    /**\n     * Run the migrations.\n     * \n     * This migration encrypts existing tokens and refresh tokens in google_accounts\n     * and outlook_accounts tables. It checks if tokens are already encrypted by\n     * attempting to decrypt them. If decryption fails, the token is encrypted.\n     */\n    public function up(): void\n    {\n        // Encrypt Google accounts tokens\n        $googleAccounts = DB::table('google_accounts')->get();\n        \n        foreach ($googleAccounts as $account) {\n            $updates = [];\n            \n            // Encrypt token if not already encrypted\n            if (!empty($account->token) && !$this->isEncrypted($account->token)) {\n                $updates['token'] = Crypt::encryptString($account->token);\n            }\n            \n            // Encrypt refresh_token if not already encrypted\n            if (!empty($account->refresh_token) && !$this->isEncrypted($account->refresh_token)) {\n                $updates['refresh_token'] = Crypt::encryptString($account->refresh_token);\n            }\n            \n            // Update only if there are changes\n            if (!empty($updates)) {\n                DB::table('google_accounts')\n                    ->where('id', $account->id)\n                    ->update($updates);\n            }\n        }\n        \n        // Encrypt Outlook accounts tokens\n        $outlookAccounts = DB::table('outlook_accounts')->get();\n        \n        foreach ($outlookAccounts as $account) {\n            $updates = [];\n            \n            // Encrypt token if not already encrypted\n            if (!empty($account->token) && !$this->isEncrypted($account->token)) {\n                $updates['token'] = Crypt::encryptString($account->token);\n            }\n            \n            // Encrypt refresh_token if not already encrypted\n            if (!empty($account->refresh_token) && !$this->isEncrypted($account->refresh_token)) {\n                $updates['refresh_token'] = Crypt::encryptString($account->refresh_token);\n            }\n            \n            // Update only if there are changes\n            if (!empty($updates)) {\n                DB::table('outlook_accounts')\n                    ->where('id', $account->id)\n                    ->update($updates);\n            }\n        }\n    }\n\n    /**\n     * Reverse the migrations.\n     * \n     * WARNING: This will decrypt all tokens. Only use this if you need to rollback\n     * and understand the security implications.\n     */\n    public function down(): void\n    {\n        // Decrypt Google accounts tokens\n        $googleAccounts = DB::table('google_accounts')->get();\n        \n        foreach ($googleAccounts as $account) {\n            $updates = [];\n            \n            // Decrypt token if encrypted\n            if (!empty($account->token)) {\n                try {\n                    $decrypted = Crypt::decryptString($account->token);\n                    $updates['token'] = $decrypted;\n                } catch (\\Exception $e) {\n                    // Already decrypted or invalid, skip\n                }\n            }\n            \n            // Decrypt refresh_token if encrypted\n            if (!empty($account->refresh_token)) {\n                try {\n                    $decrypted = Crypt::decryptString($account->refresh_token);\n                    $updates['refresh_token'] = $decrypted;\n                } catch (\\Exception $e) {\n                    // Already decrypted or invalid, skip\n                }\n            }\n            \n            // Update only if there are changes\n            if (!empty($updates)) {\n                DB::table('google_accounts')\n                    ->where('id', $account->id)\n                    ->update($updates);\n            }\n        }\n        \n        // Decrypt Outlook accounts tokens\n        $outlookAccounts = DB::table('outlook_accounts')->get();\n        \n        foreach ($outlookAccounts as $account) {\n            $updates = [];\n            \n            // Decrypt token if encrypted\n            if (!empty($account->token)) {\n                try {\n                    $decrypted = Crypt::decryptString($account->token);\n                    $updates['token'] = $decrypted;\n                } catch (\\Exception $e) {\n                    // Already decrypted or invalid, skip\n                }\n            }\n            \n            // Decrypt refresh_token if encrypted\n            if (!empty($account->refresh_token)) {\n                try {\n                    $decrypted = Crypt::decryptString($account->refresh_token);\n                    $updates['refresh_token'] = $decrypted;\n                } catch (\\Exception $e) {\n                    // Already decrypted or invalid, skip\n                }\n            }\n            \n            // Update only if there are changes\n            if (!empty($updates)) {\n                DB::table('outlook_accounts')\n                    ->where('id', $account->id)\n                    ->update($updates);\n            }\n        }\n    }\n};\n\n"
  },
  {
    "path": "backend/database/migrations/2025_12_06_000003_add_first_name_and_last_name_to_users_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('users', function (Blueprint $table) {\n            $table->string('first_name')->nullable()->after('name');\n        });\n\n        Schema::table('users', function (Blueprint $table) {\n            $table->string('last_name')->nullable()->after('first_name');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('users', function (Blueprint $table) {\n            $table->dropColumn(['first_name', 'last_name']);\n        });\n    }\n};\n\n"
  },
  {
    "path": "backend/database/migrations/2025_12_30_000000_create_workspaces_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('workspaces', function (Blueprint $table) {\n            $table->ulid('id')->primary();\n            $table->string('name');\n            $table->timestamps();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('workspaces');\n    }\n};\n\n"
  },
  {
    "path": "backend/database/migrations/2025_12_30_000001_create_workspace_members_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('workspace_members', function (Blueprint $table) {\n            $table->ulid('id')->primary();\n            $table->foreignUlid('workspace_id')->constrained()->onDelete('cascade');\n            $table->foreignUlid('user_id')->constrained()->onDelete('cascade');\n            $table->string('role')->default('member'); // Uses WorkspaceRole enum: 'owner', 'admin', 'member'\n            $table->timestamps();\n\n            // Ensure a user can only be a member once per workspace\n            $table->unique(['workspace_id', 'user_id']);\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('workspace_members');\n    }\n};\n\n"
  },
  {
    "path": "backend/database/migrations/2025_12_30_000002_add_workspace_id_to_tables.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        // Add workspace_id to displays\n        Schema::table('displays', function (Blueprint $table) {\n            $table->foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade');\n        });\n\n        // Add workspace_id to devices\n        Schema::table('devices', function (Blueprint $table) {\n            $table->foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade');\n        });\n\n        // Add workspace_id to calendars\n        Schema::table('calendars', function (Blueprint $table) {\n            $table->foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade');\n        });\n\n        // Add workspace_id to rooms\n        Schema::table('rooms', function (Blueprint $table) {\n            $table->foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('displays', function (Blueprint $table) {\n            $table->dropForeign(['workspace_id']);\n            $table->dropColumn('workspace_id');\n        });\n\n        Schema::table('devices', function (Blueprint $table) {\n            $table->dropForeign(['workspace_id']);\n            $table->dropColumn('workspace_id');\n        });\n\n        Schema::table('calendars', function (Blueprint $table) {\n            $table->dropForeign(['workspace_id']);\n            $table->dropColumn('workspace_id');\n        });\n\n        Schema::table('rooms', function (Blueprint $table) {\n            $table->dropForeign(['workspace_id']);\n            $table->dropColumn('workspace_id');\n        });\n    }\n};\n\n"
  },
  {
    "path": "backend/database/migrations/2025_12_30_000003_add_workspace_id_to_accounts_tables.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        // Add workspace_id to outlook_accounts\n        Schema::table('outlook_accounts', function (Blueprint $table) {\n            $table->foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade');\n        });\n\n        // Add workspace_id to google_accounts\n        Schema::table('google_accounts', function (Blueprint $table) {\n            $table->foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade');\n        });\n\n        // Add workspace_id to caldav_accounts\n        Schema::table('caldav_accounts', function (Blueprint $table) {\n            $table->foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('outlook_accounts', function (Blueprint $table) {\n            $table->dropForeign(['workspace_id']);\n            $table->dropColumn('workspace_id');\n        });\n\n        Schema::table('google_accounts', function (Blueprint $table) {\n            $table->dropForeign(['workspace_id']);\n            $table->dropColumn('workspace_id');\n        });\n\n        Schema::table('caldav_accounts', function (Blueprint $table) {\n            $table->dropForeign(['workspace_id']);\n            $table->dropColumn('workspace_id');\n        });\n    }\n};\n\n"
  },
  {
    "path": "backend/database/migrations/2025_12_30_000004_create_workspaces_for_existing_users.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\nuse Illuminate\\Support\\Facades\\DB;\nuse App\\Models\\User;\nuse App\\Models\\Workspace;\nuse App\\Models\\WorkspaceMember;\nuse App\\Models\\Display;\nuse App\\Models\\Device;\nuse App\\Models\\Calendar;\nuse App\\Models\\Room;\nuse App\\Models\\OutlookAccount;\nuse App\\Models\\GoogleAccount;\nuse App\\Models\\CalDAVAccount;\nuse App\\Enums\\WorkspaceRole;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        // Create a workspace for each existing user and migrate their data\n        User::chunk(100, function ($users) {\n            foreach ($users as $user) {\n                // Skip if user already has a workspace\n                if ($user->workspaces()->exists()) {\n                    continue;\n                }\n\n                // Wrap per-user migration logic in a transaction for atomicity\n                DB::transaction(function () use ($user) {\n                    // Create workspace for user\n                    $workspace = Workspace::create([\n                        'name' => $user->name . \"'s Workspace\",\n                    ]);\n\n                    // Add user as owner member (use WorkspaceMember::create to generate ULID)\n                    WorkspaceMember::create([\n                        'workspace_id' => $workspace->id,\n                        'user_id' => $user->id,\n                        'role' => WorkspaceRole::OWNER,\n                    ]);\n\n                    // Migrate displays to workspace\n                    Display::where('user_id', $user->id)->update(['workspace_id' => $workspace->id]);\n\n                    // Migrate devices to workspace\n                    Device::where('user_id', $user->id)->update(['workspace_id' => $workspace->id]);\n\n                    // Migrate calendars to workspace\n                    Calendar::where('user_id', $user->id)->update(['workspace_id' => $workspace->id]);\n\n                    // Migrate rooms to workspace\n                    Room::where('user_id', $user->id)->update(['workspace_id' => $workspace->id]);\n\n                    // Migrate Outlook accounts to workspace\n                    OutlookAccount::where('user_id', $user->id)\n                        ->whereNull('workspace_id')\n                        ->update(['workspace_id' => $workspace->id]);\n\n                    // Migrate Google accounts to workspace\n                    GoogleAccount::where('user_id', $user->id)\n                        ->whereNull('workspace_id')\n                        ->update(['workspace_id' => $workspace->id]);\n\n                    // Migrate CalDAV accounts to workspace\n                    CalDAVAccount::where('user_id', $user->id)\n                        ->whereNull('workspace_id')\n                        ->update(['workspace_id' => $workspace->id]);\n                });\n            }\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        // This migration cannot be fully reversed as we don't know which workspace\n        // data belongs to which user after potential member additions.\n        // In practice, you'd need to keep the user_id relationships intact.\n    }\n};\n\n"
  },
  {
    "path": "backend/database/migrations/2026_02_28_000000_increase_events_description_column_size.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('events', function (Blueprint $table) {\n            $table->mediumText('description')->nullable()->change();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('events', function (Blueprint $table) {\n            $table->text('description')->nullable()->change();\n        });\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2026_02_28_000001_increase_caldav_accounts_password_column_size.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('caldav_accounts', function (Blueprint $table) {\n            $table->text('password')->change();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('caldav_accounts', function (Blueprint $table) {\n            $table->string('password')->change();\n        });\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2026_02_28_120000_create_boards_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('boards', function (Blueprint $table) {\n            $table->ulid('id')->primary();\n            $table->foreignUlid('workspace_id')->constrained()->onDelete('cascade');\n            $table->foreignUlid('user_id')->nullable()->constrained()->nullOnDelete();\n            $table->string('name');\n            $table->boolean('show_all_displays')->default(false);\n            $table->timestamps();\n            \n            $table->index('workspace_id');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('boards');\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2026_02_28_120001_create_board_displays_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('board_displays', function (Blueprint $table) {\n            $table->ulid('id')->primary();\n            $table->foreignUlid('board_id')->constrained('boards')->onDelete('cascade');\n            $table->foreignUlid('display_id')->constrained()->onDelete('cascade');\n            $table->timestamps();\n            \n            $table->unique(['board_id', 'display_id']);\n            $table->index('board_id');\n            $table->index('display_id');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('board_displays');\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2026_02_28_120002_add_theme_to_boards_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('boards', function (Blueprint $table) {\n            $table->string('theme')->default('dark')->after('show_all_displays');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('boards', function (Blueprint $table) {\n            $table->dropColumn('theme');\n        });\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2026_02_28_120003_add_logo_to_boards_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('boards', function (Blueprint $table) {\n            $table->string('logo')->nullable()->after('theme');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('boards', function (Blueprint $table) {\n            $table->dropColumn('logo');\n        });\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2026_02_28_120004_add_display_options_to_boards_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('boards', function (Blueprint $table) {\n            $table->boolean('show_title')->default(true)->after('logo');\n            $table->boolean('show_booker')->default(true)->after('show_title');\n            $table->boolean('show_next_event')->default(true)->after('show_booker');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('boards', function (Blueprint $table) {\n            $table->dropColumn(['show_title', 'show_booker', 'show_next_event']);\n        });\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2026_02_28_120005_add_additional_settings_to_boards_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('boards', function (Blueprint $table) {\n            $table->boolean('show_transitioning')->default(true)->after('show_next_event');\n            $table->integer('transitioning_minutes')->default(10)->after('show_transitioning');\n            $table->string('font_family')->default('Inter')->after('transitioning_minutes');\n            $table->string('language')->default('en')->after('font_family');\n            $table->boolean('show_meeting_title')->default(true)->after('language');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('boards', function (Blueprint $table) {\n            $table->dropColumn([\n                'show_transitioning',\n                'transitioning_minutes',\n                'font_family',\n                'language',\n                'show_meeting_title',\n            ]);\n        });\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2026_02_28_120007_add_title_and_subtitle_to_boards_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('boards', function (Blueprint $table) {\n            $table->string('title')->nullable()->after('name');\n            $table->string('subtitle')->nullable()->after('title');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('boards', function (Blueprint $table) {\n            $table->dropColumn(['title', 'subtitle']);\n        });\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2026_02_28_120008_add_view_mode_to_boards_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('boards', function (Blueprint $table) {\n            $table->string('view_mode')->default('card')->after('language');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('boards', function (Blueprint $table) {\n            $table->dropColumn('view_mode');\n        });\n    }\n};\n"
  },
  {
    "path": "backend/database/migrations/2026_02_28_140000_add_boards_count_to_instances_table.php",
    "content": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('instances', function (Blueprint $table) {\n            $table->integer('boards_count')->nullable()->after('rooms_count');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('instances', function (Blueprint $table) {\n            $table->dropColumn('boards_count');\n        });\n    }\n};\n"
  },
  {
    "path": "backend/database/seeders/DatabaseSeeder.php",
    "content": "<?php\n\nnamespace Database\\Seeders;\n\n// use Illuminate\\Database\\Console\\Seeds\\WithoutModelEvents;\nuse App\\Models\\Plan;\nuse Illuminate\\Database\\Seeder;\n\nclass DatabaseSeeder extends Seeder\n{\n    /**\n     * Seed the application's database.\n     */\n    public function run(): void\n    {\n        // \\App\\Models\\User::factory(10)->create();\n\n        // \\App\\Models\\User::factory()->create([\n        //     'name' => 'Test User',\n        //     'email' => 'test@example.com',\n        // ]);\n    }\n}\n"
  },
  {
    "path": "backend/docs/CODING_STANDARDS.md",
    "content": "# Coding Standards\n\nThis document outlines coding standards and best practices for the Spacepad backend codebase.\n\n## Import Statements\n\n**Always use import statements at the top of files instead of inline fully qualified class names.**\n\n### ✅ Correct\n\n```php\n<?php\n\nnamespace App\\Http\\Controllers;\n\nuse App\\Models\\Display;\nuse App\\Models\\User;\nuse App\\Services\\InstanceService;\n\nclass DashboardController extends Controller\n{\n    public function __invoke()\n    {\n        $displays = Display::where('status', 'active')->get();\n        $user = User::find(1);\n        $isValid = InstanceService::hasValidLicense();\n    }\n}\n```\n\n### ❌ Incorrect\n\n```php\n<?php\n\nnamespace App\\Http\\Controllers;\n\nclass DashboardController extends Controller\n{\n    public function __invoke()\n    {\n        $displays = \\App\\Models\\Display::where('status', 'active')->get();\n        $user = \\App\\Models\\User::find(1);\n        $isValid = \\App\\Services\\InstanceService::hasValidLicense();\n    }\n}\n```\n\n### Why?\n\n- **Readability**: Import statements make it clear which classes are used in a file\n- **Maintainability**: Easier to refactor and understand dependencies\n- **IDE Support**: Better autocomplete and navigation\n- **PSR Standards**: Follows PSR-12 coding standard\n- **Consistency**: Matches Laravel conventions\n\n### When to Use Fully Qualified Names\n\nOnly use fully qualified class names (`\\App\\Models\\...`) when:\n- There's a naming conflict that requires disambiguation\n- You're using a class from a different namespace that's not commonly imported\n\nIn all other cases, use `use` statements at the top of the file.\n\n"
  },
  {
    "path": "backend/docs/WORKSPACE_SETUP.md",
    "content": "# Workspace System Documentation\n\n## Overview\n\nThe workspace system allows multiple users to collaborate on managing displays, devices, calendars, and rooms. Each user automatically gets their own workspace, and Pro users can invite colleagues to join their workspace.\n\n## Architecture\n\n### Models\n\n1. **Workspace** - Represents a team/workspace\n   - Has an `owner` (User)\n   - Has many `members` (Users with roles)\n   - Contains displays, devices, calendars, rooms\n\n2. **WorkspaceMember** - Pivot table linking users to workspaces\n   - Roles: `owner`, `admin`, `member`\n   - `owner` role is implicit for the workspace owner\n\n### Relationships\n\n- **User** → **Workspace** (one-to-many: owned workspaces)\n- **User** ↔ **Workspace** (many-to-many: member workspaces)\n- **Workspace** → **Display** (one-to-many)\n- **Workspace** → **Device** (one-to-many)\n- **Workspace** → **Calendar** (one-to-many)\n- **Workspace** → **Room** (one-to-many)\n\n## Migration Strategy\n\n1. **Existing Users**: Each user automatically gets a workspace created with their name\n2. **Existing Data**: All displays, devices, calendars, and rooms are migrated to the user's workspace\n3. **Backward Compatibility**: The `user_id` field is kept for backward compatibility\n\n## Permissions\n\n### Workspace Roles\n\n- **Owner**: Full control (can delete workspace, manage all members)\n- **Admin**: Can manage members and workspace settings\n- **Member**: Can view and use workspace resources\n\n### Display Access\n\n- Users can access displays they own directly (`user_id`)\n- Users can access displays in workspaces they're members of (`workspace_id`)\n- Device authentication checks workspace membership\n\n## Usage\n\n### Adding a Colleague\n\n1. Navigate to workspace settings (requires Pro)\n2. Enter colleague's email address\n3. Select role (admin or member)\n4. Colleague receives access to all workspace resources\n\n### Managing Members\n\n- **Add Member**: Only owners/admins can add members\n- **Update Role**: Change member role between admin/member\n- **Remove Member**: Remove access from workspace\n\n## API Changes\n\n### DisplayController\n\n- `index()` now returns displays from user's workspace(s)\n- Access checks include workspace membership\n\n### DisplayService\n\n- `validateDisplayPermission()` checks workspace membership\n- Pro features check workspace owner's Pro status\n\n## Frontend Changes Needed\n\n1. **Workspace Management UI**\n   - List workspaces\n   - View workspace members\n   - Add/remove members\n   - Update member roles\n\n2. **Display Creation**\n   - Automatically assign to user's primary workspace\n   - Allow selecting workspace (if user has multiple)\n\n3. **Device Connection**\n   - Connect code should work with workspace\n   - Devices inherit workspace from user\n\n## Migration Commands\n\nRun migrations in order:\n\n```bash\nphp artisan migrate\n```\n\nThe migration `2025_12_30_000003_create_workspaces_for_existing_users.php` will:\n1. Create a workspace for each existing user\n2. Migrate all user's displays, devices, calendars, and rooms to their workspace\n3. Add the user as an owner member\n\n## Notes\n\n- Pro subscription is required to add team members\n- Workspace owner cannot be removed\n- All existing functionality remains backward compatible\n- `user_id` fields are kept for direct ownership tracking\n\n"
  },
  {
    "path": "backend/lang/de/boards.php",
    "content": "<?php\n\nreturn [\n    'meeting_room_overview' => 'Übersicht der Besprechungsräume',\n    'busy' => 'Belegt',\n    'transitioning' => 'Übergang',\n    'check_in' => 'Einchecken',\n    'error' => 'Fehler',\n    'available' => 'Verfügbar',\n    'available_until' => 'Verfügbar bis :time',\n    'available_until_end_of_day' => 'Verfügbar bis Tagesende',\n    'next' => 'Nächste',\n    'room' => 'Raum',\n    'status' => 'Status',\n    'current' => 'Aktuell',\n    'no_displays' => 'Keine Displays für dieses Board verfügbar.',\n    'transitioning_minutes' => 'Übergang (:minutes min)',\n];\n"
  },
  {
    "path": "backend/lang/en/boards.php",
    "content": "<?php\n\nreturn [\n    'meeting_room_overview' => 'Meeting Room Overview',\n    'busy' => 'In use',\n    'transitioning' => 'Transitioning',\n    'check_in' => 'Check-in',\n    'error' => 'Error',\n    'available' => 'Available',\n    'available_until' => 'Available until :time',\n    'available_until_end_of_day' => 'Available until end of day',\n    'next' => 'Next',\n    'room' => 'Room',\n    'status' => 'Status',\n    'current' => 'Current',\n    'no_displays' => 'No displays available for this board.',\n    'transitioning_minutes' => 'Transitioning (:minutes min)',\n];\n"
  },
  {
    "path": "backend/lang/en/validation.php",
    "content": "<?php\n\nreturn [\n    'accepted' => 'The :attribute must be accepted.',\n    'accepted_if' => 'The :attribute must be accepted when :other is :value.',\n    'active_url' => 'The :attribute is not a valid URL.',\n    'after' => 'The :attribute must be a date after :date.',\n    'after_or_equal' => 'The :attribute must be a date after or equal to :date.',\n    'alpha' => 'The :attribute must only contain letters.',\n    'alpha_dash' => 'The :attribute must only contain letters, numbers, dashes and underscores.',\n    'alpha_num' => 'The :attribute must only contain letters and numbers.',\n    'array' => 'The :attribute must be an array.',\n    'before' => 'The :attribute must be a date before :date.',\n    'before_or_equal' => 'The :attribute must be a date before or equal to :date.',\n    'between' => [\n        'array' => 'The :attribute must have between :min and :max items.',\n        'file' => 'The :attribute must be between :min and :max kilobytes.',\n        'numeric' => 'The :attribute must be between :min and :max.',\n        'string' => 'The :attribute must be between :min and :max characters.',\n    ],\n    'boolean' => 'The :attribute field must be true or false.',\n    'confirmed' => 'The :attribute confirmation does not match.',\n    'current_password' => 'The password is incorrect.',\n    'date' => 'The :attribute is not a valid date.',\n    'date_equals' => 'The :attribute must be a date equal to :date.',\n    'date_format' => 'The :attribute does not match the format :format.',\n    'declined' => 'The :attribute must be declined.',\n    'declined_if' => 'The :attribute must be declined when :other is :value.',\n    'different' => 'The :attribute and :other must be different.',\n    'digits' => 'The :attribute must be :digits digits.',\n    'digits_between' => 'The :attribute must be between :min and :max digits.',\n    'dimensions' => 'The :attribute has invalid image dimensions.',\n    'distinct' => 'The :attribute field has a duplicate value.',\n    'doesnt_end_with' => 'The :attribute may not end with one of the following: :values.',\n    'doesnt_start_with' => 'The :attribute may not start with one of the following: :values.',\n    'email' => 'The :attribute must be a valid email address.',\n    'ends_with' => 'The :attribute must end with one of the following: :values.',\n    'enum' => 'The selected :attribute is invalid.',\n    'exists' => 'The selected :attribute is invalid.',\n    'file' => 'The :attribute must be a file.',\n    'filled' => 'The :attribute field must have a value.',\n    'gt' => [\n        'array' => 'The :attribute must have more than :value items.',\n        'file' => 'The :attribute must be greater than :value kilobytes.',\n        'numeric' => 'The :attribute must be greater than :value.',\n        'string' => 'The :attribute must be greater than :value characters.',\n    ],\n    'gte' => [\n        'array' => 'The :attribute must have :value items or more.',\n        'file' => 'The :attribute must be greater than or equal to :value kilobytes.',\n        'numeric' => 'The :attribute must be greater than or equal to :value.',\n        'string' => 'The :attribute must be greater than or equal to :value characters.',\n    ],\n    'image' => 'The :attribute must be an image.',\n    'in' => 'The selected :attribute is invalid.',\n    'in_array' => 'The :attribute field does not exist in :other.',\n    'integer' => 'The :attribute must be an integer.',\n    'ip' => 'The :attribute must be a valid IP address.',\n    'ipv4' => 'The :attribute must be a valid IPv4 address.',\n    'ipv6' => 'The :attribute must be a valid IPv6 address.',\n    'json' => 'The :attribute must be a valid JSON string.',\n    'lt' => [\n        'array' => 'The :attribute must have less than :value items.',\n        'file' => 'The :attribute must be less than :value kilobytes.',\n        'numeric' => 'The :attribute must be less than :value.',\n        'string' => 'The :attribute must be less than :value characters.',\n    ],\n    'lte' => [\n        'array' => 'The :attribute must not have more than :value items.',\n        'file' => 'The :attribute must be less than or equal to :value kilobytes.',\n        'numeric' => 'The :attribute must be less than or equal to :value.',\n        'string' => 'The :attribute must be less than or equal to :value characters.',\n    ],\n    'mac_address' => 'The :attribute must be a valid MAC address.',\n    'max' => [\n        'array' => 'The :attribute must not have more than :max items.',\n        'file' => 'The :attribute must not be greater than :max kilobytes.',\n        'numeric' => 'The :attribute must not be greater than :max.',\n        'string' => 'The :attribute must not be greater than :max characters.',\n    ],\n    'max_digits' => 'The :attribute must not have more than :max digits.',\n    'mimes' => 'The :attribute must be a file of type: :values.',\n    'mimetypes' => 'The :attribute must be a file of type: :values.',\n    'min' => [\n        'array' => 'The :attribute must have at least :min items.',\n        'file' => 'The :attribute must be at least :min kilobytes.',\n        'numeric' => 'The :attribute must be at least :min.',\n        'string' => 'The :attribute must be at least :min characters.',\n    ],\n    'min_digits' => 'The :attribute must have at least :min digits.',\n    'multiple_of' => 'The :attribute must be a multiple of :value.',\n    'not_in' => 'The selected :attribute is invalid.',\n    'not_regex' => 'The :attribute format is invalid.',\n    'numeric' => 'The :attribute must be a number.',\n    'password' => [\n        'letters' => 'The :attribute must contain at least one letter.',\n        'mixed' => 'The :attribute must contain at least one uppercase and one lowercase letter.',\n        'numbers' => 'The :attribute must contain at least one number.',\n        'symbols' => 'The :attribute must contain at least one symbol.',\n        'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.',\n    ],\n    'present' => 'The :attribute field must be present.',\n    'prohibited' => 'The :attribute field is prohibited.',\n    'prohibited_if' => 'The :attribute field is prohibited when :other is :value.',\n    'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.',\n    'prohibits' => 'The :attribute field prohibits :other from being present.',\n    'regex' => 'The :attribute format is invalid.',\n    'required' => 'The :attribute field is required.',\n    'required_array_keys' => 'The :attribute field must contain entries for: :values.',\n    'required_if' => 'The :attribute field is required when :other is :value.',\n    'required_if_accepted' => 'The :attribute field is required when :other is accepted.',\n    'required_unless' => 'The :attribute field is required unless :other is in :values.',\n    'required_with' => 'The :attribute field is required when :values is present.',\n    'required_with_all' => 'The :attribute field is required when :values are present.',\n    'required_without' => 'The :attribute field is required when :values is not present.',\n    'required_without_all' => 'The :attribute field is required when none of :values are present.',\n    'same' => 'The :attribute and :other must match.',\n    'size' => [\n        'array' => 'The :attribute must contain :size items.',\n        'file' => 'The :attribute must be :size kilobytes.',\n        'numeric' => 'The :attribute must be :size.',\n        'string' => 'The :attribute must be :size characters.',\n    ],\n    'starts_with' => 'The :attribute must start with one of the following: :values.',\n    'string' => 'The :attribute must be a string.',\n    'timezone' => 'The :attribute must be a valid timezone.',\n    'unique' => 'The :attribute has already been taken.',\n    'uploaded' => 'The :attribute failed to upload.',\n    'url' => 'The :attribute must be a valid URL.',\n    'uuid' => 'The :attribute must be a valid UUID.',\n\n    'custom' => [\n        'g-recaptcha-response' => [\n            'recaptchav3' => 'Please wait a moment and try again. This helps us protect against automated submissions.',\n        ],\n    ],\n\n    'attributes' => [],\n]; "
  },
  {
    "path": "backend/lang/es/boards.php",
    "content": "<?php\n\nreturn [\n    'meeting_room_overview' => 'Resumen de Salas de Reuniones',\n    'busy' => 'En uso',\n    'transitioning' => 'Transición',\n    'check_in' => 'Registro',\n    'error' => 'Error',\n    'available' => 'Disponible',\n    'available_until' => 'Disponible hasta :time',\n    'available_until_end_of_day' => 'Disponible hasta el final del día',\n    'next' => 'Siguiente',\n    'room' => 'Sala',\n    'status' => 'Estado',\n    'current' => 'Actual',\n    'no_displays' => 'No hay pantallas disponibles para este tablero.',\n    'transitioning_minutes' => 'Transición (:minutes min)',\n];\n"
  },
  {
    "path": "backend/lang/fr/boards.php",
    "content": "<?php\n\nreturn [\n    'meeting_room_overview' => 'Vue d\\'ensemble des Salles de Réunion',\n    'busy' => 'Occupé',\n    'transitioning' => 'Transition',\n    'check_in' => 'Enregistrement',\n    'error' => 'Erreur',\n    'available' => 'Disponible',\n    'available_until' => 'Disponible jusqu\\'à :time',\n    'available_until_end_of_day' => 'Disponible jusqu\\'à la fin de la journée',\n    'next' => 'Suivant',\n    'room' => 'Salle',\n    'status' => 'Statut',\n    'current' => 'Actuel',\n    'no_displays' => 'Aucun écran disponible pour ce tableau.',\n    'transitioning_minutes' => 'Transition (:minutes min)',\n];\n"
  },
  {
    "path": "backend/lang/nl/boards.php",
    "content": "<?php\n\nreturn [\n    'meeting_room_overview' => 'Overzicht Vergaderruimtes',\n    'busy' => 'In gebruik',\n    'transitioning' => 'Overgang',\n    'check_in' => 'Inchecken',\n    'error' => 'Fout',\n    'available' => 'Beschikbaar',\n    'available_until' => 'Beschikbaar tot :time',\n    'available_until_end_of_day' => 'Beschikbaar tot einde dag',\n    'next' => 'Volgende',\n    'room' => 'Ruimte',\n    'status' => 'Status',\n    'current' => 'Huidig',\n    'no_displays' => 'Geen displays beschikbaar voor dit bord.',\n    'transitioning_minutes' => 'Overgang (:minutes min)',\n];\n"
  },
  {
    "path": "backend/lang/sv/boards.php",
    "content": "<?php\n\nreturn [\n    'meeting_room_overview' => 'Översikt av Mötesrum',\n    'busy' => 'I användning',\n    'transitioning' => 'Övergång',\n    'check_in' => 'Checka in',\n    'error' => 'Fel',\n    'available' => 'Tillgänglig',\n    'available_until' => 'Tillgänglig till :time',\n    'available_until_end_of_day' => 'Tillgänglig till dagens slut',\n    'next' => 'Nästa',\n    'room' => 'Rum',\n    'status' => 'Status',\n    'current' => 'Nuvarande',\n    'no_displays' => 'Inga skärmar tillgängliga för denna tavla.',\n    'transitioning_minutes' => 'Övergång (:minutes min)',\n];\n"
  },
  {
    "path": "backend/package.json",
    "content": "{\n    \"private\": true,\n    \"type\": \"module\",\n    \"scripts\": {\n        \"build\": \"vite build\",\n        \"dev\": \"vite\"\n    },\n    \"devDependencies\": {\n        \"autoprefixer\": \"^10.4.21\",\n        \"axios\": \"^1.9.0\",\n        \"concurrently\": \"^9.1.2\",\n        \"laravel-echo\": \"^1.19.0\",\n        \"vite\": \"^6.3.5\"\n    },\n    \"dependencies\": {\n        \"@fortawesome/fontawesome-free\": \"^6.7.2\",\n        \"@tailwindcss/vite\": \"^4.1.8\",\n        \"alpinejs\": \"^3.14.9\",\n        \"htmx.org\": \"^1.9.12\",\n        \"laravel-vite-plugin\": \"^1.3.0\",\n        \"tailwindcss\": \"^4.1.8\"\n    }\n}\n"
  },
  {
    "path": "backend/phpunit.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<phpunit xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n         xsi:noNamespaceSchemaLocation=\"vendor/phpunit/phpunit/phpunit.xsd\"\n         bootstrap=\"vendor/autoload.php\"\n         colors=\"true\"\n>\n    <testsuites>\n        <testsuite name=\"Unit\">\n            <directory>tests/Unit</directory>\n        </testsuite>\n        <testsuite name=\"Feature\">\n            <directory>tests/Feature</directory>\n        </testsuite>\n    </testsuites>\n    <source>\n        <include>\n            <directory>app</directory>\n        </include>\n    </source>\n    <php>\n        <env name=\"APP_ENV\" value=\"testing\"/>\n        <env name=\"APP_MAINTENANCE_DRIVER\" value=\"file\"/>\n        <env name=\"BCRYPT_ROUNDS\" value=\"4\"/>\n        <env name=\"CACHE_STORE\" value=\"array\"/>\n        <env name=\"DB_CONNECTION\" value=\"sqlite\"/>\n        <env name=\"DB_DATABASE\" value=\":memory:\"/>\n        <env name=\"MAIL_MAILER\" value=\"array\"/>\n        <env name=\"PULSE_ENABLED\" value=\"false\"/>\n        <env name=\"QUEUE_CONNECTION\" value=\"sync\"/>\n        <env name=\"SESSION_DRIVER\" value=\"array\"/>\n        <env name=\"TELESCOPE_ENABLED\" value=\"false\"/>\n        <env name=\"EVENTS_CACHE_ENABLED\" value=\"true\"/>\n        <env name=\"SELF_HOSTED\" value=\"true\"/>\n    </php>\n</phpunit>\n"
  },
  {
    "path": "backend/public/.htaccess",
    "content": "<IfModule mod_rewrite.c>\n    <IfModule mod_negotiation.c>\n        Options -MultiViews -Indexes\n    </IfModule>\n\n    RewriteEngine On\n\n    # Handle Authorization Header\n    RewriteCond %{HTTP:Authorization} .\n    RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]\n\n    # Redirect Trailing Slashes If Not A Folder...\n    RewriteCond %{REQUEST_FILENAME} !-d\n    RewriteCond %{REQUEST_URI} (.+)/$\n    RewriteRule ^ %1 [L,R=301]\n\n    # Send Requests To Front Controller...\n    RewriteCond %{REQUEST_FILENAME} !-d\n    RewriteCond %{REQUEST_FILENAME} !-f\n    RewriteRule ^ index.php [L]\n</IfModule>\n"
  },
  {
    "path": "backend/public/images/backgrounds/README.md",
    "content": "# Default Background Images\n\nThis directory contains the default background images that users can select for their displays.\n\n## Required Images\n\nPlace the following 8 default background images in this directory:\n\n1. `default_1.jpg` - First default background option\n2. `default_2.jpg` - Second default background option\n3. `default_3.jpg` - Third default background option\n4. `default_4.jpg` - Fourth default background option\n5. `default_5.jpg` - Fifth default background option\n6. `default_6.jpg` - Sixth default background option\n7. `default_7.jpg` - Seventh default background option\n8. `default_8.jpg` - Eighth default background option\n\n## Image Specifications\n\n- **Format**: JPEG (JPG) recommended for file size optimization\n- **Dimensions**: 1920x1080px or similar 16:9 aspect ratio\n- **File Size**: Aim for under 500KB per image for optimal loading\n- **Quality**: Use high-quality images that look good on large displays\n\n## Design Recommendations\n\nGood background images for room displays should:\n\n- Have soft, muted colors that don't overpower text\n- Avoid busy patterns that reduce readability\n- Work well with white text overlay\n- Be professional and appropriate for office environments\n- Consider using:\n  - Abstract gradients\n  - Soft nature scenes (mountains, forests, water)\n  - Minimalist geometric patterns\n  - Blurred cityscapes\n  - Subtle textures (wood, fabric, stone)\n\n## Adding New Default Backgrounds\n\nTo add more default backgrounds:\n\n1. Add the image file to this directory\n2. Update `backend/app/Services/ImageService.php`:\n   - Add the new background to the `DEFAULT_BACKGROUNDS` constant\n3. Update `backend/app/Http/Requests/UpdateDisplayCustomizationRequest.php`:\n   - Add the new key to the `default_background` validation rule\n\nExample:\n```php\npublic const DEFAULT_BACKGROUNDS = [\n    'default_1' => 'images/backgrounds/default_1.jpg',\n    'default_2' => 'images/backgrounds/default_2.jpg',\n    'default_3' => 'images/backgrounds/default_3.jpg',\n    'default_4' => 'images/backgrounds/default_4.jpg',\n    'default_5' => 'images/backgrounds/default_5.jpg',\n    'default_6' => 'images/backgrounds/default_6.jpg',\n    'default_7' => 'images/backgrounds/default_7.jpg',\n    'default_8' => 'images/backgrounds/default_8.jpg',\n    'default_9' => 'images/backgrounds/default_9.jpg', // New background\n];\n```\n\n"
  },
  {
    "path": "backend/public/index.php",
    "content": "<?php\n\nuse Illuminate\\Http\\Request;\n\ndefine('LARAVEL_START', microtime(true));\n\n// Determine if the application is in maintenance mode...\nif (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {\n    require $maintenance;\n}\n\n// Configure OpenTelemetry before autoloader (prevents warnings when extension not installed)\nrequire __DIR__.'/../bootstrap/opentelemetry.php';\n\n// Register the Composer autoloader...\nrequire __DIR__.'/../vendor/autoload.php';\n\n// Bootstrap Laravel and handle the request...\n(require_once __DIR__.'/../bootstrap/app.php')\n    ->handleRequest(Request::capture());\n"
  },
  {
    "path": "backend/public/robots.txt",
    "content": "User-agent: *\nDisallow:\n"
  },
  {
    "path": "backend/public/site.webmanifest",
    "content": "{\n  \"name\": \"MyWebSite\",\n  \"short_name\": \"MySite\",\n  \"icons\": [\n    {\n      \"src\": \"/web-app-manifest-192x192.png\",\n      \"sizes\": \"192x192\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable\"\n    },\n    {\n      \"src\": \"/web-app-manifest-512x512.png\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable\"\n    }\n  ],\n  \"theme_color\": \"#ffffff\",\n  \"background_color\": \"#ffffff\",\n  \"display\": \"standalone\"\n}"
  },
  {
    "path": "backend/requests/.gitignore",
    "content": "http-client.env.json\n"
  },
  {
    "path": "backend/requests/api/activate.http",
    "content": "POST {{baseUrl}}/api/v1/instances/activate\nAuthorization: Bearer {{bearerToken}}\nContent-Type: application/json\nAccept: application/json\n\n{\n    \"instance_key\": \"\",\n    \"license_key\": \"\"\n}\n"
  },
  {
    "path": "backend/requests/api/auth/login.http",
    "content": "POST {{baseUrl}}/api/auth/login\nContent-Type: application/json\nAccept: application/json\n\n{\n  \"code\": \"919640\",\n  \"uid\": \"uniqueid1\",\n  \"name\": \"Tablet 1\"\n}\n"
  },
  {
    "path": "backend/requests/api/book-room.http",
    "content": "POST {{baseUrl}}/api/displays/01JXT3S52HKWH41138BJ8TEWTX/book\nAuthorization: Bearer {{bearerToken}}\nContent-Type: application/json\nAccept: application/json\n\n{\n    \"duration\": 15\n}\n"
  },
  {
    "path": "backend/requests/api/cancel-event.http",
    "content": "DELETE {{baseUrl}}/api/displays/01JXT3S52HKWH41138BJ8TEWTX/events/01K053J5167XGD88JA175CGCCT\nAuthorization: Bearer {{bearerToken}}\nContent-Type: application/json\nAccept: application/json\n"
  },
  {
    "path": "backend/requests/api/change-display.http",
    "content": "PUT {{baseUrl}}/api/devices/display\nAuthorization: Bearer {{bearerToken}}\nContent-Type: application/json\nAccept: application/json\n\n{\n    \"display_id\": \"01JVQPZW49T4CCT77TBCENDSGK\"\n}\n"
  },
  {
    "path": "backend/requests/api/check-in-event.http",
    "content": "POST {{baseUrl}}/api/events/01JZR8Z1455S6ZVBYFN0SX7C0A/check-in\nAuthorization: Bearer {{bearerToken}}\nContent-Type: application/json\nAccept: application/json\n"
  },
  {
    "path": "backend/requests/api/get-display-data.http",
    "content": "GET {{baseUrl}}/api/displays/01JXT3S52HKWH41138BJ8TEWTX/data\nAuthorization: Bearer {{bearerToken}}\nContent-Type: application/json\nAccept: application/json\n"
  },
  {
    "path": "backend/requests/api/get-displays.http",
    "content": "GET {{baseUrl}}/api/displays\nAuthorization: Bearer {{bearerToken}}\nContent-Type: application/json\nAccept: application/json\n"
  },
  {
    "path": "backend/requests/api/get-events.http",
    "content": "GET {{baseUrl}}/api/events\nAuthorization: Bearer {{bearerToken}}\nContent-Type: application/json\nAccept: application/json\n"
  },
  {
    "path": "backend/requests/api/get-me.http",
    "content": "GET {{baseUrl}}/api/devices/me\nAuthorization: Bearer {{bearerToken}}\nContent-Type: application/json\nAccept: application/json\n"
  },
  {
    "path": "backend/requests/api/heartbeat.http",
    "content": "### Send heartbeat to Spacepad server\nPOST {{baseUrl}}/api/v1/instances/heartbeat\nContent-Type: application/json\nAccept: application/json\n\n{\n    \"instance_key\": \"\",\n    \"license_key\": \"\",\n    \"license_valid\": true,\n    \"license_expires_at\": \"2024-12-31T23:59:59Z\",\n    \"is_self_hosted\": true,\n    \"displays_count\": 2,\n    \"rooms_count\": 1,\n    \"version\": \"1.0.0\",\n    \"users\": [\n        {\n            \"email\": \"user@example.com\",\n            \"usage_type\": \"personal\",\n            \"is_unlimited\": false,\n            \"terms_accepted_at\": \"2024-01-01T00:00:00Z\"\n        }\n    ]\n}\n"
  },
  {
    "path": "backend/requests/api/outlook/get-outlook-calendars.http",
    "content": "### Get Outlook Calendars\nGET http://localhost:8000/api/outlook/calendars\nAuthorization: Bearer YOUR_ACCESS_TOKEN\nContent-Type: application/json\n"
  },
  {
    "path": "backend/requests/api/outlook/outlook-auth.http",
    "content": "### Redirect to Outlook OAuth URL\nGET http://localhost:8000/outlook/auth"
  },
  {
    "path": "backend/requests/graph/get-calendar-by-email.http",
    "content": "GET https://graph.microsoft.com/v1.0/users/HorizonM@magweter.com/calendarview\nAuthorization: Bearer {{outlookToken}}\nContent-Type: application/json\n"
  },
  {
    "path": "backend/requests/graph/get-calendars.http",
    "content": "GET https://graph.microsoft.com/v1.0/me/calendars\nAuthorization: Bearer {{outlookToken}}\nContent-Type: application/json\n"
  },
  {
    "path": "backend/requests/graph/get-events.http",
    "content": "GET https://graph.microsoft.com/v1.0/me/calendars/AQMkAGMwOTg0N2IyLTMxM2YtNGM3OC1iZmUxLTlkODlkYTZkM2Y5NQBGAAADmjZsXgRn_0ulz0qs3WatIAcAranw5q4G4E6C7jhn9RjFbAAAAgEGAAAAranw5q4G4E6C7jhn9RjFbAAAAr84AAAA/calendarview?StartDateTime=2025-04-04T00:00:00&EndDateTime=2026-01-01T23:59:59\nAuthorization: Bearer {{outlookToken}}\nContent-Type: application/json\n"
  },
  {
    "path": "backend/requests/graph/get-rooms.http",
    "content": "GET https://graph.microsoft.com/v1.0/places/microsoft.graph.room\nAuthorization: Bearer {{outlookToken}}\nContent-Type: application/json\n"
  },
  {
    "path": "backend/requests/webhook-tests.http",
    "content": "### Test Onboarding Complete Webhook\nPOST {{onboarding_webhook_url}}\nContent-Type: application/json\n\n{\n    \"user_id\": 123,\n    \"email\": \"john.doe@example.com\",\n    \"name\": \"John Doe\",\n    \"display\": \"Office Display\",\n    \"event\": \"onboarding_complete\"\n}\n\n### Test Registration Webhook\nPOST {{registration_webhook_url}}\nContent-Type: application/json\n\n{\n    \"user_id\": 456,\n    \"email\": \"jane.smith@example.com\",\n    \"name\": \"Jane Smith\",\n    \"display\": \"Main Office Display\",\n    \"event\": \"onboarding_complete\"\n}"
  },
  {
    "path": "backend/resources/css/app.css",
    "content": "@import 'tailwindcss';\n@import '@fortawesome/fontawesome-free/css/all.css';\n@source \"../views\";\n\n.bg-oxford {\n    background: #14213D;\n}\n.bg-orange {\n    background: #FCA311;\n}\n.text-orange {\n    color: #FCA311;\n}\n.bg-platinum {\n    background: #E5E5E5;\n}\n.grecaptcha-badge { visibility: hidden !important; }\n[x-cloak] { display: none !important; }\nbutton { cursor: pointer; }\n"
  },
  {
    "path": "backend/resources/js/app.js",
    "content": "import './bootstrap';\nimport Alpine from 'alpinejs';\n\nwindow.Alpine = Alpine;\nAlpine.start();\n"
  },
  {
    "path": "backend/resources/js/bootstrap.js",
    "content": "import axios from 'axios';\nimport htmx from 'htmx.org';\n\nwindow.axios = axios;\nwindow.htmx = htmx;\n\nwindow.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';\n"
  },
  {
    "path": "backend/resources/js/echo.js",
    "content": "import Echo from 'laravel-echo';\n\nimport { WaveConnector } from 'laravel-wave';\n\nwindow.Echo = new Echo({\n    broadcaster: WaveConnector,\n});\n"
  },
  {
    "path": "backend/resources/views/.gitkeep",
    "content": "\n"
  },
  {
    "path": "backend/resources/views/auth/login.blade.php",
    "content": "@extends('layouts.blank')\n@section('title', 'Sign in')\n@section('page')\n    <div class=\"flex min-h-full flex-col justify-center py-24 sm:px-6 lg:px-8\">\n        <div class=\"sm:mx-auto sm:w-full sm:max-w-md\">\n            <img class=\"mx-auto h-12 w-auto\" src=\"/images/logo-black.svg\" alt=\"Logo\">\n            <h2 class=\"mt-6 text-center text-2xl/9 font-bold tracking-tight text-gray-900\">Welcome to Spacepad</h2>\n            <p class=\"mt-2 text-center text-lg text-gray-500\">Please sign in to continue</p>\n        </div>\n\n        <x-cards.card class=\"mt-10 sm:mx-auto sm:w-full sm:max-w-[450px]\">\n            <div class=\"py-6 sm:px-6\">\n                <x-alerts.alert />\n\n                @if(! config('settings.disable_email_login'))\n                    <form action=\"{{ route('login.store') }}\" method=\"POST\">\n                        @csrf\n                        {!! RecaptchaV3::field('login') !!}\n                        <div class=\"mb-6\">\n                            <label for=\"email\" class=\"block text-sm/6 font-medium text-gray-900\">Email address</label>\n                            <div class=\"mt-2\">\n                                <input id=\"email\" name=\"email\" type=\"email\" autocomplete=\"email\" required class=\"block w-full rounded-md border-0 px-3 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm/6\">\n                            </div>\n                        </div>\n                        <div>\n                            <button type=\"submit\" class=\"flex w-full justify-center rounded-md bg-oxford px-3 py-2 text-sm/6 font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600\">Send login link</button>\n                        </div>\n                    </form>\n\n                    <div class=\"relative my-6\">\n                        <div class=\"absolute inset-0 flex items-center\" aria-hidden=\"true\">\n                            <div class=\"w-full border-t border-gray-200\"></div>\n                        </div>\n                        <div class=\"relative flex justify-center text-sm/6 font-medium\">\n                            <span class=\"bg-white px-6 text-gray-900\">Or continue with</span>\n                        </div>\n                    </div>\n                @endif\n\n                <div class=\"flex flex-col space-y-4\">\n                    @if(config('services.microsoft.enabled'))\n                        <a href=\"{{ route('auth.microsoft.redirect') }}\" class=\"flex w-full items-center justify-center gap-3 rounded-md bg-white px-3 py-3 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:ring-transparent\">\n                            <svg class=\"h-5 w-5\" xmlns=\"http://www.w3.org/2000/svg\" x=\"0px\" y=\"0px\" width=\"100\" height=\"100\" viewBox=\"0 0 48 48\">\n                                <path fill=\"#ff5722\" d=\"M6 6H22V22H6z\" transform=\"rotate(-180 14 14)\"></path><path fill=\"#4caf50\" d=\"M26 6H42V22H26z\" transform=\"rotate(-180 34 14)\"></path><path fill=\"#ffc107\" d=\"M26 26H42V42H26z\" transform=\"rotate(-180 34 34)\"></path><path fill=\"#03a9f4\" d=\"M6 26H22V42H6z\" transform=\"rotate(-180 14 34)\"></path>\n                            </svg>\n                            <span class=\"text-sm/6 font-semibold\">Microsoft</span>\n                        </a>\n                    @endif\n\n                    @if(config('services.google.enabled'))\n                        <a href=\"{{ route('auth.google.redirect') }}\" class=\"flex w-full items-center justify-center gap-3 rounded-md bg-white px-3 py-3 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:ring-transparent\">\n                            <svg class=\"h-5 w-5\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n                                <path d=\"M12.0003 4.75C13.7703 4.75 15.3553 5.36002 16.6053 6.54998L20.0303 3.125C17.9502 1.19 15.2353 0 12.0003 0C7.31028 0 3.25527 2.69 1.28027 6.60998L5.27028 9.70498C6.21525 6.86002 8.87028 4.75 12.0003 4.75Z\" fill=\"#EA4335\" />\n                                <path d=\"M23.49 12.275C23.49 11.49 23.415 10.73 23.3 10H12V14.51H18.47C18.18 15.99 17.34 17.25 16.08 18.1L19.945 21.1C22.2 19.01 23.49 15.92 23.49 12.275Z\" fill=\"#4285F4\" />\n                                <path d=\"M5.26498 14.2949C5.02498 13.5699 4.88501 12.7999 4.88501 11.9999C4.88501 11.1999 5.01998 10.4299 5.26498 9.7049L1.275 6.60986C0.46 8.22986 0 10.0599 0 11.9999C0 13.9399 0.46 15.7699 1.28 17.3899L5.26498 14.2949Z\" fill=\"#FBBC05\" />\n                                <path d=\"M12.0004 24.0001C15.2404 24.0001 17.9654 22.935 19.9454 21.095L16.0804 18.095C15.0054 18.82 13.6204 19.245 12.0004 19.245C8.8704 19.245 6.21537 17.135 5.2654 14.29L1.27539 17.385C3.25539 21.31 7.3104 24.0001 12.0004 24.0001Z\" fill=\"#34A853\" />\n                            </svg>\n                            <span class=\"text-sm/6 font-semibold\">Google</span>\n                        </a>\n                    @endif\n\n                    @if(config('settings.disable_email_login') && ! config('services.microsoft.enabled') && ! config('services.google.enabled'))\n                        <div class=\"p-4 bg-orange-100 text-orange-800 rounded text-center\">No email login or authentication providers configured.</div>\n                    @endif\n                </div>\n            </div>\n        </x-cards.card>\n\n        <div class=\"mt-8 text-center\">\n            <p class=\"text-sm text-gray-600\">\n                Don't have an account? <a href=\"{{ route('register') }}\" class=\"font-semibold text-oxford hover:text-blue-500\">Create one</a>\n            </p>\n        </div>\n    </div>\n@endsection\n"
  },
  {
    "path": "backend/resources/views/auth/register.blade.php",
    "content": "@extends('layouts.blank')\n@section('title', 'Register')\n@section('page')\n    <div class=\"flex min-h-full flex-col justify-center py-24 sm:px-6 lg:px-8\">\n    <div class=\"sm:mx-auto sm:w-full sm:max-w-md\">\n            <img class=\"mx-auto h-12 w-auto\" src=\"/images/logo-black.svg\" alt=\"Logo\">\n            <h2 class=\"mt-6 text-center text-2xl/9 font-bold tracking-tight text-gray-900\">Welcome to Spacepad</h2>\n            <p class=\"mt-2 text-center text-lg text-gray-500\">Register to start using your display today</p>\n        </div>\n\n        <x-cards.card class=\"mt-10 sm:mx-auto sm:w-full sm:max-w-[450px]\">\n            <div class=\"py-6 sm:px-6\">\n                <x-alerts.alert />\n\n                @if(session('registered'))\n                    <div class=\"flex flex-col items-center justify-center p-8 mb-8\">\n                        <div class=\"mb-4\">\n                            <svg class=\"h-12 w-12 text-orange\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75\" />\n                            </svg>\n                        </div>\n                        <h3 class=\"text-xl font-semibold text-gray-900 mb-2\">Please check your email</h3>\n                        <p class=\"text-gray-700 text-center\">You should receive an e-mail with a login link shortly.</p>\n                    </div>\n                @else\n                    @if(! config('settings.disable_email_login'))\n                        <form action=\"{{ route('register.store') }}\" method=\"POST\">\n                            @csrf\n                            {!! RecaptchaV3::field('register') !!}\n                            <div class=\"mb-3\">\n                                <label for=\"name\" class=\"block text-sm/6 font-medium text-gray-900\">Name</label>\n                                <div class=\"mt-2\">\n                                    <input id=\"name\" name=\"name\" type=\"text\" autocomplete=\"name\" required class=\"block w-full rounded-md border-0 px-3 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm/6\">\n                                </div>\n                            </div>\n                            <div class=\"mb-6\">\n                                <label for=\"email\" class=\"block text-sm/6 font-medium text-gray-900\">Email address</label>\n                                <div class=\"mt-2\">\n                                    <input id=\"email\" name=\"email\" type=\"email\" autocomplete=\"email\" required class=\"block w-full rounded-md border-0 px-3 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm/6\">\n                                </div>\n                            </div>\n                            <div class=\"mb-4\">\n                                <button type=\"submit\" class=\"flex w-full justify-center rounded-md bg-oxford px-3 py-2 text-sm/6 font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600\">\n                                    {{ config('settings.is_self_hosted') ? 'Register' : 'Get started' }}\n                                </button>\n                            </div>\n                        </form>\n\n                        <div class=\"relative my-6\">\n                            <div class=\"absolute inset-0 flex items-center\" aria-hidden=\"true\">\n                                <div class=\"w-full border-t border-gray-200\"></div>\n                            </div>\n                            <div class=\"relative flex justify-center text-sm/6 font-medium\">\n                                <span class=\"bg-white px-6 text-gray-900\">Or continue with</span>\n                            </div>\n                        </div>\n                    @endif\n\n                    <div class=\"flex flex-col space-y-4\">\n                        @if(config('services.microsoft.enabled'))\n                            <a href=\"{{ route('auth.microsoft.redirect') }}\" class=\"flex w-full items-center justify-center gap-3 rounded-md bg-white px-3 py-3 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:ring-transparent\">\n                                <svg class=\"h-5 w-5\" xmlns=\"http://www.w3.org/2000/svg\" x=\"0px\" y=\"0px\" width=\"100\" height=\"100\" viewBox=\"0 0 48 48\">\n                                    <path fill=\"#ff5722\" d=\"M6 6H22V22H6z\" transform=\"rotate(-180 14 14)\"></path><path fill=\"#4caf50\" d=\"M26 6H42V22H26z\" transform=\"rotate(-180 34 14)\"></path><path fill=\"#ffc107\" d=\"M26 26H42V42H26z\" transform=\"rotate(-180 34 34)\"></path><path fill=\"#03a9f4\" d=\"M6 26H22V42H6z\" transform=\"rotate(-180 14 34)\"></path>\n                                </svg>\n                                <span class=\"text-sm/6 font-semibold\">Microsoft</span>\n                            </a>\n                        @endif\n\n                        @if(config('services.google.enabled'))\n                            <a href=\"{{ route('auth.google.redirect') }}\" class=\"flex w-full items-center justify-center gap-3 rounded-md bg-white px-3 py-3 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:ring-transparent\">\n                                <svg class=\"h-5 w-5\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n                                    <path d=\"M12.0003 4.75C13.7703 4.75 15.3553 5.36002 16.6053 6.54998L20.0303 3.125C17.9502 1.19 15.2353 0 12.0003 0C7.31028 0 3.25527 2.69 1.28027 6.60998L5.27028 9.70498C6.21525 6.86002 8.87028 4.75 12.0003 4.75Z\" fill=\"#EA4335\" />\n                                    <path d=\"M23.49 12.275C23.49 11.49 23.415 10.73 23.3 10H12V14.51H18.47C18.18 15.99 17.34 17.25 16.08 18.1L19.945 21.1C22.2 19.01 23.49 15.92 23.49 12.275Z\" fill=\"#4285F4\" />\n                                    <path d=\"M5.26498 14.2949C5.02498 13.5699 4.88501 12.7999 4.88501 11.9999C4.88501 11.1999 5.01998 10.4299 5.26498 9.7049L1.275 6.60986C0.46 8.22986 0 10.0599 0 11.9999C0 13.9399 0.46 15.7699 1.28 17.3899L5.26498 14.2949Z\" fill=\"#FBBC05\" />\n                                    <path d=\"M12.0004 24.0001C15.2404 24.0001 17.9654 22.935 19.9454 21.095L16.0804 18.095C15.0054 18.82 13.6204 19.245 12.0004 19.245C8.8704 19.245 6.21537 17.135 5.2654 14.29L1.27539 17.385C3.25539 21.31 7.3104 24.0001 12.0004 24.0001Z\" fill=\"#34A853\" />\n                                </svg>\n                                <span class=\"text-sm/6 font-semibold\">Google</span>\n                            </a>\n                        @endif\n\n                        @if(config('settings.disable_email_login') && ! config('services.microsoft.enabled') && ! config('services.google.enabled'))\n                            <div class=\"p-4 bg-orange-100 text-orange-800 rounded text-center\">No email registration or authentication provider configured.</div>\n                        @endif\n                    </div>\n\n                    <div class=\"mt-6 text-sm text-center\">\n                        <label class=\"text-gray-500\">By continuing, you indicate that you agree to our<br> <a href=\"https://spacepad.io/terms\" target=\"_blank\" class=\"text-blue-600 hover:text-blue-500\">Terms of Service</a> and <a href=\"https://spacepad.io/privacy\" target=\"_blank\" class=\"text-blue-600 hover:text-blue-500\">Privacy Policy</a>.</label>\n                    </div>\n                @endif\n            </div>\n        </x-cards.card>\n    </div>\n@endsection\n"
  },
  {
    "path": "backend/resources/views/components/alerts/alert.blade.php",
    "content": "@props([\n    'type' => 'info',\n    'title' => null,\n    'message' => null,\n    'dismissible' => false,\n    'autoDismiss' => true,\n    'autoDismissDelay' => 5000,\n    'errors' => null,\n])\n\n@php\n    // Set type and title based on session messages\n    if (session('success')) {\n        $type = 'success';\n        $title = 'Success!';\n    } elseif (session('error')) {\n        $type = 'error';\n        $title = 'Something went wrong';\n    } elseif (session('warning')) {\n        $type = 'warning';\n        $title = 'Heads up';\n    } elseif (session('info')) {\n        $type = 'info';\n        $title = 'Please note:';\n    }\n\n    $hasErrors = $errors->any() && !$errors->has('license_key');\n    if ($hasErrors) {\n        $type = 'error';\n        $title = 'There were errors with your submission';\n    }\n\n    $alertClasses = [\n        'success' => 'bg-green-50 ring-green-600',\n        'error' => 'bg-red-50 ring-red-600',\n        'warning' => 'bg-yellow-50 ring-yellow-600',\n        'info' => 'bg-blue-50 ring-blue-600',\n    ][$type] ?? 'bg-blue-50 ring-blue-600';\n\n    $titleClasses = [\n        'success' => 'text-green-700',\n        'error' => 'text-red-700',\n        'warning' => 'text-yellow-700',\n        'info' => 'text-blue-700',\n    ][$type] ?? 'text-blue-700';\n\n    $messageClasses = [\n        'success' => 'text-green-700',\n        'error' => 'text-red-700',\n        'warning' => 'text-yellow-700',\n        'info' => 'text-blue-700',\n    ][$type] ?? 'text-blue-700';\n@endphp\n\n@if(session('success') || session('error') || session('warning') || session('info') || $hasErrors)\n    <div id=\"alert\" class=\"rounded-md p-4 mb-4 ring-1 ring-inset {{ $alertClasses }}\">\n        <div class=\"flex flex-col\">\n            @if($title)\n                <h3 class=\"text-base font-semibold mb-1 {{ $titleClasses }}\">{{ $title }}</h3>\n            @endif\n            <div class=\"text-sm {{ $messageClasses }}\">\n                @if($message)\n                    <p>{{ $message }}</p>\n                @endif\n                @if($hasErrors)\n                    <ul class=\"list-disc pl-5 space-y-1\">\n                        @foreach($errors->all() as $error)\n                            <li>{{ $error }}</li>\n                        @endforeach\n                    </ul>\n                @endif\n                @if(session('success'))\n                    <p>{{ session('success') }}</p>\n                @endif\n                @if(session('error'))\n                    <p>{{ session('error') }}</p>\n                @endif\n                @if(session('warning'))\n                    <p>{{ session('warning') }}</p>\n                @endif\n                @if(session('info'))\n                    <p>{{ session('info') }}</p>\n                @endif\n            </div>\n        </div>\n    </div>\n\n    @if($autoDismiss)\n        @push('scripts')\n        <script>\n            document.addEventListener('DOMContentLoaded', function() {\n                const alert = document.getElementById('alert');\n                if (alert) {\n                    setTimeout(() => {\n                        alert.remove();\n                    }, {{ $autoDismissDelay }});\n                }\n            });\n        </script>\n        @endpush\n    @endif\n@endif\n"
  },
  {
    "path": "backend/resources/views/components/calendars/picker.blade.php",
    "content": "<label for=\"calendar\" class=\"block text-sm font-medium leading-6 text-gray-900\">Connected calendar</label>\n<div class=\"mt-1\">\n    <select name=\"calendar\" id=\"calendar\" class=\"block w-full rounded-md border-0 py-2 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6\">\n        @if(empty($calendars))\n            <option value=\"\">No calendars found</option>\n        @else\n            <option value=\"\">Select a calendar</option>\n            @foreach($calendars as $calendar)\n                <option value=\"{{ $calendar['id'] . ',' . $calendar['name'] }}\">{{ $calendar['name'] }}</option>\n            @endforeach\n        @endif\n    </select>\n</div>\n\n@if(isset($error))\n    <div class=\"mt-4\">\n        <div class=\"rounded-md bg-red-50 p-4\">\n            <div class=\"flex\">\n                <div class=\"flex-shrink-0\">\n                    <svg class=\"h-5 w-5 text-red-400\" viewBox=\"0 0 20 20\" fill=\"currentColor\" aria-hidden=\"true\">\n                        <path fill-rule=\"evenodd\" d=\"M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z\" clip-rule=\"evenodd\" />\n                    </svg>\n                </div>\n                <div class=\"ml-3\">\n                    <h3 class=\"text-sm font-medium text-red-800\">Something went wrong</h3>\n                    <div class=\"mt-2 text-sm text-red-700\">\n                        <p>{{ $error }}</p>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </div>\n@endif "
  },
  {
    "path": "backend/resources/views/components/cards/card.blade.php",
    "content": "@props(['class' => ''])\n<div class=\"overflow-hidden rounded-lg bg-white shadow-sm px-4 py-5 sm:p-6 {{ $class }}\">\n    {{ $slot }}\n</div>\n"
  },
  {
    "path": "backend/resources/views/components/displays/table-row.blade.php",
    "content": "@props(['display'])\n\n<tr>\n    <td class=\"whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0\">\n        <div class=\"font-medium text-gray-900\">{{ $display->name }}</div>\n        <div class=\"text-gray-500\">{{ $display->display_name }}</div>\n    </td>\n    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">\n        <div class=\"flex flex-col gap-1\">\n            @if($display->calendar->outlookAccount)\n                <div class=\"flex items-center\">\n                    <x-icons.microsoft class=\"h-4 w-4 text-gray-900 mr-2\" />\n                    <span class=\"text-gray-900\">{{ $display->calendar->outlookAccount->name }}</span>\n                </div>\n            @endif\n            @if($display->calendar->googleAccount)\n                <div class=\"flex items-center\">\n                    <x-icons.google class=\"h-4 w-4 text-gray-900 mr-2\" />\n                    <span class=\"text-gray-900\">{{ $display->calendar->googleAccount->name }}</span>\n                </div>\n            @endif\n            @if($display->calendar->caldavAccount)\n                <div class=\"flex items-center\">\n                    <x-icons.calendar class=\"h-4 w-4 text-gray-900 mr-2\" />\n                    <span class=\"text-gray-900\">{{ $display->calendar->caldavAccount->name }}</span>\n                </div>\n            @endif\n            <div class=\"text-gray-500\">{{ $display->calendar->name }}</div>\n        </div>\n    </td>\n    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">\n        <span class=\"inline-flex items-center rounded-md bg-{{ $display->status->color() }}-50 px-2 py-1 text-xs font-medium text-{{ $display->status->color() }}-700 ring-1 ring-inset ring-{{ $display->status->color() }}-600\">\n            {{ $display->status->label() }}\n        </span>\n    </td>\n    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">\n        <div class=\"flex flex-col gap-1\">\n            <div class=\"flex items-center gap-x-1.5\">\n                @if($display->devices->isNotEmpty())\n                    <div class=\"flex-none rounded-full bg-emerald-500/20 p-1\">\n                        <div class=\"h-2 w-2 rounded-full bg-emerald-500\"></div>\n                    </div>\n                    <div class=\"group relative\">\n                        <button type=\"button\" class=\"flex items-center gap-x-1 text-sm text-gray-500 hover:text-gray-900\">\n                            <span>{{ $display->devices->count() }} device{{ $display->devices->count() > 1 ? 's' : '' }}</span>\n                            <x-icons.information class=\"h-4 w-4 text-gray-400\" />\n                        </button>\n                        <div class=\"absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block\">\n                            <div class=\"rounded-md bg-gray-900 px-2 py-1 text-xs text-white shadow-lg\">\n                                <div class=\"whitespace-nowrap\">\n                                    @foreach($display->devices as $device)\n                                        <div class=\"flex items-center gap-x-1\">\n                                            <span>{{ $device->name }}</span>\n                                            @if($device->last_activity_at)\n                                                <span class=\"text-gray-400\">({{ $device->last_activity_at->diffForHumans() }})</span>\n                                            @endif\n                                        </div>\n                                    @endforeach\n                                </div>\n                            </div>\n                        </div>\n                    </div>\n                @else\n                    <div class=\"flex-none rounded-full bg-gray-500/20 p-1\">\n                        <div class=\"h-2 w-2 rounded-full bg-gray-500\"></div>\n                    </div>\n                    <span class=\"text-gray-500\">No devices</span>\n                @endif\n            </div>\n            @if($display->last_sync_at)\n                <div class=\"text-gray-400 text-xs\">Last sync {{ $display->last_sync_at->diffForHumans() }}</div>\n            @endif\n        </div>\n    </td>\n    <td class=\"relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0\">\n        <div class=\"flex justify-end gap-x-2\">\n            <form action=\"{{ route('displays.updateStatus', $display) }}\" method=\"POST\" class=\"inline\">\n                @csrf\n                @method('PATCH')\n                <input type=\"hidden\" name=\"status\" value=\"{{ $display->status === \\App\\Enums\\DisplayStatus::ACTIVE ? 'deactivated' : 'active' }}\">\n                <button type=\"submit\" class=\"inline-flex items-center rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50\">\n                    @if($display->status === \\App\\Enums\\DisplayStatus::ACTIVE)\n                        <x-icons.pause class=\"h-4 w-4\" />\n                    @else\n                        <x-icons.play class=\"h-4 w-4\" />\n                    @endif\n                </button>\n            </form>\n            @if(auth()->user()->hasProForCurrentWorkspace())\n                <a href=\"{{ route('displays.customization', $display) }}\" class=\"inline-flex items-center rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-indigo-600 shadow-sm ring-1 ring-inset ring-indigo-300 hover:bg-indigo-50\" title=\"Customize display (Pro)\">\n                    <x-icons.brush class=\"h-4 w-4\" />\n                </a>\n                <a href=\"{{ route('displays.settings.index', $display) }}\" class=\"inline-flex items-center rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-blue-600 shadow-sm ring-1 ring-inset ring-blue-300 hover:bg-blue-50\" title=\"Display settings (Pro)\">\n                    <x-icons.settings class=\"h-4 w-4\" />\n                </a>\n            @else\n                <span class=\"inline-flex items-center rounded-md bg-gray-100 px-2.5 py-1.5 text-sm font-semibold text-gray-400 shadow-sm ring-1 ring-inset ring-gray-200 cursor-not-allowed\" title=\"Upgrade to Pro to unlock customization\">\n                    <x-icons.brush class=\"h-4 w-4\" />\n                </span>\n                <span class=\"inline-flex items-center rounded-md bg-gray-100 px-2.5 py-1.5 text-sm font-semibold text-gray-400 shadow-sm ring-1 ring-inset ring-gray-200 cursor-not-allowed\" title=\"Upgrade to Pro to unlock settings\">\n                    <x-icons.settings class=\"h-4 w-4\" />\n                </span>\n            @endif\n            <form action=\"{{ route('displays.delete', $display) }}\" method=\"POST\" class=\"inline\" onsubmit=\"return confirm('Are you sure you want to delete this display?');\">\n                @csrf\n                @method('DELETE')\n                <button type=\"submit\" class=\"inline-flex items-center rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-red-600 shadow-sm ring-1 ring-inset ring-red-300 hover:bg-red-50\" title=\"Delete display\">\n                    <x-icons.trash class=\"h-4 w-4\" />\n                </button>\n            </form>\n        </div>\n    </td>\n</tr>\n\n"
  },
  {
    "path": "backend/resources/views/components/icons/arrow-left.blade.php",
    "content": "<svg {{ $attributes }} xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18\" />\n</svg> "
  },
  {
    "path": "backend/resources/views/components/icons/brush.blade.php",
    "content": "<svg {{ $attributes }} fill=\"currentColor\" viewBox=\"0 0 297 297\" xmlns=\"http://www.w3.org/2000/svg\">\n  <path d=\"M249.774,121.802l-0.008-111.81c0-5.513-4.469-9.981-9.98-9.981h-33.201c-4.297,0-8.111,2.749-9.469,6.825l-7.125,21.375\n    l-7.137-21.383c-1.359-4.073-5.172-6.821-9.467-6.821L57.198,0c-0.002,0-0.002,0-0.002,0c-2.646,0-5.186,1.052-7.057,2.923\n    c-1.873,1.873-2.924,4.412-2.924,7.059l0.008,111.813c-1.287,0.934-2.514,1.972-3.662,3.119c-5.666,5.666-8.785,13.18-8.785,21.155\n    v9.836c0.002,16.512,13.436,29.944,29.945,29.944l47.756,0.004l0.002,75.129c-0.002,9.622,3.746,18.668,10.553,25.471\n    c6.805,6.801,15.852,10.547,25.473,10.547c0.004,0,0.008,0,0.012,0c9.621,0,18.666-3.744,25.469-10.544\n    c6.807-6.804,10.555-15.851,10.549-25.472l-0.004-75.126l47.75,0.004l0,0c7.976,0,15.49-3.121,21.156-8.787\n    c5.668-5.667,8.787-13.181,8.787-21.156v-9.84C262.222,136.098,257.306,127.245,249.774,121.802z M67.187,116.125l-0.008-96.161\n    l9.566,0.001v45.79c0,5.513,4.471,9.981,9.982,9.981c5.512,0,9.981-4.469,9.981-9.981V19.966l13.738,0.001v68.236\n    c0,5.513,4.469,9.982,9.98,9.982c5.514,0,9.982-4.47,9.982-9.982V19.969h10.93v23.33c0,5.512,4.469,9.981,9.98,9.981\n    c5.512,0,9.982-4.47,9.982-9.981V19.971h4.893l14.334,42.95c1.361,4.074,5.174,6.822,9.471,6.821\n    c4.295-0.001,8.109-2.75,9.467-6.825l14.314-42.942h16.025l0.004,96.159L67.187,116.125z M164.571,260.99\n    c0.002,4.286-1.666,8.316-4.699,11.347c-3.033,3.031-7.064,4.7-11.359,4.7c-0.002,0-0.004,0-0.004,0\n    c-4.295,0-8.33-1.671-11.363-4.703c-3.033-3.031-4.703-7.063-4.703-11.351l-0.002-75.129l32.127,0.003L164.571,260.99z\n     M232.286,136.099c5.5,0.005,9.973,4.481,9.975,9.981v9.84c0,2.644-1.045,5.144-2.939,7.04c-1.896,1.895-4.396,2.939-7.039,2.939\n    l-167.559-0.013c-5.504,0-9.982-4.479-9.984-9.982v-9.836c0-2.644,1.045-5.145,2.939-7.04c1.896-1.896,4.396-2.939,7.041-2.939\n    L232.286,136.099z\"/>\n</svg> "
  },
  {
    "path": "backend/resources/views/components/icons/building.blade.php",
    "content": "@props(['class' => ''])\n\n<svg {{ $attributes->merge(['class' => $class]) }} xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008z\" />\n</svg> "
  },
  {
    "path": "backend/resources/views/components/icons/caldav.blade.php",
    "content": "<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"{{ $class }}\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\">\n    <path d=\"M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM7 10h5v5H7z\"/>\n</svg> "
  },
  {
    "path": "backend/resources/views/components/icons/calendar.blade.php",
    "content": "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\" class=\"{{ $class }}\">\n  <rect x=\"3\" y=\"6\" width=\"18\" height=\"15\" rx=\"2\" fill=\"currentColor\" fill-opacity=\"0.05\"/>\n  <rect x=\"3\" y=\"6\" width=\"18\" height=\"15\" rx=\"2\" stroke=\"currentColor\" stroke-width=\"1.5\"/>\n  <path stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" d=\"M16 3v4M8 3v4\"/>\n  <path stroke=\"currentColor\" stroke-width=\"1.5\" d=\"M3 10h18\"/>\n</svg> "
  },
  {
    "path": "backend/resources/views/components/icons/display.blade.php",
    "content": "<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"{{ $class }}\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\">\n    <path d=\"M20 3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H4V5h16v14zM6 7h12v2H6zm0 4h12v2H6zm0 4h12v2H6z\"/>\n</svg>"
  },
  {
    "path": "backend/resources/views/components/icons/external.blade.php",
    "content": "@props(['class' => ''])\n\n<svg {{ $attributes->merge(['class' => $class]) }} xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25\" />\n</svg>\n"
  },
  {
    "path": "backend/resources/views/components/icons/google.blade.php",
    "content": "<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"{{ $class }}\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M21.35 11.1h-9.17v2.73h6.51c-.33 3.81-3.5 5.44-6.5 5.44C8.36 19.27 5 16.25 5 12c0-4.1 3.2-7.27 7.2-7.27c3.09 0 4.9 1.97 4.9 1.97L19 4.72S16.56 2 12.1 2C6.42 2 2.03 6.8 2.03 12c0 5.05 4.13 10 10.22 10c5.35 0 9.25-3.67 9.25-9.09c0-1.15-.15-1.81-.15-1.81\"/></svg>\n"
  },
  {
    "path": "backend/resources/views/components/icons/information.blade.php",
    "content": "@props(['class' => ''])\n\n<svg {{ $attributes->merge(['class' => $class]) }} viewBox=\"0 0 20 20\" fill=\"currentColor\" aria-hidden=\"true\">\n    <path fill-rule=\"evenodd\" d=\"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z\" clip-rule=\"evenodd\" />\n</svg>"
  },
  {
    "path": "backend/resources/views/components/icons/logout.blade.php",
    "content": "@props(['class' => ''])\n\n<svg {{ $attributes->merge(['class' => $class]) }} xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75\" />\n</svg> "
  },
  {
    "path": "backend/resources/views/components/icons/microsoft.blade.php",
    "content": "<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"{{ $class }}\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M2 3h9v9H2zm9 19H2v-9h9zM21 3v9h-9V3zm0 19h-9v-9h9z\"/></svg>\n"
  },
  {
    "path": "backend/resources/views/components/icons/pause.blade.php",
    "content": "@props(['class' => ''])\n\n<svg {{ $attributes->merge(['class' => $class]) }} xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\">\n    <path fill-rule=\"evenodd\" d=\"M6.75 5.25a.75.75 0 01.75-.75H9a.75.75 0 01.75.75v13.5a.75.75 0 01-.75.75H7.5a.75.75 0 01-.75-.75V5.25zm7.5 0A.75.75 0 0115 4.5h1.5a.75.75 0 01.75.75v13.5a.75.75 0 01-.75.75H15a.75.75 0 01-.75-.75V5.25z\" clip-rule=\"evenodd\" />\n</svg> "
  },
  {
    "path": "backend/resources/views/components/icons/play.blade.php",
    "content": "@props(['class' => ''])\n\n<svg {{ $attributes->merge(['class' => $class]) }} xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\">\n    <path fill-rule=\"evenodd\" d=\"M4.5 5.653c0-1.426 1.529-2.33 2.779-1.643l11.54 6.348c1.295.712 1.295 2.573 0 3.285L7.28 19.991c-1.25.687-2.779-.217-2.779-1.643V5.653z\" clip-rule=\"evenodd\" />\n</svg> "
  },
  {
    "path": "backend/resources/views/components/icons/plus.blade.php",
    "content": "<svg class=\"{{ $class }}\" viewBox=\"0 0 20 20\" fill=\"currentColor\" aria-hidden=\"true\">\n    <path d=\"M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z\" />\n</svg>"
  },
  {
    "path": "backend/resources/views/components/icons/room.blade.php",
    "content": "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\" class=\"w-6 h-6\">\n  <rect x=\"4\" y=\"10\" width=\"16\" height=\"8\" rx=\"2\" fill=\"currentColor\" fill-opacity=\"0.05\"/>\n  <rect x=\"4\" y=\"10\" width=\"16\" height=\"8\" rx=\"2\" stroke=\"currentColor\" stroke-width=\"1.5\"/>\n  <path stroke=\"currentColor\" stroke-width=\"1.5\" d=\"M4 10V8a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v2\"/>\n  <circle cx=\"8\" cy=\"14\" r=\"1\" fill=\"currentColor\"/>\n  <circle cx=\"16\" cy=\"14\" r=\"1\" fill=\"currentColor\"/>\n</svg> "
  },
  {
    "path": "backend/resources/views/components/icons/settings.blade.php",
    "content": "<svg {{ $attributes }} xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 0 1 0 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 0 1 0-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281Z\" />\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z\" />\n</svg> "
  },
  {
    "path": "backend/resources/views/components/icons/trash.blade.php",
    "content": "@props(['class' => ''])\n\n<svg {{ $attributes->merge(['class' => $class]) }} xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\">\n    <path fill-rule=\"evenodd\" d=\"M16.5 4.478v.227a48.816 48.816 0 013.878.512.75.75 0 11-.256 1.478l-.209-.035-1.005 13.07a3 3 0 01-2.991 2.77H8.084a3 3 0 01-2.991-2.77L4.087 6.66l-.209.035a.75.75 0 01-.256-1.478A48.567 48.567 0 017.5 4.705v-.227c0-1.564 1.213-2.9 2.816-2.951a52.662 52.662 0 013.369 0c1.603.051 2.815 1.387 2.815 2.951zm-6.136-1.452a51.196 51.196 0 013.273 0C14.39 3.05 15 3.684 15 4.478v.113a49.488 49.488 0 00-6 0v-.113c0-.794.609-1.428 1.364-1.452zm-.355 5.945a.75.75 0 10-1.5.058l.347 9a.75.75 0 101.499-.058l-.346-9zm5.48.058a.75.75 0 10-1.498-.058l-.347 9a.75.75 0 001.5.058l.345-9z\" clip-rule=\"evenodd\" />\n</svg> "
  },
  {
    "path": "backend/resources/views/components/icons/users.blade.php",
    "content": "@props(['class' => ''])\n\n<svg {{ $attributes->merge(['class' => $class]) }} xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z\" />\n</svg>"
  },
  {
    "path": "backend/resources/views/components/impersonation-banner.blade.php",
    "content": "@if(session('impersonating'))\n    <div class=\"bg-yellow-50 border-b border-yellow-200\">\n        <div class=\"mx-auto container px-4 sm:px-6\">\n            <div class=\"flex h-12 items-center justify-between px-4 sm:px-0\">\n                <div class=\"flex items-center gap-2\">\n                    <span class=\"text-sm font-medium text-yellow-800\">\n                        ⚠️ You are impersonating {{ auth()->user()->email }}\n                    </span>\n                </div>\n                <form action=\"{{ route('admin.stop-impersonating') }}\" method=\"POST\" class=\"inline\">\n                    @csrf\n                    <button type=\"submit\" class=\"rounded-md bg-yellow-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-yellow-700\">\n                        Stop Impersonating\n                    </button>\n                </form>\n            </div>\n        </div>\n    </div>\n@endif\n\n"
  },
  {
    "path": "backend/resources/views/components/modals/google-service-account.blade.php",
    "content": "<div x-data=\"{ \n        show: false,\n        googleAccountId: null,\n        loading: false\n    }\" \n    x-show=\"show\" \n    x-cloak\n    @open-service-account-modal.window=\"show = true; googleAccountId = $event.detail.googleAccountId\"\n    x-on:keydown.escape.window=\"show = false\" \n    class=\"relative z-50\" \n    role=\"dialog\" \n    aria-modal=\"true\">\n    {{-- Background backdrop --}}\n    <div x-show=\"show\" x-transition:enter=\"ease-out duration-300\" x-transition:leave=\"ease-in duration-200\"\n        class=\"fixed inset-0 bg-gray-500 opacity-75 transition-opacity\" @click=\"show = false\"></div>\n\n    <div class=\"fixed inset-0 z-50 overflow-y-auto\">\n        <div class=\"flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0\">\n            <div x-show=\"show\" x-transition:enter=\"ease-out duration-300\"\n                x-transition:enter-start=\"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95\"\n                x-transition:enter-end=\"opacity-100 translate-y-0 sm:scale-100\"\n                x-transition:leave=\"ease-in duration-200\"\n                x-transition:leave-start=\"opacity-100 translate-y-0 sm:scale-100\"\n                x-transition:leave-end=\"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95\"\n                class=\"relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6\"\n                @click.away=\"show = false\">\n                <div>\n                    <div class=\"mt-3 text-center sm:mt-5\">\n                        <h3 class=\"text-base font-semibold leading-6 text-gray-900\">Create a Google Service Account</h3>\n                        <p class=\"mt-2 text-sm text-gray-700\">\n                            To enable direct room booking for Google Workspace accounts, you need to create and upload a service account JSON file.<br><br>\n                            Because of security reasons, Google intentionally introduced some complexity to the process. Newer more simple ways of authenticating with Google Workspace API's are unfortunately not yet available. So for now, using a service account is the only way to enable direct room booking for Google Workspace accounts.\n                        </p>\n                    </div>\n\n                    <div class=\"mt-4 mb-6 rounded-lg bg-yellow-50 border-l-4 border-yellow-400 p-4 flex items-start gap-3\">\n                        <svg class=\"h-5 w-5 text-yellow-500 mt-0.5 flex-shrink-0\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\">\n                            <path d=\"M12 9v2m0 4h.01M5.07 20A9.938 9.938 0 0 1 2 12C2 6.48 6.48 2 12 2c5.52 0 10 4.48 10 10a9.938 9.938 0 0 1-3.07 8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n                        </svg>\n                        <div class=\"text-left\">\n                            <div class=\"font-semibold text-yellow-800\">Before you start</div>\n                            <div class=\"text-yellow-800 text-sm mt-1\">\n                                Make sure you disable the policy preventing the creation of service account keys in your Google Cloud Console.\n                                <a href=\"https://www.youtube.com/watch?v=VY_lrX5iY1U&start=0\" target=\"_blank\" class=\"underline text-yellow-900 hover:text-yellow-700 font-medium\">View this video for instructions.</a>\n                            </div>\n                        </div>\n                    </div>\n\n                    <div class=\"mt-6 space-y-4\">\n                        <div class=\"rounded-lg bg-blue-50 p-4\">\n                            <h4 class=\"text-sm font-semibold text-blue-900 mb-2\">How to get your service account file:</h4>\n                            <ol class=\"list-decimal list-inside space-y-2 text-sm text-blue-800\">\n                                <li>Go to <a href=\"https://console.cloud.google.com/\" target=\"_blank\" class=\"underline font-medium\">Google Cloud Console</a></li>\n                                <li>Create a new project or select an existing one</li>\n                                <li>Enable the <strong>Calendar API</strong> in the <strong>APIs & Services</strong> section</li>\n                                <li>Navigate to <strong>IAM & Admin</strong> &gt; <strong>Service Accounts</strong></li>\n                                <li>Click <strong>Create Service Account</strong></li>\n                                    <ul class=\"list-disc list-inside ml-4 mt-1\">\n                                        <li>Enter any name for the service account</li>\n                                        <li>Click <strong>Done</strong> (you can skip the permissions and principals with access steps)</li>\n                                    </ul>\n                                <li>Click on the service account you just created to open the details page</li>\n                                <li>Go to <strong>Keys</strong> tab and click <strong>Add Key</strong> &gt; <strong>Create new key</strong></li>\n                                <li>Select <strong>JSON</strong> format and download the file</li>\n                                <li>Copy the Client ID (long digit code 'Unique ID' from service account details page)</li>\n                                <li>Head to the <a href=\"https://admin.google.com/\" target=\"_blank\" class=\"underline font-medium\">Google Workspace Admin Console</a></li>\n                                <li>Go to <strong>Security</strong> &gt; <strong>Access &amp; control</strong> &gt; <strong>API Controls</strong></li>\n                                <li>Click on <strong>Manage domain-wide delegation</strong></li>\n                                <li>Click on <strong>Add new</strong></li>\n                                <li>Enter the Client ID you copied earlier</li>\n                                <li>Add the following scopes:\n                                    <ul class=\"list-disc list-inside ml-4 mt-1\">\n                                        <li><code class=\"text-xs\">https://www.googleapis.com/auth/calendar.readonly</code></li>\n                                        <li><code class=\"text-xs\">https://www.googleapis.com/auth/calendar.events</code></li>\n                                    </ul>\n                                </li>\n                                <li><strong>Important:</strong> After uploading the service account file below, you must share each room calendar with the service account email (found in the JSON file as \"client_email\") and grant it \"Make changes to events\" permission. You can do this by:\n                                    <ul class=\"list-disc list-inside ml-4 mt-1\">\n                                        <li>Opening Google Calendar</li>\n                                        <li>Finding your room calendar</li>\n                                        <li>Clicking on the calendar settings (three dots)</li>\n                                        <li>Selecting \"Settings and sharing\"</li>\n                                        <li>Under \"Share with specific people\", add the service account email</li>\n                                        <li>Grant \"Make changes to events\" permission</li>\n                                    </ul>\n                                </li>\n                                <li>Upload the service account JSON file you downloaded earlier below</li>\n                            </ol>\n                        </div>\n\n                        <form x-ref=\"serviceAccountForm\"\n                            action=\"{{ route('google-accounts.service-account') }}\"\n                            method=\"POST\" \n                            enctype=\"multipart/form-data\"\n                            @submit.prevent=\"loading = true; $refs.serviceAccountForm.submit()\" \n                            class=\"mt-6\">\n                            @csrf\n                            <input type=\"hidden\" name=\"google_account_id\" :value=\"googleAccountId\">\n                            \n                            <div>\n                                <label for=\"service_account_file\" class=\"block text-sm font-medium leading-6 text-gray-900\">\n                                    Service Account JSON File\n                                </label>\n                                <div class=\"mt-2\">\n                                    <input \n                                        type=\"file\" \n                                        id=\"service_account_file\" \n                                        name=\"service_account_file\" \n                                        accept=\".json\"\n                                        required\n                                        class=\"block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100\">\n                                </div>\n                                <p class=\"mt-1 text-xs text-gray-500\">Upload the JSON key file downloaded from Google Cloud Console.</p>\n                            </div>\n\n                            <div class=\"mt-6 flex items-center justify-end gap-x-3\">\n                                <button type=\"button\" @click=\"show = false\" :disabled=\"loading\"\n                                    class=\"text-sm font-semibold leading-6 text-gray-900 disabled:opacity-50\">\n                                    Skip for now\n                                </button>\n                                <button type=\"submit\" :disabled=\"loading\"\n                                    class=\"rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50 disabled:cursor-not-allowed\">\n                                    <span x-show=\"!loading\">Upload</span>\n                                    <span x-show=\"loading\">Uploading...</span>\n                                </button>\n                            </div>\n                        </form>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </div>\n</div>\n\n"
  },
  {
    "path": "backend/resources/views/components/modals/license-key.blade.php",
    "content": "@props(['show' => false])\n\n<div\n    x-data=\"{ show: false }\"\n    x-show=\"show\"\n    x-cloak\n    @open-modal.window=\"if ($event.detail === 'license-key') show = true\"\n    x-on:keydown.escape.window=\"show = false\"\n    class=\"relative z-50\"\n    role=\"dialog\"\n    aria-modal=\"true\"\n>\n    @if($errors->has('license_key'))\n        <script>\n            document.addEventListener('DOMContentLoaded', () => {\n                window.dispatchEvent(new CustomEvent('open-modal', { detail: 'license-key' }));\n            });\n        </script>\n    @endif\n\n    {{-- Background backdrop --}}\n    <div\n        x-show=\"show\"\n        x-transition:enter=\"ease-out duration-300\"\n        x-transition:leave=\"ease-in duration-200\"\n        class=\"fixed inset-0 bg-gray-500 opacity-75 transition-opacity\"\n    ></div>\n\n    <div class=\"fixed inset-0 z-50 overflow-y-auto\">\n        <div class=\"flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0\">\n            <div\n                x-show=\"show\"\n                x-transition:enter=\"ease-out duration-300\"\n                x-transition:enter-start=\"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95\"\n                x-transition:enter-end=\"opacity-100 translate-y-0 sm:scale-100\"\n                x-transition:leave=\"ease-in duration-200\"\n                x-transition:leave-start=\"opacity-100 translate-y-0 sm:scale-100\"\n                x-transition:leave-end=\"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95\"\n                class=\"relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6\"\n            >\n                <div>\n                    <div class=\"mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-orange-100\">\n                        <svg class=\"h-6 w-6 text-orange-600\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z\" />\n                        </svg>\n                    </div>\n                    <div class=\"mt-3 text-center sm:mt-5\">\n                        <h3 class=\"text-base font-semibold leading-6 text-gray-900\">Enter License Key</h3>\n                        <div class=\"mt-2\">\n                            <p class=\"text-sm text-gray-500 max-w-sm mx-auto\">\n                                Please enter your license key. Get your license key by purchasing a <a class=\"text-blue-500 underline\" href=\"https://spacepad.io/purchase/self-hosted-pro\" target=\"_blank\">Self Hosted Pro license</a>.\n                                You will receive a purchase confirmation email with the license key.\n                            </p>\n                        </div>\n                    </div>\n                </div>\n\n                <form action=\"{{ route('license.validate') }}\" method=\"POST\" class=\"mt-5 sm:mt-6\" x-on:submit=\"if ($event.target.checkValidity()) show = false\">\n                    @csrf\n                    <div>\n                        <label for=\"license_key\" class=\"sr-only\">License Key</label>\n                        <input\n                            type=\"text\"\n                            name=\"license_key\"\n                            id=\"license_key\"\n                            class=\"block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-orange-600 sm:text-sm sm:leading-6\"\n                            placeholder=\"XXXX-XXXX-XXXX-XXXX\"\n                            required\n                        >\n                    </div>\n\n                    @error('license_key')\n                        <p class=\"mt-2 text-sm text-red-600\">{{ $message }}</p>\n                    @enderror\n\n                    <div class=\"mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3\">\n                        <button\n                            type=\"submit\"\n                            class=\"inline-flex w-full justify-center rounded-md bg-orange px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-orange-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-600 sm:col-start-2\"\n                        >\n                            Validate License\n                        </button>\n                        <button\n                            type=\"button\"\n                            @click=\"show = false\"\n                            class=\"mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:col-start-1 sm:mt-0\"\n                        >\n                            Cancel\n                        </button>\n                    </div>\n                </form>\n            </div>\n        </div>\n    </div>\n</div>\n"
  },
  {
    "path": "backend/resources/views/components/modals/manage-subscription.blade.php",
    "content": "@props(['show' => false])\n\n<div\n    x-data=\"{ show: false }\"\n    x-show=\"show\"\n    x-cloak\n    @open-modal.window=\"if ($event.detail === 'manage-subscription') show = true\"\n    x-on:keydown.escape.window=\"show = false\"\n    class=\"relative z-50\"\n    role=\"dialog\"\n    aria-modal=\"true\"\n>\n    {{-- Background backdrop --}}\n    <div\n        x-show=\"show\"\n        x-transition:enter=\"ease-out duration-300\"\n        x-transition:leave=\"ease-in duration-200\"\n        class=\"fixed inset-0 bg-gray-500 opacity-75 transition-opacity\"\n    ></div>\n\n    <div class=\"fixed inset-0 z-50 overflow-y-auto\">\n        <div class=\"flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0\">\n            <div\n                x-show=\"show\"\n                x-transition:enter=\"ease-out duration-300\"\n                x-transition:enter-start=\"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95\"\n                x-transition:enter-end=\"opacity-100 translate-y-0 sm:scale-100\"\n                x-transition:leave=\"ease-in duration-200\"\n                x-transition:leave-start=\"opacity-100 translate-y-0 sm:scale-100\"\n                x-transition:leave-end=\"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95\"\n                class=\"relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6\"\n            >\n                <div>\n                    <div class=\"mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-100\">\n                        <svg class=\"h-10 w-10 text-blue-600\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M12 6v6l4 2\" />\n                        </svg>\n                    </div>\n                    <div class=\"mt-3 text-center sm:mt-5\">\n                        <h3 class=\"text-base font-semibold leading-6 text-gray-900\">Manage your subscription</h3>\n                        <div class=\"mt-2\">\n                            <p class=\"text-sm text-gray-500 max-w-sm mx-auto\">\n                                You can manage your subscription in Lemon Squeezy using the order and payment emails you've received. In the email, you will find a big \"Manage Subscription\" button that works for both cloud-hosted and self-hosted subscriptions.\n                            </p>\n                            <p class=\"text-sm text-gray-500 max-w-sm mx-auto mt-2\">\n                                If you have a usage-based subscription (cloud hosted), your subscription automatically adapts every month based on your usage.\n                            </p>\n                            <p class=\"text-sm text-gray-500 max-w-sm mx-auto mt-2\">\n                                Can't find the email? Reach out to us at <a href=\"mailto:support@spacepad.io\" class=\"text-blue-600 hover:text-blue-700\">support@spacepad.io</a>.\n                            </p>\n                        </div>\n                    </div>\n                </div>\n                <div class=\"mt-5 sm:mt-6\">\n                    <button\n                        type=\"button\"\n                        @click=\"show = false\"\n                        class=\"inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50\"\n                    >\n                        Close\n                    </button>\n                </div>\n            </div>\n        </div>\n    </div>\n</div> "
  },
  {
    "path": "backend/resources/views/components/modals/select-google-booking-method.blade.php",
    "content": "<div x-data=\"{ \n        show: false,\n        bookingMethod: 'user_account',\n        googleAccountId: null,\n        loading: false\n    }\" \n    x-show=\"show\" \n    x-cloak\n    @open-google-booking-method-modal.window=\"show = true; bookingMethod = 'user_account'; googleAccountId = $event.detail || null;\"\n    x-on:keydown.escape.window=\"show = false\" \n    class=\"relative z-50\" \n    role=\"dialog\" \n    aria-modal=\"true\">\n    {{-- Background backdrop --}}\n    <div x-show=\"show\" x-transition:enter=\"ease-out duration-300\" x-transition:leave=\"ease-in duration-200\"\n        class=\"fixed inset-0 bg-gray-500 opacity-75 transition-opacity\" @click=\"show = false\"></div>\n\n    <div class=\"fixed inset-0 z-50 overflow-y-auto\">\n        <div class=\"flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0\">\n            <div x-show=\"show\" x-transition:enter=\"ease-out duration-300\"\n                x-transition:enter-start=\"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95\"\n                x-transition:enter-end=\"opacity-100 translate-y-0 sm:scale-100\"\n                x-transition:leave=\"ease-in duration-200\"\n                x-transition:leave-start=\"opacity-100 translate-y-0 sm:scale-100\"\n                x-transition:leave-end=\"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95\"\n                class=\"relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6\"\n                @click.away=\"show = false\">\n                <div>\n                    <div class=\"mt-3 text-center sm:mt-5\">\n                        <h3 class=\"text-base font-semibold leading-6 text-gray-900\">Choose Booking Method</h3>\n                        <p class=\"mt-2 text-sm text-gray-700\">\n                            How would you like to handle room bookings for your Google Workspace account?\n                        </p>\n                    </div>\n\n                    <form x-ref=\"bookingMethodForm\"\n                        action=\"{{ route('google-accounts.set-booking-method') }}\"\n                        method=\"POST\" \n                        @submit.prevent=\"loading = true; $refs.bookingMethodForm.submit()\"\n                        class=\"mt-6\">\n                        @csrf\n                        <input type=\"hidden\" name=\"google_account_id\" :value=\"googleAccountId\">\n                        <div class=\"space-y-4\">\n                            <!-- User Account Option -->\n                            <label\n                                class=\"relative flex cursor-pointer rounded-lg border p-4 shadow-sm focus:outline-none transition-all\"\n                                :class=\"bookingMethod === 'user_account' ? 'border-blue-600 bg-blue-50' : 'border-gray-300 bg-white hover:border-gray-400'\">\n                                <input type=\"radio\" name=\"booking_method\" value=\"user_account\" class=\"sr-only\"\n                                    x-model=\"bookingMethod\">\n                                <span class=\"flex flex-1\">\n                                    <span class=\"flex flex-col\">\n                                        <span class=\"block text-sm font-medium\"\n                                            :class=\"bookingMethod === 'user_account' ? 'text-blue-900' : 'text-gray-900'\">\n                                            User Account\n                                            <span\n                                                class=\"ml-2 inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-600/20\">Recommended</span>\n                                        </span>\n                                        <span class=\"mt-1 text-sm\"\n                                            :class=\"bookingMethod === 'user_account' ? 'text-blue-700' : 'text-gray-500'\">\n                                            Simpler setup. Room bookings will appear in your personal calendar and you will be an attendee of every room booking. \n                                            <strong class=\"font-semibold\">We recommend using a dedicated Google Workspace account for this app.</strong>\n                                        </span>\n                                    </span>\n                                </span>\n                                <svg class=\"h-5 w-5 flex-shrink-0\"\n                                    :class=\"bookingMethod === 'user_account' ? 'text-blue-600' : 'text-gray-400'\"\n                                    viewBox=\"0 0 20 20\" fill=\"currentColor\" aria-hidden=\"true\">\n                                    <path fill-rule=\"evenodd\"\n                                        d=\"M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a1 1 0 00-1.714-1.382L9 9.586 7.857 8.809a1 1 0 00-1.714 1.382l2 2.5a1 1 0 001.428 0l4-5z\"\n                                        clip-rule=\"evenodd\" />\n                                </svg>\n                                <span class=\"pointer-events-none absolute -inset-px rounded-lg border-2\"\n                                    aria-hidden=\"true\"\n                                    :class=\"bookingMethod === 'user_account' ? 'border-blue-600' : 'border-transparent'\"></span>\n                            </label>\n\n                            <!-- Service Account Option -->\n                            <label\n                                class=\"relative flex cursor-pointer rounded-lg border p-4 shadow-sm focus:outline-none transition-all\"\n                                :class=\"bookingMethod === 'service_account' ? 'border-blue-600 bg-blue-50' : 'border-gray-300 bg-white hover:border-gray-400'\">\n                                <input type=\"radio\" name=\"booking_method\" value=\"service_account\" class=\"sr-only\"\n                                    x-model=\"bookingMethod\">\n                                <span class=\"flex flex-1\">\n                                    <span class=\"flex flex-col\">\n                                        <span class=\"block text-sm font-medium\"\n                                            :class=\"bookingMethod === 'service_account' ? 'text-blue-900' : 'text-gray-900'\">\n                                            Service Account\n                                        </span>\n                                        <span class=\"mt-1 text-sm\"\n                                            :class=\"bookingMethod === 'service_account' ? 'text-blue-700' : 'text-gray-500'\">\n                                            The most professional way. Room bookings appear directly on the room calendar without you being an attendee. \n                                            <strong class=\"font-semibold\">Requires extensive setup with Google Workspace admin.</strong>\n                                        </span>\n                                    </span>\n                                </span>\n                                <svg class=\"h-5 w-5 flex-shrink-0\"\n                                    :class=\"bookingMethod === 'service_account' ? 'text-blue-600' : 'text-gray-400'\"\n                                    viewBox=\"0 0 20 20\" fill=\"currentColor\" aria-hidden=\"true\">\n                                    <path fill-rule=\"evenodd\"\n                                        d=\"M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a1 1 0 00-1.714-1.382L9 9.586 7.857 8.809a1 1 0 00-1.714 1.382l2 2.5a1 1 0 001.428 0l4-5z\"\n                                        clip-rule=\"evenodd\" />\n                                </svg>\n                                <span class=\"pointer-events-none absolute -inset-px rounded-lg border-2\"\n                                    aria-hidden=\"true\"\n                                    :class=\"bookingMethod === 'service_account' ? 'border-blue-600' : 'border-transparent'\"></span>\n                            </label>\n                        </div>\n\n                        <div class=\"mt-6 flex items-center justify-end gap-x-3\">\n                            <button type=\"button\" @click=\"show = false\" :disabled=\"loading\"\n                                class=\"text-sm font-semibold leading-6 text-gray-900 disabled:opacity-50\">\n                                Cancel\n                            </button>\n                            <button type=\"submit\" :disabled=\"loading\"\n                                class=\"rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50 disabled:cursor-not-allowed\">\n                                <span x-show=\"!loading\">Continue</span>\n                                <span x-show=\"loading\">Loading...</span>\n                            </button>\n                        </div>\n                    </form>\n                </div>\n            </div>\n        </div>\n    </div>\n</div>\n\n"
  },
  {
    "path": "backend/resources/views/components/modals/select-permission.blade.php",
    "content": "@props(['provider' => 'outlook'])\n\n<div x-data=\"{ \n        show: false,\n        provider: '{{ $provider }}',\n        permissionType: 'write',\n        loading: false\n    }\" x-show=\"show\" x-cloak\n    @open-permission-modal.window=\"if ($event.detail.provider === provider) { show = true; permissionType = 'write'; }\"\n    x-on:keydown.escape.window=\"show = false\" class=\"relative z-50\" role=\"dialog\" aria-modal=\"true\">\n    {{-- Background backdrop --}}\n    <div x-show=\"show\" x-transition:enter=\"ease-out duration-300\" x-transition:leave=\"ease-in duration-200\"\n        class=\"fixed inset-0 bg-gray-500 opacity-75 transition-opacity\" @click=\"show = false\"></div>\n\n    <div class=\"fixed inset-0 z-50 overflow-y-auto\">\n        <div class=\"flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0\">\n            <div x-show=\"show\" x-transition:enter=\"ease-out duration-300\"\n                x-transition:enter-start=\"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95\"\n                x-transition:enter-end=\"opacity-100 translate-y-0 sm:scale-100\"\n                x-transition:leave=\"ease-in duration-200\"\n                x-transition:leave-start=\"opacity-100 translate-y-0 sm:scale-100\"\n                x-transition:leave-end=\"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95\"\n                class=\"relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6\"\n                @click.away=\"show = false\">\n                <div>\n                    <div class=\"mt-3 text-center sm:mt-5\">\n                        <h3 class=\"text-base font-semibold leading-6 text-gray-900\">Select account permissions</h3>\n                        <p class=\"mt-2 text-sm text-gray-700\">Choose the level of access you want to grant to your\n                            {{ ucfirst($provider) }} account.\n                        </p>\n                    </div>\n\n                    <form x-ref=\"permissionForm\"\n                        :action=\"provider === 'outlook' ? '{{ route('outlook-accounts.auth') }}' : (provider === 'google' ? '{{ route('google-accounts.auth') }}' : '#')\"\n                        method=\"POST\" \n                        @submit.prevent=\"loading = true; $refs.permissionForm.submit()\" \n                        class=\"mt-6\">\n                        @csrf\n                        <div class=\"space-y-4\">\n                            <!-- Write Permission Option -->\n                            <label\n                                class=\"relative flex cursor-pointer rounded-lg border p-4 shadow-sm focus:outline-none transition-all\"\n                                :class=\"permissionType === 'write' ? 'border-blue-600 bg-blue-50' : 'border-gray-300 bg-white hover:border-gray-400'\">\n                                <input type=\"radio\" name=\"permission_type\" value=\"write\" class=\"sr-only\"\n                                    x-model=\"permissionType\">\n                                <span class=\"flex flex-1\">\n                                    <span class=\"flex flex-col\">\n                                        <span class=\"block text-sm font-medium\"\n                                            :class=\"permissionType === 'write' ? 'text-blue-900' : 'text-gray-900'\">\n                                            Read & Write\n                                            <span\n                                                class=\"ml-2 inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-600/20\">Recommended</span>\n                                        </span>\n                                        <span class=\"mt-1 text-sm\"\n                                            :class=\"permissionType === 'write' ? 'text-blue-700' : 'text-gray-500'\">\n                                            View calendar events and create new bookings. Allows events booked from the tablet display to be written to the calendar.\n                                        </span>\n                                    </span>\n                                </span>\n                                <svg class=\"h-5 w-5 flex-shrink-0\"\n                                    :class=\"permissionType === 'write' ? 'text-blue-600' : 'text-gray-400'\"\n                                    viewBox=\"0 0 20 20\" fill=\"currentColor\" aria-hidden=\"true\">\n                                    <path fill-rule=\"evenodd\"\n                                        d=\"M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a1 1 0 00-1.714-1.382L9 9.586 7.857 8.809a1 1 0 00-1.714 1.382l2 2.5a1 1 0 001.428 0l4-5z\"\n                                        clip-rule=\"evenodd\" />\n                                </svg>\n                                <span class=\"pointer-events-none absolute -inset-px rounded-lg border-2\"\n                                    aria-hidden=\"true\"\n                                    :class=\"permissionType === 'write' ? 'border-blue-600' : 'border-transparent'\"></span>\n                            </label>\n\n                            <!-- Read Permission Option -->\n                            <label\n                                class=\"relative flex cursor-pointer rounded-lg border p-4 shadow-sm focus:outline-none transition-all\"\n                                :class=\"permissionType === 'read' ? 'border-blue-600 bg-blue-50' : 'border-gray-300 bg-white hover:border-gray-400'\">\n                                <input type=\"radio\" name=\"permission_type\" value=\"read\" class=\"sr-only\"\n                                    x-model=\"permissionType\">\n                                <span class=\"flex flex-1\">\n                                    <span class=\"flex flex-col\">\n                                        <span class=\"block text-sm font-medium\"\n                                            :class=\"permissionType === 'read' ? 'text-blue-900' : 'text-gray-900\">Read\n                                            Only</span>\n                                        <span class=\"mt-1 text-sm\"\n                                            :class=\"permissionType === 'read' ? 'text-blue-700' : 'text-gray-500'\">\n                                            View calendar events and room availability. Cannot create or modify\n                                            events in your calendar.<br><br>\n                                            <strong class=\"font-semibold\"\n                                                :class=\"permissionType === 'read' ? 'text-blue-900' : 'text-gray-900'\">When\n                                                you choose this option, ad-hoc room bookings will not appear in your\n                                                calendar.</strong>\n                                        </span>\n                                    </span>\n                                </span>\n                                <svg class=\"h-5 w-5 flex-shrink-0\"\n                                    :class=\"permissionType === 'read' ? 'text-blue-600' : 'text-gray-400'\"\n                                    viewBox=\"0 0 20 20\" fill=\"currentColor\" aria-hidden=\"true\">\n                                    <path fill-rule=\"evenodd\"\n                                        d=\"M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a1 1 0 00-1.714-1.382L9 9.586 7.857 8.809a1 1 0 00-1.714 1.382l2 2.5a1 1 0 001.428 0l4-5z\"\n                                        clip-rule=\"evenodd\" />\n                                </svg>\n                                <span class=\"pointer-events-none absolute -inset-px rounded-lg border-2\"\n                                    aria-hidden=\"true\"\n                                    :class=\"permissionType === 'read' ? 'border-blue-600' : 'border-transparent'\"></span>\n                            </label>\n                        </div>\n\n                        <div class=\"mt-6 flex items-center justify-end gap-x-3\">\n                            <button type=\"button\" @click=\"show = false\" :disabled=\"loading\"\n                                class=\"text-sm font-semibold leading-6 text-gray-900 disabled:opacity-50\">\n                                Cancel\n                            </button>\n                            <button type=\"submit\" :disabled=\"loading\"\n                                class=\"rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50 disabled:cursor-not-allowed\">\n                                <span x-show=\"!loading\">Continue to {{ ucfirst($provider) }}</span>\n                                <span x-show=\"loading\">Loading...</span>\n                            </button>\n                        </div>\n                    </form>\n                </div>\n            </div>\n        </div>\n    </div>\n</div>"
  },
  {
    "path": "backend/resources/views/components/rooms/picker.blade.php",
    "content": "<label for=\"room\" class=\"block text-sm font-medium leading-6 text-gray-900\">Connected room</label>\n<div class=\"mt-1\">\n    <select name=\"room\" id=\"room\" class=\"block w-full rounded-md border-0 py-2 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6\">\n        @if(empty($rooms))\n            <option value=\"\">No rooms found</option>\n        @else\n            <option value=\"\">Select a room</option>\n            @foreach($rooms as $room)\n                <option value=\"{{ $room['emailAddress'] . ',' . $room['name'] }}\">{{ $room['name'] }}</option>\n            @endforeach\n        @endif\n    </select>\n</div>\n\n@if(isset($error))\n    <div class=\"mt-4\">\n        <div class=\"rounded-md bg-red-50 p-4\">\n            <div class=\"flex\">\n                <div class=\"flex-shrink-0\">\n                    <svg class=\"h-5 w-5 text-red-400\" viewBox=\"0 0 20 20\" fill=\"currentColor\" aria-hidden=\"true\">\n                        <path fill-rule=\"evenodd\" d=\"M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z\" clip-rule=\"evenodd\" />\n                    </svg>\n                </div>\n                <div class=\"ml-3\">\n                    <h3 class=\"text-sm font-medium text-red-800\">Something went wrong</h3>\n                    <div class=\"mt-2 text-sm text-red-700\">\n                        <p>{{ $error }}</p>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </div>\n@endif\n\n@if($type === \\App\\Enums\\Provider::GOOGLE)\n<div id=\"roomWarning\" class=\"mt-4 hidden\">\n    <div class=\"rounded-md bg-yellow-50 p-4\">\n        <div class=\"flex items-start\">\n            <div class=\"flex-shrink-0\">\n                <svg class=\"h-5 w-5 text-yellow-400\" viewBox=\"0 0 20 20\" fill=\"currentColor\" aria-hidden=\"true\">\n                    <path fill-rule=\"evenodd\" d=\"M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z\" clip-rule=\"evenodd\" />\n                </svg>\n            </div>\n            <div class=\"flex-1 pl-2\">\n                <h3 class=\"text-sm font-medium text-yellow-800\">Important: Calendar Read Access Required</h3>\n                <div class=\"mt-2 text-sm text-yellow-700\">\n                    <p>\n                        By default, organizational admins should have access to all rooms. To ensure you can view calendar events, you can test your access by adding the room's calendar to your Google Calendar. Here's how:\n                    </p>\n                    <ol class=\"list-decimal list-inside mt-2 space-y-1\">\n                        <li>Open Google Calendar</li>\n                        <li>Click the \"+\" next to \"Other calendars\"</li>\n                        <li>Select \"Subscribe to calendar\"</li>\n                        <li>Enter the room's email address</li>\n                        <li>Click \"Add calendar\"</li>\n                    </ol>\n                </div>\n            </div>\n            <div class=\"ml-6 flex-shrink-0\">\n                <img src=\"{{ asset('images/gcal-instruction.png') }}\" alt=\"Google Calendar Instructions\" class=\"h-32 w-auto rounded-lg border border-gray-200\">\n            </div>\n        </div>\n    </div>\n</div>\n\n<script>\ndocument.getElementById('room').addEventListener('change', function() {\n    const warning = document.getElementById('roomWarning');\n    warning.classList.toggle('hidden', !this.value);\n});\n</script>\n@endif\n"
  },
  {
    "path": "backend/resources/views/components/scripts/clarity.blade.php",
    "content": "<script type=\"text/javascript\">\n    (function(c,l,a,r,i,t,y){\n        c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};\n        t=l.createElement(r);t.async=1;t.src=\"https://www.clarity.ms/tag/\"+i;\n        y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);\n    })(window, document, \"clarity\", \"script\", \"{{ config('services.clarity.tag_code') }}\");\n</script>\n"
  },
  {
    "path": "backend/resources/views/components/scripts/faro.blade.php",
    "content": "@if(config('faro.enabled'))\n<script type=\"module\">\n    import { initializeFaro, getWebInstrumentations } from 'https://cdn.jsdelivr.net/npm/@grafana/faro-web-sdk@latest/+esm';\n    \n    try {\n        const faroInstance = initializeFaro({\n            url: @json(config('faro.collector_url')),\n            apiKey: @json(config('faro.api_key')),\n            app: @json(config('faro.app')),\n            instrumentations: getWebInstrumentations(),\n            sessionTracking: {\n                enabled: @json(config('faro.session_tracking')),\n            },\n        });\n        \n        // Store in window for debugging/access\n        if (window) {\n            Object.defineProperty(window, 'faroInstance', {\n                value: faroInstance,\n                writable: false,\n                configurable: true\n            });\n        }\n        \n        console.log('[FARO] Initialized - RUM telemetry enabled');\n    } catch (error) {\n        console.error('[FARO] Failed to initialize:', error);\n    }\n</script>\n@endif\n\n"
  },
  {
    "path": "backend/resources/views/errors/403.blade.php",
    "content": "@extends('layouts.error')\n\n@section('title', 'Access Denied')\n\n@section('content')\n    <div class=\"mt-6 text-center\">\n        <h1 class=\"text-6xl font-bold text-oxford\">403</h1>\n        <h2 class=\"mt-4 text-2xl font-semibold text-gray-900\">Access Denied</h2>\n        <p class=\"mt-2 text-gray-600\">You don't have permission to access this page.</p>\n        <div class=\"mt-6\">\n            <a href=\"{{ route('dashboard') }}\" class=\"inline-flex items-center rounded-md bg-oxford px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600\">\n                Go back to dashboard\n            </a>\n        </div>\n    </div>\n@endsection "
  },
  {
    "path": "backend/resources/views/errors/404.blade.php",
    "content": "@extends('layouts.error')\n\n@section('title', 'Page Not Found')\n\n@section('content')\n    <div class=\"mt-6 text-center\">\n        <h1 class=\"text-6xl font-bold text-oxford\">404</h1>\n        <h2 class=\"mt-4 text-2xl font-semibold text-gray-900\">Page Not Found</h2>\n        <p class=\"mt-2 text-gray-600\">The page you're looking for doesn't exist or has been moved.</p>\n        <div class=\"mt-6\">\n            <a href=\"{{ route('dashboard') }}\" class=\"inline-flex items-center rounded-md bg-oxford px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600\">\n                Go back to dashboard\n            </a>\n        </div>\n    </div>\n@endsection "
  },
  {
    "path": "backend/resources/views/errors/419.blade.php",
    "content": "@extends('layouts.error')\n\n@section('title', 'Page Expired')\n\n@section('content')\n    <div class=\"mt-6 text-center\">\n        <h1 class=\"text-6xl font-bold text-oxford\">419</h1>\n        <h2 class=\"mt-4 text-2xl font-semibold text-gray-900\">Page Expired</h2>\n        <p class=\"mt-2 text-gray-600\">Your session has expired. Please refresh the page and try again.</p>\n        <div class=\"mt-6\">\n            <a href=\"{{ route('dashboard') }}\" class=\"inline-flex items-center rounded-md bg-oxford px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600\">\n                Go back to dashboard\n            </a>\n        </div>\n    </div>\n@endsection "
  },
  {
    "path": "backend/resources/views/errors/429.blade.php",
    "content": "@extends('layouts.error')\n\n@section('title', 'Too Many Requests')\n\n@section('content')\n    <div class=\"mt-6 text-center\">\n        <h1 class=\"text-6xl font-bold text-oxford\">429</h1>\n        <h2 class=\"mt-4 text-2xl font-semibold text-gray-900\">Too Many Requests</h2>\n        <p class=\"mt-2 text-gray-600\">You've made too many requests. Please wait a moment and try again.</p>\n        <div class=\"mt-6\">\n            <a href=\"{{ route('dashboard') }}\" class=\"inline-flex items-center rounded-md bg-oxford px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600\">\n                Go back to dashboard\n            </a>\n        </div>\n    </div>\n@endsection "
  },
  {
    "path": "backend/resources/views/errors/500.blade.php",
    "content": "@extends('layouts.error')\n\n@section('title', 'Server Error')\n\n@section('content')\n    <div class=\"mt-6 text-center\">\n        <h1 class=\"text-6xl font-bold text-oxford\">500</h1>\n        <h2 class=\"mt-4 text-2xl font-semibold text-gray-900\">Server Error</h2>\n        <p class=\"mt-2 text-gray-600\">Something went wrong on our end. Please try again later.</p>\n        <div class=\"mt-6\">\n            <a href=\"{{ route('dashboard') }}\" class=\"inline-flex items-center rounded-md bg-oxford px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600\">\n                Go back to dashboard\n            </a>\n        </div>\n    </div>\n@endsection "
  },
  {
    "path": "backend/resources/views/layouts/base.blade.php",
    "content": "@extends('layouts.blank')\n@section('page')\n    <nav class=\"bg-white border-b border-gray-200 mb-8\">\n        <div class=\"mx-auto container px-4 sm:px-6\">\n            <div class=\"flex h-16 items-center justify-between px-4 sm:px-0\">\n                <a href=\"/\" class=\"flex items-center\">\n                    <div class=\"flex-shrink-0 me-3\">\n                        <img class=\"h-7 w-7\" src=\"/images/logo-black.svg\" alt=\"Logo\">\n                    </div>\n                    <span class=\"text-xl font-semibold text-black\">Spacepad</span>\n                    @if(auth()->user()->hasProForCurrentWorkspace())\n                        <span class=\"ml-2 inline-flex items-center rounded-md bg-blue-50 px-1.5 py-0.5 text-sm font-medium text-blue-700 ring-1 ring-inset ring-blue-600\">Pro</span>\n                    @endif\n                </a>\n                <div class=\"ml-4 flex items-center space-x-4\">\n                    @php\n                        $workspaces = auth()->user()->workspaces()->withPivot('role')->get();\n                        $selectedWorkspace = auth()->user()->getSelectedWorkspace();\n                    @endphp\n                    @if($workspaces->count() > 1)\n                        <form action=\"{{ route('workspaces.switch') }}\" method=\"POST\" id=\"workspace-switch-form\" class=\"flex items-center\">\n                            @csrf\n                            <select \n                                id=\"workspace-select\"\n                                name=\"workspace_id\" \n                                onchange=\"this.form.submit();\"\n                                class=\"rounded-md px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer\"\n                            >\n                                @foreach($workspaces as $workspace)\n                                    <option value=\"{{ $workspace->id }}\" {{ ($selectedWorkspace?->id ?? $workspaces->first()->id) === $workspace->id ? 'selected' : '' }}>\n                                        {{ $workspace->name }}\n                                        @if($workspace->pivot->role === \\App\\Enums\\WorkspaceRole::OWNER->value)\n                                            (Owner)\n                                        @elseif($workspace->pivot->role === \\App\\Enums\\WorkspaceRole::ADMIN->value)\n                                            (Admin)\n                                        @endif\n                                    </option>\n                                @endforeach\n                            </select>\n                        </form>\n                    @endif\n                    @if(!session('impersonating') && auth()->user()->isAdmin() && !config('settings.is_self_hosted'))\n                        <a href=\"{{ route('admin.index') }}\" class=\"rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300 hover:text-black\">\n                            Admin\n                        </a>\n                    @endif\n                    @if(auth()->user()->hasProForCurrentWorkspace())\n                        <a href=\"{{ route('usage.index') }}\" class=\"rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300 hover:text-black\">\n                            Usage\n                        </a>\n                        <button type=\"button\" onclick=\"window.dispatchEvent(new CustomEvent('open-modal', { detail: 'manage-subscription' }))\" class=\"rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300 hover:text-black\">\n                            Manage subscription\n                        </button>\n                        <a href=\"mailto:support@spacepad.io\" class=\"hidden md:block rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300 hover:text-black\">\n                            Need help?\n                        </a>\n                    @endif\n                    @auth\n                        <form action=\"{{ route('logout') }}\" method=\"POST\">\n                            @csrf\n                            <button type=\"submit\" class=\"rounded-md px-3 py-2 text-sm border border-gray-300 font-medium text-gray-700 hover:bg-gray-300 hover:text-black\">\n                                Log out\n                            </button>\n                        </form>\n                    @endauth\n                </div>\n            </div>\n        </div>\n    </nav>\n\n    <header class=\"mx-auto @yield('container_class', 'container') px-4 sm:px-6 py-6\">\n        <div class=\"flex gap-4 items-center\">\n            <h1 class=\"text-2xl/7 font-bold text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight\">@yield('title')</h1>\n            @yield('actions')\n        </div>\n    </header>\n\n    <main class=\"mx-auto @yield('container_class', 'container') px-4 sm:px-6 pb-16\">\n        @yield('content')\n    </main>\n\n    @include('components.modals.manage-subscription')\n@endsection\n"
  },
  {
    "path": "backend/resources/views/layouts/blank.blade.php",
    "content": "<!doctype html>\n<html class=\"h-full bg-white\" lang=\"{{ App::currentLocale() }}\">\n    <head>\n        <meta charset=\"utf-8\"/>\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>\n        <meta name=\"csrf-token\" content=\"{{ csrf_token() }}\">\n        <meta name=\"htmx-config\" content='{\"selfRequestsOnly\": false}' />\n\n        <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/apple-touch-icon.png\">\n        <link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/favicon-32x32.png\">\n        <link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/favicon-16x16.png\">\n        <link rel=\"manifest\" href=\"/site.webmanifest\">\n        <link rel=\"mask-icon\" href=\"/safari-pinned-tab.svg\" color=\"#4fad32\">\n        <link rel=\"shortcut icon\" href=\"/favicon.ico\">\n        <meta name=\"msapplication-TileColor\" content=\"#ffffff\">\n        <meta name=\"msapplication-config\" content=\"/browserconfig.xml\">\n        <meta name=\"theme-color\" content=\"#ffffff\">\n\n        <meta name=\"robots\" content=\"noindex, nofollow\">\n        <title>@yield('title', config('app.name'))</title>\n\n        @includeWhen(config('googletagmanager.enabled') && config('googletagmanager.id'), 'googletagmanager::head')\n\n        @vite(['resources/css/app.css', 'resources/js/app.js'])\n\n        {!! RecaptchaV3::initJs() !!}\n\n        @stack('styles')\n        @lemonJS\n        @includeWhen(config('services.clarity.tag_code'), 'components.scripts.clarity')\n        @include('components.scripts.faro')\n    </head>\n    <body class=\"h-full @yield('body-classes')\">\n        @includeWhen(config('googletagmanager.enabled') && config('googletagmanager.id'), 'googletagmanager::body')\n        @stack('modals')\n        \n        @include('components.impersonation-banner')\n        \n        <div class=\"min-h-full bg-gray-50\">\n            @yield('page')\n        </div>\n        @stack('scripts')\n    </body>\n</html>\n"
  },
  {
    "path": "backend/resources/views/layouts/error.blade.php",
    "content": "<!DOCTYPE html>\n<html lang=\"{{ str_replace('_', '-', app()->getLocale()) }}\" class=\"h-full bg-gray-50\">\n<head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n    <meta name=\"csrf-token\" content=\"{{ csrf_token() }}\">\n\n    <title>{{ $title ?? 'Error' }} - Spacepad</title>\n\n    <!-- Scripts -->\n    @vite(['resources/css/app.css', 'resources/js/app.js'])\n    @include('components.scripts.faro')\n</head>\n<body class=\"h-full\">\n    <div class=\"flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8\">\n        <div class=\"sm:mx-auto sm:w-full sm:max-w-md\">\n            <div class=\"flex justify-center\">\n                <img class=\"h-12 w-auto\" src=\"/images/logo-black.svg\" alt=\"Logo\">\n            </div>\n            @yield('content')\n        </div>\n    </div>\n</body>\n</html> "
  },
  {
    "path": "backend/resources/views/pages/admin/user.blade.php",
    "content": "@extends('layouts.base')\n@section('title', 'User Details - ' . $user->email)\n@section('container_class', 'max-w-4xl')\n\n@section('content')\n    <x-cards.card>\n        <div class=\"sm:flex sm:items-center mb-6\">\n            <div class=\"sm:flex-auto\">\n                <h1 class=\"text-lg font-semibold leading-6 text-gray-900\">User Details</h1>\n                <p class=\"mt-1 text-sm text-gray-500\">View and manage user account information</p>\n            </div>\n            <div class=\"mt-4 sm:ml-16 sm:mt-0 sm:flex-none\">\n                <a href=\"{{ route('admin.index') }}\" class=\"inline-flex items-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50\">\n                    <x-icons.arrow-left class=\"h-4 w-4\" />\n                    Back to Admin\n                </a>\n            </div>\n        </div>\n\n        @if(session('error'))\n            <div class=\"mb-6 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded\">\n                {{ session('error') }}\n            </div>\n        @endif\n\n        @if(session('success'))\n            <div class=\"mb-6 bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded\">\n                {{ session('success') }}\n            </div>\n        @endif\n\n        <div class=\"space-y-6\">\n            <div class=\"border border-gray-200 rounded-lg p-6\">\n                <h3 class=\"text-base font-semibold text-gray-900 mb-4\">Account Information</h3>\n                <dl class=\"grid grid-cols-1 gap-4 sm:grid-cols-2\">\n                    <div>\n                        <dt class=\"text-sm font-medium text-gray-500\">Email</dt>\n                        <dd class=\"mt-1 text-sm text-gray-900\">{{ $user->email }}</dd>\n                    </div>\n                    <div>\n                        <dt class=\"text-sm font-medium text-gray-500\">Name</dt>\n                        <dd class=\"mt-1 text-sm text-gray-900\">{{ $user->name }}</dd>\n                    </div>\n                    <div>\n                        <dt class=\"text-sm font-medium text-gray-500\">User ID</dt>\n                        <dd class=\"mt-1 text-sm text-gray-900 font-mono\">{{ $user->id }}</dd>\n                    </div>\n                    <div>\n                        <dt class=\"text-sm font-medium text-gray-500\">Usage Type</dt>\n                        <dd class=\"mt-1 text-sm text-gray-900\">{{ $user->usage_type?->label() ?? 'Not set' }}</dd>\n                    </div>\n                    <div>\n                        <dt class=\"text-sm font-medium text-gray-500\">Created</dt>\n                        <dd class=\"mt-1 text-sm text-gray-900\">{{ $user->created_at->format('Y-m-d H:i:s') }}</dd>\n                    </div>\n                    <div>\n                        <dt class=\"text-sm font-medium text-gray-500\">Last Activity</dt>\n                        <dd class=\"mt-1 text-sm text-gray-900\">{{ $user->last_activity_at ? $user->last_activity_at->format('Y-m-d H:i:s') : 'Never' }}</dd>\n                    </div>\n                </dl>\n            </div>\n\n            @if($user->hasPro() || $subscriptionInfo)\n                <div class=\"border border-gray-200 rounded-lg p-6\">\n                    <h3 class=\"text-base font-semibold text-gray-900 mb-4\">Subscription Information</h3>\n                    <dl class=\"grid grid-cols-1 gap-4 sm:grid-cols-2\">\n                        <div>\n                            <dt class=\"text-sm font-medium text-gray-500\">Plan</dt>\n                            <dd class=\"mt-1\">\n                                @if($user->is_unlimited)\n                                    <span class=\"inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20\">Unlimited</span>\n                                @elseif($subscriptionInfo)\n                                    <span class=\"inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-600/20\">Pro</span>\n                                @else\n                                    <span class=\"inline-flex items-center rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-600/20\">Free</span>\n                                @endif\n                            </dd>\n                        </div>\n                        @if($subscriptionInfo)\n                            <div>\n                                <dt class=\"text-sm font-medium text-gray-500\">Status</dt>\n                                <dd class=\"mt-1\">\n                                    @php\n                                        $status = $subscriptionInfo['status'];\n                                        $statusLabel = ucwords(str_replace('_', ' ', $status));\n                                        $statusColors = match($status) {\n                                            'active' => 'bg-green-50 text-green-700 ring-green-600/20',\n                                            'past_due' => 'bg-yellow-50 text-yellow-700 ring-yellow-600/20',\n                                            'unpaid' => 'bg-red-50 text-red-700 ring-red-600/20',\n                                            'cancelled' => 'bg-gray-50 text-gray-700 ring-gray-600/20',\n                                            'on_trial' => 'bg-blue-50 text-blue-700 ring-blue-600/20',\n                                            'paused' => 'bg-orange-50 text-orange-700 ring-orange-600/20',\n                                            default => 'bg-gray-50 text-gray-700 ring-gray-600/20',\n                                        };\n                                    @endphp\n                                    <span class=\"inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset {{ $statusColors }}\">\n                                        {{ $statusLabel }}\n                                    </span>\n                                </dd>\n                            </div>\n                            <div>\n                                <dt class=\"text-sm font-medium text-gray-500\">Monthly Price</dt>\n                                <dd class=\"mt-1 text-sm text-gray-900\">${{ number_format($subscriptionInfo['price'], 2) }}</dd>\n                            </div>\n                            <div>\n                                <dt class=\"text-sm font-medium text-gray-500\">MRR</dt>\n                                <dd class=\"mt-1 text-sm text-gray-900\">${{ number_format($subscriptionInfo['mrr'], 2) }}</dd>\n                            </div>\n                            @if($subscriptionInfo['ends_at'])\n                                <div>\n                                    <dt class=\"text-sm font-medium text-gray-500\">Subscription Ends</dt>\n                                    <dd class=\"mt-1 text-sm text-gray-900\">{{ \\Carbon\\Carbon::parse($subscriptionInfo['ends_at'])->format('Y-m-d') }}</dd>\n                                </div>\n                            @endif\n                        @endif\n                    </dl>\n                </div>\n            @endif\n\n            <div class=\"border border-gray-200 rounded-lg p-6\">\n                <h3 class=\"text-base font-semibold text-gray-900 mb-4\">Data Summary</h3>\n                <dl class=\"grid grid-cols-1 gap-4 sm:grid-cols-2\">\n                    <div>\n                        <dt class=\"text-sm font-medium text-gray-500\">Outlook Accounts</dt>\n                        <dd class=\"mt-1 text-sm text-gray-900\">{{ $user->outlookAccounts->count() }}</dd>\n                    </div>\n                    <div>\n                        <dt class=\"text-sm font-medium text-gray-500\">Google Accounts</dt>\n                        <dd class=\"mt-1 text-sm text-gray-900\">{{ $user->googleAccounts->count() }}</dd>\n                    </div>\n                    <div>\n                        <dt class=\"text-sm font-medium text-gray-500\">CalDAV Accounts</dt>\n                        <dd class=\"mt-1 text-sm text-gray-900\">{{ $user->caldavAccounts->count() }}</dd>\n                    </div>\n                    <div>\n                        <dt class=\"text-sm font-medium text-gray-500\">Displays</dt>\n                        <dd class=\"mt-1 text-sm text-gray-900\">{{ $user->displays->count() }}</dd>\n                    </div>\n                    <div>\n                        <dt class=\"text-sm font-medium text-gray-500\">Devices</dt>\n                        <dd class=\"mt-1 text-sm text-gray-900\">{{ $user->devices->count() }}</dd>\n                    </div>\n                    <div>\n                        <dt class=\"text-sm font-medium text-gray-500\">Workspaces</dt>\n                        <dd class=\"mt-1 text-sm text-gray-900\">{{ $user->workspaces->count() }}</dd>\n                    </div>\n                </dl>\n            </div>\n\n            @if($user->id !== auth()->id())\n                <div class=\"border border-red-200 rounded-lg p-6 bg-red-50\">\n                    <h3 class=\"text-base font-semibold text-red-900 mb-4\">⚠️ Delete User Account</h3>\n                    <p class=\"text-sm text-red-800 mb-3\">\n                        This action cannot be undone. All data associated with this user will be permanently deleted:\n                    </p>\n                    <ul class=\"text-sm text-red-800 list-disc list-inside space-y-1 mb-4\">\n                        <li>All connected accounts (Outlook, Google, CalDAV)</li>\n                        <li>All displays and their settings</li>\n                        <li>All devices</li>\n                        <li>All calendars and events</li>\n                        <li>All rooms</li>\n                        <li>All workspace memberships</li>\n                        <li>All personal access tokens</li>\n                    </ul>\n\n                    <form action=\"{{ route('admin.users.delete', $user) }}\" method=\"POST\" class=\"mt-4\">\n                        @csrf\n                        @method('DELETE')\n\n                        <div class=\"mb-4\">\n                            <label for=\"confirm_email\" class=\"block text-sm font-medium text-gray-700 mb-2\">\n                                To confirm deletion, please type the user's email address:\n                            </label>\n                            <input\n                                type=\"email\"\n                                id=\"confirm_email\"\n                                name=\"confirm_email\"\n                                required\n                                class=\"mt-1 px-3 py-2 block w-full border rounded-md border-gray-300 focus:border-red-500 focus:ring-red-500 sm:text-sm\"\n                                placeholder=\"{{ $user->email }}\"\n                            >\n                            @error('confirm_email')\n                                <p class=\"mt-1 text-sm text-red-600\">{{ $message }}</p>\n                            @enderror\n                        </div>\n\n                        <div class=\"flex justify-end gap-x-3\">\n                            <button\n                                type=\"submit\"\n                                class=\"rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600\"\n                                onclick=\"return confirm('Are you absolutely sure you want to delete this user account? This action cannot be undone.')\"\n                            >\n                                Permanently delete user\n                            </button>\n                        </div>\n                    </form>\n                </div>\n            @else\n                <div class=\"border border-yellow-200 rounded-lg p-6 bg-yellow-50\">\n                    <h3 class=\"text-base font-semibold text-yellow-900 mb-2\">⚠️ Notice</h3>\n                    <p class=\"text-sm text-yellow-800\">\n                        You cannot delete your own account. Please ask another admin to perform this action if needed.\n                    </p>\n                </div>\n            @endif\n        </div>\n    </x-cards.card>\n@endsection\n"
  },
  {
    "path": "backend/resources/views/pages/admin.blade.php",
    "content": "@extends('layouts.base')\n\n@section('title', 'Admin dashboard')\n\n@section('content')\n    <div x-data=\"{ activeTab: 'users-overview' }\">\n        <!-- Tab Navigation -->\n        <div class=\"border-b border-gray-200\">\n            <nav class=\"-mb-px flex space-x-8\" aria-label=\"Tabs\">\n                <button\n                    @click=\"activeTab = 'users-overview'\"\n                    :class=\"activeTab === 'users-overview' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'\"\n                    class=\"whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium\"\n                >\n                    Users Overview\n                </button>\n                <button\n                    @click=\"activeTab = 'instances'\"\n                    :class=\"activeTab === 'instances' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'\"\n                    class=\"whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium\"\n                >\n                    Self-Hosted Instances\n                </button>\n                <button\n                    @click=\"activeTab = 'active-users'\"\n                    :class=\"activeTab === 'active-users' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'\"\n                    class=\"whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium\"\n                >\n                    Cloud-Hosted Users\n                </button>\n                <button\n                    @click=\"activeTab = 'paying-users'\"\n                    :class=\"activeTab === 'paying-users' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'\"\n                    class=\"whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium\"\n                >\n                    Paying Cloud-Hosted Users\n                </button>\n            </nav>\n        </div>\n\n        <!-- Tab 1: Self-Hosted Instances -->\n        <div x-show=\"activeTab === 'instances'\" x-transition:enter=\"transition ease-out duration-200\" x-transition:enter-start=\"opacity-0\" x-transition:enter-end=\"opacity-100\" x-transition:leave=\"transition ease-in duration-150\" x-transition:leave-start=\"opacity-100\" x-transition:leave-end=\"opacity-0\">\n            <div class=\"mt-6 grid grid-cols-1 gap-6 sm:grid-cols-2\">\n                <div class=\"bg-white overflow-hidden shadow rounded-lg\">\n                    <div class=\"p-5 flex items-center\">\n                        <div class=\"flex-shrink-0\">\n                            <!-- Heroicon: Bolt (outline) -->\n                            <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\" class=\"h-8 w-8 text-green-500\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M13.5 4.5L6 14.25h7.5L10.5 19.5 18 9.75h-7.5z\" /></svg>\n                        </div>\n                        <div class=\"ml-5 w-0 flex-1\">\n                            <dl>\n                                <dt class=\"text-sm font-medium text-gray-500 truncate\">Active Instances</dt>\n                                <dd class=\"mt-1 text-2xl font-semibold text-gray-900\">{{ $activeInstancesCount }}</dd>\n                            </dl>\n                        </div>\n                    </div>\n                </div>\n                <div class=\"bg-white overflow-hidden shadow rounded-lg\">\n                    <div class=\"p-5 flex items-center\">\n                        <div class=\"flex-shrink-0\">\n                            <!-- Heroicon: Hexagon (outline) -->\n                            <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\" class=\"h-8 w-8 text-yellow-500\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M3.25 7.5v9a2.25 2.25 0 001.125 1.95l6.75 3.9a2.25 2.25 0 002.25 0l6.75-3.9A2.25 2.25 0 0020.75 16.5v-9a2.25 2.25 0 00-1.125-1.95l-6.75-3.9a2.25 2.25 0 00-2.25 0l-6.75 3.9A2.25 2.25 0 003.25 7.5z\" /></svg>\n                        </div>\n                        <div class=\"ml-5 w-0 flex-1\">\n                            <dl>\n                                <dt class=\"text-sm font-medium text-gray-500 truncate\">Total Instances</dt>\n                                <dd class=\"mt-1 text-2xl font-semibold text-gray-900\">{{ $totalInstances }}</dd>\n                            </dl>\n                        </div>\n                    </div>\n                </div>\n            </div>\n\n            <div class=\"mt-10\">\n                <h2 class=\"text-xl font-bold mb-4\">Active Self-Hosted Instances</h2>\n                <div class=\"bg-white shadow rounded-lg p-6\">\n                    <div class=\"overflow-x-auto\">\n                        <table class=\"min-w-full divide-y divide-gray-300\">\n                            <thead>\n                            <tr>\n                                <th class=\"py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0\">Instance Key</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900 align-top\">Users</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900 align-top\">Displays</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900 align-top\">Boards</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900 align-top\">Rooms</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900 align-top\">Last Heartbeat</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900 align-top\">Paid</th>\n                            </tr>\n                            </thead>\n                            <tbody class=\"divide-y divide-gray-200\">\n                            @forelse($activeInstances as $instance)\n                                <tr>\n                                    <td class=\"whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0 align-top\">{{ $instance->instance_key }}</td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500 align-top\">\n                                        @if(is_array($instance->users))\n                                            @foreach($instance->users as $user)\n                                                <div>\n                                                    {{ $user['email'] ?? '' }}\n                                                    @if(!empty($user['usage_type']))\n                                                        ({{ ucfirst($user['usage_type']) }})\n                                                    @endif\n                                                </div>\n                                            @endforeach\n                                        @endif\n                                    </td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500 align-top\">{{ $instance->displays_count }}</td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500 align-top\">{{ $instance->boards_count ?? '-' }}</td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500 align-top\">{{ $instance->rooms_count }}</td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500 align-top\">{{ $instance->last_heartbeat_at?->diffForHumans() ?? '-' }}</td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500 align-top\">{{ $instance->is_paid ? 'Yes' : 'No' }}</td>\n                                </tr>\n                            @empty\n                                <tr><td colspan=\"7\" class=\"text-center py-4 text-gray-400\">No active self-hosted instances found.</td></tr>\n                            @endforelse\n                            </tbody>\n                        </table>\n                    </div>\n                </div>\n            </div>\n        </div>\n\n        <!-- Tab 2: Active Cloud-Hosted Users -->\n        <div x-show=\"activeTab === 'active-users'\" x-transition:enter=\"transition ease-out duration-200\" x-transition:enter-start=\"opacity-0\" x-transition:enter-end=\"opacity-100\" x-transition:leave=\"transition ease-in duration-150\" x-transition:leave-start=\"opacity-100\" x-transition:leave-end=\"opacity-0\">\n            <div class=\"mt-6 grid grid-cols-1 gap-6 sm:grid-cols-3\">\n                <div class=\"bg-white overflow-hidden shadow rounded-lg\">\n                    <div class=\"p-5 flex items-center\">\n                        <div class=\"flex-shrink-0\">\n                            <!-- Heroicon: PresentationChartBar (outline) -->\n                            <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\" class=\"h-8 w-8 text-blue-500\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M3 3v12a2.25 2.25 0 002.25 2.25h13.5A2.25 2.25 0 0021 15V3\" /><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 17v4m6-4v4M9 13.5V10.5m6 3V7.5\" /></svg>\n                        </div>\n                        <div class=\"ml-5 w-0 flex-1\">\n                            <dl>\n                                <dt class=\"text-sm font-medium text-gray-500 truncate\">Active Displays</dt>\n                                <dd class=\"mt-1 text-2xl font-semibold text-gray-900\">{{ $activeDisplaysCount }}</dd>\n                            </dl>\n                        </div>\n                    </div>\n                </div>\n                <div class=\"bg-white overflow-hidden shadow rounded-lg\">\n                    <div class=\"p-5 flex items-center\">\n                        <div class=\"flex-shrink-0\">\n                            <!-- Heroicon: RectangleStack (outline) -->\n                            <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\" class=\"h-8 w-8 text-indigo-500\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M3 7.5V6.75A2.25 2.25 0 015.25 4.5h13.5A2.25 2.25 0 0121 6.75v.75m-18 0v10.5A2.25 2.25 0 005.25 20.25h13.5A2.25 2.25 0 0021 18.75V8.25m-18 0h18\" /></svg>\n                        </div>\n                        <div class=\"ml-5 w-0 flex-1\">\n                            <dl>\n                                <dt class=\"text-sm font-medium text-gray-500 truncate\">Total Displays</dt>\n                                <dd class=\"mt-1 text-2xl font-semibold text-gray-900\">{{ $totalDisplays }}</dd>\n                            </dl>\n                        </div>\n                    </div>\n                </div>\n                <div class=\"bg-white overflow-hidden shadow rounded-lg\">\n                    <div class=\"p-5 flex items-center\">\n                        <div class=\"flex-shrink-0\">\n                            <!-- Heroicon: Squares2X2 (outline) -->\n                            <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\" class=\"h-8 w-8 text-purple-500\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z\" /></svg>\n                        </div>\n                        <div class=\"ml-5 w-0 flex-1\">\n                            <dl>\n                                <dt class=\"text-sm font-medium text-gray-500 truncate\">Total Boards</dt>\n                                <dd class=\"mt-1 text-2xl font-semibold text-gray-900\">{{ $totalBoards }}</dd>\n                            </dl>\n                        </div>\n                    </div>\n                </div>\n            </div>\n\n            <div class=\"mt-10\">\n                <h2 class=\"text-xl font-bold mb-4\">Active Cloud-Hosted Users</h2>\n                <div class=\"bg-white shadow rounded-lg p-6\">\n                    <div class=\"overflow-x-auto\">\n                        <table class=\"min-w-full divide-y divide-gray-300\">\n                            <thead>\n                            <tr>\n                                <th class=\"py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0\">Name</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Email</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Usage Type</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Displays</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Boards</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Last Display Activity</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Paid</th>\n                            </tr>\n                            </thead>\n                            <tbody class=\"divide-y divide-gray-200\">\n                            @forelse($activeDisplays as $user)\n                                <tr>\n                                    <td class=\"whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0\">{{ $user->name }}</td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">{{ $user->email }}</td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">{{ $user->usage_type?->label() }}</td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">{{ $user->displays_count }}</td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">{{ $user->boards_count ?? 0 }}</td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">{{ $user->last_display_activity ? \\Carbon\\Carbon::parse($user->last_display_activity)->diffForHumans() : '-' }}</td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">{{ $user->is_paid ? 'Yes' : 'No' }}</td>\n                                </tr>\n                            @empty\n                                <tr><td colspan=\"7\" class=\"text-center py-4 text-gray-400\">No active cloud-hosted users found.</td></tr>\n                            @endforelse\n                            </tbody>\n                        </table>\n                    </div>\n                </div>\n            </div>\n        </div>\n\n        <!-- Tab 3: Paying Cloud-Hosted Users -->\n        <div x-show=\"activeTab === 'paying-users'\" x-transition:enter=\"transition ease-out duration-200\" x-transition:enter-start=\"opacity-0\" x-transition:enter-end=\"opacity-100\" x-transition:leave=\"transition ease-in duration-150\" x-transition:leave-start=\"opacity-100\" x-transition:leave-end=\"opacity-0\">\n            <div class=\"mt-6 grid grid-cols-1 gap-6 sm:grid-cols-3\">\n                <div class=\"bg-white overflow-hidden shadow rounded-lg\">\n                    <div class=\"p-5 flex items-center\">\n                        <div class=\"flex-shrink-0\">\n                            <!-- Heroicon: Users (outline) -->\n                            <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\" class=\"h-8 w-8 text-purple-500\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z\" /></svg>\n                        </div>\n                        <div class=\"ml-5 w-0 flex-1\">\n                            <dl>\n                                <dt class=\"text-sm font-medium text-gray-500 truncate\">Paying Users</dt>\n                                <dd class=\"mt-1 text-2xl font-semibold text-gray-900\">{{ $payingUsersCount }}</dd>\n                            </dl>\n                        </div>\n                    </div>\n                </div>\n                <div class=\"bg-green-50 border border-green-200 rounded-lg overflow-hidden shadow\">\n                    <div class=\"p-5 flex items-center\">\n                        <div class=\"flex-shrink-0\">\n                            <!-- Heroicon: CurrencyDollar (outline) -->\n                            <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\" class=\"h-8 w-8 text-green-600\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" /></svg>\n                        </div>\n                        <div class=\"ml-5 w-0 flex-1\">\n                            <dl>\n                                <dt class=\"text-sm font-medium text-green-600 truncate\">Total MRR</dt>\n                                <dd class=\"mt-1 text-2xl font-bold text-green-700\">${{ number_format($totalMRR, 2) }}</dd>\n                            </dl>\n                        </div>\n                    </div>\n                </div>\n                <div class=\"bg-yellow-50 border border-yellow-200 rounded-lg overflow-hidden shadow\">\n                    <div class=\"p-5 flex items-center\">\n                        <div class=\"flex-shrink-0\">\n                            <!-- Heroicon: ChartBar (outline) -->\n                            <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\" class=\"h-8 w-8 text-yellow-600\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z\" /></svg>\n                        </div>\n                        <div class=\"ml-5 w-0 flex-1\">\n                            <dl>\n                                <dt class=\"text-sm font-medium text-yellow-600 truncate\">Forecasted MRR</dt>\n                                <dd class=\"mt-1 text-2xl font-bold text-yellow-700\">${{ number_format($forecastedMRR, 2) }}</dd>\n                            </dl>\n                        </div>\n                    </div>\n                </div>\n            </div>\n\n            <div class=\"mt-10\">\n                <h2 class=\"text-xl font-bold mb-4\">Paying Cloud-Hosted Users ({{ $payingUsersCount }})</h2>\n                <div class=\"bg-white shadow rounded-lg p-6\">\n                    <div class=\"overflow-x-auto\">\n                        <table class=\"min-w-full divide-y divide-gray-300\">\n                            <thead>\n                            <tr>\n                                <th class=\"py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0\">Name</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Email</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Usage Type</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Displays</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Boards</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Subscription Status</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">LS Status</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Price</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">MRR</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Subscription Ends</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Registered</th>\n                            </tr>\n                            </thead>\n                            <tbody class=\"divide-y divide-gray-200\">\n                            @forelse($payingUsers as $user)\n                                <tr>\n                                    <td class=\"whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0\">{{ $user->name }}</td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">{{ $user->email }}</td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">{{ $user->usage_type?->label() }}</td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">{{ $user->displays_count }}</td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">\n                                        <span class=\"inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset {{ $user->is_unlimited ? 'bg-green-50 text-green-700 ring-green-600/20' : 'bg-blue-50 text-blue-700 ring-blue-600/20' }}\">\n                                            {{ $user->subscription_status }}\n                                        </span>\n                                    </td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">\n                                        @if($user->lemon_squeezy_status)\n                                            @php\n                                                $status = $user->lemon_squeezy_status;\n                                                $statusLabel = ucwords(str_replace('_', ' ', $status));\n                                                $statusColors = match($status) {\n                                                    'active' => 'bg-green-50 text-green-700 ring-green-600/20',\n                                                    'past_due' => 'bg-yellow-50 text-yellow-700 ring-yellow-600/20',\n                                                    'unpaid' => 'bg-red-50 text-red-700 ring-red-600/20',\n                                                    'cancelled' => 'bg-gray-50 text-gray-700 ring-gray-600/20',\n                                                    'on_trial' => 'bg-blue-50 text-blue-700 ring-blue-600/20',\n                                                    'paused' => 'bg-orange-50 text-orange-700 ring-orange-600/20',\n                                                    default => 'bg-gray-50 text-gray-700 ring-gray-600/20',\n                                                };\n                                            @endphp\n                                            <span class=\"inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset {{ $statusColors }}\">\n                                                {{ $statusLabel }}\n                                            </span>\n                                        @else\n                                            <span class=\"text-gray-400\">-</span>\n                                        @endif\n                                    </td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">\n                                        @if($user->price > 0)\n                                            <span class=\"font-semibold text-gray-900\">${{ number_format($user->price, 2) }}</span>\n                                        @else\n                                            <span class=\"text-gray-400\">-</span>\n                                        @endif\n                                    </td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">\n                                        @if($user->mrr > 0)\n                                            <span class=\"font-semibold text-gray-900\">${{ number_format($user->mrr, 2) }}</span>\n                                        @else\n                                            <span class=\"text-gray-400\">-</span>\n                                        @endif\n                                    </td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">\n                                        @if($user->subscription_ends_at)\n                                            {{ \\Carbon\\Carbon::parse($user->subscription_ends_at)->format('Y-m-d') }}\n                                        @else\n                                            <span class=\"text-gray-400\">-</span>\n                                        @endif\n                                    </td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">{{ $user->created_at->format('Y-m-d') }}</td>\n                                </tr>\n                            @empty\n                                <tr><td colspan=\"11\" class=\"text-center py-4 text-gray-400\">No paying cloud-hosted users found.</td></tr>\n                            @endforelse\n                            </tbody>\n                        </table>\n                    </div>\n                </div>\n            </div>\n        </div>\n\n        <!-- Tab 4: Users Overview -->\n        <div x-show=\"activeTab === 'users-overview'\" x-transition:enter=\"transition ease-out duration-200\" x-transition:enter-start=\"opacity-0\" x-transition:enter-end=\"opacity-100\" x-transition:leave=\"transition ease-in duration-150\" x-transition:leave-start=\"opacity-100\" x-transition:leave-end=\"opacity-0\">\n            <div class=\"mt-6 grid grid-cols-1 gap-6 sm:grid-cols-3\">\n                <div class=\"bg-white overflow-hidden shadow rounded-lg\">\n                    <div class=\"p-5 flex items-center\">\n                        <div class=\"flex-shrink-0\">\n                            <!-- Heroicon: Users (outline) -->\n                            <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\" class=\"h-8 w-8 text-purple-500\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z\" /></svg>\n                        </div>\n                        <div class=\"ml-5 w-0 flex-1\">\n                            <dl>\n                                <dt class=\"text-sm font-medium text-gray-500 truncate\">Total Users</dt>\n                                <dd class=\"mt-1 text-2xl font-semibold text-gray-900\">{{ $allUsers->count() }}</dd>\n                            </dl>\n                        </div>\n                    </div>\n                </div>\n                <div class=\"bg-white overflow-hidden shadow rounded-lg\">\n                    <div class=\"p-5 flex items-center\">\n                        <div class=\"flex-shrink-0\">\n                            <!-- Heroicon: UserCircle (outline) -->\n                            <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\" class=\"h-8 w-8 text-blue-500\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z\" /></svg>\n                        </div>\n                        <div class=\"ml-5 w-0 flex-1\">\n                            <dl>\n                                <dt class=\"text-sm font-medium text-gray-500 truncate\">Users with Displays</dt>\n                                <dd class=\"mt-1 text-2xl font-semibold text-gray-900\">{{ $allUsers->filter(fn($u) => $u->displays_count > 0)->count() }}</dd>\n                            </dl>\n                        </div>\n                    </div>\n                </div>\n                <div class=\"bg-white overflow-hidden shadow rounded-lg\">\n                    <div class=\"p-5 flex items-center\">\n                        <div class=\"flex-shrink-0\">\n                            <!-- Heroicon: CheckCircle (outline) -->\n                            <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\" class=\"h-8 w-8 text-green-500\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" /></svg>\n                        </div>\n                        <div class=\"ml-5 w-0 flex-1\">\n                            <dl>\n                                <dt class=\"text-sm font-medium text-gray-500 truncate\">Pro Users</dt>\n                                <dd class=\"mt-1 text-2xl font-semibold text-gray-900\">{{ $allUsers->filter(fn($u) => $u->hasPro())->count() }}</dd>\n                            </dl>\n                        </div>\n                    </div>\n                </div>\n            </div>\n\n            <div class=\"mt-10\">\n                <h2 class=\"text-xl font-bold mb-4\">All Users</h2>\n                <div class=\"bg-white shadow rounded-lg p-6\">\n                    <form method=\"GET\" action=\"{{ route('admin.index') }}\" class=\"mb-4\">\n                        <input\n                            type=\"text\"\n                            name=\"search\"\n                            value=\"{{ request('search') }}\"\n                            placeholder=\"Search by name or email...\"\n                            class=\"block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm px-3 py-2 border\"\n                        >\n                    </form>\n                    <div class=\"overflow-x-auto\">\n                        <table class=\"min-w-full divide-y divide-gray-300\">\n                            <thead>\n                            <tr>\n                                <th class=\"py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0\">Name</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Email</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Usage Type</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Displays</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Boards</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Pro</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Registered</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Last Activity</th>\n                                <th class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Actions</th>\n                            </tr>\n                            </thead>\n                            <tbody class=\"divide-y divide-gray-200\">\n                            @forelse($allUsers as $user)\n                                <tr>\n                                    <td class=\"whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0\">{{ $user->name }}</td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">{{ $user->email }}</td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">{{ $user->usage_type?->label() ?? '-' }}</td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">{{ $user->displays_count }}</td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">{{ $user->boards_count ?? 0 }}</td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">\n                                        @if($user->hasPro())\n                                            <span class=\"inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20\">Yes</span>\n                                        @else\n                                            <span class=\"inline-flex items-center rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-600/20\">No</span>\n                                        @endif\n                                    </td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">{{ $user->created_at->format('Y-m-d') }}</td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">{{ $user->last_activity_at ? $user->last_activity_at->format('Y-m-d') : 'Never' }}</td>\n                                    <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">\n                                        <div class=\"flex items-center gap-2\">\n                                            <a href=\"{{ route('admin.users.show', $user) }}\" class=\"text-blue-600 hover:text-blue-900 font-medium\">\n                                                View\n                                            </a>\n                                            <form action=\"{{ route('admin.users.impersonate', $user) }}\" method=\"POST\" class=\"inline\">\n                                                @csrf\n                                                <button type=\"submit\" class=\"text-purple-600 hover:text-purple-900 font-medium\" onclick=\"return confirm('Are you sure you want to impersonate {{ $user->email }}?')\">\n                                                    Impersonate\n                                                </button>\n                                            </form>\n                                        </div>\n                                    </td>\n                                </tr>\n                            @empty\n                                <tr>\n                                    <td colspan=\"9\" class=\"py-8 text-center text-sm text-gray-500\">\n                                        @if(request('search'))\n                                            No users found matching \"{{ request('search') }}\"\n                                        @else\n                                            No users found\n                                        @endif\n                                    </td>\n                                </tr>\n                            @endforelse\n                            </tbody>\n                        </table>\n                    </div>\n                    @if($allUsers->hasPages())\n                        <div class=\"mt-6\">\n                            {{ $allUsers->links('vendor.pagination.tailwind') }}\n                        </div>\n                    @endif\n                </div>\n            </div>\n        </div>\n    </div>\n@endsection\n"
  },
  {
    "path": "backend/resources/views/pages/boards/form.blade.php",
    "content": "@extends('layouts.base')\n@section('title', $board ? 'Edit Board' : 'Create Board')\n@section('container_class', 'max-w-3xl')\n\n@section('content')\n    <x-cards.card>\n        {{-- Session Status Alert --}}\n        <x-alerts.alert />\n\n        <form action=\"{{ $board ? route('boards.update', $board) : route('boards.store') }}\" method=\"POST\" enctype=\"multipart/form-data\">\n            @csrf\n            @if($board)\n                @method('PUT')\n            @endif\n\n            <div class=\"space-y-6\">\n                <div>\n                    <label for=\"name\" class=\"block text-sm font-medium leading-6 text-gray-900\">Board Name</label>\n                    <div class=\"mt-2\">\n                        <input type=\"text\" name=\"name\" id=\"name\" value=\"{{ old('name', $board?->name) }}\"\n                               class=\"block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6\"\n                               placeholder=\"e.g., Main Floor, Building A, etc.\" required>\n                    </div>\n                    <p class=\"mt-2 text-sm text-gray-500\">Give your board a descriptive name for your own reference. This will not be displayed to your users.</p>\n                </div>\n\n                <div>\n                    <label for=\"title\" class=\"block text-sm font-medium leading-6 text-gray-900\">Title</label>\n                    <div class=\"mt-2\">\n                        <input type=\"text\" name=\"title\" id=\"title\" value=\"{{ old('title', $board?->title) }}\"\n                               class=\"block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6\"\n                               placeholder=\"Custom title for the board\">\n                    </div>\n                    <p class=\"mt-2 text-sm text-gray-500\">The large title displayed top left in the board. If left empty, the title will default to \"Meeting Room Overview\" in the selected language.</p>\n                </div>\n\n                <div>\n                    <label for=\"subtitle\" class=\"block text-sm font-medium leading-6 text-gray-900\">Subtitle</label>\n                    <div class=\"mt-2\">\n                        <input type=\"text\" name=\"subtitle\" id=\"subtitle\" value=\"{{ old('subtitle', $board?->subtitle) }}\"\n                               class=\"block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6\"\n                               placeholder=\"e.g., 2nd Floor, Building A\">\n                    </div>\n                    <p class=\"mt-2 text-sm text-gray-500\">A smaller subtitle displayed below the title. Optional, for example to indicate which floor you're on.</p>\n                </div>\n\n                <input type=\"hidden\" name=\"workspace_id\" value=\"{{ $workspace->id }}\">\n\n                <div>\n                    <label class=\"block text-sm font-medium leading-6 text-gray-900 mb-3\">Display Selection</label>\n                    <div class=\"space-y-4\">\n                        <div class=\"space-y-1\">\n                            <div class=\"flex items-center\">\n                                <input id=\"show_all_displays_1\" name=\"show_all_displays\" type=\"radio\" value=\"1\" \n                                       class=\"h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-600\"\n                                       {{ old('show_all_displays', $board ? ($board->show_all_displays ? '1' : '0') : '1') === '1' ? 'checked' : '' }}\n                                       onchange=\"toggleDisplaySelection()\">\n                                <label for=\"show_all_displays_1\" class=\"ml-3 block text-sm font-medium leading-6 text-gray-900\">\n                                    Show all displays automatically\n                                </label>\n                            </div>\n                            <p class=\"ml-7 text-sm text-gray-500\">All active displays in this workspace will be shown on the board.</p>\n                        </div>\n\n                        <div class=\"space-y-1\">\n                            <div class=\"flex items-center\">\n                                <input id=\"show_all_displays_0\" name=\"show_all_displays\" type=\"radio\" value=\"0\"\n                                       class=\"h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-600\"\n                                       {{ old('show_all_displays', $board ? ($board->show_all_displays ? '1' : '0') : '1') === '0' ? 'checked' : '' }}\n                                       onchange=\"toggleDisplaySelection()\">\n                                <label for=\"show_all_displays_0\" class=\"ml-3 block text-sm font-medium leading-6 text-gray-900\">\n                                    Select specific displays\n                                </label>\n                            </div>\n                            <p class=\"ml-7 text-sm text-gray-500\">Choose which displays to show on this board.</p>\n                        </div>\n                    </div>\n                </div>\n\n                <div id=\"display_selection\" class=\"{{ old('show_all_displays', $board ? ($board->show_all_displays ? '1' : '0') : '1') === '1' ? 'hidden' : '' }}\">\n                    <label class=\"block text-sm font-medium leading-6 text-gray-900 mb-3\">Select Displays</label>\n                    @if($displays->isEmpty())\n                        <p class=\"text-sm text-gray-500\">No active displays available in this workspace.</p>\n                    @else\n                        <div class=\"space-y-2 max-h-64 overflow-y-auto border border-gray-200 rounded-md p-4\">\n                            @foreach($displays as $display)\n                                <div class=\"flex items-center\">\n                                    <input id=\"display_{{ $display->id }}\" name=\"display_ids[]\" type=\"checkbox\" value=\"{{ $display->id }}\"\n                                           class=\"h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600\"\n                                           {{ old('display_ids', $board && !$board->show_all_displays ? $board->displays->pluck('id')->toArray() : []) && in_array($display->id, old('display_ids', $board && !$board->show_all_displays ? $board->displays->pluck('id')->toArray() : [])) ? 'checked' : '' }}>\n                                    <label for=\"display_{{ $display->id }}\" class=\"ml-3 block text-sm text-gray-900\">\n                                        {{ $display->name }} <span class=\"text-gray-500\">({{ $display->display_name }})</span>\n                                    </label>\n                                </div>\n                            @endforeach\n                        </div>\n                        <p class=\"mt-2 text-sm text-gray-500\">Select one or more displays to include in this board.</p>\n                    @endif\n                </div>\n\n                <div>\n                    <label class=\"block text-sm font-medium leading-6 text-gray-900 mb-3\">Logo</label>\n                    <div class=\"flex items-center space-x-4\">\n                        @if($board && $board->logo)\n                            <div class=\"flex-shrink-0\">\n                                <img src=\"{{ route('boards.images.logo', $board) }}?v={{ $board->updated_at->timestamp }}\" alt=\"Current logo\" class=\"h-16 w-auto object-contain border border-gray-300 rounded\">\n                            </div>\n                            <div>\n                                <p class=\"text-sm text-gray-500\">Current logo</p>\n                                <label class=\"inline-flex items-center text-sm text-red-600 hover:text-red-500 cursor-pointer\">\n                                    <input type=\"checkbox\" name=\"remove_logo\" value=\"1\" class=\"mr-1\">\n                                    Remove logo\n                                </label>\n                            </div>\n                        @endif\n                    </div>\n                    <div class=\"mt-2\">\n                        <label for=\"logo\" class=\"inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-oxford hover:bg-oxford-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-oxford-500 cursor-pointer\">\n                            <svg class=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12\"></path>\n                            </svg>\n                            Choose Logo File\n                        </label>\n                        <input type=\"file\" name=\"logo\" id=\"logo\" accept=\"image/*\" class=\"hidden\" onchange=\"document.getElementById('logo-filename').textContent = this.files[0]?.name || ''\">\n                        <span id=\"logo-filename\" class=\"ml-2 text-sm text-gray-500\"></span>\n                    </div>\n                    <p class=\"mt-2 text-sm text-gray-500\">Upload a logo to display in the top left corner of the board. Recommended size: 200x60px or similar aspect ratio.</p>\n                </div>\n\n                <div>\n                    <label class=\"block text-sm font-medium leading-6 text-gray-900 mb-3\">Display Options</label>\n                    <div class=\"space-y-4\">\n                        <div class=\"space-y-1\">\n                            <div class=\"flex items-center\">\n                                <input id=\"show_title\" name=\"show_title\" type=\"checkbox\" value=\"1\"\n                                       class=\"h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600\"\n                                       {{ old('show_title', $board?->show_title ?? true) ? 'checked' : '' }}>\n                                <label for=\"show_title\" class=\"ml-3 block text-sm font-medium leading-6 text-gray-900\">\n                                    Show event title\n                                </label>\n                            </div>\n                            <p class=\"ml-7 text-sm text-gray-500\">Display the meeting title/event name on the board.</p>\n                        </div>\n\n                        <div class=\"space-y-1\">\n                            <div class=\"flex items-center\">\n                                <input id=\"show_booker\" name=\"show_booker\" type=\"checkbox\" value=\"1\"\n                                       class=\"h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600\"\n                                       {{ old('show_booker', $board?->show_booker ?? true) ? 'checked' : '' }}>\n                                <label for=\"show_booker\" class=\"ml-3 block text-sm font-medium leading-6 text-gray-900\">\n                                    Show booker/organizer\n                                </label>\n                            </div>\n                            <p class=\"ml-7 text-sm text-gray-500\">Display the name of the person who booked the meeting.</p>\n                        </div>\n\n                        <div class=\"space-y-1\">\n                            <div class=\"flex items-center\">\n                                <input id=\"show_next_event\" name=\"show_next_event\" type=\"checkbox\" value=\"1\"\n                                       class=\"h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600\"\n                                       {{ old('show_next_event', $board?->show_next_event ?? true) ? 'checked' : '' }}>\n                                <label for=\"show_next_event\" class=\"ml-3 block text-sm font-medium leading-6 text-gray-900\">\n                                    Show 'next up' event\n                                </label>\n                            </div>\n                            <p class=\"ml-7 text-sm text-gray-500\">Display upcoming events when a room is currently available.</p>\n                        </div>\n                    </div>\n                </div>\n\n                <div>\n                    <label class=\"block text-sm font-medium leading-6 text-gray-900 mb-3\">Transitioning Settings</label>\n                    <div class=\"space-y-4\">\n                        <div class=\"space-y-1\">\n                            <div class=\"flex items-center\">\n                                <input id=\"show_transitioning\" name=\"show_transitioning\" type=\"checkbox\" value=\"1\"\n                                       class=\"h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600\"\n                                       {{ old('show_transitioning', $board?->show_transitioning ?? true) ? 'checked' : '' }}>\n                                <label for=\"show_transitioning\" class=\"ml-3 block text-sm font-medium leading-6 text-gray-900\">\n                                    Show transitioning state\n                                </label>\n                            </div>\n                            <p class=\"ml-7 text-sm text-gray-500\">Display the transitioning state when a meeting is ending or starting soon.</p>\n                        </div>\n\n                        <div class=\"space-y-1\">\n                            <label for=\"transitioning_minutes\" class=\"block text-sm font-medium text-gray-700\">Transitioning Minutes</label>\n                            <input type=\"number\" min=\"1\" max=\"60\" name=\"transitioning_minutes\" id=\"transitioning_minutes\" value=\"{{ old('transitioning_minutes', $board?->transitioning_minutes ?? 10) }}\" class=\"mt-1 px-3 py-2 block w-32 border rounded-md border-gray-300 focus:border-blue-500 focus:ring-blue-500 sm:text-sm\" />\n                            <p class=\"mt-1 text-sm text-gray-500\">Display rooms as transitioning (orange) when a meeting is ending or starting within this many minutes. Default: 10 minutes.</p>\n                        </div>\n                    </div>\n                </div>\n\n                <div>\n                    <label class=\"block text-sm font-medium leading-6 text-gray-900 mb-3\">Typography</label>\n                    <div class=\"space-y-1\">\n                        <label for=\"font_family\" class=\"block text-sm font-medium text-gray-700 mb-2\">Font Family</label>\n                        <select name=\"font_family\" id=\"font_family\" class=\"block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm\">\n                            <option value=\"Inter\" {{ old('font_family', $board?->font_family ?? 'Inter') === 'Inter' ? 'selected' : '' }}>Inter</option>\n                            <option value=\"Roboto\" {{ old('font_family', $board?->font_family ?? 'Inter') === 'Roboto' ? 'selected' : '' }}>Roboto</option>\n                            <option value=\"Open Sans\" {{ old('font_family', $board?->font_family ?? 'Inter') === 'Open Sans' ? 'selected' : '' }}>Open Sans</option>\n                            <option value=\"Lato\" {{ old('font_family', $board?->font_family ?? 'Inter') === 'Lato' ? 'selected' : '' }}>Lato</option>\n                            <option value=\"Poppins\" {{ old('font_family', $board?->font_family ?? 'Inter') === 'Poppins' ? 'selected' : '' }}>Poppins</option>\n                            <option value=\"Montserrat\" {{ old('font_family', $board?->font_family ?? 'Inter') === 'Montserrat' ? 'selected' : '' }}>Montserrat</option>\n                        </select>\n                        <p class=\"mt-1 text-sm text-gray-500\">Choose a font family for the board text.</p>\n                    </div>\n                </div>\n\n                <div>\n                    <label class=\"block text-sm font-medium leading-6 text-gray-900 mb-3\">Display Settings</label>\n                    <div class=\"space-y-4\">\n                        <div class=\"space-y-1\">\n                            <label for=\"view_mode\" class=\"block text-sm font-medium text-gray-700 mb-2\">View Mode</label>\n                            <select name=\"view_mode\" id=\"view_mode\" class=\"block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm\">\n                                <option value=\"card\" {{ old('view_mode', $board?->view_mode ?? 'card') === 'card' ? 'selected' : '' }}>Card View</option>\n                                <option value=\"table\" {{ old('view_mode', $board?->view_mode ?? 'card') === 'table' ? 'selected' : '' }}>Table View</option>\n                                <option value=\"grid\" {{ old('view_mode', $board?->view_mode ?? 'card') === 'grid' ? 'selected' : '' }}>Grid View</option>\n                            </select>\n                            <p class=\"mt-1 text-sm text-gray-500\">Choose how displays are displayed on the board.</p>\n                        </div>\n                    </div>\n                </div>\n\n                <div>\n                    <label class=\"block text-sm font-medium leading-6 text-gray-900 mb-3\">Language</label>\n                    <div class=\"space-y-1\">\n                        <label for=\"language\" class=\"block text-sm font-medium text-gray-700 mb-2\">Display Language</label>\n                        <select name=\"language\" id=\"language\" class=\"block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm\">\n                            <option value=\"en\" {{ old('language', $board?->language ?? 'en') === 'en' ? 'selected' : '' }}>English</option>\n                            <option value=\"nl\" {{ old('language', $board?->language ?? 'en') === 'nl' ? 'selected' : '' }}>Nederlands</option>\n                            <option value=\"fr\" {{ old('language', $board?->language ?? 'en') === 'fr' ? 'selected' : '' }}>Français</option>\n                            <option value=\"de\" {{ old('language', $board?->language ?? 'en') === 'de' ? 'selected' : '' }}>Deutsch</option>\n                            <option value=\"es\" {{ old('language', $board?->language ?? 'en') === 'es' ? 'selected' : '' }}>Español</option>\n                            <option value=\"sv\" {{ old('language', $board?->language ?? 'en') === 'sv' ? 'selected' : '' }}>Svenska</option>\n                        </select>\n                        <p class=\"mt-1 text-sm text-gray-500\">Choose the language for date and time formatting on the board.</p>\n                    </div>\n                </div>\n\n                <div>\n                    <label class=\"block text-sm font-medium leading-6 text-gray-900 mb-3\">Privacy</label>\n                    <div class=\"space-y-1\">\n                        <div class=\"flex items-center\">\n                            <input id=\"show_meeting_title\" name=\"show_meeting_title\" type=\"checkbox\" value=\"1\"\n                                   class=\"h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600\"\n                                   {{ old('show_meeting_title', $board?->show_meeting_title ?? true) ? 'checked' : '' }}>\n                            <label for=\"show_meeting_title\" class=\"ml-3 block text-sm font-medium leading-6 text-gray-900\">\n                                Show meeting titles\n                            </label>\n                        </div>\n                        <p class=\"ml-7 text-sm text-gray-500\">If unchecked, meeting titles will be hidden for privacy-sensitive environments.</p>\n                    </div>\n                </div>\n\n                <div>\n                    <label class=\"block text-sm font-medium leading-6 text-gray-900 mb-3\">Theme</label>\n                    <div class=\"space-y-4\">\n                        <div class=\"space-y-1\">\n                            <div class=\"flex items-center\">\n                                <input id=\"theme_dark\" name=\"theme\" type=\"radio\" value=\"dark\" \n                                       class=\"h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-600\"\n                                       {{ old('theme', $board?->theme ?? 'dark') === 'dark' ? 'checked' : '' }}>\n                                <label for=\"theme_dark\" class=\"ml-3 block text-sm font-medium leading-6 text-gray-900\">\n                                    Dark mode\n                                </label>\n                            </div>\n                            <p class=\"ml-7 text-sm text-gray-500\">Dark background with light text for better visibility in low-light environments.</p>\n                        </div>\n\n                        <div class=\"space-y-1\">\n                            <div class=\"flex items-center\">\n                                <input id=\"theme_light\" name=\"theme\" type=\"radio\" value=\"light\"\n                                       class=\"h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-600\"\n                                       {{ old('theme', $board?->theme ?? 'dark') === 'light' ? 'checked' : '' }}>\n                                <label for=\"theme_light\" class=\"ml-3 block text-sm font-medium leading-6 text-gray-900\">\n                                    Light mode\n                                </label>\n                            </div>\n                            <p class=\"ml-7 text-sm text-gray-500\">Light background with dark text for better visibility in bright environments.</p>\n                        </div>\n\n                        <div class=\"space-y-1\">\n                            <div class=\"flex items-center\">\n                                <input id=\"theme_system\" name=\"theme\" type=\"radio\" value=\"system\"\n                                       class=\"h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-600\"\n                                       {{ old('theme', $board?->theme ?? 'dark') === 'system' ? 'checked' : '' }}>\n                                <label for=\"theme_system\" class=\"ml-3 block text-sm font-medium leading-6 text-gray-900\">\n                                    System preference\n                                </label>\n                            </div>\n                            <p class=\"ml-7 text-sm text-gray-500\">Automatically match your device's dark/light mode preference.</p>\n                        </div>\n                    </div>\n                </div>\n\n                <div class=\"flex items-center justify-end gap-x-6 pt-4 border-t border-gray-200\">\n                    <a href=\"{{ route('dashboard') }}?tab=boards\" class=\"text-sm font-semibold leading-6 text-gray-900\">Cancel</a>\n                    <button type=\"submit\" class=\"rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600\">\n                        {{ $board ? 'Update Board' : 'Create Board' }}\n                    </button>\n                </div>\n            </div>\n        </form>\n    </x-cards.card>\n@endsection\n\n@push('scripts')\n    <script>\n        function toggleDisplaySelection() {\n            const showAll = document.getElementById('show_all_displays_1').checked;\n            const displaySelection = document.getElementById('display_selection');\n            \n            if (showAll) {\n                displaySelection.classList.add('hidden');\n                // Uncheck all display checkboxes\n                document.querySelectorAll('input[name=\"display_ids[]\"]').forEach(checkbox => {\n                    checkbox.checked = false;\n                });\n            } else {\n                displaySelection.classList.remove('hidden');\n            }\n        }\n\n        // Initialize on page load\n        document.addEventListener('DOMContentLoaded', function() {\n            toggleDisplaySelection();\n        });\n    </script>\n@endpush\n"
  },
  {
    "path": "backend/resources/views/pages/boards/index.blade.php",
    "content": "@extends('layouts.base')\n@section('title', 'Boards')\n\n@section('content')\n    <x-cards.card>\n        <div class=\"sm:flex sm:items-center mb-4\">\n            <div class=\"sm:flex-auto\">\n                <h2 class=\"text-lg font-semibold leading-6 text-gray-900\">Boards</h2>\n                <p class=\"mt-1 text-sm text-gray-500\">\n                    Overview of your boards and their configuration.\n                </p>\n            </div>\n            <div class=\"mt-4 sm:ml-16 sm:mt-0 sm:flex-none\">\n                <a href=\"{{ route('boards.create') }}\" class=\"inline-flex items-center rounded-md bg-oxford px-3 py-2 text-center text-sm font-semibold text-white\">\n                    <x-icons.plus class=\"h-5 w-5 mr-1\" />\n                    Create new board\n                </a>\n            </div>\n        </div>\n\n        {{-- Session Status Alert --}}\n        <x-alerts.alert />\n\n        <div class=\"mt-6 flow-root\">\n            <div class=\"-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8\">\n                <div class=\"inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8\">\n                    <table class=\"min-w-full divide-y divide-gray-300\">\n                        <thead>\n                        <tr>\n                            <th scope=\"col\" class=\"py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0\">Name</th>\n                            <th scope=\"col\" class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Displays</th>\n                            <th scope=\"col\" class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Type</th>\n                            <th scope=\"col\" class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Created by</th>\n                            <th scope=\"col\" class=\"relative py-3.5 pr-4 pl-3 sm:pr-0\">\n                                <span class=\"sr-only\">Actions</span>\n                            </th>\n                        </tr>\n                        </thead>\n                        <tbody class=\"divide-y divide-gray-200\">\n                        @forelse($boards as $board)\n                            <tr>\n                                <td class=\"whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0\">\n                                    <a href=\"{{ route('boards.show', $board) }}\" class=\"text-blue-600 hover:text-blue-900\">\n                                        {{ $board->name }}\n                                    </a>\n                                </td>\n                                <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">\n                                    {{ $board->display_count }} {{ $board->display_count === 1 ? 'display' : 'displays' }}\n                                </td>\n                                <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">\n                                    @if($board->show_all_displays)\n                                        <span class=\"inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20\">\n                                            Show all\n                                        </span>\n                                    @else\n                                        <span class=\"inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-600/20\">\n                                            Selected\n                                        </span>\n                                    @endif\n                                </td>\n                                <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">\n                                    {{ $board->user->name }}\n                                </td>\n                                <td class=\"relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0\">\n                                    <div class=\"flex items-center justify-end gap-2\">\n                                        <a href=\"{{ route('boards.show', $board) }}\" class=\"text-blue-600 hover:text-blue-900\" title=\"View\">\n                                            <x-icons.play class=\"h-5 w-5\" />\n                                        </a>\n                                        @can('update', $board)\n                                            <a href=\"{{ route('boards.edit', $board) }}\" class=\"text-gray-600 hover:text-gray-900\" title=\"Edit\">\n                                                <x-icons.settings class=\"h-5 w-5\" />\n                                            </a>\n                                        @endcan\n                                        @can('delete', $board)\n                                            <form action=\"{{ route('boards.destroy', $board) }}\" method=\"POST\" class=\"inline\" onsubmit=\"return confirm('Are you sure you want to delete this board?');\">\n                                                @csrf\n                                                @method('DELETE')\n                                                <button type=\"submit\" class=\"text-red-600 hover:text-red-900\" title=\"Delete\">\n                                                    <x-icons.trash class=\"h-5 w-5\" />\n                                                </button>\n                                            </form>\n                                        @endcan\n                                    </div>\n                                </td>\n                            </tr>\n                        @empty\n                            <tr>\n                                <td colspan=\"5\" class=\"py-16 text-center\">\n                                    <div class=\"flex flex-col items-center justify-center\">\n                                        <x-icons.display class=\"h-12 w-12 text-orange mb-3\" />\n                                        <h3 class=\"mb-2 text-md font-semibold text-gray-900\">\n                                            No boards yet\n                                        </h3>\n                                        <p class=\"mb-6 text-sm text-gray-500 max-w-sm\">Create your first board to display room availability on a big screen.</p>\n                                        <a href=\"{{ route('boards.create') }}\" class=\"inline-flex items-center rounded-md bg-oxford px-3 py-2 text-center text-sm font-semibold text-white\">\n                                            <x-icons.plus class=\"h-5 w-5 mr-1\" />\n                                            Create new board\n                                        </a>\n                                    </div>\n                                </td>\n                            </tr>\n                        @endforelse\n                        </tbody>\n                    </table>\n                </div>\n            </div>\n        </div>\n    </x-cards.card>\n@endsection\n"
  },
  {
    "path": "backend/resources/views/pages/boards/show.blade.php",
    "content": "@extends('layouts.blank')\n@section('title', $board->name . ' - ' . config('app.name'))\n@php\n    $theme = $board->theme ?? 'dark';\n    // For system theme, don't add initial class - let JavaScript handle it to prevent flash\n    // For dark/light themes, we can add the class directly since we know it's correct\n    $initialThemeClass = $theme === 'system' ? '' : 'board-' . $theme;\n    \n    // Map font names to Google Fonts format\n    $fontFamily = $board->font_family ?? 'Inter';\n    $googleFontMap = [\n        'Inter' => 'Inter:wght@400;500;600;700',\n        'Roboto' => 'Roboto:wght@400;500;700',\n        'Open Sans' => 'Open+Sans:wght@400;600;700',\n        'Lato' => 'Lato:wght@400;700',\n        'Poppins' => 'Poppins:wght@400;600;700',\n        'Montserrat' => 'Montserrat:wght@400;600;700',\n    ];\n    $googleFontUrl = $googleFontMap[$fontFamily] ?? 'Inter:wght@400;500;600;700';\n    \n    // Set locale for translations\n    $boardLanguage = $board->language ?? 'en';\n    $originalLocale = app()->getLocale();\n    app()->setLocale($boardLanguage);\n    \n    // Helper function to get translation in board language\n    $t = function($key, $replace = []) use ($boardLanguage) {\n        return \\Illuminate\\Support\\Facades\\Lang::get($key, $replace, $boardLanguage);\n    };\n@endphp\n\n@push('styles')\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family={{ $googleFontUrl }}&display=swap\" rel=\"stylesheet\">\n<script>\n    // Set theme immediately to prevent flash - runs synchronously before page renders\n    (function() {\n        const theme = '{{ $theme }}';\n        const isSystem = theme === 'system';\n        let actualTheme = theme;\n        \n        if (isSystem && typeof window !== 'undefined' && window.matchMedia) {\n            actualTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';\n        }\n        \n        // Store for immediate use\n        window.__boardInitialTheme = actualTheme;\n        \n        // Apply theme as soon as container exists\n        const applyTheme = function() {\n            const container = document.getElementById('board-container');\n            if (container) {\n                if (actualTheme === 'light') {\n                    container.classList.remove('board-dark');\n                    container.classList.add('board-light');\n                } else {\n                    container.classList.remove('board-light');\n                    container.classList.add('board-dark');\n                }\n            }\n        };\n        \n        // Try immediately\n        if (document.readyState === 'loading') {\n            // DOM is still loading, wait for it\n            if (document.addEventListener) {\n                document.addEventListener('DOMContentLoaded', applyTheme);\n            }\n        } else {\n            // DOM already loaded\n            applyTheme();\n        }\n        \n        // Also try immediately in case container already exists\n        setTimeout(applyTheme, 0);\n    })();\n</script>\n<style>\n    /* Prevent flash by hiding container until theme is applied */\n    #board-container:not(.board-dark):not(.board-light) {\n        opacity: 0;\n    }\n    #board-container.board-dark,\n    #board-container.board-light {\n        opacity: 1;\n        transition: opacity 0.1s ease;\n    }\n    \n    /* Dark mode (default) */\n    .board-dark {\n        background-color: #0f172a;\n        color: #ffffff;\n    }\n    .board-dark .board-card {\n        background: linear-gradient(135deg, #1e293b 0%, #1e293b 100%);\n        border: 1px solid rgba(255, 255, 255, 0.05);\n    }\n    .board-dark .board-card:hover {\n        background: linear-gradient(135deg, #334155 0%, #1e293b 100%);\n        border-color: rgba(255, 255, 255, 0.1);\n    }\n    .board-dark .board-text-primary {\n        color: #f1f5f9;\n    }\n    .board-dark .board-text-secondary {\n        color: #94a3b8;\n    }\n    .board-dark .board-text-tertiary {\n        color: #64748b;\n    }\n    .board-dark .board-text-accent {\n        color: #fbbf24;\n    }\n    .board-dark .board-icon {\n        color: #475569;\n    }\n    .board-dark .board-button {\n        background-color: #1e293b;\n    }\n    .board-dark .board-button:hover {\n        background-color: #334155;\n    }\n\n    /* Light mode */\n    .board-light {\n        background-color: #f8fafc;\n        color: #0f172a;\n    }\n    .board-light .board-card {\n        background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);\n        border: 1px solid rgba(0, 0, 0, 0.08);\n    }\n    .board-light .board-card:hover {\n        background: linear-gradient(135deg, #ffffff 0%, #f1f5f9 100%);\n        border-color: rgba(0, 0, 0, 0.12);\n    }\n    .board-light .board-text-primary {\n        color: #0f172a;\n    }\n    .board-light .board-text-secondary {\n        color: #475569;\n    }\n    .board-light .board-text-tertiary {\n        color: #64748b;\n    }\n    .board-light .board-text-accent {\n        color: #d97706;\n    }\n    .board-light .board-icon {\n        color: #cbd5e1;\n    }\n    .board-light .board-button {\n        background-color: #ffffff;\n        border: 1px solid rgba(0, 0, 0, 0.1);\n    }\n    .board-light .board-button:hover {\n        background-color: #f1f5f9;\n    }\n    \n    /* Border styling */\n    .board-dark .board-border {\n        border-color: rgba(255, 255, 255, 0.1);\n    }\n    .board-light .board-border {\n        border-color: rgba(0, 0, 0, 0.1);\n    }\n    \n    /* Modern card styling */\n    .board-card {\n        backdrop-filter: blur(10px);\n    }\n    \n    /* Smooth transitions */\n    .board-card * {\n        transition: color 0.2s ease, background-color 0.2s ease;\n    }\n    \n    /* Status bar hover effect */\n    .board-card:hover .absolute.left-0 {\n        opacity: 0.9;\n    }\n</style>\n@endpush\n\n@section('page')\n<div class=\"min-h-screen bg-gray-900 text-white p-8 {{ $initialThemeClass }}\" id=\"board-container\" data-theme=\"{{ $theme }}\" style=\"font-family: '{{ $fontFamily }}', sans-serif;\" data-language=\"{{ $board->language ?? 'en' }}\">\n    {{-- Header --}}\n    <div class=\"flex items-center justify-between mb-10 pb-8 border-b border-white/10 board-border\">\n        <div class=\"flex items-center gap-5\">\n            @if($board->logo)\n                <div class=\"flex-shrink-0\">\n                    <img src=\"{{ route('boards.images.logo', $board) }}?v={{ $board->updated_at->timestamp }}\" alt=\"Board logo\" class=\"h-14 w-auto object-contain\">\n                </div>\n            @else\n                <div class=\"h-14 w-14 rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-bold text-xl\">\n                    {{ strtoupper(substr($board->name, 0, 1)) }}\n                </div>\n            @endif\n            <div>\n                <h1 class=\"text-3xl font-bold tracking-tight board-text-primary\">{{ $board->title ?: $t('boards.meeting_room_overview') }}</h1>\n                @if($board->subtitle)\n                    <p class=\"text-base mt-1.5 board-text-secondary font-medium\">{{ $board->subtitle }}</p>\n                @endif\n            </div>\n        </div>\n        <div class=\"text-right\">\n            <div id=\"current-time\" class=\"text-3xl font-mono font-bold board-text-primary tracking-tight\"></div>\n            <div id=\"current-date\" class=\"text-sm mt-2 board-text-tertiary font-medium\"></div>\n        </div>\n    </div>\n\n    {{-- Room List --}}\n    @php\n        $viewMode = $board->view_mode ?? 'card';\n    @endphp\n    \n    @if($viewMode === 'table')\n        {{-- Row View --}}\n        <div class=\"overflow-x-auto\">\n            <table class=\"w-full border-collapse\">\n                <thead>\n                    <tr class=\"border-b border-gray-700/30\">\n                        <th class=\"text-left py-3 px-4 text-sm font-semibold uppercase tracking-wider board-text-secondary\">{{ $t('boards.room') }}</th>\n                        <th class=\"text-left py-3 px-4 text-sm font-semibold uppercase tracking-wider board-text-secondary\">{{ $t('boards.status') }}</th>\n                        <th class=\"text-left py-3 px-4 text-sm font-semibold uppercase tracking-wider board-text-secondary\">{{ $t('boards.current') }}</th>\n                        @if($board->show_next_event ?? true)\n                            <th class=\"text-left py-3 px-4 text-sm font-semibold uppercase tracking-wider board-text-secondary\">{{ $t('boards.next') }}</th>\n                        @endif\n                    </tr>\n                </thead>\n                <tbody id=\"displays-list\">\n                    @forelse($displays as $displayData)\n                        @php\n                            $display = $displayData['display'];\n                            $status = $displayData['status'];\n                            $statusText = $displayData['statusText'];\n                            $currentEvent = $displayData['currentEvent'];\n                            $nextEvent = $displayData['nextEvent'];\n                            $transitioningMinutes = $displayData['transitioningMinutes'] ?? null;\n                            \n                            // Status colors\n                            $statusBarColor = match($status) {\n                                'busy' => 'bg-red-500',\n                                'transitioning' => 'bg-amber-500',\n                                'error' => 'bg-gray-500',\n                                default => 'bg-green-500',\n                            };\n                            \n                            // Update status text with minutes if transitioning\n                            $statusTextParts = [];\n                            if ($status === 'transitioning' && $transitioningMinutes !== null) {\n                                $statusTextParts = [\n                                    'label' => $t('boards.transitioning'),\n                                    'minutes' => '(' . $transitioningMinutes . ' min)'\n                                ];\n                            } else {\n                                $statusTextParts = ['label' => $statusText];\n                            }\n                            \n                            $statusBadgeClass = match($status) {\n                                'busy' => 'bg-red-500/10 text-red-400 border-red-500/20',\n                                'transitioning' => 'bg-amber-500/10 text-amber-400 border-amber-500/20',\n                                'error' => 'bg-gray-500/10 text-gray-400 border-gray-500/20',\n                                default => 'bg-green-500/10 text-green-400 border-green-500/20',\n                            };\n                        @endphp\n                        <tr class=\"border-b border-gray-700/20 hover:bg-gray-800/30 transition-colors\">\n                            <td class=\"py-3 px-4\">\n                                <div class=\"font-semibold text-base board-text-primary\">{{ $display->display_name ?: $display->name }}</div>\n                            </td>\n                            <td class=\"py-3 px-4\">\n                                <span class=\"inline-flex flex-col items-center justify-center px-2 py-1 rounded text-xs font-semibold uppercase tracking-wider text-center border {{ $statusBadgeClass }}\">\n                                    @if(isset($statusTextParts['minutes']))\n                                        <span>{{ $statusTextParts['label'] }}</span>\n                                        <span>{{ $statusTextParts['minutes'] }}</span>\n                                    @else\n                                        <span>{{ $statusText }}</span>\n                                    @endif\n                                </span>\n                            </td>\n                            <td class=\"py-3 px-4\">\n                                @if($currentEvent)\n                                    <div class=\"space-y-1\">\n                                        @if($board->show_title ?? true)\n                                            <div class=\"text-sm font-semibold board-text-primary\">{{ $currentEvent['summary'] }}</div>\n                                        @endif\n                                        <div class=\"flex items-center gap-2 text-xs board-text-secondary\">\n                                            <svg class=\"w-3 h-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z\"></path>\n                                            </svg>\n                                            <span class=\"event-time\" data-start=\"{{ $currentEvent['start']->toIso8601String() }}\" data-end=\"{{ $currentEvent['end']->toIso8601String() }}\"></span>\n                                        </div>\n                                    </div>\n                                @else\n                                    @if($nextEvent)\n                                        <span class=\"text-sm board-text-secondary\">\n                                            {{ $t('boards.available_until', ['time' => '']) }}<span class=\"available-until-time\" data-time=\"{{ $nextEvent['start']->toIso8601String() }}\"></span>\n                                        </span>\n                                    @else\n                                        <span class=\"text-sm board-text-secondary\">{{ $t('boards.available_until_end_of_day') }}</span>\n                                    @endif\n                                @endif\n                            </td>\n                            @if($board->show_next_event ?? true)\n                                <td class=\"py-3 px-4\">\n                                    @if($nextEvent)\n                                        <div class=\"space-y-1\">\n                                            @if($board->show_title ?? true)\n                                                <div class=\"text-sm font-semibold board-text-primary\">{{ $nextEvent['summary'] }}</div>\n                                            @endif\n                                            <div class=\"flex items-center gap-2 text-xs board-text-secondary\">\n                                                <svg class=\"w-3 h-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z\"></path>\n                                                </svg>\n                                                <span class=\"event-time\" data-start=\"{{ $nextEvent['start']->toIso8601String() }}\" data-end=\"{{ $nextEvent['end']->toIso8601String() }}\"></span>\n                                            </div>\n                                        </div>\n                                    @else\n                                        <span class=\"text-sm board-text-secondary\">—</span>\n                                    @endif\n                                </td>\n                            @endif\n                        </tr>\n                    @empty\n                        <tr>\n                            <td colspan=\"{{ ($board->show_next_event ?? true) ? '4' : '3' }}\" class=\"py-16 text-center\">\n                                <div class=\"flex flex-col items-center gap-4\">\n                                    <div class=\"h-16 w-16 rounded-full bg-gray-500/20 flex items-center justify-center\">\n                                        <x-icons.display class=\"h-8 w-8 board-text-tertiary\" />\n                                    </div>\n                                    <p class=\"text-lg board-text-secondary\">{{ $t('boards.no_displays') }}</p>\n                                </div>\n                            </td>\n                        </tr>\n                    @endforelse\n                </tbody>\n            </table>\n        </div>\n    @elseif($viewMode === 'grid')\n        {{-- Grid View --}}\n        <div class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4\" id=\"displays-list\">\n            @forelse($displays as $displayData)\n                @php\n                    $display = $displayData['display'];\n                    $status = $displayData['status'];\n                    $statusText = $displayData['statusText'];\n                    $currentEvent = $displayData['currentEvent'];\n                    $nextEvent = $displayData['nextEvent'];\n                    $transitioningMinutes = $displayData['transitioningMinutes'] ?? null;\n                    \n                    // Status colors\n                    $statusBarColor = match($status) {\n                        'busy' => 'bg-red-500',\n                        'transitioning' => 'bg-amber-500',\n                        'error' => 'bg-gray-500',\n                        default => 'bg-green-500',\n                    };\n                    \n                    // Update status text with minutes if transitioning\n                    $statusTextParts = [];\n                    if ($status === 'transitioning' && $transitioningMinutes !== null) {\n                        $statusTextParts = [\n                            'label' => $t('boards.transitioning'),\n                            'minutes' => '(' . $transitioningMinutes . ' min)'\n                        ];\n                    } else {\n                        $statusTextParts = ['label' => $statusText];\n                    }\n                @endphp\n                <div class=\"board-card relative overflow-hidden rounded-xl transition-all duration-300 hover:scale-[1.01]\">\n                    {{-- Status Indicator Bar - Full height on left edge --}}\n                    <div class=\"absolute left-0 top-0 bottom-0 w-1 {{ $statusBarColor }}\"></div>\n                    \n                    <div class=\"pl-8 pr-6 py-5 flex flex-col gap-2\">\n                        {{-- Status Badge and Room Name --}}\n                        <div>\n                            {{-- Status Badge - Above Title --}}\n                            <div class=\"mb-5\">\n                                @php\n                                    $statusBadgeClass = match($status) {\n                                        'busy' => 'bg-red-500/10 text-red-400 border-red-500/20',\n                                        'transitioning' => 'bg-amber-500/10 text-amber-400 border-amber-500/20',\n                                        'error' => 'bg-gray-500/10 text-gray-400 border-gray-500/20',\n                                        default => 'bg-green-500/10 text-green-400 border-green-500/20',\n                                    };\n                                @endphp\n                                <span class=\"inline-flex flex-col items-center justify-center px-3 py-1.5 rounded-lg text-xs font-semibold uppercase tracking-wider text-center border {{ $statusBadgeClass }}\">\n                                    @if(isset($statusTextParts['minutes']))\n                                        <span>{{ $statusTextParts['label'] }}</span>\n                                        <span>{{ $statusTextParts['minutes'] }}</span>\n                                    @else\n                                        <span>{{ $statusText }}</span>\n                                    @endif\n                                </span>\n                            </div>\n                            {{-- Room Name --}}\n                            <h3 class=\"text-xl font-bold board-text-primary\">{{ $display->display_name ?: $display->name }}</h3>\n                        </div>\n                        \n                        {{-- Event Info --}}\n                        <div>\n                            @if($currentEvent)\n                                {{-- Current Event --}}\n                                <div class=\"space-y-1\">\n                                    @if($board->show_title ?? true)\n                                        <div class=\"text-base font-semibold board-text-primary\">{{ $currentEvent['summary'] }}</div>\n                                    @endif\n                                    <div class=\"flex items-center gap-3 text-sm board-text-secondary\">\n                                        <div class=\"flex items-center gap-1.5\">\n                                            <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z\"></path>\n                                            </svg>\n                                            <span class=\"event-time\" data-start=\"{{ $currentEvent['start']->toIso8601String() }}\" data-end=\"{{ $currentEvent['end']->toIso8601String() }}\"></span>\n                                        </div>\n                                        @if(($board->show_booker ?? true) && $currentEvent['organizer'] !== 'Unknown')\n                                            <span class=\"text-gray-500\">•</span>\n                                            <div class=\"flex items-center gap-1.5\">\n                                                <svg class=\"w-4 w-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z\"></path>\n                                                </svg>\n                                                <span>{{ $currentEvent['organizer'] }}</span>\n                                            </div>\n                                        @endif\n                                    </div>\n                                </div>\n                            @else\n                                {{-- Available --}}\n                                @if($nextEvent)\n                                    <span class=\"text-base font-medium board-text-secondary\">\n                                        {{ $t('boards.available_until', ['time' => '']) }}<span class=\"available-until-time\" data-time=\"{{ $nextEvent['start']->toIso8601String() }}\"></span>\n                                    </span>\n                                @else\n                                    <span class=\"text-base font-medium board-text-secondary\">{{ $t('boards.available_until_end_of_day') }}</span>\n                                @endif\n                            @endif\n\n                            {{-- Next Up Event - Below Current Event --}}\n                            @if($nextEvent && ($board->show_next_event ?? true))\n                                <div class=\"pt-3 mt-3 border-t border-gray-700/30\">\n                                    <div class=\"space-y-1\">\n                                        @if($board->show_title ?? true)\n                                            <div class=\"flex items-center justify-between gap-2\">\n                                                <div class=\"text-base font-semibold board-text-primary truncate\">{{ $nextEvent['summary'] }}</div>\n                                                <span class=\"inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-500/10 text-blue-400 border border-blue-500/20\">{{ $t('boards.next') }}</span>\n                                            </div>\n                                        @endif\n                                        <div class=\"flex items-center gap-3 text-sm board-text-secondary\">\n                                            <div class=\"flex items-center gap-1.5\">\n                                                <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z\"></path>\n                                                </svg>\n                                                <span class=\"event-time\" data-start=\"{{ $nextEvent['start']->toIso8601String() }}\" data-end=\"{{ $nextEvent['end']->toIso8601String() }}\"></span>\n                                            </div>\n                                            @if(($board->show_booker ?? true) && isset($nextEvent['organizer']) && $nextEvent['organizer'] !== 'Unknown')\n                                                <span class=\"text-gray-500\">•</span>\n                                                <div class=\"flex items-center gap-1.5\">\n                                                    <svg class=\"w-4 w-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                                        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z\"></path>\n                                                    </svg>\n                                                    <span>{{ $nextEvent['organizer'] }}</span>\n                                                </div>\n                                            @endif\n                                        </div>\n                                    </div>\n                                </div>\n                            @endif\n                        </div>\n                    </div>\n                </div>\n            @empty\n                <div class=\"col-span-full board-card rounded-xl p-16 text-center\">\n                    <div class=\"flex flex-col items-center gap-4\">\n                        <div class=\"h-16 w-16 rounded-full bg-gray-500/20 flex items-center justify-center\">\n                            <x-icons.display class=\"h-8 w-8 board-text-tertiary\" />\n                        </div>\n                        <p class=\"text-lg font-medium board-text-secondary\">{{ $t('boards.no_displays') }}</p>\n                    </div>\n                </div>\n            @endforelse\n        </div>\n    @else\n        {{-- Card View (Default) --}}\n        <div class=\"space-y-4\" id=\"displays-list\">\n            @forelse($displays as $displayData)\n                @php\n                    $display = $displayData['display'];\n                    $status = $displayData['status'];\n                    $statusText = $displayData['statusText'];\n                    $currentEvent = $displayData['currentEvent'];\n                    $nextEvent = $displayData['nextEvent'];\n                    $transitioningMinutes = $displayData['transitioningMinutes'] ?? null;\n                    \n                    // Status colors\n                    $statusBarColor = match($status) {\n                        'busy' => 'bg-red-500',\n                        'transitioning' => 'bg-amber-500',\n                        'error' => 'bg-gray-500',\n                        default => 'bg-green-500',\n                    };\n                    \n                    // Update status text with minutes if transitioning\n                    $statusTextParts = [];\n                    if ($status === 'transitioning' && $transitioningMinutes !== null) {\n                        $statusTextParts = [\n                            'label' => $t('boards.transitioning'),\n                            'minutes' => '(' . $transitioningMinutes . ' min)'\n                        ];\n                    } else {\n                        $statusTextParts = ['label' => $statusText];\n                    }\n                @endphp\n                <div class=\"board-card relative overflow-hidden rounded-xl transition-all duration-300 hover:scale-[1.01]\">\n                {{-- Status Indicator Bar - Full height on left edge --}}\n                <div class=\"absolute left-0 top-0 bottom-0 w-1 {{ $statusBarColor }}\"></div>\n                \n                <div class=\"pl-8 pr-6 py-4.5 flex items-center gap-6\">\n                    {{-- Status Badge --}}\n                    <div class=\"flex-shrink-0 w-36 flex justify-center\">\n                        @php\n                            $statusBadgeClass = match($status) {\n                                'busy' => 'bg-red-500/10 text-red-400 border-red-500/20',\n                                'transitioning' => 'bg-amber-500/10 text-amber-400 border-amber-500/20',\n                                'error' => 'bg-gray-500/10 text-gray-400 border-gray-500/20',\n                                default => 'bg-green-500/10 text-green-400 border-green-500/20',\n                            };\n                        @endphp\n                        <span class=\"inline-flex flex-col items-center justify-center px-3 py-1.5 rounded-lg text-xs font-semibold uppercase tracking-wider text-center border w-full {{ $statusBadgeClass }}\">\n                            @if(isset($statusTextParts['minutes']))\n                                <span>{{ $statusTextParts['label'] }}</span>\n                                <span>{{ $statusTextParts['minutes'] }}</span>\n                            @else\n                                <span>{{ $statusText }}</span>\n                            @endif\n                        </span>\n                    </div>\n                    \n                    {{-- Room Name and Current Event Info --}}\n                    <div class=\"flex-1 min-w-0 pl-2\">\n                        <h3 class=\"text-xl font-bold mb-2 board-text-primary\">{{ $display->display_name ?: $display->name }}</h3>\n                        \n                        @if($currentEvent)\n                            {{-- Current Event --}}\n                            <div class=\"space-y-1\">\n                                @if($board->show_title ?? true)\n                                    <div class=\"text-base font-semibold board-text-primary truncate\">{{ $currentEvent['summary'] }}</div>\n                                @endif\n                                <div class=\"flex items-center gap-3 text-sm board-text-secondary\">\n                                    <div class=\"flex items-center gap-1.5\">\n                                        <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z\"></path>\n                                        </svg>\n                                        <span class=\"event-time\" data-start=\"{{ $currentEvent['start']->toIso8601String() }}\" data-end=\"{{ $currentEvent['end']->toIso8601String() }}\"></span>\n                                    </div>\n                                    @if(($board->show_booker ?? true) && $currentEvent['organizer'] !== 'Unknown')\n                                        <span class=\"text-gray-500\">•</span>\n                                        <div class=\"flex items-center gap-1.5\">\n                                            <svg class=\"w-4 w-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z\"></path>\n                                            </svg>\n                                            <span>{{ $currentEvent['organizer'] }}</span>\n                                        </div>\n                                    @endif\n                                </div>\n                            </div>\n                        @else\n                            {{-- Available --}}\n                            @if($nextEvent)\n                                <span class=\"text-base font-medium board-text-secondary\">\n                                    {{ $t('boards.available_until', ['time' => '']) }}<span class=\"available-until-time\" data-time=\"{{ $nextEvent['start']->toIso8601String() }}\"></span>\n                                </span>\n                            @else\n                                <span class=\"text-base font-medium board-text-secondary\">{{ $t('boards.available_until_end_of_day') }}</span>\n                            @endif\n                        @endif\n                    </div>\n\n                    {{-- Next Up Event - Right Side --}}\n                    @if($nextEvent && ($board->show_next_event ?? true))\n                        <div class=\"flex-shrink-0 text-right ml-6\">\n                            <div class=\"space-y-2\">\n                                <div class=\"flex items-center justify-end gap-2\">\n                                    <span class=\"inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-500/10 text-blue-400 border border-blue-500/20\">{{ $t('boards.next') }}</span>\n                                </div>\n                                <div class=\"space-y-1\">\n                                    @if($board->show_title ?? true)\n                                        <div class=\"text-base font-semibold board-text-primary truncate\">{{ $nextEvent['summary'] }}</div>\n                                    @endif\n                                    <div class=\"flex items-center justify-end gap-3 text-sm board-text-secondary\">\n                                        <div class=\"flex items-center gap-1.5\">\n                                            <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z\"></path>\n                                            </svg>\n                                            <span class=\"event-time\" data-start=\"{{ $nextEvent['start']->toIso8601String() }}\" data-end=\"{{ $nextEvent['end']->toIso8601String() }}\"></span>\n                                        </div>\n                                    </div>\n                                </div>\n                            </div>\n                        </div>\n                    @endif\n                </div>\n            </div>\n            @empty\n                <div class=\"board-card rounded-xl p-16 text-center\">\n                    <div class=\"flex flex-col items-center gap-4\">\n                        <div class=\"h-16 w-16 rounded-full bg-gray-500/20 flex items-center justify-center\">\n                            <x-icons.display class=\"h-8 w-8 board-text-tertiary\" />\n                        </div>\n                        <p class=\"text-lg font-medium board-text-secondary\">{{ $t('boards.no_displays') }}</p>\n                    </div>\n                </div>\n            @endforelse\n        </div>\n    @endif\n</div>\n@endsection\n\n@php\n    // Restore original locale\n    app()->setLocale($originalLocale);\n@endphp\n\n@push('scripts')\n<script>\n    // Theme management - use theme from database\n    function getSystemTheme() {\n        return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';\n    }\n\n    function setTheme(theme) {\n        const container = document.getElementById('board-container');\n        if (!container) return;\n        \n        // If theme is 'system', use browser preference\n        if (theme === 'system') {\n            theme = getSystemTheme();\n        }\n        \n        if (theme === 'light') {\n            container.classList.remove('board-dark');\n            container.classList.add('board-light');\n        } else {\n            container.classList.remove('board-light');\n            container.classList.add('board-dark');\n        }\n        \n    }\n\n    // Initialize theme - use pre-calculated theme from inline script if available\n    function initializeTheme() {\n        const container = document.getElementById('board-container');\n        if (!container) return;\n        \n        const theme = container.dataset.theme || 'dark';\n        \n        // Use pre-calculated theme if available (from inline script in head)\n        const initialTheme = window.__boardInitialTheme;\n        if (initialTheme) {\n            setTheme(initialTheme);\n        } else {\n            setTheme(theme === 'system' ? getSystemTheme() : theme);\n        }\n        \n        // Listen for system theme changes if using system preference\n        if (theme === 'system' && window.matchMedia) {\n            const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n            const handleThemeChange = function() {\n                setTheme('system');\n            };\n            // Modern browsers\n            if (mediaQuery.addEventListener) {\n                mediaQuery.addEventListener('change', handleThemeChange);\n            } else {\n                // Fallback for older browsers\n                mediaQuery.addListener(handleThemeChange);\n            }\n        }\n    }\n\n    // Run immediately\n    initializeTheme();\n\n    // Also run on DOMContentLoaded as fallback\n    document.addEventListener('DOMContentLoaded', initializeTheme);\n\n        // Get board language from data attribute\n        const boardLanguage = document.getElementById('board-container')?.dataset.language || 'en';\n        \n        // Format time using board language (without seconds)\n        function formatTime(date) {\n            return date.toLocaleTimeString(boardLanguage, { \n                hour: 'numeric', \n                minute: '2-digit'\n            });\n        }\n\n    // Format time range using browser locale\n    function formatTimeRange(startDate, endDate) {\n        const start = formatTime(startDate);\n        const end = formatTime(endDate);\n        return `${start} - ${end}`;\n    }\n\n        // Format date using board language\n        function formatDate(date) {\n            return date.toLocaleDateString(boardLanguage, {\n                weekday: 'long',\n                year: 'numeric',\n                month: 'long',\n                day: 'numeric'\n            });\n        }\n\n    // Update current time every second\n    function updateTime() {\n        const now = new Date();\n        // Format current time with seconds for the clock display\n        const timeString = now.toLocaleTimeString(undefined, { \n            hour: 'numeric', \n            minute: '2-digit', \n            second: '2-digit'\n        });\n        document.getElementById('current-time').textContent = timeString;\n        document.getElementById('current-date').textContent = formatDate(now);\n    }\n    \n    // Format all event times using browser locale (without seconds)\n    function formatEventTimes() {\n        document.querySelectorAll('.event-time').forEach(element => {\n            const start = new Date(element.dataset.start);\n            const end = new Date(element.dataset.end);\n            element.textContent = formatTimeRange(start, end);\n        });\n    }\n    \n    // Format all \"available until\" times\n    function formatAvailableUntilTimes() {\n        document.querySelectorAll('.available-until-time').forEach(element => {\n            const time = new Date(element.dataset.time);\n            element.textContent = formatTime(time);\n        });\n    }\n    \n    // Initialize on page load\n    updateTime();\n    formatEventTimes();\n    formatAvailableUntilTimes();\n    \n    // Update every second\n    setInterval(updateTime, 1000);\n    \n    // Auto-refresh display data every 30 seconds (full page reload for simplicity)\n    let refreshInterval;\n    \n    function refreshDisplayData() {\n        // Simple full page reload - more reliable than parsing HTML\n        window.location.reload();\n    }\n    \n    // Start auto-refresh every 30 seconds\n    refreshInterval = setInterval(refreshDisplayData, 30000);\n    \n    // Clean up on page unload\n    window.addEventListener('beforeunload', function() {\n        if (refreshInterval) {\n            clearInterval(refreshInterval);\n        }\n    });\n</script>\n@endpush\n"
  },
  {
    "path": "backend/resources/views/pages/caldav-accounts/create.blade.php",
    "content": "@extends('layouts.base')\n@section('title', 'Create a new CalDAV Account')\n@section('container_class', 'max-w-5xl')\n@section('content')\n    <x-cards.card>\n        {{-- Session Status Alert --}}\n        <x-alerts.alert />\n\n        <div>\n            <h1 class=\"text-base font-semibold leading-6 text-gray-900\">Enter your credentials</h1>\n            <p class=\"mt-2 text-sm text-gray-700\">We'll connect to your server to access your calendars.</p>\n        </div>\n\n        <form action=\"{{ route('caldav-accounts.store') }}\" method=\"POST\" class=\"mt-6\">\n            @csrf\n            <div class=\"space-y-6\">\n                <div>\n                    <label for=\"url\" class=\"block text-sm font-medium leading-6 text-gray-900\">Server URL</label>\n                    <div class=\"mt-2\">\n                        <input type=\"url\" name=\"url\" id=\"url\" value=\"{{ old('url') }}\"\n                               class=\"block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6\"\n                               placeholder=\"https://example.com/remote.php/dav\">\n                    </div>\n                    <p class=\"mt-2 text-sm text-gray-500\">The URL of your CalDAV server.</p>\n                </div>\n\n                <div>\n                    <label for=\"username\" class=\"block text-sm font-medium leading-6 text-gray-900\">Username</label>\n                    <div class=\"mt-2\">\n                        <input type=\"text\" name=\"username\" id=\"username\" value=\"{{ old('username') }}\"\n                               class=\"block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6\">\n                    </div>\n                </div>\n\n                <div>\n                    <label for=\"password\" class=\"block text-sm font-medium leading-6 text-gray-900\">Password</label>\n                    <div class=\"mt-2\">\n                        <input type=\"password\" name=\"password\" id=\"password\"\n                               class=\"block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6\">\n                    </div>\n                </div>\n\n                <div class=\"flex items-center justify-end gap-x-6\">\n                    <a href=\"{{ route('dashboard') }}\" class=\"text-sm font-semibold leading-6 text-gray-900\">Cancel</a>\n                    <button type=\"submit\"\n                            class=\"rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600\">\n                        Connect Account\n                    </button>\n                </div>\n            </div>\n        </form>\n    </x-cards.card>\n@endsection\n"
  },
  {
    "path": "backend/resources/views/pages/dashboard.blade.php",
    "content": "@extends('layouts.base')\n@section('title', 'Management dashboard')\n\n@section('actions')\n    {{-- Connect Code --}}\n    @if((auth()->user()->hasAnyDisplay() || auth()->user()->workspaces()->count() > 1) && $connectCode)\n        <div class=\"items-center flex ml-auto gap-4\">\n            <div class=\"flex border border-dashed rounded-lg px-4 h-14 items-center border-gray-400\">\n                <h3 class=\"text-sm font-semibold text-gray-900 mr-8 flex items-center\">Connect code</h3>\n                <div class=\"flex-1 text-sm text-gray-500\">\n                    <p class=\"font-mono\">{{ chunk_split($connectCode, 3, ' ') }}</p>\n                </div>\n            </div>\n        </div>\n    @endif\n@endsection\n\n@section('content')\n    @php\n        $isSelfHosted = config('settings.is_self_hosted');\n        $checkout = auth()->user()->getCheckoutUrl(route('billing.thanks'));\n        $showLicenseModal = $isSelfHosted && !auth()->user()->hasPro();\n    @endphp\n\n    {{-- Session Status Alert --}}\n    <x-alerts.alert :errors=\"$errors\" />\n\n    {{-- Google Workspace Booking Method Selection Warnings --}}\n    @foreach($googleAccounts as $googleAccount)\n        @if($googleAccount->permission_type === \\App\\Enums\\PermissionType::WRITE && $googleAccount->isBusiness() && $googleAccount->booking_method === null)\n            <div class=\"mb-4 rounded-md bg-yellow-50 ring-1 ring-inset ring-yellow-600 p-4 flex items-start gap-4\" x-data>\n                <div class=\"flex-shrink-0 mt-1\">\n                    <span class=\"inline-flex items-center justify-center h-10 w-10 rounded-full bg-yellow-100\">\n                        <svg class=\"h-6 w-6 text-yellow-600\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\">\n                            <path d=\"M12 9v2m0 4h.01M5.07 20A9.938 9.938 0 0 1 2 12C2 6.48 6.48 2 12 2c5.52 0 10 4.48 10 10a9.938 9.938 0 0 1-3.07 8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n                        </svg>\n                    </span>\n                </div>\n                <div class=\"flex-1\">\n                    <h3 class=\"text-md font-semibold text-yellow-900 mb-1\">Booking Method Required</h3>\n                    <p class=\"text-sm text-yellow-800 mb-1\">\n                        Please select a booking method for your Google Workspace account <strong>{{ $googleAccount->name }}</strong> ({{ $googleAccount->email }}).\n                    </p>\n                </div>\n                <div class=\"flex-shrink-0 ml-4 mt-2\">\n                    <button \n                        type=\"button\"\n                        @click=\"$dispatch('open-google-booking-method-modal', '{{ $googleAccount->id }}')\"\n                        class=\"inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-yellow-700\">\n                        Select booking method\n                    </button>\n                </div>\n            </div>\n        @endif\n    @endforeach\n\n    {{-- Service Account Warnings --}}\n    @foreach($googleAccounts as $googleAccount)\n        @if($googleAccount->isBusiness() && $googleAccount->booking_method === \\App\\Enums\\GoogleBookingMethod::SERVICE_ACCOUNT && !$googleAccount->service_account_file_path)\n            <div class=\"mb-4 rounded-md bg-yellow-50 ring-1 ring-inset ring-yellow-600 p-4 flex items-start gap-4\" x-data>\n                <div class=\"flex-shrink-0 mt-1\">\n                    <span class=\"inline-flex items-center justify-center h-10 w-10 rounded-full bg-yellow-100\">\n                        <svg class=\"h-6 w-6 text-yellow-600\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\">\n                            <path d=\"M12 9v2m0 4h.01M5.07 20A9.938 9.938 0 0 1 2 12C2 6.48 6.48 2 12 2c5.52 0 10 4.48 10 10a9.938 9.938 0 0 1-3.07 8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n                        </svg>\n                    </span>\n                </div>\n                <div class=\"flex-1\">\n                    <h3 class=\"text-md font-semibold text-yellow-900 mb-1\">Service Account Required</h3>\n                    <p class=\"text-sm text-yellow-800 mb-1\">\n                        The Google Workspace account <strong>{{ $googleAccount->name }}</strong> ({{ $googleAccount->email }}) is configured to use service account booking but the service account file has not been uploaded yet. Please upload your service account file to enable room bookings.\n                    </p>\n                </div>\n                <div class=\"flex-shrink-0 ml-4 mt-2\">\n                    <button \n                        type=\"button\"\n                        @click=\"$dispatch('open-service-account-modal', { googleAccountId: '{{ $googleAccount->id }}' })\"\n                        class=\"inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-yellow-700\">\n                        Set up service account\n                    </button>\n                </div>\n            </div>\n        @endif\n    @endforeach\n\n    {{-- License Key Modal --}}\n    <x-modals.license-key />\n\n    {{-- Commercial Banner --}}\n    @if(! auth()->user()->hasProForCurrentWorkspace() && auth()->user()->hasAnyDisplay())\n        <div class=\"mb-4 rounded-lg bg-indigo-50 border border-indigo-200 p-4 flex items-start gap-4\">\n            <div class=\"flex-shrink-0 mt-1\">\n                <span class=\"inline-flex items-center justify-center h-10 w-10 rounded-full bg-indigo-100\">\n                    <x-icons.settings class=\"h-6 w-6 text-indigo-500\" />\n                </span>\n            </div>\n            <div class=\"flex-1\">\n                <h3 class=\"text-md font-semibold text-indigo-900 mb-1\">Unlock all features</h3>\n                <p class=\"text-sm text-indigo-800 mb-1\">\n                    Upgrade to Pro to unlock all features, including multiple displays, creating boards, book on-display, personalize displays, enable check-in and more!\n                </p>\n                <p class=\"text-sm text-indigo-700 mb-0\">\n                    <a href=\"https://spacepad.io/#features\" target=\"_blank\" class=\"underline hover:text-indigo-900 inline-block\">See all Pro features</a> or <a href=\"https://spacepad.io/pricing\" target=\"_blank\" class=\"underline hover:text-indigo-900 inline-block\">see pricing</a>.\n                </p>\n            </div>\n            <div class=\"flex-shrink-0 ml-4 mt-2\">\n                @if($isSelfHosted)\n                    <button type=\"button\" x-data @click=\"$dispatch('open-modal', 'license-key')\" class=\"inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-700\">\n                        Try Pro 14 days for free\n                    </button>\n                @else\n                    <x-lemon-button :href=\"$checkout\" class=\"inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-700\">\n                        Try Pro 14 days for free\n                    </x-lemon-button>\n                @endif\n            </div>\n        </div>\n    @endif\n\n    <div class=\"grid gap-4 grid-cols-12 min-h-[600px]\">\n        <x-cards.card class=\"col-span-12 xl:col-span-8\">\n            {{-- Tabs --}}\n            <div class=\"border-b border-gray-200 mb-6\">\n                <nav class=\"-mb-px flex space-x-8\" aria-label=\"Tabs\">\n                    <button onclick=\"switchTab('displays')\" id=\"tab-displays\" class=\"tab-button border-b-2 border-blue-600 pb-4 px-1 text-sm font-medium text-blue-600 whitespace-nowrap\">\n                        Displays\n                    </button>\n                    @if(auth()->user()->hasProForCurrentWorkspace())\n                        <button onclick=\"switchTab('boards')\" id=\"tab-boards\" class=\"tab-button border-b-2 border-transparent pb-4 px-1 text-sm font-medium text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap\">\n                            Boards <span class=\"ml-1 inline-flex items-center rounded-md bg-green-50 px-1.5 py-0.5 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20\">New</span>\n                        </button>\n                    @else\n                        <div class=\"relative group\">\n                            <button type=\"button\" disabled id=\"tab-boards\" class=\"tab-button border-b-2 border-transparent pb-4 px-1 text-sm font-medium text-gray-400 cursor-not-allowed whitespace-nowrap flex items-center gap-1\">\n                                Boards\n                                <svg class=\"h-4 w-4\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n                                    <path fill-rule=\"evenodd\" d=\"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z\" clip-rule=\"evenodd\"></path>\n                                </svg>\n                            </button>\n                            <div class=\"absolute left-0 top-full mt-2 w-72 p-3 bg-gray-900 text-white text-xs rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-10\">\n                                <div class=\"font-semibold mb-1\">Boards (Pro Feature)</div>\n                                <div class=\"mb-2\">Boards are meeting room availability overviews for big screens, allowing you to display multiple rooms at once.</div>\n                                <div>Find more information on <a href=\"https://spacepad.io\" target=\"_blank\" class=\"underline font-semibold hover:text-blue-300\">spacepad.io</a></div>\n                                <div class=\"absolute bottom-full left-4 w-0 h-0 border-l-4 border-r-4 border-b-4 border-transparent border-b-gray-900\"></div>\n                            </div>\n                        </div>\n                    @endif\n                </nav>\n            </div>\n\n            {{-- Displays Tab Content --}}\n            <div id=\"tab-content-displays\" class=\"tab-content\">\n                <div class=\"sm:flex sm:items-center mb-4\">\n                    <div class=\"sm:flex-auto\">\n                        <h2 class=\"text-lg font-semibold leading-6 text-gray-900\">Displays</h2>\n                        <p class=\"mt-1 text-sm text-gray-500\">\n                            Overview of your displays and their status.\n                        </p>\n                    </div>\n                    <div class=\"mt-4 sm:ml-16 sm:mt-0 sm:flex-none flex items-center gap-2\">\n                        @if(auth()->user()->hasAnyDisplay() || auth()->user()->workspaces()->count() > 1)\n                            <button type=\"button\" onclick=\"openConnectModal()\" class=\"inline-flex items-center gap-x-1.5 rounded-md bg-oxford px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-oxford-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-oxford-600\">\n                                <x-icons.display class=\"h-4 w-4\" />\n                                How to connect a tablet\n                            </button>\n                        @endif\n                        @if(auth()->user()->can('create', \\App\\Models\\Display::class))\n                            @if(auth()->user()->shouldUpgradeForCurrentWorkspace())\n                                <span class=\"inline-flex items-center rounded-md bg-gray-100 px-3 py-2 text-center text-sm font-semibold text-gray-400 shadow-sm ring-1 ring-inset ring-gray-200 cursor-not-allowed\" title=\"Upgrade to Pro to create more displays\">\n                                    <x-icons.plus class=\"h-5 w-5 mr-1\" />\n                                    Create new display <span class=\"ml-2 inline-flex items-center rounded-md bg-blue-50 px-1.5 py-0.5 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-600\">Pro</span>\n                                </span>\n                            @else\n                                <a href=\"{{ route('displays.create') }}\" class=\"inline-flex items-center rounded-md bg-oxford px-3 py-2 text-center text-sm font-semibold text-white\">\n                                    <x-icons.plus class=\"h-5 w-5 mr-1\" />\n                                    Create new display\n                                </a>\n                            @endif\n                        @endif\n                    </div>\n                </div>\n\n            {{-- Connect Instructions Modal --}}\n            <div id=\"connectModal\" class=\"relative z-10 hidden\" aria-labelledby=\"modal-title\" role=\"dialog\" aria-modal=\"true\">\n                <div class=\"fixed inset-0 bg-gray-500 opacity-75 transition-opacity\"></div>\n\n                <div class=\"fixed inset-0 z-10 overflow-y-auto\">\n                    <div class=\"flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0\">\n                        <div class=\"relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6\">\n                            <div>\n                                <div class=\"mt-2 text-center\">\n                                    <h3 class=\"text-lg font-semibold leading-6 text-gray-900\" id=\"modal-title\">Instructions on connecting a new device</h3>\n                                    <div class=\"mt-2 mx-auto max-w-md\">\n                                        <p class=\"text-sm text-gray-700\">Connect a new device like a tablet or phone by downloading the app from the <a target=\"_blank\" href=\"https://play.google.com/store/apps/details?id=com.magweter.spacepad\" class=\"text-blue-600 hover:text-blue-500\">Play Store</a> or <a target=\"_blank\" href=\"https://apps.apple.com/nl/app/spacepad/id6745528995\" class=\"text-blue-600 hover:text-blue-500\">App Store</a>.</p>\n                                    </div>\n                                    @if(config('settings.is_self_hosted'))\n                                        <div class=\"mt-6 mx-auto max-w-md text-center\">\n                                            <p class=\"text-sm text-gray-700\">Select 'self-hosted' and enter the following url:</p>\n                                        </div>\n                                        <div class=\"mt-4 p-4 bg-gray-50 rounded-lg\">\n                                            <p class=\"text-lg font-mono text-center\">{{ config('app.url') }}</p>\n                                        </div>\n                                    @endif\n                                    <div class=\"mt-6 mx-auto max-w-md text-center\">\n                                        <p class=\"text-sm text-gray-700\">Enter the following connect code:</p>\n                                    </div>\n                                    <div class=\"mt-4 p-4 bg-gray-50 rounded-lg\">\n                                        <p class=\"text-2xl font-mono text-center\">{{ chunk_split($connectCode, 3, ' ') }}</p>\n                                    </div>\n                                </div>\n                            </div>\n                            <div class=\"mt-5 sm:mt-8\">\n                                <button type=\"button\" onclick=\"closeConnectModal()\" class=\"inline-flex w-full justify-center rounded-md bg-oxford px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-oxford-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-oxford-600\">Close</button>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n            </div>\n\n                <div class=\"mt-6 flow-root\">\n                    <div class=\"-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8\">\n                        <div class=\"inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8\">\n                            <table class=\"min-w-full divide-y divide-gray-300\">\n                                <thead>\n                                <tr>\n                                    <th scope=\"col\" class=\"py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0\">Name</th>\n                                    <th scope=\"col\" class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Account</th>\n                                    <th scope=\"col\" class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Status</th>\n                                    <th scope=\"col\" class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Activity</th>\n                                    <th scope=\"col\" class=\"relative py-3.5 pr-4 pl-3 sm:pr-0\">\n                                        <span class=\"sr-only\">Actions</span>\n                                    </th>\n                                </tr>\n                                </thead>\n                                <tbody class=\"divide-y divide-gray-200\" id=\"displays-table\">\n                                @forelse($displays as $display)\n                                    <x-displays.table-row :display=\"$display\" />\n                                @empty\n                                    <tr>\n                                        <td colspan=\"5\" class=\"py-16 text-center\">\n                                            <div class=\"flex flex-col items-center justify-center\">\n                                                <x-icons.display class=\"h-12 w-12 text-orange mb-3\" />\n                                                <h3 class=\"mb-2 text-md font-semibold text-gray-900\">\n                                                    One more step and you're set up\n                                                </h3>\n                                                <p class=\"mb-6 text-sm text-gray-500 max-w-sm\">Pick the calendar or room you would like to synchronize. You are able to connect multiple tablets to one display.</p>\n                                                @if(! $isSelfHosted && auth()->user()->shouldUpgradeForCurrentWorkspace())\n                                                    <span class=\"inline-flex items-center rounded-md bg-gray-100 px-3 py-2 text-center text-sm font-semibold text-gray-400 shadow-sm ring-1 ring-inset ring-gray-200 cursor-not-allowed\" title=\"Upgrade to Pro to create more displays\">\n                                                        <x-icons.plus class=\"h-5 w-5 mr-1\" /> Create new display <span class=\"ml-2 inline-flex items-center rounded-md bg-blue-50 px-1.5 py-0.5 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-600\">Pro</span>\n                                                    </span>\n                                                @elseif($isSelfHosted && auth()->user()->shouldUpgradeForCurrentWorkspace())\n                                                    <span class=\"inline-flex items-center rounded-md bg-gray-100 px-3 py-2 text-center text-sm font-semibold text-gray-400 shadow-sm ring-1 ring-inset ring-gray-200 cursor-not-allowed\" title=\"Upgrade to Pro to create more displays\">\n                                                        <x-icons.plus class=\"h-5 w-5 mr-1\" /> Create new display <span class=\"ml-2 inline-flex items-center rounded-md bg-blue-50 px-1.5 py-0.5 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-600\">Pro</span>\n                                                    </span>\n                                                @else\n                                                    <a href=\"{{ route('displays.create') }}\" class=\"inline-flex items-center rounded-md bg-oxford px-3 py-2 text-center text-sm font-semibold text-white\">\n                                                        <x-icons.plus class=\"h-5 w-5 mr-1\" />\n                                                        Create new display\n                                                    </a>\n                                                @endif\n                                            </div>\n                                        </td>\n                                    </tr>\n                                @endforelse\n                                </tbody>\n                            </table>\n                        </div>\n                    </div>\n                </div>\n            </div>\n\n            {{-- Boards Tab Content --}}\n            @if(auth()->user()->hasProForCurrentWorkspace())\n                <div id=\"tab-content-boards\" class=\"tab-content hidden\">\n                    <div class=\"sm:flex sm:items-center mb-4\">\n                        <div class=\"sm:flex-auto\">\n                            <div class=\"flex items-center gap-1\">\n                                <h2 class=\"text-lg font-semibold leading-6 text-gray-900\">Boards</h2>\n                                <div class=\"relative group\">\n                                    <button type=\"button\" class=\"text-gray-400 hover:text-gray-500 focus:outline-none flex items-center gap-1\">\n                                        <svg class=\"h-4 w-4\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n                                            <path fill-rule=\"evenodd\" d=\"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z\" clip-rule=\"evenodd\"></path>\n                                        </svg>\n                                    </button>\n                                    <div class=\"absolute left-0 top-full mt-2 w-64 p-3 bg-gray-900 text-white text-xs rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-10\">\n                                        <div class=\"font-semibold mb-1\">Billing Information</div>\n                                        <div>Boards are billed as <strong>2x displays</strong> to keep the base product accessible and fair for users who don't use boards.</div>\n                                        <div class=\"absolute bottom-full left-4 w-0 h-0 border-l-4 border-r-4 border-b-4 border-transparent border-b-gray-900\"></div>\n                                    </div>\n                                </div>\n                            </div>\n                            <p class=\"mt-1 text-sm text-gray-500\">\n                                Meeting room availability overviews for big screens.\n                            </p>\n                        </div>\n                        <div class=\"mt-4 sm:ml-16 sm:mt-0 sm:flex-none\">\n                            <a href=\"{{ route('boards.create') }}\" class=\"inline-flex items-center rounded-md bg-oxford px-3 py-2 text-center text-sm font-semibold text-white\">\n                                <x-icons.plus class=\"h-5 w-5 mr-1\" />\n                                Create new board\n                            </a>\n                        </div>\n                    </div>\n\n                    <div class=\"mt-6 flow-root\">\n                        <div class=\"-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8\">\n                            <div class=\"inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8\">\n                                <table class=\"min-w-full divide-y divide-gray-300\">\n                                    <thead>\n                                    <tr>\n                                        <th scope=\"col\" class=\"py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0\">Name</th>\n                                        <th scope=\"col\" class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Displays</th>\n                                        <th scope=\"col\" class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Type</th>\n                                        <th scope=\"col\" class=\"px-3 py-3.5 text-left text-sm font-semibold text-gray-900\">Created by</th>\n                                        <th scope=\"col\" class=\"relative py-3.5 pr-4 pl-3 sm:pr-0\">\n                                            <span class=\"sr-only\">Actions</span>\n                                        </th>\n                                    </tr>\n                                    </thead>\n                                    <tbody class=\"divide-y divide-gray-200\">\n                                    @forelse($boards as $board)\n                                        <tr>\n                                            <td class=\"whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0\">\n                                                {{ $board->name }}\n                                            </td>\n                                            <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">\n                                                {{ $board->display_count }} {{ $board->display_count === 1 ? 'display' : 'displays' }}\n                                            </td>\n                                            <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">\n                                                @if($board->show_all_displays)\n                                                    <span class=\"inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20\">\n                                                        Show all\n                                                    </span>\n                                                @else\n                                                    <span class=\"inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-600/20\">\n                                                        Selected\n                                                    </span>\n                                                @endif\n                                            </td>\n                                            <td class=\"whitespace-nowrap px-3 py-4 text-sm text-gray-500\">\n                                                {{ $board->user->name }}\n                                            </td>\n                                            <td class=\"relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0\">\n                                                <div class=\"flex justify-end gap-x-2\">\n                                                    <a href=\"{{ route('boards.show', $board) }}\" target=\"_blank\" class=\"inline-flex items-center rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50\" title=\"Open board in new tab\">\n                                                        <x-icons.external class=\"h-4 w-4\" />\n                                                    </a>\n                                                    @can('update', $board)\n                                                        <a href=\"{{ route('boards.edit', $board) }}\" class=\"inline-flex items-center rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-blue-600 shadow-sm ring-1 ring-inset ring-blue-300 hover:bg-blue-50\" title=\"Edit board\">\n                                                            <x-icons.settings class=\"h-4 w-4\" />\n                                                        </a>\n                                                    @endcan\n                                                    @can('delete', $board)\n                                                        <form action=\"{{ route('boards.destroy', $board) }}\" method=\"POST\" class=\"inline\" onsubmit=\"return confirm('Are you sure you want to delete this board?');\">\n                                                            @csrf\n                                                            @method('DELETE')\n                                                            <button type=\"submit\" class=\"inline-flex items-center rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-red-600 shadow-sm ring-1 ring-inset ring-red-300 hover:bg-red-50\" title=\"Delete board\">\n                                                                <x-icons.trash class=\"h-4 w-4\" />\n                                                            </button>\n                                                        </form>\n                                                    @endcan\n                                                </div>\n                                            </td>\n                                        </tr>\n                                    @empty\n                                        <tr>\n                                            <td colspan=\"5\" class=\"py-16 text-center\">\n                                                <div class=\"flex flex-col items-center justify-center\">\n                                                    <x-icons.display class=\"h-12 w-12 text-orange mb-3\" />\n                                                    <h3 class=\"mb-2 text-md font-semibold text-gray-900\">\n                                                        No boards yet\n                                                    </h3>\n                                                    <p class=\"mb-6 text-sm text-gray-500 max-w-sm\">Create your first board to display room availability on a big screen.</p>\n                                                    <a href=\"{{ route('boards.create') }}\" class=\"inline-flex items-center rounded-md bg-oxford px-3 py-2 text-center text-sm font-semibold text-white\">\n                                                        <x-icons.plus class=\"h-5 w-5 mr-1\" />\n                                                        Create new board\n                                                    </a>\n                                                </div>\n                                            </td>\n                                        </tr>\n                                    @endforelse\n                                    </tbody>\n                                </table>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n            @endif\n        </x-cards.card>\n        \n        <x-cards.card class=\"col-span-12 xl:col-span-4 space-y-6\">\n            <div>\n                <h2 class=\"text-lg font-semibold leading-6 text-gray-900\">Accounts</h2>\n                <p class=\"mt-1 text-sm text-gray-500\">The accounts used to connect to calendars and rooms.</p>\n            </div>\n            <div>\n                <div class=\"flex flex-col md:flex-row gap-4\">\n                    @if(config('services.microsoft.enabled'))\n                        <button \n                            type=\"button\"\n                            onclick=\"window.dispatchEvent(new CustomEvent('open-permission-modal', { detail: { provider: 'outlook' } }))\"\n                            class=\"grow flex items-center justify-center gap-3 rounded-lg border border-gray-300 bg-white p-4 shadow-sm hover:border-blue-500 hover:shadow-md transition-all duration-200\">\n                            <x-icons.microsoft class=\"h-6 w-6\" />\n                            <span class=\"font-medium text-gray-900\">Microsoft</span>\n                        </button>\n                    @endif\n\n                    @if(config('services.google.enabled'))\n                        <button \n                            type=\"button\"\n                            onclick=\"window.dispatchEvent(new CustomEvent('open-permission-modal', { detail: { provider: 'google' } }))\"\n                            class=\"grow flex items-center justify-center gap-3 rounded-lg border border-gray-300 bg-white p-4 shadow-sm hover:border-blue-500 hover:shadow-md transition-all duration-200\">\n                            <x-icons.google class=\"h-6 w-6\" />\n                            <span class=\"font-medium text-gray-900\">Google</span>\n                        </button>\n                    @endif\n\n                    @if(config('services.caldav.enabled'))\n                        <a href=\"{{ route('caldav-accounts.create') }}\"\n                           class=\"grow flex items-center justify-center gap-3 rounded-lg border border-gray-300 bg-white p-4 shadow-sm hover:border-blue-500 hover:shadow-md transition-all duration-200\">\n                            <x-icons.calendar class=\"h-6 w-6 text-gray-600\" />\n                            <span class=\"font-medium text-gray-900\">CalDAV</span>\n                        </a>\n                    @endif\n                </div>\n            </div>\n            <div class=\"relative\">\n                <div class=\"absolute inset-0 flex items-center\" aria-hidden=\"true\">\n                    <div class=\"w-full border-t border-gray-300\"></div>\n                </div>\n                <div class=\"relative flex justify-center\">\n                    <span class=\"bg-white px-2 text-sm text-gray-500\">Connected accounts</span>\n                </div>\n            </div>\n            @if($outlookAccounts->isEmpty() && $googleAccounts->isEmpty() && $caldavAccounts->isEmpty())\n                <div class=\"py-12 text-center\">\n                    <div class=\"flex flex-col items-center justify-center\">\n                        <h3 class=\"mb-2 text-md font-semibold text-gray-900\">\n                            No accounts connected yet\n                        </h3>\n                        <p class=\"mb-0 text-sm text-gray-500 max-w-sm\">\n                            Connect a calendar account above to get started. You can connect Microsoft, Google, or CalDAV accounts.\n                        </p>\n                    </div>\n                </div>\n            @else\n                <div class=\"grid grid-cols-1 md:grid-cols-2 xl:grid-cols-1 gap-4\">\n                    @foreach($outlookAccounts as $outlookAccount)\n                    <div class=\"relative flex items-center space-x-4 rounded-lg border border-gray-300 bg-white px-5 py-4 shadow-sm focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2 hover:border-gray-400\">\n                        @if($outlookAccount->calendars->isEmpty())\n                            <form action=\"{{ route('outlook-accounts.delete', $outlookAccount) }}\" method=\"POST\" class=\"absolute top-4.5 right-2 z-10\">\n                                @csrf\n                                @method('DELETE')\n                                <button type=\"submit\" class=\"group p-1 rounded hover:bg-gray-100\" title=\"Disconnect\">\n                                    <x-icons.trash class=\"h-4 w-4 text-gray-400 group-hover:text-red-600\" />\n                                </button>\n                            </form>\n                        @else\n                            <span class=\"flex absolute top-4.5 right-2 z-10 group cursor-not-allowed\" title=\"Delete all connected displays first before disconnecting the account\">\n                                <span class=\"p-1 rounded\">\n                                    <x-icons.trash class=\"h-4 w-4 text-gray-300\" />\n                                </span>\n                            </span>\n                        @endif\n                        <div class=\"flex-shrink-0 px-1\">\n                            <x-icons.microsoft class=\"h-12 w-12\" />\n                        </div>\n                        <div class=\"min-w-0 flex-1\">\n                            <span class=\"absolute inset-0\" aria-hidden=\"true\"></span>\n                            <div class=\"flex items-center gap-2 flex-wrap\">\n                                <p class=\"text-md font-medium text-gray-900\">{{ $outlookAccount->name }}</p>\n                            </div>\n                            <div class=\"truncate text-sm text-gray-500 flex items-center gap-2 flex-wrap\">\n                                <span>{{ $outlookAccount->email }}</span>\n                            </div>\n                            <div class=\"truncate text-sm text-gray-500 flex items-center gap-2 mt-1 flex-wrap\">\n                                <p class=\"mt-0.5 whitespace-nowrap rounded-md bg-green-50 px-1.5 py-0.5 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600\">Connected</p>\n                                <p class=\"mt-0.5 whitespace-nowrap rounded-md px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset {{ $outlookAccount->isBusiness() ? 'bg-purple-50 text-purple-700 ring-purple-600' : 'bg-gray-50 text-gray-700 ring-gray-600' }}\">\n                                    {{ $outlookAccount->isBusiness() ? 'Microsoft 365' : 'Personal' }}\n                                </p>\n                                @if($outlookAccount->permission_type)\n                                    <p class=\"mt-0.5 whitespace-nowrap rounded-md px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset {{ $outlookAccount->permission_type === \\App\\Enums\\PermissionType::WRITE ? 'bg-blue-50 text-blue-700 ring-blue-600' : 'bg-gray-50 text-gray-700 ring-gray-600' }}\">\n                                        {{ $outlookAccount->permission_type->label() }}\n                                    </p>\n                                @endif\n                            </div>\n                        </div>\n                    </div>\n                @endforeach\n                @foreach($googleAccounts as $googleAccount)\n                    <div class=\"relative flex items-center space-x-4 rounded-lg border border-gray-300 bg-white px-5 py-4 shadow-sm focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2 hover:border-gray-400\">\n                        @if($googleAccount->calendars->isEmpty())\n                            <form action=\"{{ route('google-accounts.delete', $googleAccount) }}\" method=\"POST\" class=\"absolute top-4.5 right-2 z-10\">\n                                @csrf\n                                @method('DELETE')\n                                <button type=\"submit\" class=\"group p-1 rounded hover:bg-gray-100\" title=\"Disconnect\">\n                                    <x-icons.trash class=\"h-4 w-4 text-gray-400 group-hover:text-red-600\" />\n                                </button>\n                            </form>\n                        @else\n                            <span class=\"flex absolute top-4.5 right-2 z-10 group cursor-not-allowed\" title=\"Delete all connected displays first before disconnecting the account\">\n                                <span class=\"p-1 rounded\">\n                                    <x-icons.trash class=\"h-4 w-4 text-gray-300\" />\n                                </span>\n                            </span>\n                        @endif\n                        <div class=\"flex-shrink-0 px-1\">\n                            <x-icons.google class=\"h-12 w-12\" />\n                        </div>\n                        <div class=\"min-w-0 flex-1\">\n                            <span class=\"absolute inset-0\" aria-hidden=\"true\"></span>\n                            <div class=\"flex items-center gap-2 flex-wrap\">\n                                <p class=\"text-md font-medium text-gray-900\">{{ $googleAccount->name }}</p>\n                            </div>\n                            <div class=\"truncate text-sm text-gray-500 flex items-center gap-2 flex-wrap\">\n                                <span>{{ $googleAccount->email }}</span>\n                            </div>\n                            <div class=\"truncate text-sm text-gray-500 flex items-center gap-2 mt-1 flex-wrap\">\n                                <p class=\"mt-0.5 whitespace-nowrap rounded-md bg-green-50 px-1.5 py-0.5 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600\">Connected</p>\n                                <p class=\"mt-0.5 whitespace-nowrap rounded-md px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset {{ $googleAccount->isBusiness() ? 'bg-purple-50 text-purple-700 ring-purple-600' : 'bg-gray-50 text-gray-700 ring-gray-600' }}\">\n                                    {{ $googleAccount->isBusiness() ? 'Workspace' : 'Personal' }}\n                                </p>\n                                @if($googleAccount->permission_type)\n                                    <p class=\"mt-0.5 whitespace-nowrap rounded-md px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset {{ $googleAccount->permission_type === \\App\\Enums\\PermissionType::WRITE ? 'bg-blue-50 text-blue-700 ring-blue-600' : 'bg-gray-50 text-gray-700 ring-gray-600' }}\">\n                                        {{ $googleAccount->permission_type->label() }}\n                                    </p>\n                                @endif\n                                @if($googleAccount->isBusiness() && $googleAccount->booking_method)\n                                    <p class=\"mt-0.5 whitespace-nowrap rounded-md px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset {{ $googleAccount->booking_method === \\App\\Enums\\GoogleBookingMethod::SERVICE_ACCOUNT ? 'bg-orange-50 text-orange-700 ring-orange-600' : 'bg-indigo-50 text-indigo-700 ring-indigo-600' }}\">\n                                        {{ $googleAccount->booking_method === \\App\\Enums\\GoogleBookingMethod::SERVICE_ACCOUNT ? 'Service Account' : 'User Account' }}\n                                    </p>\n                                @endif\n                            </div>\n                        </div>\n                    </div>\n                @endforeach\n                @foreach($caldavAccounts as $caldavAccount)\n                    <div class=\"relative flex items-center space-x-4 rounded-lg border border-gray-300 bg-white px-5 py-4 shadow-sm focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2 hover:border-gray-400\">\n                        @if($caldavAccount->calendars->isEmpty())\n                            <form action=\"{{ route('caldav-accounts.delete', $caldavAccount) }}\" method=\"POST\" class=\"absolute top-4.5 right-2 z-10\">\n                                @csrf\n                                @method('DELETE')\n                                <button type=\"submit\" class=\"group p-1 rounded hover:bg-gray-100\" title=\"Disconnect\">\n                                    <x-icons.trash class=\"h-4 w-4 text-gray-400 group-hover:text-red-600\" />\n                                </button>\n                            </form>\n                        @else\n                            <span class=\"flex absolute top-4.5 right-2 z-10 group cursor-not-allowed\" title=\"Delete all connected displays first before disconnecting the account\">\n                                <span class=\"p-1 rounded\">\n                                    <x-icons.trash class=\"h-4 w-4 text-gray-300\" />\n                                </span>\n                            </span>\n                        @endif\n                        <div class=\"flex-shrink-0 px-1\">\n                            <x-icons.calendar class=\"h-12 w-12\" />\n                        </div>\n                        <div class=\"min-w-0 flex-1\">\n                            <span class=\"absolute inset-0\" aria-hidden=\"true\"></span>\n                            <div class=\"flex items-center gap-2 flex-wrap\">\n                                <p class=\"text-md font-medium text-gray-900\">{{ $caldavAccount->name }}</p>\n                            </div>\n                            <div class=\"truncate text-sm text-gray-500 flex items-center gap-2 flex-wrap\">\n                                <span>{{ $caldavAccount->email }}</span>\n                            </div>\n                            <div class=\"truncate text-sm text-gray-500 flex items-center gap-2 mt-1 flex-wrap\">\n                                <p class=\"mt-0.5 whitespace-nowrap rounded-md bg-green-50 px-1.5 py-0.5 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600\">Connected</p>\n                                @if($caldavAccount->permission_type)\n                                    <p class=\"mt-0.5 whitespace-nowrap rounded-md px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset {{ $caldavAccount->permission_type === \\App\\Enums\\PermissionType::WRITE ? 'bg-blue-50 text-blue-700 ring-blue-600' : 'bg-gray-50 text-gray-700 ring-gray-600' }}\">\n                                        {{ $caldavAccount->permission_type->label() }}\n                                    </p>\n                                @endif\n                            </div>\n                        </div>\n                    </div>\n                @endforeach\n                </div>\n            @endif\n        </x-cards.card>\n    </div>\n\n    {{-- Server Info (Self-hosted only) --}}\n    @if($isSelfHosted)\n        <div class=\"mt-8 flex flex-wrap items-center gap-3 text-sm text-gray-500\">\n            <span class=\"inline-flex items-center rounded-md bg-gray-100 px-2.5 py-1.5 text-xs font-medium text-gray-600\">\n                Self-hosted\n            </span>\n            @if($version)\n                <span class=\"inline-flex items-center rounded-md bg-gray-100 px-2.5 py-1.5 text-xs font-medium text-gray-600\">\n                    v{{ $version }}\n                </span>\n            @endif\n            @if($appUrl)\n                <span class=\"inline-flex items-center rounded-md bg-gray-100 px-2.5 py-1.5 text-xs font-medium text-gray-600\">\n                    {{ parse_url($appUrl, PHP_URL_HOST) }} ({{ $appEnv }})\n                </span>\n            @endif\n        </div>\n    @endif\n@endsection\n\n@push('scripts')\n    <script>\n        function openConnectModal() {\n            document.getElementById('connectModal').classList.remove('hidden');\n        }\n\n        function closeConnectModal() {\n            document.getElementById('connectModal').classList.add('hidden');\n        }\n\n        // Close modal when clicking outside\n        document.getElementById('connectModal').addEventListener('click', function(e) {\n            if (e.target === this) {\n                closeConnectModal();\n            }\n        });\n\n        // Close modal when pressing Escape key\n        document.addEventListener('keydown', function(e) {\n            if (e.key === 'Escape') {\n                closeConnectModal();\n            }\n        });\n\n        // Show service account modal if needed\n        @if(session('open-service-account-modal'))\n            window.addEventListener('DOMContentLoaded', function() {\n                window.dispatchEvent(new CustomEvent('open-service-account-modal', {\n                    detail: { googleAccountId: '{{ session('open-service-account-modal') }}' }\n                }));\n            });\n        @endif\n\n        // Show booking method modal if needed (after connecting Google Workspace account with write permission)\n        @if(session('open-google-booking-method-modal'))\n            window.addEventListener('DOMContentLoaded', function() {\n                window.dispatchEvent(new CustomEvent('open-google-booking-method-modal', {\n                    detail: '{{ session('open-google-booking-method-modal') }}'\n                }));\n            });\n        @endif\n\n        // Tab switching functionality\n        function switchTab(tabName, updateUrl = true) {\n            // Hide all tab contents\n            document.querySelectorAll('.tab-content').forEach(content => {\n                content.classList.add('hidden');\n            });\n\n            // Remove active state from all tabs\n            document.querySelectorAll('.tab-button').forEach(button => {\n                button.classList.remove('border-blue-600', 'text-blue-600');\n                button.classList.add('border-transparent', 'text-gray-500');\n            });\n\n            // Show selected tab content\n            const selectedContent = document.getElementById('tab-content-' + tabName);\n            if (selectedContent) {\n                selectedContent.classList.remove('hidden');\n            }\n\n            // Activate selected tab button\n            const selectedTab = document.getElementById('tab-' + tabName);\n            if (selectedTab) {\n                selectedTab.classList.remove('border-transparent', 'text-gray-500');\n                selectedTab.classList.add('border-blue-600', 'text-blue-600');\n            }\n\n            // Update URL with tab parameter\n            if (updateUrl) {\n                const url = new URL(window.location);\n                if (tabName === 'displays') {\n                    // Remove tab parameter for default tab\n                    url.searchParams.delete('tab');\n                } else {\n                    url.searchParams.set('tab', tabName);\n                }\n                window.history.pushState({ tab: tabName }, '', url);\n            }\n        }\n\n        // Restore active tab on page load from URL\n        document.addEventListener('DOMContentLoaded', function() {\n            const urlParams = new URLSearchParams(window.location.search);\n            const tabFromUrl = urlParams.get('tab');\n            const defaultTab = 'displays';\n            const tabToShow = tabFromUrl || defaultTab;\n            \n            // Check if the tab exists (e.g., boards tab might not exist for non-Pro users)\n            const tabButton = document.getElementById('tab-' + tabToShow);\n            if (tabButton) {\n                switchTab(tabToShow, false); // Don't update URL on initial load\n            } else {\n                // If tab from URL doesn't exist, fall back to default\n                switchTab(defaultTab, false);\n            }\n        });\n\n        // Handle browser back/forward buttons\n        window.addEventListener('popstate', function(event) {\n            const urlParams = new URLSearchParams(window.location.search);\n            const tabFromUrl = urlParams.get('tab') || 'displays';\n            const tabButton = document.getElementById('tab-' + tabFromUrl);\n            if (tabButton) {\n                switchTab(tabFromUrl, false);\n            }\n        });\n\n    </script>\n@endpush\n\n@push('modals')\n    <x-modals.select-permission provider=\"outlook\" />\n    <x-modals.select-permission provider=\"google\" />\n    <x-modals.select-google-booking-method />\n    <x-modals.google-service-account />\n@endpush\n"
  },
  {
    "path": "backend/resources/views/pages/displays/create.blade.php",
    "content": "@extends('layouts.base')\n@section('title', 'Create a new display')\n@section('container_class', 'max-w-5xl')\n@section('content')\n    @php\n        $isSelfHosted = config('settings.is_self_hosted');\n        $checkout = auth()->user()->getCheckoutUrl(route('displays.create'));\n        $userHasPro = auth()->user()->hasProForCurrentWorkspace();\n    @endphp\n\n    {{-- License Key Modal --}}\n    <x-modals.license-key />\n\n    <x-cards.card>\n        {{-- Session Status Alert --}}\n        <x-alerts.alert />\n\n        <form id=\"createForm\" action=\"{{ route('displays.store') }}\" method=\"POST\">\n            @csrf\n            <input type=\"hidden\" name=\"provider\" id=\"providerInput\" value=\"\">\n            <div class=\"flex flex-col\">\n                <div class=\"flow-root\">\n                    <div class=\"grid grid-cols-1 lg:grid-cols-2 gap-4\">\n                        <div class=\"mb-4\">\n                            <label for=\"name\" class=\"block text-sm font-medium leading-6 text-gray-900\">Device name</label>\n                            <div class=\"mt-2\">\n                                <input type=\"text\" name=\"name\" id=\"name\" value=\"{{ old('name') }}\"\n                                       class=\"block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6\"\n                                       placeholder=\"\">\n                            </div>\n                            <p class=\"mt-2 text-sm text-gray-500\">This name is only used in the dashboard and for your identification.</p>\n                        </div>\n                        <div class=\"mb-4\">\n                            <label for=\"displayName\" class=\"block text-sm font-medium leading-6 text-gray-900\">Room name</label>\n                            <div class=\"mt-2\">\n                                <input type=\"text\" name=\"displayName\" id=\"displayName\" value=\"{{ old('displayName') }}\"\n                                       class=\"block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6\"\n                                       placeholder=\"\">\n                            </div>\n                            <p class=\"mt-2 text-sm text-gray-500\">This name will be displayed on the top right corner of the display.</p>\n                        </div>\n                    </div>\n\n                    @if($workspaces->count() > 1)\n                        <div class=\"mb-4\">\n                            <label for=\"workspace_id\" class=\"block text-sm font-medium leading-6 text-gray-900\">Workspace</label>\n                            <div class=\"mt-2\">\n                                <select name=\"workspace_id\" id=\"workspace_id\" class=\"block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6\">\n                                    @foreach($workspaces as $workspace)\n                                        <option value=\"{{ $workspace->id }}\" {{ $workspace->id === $defaultWorkspace?->id ? 'selected' : '' }}>\n                                            {{ $workspace->name }}\n                                            @if($workspace->pivot->role === \\App\\Enums\\WorkspaceRole::OWNER->value)\n                                                (Owner)\n                                            @elseif($workspace->pivot->role === \\App\\Enums\\WorkspaceRole::ADMIN->value)\n                                                (Admin)\n                                            @endif\n                                        </option>\n                                    @endforeach\n                                </select>\n                            </div>\n                            <p class=\"mt-2 text-sm text-gray-500\">Select which workspace this display should belong to.</p>\n                        </div>\n                    @else\n                        <input type=\"hidden\" name=\"workspace_id\" value=\"{{ $defaultWorkspace?->id }}\">\n                    @endif\n\n                    <!-- Step 1: Provider Selection -->\n                    <div class=\"mt-4\" id=\"providerSelection\">\n                        <p class=\"text-sm font-semibold leading-6 text-gray-900\">1. Select a calendar account</p>\n                        <p class=\"mt-1 text-sm leading-6 text-gray-600\">Choose the service you want to connect to.</p>\n                        <div class=\"mt-4 grid grid-cols-1 gap-4 sm:grid-cols-3\">\n                            <div class=\"relative flex items-center space-x-3 rounded-lg border border-gray-300 {{ count($outlookAccounts) > 0 && config('services.microsoft.enabled') ? 'bg-white hover:border-gray-400 cursor-pointer' : 'bg-gray-50 opacity-75 cursor-not-allowed' }} px-6 py-5 shadow-sm focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2 provider-tile\" data-provider=\"outlook\">\n                                <div class=\"flex-shrink-0\">\n                                    <x-icons.microsoft class=\"h-10 w-10\" />\n                                </div>\n                                <div class=\"min-w-0 flex-1\">\n                                    <span class=\"absolute inset-0\" aria-hidden=\"true\"></span>\n                                    <p class=\"text-sm font-medium text-gray-900\">Microsoft 365</p>\n                                    <p class=\"truncate text-sm text-gray-500\">\n                                        @if(count($outlookAccounts) > 0)\n                                            Connect to Outlook calendars\n                                        @else\n                                            Connect an account first\n                                        @endif\n                                    </p>\n                                </div>\n                            </div>\n                            <div class=\"relative flex items-center space-x-3 rounded-lg border border-gray-300 {{ count($googleAccounts) > 0 && config('services.google.enabled') ? 'bg-white hover:border-gray-400 cursor-pointer' : 'bg-gray-50 opacity-75 cursor-not-allowed' }} px-6 py-5 shadow-sm focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2 provider-tile\" data-provider=\"google\">\n                                <div class=\"flex-shrink-0\">\n                                    <x-icons.google class=\"h-10 w-10\" />\n                                </div>\n                                <div class=\"min-w-0 flex-1\">\n                                    <span class=\"absolute inset-0\" aria-hidden=\"true\"></span>\n                                    <p class=\"text-sm font-medium text-gray-900\">Google</p>\n                                    <p class=\"truncate text-sm text-gray-500\">\n                                        @if(count($googleAccounts) > 0)\n                                            Connect to Google calendars\n                                        @else\n                                            Connect an account first\n                                        @endif\n                                    </p>\n                                </div>\n                            </div>\n                            <div class=\"relative flex items-center space-x-3 rounded-lg border border-gray-300 {{ count($caldavAccounts) > 0 ? 'bg-white hover:border-gray-400 cursor-pointer' : 'bg-gray-50 opacity-75 cursor-not-allowed' }} px-6 py-5 shadow-sm focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2 provider-tile\" data-provider=\"caldav\">\n                                <div class=\"flex-shrink-0\">\n                                    <x-icons.caldav class=\"h-10 w-10\" />\n                                </div>\n                                <div class=\"min-w-0 flex-1\">\n                                    <span class=\"absolute inset-0\" aria-hidden=\"true\"></span>\n                                    <p class=\"text-sm font-medium text-gray-900\">CalDAV</p>\n                                    <p class=\"truncate text-sm text-gray-500\">\n                                        @if(count($caldavAccounts) > 0)\n                                            Connect to CalDAV calendars\n                                        @else\n                                            Connect an account first\n                                        @endif\n                                    </p>\n                                </div>\n                            </div>\n                        </div>\n                    </div>\n\n                    <!-- Step 2: Search Method Selection (initially hidden) -->\n                    <div class=\"mt-6 hidden\" id=\"searchMethodSelection\">\n                        <p class=\"text-sm font-semibold leading-6 text-gray-900\">2. How do you want to find your calendar?</p>\n                        <p class=\"mt-1 text-sm leading-6 text-gray-600\">Choose how you want to search for your calendar.</p>\n                        <div class=\"mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2\">\n                            <div data-method=\"calendar\" class=\"search-method-tile relative flex items-center space-x-3 rounded-lg border border-gray-300 bg-white px-6 py-5 shadow-sm focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2 hover:border-gray-400 cursor-pointer\">\n                                <div class=\"min-w-0 flex-1\">\n                                    <p class=\"text-sm font-medium text-gray-900 mb-1\">Connect by Calendar</p>\n                                    <p class=\"truncate text-sm text-gray-500\">Search by personal or work calendar</p>\n                                </div>\n                            </div>\n\n                            <div data-method=\"room\" class=\"search-method-tile relative flex items-center space-x-3 rounded-lg border border-gray-300 bg-white px-6 py-5 shadow-sm focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2 hover:border-gray-400 cursor-pointer\">\n                                <div class=\"min-w-0 flex-1\">\n                                    <p class=\"text-sm font-medium text-gray-900 mb-1\">Connect by Room</p>\n                                    <p class=\"truncate text-sm text-gray-500\">Search organization resources like rooms</p>\n                                </div>\n                            </div>\n                        </div>\n                    </div>\n\n                    <!-- Step 3: Calendar/Room Selection (initially hidden) -->\n                    <div class=\"mt-6 hidden\" id=\"calendarSelection\">\n                        <div id=\"outlookSelection\" class=\"hidden\">\n                            <p class=\"text-sm font-semibold leading-6 text-gray-900\">3. What account do you want to use?</p>\n                            <p class=\"mt-1 text-sm leading-6 text-gray-600\">Pick the account and the desired calendar or room to display.</p>\n                            <div class=\"mt-4 space-y-4\">\n                                @foreach($outlookAccounts as $outlookAccount)\n                                    <div class=\"flex items-start\">\n                                        <div class=\"flex items-center p-1 mr-2\">\n                                            <input\n                                                id=\"{{ $outlookAccount->id }}\"\n                                                name=\"account\"\n                                                value=\"{{ $outlookAccount->id }}\"\n                                                type=\"radio\"\n                                                class=\"h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-600\"\n                                                data-calendar-url=\"{{ route('calendars.outlook', $outlookAccount->id) }}\"\n                                                data-room-url=\"{{ route('rooms.outlook', $outlookAccount->id) }}\"\n                                                hx-target=\"#pickResource\"\n                                                hx-swap=\"innerHTML\"\n                                                hx-indicator=\"#hxSpinner\"\n                                            >\n                                        </div>\n                                        <div class=\"flex items-center p-1 mr-2\">\n                                            <x-icons.microsoft class=\"size-4 text-muted-foreground inline-flex\"/>\n                                        </div>\n                                        <div class=\"min-w-0 flex-1\">\n                                            <label for=\"account\" class=\"font-medium text-gray-900 text-sm\">{{ $outlookAccount->name }}</label>\n                                            <p class=\"text-gray-500 text-sm\">{{ $outlookAccount->email }}</p>\n                                        </div>\n                                    </div>\n                                @endforeach\n                            </div>\n                        </div>\n                        <div id=\"googleSelection\" class=\"hidden\">\n                            <p class=\"text-sm font-semibold leading-6 text-gray-900\">3. What account do you want to use?</p>\n                            <p class=\"mt-1 text-sm leading-6 text-gray-600\">Pick the account and the desired calendar or room to display.</p>\n                            <div class=\"mt-4 space-y-4\">\n                                @foreach($googleAccounts as $googleAccount)\n                                    <div class=\"flex items-start\">\n                                        <div class=\"flex items-center p-1 mr-2\">\n                                            <input\n                                                id=\"{{ $googleAccount->id }}\"\n                                                name=\"account\"\n                                                value=\"{{ $googleAccount->id }}\"\n                                                type=\"radio\"\n                                                class=\"h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-600\"\n                                                data-calendar-url=\"{{ route('calendars.google', $googleAccount->id) }}\"\n                                                data-room-url=\"{{ route('rooms.google', $googleAccount->id) }}\"\n                                                hx-target=\"#pickResource\"\n                                                hx-swap=\"innerHTML\"\n                                                hx-indicator=\"#hxSpinner\"\n                                            >\n                                        </div>\n                                        <div class=\"flex items-center p-1 mr-2\">\n                                            <x-icons.google class=\"size-4 text-muted-foreground inline-flex\"/>\n                                        </div>\n                                        <div class=\"min-w-0 flex-1\">\n                                            <label for=\"account\" class=\"font-medium text-gray-900 text-sm\">{{ $googleAccount->name }}</label>\n                                            <p class=\"text-gray-500 text-sm\">{{ $googleAccount->email }}</p>\n                                        </div>\n                                    </div>\n                                @endforeach\n                            </div>\n                        </div>\n                        <div id=\"caldavSelection\" class=\"hidden\">\n                            <p class=\"text-sm font-semibold leading-6 text-gray-900\">3. What account do you want to use?</p>\n                            <p class=\"mt-1 text-sm leading-6 text-gray-600\">Pick the account and the desired calendar to display.</p>\n                            <div class=\"mt-4 space-y-4\">\n                                @foreach($caldavAccounts as $caldavAccount)\n                                    <div class=\"flex items-start\">\n                                        <div class=\"flex items-center p-1 mr-2\">\n                                            <input\n                                                id=\"{{ $caldavAccount->id }}\"\n                                                name=\"account\"\n                                                value=\"{{ $caldavAccount->id }}\"\n                                                type=\"radio\"\n                                                class=\"h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-600\"\n                                                hx-get=\"{{ route('calendars.caldav', $caldavAccount->id) }}\"\n                                                hx-target=\"#pickResource\"\n                                                hx-swap=\"innerHTML\"\n                                                hx-indicator=\"#hxSpinner\"\n                                            >\n                                        </div>\n                                        <div class=\"flex items-center p-1 mr-2\">\n                                            <x-icons.calendar class=\"size-4 text-muted-foreground inline-flex\"/>\n                                        </div>\n                                        <div class=\"min-w-0 flex-1\">\n                                            <label for=\"account\" class=\"font-medium text-gray-900 text-sm\">{{ $caldavAccount->name }}</label>\n                                            <p class=\"text-gray-500 text-sm\">{{ $caldavAccount->email }}</p>\n                                        </div>\n                                    </div>\n                                @endforeach\n                            </div>\n                        </div>\n\n                        <div id=\"pickResource\" class=\"mt-4 relative\"></div>\n                    </div>\n                </div>\n\n                <div class=\"flex justify-between items-center mt-6\">\n                    <div id=\"hxSpinner\" class=\"relative\">\n                        <div class=\"absolute htmx-indicator flex bg-white opacity-75\">\n                            <svg class=\"animate-spin h-6 w-6 text-gray-400\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\">\n                                <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n                                <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n                            </svg>\n                        </div>\n                    </div>\n                    <div class=\"flex items-center justify-end gap-x-6\">\n                        <a href=\"{{ route('dashboard') }}\" class=\"text-sm font-semibold leading-6 text-gray-900\">Cancel</a>\n                        <button type=\"submit\" id=\"submitButton\"\n                                class=\"relative block rounded-md bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed px-3 py-2 text-center text-sm font-semibold text-white hover:bg-blue-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600\" disabled>\n                            <span id=\"buttonText\">Continue and create display</span>\n                        </button>\n                    </div>\n                </div>\n            </div>\n        </form>\n    </x-cards.card>\n@endsection\n\n@push('scripts')\n    <script>\n        const submitButton = document.getElementById('submitButton');\n        const buttonText = document.getElementById('buttonText');\n        const form = document.getElementById('createForm');\n        const providerInput = document.getElementById('providerInput');\n        const providerSelection = document.getElementById('providerSelection');\n        const searchMethodSelection = document.getElementById('searchMethodSelection');\n        const calendarSelection = document.getElementById('calendarSelection');\n        const outlookSelection = document.getElementById('outlookSelection');\n        const googleSelection = document.getElementById('googleSelection');\n        const caldavSelection = document.getElementById('caldavSelection');\n        const pickResource = document.getElementById('pickResource');\n        const roomMethodTile = document.querySelector('.search-method-tile[data-method=\"room\"]');\n\n        // Function to check if a calendar or room is selected\n        function checkSelection() {\n            const selectedCalendar = pickResource.querySelector('input[name=\"calendar\"]:checked');\n            const selectedRoom = pickResource.querySelector('input[name=\"room\"]:checked');\n            const selectedCalendarSelect = pickResource.querySelector('select[name=\"calendar\"]');\n            const selectedRoomSelect = pickResource.querySelector('select[name=\"room\"]');\n\n            const hasCalendarSelection = selectedCalendar || (selectedCalendarSelect && selectedCalendarSelect.value !== '');\n            const hasRoomSelection = selectedRoom || (selectedRoomSelect && selectedRoomSelect.value !== '');\n\n            submitButton.disabled = !(hasCalendarSelection || hasRoomSelection);\n        }\n\n        // Listen for changes in the pickResource div\n        const observer = new MutationObserver(checkSelection);\n        observer.observe(pickResource, {\n            childList: true,\n            subtree: true\n        });\n\n        // Also listen for changes in select elements\n        document.addEventListener('change', function(e) {\n            if (e.target.matches('select[name=\"calendar\"], select[name=\"room\"]')) {\n                checkSelection();\n            }\n        });\n\n        // Provider selection\n        document.querySelectorAll('.provider-tile').forEach(tile => {\n            tile.addEventListener('click', function() {\n                // Only allow click if the tile is not disabled\n                if (this.classList.contains('cursor-not-allowed')) {\n                    return;\n                }\n\n                // Remove selected state from all tiles\n                document.querySelectorAll('.provider-tile').forEach(t => {\n                    t.classList.remove('ring-2', 'ring-blue-500', 'ring-offset-2');\n                });\n                // Add selected state to clicked tile\n                this.classList.add('ring-2', 'ring-blue-500', 'ring-offset-2');\n\n                // Set the provider value\n                providerInput.value = this.dataset.provider;\n\n                // Show search method selection\n                searchMethodSelection.classList.remove('hidden');\n\n                // Hide calendar selection initially\n                calendarSelection.classList.add('hidden');\n\n                // Remove selected state from all tiles\n                document.querySelectorAll('.search-method-tile').forEach(t => {\n                    t.classList.remove('ring-2', 'ring-blue-500', 'ring-offset-2');\n                });\n\n                // Disable submit button when changing provider\n                submitButton.disabled = true;\n\n                // Enable/disable room option based on provider\n                if (this.dataset.provider === 'caldav') {\n                    roomMethodTile?.classList.add('opacity-50', 'cursor-not-allowed');\n                } else {\n                    roomMethodTile?.classList.remove('opacity-50', 'cursor-not-allowed');\n                }\n            });\n        });\n\n        // Search method selection\n        document.querySelectorAll('.search-method-tile').forEach(tile => {\n            tile.addEventListener('click', function() {\n                // Don't allow clicking if disabled\n                if (this.classList.contains('cursor-not-allowed')) {\n                    return;\n                }\n\n                // Remove selected state from all tiles\n                document.querySelectorAll('.search-method-tile').forEach(t => {\n                    t.classList.remove('ring-2', 'ring-blue-500', 'ring-offset-2');\n                });\n                // Add selected state to clicked tile\n                this.classList.add('ring-2', 'ring-blue-500', 'ring-offset-2');\n\n                // Clear the pickResource div\n                pickResource.innerHTML = '';\n\n                // Show calendar selection\n                calendarSelection.classList.remove('hidden');\n\n                // Show the appropriate provider selection\n                const selectedProvider = document.querySelector('.provider-tile.ring-2').dataset.provider;\n                const selectedMethod = this.dataset.method;\n\n                console.log(selectedProvider);\n                if (selectedProvider !== 'caldav') {\n                    // Update all radio buttons' hx-get URLs based on the selected method\n                    document.querySelectorAll('input[name=\"account\"]').forEach(radio => {\n                        radio.setAttribute('hx-get', selectedMethod === 'calendar'\n                            ? radio.dataset.calendarUrl\n                            : radio.dataset.roomUrl\n                        );\n                    });\n                }\n\n                htmx.process(document.body);\n\n                outlookSelection.classList.add('hidden');\n                googleSelection.classList.add('hidden');\n                caldavSelection.classList.add('hidden');\n\n                // Uncheck all radio buttons\n                document.querySelectorAll('input[name=\"account\"]').forEach(radio => {\n                    radio.checked = false;\n                });\n\n                switch(selectedProvider) {\n                    case 'outlook':\n                        outlookSelection.classList.remove('hidden');\n                        break;\n                    case 'google':\n                        googleSelection.classList.remove('hidden');\n                        break;\n                    case 'caldav':\n                        caldavSelection.classList.remove('hidden');\n                        break;\n                }\n\n                // Disable submit button when changing search method\n                submitButton.disabled = true;\n            });\n        });\n\n        submitButton.addEventListener('click', function () {\n            // Show the spinner and hide the button text\n            buttonText.innerText = 'Creating...';\n\n            // Optionally, disable the button to prevent multiple submissions\n            submitButton.disabled = true;\n\n            // Submit the form\n            form.submit();\n        });\n    </script>\n@endpush\n"
  },
  {
    "path": "backend/resources/views/pages/displays/customization.blade.php",
    "content": "@extends('layouts.base')\n@section('title', 'Display Customization - ' . $display->name)\n@section('container_class', 'max-w-2xl')\n\n@section('content')\n    @if(!auth()->user()->hasProForCurrentWorkspace())\n        <x-cards.card>\n            <div class=\"text-center py-12\">\n                <x-icons.settings class=\"h-12 w-12 text-gray-400 mx-auto mb-4\" />\n                <h2 class=\"text-lg font-semibold text-gray-900 mb-2\">Pro Feature</h2>\n                <p class=\"text-gray-600 mb-6\">Display customization is only available for Pro users. Upgrade to Pro to customize your display texts.</p>\n                <a href=\"{{ route('dashboard') }}\" class=\"inline-flex items-center rounded-md bg-oxford px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-oxford-600\">\n                    Back to Dashboard\n                </a>\n            </div>\n        </x-cards.card>\n    @else\n        <x-cards.card>\n        <div class=\"sm:flex sm:items-center mb-6\">\n            <div class=\"sm:flex-auto\">\n                <h1 class=\"text-lg font-semibold leading-6 text-gray-900\">Display Customization</h1>\n                <p class=\"mt-1 text-sm text-gray-500\">Customize the texts and privacy options for \"{{ $display->name }}\"</p>\n            </div>\n            <div class=\"mt-4 sm:ml-16 sm:mt-0 sm:flex-none\">\n                <a href=\"{{ route('dashboard') }}\" class=\"inline-flex items-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50\">\n                    <x-icons.arrow-left class=\"h-4 w-4\" />\n                    Back to Dashboard\n                </a>\n            </div>\n        </div>\n\n        {{-- Session Status Alert --}}\n        <x-alerts.alert :errors=\"$errors\" />\n\n        <form id=\"customizationForm\" action=\"{{ route('displays.customization.update', $display) }}\" method=\"POST\" enctype=\"multipart/form-data\">\n            @csrf\n            @method('PUT')\n            <div class=\"space-y-6\">\n                <div class=\"border border-gray-200 rounded-lg p-6\">\n                    <h3 class=\"text-base font-semibold text-gray-900 mb-4\">State Texts</h3>\n                    <div class=\"mb-4\">\n                        <label for=\"text_available\" class=\"block text-sm font-medium text-gray-700\">Available State Text</label>\n                        <input type=\"text\" name=\"text_available\" id=\"text_available\" maxlength=\"64\" placeholder=\"All yours!\" value=\"{{ old('text_available', \\App\\Helpers\\DisplaySettings::getAvailableText($display)) }}\" class=\"mt-1 px-3 py-2 block w-full border rounded-md border-gray-300 focus:border-blue-500 focus:ring-blue-500 sm:text-sm\" />\n                    </div>\n                    <div class=\"mb-4\">\n                        <label for=\"text_transitioning\" class=\"block text-sm font-medium text-gray-700\">Transitioning State Text</label>\n                        <input type=\"text\" name=\"text_transitioning\" id=\"text_transitioning\" maxlength=\"64\" placeholder=\"Keep it short!\" value=\"{{ old('text_transitioning', \\App\\Helpers\\DisplaySettings::getTransitioningText($display)) }}\" class=\"mt-1 px-3 py-2 block w-full border rounded-md border-gray-300 focus:border-blue-500 focus:ring-blue-500 sm:text-sm\" />\n                    </div>\n                    <div class=\"mb-4\">\n                        <label for=\"text_reserved\" class=\"block text-sm font-medium text-gray-700\">Reserved State Text</label>\n                        <input type=\"text\" name=\"text_reserved\" id=\"text_reserved\" maxlength=\"64\" placeholder=\"Meeting\" value=\"{{ old('text_reserved', \\App\\Helpers\\DisplaySettings::getReservedText($display)) }}\" class=\"mt-1 px-3 py-2 block w-full border rounded-md border-gray-300 focus:border-blue-500 focus:ring-blue-500 sm:text-sm\" />\n                    </div>\n                    <div class=\"mb-4\">\n                        <label for=\"text_checkin\" class=\"block text-sm font-medium text-gray-700\">Check-in State Text</label>\n                        <input type=\"text\" name=\"text_checkin\" id=\"text_checkin\" maxlength=\"64\" placeholder=\"Check in for meeting\" value=\"{{ old('text_checkin', \\App\\Helpers\\DisplaySettings::getCheckInText($display)) }}\" class=\"mt-1 px-3 py-2 block w-full border rounded-md border-gray-300 focus:border-blue-500 focus:ring-blue-500 sm:text-sm\" />\n                    </div>\n                </div>\n                <div class=\"border border-gray-200 rounded-lg p-6\">\n                    <h3 class=\"text-base font-semibold text-gray-900 mb-4\">Visual Customization</h3>\n                    \n                    {{-- Logo Upload --}}\n                    <div class=\"mb-6\">\n                        <label for=\"logo\" class=\"block text-sm font-medium text-gray-700 mb-2\">Display Logo</label>\n                        <div class=\"flex items-center space-x-4\">\n                            @if(\\App\\Helpers\\DisplaySettings::getLogo($display))\n                                <div class=\"flex-shrink-0\">\n                                    <img src=\"{{ route('displays.images', ['display' => $display, 'type' => 'logo']) }}?v={{ $display->updated_at->timestamp }}\" alt=\"Current logo\" class=\"h-16 w-24 object-contain border border-gray-300 rounded\">\n                                </div>\n                                <div>\n                                    <p class=\"text-sm text-gray-500\">Current logo</p>\n                                    <label class=\"inline-flex items-center text-sm text-red-600 hover:text-red-500 cursor-pointer\">\n                                        <input type=\"checkbox\" name=\"remove_logo\" value=\"1\" class=\"mr-1\">\n                                        Remove logo\n                                    </label>\n                                </div>\n                            @endif\n                        </div>\n                        <div class=\"mt-2\">\n                            <label for=\"logo\" class=\"inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-oxford hover:bg-oxford-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-oxford-500 cursor-pointer\">\n                                <svg class=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12\"></path>\n                                </svg>\n                                Choose Logo File\n                            </label>\n                            <input type=\"file\" name=\"logo\" id=\"logo\" accept=\"image/*\" class=\"hidden\">\n                            <span id=\"logo-filename\" class=\"ml-3 text-sm text-gray-500\">No file chosen</span>\n                        </div>\n                        <p class=\"mt-1 text-xs text-gray-500\">Upload a logo image (PNG, JPG, GIF). Recommended size: 200x100px or similar aspect ratio.</p>\n                    </div>\n\n                    {{-- Background Image Upload --}}\n                    <div class=\"mb-4\">\n                        <label class=\"block text-sm font-medium text-gray-700 mb-2\">Background Image</label>\n                        \n                        {{-- Current Background --}}\n                        @if(\\App\\Helpers\\DisplaySettings::getBackgroundImage($display))\n                            <div class=\"flex items-center space-x-4 mb-4\">\n                                <div class=\"flex-shrink-0\">\n                                    <img src=\"{{ route('displays.images', ['display' => $display, 'type' => 'background']) }}?v={{ $display->updated_at->timestamp }}\" alt=\"Current background\" class=\"h-16 w-24 object-cover border border-gray-300 rounded\">\n                                </div>\n                                <div>\n                                    <p class=\"text-sm text-gray-500\">Current background</p>\n                                    <label class=\"inline-flex items-center text-sm text-red-600 hover:text-red-500 cursor-pointer\">\n                                        <input type=\"checkbox\" name=\"remove_background_image\" value=\"1\" class=\"mr-1\">\n                                        Remove background\n                                    </label>\n                                </div>\n                            </div>\n                        @endif\n                        \n                        {{-- Default Backgrounds Selection --}}\n                        <div class=\"mb-4\">\n                            <p class=\"text-sm text-gray-700 mb-2\">Default backgrounds</p>\n                            <div class=\"grid grid-cols-4 gap-3\">\n                                @php\n                                    $currentBackground = \\App\\Helpers\\DisplaySettings::getBackgroundImage($display);\n                                    $isDefaultBackground = $currentBackground && isset(\\App\\Services\\ImageService::DEFAULT_BACKGROUNDS[$currentBackground]);\n                                @endphp\n                                @foreach(\\App\\Services\\ImageService::DEFAULT_BACKGROUNDS as $key => $path)\n                                    <label class=\"relative cursor-pointer group col-span-1\">\n                                        <input type=\"radio\" name=\"default_background\" value=\"{{ $key }}\" class=\"peer sr-only\" \n                                               {{ (old('default_background') === $key || ($isDefaultBackground && $currentBackground === $key)) ? 'checked' : '' }}>\n                                        <div class=\"relative h-20 rounded-lg border-2 border-gray-300 overflow-hidden transition-all peer-checked:border-oxford peer-checked:ring-2 peer-checked:ring-oxford peer-checked:ring-offset-2 hover:border-oxford-400\">\n                                            <img src=\"{{ asset($path) }}\" alt=\"Default background {{ $key }}\" class=\"w-full h-full object-cover\" onerror=\"this.src='data:image/svg+xml,%3Csvg xmlns=\\'http://www.w3.org/2000/svg\\' width=\\'200\\' height=\\'120\\'%3E%3Crect fill=\\'%23ddd\\' width=\\'200\\' height=\\'120\\'/%3E%3Ctext x=\\'50%25\\' y=\\'50%25\\' dominant-baseline=\\'middle\\' text-anchor=\\'middle\\' fill=\\'%23999\\' font-size=\\'14\\'%3EImage {{ $key }}%3C/text%3E%3C/svg%3E'\">\n                                            <div class=\"absolute inset-0 flex items-center justify-center opacity-0 peer-checked:opacity-100 transition-opacity\">\n                                                <svg class=\"w-6 h-6 text-white drop-shadow-lg\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n                                                    <path fill-rule=\"evenodd\" d=\"M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z\" clip-rule=\"evenodd\"/>\n                                                </svg>\n                                            </div>\n                                        </div>\n                                    </label>\n                                @endforeach\n                            </div>\n                        </div>\n                        \n                        {{-- Custom Upload --}}\n                        <div class=\"mt-4\">\n                            <p class=\"text-sm text-gray-700 mb-2\">Or upload custom background</p>\n                            <div>\n                                <label for=\"background_image\" class=\"inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-oxford hover:bg-oxford-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-oxford-500 cursor-pointer\">\n                                    <svg class=\"w-4 h-4 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12\"></path>\n                                    </svg>\n                                    Choose Background Image\n                                </label>\n                                <input type=\"file\" name=\"background_image\" id=\"background_image\" accept=\"image/*\" class=\"hidden\">\n                                <span id=\"background-filename\" class=\"ml-3 text-sm text-gray-500\">No file chosen</span>\n                            </div>\n                            <p class=\"mt-1 text-xs text-gray-500\">Upload a custom background image (PNG, JPG, GIF). Recommended size: 1920x1080px or similar aspect ratio.</p>\n                        </div>\n                    </div>\n                </div>\n\n                <div class=\"border border-gray-200 rounded-lg p-6\">\n                    <h3 class=\"text-base font-semibold text-gray-900 mb-4\">Typography</h3>\n                    <div class=\"mb-4\">\n                        <label for=\"font_family\" class=\"block text-sm font-medium text-gray-700 mb-2\">Font Family</label>\n                        <select name=\"font_family\" id=\"font_family\" class=\"mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm\">\n                            <option value=\"Inter\" {{ old('font_family', \\App\\Helpers\\DisplaySettings::getFontFamily($display)) === 'Inter' ? 'selected' : '' }}>Inter</option>\n                            <option value=\"Roboto\" {{ old('font_family', \\App\\Helpers\\DisplaySettings::getFontFamily($display)) === 'Roboto' ? 'selected' : '' }}>Roboto</option>\n                            <option value=\"Open Sans\" {{ old('font_family', \\App\\Helpers\\DisplaySettings::getFontFamily($display)) === 'Open Sans' ? 'selected' : '' }}>Open Sans</option>\n                            <option value=\"Lato\" {{ old('font_family', \\App\\Helpers\\DisplaySettings::getFontFamily($display)) === 'Lato' ? 'selected' : '' }}>Lato</option>\n                            <option value=\"Poppins\" {{ old('font_family', \\App\\Helpers\\DisplaySettings::getFontFamily($display)) === 'Poppins' ? 'selected' : '' }}>Poppins</option>\n                            <option value=\"Montserrat\" {{ old('font_family', \\App\\Helpers\\DisplaySettings::getFontFamily($display)) === 'Montserrat' ? 'selected' : '' }}>Montserrat</option>\n                        </select>\n                        <p class=\"mt-1 text-xs text-gray-500\">Choose a font family for the display text. Changes will be applied immediately.</p>\n                    </div>\n                </div>\n\n                <div class=\"border border-gray-200 rounded-lg p-6\">\n                    <h3 class=\"text-base font-semibold text-gray-900 mb-4\">Privacy</h3>\n                    <div class=\"flex items-center\">\n                        <input type=\"checkbox\" id=\"show_meeting_title\" name=\"show_meeting_title\" value=\"1\" {{ old('show_meeting_title', \\App\\Helpers\\DisplaySettings::getShowMeetingTitle($display)) ? 'checked' : '' }} class=\"h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600\">\n                        <label for=\"show_meeting_title\" class=\"ml-2 block text-sm text-gray-700\">Show meeting title on display</label>\n                    </div>\n                    <p class=\"mt-1 text-xs text-gray-500\">If unchecked, meeting titles will be hidden for privacy-sensitive environments.</p>\n                </div>\n            </div>\n            <div class=\"mt-8 flex justify-end gap-x-3\">\n                <a href=\"{{ route('dashboard') }}\" class=\"rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50\">\n                    Cancel\n                </a>\n                <button type=\"submit\" class=\"rounded-md bg-oxford px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-oxford-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-oxford-600\">\n                    Save Customization\n                </button>\n            </div>\n        </form>\n    </x-cards.card>\n    @endif\n\n    <script>\n        document.addEventListener('DOMContentLoaded', function() {\n            // Handle logo file selection\n            const logoInput = document.getElementById('logo');\n            const logoFilename = document.getElementById('logo-filename');\n            \n            if (logoInput && logoFilename) {\n                logoInput.addEventListener('change', function(e) {\n                    const file = e.target.files[0];\n                    if (file) {\n                        logoFilename.textContent = file.name;\n                    } else {\n                        logoFilename.textContent = 'No file chosen';\n                    }\n                });\n            }\n\n            // Handle background image file selection\n            const backgroundInput = document.getElementById('background_image');\n            const backgroundFilename = document.getElementById('background-filename');\n            \n            if (backgroundInput && backgroundFilename) {\n                backgroundInput.addEventListener('change', function(e) {\n                    const file = e.target.files[0];\n                    if (file) {\n                        backgroundFilename.textContent = file.name;\n                    } else {\n                        backgroundFilename.textContent = 'No file chosen';\n                    }\n                });\n            }\n        });\n    </script>\n@endsection\n"
  },
  {
    "path": "backend/resources/views/pages/displays/settings.blade.php",
    "content": "@extends('layouts.base')\n@section('title', 'Display Settings - ' . $display->name)\n@section('container_class', 'max-w-2xl')\n\n@section('content')\n    @if(!auth()->user()->hasProForCurrentWorkspace())\n        <x-cards.card>\n            <div class=\"text-center py-12\">\n                <x-icons.settings class=\"h-12 w-12 text-gray-400 mx-auto mb-4\" />\n                <h2 class=\"text-lg font-semibold text-gray-900 mb-2\">Pro Feature</h2>\n                <p class=\"text-gray-600 mb-6\">Display settings are only available for Pro users. Upgrade to Pro to customize your display settings.</p>\n                <a href=\"{{ route('dashboard') }}\" class=\"inline-flex items-center rounded-md bg-oxford px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-oxford-600\">\n                    Back to Dashboard\n                </a>\n            </div>\n        </x-cards.card>\n    @else\n        <x-cards.card>\n        <div class=\"sm:flex sm:items-center mb-6\">\n            <div class=\"sm:flex-auto\">\n                <h1 class=\"text-lg font-semibold leading-6 text-gray-900\">Display Settings</h1>\n                <p class=\"mt-1 text-sm text-gray-500\">Configure settings for \"{{ $display->name }}\"</p>\n            </div>\n            <div class=\"mt-4 sm:ml-16 sm:mt-0 sm:flex-none\">\n                <a href=\"{{ route('dashboard') }}\" class=\"inline-flex items-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50\">\n                    <x-icons.arrow-left class=\"h-4 w-4\" />\n                    Back to Dashboard\n                </a>\n            </div>\n        </div>\n\n        {{-- Session Status Alert --}}\n        <x-alerts.alert :errors=\"$errors\" />\n\n        {{-- Pro Features Notice --}}\n        <div class=\"mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg\">\n            <div class=\"flex\">\n                <div class=\"flex-shrink-0\">\n                    <x-icons.information class=\"h-5 w-5 text-blue-400\" />\n                </div>\n                <div class=\"ml-3\">\n                    <h3 class=\"text-sm font-medium text-blue-800\">Pro Features</h3>\n                    <div class=\"mt-2 text-sm text-blue-700\">\n                        <p>Display settings are Pro features that allow you to customize how users interact with your displays. These settings control check-in and booking functionality.</p>\n                    </div>\n                </div>\n            </div>\n        </div>\n\n        <form id=\"settingsForm\" action=\"{{ route('displays.settings.update', $display) }}\" method=\"POST\">\n            @csrf\n            @method('PUT')\n            \n            <div class=\"space-y-6\">\n                <!-- Check-in Settings -->\n                <div class=\"border border-gray-200 rounded-lg p-6\">\n                    <div class=\"flex items-center justify-between mb-4\">\n                        <div>\n                            <h3 class=\"text-base font-semibold text-gray-900\">Check-in Settings</h3>\n                            <p class=\"text-sm text-gray-500\">Allow users to check in to meetings on this display</p>\n                        </div>\n                        <div class=\"flex items-center\">\n                            <input type=\"checkbox\" id=\"check_in_enabled\" name=\"check_in_enabled\" value=\"1\" \n                                   {{ $display->isCheckInEnabled() ? 'checked' : '' }}\n                                   class=\"h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600\">\n                        </div>\n                    </div>\n                    <div class=\"text-sm text-gray-600\">\n                        <p>When enabled, users can check in to meetings directly from this display. This feature allows attendees to mark their attendance for meetings.</p>\n                    </div>\n                    @php $checkInMinutes = $display->getCheckInMinutes(); @endphp\n                    <div id=\"checkInMinutesInput\" class=\"mt-4\" style=\"display: {{ $display->isCheckInEnabled() ? 'block' : 'none' }};\">\n                        <label for=\"check_in_minutes\" class=\"block text-sm font-medium text-gray-700\">Check-in Minutes (before meeting)</label>\n                        <input type=\"number\" min=\"1\" max=\"60\" name=\"check_in_minutes\" id=\"check_in_minutes\" value=\"{{ $checkInMinutes }}\" class=\"mt-1 px-3 py-2 block w-32 border rounded-md border-gray-300 focus:border-blue-500 focus:ring-blue-500 sm:text-sm\" />\n                        <p class=\"mt-1 text-xs text-gray-500\">How many minutes before the meeting users can check in. Default: 15 minutes.</p>\n                    </div>\n                    @php $gracePeriod = $display->getCheckInGracePeriod(); @endphp\n                    <div id=\"gracePeriodInput\" class=\"mt-4\" style=\"display: {{ $display->isCheckInEnabled() ? 'block' : 'none' }};\">\n                        <label for=\"check_in_grace_period\" class=\"block text-sm font-medium text-gray-700\">Check-in Grace Period (minutes)</label>\n                        <input type=\"number\" min=\"1\" max=\"30\" name=\"check_in_grace_period\" id=\"check_in_grace_period\" value=\"{{ $gracePeriod }}\" class=\"mt-1 px-3 py-2 block w-32 border rounded-md border-gray-300 focus:border-blue-500 focus:ring-blue-500 sm:text-sm\" />\n                        <p class=\"mt-1 text-xs text-gray-500\">How many minutes after the meeting starts users can still check in. Default: 15 minutes.</p>\n                    </div>\n                </div>\n\n                <!-- Booking Settings -->\n                <div class=\"border border-gray-200 rounded-lg p-6\">\n                    <div class=\"flex items-center justify-between mb-4\">\n                        <div>\n                            <h3 class=\"text-base font-semibold text-gray-900\">Booking Settings</h3>\n                            <p class=\"text-sm text-gray-500\">Allow users to book rooms directly from this display</p>\n                        </div>\n                        <div class=\"flex items-center\">\n                            <input type=\"checkbox\" id=\"booking_enabled\" name=\"booking_enabled\" value=\"1\" \n                                   {{ $display->isBookingEnabled() ? 'checked' : '' }}\n                                   class=\"h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600\">\n                        </div>\n                    </div>\n                    <div class=\"text-sm text-gray-600\">\n                        <p>When enabled, users can book the room for immediate use directly from this display. This is a Pro feature that allows quick room reservations.</p>\n                    </div>\n                </div>\n\n                <!-- Calendar Settings -->\n                <div class=\"border border-gray-200 rounded-lg p-6\">\n                    <div class=\"flex items-center justify-between mb-4\">\n                        <div>\n                            <h3 class=\"text-base font-semibold text-gray-900\">Calendar Settings</h3>\n                            <p class=\"text-sm text-gray-500\">Allow users to view today's schedule on this display</p>\n                        </div>\n                        <div class=\"flex items-center\">\n                            <input type=\"checkbox\" id=\"calendar_enabled\" name=\"calendar_enabled\" value=\"1\" \n                                   {{ $display->isCalendarEnabled() ? 'checked' : '' }}\n                                   class=\"h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600\">\n                        </div>\n                    </div>\n                    <div class=\"text-sm text-gray-600\">\n                        <p>When enabled, users can view today's schedule in a calendar view directly from this display. This allows users to see all meetings scheduled for the day.</p>\n                    </div>\n                </div>\n\n                <!-- Admin Actions Settings -->\n                <div class=\"border border-gray-200 rounded-lg p-6\">\n                    <div class=\"flex items-center justify-between mb-4\">\n                        <div>\n                            <h3 class=\"text-base font-semibold text-gray-900\">Admin Actions</h3>\n                            <p class=\"text-sm text-gray-500\">Hide administrative actions (like switch room and logout) on this display</p>\n                        </div>\n                        <div class=\"flex items-center\">\n                            <input type=\"checkbox\" id=\"hide_admin_actions\" name=\"hide_admin_actions\" value=\"1\" \n                                   {{ $display->isAdminActionsHidden() ? 'checked' : '' }}\n                                   class=\"h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600\">\n                        </div>\n                    </div>\n                    <div class=\"text-sm text-gray-600\">\n                        <p>When enabled, administrative actions such as the switch room button will be hidden on this display. This is useful for public-facing displays where you don't want users to be able to switch rooms.</p>\n                        <p class=\"mt-2\"><strong>Hidden Access:</strong> When admin actions are hidden, administrators can still access them by long-pressing the room name in the top-right corner. The admin actions will appear temporarily for 30 seconds, then automatically hide again.</p>\n                    </div>\n                </div>\n\n                <!-- Cancel Permission Settings -->\n                <div class=\"border border-gray-200 rounded-lg p-6\">\n                    <div class=\"mb-4\">\n                        <h3 class=\"text-base font-semibold text-gray-900\">Cancel Permission</h3>\n                        <p class=\"text-sm text-gray-500\">Control which events can be cancelled via the tablet</p>\n                    </div>\n                    <div class=\"text-sm text-gray-600 mb-4\">\n                        <p>Choose which events users can cancel directly from this display.</p>\n                    </div>\n                    <div class=\"space-y-2\">\n                        <div class=\"flex items-center\">\n                            <input type=\"radio\" id=\"cancel_permission_all\" name=\"cancel_permission\" value=\"all\" \n                                   {{ \\App\\Helpers\\DisplaySettings::getCancelPermission($display) === 'all' ? 'checked' : '' }}\n                                   class=\"h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-600\">\n                            <label for=\"cancel_permission_all\" class=\"ml-2 text-sm text-gray-700\">\n                                <strong>All events</strong> - Users can cancel any event (default)\n                            </label>\n                        </div>\n                        <div class=\"flex items-center\">\n                            <input type=\"radio\" id=\"cancel_permission_tablet_only\" name=\"cancel_permission\" value=\"tablet_only\" \n                                   {{ \\App\\Helpers\\DisplaySettings::getCancelPermission($display) === 'tablet_only' ? 'checked' : '' }}\n                                   class=\"h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-600\">\n                            <label for=\"cancel_permission_tablet_only\" class=\"ml-2 text-sm text-gray-700\">\n                                <strong>Tablet bookings only</strong> - Users can only cancel events booked via this tablet\n                            </label>\n                        </div>\n                        <div class=\"flex items-center\">\n                            <input type=\"radio\" id=\"cancel_permission_none\" name=\"cancel_permission\" value=\"none\" \n                                   {{ \\App\\Helpers\\DisplaySettings::getCancelPermission($display) === 'none' ? 'checked' : '' }}\n                                   class=\"h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-600\">\n                            <label for=\"cancel_permission_none\" class=\"ml-2 text-sm text-gray-700\">\n                                <strong>None</strong> - Users cannot cancel any events via this tablet\n                            </label>\n                        </div>\n                    </div>\n                </div>\n\n                <!-- Border Thickness Settings -->\n                <div class=\"border border-gray-200 rounded-lg p-6\">\n                    <div class=\"mb-4\">\n                        <h3 class=\"text-base font-semibold text-gray-900\">Border Thickness</h3>\n                        <p class=\"text-sm text-gray-500\">Control the thickness of borders and panels on this display</p>\n                    </div>\n                    <div class=\"text-sm text-gray-600 mb-4\">\n                        <p>Adjust the visual thickness of borders and panels to match your design preferences.</p>\n                    </div>\n                    <div class=\"space-y-2\">\n                        <div class=\"flex items-center\">\n                            <input type=\"radio\" id=\"border_thickness_small\" name=\"border_thickness\" value=\"small\" \n                                   {{ \\App\\Helpers\\DisplaySettings::getBorderThickness($display) === 'small' ? 'checked' : '' }}\n                                   class=\"h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-600\">\n                            <label for=\"border_thickness_small\" class=\"ml-2 text-sm text-gray-700\">\n                                <strong>Small</strong> - Thin borders for a minimalist look\n                            </label>\n                        </div>\n                        <div class=\"flex items-center\">\n                            <input type=\"radio\" id=\"border_thickness_medium\" name=\"border_thickness\" value=\"medium\" \n                                   {{ \\App\\Helpers\\DisplaySettings::getBorderThickness($display) === 'medium' ? 'checked' : '' }}\n                                   class=\"h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-600\">\n                            <label for=\"border_thickness_medium\" class=\"ml-2 text-sm text-gray-700\">\n                                <strong>Medium</strong> - Standard border thickness (default)\n                            </label>\n                        </div>\n                        <div class=\"flex items-center\">\n                            <input type=\"radio\" id=\"border_thickness_large\" name=\"border_thickness\" value=\"large\" \n                                   {{ \\App\\Helpers\\DisplaySettings::getBorderThickness($display) === 'large' ? 'checked' : '' }}\n                                   class=\"h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-600\">\n                            <label for=\"border_thickness_large\" class=\"ml-2 text-sm text-gray-700\">\n                                <strong>Large</strong> - Thick borders for better visibility\n                            </label>\n                        </div>\n                    </div>\n                </div>\n\n                <!-- Display Information -->\n                <div class=\"border border-gray-200 rounded-lg p-6 bg-gray-50\">\n                    <h3 class=\"text-base font-semibold text-gray-900 mb-4\">Display Information</h3>\n                    <dl class=\"grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2\">\n                        <div>\n                            <dt class=\"text-sm font-medium text-gray-500\">Display Name</dt>\n                            <dd class=\"text-sm text-gray-900\">{{ $display->display_name }}</dd>\n                        </div>\n                        <div>\n                            <dt class=\"text-sm font-medium text-gray-500\">Calendar</dt>\n                            <dd class=\"text-sm text-gray-900\">{{ $display->calendar->name }}</dd>\n                        </div>\n                        <div>\n                            <dt class=\"text-sm font-medium text-gray-500\">Status</dt>\n                            <dd class=\"text-sm\">\n                                @if($display->status === \\App\\Enums\\DisplayStatus::ACTIVE)\n                                    <span class=\"inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20\">Active</span>\n                                @else\n                                    <span class=\"inline-flex items-center rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10\">Inactive</span>\n                                @endif\n                            </dd>\n                        </div>\n                        <div>\n                            <dt class=\"text-sm font-medium text-gray-500\">Last Sync</dt>\n                            <dd class=\"text-sm text-gray-900\">\n                                @if($display->last_sync_at)\n                                    {{ $display->last_sync_at->diffForHumans() }}\n                                @else\n                                    Never\n                                @endif\n                            </dd>\n                        </div>\n                    </dl>\n                </div>\n            </div>\n\n            <div class=\"mt-8 flex justify-end gap-x-3\">\n                <a href=\"{{ route('dashboard') }}\" class=\"rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50\">\n                    Cancel\n                </a>\n                <button type=\"submit\" class=\"rounded-md bg-oxford px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-oxford-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-oxford-600\">\n                    Save Settings\n                </button>\n            </div>\n        </form>\n    </x-cards.card>\n    @endif\n@endsection\n\n@push('scripts')\n<script>\n    // Add any JavaScript for form handling if needed\n    document.getElementById('settingsForm').addEventListener('submit', function(e) {\n        // Form will be submitted normally, but we could add validation here if needed\n    });\n    // Show/hide grace period input based on check-in enabled\n    document.getElementById('check_in_enabled').addEventListener('change', function(e) {\n        document.getElementById('gracePeriodInput').style.display = this.checked ? 'block' : 'none';\n        document.getElementById('checkInMinutesInput').style.display = this.checked ? 'block' : 'none';\n    });\n</script>\n@endpush "
  },
  {
    "path": "backend/resources/views/pages/onboarding.blade.php",
    "content": "@extends('layouts.blank')\n@section('title', 'Welcome, ' . auth()->user()->name . '!')\n@section('page')\n\n    <div class=\"relative\">\n        <div class=\"absolute top-4 right-4\">\n            <form action=\"{{ route('logout') }}\" method=\"POST\">\n                @csrf\n                <button type=\"submit\"\n                    class=\"inline-flex items-center gap-x-2 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50\">\n                    <x-icons.logout class=\"h-5 w-5 text-gray-400\" />\n                    Logout\n                </button>\n            </form>\n        </div>\n\n        <div class=\"flex min-h-full flex-col justify-center py-24 sm:px-6 lg:px-8\">\n            <div class=\"sm:mx-auto sm:w-full sm:max-w-md\">\n                <div class=\"flex justify-center\">\n                    <img class=\"h-12 w-auto mb-4\" src=\"/images/logo-black.svg\" alt=\"Logo\">\n                </div>\n                <h2 class=\"mt-6 text-center text-2xl/9 font-bold tracking-tight text-gray-900\">Let's get you set up! 🥳</h2>\n                <p class=\"mt-2 text-center text-lg text-gray-500\">We'll walk you through setting up a display<br> in just a\n                    few minutes</p>\n            </div>\n\n            <x-cards.card class=\"mt-10 mx-auto w-full max-w-xl\">\n                <div class=\"p-8\">\n                    <x-alerts.alert />\n\n                    @if(!$hasUsageType)\n                        <div class=\"min-w-0 text-center\">\n                            <div class=\"flex items-center justify-center gap-x-3\">\n                                <p class=\"text-lg font-semibold leading-6 text-gray-900\">How will you use Spacepad?</p>\n                            </div>\n                            <div\n                                class=\"mt-3 flex items-center justify-center gap-x-2 text-md leading-5 text-gray-500 max-w-md mx-auto\">\n                                <p class=\"break-words leading-6\">This helps us understand our user base and provide appropriate\n                                    features and pricing.</p>\n                            </div>\n                        </div>\n                        <form action=\"{{ route('onboarding.usage-type') }}\" method=\"POST\" class=\"mt-8 space-y-4\">\n                            @csrf\n                            <div class=\"grid grid-cols-1 gap-4\">\n                                <button type=\"submit\" name=\"usage_type\" value=\"business\"\n                                    class=\"flex items-center justify-center gap-3 rounded-lg border border-gray-200 bg-white p-4 shadow-sm hover:border-blue-500 hover:shadow-md transition-all duration-200 cursor-pointer\">\n                                    <x-icons.building class=\"h-6 w-6\" />\n                                    <span class=\"font-medium text-gray-900\">I am using this for a business or\n                                        organization</span>\n                                </button>\n                                <button type=\"submit\" name=\"usage_type\" value=\"personal\"\n                                    class=\"flex items-center justify-center gap-3 rounded-lg border border-gray-200 bg-white p-4 shadow-sm hover:border-blue-500 hover:shadow-md transition-all duration-200 cursor-pointer\">\n                                    <x-icons.users class=\"h-6 w-6\" />\n                                    <span class=\"font-medium text-gray-900\">I am a hobbyist / personal user</span>\n                                </button>\n                            </div>\n                        </form>\n                    @elseif(!$hasAcceptedTerms)\n                        <div class=\"min-w-0 text-center\">\n                            <div class=\"flex items-center justify-center gap-x-3\">\n                                <p class=\"text-lg font-semibold leading-6 text-gray-900\">Self-hosted License Agreement</p>\n                            </div>\n                            <div\n                                class=\"mt-3 flex flex-col items-center justify-center gap-x-2 text-md leading-5 text-gray-500 max-w-md mx-auto\">\n                                <p class=\"break-words leading-6 mb-3\">\n                                    In order to be the perfect all-encompassing room display solution for SMB's it is mandatory\n                                    Spacepad is sustainable.\n                                </p>\n                                <p class=\"break-words leading-6\">\n                                    That's why as a self-hosted user, we need your agreement on two important points:\n                                </p>\n                            </div>\n                            <div class=\"mt-6 text-left space-y-4\">\n                                <div class=\"bg-gray-50 p-4 rounded-lg\">\n                                    <h4 class=\"font-medium text-gray-900 mb-2\">1. Fair Use</h4>\n                                    <p class=\"text-gray-600\">We collect your email address to verify licensing and ensure fair\n                                        use. This is not shared externally and is only used for administrative purposes.</p>\n                                </div>\n                                <div class=\"bg-gray-50 p-4 rounded-lg\">\n                                    <h4 class=\"font-medium text-gray-900 mb-2\">2. License Agreement</h4>\n                                    <p class=\"text-gray-600\">By using Spacepad, you agree to our <a\n                                            class=\"text-blue-500 underline\"\n                                            href=\"https://github.com/magweter/spacepad?tab=readme-ov-file#license\"\n                                            target=\"_blank\">licensing terms</a>. Personal users get full access, while business\n                                        users need a valid license key for multiple displays and Pro features.</p>\n                                </div>\n                            </div>\n                        </div>\n                        <form action=\"{{ route('onboarding.terms') }}\" method=\"POST\" class=\"mt-8\">\n                            @csrf\n                            <div class=\"flex items-center justify-center\">\n                                <button type=\"submit\"\n                                    class=\"inline-flex items-center rounded-md bg-oxford px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600\">\n                                    I understand and agree\n                                </button>\n                            </div>\n                        </form>\n                    @elseif(!$hasAnyAccount)\n                        <div class=\"min-w-0 text-center\">\n                            <div class=\"flex items-center justify-center gap-x-3\">\n                                <p class=\"text-lg font-semibold leading-6 text-gray-900\">Connect your first account</p>\n                            </div>\n                            <div\n                                class=\"mt-3 flex items-center justify-center gap-x-2 text-md leading-5 text-gray-500 max-w-md mx-auto\">\n                                <p class=\"break-words leading-6\">You'll be able to connect multiple accounts from different\n                                    providers and display events from the calendars and rooms of the account.</p>\n                            </div>\n                        </div>\n                        <div class=\"flex flex-col gap-y-4 mt-8 w-full\">\n                            @if(config('services.microsoft.enabled'))\n                                <button type=\"button\"\n                                    onclick=\"window.dispatchEvent(new CustomEvent('open-permission-modal', { detail: { provider: 'outlook' } }))\"\n                                    class=\"flex items-center justify-center gap-3 rounded-lg border border-gray-200 bg-white p-4 shadow-sm hover:border-blue-500 hover:shadow-md transition-all duration-200\">\n                                    <x-icons.microsoft class=\"h-6 w-6\" />\n                                    <span class=\"font-medium text-gray-900\">Connect a Microsoft account</span>\n                                </button>\n                            @endif\n\n                            @if(config('services.google.enabled'))\n                                <button type=\"button\"\n                                    onclick=\"window.dispatchEvent(new CustomEvent('open-permission-modal', { detail: { provider: 'google' } }))\"\n                                    class=\"flex items-center justify-center gap-3 rounded-lg border border-gray-200 bg-white p-4 shadow-sm hover:border-blue-500 hover:shadow-md transition-all duration-200\">\n                                    <x-icons.google class=\"h-6 w-6\" />\n                                    <span class=\"font-medium text-gray-900\">Connect a Google account</span>\n                                </button>\n                            @endif\n\n                            @if(config('services.caldav.enabled'))\n                                <a href=\"{{ route('caldav-accounts.create') }}\"\n                                    class=\"flex items-center justify-center gap-3 rounded-lg border border-gray-200 bg-white p-4 shadow-sm hover:border-blue-500 hover:shadow-md transition-all duration-200\">\n                                    <x-icons.calendar class=\"h-6 w-6 text-gray-600\" />\n                                    <span class=\"font-medium text-gray-900\">Connect to a CalDAV server</span>\n                                </a>\n                            @endif\n                        </div>\n                    @endif\n                </div>\n            </x-cards.card>\n        </div>\n    </div>\n@endsection\n\n@push('modals')\n    <x-modals.select-permission provider=\"outlook\" />\n    <x-modals.select-permission provider=\"google\" />\n    <x-modals.select-google-booking-method />\n    <x-modals.google-service-account />\n@endpush\n\n@push('scripts')\n    <script>\n        // Show service account modal if needed\n        @if(session('open-service-account-modal'))\n            window.addEventListener('DOMContentLoaded', function() {\n                window.dispatchEvent(new CustomEvent('open-service-account-modal', {\n                    detail: { googleAccountId: '{{ session('open-service-account-modal') }}' }\n                }));\n            });\n        @endif\n\n        // Show booking method modal if needed (after write permission selection)\n        // Show booking method modal if needed (after connecting Google Workspace account with write permission)\n        @if(session('open-google-booking-method-modal'))\n            window.addEventListener('DOMContentLoaded', function() {\n                window.dispatchEvent(new CustomEvent('open-google-booking-method-modal', {\n                    detail: '{{ session('open-google-booking-method-modal') }}'\n                }));\n            });\n        @endif\n    </script>\n@endpush"
  },
  {
    "path": "backend/resources/views/pages/usage/index.blade.php",
    "content": "@extends('layouts.base')\n@section('title', 'Usage & Billing')\n\n@section('content')\n    <x-cards.card>\n        <div class=\"space-y-6\">\n            {{-- Usage Breakdown --}}\n            <div>\n                <h2 class=\"text-lg font-semibold text-gray-900 mb-4\">Current Usage</h2>\n                <div class=\"bg-gray-50 rounded-lg p-6\">\n                    <dl class=\"grid grid-cols-1 gap-6 sm:grid-cols-3\">\n                        <div>\n                            <dt class=\"text-sm font-medium text-gray-500\">Displays</dt>\n                            <dd class=\"mt-1 text-3xl font-semibold text-gray-900\">{{ $usageBreakdown['displays'] }}</dd>\n                        </div>\n                        <div>\n                            <dt class=\"text-sm font-medium text-gray-500\">Boards</dt>\n                            <dd class=\"mt-1 text-3xl font-semibold text-gray-900\">{{ $usageBreakdown['boards'] }}</dd>\n                        </div>\n                        <div>\n                            <dt class=\"text-sm font-medium text-gray-500\">Total Usage</dt>\n                            <dd class=\"mt-1 text-3xl font-semibold text-gray-900\">{{ $usageBreakdown['total'] }}</dd>\n                        </div>\n                    </dl>\n                </div>\n            </div>\n\n            {{-- Breakdown Details --}}\n            <div>\n                <h2 class=\"text-lg font-semibold text-gray-900 mb-4\">Usage Breakdown</h2>\n                <div class=\"space-y-4\">\n                    <div class=\"flex items-center justify-between p-4 bg-white border border-gray-200 rounded-lg\">\n                        <div>\n                            <div class=\"text-sm font-medium text-gray-900\">Displays</div>\n                            <div class=\"text-sm text-gray-500\">{{ $usageBreakdown['displays'] }} active display(s)</div>\n                        </div>\n                        <div class=\"text-lg font-semibold text-gray-900\">{{ $usageBreakdown['displays'] }} unit(s)</div>\n                    </div>\n                    <div class=\"flex items-center justify-between p-4 bg-white border border-gray-200 rounded-lg\">\n                        <div>\n                            <div class=\"text-sm font-medium text-gray-900\">Boards</div>\n                            <div class=\"text-sm text-gray-500\">{{ $usageBreakdown['boards'] }} board(s) × 2</div>\n                        </div>\n                        <div class=\"text-lg font-semibold text-gray-900\">{{ $usageBreakdown['board_usage'] }} unit(s)</div>\n                    </div>\n                    <div class=\"flex items-center justify-between p-4 bg-blue-50 border border-blue-200 rounded-lg\">\n                        <div>\n                            <div class=\"text-sm font-medium text-blue-900\">Total Usage</div>\n                            <div class=\"text-sm text-blue-700\">Billed to your subscription</div>\n                        </div>\n                        <div class=\"text-2xl font-bold text-blue-900\">{{ $usageBreakdown['total'] }} unit(s)</div>\n                    </div>\n                </div>\n            </div>\n\n            {{-- What Counts Towards Usage --}}\n            <div>\n                <h2 class=\"text-lg font-semibold text-gray-900 mb-4\">What counts towards usage?</h2>\n                <div class=\"space-y-3\">\n                    <div class=\"flex items-start gap-3\">\n                        <div class=\"flex-shrink-0\">\n                            <div class=\"flex h-8 w-8 items-center justify-center rounded-full bg-green-100\">\n                                <svg class=\"h-5 w-5 text-green-600\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z\"></path>\n                                </svg>\n                            </div>\n                        </div>\n                        <div>\n                            <div class=\"text-sm font-medium text-gray-900\">Displays</div>\n                            <div class=\"text-sm text-gray-600\">Each active display counts as <strong>1 usage unit</strong>. Every display in your workspace is included in your usage count.</div>\n                        </div>\n                    </div>\n                    <div class=\"flex items-start gap-3\">\n                        <div class=\"flex-shrink-0\">\n                            <div class=\"flex h-8 w-8 items-center justify-center rounded-full bg-blue-100\">\n                                <svg class=\"h-5 w-5 text-blue-600\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z\"></path>\n                                </svg>\n                            </div>\n                        </div>\n                        <div>\n                            <div class=\"text-sm font-medium text-gray-900\">Boards</div>\n                            <div class=\"text-sm text-gray-600\">Each board counts as <strong>2 usage units</strong>. This pricing model ensures fairness for users who don't use boards, keeping the base product accessible.</div>\n                        </div>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </x-cards.card>\n@endsection\n"
  },
  {
    "path": "backend/resources/views/vendor/googletagmanager/body.blade.php",
    "content": "<?php\n/**\n * @var bool $enabled\n * @var string $id\n * @var string $domain\n * @var \\Spatie\\GoogleTagManager\\DataLayer $dataLayer\n * @var iterable<\\Spatie\\GoogleTagManager\\DataLayer> $pushData\n */\n?>\n@if($enabled)\n    <script>\n        function gtmPush() {\n            @foreach($pushData as $item)\n            window.dataLayer.push({!! json_encode($item->toArray(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) !!});\n            @endforeach\n        }\n        addEventListener(\"load\", gtmPush);\n    </script>\n    <noscript>\n        <iframe\n            src=\"https://{{ $domain }}/ns.html?id={{ $id }}\"\n            height=\"0\"\n            width=\"0\"\n            style=\"display:none;visibility:hidden\"\n        ></iframe>\n    </noscript>\n@endif\n\n"
  },
  {
    "path": "backend/resources/views/vendor/pagination/tailwind.blade.php",
    "content": "@if ($paginator->hasPages())\n    <nav role=\"navigation\" aria-label=\"Pagination Navigation\" class=\"flex items-center justify-between\">\n        <div class=\"flex justify-between flex-1 sm:hidden\">\n            @if ($paginator->onFirstPage())\n                <span class=\"relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default leading-5 rounded-md\">\n                    {!! __('pagination.previous') !!}\n                </span>\n            @else\n                <a href=\"{{ $paginator->previousPageUrl() }}\" class=\"relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150\">\n                    {!! __('pagination.previous') !!}\n                </a>\n            @endif\n\n            @if ($paginator->hasMorePages())\n                <a href=\"{{ $paginator->nextPageUrl() }}\" class=\"relative inline-flex items-center px-4 py-2 ml-3 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150\">\n                    {!! __('pagination.next') !!}\n                </a>\n            @else\n                <span class=\"relative inline-flex items-center px-4 py-2 ml-3 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default leading-5 rounded-md\">\n                    {!! __('pagination.next') !!}\n                </span>\n            @endif\n        </div>\n\n        <div class=\"hidden sm:flex-1 sm:flex sm:items-center sm:justify-between\">\n            <div>\n                <p class=\"text-sm text-gray-700 leading-5\">\n                    {!! __('Showing') !!}\n                    @if ($paginator->firstItem())\n                        <span class=\"font-medium\">{{ $paginator->firstItem() }}</span>\n                        {!! __('to') !!}\n                        <span class=\"font-medium\">{{ $paginator->lastItem() }}</span>\n                    @else\n                        {{ $paginator->count() }}\n                    @endif\n                    {!! __('of') !!}\n                    <span class=\"font-medium\">{{ $paginator->total() }}</span>\n                    {!! __('results') !!}\n                </p>\n            </div>\n\n            <div>\n                <span class=\"relative z-0 inline-flex shadow-sm rounded-md\">\n                    {{-- Previous Page Link --}}\n                    @if ($paginator->onFirstPage())\n                        <span aria-disabled=\"true\" aria-label=\"{{ __('pagination.previous') }}\">\n                            <span class=\"relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default rounded-l-md leading-5\" aria-hidden=\"true\">\n                                <svg class=\"w-5 h-5\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n                                    <path fill-rule=\"evenodd\" d=\"M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z\" clip-rule=\"evenodd\" />\n                                </svg>\n                            </span>\n                        </span>\n                    @else\n                        <a href=\"{{ $paginator->previousPageUrl() }}\" rel=\"prev\" class=\"relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-l-md leading-5 hover:text-gray-400 focus:z-10 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-500 transition ease-in-out duration-150\" aria-label=\"{{ __('pagination.previous') }}\">\n                            <svg class=\"w-5 h-5\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n                                <path fill-rule=\"evenodd\" d=\"M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z\" clip-rule=\"evenodd\" />\n                            </svg>\n                        </a>\n                    @endif\n\n                    {{-- Pagination Elements --}}\n                    @foreach ($elements as $element)\n                        {{-- \"Three Dots\" Separator --}}\n                        @if (is_string($element))\n                            <span aria-disabled=\"true\">\n                                <span class=\"relative inline-flex items-center px-4 py-2 -ml-px text-sm font-medium text-gray-700 bg-white border border-gray-300 cursor-default leading-5\">{{ $element }}</span>\n                            </span>\n                        @endif\n\n                        {{-- Array Of Links --}}\n                        @if (is_array($element))\n                            @foreach ($element as $page => $url)\n                                @if ($page == $paginator->currentPage())\n                                    <span aria-current=\"page\">\n                                        <span class=\"relative inline-flex items-center px-4 py-2 -ml-px text-sm font-medium text-blue-600 bg-blue-50 border border-blue-500 cursor-default leading-5\">{{ $page }}</span>\n                                    </span>\n                                @else\n                                    <a href=\"{{ $url }}\" class=\"relative inline-flex items-center px-4 py-2 -ml-px text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 hover:text-gray-500 focus:z-10 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150\" aria-label=\"{{ __('Go to page :page', ['page' => $page]) }}\">\n                                        {{ $page }}\n                                    </a>\n                                @endif\n                            @endforeach\n                        @endif\n                    @endforeach\n\n                    {{-- Next Page Link --}}\n                    @if ($paginator->hasMorePages())\n                        <a href=\"{{ $paginator->nextPageUrl() }}\" rel=\"next\" class=\"relative inline-flex items-center px-2 py-2 -ml-px text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-r-md leading-5 hover:text-gray-400 focus:z-10 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-500 transition ease-in-out duration-150\" aria-label=\"{{ __('pagination.next') }}\">\n                            <svg class=\"w-5 h-5\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n                                <path fill-rule=\"evenodd\" d=\"M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z\" clip-rule=\"evenodd\" />\n                            </svg>\n                        </a>\n                    @else\n                        <span aria-disabled=\"true\" aria-label=\"{{ __('pagination.next') }}\">\n                            <span class=\"relative inline-flex items-center px-2 py-2 -ml-px text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default rounded-r-md leading-5\" aria-hidden=\"true\">\n                                <svg class=\"w-5 h-5\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n                                    <path fill-rule=\"evenodd\" d=\"M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z\" clip-rule=\"evenodd\" />\n                                </svg>\n                            </span>\n                        </span>\n                    @endif\n                </span>\n            </div>\n        </div>\n    </nav>\n@endif\n\n"
  },
  {
    "path": "backend/routes/api.php",
    "content": "<?php\n\nuse App\\Http\\Controllers\\API\\Auth\\AuthController;\nuse App\\Http\\Controllers\\API\\Cloud\\InstanceController;\nuse App\\Http\\Controllers\\API\\DeviceController;\nuse App\\Http\\Controllers\\API\\DisplayController;\nuse App\\Http\\Controllers\\API\\EventController;\nuse App\\Http\\Controllers\\GoogleWebhookController;\nuse App\\Http\\Controllers\\OutlookWebhookController;\nuse Illuminate\\Support\\Facades\\Route;\n\nRoute::prefix('auth')->group(function () {\n    Route::post('login', [AuthController::class, 'login']);\n});\n\nRoute::middleware(['auth:sanctum', 'user.update-last-activity'])->group(function () {\n    Route::get('devices/me', [DeviceController::class, 'me']);\n    Route::put('devices/display', [DeviceController::class, 'changeDisplay']); # Deprecated > v1.2.0\n\n    Route::get('displays', [DisplayController::class, 'index']);\n    Route::get('displays/{display}/data', [DisplayController::class, 'getData']);\n    Route::post('displays/{display}/book', [DisplayController::class, 'book']);\n    Route::post('displays/{display}/events/{eventId}/check-in', [DisplayController::class, 'checkIn']);\n    Route::delete('displays/{display}/events/{eventId}', [DisplayController::class, 'cancel']);\n\n    Route::get('events', [EventController::class, 'index']); # Deprecated > v1.2.0\n    \n    // Display image serving for mobile app\n    Route::get('displays/{display}/images/{type}', [DisplayController::class, 'serveImage']);\n});\n\n// Webhook endpoints with rate limiting (100 requests per minute per IP)\nRoute::middleware(['throttle:100,1'])->group(function () {\n    Route::post('webhook/outlook', [OutlookWebhookController::class, 'handleNotification']);\n    Route::post('webhook/google', [GoogleWebhookController::class, 'handleNotification']);\n});\n\n// Instance management endpoints with rate limiting (60 requests per minute per IP)\nRoute::prefix('v1')->middleware(['throttle:60,1'])->group(function () {\n    Route::post('/instances/activate', [InstanceController::class, 'activate']);\n    Route::post('/instances/heartbeat', [InstanceController::class, 'heartbeat']);\n    Route::post('/instances/validate', [InstanceController::class, 'validateInstance']);\n});\n"
  },
  {
    "path": "backend/routes/channels.php",
    "content": "<?php\n\nuse Illuminate\\Support\\Facades\\Broadcast;\n\nBroadcast::channel('App.Models.User.{id}', function ($user, $id) {\n    return (int) $user->id === (int) $id;\n});\n"
  },
  {
    "path": "backend/routes/console.php",
    "content": "<?php\n\nuse App\\Console\\Commands\\CheckMarketingTriggers;\nuse App\\Console\\Commands\\CleanupExpiredEvents;\nuse App\\Console\\Commands\\RenewEventSubscriptions;\nuse App\\Console\\Commands\\SendHeartbeat;\nuse App\\Console\\Commands\\SyncDisplayUsageToLemonSqueezy;\nuse App\\Console\\Commands\\UpdateLemonSqueezySubscriptions;\nuse App\\Console\\Commands\\ValidateLicense;\nuse App\\Services\\InstanceService;\nuse Illuminate\\Support\\Facades\\Schedule;\n\n// Generate random minutes for scheduling (between 0-59)\n$heartbeatMinute = rand(0, 59);\n$validateMinute = rand(0, 59);\n\nSchedule::command(RenewEventSubscriptions::class)\n    ->everyMinute()\n    ->withoutOverlapping();\n\nSchedule::command(SendHeartbeat::class)\n    ->when(fn() => config('settings.is_self_hosted'))\n    ->hourlyAt($heartbeatMinute)\n    ->withoutOverlapping();\n\nSchedule::command(ValidateLicense::class)\n    ->when(fn() => config('settings.is_self_hosted') && InstanceService::hasLicense())\n    ->hourlyAt($validateMinute)\n    ->withoutOverlapping();\n\nSchedule::command(CleanupExpiredEvents::class)\n    ->hourly()\n    ->withoutOverlapping();\n\nSchedule::command(UpdateLemonSqueezySubscriptions::class)\n    ->when(fn() => ! config('settings.is_self_hosted'))\n    ->hourly()\n    ->withoutOverlapping();\n\nSchedule::command(CheckMarketingTriggers::class)\n    ->when(fn() => ! config('settings.is_self_hosted'))\n    ->hourly()\n    ->withoutOverlapping();\n"
  },
  {
    "path": "backend/routes/web.php",
    "content": "<?php\n\nuse App\\Http\\Controllers\\Auth\\GoogleController;\nuse App\\Http\\Controllers\\Auth\\MicrosoftController;\nuse App\\Http\\Controllers\\Auth\\LoginController;\nuse App\\Http\\Controllers\\Auth\\RegisterController;\nuse App\\Http\\Controllers\\CalendarController;\nuse App\\Http\\Controllers\\RoomController;\nuse App\\Http\\Controllers\\DashboardController;\nuse App\\Http\\Controllers\\OnboardingController;\nuse App\\Http\\Controllers\\GoogleAccountsController;\nuse App\\Http\\Controllers\\OutlookAccountsController;\nuse App\\Http\\Controllers\\DisplayController;\nuse App\\Http\\Controllers\\DisplaySettingsController;\nuse App\\Http\\Controllers\\OutlookWebhookController;\nuse App\\Http\\Controllers\\CalDAVAccountsController;\nuse App\\Http\\Controllers\\LicenseController;\nuse Illuminate\\Support\\Facades\\Route;\nuse App\\Http\\Controllers\\AdminController;\nuse App\\Http\\Controllers\\WorkspaceController;\nuse App\\Http\\Controllers\\BoardController;\nuse App\\Http\\Controllers\\UsageController;\n\nRoute::get('/login', [LoginController::class, 'create'])\n    ->middleware('guest')\n    ->name('login');\n\nRoute::post('/login', [LoginController::class, 'store'])\n    ->middleware('guest')\n    ->name('login.store');\n\nRoute::get('/register', [RegisterController::class, 'create'])\n    ->middleware('guest')\n    ->name('register');\n\nRoute::post('/register', [RegisterController::class, 'store'])\n    ->middleware('guest')\n    ->name('register.store');\n\nRoute::post('/logout', [LoginController::class, 'destroy'])\n    ->middleware('auth')\n    ->name('logout');\n\nRoute::prefix('auth')->group(function () {\n    Route::get('/microsoft/redirect', [MicrosoftController::class, 'redirect'])->name('auth.microsoft.redirect');\n    Route::get('/microsoft/callback', [MicrosoftController::class, 'callback']);\n    Route::get('/google/redirect', [GoogleController::class, 'redirect'])->name('auth.google.redirect');\n    Route::get('/google/callback', [GoogleController::class, 'callback']);\n});\n\nRoute::middleware(['auth', 'user.update-last-activity', 'gtm'])->group(function () {\n    Route::get('/', DashboardController::class)->name('dashboard')->middleware('user.active');\n    Route::get('/onboarding', [OnboardingController::class, 'index'])->name('onboarding')->middleware('user.onboarding');\n    Route::post('/onboarding/usage-type', [OnboardingController::class, 'updateUsageType'])->name('onboarding.usage-type');\n    Route::post('/onboarding/terms', [OnboardingController::class, 'acceptTerms'])->name('onboarding.terms');\n\n    Route::post('/outlook-accounts/auth', [OutlookAccountsController::class, 'auth'])->name('outlook-accounts.auth');\n    Route::get('/outlook-accounts/callback', [OutlookAccountsController::class, 'callback']);\n    Route::get('/outlook-accounts/calendars', [OutlookAccountsController::class, 'getCalendars']);\n    Route::delete('/outlook-accounts/{outlookAccount}', [OutlookAccountsController::class, 'delete'])->name('outlook-accounts.delete');\n\n    Route::post('/google-accounts/booking-method', [GoogleAccountsController::class, 'setBookingMethod'])->name('google-accounts.set-booking-method');\n    Route::post('/google-accounts/auth', [GoogleAccountsController::class, 'auth'])->name('google-accounts.auth');\n    Route::post('/google-accounts/service-account', [GoogleAccountsController::class, 'uploadServiceAccount'])->name('google-accounts.service-account');\n    Route::get('/google-accounts/callback', [GoogleAccountsController::class, 'callback']);\n    Route::get('/google-accounts/calendars', [GoogleAccountsController::class, 'getCalendars']);\n    Route::delete('/google-accounts/{googleAccount}', [GoogleAccountsController::class, 'delete'])->name('google-accounts.delete');\n\n    Route::get('/caldav-accounts/create', [CalDAVAccountsController::class, 'create'])->name('caldav-accounts.create');\n    Route::post('/caldav-accounts', [CalDAVAccountsController::class, 'store'])->name('caldav-accounts.store');\n    Route::delete('/caldav-accounts/{caldavAccount}', [CalDAVAccountsController::class, 'delete'])->name('caldav-accounts.delete');\n\n    Route::get('/displays/create', [DisplayController::class, 'create'])\n        ->name('displays.create');\n    Route::post('/displays', [DisplayController::class, 'store'])->name('displays.store');\n    Route::patch('/displays/{display}/status', [DisplayController::class, 'updateStatus'])\n        ->name('displays.updateStatus');\n    Route::delete('/displays/{display}', [DisplayController::class, 'delete'])->name('displays.delete');\n\n    // Display settings routes\n    Route::get('/displays/{display}/settings', [DisplaySettingsController::class, 'index'])\n        ->name('displays.settings.index');\n    Route::put('/displays/{display}/settings', [DisplaySettingsController::class, 'update'])\n        ->name('displays.settings.update');\n\n    // Display customization routes\n    Route::get('/displays/{display}/customization', [DisplaySettingsController::class, 'customization'])\n        ->name('displays.customization');\n    Route::put('/displays/{display}/customization', [DisplaySettingsController::class, 'updateCustomization'])\n        ->name('displays.customization.update');\n\n    Route::get('/calendars/outlook/{id}', [CalendarController::class, 'outlook'])\n        ->name('calendars.outlook');\n    Route::get('/calendars/google/{id}', [CalendarController::class, 'google'])\n        ->name('calendars.google');\n    Route::get('/calendars/caldav/{id}', [CalendarController::class, 'caldav'])\n        ->name('calendars.caldav');\n    Route::get('/rooms/outlook/{id}', [RoomController::class, 'outlook'])\n        ->name('rooms.outlook');\n    Route::get('/rooms/google/{id}', [RoomController::class, 'google'])\n        ->name('rooms.google');\n\n    Route::post('/license/validate', [LicenseController::class, 'validateLicense'])->name('license.validate');\n\n    Route::post('/workspaces/switch', [WorkspaceController::class, 'switch'])->name('workspaces.switch');\n\n    Route::get('/billing/thanks', function () {\n        \\Spatie\\GoogleTagManager\\GoogleTagManagerFacade::flashPush([\n            'event' => 'purchase',\n        ]);\n        if (config('services.google_conversion.send_to')) {\n            \\Spatie\\GoogleTagManager\\GoogleTagManagerFacade::flashPush([\n                'event' => 'conversion',\n                'send_to' => config('services.google_conversion.send_to'),\n                'value' => config('services.google_conversion.value'),\n                'currency' => config('services.google_conversion.currency'),\n                'transaction_id' => '',\n            ]);\n        }\n        return redirect()->route('dashboard');\n    })->name('billing.thanks');\n\n    Route::get('/admin', [AdminController::class, 'index'])->name('admin.index');\n    Route::get('/admin/users/{user}', [AdminController::class, 'showUser'])->name('admin.users.show');\n    Route::delete('/admin/users/{user}', [AdminController::class, 'deleteUser'])->name('admin.users.delete');\n    Route::post('/admin/users/{user}/impersonate', [AdminController::class, 'impersonate'])->name('admin.users.impersonate');\n    Route::post('/admin/stop-impersonating', [AdminController::class, 'stopImpersonating'])->name('admin.stop-impersonating');\n\n    // Display image serving route\n    Route::get('/displays/{display}/images/{type}', [DisplaySettingsController::class, 'serveImage'])\n        ->name('displays.images');\n\n    // Boards routes\n    Route::get('/boards/create', [BoardController::class, 'create'])->name('boards.create');\n    Route::post('/boards', [BoardController::class, 'store'])->name('boards.store');\n    Route::get('/boards/{board}', [BoardController::class, 'show'])->name('boards.show');\n    Route::get('/boards/{board}/edit', [BoardController::class, 'edit'])->name('boards.edit');\n    Route::put('/boards/{board}', [BoardController::class, 'update'])->name('boards.update');\n    Route::delete('/boards/{board}', [BoardController::class, 'destroy'])->name('boards.destroy');\n    Route::get('/boards/{board}/images/logo', [BoardController::class, 'serveLogo'])->name('boards.images.logo');\n\n    // Usage routes\n    Route::get('/usage', [UsageController::class, 'index'])->name('usage.index');\n});\n"
  },
  {
    "path": "backend/storage/app/.gitignore",
    "content": "*\n!private/\n!public/\n!.gitignore\n"
  },
  {
    "path": "backend/storage/framework/.gitignore",
    "content": "compiled.php\nconfig.php\ndown\nevents.scanned.php\nmaintenance.php\nroutes.php\nroutes.scanned.php\nschedule-*\nservices.json\n"
  },
  {
    "path": "backend/storage/framework/cache/.gitignore",
    "content": "*\n!data/\n!.gitignore\n"
  },
  {
    "path": "backend/storage/framework/sessions/.gitignore",
    "content": "*\n!.gitignore\n"
  },
  {
    "path": "backend/storage/framework/testing/.gitignore",
    "content": "*\n!.gitignore\n"
  },
  {
    "path": "backend/storage/framework/views/.gitignore",
    "content": "*\n!.gitignore\n"
  },
  {
    "path": "backend/storage/logs/.gitignore",
    "content": "*\n!.gitignore\n"
  },
  {
    "path": "backend/tailwind.config.js",
    "content": "import defaultTheme from 'tailwindcss/defaultTheme';\n\n/** @type {import('tailwindcss').Config} */\nexport default {\n    content: [\n        './vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php',\n        './storage/framework/views/*.php',\n        './resources/**/*.blade.php',\n        './resources/**/*.js',\n        './resources/**/*.vue',\n    ],\n    theme: {\n        extend: {\n            fontFamily: {\n                sans: ['Figtree', ...defaultTheme.fontFamily.sans],\n            },\n        },\n    },\n    plugins: [],\n};\n"
  },
  {
    "path": "backend/tests/Feature/API/AuthControllerTest.php",
    "content": "<?php\n\nuse App\\Models\\Device;\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Illuminate\\Support\\Facades\\Cache;\n\nuses(RefreshDatabase::class);\n\nbeforeEach(function () {\n    // Clear cache before each test\n    Cache::flush();\n});\n\nit('successfully connects with a valid connect code', function () {\n    $user = User::factory()->create();\n    $code = $user->getConnectCode();\n    $uid = 'test-device-uid-123';\n    $name = 'Test Device';\n\n    $response = $this->postJson('/api/auth/login', [\n        'code' => $code,\n        'uid' => $uid,\n        'name' => $name,\n    ]);\n\n    $response->assertOk()\n        ->assertJsonStructure([\n            'data' => [\n                'token',\n                'device' => [\n                    'id',\n                    'name',\n                    'user',\n                    'display',\n                ],\n            ],\n        ]);\n\n    // Verify device was created\n    $device = Device::where('uid', $uid)->first();\n    expect($device)->not->toBeNull()\n        ->and($device->user_id)->toBe($user->id)\n        ->and($device->name)->toBe($name)\n        ->and($device->workspace_id)->toBe($user->primaryWorkspace()?->id);\n});\n\nit('returns error when connect code is invalid', function () {\n    $response = $this->postJson('/api/auth/login', [\n        'code' => '999999',\n        'uid' => 'test-device-uid-123',\n        'name' => 'Test Device',\n    ]);\n\n    $response->assertStatus(400)\n        ->assertJson([\n            'message' => 'Code is incorrect.',\n            'errors' => [\n                'code' => ['incorrect'],\n            ],\n        ]);\n});\n\nit('can only use a connect code once', function () {\n    $user = User::factory()->create();\n    $code = $user->getConnectCode();\n    $uid1 = 'test-device-uid-1';\n    $uid2 = 'test-device-uid-2';\n\n    // First use - should succeed\n    $response1 = $this->postJson('/api/auth/login', [\n        'code' => $code,\n        'uid' => $uid1,\n        'name' => 'Device 1',\n    ]);\n\n    $response1->assertOk();\n\n    // Second use with same code - should fail\n    $response2 = $this->postJson('/api/auth/login', [\n        'code' => $code,\n        'uid' => $uid2,\n        'name' => 'Device 2',\n    ]);\n\n    $response2->assertStatus(400)\n        ->assertJson([\n            'message' => 'Code is incorrect.',\n        ]);\n\n    // Verify only first device was created\n    expect(Device::where('uid', $uid1)->exists())->toBeTrue()\n        ->and(Device::where('uid', $uid2)->exists())->toBeFalse();\n});\n\nit('removes connect code from cache after use', function () {\n    $user = User::factory()->create();\n    $code = $user->getConnectCode();\n\n    // Verify code exists in cache\n    expect(Cache::has(\"connect-code:$code\"))->toBeTrue();\n\n    // Use the code\n    $this->postJson('/api/auth/login', [\n        'code' => $code,\n        'uid' => 'test-device-uid',\n        'name' => 'Test Device',\n    ])->assertOk();\n\n    // Verify code is removed from cache\n    expect(Cache::has(\"connect-code:$code\"))->toBeFalse();\n    expect(Cache::has(\"user:{$user->id}:connect-code\"))->toBeFalse();\n});\n\nit('handles expired connect codes', function () {\n    $user = User::factory()->create();\n    $code = $user->getConnectCode();\n\n    // Manually remove the code from cache to simulate expiration\n    Cache::forget(\"connect-code:$code\");\n    Cache::forget(\"user:{$user->id}:connect-code\");\n\n    $response = $this->postJson('/api/auth/login', [\n        'code' => $code,\n        'uid' => 'test-device-uid',\n        'name' => 'Test Device',\n    ]);\n\n    $response->assertStatus(400)\n        ->assertJson([\n            'message' => 'Code is incorrect.',\n        ]);\n});\n\nit('creates new device if device with same uid does not exist', function () {\n    $user = User::factory()->create();\n    $code = $user->getConnectCode();\n    $uid = 'test-device-uid';\n\n    $this->postJson('/api/auth/login', [\n        'code' => $code,\n        'uid' => $uid,\n        'name' => 'New Device',\n    ])->assertOk();\n\n    $device = Device::where('uid', $uid)->first();\n    expect($device)->not->toBeNull()\n        ->and($device->name)->toBe('New Device');\n});\n\nit('updates existing device when connecting with same uid', function () {\n    $user = User::factory()->create();\n    $existingDevice = Device::factory()->create([\n        'user_id' => $user->id,\n        'uid' => 'test-device-uid',\n        'name' => 'Old Device Name',\n        'workspace_id' => null,\n    ]);\n\n    $code = $user->getConnectCode();\n\n    $response = $this->postJson('/api/auth/login', [\n        'code' => $code,\n        'uid' => 'test-device-uid',\n        'name' => 'Updated Device Name',\n    ])->assertOk();\n\n    // Verify device was updated, not duplicated\n    $devices = Device::where('uid', 'test-device-uid')->get();\n    expect($devices)->toHaveCount(1);\n\n    $existingDevice->refresh();\n    expect($existingDevice->name)->toBe('Updated Device Name')\n        ->and($existingDevice->workspace_id)->toBe($user->primaryWorkspace()?->id);\n});\n\nit('works with different users having different codes', function () {\n    $user1 = User::factory()->create();\n    $user2 = User::factory()->create();\n    \n    $code1 = $user1->getConnectCode();\n    $code2 = $user2->getConnectCode();\n\n    // Verify codes are different\n    expect($code1)->not->toBe($code2);\n\n    // Connect device 1 with user 1's code\n    $response1 = $this->postJson('/api/auth/login', [\n        'code' => $code1,\n        'uid' => 'device-1',\n        'name' => 'Device 1',\n    ])->assertOk();\n\n    // Connect device 2 with user 2's code\n    $response2 = $this->postJson('/api/auth/login', [\n        'code' => $code2,\n        'uid' => 'device-2',\n        'name' => 'Device 2',\n    ])->assertOk();\n\n    // Verify devices are connected to correct users\n    $device1 = Device::where('uid', 'device-1')->first();\n    $device2 = Device::where('uid', 'device-2')->first();\n\n    expect($device1->user_id)->toBe($user1->id)\n        ->and($device2->user_id)->toBe($user2->id);\n});\n\nit('returns same code when getConnectCode is called multiple times before expiration', function () {\n    $user = User::factory()->create();\n    \n    $code1 = $user->getConnectCode();\n    $code2 = $user->getConnectCode();\n\n    expect($code1)->toBe($code2);\n});\n\nit('generates new code after previous code is used', function () {\n    $user = User::factory()->create();\n    $code1 = $user->getConnectCode();\n\n    // Use the code\n    $this->postJson('/api/auth/login', [\n        'code' => $code1,\n        'uid' => 'test-device-uid',\n        'name' => 'Test Device',\n    ])->assertOk();\n\n    // Get a new code - should be different\n    $code2 = $user->getConnectCode();\n    expect($code2)->not->toBe($code1);\n});\n\nit('validates required fields', function () {\n    // Missing code\n    $this->postJson('/api/auth/login', [\n        'uid' => 'test-device-uid',\n        'name' => 'Test Device',\n    ])->assertStatus(422)\n        ->assertJsonValidationErrors(['code']);\n\n    // Missing uid\n    $this->postJson('/api/auth/login', [\n        'code' => '123456',\n        'name' => 'Test Device',\n    ])->assertStatus(422)\n        ->assertJsonValidationErrors(['uid']);\n\n    // Missing name\n    $this->postJson('/api/auth/login', [\n        'code' => '123456',\n        'uid' => 'test-device-uid',\n    ])->assertStatus(422)\n        ->assertJsonValidationErrors(['name']);\n});\n\nit('handles case when user associated with code no longer exists', function () {\n    $user = User::factory()->create();\n    $code = $user->getConnectCode();\n    \n    // Manually set cache with a non-existent user ID\n    Cache::put(\"connect-code:$code\", 'non-existent-user-id', now()->addMinutes(30));\n\n    $response = $this->postJson('/api/auth/login', [\n        'code' => $code,\n        'uid' => 'test-device-uid',\n        'name' => 'Test Device',\n    ]);\n\n    $response->assertStatus(400)\n        ->assertJson([\n            'message' => 'Code is incorrect.',\n        ]);\n});\n\nit('returns token that can be used for authenticated requests', function () {\n    $user = User::factory()->create();\n    $code = $user->getConnectCode();\n\n    $response = $this->postJson('/api/auth/login', [\n        'code' => $code,\n        'uid' => 'test-device-uid',\n        'name' => 'Test Device',\n    ])->assertOk();\n\n    $token = $response->json('data.token');\n    expect($token)->not->toBeNull();\n\n    // Verify token can be used for authenticated requests\n    $device = Device::where('uid', 'test-device-uid')->first();\n    $this->withHeader('Authorization', \"Bearer $token\")\n        ->getJson('/api/devices/me')\n        ->assertOk();\n});\n"
  },
  {
    "path": "backend/tests/Feature/API/EventControllerTest.php",
    "content": "<?php\n\nuse App\\Models\\Device;\nuse App\\Models\\Display;\nuse App\\Models\\EventSubscription;\nuse App\\Models\\User;\nuse App\\Services\\OutlookService;\nuse App\\Services\\GoogleService;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse App\\Models\\Calendar;\nuse App\\Models\\GoogleAccount;\nuse App\\Models\\OutlookAccount;\nuse App\\Models\\Room;\n\nuses(RefreshDatabase::class);\n\nbeforeEach(function () {\n    $this->user = User::factory()->create();\n    // User boot method automatically creates a workspace, get the primary workspace\n    $this->workspace = $this->user->primaryWorkspace();\n    \n    $this->device = Device::factory()->create([\n        'user_id' => $this->user->id,\n        'workspace_id' => $this->workspace->id,\n    ]);\n\n    // Create calendar first\n    $this->calendar = Calendar::factory()->create([\n        'user_id' => $this->user->id,\n        'workspace_id' => $this->workspace->id,\n        'calendar_id' => 'test@example.com',\n        'name' => 'Test Calendar'\n    ]);\n\n    // Then create display with calendar\n    $this->display = Display::factory()->create([\n        'user_id' => $this->user->id,\n        'workspace_id' => $this->workspace->id,\n        'calendar_id' => $this->calendar->id,\n        'status' => 'active'\n    ]);\n\n    $this->device->update(['display_id' => $this->display->id]);\n});\n\nit('returns 400 when device is not connected to a display', function () {\n    $this->device->update(['display_id' => null]);\n\n    $this->actingAs($this->device)\n        ->getJson('/api/events')\n        ->assertStatus(404)\n        ->assertJson(['message' => 'Display not found']);\n});\n\nit('returns 400 when display is deactivated', function () {\n    $this->display->update(['status' => 'deactivated']);\n\n    $this->actingAs($this->device)\n        ->getJson('/api/events')\n        ->assertStatus(400)\n        ->assertJson(['message' => 'Display is deactivated']);\n});\n\nit('returns outlook events in the correct format', function () {\n    // Create accounts and link them to the calendar\n    $outlookAccount = OutlookAccount::factory()->create(['user_id' => $this->user->id]);\n    Room::factory()->create([\n        'user_id' => $this->user->id,\n        'workspace_id' => $this->workspace->id,\n        'calendar_id' => $this->calendar->id,\n        'email_address' => 'test@example.com'\n    ]);\n\n    $this->calendar->update([\n        'outlook_account_id' => $outlookAccount->id\n    ]);\n\n    // Mock Outlook service response\n    $outlookEvents = [\n        [\n            'id' => 'outlook-1',\n            'subject' => 'Test Outlook Event',\n            'body' => ['content' => 'Test Description'],\n            'bodyPreview' => 'Test Description',\n            'isAllDay' => false,\n            'location' => ['displayName' => 'Test Location'],\n            'start' => [\n                'dateTime' => now()->addHour()->toIso8601String(),\n                'timeZone' => 'UTC'\n            ],\n            'end' => [\n                'dateTime' => now()->addHours(2)->toIso8601String(),\n                'timeZone' => 'UTC'\n            ]\n        ]\n    ];\n\n    // Mock the service\n    $outlookService = Mockery::mock(OutlookService::class);\n    $outlookService->shouldReceive('fetchEventsByUser')\n        ->once()\n        ->andReturn($outlookEvents);\n\n    $this->app->instance(OutlookService::class, $outlookService);\n\n    $response = $this->actingAs($this->device)\n        ->getJson('/api/events')\n        ->assertOk()\n        ->assertJsonStructure([\n            'data' => [\n                '*' => [\n                    'id',\n                    'summary',\n                    'location',\n                    'description',\n                    'start',\n                    'end',\n                    'timezone',\n                ]\n            ]\n        ]);\n\n    $events = $response->json('data');\n    expect($events)->toHaveCount(1);\n\n    // Verify Outlook event format\n    $event = $events[0];\n    expect($event)->toBeArray()\n        ->and($event['summary'])->toBe('Test Outlook Event')\n        ->and($event['location'])->toBe('Test Location')\n        ->and($event['description'])->toBe('Test Description')\n        ->and($event['timezone'])->toBe('UTC');\n});\n\nit('returns google events in the correct format', function () {\n    // Create accounts and link them to the calendar\n    $googleAccount = GoogleAccount::factory()->create(['user_id' => $this->user->id]);\n    Room::factory()->create([\n        'user_id' => $this->user->id,\n        'workspace_id' => $this->workspace->id,\n        'calendar_id' => $this->calendar->id,\n        'email_address' => 'test@example.com'\n    ]);\n\n    $this->calendar->update([\n        'google_account_id' => $googleAccount->id\n    ]);\n\n    // Mock Google service response\n    $googleEvent = new \\Google\\Service\\Calendar\\Event();\n    $googleEvent->setId('google-1');\n    $googleEvent->setSummary('Test Google Event');\n    $googleEvent->setDescription('Test Description');\n    $googleEvent->setLocation('Test Location');\n    $googleEvent->setStart(new \\Google\\Service\\Calendar\\EventDateTime([\n        'dateTime' => now()->addHour()->toIso8601String(),\n        'timeZone' => 'UTC'\n    ]));\n    $googleEvent->setEnd(new \\Google\\Service\\Calendar\\EventDateTime([\n        'dateTime' => now()->addHours(2)->toIso8601String(),\n        'timeZone' => 'UTC'\n    ]));\n\n    // Mock the service\n    $googleService = Mockery::mock(GoogleService::class);\n    $googleService->shouldReceive('fetchEvents')\n        ->once()\n        ->with(\n            Mockery::type(GoogleAccount::class),\n            'test@example.com',\n            Mockery::type(\\Carbon\\Carbon::class),\n            Mockery::type(\\Carbon\\Carbon::class)\n        )\n        ->andReturn([$googleEvent]);\n\n    $this->app->instance(GoogleService::class, $googleService);\n\n    $response = $this->actingAs($this->device)\n        ->getJson('/api/events')\n        ->assertOk()\n        ->assertJsonStructure([\n            'data' => [\n                '*' => [\n                    'id',\n                    'summary',\n                    'location',\n                    'description',\n                    'start',\n                    'end',\n                    'timezone'\n                ]\n            ]\n        ]);\n\n    $events = $response->json('data');\n    expect($events)->toHaveCount(1);\n\n    // Verify Google event format\n    $event = $events[0];\n    expect($event)->toBeArray()\n        ->and($event['summary'])->toBe('Test Google Event')\n        ->and($event['location'])->toBe('Test Location')\n        ->and($event['description'])->toBe('Test Description')\n        ->and($event['timezone'])->toBe('UTC');\n});\n\nit('does not cache events when no event subscription exists', function () {\n    // Create accounts and link them to the calendar\n    $outlookAccount = OutlookAccount::factory()->create(['user_id' => $this->user->id]);\n    $room = Room::factory()->create([\n        'user_id' => $this->user->id,\n        'calendar_id' => $this->calendar->id,\n        'email_address' => 'test@example.com'\n    ]);\n\n    $this->calendar->update([\n        'outlook_account_id' => $outlookAccount->id\n    ]);\n\n    // Mock Outlook service response\n    $outlookEvents = [\n        [\n            'id' => 'outlook-1',\n            'subject' => 'Test Outlook Event',\n            'body' => ['content' => 'Test Description'],\n            'bodyPreview' => 'Test Description',\n            'isAllDay' => false,\n            'location' => ['displayName' => 'Test Location'],\n            'start' => [\n                'dateTime' => now()->addHour()->toIso8601String(),\n                'timeZone' => 'UTC'\n            ],\n            'end' => [\n                'dateTime' => now()->addHours(2)->toIso8601String(),\n                'timeZone' => 'UTC'\n            ]\n        ]\n    ];\n\n    // Mock the service\n    $outlookService = Mockery::mock(OutlookService::class);\n    $outlookService->shouldReceive('fetchEventsByUser')\n        ->twice() // Should be called twice since no caching\n        ->andReturn($outlookEvents);\n\n    $this->app->instance(OutlookService::class, $outlookService);\n\n    // First request\n    $this->actingAs($this->device)\n        ->getJson('/api/events')\n        ->assertOk();\n\n    // Second request\n    $this->actingAs($this->device)\n        ->getJson('/api/events')\n        ->assertOk();\n\n    // Verify no cache exists\n    expect(cache()->has($this->display->getEventsCacheKey()))->toBeFalse();\n});\n\nit('caches events when event subscription exists', function () {\n    // Create accounts and link them to the calendar\n    $outlookAccount = OutlookAccount::factory()->create(['user_id' => $this->user->id]);\n    Room::factory()->create([\n        'user_id' => $this->user->id,\n        'workspace_id' => $this->workspace->id,\n        'calendar_id' => $this->calendar->id,\n        'email_address' => 'test@example.com'\n    ]);\n\n    $this->calendar->update([\n        'outlook_account_id' => $outlookAccount->id\n    ]);\n\n    // Create event subscription for the Outlook account\n    $test = EventSubscription::factory()\n        ->outlook($outlookAccount)\n        ->create([\n            'display_id' => $this->display->id\n        ]);\n\n    // Mock Outlook service response\n    $outlookEvents = [\n        [\n            'id' => 'outlook-1',\n            'subject' => 'Test Outlook Event',\n            'body' => ['content' => 'Test Description'],\n            'bodyPreview' => 'Test Description',\n            'isAllDay' => false,\n            'location' => ['displayName' => 'Test Location'],\n            'start' => [\n                'dateTime' => now()->addHour()->toIso8601String(),\n                'timeZone' => 'UTC'\n            ],\n            'end' => [\n                'dateTime' => now()->addHours(2)->toIso8601String(),\n                'timeZone' => 'UTC'\n            ]\n        ]\n    ];\n\n    // Mock the service\n    $outlookService = Mockery::mock(OutlookService::class);\n    $outlookService->shouldReceive('fetchEventsByUser')\n        ->once() // Should be called only once due to caching\n        ->andReturn($outlookEvents);\n\n    $this->app->instance(OutlookService::class, $outlookService);\n\n    // First request should call the service\n    $this->actingAs($this->device)\n        ->getJson('/api/events')\n        ->assertOk();\n\n    // Second request should use cache\n    $this->actingAs($this->device)\n        ->getJson('/api/events')\n        ->assertOk();\n\n    // Verify cache exists\n    expect(cache()->has($this->display->getEventsCacheKey()))->toBeTrue();\n});\n\nit('handles errors gracefully', function () {\n    // Create accounts and link them to the calendar\n    $outlookAccount = OutlookAccount::factory()->create(['user_id' => $this->user->id]);\n    $room = Room::factory()->create([\n        'user_id' => $this->user->id,\n        'calendar_id' => $this->calendar->id,\n        'email_address' => 'test@example.com'\n    ]);\n\n    $this->calendar->update([\n        'outlook_account_id' => $outlookAccount->id\n    ]);\n\n    // Mock the service to throw an exception\n    $outlookService = Mockery::mock(OutlookService::class);\n    $outlookService->shouldReceive('fetchEventsByUser')\n        ->once()\n        ->andThrow(new \\Exception('Service error'));\n\n    $this->app->instance(OutlookService::class, $outlookService);\n\n    $this->actingAs($this->device)\n        ->getJson('/api/events')\n        ->assertStatus(500)\n        ->assertJson(['message' => 'Service error']);\n});\n"
  },
  {
    "path": "backend/tests/Feature/AdminBoardsTest.php",
    "content": "<?php\n\nnamespace Tests\\Feature;\n\nuse App\\Enums\\DisplayStatus;\nuse App\\Models\\Board;\nuse App\\Models\\Display;\nuse App\\Models\\Instance;\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Tests\\TestCase;\n\nuses(RefreshDatabase::class);\n\nbeforeEach(function () {\n    // Disable self-hosted mode for admin tests\n    config(['settings.is_self_hosted' => false]);\n    \n    $this->admin = User::factory()->create([\n        'is_admin' => true,\n        'is_unlimited' => true,\n    ]);\n    \n    // Set selected workspace for admin\n    $workspace = $this->admin->primaryWorkspace();\n    session()->put('selected_workspace_id', $workspace->id);\n});\n\ntest('admin can see total boards count', function () {\n    // Create boards without triggering subscription queries\n    $user = User::factory()->create(['is_unlimited' => true]);\n    $workspace = $user->primaryWorkspace();\n    \n    Board::factory()->count(5)->create([\n        'workspace_id' => $workspace->id,\n    ]);\n    \n    $response = $this->actingAs($this->admin)\n        ->get(route('admin.index'));\n    \n    $response->assertStatus(200);\n    $response->assertSee('Total Boards');\n    $response->assertSee('5');\n});\n\ntest('admin can see boards count per user in active users tab', function () {\n    $user = User::factory()->active()->create();\n    $workspace = $user->primaryWorkspace();\n    \n    Display::factory()->count(2)->create([\n        'workspace_id' => $workspace->id,\n        'status' => DisplayStatus::ACTIVE,\n        'last_sync_at' => now(),\n    ]);\n    \n    Board::factory()->count(3)->create([\n        'workspace_id' => $workspace->id,\n        'user_id' => $user->id,\n    ]);\n    \n    $response = $this->actingAs($this->admin)\n        ->get(route('admin.index'));\n    \n    $response->assertStatus(200);\n    $response->assertSee('Boards');\n    // Check that boards count appears in the table\n    $response->assertSee('3');\n});\n\ntest('admin can see boards count in paying users tab', function () {\n    $user = User::factory()->active()->create([\n        'is_unlimited' => true,\n    ]);\n    $workspace = $user->primaryWorkspace();\n    \n    Board::factory()->count(2)->create([\n        'workspace_id' => $workspace->id,\n        'user_id' => $user->id,\n    ]);\n    \n    $response = $this->actingAs($this->admin)\n        ->get(route('admin.index'));\n    \n    $response->assertStatus(200);\n    // Should see boards count in paying users table\n    $response->assertSee('Boards');\n});\n\ntest('admin can see boards count in users overview tab', function () {\n    $user = User::factory()->create();\n    $workspace = $user->primaryWorkspace();\n    \n    Board::factory()->count(4)->create([\n        'workspace_id' => $workspace->id,\n        'user_id' => $user->id,\n    ]);\n    \n    $response = $this->actingAs($this->admin)\n        ->get(route('admin.index'));\n    \n    $response->assertStatus(200);\n    $response->assertSee('Boards');\n    // Should see boards count in users overview table\n});\n\ntest('admin can see boards count for self-hosted instances', function () {\n    $instance = Instance::factory()->create([\n        'is_self_hosted' => true,\n        'displays_count' => 5,\n        'rooms_count' => 2,\n        'boards_count' => 3,\n        'last_heartbeat_at' => now(),\n    ]);\n    \n    $response = $this->actingAs($this->admin)\n        ->get(route('admin.index'));\n    \n    $response->assertStatus(200);\n    $response->assertSee('Boards');\n    // Should see boards count in instances table\n    $response->assertSee('3');\n});\n"
  },
  {
    "path": "backend/tests/Feature/BoardControllerTest.php",
    "content": "<?php\n\nnamespace Tests\\Feature;\n\nuse App\\Enums\\DisplayStatus;\nuse App\\Models\\Board;\nuse App\\Models\\Display;\nuse App\\Models\\User;\nuse App\\Models\\Workspace;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Tests\\TestCase;\n\nuses(RefreshDatabase::class);\n\nbeforeEach(function () {\n    $this->user = User::factory()->active()->create([\n        'is_unlimited' => true, // Make user pro for testing boards\n    ]);\n    $this->workspace = $this->user->primaryWorkspace();\n    \n    // Set selected workspace in session\n    session()->put('selected_workspace_id', $this->workspace->id);\n});\n\ntest('user can create a board', function () {\n    Display::factory()->count(2)->create([\n        'workspace_id' => $this->workspace->id,\n        'status' => DisplayStatus::ACTIVE,\n    ]);\n    \n    $response = $this->actingAs($this->user)\n        ->post(route('boards.store'), [\n            'name' => 'Test Board',\n            'workspace_id' => $this->workspace->id,\n            'show_all_displays' => true,\n            'theme' => 'dark',\n            'show_title' => true,\n            'show_booker' => true,\n            'show_next_event' => true,\n            'show_transitioning' => true,\n            'transitioning_minutes' => 10,\n            'font_family' => 'Inter',\n            'language' => 'en',\n            'view_mode' => 'card',\n            'show_meeting_title' => true,\n        ]);\n    \n    $response->assertRedirect();\n    $this->assertDatabaseHas('boards', [\n        'name' => 'Test Board',\n        'workspace_id' => $this->workspace->id,\n        'user_id' => $this->user->id,\n    ]);\n});\n\ntest('user can view boards list', function () {\n    Board::factory()->count(3)->create([\n        'workspace_id' => $this->workspace->id,\n        'user_id' => $this->user->id,\n    ]);\n    \n    $response = $this->actingAs($this->user)\n        ->get(route('dashboard') . '?tab=boards');\n    \n    $response->assertStatus(200);\n    $response->assertSee('Boards');\n});\n\ntest('user can view a board', function () {\n    $board = Board::factory()->create([\n        'workspace_id' => $this->workspace->id,\n        'user_id' => $this->user->id,\n    ]);\n    \n    $response = $this->actingAs($this->user)\n        ->get(route('boards.show', $board));\n    \n    $response->assertStatus(200);\n    $response->assertViewIs('pages.boards.show');\n});\n\ntest('user can update a board', function () {\n    $board = Board::factory()->create([\n        'workspace_id' => $this->workspace->id,\n        'user_id' => $this->user->id,\n    ]);\n    \n    $response = $this->actingAs($this->user)\n        ->put(route('boards.update', $board), [\n            'name' => 'Updated Board Name',\n            'workspace_id' => $this->workspace->id,\n            'show_all_displays' => false,\n            'theme' => 'light',\n            'show_title' => true,\n            'show_booker' => true,\n            'show_next_event' => true,\n            'show_transitioning' => true,\n            'transitioning_minutes' => 10,\n            'font_family' => 'Inter',\n            'language' => 'en',\n            'view_mode' => 'card',\n            'show_meeting_title' => true,\n        ]);\n    \n    $response->assertRedirect();\n    $this->assertDatabaseHas('boards', [\n        'id' => $board->id,\n        'name' => 'Updated Board Name',\n        'theme' => 'light',\n    ]);\n});\n\ntest('user can delete a board', function () {\n    $board = Board::factory()->create([\n        'workspace_id' => $this->workspace->id,\n        'user_id' => $this->user->id,\n    ]);\n    \n    $response = $this->actingAs($this->user)\n        ->delete(route('boards.destroy', $board));\n    \n    $response->assertRedirect();\n    $this->assertDatabaseMissing('boards', [\n        'id' => $board->id,\n    ]);\n});\n"
  },
  {
    "path": "backend/tests/Feature/BoardUsageTest.php",
    "content": "<?php\n\nnamespace Tests\\Feature;\n\nuse App\\Models\\Board;\nuse App\\Models\\Display;\nuse App\\Models\\User;\nuse App\\Enums\\DisplayStatus;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Tests\\TestCase;\n\nuses(RefreshDatabase::class);\n\ntest('usage page shows correct breakdown for workspace', function () {\n    $user = User::factory()->active()->create();\n    $workspace = $user->primaryWorkspace();\n    \n    Display::factory()->count(3)->create([\n        'workspace_id' => $workspace->id,\n        'status' => DisplayStatus::ACTIVE,\n    ]);\n    \n    Board::factory()->count(2)->create([\n        'workspace_id' => $workspace->id,\n    ]);\n    \n    $response = $this->actingAs($user)\n        ->get(route('usage.index'));\n    \n    $response->assertStatus(200);\n    $response->assertViewIs('pages.usage.index');\n    $response->assertViewHas('usageBreakdown', function ($breakdown) {\n        return $breakdown['displays'] === 3\n            && $breakdown['boards'] === 2\n            && $breakdown['board_usage'] === 4\n            && $breakdown['total'] === 7;\n    });\n});\n\ntest('usage page shows correct data structure', function () {\n    $user = User::factory()->active()->create([\n        'is_unlimited' => true,\n    ]);\n    $workspace = $user->primaryWorkspace();\n    session()->put('selected_workspace_id', $workspace->id);\n    \n    Display::factory()->count(1)->create([\n        'workspace_id' => $workspace->id,\n        'status' => DisplayStatus::ACTIVE,\n    ]);\n    \n    Board::factory()->count(1)->create([\n        'workspace_id' => $workspace->id,\n    ]);\n    \n    $response = $this->actingAs($user)\n        ->get(route('usage.index'));\n    \n    $response->assertStatus(200);\n    $response->assertViewHas('usageBreakdown');\n    $response->assertViewHas('workspace');\n});\n"
  },
  {
    "path": "backend/tests/Feature/DisplaySettingsApiTest.php",
    "content": "<?php\n\nnamespace Tests\\Feature;\n\nuse App\\Enums\\UsageType;\nuse App\\Helpers\\DisplaySettings;\nuse App\\Models\\Device;\nuse App\\Models\\Display;\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Tests\\TestCase;\n\nclass DisplaySettingsApiTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_display_api_includes_settings()\n    {\n        $user = User::factory()->create([\n            'usage_type' => UsageType::PERSONAL,\n        ]);\n        $workspace = $user->primaryWorkspace();\n\n        $display = Display::factory()->create([\n            'user_id' => $user->id,\n            'workspace_id' => $workspace->id,\n        ]);\n        $device = Device::factory()->create([\n            'user_id' => $user->id,\n            'workspace_id' => $workspace->id,\n            'display_id' => $display->id,\n        ]);\n\n        // Set some display settings\n        DisplaySettings::setCheckInEnabled($display, true);\n        DisplaySettings::setBookingEnabled($display, false);\n\n        $response = $this->actingAs($device)\n            ->getJson('/api/displays');\n\n        $response->assertStatus(200);\n        $response->assertJsonStructure([\n            'data' => [\n                '*' => [\n                    'id',\n                    'name',\n                    'settings' => [\n                        'check_in_enabled',\n                        'booking_enabled',\n                    ],\n                ],\n            ],\n        ]);\n\n        $displayData = $response->json('data.0');\n        $this->assertTrue($displayData['settings']['check_in_enabled']);\n        $this->assertFalse($displayData['settings']['booking_enabled']);\n    }\n\n    public function test_display_api_includes_default_settings_when_none_set()\n    {\n        $user = User::factory()->create([\n            'usage_type' => UsageType::PERSONAL,\n        ]);\n        $workspace = $user->primaryWorkspace();\n\n        $display = Display::factory()->create([\n            'user_id' => $user->id,\n            'workspace_id' => $workspace->id,\n        ]);\n        $device = Device::factory()->create([\n            'user_id' => $user->id,\n            'workspace_id' => $workspace->id,\n            'display_id' => $display->id,\n        ]);\n\n        $response = $this->actingAs($device)\n            ->getJson('/api/displays');\n\n        $response->assertStatus(200);\n\n        $displayData = $response->json('data.0');\n        $this->assertFalse($displayData['settings']['check_in_enabled']);\n        $this->assertFalse($displayData['settings']['booking_enabled']);\n    }\n\n    public function test_display_settings_are_encrypted_in_database()\n    {\n        $user = User::factory()->create([\n            'usage_type' => UsageType::PERSONAL,\n        ]);\n        $workspace = $user->primaryWorkspace();\n\n        $display = Display::factory()->create([\n            'user_id' => $user->id,\n            'workspace_id' => $workspace->id,\n        ]);\n        Device::factory()->create([\n            'user_id' => $user->id,\n            'workspace_id' => $workspace->id,\n            'display_id' => $display->id,\n        ]);\n\n        // Set display settings\n        DisplaySettings::setCheckInEnabled($display, true);\n        DisplaySettings::setBookingEnabled($display, true);\n\n        // Check that the raw database values are encrypted\n        $displaySetting = $display->settings()->where('key', 'check_in_enabled')->first();\n        $this->assertNotNull($displaySetting);\n        $this->assertNotEquals('true', $displaySetting->getRawOriginal('value'));\n        $this->assertTrue($displaySetting->value); // Decrypted value should be true\n    }\n}\n"
  },
  {
    "path": "backend/tests/Feature/InstanceHeartbeatTest.php",
    "content": "<?php\n\nnamespace Tests\\Feature;\n\nuse App\\Models\\Instance;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Tests\\TestCase;\n\nuses(RefreshDatabase::class);\n\ntest('instance heartbeat can include boards_count', function () {\n    $response = $this->postJson('/api/v1/instances/heartbeat', [\n        'instance_key' => 'test-instance-key',\n        'license_key' => null,\n        'license_valid' => false,\n        'license_expires_at' => null,\n        'is_self_hosted' => true,\n        'displays_count' => 5,\n        'rooms_count' => 2,\n        'boards_count' => 3,\n        'version' => '1.0.0',\n        'users' => [\n            [\n                'email' => 'test@example.com',\n                'usage_type' => 'personal',\n            ],\n        ],\n    ]);\n    \n    $response->assertStatus(200);\n    \n    $instance = Instance::where('instance_key', 'test-instance-key')->first();\n    expect($instance)->not->toBeNull();\n    expect($instance->boards_count)->toBe(3);\n});\n\ntest('instance heartbeat works without boards_count for backward compatibility', function () {\n    $response = $this->postJson('/api/v1/instances/heartbeat', [\n        'instance_key' => 'test-instance-key-2',\n        'license_key' => null,\n        'license_valid' => false,\n        'license_expires_at' => null,\n        'is_self_hosted' => true,\n        'displays_count' => 5,\n        'rooms_count' => 2,\n        'version' => '1.0.0',\n        'users' => [\n            [\n                'email' => 'test@example.com',\n                'usage_type' => 'personal',\n            ],\n        ],\n    ]);\n    \n    $response->assertStatus(200);\n    \n    $instance = Instance::where('instance_key', 'test-instance-key-2')->first();\n    expect($instance)->not->toBeNull();\n    expect($instance->boards_count)->toBeNull();\n});\n\ntest('instance heartbeat updates existing instance with boards_count', function () {\n    $instance = Instance::factory()->create([\n        'instance_key' => 'existing-instance',\n        'boards_count' => null,\n    ]);\n    \n    $response = $this->postJson('/api/v1/instances/heartbeat', [\n        'instance_key' => 'existing-instance',\n        'license_key' => null,\n        'license_valid' => false,\n        'license_expires_at' => null,\n        'is_self_hosted' => true,\n        'displays_count' => 5,\n        'rooms_count' => 2,\n        'boards_count' => 7,\n        'version' => '1.0.0',\n        'users' => [\n            [\n                'email' => 'test@example.com',\n                'usage_type' => 'personal',\n            ],\n        ],\n    ]);\n    \n    $response->assertStatus(200);\n    \n    $instance->refresh();\n    expect($instance->boards_count)->toBe(7);\n});\n"
  },
  {
    "path": "backend/tests/Feature/PageReachabilityTest.php",
    "content": "<?php\n\nnamespace Tests\\Feature;\n\nuse App\\Models\\Device;\nuse App\\Models\\Display;\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Tests\\TestCase;\n\nuses(RefreshDatabase::class);\n\ntest('it redirects to login page when visiting dashboard unauthenticated', function () {\n    $response = $this->get(route('dashboard'));\n\n    $response->assertStatus(302);\n    $response->assertRedirect(route('login'));\n});\n\ntest('it redirects to login page when visiting displays unauthenticated', function () {\n    $response = $this->get(route('displays.create'));\n\n    $response->assertStatus(302);\n    $response->assertRedirect(route('login'));\n});\n\ntest('it loads login page successfully', function () {\n    $response = $this->get(route('login'));\n\n    $response->assertStatus(200);\n    $response->assertViewIs('auth.login');\n});\n\ntest('it loads register page successfully', function () {\n    $response = $this->get(route('register'));\n\n    $response->assertStatus(200);\n    $response->assertViewIs('auth.register');\n});\n\ntest('it loads dashboard when authenticated', function () {\n    $user = User::factory()->active()->create();\n\n    $response = $this->actingAs($user)\n        ->get(route('dashboard'));\n\n    $response->assertStatus(200);\n    $response->assertViewIs('pages.dashboard');\n});\n\ntest('it loads displays page when authenticated', function () {\n    $user = User::factory()->active()->create();\n\n    $response = $this->actingAs($user)\n        ->get(route('displays.create'));\n\n    $response->assertStatus(200);\n    $response->assertViewIs('pages.displays.create');\n});\n\ntest('it redirects to dashboard when visiting login while authenticated', function () {\n    $user = User::factory()->create();\n\n    $response = $this->actingAs($user)\n        ->get(route('login'));\n\n    $response->assertStatus(302);\n    $response->assertRedirect(route('dashboard'));\n});\n\ntest('it redirects to dashboard when visiting register while authenticated', function () {\n    $user = User::factory()->create();\n\n    $response = $this->actingAs($user)\n        ->get(route('register'));\n\n    $response->assertStatus(302);\n    $response->assertRedirect(route('dashboard'));\n});\n\ntest('it handles 404 for invalid routes', function () {\n    $response = $this->get('/invalid-route');\n\n    $response->assertStatus(404);\n});\n\ntest('it handles 404 for invalid routes when authenticated', function () {\n    $user = User::factory()->create();\n\n    $response = $this->actingAs($user)\n        ->get('/invalid-route');\n\n    $response->assertStatus(404);\n});\n"
  },
  {
    "path": "backend/tests/Pest.php",
    "content": "<?php\n\n/*\n|--------------------------------------------------------------------------\n| Test Case\n|--------------------------------------------------------------------------\n|\n| The closure you provide to your test functions is always bound to a specific PHPUnit test\n| case class. By default, that class is \"PHPUnit\\Framework\\TestCase\". Of course, you may\n| need to change it using the \"pest()\" function to bind a different classes or traits.\n|\n*/\n\npest()->extend(Tests\\TestCase::class)\n    ->in('Feature', 'Unit');\n\n/*\n|--------------------------------------------------------------------------\n| Expectations\n|--------------------------------------------------------------------------\n|\n| When you're writing tests, you often need to check that values meet certain conditions. The\n| \"expect()\" function gives you access to a set of \"expectations\" methods that you can use\n| to assert different things. Of course, you may extend the Expectation API at any time.\n|\n*/\n\nexpect()->extend('toBeOne', function () {\n    return $this->toBe(1);\n});\n\n/*\n|--------------------------------------------------------------------------\n| Functions\n|--------------------------------------------------------------------------\n|\n| While Pest is very powerful out-of-the-box, you may have some testing code specific to your\n| project that you don't want to repeat in every file. Here you can also expose helpers as\n| global functions to help you to reduce the number of lines of code in your test files.\n|\n*/\n\nfunction something()\n{\n    // ..\n}\n"
  },
  {
    "path": "backend/tests/TestCase.php",
    "content": "<?php\n\nnamespace Tests;\n\nuse Illuminate\\Foundation\\Testing\\TestCase as BaseTestCase;\n\nabstract class TestCase extends BaseTestCase\n{\n    protected function setUp(): void\n    {\n        parent::setUp();\n\n        $this->withoutVite();\n    }\n}\n"
  },
  {
    "path": "backend/tests/Unit/DisplaySettingsTest.php",
    "content": "<?php\n\nnamespace Tests\\Unit;\n\nuse App\\Helpers\\DisplaySettings;\nuse App\\Models\\Display;\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Tests\\TestCase;\n\nuses(RefreshDatabase::class);\n\ntest('display settings helper can get and set boolean values', function () {\n    $user = User::factory()->create();\n    $workspace = $user->primaryWorkspace();\n    $display = Display::factory()->create([\n        'user_id' => $user->id,\n        'workspace_id' => $workspace->id,\n    ]);\n    \n    // Test setting check-in enabled\n    expect(DisplaySettings::setCheckInEnabled($display, true))->toBeTrue();\n    expect(DisplaySettings::isCheckInEnabled($display))->toBeTrue();\n    \n    // Test setting booking enabled\n    expect(DisplaySettings::setBookingEnabled($display, true))->toBeTrue();\n    expect(DisplaySettings::isBookingEnabled($display))->toBeTrue();\n    \n    // Test default values\n    $newDisplay = Display::factory()->create([\n        'user_id' => $user->id,\n        'workspace_id' => $workspace->id,\n    ]);\n    expect(DisplaySettings::isCheckInEnabled($newDisplay))->toBeFalse();\n    expect(DisplaySettings::isBookingEnabled($newDisplay))->toBeFalse();\n});\n\ntest('display model convenience methods work correctly', function () {\n    $user = User::factory()->create();\n    $workspace = $user->primaryWorkspace();\n    $display = Display::factory()->create([\n        'user_id' => $user->id,\n        'workspace_id' => $workspace->id,\n    ]);\n    \n    // Test default values\n    expect($display->isCheckInEnabled())->toBeFalse();\n    expect($display->isBookingEnabled())->toBeFalse();\n    \n    // Test setting values\n    expect($display->setCheckInEnabled(true))->toBeTrue();\n    expect($display->setBookingEnabled(true))->toBeTrue();\n    \n    // Test getting values\n    expect($display->isCheckInEnabled())->toBeTrue();\n    expect($display->isBookingEnabled())->toBeTrue();\n});\n\ntest('display settings can be retrieved as array', function () {\n    $user = User::factory()->create();\n    $workspace = $user->primaryWorkspace();\n    $display = Display::factory()->create([\n        'user_id' => $user->id,\n        'workspace_id' => $workspace->id,\n    ]);\n    \n    // Set some settings\n    DisplaySettings::setCheckInEnabled($display, true);\n    DisplaySettings::setBookingEnabled($display, false);\n    \n    $allSettings = DisplaySettings::getAllSettings($display);\n    \n    expect($allSettings)->toBeArray()\n        ->toHaveKey('check_in_enabled', true)\n        ->toHaveKey('booking_enabled', false);\n}); "
  },
  {
    "path": "backend/tests/Unit/SettingsTest.php",
    "content": "<?php\n\nnamespace Tests\\Unit;\n\nuse App\\Helpers\\Settings;\nuse App\\Models\\Setting;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Tests\\TestCase;\n\nuses(RefreshDatabase::class);\n\ntest('settings helper can get and set string values', function () {\n    $key = 'test_string';\n    $value = 'test value';\n    \n    // Test setting a value\n    expect(Settings::setSetting($key, $value))->toBeTrue();\n    \n    // Test getting the value\n    expect(Settings::getSetting($key))->toBe($value);\n    \n    // Test getting with default value\n    expect(Settings::getSetting('non_existent_key', 'default'))->toBe('default');\n});\n\ntest('settings helper can handle different types', function () {\n    // Test boolean\n    Settings::setSetting('test_bool', true, 'boolean');\n    expect(Settings::getSetting('test_bool'))->toBeTrue();\n    \n    // Test integer\n    Settings::setSetting('test_int', 42, 'integer');\n    expect(Settings::getSetting('test_int'))->toBe(42);\n    \n    // Test float\n    Settings::setSetting('test_float', 3.14, 'float');\n    expect(Settings::getSetting('test_float'))->toBe(3.14);\n    \n    // Test array\n    $array = ['key' => 'value'];\n    Settings::setSetting('test_array', $array, 'array');\n    expect(Settings::getSetting('test_array'))->toBe($array);\n});\n\ntest('settings helper can delete settings', function () {\n    $key = 'test_delete';\n    $value = 'to be deleted';\n    \n    // Set a value\n    Settings::setSetting($key, $value);\n    expect(Settings::getSetting($key))->toBe($value);\n    \n    // Delete the value\n    expect(Settings::deleteSetting($key))->toBeTrue();\n    expect(Settings::getSetting($key))->toBeNull();\n    \n    // Test deleting non-existent key\n    expect(Settings::deleteSetting('non_existent_key'))->toBeFalse();\n});\n\ntest('settings helper can get all settings', function () {\n    // Set multiple settings\n    Settings::setSetting('key1', 'value1');\n    Settings::setSetting('key2', 'value2');\n    \n    $allSettings = Settings::getAllSettings();\n    \n    expect($allSettings)->toBeArray()\n        ->toHaveCount(2)\n        ->toHaveKey('key1', 'value1')\n        ->toHaveKey('key2', 'value2');\n});"
  },
  {
    "path": "backend/tests/Unit/WorkspaceUsageTest.php",
    "content": "<?php\n\nnamespace Tests\\Unit;\n\nuse App\\Enums\\DisplayStatus;\nuse App\\Models\\Board;\nuse App\\Models\\Display;\nuse App\\Models\\User;\nuse App\\Models\\Workspace;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Tests\\TestCase;\n\nuses(RefreshDatabase::class);\n\ntest('workspace calculates total usage correctly with only displays', function () {\n    $user = User::factory()->create();\n    $workspace = $user->primaryWorkspace();\n    \n    Display::factory()->count(3)->create([\n        'workspace_id' => $workspace->id,\n        'status' => DisplayStatus::ACTIVE,\n    ]);\n    \n    $usage = $workspace->getTotalUsageCount();\n    expect($usage)->toBe(3);\n});\n\ntest('workspace calculates total usage correctly with only boards', function () {\n    $user = User::factory()->create();\n    $workspace = $user->primaryWorkspace();\n    \n    Board::factory()->count(2)->create([\n        'workspace_id' => $workspace->id,\n    ]);\n    \n    $usage = $workspace->getTotalUsageCount();\n    expect($usage)->toBe(4); // 2 boards * 2 = 4\n});\n\ntest('workspace calculates total usage correctly with displays and boards', function () {\n    $user = User::factory()->create();\n    $workspace = $user->primaryWorkspace();\n    \n    Display::factory()->count(2)->create([\n        'workspace_id' => $workspace->id,\n        'status' => DisplayStatus::ACTIVE,\n    ]);\n    \n    Board::factory()->count(3)->create([\n        'workspace_id' => $workspace->id,\n    ]);\n    \n    $usage = $workspace->getTotalUsageCount();\n    expect($usage)->toBe(8); // 2 displays + (3 boards * 2) = 8\n});\n\ntest('workspace usage breakdown includes all components', function () {\n    $user = User::factory()->create();\n    $workspace = $user->primaryWorkspace();\n    \n    Display::factory()->count(2)->create([\n        'workspace_id' => $workspace->id,\n        'status' => DisplayStatus::ACTIVE,\n    ]);\n    \n    Board::factory()->count(3)->create([\n        'workspace_id' => $workspace->id,\n    ]);\n    \n    $breakdown = $workspace->getUsageBreakdown();\n    \n    expect($breakdown['displays'])->toBe(2);\n    expect($breakdown['boards'])->toBe(3);\n    expect($breakdown['board_usage'])->toBe(6); // 3 boards * 2\n    expect($breakdown['total'])->toBe(8); // 2 + 6\n});\n\ntest('workspace usage counts all displays regardless of status', function () {\n    $user = User::factory()->create();\n    $workspace = $user->primaryWorkspace();\n    \n    Display::factory()->create([\n        'workspace_id' => $workspace->id,\n        'status' => DisplayStatus::ACTIVE,\n    ]);\n    \n    Display::factory()->create([\n        'workspace_id' => $workspace->id,\n        'status' => DisplayStatus::READY,\n    ]);\n    \n    Display::factory()->create([\n        'workspace_id' => $workspace->id,\n        'status' => DisplayStatus::DEACTIVATED,\n    ]);\n    \n    Board::factory()->count(1)->create([\n        'workspace_id' => $workspace->id,\n    ]);\n    \n    $usage = $workspace->getTotalUsageCount();\n    expect($usage)->toBe(5); // 3 displays + (1 board * 2) = 5\n});\n"
  },
  {
    "path": "backend/vite.config.js",
    "content": "import { defineConfig } from 'vite';\nimport laravel from 'laravel-vite-plugin';\nimport tailwindcss from '@tailwindcss/vite';\n\nexport default defineConfig({\n    plugins: [\n        laravel({\n            input: ['resources/css/app.css', 'resources/js/app.js'],\n            refresh: true,\n        }),\n        tailwindcss(),\n    ],\n});"
  },
  {
    "path": "deployment/docker-compose.mariadb-redis.yml",
    "content": "name: spacepad-mariadb\nservices:\n  app:\n    image: ghcr.io/magweter/spacepad:latest\n    restart: unless-stopped\n    platform: linux/amd64\n    ports:\n      - \"8080:8080\"\n    volumes:\n      - storage_data:/var/www/html/storage\n      - database_data:/var/www/html/database\n      - .env:/var/www/html/.env:ro\n    environment:\n      PHP_OPCACHE_ENABLE: 1\n      AUTORUN_ENABLED: 'true'\n      AUTORUN_LARAVEL_MIGRATION: 'true'\n    depends_on:\n      - mariadb\n\n  scheduler:\n    image: ghcr.io/magweter/spacepad:latest\n    restart: unless-stopped\n    command: [\"php\", \"/var/www/html/artisan\", \"schedule:work\"]\n    platform: linux/amd64\n    volumes:\n      - storage_data:/var/www/html/storage\n      - database_data:/var/www/html/database\n      - .env:/var/www/html/.env:ro\n    environment:\n      PHP_OPCACHE_ENABLE: 1\n    healthcheck:\n      # This is our native healthcheck script for the scheduler\n      test: [\"CMD\", \"healthcheck-schedule\"]\n      start_period: 10s\n    depends_on:\n      - mariadb\n\n  database:\n    image: mariadb:lts\n    restart: unless-stopped\n    ports:\n      - \"3306:3306\"\n    networks:\n      - backend\n    volumes:\n      - database_data:/var/lib/mysql\n    environment:\n      MARIADB_DATABASE: ${DB_DATABASE}\n      MARIADB_USER: ${DB_USERNAME}\n      MARIADB_PASSWORD: ${DB_PASSWORD}\n      MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}\n    healthcheck:\n      test: [\"CMD\", \"healthcheck.sh\", \"--connect\", \"--innodb_initialized\"]\n      start_period: 10s\n      interval: 10s\n      timeout: 5s\n      retries: 3\n\nvolumes:\n  storage_data:\n  database_data:\n  mariadb_data: "
  },
  {
    "path": "docker-compose.dev.yml",
    "content": "name: spacepad-dev\nservices:\n  app:\n    image: spacepad/app:latest\n    restart: unless-stopped\n    build:\n      context: ./backend\n      dockerfile: ./Dockerfile\n    networks:\n      - app\n    volumes:\n      - ./backend:/var/www/html\n    environment:\n      PHP_OPCACHE_ENABLE: 0\n      AUTORUN_ENABLED: 'false'\n      AUTORUN_LARAVEL_MIGRATION: 'false'\n      # OpenTelemetry configuration\n      OTEL_PHP_AUTOLOAD_ENABLED: true\n      OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf\n      OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://host.docker.internal:4318}\n      OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-}\n      OTEL_RESOURCE_ATTRIBUTES: \"service.name=spacepad-app,service.namespace=spacepad,service.version=${SPACEPAD_VERSION:-dev},deployment.environment=local\"\n      OTEL_SERVICE_NAME: spacepad-app\n      OTEL_METRICS_EXPORTER: otlp\n      OTEL_TRACES_EXPORTER: otlp\n      OTEL_LOGS_EXPORTER: otlp\n      OTEL_EXPERIMENTAL_METRIC_ENABLE: true\n    ports:\n      - \"8000:8080\"\n    extra_hosts:\n      - \"host.docker.internal:host-gateway\"\n\n  scheduler:\n    image: spacepad/app:latest\n    restart: unless-stopped\n    command: php artisan schedule:work\n    networks:\n      - app\n    volumes:\n      - ./backend:/var/www/html\n    extra_hosts:\n      - \"host.docker.internal:host-gateway\"\n    environment:\n      # OpenTelemetry configuration\n      OTEL_PHP_AUTOLOAD_ENABLED: true\n      OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf\n      OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://host.docker.internal:4318}\n      OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-}\n      OTEL_RESOURCE_ATTRIBUTES: \"service.name=spacepad-scheduler,service.namespace=spacepad,service.version=${SPACEPAD_VERSION:-dev},deployment.environment=local\"\n      OTEL_SERVICE_NAME: spacepad-scheduler\n      OTEL_METRICS_EXPORTER: otlp\n      OTEL_TRACES_EXPORTER: otlp\n      OTEL_LOGS_EXPORTER: otlp\n      OTEL_EXPERIMENTAL_METRIC_ENABLE: true\n\n  mariadb:\n    image: mariadb:lts\n    restart: unless-stopped\n    networks:\n      - app\n    environment:\n      MYSQL_ROOT_PASSWORD: root\n      MYSQL_DATABASE: spacepad\n      MYSQL_USER: spacepad\n      MYSQL_PASSWORD: spacepad\n    volumes:\n      - mariadb_data:/var/lib/mysql\n    ports:\n      - \"3306:3306\"\n\n  redis:\n    image: redis:alpine\n    restart: unless-stopped\n    networks:\n      - app\n    ports:\n      - \"6379:6379\"\n    volumes:\n      - redis_data:/data\n\n  mailhog:\n    image: mailhog/mailhog:latest\n    restart: unless-stopped\n    networks:\n      - app\n    ports:\n      - \"1025:1025\"  # SMTP server\n      - \"8025:8025\"  # Web interface\n\n  # k6 Load Generator (Continuous Traffic)\n  k6-load:\n    image: grafana/k6:latest\n    volumes:\n      - ./k6:/scripts\n    command: run /scripts/load-test.js\n    environment:\n      - BACKEND_URL=http://app:8080\n    networks:\n      - app\n    depends_on:\n      - app\n    restart: unless-stopped\n\nnetworks:\n  app:\n\nvolumes:\n  mariadb_data:\n  redis_data:"
  },
  {
    "path": "docker-compose.prod.yml",
    "content": "name: spacepad\nservices:\n  traefik:\n    image: traefik:latest\n    restart: always\n    command:\n      - \"--api.insecure=false\"\n      - \"--providers.docker=true\"\n      - \"--providers.docker.exposedbydefault=false\"\n      - \"--entrypoints.web.address=:80\"\n      - \"--entrypoints.web.http.redirections.entryPoint.to=websecure\"\n      - \"--entrypoints.web.http.redirections.entrypoint.scheme=https\"\n      - \"--entrypoints.websecure.address=:443\"\n      - \"--certificatesresolvers.mytlsresolver.acme.tlschallenge=true\"\n      - \"--certificatesresolvers.mytlsresolver.acme.email=${ACME_EMAIL}\"\n      - \"--certificatesresolvers.mytlsresolver.acme.storage=/letsencrypt/acme.json\"\n    ports:\n      - \"80:80\"\n      - \"443:443\"\n    platform: linux/amd64\n    volumes:\n      - ./traefik/letsencrypt:/letsencrypt\n      - /var/run/docker.sock:/var/run/docker.sock:ro\n    networks:\n      - traefik\n    labels:\n      - \"traefik.enable=true\"\n  \n  app:\n    image: ghcr.io/magweter/spacepad:latest\n    restart: unless-stopped\n    platform: linux/amd64\n    ports:\n      - \"8080:8080\"\n    networks:\n      - traefik\n    volumes:\n      - storage_data:/var/www/html/storage\n      - .env:/var/www/html/.env:ro\n    environment:\n      PHP_OPCACHE_ENABLE: 1\n      AUTORUN_ENABLED: 'true'\n      AUTORUN_LARAVEL_MIGRATION: 'true'\n    labels:\n      - \"traefik.enable=true\"\n      - \"traefik.http.routers.spacepad.rule=Host(`${DOMAIN}`)\"\n      - \"traefik.http.routers.spacepad.entrypoints=websecure\"\n      - \"traefik.http.routers.spacepad.tls.certresolver=mytlsresolver\"\n      - \"traefik.http.services.spacepad.loadbalancer.server.port=8080\"\n\n  scheduler:\n    image: ghcr.io/magweter/spacepad:latest\n    restart: unless-stopped\n    command: [\"php\", \"/var/www/html/artisan\", \"schedule:work\"]\n    platform: linux/amd64\n    volumes:\n      - storage_data:/var/www/html/storage\n      - .env:/var/www/html/.env:ro\n    environment:\n      PHP_OPCACHE_ENABLE: 1\n    healthcheck:\n      # This is our native healthcheck script for the scheduler\n      test: [\"CMD\", \"healthcheck-schedule\"]\n      start_period: 10s\n\nnetworks:\n  traefik:\n    name: traefik\n    driver: bridge\n\nvolumes:\n  storage_data:\n  database_data:"
  },
  {
    "path": "docker-compose.yml",
    "content": "name: spacepad\nservices:\n  app:\n    image: ghcr.io/magweter/spacepad:latest\n    restart: unless-stopped\n    platform: linux/amd64\n    ports:\n      - \"8080:8080\"\n    volumes:\n      - storage_data:/var/www/html/storage\n      - .env:/var/www/html/.env:ro\n    environment:\n      PHP_OPCACHE_ENABLE: 1\n      AUTORUN_ENABLED: 'true'\n      AUTORUN_LARAVEL_MIGRATION: 'true'\n\n  scheduler:\n    image: ghcr.io/magweter/spacepad:latest\n    restart: unless-stopped\n    command: [\"php\", \"/var/www/html/artisan\", \"schedule:work\"]\n    platform: linux/amd64\n    volumes:\n      - storage_data:/var/www/html/storage\n      - .env:/var/www/html/.env:ro\n    environment:\n      PHP_OPCACHE_ENABLE: 1\n    healthcheck:\n      # This is our native healthcheck script for the scheduler\n      test: [\"CMD\", \"healthcheck-schedule\"]\n      start_period: 10s\n\nvolumes:\n  storage_data:\n  database_data:"
  },
  {
    "path": "docs/REVERSE_PROXY.md",
    "content": "# Using Spacepad with Nginx and Apache\n\nThis guide explains how to configure Spacepad behind Nginx or Apache reverse proxies. By default, Spacepad runs on port `8080` inside the container and can be accessed directly, but using a reverse proxy provides benefits like SSL termination, better performance, and easier domain management.\n\n## Prerequisites\n\n- Spacepad container running (using `docker compose up -d` or `docker compose -f docker-compose.yml up -d`)\n- Nginx or Apache installed on your host system\n- Domain name pointing to your server (for SSL certificates)\n- Basic understanding of reverse proxy configuration\n\n## General Configuration Notes\n\n- **Container Port**: Spacepad listens on port `8080` inside the container\n- **Proxy Protocol**: The application trusts all proxies, so it will correctly handle forwarded headers\n- **Health Check**: The application exposes a health endpoint at `/health`\n- **Static Files**: Laravel handles static assets through the application, so all requests should be proxied\n\n## Nginx Configuration\n\n### Basic HTTP Configuration\n\nCreate or edit your Nginx configuration file (typically `/etc/nginx/sites-available/spacepad`):\n\n```nginx\nserver {\n    listen 80;\n    server_name your-domain.com;\n\n    # Increase body size limit for file uploads\n    client_max_body_size 100M;\n\n    location / {\n        proxy_pass http://127.0.0.1:8080;\n        proxy_http_version 1.1;\n        \n        # Headers for proper proxying\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header X-Forwarded-Host $host;\n        proxy_set_header X-Forwarded-Port $server_port;\n        \n        # WebSocket support (if needed)\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n        \n        # Timeouts\n        proxy_connect_timeout 60s;\n        proxy_send_timeout 60s;\n        proxy_read_timeout 60s;\n        \n        # Buffering\n        proxy_buffering off;\n        proxy_request_buffering off;\n    }\n}\n```\n\n### HTTPS Configuration with Let's Encrypt\n\nFor production use, you should enable HTTPS. Here's a complete configuration using Let's Encrypt:\n\n```nginx\n# HTTP server - redirect to HTTPS\nserver {\n    listen 80;\n    listen [::]:80;\n    server_name your-domain.com;\n\n    # Let's Encrypt challenge\n    location /.well-known/acme-challenge/ {\n        root /var/www/html;\n    }\n\n    # Redirect all other traffic to HTTPS\n    location / {\n        return 301 https://$server_name$request_uri;\n    }\n}\n\n# HTTPS server\nserver {\n    listen 443 ssl http2;\n    listen [::]:443 ssl http2;\n    server_name your-domain.com;\n\n    # SSL certificates (adjust paths based on your certbot setup)\n    ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;\n    ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;\n    \n    # SSL configuration\n    ssl_protocols TLSv1.2 TLSv1.3;\n    ssl_ciphers HIGH:!aNULL:!MD5;\n    ssl_prefer_server_ciphers on;\n    ssl_session_cache shared:SSL:10m;\n    ssl_session_timeout 10m;\n\n    # Security headers\n    add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always;\n    add_header X-Frame-Options \"SAMEORIGIN\" always;\n    add_header X-Content-Type-Options \"nosniff\" always;\n    add_header X-XSS-Protection \"1; mode=block\" always;\n\n    # Increase body size limit for file uploads\n    client_max_body_size 100M;\n\n    location / {\n        proxy_pass http://127.0.0.1:8080;\n        proxy_http_version 1.1;\n        \n        # Headers for proper proxying\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header X-Forwarded-Host $host;\n        proxy_set_header X-Forwarded-Port $server_port;\n        \n        # WebSocket support (if needed)\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n        \n        # Timeouts\n        proxy_connect_timeout 60s;\n        proxy_send_timeout 60s;\n        proxy_read_timeout 60s;\n        \n        # Buffering\n        proxy_buffering off;\n        proxy_request_buffering off;\n    }\n\n    # Health check endpoint\n    location /health {\n        proxy_pass http://127.0.0.1:8080/health;\n        access_log off;\n    }\n}\n```\n\n### Enabling the Configuration\n\n1. Create a symbolic link to enable the site:\n   ```bash\n   sudo ln -s /etc/nginx/sites-available/spacepad /etc/nginx/sites-enabled/\n   ```\n\n2. Test the configuration:\n   ```bash\n   sudo nginx -t\n   ```\n\n3. Reload Nginx:\n   ```bash\n   sudo systemctl reload nginx\n   ```\n\n### Obtaining SSL Certificates with Certbot\n\nIf you haven't already obtained SSL certificates:\n\n```bash\n# Install certbot\nsudo apt-get update\nsudo apt-get install certbot python3-certbot-nginx\n\n# Obtain certificate (Nginx will automatically configure SSL)\nsudo certbot --nginx -d your-domain.com\n\n# Test automatic renewal\nsudo certbot renew --dry-run\n```\n\n## Apache Configuration\n\n### Enable Required Modules\n\nFirst, enable the necessary Apache modules:\n\n```bash\nsudo a2enmod proxy\nsudo a2enmod proxy_http\nsudo a2enmod headers\nsudo a2enmod ssl\nsudo a2enmod rewrite\n```\n\n### Basic HTTP Configuration\n\nCreate or edit your Apache virtual host configuration (typically `/etc/apache2/sites-available/spacepad.conf`):\n\n```apache\n<VirtualHost *:80>\n    ServerName your-domain.com\n    \n    # Increase body size limit for file uploads\n    LimitRequestBody 104857600\n\n    ProxyPreserveHost On\n    ProxyRequests Off\n    \n    # Proxy all requests to Spacepad container\n    ProxyPass / http://127.0.0.1:8080/\n    ProxyPassReverse / http://127.0.0.1:8080/\n    \n    # Headers for proper proxying\n    RequestHeader set X-Forwarded-Proto \"http\"\n    RequestHeader set X-Forwarded-Port \"80\"\n    \n    # Logging\n    ErrorLog ${APACHE_LOG_DIR}/spacepad_error.log\n    CustomLog ${APACHE_LOG_DIR}/spacepad_access.log combined\n</VirtualHost>\n```\n\n### HTTPS Configuration with Let's Encrypt\n\nFor production use with HTTPS:\n\n```apache\n# HTTP server - redirect to HTTPS\n<VirtualHost *:80>\n    ServerName your-domain.com\n    \n    # Let's Encrypt challenge\n    <Location /.well-known/acme-challenge/>\n        ProxyPass !\n    </Location>\n    Alias /.well-known/acme-challenge/ /var/www/html/.well-known/acme-challenge/\n    \n    # Redirect all other traffic to HTTPS\n    RewriteEngine On\n    RewriteCond %{HTTPS} off\n    RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]\n</VirtualHost>\n\n# HTTPS server\n<VirtualHost *:443>\n    ServerName your-domain.com\n    \n    # SSL configuration\n    SSLEngine on\n    SSLCertificateFile /etc/letsencrypt/live/your-domain.com/fullchain.pem\n    SSLCertificateKeyFile /etc/letsencrypt/live/your-domain.com/privkey.pem\n    \n    # SSL protocol and cipher configuration\n    SSLProtocol all -SSLv2 -SSLv3\n    SSLCipherSuite HIGH:!aNULL:!MD5\n    SSLHonorCipherOrder on\n\n    # Security headers\n    Header always set Strict-Transport-Security \"max-age=31536000; includeSubDomains\"\n    Header always set X-Frame-Options \"SAMEORIGIN\"\n    Header always set X-Content-Type-Options \"nosniff\"\n    Header always set X-XSS-Protection \"1; mode=block\"\n\n    # Increase body size limit for file uploads\n    LimitRequestBody 104857600\n\n    ProxyPreserveHost On\n    ProxyRequests Off\n    \n    # Proxy all requests to Spacepad container\n    ProxyPass / http://127.0.0.1:8080/\n    ProxyPassReverse / http://127.0.0.1:8080/\n    \n    # Headers for proper proxying\n    RequestHeader set X-Forwarded-Proto \"https\"\n    RequestHeader set X-Forwarded-Port \"443\"\n    \n    # WebSocket support (if needed)\n    RewriteEngine on\n    RewriteCond %{HTTP:Upgrade} websocket [NC]\n    RewriteCond %{HTTP:Connection} upgrade [NC]\n    RewriteRule ^/?(.*) \"ws://127.0.0.1:8080/$1\" [P,L]\n    \n    # Logging\n    ErrorLog ${APACHE_LOG_DIR}/spacepad_ssl_error.log\n    CustomLog ${APACHE_LOG_DIR}/spacepad_ssl_access.log combined\n</VirtualHost>\n```\n\n### Enabling the Configuration\n\n1. Enable the site:\n   ```bash\n   sudo a2ensite spacepad.conf\n   ```\n\n2. Disable the default site (if needed):\n   ```bash\n   sudo a2dissite 000-default.conf\n   ```\n\n3. Test the configuration:\n   ```bash\n   sudo apache2ctl configtest\n   ```\n\n4. Reload Apache:\n   ```bash\n   sudo systemctl reload apache2\n   ```\n\n### Obtaining SSL Certificates with Certbot\n\nIf you haven't already obtained SSL certificates:\n\n```bash\n# Install certbot\nsudo apt-get update\nsudo apt-get install certbot python3-certbot-apache\n\n# Obtain certificate (Apache will automatically configure SSL)\nsudo certbot --apache -d your-domain.com\n\n# Test automatic renewal\nsudo certbot renew --dry-run\n```\n\n## Docker Compose Configuration\n\nWhen using a reverse proxy, you typically don't need to expose port 8080 to the host. However, if your reverse proxy is running on the same host (not in Docker), you'll need to keep the port mapping.\n\n### Option 1: Reverse Proxy on Host (Recommended)\n\nKeep the port mapping in `docker-compose.yml`:\n\n```yaml\nservices:\n  app:\n    image: ghcr.io/magweter/spacepad:latest\n    restart: unless-stopped\n    platform: linux/amd64\n    ports:\n      - \"127.0.0.1:8080:8080\"  # Only bind to localhost\n    volumes:\n      - storage_data:/var/www/html/storage\n      - .env:/var/www/html/.env:ro\n    # ... rest of configuration\n```\n\nBinding to `127.0.0.1:8080` ensures the port is only accessible from the localhost, which is more secure.\n\n### Option 2: Reverse Proxy in Docker Network\n\nIf your reverse proxy is also running in Docker, you can use a shared network:\n\n```yaml\nname: spacepad\nservices:\n  app:\n    image: ghcr.io/magweter/spacepad:latest\n    restart: unless-stopped\n    platform: linux/amd64\n    networks:\n      - proxy_network\n    volumes:\n      - storage_data:/var/www/html/storage\n      - .env:/var/www/html/.env:ro\n    # ... rest of configuration\n\nnetworks:\n  proxy_network:\n    external: true\n```\n\nThen configure your reverse proxy to use the service name `app` instead of `127.0.0.1:8080`.\n\n## Troubleshooting\n\n### Connection Refused\n\n- **Check container is running**: `docker compose ps`\n- **Verify port mapping**: `docker compose port app 8080`\n- **Check firewall**: Ensure port 80/443 are open, but 8080 can be restricted to localhost\n\n### 502 Bad Gateway\n\n- **Check container logs**: `docker compose logs app`\n- **Verify proxy_pass URL**: Ensure it matches your container's exposed port\n- **Check network connectivity**: Test with `curl http://127.0.0.1:8080/health`\n\n### SSL Certificate Issues\n\n- **Verify certificate paths**: Ensure paths in configuration match actual certificate locations\n- **Check certificate expiration**: `sudo certbot certificates`\n- **Test renewal**: `sudo certbot renew --dry-run`\n\n### Headers Not Working\n\n- **Verify proxy headers**: Ensure all `X-Forwarded-*` headers are set correctly\n- **Check Laravel trust proxies**: The application trusts all proxies by default, but verify your `.env` doesn't override this\n\n### Performance Issues\n\n- **Enable caching**: Consider adding caching headers for static assets\n- **Adjust timeouts**: Increase proxy timeouts if requests are timing out\n- **Check container resources**: Ensure Docker has adequate CPU and memory\n\n## Additional Security Considerations\n\n1. **Restrict container port**: Bind port 8080 only to `127.0.0.1` instead of `0.0.0.0`\n2. **Rate limiting**: Consider adding rate limiting in your reverse proxy configuration\n3. **IP whitelisting**: If needed, restrict access by IP in your reverse proxy\n4. **Fail2ban**: Consider setting up Fail2ban to protect against brute force attacks\n5. **Regular updates**: Keep your reverse proxy and SSL certificates up to date\n\n## Testing Your Configuration\n\nAfter configuring your reverse proxy:\n\n1. **Test HTTP redirect** (if using HTTPS):\n   ```bash\n   curl -I http://your-domain.com\n   ```\n\n2. **Test HTTPS connection**:\n   ```bash\n   curl -I https://your-domain.com\n   ```\n\n3. **Test health endpoint**:\n   ```bash\n   curl https://your-domain.com/health\n   ```\n\n4. **Verify headers**:\n   ```bash\n   curl -I https://your-domain.com | grep -i \"strict-transport\"\n   ```\n\n## Next Steps\n\nOnce your reverse proxy is configured:\n\n1. Update your `.env` file with the correct `APP_URL`:\n   ```env\n   APP_URL=https://your-domain.com\n   ```\n\n2. Restart your Spacepad containers:\n   ```bash\n   docker compose restart\n   ```\n\n3. Test the application in your browser and verify all functionality works correctly\n\n4. Set up monitoring and logging for your reverse proxy to track usage and troubleshoot issues\n\n"
  },
  {
    "path": "docs/SETUP.md",
    "content": "# Setting up your self-hosted Spacepad\n\nTo self host this application, you can deploy your own instance using Docker and Traefik out of the box.\nUsing other reverse proxies will also work, but might require a bit more configuration.\n\nGet started setting up your own self hosted (production) instance:\n\n```bash\n# Clone the repository\ngit clone https://github.com/magweter/spacepad.git\ncd spacepad\n\n# Create the environment config\ncp .env.example .env\n```\n\nSet the app key for the application:\n\n```bash\n# Linux\nsed -i \"s/^APP_KEY=.*/APP_KEY=base64:$(openssl rand -base64 32)/\" .env\n\n# macOS\nsed -i '' \"s/^APP_KEY=.*/APP_KEY=base64:$(openssl rand -base64 32)/\" .env\n\n# Windows (PowerShell)\n$appKey = \"base64:\" + [Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))\n(Get-Content .env) -replace '^APP_KEY=.*', \"APP_KEY=$appKey\" | Set-Content .env\n```\n\nNow open the .env file and configure your domain and email. Edit the DOMAIN and ACME_EMAIL variables:\n```env\nDOMAIN=\"mypublicdomain.com\"\nACME_EMAIL=\"your-email@example.com\"\n```\n\n> [!NOTE]\n> When using Microsoft as integration, you are not able to use http due to security limitations. So your server is required to use https and be publicly available.\n\nYou can log into the app using three different methods; Email, Microsoft (OAuth) or Google (OAuth).\n\nIn order to use the regular email login you should configure an email provider, as it sends a 'magic link' by email. Edit the following variables:\n```env\nMAIL_MAILER=smtp\nMAIL_HOST=\nMAIL_PORT=587\nMAIL_USERNAME=\nMAIL_PASSWORD=\nMAIL_FROM_ADDRESS=\"hello@example.com\"\n```\n\nConfiguring the following providers is optional, but you do require at least one. Leaving the client id of the provider empty will ensure it is not enabled.\n\nConfiguring the Outlook provider:\n1. Go to [Azure Portal - App Registrations](https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade/quickStartType~/null/sourceType/Microsoft_AAD_IAM)\n> [!NOTE]\n> Please ensure you have selected 'multi-tenant' for your app. Using the single tenant configuration is not yet supported.\n1. Click on 'New registration', add a name for the applicaton e.g. \"Spacepad\" and click 'register'\n1. You will be taken to the Overview Page, record the \"Application (client) ID\" as this is the \"AZURE_AD_CLIENT_ID=\"\n1. Click on the 'Authentication' tab and create two new 'web' platforms:\n    - https://your-domain.com/outlook-accounts/callback\n    - https://your-domain.com/auth/microsoft/callback\n1. Save, and click on 'API-permissions'\n1. Click 'Microsoft Graph', click 'Delegated permissions' and search for and select the following permissions `Calendars.Read.Shared`, `Place.Read.All` and `User.Read`.\n   > [!NOTE]\n   > If you want users to be able to write events back to their calendar (e.g., when booking rooms directly from the tablet display), you also need to add the `Calendars.ReadWrite.Shared` permission. This allows the application to create and modify calendar events on behalf of users.\n1. Save, and click on 'certificates and secrets'\n1. Create a new secret (not certificate) and copy the value\n1. Click on 'overview' and copy the 'client id'. Beware: this is the client ID value you need, not the ID of the secret you just created.\n1. Paste the values in the .env 'AZURE_AD...' variables\n\nConfiguring the Google provider:\n1. Go to [Google Cloud Console](https://console.cloud.google.com/)\n1. Create a new project or select an existing one\n1. Navigate to \"APIs & Services\" > \"Credentials\"\n1. Click \"Create Credentials\" > \"OAuth client ID\"\n1. Select \"Web application\" as the application type\n1. Add authorized redirect URIs:\n    - https://your-domain.com/google-accounts/callback\n    - https://your-domain.com/auth/google/callback\n1. Click \"Create\"\n1. Enable the required Google APIs:\n   - Go to \"APIs & Services\" > \"Library\"\n   - Search for and enable:\n     - Google Calendar API\n     - Google Admin SDK API\n1. Copy the Client ID and Client Secret\n1. Paste the values in your .env file:\n   - GOOGLE_CLIENT_ID=your_client_id\n   - GOOGLE_CLIENT_SECRET=your_client_secret\n\nNow you can choose to run the application with or without built-in proxy using Docker Compose.\n\nTo run the application with Traefik as a proxy:\n```bash\ndocker compose -f docker-compose.prod.yml up -d\n```\n\nTo run the application standalone (e.g. to use your own proxy):\n```bash\ndocker compose up -d\n```\n\n> [!TIP]\n> If you're using Nginx or Apache as your reverse proxy, see the [Reverse Proxy Guide](REVERSE_PROXY.md) for complete configuration instructions.\n\nGreat! You should now be able to access the application at http://localhost or without proxy at http://localhost:8080.\n\nDownload the mobile app from the App Store or Play Store and follow the instructions 🚀\n\n> **Email login security**\n> \n> If you want to disable email login (for example, to prevent spam or abuse of the email login form), you can set the following environment variable in your `.env` file:\n>\n> ```env\n> DISABLE_EMAIL_LOGIN=true\n> ```\n>\n> When this is set to `true`, users will not be able to log in or register using email. Only OAuth (Microsoft/Google) will be available.\n\n> **Restricting login to specific domains or emails**\n>\n> To restrict who can log in or register, set the `ALLOWED_LOGINS` environment variable in your `.env` file. This can be a comma-separated list of allowed email addresses and/or domains. For example:\n>\n> ```env\n> ALLOWED_LOGINS=yourcompany.com,anothercompany.com,admin@special.com\n> ```\n>\n> - To allow all users from a domain, add the domain (e.g. `yourcompany.com`).\n> - To allow a specific email, add the full email address (e.g. `admin@special.com`).\n> - Leave empty to allow all users."
  },
  {
    "path": "docs/UPGRADE_GUIDE.md",
    "content": "# Upgrade Guide\n\n## Database Tables Missing\n\nDue to incorrect mounting of a volume in pre v1.3.0 docker-compose files, changes need to be made in order to upgrade to a new self hosted Spacepad version.\n\nEdit docker compose volumes section (for both app and scheduler), and change the database volume to a file path mount:\n\n```yml\nvolumes:\n    - storage_data:/var/www/html/storage\n    - ./database.sqlite:/var/www/html/storage/database.sqlite\n    - .env:/var/www/html/.env:ro\n```\n\nThen, execute the following commands to make your database writeable for the application:\n\n```bash\nsudo chmod -R 775 database.sqlite\nsudo chown -R 33:33 database.sqlite\n```\n\nAfter having made these changes, execute the following commands:\n```bash\ndocker compose down\ndocker compose up -d\n```\n\nThe migrations should now be able to be updated by the image, thus errors regarding the missing of database tables should be fixed."
  },
  {
    "path": "k6/README.md",
    "content": "# k6 Load Testing for Spacepad\n\nk6 script for generating realistic traffic to test the Spacepad application and demonstrate the observability stack.\n\n## Script\n\n### `load-test.js`\nA unified script that can run in two modes:\n\n**Load Test Mode (default)**: Variable load with different stages (ramp up, steady state, ramp down). Good for testing under different load conditions.\n\n**Continuous Mode**: Steady, continuous traffic (2 requests/second) indefinitely. Perfect for demonstrating observability in action.\n\n## Usage\n\n### Load Test Mode (Default)\n```bash\n# Run with default settings\nk6 run k6/load-test.js\n\n# Or with custom backend URL\nBACKEND_URL=http://localhost:8000 k6 run k6/load-test.js\n\n# Via docker\ndocker run --rm -i --network spacepad-dev_app \\\n  -v $(pwd)/k6:/scripts \\\n  -e BACKEND_URL=http://app:8080 \\\n  -e CONNECT_CODE=100001 \\\n  grafana/k6 run /scripts/load-test.js\n```\n\n### Continuous Mode\n```bash\n# Set CONTINUOUS=true to enable continuous mode\nCONTINUOUS=true BACKEND_URL=http://localhost:8000 k6 run k6/load-test.js\n\n# Via docker\ndocker run --rm -i --network spacepad-dev_app \\\n  -v $(pwd)/k6:/scripts \\\n  -e BACKEND_URL=http://app:8080 \\\n  -e CONNECT_CODE=100001 \\\n  -e CONTINUOUS=true \\\n  grafana/k6 run /scripts/load-test.js\n```\n\n## Configuration\n\nThe script can be configured via environment variables:\n\n- `BACKEND_URL`: Backend API URL (default: `http://localhost:8000`)\n- `CONNECT_CODE`: Connect code for authentication (default: `100001`)\n- `CONTINUOUS`: Set to `true` or `1` to enable continuous mode (default: `false`)\n\n## Authentication\n\nThe script automatically authenticates using the connect code system:\n1. Each Virtual User (VU) authenticates once during setup using `/api/auth/login` with connect code `100001`\n2. The authentication token is stored and reused for all API requests\n3. The scripts fetch available displays and use the first display for testing\n4. Includes retry logic (up to 3 attempts) for robust authentication\n5. Automatic recovery if authentication is lost during execution\n\n## Traffic Patterns\n\nThe script simulates realistic user behavior:\n\n- **Display Data API** (`/api/displays/{display}/data`): 70% of requests\n  - Most frequently used endpoint\n  - Requires authentication token\n  - Returns display calendar data and events\n\n- **Dashboard Page** (`/`): 30% of requests\n  - Web dashboard page\n  - Simulates user browsing\n\nEach request includes:\n- Proper authentication headers (for API endpoints)\n- Random think time (simulates user reading/thinking)\n- Proper headers (User-Agent, Accept)\n- OpenTelemetry trace correlation\n\n## Load Test Mode Stages\n\nWhen running in load test mode (default), the script follows these stages:\n- Ramp up to 5 users (30s)\n- Ramp up to 10 users (2m)\n- Stay at 10 users (5m)\n- Ramp up to 20 users (2m)\n- Stay at 20 users (5m)\n- Ramp down to 10 users (2m)\n- Stay at 10 users (5m)\n\n## Continuous Mode\n\nWhen running in continuous mode (`CONTINUOUS=true`):\n- Generates 2 requests per second\n- Runs for 24 hours (effectively continuous)\n- Pre-allocates 5 VUs, scales up to 20 VUs if needed\n- Lower think time (0.5-2s) for higher throughput\n\n## Observing Traffic\n\nWith k6 running, you can observe:\n\n1. **Grafana** (http://localhost:3000):\n   - Traces in Tempo showing request flows\n   - Metrics in Prometheus showing request rates, latencies\n   - Service maps showing service dependencies\n   - Logs in Loki showing application logs\n\n2. **Prometheus** (http://localhost:9090):\n   - Query: `rate(http_requests_total[1m])`\n   - Query: `histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))`\n\n3. **Tempo** (via Grafana):\n   - Search for traces from `spacepad-app`\n   - View trace details and spans\n   - Filter by route: `/api/displays/{display}/data` or `/`\n\n4. **Loki** (via Grafana):\n   - Search for logs from the application\n   - View log entries with trace context\n\n## Troubleshooting\n\n### Authentication Failures\n- Ensure connect code `100001` exists and is valid\n- Check that the user associated with the connect code has displays configured\n- Verify the `BACKEND_URL` is correct\n- The script includes retry logic, but check logs for persistent failures\n\n### No Displays Available\n- The script will skip API calls if no displays are found\n- Ensure the authenticated user has at least one display configured\n- Check display status (should be READY or ACTIVE)\n\n### High Error Rates\n- Check application logs for errors\n- Verify database connectivity\n- Ensure all required services are running\n- Check network connectivity between k6 and the backend\n\n### VUs Failing Authentication\n- The script retries authentication up to 3 times\n- Check backend logs for authentication errors\n- Verify the connect code is valid and not expired\n- Ensure the backend is accessible from k6\n\n## Customization\n\nEdit the script to:\n- Change request rates (modify `rate` in continuous mode or `stages` in load test mode)\n- Modify the endpoint distribution (currently 70% API, 30% dashboard)\n- Add more endpoints\n- Modify user behavior patterns\n- Add custom metrics\n- Change load patterns\n"
  },
  {
    "path": "k6/load-test.js",
    "content": "/**\n * k6 Load Test Script for Spacepad\n * \n * Tests the dashboard page and the /api/displays/{display}/data endpoint\n * Uses connect code 100001 for authentication\n * \n * Can run in two modes:\n * - Load test mode (default): Variable load with stages\n * - Continuous mode: Steady continuous load (set CONTINUOUS=true)\n */\n\nimport http from 'k6/http';\nimport { check, sleep } from 'k6';\nimport { Rate, Trend, Counter } from 'k6/metrics';\n\n// Custom metrics\nconst errorRate = new Rate('errors');\nconst requestDuration = new Trend('request_duration');\nconst requestsCounter = new Counter('requests_total');\n\n// Configuration\nconst CONTINUOUS_MODE = __ENV.CONTINUOUS === 'true' || __ENV.CONTINUOUS === '1';\nconst BASE_URL = __ENV.BACKEND_URL || 'http://localhost:8000';\nconst CONNECT_CODE = __ENV.CONNECT_CODE || '100001';\n\n// Different execution patterns based on mode\nexport const options = CONTINUOUS_MODE ? {\n  scenarios: {\n    continuous_load: {\n      executor: 'constant-arrival-rate',\n      rate: 2,                    // 2 requests per second\n      timeUnit: '1s',\n      duration: '24h',            // Run for 24 hours (effectively continuous)\n      preAllocatedVUs: 5,         // Pre-allocate 5 VUs\n      maxVUs: 20,                 // Max 20 VUs if needed\n    },\n  },\n  thresholds: {\n    http_req_duration: ['p(95)<1000'],\n    http_req_failed: ['rate<0.1'],\n  },\n} : {\n  stages: [\n    { duration: '30s', target: 5 },   // Ramp up to 5 users\n    { duration: '2m', target: 10 },    // Ramp up to 10 users\n    { duration: '5m', target: 10 },    // Stay at 10 users\n    { duration: '2m', target: 20 },    // Ramp up to 20 users\n    { duration: '5m', target: 20 },    // Stay at 20 users\n    { duration: '2m', target: 10 },    // Ramp down to 10 users\n    { duration: '5m', target: 10 },    // Stay at 10 users\n  ],\n  thresholds: {\n    http_req_duration: ['p(95)<500', 'p(99)<1000'], // 95% of requests under 500ms, 99% under 1s\n    http_req_failed: ['rate<0.05'],                   // Less than 5% errors\n    errors: ['rate<0.05'],\n  },\n};\n\n// Shared state for VU - stores auth token and display ID\nlet authToken = null;\nlet displayId = null;\n\n// Setup function - runs once per VU to authenticate\nexport function setup() {\n  if (!CONTINUOUS_MODE) {\n    console.log(`Starting k6 load test against ${BASE_URL}`);\n  }\n  \n  // Authenticate once per VU with retry logic\n  const deviceUid = `k6-device-${__VU}-${Date.now()}`;\n  const deviceName = CONTINUOUS_MODE ? `k6-continuous-${__VU}` : `k6-load-test-${__VU}`;\n  \n  let token = null;\n  let displayIdToUse = null;\n  \n  // Retry authentication up to 3 times\n  for (let attempt = 1; attempt <= 3; attempt++) {\n    const loginResponse = http.post(\n      `${BASE_URL}/api/auth/login`,\n      JSON.stringify({\n        code: CONNECT_CODE,\n        uid: deviceUid,\n        name: deviceName,\n      }),\n      {\n        headers: {\n          'Content-Type': 'application/json',\n          'Accept': 'application/json',\n        },\n        tags: {\n          endpoint: '/api/auth/login',\n          test_type: 'setup',\n        },\n        timeout: '10s',\n      }\n    );\n\n    const loginSuccess = check(loginResponse, {\n      'login status is 200': (r) => r.status === 200,\n      'login has token': (r) => {\n        try {\n          const body = JSON.parse(r.body);\n          return body.data && body.data.token !== undefined;\n        } catch {\n          return false;\n        }\n      },\n    });\n\n    if (loginSuccess) {\n      try {\n        const loginBody = JSON.parse(loginResponse.body);\n        token = loginBody.data.token;\n        if (!CONTINUOUS_MODE) {\n          console.log(`VU ${__VU} authenticated successfully on attempt ${attempt}`);\n        }\n        break;\n      } catch (e) {\n        console.error(`VU ${__VU} failed to parse login response on attempt ${attempt}: ${e}`);\n      }\n    } else {\n      console.error(`VU ${__VU} authentication failed on attempt ${attempt}: ${loginResponse.status} - ${loginResponse.body}`);\n      if (attempt < 3) {\n        sleep(1); // Wait before retry\n      }\n    }\n  }\n\n  if (!token) {\n    console.error(`VU ${__VU} failed to authenticate after 3 attempts`);\n    return { baseUrl: BASE_URL, token: null, displayId: null };\n  }\n\n  // Get displays list to find a display ID with retry\n  for (let attempt = 1; attempt <= 3; attempt++) {\n    const displaysResponse = http.get(\n      `${BASE_URL}/api/displays`,\n      {\n        headers: {\n          'Authorization': `Bearer ${token}`,\n          'Accept': 'application/json',\n        },\n        tags: {\n          endpoint: '/api/displays',\n          test_type: 'setup',\n        },\n        timeout: '10s',\n      }\n    );\n\n    const displaysSuccess = check(displaysResponse, {\n      'displays status is 200': (r) => r.status === 200,\n      'displays has data': (r) => {\n        try {\n          const body = JSON.parse(r.body);\n          return body.data && Array.isArray(body.data) && body.data.length > 0;\n        } catch {\n          return false;\n        }\n      },\n    });\n\n    if (displaysSuccess) {\n      try {\n        const displaysBody = JSON.parse(displaysResponse.body);\n        if (displaysBody.data && displaysBody.data.length > 0) {\n          displayIdToUse = displaysBody.data[0].id;\n          if (!CONTINUOUS_MODE) {\n            console.log(`VU ${__VU} found display ID: ${displayIdToUse}`);\n          }\n          break;\n        } else {\n          console.warn(`VU ${__VU} authenticated but no displays available`);\n        }\n      } catch (e) {\n        console.error(`VU ${__VU} failed to parse displays response on attempt ${attempt}: ${e}`);\n      }\n    } else {\n      console.error(`VU ${__VU} failed to get displays on attempt ${attempt}: ${displaysResponse.status} - ${displaysResponse.body}`);\n      if (attempt < 3) {\n        sleep(1); // Wait before retry\n      }\n    }\n  }\n\n  return {\n    baseUrl: BASE_URL,\n    token: token,\n    displayId: displayIdToUse,\n  };\n}\n\n// Main test function\nexport default function (data) {\n  // Use token and displayId from setup\n  authToken = data.token;\n  displayId = data.displayId;\n\n  if (!authToken) {\n    // If no token, try to re-authenticate (might be a transient issue)\n    const deviceUid = `k6-device-${__VU}-${Date.now()}`;\n    const deviceName = CONTINUOUS_MODE ? `k6-continuous-${__VU}` : `k6-load-test-${__VU}`;\n    \n    const loginResponse = http.post(\n      `${BASE_URL}/api/auth/login`,\n      JSON.stringify({\n        code: CONNECT_CODE,\n        uid: deviceUid,\n        name: deviceName,\n      }),\n      {\n        headers: {\n          'Content-Type': 'application/json',\n          'Accept': 'application/json',\n        },\n        tags: {\n          endpoint: '/api/auth/login',\n          test_type: 'recovery',\n        },\n        timeout: '10s',\n      }\n    );\n\n    if (loginResponse.status === 200) {\n      try {\n        const loginBody = JSON.parse(loginResponse.body);\n        authToken = loginBody.data.token;\n        data.token = authToken; // Update data for future iterations\n        if (!CONTINUOUS_MODE) {\n          console.log(`VU ${__VU} recovered authentication`);\n        }\n      } catch (e) {\n        console.error(`VU ${__VU} failed to parse recovery login response: ${e}`);\n      }\n    }\n\n    if (!authToken) {\n      console.error(`VU ${__VU} has no auth token, skipping iteration`);\n      sleep(1);\n      return;\n    }\n  }\n\n  // Weighted endpoint selection\n  // 70% of requests go to the display data endpoint (most used)\n  // 30% go to dashboard page\n  const useDisplayData = Math.random() < 0.7;\n\n  let url, params, endpoint;\n\n  if (useDisplayData && displayId) {\n    // Call the display data endpoint\n    endpoint = `/api/displays/${displayId}/data`;\n    url = `${BASE_URL}${endpoint}`;\n    params = {\n      headers: {\n        'Authorization': `Bearer ${authToken}`,\n        'Accept': 'application/json',\n        'User-Agent': CONTINUOUS_MODE ? `k6-continuous/${__VU}` : `k6-load-test/${__VU}`,\n      },\n      tags: {\n        endpoint: endpoint,\n        test_type: 'api',\n        load_type: CONTINUOUS_MODE ? 'continuous' : 'load_test',\n      },\n    };\n  } else {\n    // Call the dashboard page\n    endpoint = '/';\n    url = `${BASE_URL}${endpoint}`;\n    params = {\n      headers: {\n        'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',\n        'User-Agent': CONTINUOUS_MODE ? `k6-continuous/${__VU}` : `k6-load-test/${__VU}`,\n      },\n      tags: {\n        endpoint: endpoint,\n        test_type: 'web',\n        load_type: CONTINUOUS_MODE ? 'continuous' : 'load_test',\n      },\n    };\n  }\n\n  const startTime = Date.now();\n  const response = http.get(url, params);\n  const duration = Date.now() - startTime;\n\n  requestsCounter.add(1, { endpoint: endpoint });\n  requestDuration.add(duration, { endpoint: endpoint });\n\n  const success = check(response, {\n    'status is 200 or 302': (r) => r.status === 200 || r.status === 302,\n    'response time < 2000ms': (r) => r.timings.duration < 2000,\n  });\n\n  // For API endpoints, also check for valid JSON\n  if (useDisplayData && displayId) {\n    const jsonCheck = check(response, {\n      'has valid JSON': (r) => {\n        try {\n          JSON.parse(r.body);\n          return true;\n        } catch {\n          return false;\n        }\n      },\n      'has display data': (r) => {\n        try {\n          const body = JSON.parse(r.body);\n          return body.data !== undefined;\n        } catch {\n          return false;\n        }\n      },\n    });\n    errorRate.add(!success || !jsonCheck);\n  } else {\n    errorRate.add(!success);\n  }\n\n  // Simulate user think time\n  // Continuous mode: 0.5-2 seconds, Load test mode: 1-3 seconds\n  const thinkTime = CONTINUOUS_MODE \n    ? Math.random() * 1.5 + 0.5 \n    : Math.random() * 2 + 1;\n  sleep(thinkTime);\n}\n\n// Teardown function - runs once after all VUs finish\nexport function teardown(data) {\n  const mode = CONTINUOUS_MODE ? 'continuous load test' : 'load test';\n  console.log(`${mode} completed for ${data.baseUrl}`);\n  if (data.displayId) {\n    console.log(`Tested display ID: ${data.displayId}`);\n  }\n}\n"
  },
  {
    "path": "k6/tags.js",
    "content": "/**\n * k6 StatsD Tags Script\n * Adds custom tags to metrics for better observability\n */\n\nexport function tags(data) {\n  return {\n    test_type: 'continuous_load',\n    service: 'spacepad-app',\n  };\n}\n\n"
  }
]