diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e08cd5e..45d40a5 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -25,7 +25,11 @@ "bmewburn.vscode-intelephense-client", "onecentlin.laravel-blade", "shufo.vscode-blade-formatter", - "bradlc.vscode-tailwindcss" + "bradlc.vscode-tailwindcss", + "Anthropic.claude-code", + "adrianwilczynski.alpine-js-intellisense", + "onecentlin.laravel-extension-pack", + "cierra.livewire-vscode" ] } }, diff --git a/.env.example b/.env.example index 35db1dd..3ca587d 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,8 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +# Testing: Bei true wird die Datenbank nicht zurückgesetzt (DatabaseTransactions statt RefreshDatabase). +# Nützlich in der Entwicklung, um Seed-Daten und manuelle Testeinträge zu erhalten. +# Hinweis: Migrationen müssen mindestens einmal für die Test-DB ausgeführt werden: DB_DATABASE=testing sail artisan migrate +# TESTING_PRESERVE_DATABASE=false diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..5678bb6 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,27 @@ +{ + "mcpServers": { + "laravel-boost": { + "command": "php", + "args": [ + "artisan", + "boost:mcp" + ] + }, + "context7": { + "command": "npx", + "args": [ + "-y", + "@upstash/context7-mcp", + "--api-key", + "ctx7sk-119cd4ab-8983-4229-8702-e84c59c34fc9" + ] + }, + "sequential-thinking": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-sequential-thinking" + ] + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 501d160..eb0312a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,26 @@ This is a multi-domain Laravel application with Fortify/Sanctum authentication. - **Stileigentum**: stileigentum.test - Frontend landing page (uses TailwindCSS + Tailwind UI/Plus) - **Style2own**: style2own.test - Frontend landing page (uses TailwindCSS + Tailwind UI/Plus) +## Environment + +This project runs inside a **Dev Container**. Commands are executed directly with `php`, `composer`, and `npm` — **without** the `vendor/bin/sail` prefix. All `sail`-prefixed commands in docs or rules should be translated to their direct equivalents: + +| Sail | Dev Container | +|------|---------------| +| `vendor/bin/sail artisan ...` | `php artisan ...` | +| `vendor/bin/sail composer ...` | `composer ...` | +| `vendor/bin/sail npm ...` | `npm ...` | +| `vendor/bin/sail php ...` | `php ...` | +| `vendor/bin/sail bin pint` | `./vendor/bin/pint` | + +## Testing + +Tests laufen auf **SQLite in-memory** — die MySQL-Datenbank wird dabei **nie angefasst**. + +- Der Test-Bootstrap (`tests/bootstrap.php`) löscht automatisch den Config-Cache vor jedem Test-Run, damit die SQLite-Konfiguration aus `phpunit.xml` greift. +- Nach dem Test-Run ist der Config-Cache weg. Bei Bedarf neu generieren: `php artisan config:cache` +- Zum Testen: `php artisan test --compact` (ganzer Suite) oder `php artisan test --compact tests/Feature/FooTest.php` (einzelne Datei) + ## Development Commands ### Setup & Installation @@ -127,3 +147,530 @@ For local development, add domains to your hosts file: - `FORTIFY-SANCTUM-SETUP.md`: Authentication setup guide - `composer.json`: Contains useful dev scripts (`composer run dev`, `composer run test`) - Multiple Vite/Tailwind configs for different build targets + +=== + + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. + +## Foundational Context +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.4.13 +- laravel/fortify (FORTIFY) - v1 +- laravel/framework (LARAVEL) - v12 +- laravel/prompts (PROMPTS) - v0 +- laravel/sanctum (SANCTUM) - v4 +- livewire/flux (FLUXUI_FREE) - v2 +- livewire/flux-pro (FLUXUI_PRO) - v2 +- livewire/livewire (LIVEWIRE) - v4 +- livewire/volt (VOLT) - v1 +- laravel/dusk (DUSK) - v8 +- laravel/mcp (MCP) - v0 +- laravel/pint (PINT) - v1 +- laravel/sail (SAIL) - v1 +- pestphp/pest (PEST) - v3 +- phpunit/phpunit (PHPUNIT) - v11 +- alpinejs (ALPINEJS) - v3 +- tailwindcss (TAILWINDCSS) - v4 + +## docs +https://livewire.laravel.com/docs/4.x/ +https://fluxui.dev/docs/ + +## Conventions +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts +- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +## Application Structure & Architecture +- Stick to existing directory structure; don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them. + +## Replies +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +## Documentation Files +- You must only create documentation files if explicitly requested by the user. + +=== boost rules === + +## Laravel Boost +- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. + +## Artisan +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters. + +## URLs +- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port. + +## Tinker / Debugging +- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. + +## Reading Browser Logs With the `browser-logs` Tool +- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. +- Only recent browser logs will be useful - ignore old logs. + +## Searching Documentation (Critically Important) +- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. +- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. +- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches. +- Search the documentation before making code changes to ensure we are taking the correct approach. +- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. +- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. + +### Available Search Syntax +- You can and should pass multiple queries at once. The most relevant results will be returned first. + +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'. +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit". +3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order. +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit". +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms. + +=== php rules === + +## PHP + +- Always use curly braces for control structures, even if it has one line. + +### Constructors +- Use PHP 8 constructor property promotion in `__construct()`. + - public function __construct(public GitHub $github) { } +- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private. + +### Type Declarations +- Always use explicit return type declarations for methods and functions. +- Use appropriate PHP type hints for method parameters. + + +protected function isAccessible(User $user, ?string $path = null): bool +{ + ... +} + + +## Comments +- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on. + +## PHPDoc Blocks +- Add useful array shape type definitions for arrays when appropriate. + +## Enums +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. + +=== sail rules === + +## Laravel Sail + +- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail. +- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`. +- Open the application in the browser by running `vendor/bin/sail open`. +- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples: + - Run Artisan Commands: `vendor/bin/sail artisan migrate` + - Install Composer packages: `vendor/bin/sail composer install` + - Execute Node commands: `vendor/bin/sail npm run dev` + - Execute PHP scripts: `vendor/bin/sail php [script]` +- View all available Sail commands by running `vendor/bin/sail` without arguments. + +=== tests rules === + +## Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter. + +=== laravel/core rules === + +## Do Things the Laravel Way + +- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Database +- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. +- Use Eloquent models and relationships before suggesting raw database queries. +- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. +- Generate code that prevents N+1 query problems by using eager loading. +- Use Laravel's query builder for very complex database operations. + +### Model Creation +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`. + +### APIs & Eloquent Resources +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +### Controllers & Validation +- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- Check sibling Form Requests to see if the application uses array or string based validation rules. + +### Queues +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +### Authentication & Authorization +- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). + +### URL Generation +- When generating links to other pages, prefer named routes and the `route()` function. + +### Configuration +- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. + +### Testing +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +### Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`. + +=== laravel/v12 rules === + +## Laravel 12 + +- Use the `search-docs` tool to get version-specific documentation. +- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. + +### Laravel 12 Structure +- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`. +- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` contains application specific service providers. +- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration. +- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration. + +### Database +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + +=== fluxui-pro/core rules === + +## Flux UI Pro + +- This project is using the Pro version of Flux UI. It has full access to the free components and variants, as well as full access to the Pro components and variants. +- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize. +- You should use Flux UI components when available. +- Fallback to standard Blade components if Flux is unavailable. +- If available, use the `search-docs` tool to get the exact documentation and code snippets available for this project. +- Flux UI components look like this: + + + + + +### Available Components +This is correct as of Boost installation, but there may be additional components within the codebase. + + +accordion, autocomplete, avatar, badge, brand, breadcrumbs, button, calendar, callout, card, chart, checkbox, command, composer, context, date-picker, dropdown, editor, field, file-upload, heading, icon, input, kanban, modal, navbar, otp-input, pagination, pillbox, popover, profile, radio, select, separator, skeleton, slider, switch, table, tabs, text, textarea, time-picker, toast, tooltip + + +=== livewire/core rules === + +## Livewire + +- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests. +- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components. +- State should live on the server, with the UI reflecting it. +- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions. + +## Livewire Best Practices +- Livewire components require a single root element. +- Use `wire:loading` and `wire:dirty` for delightful loading states. +- Add `wire:key` in loops: + + ```blade + @foreach ($items as $item) +
+ {{ $item->name }} +
+ @endforeach + ``` + +- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects: + + + public function mount(User $user) { $this->user = $user; } + public function updatedSearch() { $this->resetPage(); } + + +## Testing Livewire + + + Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1) + ->assertSee(1) + ->assertStatus(200); + + + + $this->get('/posts/create') + ->assertSeeLivewire(CreatePost::class); + + +=== volt/core rules === + +## Livewire Volt + +- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt. +- Make new Volt components using `vendor/bin/sail artisan make:volt [name] [--test] [--pest]`. +- Volt is a class-based and functional API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to coexist in the same file. +- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@volt` directive. +- You must check existing Volt components to determine if they're functional or class-based. If you can't detect that, ask the user which they prefer before writing a Volt component. + +### Volt Functional Component Example + + +@volt + 0]); + +$increment = fn () => $this->count++; +$decrement = fn () => $this->count--; + +$double = computed(fn () => $this->count * 2); +?> + +
+

Count: {{ $count }}

+

Double: {{ $this->double }}

+ + +
+@endvolt +
+ +### Volt Class Based Component Example +To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax: + + +use Livewire\Volt\Component; + +new class extends Component { + public $count = 0; + + public function increment() + { + $this->count++; + } +} ?> + +
+

{{ $count }}

+ +
+
+ +### Testing Volt & Volt Components +- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`. + + +use Livewire\Volt\Volt; + +test('counter increments', function () { + Volt::test('counter') + ->assertSee('Count: 0') + ->call('increment') + ->assertSee('Count: 1'); +}); + + + +declare(strict_types=1); + +use App\Models\{User, Product}; +use Livewire\Volt\Volt; + +test('product form creates product', function () { + $user = User::factory()->create(); + + Volt::test('pages.products.create') + ->actingAs($user) + ->set('form.name', 'Test Product') + ->set('form.description', 'Test Description') + ->set('form.price', 99.99) + ->call('create') + ->assertHasNoErrors(); + + expect(Product::where('name', 'Test Product')->exists())->toBeTrue(); +}); + + +### Common Patterns + + + null, 'search' => '']); + +$products = computed(fn() => Product::when($this->search, + fn($q) => $q->where('name', 'like', "%{$this->search}%") +)->get()); + +$edit = fn(Product $product) => $this->editing = $product->id; +$delete = fn(Product $product) => $product->delete(); + +?> + + + + + + + + + + + Save + Saving... + + + +=== pint/core rules === + +## Laravel Pint Code Formatter + +- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues. + +=== pest/core rules === + +## Pest +### Testing +- If you need to verify a feature is working, write or update a Unit / Feature test. + +### Pest Tests +- All tests must be written using Pest. Use `vendor/bin/sail artisan make:test --pest {name}`. +- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. +- Tests should test all of the happy paths, failure paths, and weird paths. +- Tests live in the `tests/Feature` and `tests/Unit` directories. +- Pest tests look and behave like this: + +it('is true', function () { + expect(true)->toBeTrue(); +}); + + +### Running Tests +- Run the minimal number of tests using an appropriate filter before finalizing code edits. +- To run all tests: `vendor/bin/sail artisan test --compact`. +- To run all tests in a file: `vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`. +- To filter on a particular test name: `vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file). +- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. + +### Pest Assertions +- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: + +it('returns all', function () { + $response = $this->postJson('/api/docs', []); + + $response->assertSuccessful(); +}); + + +### Mocking +- Mocking can be very helpful when appropriate. +- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. +- You can also create partial mocks using the same import or self method. + +### Datasets +- Use datasets in Pest to simplify tests that have a lot of duplicated data. This is often the case when testing validation rules, so consider this solution when writing tests for validation rules. + + +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); + + +=== tailwindcss/core rules === + +## Tailwind CSS + +- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own. +- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.). +- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically. +- You can use the `search-docs` tool to get exact examples from the official documentation when needed. + +### Spacing +- When listing items, use gap utilities for spacing; don't use margins. + + +
+
Superior
+
Michigan
+
Erie
+
+
+ +### Dark Mode +- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. + +=== tailwindcss/v4 rules === + +## Tailwind CSS 4 + +- Always use Tailwind CSS v4; do not use the deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. +- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed. + + +@theme { + --color-brand: oklch(0.72 0.11 178); +} + + +- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: + + + - @tailwind base; + - @tailwind components; + - @tailwind utilities; + + @import "tailwindcss"; + + +### Replaced Utilities +- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement. +- Opacity values are still numeric. + +| Deprecated | Replacement | +|------------+--------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | +
diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 7bf18d0..4ba8e1f 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -2,6 +2,7 @@ namespace App\Actions\Fortify; +use App\Enums\UserOrigin; use App\Models\User; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Validator; @@ -31,10 +32,15 @@ class CreateNewUser implements CreatesNewUsers 'password' => $this->passwordRules(), ])->validate(); + $theme = config('app.theme', ''); + $origin = UserOrigin::tryFrom($theme); + return User::create([ 'name' => $input['name'], 'email' => $input['email'], 'password' => Hash::make($input['password']), + 'origin' => $origin?->value, + 'hub_id' => $input['hub_id'] ?? null, ]); } } diff --git a/app/Casts/PartnerTypeCast.php b/app/Casts/PartnerTypeCast.php new file mode 100644 index 0000000..a6c20c8 --- /dev/null +++ b/app/Casts/PartnerTypeCast.php @@ -0,0 +1,44 @@ + $attributes + */ + public function get(Model $model, string $key, mixed $value, array $attributes): ?PartnerType + { + if ($value === null || $value === '') { + return null; + } + + $normalized = match (strtolower((string) $value)) { + 'retailer' => PartnerType::Retailer->value, + 'manufacturer' => PartnerType::Manufacturer->value, + 'estate-agent', 'broker' => PartnerType::EstateAgent->value, + 'customer' => PartnerType::Customer->value, + default => $value, + }; + + return PartnerType::tryFrom($normalized); + } + + /** + * @param array $attributes + */ + public function set(Model $model, string $key, mixed $value, array $attributes): ?string + { + if ($value === null) { + return null; + } + + return $value instanceof PartnerType ? $value->value : (string) $value; + } +} diff --git a/app/Enums/CurationStatus.php b/app/Enums/CurationStatus.php new file mode 100644 index 0000000..4c60a2e --- /dev/null +++ b/app/Enums/CurationStatus.php @@ -0,0 +1,28 @@ + 'Ausstehend', + self::Approved => 'Freigegeben', + self::Rejected => 'Abgelehnt', + }; + } + + public function color(): string + { + return match ($this) { + self::Pending => 'yellow', + self::Approved => 'green', + self::Rejected => 'red', + }; + } +} diff --git a/app/Enums/PartnerType.php b/app/Enums/PartnerType.php new file mode 100644 index 0000000..9d6ec99 --- /dev/null +++ b/app/Enums/PartnerType.php @@ -0,0 +1,21 @@ + 'Händler', + self::Manufacturer => 'Hersteller', + self::EstateAgent => 'Makler', + self::Customer => 'Kunde', + }; + } +} diff --git a/app/Enums/PriceType.php b/app/Enums/PriceType.php new file mode 100644 index 0000000..4803c34 --- /dev/null +++ b/app/Enums/PriceType.php @@ -0,0 +1,19 @@ + 'Festpreis', + self::FromPrice => 'Ab-Preis', + self::OnRequest => 'Preis auf Anfrage', + }; + } +} diff --git a/app/Enums/ProductStatus.php b/app/Enums/ProductStatus.php new file mode 100644 index 0000000..364748e --- /dev/null +++ b/app/Enums/ProductStatus.php @@ -0,0 +1,37 @@ + 'Entwurf', + self::Pending => 'In Prüfung', + self::Correction => 'Korrektur nötig', + self::Active => 'Freigegeben', + self::Archived => 'Archiviert', + self::Sold => 'Verkauft', + }; + } + + public function color(): string + { + return match ($this) { + self::Draft => 'yellow', + self::Pending => 'blue', + self::Correction => 'orange', + self::Active => 'green', + self::Archived => 'zinc', + self::Sold => 'red', + }; + } +} diff --git a/app/Enums/ProductType.php b/app/Enums/ProductType.php new file mode 100644 index 0000000..d90a416 --- /dev/null +++ b/app/Enums/ProductType.php @@ -0,0 +1,54 @@ + 'Local Express (Lagerware)', + self::SmartOrder => 'Smart Club (Konfiguration)', + }; + } + + /** + * Typ A (LocalStock) erfordert zwingend ein Ticket für den Ladenbesuch. + * Typ B (SmartOrder) kann direkt online bestellt werden – Ticket optional. + */ + public function requiresTicket(): bool + { + return match ($this) { + self::LocalStock => true, + self::SmartOrder => false, + }; + } + + /** + * Erlaubte Preistypen je Produkttyp. + * + * Typ A (Teaser): Kein Festpreis online möglich – nur Ab-Preis oder Preis auf Anfrage. + * Typ B (Standard): Alle Preistypen erlaubt. + * + * @return array + */ + public function allowedPriceTypes(): array + { + return match ($this) { + self::LocalStock => [PriceType::FromPrice, PriceType::OnRequest], + self::SmartOrder => [PriceType::Fixed, PriceType::FromPrice, PriceType::OnRequest], + }; + } +} diff --git a/app/Enums/UserOrigin.php b/app/Enums/UserOrigin.php new file mode 100644 index 0000000..104c220 --- /dev/null +++ b/app/Enums/UserOrigin.php @@ -0,0 +1,28 @@ + 'Style2Own', + self::StilEigentum => 'StilEigentum', + }; + } + + /** + * @return 'du'|'sie' + */ + public function tonality(): string + { + return match ($this) { + self::Style2Own => 'du', + self::StilEigentum => 'sie', + }; + } +} diff --git a/app/Http/Middleware/BasicAuthMiddleware.php b/app/Http/Middleware/BasicAuthMiddleware.php index 6c7e383..72b8350 100644 --- a/app/Http/Middleware/BasicAuthMiddleware.php +++ b/app/Http/Middleware/BasicAuthMiddleware.php @@ -21,9 +21,13 @@ class BasicAuthMiddleware if ( str_starts_with($path, 'livewire/') || + str_starts_with($path, 'livewire-') || str_contains($path, '/livewire/') || + str_contains($path, '/livewire-') || $request->is('livewire/*') || - $request->is('*/livewire/*') + $request->is('livewire-*') || + $request->is('*/livewire/*') || + $request->is('*/livewire-*') ) { return $next($request); } diff --git a/app/Models/Attribute.php b/app/Models/Attribute.php index f1d50c3..f3852d6 100644 --- a/app/Models/Attribute.php +++ b/app/Models/Attribute.php @@ -3,8 +3,20 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; class Attribute extends Model { - // + protected $fillable = [ + 'name', + 'slug', + ]; + + /** + * Ein Attribut hat viele Werte (z.B. "Farbe" → "Rot", "Blau", "Grün"). + */ + public function values(): HasMany + { + return $this->hasMany(AttributeValue::class); + } } diff --git a/app/Models/AttributeValue.php b/app/Models/AttributeValue.php index fa2254a..2900de0 100644 --- a/app/Models/AttributeValue.php +++ b/app/Models/AttributeValue.php @@ -3,8 +3,30 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; class AttributeValue extends Model { - // + protected $fillable = [ + 'attribute_id', + 'value', + 'slug', + ]; + + /** + * Gehört zu einem Attribut. + */ + public function attribute(): BelongsTo + { + return $this->belongsTo(Attribute::class); + } + + /** + * Kann mehreren Produkt-Varianten zugeordnet sein. + */ + public function productVariants(): BelongsToMany + { + return $this->belongsToMany(ProductVariant::class, 'product_variant_attributes'); + } } diff --git a/app/Models/Category.php b/app/Models/Category.php index 6a8a406..c940a0b 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -2,9 +2,44 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; class Category extends Model { - // + use HasFactory; + + protected $fillable = [ + 'parent_id', + 'name', + 'slug', + 'description', + ]; + + /** + * Übergeordnete Kategorie. + */ + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + /** + * Untergeordnete Kategorien. + */ + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id'); + } + + /** + * Produkte in dieser Kategorie. + */ + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class); + } } diff --git a/app/Models/Collection.php b/app/Models/Collection.php index 17edcb3..cee2b1a 100644 --- a/app/Models/Collection.php +++ b/app/Models/Collection.php @@ -3,8 +3,21 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; class Collection extends Model { - // + protected $fillable = [ + 'name', + 'slug', + 'description', + ]; + + /** + * Produkte in dieser Kollektion. + */ + public function products(): HasMany + { + return $this->hasMany(Product::class); + } } diff --git a/app/Models/Hub.php b/app/Models/Hub.php index c994573..495854f 100644 --- a/app/Models/Hub.php +++ b/app/Models/Hub.php @@ -33,4 +33,24 @@ class Hub extends Model { return $this->hasMany(Partner::class); } + + /** + * Ein Hub hat viele direkt zugeordnete User (Kunden). + */ + public function users(): HasMany + { + return $this->hasMany(User::class); + } + + /** + * Ein Hub hat viele Produkte. + */ + public function products(): HasMany + { + return $this->hasMany(Product::class); + } + + protected $casts = [ + 'is_active' => 'boolean', + ]; } diff --git a/app/Models/Media.php b/app/Models/Media.php new file mode 100644 index 0000000..8e3a5b1 --- /dev/null +++ b/app/Models/Media.php @@ -0,0 +1,36 @@ + 'integer', + ]; + } + + /** + * Polymorphe Beziehung zum Eltern-Model (Product, Partner, etc.). + */ + public function model(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Models/Partner.php b/app/Models/Partner.php index 36ecb9b..53872a2 100644 --- a/app/Models/Partner.php +++ b/app/Models/Partner.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Casts\PartnerTypeCast; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -39,12 +40,19 @@ class Partner extends Model 'assembly_radius_km', 'provision_fixed_amount', 'provision_rate_percentage', + 'story_text', + 'opening_hours', + 'specialties', + 'founded_year', ]; protected $casts = [ + 'type' => PartnerTypeCast::class, 'is_active' => 'boolean', 'setup_completed' => 'boolean', 'setup_completed_at' => 'datetime', + 'opening_hours' => 'array', + 'specialties' => 'array', ]; /** @@ -104,9 +112,19 @@ class Partner extends Model return $this->hasOne(Brand::class); } - // TODO: Später die Beziehung zu Products hinzufügen - // public function products(): HasMany - // { - // return $this->hasMany(Product::class); - // } + /** + * Ein Partner hat viele Produkte. + */ + public function products(): HasMany + { + return $this->hasMany(Product::class); + } + + /** + * Polymorphe Beziehung zu Media (Team-Fotos, Showroom-Galerie, etc.). + */ + public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany + { + return $this->morphMany(Media::class, 'model'); + } } diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 0000000..bbbe157 --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,246 @@ + + */ + protected function casts(): array + { + return [ + 'product_type' => ProductType::class, + 'status' => ProductStatus::class, + 'price_type' => PriceType::class, + 'dimensions_specific' => 'array', + 'is_curated' => 'boolean', + 'curated_at' => 'datetime', + 'is_available' => 'boolean', + 'productIsAvailable' => 'boolean', + 'certificates' => 'array', + 'assembly_service' => 'boolean', + 'is_regional_production' => 'boolean', + 'visible_from' => 'date', + 'visible_until' => 'date', + 'co2_footprint_kg' => 'decimal:2', + ]; + } + + /** + * Produkt gehört zu einem Partner (Händler oder Hersteller). + */ + public function partner(): BelongsTo + { + return $this->belongsTo(Partner::class); + } + + /** + * Produkt gehört zu einer Marke. + */ + public function brand(): BelongsTo + { + return $this->belongsTo(Brand::class); + } + + /** + * Produkt gehört zu einer Kollektion. + */ + public function collection(): BelongsTo + { + return $this->belongsTo(Collection::class); + } + + /** + * Produkt gehört zu einem Hub (regionale Zuordnung). + */ + public function hub(): BelongsTo + { + return $this->belongsTo(Hub::class); + } + + /** + * User, der das Produkt kuratiert/freigegeben hat. + */ + public function curator(): BelongsTo + { + return $this->belongsTo(User::class, 'curated_by'); + } + + /** + * Produkt kann mehreren Kategorien zugeordnet sein. + */ + public function categories(): BelongsToMany + { + return $this->belongsToMany(Category::class); + } + + /** + * Produkt kann mehrere Tags haben. + */ + public function tags(): BelongsToMany + { + return $this->belongsToMany(Tag::class); + } + + /** + * Produkt hat mehrere Varianten (Farben, Größen, etc.). + */ + public function variants(): HasMany + { + return $this->hasMany(ProductVariant::class); + } + + /** + * Polymorphe Beziehung zu Media (Bilder, Videos, PDFs). + */ + public function media(): MorphMany + { + return $this->morphMany(Media::class, 'model'); + } + + /** + * Holzherkunft-Einträge für EUDR-Compliance. + */ + public function woodOrigins(): HasMany + { + return $this->hasMany(ProductWoodOrigin::class); + } + + /** + * Änderungshistorie. + */ + public function activities(): HasMany + { + return $this->hasMany(ProductActivity::class); + } + + /** + * Verwandte Produkte. + */ + public function relatedProducts(): BelongsToMany + { + return $this->belongsToMany(self::class, 'related_products', 'product_id', 'related_product_id'); + } + + /** + * Scope: Nur aktive Produkte. + */ + public function scopeActive(Builder $query): void + { + $query->where('status', ProductStatus::Active); + } + + /** + * Scope: Nur kuratierte/freigegebene Produkte. + */ + public function scopeCurated(Builder $query): void + { + $query->where('is_curated', true); + } + + /** + * Scope: Nur Produkte, die auf Freigabe warten. + */ + public function scopePending(Builder $query): void + { + $query->where('status', ProductStatus::Pending); + } + + /** + * Scope: Nur Local Stock Produkte (Säule A). + */ + public function scopeLocalStock(Builder $query): void + { + $query->where('product_type', ProductType::LocalStock); + } + + /** + * Scope: Nur Smart Order Produkte (Säule B). + */ + public function scopeSmartOrder(Builder $query): void + { + $query->where('product_type', ProductType::SmartOrder); + } + + /** + * Scope: Produkte in einem bestimmten Hub. + */ + public function scopeInHub(Builder $query, int $hubId): void + { + $query->where('hub_id', $hubId); + } + + /** + * Scope: Verfügbare Produkte (aktiv, kuratiert, verfügbar). + */ + public function scopeAvailable(Builder $query): void + { + $query->active()->curated()->where('is_available', true); + } +} diff --git a/app/Models/ProductActivity.php b/app/Models/ProductActivity.php new file mode 100644 index 0000000..aaf6595 --- /dev/null +++ b/app/Models/ProductActivity.php @@ -0,0 +1,29 @@ +belongsTo(Product::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/ProductLogistics.php b/app/Models/ProductLogistics.php new file mode 100644 index 0000000..722305e --- /dev/null +++ b/app/Models/ProductLogistics.php @@ -0,0 +1,50 @@ + 'integer', + 'is_palletizable' => 'boolean', + ]; + } + + /** + * Gehört zu einer Produkt-Variante. + */ + public function productVariant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class); + } + + /** + * Versandklasse. + */ + public function shippingClass(): BelongsTo + { + return $this->belongsTo(ShippingClass::class); + } +} diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php index ac6b298..7d17433 100644 --- a/app/Models/ProductVariant.php +++ b/app/Models/ProductVariant.php @@ -3,8 +3,89 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Database\Eloquent\Relations\MorphMany; class ProductVariant extends Model { - // + protected $fillable = [ + 'product_id', + 'name_suffix', + 'is_master_variant', + 'sku', + 'han_mpn', + 'ean_gtin', + 'selling_price', + 'msrp', + 'purchase_price', + 'tax_rate_id', + 'stock_quantity', + 'stock_min_threshold', + 'availability_status', + 'delivery_time_text', + 'currency', + 'is_rentable', + 'rental_duration_options', + 'rental_rate_formula', + 'residual_value_percentage', + 'variant_weight_g', + 'is_active', + ]; + + protected function casts(): array + { + return [ + 'is_master_variant' => 'boolean', + 'selling_price' => 'integer', + 'msrp' => 'integer', + 'purchase_price' => 'integer', + 'stock_quantity' => 'integer', + 'is_rentable' => 'boolean', + 'rental_duration_options' => 'array', + 'residual_value_percentage' => 'decimal:2', + 'is_active' => 'boolean', + ]; + } + + /** + * Gehört zu einem Produkt. + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * Steuersatz der Variante. + */ + public function taxRate(): BelongsTo + { + return $this->belongsTo(TaxRate::class); + } + + /** + * Attribut-Werte dieser Variante (z.B. Farbe: Rot, Größe: L). + */ + public function attributeValues(): BelongsToMany + { + return $this->belongsToMany(AttributeValue::class, 'product_variant_attributes'); + } + + /** + * Logistik-Daten der Variante (1:1). + */ + public function logistics(): HasOne + { + return $this->hasOne(ProductLogistics::class); + } + + /** + * Bilder der Variante. + */ + public function media(): MorphMany + { + return $this->morphMany(Media::class, 'model'); + } } diff --git a/app/Models/ProductWoodOrigin.php b/app/Models/ProductWoodOrigin.php new file mode 100644 index 0000000..c2cdd13 --- /dev/null +++ b/app/Models/ProductWoodOrigin.php @@ -0,0 +1,48 @@ + */ + use HasFactory; + + protected $fillable = [ + 'product_id', + 'wood_species', + 'origin_country', + 'origin_region', + 'harvest_year', + 'forest_operator', + 'sustainability_certificate', + 'eudr_reference_id', + ]; + + protected function casts(): array + { + return [ + 'harvest_year' => 'integer', + ]; + } + + /** + * Gehört zu einem Produkt. + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * EUDR-Dokumente (polymorphe Beziehung via Media). + */ + public function media(): MorphMany + { + return $this->morphMany(Media::class, 'model'); + } +} diff --git a/app/Models/Setting.php b/app/Models/Setting.php new file mode 100644 index 0000000..592f94e --- /dev/null +++ b/app/Models/Setting.php @@ -0,0 +1,55 @@ +where('group', $group) + ->where('key', $key) + ->first(); + + if (! $setting) { + return $default; + } + + return match ($setting->type) { + 'integer' => (int) $setting->value, + 'boolean' => filter_var($setting->value, FILTER_VALIDATE_BOOLEAN), + 'json' => json_decode($setting->value, true), + default => $setting->value, + }; + } + + /** + * Setze einen Setting-Wert. + */ + public static function setValue(string $group, string $key, mixed $value): void + { + $setting = self::query() + ->where('group', $group) + ->where('key', $key) + ->first(); + + if ($setting) { + $setting->update([ + 'value' => is_array($value) ? json_encode($value) : (string) $value, + ]); + } + } +} diff --git a/app/Models/ShippingClass.php b/app/Models/ShippingClass.php index 3dd5911..b573db8 100644 --- a/app/Models/ShippingClass.php +++ b/app/Models/ShippingClass.php @@ -3,8 +3,20 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; class ShippingClass extends Model { - // + protected $fillable = [ + 'name', + 'description', + ]; + + /** + * Logistik-Einträge mit dieser Versandklasse. + */ + public function productLogistics(): HasMany + { + return $this->hasMany(ProductLogistics::class); + } } diff --git a/app/Models/Tag.php b/app/Models/Tag.php index bb26fdd..18cf900 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -3,8 +3,20 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; class Tag extends Model { - // + protected $fillable = [ + 'name', + 'slug', + ]; + + /** + * Produkte mit diesem Tag. + */ + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class); + } } diff --git a/app/Models/TaxRate.php b/app/Models/TaxRate.php index a9ba5e3..ac093b1 100644 --- a/app/Models/TaxRate.php +++ b/app/Models/TaxRate.php @@ -3,8 +3,29 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; class TaxRate extends Model { - // + protected $fillable = [ + 'name', + 'rate_percentage', + 'is_default', + ]; + + protected function casts(): array + { + return [ + 'rate_percentage' => 'decimal:2', + 'is_default' => 'boolean', + ]; + } + + /** + * Varianten mit diesem Steuersatz. + */ + public function productVariants(): HasMany + { + return $this->hasMany(ProductVariant::class); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 11cff19..cd14824 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,10 +2,13 @@ namespace App\Models; +use App\Enums\UserOrigin; use App\Notifications\CustomResetPasswordNotification; use App\Notifications\CustomVerifyEmailNotification; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -13,13 +16,11 @@ use Illuminate\Support\Str; use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Sanctum\HasApiTokens; use Spatie\Permission\Traits\HasRoles; -use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Relations\HasOne; class User extends Authenticatable implements MustVerifyEmail { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasApiTokens, HasFactory, HasRoles, Notifiable, TwoFactorAuthenticatable, SoftDeletes; + use HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable; /** * The attributes that are mass assignable. @@ -28,6 +29,8 @@ class User extends Authenticatable implements MustVerifyEmail */ protected $fillable = [ 'partner_id', + 'hub_id', + 'origin', 'name', 'display_name', 'email', @@ -56,6 +59,7 @@ class User extends Authenticatable implements MustVerifyEmail 'email_verified_at' => 'datetime', 'deleted_at' => 'datetime', 'password' => 'hashed', + 'origin' => UserOrigin::class, ]; } @@ -64,6 +68,14 @@ class User extends Authenticatable implements MustVerifyEmail return $this->belongsTo(Partner::class); } + /** + * Direkte Hub-Zuordnung des Users (für schnelle Queries). + */ + public function hub(): BelongsTo + { + return $this->belongsTo(Hub::class); + } + /** * Get the registration code used by this user */ @@ -79,7 +91,7 @@ class User extends Authenticatable implements MustVerifyEmail { return Str::of($this->name) ->explode(' ') - ->map(fn(string $name) => Str::of($name)->substr(0, 1)) + ->map(fn (string $name) => Str::of($name)->substr(0, 1)) ->implode(''); } @@ -89,9 +101,9 @@ class User extends Authenticatable implements MustVerifyEmail public function anonymize(): void { $this->update([ - 'name' => 'Gelöschter Benutzer #' . $this->id, + 'name' => 'Gelöschter Benutzer #'.$this->id, 'display_name' => null, - 'email' => 'deleted_' . $this->id . '@anonymized.local', + 'email' => 'deleted_'.$this->id.'@anonymized.local', 'password' => bcrypt(Str::random(64)), ]); @@ -118,7 +130,6 @@ class User extends Authenticatable implements MustVerifyEmail * Send the password reset notification. * * @param string $token - * @return void */ public function sendPasswordResetNotification($token): void { @@ -127,8 +138,6 @@ class User extends Authenticatable implements MustVerifyEmail /** * Send the email verification notification. - * - * @return void */ public function sendEmailVerificationNotification(): void { diff --git a/app/Policies/PartnerPolicy.php b/app/Policies/PartnerPolicy.php new file mode 100644 index 0000000..f16a961 --- /dev/null +++ b/app/Policies/PartnerPolicy.php @@ -0,0 +1,67 @@ +hasAnyRole(['Admin', 'Super-Admin']); + } + + /** + * Admins sehen alle Partner; Partner sehen ihren eigenen Eintrag. + */ + public function view(User $user, Partner $partner): bool + { + if ($user->hasAnyRole(['Admin', 'Super-Admin'])) { + return true; + } + + return $user->partner_id === $partner->id; + } + + /** + * Nur Admins können neue Partner direkt anlegen (normale Partner werden eingeladen). + */ + public function create(User $user): bool + { + return $user->hasAnyRole(['Admin', 'Super-Admin']); + } + + /** + * Admins können jeden Partner bearbeiten. + * Partner können ihr eigenes Profil bearbeiten. + */ + public function update(User $user, Partner $partner): bool + { + if ($user->hasAnyRole(['Admin', 'Super-Admin'])) { + return true; + } + + return $user->partner_id === $partner->id; + } + + /** + * Nur Admins können Partner löschen. + */ + public function delete(User $user, Partner $partner): bool + { + return $user->hasAnyRole(['Admin', 'Super-Admin']); + } + + /** + * Produkte eines Partners kuratieren (freigeben/ablehnen). + */ + public function curateProducts(User $user): bool + { + return $user->hasPermissionTo('curate products'); + } +} diff --git a/app/Policies/ProductPolicy.php b/app/Policies/ProductPolicy.php new file mode 100644 index 0000000..d0f4431 --- /dev/null +++ b/app/Policies/ProductPolicy.php @@ -0,0 +1,73 @@ +hasAnyRole(['Admin', 'Super-Admin', 'Retailer', 'Manufacturer']); + } + + /** + * Admins sehen alle Produkte; Partner sehen nur ihre eigenen. + */ + public function view(User $user, Product $product): bool + { + if ($user->hasAnyRole(['Admin', 'Super-Admin'])) { + return true; + } + + return $product->partner_id === $user->partner_id; + } + + /** + * Retailer dürfen Teaser-Produkte (Typ A) anlegen. + * Manufacturer dürfen Konfigurations-Produkte (Typ B) anlegen. + * Admins dürfen beide Typen anlegen. + */ + public function create(User $user): bool + { + return $user->hasAnyRole(['Admin', 'Super-Admin', 'Retailer', 'Manufacturer']); + } + + /** + * Admins können alle Produkte bearbeiten. + * Partner können nur ihre eigenen Produkte bearbeiten. + */ + public function update(User $user, Product $product): bool + { + if ($user->hasAnyRole(['Admin', 'Super-Admin'])) { + return true; + } + + return $product->partner_id === $user->partner_id; + } + + /** + * Admins können alle Produkte löschen. + * Partner können nur ihre eigenen Produkte löschen. + */ + public function delete(User $user, Product $product): bool + { + if ($user->hasAnyRole(['Admin', 'Super-Admin'])) { + return true; + } + + return $product->partner_id === $user->partner_id; + } + + /** + * Nur Admins können Produkte kuratieren (freigeben/ablehnen). + */ + public function curate(User $user): bool + { + return $user->hasPermissionTo('curate products'); + } +} diff --git a/boost.json b/boost.json new file mode 100644 index 0000000..98a4cb6 --- /dev/null +++ b/boost.json @@ -0,0 +1,12 @@ +{ + "agents": [ + "claude_code", + "cursor" + ], + "editors": [ + "claude_code", + "cursor" + ], + "guidelines": [], + "sail": true +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 77e223e..078ae11 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -6,11 +6,15 @@ use Illuminate\Foundation\Configuration\Middleware; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( - web: __DIR__ . '/../routes/domains.php', - commands: __DIR__ . '/../routes/console.php', + web: __DIR__.'/../routes/domains.php', + commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware) { + // Traefik Reverse-Proxy vertraut die X-Forwarded-Proto/For Headers + // damit $request->url() korrekt https:// zurückgibt (nötig für signierte Upload-URLs) + $middleware->trustProxies(at: '*'); + // Domain-URL Konfiguration - muss ganz am Anfang ausgeführt werden // um sicherzustellen, dass url() und asset() die richtige Domain verwenden $middleware->prepend(\App\Http\Middleware\SetDomainUrl::class); diff --git a/composer.json b/composer.json index 25e3ef2..f8f0d53 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "require-dev": { "barryvdh/laravel-debugbar": "^3.16", "fakerphp/faker": "^1.23.1", + "laravel/boost": "^1.8", "laravel/dusk": "^8.2", "laravel/pail": "^1.2.2", "laravel/pint": "^1.18", @@ -53,7 +54,8 @@ "@php artisan package:discover --ansi" ], "post-update-cmd": [ - "@php artisan vendor:publish --tag=laravel-assets --ansi --force" + "@php artisan vendor:publish --tag=laravel-assets --ansi --force", + "@php artisan boost:update --ansi" ], "post-root-package-install": [ "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" diff --git a/composer.lock b/composer.lock index fcd38df..a959fe3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "dec4bfb2c36983f51725d04db995a549", + "content-hash": "821f090295ee13d40f6e4692d39690d6", "packages": [ { "name": "bacon/bacon-qr-code", @@ -132,16 +132,16 @@ }, { "name": "blade-ui-kit/blade-icons", - "version": "1.8.0", + "version": "1.8.1", "source": { "type": "git", "url": "https://github.com/driesvints/blade-icons.git", - "reference": "7b743f27476acb2ed04cb518213d78abe096e814" + "reference": "47e7b6f43250e6404e4224db8229219cd42b543c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/driesvints/blade-icons/zipball/7b743f27476acb2ed04cb518213d78abe096e814", - "reference": "7b743f27476acb2ed04cb518213d78abe096e814", + "url": "https://api.github.com/repos/driesvints/blade-icons/zipball/47e7b6f43250e6404e4224db8229219cd42b543c", + "reference": "47e7b6f43250e6404e4224db8229219cd42b543c", "shasum": "" }, "require": { @@ -188,7 +188,7 @@ } ], "description": "A package to easily make use of icons in your Laravel Blade views.", - "homepage": "https://github.com/blade-ui-kit/blade-icons", + "homepage": "https://github.com/driesvints/blade-icons", "keywords": [ "blade", "icons", @@ -196,8 +196,8 @@ "svg" ], "support": { - "issues": "https://github.com/blade-ui-kit/blade-icons/issues", - "source": "https://github.com/blade-ui-kit/blade-icons" + "issues": "https://github.com/driesvints/blade-icons/issues", + "source": "https://github.com/driesvints/blade-icons" }, "funding": [ { @@ -209,20 +209,20 @@ "type": "paypal" } ], - "time": "2025-02-13T20:35:06+00:00" + "time": "2026-01-20T09:46:32+00:00" }, { "name": "brick/math", - "version": "0.14.1", + "version": "0.14.8", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "f05858549e5f9d7bb45875a75583240a38a281d0" + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/f05858549e5f9d7bb45875a75583240a38a281d0", - "reference": "f05858549e5f9d7bb45875a75583240a38a281d0", + "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", "shasum": "" }, "require": { @@ -261,7 +261,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.1" + "source": "https://github.com/brick/math/tree/0.14.8" }, "funding": [ { @@ -269,7 +269,7 @@ "type": "github" } ], - "time": "2025-11-24T14:40:29+00:00" + "time": "2026-02-10T14:33:43+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -836,24 +836,24 @@ }, { "name": "graham-campbell/result-type", - "version": "v1.1.3", + "version": "v1.1.4", "source": { "type": "git", "url": "https://github.com/GrahamCampbell/Result-Type.git", - "reference": "3ba905c11371512af9d9bdd27d99b782216b6945" + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945", - "reference": "3ba905c11371512af9d9bdd27d99b782216b6945", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.3" + "phpoption/phpoption": "^1.9.5" }, "require-dev": { - "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" }, "type": "library", "autoload": { @@ -882,7 +882,7 @@ ], "support": { "issues": "https://github.com/GrahamCampbell/Result-Type/issues", - "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3" + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" }, "funding": [ { @@ -894,7 +894,7 @@ "type": "tidelift" } ], - "time": "2024-07-20T21:45:45+00:00" + "time": "2025-12-27T19:43:20+00:00" }, { "name": "guzzlehttp/guzzle", @@ -1309,28 +1309,28 @@ }, { "name": "laravel/fortify", - "version": "v1.33.0", + "version": "v1.34.1", "source": { "type": "git", "url": "https://github.com/laravel/fortify.git", - "reference": "e0666dabeec0b6428678af1d51f436dcfb24e3a9" + "reference": "412575e9c0cb21d49a30b7045ad4902019f538c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/fortify/zipball/e0666dabeec0b6428678af1d51f436dcfb24e3a9", - "reference": "e0666dabeec0b6428678af1d51f436dcfb24e3a9", + "url": "https://api.github.com/repos/laravel/fortify/zipball/412575e9c0cb21d49a30b7045ad4902019f538c2", + "reference": "412575e9c0cb21d49a30b7045ad4902019f538c2", "shasum": "" }, "require": { "bacon/bacon-qr-code": "^3.0", "ext-json": "*", - "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", "php": "^8.1", - "pragmarx/google2fa": "^9.0", - "symfony/console": "^6.0|^7.0" + "pragmarx/google2fa": "^9.0" }, "require-dev": { - "orchestra/testbench": "^8.36|^9.15|^10.8", + "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", "phpstan/phpstan": "^1.10" }, "type": "library", @@ -1368,20 +1368,20 @@ "issues": "https://github.com/laravel/fortify/issues", "source": "https://github.com/laravel/fortify" }, - "time": "2025-12-15T14:48:33+00:00" + "time": "2026-02-03T06:55:55+00:00" }, { "name": "laravel/framework", - "version": "v12.43.1", + "version": "v12.51.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "195b893593a9298edee177c0844132ebaa02102f" + "reference": "ce4de3feb211e47c4f959d309ccf8a2733b1bc16" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/195b893593a9298edee177c0844132ebaa02102f", - "reference": "195b893593a9298edee177c0844132ebaa02102f", + "url": "https://api.github.com/repos/laravel/framework/zipball/ce4de3feb211e47c4f959d309ccf8a2733b1bc16", + "reference": "ce4de3feb211e47c4f959d309ccf8a2733b1bc16", "shasum": "" }, "require": { @@ -1494,7 +1494,7 @@ "league/flysystem-sftp-v3": "^3.25.1", "mockery/mockery": "^1.6.10", "opis/json-schema": "^2.4.1", - "orchestra/testbench-core": "^10.8.1", + "orchestra/testbench-core": "^10.9.0", "pda/pheanstalk": "^5.0.6|^7.0.0", "php-http/discovery": "^1.15", "phpstan/phpstan": "^2.0", @@ -1590,34 +1590,34 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-12-16T18:53:08+00:00" + "time": "2026-02-10T18:20:19+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.8", + "version": "v0.3.13", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "096748cdfb81988f60090bbb839ce3205ace0d35" + "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/096748cdfb81988f60090bbb839ce3205ace0d35", - "reference": "096748cdfb81988f60090bbb839ce3205ace0d35", + "url": "https://api.github.com/repos/laravel/prompts/zipball/ed8c466571b37e977532fb2fd3c272c784d7050d", + "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", "ext-mbstring": "*", "php": "^8.1", - "symfony/console": "^6.2|^7.0" + "symfony/console": "^6.2|^7.0|^8.0" }, "conflict": { "illuminate/console": ">=10.17.0 <10.25.0", "laravel/framework": ">=10.17.0 <10.25.0" }, "require-dev": { - "illuminate/collections": "^10.0|^11.0|^12.0", + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", "mockery/mockery": "^1.5", "pestphp/pest": "^2.3|^3.4|^4.0", "phpstan/phpstan": "^1.12.28", @@ -1647,36 +1647,36 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.8" + "source": "https://github.com/laravel/prompts/tree/v0.3.13" }, - "time": "2025-11-21T20:52:52+00:00" + "time": "2026-02-06T12:17:10+00:00" }, { "name": "laravel/sanctum", - "version": "v4.2.1", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/laravel/sanctum.git", - "reference": "f5fb373be39a246c74a060f2cf2ae2c2145b3664" + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sanctum/zipball/f5fb373be39a246c74a060f2cf2ae2c2145b3664", - "reference": "f5fb373be39a246c74a060f2cf2ae2c2145b3664", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", "shasum": "" }, "require": { "ext-json": "*", - "illuminate/console": "^11.0|^12.0", - "illuminate/contracts": "^11.0|^12.0", - "illuminate/database": "^11.0|^12.0", - "illuminate/support": "^11.0|^12.0", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", "php": "^8.2", - "symfony/console": "^7.0" + "symfony/console": "^7.0|^8.0" }, "require-dev": { "mockery/mockery": "^1.6", - "orchestra/testbench": "^9.15|^10.8", + "orchestra/testbench": "^9.15|^10.8|^11.0", "phpstan/phpstan": "^1.10" }, "type": "library", @@ -1712,31 +1712,31 @@ "issues": "https://github.com/laravel/sanctum/issues", "source": "https://github.com/laravel/sanctum" }, - "time": "2025-11-21T13:59:03+00:00" + "time": "2026-02-07T17:19:31+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.7", + "version": "v2.0.9", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd" + "reference": "8f631589ab07b7b52fead814965f5a800459cb3e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/cb291e4c998ac50637c7eeb58189c14f5de5b9dd", - "reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/8f631589ab07b7b52fead814965f5a800459cb3e", + "reference": "8f631589ab07b7b52fead814965f5a800459cb3e", "shasum": "" }, "require": { "php": "^8.1" }, "require-dev": { - "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", "nesbot/carbon": "^2.67|^3.0", "pestphp/pest": "^2.36|^3.0|^4.0", "phpstan/phpstan": "^2.0", - "symfony/var-dumper": "^6.2.0|^7.0.0" + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" }, "type": "library", "extra": { @@ -1773,20 +1773,20 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2025-11-21T20:52:36+00:00" + "time": "2026-02-03T06:55:34+00:00" }, { "name": "laravel/tinker", - "version": "v2.10.2", + "version": "v2.11.1", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "3bcb5f62d6f837e0f093a601e26badafb127bd4c" + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/3bcb5f62d6f837e0f093a601e26badafb127bd4c", - "reference": "3bcb5f62d6f837e0f093a601e26badafb127bd4c", + "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", "shasum": "" }, "require": { @@ -1795,7 +1795,7 @@ "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", "php": "^7.2.5|^8.0", "psy/psysh": "^0.11.1|^0.12.0", - "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0" + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" }, "require-dev": { "mockery/mockery": "~1.3.3|^1.4.2", @@ -1837,9 +1837,9 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.10.2" + "source": "https://github.com/laravel/tinker/tree/v2.11.1" }, - "time": "2025-11-20T16:29:12+00:00" + "time": "2026-02-06T14:12:35+00:00" }, { "name": "league/commonmark", @@ -2032,16 +2032,16 @@ }, { "name": "league/flysystem", - "version": "3.30.2", + "version": "3.31.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277" + "reference": "1717e0b3642b0df65ecb0cc89cdd99fa840672ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277", - "reference": "5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/1717e0b3642b0df65ecb0cc89cdd99fa840672ff", + "reference": "1717e0b3642b0df65ecb0cc89cdd99fa840672ff", "shasum": "" }, "require": { @@ -2109,22 +2109,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.30.2" + "source": "https://github.com/thephpleague/flysystem/tree/3.31.0" }, - "time": "2025-11-10T17:13:11+00:00" + "time": "2026-01-23T15:38:47+00:00" }, { "name": "league/flysystem-local", - "version": "3.30.2", + "version": "3.31.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-local.git", - "reference": "ab4f9d0d672f601b102936aa728801dd1a11968d" + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/ab4f9d0d672f601b102936aa728801dd1a11968d", - "reference": "ab4f9d0d672f601b102936aa728801dd1a11968d", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", "shasum": "" }, "require": { @@ -2158,9 +2158,9 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.2" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" }, - "time": "2025-11-10T11:23:37+00:00" + "time": "2026-01-23T15:30:45+00:00" }, { "name": "league/mime-type-detection", @@ -2220,20 +2220,20 @@ }, { "name": "league/uri", - "version": "7.7.0", + "version": "7.8.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807" + "reference": "4436c6ec8d458e4244448b069cc572d088230b76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/8d587cddee53490f9b82bf203d3a9aa7ea4f9807", - "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/4436c6ec8d458e4244448b069cc572d088230b76", + "reference": "4436c6ec8d458e4244448b069cc572d088230b76", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.7", + "league/uri-interfaces": "^7.8", "php": "^8.1", "psr/http-factory": "^1" }, @@ -2247,11 +2247,11 @@ "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", "ext-uri": "to use the PHP native URI class", - "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", - "league/uri-components": "Needed to easily manipulate URI objects components", - "league/uri-polyfill": "Needed to backport the PHP URI extension for older versions of PHP", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", "php-64bit": "to improve IPV4 host parsing", - "rowbot/url": "to handle WHATWG URL", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -2306,7 +2306,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.7.0" + "source": "https://github.com/thephpleague/uri/tree/7.8.0" }, "funding": [ { @@ -2314,20 +2314,20 @@ "type": "github" } ], - "time": "2025-12-07T16:02:06+00:00" + "time": "2026-01-14T17:24:56+00:00" }, { "name": "league/uri-interfaces", - "version": "7.7.0", + "version": "7.8.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c" + "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/62ccc1a0435e1c54e10ee6022df28d6c04c2946c", - "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/c5c5cd056110fc8afaba29fa6b72a43ced42acd4", + "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4", "shasum": "" }, "require": { @@ -2340,7 +2340,7 @@ "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", "php-64bit": "to improve IPV4 host parsing", - "rowbot/url": "to handle WHATWG URL", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -2390,7 +2390,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.7.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.0" }, "funding": [ { @@ -2398,20 +2398,20 @@ "type": "github" } ], - "time": "2025-12-07T16:03:21+00:00" + "time": "2026-01-15T06:54:53+00:00" }, { "name": "livewire/flux", - "version": "v2.10.1", + "version": "v2.12.0", "source": { "type": "git", "url": "https://github.com/livewire/flux.git", - "reference": "11f04bca8cd57e05d594a96188c26f0c118c4c4f" + "reference": "78bc26f54a29c28ff916751b9f796f4ce1592003" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/flux/zipball/11f04bca8cd57e05d594a96188c26f0c118c4c4f", - "reference": "11f04bca8cd57e05d594a96188c26f0c118c4c4f", + "url": "https://api.github.com/repos/livewire/flux/zipball/78bc26f54a29c28ff916751b9f796f4ce1592003", + "reference": "78bc26f54a29c28ff916751b9f796f4ce1592003", "shasum": "" }, "require": { @@ -2419,7 +2419,7 @@ "illuminate/support": "^10.0|^11.0|^12.0", "illuminate/view": "^10.0|^11.0|^12.0", "laravel/prompts": "^0.1|^0.2|^0.3", - "livewire/livewire": "^3.5.19|^4.0", + "livewire/livewire": "^3.7.4|^4.0", "php": "^8.1", "symfony/console": "^6.0|^7.0" }, @@ -2462,26 +2462,26 @@ ], "support": { "issues": "https://github.com/livewire/flux/issues", - "source": "https://github.com/livewire/flux/tree/v2.10.1" + "source": "https://github.com/livewire/flux/tree/v2.12.0" }, - "time": "2025-12-17T23:17:22+00:00" + "time": "2026-02-09T23:35:27+00:00" }, { "name": "livewire/flux-pro", - "version": "2.10.1", + "version": "2.12.0", "dist": { "type": "zip", - "url": "https://composer.fluxui.dev/download/a09e36f4-80ea-4712-8049-63bb48f28bab/flux-pro-2.10.1.zip", - "reference": "f10c97d73b952f60923b8769b333e052e4f72636", - "shasum": "9609b7fb4135979caba039ab9eb1970ca4ce27dd" + "url": "https://composer.fluxui.dev/download/a10af361-cb0b-48fd-8bc5-5fb4dda59618/flux-pro-2.12.0.zip", + "reference": "15e43b7d8f96914195432b18b7b64b3e30a96288", + "shasum": "42d5b2f496b6b126d3e26186d355c6c9a92623e1" }, "require": { "illuminate/console": "^10.0|^11.0|^12.0", "illuminate/support": "^10.0|^11.0|^12.0", "illuminate/view": "^10.0|^11.0|^12.0", "laravel/prompts": "^0.1.24|^0.2|^0.3", - "livewire/flux": "2.10.1|dev-main", - "livewire/livewire": "^3.7.0|^4.0", + "livewire/flux": "2.12.0|dev-main", + "livewire/livewire": "^3.7.4|^4.0", "php": "^8.1", "symfony/console": "^6.0|^7.0" }, @@ -2537,20 +2537,20 @@ "livewire", "ui" ], - "time": "2025-12-17T23:24:34+00:00" + "time": "2026-02-10T00:40:52+00:00" }, { "name": "livewire/livewire", - "version": "v3.7.2", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "b13a1e50aad156d382815c64e6c7da4a4fd08407" + "reference": "4697085e02a1f5f11410a1b5962400e3539f8843" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/b13a1e50aad156d382815c64e6c7da4a4fd08407", - "reference": "b13a1e50aad156d382815c64e6c7da4a4fd08407", + "url": "https://api.github.com/repos/livewire/livewire/zipball/4697085e02a1f5f11410a1b5962400e3539f8843", + "reference": "4697085e02a1f5f11410a1b5962400e3539f8843", "shasum": "" }, "require": { @@ -2605,7 +2605,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.7.2" + "source": "https://github.com/livewire/livewire/tree/v4.1.4" }, "funding": [ { @@ -2613,20 +2613,20 @@ "type": "github" } ], - "time": "2025-12-17T01:53:59+00:00" + "time": "2026-02-09T22:59:54+00:00" }, { "name": "livewire/volt", - "version": "v1.10.1", + "version": "v1.10.2", "source": { "type": "git", "url": "https://github.com/livewire/volt.git", - "reference": "48cff133990c6261c63ee279fc091af6f6c6654e" + "reference": "4aa52b9adbdcb0f58af9cdb1ebabfbcbee32fac9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/volt/zipball/48cff133990c6261c63ee279fc091af6f6c6654e", - "reference": "48cff133990c6261c63ee279fc091af6f6c6654e", + "url": "https://api.github.com/repos/livewire/volt/zipball/4aa52b9adbdcb0f58af9cdb1ebabfbcbee32fac9", + "reference": "4aa52b9adbdcb0f58af9cdb1ebabfbcbee32fac9", "shasum": "" }, "require": { @@ -2684,20 +2684,20 @@ "issues": "https://github.com/livewire/volt/issues", "source": "https://github.com/livewire/volt" }, - "time": "2025-11-25T16:19:15+00:00" + "time": "2026-01-28T03:03:30+00:00" }, { "name": "monolog/monolog", - "version": "3.9.0", + "version": "3.10.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6" + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6", - "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", "shasum": "" }, "require": { @@ -2715,7 +2715,7 @@ "graylog2/gelf-php": "^1.4.2 || ^2.0", "guzzlehttp/guzzle": "^7.4.5", "guzzlehttp/psr7": "^2.2", - "mongodb/mongodb": "^1.8", + "mongodb/mongodb": "^1.8 || ^2.0", "php-amqplib/php-amqplib": "~2.4 || ^3", "php-console/php-console": "^3.1.8", "phpstan/phpstan": "^2", @@ -2775,7 +2775,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.9.0" + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" }, "funding": [ { @@ -2787,20 +2787,20 @@ "type": "tidelift" } ], - "time": "2025-03-24T10:02:05+00:00" + "time": "2026-01-02T08:56:05+00:00" }, { "name": "nesbot/carbon", - "version": "3.11.0", + "version": "3.11.1", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "bdb375400dcd162624531666db4799b36b64e4a1" + "reference": "f438fcc98f92babee98381d399c65336f3a3827f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/bdb375400dcd162624531666db4799b36b64e4a1", - "reference": "bdb375400dcd162624531666db4799b36b64e4a1", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/f438fcc98f92babee98381d399c65336f3a3827f", + "reference": "f438fcc98f92babee98381d399c65336f3a3827f", "shasum": "" }, "require": { @@ -2824,7 +2824,7 @@ "phpstan/extension-installer": "^1.4.3", "phpstan/phpstan": "^2.1.22", "phpunit/phpunit": "^10.5.53", - "squizlabs/php_codesniffer": "^3.13.4" + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" }, "bin": [ "bin/carbon" @@ -2867,14 +2867,14 @@ } ], "description": "An API extension for DateTime that supports 281 different languages.", - "homepage": "https://carbon.nesbot.com", + "homepage": "https://carbonphp.github.io/carbon/", "keywords": [ "date", "datetime", "time" ], "support": { - "docs": "https://carbon.nesbot.com/docs", + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", "issues": "https://github.com/CarbonPHP/carbon/issues", "source": "https://github.com/CarbonPHP/carbon" }, @@ -2892,20 +2892,20 @@ "type": "tidelift" } ], - "time": "2025-12-02T21:04:28+00:00" + "time": "2026-01-29T09:26:29+00:00" }, { "name": "nette/schema", - "version": "v1.3.3", + "version": "v1.3.4", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004" + "reference": "086497a2f34b82fede9b5a41cc8e131d087cd8f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/2befc2f42d7c715fd9d95efc31b1081e5d765004", - "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004", + "url": "https://api.github.com/repos/nette/schema/zipball/086497a2f34b82fede9b5a41cc8e131d087cd8f7", + "reference": "086497a2f34b82fede9b5a41cc8e131d087cd8f7", "shasum": "" }, "require": { @@ -2913,8 +2913,8 @@ "php": "8.1 - 8.5" }, "require-dev": { - "nette/tester": "^2.5.2", - "phpstan/phpstan-nette": "^2.0@stable", + "nette/tester": "^2.6", + "phpstan/phpstan": "^2.0@stable", "tracy/tracy": "^2.8" }, "type": "library", @@ -2955,22 +2955,22 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.3" + "source": "https://github.com/nette/schema/tree/v1.3.4" }, - "time": "2025-10-30T22:57:59+00:00" + "time": "2026-02-08T02:54:00+00:00" }, { "name": "nette/utils", - "version": "v4.1.0", + "version": "v4.1.2", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "fa1f0b8261ed150447979eb22e373b7b7ad5a8e0" + "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/fa1f0b8261ed150447979eb22e373b7b7ad5a8e0", - "reference": "fa1f0b8261ed150447979eb22e373b7b7ad5a8e0", + "url": "https://api.github.com/repos/nette/utils/zipball/f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", + "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", "shasum": "" }, "require": { @@ -2983,7 +2983,7 @@ "require-dev": { "jetbrains/phpstorm-attributes": "^1.2", "nette/tester": "^2.5", - "phpstan/phpstan-nette": "^2.0@stable", + "phpstan/phpstan": "^2.0@stable", "tracy/tracy": "^2.9" }, "suggest": { @@ -3044,9 +3044,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.0" + "source": "https://github.com/nette/utils/tree/v4.1.2" }, - "time": "2025-12-01T17:49:23+00:00" + "time": "2026-02-03T17:21:09+00:00" }, { "name": "nikic/php-parser", @@ -3264,16 +3264,16 @@ }, { "name": "phpoption/phpoption", - "version": "1.9.4", + "version": "1.9.5", "source": { "type": "git", "url": "https://github.com/schmittjoh/php-option.git", - "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d" + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", - "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", "shasum": "" }, "require": { @@ -3323,7 +3323,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.4" + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" }, "funding": [ { @@ -3335,7 +3335,7 @@ "type": "tidelift" } ], - "time": "2025-08-21T11:53:16+00:00" + "time": "2025-12-27T19:41:33+00:00" }, { "name": "pragmarx/google2fa", @@ -3803,16 +3803,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.18", + "version": "v0.12.20", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196" + "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ddff0ac01beddc251786fe70367cd8bbdb258196", - "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/19678eb6b952a03b8a1d96ecee9edba518bb0373", + "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373", "shasum": "" }, "require": { @@ -3876,9 +3876,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.18" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.20" }, - "time": "2025-12-17T14:35:46+00:00" + "time": "2026-02-11T15:05:28+00:00" }, { "name": "ralouphie/getallheaders", @@ -4080,16 +4080,16 @@ }, { "name": "spatie/laravel-permission", - "version": "6.24.0", + "version": "6.24.1", "source": { "type": "git", "url": "https://github.com/spatie/laravel-permission.git", - "reference": "76adb1fc8d07c16a0721c35c4cc330b7a12598d7" + "reference": "eefc9d17eba80d023d6bff313f882cb2bcd691a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/76adb1fc8d07c16a0721c35c4cc330b7a12598d7", - "reference": "76adb1fc8d07c16a0721c35c4cc330b7a12598d7", + "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/eefc9d17eba80d023d6bff313f882cb2bcd691a3", + "reference": "eefc9d17eba80d023d6bff313f882cb2bcd691a3", "shasum": "" }, "require": { @@ -4151,7 +4151,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-permission/issues", - "source": "https://github.com/spatie/laravel-permission/tree/6.24.0" + "source": "https://github.com/spatie/laravel-permission/tree/6.24.1" }, "funding": [ { @@ -4159,7 +4159,7 @@ "type": "github" } ], - "time": "2025-12-13T21:45:21+00:00" + "time": "2026-02-09T21:10:03+00:00" }, { "name": "symfony/clock", @@ -4240,16 +4240,16 @@ }, { "name": "symfony/console", - "version": "v7.4.1", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e" + "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e", - "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e", + "url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894", + "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894", "shasum": "" }, "require": { @@ -4314,7 +4314,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.1" + "source": "https://github.com/symfony/console/tree/v7.4.4" }, "funding": [ { @@ -4334,7 +4334,7 @@ "type": "tidelift" } ], - "time": "2025-12-05T15:23:39+00:00" + "time": "2026-01-13T11:36:38+00:00" }, { "name": "symfony/css-selector", @@ -4474,16 +4474,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.4.0", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2" + "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/48be2b0653594eea32dcef130cca1c811dcf25c2", - "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8da531f364ddfee53e36092a7eebbbd0b775f6b8", + "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8", "shasum": "" }, "require": { @@ -4532,7 +4532,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.0" + "source": "https://github.com/symfony/error-handler/tree/v7.4.4" }, "funding": [ { @@ -4552,20 +4552,20 @@ "type": "tidelift" } ], - "time": "2025-11-05T14:29:59+00:00" + "time": "2026-01-20T16:42:42+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.0.0", + "version": "v8.0.4", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "573f95783a2ec6e38752979db139f09fec033f03" + "reference": "99301401da182b6cfaa4700dbe9987bb75474b47" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/573f95783a2ec6e38752979db139f09fec033f03", - "reference": "573f95783a2ec6e38752979db139f09fec033f03", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/99301401da182b6cfaa4700dbe9987bb75474b47", + "reference": "99301401da182b6cfaa4700dbe9987bb75474b47", "shasum": "" }, "require": { @@ -4617,7 +4617,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.4" }, "funding": [ { @@ -4637,7 +4637,7 @@ "type": "tidelift" } ], - "time": "2025-10-30T14:17:19+00:00" + "time": "2026-01-05T11:45:55+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -4717,16 +4717,16 @@ }, { "name": "symfony/finder", - "version": "v7.4.0", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "340b9ed7320570f319028a2cbec46d40535e94bd" + "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/340b9ed7320570f319028a2cbec46d40535e94bd", - "reference": "340b9ed7320570f319028a2cbec46d40535e94bd", + "url": "https://api.github.com/repos/symfony/finder/zipball/ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", "shasum": "" }, "require": { @@ -4761,7 +4761,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.0" + "source": "https://github.com/symfony/finder/tree/v7.4.5" }, "funding": [ { @@ -4781,20 +4781,20 @@ "type": "tidelift" } ], - "time": "2025-11-05T05:42:40+00:00" + "time": "2026-01-26T15:07:59+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.1", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "bd1af1e425811d6f077db240c3a588bdb405cd27" + "reference": "446d0db2b1f21575f1284b74533e425096abdfb6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bd1af1e425811d6f077db240c3a588bdb405cd27", - "reference": "bd1af1e425811d6f077db240c3a588bdb405cd27", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/446d0db2b1f21575f1284b74533e425096abdfb6", + "reference": "446d0db2b1f21575f1284b74533e425096abdfb6", "shasum": "" }, "require": { @@ -4843,7 +4843,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.1" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.5" }, "funding": [ { @@ -4863,20 +4863,20 @@ "type": "tidelift" } ], - "time": "2025-12-07T11:13:10+00:00" + "time": "2026-01-27T16:16:02+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.2", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "f6e6f0a5fa8763f75a504b930163785fb6dd055f" + "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/f6e6f0a5fa8763f75a504b930163785fb6dd055f", - "reference": "f6e6f0a5fa8763f75a504b930163785fb6dd055f", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/229eda477017f92bd2ce7615d06222ec0c19e82a", + "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a", "shasum": "" }, "require": { @@ -4962,7 +4962,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.2" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.5" }, "funding": [ { @@ -4982,20 +4982,20 @@ "type": "tidelift" } ], - "time": "2025-12-08T07:43:37+00:00" + "time": "2026-01-28T10:33:42+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.0", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "a3d9eea8cfa467ece41f0f54ba28185d74bd53fd" + "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/a3d9eea8cfa467ece41f0f54ba28185d74bd53fd", - "reference": "a3d9eea8cfa467ece41f0f54ba28185d74bd53fd", + "url": "https://api.github.com/repos/symfony/mailer/zipball/7b750074c40c694ceb34cb926d6dffee231c5cd6", + "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6", "shasum": "" }, "require": { @@ -5046,7 +5046,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.0" + "source": "https://github.com/symfony/mailer/tree/v7.4.4" }, "funding": [ { @@ -5066,20 +5066,20 @@ "type": "tidelift" } ], - "time": "2025-11-21T15:26:00+00:00" + "time": "2026-01-08T08:25:11+00:00" }, { "name": "symfony/mime", - "version": "v7.4.0", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a" + "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/bdb02729471be5d047a3ac4a69068748f1a6be7a", - "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a", + "url": "https://api.github.com/repos/symfony/mime/zipball/b18c7e6e9eee1e19958138df10412f3c4c316148", + "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148", "shasum": "" }, "require": { @@ -5090,15 +5090,15 @@ }, "conflict": { "egulias/email-validator": "~3.0.0", - "phpdocumentor/reflection-docblock": "<3.2.2", - "phpdocumentor/type-resolver": "<1.4.0", + "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/type-resolver": "<1.5.1", "symfony/mailer": "<6.4", "symfony/serializer": "<6.4.3|>7.0,<7.0.3" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "phpdocumentor/reflection-docblock": "^5.2", "symfony/dependency-injection": "^6.4|^7.0|^8.0", "symfony/process": "^6.4|^7.0|^8.0", "symfony/property-access": "^6.4|^7.0|^8.0", @@ -5135,7 +5135,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.0" + "source": "https://github.com/symfony/mime/tree/v7.4.5" }, "funding": [ { @@ -5155,7 +5155,7 @@ "type": "tidelift" } ], - "time": "2025-11-16T10:14:42+00:00" + "time": "2026-01-27T08:59:58+00:00" }, { "name": "symfony/polyfill-ctype", @@ -5988,16 +5988,16 @@ }, { "name": "symfony/process", - "version": "v7.4.0", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8" + "reference": "608476f4604102976d687c483ac63a79ba18cc97" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/7ca8dc2d0dcf4882658313aba8be5d9fd01026c8", - "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8", + "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", + "reference": "608476f4604102976d687c483ac63a79ba18cc97", "shasum": "" }, "require": { @@ -6029,7 +6029,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.0" + "source": "https://github.com/symfony/process/tree/v7.4.5" }, "funding": [ { @@ -6049,20 +6049,20 @@ "type": "tidelift" } ], - "time": "2025-10-16T11:21:06+00:00" + "time": "2026-01-26T15:07:59+00:00" }, { "name": "symfony/routing", - "version": "v7.4.0", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "4720254cb2644a0b876233d258a32bf017330db7" + "reference": "0798827fe2c79caeed41d70b680c2c3507d10147" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/4720254cb2644a0b876233d258a32bf017330db7", - "reference": "4720254cb2644a0b876233d258a32bf017330db7", + "url": "https://api.github.com/repos/symfony/routing/zipball/0798827fe2c79caeed41d70b680c2c3507d10147", + "reference": "0798827fe2c79caeed41d70b680c2c3507d10147", "shasum": "" }, "require": { @@ -6114,7 +6114,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.0" + "source": "https://github.com/symfony/routing/tree/v7.4.4" }, "funding": [ { @@ -6134,7 +6134,7 @@ "type": "tidelift" } ], - "time": "2025-11-27T13:27:24+00:00" + "time": "2026-01-12T12:19:02+00:00" }, { "name": "symfony/service-contracts", @@ -6225,16 +6225,16 @@ }, { "name": "symfony/string", - "version": "v8.0.1", + "version": "v8.0.4", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc" + "reference": "758b372d6882506821ed666032e43020c4f57194" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/ba65a969ac918ce0cc3edfac6cdde847eba231dc", - "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc", + "url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194", + "reference": "758b372d6882506821ed666032e43020c4f57194", "shasum": "" }, "require": { @@ -6291,7 +6291,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.1" + "source": "https://github.com/symfony/string/tree/v8.0.4" }, "funding": [ { @@ -6311,20 +6311,20 @@ "type": "tidelift" } ], - "time": "2025-12-01T09:13:36+00:00" + "time": "2026-01-12T12:37:40+00:00" }, { "name": "symfony/translation", - "version": "v8.0.1", + "version": "v8.0.4", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "770e3b8b0ba8360958abedcabacd4203467333ca" + "reference": "db70c8ce7db74fd2da7b1d268db46b2a8ce32c10" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/770e3b8b0ba8360958abedcabacd4203467333ca", - "reference": "770e3b8b0ba8360958abedcabacd4203467333ca", + "url": "https://api.github.com/repos/symfony/translation/zipball/db70c8ce7db74fd2da7b1d268db46b2a8ce32c10", + "reference": "db70c8ce7db74fd2da7b1d268db46b2a8ce32c10", "shasum": "" }, "require": { @@ -6384,7 +6384,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.1" + "source": "https://github.com/symfony/translation/tree/v8.0.4" }, "funding": [ { @@ -6404,7 +6404,7 @@ "type": "tidelift" } ], - "time": "2025-12-01T09:13:36+00:00" + "time": "2026-01-13T13:06:50+00:00" }, { "name": "symfony/translation-contracts", @@ -6490,16 +6490,16 @@ }, { "name": "symfony/uid", - "version": "v7.4.0", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "2498e9f81b7baa206f44de583f2f48350b90142c" + "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/2498e9f81b7baa206f44de583f2f48350b90142c", - "reference": "2498e9f81b7baa206f44de583f2f48350b90142c", + "url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36", + "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36", "shasum": "" }, "require": { @@ -6544,7 +6544,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.0" + "source": "https://github.com/symfony/uid/tree/v7.4.4" }, "funding": [ { @@ -6564,20 +6564,20 @@ "type": "tidelift" } ], - "time": "2025-09-25T11:02:55+00:00" + "time": "2026-01-03T23:30:35+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.0", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece" + "reference": "0e4769b46a0c3c62390d124635ce59f66874b282" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/41fd6c4ae28c38b294b42af6db61446594a0dece", - "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/0e4769b46a0c3c62390d124635ce59f66874b282", + "reference": "0e4769b46a0c3c62390d124635ce59f66874b282", "shasum": "" }, "require": { @@ -6631,7 +6631,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.0" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.4" }, "funding": [ { @@ -6651,7 +6651,7 @@ "type": "tidelift" } ], - "time": "2025-10-27T20:36:44+00:00" + "time": "2026-01-01T22:13:48+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -6710,26 +6710,26 @@ }, { "name": "vlucas/phpdotenv", - "version": "v5.6.2", + "version": "v5.6.3", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af" + "reference": "955e7815d677a3eaa7075231212f2110983adecc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af", - "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", "shasum": "" }, "require": { "ext-pcre": "*", - "graham-campbell/result-type": "^1.1.3", + "graham-campbell/result-type": "^1.1.4", "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.3", - "symfony/polyfill-ctype": "^1.24", - "symfony/polyfill-mbstring": "^1.24", - "symfony/polyfill-php80": "^1.24" + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", @@ -6778,7 +6778,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" }, "funding": [ { @@ -6790,7 +6790,7 @@ "type": "tidelift" } ], - "time": "2025-04-30T23:37:27+00:00" + "time": "2025-12-27T19:49:13+00:00" }, { "name": "voku/portable-ascii", @@ -6870,16 +6870,16 @@ "packages-dev": [ { "name": "barryvdh/laravel-debugbar", - "version": "v3.16.2", + "version": "v3.16.5", "source": { "type": "git", - "url": "https://github.com/barryvdh/laravel-debugbar.git", - "reference": "730dbf8bf41f5691e026dd771e64dd54ad1b10b3" + "url": "https://github.com/fruitcake/laravel-debugbar.git", + "reference": "e85c0a8464da67e5b4a53a42796d46a43fc06c9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/730dbf8bf41f5691e026dd771e64dd54ad1b10b3", - "reference": "730dbf8bf41f5691e026dd771e64dd54ad1b10b3", + "url": "https://api.github.com/repos/fruitcake/laravel-debugbar/zipball/e85c0a8464da67e5b4a53a42796d46a43fc06c9a", + "reference": "e85c0a8464da67e5b4a53a42796d46a43fc06c9a", "shasum": "" }, "require": { @@ -6888,7 +6888,7 @@ "illuminate/support": "^10|^11|^12", "php": "^8.1", "php-debugbar/php-debugbar": "^2.2.4", - "symfony/finder": "^6|^7" + "symfony/finder": "^6|^7|^8" }, "require-dev": { "mockery/mockery": "^1.3.3", @@ -6938,8 +6938,8 @@ "webprofiler" ], "support": { - "issues": "https://github.com/barryvdh/laravel-debugbar/issues", - "source": "https://github.com/barryvdh/laravel-debugbar/tree/v3.16.2" + "issues": "https://github.com/fruitcake/laravel-debugbar/issues", + "source": "https://github.com/fruitcake/laravel-debugbar/tree/v3.16.5" }, "funding": [ { @@ -6951,20 +6951,20 @@ "type": "github" } ], - "time": "2025-12-03T14:52:46+00:00" + "time": "2026-01-23T15:03:22+00:00" }, { "name": "brianium/paratest", - "version": "v7.8.4", + "version": "v7.8.5", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "130a9bf0e269ee5f5b320108f794ad03e275cad4" + "reference": "9b324c8fc319cf9728b581c7a90e1c8f6361c5e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/130a9bf0e269ee5f5b320108f794ad03e275cad4", - "reference": "130a9bf0e269ee5f5b320108f794ad03e275cad4", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/9b324c8fc319cf9728b581c7a90e1c8f6361c5e5", + "reference": "9b324c8fc319cf9728b581c7a90e1c8f6361c5e5", "shasum": "" }, "require": { @@ -6972,27 +6972,27 @@ "ext-pcre": "*", "ext-reflection": "*", "ext-simplexml": "*", - "fidry/cpu-core-counter": "^1.2.0", + "fidry/cpu-core-counter": "^1.3.0", "jean85/pretty-package-versions": "^2.1.1", - "php": "~8.2.0 || ~8.3.0 || ~8.4.0", - "phpunit/php-code-coverage": "^11.0.10", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "phpunit/php-code-coverage": "^11.0.12", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-timer": "^7.0.1", - "phpunit/phpunit": "^11.5.24", + "phpunit/phpunit": "^11.5.46", "sebastian/environment": "^7.2.1", - "symfony/console": "^6.4.22 || ^7.3.0", - "symfony/process": "^6.4.20 || ^7.3.0" + "symfony/console": "^6.4.22 || ^7.3.4 || ^8.0.3", + "symfony/process": "^6.4.20 || ^7.3.4 || ^8.0.3" }, "require-dev": { "doctrine/coding-standard": "^12.0.0", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.17", + "phpstan/phpstan": "^2.1.33", "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.6", - "phpstan/phpstan-strict-rules": "^2.0.4", - "squizlabs/php_codesniffer": "^3.13.2", - "symfony/filesystem": "^6.4.13 || ^7.3.0" + "phpstan/phpstan-phpunit": "^2.0.11", + "phpstan/phpstan-strict-rules": "^2.0.7", + "squizlabs/php_codesniffer": "^3.13.5", + "symfony/filesystem": "^6.4.13 || ^7.3.2 || ^8.0.1" }, "bin": [ "bin/paratest", @@ -7032,7 +7032,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.8.4" + "source": "https://github.com/paratestphp/paratest/tree/v7.8.5" }, "funding": [ { @@ -7044,7 +7044,7 @@ "type": "paypal" } ], - "time": "2025-06-23T06:07:21+00:00" + "time": "2026-01-08T08:02:38+00:00" }, { "name": "composer/semver", @@ -7231,29 +7231,29 @@ }, { "name": "doctrine/deprecations", - "version": "1.1.5", + "version": "1.1.6", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "conflict": { - "phpunit/phpunit": "<=7.5 || >=13" + "phpunit/phpunit": "<=7.5 || >=14" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^13", - "phpstan/phpstan": "1.4.10 || 2.1.11", + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -7273,9 +7273,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2025-04-07T20:06:18+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { "name": "fakerphp/faker", @@ -7584,40 +7584,106 @@ "time": "2025-03-19T14:43:43+00:00" }, { - "name": "laravel/dusk", - "version": "v8.3.4", + "name": "laravel/boost", + "version": "v1.8.10", "source": { "type": "git", - "url": "https://github.com/laravel/dusk.git", - "reference": "33a4211c7b63ffe430bf30ec3c014012dcb6dfa6" + "url": "https://github.com/laravel/boost.git", + "reference": "aad8b2a423b0a886c2ce7ee92abbfde69992ff32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/dusk/zipball/33a4211c7b63ffe430bf30ec3c014012dcb6dfa6", - "reference": "33a4211c7b63ffe430bf30ec3c014012dcb6dfa6", + "url": "https://api.github.com/repos/laravel/boost/zipball/aad8b2a423b0a886c2ce7ee92abbfde69992ff32", + "reference": "aad8b2a423b0a886c2ce7ee92abbfde69992ff32", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.9", + "illuminate/console": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/contracts": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/routing": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/support": "^10.49.0|^11.45.3|^12.41.1", + "laravel/mcp": "^0.5.1", + "laravel/prompts": "0.1.25|^0.3.6", + "laravel/roster": "^0.2.9", + "php": "^8.1" + }, + "require-dev": { + "laravel/pint": "^1.20.0", + "mockery/mockery": "^1.6.12", + "orchestra/testbench": "^8.36.0|^9.15.0|^10.6", + "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Boost\\BoostServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Boost\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.", + "homepage": "https://github.com/laravel/boost", + "keywords": [ + "ai", + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/boost/issues", + "source": "https://github.com/laravel/boost" + }, + "time": "2026-01-14T14:51:16+00:00" + }, + { + "name": "laravel/dusk", + "version": "v8.3.6", + "source": { + "type": "git", + "url": "https://github.com/laravel/dusk.git", + "reference": "5c3beee54f91f575f50cadcd7e5d44c80cc9a9aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/dusk/zipball/5c3beee54f91f575f50cadcd7e5d44c80cc9a9aa", + "reference": "5c3beee54f91f575f50cadcd7e5d44c80cc9a9aa", "shasum": "" }, "require": { "ext-json": "*", "ext-zip": "*", "guzzlehttp/guzzle": "^7.5", - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", "php": "^8.1", "php-webdriver/webdriver": "^1.15.2", - "symfony/console": "^6.2|^7.0", - "symfony/finder": "^6.2|^7.0", - "symfony/process": "^6.2|^7.0", + "symfony/console": "^6.2|^7.0|^8.0", + "symfony/finder": "^6.2|^7.0|^8.0", + "symfony/process": "^6.2|^7.0|^8.0", "vlucas/phpdotenv": "^5.2" }, "require-dev": { - "laravel/framework": "^10.0|^11.0|^12.0", + "laravel/framework": "^10.0|^11.0|^12.0|^13.0", "mockery/mockery": "^1.6", - "orchestra/testbench-core": "^8.19|^9.17|^10.8", + "orchestra/testbench-core": "^8.19|^9.17|^10.8|^11.0", "phpstan/phpstan": "^1.10", "phpunit/phpunit": "^10.1|^11.0|^12.0.1", "psy/psysh": "^0.11.12|^0.12", - "symfony/yaml": "^6.2|^7.0" + "symfony/yaml": "^6.2|^7.0|^8.0" }, "suggest": { "ext-pcntl": "Used to gracefully terminate Dusk when tests are running." @@ -7653,43 +7719,117 @@ ], "support": { "issues": "https://github.com/laravel/dusk/issues", - "source": "https://github.com/laravel/dusk/tree/v8.3.4" + "source": "https://github.com/laravel/dusk/tree/v8.3.6" }, - "time": "2025-11-20T16:26:16+00:00" + "time": "2026-02-10T18:14:59+00:00" }, { - "name": "laravel/pail", - "version": "v1.2.4", + "name": "laravel/mcp", + "version": "v0.5.6", "source": { "type": "git", - "url": "https://github.com/laravel/pail.git", - "reference": "49f92285ff5d6fc09816e976a004f8dec6a0ea30" + "url": "https://github.com/laravel/mcp.git", + "reference": "87905978bf2a230d6c01f8d03e172249e37917f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pail/zipball/49f92285ff5d6fc09816e976a004f8dec6a0ea30", - "reference": "49f92285ff5d6fc09816e976a004f8dec6a0ea30", + "url": "https://api.github.com/repos/laravel/mcp/zipball/87905978bf2a230d6c01f8d03e172249e37917f7", + "reference": "87905978bf2a230d6c01f8d03e172249e37917f7", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2" + }, + "require-dev": { + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2026-02-09T22:08:43+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.6", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", + "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", "shasum": "" }, "require": { "ext-mbstring": "*", - "illuminate/console": "^10.24|^11.0|^12.0", - "illuminate/contracts": "^10.24|^11.0|^12.0", - "illuminate/log": "^10.24|^11.0|^12.0", - "illuminate/process": "^10.24|^11.0|^12.0", - "illuminate/support": "^10.24|^11.0|^12.0", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", "nunomaduro/termwind": "^1.15|^2.0", "php": "^8.2", - "symfony/console": "^6.0|^7.0" + "symfony/console": "^6.0|^7.0|^8.0" }, "require-dev": { - "laravel/framework": "^10.24|^11.0|^12.0", + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", "laravel/pint": "^1.13", - "orchestra/testbench-core": "^8.13|^9.17|^10.8", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", "pestphp/pest": "^2.20|^3.0|^4.0", "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", "phpstan/phpstan": "^1.12.27", - "symfony/var-dumper": "^6.3|^7.0" + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" }, "type": "library", "extra": { @@ -7734,20 +7874,20 @@ "issues": "https://github.com/laravel/pail/issues", "source": "https://github.com/laravel/pail" }, - "time": "2025-11-20T16:29:35+00:00" + "time": "2026-02-09T13:44:54+00:00" }, { "name": "laravel/pint", - "version": "v1.26.0", + "version": "v1.27.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f" + "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/69dcca060ecb15e4b564af63d1f642c81a241d6f", - "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f", + "url": "https://api.github.com/repos/laravel/pint/zipball/54cca2de13790570c7b6f0f94f37896bee4abcb5", + "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5", "shasum": "" }, "require": { @@ -7758,13 +7898,13 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.90.0", - "illuminate/view": "^12.40.1", - "larastan/larastan": "^3.8.0", - "laravel-zero/framework": "^12.0.4", + "friendsofphp/php-cs-fixer": "^3.93.1", + "illuminate/view": "^12.51.0", + "larastan/larastan": "^3.9.2", + "laravel-zero/framework": "^12.0.5", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.3.3", - "pestphp/pest": "^3.8.4" + "pestphp/pest": "^3.8.5" }, "bin": [ "builds/pint" @@ -7801,32 +7941,93 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-11-25T21:15:52+00:00" + "time": "2026-02-10T20:00:20+00:00" }, { - "name": "laravel/sail", - "version": "v1.51.0", + "name": "laravel/roster", + "version": "v0.2.9", "source": { "type": "git", - "url": "https://github.com/laravel/sail.git", - "reference": "1c74357df034e869250b4365dd445c9f6ba5d068" + "url": "https://github.com/laravel/roster.git", + "reference": "82bbd0e2de614906811aebdf16b4305956816fa6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/1c74357df034e869250b4365dd445c9f6ba5d068", - "reference": "1c74357df034e869250b4365dd445c9f6ba5d068", + "url": "https://api.github.com/repos/laravel/roster/zipball/82bbd0e2de614906811aebdf16b4305956816fa6", + "reference": "82bbd0e2de614906811aebdf16b4305956816fa6", "shasum": "" }, "require": { - "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0", - "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0", - "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0", - "php": "^8.0", - "symfony/console": "^6.0|^7.0", - "symfony/yaml": "^6.0|^7.0" + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "php": "^8.1|^8.2", + "symfony/yaml": "^6.4|^7.2" }, "require-dev": { - "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", + "laravel/pint": "^1.14", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "pestphp/pest": "^2.0|^3.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Roster\\RosterServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Roster\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Detect packages & approaches in use within a Laravel project", + "homepage": "https://github.com/laravel/roster", + "keywords": [ + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/roster/issues", + "source": "https://github.com/laravel/roster" + }, + "time": "2025-10-20T09:56:46+00:00" + }, + { + "name": "laravel/sail", + "version": "v1.53.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/sail.git", + "reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sail/zipball/e340eaa2bea9b99192570c48ed837155dbf24fbb", + "reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb", + "shasum": "" + }, + "require": { + "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/yaml": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0", "phpstan/phpstan": "^2.0" }, "bin": [ @@ -7864,7 +8065,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2025-12-09T13:33:49+00:00" + "time": "2026-02-06T12:16:02+00:00" }, { "name": "mockery/mockery", @@ -8253,16 +8454,16 @@ }, { "name": "orchestra/sidekick", - "version": "v1.2.18", + "version": "v1.2.20", "source": { "type": "git", "url": "https://github.com/orchestral/sidekick.git", - "reference": "0e080ef62eed6c45aaea3619566a1fce02b62094" + "reference": "267a71b56cb2fe1a634d69fc99889c671b77ff43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/orchestral/sidekick/zipball/0e080ef62eed6c45aaea3619566a1fce02b62094", - "reference": "0e080ef62eed6c45aaea3619566a1fce02b62094", + "url": "https://api.github.com/repos/orchestral/sidekick/zipball/267a71b56cb2fe1a634d69fc99889c671b77ff43", + "reference": "267a71b56cb2fe1a634d69fc99889c671b77ff43", "shasum": "" }, "require": { @@ -8285,6 +8486,7 @@ "autoload": { "files": [ "src/Eloquent/functions.php", + "src/Filesystem/functions.php", "src/Http/functions.php", "src/functions.php" ], @@ -8305,22 +8507,22 @@ "description": "Packages Toolkit Utilities and Helpers for Laravel", "support": { "issues": "https://github.com/orchestral/sidekick/issues", - "source": "https://github.com/orchestral/sidekick/tree/v1.2.18" + "source": "https://github.com/orchestral/sidekick/tree/v1.2.20" }, - "time": "2025-11-29T15:16:23+00:00" + "time": "2026-01-12T11:09:33+00:00" }, { "name": "orchestra/testbench", - "version": "v10.8.0", + "version": "v10.9.0", "source": { "type": "git", "url": "https://github.com/orchestral/testbench.git", - "reference": "003922508c1d9f75bbe44f68364616d5ddee1939" + "reference": "040a37b60e1a9d7ae10b496407b6c3bb63b47038" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/orchestral/testbench/zipball/003922508c1d9f75bbe44f68364616d5ddee1939", - "reference": "003922508c1d9f75bbe44f68364616d5ddee1939", + "url": "https://api.github.com/repos/orchestral/testbench/zipball/040a37b60e1a9d7ae10b496407b6c3bb63b47038", + "reference": "040a37b60e1a9d7ae10b496407b6c3bb63b47038", "shasum": "" }, "require": { @@ -8328,8 +8530,8 @@ "fakerphp/faker": "^1.23", "laravel/framework": "^12.40.0", "mockery/mockery": "^1.6.10", - "orchestra/testbench-core": "^10.8.0", - "orchestra/workbench": "^10.0.7", + "orchestra/testbench-core": "^10.9.0", + "orchestra/workbench": "^10.0.8", "php": "^8.2", "phpunit/phpunit": "^11.5.3|^12.0.1", "symfony/process": "^7.2", @@ -8360,30 +8562,30 @@ ], "support": { "issues": "https://github.com/orchestral/testbench/issues", - "source": "https://github.com/orchestral/testbench/tree/v10.8.0" + "source": "https://github.com/orchestral/testbench/tree/v10.9.0" }, - "time": "2025-11-24T09:44:51+00:00" + "time": "2026-01-14T03:26:57+00:00" }, { "name": "orchestra/testbench-core", - "version": "v10.8.1", + "version": "v10.9.0", "source": { "type": "git", "url": "https://github.com/orchestral/testbench-core.git", - "reference": "f1da36cedc677d015d2a46d36abee54ffd5ba711" + "reference": "754d2b71601822d1f57f28119e4dea27ed1a5205" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/orchestral/testbench-core/zipball/f1da36cedc677d015d2a46d36abee54ffd5ba711", - "reference": "f1da36cedc677d015d2a46d36abee54ffd5ba711", + "url": "https://api.github.com/repos/orchestral/testbench-core/zipball/754d2b71601822d1f57f28119e4dea27ed1a5205", + "reference": "754d2b71601822d1f57f28119e4dea27ed1a5205", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", - "orchestra/sidekick": "~1.1.21|~1.2.18", + "orchestra/sidekick": "~1.1.23|~1.2.20", "php": "^8.2", "symfony/deprecation-contracts": "^2.5|^3.0", - "symfony/polyfill-php83": "^1.32" + "symfony/polyfill-php83": "^1.33" }, "conflict": { "brianium/paratest": "<7.3.0|>=8.0.0", @@ -8398,7 +8600,7 @@ "laravel/pint": "^1.24", "laravel/serializable-closure": "^1.3|^2.0.4", "mockery/mockery": "^1.6.10", - "phpstan/phpstan": "^2.1.19", + "phpstan/phpstan": "^2.1.33", "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", "spatie/laravel-ray": "^1.42.0", "symfony/process": "^7.2.0", @@ -8455,20 +8657,20 @@ "issues": "https://github.com/orchestral/testbench/issues", "source": "https://github.com/orchestral/testbench-core" }, - "time": "2025-12-08T08:07:27+00:00" + "time": "2026-01-13T05:19:42+00:00" }, { "name": "orchestra/workbench", - "version": "v10.0.7", + "version": "v10.0.8", "source": { "type": "git", "url": "https://github.com/orchestral/workbench.git", - "reference": "7e41a6cbda2f553b725b4b0c104c684ae5d0f2b6" + "reference": "88bb9b5872539dd8b556b232a1b466f639c18259" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/orchestral/workbench/zipball/7e41a6cbda2f553b725b4b0c104c684ae5d0f2b6", - "reference": "7e41a6cbda2f553b725b4b0c104c684ae5d0f2b6", + "url": "https://api.github.com/repos/orchestral/workbench/zipball/88bb9b5872539dd8b556b232a1b466f639c18259", + "reference": "88bb9b5872539dd8b556b232a1b466f639c18259", "shasum": "" }, "require": { @@ -8479,17 +8681,17 @@ "laravel/tinker": "^2.10.1", "nunomaduro/collision": "^8.6", "orchestra/canvas": "^10.1.1", - "orchestra/sidekick": "^1.2.17", + "orchestra/sidekick": "~1.1.23|~1.2.20", "orchestra/testbench-core": "^10.8.0", "php": "^8.2", - "symfony/polyfill-php83": "^1.32", + "symfony/polyfill-php83": "^1.33", "symfony/process": "^7.2", "symfony/yaml": "^7.2" }, "require-dev": { "laravel/pint": "^1.22.0", "mockery/mockery": "^1.6.12", - "phpstan/phpstan": "^2.1.14", + "phpstan/phpstan": "^2.1.33", "phpunit/phpunit": "^11.5.3|^12.0.1", "spatie/laravel-ray": "^1.42.0" }, @@ -8521,44 +8723,44 @@ ], "support": { "issues": "https://github.com/orchestral/workbench/issues", - "source": "https://github.com/orchestral/workbench/tree/v10.0.7" + "source": "https://github.com/orchestral/workbench/tree/v10.0.8" }, - "time": "2025-11-24T06:50:12+00:00" + "time": "2026-01-12T14:48:09+00:00" }, { "name": "pestphp/pest", - "version": "v3.8.4", + "version": "v3.8.5", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "72cf695554420e21858cda831d5db193db102574" + "reference": "7796630eafcfd1c02660cecdde3bc6984fbf01f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/72cf695554420e21858cda831d5db193db102574", - "reference": "72cf695554420e21858cda831d5db193db102574", + "url": "https://api.github.com/repos/pestphp/pest/zipball/7796630eafcfd1c02660cecdde3bc6984fbf01f4", + "reference": "7796630eafcfd1c02660cecdde3bc6984fbf01f4", "shasum": "" }, "require": { - "brianium/paratest": "^7.8.4", - "nunomaduro/collision": "^8.8.2", - "nunomaduro/termwind": "^2.3.1", + "brianium/paratest": "^7.8.5", + "nunomaduro/collision": "^8.8.3", + "nunomaduro/termwind": "^2.3.3", "pestphp/pest-plugin": "^3.0.0", "pestphp/pest-plugin-arch": "^3.1.1", "pestphp/pest-plugin-mutate": "^3.0.5", "php": "^8.2.0", - "phpunit/phpunit": "^11.5.33" + "phpunit/phpunit": "^11.5.50" }, "conflict": { "filp/whoops": "<2.16.0", - "phpunit/phpunit": ">11.5.33", + "phpunit/phpunit": ">11.5.50", "sebastian/exporter": "<6.0.0", "webmozart/assert": "<1.11.0" }, "require-dev": { "pestphp/pest-dev-tools": "^3.4.0", "pestphp/pest-plugin-type-coverage": "^3.6.1", - "symfony/process": "^7.3.0" + "symfony/process": "^7.4.4" }, "bin": [ "bin/pest" @@ -8623,7 +8825,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v3.8.4" + "source": "https://github.com/pestphp/pest/tree/v3.8.5" }, "funding": [ { @@ -8635,7 +8837,7 @@ "type": "github" } ], - "time": "2025-08-20T19:12:42+00:00" + "time": "2026-01-28T01:33:45+00:00" }, { "name": "pestphp/pest-plugin", @@ -9043,31 +9245,32 @@ }, { "name": "php-debugbar/php-debugbar", - "version": "v2.2.4", + "version": "v2.2.6", "source": { "type": "git", "url": "https://github.com/php-debugbar/php-debugbar.git", - "reference": "3146d04671f51f69ffec2a4207ac3bdcf13a9f35" + "reference": "abb9fa3c5c8dbe7efe03ddba56782917481de3e8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-debugbar/php-debugbar/zipball/3146d04671f51f69ffec2a4207ac3bdcf13a9f35", - "reference": "3146d04671f51f69ffec2a4207ac3bdcf13a9f35", + "url": "https://api.github.com/repos/php-debugbar/php-debugbar/zipball/abb9fa3c5c8dbe7efe03ddba56782917481de3e8", + "reference": "abb9fa3c5c8dbe7efe03ddba56782917481de3e8", "shasum": "" }, "require": { - "php": "^8", + "php": "^8.1", "psr/log": "^1|^2|^3", - "symfony/var-dumper": "^4|^5|^6|^7" + "symfony/var-dumper": "^5.4|^6.4|^7.3|^8.0" }, "replace": { "maximebf/debugbar": "self.version" }, "require-dev": { "dbrekelmans/bdi": "^1", - "phpunit/phpunit": "^8|^9", + "phpunit/phpunit": "^10", + "symfony/browser-kit": "^6.0|7.0", "symfony/panther": "^1|^2.1", - "twig/twig": "^1.38|^2.7|^3.0" + "twig/twig": "^3.11.2" }, "suggest": { "kriswallsmith/assetic": "The best way to manage assets", @@ -9077,7 +9280,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.1-dev" + "dev-master": "2.2-dev" } }, "autoload": { @@ -9110,22 +9313,22 @@ ], "support": { "issues": "https://github.com/php-debugbar/php-debugbar/issues", - "source": "https://github.com/php-debugbar/php-debugbar/tree/v2.2.4" + "source": "https://github.com/php-debugbar/php-debugbar/tree/v2.2.6" }, - "time": "2025-07-22T14:01:30+00:00" + "time": "2025-12-22T13:21:32+00:00" }, { "name": "php-webdriver/webdriver", - "version": "1.15.2", + "version": "1.16.0", "source": { "type": "git", "url": "https://github.com/php-webdriver/php-webdriver.git", - "reference": "998e499b786805568deaf8cbf06f4044f05d91bf" + "reference": "ac0662863aa120b4f645869f584013e4c4dba46a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/998e499b786805568deaf8cbf06f4044f05d91bf", - "reference": "998e499b786805568deaf8cbf06f4044f05d91bf", + "url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/ac0662863aa120b4f645869f584013e4c4dba46a", + "reference": "ac0662863aa120b4f645869f584013e4c4dba46a", "shasum": "" }, "require": { @@ -9134,7 +9337,7 @@ "ext-zip": "*", "php": "^7.3 || ^8.0", "symfony/polyfill-mbstring": "^1.12", - "symfony/process": "^5.0 || ^6.0 || ^7.0" + "symfony/process": "^5.0 || ^6.0 || ^7.0 || ^8.0" }, "replace": { "facebook/webdriver": "*" @@ -9147,10 +9350,10 @@ "php-parallel-lint/php-parallel-lint": "^1.2", "phpunit/phpunit": "^9.3", "squizlabs/php_codesniffer": "^3.5", - "symfony/var-dumper": "^5.0 || ^6.0 || ^7.0" + "symfony/var-dumper": "^5.0 || ^6.0 || ^7.0 || ^8.0" }, "suggest": { - "ext-SimpleXML": "For Firefox profile creation" + "ext-simplexml": "For Firefox profile creation" }, "type": "library", "autoload": { @@ -9176,9 +9379,9 @@ ], "support": { "issues": "https://github.com/php-webdriver/php-webdriver/issues", - "source": "https://github.com/php-webdriver/php-webdriver/tree/1.15.2" + "source": "https://github.com/php-webdriver/php-webdriver/tree/1.16.0" }, - "time": "2024-11-21T15:12:59+00:00" + "time": "2025-12-28T23:57:40+00:00" }, { "name": "phpdocumentor/reflection-common", @@ -9235,16 +9438,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.5", + "version": "5.6.6", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "90614c73d3800e187615e2dd236ad0e2a01bf761" + "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/90614c73d3800e187615e2dd236ad0e2a01bf761", - "reference": "90614c73d3800e187615e2dd236ad0e2a01bf761", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8", "shasum": "" }, "require": { @@ -9254,7 +9457,7 @@ "phpdocumentor/reflection-common": "^2.2", "phpdocumentor/type-resolver": "^1.7", "phpstan/phpdoc-parser": "^1.7|^2.0", - "webmozart/assert": "^1.9.1" + "webmozart/assert": "^1.9.1 || ^2" }, "require-dev": { "mockery/mockery": "~1.3.5 || ~1.6.0", @@ -9293,9 +9496,9 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.5" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6" }, - "time": "2025-11-27T19:50:05+00:00" + "time": "2025-12-22T21:13:58+00:00" }, { "name": "phpdocumentor/type-resolver", @@ -9357,16 +9560,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.0", + "version": "2.3.2", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495" + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495", - "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", "shasum": "" }, "require": { @@ -9398,41 +9601,41 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" }, - "time": "2025-08-30T15:50:23+00:00" + "time": "2026-01-25T14:56:51+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "11.0.11", + "version": "11.0.12", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4" + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", - "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^5.4.0", + "nikic/php-parser": "^5.7.0", "php": ">=8.2", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-text-template": "^4.0.1", "sebastian/code-unit-reverse-lookup": "^4.0.1", "sebastian/complexity": "^4.0.1", - "sebastian/environment": "^7.2.0", + "sebastian/environment": "^7.2.1", "sebastian/lines-of-code": "^3.0.1", "sebastian/version": "^5.0.2", - "theseer/tokenizer": "^1.2.3" + "theseer/tokenizer": "^1.3.1" }, "require-dev": { - "phpunit/phpunit": "^11.5.2" + "phpunit/phpunit": "^11.5.46" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -9470,7 +9673,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.11" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" }, "funding": [ { @@ -9490,32 +9693,32 @@ "type": "tidelift" } ], - "time": "2025-08-27T14:37:49+00:00" + "time": "2025-12-24T07:01:01+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "5.1.0", + "version": "5.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -9543,15 +9746,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2024-08-27T05:02:59+00:00" + "time": "2026-02-02T13:52:54+00:00" }, { "name": "phpunit/php-invoker", @@ -9739,16 +9954,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.33", + "version": "11.5.50", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "5965e9ff57546cb9137c0ff6aa78cb7442b05cf6" + "reference": "fdfc727f0fcacfeb8fcb30c7e5da173125b58be3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5965e9ff57546cb9137c0ff6aa78cb7442b05cf6", - "reference": "5965e9ff57546cb9137c0ff6aa78cb7442b05cf6", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fdfc727f0fcacfeb8fcb30c7e5da173125b58be3", + "reference": "fdfc727f0fcacfeb8fcb30c7e5da173125b58be3", "shasum": "" }, "require": { @@ -9762,17 +9977,17 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.2", - "phpunit/php-code-coverage": "^11.0.10", + "phpunit/php-code-coverage": "^11.0.12", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-invoker": "^5.0.1", "phpunit/php-text-template": "^4.0.1", "phpunit/php-timer": "^7.0.1", "sebastian/cli-parser": "^3.0.2", "sebastian/code-unit": "^3.0.3", - "sebastian/comparator": "^6.3.2", + "sebastian/comparator": "^6.3.3", "sebastian/diff": "^6.0.2", "sebastian/environment": "^7.2.1", - "sebastian/exporter": "^6.3.0", + "sebastian/exporter": "^6.3.2", "sebastian/global-state": "^7.0.2", "sebastian/object-enumerator": "^6.0.1", "sebastian/type": "^5.1.3", @@ -9820,7 +10035,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.33" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.50" }, "funding": [ { @@ -9844,7 +10059,7 @@ "type": "tidelift" } ], - "time": "2025-08-16T05:19:02+00:00" + "time": "2026-01-27T05:59:18+00:00" }, { "name": "psr/cache", @@ -10130,16 +10345,16 @@ }, { "name": "sebastian/comparator", - "version": "6.3.2", + "version": "6.3.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8" + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8", - "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", "shasum": "" }, "require": { @@ -10198,7 +10413,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.2" + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" }, "funding": [ { @@ -10218,7 +10433,7 @@ "type": "tidelift" } ], - "time": "2025-08-10T08:07:46+00:00" + "time": "2026-01-24T09:26:40+00:00" }, { "name": "sebastian/complexity", @@ -11074,24 +11289,24 @@ }, { "name": "ta-tikoma/phpunit-architecture-test", - "version": "0.8.5", + "version": "0.8.6", "source": { "type": "git", "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git", - "reference": "cf6fb197b676ba716837c886baca842e4db29005" + "reference": "ad48430b92901fd7d003fdaf2d7b139f96c0906e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/cf6fb197b676ba716837c886baca842e4db29005", - "reference": "cf6fb197b676ba716837c886baca842e4db29005", + "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/ad48430b92901fd7d003fdaf2d7b139f96c0906e", + "reference": "ad48430b92901fd7d003fdaf2d7b139f96c0906e", "shasum": "" }, "require": { "nikic/php-parser": "^4.18.0 || ^5.0.0", "php": "^8.1.0", - "phpdocumentor/reflection-docblock": "^5.3.0", - "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0", - "symfony/finder": "^6.4.0 || ^7.0.0" + "phpdocumentor/reflection-docblock": "^5.3.0 || ^6.0.0", + "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0", + "symfony/finder": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "require-dev": { "laravel/pint": "^1.13.7", @@ -11127,9 +11342,9 @@ ], "support": { "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues", - "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.5" + "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.6" }, - "time": "2025-04-20T20:23:40+00:00" + "time": "2026-01-30T07:16:00+00:00" }, { "name": "theseer/tokenizer", @@ -11183,23 +11398,23 @@ }, { "name": "webmozart/assert", - "version": "1.12.1", + "version": "2.1.2", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" + "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", + "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", "shasum": "" }, "require": { "ext-ctype": "*", "ext-date": "*", "ext-filter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.2" }, "suggest": { "ext-intl": "", @@ -11209,7 +11424,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.10-dev" + "dev-feature/2-0": "2.0-dev" } }, "autoload": { @@ -11225,6 +11440,10 @@ { "name": "Bernhard Schussek", "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" } ], "description": "Assertions to validate method input/output with nice error messages.", @@ -11235,9 +11454,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.12.1" + "source": "https://github.com/webmozarts/assert/tree/2.1.2" }, - "time": "2025-10-29T15:56:20+00:00" + "time": "2026-01-13T14:02:24+00:00" } ], "aliases": [], diff --git a/config/domains.php b/config/domains.php index ee664ae..9219998 100644 --- a/config/domains.php +++ b/config/domains.php @@ -31,6 +31,7 @@ return [ 'domain_b2a' => env('DOMAIN_B2A', 'b2a.test'), 'domain_stileigentum' => env('DOMAIN_STILEIGENTUM', 'stileigentum.test'), 'domain_style2own' => env('DOMAIN_STYLE2OWN', 'style2own.test'), + 'domain_local4local' => env('DOMAIN_LOCAL4LOCAL', 'local4local.test'), /* |-------------------------------------------------------------------------- @@ -43,6 +44,7 @@ return [ 'domain_b2a_url' => env('DOMAIN_B2A_URL', 'https://b2a.test'), 'domain_stileigentum_url' => env('DOMAIN_STILEIGENTUM_URL', 'https://stileigentum.test'), 'domain_style2own_url' => env('DOMAIN_STYLE2OWN_URL', 'https://style2own.test'), + 'domain_local4local_url' => env('DOMAIN_LOCAL4LOCAL_URL', 'https://local4local.test'), 'domains' => [ 'portal' => [ @@ -121,5 +123,22 @@ return [ 'secondary' => 'Ephesis', ], ], + + 'local4local' => [ + 'domain_name' => env('DOMAIN_LOCAL4LOCAL', 'local4local.test'), + 'url' => env('DOMAIN_LOCAL4LOCAL_URL', 'https://local4local.test'), + 'theme' => 'local4local', + 'view_prefix' => 'local4local', + 'assets_dir' => 'build/local4local', + 'description' => 'Local for Local Marktplatz – Kunden-Frontend', + 'color_scheme' => [ + 'primary' => '#2b6b3e', // Local Green + 'secondary' => '#e8a838', // Warm Gold + ], + 'font_family' => [ + 'primary' => 'Inter', + 'secondary' => 'IBM Plex Sans', + ], + ], ], ]; diff --git a/config/livewire.php b/config/livewire.php index 294b7a4..480b9ba 100644 --- a/config/livewire.php +++ b/config/livewire.php @@ -40,6 +40,8 @@ return [ 'layout' => 'components.layouts.app', + 'component_layout' => 'components.layouts.app', + /* |--------------------------------------------------------------------------- | Lazy Loading Placeholder @@ -69,9 +71,23 @@ return [ 'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp' 'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1' 'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs... - 'png', 'gif', 'bmp', 'svg', 'wav', 'mp4', - 'mov', 'avi', 'wmv', 'mp3', 'm4a', - 'jpg', 'jpeg', 'mpga', 'webp', 'wma', + 'pdf', + 'png', + 'gif', + 'bmp', + 'svg', + 'wav', + 'mp4', + 'mov', + 'avi', + 'wmv', + 'mp3', + 'm4a', + 'jpg', + 'jpeg', + 'mpga', + 'webp', + 'wma', ], 'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated... 'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs... diff --git a/database/factories/BrandFactory.php b/database/factories/BrandFactory.php new file mode 100644 index 0000000..d40fde9 --- /dev/null +++ b/database/factories/BrandFactory.php @@ -0,0 +1,28 @@ + + */ +class BrandFactory extends Factory +{ + protected $model = Brand::class; + + public function definition(): array + { + $name = fake()->company().' '.fake()->randomElement(['Design', 'Living', 'Home']); + + return [ + 'partner_id' => Partner::factory()->manufacturer(), + 'name' => $name, + 'slug' => Str::slug($name).'-'.fake()->unique()->numberBetween(100, 999), + 'is_active' => true, + ]; + } +} diff --git a/database/factories/CategoryFactory.php b/database/factories/CategoryFactory.php new file mode 100644 index 0000000..bbdf33a --- /dev/null +++ b/database/factories/CategoryFactory.php @@ -0,0 +1,35 @@ + + */ +class CategoryFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected $model = Category::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->unique()->word(), + 'slug' => fake()->unique()->slug(2), + 'description' => fake()->sentence(), + ]; + } +} diff --git a/database/factories/HubFactory.php b/database/factories/HubFactory.php new file mode 100644 index 0000000..5e4d0ca --- /dev/null +++ b/database/factories/HubFactory.php @@ -0,0 +1,36 @@ + + */ +class HubFactory extends Factory +{ + protected $model = Hub::class; + + public function definition(): array + { + $name = fake()->city().' '.fake()->randomElement(['Region', 'Hub', 'Gebiet']); + + return [ + 'name' => $name, + 'slug' => Str::slug($name).'-'.fake()->unique()->numberBetween(100, 999), + 'is_active' => true, + ]; + } + + /** + * Inaktiver Hub. + */ + public function inactive(): static + { + return $this->state(fn (array $attributes) => [ + 'is_active' => false, + ]); + } +} diff --git a/database/factories/MediaFactory.php b/database/factories/MediaFactory.php new file mode 100644 index 0000000..fa3315f --- /dev/null +++ b/database/factories/MediaFactory.php @@ -0,0 +1,49 @@ + + */ +class MediaFactory extends Factory +{ + protected $model = Media::class; + + public function definition(): array + { + return [ + 'model_type' => Product::class, + 'model_id' => Product::factory(), + 'file_path' => 'media/'.fake()->uuid().'.jpg', + 'type' => 'image', + 'alt_text' => fake()->sentence(3), + 'order_column' => 0, + ]; + } + + /** + * Video-Media. + */ + public function video(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'video', + 'file_path' => 'media/'.fake()->uuid().'.mp4', + ]); + } + + /** + * PDF-Dokument. + */ + public function pdf(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'pdf', + 'file_path' => 'media/'.fake()->uuid().'.pdf', + ]); + } +} diff --git a/database/factories/PartnerFactory.php b/database/factories/PartnerFactory.php new file mode 100644 index 0000000..3fd5990 --- /dev/null +++ b/database/factories/PartnerFactory.php @@ -0,0 +1,79 @@ + + */ +class PartnerFactory extends Factory +{ + protected $model = Partner::class; + + public function definition(): array + { + $company = fake()->company(); + + return [ + 'company_name' => $company, + 'slug' => Str::slug($company).'-'.fake()->unique()->numberBetween(100, 999), + 'type' => 'Retailer', + 'is_active' => true, + ]; + } + + /** + * Händler (Retailer). + */ + public function retailer(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'Retailer', + 'delivery_radius_km' => fake()->numberBetween(10, 100), + ]); + } + + /** + * Hersteller (Manufacturer). + */ + public function manufacturer(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'Manufacturer', + ]); + } + + /** + * Makler (Estate-Agent). + */ + public function estateAgent(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'Estate-Agent', + ]); + } + + /** + * Partner mit Setup abgeschlossen. + */ + public function setupCompleted(): static + { + return $this->state(fn (array $attributes) => [ + 'setup_completed' => true, + 'setup_completed_at' => now(), + ]); + } + + /** + * Inaktiver Partner. + */ + public function inactive(): static + { + return $this->state(fn (array $attributes) => [ + 'is_active' => false, + ]); + } +} diff --git a/database/factories/ProductActivityFactory.php b/database/factories/ProductActivityFactory.php new file mode 100644 index 0000000..f30de5e --- /dev/null +++ b/database/factories/ProductActivityFactory.php @@ -0,0 +1,26 @@ + + */ +class ProductActivityFactory extends Factory +{ + protected $model = ProductActivity::class; + + public function definition(): array + { + return [ + 'product_id' => Product::factory(), + 'user_id' => User::factory(), + 'action' => fake()->randomElement(['created', 'updated', 'submitted', 'approved', 'correction', 'rejected']), + 'note' => null, + ]; + } +} diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php new file mode 100644 index 0000000..1221b55 --- /dev/null +++ b/database/factories/ProductFactory.php @@ -0,0 +1,93 @@ + + */ +class ProductFactory extends Factory +{ + protected $model = Product::class; + + public function definition(): array + { + $name = fake()->words(3, true); + + return [ + 'partner_id' => Partner::factory(), + 'name' => ucfirst($name), + 'slug' => Str::slug($name).'-'.fake()->unique()->numberBetween(1000, 9999), + 'product_type' => ProductType::LocalStock, + 'status' => ProductStatus::Draft, + 'price_type' => PriceType::Fixed, + 'description_short' => fake()->sentence(), + 'description_long' => fake()->paragraphs(2, true), + 'is_curated' => false, + 'is_available' => true, + ]; + } + + /** + * Teaser-Produkt (Säule A: Local Express). + */ + public function localStock(): static + { + return $this->state(fn (array $attributes) => [ + 'product_type' => ProductType::LocalStock, + 'price_type' => PriceType::Fixed, + ]); + } + + /** + * Konfigurations-Produkt (Säule B: Smart Club). + */ + public function smartOrder(): static + { + return $this->state(fn (array $attributes) => [ + 'product_type' => ProductType::SmartOrder, + 'price_type' => PriceType::FromPrice, + ]); + } + + /** + * Aktives und kuratiertes Produkt. + */ + public function active(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => ProductStatus::Active, + 'is_curated' => true, + 'curated_at' => now(), + ]); + } + + /** + * Produkt in einem bestimmten Hub. + */ + public function inHub(Hub $hub): static + { + return $this->state(fn (array $attributes) => [ + 'hub_id' => $hub->id, + ]); + } + + /** + * Verkauftes Produkt. + */ + public function sold(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => ProductStatus::Sold, + 'is_available' => false, + ]); + } +} diff --git a/database/factories/ProductWoodOriginFactory.php b/database/factories/ProductWoodOriginFactory.php new file mode 100644 index 0000000..e13ec8b --- /dev/null +++ b/database/factories/ProductWoodOriginFactory.php @@ -0,0 +1,29 @@ + + */ +class ProductWoodOriginFactory extends Factory +{ + protected $model = ProductWoodOrigin::class; + + public function definition(): array + { + return [ + 'product_id' => Product::factory(), + 'wood_species' => fake()->randomElement(['Quercus robur', 'Fagus sylvatica', 'Pinus sylvestris', 'Picea abies']), + 'origin_country' => fake()->randomElement(['DE', 'AT', 'PL', 'CZ', 'FR']), + 'origin_region' => fake()->optional()->city(), + 'harvest_year' => fake()->optional()->numberBetween(2020, 2026), + 'forest_operator' => fake()->optional()->company(), + 'sustainability_certificate' => fake()->optional()->randomElement(['FSC', 'PEFC', 'FSC/PEFC']), + 'eudr_reference_id' => fake()->optional()->uuid(), + ]; + } +} diff --git a/database/migrations/2026_02_12_000001_add_origin_and_hub_id_to_users_table.php b/database/migrations/2026_02_12_000001_add_origin_and_hub_id_to_users_table.php new file mode 100644 index 0000000..3b7cfac --- /dev/null +++ b/database/migrations/2026_02_12_000001_add_origin_and_hub_id_to_users_table.php @@ -0,0 +1,32 @@ +foreignId('hub_id') + ->nullable() + ->after('partner_id') + ->constrained('hubs') + ->nullOnDelete(); + + $table->string('origin') + ->nullable() + ->after('hub_id') + ->comment('style2own or stileigentum'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropForeign(['hub_id']); + $table->dropColumn(['hub_id', 'origin']); + }); + } +}; diff --git a/database/migrations/2026_02_12_000002_add_marketplace_fields_to_products_table.php b/database/migrations/2026_02_12_000002_add_marketplace_fields_to_products_table.php new file mode 100644 index 0000000..748bc1a --- /dev/null +++ b/database/migrations/2026_02_12_000002_add_marketplace_fields_to_products_table.php @@ -0,0 +1,70 @@ +foreignId('hub_id') + ->nullable() + ->after('collection_id') + ->constrained('hubs') + ->nullOnDelete(); + + $table->string('product_type') + ->default('local_stock') + ->after('slug') + ->comment('local_stock or smart_order'); + + $table->string('price_type') + ->default('fixed') + ->after('status') + ->comment('fixed, from_price, or on_request'); + + $table->string('price_display_text') + ->nullable() + ->after('price_type') + ->comment('Custom display text like Ab 2.500 EUR'); + + $table->boolean('is_curated') + ->default(false) + ->after('meta_description'); + + $table->timestamp('curated_at') + ->nullable() + ->after('is_curated'); + + $table->foreignId('curated_by') + ->nullable() + ->after('curated_at') + ->constrained('users') + ->nullOnDelete(); + + $table->boolean('is_available') + ->default(true) + ->after('curated_by'); + }); + } + + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropForeign(['hub_id']); + $table->dropForeign(['curated_by']); + $table->dropColumn([ + 'hub_id', + 'product_type', + 'price_type', + 'price_display_text', + 'is_curated', + 'curated_at', + 'curated_by', + 'is_available', + ]); + }); + } +}; diff --git a/database/migrations/2026_02_12_000003_add_profile_fields_to_partners_table.php b/database/migrations/2026_02_12_000003_add_profile_fields_to_partners_table.php new file mode 100644 index 0000000..96a6f02 --- /dev/null +++ b/database/migrations/2026_02_12_000003_add_profile_fields_to_partners_table.php @@ -0,0 +1,44 @@ +text('story_text') + ->nullable() + ->after('description') + ->comment('Partner story/about us text'); + + $table->json('opening_hours') + ->nullable() + ->after('story_text') + ->comment('Structured opening hours'); + + $table->json('specialties') + ->nullable() + ->after('opening_hours') + ->comment('Areas of expertise'); + + $table->integer('founded_year') + ->nullable() + ->after('specialties'); + }); + } + + public function down(): void + { + Schema::table('partners', function (Blueprint $table) { + $table->dropColumn([ + 'story_text', + 'opening_hours', + 'specialties', + 'founded_year', + ]); + }); + } +}; diff --git a/database/migrations/2026_02_12_000004_create_settings_table.php b/database/migrations/2026_02_12_000004_create_settings_table.php new file mode 100644 index 0000000..8213531 --- /dev/null +++ b/database/migrations/2026_02_12_000004_create_settings_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('group')->index(); + $table->string('key'); + $table->text('value')->nullable(); + $table->string('type')->default('string')->comment('string, integer, boolean, json'); + $table->text('description')->nullable(); + $table->timestamps(); + + $table->unique(['group', 'key']); + }); + } + + public function down(): void + { + Schema::dropIfExists('settings'); + } +}; diff --git a/database/migrations/2026_02_13_095509_make_tax_rate_id_nullable_on_product_variants_table.php b/database/migrations/2026_02_13_095509_make_tax_rate_id_nullable_on_product_variants_table.php new file mode 100644 index 0000000..c28079d --- /dev/null +++ b/database/migrations/2026_02_13_095509_make_tax_rate_id_nullable_on_product_variants_table.php @@ -0,0 +1,28 @@ +unsignedBigInteger('tax_rate_id')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('product_variants', function (Blueprint $table) { + $table->unsignedBigInteger('tax_rate_id')->nullable(false)->change(); + }); + } +}; diff --git a/database/migrations/2026_02_13_102359_add_csv_fields_to_products_table.php b/database/migrations/2026_02_13_102359_add_csv_fields_to_products_table.php new file mode 100644 index 0000000..3ac0142 --- /dev/null +++ b/database/migrations/2026_02_13_102359_add_csv_fields_to_products_table.php @@ -0,0 +1,66 @@ +string('country_of_origin', 2)->nullable()->after('care_instructions'); + $table->string('main_material')->nullable()->after('country_of_origin'); + $table->string('surface_material')->nullable()->after('main_material'); + $table->string('cover_material')->nullable()->after('surface_material'); + $table->string('color_finish')->nullable()->after('cover_material'); + $table->json('certificates')->nullable()->after('color_finish'); + + // Montage & Physisch + $table->integer('assembly_time_min')->nullable()->after('assembly_status'); + $table->integer('load_capacity_kg')->nullable()->after('assembly_time_min'); + + // Lieferung & Services + $table->string('delivery_type')->nullable()->after('load_capacity_kg'); + $table->boolean('assembly_service')->default(false)->after('delivery_type'); + $table->integer('service_radius_km')->nullable()->after('assembly_service'); + $table->integer('warranty_months')->nullable()->after('service_radius_km'); + $table->integer('production_time_days')->nullable()->after('warranty_months'); + + // Sichtbarkeit + $table->date('visible_from')->nullable()->after('is_available'); + $table->date('visible_until')->nullable()->after('visible_from'); + + // Nachhaltigkeit + $table->decimal('co2_footprint_kg', 8, 2)->nullable()->after('visible_until'); + $table->integer('recycling_percentage')->nullable()->after('co2_footprint_kg'); + $table->boolean('is_regional_production')->default(false)->after('recycling_percentage'); + + // Scoring (B2in intern) + $table->integer('storage_volume_liters')->nullable()->after('is_regional_production'); + $table->tinyInteger('assembly_effort_score')->nullable()->after('storage_volume_liters'); + $table->tinyInteger('design_score')->nullable()->after('assembly_effort_score'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn([ + 'country_of_origin', 'main_material', 'surface_material', 'cover_material', + 'color_finish', 'certificates', 'assembly_time_min', 'load_capacity_kg', + 'delivery_type', 'assembly_service', 'service_radius_km', 'warranty_months', + 'production_time_days', 'visible_from', 'visible_until', 'co2_footprint_kg', + 'recycling_percentage', 'is_regional_production', 'storage_volume_liters', + 'assembly_effort_score', 'design_score', + ]); + }); + } +}; diff --git a/database/migrations/2026_02_13_102400_add_csv_fields_to_product_logistics_table.php b/database/migrations/2026_02_13_102400_add_csv_fields_to_product_logistics_table.php new file mode 100644 index 0000000..1bda595 --- /dev/null +++ b/database/migrations/2026_02_13_102400_add_csv_fields_to_product_logistics_table.php @@ -0,0 +1,31 @@ +string('packaging_type')->nullable()->after('location_bin'); + $table->integer('packaging_recyclable_percent')->nullable()->after('packaging_type'); + $table->boolean('is_palletizable')->default(false)->after('packaging_recyclable_percent'); + $table->string('hs_code')->nullable()->after('is_palletizable'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('product_logistics', function (Blueprint $table) { + $table->dropColumn(['packaging_type', 'packaging_recyclable_percent', 'is_palletizable', 'hs_code']); + }); + } +}; diff --git a/database/migrations/2026_02_13_102400_add_currency_to_product_variants_table.php b/database/migrations/2026_02_13_102400_add_currency_to_product_variants_table.php new file mode 100644 index 0000000..779bb49 --- /dev/null +++ b/database/migrations/2026_02_13_102400_add_currency_to_product_variants_table.php @@ -0,0 +1,28 @@ +string('currency', 3)->default('EUR')->after('delivery_time_text'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('product_variants', function (Blueprint $table) { + $table->dropColumn('currency'); + }); + } +}; diff --git a/database/migrations/2026_02_13_102400_create_product_wood_origins_table.php b/database/migrations/2026_02_13_102400_create_product_wood_origins_table.php new file mode 100644 index 0000000..5bf0c9f --- /dev/null +++ b/database/migrations/2026_02_13_102400_create_product_wood_origins_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('product_id')->constrained('products')->onDelete('cascade'); + $table->string('wood_species'); + $table->string('origin_country', 2); + $table->string('origin_region')->nullable(); + $table->integer('harvest_year')->nullable(); + $table->string('forest_operator')->nullable(); + $table->string('sustainability_certificate')->nullable(); + $table->string('eudr_reference_id')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_wood_origins'); + } +}; diff --git a/database/migrations/2026_02_13_105849_add_product_numbers_and_nullable_variant_fields.php b/database/migrations/2026_02_13_105849_add_product_numbers_and_nullable_variant_fields.php new file mode 100644 index 0000000..dd8a15b --- /dev/null +++ b/database/migrations/2026_02_13_105849_add_product_numbers_and_nullable_variant_fields.php @@ -0,0 +1,36 @@ +string('partner_product_number')->nullable()->after('partner_id'); + $table->string('b2in_article_number')->nullable()->unique()->after('partner_product_number'); + $table->boolean('visibleIsAvailable')->default(false)->after('is_available'); + }); + + // Make SKU and selling_price nullable on product_variants + Schema::table('product_variants', function (Blueprint $table) { + $table->string('sku')->nullable()->change(); + $table->integer('selling_price')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn(['partner_product_number', 'b2in_article_number']); + }); + + Schema::table('product_variants', function (Blueprint $table) { + $table->string('sku')->nullable(false)->change(); + $table->integer('selling_price')->nullable(false)->change(); + }); + } +}; diff --git a/database/migrations/2026_02_13_120708_add_curation_notes_to_products_table.php b/database/migrations/2026_02_13_120708_add_curation_notes_to_products_table.php new file mode 100644 index 0000000..ad6a5dd --- /dev/null +++ b/database/migrations/2026_02_13_120708_add_curation_notes_to_products_table.php @@ -0,0 +1,28 @@ +text('curation_notes')->nullable()->after('curated_by'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn('curation_notes'); + }); + } +}; diff --git a/database/migrations/2026_02_13_122527_create_product_activities_table.php b/database/migrations/2026_02_13_122527_create_product_activities_table.php new file mode 100644 index 0000000..e416a5f --- /dev/null +++ b/database/migrations/2026_02_13_122527_create_product_activities_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('action'); + $table->text('note')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_activities'); + } +}; diff --git a/database/seeders/CategorySeeder.php b/database/seeders/CategorySeeder.php new file mode 100644 index 0000000..f8536a8 --- /dev/null +++ b/database/seeders/CategorySeeder.php @@ -0,0 +1,41 @@ +updateOrInsert( + ['slug' => Str::slug($name)], + [ + 'name' => $name, + 'slug' => Str::slug($name), + 'created_at' => now(), + 'updated_at' => now(), + ] + ); + } + } +} diff --git a/database/seeders/RestoreBackupSeeder.php b/database/seeders/RestoreBackupSeeder.php new file mode 100644 index 0000000..6d93540 --- /dev/null +++ b/database/seeders/RestoreBackupSeeder.php @@ -0,0 +1,292 @@ +getDriverName() === 'mysql'; + + if ($isMysql) { + DB::statement('SET FOREIGN_KEY_CHECKS=0'); + } + + $this->seedCategories(); + $this->seedRoles(); + $this->seedPermissions(); + $this->seedRoleHasPermissions(); + $this->seedPartners(); + $this->seedUsers(); + $this->seedModelHasRoles(); + $this->seedBrands(); + $this->seedPartnerInvitations(); + $this->seedRegistrationCodes(); + $this->seedDisplayVideos(); + $this->seedDisplayFooterContents(); + + if ($isMysql) { + DB::statement('SET FOREIGN_KEY_CHECKS=1'); + } + + $this->command->info('Backup-Daten erfolgreich wiederhergestellt.'); + } + + public function seedCategories(): void + { + $categories = [ + 'Wohnzimmer', + 'Schlafzimmer', + 'Esszimmer', + 'Küche', + 'Bad & Wellness', + 'Arbeitszimmer', + 'Kinderzimmer', + 'Outdoor & Garten', + 'Beleuchtung', + 'Textilien & Dekoration', + 'Teppiche', + 'Maßmöbel', + 'Dienstleistungen', + ]; + + foreach ($categories as $name) { + DB::table('categories')->updateOrInsert( + ['slug' => Str::slug($name)], + [ + 'name' => $name, + 'slug' => Str::slug($name), + 'created_at' => now(), + 'updated_at' => now(), + ] + ); + } + } + + private function seedRoles(): void + { + DB::table('roles')->truncate(); + DB::table('roles')->insert([ + ['id' => 1, 'name' => 'Customer', 'display_name' => 'Kunde (Customer)', 'icon' => 'user', 'can_be_invited' => 1, 'reg_prefix' => 'K', 'reg_description' => 'Kundencodes werden Maklern oder Händlern zugeordnet', 'reg_start_number' => 40000001, 'guard_name' => 'web', 'color' => 'indigo', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-12-16 14:56:59'], + ['id' => 2, 'name' => 'Broker', 'display_name' => 'Makler (Broker)', 'icon' => 'home', 'can_be_invited' => 1, 'reg_prefix' => 'M', 'reg_description' => 'Maklercodes für die Registrierung von Maklern', 'reg_start_number' => 10000001, 'guard_name' => 'web', 'color' => 'lime', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-12-17 12:00:39'], + ['id' => 3, 'name' => 'Retailer', 'display_name' => 'Händler (Retailer)', 'icon' => 'building-storefront', 'can_be_invited' => 1, 'reg_prefix' => 'H', 'reg_description' => 'Händlercodes für die Registrierung von Händlern', 'reg_start_number' => 20000001, 'guard_name' => 'web', 'color' => 'teal', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-12-16 14:57:09'], + ['id' => 4, 'name' => 'Manufacturer', 'display_name' => 'Hersteller (Manufacturer)', 'icon' => 'wrench-screwdriver', 'can_be_invited' => 1, 'reg_prefix' => 'P', 'reg_description' => 'Herstellercodes für die Registrierung von Herstellern', 'reg_start_number' => 30000001, 'guard_name' => 'web', 'color' => 'orange', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-12-16 14:57:15'], + ['id' => 5, 'name' => 'Admin', 'display_name' => 'Admin (Administrator)', 'icon' => 'user-circle', 'can_be_invited' => 0, 'reg_prefix' => null, 'reg_description' => null, 'reg_start_number' => null, 'guard_name' => 'web', 'color' => 'purple', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-11-21 14:29:15'], + ['id' => 6, 'name' => 'Super-Admin', 'display_name' => 'Super-Admin (Entwickler)', 'icon' => 'shield-check', 'can_be_invited' => 0, 'reg_prefix' => null, 'reg_description' => null, 'reg_start_number' => null, 'guard_name' => 'web', 'color' => 'red', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-11-21 14:29:15'], + ]); + } + + private function seedPermissions(): void + { + DB::table('permissions')->truncate(); + DB::table('permissions')->insert([ + ['id' => 1, 'name' => 'view hubs', 'guard_name' => 'web', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-11-21 14:29:15'], + ['id' => 2, 'name' => 'create hubs', 'guard_name' => 'web', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-11-21 14:29:15'], + ['id' => 3, 'name' => 'edit hubs', 'guard_name' => 'web', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-11-21 14:29:15'], + ['id' => 4, 'name' => 'delete hubs', 'guard_name' => 'web', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-11-21 14:29:15'], + ['id' => 5, 'name' => 'view partners', 'guard_name' => 'web', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-11-21 14:29:15'], + ['id' => 6, 'name' => 'create partners', 'guard_name' => 'web', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-11-21 14:29:15'], + ['id' => 7, 'name' => 'edit partners', 'guard_name' => 'web', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-11-21 14:29:15'], + ['id' => 8, 'name' => 'delete partners', 'guard_name' => 'web', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-11-21 14:29:15'], + ['id' => 9, 'name' => 'manage provisions', 'guard_name' => 'web', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-11-21 14:29:15'], + ['id' => 10, 'name' => 'view products', 'guard_name' => 'web', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-11-21 14:29:15'], + ['id' => 11, 'name' => 'create products', 'guard_name' => 'web', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-11-21 14:29:15'], + ['id' => 12, 'name' => 'edit products', 'guard_name' => 'web', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-11-21 14:29:15'], + ['id' => 13, 'name' => 'delete products', 'guard_name' => 'web', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-11-21 14:29:15'], + ['id' => 14, 'name' => 'manage rental options', 'guard_name' => 'web', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-11-21 14:29:15'], + ['id' => 15, 'name' => 'view orders', 'guard_name' => 'web', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-11-21 14:29:15'], + ['id' => 16, 'name' => 'manage orders', 'guard_name' => 'web', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-11-21 14:29:15'], + ['id' => 17, 'name' => 'view users', 'guard_name' => 'web', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-11-21 14:29:15'], + ['id' => 18, 'name' => 'manage users', 'guard_name' => 'web', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-11-21 14:29:15'], + ['id' => 19, 'name' => 'manage roles', 'guard_name' => 'web', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-11-21 14:29:15'], + ['id' => 20, 'name' => 'access dashboard', 'guard_name' => 'web', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-11-21 14:29:15'], + ['id' => 21, 'name' => 'place orders', 'guard_name' => 'web', 'created_at' => '2025-11-21 14:29:15', 'updated_at' => '2025-11-21 14:29:15'], + ]); + } + + private function seedRoleHasPermissions(): void + { + DB::table('role_has_permissions')->truncate(); + DB::table('role_has_permissions')->insert([ + ['permission_id' => 1, 'role_id' => 2], + ['permission_id' => 1, 'role_id' => 5], + ['permission_id' => 2, 'role_id' => 5], + ['permission_id' => 3, 'role_id' => 5], + ['permission_id' => 4, 'role_id' => 5], + ['permission_id' => 5, 'role_id' => 2], + ['permission_id' => 5, 'role_id' => 5], + ['permission_id' => 6, 'role_id' => 5], + ['permission_id' => 7, 'role_id' => 5], + ['permission_id' => 8, 'role_id' => 5], + ['permission_id' => 9, 'role_id' => 5], + ['permission_id' => 10, 'role_id' => 1], + ['permission_id' => 10, 'role_id' => 3], + ['permission_id' => 10, 'role_id' => 4], + ['permission_id' => 10, 'role_id' => 5], + ['permission_id' => 11, 'role_id' => 3], + ['permission_id' => 11, 'role_id' => 4], + ['permission_id' => 11, 'role_id' => 5], + ['permission_id' => 12, 'role_id' => 3], + ['permission_id' => 12, 'role_id' => 4], + ['permission_id' => 12, 'role_id' => 5], + ['permission_id' => 13, 'role_id' => 3], + ['permission_id' => 13, 'role_id' => 4], + ['permission_id' => 13, 'role_id' => 5], + ['permission_id' => 14, 'role_id' => 3], + ['permission_id' => 14, 'role_id' => 4], + ['permission_id' => 14, 'role_id' => 5], + ['permission_id' => 15, 'role_id' => 1], + ['permission_id' => 15, 'role_id' => 3], + ['permission_id' => 15, 'role_id' => 4], + ['permission_id' => 15, 'role_id' => 5], + ['permission_id' => 16, 'role_id' => 3], + ['permission_id' => 16, 'role_id' => 4], + ['permission_id' => 16, 'role_id' => 5], + ['permission_id' => 17, 'role_id' => 5], + ['permission_id' => 18, 'role_id' => 5], + ['permission_id' => 19, 'role_id' => 5], + ['permission_id' => 20, 'role_id' => 2], + ['permission_id' => 20, 'role_id' => 3], + ['permission_id' => 20, 'role_id' => 4], + ['permission_id' => 20, 'role_id' => 5], + ['permission_id' => 21, 'role_id' => 1], + ]); + } + + private function seedPartners(): void + { + DB::table('partners')->truncate(); + DB::table('partners')->insert([ + ['id' => 4, 'company_name' => 'Media Matters ', 'display_name' => null, 'slug' => 'media-matters', 'type' => 'Retailer', 'brand' => null, 'salutation' => null, 'first_name' => null, 'last_name' => null, 'hub_id' => null, 'parent_partner_id' => null, 'description' => null, 'street' => null, 'house_number' => null, 'zip' => null, 'city' => null, 'country' => 'Deutschland', 'phone' => null, 'website' => null, 'logo_url' => null, 'is_active' => 0, 'setup_completed' => 0, 'setup_completed_at' => null, 'delivery_radius_km' => null, 'assembly_radius_km' => null, 'provision_fixed_amount' => null, 'provision_rate_percentage' => null, 'created_at' => '2025-11-21 15:01:15', 'updated_at' => '2025-11-21 15:01:15'], + ['id' => 5, 'company_name' => 'Partner M10000001', 'display_name' => null, 'slug' => 'partner-m10000001-91', 'type' => 'Estate-Agent', 'brand' => null, 'salutation' => null, 'first_name' => null, 'last_name' => null, 'hub_id' => null, 'parent_partner_id' => null, 'description' => '', 'street' => null, 'house_number' => null, 'zip' => null, 'city' => null, 'country' => 'Deutschland', 'phone' => null, 'website' => null, 'logo_url' => null, 'is_active' => 0, 'setup_completed' => 0, 'setup_completed_at' => null, 'delivery_radius_km' => null, 'assembly_radius_km' => null, 'provision_fixed_amount' => null, 'provision_rate_percentage' => null, 'created_at' => '2025-12-16 15:05:26', 'updated_at' => '2025-12-16 16:47:39'], + ['id' => 6, 'company_name' => 'Markler Klaus M10000002', 'display_name' => 'Max Schmidt', 'slug' => 'partner-m10000002-104', 'type' => 'Estate-Agent', 'brand' => null, 'salutation' => 'Herr', 'first_name' => 'test', 'last_name' => 'test123', 'hub_id' => null, 'parent_partner_id' => null, 'description' => '', 'street' => 'Teststraße', 'house_number' => '123', 'zip' => '12344', 'city' => 'Musterstadt', 'country' => 'Deutschland', 'phone' => '', 'website' => '', 'logo_url' => null, 'is_active' => 1, 'setup_completed' => 1, 'setup_completed_at' => '2025-12-17 16:41:19', 'delivery_radius_km' => null, 'assembly_radius_km' => null, 'provision_fixed_amount' => null, 'provision_rate_percentage' => null, 'created_at' => '2025-12-16 16:29:03', 'updated_at' => '2025-12-17 16:41:19'], + ['id' => 9, 'company_name' => 'roles.customer K40000001', 'display_name' => null, 'slug' => 'rolescustomer-k40000001-93', 'type' => 'customer', 'brand' => null, 'salutation' => 'Herr', 'first_name' => 'asd', 'last_name' => 'asd', 'hub_id' => null, 'parent_partner_id' => 5, 'description' => null, 'street' => 'Musterstraße', 'house_number' => '1235', 'zip' => '12343', 'city' => '2134', 'country' => 'Deutschland', 'phone' => '', 'website' => null, 'logo_url' => null, 'is_active' => 0, 'setup_completed' => 0, 'setup_completed_at' => null, 'delivery_radius_km' => null, 'assembly_radius_km' => null, 'provision_fixed_amount' => null, 'provision_rate_percentage' => null, 'created_at' => '2025-12-17 12:38:20', 'updated_at' => '2025-12-17 16:43:08'], + ['id' => 10, 'company_name' => 'Hersteller P30000001', 'display_name' => null, 'slug' => 'rolesmanufacturer-p30000001-105', 'type' => 'manufacturer', 'brand' => 'b2in', 'salutation' => 'Herr', 'first_name' => 'Herr', 'last_name' => 'Steller', 'hub_id' => null, 'parent_partner_id' => null, 'description' => '', 'street' => 'Musterstraße', 'house_number' => '123', 'zip' => '12345', 'city' => 'Bielefeld', 'country' => 'Deutschland', 'phone' => '', 'website' => '', 'logo_url' => null, 'is_active' => 1, 'setup_completed' => 1, 'setup_completed_at' => '2025-12-18 13:10:19', 'delivery_radius_km' => null, 'assembly_radius_km' => null, 'provision_fixed_amount' => null, 'provision_rate_percentage' => null, 'created_at' => '2025-12-18 08:11:34', 'updated_at' => '2025-12-18 17:56:39'], + ['id' => 11, 'company_name' => 'Max Möbelmann', 'display_name' => null, 'slug' => 'max-mobelmann', 'type' => 'customer', 'brand' => 'stileigentum', 'salutation' => 'Frau', 'first_name' => 'Franz', 'last_name' => 'Hatstil', 'hub_id' => null, 'parent_partner_id' => null, 'description' => null, 'street' => 'In der Lake', 'house_number' => '4', 'zip' => '33739', 'city' => 'Bielefeld', 'country' => 'Deutschland', 'phone' => '170206113', 'website' => null, 'logo_url' => null, 'is_active' => 1, 'setup_completed' => 1, 'setup_completed_at' => '2025-12-18 10:14:50', 'delivery_radius_km' => null, 'assembly_radius_km' => null, 'provision_fixed_amount' => null, 'provision_rate_percentage' => null, 'created_at' => '2025-12-18 09:46:11', 'updated_at' => '2025-12-18 17:55:59'], + ['id' => 12, 'company_name' => 'roles.customer K40000003', 'display_name' => null, 'slug' => 'rolescustomer-k40000003-95', 'type' => 'customer', 'brand' => 'style2own', 'salutation' => 'Frau', 'first_name' => 'Steffi', 'last_name' => 'Willmöbel', 'hub_id' => null, 'parent_partner_id' => 5, 'description' => null, 'street' => 'In der Lake', 'house_number' => '4', 'zip' => '33739', 'city' => 'Bielefeld', 'country' => 'Deutschland', 'phone' => '170206113', 'website' => null, 'logo_url' => null, 'is_active' => 1, 'setup_completed' => 1, 'setup_completed_at' => '2025-12-18 10:26:45', 'delivery_radius_km' => null, 'assembly_radius_km' => null, 'provision_fixed_amount' => null, 'provision_rate_percentage' => null, 'created_at' => '2025-12-18 10:23:23', 'updated_at' => '2025-12-18 17:54:55'], + ['id' => 13, 'company_name' => 'Immobilien M10000004', 'display_name' => 'Markler Max ', 'slug' => 'rolesbroker-m10000004-108', 'type' => 'broker', 'brand' => 'b2in', 'salutation' => 'Herr', 'first_name' => 'Immobilien', 'last_name' => 'Schulz', 'hub_id' => null, 'parent_partner_id' => null, 'description' => '', 'street' => 'In der Lake', 'house_number' => '4', 'zip' => '33739', 'city' => 'Bielefeld', 'country' => 'Deutschland', 'phone' => '170206113', 'website' => '', 'logo_url' => null, 'is_active' => 1, 'setup_completed' => 1, 'setup_completed_at' => '2025-12-18 10:36:31', 'delivery_radius_km' => null, 'assembly_radius_km' => null, 'provision_fixed_amount' => null, 'provision_rate_percentage' => null, 'created_at' => '2025-12-18 10:30:41', 'updated_at' => '2025-12-18 17:54:21'], + ['id' => 14, 'company_name' => 'Händler H20000001', 'display_name' => null, 'slug' => 'rolesretailer-h20000001-109', 'type' => 'retailer', 'brand' => 'b2in', 'salutation' => 'Herr', 'first_name' => 'Händler', 'last_name' => 'Max', 'hub_id' => null, 'parent_partner_id' => null, 'description' => '', 'street' => 'In der Lake', 'house_number' => '12', 'zip' => '33739', 'city' => 'Bielefeld', 'country' => 'Deutschland', 'phone' => '170206113', 'website' => '', 'logo_url' => null, 'is_active' => 1, 'setup_completed' => 1, 'setup_completed_at' => '2025-12-18 10:40:48', 'delivery_radius_km' => 20, 'assembly_radius_km' => 30, 'provision_fixed_amount' => null, 'provision_rate_percentage' => null, 'created_at' => '2025-12-18 10:37:29', 'updated_at' => '2025-12-18 17:53:56'], + ['id' => 15, 'company_name' => 'Hersteller P30000002', 'display_name' => null, 'slug' => 'rolesmanufacturer-p30000002-107', 'type' => 'manufacturer', 'brand' => 'b2in', 'salutation' => 'Herr', 'first_name' => 'Hersteller', 'last_name' => 'Moritz', 'hub_id' => null, 'parent_partner_id' => null, 'description' => '', 'street' => 'In der Lake', 'house_number' => '4', 'zip' => '33739', 'city' => 'Bielefeld', 'country' => 'Deutschland', 'phone' => '170206113', 'website' => '', 'logo_url' => null, 'is_active' => 1, 'setup_completed' => 1, 'setup_completed_at' => '2025-12-18 10:55:57', 'delivery_radius_km' => null, 'assembly_radius_km' => null, 'provision_fixed_amount' => null, 'provision_rate_percentage' => null, 'created_at' => '2025-12-18 10:46:07', 'updated_at' => '2025-12-18 17:53:22'], + ['id' => 16, 'company_name' => 'Marcel Scheibe', 'display_name' => null, 'slug' => 'marcel-scheibe', 'type' => 'Customer', 'brand' => null, 'salutation' => 'Herr', 'first_name' => 'Marcel', 'last_name' => 'Scheibe', 'hub_id' => null, 'parent_partner_id' => null, 'description' => null, 'street' => 'In der Lake', 'house_number' => '4', 'zip' => '33739', 'city' => 'Bielefeld', 'country' => 'Deutschland', 'phone' => '170206113', 'website' => null, 'logo_url' => null, 'is_active' => 1, 'setup_completed' => 1, 'setup_completed_at' => '2025-12-19 11:21:05', 'delivery_radius_km' => null, 'assembly_radius_km' => null, 'provision_fixed_amount' => null, 'provision_rate_percentage' => null, 'created_at' => '2025-12-19 11:20:56', 'updated_at' => '2025-12-19 11:21:05'], + ['id' => 17, 'company_name' => 'Makler M10000005', 'display_name' => 'B2in TEST', 'slug' => 'rolesbroker-m10000005-110', 'type' => 'broker', 'brand' => 'b2in', 'salutation' => 'Herr', 'first_name' => 'Marcel', 'last_name' => 'Scheibe', 'hub_id' => null, 'parent_partner_id' => null, 'description' => 'Hallo', 'street' => 'Feldstrasse ', 'house_number' => '59', 'zip' => '32120', 'city' => 'Hiddenhausen', 'country' => 'Deutschland', 'phone' => '015151002992', 'website' => '', 'logo_url' => null, 'is_active' => 1, 'setup_completed' => 1, 'setup_completed_at' => '2026-01-14 10:48:58', 'delivery_radius_km' => null, 'assembly_radius_km' => null, 'provision_fixed_amount' => null, 'provision_rate_percentage' => null, 'created_at' => '2026-01-14 10:45:36', 'updated_at' => '2026-01-14 10:48:58'], + ]); + } + + private function seedUsers(): void + { + DB::table('users')->truncate(); + DB::table('users')->insert([ + ['id' => 1, 'partner_id' => null, 'hub_id' => null, 'origin' => null, 'name' => 'Kevin Adametz', 'display_name' => null, 'email' => 'kevin.adametz@me.com', 'email_verified_at' => '2025-11-21 14:29:10', 'password' => '$2y$12$pw7z0He1cIJ/owZOJWYu8O.d6dh6uIgH1tQeB8EiAS7PE3iwnL7Si', 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, 'two_factor_confirmed_at' => null, 'remember_token' => 'aznSzEV51VHUf5GBlkykgRKRpGW8zQaMHdS792uoCXg5zySz8NGI3YxdBwwo', 'created_at' => '2025-11-21 14:29:10', 'updated_at' => '2025-12-19 10:58:54', 'deleted_at' => null], + ['id' => 5, 'partner_id' => 4, 'hub_id' => null, 'origin' => null, 'name' => 'Gelöschter Benutzer #5', 'display_name' => null, 'email' => 'deleted_5@anonymized.local', 'email_verified_at' => '2025-11-21 15:01:16', 'password' => '$2y$12$UH.TScgahDROtFFDI/vkw.hZvXUrmStdyaDRG85I1kAHBpcQMYZSq', 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, 'two_factor_confirmed_at' => null, 'remember_token' => 'ZB4rw542j7Uq2rcvoFz0NLa4ldftZvW5yYqUmGtkvGclZsxma0S5kLC9KWQt', 'created_at' => '2025-11-21 15:01:16', 'updated_at' => '2025-12-18 08:07:32', 'deleted_at' => '2025-12-18 08:07:32'], + ['id' => 6, 'partner_id' => 5, 'hub_id' => null, 'origin' => null, 'name' => 'Gelöschter Benutzer #6', 'display_name' => null, 'email' => 'deleted_6@anonymized.local', 'email_verified_at' => '2025-12-16 15:05:26', 'password' => '$2y$12$UD3ivNt9uqv.TcooAf.jQOqeCfhX1aXRABGYvCC7XLznhqrbm681.', 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, 'two_factor_confirmed_at' => null, 'remember_token' => 'Lega5Eb5bhmGYVT5z6Fkzf7wuF9GwQLG1VEoerP7yvhz4YfrLIEcibDZxOOv', 'created_at' => '2025-12-16 15:05:26', 'updated_at' => '2025-12-18 08:07:20', 'deleted_at' => '2025-12-18 08:07:20'], + ['id' => 7, 'partner_id' => 6, 'hub_id' => null, 'origin' => null, 'name' => 'Gelöschter Benutzer #7', 'display_name' => null, 'email' => 'deleted_7@anonymized.local', 'email_verified_at' => '2025-12-16 16:29:03', 'password' => '$2y$12$DmnePN980lksdVtWhL6HyeSD6sJ3oGoRKq3pqs4UV0ADpWUYKQq3e', 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, 'two_factor_confirmed_at' => null, 'remember_token' => null, 'created_at' => '2025-12-16 16:29:03', 'updated_at' => '2025-12-18 08:07:16', 'deleted_at' => '2025-12-18 08:07:16'], + ['id' => 10, 'partner_id' => 9, 'hub_id' => null, 'origin' => null, 'name' => 'Gelöschter Benutzer #10', 'display_name' => null, 'email' => 'deleted_10@anonymized.local', 'email_verified_at' => '2025-12-17 12:57:18', 'password' => '$2y$12$q0O2UymzIdLS3zr.rvMib.QBoeo7tPg3K9YM9a5bw5zx0gi9OHbta', 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, 'two_factor_confirmed_at' => null, 'remember_token' => null, 'created_at' => '2025-12-17 12:38:20', 'updated_at' => '2025-12-18 08:07:01', 'deleted_at' => '2025-12-18 08:07:01'], + ['id' => 11, 'partner_id' => 10, 'hub_id' => null, 'origin' => null, 'name' => 'Herr Steller', 'display_name' => 'Herr Steller', 'email' => 'info1@adametz.media', 'email_verified_at' => '2025-12-18 08:11:52', 'password' => '$2y$12$HpQQMRo6dtQHsJC0ZNpBxO6pKRUpAEZnIXBEYdF4i5TC4j0P6alUa', 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, 'two_factor_confirmed_at' => null, 'remember_token' => null, 'created_at' => '2025-12-18 08:11:34', 'updated_at' => '2025-12-18 17:51:08', 'deleted_at' => null], + ['id' => 12, 'partner_id' => 11, 'hub_id' => null, 'origin' => null, 'name' => 'Franz Hatstil', 'display_name' => null, 'email' => 'info3@adametz.media', 'email_verified_at' => '2025-12-18 09:46:11', 'password' => '$2y$12$2EeN31d8rapcACWOsVWON.j6AeTJ1puR3VNWUE3e.wDsklwLT8jom', 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, 'two_factor_confirmed_at' => null, 'remember_token' => null, 'created_at' => '2025-12-18 09:46:11', 'updated_at' => '2025-12-18 17:55:44', 'deleted_at' => null], + ['id' => 13, 'partner_id' => 12, 'hub_id' => null, 'origin' => null, 'name' => 'Steffi Willmöbel', 'display_name' => null, 'email' => 'info4@adametz.media', 'email_verified_at' => '2025-12-18 10:23:38', 'password' => '$2y$12$VCPjieqeb9aTERmnVd6JJ.koqOGGokq0k2143u1e6FjKrHDKrER2y', 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, 'two_factor_confirmed_at' => null, 'remember_token' => null, 'created_at' => '2025-12-18 10:23:23', 'updated_at' => '2025-12-18 17:55:13', 'deleted_at' => null], + ['id' => 14, 'partner_id' => 13, 'hub_id' => null, 'origin' => null, 'name' => 'Immobilien Schulz', 'display_name' => 'Immobilien Schulz', 'email' => 'info5@adametz.media', 'email_verified_at' => '2025-12-18 10:31:14', 'password' => '$2y$12$i4sN9dx9XGMgTyh3dG8ice38bpqqcd4OQ9ziw.uxUI7O1iK4e1c1m', 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, 'two_factor_confirmed_at' => null, 'remember_token' => null, 'created_at' => '2025-12-18 10:30:41', 'updated_at' => '2025-12-18 17:52:20', 'deleted_at' => null], + ['id' => 15, 'partner_id' => 14, 'hub_id' => null, 'origin' => null, 'name' => 'Händler Max', 'display_name' => 'Händler Max', 'email' => 'info6@adametz.media', 'email_verified_at' => '2025-12-18 10:37:36', 'password' => '$2y$12$9FibRJHcYDXZ5Uvp.yL3pe21eEUXIAfsFUjKsNiSb.wYBOJnI1him', 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, 'two_factor_confirmed_at' => null, 'remember_token' => null, 'created_at' => '2025-12-18 10:37:29', 'updated_at' => '2025-12-18 17:34:16', 'deleted_at' => null], + ['id' => 16, 'partner_id' => 15, 'hub_id' => null, 'origin' => null, 'name' => 'Hersteller Moritz', 'display_name' => 'Hersteller Moritz', 'email' => 'kevin.adametz.media@gmail.com', 'email_verified_at' => '2025-12-18 10:46:20', 'password' => '$2y$12$UXIpAVmxUAKerqhdXQt.gebMM/NEbSi713Fq69XsOpmnbbLMHIW/a', 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, 'two_factor_confirmed_at' => null, 'remember_token' => null, 'created_at' => '2025-12-18 10:46:07', 'updated_at' => '2025-12-18 17:34:32', 'deleted_at' => null], + ['id' => 17, 'partner_id' => 16, 'hub_id' => null, 'origin' => null, 'name' => 'Marcel Scheibe', 'display_name' => null, 'email' => 'marcel.scheibe@bridges2america.com', 'email_verified_at' => '2025-12-19 11:20:57', 'password' => '$2y$12$pw9PjzTmlWnC1wJ.ioD69OhcV8kxjQTDyKUk71rnP8kiweViEpeRe', 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, 'two_factor_confirmed_at' => null, 'remember_token' => 'pHLCoFTBHJet4D5aeoBGl20lNqOuXW1z4g5J7uiaSMFQU3jdXmWOAv5nMFH8', 'created_at' => '2025-12-19 11:20:57', 'updated_at' => '2026-01-14 10:35:09', 'deleted_at' => null], + ['id' => 18, 'partner_id' => 17, 'hub_id' => null, 'origin' => null, 'name' => 'Marcel Scheibe', 'display_name' => 'TEST MS', 'email' => 'marcel_scheibe@web.de', 'email_verified_at' => '2026-01-14 10:47:04', 'password' => '$2y$12$b1wZQv9bBVet3RwzIU9uaubHmo7TTC/C/ECro.RBUSAy/CRKy/1AC', 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, 'two_factor_confirmed_at' => null, 'remember_token' => null, 'created_at' => '2026-01-14 10:45:37', 'updated_at' => '2026-01-14 10:47:04', 'deleted_at' => null], + ]); + } + + private function seedModelHasRoles(): void + { + DB::table('model_has_roles')->truncate(); + DB::table('model_has_roles')->insert([ + ['role_id' => 1, 'model_type' => 'App\\Models\\User', 'model_id' => 12], + ['role_id' => 1, 'model_type' => 'App\\Models\\User', 'model_id' => 13], + ['role_id' => 2, 'model_type' => 'App\\Models\\User', 'model_id' => 14], + ['role_id' => 2, 'model_type' => 'App\\Models\\User', 'model_id' => 18], + ['role_id' => 3, 'model_type' => 'App\\Models\\User', 'model_id' => 15], + ['role_id' => 4, 'model_type' => 'App\\Models\\User', 'model_id' => 11], + ['role_id' => 4, 'model_type' => 'App\\Models\\User', 'model_id' => 16], + ['role_id' => 5, 'model_type' => 'App\\Models\\User', 'model_id' => 1], + ['role_id' => 5, 'model_type' => 'App\\Models\\User', 'model_id' => 17], + ]); + } + + private function seedBrands(): void + { + DB::table('brands')->truncate(); + DB::table('brands')->insert([ + ['id' => 1, 'partner_id' => 15, 'name' => 'Moritz Möbel', 'slug' => 'moritz-mobel', 'logo_url' => null, 'description' => '', 'is_active' => 1, 'created_at' => '2025-12-18 10:55:57', 'updated_at' => '2025-12-18 17:53:22'], + ['id' => 2, 'partner_id' => 10, 'name' => 'Möbelfritze', 'slug' => 'mobelfritze', 'logo_url' => null, 'description' => '', 'is_active' => 1, 'created_at' => '2025-12-18 13:10:19', 'updated_at' => '2025-12-18 17:57:05'], + ]); + } + + private function seedPartnerInvitations(): void + { + DB::table('partner_invitations')->truncate(); + DB::table('partner_invitations')->insert([ + ['id' => 1, 'company_name' => 'Möbelhaus', 'contact_first_name' => 'Kevin', 'contact_last_name' => 'Adametz', 'role_id' => 3, 'email' => 'kevin.adametz@me.com', 'token' => 'CDfvxGVOOUhmGur6A0N7hWW7mZkfJUzT3kZGwjfxAmdWqnWJuvm79FilEjeKz19k', 'status' => 'pending', 'expires_at' => '2025-11-28 14:31:19', 'invited_by' => 1, 'partner_id' => null, 'accepted_at' => null, 'created_at' => '2025-11-21 14:31:19', 'updated_at' => '2025-11-21 14:31:19'], + ['id' => 3, 'company_name' => 'Media Matters ', 'contact_first_name' => null, 'contact_last_name' => null, 'role_id' => 3, 'email' => 'register@adametz.media', 'token' => 'blUPdDvXCyYsuMrl6RmB6pr8PTrXnmBGUM3UdVFwjP1Wm88FoXTACXlV6FqeKvgv', 'status' => 'accepted', 'expires_at' => '2025-11-28 14:54:29', 'invited_by' => 1, 'partner_id' => 4, 'accepted_at' => '2025-11-21 15:01:16', 'created_at' => '2025-11-21 14:54:29', 'updated_at' => '2025-11-21 15:01:16'], + ['id' => 5, 'company_name' => 'Max Möbelmann', 'contact_first_name' => null, 'contact_last_name' => null, 'role_id' => 1, 'email' => 'info3@adametz.media', 'token' => 'QhQLbYBa1TOOVEs2p7r4UMZ4PUb14O5KLoLQjhaZQCnX53lwpw9LG9zK3J8ADHGh', 'status' => 'accepted', 'expires_at' => '2025-12-25 09:45:27', 'invited_by' => 1, 'partner_id' => 11, 'accepted_at' => '2025-12-18 09:46:11', 'created_at' => '2025-12-18 09:45:27', 'updated_at' => '2025-12-18 09:46:11'], + ['id' => 7, 'company_name' => 'Kevin Einladung', 'contact_first_name' => 'Kevin', 'contact_last_name' => 'Einladung', 'role_id' => 1, 'email' => 'register@adametz.media', 'token' => 'dDjcXk8pju5uxAOmQrzszssTdh06gO0k81nonZfwRggDFeNsLy1BIN8rMocigUcb', 'status' => 'accepted', 'expires_at' => '2025-12-26 11:14:21', 'invited_by' => 1, 'partner_id' => 16, 'accepted_at' => '2025-12-19 11:20:57', 'created_at' => '2025-12-19 11:14:21', 'updated_at' => '2025-12-19 11:20:57'], + ['id' => 8, 'company_name' => 'Kevin Google Mail ', 'contact_first_name' => 'Kevin', 'contact_last_name' => 'Google', 'role_id' => 1, 'email' => 'kevin.adametz.media@gmail.com', 'token' => 'NIex1PbOgVlIL6GUfhq62QJx79yipDo3VcncBu23bSNukTmU4V3NUWlFsWX4L9L9', 'status' => 'pending', 'expires_at' => '2025-12-26 11:18:38', 'invited_by' => 1, 'partner_id' => null, 'accepted_at' => null, 'created_at' => '2025-12-19 11:18:38', 'updated_at' => '2025-12-19 11:18:38'], + ]); + } + + private function seedRegistrationCodes(): void + { + DB::table('registration_codes')->truncate(); + DB::table('registration_codes')->insert([ + ['id' => 91, 'code' => 'M10000001', 'role' => 'broker', 'name' => 'Max Markler', 'status' => 'used', 'broker_partner_id' => null, 'assigned_to_code_id' => null, 'partner_id' => 5, 'used_by_user_id' => 6, 'used_at' => '2025-12-16 15:05:26', 'expires_at' => '2025-12-23 23:59:59', 'metadata' => null, 'created_at' => '2025-12-16 14:08:08', 'updated_at' => '2025-12-16 15:05:26'], + ['id' => 92, 'code' => 'K40000000', 'role' => 'customer', 'name' => null, 'status' => 'available', 'broker_partner_id' => null, 'assigned_to_code_id' => 91, 'partner_id' => null, 'used_by_user_id' => null, 'used_at' => null, 'expires_at' => '2025-12-23 23:59:59', 'metadata' => null, 'created_at' => '2025-12-16 14:08:58', 'updated_at' => '2025-12-16 14:08:58'], + ['id' => 93, 'code' => 'K40000001', 'role' => 'customer', 'name' => null, 'status' => 'used', 'broker_partner_id' => null, 'assigned_to_code_id' => 91, 'partner_id' => 9, 'used_by_user_id' => 10, 'used_at' => '2025-12-17 12:38:20', 'expires_at' => '2025-12-23 23:59:59', 'metadata' => null, 'created_at' => '2025-12-16 14:08:58', 'updated_at' => '2025-12-17 12:38:20'], + ['id' => 94, 'code' => 'K40000002', 'role' => 'customer', 'name' => null, 'status' => 'available', 'broker_partner_id' => null, 'assigned_to_code_id' => 91, 'partner_id' => null, 'used_by_user_id' => null, 'used_at' => null, 'expires_at' => '2025-12-23 23:59:59', 'metadata' => null, 'created_at' => '2025-12-16 14:08:58', 'updated_at' => '2025-12-16 14:08:58'], + ['id' => 95, 'code' => 'K40000003', 'role' => 'customer', 'name' => null, 'status' => 'used', 'broker_partner_id' => null, 'assigned_to_code_id' => 91, 'partner_id' => 12, 'used_by_user_id' => 13, 'used_at' => '2025-12-18 10:23:23', 'expires_at' => '2025-12-23 23:59:59', 'metadata' => null, 'created_at' => '2025-12-16 14:08:58', 'updated_at' => '2025-12-18 10:23:23'], + ['id' => 96, 'code' => 'K40000004', 'role' => 'customer', 'name' => null, 'status' => 'available', 'broker_partner_id' => null, 'assigned_to_code_id' => 91, 'partner_id' => null, 'used_by_user_id' => null, 'used_at' => null, 'expires_at' => '2025-12-23 23:59:59', 'metadata' => null, 'created_at' => '2025-12-16 14:08:58', 'updated_at' => '2025-12-16 14:08:58'], + ['id' => 97, 'code' => 'K40000005', 'role' => 'customer', 'name' => null, 'status' => 'available', 'broker_partner_id' => null, 'assigned_to_code_id' => 91, 'partner_id' => null, 'used_by_user_id' => null, 'used_at' => null, 'expires_at' => '2025-12-23 23:59:59', 'metadata' => null, 'created_at' => '2025-12-16 14:08:58', 'updated_at' => '2025-12-16 14:08:58'], + ['id' => 98, 'code' => 'K40000006', 'role' => 'customer', 'name' => null, 'status' => 'available', 'broker_partner_id' => null, 'assigned_to_code_id' => 91, 'partner_id' => null, 'used_by_user_id' => null, 'used_at' => null, 'expires_at' => '2025-12-23 23:59:59', 'metadata' => null, 'created_at' => '2025-12-16 14:08:58', 'updated_at' => '2025-12-16 14:08:58'], + ['id' => 99, 'code' => 'K40000007', 'role' => 'customer', 'name' => null, 'status' => 'available', 'broker_partner_id' => null, 'assigned_to_code_id' => 91, 'partner_id' => null, 'used_by_user_id' => null, 'used_at' => null, 'expires_at' => '2025-12-23 23:59:59', 'metadata' => null, 'created_at' => '2025-12-16 14:08:58', 'updated_at' => '2025-12-16 14:08:58'], + ['id' => 100, 'code' => 'K40000008', 'role' => 'customer', 'name' => null, 'status' => 'available', 'broker_partner_id' => null, 'assigned_to_code_id' => 91, 'partner_id' => null, 'used_by_user_id' => null, 'used_at' => null, 'expires_at' => '2025-12-23 23:59:59', 'metadata' => null, 'created_at' => '2025-12-16 14:08:58', 'updated_at' => '2025-12-16 14:08:58'], + ['id' => 101, 'code' => 'K40000009', 'role' => 'customer', 'name' => null, 'status' => 'available', 'broker_partner_id' => null, 'assigned_to_code_id' => 91, 'partner_id' => null, 'used_by_user_id' => null, 'used_at' => null, 'expires_at' => '2025-12-23 23:59:59', 'metadata' => null, 'created_at' => '2025-12-16 14:08:58', 'updated_at' => '2025-12-16 14:08:58'], + ['id' => 102, 'code' => 'K40000010', 'role' => 'customer', 'name' => null, 'status' => 'available', 'broker_partner_id' => null, 'assigned_to_code_id' => 91, 'partner_id' => null, 'used_by_user_id' => null, 'used_at' => null, 'expires_at' => '2025-12-23 23:59:59', 'metadata' => null, 'created_at' => '2025-12-16 14:08:58', 'updated_at' => '2025-12-16 14:08:58'], + ['id' => 103, 'code' => 'M10000000', 'role' => 'broker', 'name' => 'test', 'status' => 'available', 'broker_partner_id' => null, 'assigned_to_code_id' => null, 'partner_id' => null, 'used_by_user_id' => null, 'used_at' => null, 'expires_at' => '2025-12-23 23:59:59', 'metadata' => null, 'created_at' => '2025-12-16 14:56:42', 'updated_at' => '2025-12-16 14:56:42'], + ['id' => 104, 'code' => 'M10000002', 'role' => 'broker', 'name' => 'test', 'status' => 'used', 'broker_partner_id' => null, 'assigned_to_code_id' => null, 'partner_id' => 6, 'used_by_user_id' => 7, 'used_at' => '2025-12-16 16:29:03', 'expires_at' => '2025-12-23 23:59:59', 'metadata' => null, 'created_at' => '2025-12-16 16:25:28', 'updated_at' => '2025-12-16 16:29:03'], + ['id' => 105, 'code' => 'P30000001', 'role' => 'manufacturer', 'name' => 'MHerr Steller', 'status' => 'used', 'broker_partner_id' => null, 'assigned_to_code_id' => null, 'partner_id' => 10, 'used_by_user_id' => 11, 'used_at' => '2025-12-18 08:11:34', 'expires_at' => '2025-12-25 23:59:59', 'metadata' => null, 'created_at' => '2025-12-18 08:09:04', 'updated_at' => '2025-12-18 08:11:34'], + ['id' => 106, 'code' => 'M10000003', 'role' => 'broker', 'name' => 'mac ma', 'status' => 'available', 'broker_partner_id' => null, 'assigned_to_code_id' => null, 'partner_id' => null, 'used_by_user_id' => null, 'used_at' => null, 'expires_at' => '2025-12-25 23:59:59', 'metadata' => null, 'created_at' => '2025-12-18 10:29:41', 'updated_at' => '2025-12-18 10:29:41'], + ['id' => 107, 'code' => 'P30000002', 'role' => 'manufacturer', 'name' => 'asdasd', 'status' => 'used', 'broker_partner_id' => null, 'assigned_to_code_id' => null, 'partner_id' => 15, 'used_by_user_id' => 16, 'used_at' => '2025-12-18 10:46:07', 'expires_at' => '2025-12-25 23:59:59', 'metadata' => null, 'created_at' => '2025-12-18 10:29:45', 'updated_at' => '2025-12-18 10:46:07'], + ['id' => 108, 'code' => 'M10000004', 'role' => 'broker', 'name' => 'asd', 'status' => 'used', 'broker_partner_id' => null, 'assigned_to_code_id' => null, 'partner_id' => 13, 'used_by_user_id' => 14, 'used_at' => '2025-12-18 10:30:41', 'expires_at' => '2025-12-25 23:59:59', 'metadata' => null, 'created_at' => '2025-12-18 10:29:48', 'updated_at' => '2025-12-18 10:30:41'], + ['id' => 109, 'code' => 'H20000001', 'role' => 'retailer', 'name' => 'asds', 'status' => 'used', 'broker_partner_id' => null, 'assigned_to_code_id' => null, 'partner_id' => 14, 'used_by_user_id' => 15, 'used_at' => '2025-12-18 10:37:29', 'expires_at' => '2025-12-25 23:59:59', 'metadata' => null, 'created_at' => '2025-12-18 10:36:54', 'updated_at' => '2025-12-18 10:37:29'], + ['id' => 110, 'code' => 'M10000005', 'role' => 'broker', 'name' => 'TEST MS', 'status' => 'used', 'broker_partner_id' => null, 'assigned_to_code_id' => null, 'partner_id' => 17, 'used_by_user_id' => 18, 'used_at' => '2026-01-14 10:45:37', 'expires_at' => '2026-01-21 23:59:59', 'metadata' => null, 'created_at' => '2026-01-14 10:44:15', 'updated_at' => '2026-01-14 10:45:37'], + ]); + } + + private function seedDisplayVideos(): void + { + DB::table('display_videos')->truncate(); + DB::table('display_videos')->insert([ + ['id' => 1, 'filename' => 'herbst_2025.mp4', 'title' => 'Herbst 2025', 'position' => 25, 'sort_order' => 2, 'is_active' => 1, 'created_at' => '2025-12-18 13:19:22', 'updated_at' => '2026-01-06 12:23:48'], + ['id' => 2, 'filename' => 'fruehjahr_2025.mp4', 'title' => 'Frühjahr 2025', 'position' => 10, 'sort_order' => 1, 'is_active' => 1, 'created_at' => '2025-12-18 13:19:22', 'updated_at' => '2026-01-06 12:23:50'], + ['id' => 3, 'filename' => 'fruehjahr_2024.mp4', 'title' => 'Frühjahr 2024', 'position' => 40, 'sort_order' => 0, 'is_active' => 1, 'created_at' => '2025-12-18 13:19:22', 'updated_at' => '2026-01-31 15:28:23'], + ['id' => 4, 'filename' => 'herbst_2024.mp4', 'title' => 'Herbst 2024', 'position' => 25, 'sort_order' => 3, 'is_active' => 1, 'created_at' => '2025-12-18 13:19:22', 'updated_at' => '2025-12-18 13:19:22'], + ]); + } + + private function seedDisplayFooterContents(): void + { + DB::table('display_footer_contents')->truncate(); + DB::table('display_footer_contents')->insert([ + ['id' => 1, 'headline' => 'Beratung & Termin', 'subline' => 'Jetzt Termin vereinbaren.', 'url' => 'https://www.cabinet.de/bielefeld?utm_source=store_display&utm_medium=qr_code&utm_campaign=bielefeld_pos&utm_content=termin_buchung#c39393', 'short_code' => 'c59kjb', 'clicks' => 6, 'sort_order' => 1, 'is_active' => 1, 'created_at' => '2025-12-18 13:19:22', 'updated_at' => '2025-12-18 14:10:35'], + ['id' => 2, 'headline' => 'Beratung vor Ort', 'subline' => 'Einfach reinkommen.', 'url' => 'https://www.cabinet.de/bielefeld?utm_source=store_display&utm_medium=qr_code&utm_campaign=bielefeld_pos&utm_content=termin_buchung#c39393', 'short_code' => '3bi07j', 'clicks' => 2, 'sort_order' => 2, 'is_active' => 1, 'created_at' => '2025-12-18 13:19:22', 'updated_at' => '2025-12-18 14:10:33'], + ['id' => 3, 'headline' => 'Pinterest', 'subline' => 'Inspirationen entdecken.', 'url' => 'https://de.pinterest.com/cabinet_AG/', 'short_code' => '1cl8so', 'clicks' => 0, 'sort_order' => 3, 'is_active' => 1, 'created_at' => '2025-12-18 13:19:22', 'updated_at' => '2025-12-18 14:10:32'], + ['id' => 4, 'headline' => 'Instagram', 'subline' => 'Tägliche Einblicke & Design.', 'url' => 'https://www.instagram.com/cabinet_schranksysteme/', 'short_code' => 'hz1tx2', 'clicks' => 0, 'sort_order' => 4, 'is_active' => 1, 'created_at' => '2025-12-18 13:19:22', 'updated_at' => '2025-12-18 14:10:30'], + ['id' => 5, 'headline' => 'Facebook', 'subline' => 'News, Aktionen & Community.', 'url' => 'https://de-de.facebook.com/cabinetschranksysteme/', 'short_code' => 'almb7t', 'clicks' => 0, 'sort_order' => 5, 'is_active' => 1, 'created_at' => '2025-12-18 13:19:22', 'updated_at' => '2025-12-18 14:10:29'], + ]); + } +} diff --git a/database/seeders/RoleSeeder.php b/database/seeders/RoleSeeder.php index fa6a729..96f7256 100644 --- a/database/seeders/RoleSeeder.php +++ b/database/seeders/RoleSeeder.php @@ -3,8 +3,8 @@ namespace Database\Seeders; use Illuminate\Database\Seeder; -use Spatie\Permission\Models\Role; use Spatie\Permission\Models\Permission; +use Spatie\Permission\Models\Role; class RoleSeeder extends Seeder { @@ -36,6 +36,7 @@ class RoleSeeder extends Seeder 'create products', 'edit products', 'delete products', + 'curate products', // Produkte freigeben/ablehnen (Admin) 'manage rental options', // Miet-Parameter verwalten // Order Management (für später) @@ -49,7 +50,7 @@ class RoleSeeder extends Seeder // Frontend/Customer facing 'access dashboard', // Genereller Backend-Zugriff - 'place orders' // Für Kunden + 'place orders', // Für Kunden ]; // Erstelle Permissions @@ -73,7 +74,7 @@ class RoleSeeder extends Seeder $customerRole->givePermissionTo([ 'view products', 'place orders', - 'view orders' // Eigene Bestellungen sehen + 'view orders', // Eigene Bestellungen sehen ]); // 2. Estate-Agent (Makler) @@ -90,7 +91,7 @@ class RoleSeeder extends Seeder $estateAgentRole->givePermissionTo([ 'access dashboard', 'view partners', // Damit sie sehen können, wen sie empfehlen - 'view hubs' + 'view hubs', // Makler bekommen KEINE Produkt- oder Order-Rechte ]); @@ -113,7 +114,7 @@ class RoleSeeder extends Seeder 'delete products', // Später eingeschränkt auf EIGENE Produkte 'manage rental options', 'view orders', // Eigene Bestellungen - 'manage orders' // Eigene Bestellungen + 'manage orders', // Eigene Bestellungen ]); // 4. Manufacturer (Hersteller) @@ -135,7 +136,7 @@ class RoleSeeder extends Seeder 'delete products', // Später eingeschränkt auf EIGENE Produkte 'manage rental options', 'view orders', // Eigene Bestellungen - 'manage orders' // Eigene Bestellungen + 'manage orders', // Eigene Bestellungen ]); // 5. Admin (B2In Management / Marcel) @@ -144,7 +145,7 @@ class RoleSeeder extends Seeder 'display_name' => 'Admin (Administrator)', 'icon' => 'user-circle', 'color' => 'purple', - 'can_be_invited' => false // Admins werden NICHT eingeladen + 'can_be_invited' => false, // Admins werden NICHT eingeladen ]); $adminRole->givePermissionTo([ 'access dashboard', @@ -161,12 +162,13 @@ class RoleSeeder extends Seeder 'create products', 'edit products', 'delete products', + 'curate products', 'manage rental options', 'view orders', 'manage orders', 'view users', 'manage users', - 'manage roles' + 'manage roles', ]); // 6. Super-Admin (Entwickler) @@ -177,7 +179,7 @@ class RoleSeeder extends Seeder 'display_name' => 'Super-Admin (Entwickler)', 'icon' => 'shield-check', 'color' => 'red', - 'can_be_invited' => false // Super-Admins werden NICHT eingeladen + 'can_be_invited' => false, // Super-Admins werden NICHT eingeladen ]); } } diff --git a/database/seeders/SettingsSeeder.php b/database/seeders/SettingsSeeder.php new file mode 100644 index 0000000..5ea8191 --- /dev/null +++ b/database/seeders/SettingsSeeder.php @@ -0,0 +1,67 @@ + 'tickets', + 'key' => 'validity_days', + 'value' => '30', + 'type' => 'integer', + 'description' => 'Gültigkeitsdauer eines Tickets in Tagen', + ], + [ + 'group' => 'tickets', + 'key' => 'receipt_upload_deadline_days', + 'value' => '30', + 'type' => 'integer', + 'description' => 'Frist für den Beleg-Upload nach Ticket-Einlösung in Tagen', + ], + [ + 'group' => 'tickets', + 'key' => 'max_per_merchant_per_customer', + 'value' => '3', + 'type' => 'integer', + 'description' => 'Max. Tickets pro Händler pro Kunde', + ], + [ + 'group' => 'tickets', + 'key' => 'max_merchants_per_customer', + 'value' => '4', + 'type' => 'integer', + 'description' => 'Max. Händler pro Kunde pro Zeitraum', + ], + + // Provisions-Einstellungen + [ + 'group' => 'commissions', + 'key' => 'default_broker_rate', + 'value' => '0', + 'type' => 'integer', + 'description' => 'Standard-Makler-Provision in Prozent (individuell je Partner)', + ], + [ + 'group' => 'commissions', + 'key' => 'default_cashback_rate', + 'value' => '0', + 'type' => 'integer', + 'description' => 'Standard-Kunden-Cashback in Prozent (individuell je Partner)', + ], + ]; + + foreach ($settings as $setting) { + Setting::query()->updateOrCreate( + ['group' => $setting['group'], 'key' => $setting['key']], + $setting + ); + } + } +} diff --git a/dev/12-01-2026/Moebeldatenliste Stand 4.11.2025.csv b/dev/12-01-2026/Moebeldatenliste Stand 4.11.2025.csv new file mode 100644 index 0000000..0e95a16 --- /dev/null +++ b/dev/12-01-2026/Moebeldatenliste Stand 4.11.2025.csv @@ -0,0 +1,70 @@ +Sektion;Feld;Beschreibung;Beispiel;Pflichtgrad +1. Identität & Katalog;B2in-Artikelnummer (intern);Fortlaufende Nummer (vom System vergeben);B2IN-000471;🟥 Pflichtfeld +;Lieferanten-Artikelnummer (SKU);Originalnummer des Herstellers;SOFA-ALBA-3S-ANTHR;🟥 Pflichtfeld +;Produktname;Anzeigename auf Website;Sofa ALBA 3-Sitzer;🟥 Pflichtfeld +;Marke / Hersteller;Produzent oder Label;Möbelwerk Nord;🟨 Empfohlen +;Kategorie;z. B. „Wohnzimmer > Sofa > 3-Sitzer“;Wohnzimmer > Sofas;🟥 Pflichtfeld +;Kurzbeschreibung;Max. 180 Zeichen für Snippets;Modernes Sofa mit Holzrahmen und Stoffbezug.;🟨 Empfohlen +;Langbeschreibung;Detaillierter Text für Produktseite;Das Sofa ALBA verbindet zeitloses Design...;🟩 Optional +;Status;Aktiv / Entwurf;Aktiv;🟥 Pflichtfeld +;Erstelldatum / Änderungsdatum;ISO-Datum;2025-11-04;🟩 Optional +2. Varianten & Attribute;Variantenattribute (Steuernd);Merkmale, die SKUs definieren;Farbe, Bezug, Gestellfarbe;🟨 Empfohlen +;Varianten (Kombinationen);Konkrete Ausprägungen;Anthrazit / Stoff A / Eiche hell;🟨 Empfohlen +;Weitere Attribute;Zusatzinfos (z. B. Sitzhärte, Stil);Sitzhärte: mittel;🟩 Optional +3. Maße & Gewicht (Produkt);Breite (mm);Gesamtbreite;2200;🟥 Pflichtfeld +;Tiefe (mm);Gesamttiefe;950;🟥 Pflichtfeld +;Höhe (mm);Gesamthöhe;830;🟥 Pflichtfeld +;Gewicht netto (kg);Möbel ohne Verpackung;68;🟨 Empfohlen +;Aufbauart;montiert / teilmontiert / zerlegt;zerlegt;🟨 Empfohlen +;Montagezeit (min);Aufbauzeit;45;🟩 Optional +;Traglast (kg);Belastbarkeit;120;🟩 Optional +4. Verpackung & Logistik;Anzahl Packstücke;;2;🟨 Empfohlen +;Gesamtgewicht brutto (kg);inkl. Verpackung;75;🟨 Empfohlen +;Verpackungsart;Karton, Holzrahmen usw.;Karton mit Kantenschutz;🟨 Empfohlen +;Verpackung recyclingfähig (%);Anteil recycelbarer Materialien der Verpackung;85;🟨 Empfohlen +;Kolli 1 Maße (mm);L × B × H;1500 × 950 × 600;🟨 Empfohlen +;Kolli 1 Gewicht (kg);;45;🟩 Optional +;Palettierfähig;Ja / Nein;Ja;🟨 Empfohlen +;HS-Code (Zolltarifnummer);;94016100;🟨 Empfohlen +5. Materialien & Qualität;Hauptmaterial;Tragende Struktur;Massivholz Buche;🟥 Pflichtfeld +;Oberflächenmaterial;Sichtflächen;Furnier Eiche geölt;🟨 Empfohlen +;Bezugsmaterial;Stoff / Leder / Synthetik;Stoff (Polyester);🟨 Empfohlen +;Farbton / Dekor;;Eiche natur / Anthrazit;🟨 Empfohlen +;Herkunftsland (Produktion);ISO-Land;PL;🟥 Pflichtfeld +;Pflegehinweise;Reinigung & Pflege;Mit feuchtem Tuch abwischen.;🟩 Optional +;Zertifikate / Labels;FSC, OEKO-TEX, Blauer Engel etc.;FSC;🟩 Optional +6. Holzherkunft & EUDR;Holzart(en);Botanische Bezeichnung (falls Holz enthalten);Quercus robur (Eiche);🟥 Pflichtfeld +;Herkunftsland des Holzes;ISO-Code (falls Holz enthalten);PL;🟥 Pflichtfeld +;Region / Provinz;falls erforderlich für EUDR;Masowien;🟨 Empfohlen +;Erntejahr;Jahr der Holzgewinnung;2024;🟨 Empfohlen +;Forstbetrieb / Lieferant;;ForestPol Sp. z o.o.;🟨 Empfohlen +;Nachhaltigkeitszertifikat;FSC / PEFC;FSC C123456;🟨 Empfohlen +;Sorgfaltserklärung (EUDR-ID);offizielle Referenz;EUDR-DD-2025-PL-03421;🟨 Empfohlen +;Nachweisdatei (Upload);PDF / Link zum Statement;/uploads/EUDR_Statement_ALBA.pdf;🟩 Optional +7. Preise & Konditionen;Einkaufspreis (net);;680,00€;🟨 Empfohlen +;Verkaufspreis (net);Für B2in-Plattform;1.250,00€;🟥 Pflichtfeld +;Währung;;EUR;🟥 Pflichtfeld +;Steuersatz (%);;19;🟥 Pflichtfeld +;UVP (Brutto);Unverbindliche Preisempfehlung;1.499,00€;🟩 Optional +8. Verfügbarkeit & Lieferzeit;Lagerstatus;Auf Lager / Auf Bestellung / Nicht verfügbar;Auf Bestellung;🟥 Pflichtfeld +;Lieferzeit (Wochen);Min–Max-Spanne;4–6;🟥 Pflichtfeld +;Produktionszeit (Tage);falls relevant;21;🟩 Optional +9. Lieferung, Montage & Services;Lieferart;Abholung / Lieferung / Spedition / Paket;Spedition;🟨 Empfohlen +;Montageservice;Ja / Nein;Ja;🟩 Optional +;Service-Radius (km);Für Montageservice;50;🟩 Optional +;Garantie (Monate);;24;🟩 Optional +10. Händler- / Herstellerzuordnung;VerkäufertypHändler;/ Hersteller / Makler;Händler;🟥 Pflichtfeld +;Verkäufername;;WohnDesign Bielefeld;🟥 Pflichtfeld +;Verkäufer-ID;;SELLER-1123;🟨 Empfohlen +;Region / Hub;Logistische Zuordnung;OWL;🟥 Pflichtfeld +;Ort / PLZ;Standort des Verkäufers/Lagers;33602 Bielefeld;🟨 Empfohlen +11. Nachhaltigkeit & Umwelt;CO₂-Fußabdruck (kg CO₂e);pro Stück;35;🟩 Optional +;Recyclinganteil (%);Anteil recycelter Materialien im Produkt;40;🟨 Empfohlen +;Regionale Produktion;Ja / Nein (Umkreis z. B. < 500 km);Ja;🟩 Optional +12. Scoring-System (B2in Intern);Stauraumvolumen (L);Innenvolumen;280;🟩 Optional +;Aufbauaufwand (1–5);gering = 1;3;🟩 Optional +;Designpunkte (1–5);interne Bewertung;5;🟩 Optional +;Gesamt-Score;automatisch berechnet;4,2;🟩 Optional +13. Verwaltung & Lebenszyklus;Sichtbar ab / bis (Datum);Steuerung der Veröffentlichung;2025-01-01 / 2026-01-01;🟩 Optional +;Freigabe durch B2in erforderlich;Ja / Nein;Ja;🟨 Empfohlen +;Letzte Änderung;Datum der letzten Aktualisierung;2025-11-04;🟩 Optional \ No newline at end of file diff --git a/dev/12-01-2026/db-backup.sql b/dev/12-01-2026/db-backup.sql new file mode 100644 index 0000000..c52fa6b --- /dev/null +++ b/dev/12-01-2026/db-backup.sql @@ -0,0 +1,941 @@ +-- ------------------------------------------------------------- +-- TablePlus 6.8.0(654) +-- +-- https://tableplus.com/ +-- +-- Database: b2in +-- Generation Time: 2026-02-12 13:29:06.4400 +-- ------------------------------------------------------------- + + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!40101 SET NAMES utf8mb4 */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + + +DROP TABLE IF EXISTS `attribute_values`; +CREATE TABLE `attribute_values` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `attribute_id` bigint unsigned NOT NULL, + `value` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `slug` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `attribute_values_slug_unique` (`slug`), + KEY `attribute_values_attribute_id_foreign` (`attribute_id`), + CONSTRAINT `attribute_values_attribute_id_foreign` FOREIGN KEY (`attribute_id`) REFERENCES `attributes` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `attributes`; +CREATE TABLE `attributes` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `slug` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `attributes_slug_unique` (`slug`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `brands`; +CREATE TABLE `brands` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `partner_id` bigint unsigned DEFAULT NULL, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `slug` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `logo_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `is_active` tinyint(1) NOT NULL DEFAULT '0', + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `brands_slug_unique` (`slug`), + KEY `brands_partner_id_foreign` (`partner_id`), + CONSTRAINT `brands_partner_id_foreign` FOREIGN KEY (`partner_id`) REFERENCES `partners` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `cache`; +CREATE TABLE `cache` ( + `key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `value` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `expiration` int NOT NULL, + PRIMARY KEY (`key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `cache_locks`; +CREATE TABLE `cache_locks` ( + `key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `owner` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `expiration` int NOT NULL, + PRIMARY KEY (`key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `categories`; +CREATE TABLE `categories` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `parent_id` bigint unsigned DEFAULT NULL, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `slug` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `categories_slug_unique` (`slug`), + KEY `categories_parent_id_foreign` (`parent_id`), + CONSTRAINT `categories_parent_id_foreign` FOREIGN KEY (`parent_id`) REFERENCES `categories` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `category_product`; +CREATE TABLE `category_product` ( + `category_id` bigint unsigned NOT NULL, + `product_id` bigint unsigned NOT NULL, + PRIMARY KEY (`category_id`,`product_id`), + KEY `category_product_product_id_foreign` (`product_id`), + CONSTRAINT `category_product_category_id_foreign` FOREIGN KEY (`category_id`) REFERENCES `categories` (`id`) ON DELETE CASCADE, + CONSTRAINT `category_product_product_id_foreign` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `collections`; +CREATE TABLE `collections` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `slug` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `collections_slug_unique` (`slug`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `display_footer_contents`; +CREATE TABLE `display_footer_contents` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `headline` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `subline` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `short_code` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `clicks` int unsigned NOT NULL DEFAULT '0', + `sort_order` int NOT NULL DEFAULT '0', + `is_active` tinyint(1) NOT NULL DEFAULT '1', + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `display_footer_contents_short_code_unique` (`short_code`) +) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `display_videos`; +CREATE TABLE `display_videos` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `filename` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `position` int NOT NULL DEFAULT '25', + `sort_order` int NOT NULL DEFAULT '0', + `is_active` tinyint(1) NOT NULL DEFAULT '1', + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `failed_jobs`; +CREATE TABLE `failed_jobs` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `uuid` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `connection` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `queue` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `payload` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `exception` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `failed_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `failed_jobs_uuid_unique` (`uuid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `hub_locations`; +CREATE TABLE `hub_locations` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `hub_id` bigint unsigned NOT NULL, + `city_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `zip_code` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `hub_locations_hub_id_zip_code_unique` (`hub_id`,`zip_code`), + KEY `hub_locations_zip_code_index` (`zip_code`), + CONSTRAINT `hub_locations_hub_id_foreign` FOREIGN KEY (`hub_id`) REFERENCES `hubs` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `hubs`; +CREATE TABLE `hubs` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `slug` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `keyvisual_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `emblem_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `is_active` tinyint(1) NOT NULL DEFAULT '0', + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `hubs_slug_unique` (`slug`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `job_batches`; +CREATE TABLE `job_batches` ( + `id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `total_jobs` int NOT NULL, + `pending_jobs` int NOT NULL, + `failed_jobs` int NOT NULL, + `failed_job_ids` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `options` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `cancelled_at` int DEFAULT NULL, + `created_at` int NOT NULL, + `finished_at` int DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `jobs`; +CREATE TABLE `jobs` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `queue` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `payload` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `attempts` tinyint unsigned NOT NULL, + `reserved_at` int unsigned DEFAULT NULL, + `available_at` int unsigned NOT NULL, + `created_at` int unsigned NOT NULL, + PRIMARY KEY (`id`), + KEY `jobs_queue_index` (`queue`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `media`; +CREATE TABLE `media` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `model_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `model_id` bigint unsigned NOT NULL, + `file_path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'image', + `alt_text` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `order_column` int NOT NULL DEFAULT '0', + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `media_model_type_model_id_index` (`model_type`,`model_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `migrations`; +CREATE TABLE `migrations` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `migration` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `batch` int NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=121 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `model_has_permissions`; +CREATE TABLE `model_has_permissions` ( + `permission_id` bigint unsigned NOT NULL, + `model_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `model_id` bigint unsigned NOT NULL, + PRIMARY KEY (`permission_id`,`model_id`,`model_type`), + KEY `model_has_permissions_model_id_model_type_index` (`model_id`,`model_type`), + CONSTRAINT `model_has_permissions_permission_id_foreign` FOREIGN KEY (`permission_id`) REFERENCES `permissions` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `model_has_roles`; +CREATE TABLE `model_has_roles` ( + `role_id` bigint unsigned NOT NULL, + `model_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `model_id` bigint unsigned NOT NULL, + PRIMARY KEY (`role_id`,`model_id`,`model_type`), + KEY `model_has_roles_model_id_model_type_index` (`model_id`,`model_type`), + CONSTRAINT `model_has_roles_role_id_foreign` FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `partner_invitations`; +CREATE TABLE `partner_invitations` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `company_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `contact_first_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `contact_last_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `role_id` bigint unsigned NOT NULL, + `email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `token` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `status` enum('pending','accepted','expired','cancelled') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'pending', + `expires_at` timestamp NOT NULL, + `invited_by` bigint unsigned NOT NULL, + `partner_id` bigint unsigned DEFAULT NULL, + `accepted_at` timestamp NULL DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `partner_invitations_token_unique` (`token`), + KEY `partner_invitations_invited_by_foreign` (`invited_by`), + KEY `partner_invitations_partner_id_foreign` (`partner_id`), + KEY `partner_invitations_token_status_index` (`token`,`status`), + KEY `partner_invitations_email_status_index` (`email`,`status`), + KEY `partner_invitations_role_id_foreign` (`role_id`), + CONSTRAINT `partner_invitations_invited_by_foreign` FOREIGN KEY (`invited_by`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `partner_invitations_partner_id_foreign` FOREIGN KEY (`partner_id`) REFERENCES `partners` (`id`) ON DELETE SET NULL, + CONSTRAINT `partner_invitations_role_id_foreign` FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `partners`; +CREATE TABLE `partners` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `company_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `display_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `slug` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `brand` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `salutation` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `first_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `last_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `hub_id` bigint unsigned DEFAULT NULL, + `parent_partner_id` bigint unsigned DEFAULT NULL, + `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `street` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `house_number` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `zip` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `city` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `country` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'Deutschland', + `phone` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `website` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `logo_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `is_active` tinyint(1) NOT NULL DEFAULT '0', + `setup_completed` tinyint(1) NOT NULL DEFAULT '0', + `setup_completed_at` timestamp NULL DEFAULT NULL, + `delivery_radius_km` int DEFAULT NULL, + `assembly_radius_km` int DEFAULT NULL, + `provision_fixed_amount` int DEFAULT NULL, + `provision_rate_percentage` decimal(5,2) DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `partners_slug_unique` (`slug`), + KEY `partners_hub_id_foreign` (`hub_id`), + KEY `partners_broker_partner_id_index` (`parent_partner_id`), + KEY `partners_brand_index` (`brand`), + CONSTRAINT `partners_broker_partner_id_foreign` FOREIGN KEY (`parent_partner_id`) REFERENCES `partners` (`id`) ON DELETE SET NULL, + CONSTRAINT `partners_hub_id_foreign` FOREIGN KEY (`hub_id`) REFERENCES `hubs` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB AUTO_INCREMENT=18 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `password_reset_tokens`; +CREATE TABLE `password_reset_tokens` ( + `email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `token` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `permissions`; +CREATE TABLE `permissions` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `guard_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `permissions_name_guard_name_unique` (`name`,`guard_name`) +) ENGINE=InnoDB AUTO_INCREMENT=22 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `personal_access_tokens`; +CREATE TABLE `personal_access_tokens` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `tokenable_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `tokenable_id` bigint unsigned NOT NULL, + `name` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `token` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `abilities` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `last_used_at` timestamp NULL DEFAULT NULL, + `expires_at` timestamp NULL DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `personal_access_tokens_token_unique` (`token`), + KEY `personal_access_tokens_tokenable_type_tokenable_id_index` (`tokenable_type`,`tokenable_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `product_logistics`; +CREATE TABLE `product_logistics` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `product_variant_id` bigint unsigned NOT NULL, + `shipping_class_id` bigint unsigned DEFAULT NULL, + `package_width_cm` int DEFAULT NULL, + `package_height_cm` int DEFAULT NULL, + `package_depth_cm` int DEFAULT NULL, + `package_weight_g` int DEFAULT NULL, + `package_count` int NOT NULL DEFAULT '1', + `location_bin` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `product_logistics_product_variant_id_foreign` (`product_variant_id`), + KEY `product_logistics_shipping_class_id_foreign` (`shipping_class_id`), + CONSTRAINT `product_logistics_product_variant_id_foreign` FOREIGN KEY (`product_variant_id`) REFERENCES `product_variants` (`id`) ON DELETE CASCADE, + CONSTRAINT `product_logistics_shipping_class_id_foreign` FOREIGN KEY (`shipping_class_id`) REFERENCES `shipping_classes` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `product_tag`; +CREATE TABLE `product_tag` ( + `product_id` bigint unsigned NOT NULL, + `tag_id` bigint unsigned NOT NULL, + PRIMARY KEY (`product_id`,`tag_id`), + KEY `product_tag_tag_id_foreign` (`tag_id`), + CONSTRAINT `product_tag_product_id_foreign` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) ON DELETE CASCADE, + CONSTRAINT `product_tag_tag_id_foreign` FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `product_variant_attributes`; +CREATE TABLE `product_variant_attributes` ( + `product_variant_id` bigint unsigned NOT NULL, + `attribute_value_id` bigint unsigned NOT NULL, + PRIMARY KEY (`product_variant_id`,`attribute_value_id`), + KEY `product_variant_attributes_attribute_value_id_foreign` (`attribute_value_id`), + CONSTRAINT `product_variant_attributes_attribute_value_id_foreign` FOREIGN KEY (`attribute_value_id`) REFERENCES `attribute_values` (`id`) ON DELETE CASCADE, + CONSTRAINT `product_variant_attributes_product_variant_id_foreign` FOREIGN KEY (`product_variant_id`) REFERENCES `product_variants` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `product_variants`; +CREATE TABLE `product_variants` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `product_id` bigint unsigned NOT NULL, + `name_suffix` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `is_master_variant` tinyint(1) NOT NULL DEFAULT '0', + `sku` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `han_mpn` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `ean_gtin` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `selling_price` int NOT NULL, + `msrp` int DEFAULT NULL, + `purchase_price` int DEFAULT NULL, + `tax_rate_id` bigint unsigned NOT NULL, + `stock_quantity` int NOT NULL DEFAULT '0', + `stock_min_threshold` int DEFAULT NULL, + `availability_status` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `delivery_time_text` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `is_rentable` tinyint(1) NOT NULL DEFAULT '0', + `rental_duration_options` json DEFAULT NULL, + `rental_rate_formula` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `residual_value_percentage` decimal(5,2) DEFAULT NULL, + `variant_weight_g` int DEFAULT NULL, + `is_active` tinyint(1) NOT NULL DEFAULT '1', + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `product_variants_sku_unique` (`sku`), + KEY `product_variants_product_id_foreign` (`product_id`), + KEY `product_variants_tax_rate_id_foreign` (`tax_rate_id`), + KEY `product_variants_han_mpn_index` (`han_mpn`), + KEY `product_variants_ean_gtin_index` (`ean_gtin`), + CONSTRAINT `product_variants_product_id_foreign` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) ON DELETE CASCADE, + CONSTRAINT `product_variants_tax_rate_id_foreign` FOREIGN KEY (`tax_rate_id`) REFERENCES `tax_rates` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `products`; +CREATE TABLE `products` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `partner_id` bigint unsigned NOT NULL, + `brand_id` bigint unsigned DEFAULT NULL, + `collection_id` bigint unsigned DEFAULT NULL, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `slug` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `status` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'draft', + `description_short` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `description_long` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `care_instructions` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `width_cm` int DEFAULT NULL, + `height_cm` int DEFAULT NULL, + `depth_cm` int DEFAULT NULL, + `dimensions_specific` json DEFAULT NULL, + `assembly_status` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `meta_title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `meta_description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `products_slug_unique` (`slug`), + KEY `products_partner_id_foreign` (`partner_id`), + KEY `products_brand_id_foreign` (`brand_id`), + KEY `products_collection_id_foreign` (`collection_id`), + CONSTRAINT `products_brand_id_foreign` FOREIGN KEY (`brand_id`) REFERENCES `brands` (`id`) ON DELETE SET NULL, + CONSTRAINT `products_collection_id_foreign` FOREIGN KEY (`collection_id`) REFERENCES `collections` (`id`) ON DELETE SET NULL, + CONSTRAINT `products_partner_id_foreign` FOREIGN KEY (`partner_id`) REFERENCES `partners` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `registration_codes`; +CREATE TABLE `registration_codes` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `code` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `role` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `status` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'available', + `broker_partner_id` bigint unsigned DEFAULT NULL, + `assigned_to_code_id` bigint unsigned DEFAULT NULL, + `partner_id` bigint unsigned DEFAULT NULL, + `used_by_user_id` bigint unsigned DEFAULT NULL, + `used_at` timestamp NULL DEFAULT NULL, + `expires_at` timestamp NULL DEFAULT NULL, + `metadata` json DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `registration_codes_code_unique` (`code`), + KEY `registration_codes_used_by_user_id_foreign` (`used_by_user_id`), + KEY `registration_codes_role_status_index` (`role`,`status`), + KEY `registration_codes_broker_partner_id_index` (`broker_partner_id`), + KEY `registration_codes_partner_id_index` (`partner_id`), + KEY `registration_codes_name_index` (`name`), + KEY `registration_codes_assigned_to_code_id_index` (`assigned_to_code_id`), + CONSTRAINT `registration_codes_assigned_to_code_id_foreign` FOREIGN KEY (`assigned_to_code_id`) REFERENCES `registration_codes` (`id`) ON DELETE SET NULL, + CONSTRAINT `registration_codes_broker_partner_id_foreign` FOREIGN KEY (`broker_partner_id`) REFERENCES `partners` (`id`) ON DELETE SET NULL, + CONSTRAINT `registration_codes_partner_id_foreign` FOREIGN KEY (`partner_id`) REFERENCES `partners` (`id`) ON DELETE SET NULL, + CONSTRAINT `registration_codes_used_by_user_id_foreign` FOREIGN KEY (`used_by_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB AUTO_INCREMENT=111 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `related_products`; +CREATE TABLE `related_products` ( + `product_id` bigint unsigned NOT NULL, + `related_product_id` bigint unsigned NOT NULL, + PRIMARY KEY (`product_id`,`related_product_id`), + KEY `related_products_related_product_id_foreign` (`related_product_id`), + CONSTRAINT `related_products_product_id_foreign` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) ON DELETE CASCADE, + CONSTRAINT `related_products_related_product_id_foreign` FOREIGN KEY (`related_product_id`) REFERENCES `products` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `role_has_permissions`; +CREATE TABLE `role_has_permissions` ( + `permission_id` bigint unsigned NOT NULL, + `role_id` bigint unsigned NOT NULL, + PRIMARY KEY (`permission_id`,`role_id`), + KEY `role_has_permissions_role_id_foreign` (`role_id`), + CONSTRAINT `role_has_permissions_permission_id_foreign` FOREIGN KEY (`permission_id`) REFERENCES `permissions` (`id`) ON DELETE CASCADE, + CONSTRAINT `role_has_permissions_role_id_foreign` FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `roles`; +CREATE TABLE `roles` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `display_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `icon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `can_be_invited` tinyint(1) NOT NULL DEFAULT '0', + `reg_prefix` varchar(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `reg_description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `reg_start_number` int DEFAULT NULL, + `guard_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `color` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'zinc', + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `roles_name_guard_name_unique` (`name`,`guard_name`), + KEY `roles_reg_prefix_index` (`reg_prefix`) +) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `sessions`; +CREATE TABLE `sessions` ( + `id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `user_id` bigint unsigned DEFAULT NULL, + `ip_address` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `user_agent` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `payload` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `last_activity` int NOT NULL, + PRIMARY KEY (`id`), + KEY `sessions_user_id_index` (`user_id`), + KEY `sessions_last_activity_index` (`last_activity`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `shipping_classes`; +CREATE TABLE `shipping_classes` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `tags`; +CREATE TABLE `tags` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `slug` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `tags_slug_unique` (`slug`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `tax_rates`; +CREATE TABLE `tax_rates` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `rate_percentage` decimal(5,2) NOT NULL, + `is_default` tinyint(1) NOT NULL DEFAULT '0', + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `users`; +CREATE TABLE `users` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `partner_id` bigint unsigned DEFAULT NULL, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `display_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `email_verified_at` timestamp NULL DEFAULT NULL, + `password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `two_factor_secret` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `two_factor_recovery_codes` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `two_factor_confirmed_at` timestamp NULL DEFAULT NULL, + `remember_token` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + `deleted_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `users_email_unique` (`email`), + KEY `users_partner_id_foreign` (`partner_id`), + KEY `users_display_name_index` (`display_name`), + CONSTRAINT `users_partner_id_foreign` FOREIGN KEY (`partner_id`) REFERENCES `partners` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB AUTO_INCREMENT=19 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO `brands` (`id`, `partner_id`, `name`, `slug`, `logo_url`, `description`, `is_active`, `created_at`, `updated_at`) VALUES +(1, 15, 'Moritz Möbel', 'moritz-mobel', NULL, '', 1, '2025-12-18 10:55:57', '2025-12-18 17:53:22'), +(2, 10, 'Möbelfritze', 'mobelfritze', NULL, '', 1, '2025-12-18 13:10:19', '2025-12-18 17:57:05'); + +INSERT INTO `display_footer_contents` (`id`, `headline`, `subline`, `url`, `short_code`, `clicks`, `sort_order`, `is_active`, `created_at`, `updated_at`) VALUES +(1, 'Beratung & Termin', 'Jetzt Termin vereinbaren.', 'https://www.cabinet.de/bielefeld?utm_source=store_display&utm_medium=qr_code&utm_campaign=bielefeld_pos&utm_content=termin_buchung#c39393', 'c59kjb', 6, 1, 1, '2025-12-18 13:19:22', '2025-12-18 14:10:35'), +(2, 'Beratung vor Ort', 'Einfach reinkommen.', 'https://www.cabinet.de/bielefeld?utm_source=store_display&utm_medium=qr_code&utm_campaign=bielefeld_pos&utm_content=termin_buchung#c39393', '3bi07j', 2, 2, 1, '2025-12-18 13:19:22', '2025-12-18 14:10:33'), +(3, 'Pinterest', 'Inspirationen entdecken.', 'https://de.pinterest.com/cabinet_AG/', '1cl8so', 0, 3, 1, '2025-12-18 13:19:22', '2025-12-18 14:10:32'), +(4, 'Instagram', 'Tägliche Einblicke & Design.', 'https://www.instagram.com/cabinet_schranksysteme/', 'hz1tx2', 0, 4, 1, '2025-12-18 13:19:22', '2025-12-18 14:10:30'), +(5, 'Facebook', 'News, Aktionen & Community.', 'https://de-de.facebook.com/cabinetschranksysteme/', 'almb7t', 0, 5, 1, '2025-12-18 13:19:22', '2025-12-18 14:10:29'); + +INSERT INTO `display_videos` (`id`, `filename`, `title`, `position`, `sort_order`, `is_active`, `created_at`, `updated_at`) VALUES +(1, 'herbst_2025.mp4', 'Herbst 2025', 25, 2, 1, '2025-12-18 13:19:22', '2026-01-06 12:23:48'), +(2, 'fruehjahr_2025.mp4', 'Frühjahr 2025', 10, 1, 1, '2025-12-18 13:19:22', '2026-01-06 12:23:50'), +(3, 'fruehjahr_2024.mp4', 'Frühjahr 2024', 40, 0, 1, '2025-12-18 13:19:22', '2026-01-31 15:28:23'), +(4, 'herbst_2024.mp4', 'Herbst 2024', 25, 3, 1, '2025-12-18 13:19:22', '2025-12-18 13:19:22'); + +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES +(72, '0001_01_01_000000_create_users_table', 1), +(73, '0001_01_01_000001_create_cache_table', 1), +(74, '0001_01_01_000002_create_jobs_table', 1), +(75, '2025_07_21_124334_add_two_factor_columns_to_users_table', 1), +(76, '2025_07_21_124345_create_personal_access_tokens_table', 1), +(77, '2025_11_05_164539_create_permission_tables', 1), +(78, '2025_11_06_115527_create_hubs_table', 1), +(79, '2025_11_06_115535_create_hub_locations_table', 1), +(80, '2025_11_06_115640_create_partners_table', 1), +(81, '2025_11_06_142927_add_color_to_roles_table', 1), +(82, '2025_11_06_151340_add_partner_id_to_users_table', 1), +(83, '2025_11_06_152910_create_attributes_table', 1), +(84, '2025_11_06_152911_create_attribute_values_table', 1), +(85, '2025_11_06_153100_create_media_table', 1), +(86, '2025_11_06_153241_create_brands_table', 1), +(87, '2025_11_06_153245_create_collections_table', 1), +(88, '2025_11_06_153250_create_categories_table', 1), +(89, '2025_11_06_153254_create_tax_rates_table', 1), +(90, '2025_11_06_153259_create_shipping_classes_table', 1), +(91, '2025_11_06_153520_create_tags_table', 1), +(92, '2025_11_06_154757_create_products_table', 1), +(93, '2025_11_06_154835_create_product_variants_table', 1), +(94, '2025_11_06_154906_create_product_variant_attributes_table', 1), +(95, '2025_11_06_155516_create_partner_invitations_table', 1), +(96, '2025_11_06_155526_create_category_product_table', 1), +(97, '2025_11_06_155530_create_product_tag_table', 1), +(98, '2025_11_06_155534_create_related_products_table', 1), +(99, '2025_11_06_155852_create_product_logistics_table', 1), +(100, '2025_11_06_160618_add_display_name_to_roles_table', 1), +(101, '2025_11_06_162747_add_contact_name_to_partner_invitations_table', 1), +(102, '2025_11_06_170546_add_can_be_invited_to_roles_table', 1), +(103, '2025_11_06_170610_change_partner_type_to_role_id_in_partner_invitations', 1), +(104, '2025_11_21_153912_add_setup_completed_to_partners_table', 2), +(105, '2025_12_11_000001_create_registration_codes_table', 3), +(106, '2025_12_16_134959_add_name_and_assigned_to_code_id_to_registration_codes_table', 4), +(107, '2025_12_16_135608_add_registration_fields_to_roles_table', 5), +(108, '2025_12_17_110248_add_display_name_to_users_table', 6), +(109, '2025_12_17_123210_add_broker_partner_id_to_partners_table', 7), +(110, '2025_12_17_123422_rename_broker_partner_id_to_parent_partner_id_in_partners_table', 8), +(111, '2025_12_17_132845_add_brand_to_partners_table', 9), +(112, '2025_12_17_135808_add_address_fields_to_partners_table', 10), +(113, '2025_12_18_080601_add_soft_deletes_to_users_table', 11), +(114, '2025_12_18_105009_add_partner_id_to_brands_table', 12), +(115, '2025_12_18_131551_create_display_videos_table', 13), +(116, '2025_12_18_131552_create_display_footer_contents_table', 13), +(118, '2025_12_18_134623_add_short_code_and_clicks_to_display_footer_contents', 14), +(119, '2025_12_18_135703_add_display_path_config_to_display_footer_contents', 14), +(120, '2025_12_18_140712_make_url_nullable_in_display_footer_contents', 15); + +INSERT INTO `model_has_roles` (`role_id`, `model_type`, `model_id`) VALUES +(1, 'App\\Models\\User', 12), +(1, 'App\\Models\\User', 13), +(2, 'App\\Models\\User', 14), +(2, 'App\\Models\\User', 18), +(3, 'App\\Models\\User', 15), +(4, 'App\\Models\\User', 11), +(4, 'App\\Models\\User', 16), +(5, 'App\\Models\\User', 1), +(5, 'App\\Models\\User', 17); + +INSERT INTO `partner_invitations` (`id`, `company_name`, `contact_first_name`, `contact_last_name`, `role_id`, `email`, `token`, `status`, `expires_at`, `invited_by`, `partner_id`, `accepted_at`, `created_at`, `updated_at`) VALUES +(1, 'Möbelhaus', 'Kevin', 'Adametz', 3, 'kevin.adametz@me.com', 'CDfvxGVOOUhmGur6A0N7hWW7mZkfJUzT3kZGwjfxAmdWqnWJuvm79FilEjeKz19k', 'pending', '2025-11-28 14:31:19', 1, NULL, NULL, '2025-11-21 14:31:19', '2025-11-21 14:31:19'), +(2, 'Maxy Möbelman', NULL, NULL, 1, 'info@adametz.media', 'VKg9UeqFUqlvjAugJsthdk6qAyyfjdhpiRg9fT5v5rAIsNeEddvL2wQjl4Huf2pk', 'expired', '2025-11-28 14:53:49', 1, NULL, NULL, '2025-11-21 14:53:49', '2025-12-11 10:32:28'), +(3, 'Media Matters ', NULL, NULL, 3, 'register@adametz.media', 'blUPdDvXCyYsuMrl6RmB6pr8PTrXnmBGUM3UdVFwjP1Wm88FoXTACXlV6FqeKvgv', 'accepted', '2025-11-28 14:54:29', 1, 4, '2025-11-21 15:01:16', '2025-11-21 14:54:29', '2025-11-21 15:01:16'), +(4, 'XXL Lutz', 'Max1243', 'Lustermann', 2, 'register1@adametz.media', 'nkpgrROoNcmlE52NKkTjGmN4lsNOu7jS2HS1Zxp9sgWN6NIDLf3rf6GRE0scQY5w', 'pending', '2025-12-18 10:44:37', 1, NULL, NULL, '2025-12-11 10:44:37', '2025-12-11 10:44:37'), +(5, 'Max Möbelmann', NULL, NULL, 1, 'info3@adametz.media', 'QhQLbYBa1TOOVEs2p7r4UMZ4PUb14O5KLoLQjhaZQCnX53lwpw9LG9zK3J8ADHGh', 'accepted', '2025-12-25 09:45:27', 1, 11, '2025-12-18 09:46:11', '2025-12-18 09:45:27', '2025-12-18 09:46:11'), +(7, 'Kevin Einladung', 'Kevin', 'Einladung', 1, 'register@adametz.media', 'dDjcXk8pju5uxAOmQrzszssTdh06gO0k81nonZfwRggDFeNsLy1BIN8rMocigUcb', 'accepted', '2025-12-26 11:14:21', 1, 16, '2025-12-19 11:20:57', '2025-12-19 11:14:21', '2025-12-19 11:20:57'), +(8, 'Kevin Google Mail ', 'Kevin', 'Google', 1, 'kevin.adametz.media@gmail.com', 'NIex1PbOgVlIL6GUfhq62QJx79yipDo3VcncBu23bSNukTmU4V3NUWlFsWX4L9L9', 'pending', '2025-12-26 11:18:38', 1, NULL, NULL, '2025-12-19 11:18:38', '2025-12-19 11:18:38'); + +INSERT INTO `partners` (`id`, `company_name`, `display_name`, `slug`, `type`, `brand`, `salutation`, `first_name`, `last_name`, `hub_id`, `parent_partner_id`, `description`, `street`, `house_number`, `zip`, `city`, `country`, `phone`, `website`, `logo_url`, `is_active`, `setup_completed`, `setup_completed_at`, `delivery_radius_km`, `assembly_radius_km`, `provision_fixed_amount`, `provision_rate_percentage`, `created_at`, `updated_at`) VALUES +(4, 'Media Matters ', NULL, 'media-matters', 'Retailer', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'Deutschland', NULL, NULL, NULL, 0, 0, NULL, NULL, NULL, NULL, NULL, '2025-11-21 15:01:15', '2025-11-21 15:01:15'), +(5, 'Partner M10000001', NULL, 'partner-m10000001-91', 'Estate-Agent', NULL, NULL, NULL, NULL, NULL, NULL, '', NULL, NULL, NULL, NULL, 'Deutschland', NULL, NULL, NULL, 0, 0, NULL, NULL, NULL, NULL, NULL, '2025-12-16 15:05:26', '2025-12-16 16:47:39'), +(6, 'Markler Klaus M10000002', 'Max Schmidt', 'partner-m10000002-104', 'Estate-Agent', NULL, 'Herr', 'test', 'test123', NULL, NULL, '', 'Teststraße', '123', '12344', 'Musterstadt', 'Deutschland', '', '', NULL, 1, 1, '2025-12-17 16:41:19', NULL, NULL, NULL, NULL, '2025-12-16 16:29:03', '2025-12-17 16:41:19'), +(9, 'roles.customer K40000001', NULL, 'rolescustomer-k40000001-93', 'customer', NULL, 'Herr', 'asd', 'asd', NULL, 5, NULL, 'Musterstraße', '1235', '12343', '2134', 'Deutschland', '', NULL, NULL, 0, 0, NULL, NULL, NULL, NULL, NULL, '2025-12-17 12:38:20', '2025-12-17 16:43:08'), +(10, 'Hersteller P30000001', NULL, 'rolesmanufacturer-p30000001-105', 'manufacturer', 'b2in', 'Herr', 'Herr', 'Steller', NULL, NULL, '', 'Musterstraße', '123', '12345', 'Bielefeld', 'Deutschland', '', '', NULL, 1, 1, '2025-12-18 13:10:19', NULL, NULL, NULL, NULL, '2025-12-18 08:11:34', '2025-12-18 17:56:39'), +(11, 'Max Möbelmann', NULL, 'max-mobelmann', 'customer', 'stileigentum', 'Frau', 'Franz', 'Hatstil', NULL, NULL, NULL, 'In der Lake', '4', '33739', 'Bielefeld', 'Deutschland', '170206113', NULL, NULL, 1, 1, '2025-12-18 10:14:50', NULL, NULL, NULL, NULL, '2025-12-18 09:46:11', '2025-12-18 17:55:59'), +(12, 'roles.customer K40000003', NULL, 'rolescustomer-k40000003-95', 'customer', 'style2own', 'Frau', 'Steffi', 'Willmöbel', NULL, 5, NULL, 'In der Lake', '4', '33739', 'Bielefeld', 'Deutschland', '170206113', NULL, NULL, 1, 1, '2025-12-18 10:26:45', NULL, NULL, NULL, NULL, '2025-12-18 10:23:23', '2025-12-18 17:54:55'), +(13, 'Immobilien M10000004', 'Markler Max ', 'rolesbroker-m10000004-108', 'broker', 'b2in', 'Herr', 'Immobilien', 'Schulz', NULL, NULL, '', 'In der Lake', '4', '33739', 'Bielefeld', 'Deutschland', '170206113', '', NULL, 1, 1, '2025-12-18 10:36:31', NULL, NULL, NULL, NULL, '2025-12-18 10:30:41', '2025-12-18 17:54:21'), +(14, 'Händler H20000001', NULL, 'rolesretailer-h20000001-109', 'retailer', 'b2in', 'Herr', 'Händler', 'Max', NULL, NULL, '', 'In der Lake', '12', '33739', 'Bielefeld', 'Deutschland', '170206113', '', NULL, 1, 1, '2025-12-18 10:40:48', 20, 30, NULL, NULL, '2025-12-18 10:37:29', '2025-12-18 17:53:56'), +(15, 'Hersteller P30000002', NULL, 'rolesmanufacturer-p30000002-107', 'manufacturer', 'b2in', 'Herr', 'Hersteller', 'Moritz', NULL, NULL, '', 'In der Lake', '4', '33739', 'Bielefeld', 'Deutschland', '170206113', '', NULL, 1, 1, '2025-12-18 10:55:57', NULL, NULL, NULL, NULL, '2025-12-18 10:46:07', '2025-12-18 17:53:22'), +(16, 'Marcel Scheibe', NULL, 'marcel-scheibe', 'Customer', NULL, 'Herr', 'Marcel', 'Scheibe', NULL, NULL, NULL, 'In der Lake', '4', '33739', 'Bielefeld', 'Deutschland', '170206113', NULL, NULL, 1, 1, '2025-12-19 11:21:05', NULL, NULL, NULL, NULL, '2025-12-19 11:20:56', '2025-12-19 11:21:05'), +(17, 'Makler M10000005', 'B2in TEST', 'rolesbroker-m10000005-110', 'broker', 'b2in', 'Herr', 'Marcel', 'Scheibe', NULL, NULL, 'Hallo', 'Feldstrasse ', '59', '32120', 'Hiddenhausen', 'Deutschland', '015151002992', '', NULL, 1, 1, '2026-01-14 10:48:58', NULL, NULL, NULL, NULL, '2026-01-14 10:45:36', '2026-01-14 10:48:58'); + +INSERT INTO `password_reset_tokens` (`email`, `token`, `created_at`) VALUES +('kevin.adametz.media@gmail.com', '$2y$12$IhYf3kICgy7dvm87aafn/.dvRNljUgKu/vexzy/5bg3zTq.xSro7e', '2025-12-22 13:04:57'), +('kevin.adametz@me.com', '$2y$12$5gEsviH/d726oTzs5j74i.eX8swI8h3ecztBeRKigt6xZQq3Wc1yW', '2025-12-22 12:54:03'), +('register@adametz.media', '$2y$12$iu9JlwsSs3SCyyE9RyjaGeZz4JBVo87koc6RsAk29picU9pm.up8G', '2025-12-19 11:22:27'); + +INSERT INTO `permissions` (`id`, `name`, `guard_name`, `created_at`, `updated_at`) VALUES +(1, 'view hubs', 'web', '2025-11-21 14:29:15', '2025-11-21 14:29:15'), +(2, 'create hubs', 'web', '2025-11-21 14:29:15', '2025-11-21 14:29:15'), +(3, 'edit hubs', 'web', '2025-11-21 14:29:15', '2025-11-21 14:29:15'), +(4, 'delete hubs', 'web', '2025-11-21 14:29:15', '2025-11-21 14:29:15'), +(5, 'view partners', 'web', '2025-11-21 14:29:15', '2025-11-21 14:29:15'), +(6, 'create partners', 'web', '2025-11-21 14:29:15', '2025-11-21 14:29:15'), +(7, 'edit partners', 'web', '2025-11-21 14:29:15', '2025-11-21 14:29:15'), +(8, 'delete partners', 'web', '2025-11-21 14:29:15', '2025-11-21 14:29:15'), +(9, 'manage provisions', 'web', '2025-11-21 14:29:15', '2025-11-21 14:29:15'), +(10, 'view products', 'web', '2025-11-21 14:29:15', '2025-11-21 14:29:15'), +(11, 'create products', 'web', '2025-11-21 14:29:15', '2025-11-21 14:29:15'), +(12, 'edit products', 'web', '2025-11-21 14:29:15', '2025-11-21 14:29:15'), +(13, 'delete products', 'web', '2025-11-21 14:29:15', '2025-11-21 14:29:15'), +(14, 'manage rental options', 'web', '2025-11-21 14:29:15', '2025-11-21 14:29:15'), +(15, 'view orders', 'web', '2025-11-21 14:29:15', '2025-11-21 14:29:15'), +(16, 'manage orders', 'web', '2025-11-21 14:29:15', '2025-11-21 14:29:15'), +(17, 'view users', 'web', '2025-11-21 14:29:15', '2025-11-21 14:29:15'), +(18, 'manage users', 'web', '2025-11-21 14:29:15', '2025-11-21 14:29:15'), +(19, 'manage roles', 'web', '2025-11-21 14:29:15', '2025-11-21 14:29:15'), +(20, 'access dashboard', 'web', '2025-11-21 14:29:15', '2025-11-21 14:29:15'), +(21, 'place orders', 'web', '2025-11-21 14:29:15', '2025-11-21 14:29:15'); + +INSERT INTO `registration_codes` (`id`, `code`, `role`, `name`, `status`, `broker_partner_id`, `assigned_to_code_id`, `partner_id`, `used_by_user_id`, `used_at`, `expires_at`, `metadata`, `created_at`, `updated_at`) VALUES +(91, 'M10000001', 'broker', 'Max Markler', 'used', NULL, NULL, 5, 6, '2025-12-16 15:05:26', '2025-12-23 23:59:59', NULL, '2025-12-16 14:08:08', '2025-12-16 15:05:26'), +(92, 'K40000000', 'customer', NULL, 'available', NULL, 91, NULL, NULL, NULL, '2025-12-23 23:59:59', NULL, '2025-12-16 14:08:58', '2025-12-16 14:08:58'), +(93, 'K40000001', 'customer', NULL, 'used', NULL, 91, 9, 10, '2025-12-17 12:38:20', '2025-12-23 23:59:59', NULL, '2025-12-16 14:08:58', '2025-12-17 12:38:20'), +(94, 'K40000002', 'customer', NULL, 'available', NULL, 91, NULL, NULL, NULL, '2025-12-23 23:59:59', NULL, '2025-12-16 14:08:58', '2025-12-16 14:08:58'), +(95, 'K40000003', 'customer', NULL, 'used', NULL, 91, 12, 13, '2025-12-18 10:23:23', '2025-12-23 23:59:59', NULL, '2025-12-16 14:08:58', '2025-12-18 10:23:23'), +(96, 'K40000004', 'customer', NULL, 'available', NULL, 91, NULL, NULL, NULL, '2025-12-23 23:59:59', NULL, '2025-12-16 14:08:58', '2025-12-16 14:08:58'), +(97, 'K40000005', 'customer', NULL, 'available', NULL, 91, NULL, NULL, NULL, '2025-12-23 23:59:59', NULL, '2025-12-16 14:08:58', '2025-12-16 14:08:58'), +(98, 'K40000006', 'customer', NULL, 'available', NULL, 91, NULL, NULL, NULL, '2025-12-23 23:59:59', NULL, '2025-12-16 14:08:58', '2025-12-16 14:08:58'), +(99, 'K40000007', 'customer', NULL, 'available', NULL, 91, NULL, NULL, NULL, '2025-12-23 23:59:59', NULL, '2025-12-16 14:08:58', '2025-12-16 14:08:58'), +(100, 'K40000008', 'customer', NULL, 'available', NULL, 91, NULL, NULL, NULL, '2025-12-23 23:59:59', NULL, '2025-12-16 14:08:58', '2025-12-16 14:08:58'), +(101, 'K40000009', 'customer', NULL, 'available', NULL, 91, NULL, NULL, NULL, '2025-12-23 23:59:59', NULL, '2025-12-16 14:08:58', '2025-12-16 14:08:58'), +(102, 'K40000010', 'customer', NULL, 'available', NULL, 91, NULL, NULL, NULL, '2025-12-23 23:59:59', NULL, '2025-12-16 14:08:58', '2025-12-16 14:08:58'), +(103, 'M10000000', 'broker', 'test', 'available', NULL, NULL, NULL, NULL, NULL, '2025-12-23 23:59:59', NULL, '2025-12-16 14:56:42', '2025-12-16 14:56:42'), +(104, 'M10000002', 'broker', 'test', 'used', NULL, NULL, 6, 7, '2025-12-16 16:29:03', '2025-12-23 23:59:59', NULL, '2025-12-16 16:25:28', '2025-12-16 16:29:03'), +(105, 'P30000001', 'manufacturer', 'MHerr Steller', 'used', NULL, NULL, 10, 11, '2025-12-18 08:11:34', '2025-12-25 23:59:59', NULL, '2025-12-18 08:09:04', '2025-12-18 08:11:34'), +(106, 'M10000003', 'broker', 'mac ma', 'available', NULL, NULL, NULL, NULL, NULL, '2025-12-25 23:59:59', NULL, '2025-12-18 10:29:41', '2025-12-18 10:29:41'), +(107, 'P30000002', 'manufacturer', 'asdasd', 'used', NULL, NULL, 15, 16, '2025-12-18 10:46:07', '2025-12-25 23:59:59', NULL, '2025-12-18 10:29:45', '2025-12-18 10:46:07'), +(108, 'M10000004', 'broker', 'asd', 'used', NULL, NULL, 13, 14, '2025-12-18 10:30:41', '2025-12-25 23:59:59', NULL, '2025-12-18 10:29:48', '2025-12-18 10:30:41'), +(109, 'H20000001', 'retailer', 'asds', 'used', NULL, NULL, 14, 15, '2025-12-18 10:37:29', '2025-12-25 23:59:59', NULL, '2025-12-18 10:36:54', '2025-12-18 10:37:29'), +(110, 'M10000005', 'broker', 'TEST MS', 'used', NULL, NULL, 17, 18, '2026-01-14 10:45:37', '2026-01-21 23:59:59', NULL, '2026-01-14 10:44:15', '2026-01-14 10:45:37'); + +INSERT INTO `role_has_permissions` (`permission_id`, `role_id`) VALUES +(1, 2), +(1, 5), +(2, 5), +(3, 5), +(4, 5), +(5, 2), +(5, 5), +(6, 5), +(7, 5), +(8, 5), +(9, 5), +(10, 1), +(10, 3), +(10, 4), +(10, 5), +(11, 3), +(11, 4), +(11, 5), +(12, 3), +(12, 4), +(12, 5), +(13, 3), +(13, 4), +(13, 5), +(14, 3), +(14, 4), +(14, 5), +(15, 1), +(15, 3), +(15, 4), +(15, 5), +(16, 3), +(16, 4), +(16, 5), +(17, 5), +(18, 5), +(19, 5), +(20, 2), +(20, 3), +(20, 4), +(20, 5), +(21, 1); + +INSERT INTO `roles` (`id`, `name`, `display_name`, `icon`, `can_be_invited`, `reg_prefix`, `reg_description`, `reg_start_number`, `guard_name`, `color`, `created_at`, `updated_at`) VALUES +(1, 'Customer', 'Kunde (Customer)', 'user', 1, 'K', 'Kundencodes werden Maklern oder Händlern zugeordnet', 40000001, 'web', 'indigo', '2025-11-21 14:29:15', '2025-12-16 14:56:59'), +(2, 'Broker', 'Makler (Broker)', 'home', 1, 'M', 'Maklercodes für die Registrierung von Maklern', 10000001, 'web', 'lime', '2025-11-21 14:29:15', '2025-12-17 12:00:39'), +(3, 'Retailer', 'Händler (Retailer)', 'building-storefront', 1, 'H', 'Händlercodes für die Registrierung von Händlern', 20000001, 'web', 'teal', '2025-11-21 14:29:15', '2025-12-16 14:57:09'), +(4, 'Manufacturer', 'Hersteller (Manufacturer)', 'wrench-screwdriver', 1, 'P', 'Herstellercodes für die Registrierung von Herstellern', 30000001, 'web', 'orange', '2025-11-21 14:29:15', '2025-12-16 14:57:15'), +(5, 'Admin', 'Admin (Administrator)', 'user-circle', 0, NULL, NULL, NULL, 'web', 'purple', '2025-11-21 14:29:15', '2025-11-21 14:29:15'), +(6, 'Super-Admin', 'Super-Admin (Entwickler)', 'shield-check', 0, NULL, NULL, NULL, 'web', 'red', '2025-11-21 14:29:15', '2025-11-21 14:29:15'); + +INSERT INTO `sessions` (`id`, `user_id`, `ip_address`, `user_agent`, `payload`, `last_activity`) VALUES +('0huO5a1xpWmxlnrpkKrgpqXj5ca5wS9wIbYQWFxV', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoic1ROZzhGaDlsUXlqN1RKa2NMQll5bldEb3RBR3BjeUlsU2t6MHB3MCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770888218), +('0UpRd4WBu3YZfSoTD2iEMAOgpOpCfht892vB2Jsy', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiQ0ZWeEdESE8wa045OWVUTnFtcXlhTjhET1hwc3prTkdnbUVyMzMxcCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770893558), +('1mfG4zcnhBzJsUw6fvGHtZ6sxFdSLmG9BLTkwQhh', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoidEJjaFRaSGFkeENHQ0pSUGlWNEZHTnZVZFNIOVlQNk1KQUFsWXB6UyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770896858), +('29AG8gEyHQDciOCElbPLuXNrRfRXyj8yETt6UNIJ', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiTDB0ODdqZnR5cFJIcTBtVFl3Y3pZYlVpMXVYaU9FQ1hhT1RjNnpKTyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770891158), +('2aIJbh31nGmC4TjS2RIDtFF6CDG89Nb12tpY6ikH', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiUlFKVkpnSmM3SW10RGt0RGFxQkVQMWIwNndmYzQ1QmttOUVBa2gyQyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770898358), +('2C7o9my8Vr0O1tFqTSdxbjNQWqY1xsqut0TAouUQ', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiZ096c0Y2dDVzTVRnNU5hOGRUMXRCZ2JKenYxcG1oRDJ0TWJ4OG5iTCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770899351), +('2kU2RYTXaMi1sVdydnwRZXTU42n77yyuJCH762fT', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiMjlHRHE1all6QWJLbzhsTDVOZmVRT2xSSjFGaWxOaGI3YWVJNzM3SCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770894158), +('2x963ow1cJfQRL8EdS6EmRiK5krZB6gPKzXIcQIR', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoienhydVgzckNDMG16RUVIUFN3d1dyRkQ2dFFjREhlMUpma0dtYzNEbiI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770891458), +('3i70cWhdRfoMWGOpd5UUEb0dP64j8zbMclVT95FO', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiVHB1RUlZOHZlQWtsbjMyREEyZFlxNHd4ZGxKZjg5NHp0VUZjdzFpaCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770889417), +('3RzYrkluCGlzGBKI2yUnLWhA5yC4KGS4hbpFpVyF', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiVFZGdWphempFRmhXOTQ5cjljZG1mVHFtVDU5QU9kZUFkTkxmR1FnTyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770892358), +('3tHD2TAW8rxbhcLlIdjnsKvpokHBDGchcJOpAwkL', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoibHpwN0dWd1hEY2pNQzFvQWxRNFBpMTVVYWhJNWc2bnhmYmt2em13MSI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770898751), +('4QQAd8detC8ozTbhUCV3sWJADdhUXCx8fOeieb4M', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoib2kxVm05ZmhyV2hQNGp1U2lONW1WMkJiWHd2NHVYOUlBbTRrRmN5UiI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770892658), +('4ZGKZkqyZTN0KpChyUvF4Ihxyat6Z9DtcnXOlxpj', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoib1FVaG5kSFhDS1piVmgxWjRNWDExSXoyQlkwYWc4bTluWTU1QVVzRyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770891875), +('5A1uKPmt3e6RAP4G0LCgEA70FPhKfqGURV5qefUm', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiUER2VmdmakpJN3FEeGJlRXRudmVWVWV1Wnc0RDJVU2ozMjZnOEh0diI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770890675), +('5lCoHQdA3kKXtKf6jQ6uzVozSGrmCEkUN50Wxsy2', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiNUFEc1duUkdrVmlVMGtURVYzYlI4OXFVSzJ2ZEN2clY1YUZGUjROZSI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770890317), +('6p8Y4gMO7wdnU5nkvleIgoVSEGdm0XVk8eKDwa51', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiamhROTlwSzZuWVlQallQcEQwMW5xYTVwVlZWNWUzeFZNN0Q1dEd3TSI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770890975), +('6sC9gA15xgur5req9vRByCfMLTdYrfvtufnIKnIr', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiQmdjZUROTjJVS0VwdXhXTjZXcnMyVDZiRkdGYUdhQjRKYnRZNkpzMiI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770897817), +('8LfsT9HCYmUHIHqdz79eltf7yMwNGva0EJ0WlMsR', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiRW9TdWV6N2ZjZjFYSmMwQzZsdGlIZklIRTdoUExNVXR2bm9kVW9YUiI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770895358), +('8OMiHrgJhv6cQTulbuHlchXBtjwJPF6tFYkT3XS5', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiMGI4UFFjd3RQT1ZQN2RkWWdkVmgxZVNqN2NOelVkQk5YTkNFTzlKYyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770898417), +('8TpgaVNz29gQTFRKrhgcUYLV2qp58rYGtoZ0118Y', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoidzEzQWVpcFEwYWVjTlZuTm00MDh1QklxOHl4RnE3dWJUTzdYeUFrdCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770888817), +('91J3E4136NsVeEnpJwTzBPugCIGG0wn2qlyZempc', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiMXlzMDl6WmExZWFVSnk2Qkx3cm5kUFZ1dEE4WlhOQWhRcnpWR212TyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770895658), +('9oNilyBrDzjJ2Z4VQengVGYtXfxr0RRU53s8aJc7', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiU0hNZ3p6cjF0UElMTzhKYjVkUHVKYlYwYVJneXl2RVFNTmJQM2JHUiI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770890858), +('9qLF6mLFvQlk3ypkH5hk9jfEiuaqJoox1k7L7lLO', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoib1RIWEtaMDd5anRtVHhlaGxLcEJQV1lVWmtMYkltV0FnZG9abWk1ZyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770897217), +('aNpsvmFzYCJH9VTKZERRKchHa9xsXuxSP1r9L8Ph', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiRFB5elF4b3FOYXRpQ0xUWnp1MjlxT3VrSzdnTml1dnY4SXhkYmt2ZCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770898958), +('AoYQaLv7lQzMtoMZm5g5qw0aqQx2CzBQzSgb4ip4', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiS3VZem44MzExN080a1RoQkZKa2V4TlVGWFc2Uml1U2l5MGtpbEVYVCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770888575), +('BqybQ9CMqFhdVaoZFXy0QeAtpn8FqlXalxboSzFL', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiekFhWTlXWHoyV2pkS2NOcTRHMlpqTlRsR0JocWx2dTRGMlVWSktQbyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770893975), +('BTubnX73ZYn2tjkfOdZiQVnSmhmcroLEs2Rx29uV', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiUXBJakZJYXc1eFJ0Q2xTREdXOGUxVkpiTVJ2dzhISFpOVjdoM2tsZiI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770894875), +('c4brHlD8wkVOrRGczWOFdyxD1vw3f59349opAxN2', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoicWFmdElZUU9JOFdJNDhhTkhmdnRUd2FadGpkQlFDa0hsSlNsTjh4NCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770892775), +('C7OYo02nIPH0oTkQw8iAPFAFMSnmUKbWN297Tz0q', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiM1VlS3FERGJsREt5WVZ0VU5YcnhLYkhtT3B6VUh2SWFaWUh3WGRmdSI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770890375), +('CeDHUP2OBjaZ3cLWvB2iIbWc67OB1vqRv2MXWVia', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoidlVmdThyN0drNjVLTnhtdkVEOGJaUWVwUzRYUlAzRE1EdnlicTZBdiI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770896375), +('cFF20EbpZzpRAbCzAngC9wpgOvZ2q2XbC7EFAKSq', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiQ3BzcHgwZlhHSEE3VXQ1NUxYY1VNcktQZUEyYmMzVDJWVzV6STdDMiI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770895175), +('CIa8LSJaszWC8H4icDTxPgPtbuUFTDo1tSPzdoVT', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoic3N3TlRSaWFqdERJZXRnRVNHUkYwYWVqM0puZXRhUEk4QndGVHVWdSI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770897259), +('cIBUz3d7CgspaTtbFwDF4Y7OPgVhYdUXCiGXFf32', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiZDVWOW9kSnRDSU9Fa1BtVW9adVpKUFhldmh1Q256NnBOQXQydkNRVCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770896017), +('cXTtz3be3xTHL9DEq9DEaDbxPZyClPH37Yru0znm', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiVWp6MUdBWTZmbHBLbm5aN0FpMzBrZXhYbkhBN1JoczZmb3ozRnhsMSI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770893258), +('d0mXDFkdIa1R7HAoiJDgRPlqCWGmNb1Tl2RD9S0p', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiZGtUUEdNVG5HOUFxMUFXZGZsUjF1VDlmYkpkdWs1amZFdGd3aHB4QyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770892058), +('dSvVQ8pZn64bKlO11VpSl3nD8cL4NWS5cYBIUejz', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiekFUaGRTQ29oMnNnRnN2ald0OUloWmpiSE5VcWxUbkpRVEs0QzMzVCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770889117), +('Eh366oDSMXbex2GYLdyAMMp28E4QpNRwtlV5166E', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiT3dUOE9hblhYVGRvcnZwaTRaM1V0VmJiMW9vaEZhMVdQbndaRVIwbSI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770890075), +('eOlC9oRC3fkO8AGRKZQOJAdIQg8sRsevc7796M35', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiaFp4bzBPR3B4ZWRnNVpnZTBiQmJSWWFVZEJTcXJYOFhTT1dKWHY5MSI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770896258), +('eXgEDqrzmQhWfZzoZRsySJ25xKigc4Or7Y3rqsCn', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiTjlIS0l5bUFEa1FOY1J2YlhSbFA0YWtJc0tSRmVoR1o1b3F4NTZjaSI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770892175), +('eyp6GBhdkc9CJvWaHI9hkovPNkFvfAEhMlmVR9eK', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoieVptbVE1c2wxeDRjckR0cjFlaDBvREZIelBla1NXMm9FSmdRMXBHcCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770897851), +('FgMOZcfwL4ysQQD8tva4gjLhzC5cGjp3VJv7icTH', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoid2JxUUpiM3FzTHdwRHRiWlRMblo1MU5Fc0VTY2tyQUJpd3V4ZGx2SCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770899017), +('FlHpVIcqvcThZWw1qJh6dQ4GnqUMMJxUfvrgbUZV', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoibkhMZTltQ280YzE5RDZzSVg1bWRDelpQbTZ6RTFNWHF3eUhnYnFqTiI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770899258), +('G5E9C9wEKeozXK4A5OGBluRRCg15pIkwJKAZWWEZ', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiZGNvUXRORHRtdXR6b1ZDY1ZqOTNQMUpJWWUzVTlJRzdQVmNxTjhRdCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770889775), +('GcSesdMFKK8Bh8nI6SsEtTknfBIkAzMo6H7jrwzF', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiSzJISlBpSzNuVEw5d0JLTHg1eFBxVkZqUDRicjJjMUNPSm9wazNWNSI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770897158), +('gu0HL8M8MCPvgVxcmp9axlDG6kgorNTXwNv3mx6i', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiNHQ3MTJnejI2VHBRRHZIMzFUTExtVTJYemE0WWp2TGk0czM1UGp0SCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770888458), +('hBteSDpylzOcPUVaxQvCSJqbS44nx6ne9nV8J8k9', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoic0hBaERhbEFtY2VWUHlZdDVnMHVJWmZiMURheHk5Q3dBWFFMWjVoUyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770894758), +('HgR0OcQuasPkVG6POsjSz1cDh92fKizyYEXIOHP7', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoia21BN3dVS3h6M04wcmVKc28wSDVwUkVTMXBlYTNkN2cyZ2FXQ1c2SCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770889717), +('hLyEP1DcNVqv58ESjF0FnTmyEmSQEo9DzwAdm0sA', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiZlg0Sjhqd1RVQWcyUTN0c0F1YVkwR2JWVklob2JWWk96WGJyVzhFbiI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770899051), +('HP6y0mHvND0ULOtGNEXWEElAv6GXnzMyRVx7754Z', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiNnRiRDVkWXhkdHRVZzZnUXo3VDlDY3d2bUhBSjl3MW16bWJndlhHWCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770893017), +('IMa3AKgj1az4hxLdrQkFuCNbaobrYmmpOaAUCKAq', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiZUoxU2gyREROTFVDUkZsV251YW1NNmR5MWQ5QkNuUU54U3Y5cmt6byI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770893917), +('jDPslLnlH2NmyHQFWwbdjUzHoKK7qkB1iS88rgX4', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiUndkeElDdzBhaUJ2UHRsUlE5MDExR28yOU53Y1RnY0FGVWFVRHJlZSI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770893075), +('JInHwzIsogZGMjM7JvpLYodvwrsrbxQlvPvDFYYm', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoibVJUakZnQng5QTVzMVpTQk1YWEg3RkczV0c0R1cxckhUaFBBNWNxZCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770892117), +('jS9pX4BQ0Qqg9lOht3qusanQJcCm0zOss91AVype', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiSnRyanRYSlpVd0tqb3A0UjlqMVFBUXVlZ214elNDNXZhbVRkMDlpNCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770891275), +('Jso8pDszg2F9pVWJTZjAy4P46sq0Zis5NUe3VyDY', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiQXFYRUZYSGlBalYwZVhjMzQ0SHpHQ1BnY3lqZEJqaG9JbHBYSGlEOCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770891817), +('k9t5OmgNZ3o6ALMzRpv8oJYXZ6raO4hwh824nZmF', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoib01vQ0lHM2Rzc25RNWwwZ3BEblNwcHFlcG04WTRJcnBua0l5a0pJYyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770898658), +('KQtnbX0ps8RcyBiQALGxbmxWCoB3nBI3IynokhY8', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiNXlqeFZiZmVXcWNESWdIcUpwNE5MYWxwV25PMlZhU1ZiOGdMVTBvdyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770888275), +('kXzYgZlkX9Ty89v8Ncgk41bBP79rV5Pj4M3k7gDS', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiY3FGUWNGREhiaFdNZW1iUmJsMUt4MHRvdXdGVHpTaVBLa2k2SWdHQyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770888875), +('l7vKalTwRIBfflfBy9BgsrWqwMdMaHgxx8fNfeQY', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoid29YY3RwT0NlZHlaQTVpY0R1aldBUmkyTktiNnhxYVFOYU5ONVdVNyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770896675), +('l9e68A9h5TKcOQL98SyjDcF1Qhtsi12cpizoKDM5', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiTWVrSEtQTERRVVJ4N2hRZ0RVbXhhSnN3ckNkd3Z4QnQxdk55UWVGVCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770891575), +('l9Rc4PHRFNp9mMmA9MHuZJlqCxfIWyfGWB6cykbf', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoidEtudlZ4VWYxVEYwYndGZXdyTjRZRUtLbHBXMG42VTZIRWJzejJ3dSI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770891517), +('lDDLAiZdfLXG2GkAdEvmY3TNnl0sIyqGOYh57YhR', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiZTdqRlpvMkh5a1A5Ykg2ejhBVXZaTll5TWhSSEw4ZXN5NkZxdENlTCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770897517), +('LlzqhW7M0HfiRL5DICGWOoBEfCdHHR9nzqWnXzCg', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoidnNvd2ZUMXMxRzV0WmZ0eG1FTDZYOWhTQkxBeHh1MVZmeEVpMVpHZCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770897458), +('LooIygMnhJUtVAnOOiACU874jAxEiZ8dR6CbwHjE', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiTm5PdzRNYmkxVFdNb1l0UDN1M25tbm9LSWQyb1VkMDZIbnZDck0zcyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770889058), +('lpTObHmcAQIA3ZDvZnWV8eWm0XZmjHzQW0iD34Ug', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiMmdLMFZRRnZJeTRvTkhlZUhBd2pxcG9mWVByaFp2bHpqdGNwc0xVaiI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770888758), +('lt8CPWxNN44jLKJqvOV9kpPjmf2ZAKackMVx3BRZ', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiWUlUUUU4T25tRWZxbDh5WlBOeDJ3NUZKWXc2ZWZhbWgwYk04M3ljTiI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770891217), +('lz2Bs1LYrxIHL3bcaxd0BWqSjDyyslO6r5Pk1esI', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoidTZWOENBUmtCNXJYWkxuME9HWmNtbGdJUU1qM00yb2JGU3pFVDZrSiI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770892417), +('mk5t6GUZQdwLACpHtWfhA31qCCpXsnEXrzzsYp2J', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiOXpDVzBCQWtPN1k0djhDTEZVSFZkZmxSRENDbHJxblB0TFRSaklZTSI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770893858), +('mMwtSoFVIF21ALXxtaCB2RLuOx4wUQ1uaG1ubk0x', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiTVJQOGd4bkpGWmlQWkFWbGRwTlZPRzNiS0hhNHhmemF3YlZJQnJXbSI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770893375), +('MmZN6m2tHUq5I1c92HECPDDn6AdFVl0uMAQH2AGr', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiSmlwUjZSb2VwSjhJTEZGNzhXUTU4Q21VY0dyTEZ2b1VQQWNpOVdndCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770890917), +('N7lmUFMMlk2zDRxgRgiSRhr9gsS08vkPNkcvYw0z', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiYW5ZS2tYbFhubENvZHAwQnV6QjFBMERPWWo4c1FTMHoyV1B2a2JVZSI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770898451), +('NApkvIjH2Ddsde980dc6Ixqv2gZzBATc4o9SJb1S', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiMzJ1WDdmNEdWRlJydFdFRTB5VWx6WmRvanh5dENrdkM3UWR2UWZnayI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770898717), +('NumBSlSnSQSjK4tg0RumH7honcRJULg58aMvjott', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoic3hrdkdxZGtmZTRhOFZyZkN2YlNsNVBQMUFrclFoTHpsYzlzSWw0UyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770895775), +('oF0v0FYSq7UAUZtwIlerCnw4n7Q5FdMvnCgE59OB', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiVWw2ZFQ0QXNXSHBtOHIzTEpkV2I3dmVrVDIyV0RvaFBFTzRrMEc1RCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770895475), +('OJAofVisLmIYa852HcPzejyPZXv9ASw0P4DsYlWp', NULL, '217.247.92.223', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiSzBZM0I0R3d1c3FhdEdrZFF3OHBmSmhZeVRadEtBcE45dGgyNndZVSI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770894304), +('oPFxEYcFQCMqVQv2TWB5cmk3vjMUlBUyeQmuz7PO', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiNXJDZXpZdXVHcmI5M1NmQzRVQlhMcDk5dm03RDd5SFRrTHU2bnh5OSI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770895117), +('p4SFcptT4eHwRPVkdF1ScxGc4fcOiNXAFO6LOSNI', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiSGR4RUF1aXB0bGRpcE1yRWlDbnFOUUZ6c2Z0Q0NzcmpjNGw5enNoeCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770896317), +('pf20KuoXcP5AHSvjwYB6q7JjHR8ne2Pgy46jAdJs', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiOFZmTkhob21MWGxuOVIwdTVzZm1WWWltcUVqT1did1A5YUxkQ1dHdSI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770896617), +('PL0UhTU2esJUbJu04Z5gLgXFIaeMy9OsmiPCm6qV', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoidXpOWmJTTHRkT29yQlVJUDhmcWJpbXo5U3VadmJ6NzJZeGROcGFlbCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770890617), +('PXWvxrOu2gOKZuP4v3DHPA9xNCaNQ9o5htXH073w', NULL, '217.247.92.223', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiZ1IyQmd4SEt5QkJkQzBFbk9UMmFOMnFvQkk5OGJKenB6YUh2QlNyUyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770893896), +('QaRS58xbomKpHGcb1rEwNcloXZRHE3PV9oardrtY', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiYm5TYldxYXJYRmszMDd2MWhybnNmOHFIdlRsSlVNVXN4UDdYYUJ1MyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770888517), +('qd0n13mKU9DBVOa8bLynUCOEdHv75wOBFXLkBgPo', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoic09JRW05OVlLSHkyT0QyQkdXNlE0eHI4T2dBUmJWZ1BUMzhGUmNFNiI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770893317), +('qFYZxfPav6Q10jcBfvFqSiqRPSI7W5KQTZlMqUZh', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiNjNGUEFYdEtBNTdvakdjWHNUVHJiSjNWQkR2N2oyRUFMMmRXOHY4RSI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770896975), +('qZvBpSeIQ2Q25XyGlDYjE6fTTv5MmQNbVXY4d8Ec', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoid0h4djllSlRvVzNGMUFMUTg3N1lpeXVSV2U2MTJCbWsxMDhaeGhXVCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770895717), +('R0kvDZLH3gvxsUjgdcWUBk2JGxb6rR6rSEHqlOPV', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoid3k2dDRLTXJvOHlMUjlrS1Jjb0xuNzVvZmJvcFpLd2x4WTEwYU5TTiI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770899317), +('r1H3FmzZFodfSLypuIi2m5ezfwUmk1Zw7xjOAn5d', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiR2Nsb2pGbEhLMkI3Um55eGdSNU5kQWZxTmtFY2Nwa1ExUzZ0Q1poQSI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770892717), +('RvmD9A3MVydxAHja3kqknaEf2wPdFce6kdVftjP0', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiWVVjRmRTRkdMN0tZWGVqcTZUNjZIZXNzZFBWckZPWkhEUU51UTJ0TSI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770895958), +('SCRhdcSLdYdRDP2YEmUNBbh3fRS3woXi8BOip8wE', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoieWpxMUlwUkhqbzBtSldpTDk4czUxQVFuQjZsYzRDZWNXWWNpTER6eSI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770894575), +('Sd849ETQZyIfoLm4gjnP9Dk7hVT5gMYs280aiImS', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiclVZZmNsQTVaVFFrMXVOQjBZQWhHeTRKbHVWdzM1dTA0c3BEVjVSRCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770895417), +('sXTeXAq5vyAFksx6WB2a9L6djxoUOzwifoJ4xjB7', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiclo3Wk56UDRwUXY5dFFKSnJPZHJyUGxxUEV1aE5LSERKejhFNWlsYiI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770889175), +('SxTSRmk1i2AQs51n8kMGqMr0j7yNd9MkQjzLyibw', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoidTVPMnBYdTdHSXBKemNDZFJ3dFNOZXZxN1h2bE0xVXdDV2tTYkJFMiI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770894217), +('TgUtqzlmrZSLNe54Mg79mExW2SDnZSLOnl3CSudC', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoib21LMmE1ak9BdzZ4TXdsekZLSGtRV1dobHZPQ01oTEc1M3JGWjU2OCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770892958), +('Tl5hGICX8Zsw9WHy5rqAqEIRhBMrGgQ8wbRK6jJa', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiam0xTTVkNTJqOWFNbG1BdklmdmRmN2ZvWUdQRVlCaWRzNnFmc2E1TCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770898058), +('txO09F5MSJWmAVZU4CHAGfsRXt3DipZEFPgzy5nW', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiRmFDMTBpcmN6eGRvOEtLdnFDSm1CTnUzZ0EwUGNROUhjQjNoTzl5WiI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770889475), +('unZFx6D6ZSCcFZOdoVARt5tfAtM9MAOqNk00aWin', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiYVU5QXFDdllEOVBtQ0dwbnhDNWlHR0c0anZwVDJSZUE2MXdLWmhUaiI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770898151), +('uUeUxNx1adGYfuqOdItepv5VR9MiCp1NmVJSLmfc', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiQnhTODBkMEdBc2xQTTJsTFlOTDFBNmRCVWRhb2hveUJKS1JGREp4eiI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770898117), +('VWAryPuhUrfUtwlnn1Y589xfmrreAkhHZD6jC2Ma', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiRzZ2OU9NTEZES1J2NXoxSVdhQ3pjbHk2alFtUVpqRDlJdjc0VUIxVCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770889658), +('w43XLXjbEGJ5Gt48CPEItiMIsH0DLTSKTXMwFBYJ', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiSld3bk1naERDZHI2YXZZNFNqWHcwaHkxUUF6ZXdsR1I2ZlRFRWVvZSI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770892475), +('wlEy6q5aC4nvdyO7TwYm6hF6AmOyOrkwVrKF8Lsf', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiRXZVU0c5T1BENmtVM0pHSVBNMGo3M3VZUnJwSU83SlJiWGFjUEpNTCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770891758), +('wVwr2XlsBJjA7n7zetI2xlLN61THLAxdzRTQBUFN', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiZHBnd3lRbGhYTWVjdFNsOEI0MHBBQ0ZQMGZRR3FHU1daNTRBSzh2QSI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770896075), +('WxkbleraHJTrbMFi8l3NgLm6b1lZXJSu3u3VRDOH', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiUlZSRnNCcmN2UTVWNVdnelRkNDhJendiSURId2NySVhZVjJMTFYzcyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770895058), +('x4bniHw7lcVxL4HF2QVchZtqMCeI58nzJd58VeYk', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiN3dCbzNDZllVeHpGWFBtZWlLckMyeDZCSzltV1k0RWNpN3oxNFBPdyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770893617), +('xBlCaVciNEMab8bOjrCcWMSkrwfspgNIBSVWELuJ', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiWERIRm93c2dYOFoySGFUcWtzYzREWGhGek5JSm1yUHBOM1FZeXJPVyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770889958), +('xfxeWMSMhNi22ZHIxBgduEVmzblm9HmjKZbgcxqK', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiUDZlV2swdVFHa0VKTjJpZ1poWXBrT3E1c3dBMlFqRVllWHhzMEdidyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770894517), +('XlCI1lrTeY1BfVMUSiB8jTGRZbIu7PRmbv3Gh7PH', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiRnlnWkMxRmh3Z1RIaGVXTEpuSzVLa0IyTXJjRHVkSWkyaDBKU0dtMiI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770894817), +('Y2Ne3iwNnn1sA9d8hNZNI7Vu5olufudeHusj2RJd', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoieWVNZkw3dFVlbW5rV2ZYWmhsTUlyNzFyaUhSbElXeVlOQVNnbllyayI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770896558), +('ygU9p3gZHwaH43hPmRbScXJaYwdYHvToKIzr2lgI', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiaHlKcHBLcFU2azVUZXBYemxqNmdOVFJNNzdaTzFJazhiNldDTzVQcCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770897551), +('yoSOtQHf06FXRSe74QUFugJXNzwRX0IJi7zYKMm7', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoibVNqeFNlQW5sb0M5cnA2RjdDN05GamZKVHQ4d3I4MUZDdHV5d2wydyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770889358), +('yYwq5XevpDMyHn0CT1QwwhrlKfa1lbkjq948hZ8S', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoicVdQUXFqMEVWQWxPYlY5blJRZDlncmFtQW5NaThXb1ZyTkllOG4wQyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770894458), +('zAFbdCVZ5OQSxGW3UEPTnR8wDHn4sqvMd6ugk9T1', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiaVN0NG5SZXZPNExTdFJYUTF2cXFmTUVINENQNjVDa2RYOEFBN2ZqQSI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770897758), +('ZKgpRQXsAqj2ayCFhVvNcoElI3CHRbzN8mLC1ZDB', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiNU1HcWNXWEpYMnd1VlE3MzVEM0hVSVVCODdIbWMyajlEdkJQS3VRMyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770896917), +('Zq3toLWErj9cra5NSHllsxz536gYBymo13alO1gr', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiRnN6VGlHOVhJampRR2FFMjFKdko5eDNJMHhYcnlBNHN5OHRjcXJGUiI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770894275), +('Zs0vKBUFZCwKODkfew9bPYHX8qUVsmGJ6fdXy6FZ', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiYTVIQk5uak5JMGxqT1NCNFFRc2YybURYaG9SZjUzTElDZnUwTXVqQiI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770890017), +('zSZAawZZYkeUEMxSc621cjGmqSUEu1Fq6eMmSaQu', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoibU1vbkNZck14V0kxQ2JtRHpGakQzNEZhak1XdmkyVDJQWlAzSHNPcyI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770890558), +('ZtLs4MZDViAkK1KvY4p3k12zfbcFerxaDd8FIMRy', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 13; YDH-028E Build/TQ3C.230805.001.B2; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiajVxbDNjR3NhZHloNzhlckF0Zk9idDRnWmFnSzZlSE02T2RKVDVOcCI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770893675), +('ZUbfueGlLUAWtWWkjnhRVNmdnzd0Qm87gWQrvEHV', NULL, '88.64.208.156', 'Mozilla/5.0 (Linux; Android 9; ZC-339A Build/PI; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Safari/537.36', 'YTozOntzOjY6Il90b2tlbiI7czo0MDoiYWFHb0daem4ya3VTZFFPNjRoUjV2UXZYd2ltTE1KUTdmOTdpSmhUTiI7czo5OiJfcHJldmlvdXMiO2E6Mjp7czozOiJ1cmwiO3M6MzQ6Imh0dHBzOi8vYjJpbi5ldS9hcGkvZGlzcGxheS9jb25maWciO3M6NToicm91dGUiO3M6Mjc6ImdlbmVyYXRlZDo6YjdoeWxRNXVtZFBaZkFvaCI7fXM6NjoiX2ZsYXNoIjthOjI6e3M6Mzoib2xkIjthOjA6e31zOjM6Im5ldyI7YTowOnt9fX0=', 1770890258); + +INSERT INTO `users` (`id`, `partner_id`, `name`, `display_name`, `email`, `email_verified_at`, `password`, `two_factor_secret`, `two_factor_recovery_codes`, `two_factor_confirmed_at`, `remember_token`, `created_at`, `updated_at`, `deleted_at`) VALUES +(1, NULL, 'Kevin Adametz', NULL, 'kevin.adametz@me.com', '2025-11-21 14:29:10', '$2y$12$pw7z0He1cIJ/owZOJWYu8O.d6dh6uIgH1tQeB8EiAS7PE3iwnL7Si', NULL, NULL, NULL, 'aznSzEV51VHUf5GBlkykgRKRpGW8zQaMHdS792uoCXg5zySz8NGI3YxdBwwo', '2025-11-21 14:29:10', '2025-12-19 10:58:54', NULL), +(5, 4, 'Gelöschter Benutzer #5', NULL, 'deleted_5@anonymized.local', '2025-11-21 15:01:16', '$2y$12$UH.TScgahDROtFFDI/vkw.hZvXUrmStdyaDRG85I1kAHBpcQMYZSq', NULL, NULL, NULL, 'ZB4rw542j7Uq2rcvoFz0NLa4ldftZvW5yYqUmGtkvGclZsxma0S5kLC9KWQt', '2025-11-21 15:01:16', '2025-12-18 08:07:32', '2025-12-18 08:07:32'), +(6, 5, 'Gelöschter Benutzer #6', NULL, 'deleted_6@anonymized.local', '2025-12-16 15:05:26', '$2y$12$UD3ivNt9uqv.TcooAf.jQOqeCfhX1aXRABGYvCC7XLznhqrbm681.', NULL, NULL, NULL, 'Lega5Eb5bhmGYVT5z6Fkzf7wuF9GwQLG1VEoerP7yvhz4YfrLIEcibDZxOOv', '2025-12-16 15:05:26', '2025-12-18 08:07:20', '2025-12-18 08:07:20'), +(7, 6, 'Gelöschter Benutzer #7', NULL, 'deleted_7@anonymized.local', '2025-12-16 16:29:03', '$2y$12$DmnePN980lksdVtWhL6HyeSD6sJ3oGoRKq3pqs4UV0ADpWUYKQq3e', NULL, NULL, NULL, NULL, '2025-12-16 16:29:03', '2025-12-18 08:07:16', '2025-12-18 08:07:16'), +(10, 9, 'Gelöschter Benutzer #10', NULL, 'deleted_10@anonymized.local', '2025-12-17 12:57:18', '$2y$12$q0O2UymzIdLS3zr.rvMib.QBoeo7tPg3K9YM9a5bw5zx0gi9OHbta', NULL, NULL, NULL, NULL, '2025-12-17 12:38:20', '2025-12-18 08:07:01', '2025-12-18 08:07:01'), +(11, 10, 'Herr Steller', 'Herr Steller', 'info1@adametz.media', '2025-12-18 08:11:52', '$2y$12$HpQQMRo6dtQHsJC0ZNpBxO6pKRUpAEZnIXBEYdF4i5TC4j0P6alUa', NULL, NULL, NULL, NULL, '2025-12-18 08:11:34', '2025-12-18 17:51:08', NULL), +(12, 11, 'Franz Hatstil', NULL, 'info3@adametz.media', '2025-12-18 09:46:11', '$2y$12$2EeN31d8rapcACWOsVWON.j6AeTJ1puR3VNWUE3e.wDsklwLT8jom', NULL, NULL, NULL, NULL, '2025-12-18 09:46:11', '2025-12-18 17:55:44', NULL), +(13, 12, 'Steffi Willmöbel', NULL, 'info4@adametz.media', '2025-12-18 10:23:38', '$2y$12$VCPjieqeb9aTERmnVd6JJ.koqOGGokq0k2143u1e6FjKrHDKrER2y', NULL, NULL, NULL, NULL, '2025-12-18 10:23:23', '2025-12-18 17:55:13', NULL), +(14, 13, 'Immobilien Schulz', 'Immobilien Schulz', 'info5@adametz.media', '2025-12-18 10:31:14', '$2y$12$i4sN9dx9XGMgTyh3dG8ice38bpqqcd4OQ9ziw.uxUI7O1iK4e1c1m', NULL, NULL, NULL, NULL, '2025-12-18 10:30:41', '2025-12-18 17:52:20', NULL), +(15, 14, 'Händler Max', 'Händler Max', 'info6@adametz.media', '2025-12-18 10:37:36', '$2y$12$9FibRJHcYDXZ5Uvp.yL3pe21eEUXIAfsFUjKsNiSb.wYBOJnI1him', NULL, NULL, NULL, NULL, '2025-12-18 10:37:29', '2025-12-18 17:34:16', NULL), +(16, 15, 'Hersteller Moritz', 'Hersteller Moritz', 'kevin.adametz.media@gmail.com', '2025-12-18 10:46:20', '$2y$12$UXIpAVmxUAKerqhdXQt.gebMM/NEbSi713Fq69XsOpmnbbLMHIW/a', NULL, NULL, NULL, NULL, '2025-12-18 10:46:07', '2025-12-18 17:34:32', NULL), +(17, 16, 'Marcel Scheibe', NULL, 'marcel.scheibe@bridges2america.com', '2025-12-19 11:20:57', '$2y$12$pw9PjzTmlWnC1wJ.ioD69OhcV8kxjQTDyKUk71rnP8kiweViEpeRe', NULL, NULL, NULL, 'pHLCoFTBHJet4D5aeoBGl20lNqOuXW1z4g5J7uiaSMFQU3jdXmWOAv5nMFH8', '2025-12-19 11:20:57', '2026-01-14 10:35:09', NULL), +(18, 17, 'Marcel Scheibe', 'TEST MS', 'marcel_scheibe@web.de', '2026-01-14 10:47:04', '$2y$12$b1wZQv9bBVet3RwzIU9uaubHmo7TTC/C/ECro.RBUSAy/CRKy/1AC', NULL, NULL, NULL, NULL, '2026-01-14 10:45:37', '2026-01-14 10:47:04', NULL); + + + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; \ No newline at end of file diff --git a/dev/12-01-2026/entwicklungsplan.md b/dev/12-01-2026/entwicklungsplan.md new file mode 100644 index 0000000..47091c0 --- /dev/null +++ b/dev/12-01-2026/entwicklungsplan.md @@ -0,0 +1,983 @@ +# Entwicklungsplan: B2In / Local for Local Marktplatz-Ökosystem + +**Erstellt:** 12.02.2026 +**Letzte Aktualisierung:** 12.02.2026 +**Basis:** konzeption.md (Version 1.1) +**Status:** Phase 1 ✅, Phase 2 ✅, Phase 2.5 Produkt-Bearbeitung ✅, Phase 2.6 Refactoring & UX ✅, Phase 2.7 Admin-Produktverwaltung & Freigabe ✅, Phase 3 Kern ✅ +**Docker** Projekt läuft in Docker, root /var/www/html nutze php artisan ... nicht vendor/bin/sail artisan ... +--- + +## 0. Umsetzungsprotokoll + +### Phase 2.7: Admin-Produktverwaltung, Freigabe-Workflow & Statusaktionen – ABGESCHLOSSEN (13.02.2026) + +Vollstaendige Admin-Produktverwaltung mit tabellarischer Uebersicht, Filtern, Freigabe-Workflow mit Ablehnungsgrund, sowie Archivieren/Verkauft-Aktionen fuer Haendler und Admin. + +**Produkt archivieren / als Verkauft markieren** +- Neue `archiveProduct()` und `markAsSold()` Methoden in `form-standard.blade.php`, `form-teaser.blade.php` und `products/index.blade.php` +- Edit-Formulare: Buttons "Als verkauft markieren" und "Archivieren" links neben Speichern-Button (nur im Edit-Modus) +- Produktliste: Dropdown-Menue (3-Punkte-Menue) je Produkt mit "Als verkauft" und "Archivieren" Optionen +- `wire:confirm` Dialoge fuer Sicherheitsabfrage +- Activity-Log-Eintraege (action: `archived` / `sold`) werden automatisch erstellt +- Produkte im Status Archived/Sold zeigen kein Dropdown mehr + +**Erstellungsdatum in Produktliste** +- Neue Spalte "Erstellt" in `products/index.blade.php` (Format: dd.mm.YYYY) +- Sowohl fuer Haendler als auch Admin sichtbar + +**Admin-Produktuebersicht (komplett neu)** +- `admin/products/index.blade.php` komplett umgebaut: von Card-/Tab-Layout zu tabellarischer Uebersicht +- Statistik-Karten: Gesamt, Zur Freigabe, In Korrektur, Freigegeben (klickbar als Schnellfilter) +- Filter: Suche (Name, Artikelnummer), Status (alle ProductStatus-Werte), Produkttyp, Haendler, Kategorie +- Tabelle: Produkt (mit Bild), Haendler, Kategorie, Status, Kuration (Freigabe-Buttons), Erstellt, Aktionen +- Admin kann alle Produkte bearbeiten – Edit-Link fuehrt zum gleichen Formular wie fuer Haendler +- Suche durchsucht auch `b2in_article_number` und `partner_product_number` + +**Freigabe-Workflow mit Ablehnungsgrund** +- **Freigeben:** Direkt-Button in Kuration-Spalte (Pending → Active + is_curated) +- **Korrektur:** Inline-Formular (orange) mit Pflicht-Textfeld → Status `Correction`, `curation_notes` gespeichert +- **Ablehnung (NEU):** Inline-Formular (rot) mit Pflicht-Textfeld → Status `Archived`, Ablehnungsgrund in `curation_notes` gespeichert +- Activity-Log-Eintrag bei allen drei Aktionen (mit `note` bei Korrektur/Ablehnung) +- `Flux::toast()` Benachrichtigungen statt Flash-Messages + +**Kuration-Hinweis beim Haendler** +- Standard- und Teaser-Edit-Formulare zeigen prominenten Callout wenn `curation_notes` vorhanden: + - Korrektur (Status `correction`): Gelbes Warning-Callout "Korrektur erforderlich" + - Ablehnung (Status `archived`): Rotes Danger-Callout "Produkt abgelehnt" +- Angezeigt oberhalb des Formulars, immer sichtbar + +**Admin Archiv/Verkauft in Admin-Uebersicht** +- Dropdown-Menue analog zur Haendler-Produktliste +- Admin kann alle Produkte archivieren oder als verkauft markieren + +**Erstellte/geaenderte Dateien (5):** + +| Kategorie | Dateien | Details | +|-----------|---------|---------| +| **Volt** (3) | `form-standard.blade.php`, `form-teaser.blade.php`, `products/index.blade.php` | Archive/Sold-Methoden, Kuration-Callout, Erstellungsdatum, Dropdown-Aktionen | +| **Admin-Volt** (1) | `admin/products/index.blade.php` | Komplett umgebaut: Tabelle, Filter, Statistiken, Rejection mit Textfeld | +| **Tests** (1) | `ProductCurationTest.php`, `ProductEditTest.php` | Aktualisiert + erweitert | + +**Neue/aktualisierte Tests (25):** +- ProductCurationTest: 23 Tests (komplett ueberarbeitet fuer neues Tabellen-Layout, Rejection mit Pflicht-Textfeld, Archive/Sold, Filter, Kuration-Notes-Anzeige) +- ProductEditTest: +2 Tests (archiveProduct, markAsSold aus Edit) + +**Tests gesamt: 194 Produkt-Tests, alle bestanden ✅ (533 Assertions)** + +--- + +### Phase 2.6: Refactoring, UX-Verbesserungen & Teaser-Erweiterung – ABGESCHLOSSEN (13.02.2026) + +Umfassende Qualitaetsverbesserung der Produkt-Formulare: Code-Redundanzen eliminiert, UX-Workflow optimiert, Bildsortierung implementiert, Teaser-Produkte mit Produktnummern und korrektem Freigabe-Workflow ausgestattet. + +**Refactoring: Create + Edit zu einem Formular zusammengefuehrt** +- `create.blade.php` + `edit.blade.php` → `form-standard.blade.php` (Standard-Produkte) +- `create-teaser.blade.php` + `edit-teaser.blade.php` → `form-teaser.blade.php` (Teaser-Produkte) +- Steuerung ueber `$isEditing`-Flag in `mount()`: `?Product $product = null` +- Separate `saveNew()` und `saveExisting()` Methoden +- Alte Einzeldateien geloescht + +**UX: Save-Verhalten bei Bearbeitung** +- Bei Edit: Seite bleibt offen statt Redirect zur Produktliste +- Flux Toast-Notification ("Produkt wurde gespeichert" / "zur Freigabe eingereicht") +- Bei Standard-Produkten: aktiver Tab bleibt erhalten (kein Page-Reload) +- `` global im Sidebar-Layout ergaenzt +- Bei Create: weiterhin Redirect zur Produktliste mit Flash-Message + +**Bildsortierung per Drag & Drop** +- Vorhandene Bilder per HTML5 Drag & Drop umsortierbar (Alpine.js, keine externe Dependency) +- Erstes Bild = Standardbild, visuell markiert (blaues "Standard"-Badge + blauer Ring) +- `updateMediaOrder(array $orderedIds)` Methode speichert Reihenfolge in `order_column` +- `existingMedia` wird immer nach `order_column` sortiert geladen +- Drag-Feedback: halbtransparent, blauer Ring am Ziel, Grab-Cursor, Sortier-Icon bei Hover + +**Vorschaubild in Produktliste** +- `products/index.blade.php`: Erstes Bild (nach `order_column`) als 40x40px Thumbnail vor dem Produktnamen +- Platzhalter-Icon (Foto-Symbol) wenn kein Bild vorhanden + +**Teaser-Produkte: Produktnummern & Status-Fix** +- `partnerProductNumber` hinzugefuegt: wird bei Create automatisch generiert (z.B. P003-0001), bei Edit pre-filled +- `b2inArticleNumber` wird beim Speichern automatisch erzeugt (z.B. B2IN-000001) +- Eigene "Produktnummern"-Karte im Formular (B2in-Badge + Partner-Nummer-Feld) +- **Bug-Fix:** Status "aktiv" im UI setzte Status direkt auf Active statt Pending. Jetzt korrekt: UI "aktiv" → DB `pending` (zur Freigabe) +- UI-Text vereinheitlicht: immer "Zur Freigabe einreichen" (nicht mehr "Aktiv – direkt veröffentlichen") +- Freigabe-Workflow-Hinweis erscheint bei Create und Edit + +**Routing-Aenderung:** +``` +products/create/standard → products.form-standard (products.create.standard) +products/create/teaser → products.form-teaser (products.create.teaser) +products/{product}/edit-standard → products.form-standard (products.edit.standard) +products/{product}/edit-teaser → products.form-teaser (products.edit.teaser) +``` + +**Erstellte/geaenderte Dateien (8):** + +| Kategorie | Dateien | Details | +|-----------|---------|---------| +| **Volt** (2) | `form-standard.blade.php`, `form-teaser.blade.php` | Merged Create+Edit, Toast, Bildsortierung, Produktnummern | +| **Views** (2) | `index.blade.php`, `sidebar.blade.php` | Vorschaubild, `` | +| **Routes** (1) | `routes/admin.php` | Neue Routennamen (.standard/.teaser) | +| **Tests** (4) | `StandardProductCreateTest`, `ProductEditTest`, `TeaserProductCreateTest`, `TeaserProductEditTest` | Komponentennamen aktualisiert, 13 neue Tests | + +**Geloeschte Dateien:** `create.blade.php`, `edit.blade.php`, `create-teaser.blade.php`, `edit-teaser.blade.php` + +**Neue Tests (13):** Bildsortierung (5), Teaser-Status pending/draft (2), Teaser B2in-Artikelnummer (1), Teaser Partnernummer create+edit (3), Teaser Partnernummer pre-fill (2) + +**Tests gesamt: 109 Produkt-Tests, alle bestanden ✅** + +--- + +### Phase 2.5: Produkt-Bearbeitung (Standard + Teaser) – ABGESCHLOSSEN (13.02.2026) + +Beide Produkttypen koennen jetzt vollstaendig bearbeitet werden. Standard-Produkte (SmartOrder) nutzen das 8-Tab-Formular, Teaser-Produkte (LocalStock) ein vereinfachtes Einseiten-Formular. Die Produkt-Liste leitet automatisch zur richtigen Edit-Seite weiter. + +Erstellte/geaenderte Dateien (4): +- Volt: resources/views/livewire/products/edit-teaser.blade.php (neu) - Teaser-Edit mit Pre-Fill, Status-Handling, Media-Verwaltung, Activity-Log +- Routes: routes/admin.php - products.edit.teaser Route hinzugefuegt +- Views: resources/views/livewire/products/index.blade.php - Edit-Link basierend auf product_type +- Tests: tests/Feature/TeaserProductEditTest.php (neu) - 24 Tests + +Infrastruktur-Fix: Route-Cache und Test-DB-Migration bereinigt. + +Tests gesamt: 24 Tests (TeaserProductEditTest) + 22 Tests (ProductEditTest), alle bestanden. +Produkt-bezogene Tests gesamt: 133 Tests, alle bestanden. + +--- + +### Phase 2.3b: CSV-Felder Erweiterung (Moebeldatenliste) – ✅ ABGESCHLOSSEN (13.02.2026) + +**Änderung:** Alle fehlenden Felder aus `Moebeldatenliste Stand 4.11.2025.csv` (70 Felder, 13 Sektionen) wurden als DB-Spalten und Formularfelder ergänzt. Von ~30 existierenden Feldern auf ~70 erweitert. Neue Tabelle `product_wood_origins` für EUDR-Compliance. Formular von 5 auf 8 Tabs umgebaut. + +**Erstellte/geänderte Dateien (13):** + +| Kategorie | Dateien | Details | +|-----------|---------|---------| +| **Migrationen** (4) | `add_csv_fields_to_products_table`, `add_csv_fields_to_product_logistics_table`, `add_currency_to_product_variants_table`, `create_product_wood_origins_table` | +21 Spalten products, +4 Spalten product_logistics, +1 Spalte product_variants, neue Tabelle product_wood_origins | +| **Models** (4) | `Product.php`, `ProductLogistics.php`, `ProductVariant.php`, `ProductWoodOrigin.php` (neu) | Fillable, Casts, woodOrigins() Relationship | +| **Factories** (1) | `ProductWoodOriginFactory.php` (neu) | Factory mit Holzarten, Ländern, EUDR-Daten | +| **Volt-Komponenten** (1) | `products/create.blade.php` | 8-Tab-Layout, ~25 neue Properties, Wood-Origins-Repeater, erweiterte Validierung + Save-Logik | +| **Tests** (2) | `StandardProductCreateTest.php` (+12 Tests), `Models/ProductWoodOriginTest.php` (4 Tests, neu) | Material, Logistik, Services, Nachhaltigkeit, EUDR, Scoring, Währung, Validierung | + +**Neue DB-Spalten (26):** products: country_of_origin, main_material, surface_material, cover_material, color_finish, certificates(JSON), assembly_time_min, load_capacity_kg, delivery_type, assembly_service, service_radius_km, warranty_months, production_time_days, visible_from, visible_until, co2_footprint_kg, recycling_percentage, is_regional_production, storage_volume_liters, assembly_effort_score, design_score. product_logistics: packaging_type, packaging_recyclable_percent, is_palletizable, hs_code. product_variants: currency. + +**Neue Tabelle:** `product_wood_origins` (1:n von products) – EUDR-Compliance mit Holzart, Herkunftsland, Region, Erntejahr, Forstbetrieb, Zertifikat, EUDR-ID + +**Pint-Formatierung:** ✅ (keine Korrekturen nötig) + +**Tests gesamt: 33 Tests (StandardProductCreateTest) + 4 Tests (ProductWoodOriginTest), alle bestanden ✅** + +--- + +### Phase 2.3: Standard-Produkt Erstellung (Maske 2) – ✅ ABGESCHLOSSEN (13.02.2026) + +**Änderung:** Das Standard-Produkt-Formular (`create.blade.php`) wurde komplett neu geschrieben – von einer nicht-funktionalen Dummy-Vorlage zu einem voll funktionsfähigen class-based Volt Component mit 5 Tabs. + +**Erstellte/geänderte Dateien (3):** + +| Kategorie | Dateien | Details | +|-----------|---------|---------| +| **Volt-Komponenten** (1) | `resources/views/livewire/products/create.blade.php` | Komplett neugeschrieben: 5-Tab-Layout (Basis, Bilder, Physisch, Kommerziell, Zuordnung), erstellt Product + ProductVariant + ProductLogistics + Media, inline Validierung, Preise EUR→Cents | +| **Migrationen** (1) | `database/migrations/2026_02_13_..._make_tax_rate_id_nullable_on_product_variants_table.php` | `tax_rate_id` nullable gemacht (war NOT NULL ohne Default, tax_rates Tabelle leer) | +| **Tests** (1) | `tests/Feature/StandardProductCreateTest.php` | 17 neue Tests: Zugriff (3), Happy Path (2), Physisch+Logistik (1), Kommerziell (1), SEO (1), Validierung (8), Preistypen (1) | + +**Pint-Formatierung:** ✅ (1 Fix: unused import in StandardProductCreateTest) + +**Tests gesamt: 17 Tests (StandardProductCreateTest), alle bestanden ✅** + +--- + +### Phase 2 Ergänzung: Beide Rollen → Beide Produkttypen (13.02.2026) + +**Änderung:** Sowohl Händler (Retailer) als auch Hersteller (Manufacturer) können nun BEIDE Produkttypen anlegen (Teaser + Standard). Vorher war die Erstellmaske rollenbasiert auf einen Typ beschränkt. + +**Erstellte/geänderte Dateien (3):** + +| Kategorie | Dateien | Details | +|-----------|---------|---------| +| **Volt-Komponenten** (1) | `resources/views/livewire/products/index.blade.php` | Zwei Buttons ("Neues Teaser-Produkt" + "Neues Standard-Produkt"), neuer `productTypeFilter` State + Query-Filter, Produkttyp-Dropdown im Filterbereich | +| **Tests** (2) | `tests/Feature/LocalFeedTest.php`, `tests/Feature/TeaserProductCreateTest.php` | +6 neue Tests: Produkttyp-Filter (3), Zwei-Button-Anzeige (3), Manufacturer Teaser-Zugriff (1), Manufacturer Teaser-Erstellung (1). Fix: `mainImage` → `mainImages` in bestehenden Tests | + +**Pint-Formatierung:** ✅ (keine Korrekturen nötig) + +**Tests gesamt: 21 Tests (LocalFeedTest + TeaserProductCreateTest), alle bestanden ✅** + +--- + +### Phase 3: Kunden-Frontend & Local Feed – ✅ KERN ABGESCHLOSSEN (12.02.2026) + +**Erstellte/geänderte Dateien (8):** + +| Kategorie | Dateien | Details | +|-----------|---------|---------| +| **Actions** (1) | `app/Actions/Fortify/CreateNewUser.php` | `origin` aus `config('app.theme')` via `UserOrigin::tryFrom()`, `hub_id` aus Input | +| **Models** (1) | `app/Models/Partner.php` | `PartnerType` Cast hinzugefügt (`type` Feld) | +| **Volt-Komponenten** (2) | `resources/views/livewire/products/index.blade.php`, `resources/views/livewire/partner/profile.blade.php` | products/index: echte DB-Queries, Rollen-basierte Filterung (Admin/Customer/Partner). partner/profile: neue öffentliche Profilseite | +| **Routes** (1) | `routes/admin.php` | `partner.profile` Route hinzugefügt | +| **Tests** (3) | `CreateNewUserOriginTest`, `LocalFeedTest`, `PartnerProfilePageTest` | **17 Tests – alle bestanden** | + +**Wichtige Korrekturen (12.02.2026):** +- `products` Tabelle hat keine `sku` Spalte – aus Suche und Tabellenansicht entfernt +- Partner `type` war nicht auf `PartnerType` Enum gecastet – `PartnerType::class` Cast zu `Partner.php` hinzugefügt +- `Volt::test()` wirft `ModelNotFoundException` direkt (kein HTTP 404) – 404-Tests nutzen `toThrow()` + +**Pint-Formatierung:** ✅ (5 Dateien: Partner.php, CreateNewUser.php, 3 Test-Dateien) + +**Tests Phase 3 gesamt: 17 Tests, alle bestanden ✅** + +--- + +### Phase 2: Händler-Profil & Produkt-Management – ✅ KERN ABGESCHLOSSEN (12.02.2026) + +**Erstellte/geänderte Dateien (11):** + +| Kategorie | Dateien | Details | +|-----------|---------|---------| +| **Policies** (2) | `app/Policies/PartnerPolicy.php`, `ProductPolicy.php` | PartnerPolicy: viewAny/view/create/update/delete/curateProducts. ProductPolicy: viewAny/view/create/update/delete/curate | +| **Volt-Komponenten** (4) | `admin/partners/index.blade.php`, `admin/partners/edit.blade.php`, `products/create-teaser.blade.php`, `admin/products/index.blade.php` | Partner-Übersicht, Profil-Edit (Story+Öffnungszeiten), Teaser-Erstellen (Typ A, Preislogik via Enum), Kuration-Queue | +| **Routes** (1) | `routes/admin.php` | 4 neue Routen: `admin.partners.index`, `admin.partners.edit`, `products.create.teaser`, `admin.products.index` | +| **Seeders** (1) | `database/seeders/RoleSeeder.php` | `curate products` Permission hinzugefügt | +| **Tests** (5) | `PartnerPolicyTest`, `ProductPolicyTest`, `PartnerProfileUpdateTest`, `TeaserProductCreateTest`, `ProductCurationTest` | **48 Tests – alle bestanden** | + +**Enums erweitert (12.02.2026):** +- `app/Enums/ProductType.php`: `requiresTicket(): bool`, `allowedPriceTypes(): array` +- `tests/Unit/Enums/ProductTypeTest.php`: 4 neue Tests für Typ A/B Geschäftsregeln + +**Pint-Formatierung:** ✅ (keine Style-Korrekturen nötig) + +**Wichtige Erkenntnisse:** +- Volt `actingAs()` ist `void` – für Tests immer `$this->actingAs($user)` VOR `Volt::test()` aufrufen +- Für HTTP-Tests mit `partner.setup` Middleware: `Partner::factory()->setupCompleted()->create()` verwenden +- `Storage::fake('public')` + `UploadedFile::fake()->image()` für File-Upload-Tests + +--- + +### Phase 1: DB & Core-Vervollständigung – ✅ ABGESCHLOSSEN (12.02.2026) + +**Erstellte Dateien (29 Dateien):** + +| Kategorie | Dateien | Details | +|-----------|---------|---------| +| **Enums** (6) | `app/Enums/ProductType.php`, `ProductStatus.php`, `PriceType.php`, `PartnerType.php`, `UserOrigin.php`, `CurationStatus.php` | Alle mit `label()`, Status-Enums zusätzlich mit `color()`, UserOrigin mit `tonality()` | +| **Neue Models** (4) | `app/Models/Product.php`, `Media.php`, `Setting.php`, `ProductLogistics.php` | Product: 6 Relationships, 6 Scopes. Media: Polymorphe Beziehung. Setting: Key-Value mit getValue/setValue Helpers | +| **Aktualisierte Models** (11) | `Attribute`, `AttributeValue`, `Category`, `Collection`, `Tag`, `ProductVariant`, `ShippingClass`, `TaxRate`, `Hub`, `Partner`, `User` | Alle Sparse Models mit Fillable, Casts, Relationships ergänzt. Partner: +products(), +media(). User: +hub(), +origin Cast | +| **Migrationen** (4) | `2026_02_12_000001` bis `000004` | Users (+origin, +hub_id), Products (+product_type, +is_curated, +hub_id, +price_type, +is_available, +curated_at, +curated_by), Partners (+story_text, +opening_hours, +specialties, +founded_year), Settings-Tabelle | +| **Factories** (5) | `ProductFactory`, `MediaFactory`, `PartnerFactory`, `HubFactory`, `BrandFactory` | Alle mit sinnvollen States (localStock, smartOrder, active, retailer, manufacturer, etc.) | +| **Seeders** (1) | `SettingsSeeder` | 6 Default-Settings: Ticket-Gültigkeit, Beleg-Deadline, Ticket-Limits, Provisions-Defaults | +| **Config** (1) | `config/domains.php` | +local4local Domain (local4local.test / local4local.online) | +| **Tests** (7 Dateien) | 3× Unit (Enums), 4× Feature (Product, Setting, User, Partner) | **47 Tests, 84 Assertions – alle bestanden** | + +**Migrationen auf Produktions-DB ausgeführt:** ✅ +**Settings-Seeder ausgeführt:** ✅ (6 Settings angelegt) +**Pint-Formatierung:** ✅ (7 Style-Issues automatisch korrigiert) + +**Vorbestehende Test-Failures – BEHOBEN (12.02.2026):** +Alle 16 vorbestehenden Fehler plus 15 weitere Fehler (31 insgesamt) wurden systematisch behoben. Die gesamte Test-Suite besteht jetzt mit **139 Tests, 269 Assertions – alle bestanden** ✅ + +Behobene Probleme: +- `phpunit.xml`: `BASIC_AUTH_ENABLED=false` hinzugefügt (BasicAuth-Middleware blockierte alle HTTP-Tests) +- `admin/partners/index.blade.php`: Flux UI v2 Shorthand `` → `` korrigiert +- `config/livewire.php`: `component_layout` Key hinzugefügt (Livewire 4 Layout-Resolution) +- Auth-Tests: Portal-Domain (`portal.b2in.test`) für alle HTTP-Requests +- `RegistrationTest`: Komplett neu geschrieben für code-basierte Registrierung über `/reg/{role}` +- `PasswordResetTest`: `CustomResetPasswordNotification` statt Standard-`ResetPassword` +- `EmailVerificationTest`: Redirect zu `partner.setup.wizard` statt `dashboard` +- `ProfileUpdateTest`: SoftDeletes-Assertion (`->trashed()`) und Layout-Redirect +- `DashboardTest`: `RefreshDatabase` Trait hinzugefügt +- `ProductCurationTest`, `PartnerProfileUpdateTest`, `TeaserProductCreateTest`: `actingAs()` Chaining korrigiert (Volt `actingAs()` ist `void`) +- `TeaserProductCreateTest`: `mainImage` Upload, `setupCompleted()` Factory-State, `CategoryFactory` erstellt +- `ProductCurationTest`: Authorization-Test angepasst (Component authorize in `with()`) +- `Category` Model: `HasFactory` Trait hinzugefügt + +--- + +## 1. Validierung: IST-Zustand vs. Konzeption + +### ✅ Bereits implementiert und konzeptkonform + +| Bereich | Status | Details | +|---------|--------|---------| +| **Hub-System** | ✅ Fertig | `hubs` + `hub_locations` Tabellen, Hub Model mit Relationships | +| **Partner-System** | ✅ Basis fertig | `partners` Tabelle mit `hub_id`, `type`, Provisionsfelder, Parent/Child-Beziehungen | +| **User-System** | ✅ Basis fertig | Users mit `partner_id`, SoftDeletes, Spatie Roles | +| **Rollen & Permissions** | ✅ Fertig | Customer, Estate-Agent, Retailer, Manufacturer, Admin, Super-Admin (via Spatie) | +| **Registrierungs-Codes** | ✅ Fertig | `registration_codes` mit `broker_partner_id` für Makler→Kunden Attribution | +| **Partner-Invitations** | ✅ Fertig | `partner_invitations` mit Token, Expiry, Status | +| **Multi-Domain-Routing** | ✅ Fertig | ThemeMiddleware, ThemeServiceProvider, `config/domains.php` (5 Domains) | +| **Auth-System** | ✅ Fertig | Fortify + Sanctum, Login, Register, Passwort-Reset, Email-Verifizierung, 2FA | +| **Admin-Portal** | ✅ Basis fertig | Dashboard, User-Management, Partner-Management, Hub-Management, CMS | +| **Partner-Setup-Wizard** | ✅ Fertig | Setup-Workflow für neue Partner nach Registrierung | +| **Produkt-DB-Struktur** | ✅ Tabellen vorhanden | `products`, `product_variants`, `categories`, `tags`, `brands`, `collections`, `attributes`, `media` | + +### ⚠️ Teilweise implementiert – Erweiterung nötig + +| Bereich | Was fehlt | Konzept-Referenz | +|---------|-----------|-----------------| +| **Product Model** | Migration existiert, aber **kein `App\Models\Product`** – Model muss erstellt werden | Abschnitt 2: Produkt-Modul | +| **Media Model** | Migration existiert, aber **kein `App\Models\Media`** – Model muss erstellt werden | Für Produkt-Bilder, Partner-Galerie | +| **Partner-Profil** | Basis-Felder vorhanden, aber es fehlen: Team-Fotos, Showroom-Galerie, Story-Text, Öffnungszeiten | Abschnitt 3: "Faces" Profile | +| **Produkt-Upload UI** | `livewire/products/create` existiert als Blade, aber unvollständig | Abschnitt 2: Händler-Upload | +| **Produkt-Feed** | `livewire/products/index` existiert, aber keine Hub-basierte Filterung | Abschnitt 5: Local Feed | +| **Sparse Models** | `Attribute`, `AttributeValue`, `Category`, `Collection`, `Tag`, `ProductVariant`, `ShippingClass`, `TaxRate` haben keine Relationships definiert | Allgemein | + +### ❌ Nicht implementiert – Muss gebaut werden + +| Bereich | Beschreibung | Konzept-Referenz | +|---------|-------------|-----------------| +| **User `origin` Feld** | Herkunft des Kunden (`style2own` / `stileigentum`) für Theme-Steuerung | Abschnitt 1: Core-Modul | +| **User `hub_id` Feld** | Direkte Hub-Zuordnung für Kunden (nicht nur indirekt über Partner) | Abschnitt 1: Hub-Logik | +| **`product_type` Feld** | Unterscheidung `local_stock` (Säule A) vs. `smart_order` (Säule B) | Abschnitt 2: Produkt-Modul | +| **`is_curated` Feld** | Admin-Freigabe-Flag für Produkt-Sichtbarkeit | Abschnitt 2: Produkt-Modul | +| **Ticket-System** | Komplett fehlend: Tickets, QR-Codes, Voucher-Generierung | Abschnitt 3A: Ticket-System | +| **Transaction/Receipt** | Beleg-Upload, State Machine (pending→confirmed→paid→distributed) | Abschnitt 3B: Clearing-System | +| **Wallet/Ledger** | Cashback-Wallets, Provisions-Split, Kassenbuch | Abschnitt 3C: Wallet-Logik | +| **Kunden-Dashboard** | "Mein Zuhause" – emotionaler Feed mit Produkten aus dem eigenen Hub | Abschnitt 4: User Journey | +| **Setup-Buchung** | Service-Store für Händler (z.B. Setup-Paket 399€) | Abschnitt 4: Partner-Modul | +| **Enums** | Keine PHP Enums vorhanden (ProductType, TransactionStatus, TicketStatus, etc.) | Best Practice | +| **Form Requests** | Keine Form Request Klassen vorhanden | Best Practice | +| **Policies** | Keine Authorization Policies vorhanden | Best Practice | + +--- + +## 2. Entwicklungsphasen + +### Phase 1: Datenbank & Core-Vervollständigung ✅ ABGESCHLOSSEN +**Geschätzter Aufwand:** 2-3 Tage | **Tatsächlich:** 1 Tag (12.02.2026) +**Priorität:** HÖCHSTE – Basis für alle weiteren Phasen + +#### 1.1 Fehlende Models erstellen +- [ ] `App\Models\Product` erstellen mit allen Relationships: + - `belongsTo(Partner)`, `belongsTo(Brand)`, `belongsTo(Collection)` + - `belongsToMany(Category)`, `belongsToMany(Tag)` + - `hasMany(ProductVariant)`, `morphMany(Media)` + - `hasMany(RelatedProduct)` + - Scopes: `active()`, `curated()`, `localStock()`, `smartOrder()`, `inHub($hubId)` +- [ ] `App\Models\Media` erstellen (polymorphe Beziehung) +- [ ] Fehlende Relationships in Sparse Models ergänzen: + - `Attribute` → `hasMany(AttributeValue)` + - `AttributeValue` → `belongsTo(Attribute)` + - `Category` → self-referencing parent/children, `belongsToMany(Product)` + - `Tag` → `belongsToMany(Product)` + - `ProductVariant` → `belongsTo(Product)`, `belongsToMany(AttributeValue)` + - etc. +- [ ] `Partner::products()` Relationship aktivieren (aktuell auskommentiert) + +#### 1.2 Migrationen: User-Erweiterung +``` +Migration: add_origin_and_hub_id_to_users_table +``` +- [ ] `origin` (nullable string) – `style2own` | `stileigentum` | null +- [ ] `hub_id` (nullable FK → hubs) – direkte Hub-Zuordnung für Kunden +- [ ] `broker_partner_id` (nullable FK → partners) – direkte Makler-Attribution (Alternative zu indirekter Verknüpfung über Partner-Hierarchie; **Entscheidung nötig**: reicht `partner.parent_partner_id` oder braucht User direkt ein Feld?) + +> **Offene Frage 1:** Soll der Kunde einen eigenen Partner-Eintrag bekommen (so wie jetzt über `partner_id` → Partner mit `parent_partner_id`) oder reicht eine direkte `hub_id` + `broker_partner_id` auf dem User? Die aktuelle Architektur erstellt für jeden Kunden einen Partner-Eintrag. Das könnte für das Wallet/Ledger System nützlich sein (jeder Partner hat eigene Provision-Settings). **Empfehlung:** Beibehalten, aber `hub_id` und `origin` direkt auf User setzen für schnelle Queries. + +#### 1.3 Migrationen: Produkt-Erweiterung +``` +Migration: add_marketplace_fields_to_products_table +``` +- [ ] `product_type` (string, default: `local_stock`) – `local_stock` | `smart_order` +- [ ] `is_curated` (boolean, default: false) – Admin-Freigabe +- [ ] `curated_at` (nullable datetime) – Zeitpunkt der Freigabe +- [ ] `curated_by` (nullable FK → users) – Wer hat freigegeben +- [ ] `hub_id` (nullable FK → hubs) – Direkte Hub-Zuordnung (ergänzend zu Partner→Hub) +- [ ] `price_type` (string) – `fixed` | `from_price` | `on_request` (Preisanzeige-Logik) +- [ ] `price_display_text` (nullable string) – z.B. "Ab 2.500 €" Freitext +- [ ] `is_available` (boolean, default: true) – Verfügbar/Verkauft Toggle für Händler + +#### 1.4 Migrationen: Partner-Profil Erweiterung +``` +Migration: add_profile_fields_to_partners_table +``` +- [ ] `story_text` (nullable text) – "Seit 1950 in Herford..." +- [ ] `opening_hours` (nullable JSON) – Öffnungszeiten strukturiert +- [ ] `specialties` (nullable JSON) – Fachgebiete/Spezialisierungen +- [ ] `founded_year` (nullable integer) – Gründungsjahr + +> **Hinweis:** Team-Fotos und Showroom-Galerie werden über die polymorphe `media` Tabelle abgebildet (type: `team_photo`, `showroom`, `gallery`). + +#### 1.5 Migration: Settings-Tabelle +``` +Migration: create_settings_table +``` +- [ ] `id` +- [ ] `group` (string) – z.B. `tickets`, `marketplace`, `commissions` +- [ ] `key` (string, unique innerhalb group) +- [ ] `value` (text, nullable) +- [ ] `type` (string) – `string` | `integer` | `boolean` | `json` +- [ ] `description` (nullable text) – Beschreibung für Admin-UI +- [ ] `timestamps` + +Default-Werte per Seeder: +- `tickets.validity_days` = 30 +- `tickets.receipt_upload_deadline_days` = 30 +- `tickets.max_per_merchant_per_customer` = 3 +- `tickets.max_merchants_per_customer` = 4 +- `commissions.default_broker_rate` = 0 (individuell) +- `commissions.default_cashback_rate` = 0 (individuell) + +#### 1.6 Domain-Ergänzung: local4local +- [ ] `config/domains.php` um `local4local` Domain erweitern: + - Produktion: `local4local.online` + - Entwicklung: `local4local.test` + - Theme, Farben, Fonts definieren +- [ ] `.env` Variablen: `DOMAIN_LOCAL4LOCAL=local4local.test` +- [ ] Routing in `routes/domains.php` einbinden + +#### 1.7 Enums erstellen +- [ ] `App\Enums\ProductType` – `LocalStock`, `SmartOrder` +- [ ] `App\Enums\ProductStatus` – `Draft`, `Active`, `Archived`, `Sold` +- [ ] `App\Enums\PriceType` – `Fixed`, `FromPrice`, `OnRequest` +- [ ] `App\Enums\PartnerType` – `Retailer`, `Manufacturer`, `EstateAgent` +- [ ] `App\Enums\UserOrigin` – `Style2Own`, `StilEigentum` +- [ ] `App\Enums\CurationStatus` – `Pending`, `Approved`, `Rejected` + +#### 1.8 Factories & Seeders +- [ ] `ProductFactory` erstellen +- [ ] `MediaFactory` erstellen +- [ ] `SettingFactory` erstellen +- [ ] `PartnerFactory` erweitern (fehlende Felder) +- [ ] `ProductSeeder` für Testdaten +- [ ] `SettingsSeeder` für Default-Werte +- [ ] `HubSeeder` erweitern (wenn nötig) + +#### 1.9 Tests Phase 1 +- [ ] Unit-Tests für alle neuen Models und Relationships +- [ ] Unit-Tests für Enums +- [ ] Feature-Tests für Migrations (Datenintegrität) + +--- + +### Phase 2: Händler-Profil & Produkt-Management – ✅ KERN ABGESCHLOSSEN (12.02.2026) +**Geschätzter Aufwand:** 5-6 Tage | **Tatsächlich:** 1 Tag (12.02.2026) +**Priorität:** HOCH – Content-Erstellung ermöglichen + +#### 2.0 Architektur-Entscheidung: EINE Tabelle, ZWEI Masken + +> **Entscheidung:** Beide Produkttypen nutzen dieselbe `products` Tabelle. Das Feld `product_type` (`local_stock` | `smart_order`) bestimmt, welche Eingabemaske und Validierung angewendet wird. + +**Begründung:** +- Beides sind Produkte mit identischem Kern (Name, Beschreibung, Preis, Bilder, Partner, Hub, Kategorie) +- Der Local Feed zeigt beide Typen gemischt an – eine Tabelle ermöglicht einfache Queries ohne UNION +- Das Teaser-Produkt ist ein "leichtes" Produkt: die Felder, die es nicht braucht, bleiben `null` +- Ein Model, ein Satz Relationships, ein Satz Scopes – kein doppelter Code +- Filterung per Scope: `Product::localStock()`, `Product::smartOrder()` + +--- + +#### Kundenfeedback: Typ A vs. Typ B (12.02.2026) + +> **Implementiert in `app/Enums/ProductType.php`:** +> - `requiresTicket(): bool` → `LocalStock = true`, `SmartOrder = false` +> - `allowedPriceTypes(): array` → `LocalStock = [FromPrice, OnRequest]`, `SmartOrder = [Fixed, FromPrice, OnRequest]` + +**Typ A – Teaser-Produkte** (`product_type = 'local_stock'`) + +Komplex → Beratung → Laden → **Ticket zwingend erforderlich** + +- Aufwendige Konfiguration (Maße, Module, Materialien) – Abschluss nur im Laden +- Online nur Beispiele, Referenzen, grobe Preisindikationen +- Typisch: Küchenstudios, maßgefertigte Möbel (Cabinet), Einbausysteme +- **Preistyp:** Nur `from_price` oder `on_request` – Festpreis online nicht erlaubt +- **Ticket:** zwingend (`ProductType::LocalStock->requiresTicket() === true`) + +**Typ B – Standard-Produkte** (`product_type = 'smart_order'`) + +Einfach → verständlich → skalierbar → **Ticket optional / Direktkauf möglich** + +- Klare Varianten, vollständig online konfigurierbar, ca. 99 % des Sortiments +- **Preistyp:** Alle erlaubt (`fixed`, `from_price`, `on_request`) +- **Ticket:** optional – Direktkauf online möglich oder Ticket + Ladenbesuch + +--- + +**Maske 1: Typ A – Teaser-Produkt / Local Stock (Händler)** +Ziel: Extrem vereinfacht, handy-optimiert. Kein komplexes Warenwirtschafts-Monster. + +| Feld | DB-Spalte | Pflicht | Beschreibung | +|------|-----------|---------|-------------| +| Fotos | → `media` (morph) | Ja (min. 1) | 1 Hauptbild + optional 2 Galerie-Bilder | +| Titel | `name` | Ja | Produktname | +| Kurzbeschreibung | `description_short` | Ja | Max. 180 Zeichen | +| Preistyp | `price_type` | Ja | Nur `from_price` oder `on_request` (via `allowedPriceTypes()`) | +| Preisangabe | `price_display_text` | Cond. | Pflicht wenn `from_price` (z.B. "Ab 2.500 €") | +| Kategorie | → `category_product` (Pivot) | Ja | Dropdown-Auswahl | +| Status | `status` | Ja | Verfügbar / Verkauft | + +Automatisch gesetzt: `product_type = 'local_stock'`, `hub_id` = Hub des Händlers, `partner_id` = Partner des Users. +`PriceType::Fixed` für Maske 1 **nicht erlaubt** – Validierung via `ProductType::LocalStock->allowedPriceTypes()`. + +**Maske 2: Typ B – Konfigurations-Produkt / Smart Order (Hersteller)** +Das bestehende 6-Tab-Formular (`livewire/products/create.blade.php`) mit 13 Sektionen: +Basis → Bilder → Physisch → Material & Herkunft → Kommerziell → Zuordnung & Verwaltung + +Automatisch gesetzt: `product_type = 'smart_order'`, `partner_id` = Partner des Users. +Hub-Zuordnung: Manuell wählbar (Hersteller können in mehreren Hubs aktiv sein). +Alle Preistypen erlaubt; Standard-Varianten mit Festpreis möglich. + +**Routing-Logik:** ✅ AKTUALISIERT (13.02.2026) +``` +// Beide Rollen können BEIDE Produkttypen anlegen. +// Die Produktliste zeigt zwei Buttons: +// "Neues Teaser-Produkt" → Maske 1 (Typ A: Teaser, vereinfacht, Ticket zwingend) +// "Neues Standard-Produkt" → Maske 2 (Typ B: Konfiguration, komplex, Ticket optional) +// Sichtbar für: Retailer, Manufacturer, Admin, Super-Admin +// Zusätzlich: Produkttyp-Filter in der Produktliste (Teaser / Standard / Alle) +``` + +#### 2.1 Partner-Profil Erweiterung (Admin-Portal) – ✅ Kern implementiert +- [x] Partner-Profil Formular erweitern: + - [x] Story-Text Editor (``) + - [x] Öffnungszeiten-Eingabe (strukturiertes JSON-Formular, 7 Wochentage) + - [ ] Team-Fotos Upload (mehrere Bilder via ``) – ausstehend + - [ ] Showroom-Galerie Upload – ausstehend +- [ ] Form Request: `UpdatePartnerProfileRequest` – inline Validierung verwendet +- [x] Policy: `PartnerPolicy` (viewAny, view, update, delete, curateProducts) +- [x] Admin Partner-Übersicht: `admin/partners/index.blade.php` (Suche, Filter, Tabelle) + +#### 2.2 Produkt-CRUD: Maske 1 – Teaser / Local Stock (Händler) – ✅ Implementiert +- [x] Volt-Komponente: `livewire/products/form-teaser.blade.php` (merged Create + Edit) + - [x] Einzelseiten-Formular (KEIN Tab-Layout) + - [x] Bild-Upload via `WithFileUploads` + Drag & Drop Bildsortierung + - [x] Felder: Foto, Titel, Kurzbeschreibung, Preistyp (nur FromPrice/OnRequest), Preistext, Kategorie, Status, Produktnummer + - [x] Produktnummern: Partner-Produktnummer (auto-generiert) + B2in-Artikelnummer (auto bei Save) + - [x] Automatisch: `product_type = 'local_stock'`, `hub_id` vom Händler-Hub, `partner_id` + - [x] Preistyp-Validierung via `ProductType::LocalStock->allowedPriceTypes()` (kein Festpreis) + - [x] Freigabe-Workflow: UI "aktiv" → DB `pending` (korrekt zur Freigabe, nicht direkt Active) +- [x] Produkt-Liste: Zwei Buttons ("Neues Teaser-Produkt" + "Neues Standard-Produkt") für alle Partner-Rollen +- [x] Produkt-Liste: Produkttyp-Filter (Teaser / Standard / Alle) +- [x] Produkt-Liste: Vorschaubild (erstes Bild nach order_column) vor Produktname +- [x] Produkt bearbeiten: Pre-Fill, Status-Handling, Media-Verwaltung, Activity-Log, Toast-Notification +- [x] Bildsortierung: Drag & Drop, erstes Bild = Standardbild, order_column in DB +- [x] Produkt archivieren / als "Verkauft" markieren – in Edit-Formular und Produktliste (Dropdown-Menue) +- [ ] Form Request: `StoreTeaserProductRequest` – inline Validierung verwendet +- [x] Policy: `ProductPolicy` (viewAny, view, create, update, delete, curate) + +#### 2.3 Produkt-CRUD: Maske 2 – Standard / Smart Order – ✅ Implementiert (13.02.2026) +- [x] Volt-Komponente: `livewire/products/form-standard.blade.php` (merged Create + Edit) + - [x] 8-Tab-Layout: Basis, Bilder, Maße & Material, Verpackung & Versand, Kommerziell, Service & Garantie, Nachhaltigkeit, Zuordnung + - [x] Erstellt Product + Master-Variante (ProductVariant) + ProductLogistics + Media + - [x] Preis-Typ Logik: Festpreis / Ab-Preis / Preis auf Anfrage (alle 3 für SmartOrder erlaubt) + - [x] Automatisch: `product_type = 'smart_order'`, Hub-Fallback auf Partner-Hub + - [x] File-Uploads: Mehrere Bilder (max 10, JPG/PNG, max 10 MB) + Drag & Drop Bildsortierung + - [x] Preise in EUR eingeben → automatische Umrechnung in Cents bei Speicherung + - [x] Alle optionalen Felder: MPN, EAN/GTIN, UVP, EK, Lagerstatus, Lieferzeit, SEO-Metadaten, Maße, Verpackung + - [x] Validierung inline (kein separater Form Request – konsistent mit Teaser-Formular) + - [x] Authorization: ProductPolicy + Rollenprüfung (Retailer, Manufacturer, Admin, Super-Admin) + - [x] Bei Edit: Toast-Notification, aktiver Tab bleibt erhalten, kein Redirect +- [x] Migration: `tax_rate_id` auf `product_variants` nullable gemacht (war NOT NULL ohne Default) + +#### 2.4 Admin: Produkt-Kuration (Approval Queue) – ✅ Vollstaendig implementiert +- [x] Admin-View: `admin/products/index.blade.php` – Tabelle mit Filtern (Status, Typ, Haendler, Kategorie, Suche) +- [x] Statistik-Karten: Gesamt, Zur Freigabe, In Korrektur, Freigegeben (klickbar als Schnellfilter) +- [x] Approve Action: `is_curated=true`, `curated_at`, `curated_by` + Flux::toast() +- [x] Correction Action: Inline-Formular mit Pflicht-Textfeld → `curation_notes` +- [x] Reject Action: Inline-Formular mit Pflicht-Ablehnungsgrund → `curation_notes` + `status=Archived` +- [x] Autorisierung via `ProductPolicy::curate` (`curate products` Permission) +- [x] Admin kann alle Produkte bearbeiten (gleiche Edit-Formulare wie Haendler) +- [x] Admin kann Produkte archivieren / als verkauft markieren (Dropdown-Menue) +- [x] Erstellungsdatum in Tabelle sichtbar +- [x] Kuration-Hinweis beim Haendler: Callout im Edit-Formular (Korrektur gelb, Ablehnung rot) +- [ ] Notification an Händler/Hersteller bei Freigabe/Ablehnung – ausstehend +- [ ] Admin kann `product_type` bei Bedarf nachträglich ändern – ausstehend + +#### 2.5 Tests Phase 2 – ✅ Kern abgeschlossen +- [x] Feature-Tests: `PartnerPolicyTest` (10 Tests) – viewAny, view, update, curateProducts +- [x] Feature-Tests: `ProductPolicyTest` (14 Tests) – alle Policy-Methoden +- [x] Feature-Tests: `PartnerProfileUpdateTest` (8 Tests) – Profil-Update, Öffnungszeiten, Validierung +- [x] Feature-Tests: `TeaserProductCreateTest` (18 Tests) – Happy Path, Preistyp-Validierung, Autorisierung, Status-Logik, Produktnummern +- [x] Feature-Tests: `ProductCurationTest` (23 Tests) – Approve, Reject mit Pflicht-Textfeld, Correction, Archive/Sold, Filter, Kuration-Notes-Anzeige im Edit +- [x] Feature-Tests: `StandardProductCreateTest` (39 Tests) – Zugriff, Happy Path, alle Tabs, Validierung, Preistypen, Produktnummern, Marken +- [x] Feature-Tests: `ProductEditTest` (27 Tests) – Pre-Fill, Save, Status, Validierung, Media-Sortierung, Wood Origins, Activity, Archive/Sold +- [x] Feature-Tests: `TeaserProductEditTest` (30 Tests) – Pre-Fill, Save, Status, Media-Sortierung, Produktnummern +- [x] Tests: Beide Rollen (Händler + Hersteller) können BEIDE Produkttypen anlegen (Teaser + Standard) + +**Tests Phase 2 gesamt: 109 Produkt-Tests + 48 Policy/Profil/Kuration-Tests, alle bestanden ✅** + +--- + +### Phase 3: Kunden-Frontend & Local Feed – ✅ KERN ABGESCHLOSSEN (12.02.2026) +**Geschätzter Aufwand:** 4-5 Tage | **Tatsächlich:** 1 Tag (12.02.2026) +**Priorität:** HOCH – Kunden-Facing Funktionalität + +#### 3.1 User-Origin Tracking – ✅ Implementiert +- [x] Bei Registrierung: `origin` automatisch aus Domain setzen + - Request von `style2own.test` → `origin = 'style2own'` + - Request von `stileigentum.test` → `origin = 'stileigentum'` +- [x] `CreateNewUser` Action (Fortify) erweitern um `origin` + `hub_id` +- [ ] Hub-Auswahl bei Registrierung (oder automatisch via Invite-Code des Maklers) – ausstehend + +#### 3.2 Kunden-Dashboard ("Mein Zuhause") – ✅ Kern implementiert +- [x] `topOffers` im Dashboard: echte Produkte aus dem Hub des Kunden laden + - `Product::query()->where('status', Active)->where('is_curated', true)->...->take(3)` + - Fallback auf Dummy-Daten wenn noch keine Produkte vorhanden +- [ ] Eigene Dashboard-Seite für Customer-Rolle – ausstehend +- [ ] Theme-Steuerung basierend auf `origin` (`style2own` / `stileigentum`) – ausstehend + +#### 3.3 Local Feed (Marktplatz-View) – ✅ Implementiert +- [x] `products/index` mit echter DB-Abfrage und Rollen-Filterung: + - **Admin**: sieht alle Produkte + - **Customer**: nur `active + is_curated + is_available` aus eigenem Hub + - **Retailer/Partner**: nur eigene Produkte +- [x] Suchfilter nach Name, Kategorie-Filter, Paginierung (20 Einträge) +- [ ] Produkt-Detailseite – ausstehend +- [ ] Filteroptionen: Preisbereich, Verfügbarkeit – ausstehend + +#### 3.4 Partner-Profilseite (öffentlich) – ✅ Implementiert +- [x] `partner/profile.blade.php`: Story, Öffnungszeiten, Produkte (max. 6), Spezialisierungen, Adresse +- [x] Route: `partner/{partnerId}/profile` → `partner.profile` +- [ ] Team-Fotos / Showroom-Galerie – ausstehend (abhängig von Media-Upload) + +#### 3.5 Tests Phase 3 – ✅ Abgeschlossen +- [x] `CreateNewUserOriginTest` (6 Tests) – Origin-Tracking, hub_id +- [x] `LocalFeedTest` (5 Tests) – Hub-Filterung, Kuration, Rollen, Suche, Kategorie +- [x] `PartnerProfilePageTest` (6 Tests) – Profilseite, Story, Produkte, Auth + +**Tests Phase 3 gesamt: 17 Tests, alle bestanden ✅** + +--- + +### Phase 4: Ticket-System & QR-Generierung +**Geschätzter Aufwand:** 3-4 Tage +**Priorität:** HOCH – Kern der Monetarisierung + +#### 4.1 Datenbank: Tickets +``` +Migration: create_tickets_table +``` +- [ ] `id`, `uuid` (public identifier) +- [ ] `user_id` (FK → users, Kunde) +- [ ] `merchant_partner_id` (FK → partners, Händler) +- [ ] `product_id` (nullable FK → products) +- [ ] `hub_id` (FK → hubs) +- [ ] `code` (unique string, z.B. "TK-2026-XXXXX") +- [ ] `qr_code_path` (nullable string, Pfad zur generierten QR-Datei) +- [ ] `status` (string: `active` | `redeemed` | `expired`) +- [ ] `discount_type` (string: `percentage` | `fixed`) +- [ ] `discount_value` (decimal) +- [ ] `valid_until` (datetime) +- [ ] `redeemed_at` (nullable datetime) +- [ ] `timestamps` + +#### 4.2 Ticket Model & Enum +- [ ] `App\Models\Ticket` mit Relationships +- [ ] `App\Enums\TicketStatus` – `Active`, `Redeemed`, `Expired` +- [ ] `TicketFactory` erstellen +- [ ] `TicketPolicy` erstellen + +#### 4.3 Ticket-Generierung +- [ ] Composer-Paket für QR-Code Generierung (`simplesoftwareio/simple-qrcode` oder ähnlich) – **Genehmigung einholen** +- [ ] Service: `App\Services\TicketService` + - `generateTicket(User $user, Partner $merchant, ?Product $product): Ticket` + - `generateQrCode(Ticket $ticket): string` (gibt Pfad zurück) + - `validateTicket(string $code): ?Ticket` + - `redeemTicket(Ticket $ticket): void` +- [ ] Livewire-Komponente: "Ticket ziehen" Button auf Produktdetailseite +- [ ] Mail/PDF-Generierung mit QR-Code + Händler-Info + +#### 4.4 Händler: Ticket-Einlösung +- [ ] Händler-Dashboard: "Eingelöste Tickets" Übersicht +- [ ] QR-Code Scanner (Optional, Phase 5) +- [ ] Manuelle Code-Eingabe zum Einlösen + +#### 4.5 Tests Phase 4 +- [ ] Unit-Tests für TicketService +- [ ] Feature-Tests für Ticket-Generierung +- [ ] Feature-Tests für Ticket-Einlösung +- [ ] Tests für QR-Code Generierung + +--- + +### Phase 5: Transaction-Engine & Clearing +**Geschätzter Aufwand:** 5-6 Tage +**Priorität:** MITTEL – Aufbauend auf Ticket-System + +#### 5.1 Datenbank: Transaktionen +``` +Migration: create_transactions_table +``` +- [ ] `id`, `uuid` +- [ ] `ticket_id` (FK → tickets) +- [ ] `user_id` (FK → users, Kunde) +- [ ] `merchant_partner_id` (FK → partners, Händler) +- [ ] `broker_partner_id` (nullable FK → partners, Makler) +- [ ] `amount` (integer, in Cents) +- [ ] `receipt_image_path` (nullable string, hochgeladener Beleg) +- [ ] `receipt_amount` (nullable integer, vom Kunden angegebener Betrag in Cents) +- [ ] `status` (string: State Machine) +- [ ] `merchant_confirmed_at` (nullable datetime) +- [ ] `merchant_confirmed_amount` (nullable integer, in Cents) +- [ ] `invoice_generated_at` (nullable datetime) +- [ ] `paid_at` (nullable datetime) +- [ ] `distributed_at` (nullable datetime) +- [ ] `notes` (nullable text) +- [ ] `timestamps` + +#### 5.2 Transaction State Machine +``` +Enum: App\Enums\TransactionStatus +``` +- [ ] `PendingReceipt` – Ticket eingelöst, Kunde muss Beleg hochladen +- [ ] `PendingMerchant` – Beleg hochgeladen, Händler muss bestätigen +- [ ] `Confirmed` – Händler hat bestätigt → Rechnung an Händler generieren +- [ ] `Invoiced` – Rechnung erstellt → Zahlung ausstehend +- [ ] `Paid` – Händler hat Provision an B2In überwiesen +- [ ] `Distributed` – Provisionen an Makler/Kunde ausgeschüttet +- [ ] `Rejected` – Händler hat abgelehnt +- [ ] `Disputed` – Streitfall + +#### 5.3 Beleg-Upload (Kunden-View) +- [ ] Livewire-Komponente: Beleg-Upload Formular + - Foto-Upload des Kaufbelegs + - Betrag-Eingabe + - Verknüpfung mit Ticket +- [ ] Validierung: Ticket muss `redeemed` sein +- [ ] Mail-Notification an Händler: "Neuer Beleg zur Bestätigung" + +#### 5.4 Händler-Bestätigung +- [ ] Händler-Dashboard: "Offene Umsatz-Bestätigungen" +- [ ] Beleg anzeigen mit Bestätigungs-/Ablehnungs-Buttons +- [ ] Händler kann Betrag korrigieren (falls Kunde falschen Betrag eingegeben hat) +- [ ] Bei Bestätigung: Event `TransactionConfirmed` feuern + +#### 5.5 Tests Phase 5 +- [ ] Unit-Tests für State Machine Übergänge +- [ ] Feature-Tests für Beleg-Upload +- [ ] Feature-Tests für Händler-Bestätigung +- [ ] Tests für Events und Notifications + +--- + +### Phase 6: Wallet, Cashback & Provisions-System +**Geschätzter Aufwand:** 4-5 Tage +**Priorität:** MITTEL – Monetarisierung + +#### 6.1 Datenbank: Wallets & Ledger +``` +Migration: create_wallets_table +``` +- [ ] `id` +- [ ] `partner_id` (nullable FK → partners) +- [ ] `user_id` (nullable FK → users) +- [ ] `balance` (integer, in Cents) +- [ ] `type` (string: `cashback` | `commission` | `platform`) +- [ ] `timestamps` + +``` +Migration: create_ledger_entries_table +``` +- [ ] `id`, `uuid` +- [ ] `wallet_id` (FK → wallets) +- [ ] `transaction_id` (nullable FK → transactions) +- [ ] `type` (string: `credit` | `debit`) +- [ ] `amount` (integer, in Cents, immer positiv) +- [ ] `balance_after` (integer, in Cents) +- [ ] `description` (string) +- [ ] `timestamps` + +#### 6.2 Provisions-Berechnung +- [ ] Service: `App\Services\CommissionService` + - `calculateSplit(Transaction $transaction): CommissionSplit` + - Berechnet: Makler-Anteil, Kunden-Cashback, B2In-Marge + - Nutzt `partner.provision_rate_percentage` und `partner.provision_fixed_amount` +- [ ] Event Listener für `TransactionPaid`: + - Erstellt Ledger-Einträge + - Aktualisiert Wallet-Balances + - Setzt Transaction-Status auf `distributed` + +#### 6.3 Wallet-Views +- [ ] Kunden-Dashboard: "Mein Cashback" (Guthaben, Historie) +- [ ] Makler-Dashboard: "Meine Provisionen" (Guthaben, Historie) +- [ ] Admin: Wallet-Übersicht aller Partner und Kunden + +#### 6.4 Tests Phase 6 +- [ ] Unit-Tests für CommissionService (Splits korrekt berechnet) +- [ ] Feature-Tests für Wallet-Operationen +- [ ] Feature-Tests für Event-basierte Distribution +- [ ] Edge Cases: Rundung, Null-Beträge, fehlende Provisionsregeln + +--- + +### Phase 7: Frontend-Polish & Domains +**Geschätzter Aufwand:** 3-4 Tage +**Priorität:** MITTEL + +#### 7.1 Style2Own vs. StilEigentum Feinschliff +- [ ] CSS-Variablen-Sets für beide Brands finalisieren +- [ ] Wording-Datenbank: "Du" vs. "Sie" Ansprache in allen Texten +- [ ] Landingpage-Anpassungen je Brand + +#### 7.2 "Local for Local" Branding +- [ ] Hub-spezifisches Branding (z.B. "Local for Local OWL") +- [ ] Hub-Landingpages mit regionalen Inhalten +- [ ] Domain-Routing für `localforlocal.de` (wenn verfügbar) + +#### 7.3 Responsive & Mobile +- [ ] Händler-Upload Formular mobil-optimiert ("Handy-optimiert" laut Konzept) +- [ ] Ticket-Anzeige mobil-optimiert (QR-Code gut scanbar) +- [ ] Kunden-Dashboard responsive + +#### 7.4 Tests Phase 7 +- [ ] Browser-Tests (Laravel Dusk) für Multi-Domain +- [ ] Responsive Tests + +--- + +## 3. Beantwortete Fragen & Entscheidungen + +### Architektur-Entscheidungen + +**Frage 1: Kunden-Partner-Beziehung** ✅ +> **Entscheidung:** Beibehalten. Jeder Kunde behält seinen Partner-Eintrag. Zusätzlich `hub_id` und `origin` direkt auf User für schnelle Queries. + +**Frage 2: Hub-Zuordnung bei Produkten** ✅ +> **Entscheidung:** Direktes `hub_id` Feld auf Products. Hersteller können Produkte in mehreren Hubs anbieten. + +**Frage 3: QR-Code Paket** ✅ +> **Entscheidung:** `chillerlan/php-qrcode` + +**Frage 4: PDF-Generierung für Tickets** ✅ +> **Entscheidung:** `spatie/laravel-pdf` + +**Frage 5: Makler-Invite und Hub-Zuweisung** ✅ +> **Entscheidung:** Automatisch vom Makler übernehmen. Admin kann manuell ändern. + +**Frage 6: Domain Local for Local** ✅ +> **Entscheidung:** `local4local.online` (Produktion) / `local4local.test` (Entwicklung). Muss in `config/domains.php` ergänzt werden. + +**Frage 11: Produkttypen – Eine oder zwei Tabellen?** ✅ +> **Entscheidung:** EINE `products` Tabelle, ZWEI Eingabemasken. Das Feld `product_type` (`local_stock` | `smart_order`) steuert welche Maske und Validierung greift. Siehe Phase 2.0 für Details. +> - Maske 1 (Teaser/Local Stock): Vereinfacht, handy-optimiert, für Händler +> - Maske 2 (Konfiguration/Smart Order): Komplex mit 6 Tabs, für Hersteller + +### Business-Logic Entscheidungen + +**Frage 7: Provisions-Split** ✅ +> **Entscheidung:** Individuell pro Partner. Admin-Settings-Seite mit Feldern für: +> - Makler-Provision (%) +> - Kunden-Cashback (%) +> - B2In-Marge (Rest) +> Felder werden pro Partner im Admin-Backend konfiguriert. + +**Frage 8: Ticket-Gültigkeit** ✅ +> **Entscheidung:** Default 30 Tage. Konfigurierbar über Admin-Settings-Seite. + +**Frage 9: Beleg-Upload Deadline** ✅ +> **Entscheidung:** Default 30 Tage. Konfigurierbar über Admin-Settings-Seite. + +**Frage 10: Ticket-Begrenzung pro Kunde** ✅ +> **Entscheidung:** Produkte sind immer an einen Händler gebunden. Ein Kunde kann Tickets bei verschiedenen Händlern für ähnliche Produkte ziehen. Begrenzung über Admin-Settings: +> - Max. Tickets pro Händler (z.B. 3) +> - Max. Händler pro Zeitraum (z.B. 4) +> - Konfigurierbar durch Admin + +### Benötigte Admin-Settings-Seite (Neue Anforderung) +Aus den Antworten ergibt sich die Notwendigkeit einer zentralen **Admin-Settings-Seite** für: +- Ticket-Gültigkeit (Tage) +- Beleg-Upload Deadline (Tage) +- Max. Tickets pro Händler pro Kunde +- Max. Händler pro Kunde pro Zeitraum +- Standard-Provisions-Split (Default-Werte für neue Partner) + +> **Umsetzung:** `settings` Tabelle (key-value) + Admin-Settings-View in Phase 4/5. Alternativ: `config/marketplace.php` mit `.env`-Variablen für nicht-dynamische Werte. +--- + +## 4. Technische Richtlinien für die Entwicklung + +### Architektur-Prinzipien +- **Eloquent-First:** Keine `DB::` Facades, immer `Model::query()` +- **Form Requests:** Jede Validierung in eigene Request-Klassen +- **Policies:** Jede Autorisierung über Laravel Policies +- **Events/Listeners:** Für alle Seiteneffekte (Mail, Wallet-Buchung, Notifications) +- **Services:** Business-Logik in Service-Klassen (`TicketService`, `CommissionService`) +- **Enums:** Alle Status-Werte als PHP 8.1+ Enums + +### Namenskonventionen +- Models: Singular, PascalCase (`Ticket`, `LedgerEntry`) +- Tabellen: Plural, snake_case (`tickets`, `ledger_entries`) +- Enums: PascalCase Keys (`LocalStock`, `SmartOrder`) +- Services: `{Domain}Service` (`TicketService`, `CommissionService`) +- Policies: `{Model}Policy` (`TicketPolicy`, `TransactionPolicy`) +- Form Requests: `{Action}{Model}Request` (`StoreProductRequest`, `UpdatePartnerProfileRequest`) +- Events: Past Tense (`TransactionConfirmed`, `TicketRedeemed`) + +### Testing-Strategie +- Jede Phase muss vollständig getestet sein bevor die nächste beginnt +- Feature-Tests für alle Endpoints und Livewire-Komponenten +- Unit-Tests für Services und komplexe Business-Logik +- Pest als Test-Framework, Factories für Testdaten +- Mindestens: Happy Path + Validation + Authorization pro Feature + +### Reihenfolge der Entwicklung (innerhalb jeder Phase) +1. Migration(s) erstellen und ausführen +2. Model(s) mit Relationships, Casts, Scopes +3. Enum(s) falls nötig +4. Factory und Seeder +5. Form Request(s) +6. Policy +7. Service-Klasse (falls komplexe Logik) +8. Livewire/Volt Komponente +9. Tests schreiben und ausführen +10. `pint --dirty` für Code-Formatierung + +--- + +## 5. Abhängigkeiten zwischen Phasen + +``` +Phase 1 (Core) ──────────┬──────────> Phase 2 (Produkte) + │ │ + │ ▼ + └──────────> Phase 3 (Frontend) ──> Phase 7 (Polish) + │ + ▼ + Phase 4 (Tickets) + │ + ▼ + Phase 5 (Transactions) + │ + ▼ + Phase 6 (Wallet/Cashback) +``` + +- **Phase 1** ist Voraussetzung für alles +- **Phase 2 und 3** können teilweise parallel laufen +- **Phase 4** setzt Phase 3 voraus (Produktdetailseite muss existieren) +- **Phase 5** setzt Phase 4 voraus (Tickets müssen existieren) +- **Phase 6** setzt Phase 5 voraus (Transaktionen müssen existieren) +- **Phase 7** kann teilweise parallel zu Phase 4-6 laufen + +--- + +## 6. Neue Permissions (zu ergänzen) + +Folgende Permissions müssen im RoleSeeder ergänzt werden: + +| Permission | Beschreibung | Rollen | +|-----------|-------------|--------| +| `curate products` | Produkte freigeben/ablehnen | Admin, Super-Admin | +| `view tickets` | Eigene Tickets sehen | Customer | +| `create tickets` | Ticket generieren | Customer | +| `redeem tickets` | Ticket einlösen | Retailer | +| `view transactions` | Transaktionen sehen | Customer, Retailer, Estate-Agent, Admin | +| `confirm transactions` | Umsatz bestätigen | Retailer | +| `upload receipts` | Beleg hochladen | Customer | +| `view wallet` | Wallet-Guthaben sehen | Customer, Estate-Agent, Retailer | +| `manage wallets` | Wallets verwalten | Admin | +| `view commissions` | Provisionen einsehen | Estate-Agent, Admin | +| `manage setup packages` | Setup-Pakete verwalten | Admin | +| `book setup package` | Setup-Paket buchen | Retailer | + +--- + +*Dieses Dokument dient als lebende Referenz für die Entwicklung. Bei Änderungen am Konzept muss dieser Plan entsprechend aktualisiert werden.* +ochladen | Customer | +| `view wallet` | Wallet-Guthaben sehen | Customer, Estate-Agent, Retailer | +| `manage wallets` | Wallets verwalten | Admin | +| `view commissions` | Provisionen einsehen | Estate-Agent, Admin | +| `manage setup packages` | Setup-Pakete verwalten | Admin | +| `book setup package` | Setup-Paket buchen | Retailer | + +--- + +*Dieses Dokument dient als lebende Referenz für die Entwicklung. Bei Änderungen am Konzept muss dieser Plan entsprechend aktualisiert werden.* diff --git a/dev/12-01-2026/konzeption.md b/dev/12-01-2026/konzeption.md new file mode 100644 index 0000000..53c27c4 --- /dev/null +++ b/dev/12-01-2026/konzeption.md @@ -0,0 +1,318 @@ +Konzept-Update: "Local for Local" Marktplatz +1. Strategische Neuausrichtung: Die Domains +Die Trennung wird schärfer. + +B2In (Backend/B2B): Bleibt das "Maschinenraum"-Portal für Händler, Hersteller und Makler. Hier wird verwaltet. + +Local for Local (Customer Frontend): Das wird das Gesicht zum Kunden. Der Kunde fühlt sich nicht auf einer abstrakten "B2In"-Seite, sondern in seinem regionalen Hub (z.B. "Local for Local OWL"). + +To-Do: Wir benötigen ggf. die Domain localforlocal.de (o.ä.) und routen diese auf die Endkunden-Ansicht. + +Onboarding: Findet hier statt ("Werde Teil des lokalen Kreislaufs"). + +2. Das neue Produkt-Konzept (2 Kategorien) +Wir unterscheiden im Backend bei der Produktanlage künftig zwei Typen: + +Typ A: Das "Teaser-Produkt" (Ticket-System) + +Ziel: Frequenzbringer für den stationären Handel (Online-to-Offline). + +Funktion: Der Kunde kauft nicht direkt online. Er sieht ein Möbelstück (z.B. "Sofa Bielefeld"), verliebt sich, sieht aber: "Beratung und Stoffauswahl nötig". + +Der "Ticket-Workflow" (Neu): + +Button: "Ticket & Rabatt sichern". + +System generiert einen QR-Code/Voucher für einen spezifischen Händler im Hub des Kunden. + +Call-to-Action: "Gehen Sie zu Möbelhaus Müller, zeigen Sie diesen Code und erhalten Sie 5% Rabatt + Fachberatung". + +Vorteil: Nimmt den Preisdruck, fördert Beratung, messbarer Erfolg für den Händler (er scannt das Ticket). + +Typ B: Die "Konfigurations-Anlage" + +Ziel: Darstellung komplexer Wohnwände/Systeme. + +Funktion: Zeigt die Machbarkeit und Tiefe des Sortiments ("Alles ist möglich"). Dient als "Showcase" für die Kompetenz der Hersteller. + +3. Vertrauen durch erweiterte Profile ("Faces") +Der Marktplatz wird menschlicher. Wir erweitern die Partner-Datenbank (partners Tabelle) massiv um Marketing-Elemente. + +Händler/Hersteller/Makler-Profilseite: + +Team-Vorstellung: "Ihr Beraterteam vor Ort" (Fotos von echten Menschen). + +Galerie: Bilder vom Showroom/Ladenlokal. + +Story: "Seit 1950 in Herford..." + +Zweck: Wenn der Kunde das "Ticket" zieht, weiß er schon, zu wem er geht. Das senkt die Hemmschwelle enorm. + +4. Die User Journey (Kauf-Philosophie) +Das Dashboard des Kunden ("Mein Zuhause") wird zum emotionalen Feed. + +Login/Start: Kunde sieht Möbel aus seinem Hub ("Local for Local"). + +Detailseite: + +Großes Bild. + +Philosophie-Text (Storytelling zum Möbel). + +Preisanzeige: "Ab 2.500 € – Ihr Endpreis hängt von Ihrer Konfiguration ab." (Nimmt die Angst vor Fixpreisen). + +Action: "Ticket ziehen" -> Terminvereinbarung mit Herrn [Name des Verkäufers] im lokalen Möbelhaus vorschlagen. + +Auswirkungen auf die Entwicklung (Roadmap Anpassung) +Das passt sehr gut in unseren aktuellen Plan für Januar/Februar (Produkte & Marktplatz). Wir müssen nur den Fokus leicht verschieben: + +Produkt-Datenbank (Phase 1 Abschluss): + +Wir fügen ein Feld product_type hinzu (teaser vs. config). + +Wir fügen Logik für "Preise ab" oder "Preis auf Anfrage" hinzu. + +Partner-Profile (Erweiterung): + +Im Backend (das du gerade baust) brauchen wir Upload-Felder für Team-Fotos und "Über uns"-Texte. Das ist schnell gemacht. + +Das Ticket-System (Teil von Phase 2 Marktplatz): + +Anstatt eines klassischen "Warenkorbs" bauen wir für Typ A Produkte einen "Ticket-Generator". + +Das ist technisch einfacher als ein Checkout mit Payment-Provider! Wir generieren ein PDF/QR-Code und senden eine Mail. + + +Konzeptpapier: B2In / Local for Local Marktplatz-Ökosystem +Status: Final | Version: 1.1 (Update: Marken-Hierarchie) + +1. Executive Summary +Das B2In-Ökosystem ist ein hybrider Marktplatz, der den Immobilienkauf ("Moment of Need") mit der Einrichtung verbindet. Es agiert als "Closed Shop" (Zugang nur über Makler). B2In ist die zentrale B2B-Plattform und Technologie. Local for Local ist das verbindende Prinzip innerhalb des Marktplatzes, das die Endkunden-Marken (style2own, stileigentum) mit den regionalen Händlern verknüpft. + +Der USP liegt in der Transparenz lokaler Verfügbarkeit (Säule A: Local Express) und exklusiven Insider-Konditionen (Säule B: Smart Club), abgesichert durch ein Cashback-System. + +2. Marken-Architektur & Entry-Points +Wir unterscheiden strikt zwischen dem B2B-Zugang (Partner) und den B2C-Einstiegen (Endkunden). + +A. Der B2B-Kanal (Die Dachmarke) +Marke: B2In + +Zielgruppe: Immobilienmakler, Händler, Hersteller. + +Funktion: Akquise, Partner-Login, Verwaltung ("Maschinenraum"). + +Positionierung: "Das Netzwerk für Immobilien & Einrichtung." Hier findet das Business statt. + +B. Die B2C-Kanäle (Die Zielgruppen-Türen) +Der Makler entscheidet anhand der Käuferstruktur, über welche "Tür" der Kunde das System betritt. Beide Landingpages führen in denselben Marktplatz: + +1. Marke: Style2Own + +Zielgruppe: Young Professionals, Erstbezug, Urban, Trend-orientiert. + +Tonalität: Modern, "Du"-Ansprache, Lifestyle-Fokus. + +Story: "Dein Style. Deine Stadt." + +2. Marke: StilEigentum + +Zielgruppe: Gehobenes Segment, Best Ager, Villen, Qualitäts-orientiert. + +Tonalität: Exklusiv, "Sie"-Ansprache, Werte-Fokus. + +Story: "Exzellenz und Tradition." + +C. Das verbindende Element (Inside the Portal) +Prinzip: Local for Local + +Funktion: Sobald der Kunde (egal ob über style2own oder stileigentum) eingeloggt ist, greift die "Local for Local"-Logik. + +Erlebnis: Das Portal passt sich dem Hub des Kunden an und zeigt die lokalen Händler als "Local Heroes". Es ist die Klammer, die den Marktplatz definiert. + +3. Marktplatz & Produktstrategie (Das 2-Säulen-Modell) +Im Portal angekommen, wird das Angebot nach Bedürfnis (Zeit vs. Planung) getrennt. + +Säule A: "Local Express" (Phase 1 Focus) +Narrativ: "David gegen Goliath" (Support your Locals). + +Angebot: Sofort verfügbare Ausstellungsstücke, Lagerware und kuratierte "Hidden Gems" der lokalen Fachhändler. + +Kunden-Vorteil: + +Verfügbarkeit: "Was ist heute in meiner Nähe abholbereit?" (Schlägt Google Maps). + +Preis/Leistung: Markenware oft günstiger als im Großmarkt. + +Händler-Vorteil: Abverkauf von Ausstellungsware (Liquidität), Frequenz im Laden. + +Säule B: "Smart Club" (Phase 2 Focus) +Narrativ: "Insider Access". + +Angebot: Frei konfigurierbare Neuware (Bestellung). + +Kunden-Vorteil: Zugriff auf "Closed Shop"-Konditionen (Objekt-Preise), die öffentlich nicht verfügbar sind. + +Strategie: Hersteller können hier Rabatte geben, ohne ihre öffentlichen Marktpreise zu zerstören. + +4. Monetarisierung & Tracking (Das Cashback-Clearing) +Das System löst das Problem der fehlenden Transparenz bei Offline-Käufen durch Inzentivierung des Kunden. + +Der Prozess: + +Ticket: Kunde zieht im Portal einen QR-Code für Händler X. + +Kauf: Kunde kauft vor Ort, verhandelt Preise individuell. + +Upload (Der Trigger): Kunde lädt Kaufbeleg im B2In-Portal hoch, um sein Cashback anzufordern. + +Clearing: Händler bestätigt Umsatz im Backend -> Händler zahlt Gesamt-Provision an B2In. + +Ausschüttung: Sobald Geld eingeht, verteilt das System automatisch: + +Provision an Makler (Lead-Vergütung). + +Cashback an Kunden (Motivation & Datentreue). + +Marge an B2In. + + + +1. Core-Modul: Hub & User Logic +Das Fundament. Hier wird entschieden, wer was sehen darf. + +Hub-Logik (Hub Model): + +Jeder User (Kunde, Makler, Händler) wird einer hub_id zugeordnet (z.B. Hub OWL, Hub Hamburg). + +Logik: Ein Kunde aus OWL sieht nur Händler und Produkte mit hub_id = OWL. Das ist der Kern von "Local for Local". + +User-Origin (origin Feld): + +In der users Tabelle speichern wir, woher der Kunde kam: style2own oder stileigentum. + +Zweck: Steuert das Theme im Dashboard (Modern vs. Klassisch), die Datenbank bleibt aber dieselbe. + +Rollen-System (Erweiterung): + +role: broker (Sieht Provisionen, generiert Invites). + +role: merchant (Sieht Produkt-Upload, Clearing). + +role: customer (Sieht Shop, Wallet, Upload). + +role: admin (Sieht alles + Approval Queue). + +2. Produkt-Modul: "The 2 Pillars" +Die Verwaltung der Waren. Hier unterscheiden wir die Strategie. + +Produkte Tabelle (products): + +Erweiterung um product_type: + +'local_stock' (= Säule A, Ausstellungsstück, Händler-gebunden). + +'smart_order' (= Säule B, Neuware, Hersteller-gebunden). + +is_curated (Boolean): Für die Qualitätssicherung. Erst wenn Admin "OK" gibt, ist das Produkt öffentlich sichtbar. + +Händler-Upload (Simple UI): + +Extrem vereinfachtes Formular für Händler (Handy-optimiert): Foto, Titel, Preis, Statt-Preis, Status (Verfügbar/Verkauft). + +Kein komplexes Warenwirtschafts-Monster! + +3. Transaction-Engine: Ticket & Cashback (Das Herzstück) +Hier fließt das Geld. Das ist der komplexeste Teil der Business-Logik. + +A. Das Ticket-System (Ticket Model) +Funktion: Kunde klickt "Ticket ziehen". + +Daten: user_id, merchant_id, code (Unique String/Hash), created_at. + +QR-Generierung: On-the-fly Generierung des Codes für das Frontend. + +B. Das Clearing-System (Transaction / Receipt Model) +Upload: Kunde lädt Foto hoch + gibt Betrag ein. + +Status-Maschine (State Machine): + +pending_merchant: Händler muss bestätigen ("Ja, Kunde war da und hat für 3.000€ gekauft"). + +confirmed: Händler hat bestätigt -> Rechnung an Händler wird generiert. + +paid: Händler hat Provision an B2In überwiesen. + +distributed: Provision wurde an Makler/Kunde ausgeschüttet. + +C. Die Wallet-Logik (Wallet / Commission Model) +Sobald Status auf paid springt, feuert ein Event (TransactionPaid): + +Berechnet Split: x% an Makler-Wallet, y% an Kunden-Wallet (Cashback). + +Erstellt Einträge in der ledger (Kassenbuch) Tabelle. + +4. Partner-Modul: Makler & Händler Dashboards +Die B2B-Ansichten. + +Makler-Invite System: + +Makler generiert "Invite-Links" oder Codes für style2own/stileigentum. + +Wenn sich ein Kunde damit registriert, wird broker_id fest beim Kunden hinterlegt (Attribution). + +Händler Setup-Buchung: + +Ein kleiner "Service-Store" im Händler-Backend. + +Button: "Setup-Paket buchen (399€)". Löst eine interne Notification an das B2In-Team aus (Ticket für Fotografen). + +5. Frontend-Modul: Die Weichenstellung +Wie die Landingpages mit dem System reden. + +Multi-Domain-Routing: + +Wenn Request von style2own.de kommt -> Lade CSS-Variablen-Set "Modern" + Wording "Du". + +Wenn Request von stileigentum.de kommt -> Lade CSS-Variablen-Set "Classic" + Wording "Sie". + +Der "Local Feed" (Marktplatz-View): + +Query: SELECT * FROM products WHERE hub_id = [User-Hub] AND status = 'active'. + +Sortierung: Lagerware (Säule A) gemischt mit Highlights. + +Zusammenfassung: Der Entwicklungs-Fahrplan +Das ist die logische Reihenfolge für die Programmierung: + +Phase 1: DB & Auth (Basis) + +User Table mit hub_id, origin, broker_id. + +Hub Table. + +Registrierung über Invite-Code (Verknüpfung Makler-Kunde). + +Phase 2: Händler & Produkte (Content) + +Händler-Profil (Öffnungszeiten, Bilder). + +Produkt-CRUD (Create, Read, Update, Delete) für Händler (Säule A). + +Frontend-Feed ("Zeige Produkte in meinem Hub"). + +Phase 3: Der "Geld-Kreislauf" (Logik) + +Ticket-Generierung (QR). + +WICHTIG: Beleg-Upload Formular für Kunden. + +Händler-Backend: Ansicht "Offene Umsatz-Bestätigungen". + +Phase 4: Frontend Polish + +Style2Own vs. StilEigentum Styling. + +Dashboards hübsch machen. + +Wie die Module zusammenhängen (Flow) +Landingpage (Origin) -> Registrierung (Auth + Broker-Link) -> User landet im Hub (Hub-Logik) -> Sieht Produkte (Catalog) -> Zieht Ticket -> Lädt Beleg hoch (Transaction) -> Händler bestätigt -> Cashback fließt (Wallet). diff --git a/dev/core-module.md b/dev/core-module.md new file mode 100644 index 0000000..e69de29 diff --git a/phpunit.xml b/phpunit.xml index c09b5bc..5cf230b 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,7 +1,7 @@ @@ -22,11 +22,15 @@ - + + + + + diff --git a/public/_cabinet/assets/cabinet-intro.jpg b/public/_cabinet/assets/cabinet-intro.jpg new file mode 100644 index 0000000..7bf67dd Binary files /dev/null and b/public/_cabinet/assets/cabinet-intro.jpg differ diff --git a/public/_cabinet/assets/goya.jpg b/public/_cabinet/assets/goya.jpg new file mode 100644 index 0000000..cbfe5e8 Binary files /dev/null and b/public/_cabinet/assets/goya.jpg differ diff --git a/public/_cabinet/assets/goya1.jpg b/public/_cabinet/assets/goya1.jpg new file mode 100644 index 0000000..3d6ced8 Binary files /dev/null and b/public/_cabinet/assets/goya1.jpg differ diff --git a/public/_cabinet/assets/goya2.jpg b/public/_cabinet/assets/goya2.jpg new file mode 100644 index 0000000..78641f6 Binary files /dev/null and b/public/_cabinet/assets/goya2.jpg differ diff --git a/public/_cabinet/assets/tango.jpg b/public/_cabinet/assets/tango.jpg new file mode 100644 index 0000000..32e0b26 Binary files /dev/null and b/public/_cabinet/assets/tango.jpg differ diff --git a/public/_cabinet/index.html b/public/_cabinet/index.html index 51ba88f..14d3e18 100644 --- a/public/_cabinet/index.html +++ b/public/_cabinet/index.html @@ -290,69 +290,11 @@ color: #fff; } - /* Fullscreen Button */ - #fullscreen-btn { - position: absolute; - top: 20px; - left: 20px; - z-index: 1000; - background-color: rgba(0, 159, 227, 0.8); - color: white; - border: none; - border-radius: 8px; - padding: 8px 10px; - font-size: 14px; - font-weight: 600; - cursor: pointer; - font-family: 'IBM Plex Sans', sans-serif; - transition: all 0.3s ease; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); - } - #fullscreen-btn:hover { - background-color: rgba(0, 159, 227, 1); - transform: scale(1.05); - } - - #fullscreen-btn:active { - transform: scale(0.95); - } - - /* Button ausblenden wenn bereits im Fullscreen */ - #fullscreen-btn.hidden { - opacity: 0; - pointer-events: none; - } - - /* Fullscreen Reminder (nach Reload) */ - #fullscreen-btn.reminder { - background-color: rgba(255, 152, 0, 0.95); - animation: pulse 2s infinite; - padding: 12px 20px; - font-size: 16px; - } - - @keyframes pulse { - 0%, 100% { - transform: scale(1); - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); - } - 50% { - transform: scale(1.08); - box-shadow: 0 6px 12px rgba(255, 152, 0, 0.6); - } - } - -
@@ -376,99 +318,6 @@
+ + + + + + + +
+ +
+ + +
+ + + +
+ + + + + diff --git a/public/_cabinet/logo-cabinet-300.png b/public/_cabinet/logo-cabinet-300.png new file mode 100644 index 0000000..7439317 Binary files /dev/null and b/public/_cabinet/logo-cabinet-300.png differ diff --git a/public/_cabinet/logo-cabinet.png.webp b/public/_cabinet/logo-cabinet.png.webp new file mode 100644 index 0000000..c7cd56a Binary files /dev/null and b/public/_cabinet/logo-cabinet.png.webp differ diff --git a/public/_cabinet/offer.html b/public/_cabinet/offer.html new file mode 100644 index 0000000..bf3bc2e --- /dev/null +++ b/public/_cabinet/offer.html @@ -0,0 +1,494 @@ + + + + + + CABINET Display – 9:16 + + + + +
+ +
+
+
CABINET Bielefeld
+
Planung • Beratung
Lieferung/Montage
+
+ +
+
Einzelstücke & Ausstellungsdeals – nur solange verfügbar
+
+ +
+
+
+
Heute im Fokus
+

Kuratiert. Hochwertig. Sofort.

+

Scannen Sie den QR-Code für Kontakt & Store-Infos.

+
+
+
+ CABINET Bielefeld – im Store ansprechen oder direkt reservieren +
+
+ Zwischenverkauf vorbehalten +
+
+
+ + +
+
+ + +
+
+
CABINET
+
Ausstellungsware
Einzelstück
+
+ +
+
Bildplatzhalter: GOYA Sideboard (Hero-Foto)
+
+ +
+
+
+
Ausstellungsware
+

GOYA Sideboard

+

Marke: Sudbrock

+
+ +
+
+

489 €

+
+ brutto
+ UVP neu: 4.744 €* +
+
+
*UVP nur, sofern belegbar. Zwischenverkauf vorbehalten.
+
+
+ + +
+
+ + +
+
+
CABINET
+
GOYA
Konditionen
+
+ +
+
Bildplatzhalter: GOYA Detail/2. Ansicht
+
+ +
+
+
+
GOYA | Konditionen
+

Details auf einen Blick

+
    +
  • Eingelagertes Einzelstück
  • +
  • Abholung: Lager Rheda-Wiedenbrück
  • +
  • Lieferung/Montage optional
  • +
  • Preis gilt ohne Lieferung/Montage
  • +
  • Deckel: Weiß matt (erneuert)
  • +
+
+ +
+
+ Details & Reservierung +
+
+ QR scannen +
+
+
+ + +
+
+ + +
+
+
CABINET
+
Nur 1× im Store
Sofort
+
+ +
+
Bildplatzhalter: TANDO Spiegel (Foto im Laden)
+
+ +
+
+
+
Nur 1× im Store
+

TANDO Spiegel

+

Ansehen & mitnehmen – heute

+
+ +
+

199 €

+
+ brutto
+ Jetzt sichern +
+
+
+ + +
+
+ + + +
+ + + + diff --git a/public/_cabinet/offer.md b/public/_cabinet/offer.md new file mode 100644 index 0000000..aae728a --- /dev/null +++ b/public/_cabinet/offer.md @@ -0,0 +1,149 @@ +Kurzbriefing für Entwickler: 9:16 HTML-Display (CABINET Bielefeld) – Produktloop + +Ziel +Auf dem vorhandenen 9:16-Display (HTML/Webseite) sollen zusätzlich zu CABINET-Werbevideos produktbezogene Angebots-Slides laufen. Fokus: Abverkauf von 2 Produkten (GOYA + TANDO) in einem hochwertigen, ruhigen CABINET-Look (clean, viel Weißraum, klare Typo). + +⸻ + +1) Format & technische Vorgaben + • Format: 9:16 Hochkant, 1080×1920 px (responsive skalierbar). + • Safe-Area: 64 px Rand innen (keine wichtigen Inhalte außerhalb). + • Loop: 4 Slides (Slide 0–3) als Rotation. + • Timing: + • Slide 0 (Intro): 8s + • Slide 1 (GOYA Preis/Hero): 10s + • Slide 2 (GOYA Details/Konditionen): 12s + • Slide 3 (TANDO Preis/Hero): 10s + • anschließend wieder Slide 0 + • Transition: weiche Fades (0,5–0,7s), keine harten Effekte. + • Assets: pro Slide 1 Hintergrundbild (Hero) + optional QR als PNG/SVG. + +⸻ + +2) Layout-Konzept (einheitlich für alle Slides) + +Seitenaufbau (von oben nach unten): + 1. Header: Marke + kurze Kontextzeile (z. B. „Ausstellungsware / Einzelstück“) + 2. Hero: großes Bild (clean, hochwertig) + 3. Bottom Area: + • links: Text-/Preisblock (Produktname, Subline, Preis, ggf. UVP/Bullets) + • rechts: QR-Box (CTA + QR + Kontaktzeile) + +Wiederkehrende Elemente + • QR-Box immer rechts unten gleich platziert. + • CTA-Texte kurz und klar (z. B. „Infos & Reservierung: QR“). + • Disclaimer klein: „Zwischenverkauf vorbehalten.“ + +⸻ + +3) Inhalte pro Slide (finale Texte) + +Slide 0 – Intro + +Header links: CABINET Bielefeld +Header rechts: Planung • Beratung (Zeilenumbruch möglich) + Lieferung/Montage +Hero Badge/Teaser: Einzelstücke & Ausstellungsdeals – nur solange verfügbar +Textblock (links unten): + • Eyebrow: Heute im Fokus + • Headline: Kuratiert. Hochwertig. Sofort. + • Subline: Scannen Sie den QR-Code für Kontakt & Store-Infos. + • Footer klein: CABINET Bielefeld – im Store ansprechen oder direkt reservieren + • Disclaimer klein: Zwischenverkauf vorbehalten + +QR-Box (rechts): + • Titel: Kontakt & Store-Infos + • Sub: QR scannen + • Kontaktzeile: WhatsApp: … · Tel.: … + • QR führt auf: Kontakt-/Store-Seite (URL wird geliefert) + +⸻ + +Slide 1 – GOYA (Variante B: Preis/Hero) + +Header links: CABINET +Header rechts: Ausstellungsware + Einzelstück +Hero Hinweis: Bild GOYA (Hero-Foto) + +Textblock: + • Eyebrow: Ausstellungsware + • Titel: GOYA Sideboard + • Subline: Marke: Sudbrock + • Preis groß: 489 € + • Preiszusatz: brutto + • UVP-Zeile: UVP neu: 4.744 €* + • Disclaimer klein: *UVP nur, sofern belegbar. Zwischenverkauf vorbehalten. + +QR-Box: + • Titel: Infos & Reservierung + • Sub: QR scannen + • Kontaktzeile: WhatsApp: … · Tel.: … + • QR führt auf: GOYA-Detail/Reservierung (URL wird geliefert) + +⸻ + +Slide 2 – GOYA (Variante B: Konditionen) + +Header links: CABINET +Header rechts: GOYA + Konditionen +Hero Hinweis: GOYA Detail/2. Ansicht + +Textblock: + • Eyebrow: GOYA | Konditionen + • Headline: Details auf einen Blick + • Bullets: + 1. Eingelagertes Einzelstück + 2. Abholung: Lager Rheda-Wiedenbrück + 3. Lieferung/Montage optional + 4. Preis gilt ohne Lieferung/Montage + 5. Deckel: Weiß matt (erneuert) + • Footer: Details & Reservierung + QR scannen + +QR-Box: + • Titel: Details: QR scannen + • Sub: Reservierung & Kontakt + • Disclaimer: Zwischenverkauf vorbehalten + • QR führt auf: GOYA-Detail/Reservierung (gleiche URL wie Slide 1) + +⸻ + +Slide 3 – TANDO (Variante C: Impulskauf) + +Header links: CABINET +Header rechts: Nur 1× im Store + Sofort +Hero Hinweis: Foto des Spiegels im Laden (TANDO) + +Textblock: + • Eyebrow: Nur 1× im Store + • Titel: TANDO Spiegel + • Subline: Ansehen & mitnehmen – heute + • Preis groß: 199 € + • Preiszusatz: brutto + • kleine Zusatzzeile: Jetzt sichern + +QR-Box: + • Titel: Jetzt sichern + • Sub: QR scannen oder Team ansprechen + • Kontaktzeile: WhatsApp: … · Tel.: … + • QR führt auf: TANDO-Detail/Info (URL wird geliefert) + +⸻ + +4) Inhalte/Logik als Konfiguration (Wunsch) + +Bitte so bauen, dass Texte/QR-Links/Zeiten leicht austauschbar sind: + • slides[] (JSON) mit Feldern: duration, headerLeft, headerRightLines[], heroImage, eyebrow, title, subline, price, priceNote, uvp, bullets[], qrTitle, qrSub, qrLink, contactLine, disclaimer. + +⸻ + +5) Offene Inputs vom Auftraggeber (Platzhalter) + • WhatsApp Nummer / Tel. + • QR-Ziel-URLs: + • Kontakt/Store + • GOYA Detail/Reservierung + • TANDO Detail/Info + • Bildassets (1080×mind. 1400 empfohlen): + • GOYA Hero + • GOYA Detail + • TANDO im Store + +Das Briefing ist so geschrieben, dass der Entwickler es direkt als HTML/CSS/JS-Slider umsetzen kann (oder in eure bestehende HTML-Playlist integrieren). diff --git a/public/_cabinet/offers/config.json b/public/_cabinet/offers/config.json new file mode 100644 index 0000000..636e487 --- /dev/null +++ b/public/_cabinet/offers/config.json @@ -0,0 +1,126 @@ +{ + "meta": { + "version": "1.0", + "location": "CABINET Bielefeld", + "updated": "2026-02-05" + }, + "settings": { + "loop": true, + "transition": { + "type": "fade", + "duration": 600 + } + }, + "contact": { + "whatsapp": "0521 ...", + "phone": "0521 ...", + "storeUrl": "https://cabinet-bielefeld.de" + }, + "slides": [ + { + "id": "intro", + "file": "slide-0-intro.html", + "duration": 8000, + "type": "intro", + "content": { + "headerLeft": "CABINET Bielefeld", + "headerRight": [ + "Planung • Beratung", + "Lieferung & Montage" + ], + "heroBadge": "Einzelstücke & Ausstellungsdeals – nur solange verfügbar", + "eyebrow": "Heute im Fokus", + "title": "Kuratiert. Hochwertig. Sofort.", + "subline": "Scannen Sie den QR-Code für Kontakt & Store-Infos.", + "footer": "CABINET Bielefeld – im Store ansprechen oder direkt reservieren", + "disclaimer": "Zwischenverkauf vorbehalten", + "qr": { + "title": "Kontakt & Store-Infos", + "subtitle": "QR scannen", + "url": "" + } + } + }, + { + "id": "goya-hero", + "file": "slide-1-goya-hero.html", + "duration": 10000, + "type": "product-hero", + "content": { + "headerLeft": "CABINET", + "headerRight": [ + "Ausstellungsware", + "Einzelstück" + ], + "heroImage": "assets/goya-hero.jpg", + "eyebrow": "Ausstellungsware", + "title": "GOYA Sideboard", + "subline": "Marke: Sudbrock", + "price": "489 €", + "priceNote": "brutto", + "uvp": "UVP neu: 4.744 €*", + "disclaimer": "*UVP nur, sofern belegbar. Zwischenverkauf vorbehalten.", + "qr": { + "title": "Infos & Reservierung", + "subtitle": "QR scannen", + "url": "" + } + } + }, + { + "id": "goya-details", + "file": "slide-2-goya-details.html", + "duration": 12000, + "type": "product-details", + "content": { + "headerLeft": "CABINET", + "headerRight": [ + "GOYA", + "Konditionen" + ], + "heroImage": "assets/goya-detail.jpg", + "eyebrow": "GOYA | Konditionen", + "title": "Details auf einen Blick", + "bullets": [ + "Eingelagertes Einzelstück", + "Abholung: Lager Rheda-Wiedenbrück", + "Lieferung/Montage optional", + "Preis gilt ohne Lieferung/Montage", + "Deckel: Weiß matt (erneuert)" + ], + "cta": "Details & Reservierung", + "qr": { + "title": "Details: QR scannen", + "subtitle": "Reservierung & Kontakt", + "url": "" + }, + "disclaimer": "Zwischenverkauf vorbehalten" + } + }, + { + "id": "tando", + "file": "slide-3-tando.html", + "duration": 10000, + "type": "product-impulse", + "content": { + "headerLeft": "CABINET", + "headerRight": [ + "Nur 1× im Store", + "Sofort" + ], + "heroImage": "assets/tando-store.jpg", + "eyebrow": "Nur 1× im Store", + "title": "TANDO Spiegel", + "subline": "Ansehen & mitnehmen – heute", + "price": "199 €", + "priceNote": "brutto", + "impulseTag": "Jetzt sichern", + "qr": { + "title": "Jetzt sichern", + "subtitle": "QR scannen oder Team ansprechen", + "url": "" + } + } + } + ] +} diff --git a/public/_cabinet/offers/player.html b/public/_cabinet/offers/player.html new file mode 100644 index 0000000..6afda8d --- /dev/null +++ b/public/_cabinet/offers/player.html @@ -0,0 +1,408 @@ + + + + + + CABINET – Angebote Display + + + +
+
+ +
+ Lädt Angebote... +
+ + + + +
+
+
+ + +
+ + +
+ Slide: 0 / 0 +
+
+
+ + + + diff --git a/public/_cabinet/offers/shared-styles.css b/public/_cabinet/offers/shared-styles.css new file mode 100644 index 0000000..40d7420 --- /dev/null +++ b/public/_cabinet/offers/shared-styles.css @@ -0,0 +1,493 @@ +/** + * CABINET Display - Shared Styles + * Format: 9:16 (1080×1920px) + * Safe-Area: 64px + */ + +:root { + /* Colors */ + --bg: #ffffff; + --fg: #1a1a1a; + --fg-strong: #000000; + --muted: #737373; + --muted-light: #999999; + --line: #e8e8e8; + --card: #f5f5f5; + --accent: #009FE3; /* Cabinet Blau */ + + /* Spacing */ + --safe-area: 64px; + --radius: 24px; + --radius-sm: 16px; + + /* Typography Scale (modular) */ + --text-xs: 16px; + --text-sm: 18px; + --text-base: 20px; + --text-lg: 24px; + --text-xl: 28px; + --text-2xl: 32px; + --text-3xl: 42px; + --text-4xl: 54px; + --text-5xl: 64px; + --text-6xl: 84px; + + /* Font */ + --font-main: 'IBM Plex Sans', ui-sans-serif, system-ui, -apple-system, sans-serif; + + /* Dimensions */ + --max-width: 1080px; + --max-height: 1920px; +} + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; + width: 100%; + overflow: hidden; +} + +body { + font-family: var(--font-main); + background: #0a0a0a; + color: var(--fg); + display: flex; + align-items: center; + justify-content: center; + + /* Text Rendering */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +/* ======================================== + SCREEN CONTAINER (9:16 Frame) + ======================================== */ + +.screen { + width: 100vw; + height: 100vh; + max-width: var(--max-width); + max-height: var(--max-height); + aspect-ratio: 9 / 16; + background: var(--bg); + position: relative; + overflow: hidden; +} + +/* Maintain aspect ratio */ +@media (min-aspect-ratio: 9/16) { + .screen { + width: auto; + height: 100vh; + } +} + +@media (max-aspect-ratio: 9/16) { + .screen { + width: 100vw; + height: auto; + } +} + +/* ======================================== + SLIDE LAYOUT + ======================================== */ + +.slide { + position: absolute; + inset: 0; + padding: var(--safe-area); + display: grid; + grid-template-rows: auto 1fr auto; + gap: 32px; + background: var(--bg); +} + +/* ======================================== + HEADER + ======================================== */ + +.header { + display: flex; + align-items: flex-end; + justify-content: space-between; + padding-bottom: 24px; + border-bottom: 1px solid var(--line); + min-height: 100px; +} + +.brand { + display: flex; + align-items: center; + gap: 14px; +} + +.brand-logo { + height: 82px; + width: auto; +} + +.brand-text { + font-size: var(--text-xl); + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--fg-strong); +} + +.tagline { + font-size: var(--text-lg); + color: var(--muted); + text-align: right; + line-height: 1.4; + font-weight: 400; + letter-spacing: 0.01em; +} + +/* ======================================== + HERO SECTION + ======================================== */ + +.hero { + border-radius: var(--radius); + background: linear-gradient(145deg, #f5f5f5, #fafafa); + border: 1px solid var(--line); + overflow: hidden; + position: relative; + display: flex; + align-items: flex-end; + justify-content: flex-start; + padding: 32px; +} + +.hero.has-image { + background-size: cover; + background-position: center; + background-repeat: no-repeat; +} + +.hero-badge { + font-size: var(--text-base); + font-weight: 500; + color: var(--fg); + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 100px; + padding: 14px 24px; + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + letter-spacing: 0.01em; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); +} + +/* Placeholder für Entwicklung */ +.hero-placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: var(--muted); + background: + radial-gradient(circle at 30% 30%, rgba(0,0,0,0.03), transparent 50%), + linear-gradient(145deg, #f2f2f2, #fafafa); +} + +/* ======================================== + BOTTOM SECTION (Info + QR) + ======================================== */ + +.bottom { + display: grid; + grid-template-columns: 1fr 300px; + gap: 24px; + align-items: stretch; +} + +/* Info Box */ +.info { + display: flex; + flex-direction: column; + justify-content: space-between; + background: linear-gradient(180deg, #ffffff, #fafafa); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 28px; + min-height: 340px; +} + +.info-content { + flex: 1; +} + +.eyebrow { + font-size: var(--text-sm); + color: var(--muted); + letter-spacing: 0.14em; + text-transform: uppercase; + margin-bottom: 14px; + font-weight: 500; +} + +.title { + font-size: var(--text-4xl); + line-height: 1.08; + font-weight: 700; + margin-bottom: 14px; + color: var(--fg-strong); + letter-spacing: -0.02em; +} + +.title.large { + font-size: var(--text-5xl); + letter-spacing: -0.025em; +} + +.title.medium { + font-size: var(--text-3xl); + letter-spacing: -0.015em; +} + +.subline { + font-size: var(--text-xl); + color: var(--muted); + line-height: 1.35; + margin-bottom: 16px; + font-weight: 400; +} + +/* Price Display */ +.price-block { + margin-top: auto; + padding-top: 24px; + border-top: 1px solid var(--line); +} + +.price-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 20px; +} + +.price { + font-size: var(--text-6xl); + font-weight: 700; + letter-spacing: -0.03em; + color: var(--fg-strong); + line-height: 1; + font-feature-settings: 'tnum' 1; /* Tabular numbers */ +} + +.price-note { + font-size: var(--text-lg); + color: var(--muted); + text-align: right; + line-height: 1.35; + font-weight: 400; +} + +/* Bullets List */ +.bullets { + list-style: none; + display: flex; + flex-direction: column; + gap: 14px; + margin-top: 20px; +} + +.bullets li { + font-size: var(--text-xl); + line-height: 1.35; + display: flex; + align-items: flex-start; + gap: 16px; + color: var(--fg); + font-weight: 400; +} + +.bullets .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + margin-top: 13px; + flex-shrink: 0; +} + +/* Footer Text (within info box) */ +.info-footer { + margin-top: auto; + padding-top: 20px; + border-top: 1px solid var(--line); +} + +.footer-text { + font-size: var(--text-base); + color: var(--muted); + line-height: 1.45; + font-weight: 400; +} + +.footer-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 20px; +} + +/* ======================================== + QR BOX + ======================================== */ + +.qr-box { + display: flex; + flex-direction: column; + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 20px; + gap: 12px; +} + +.qr-header { + text-align: center; +} + +.qr-title { + font-size: var(--text-lg); + font-weight: 600; + color: var(--fg-strong); + margin-bottom: 6px; + letter-spacing: -0.01em; +} + +.qr-subtitle { + font-size: var(--text-sm); + color: var(--muted); + font-weight: 400; +} + +.qr-code-wrapper { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: #ffffff; + border-radius: var(--radius-sm); + border: 1px dashed #ddd; + padding: 16px; + min-height: 180px; +} + +.qr-code-wrapper img { + width: 100%; + max-width: 180px; + height: auto; + aspect-ratio: 1; +} + +/* QR Placeholder */ +.qr-placeholder { + width: 140px; + height: 140px; + background: + repeating-linear-gradient( + 0deg, + #e0e0e0, + #e0e0e0 12px, + transparent 12px, + transparent 16px + ), + repeating-linear-gradient( + 90deg, + #e0e0e0, + #e0e0e0 12px, + transparent 12px, + transparent 16px + ); + opacity: 0.5; + border-radius: 8px; +} + +.qr-contact { + font-size: var(--text-sm); + color: var(--muted); + text-align: center; + line-height: 1.5; + font-weight: 400; +} + +/* ======================================== + DISCLAIMER + ======================================== */ + +.disclaimer { + font-size: var(--text-xs); + color: var(--muted-light); + margin-top: 12px; + font-weight: 400; + letter-spacing: 0.01em; +} + +/* ======================================== + ANIMATIONS (for player integration) + ======================================== */ + +.slide.fade-in { + animation: fadeIn 0.6s ease-out forwards; +} + +.slide.fade-out { + animation: fadeOut 0.6s ease-in forwards; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +/* ======================================== + UTILITY CLASSES + ======================================== */ + +.text-accent { + color: var(--accent); +} + +.text-muted { + color: var(--muted); +} + +.font-bold { + font-weight: 700; +} + +.font-semibold { + font-weight: 600; +} + +.mt-auto { + margin-top: auto; +} diff --git a/public/_cabinet/offers/slide-0-intro.html b/public/_cabinet/offers/slide-0-intro.html new file mode 100644 index 0000000..ecaade0 --- /dev/null +++ b/public/_cabinet/offers/slide-0-intro.html @@ -0,0 +1,100 @@ + + + + + + CABINET – Intro + + + + + + + +
+
+ + +
+
+ + Bielefeld +
+
+ Planung • Beratung
+ Lieferung & Montage +
+
+ + +
+ Ausstellungsdeals – solange verfügbar +
+ + +
+
+
+

Heute im Fokus

+

Kuratiert.
Hochwertig.
Sofort.

+
+ + +
+ + +
+ +
+
+ + + + diff --git a/public/_cabinet/offers/slide-1-goya-hero.html b/public/_cabinet/offers/slide-1-goya-hero.html new file mode 100644 index 0000000..19cc816 --- /dev/null +++ b/public/_cabinet/offers/slide-1-goya-hero.html @@ -0,0 +1,105 @@ + + + + + + CABINET – GOYA Sideboard + + + + + + + +
+
+ + +
+
+ +
+ +
+ + +
+ Einzelstück +
+ + +
+
+
+

Hersteller: Sudbrock

+

GOYA Sideboard

+
+ +
+
+ 489 € +
+ statt 4.744 € +
+
+
+
+ + +
+ +
+
+ + + + diff --git a/public/_cabinet/offers/slide-2-goya-details.html b/public/_cabinet/offers/slide-2-goya-details.html new file mode 100644 index 0000000..b0c96c0 --- /dev/null +++ b/public/_cabinet/offers/slide-2-goya-details.html @@ -0,0 +1,135 @@ + + + + + + CABINET – GOYA Konditionen + + + + + + + +
+
+ + +
+
+ +
+ +
+ + +
+ Einzelstück +
+ + +
+
+
+

Auf einen Blick<

+

GOYA Sideboard

+ +
    +
  • + + Eingelagertes Einzelstück +
  • +
  • + + Abholung in Rheda-Wiedenbrück +
  • +
  • + + Lieferung optional +
  • +
  • + + Deckel weiß matt (neu) +
  • +
+
+ +
+ + +
+ +
+
+ + + + diff --git a/public/_cabinet/offers/slide-3-tando.html b/public/_cabinet/offers/slide-3-tando.html new file mode 100644 index 0000000..bc52434 --- /dev/null +++ b/public/_cabinet/offers/slide-3-tando.html @@ -0,0 +1,109 @@ + + + + + + CABINET – TANDO Spiegel + + + + + + + +
+
+ + +
+
+ +
+ +
+ + +
+ Ausstellungsstück +
+ + +
+
+
+

Nur 1×

+

TANDO Spiegel

+

Heute mitnehmen

+
+ +
+
+ 199 € +
+ Im Store verfügbar +
+
+
+
+ + +
+ +
+
+ + + + diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php index 91044d4..1537524 100644 --- a/resources/views/admin/dashboard.blade.php +++ b/resources/views/admin/dashboard.blade.php @@ -1,6 +1,8 @@ topOffers = [ + // Top-Angebote aus dem Hub des Kunden laden + $hubId = $partner->hub_id ?? $user->hub_id; + $this->topOffers = Product::query() + ->where('status', ProductStatus::Active) + ->where('is_curated', true) + ->where('is_available', true) + ->when($hubId, fn ($q) => $q->where('hub_id', $hubId)) + ->with(['categories', 'media']) + ->latest() + ->take(3) + ->get() + ->map(fn (Product $p) => [ + 'id' => $p->id, + 'name' => $p->name, + 'description' => $p->description_short ?? '', + 'price' => $p->price ?? 0, + 'original_price' => $p->price ?? 0, + 'discount' => 0, + 'image' => $p->media->first()?->url ?? null, + 'category' => $p->categories->first()?->name ?? '', + ]) + ->toArray(); + + // Fallback: Dummy-Daten wenn noch keine Produkte vorhanden + if (empty($this->topOffers)) { + $this->topOffers = [ [ 'id' => 1, 'name' => 'Designer Sofa "Luna"', @@ -198,7 +224,7 @@ new class extends Component { 'price' => 1899.00, 'original_price' => 2499.00, 'discount' => 24, - 'image' => 'https://images.unsplash.com/photo-1555041469-a586c61ea9bc?w=400&h=300&fit=crop', + 'image' => null, 'category' => 'Wohnzimmer', ], [ @@ -208,7 +234,7 @@ new class extends Component { 'price' => 899.00, 'original_price' => 1299.00, 'discount' => 31, - 'image' => 'https://images.unsplash.com/photo-1617806118233-18e1de247200?w=400&h=300&fit=crop', + 'image' => null, 'category' => 'Esszimmer', ], [ @@ -218,10 +244,11 @@ new class extends Component { 'price' => 1599.00, 'original_price' => 2199.00, 'discount' => 27, - 'image' => 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?w=400&h=300&fit=crop', + 'image' => null, 'category' => 'Schlafzimmer', ], ]; + } } } } diff --git a/resources/views/components/layouts/app/sidebar.blade.php b/resources/views/components/layouts/app/sidebar.blade.php index 2b30aa5..b1d26a9 100644 --- a/resources/views/components/layouts/app/sidebar.blade.php +++ b/resources/views/components/layouts/app/sidebar.blade.php @@ -1,136 +1,204 @@ - - @include('partials.head') - - - - - - - - @if(session('impersonate_from')) -
-
- -
-
- {{ __('Als :name eingeloggt', ['name' => Auth::user()->name]) }} -
-
- {{ __('Sie sind temporär als dieser User angemeldet') }} -
+ + @include('partials.head') + + + + + + + + + + @if (session('impersonate_from')) +
+
+ +
+
+ {{ __('Als :name eingeloggt', ['name' => Auth::user()->name]) }} +
+
+ {{ __('Sie sind temporär als dieser User angemeldet') }}
-
- @csrf - - {{ __('Zurück zum Admin') }} - -
- @endif +
+ @csrf + + {{ __('Zurück zum Admin') }} + +
+
+ @endif - - @hasrole('Customer') + + @hasrole('Customer') - {{ __('Dashboard') }} + {{ __('Dashboard') }} - {{ __('Meine Daten') }} + {{ __('Meine Daten') }} + - @endhasrole - @hasrole('Retailer') + @endhasrole + @hasrole('Retailer') - {{ __('Dashboard') }} + {{ __('Dashboard') }} - {{ __('Produktliste') }} - {{ __('Neues Produkt') }} + {{ __('Produktliste') }} + + + {{ __('Neues Teaser-Produkt') }} + + + {{ __('Neues Standard-Produkt') }} + - {{ __('Meine Daten') }} + {{ __('Meine Daten') }} + - @endhasrole - @hasrole('Manufacturer') + @endhasrole + @hasrole('Manufacturer') - {{ __('Dashboard') }} + {{ __('Dashboard') }} - {{ __('Produktliste') }} - {{ __('Neues Produkt') }} - - - {{ __('Meine Daten') }} - - @endhasrole - @hasrole('Broker') - - {{ __('Dashboard') }} - - - {{ __('Meine Daten') }} - - @endhasrole - @hasrole('Admin|Super-Admin') - - {{ __('Dashboard') }} - - @endhasrole + {{ __('Produktliste') }} + - @hasrole('Super-Admin|Admin') + + {{ __('Neues Teaser-Produkt') }} + + + {{ __('Neues Standard-Produkt') }} + + + + {{ __('Meine Daten') }} + + + @endhasrole + @hasrole('Broker') + + {{ __('Dashboard') }} + + + {{ __('Meine Daten') }} + + + @endhasrole + @hasrole('Admin|Super-Admin') + + {{ __('Dashboard') }} + + @endhasrole + + @hasrole('Super-Admin|Admin') - - {{ __('Benutzer') }} - {{ __('Partner einladen') }} - {{ __('Registrierungscodes') }} - {{ __('Permissions') }} + + {{ __('Benutzer') }} + + + {{ __('Partner einladen') }} + + {{ __('Registrierungscodes') }} + {{ __('Permissions') }} + - + + + {{ __('Produkt-Verwaltung') }} + + + {{ __('Alle Produkte') }} + + + - {{ __('Hub-Verwaltung') }} + {{ __('Hub-Verwaltung') }} + - {{ __('Cabinet') }} + {{ __('Cabinet') }} + - - - @endhasrole - - @hasrole('Super-Admin') - - {{ __('Dashboard') }} - - @endhasrole - - - - - @hasrole('Super-Admin|Admin') - - {{ __('Projekt-Dokumentation') }} - @endhasrole + @hasrole('Super-Admin') + + {{ __('Dashboard') }} + + @endhasrole + + + + + @hasrole('Super-Admin|Admin') + + {{ __('Projekt-Dokumentation') }} + + + @endhasrole + @hasrole('Super-Admin') - - {{ __('Tailwind CSS') }} + + {{ __('Tailwind CSS') }} - {{ __('Hero Icons') }} + {{ __('Hero Icons') }} - {{ __('Flux UI') }} + {{ __('Flux UI') }} - - {{ __('Repository') }} + + {{ __('Repository') }} @@ -138,159 +206,143 @@ - @endhasrole + @endhasrole - - - + + + - - -
-
- - - {{ auth()->user()->initials() }} - + + +
+
+ + + {{ auth()->user()->initials() }} + -
- {{ auth()->user()->name }} - {{ auth()->user()->email }} -
+
+ {{ auth()->user()->name }} + {{ auth()->user()->email }}
- +
+
- + - - {{ __('Settings') }} - - + + + {{ __('Settings') }} + + - -
- - {{ __('Dunkel') }} - - - {{ __('Hell') }} - -
-
- - - -
- @csrf - - {{ __('Log Out') }} + +
+ + {{ __('Dunkel') }} - - - - + + {{ __('Hell') }} + +
+
- - - + - - - - +
+ @csrf + + {{ __('Log Out') }} + +
+
+ + - - + + + - - -
-
- - - {{ auth()->user()->initials() }} - + + + + + + + + + + +
+
+ + + {{ auth()->user()->initials() }} + -
- {{ auth()->user()->name }} - {{ auth()->user()->email }} -
+
+ {{ auth()->user()->name }} + {{ auth()->user()->email }}
- +
+
- + - - {{ __('Settings') }} - - + + + {{ __('Settings') }} + + - -
- - {{ __('Dunkel') }} - - - {{ __('Hell') }} - -
-
- - - -
- @csrf - - {{ __('Log Out') }} + +
+ + {{ __('Dunkel') }} - - - - + + {{ __('Hell') }} + +
+
- {{ $slot }} + + +
+ @csrf + + {{ __('Log Out') }} + +
+
+
+ + + {{ $slot }} + + + + @fluxScripts + - @fluxScripts - diff --git a/resources/views/livewire/admin/partners/edit.blade.php b/resources/views/livewire/admin/partners/edit.blade.php new file mode 100644 index 0000000..9df7061 --- /dev/null +++ b/resources/views/livewire/admin/partners/edit.blade.php @@ -0,0 +1,355 @@ + + */ + public array $openingHours = [ + 'monday' => ['open' => '09:00', 'close' => '18:00', 'closed' => false], + 'tuesday' => ['open' => '09:00', 'close' => '18:00', 'closed' => false], + 'wednesday' => ['open' => '09:00', 'close' => '18:00', 'closed' => false], + 'thursday' => ['open' => '09:00', 'close' => '18:00', 'closed' => false], + 'friday' => ['open' => '09:00', 'close' => '18:00', 'closed' => false], + 'saturday' => ['open' => '10:00', 'close' => '16:00', 'closed' => false], + 'sunday' => ['open' => '', 'close' => '', 'closed' => true], + ]; + + public function mount(int $partnerId): void + { + $this->partner = Partner::findOrFail($partnerId); + $this->authorize('update', $this->partner); + + $this->companyName = $this->partner->company_name ?? ''; + $this->displayName = $this->partner->display_name ?? ''; + $this->street = $this->partner->street ?? ''; + $this->houseNumber = $this->partner->house_number ?? ''; + $this->zip = $this->partner->zip ?? ''; + $this->city = $this->partner->city ?? ''; + $this->phone = $this->partner->phone ?? ''; + $this->website = $this->partner->website ?? ''; + $this->hubId = $this->partner->hub_id; + $this->isActive = $this->partner->is_active; + $this->storyText = $this->partner->story_text ?? ''; + $this->foundedYear = $this->partner->founded_year ?? ''; + $this->specialtiesInput = $this->partner->specialties + ? implode(', ', $this->partner->specialties) + : ''; + + if ($this->partner->opening_hours) { + $this->openingHours = array_merge($this->openingHours, $this->partner->opening_hours); + } + } + + public function save(): void + { + $this->authorize('update', $this->partner); + + $this->validate([ + 'companyName' => 'required|string|max:255', + 'displayName' => 'nullable|string|max:255', + 'street' => 'nullable|string|max:255', + 'houseNumber' => 'nullable|string|max:20', + 'zip' => 'nullable|string|max:10', + 'city' => 'nullable|string|max:100', + 'phone' => 'nullable|string|max:50', + 'website' => 'nullable|url|max:255', + 'hubId' => 'nullable|exists:hubs,id', + 'storyText' => 'nullable|string|max:2000', + 'foundedYear' => 'nullable|integer|min:1800|max:' . now()->year, + 'specialtiesInput' => 'nullable|string|max:500', + ], [ + 'companyName.required' => __('Bitte geben Sie einen Firmennamen ein.'), + 'website.url' => __('Bitte geben Sie eine gültige URL ein (z.B. https://example.de).'), + 'foundedYear.integer' => __('Bitte geben Sie eine gültige Jahreszahl ein.'), + 'foundedYear.min' => __('Das Gründungsjahr muss nach 1800 liegen.'), + 'foundedYear.max' => __('Das Gründungsjahr darf nicht in der Zukunft liegen.'), + ]); + + $specialties = array_filter( + array_map('trim', explode(',', $this->specialtiesInput)) + ); + + $this->partner->update([ + 'company_name' => $this->companyName, + 'display_name' => $this->displayName ?: null, + 'street' => $this->street ?: null, + 'house_number' => $this->houseNumber ?: null, + 'zip' => $this->zip ?: null, + 'city' => $this->city ?: null, + 'phone' => $this->phone ?: null, + 'website' => $this->website ?: null, + 'hub_id' => $this->hubId, + 'is_active' => $this->isActive, + 'story_text' => $this->storyText ?: null, + 'founded_year' => $this->foundedYear ?: null, + 'specialties' => array_values($specialties), + 'opening_hours' => $this->openingHours, + ]); + + session()->flash('message', __('Partner-Profil erfolgreich gespeichert.')); + } + + /** @return array */ + protected function dayLabels(): array + { + return [ + 'monday' => __('Montag'), + 'tuesday' => __('Dienstag'), + 'wednesday' => __('Mittwoch'), + 'thursday' => __('Donnerstag'), + 'friday' => __('Freitag'), + 'saturday' => __('Samstag'), + 'sunday' => __('Sonntag'), + ]; + } + + public function with(): array + { + return [ + 'hubs' => Hub::orderBy('name')->get(['id', 'name']), + 'dayLabels' => $this->dayLabels(), + ]; + } +}; ?> + +
+ {{-- Header --}} +
+ +
+ {{ $partner->company_name }} + {{ __('Partner-Profil bearbeiten') }} +
+
+ + @if (session()->has('message')) + + {{ session('message') }} + + @endif + +
+ + {{-- Basisdaten --}} + +
+ {{ __('Basisdaten') }} +
+ + +
+ + {{ __('Firmenname') }} + + @error('companyName') {{ $message }} @enderror + + + + {{ __('Anzeigename (optional)') }} + {{ __('Öffentlich sichtbarer Name, falls abweichend') }} + + @error('displayName') {{ $message }} @enderror + +
+ +
+ + {{ __('Straße') }} + + @error('street') {{ $message }} @enderror + + + + {{ __('Hausnummer') }} + + @error('houseNumber') {{ $message }} @enderror + +
+ +
+ + {{ __('PLZ') }} + + @error('zip') {{ $message }} @enderror + + + + {{ __('Stadt') }} + + @error('city') {{ $message }} @enderror + +
+ +
+ + {{ __('Telefon') }} + + @error('phone') {{ $message }} @enderror + + + + {{ __('Website') }} + + @error('website') {{ $message }} @enderror + +
+ +
+ + {{ __('Hub / Region') }} + + {{ __('– Kein Hub –') }} + @foreach ($hubs as $hub) + {{ $hub->name }} + @endforeach + + @error('hubId') {{ $message }} @enderror + + + + {{ __('Status') }} + + +
+
+ + {{-- Story & Profil --}} + +
+ {{ __('Story & Profil') }} + {{ __('Erzählen Sie die Geschichte des Partners') }} +
+ + +
+ + {{ __('Story-Text') }} + {{ __('Kurze Geschichte des Unternehmens – max. 2.000 Zeichen') }} + +
+ {{ strlen($storyText) }} / 2000 +
+ @error('storyText') {{ $message }} @enderror +
+ +
+ + {{ __('Gründungsjahr') }} + + @error('foundedYear') {{ $message }} @enderror + + + + {{ __('Fachgebiete / Spezialisierungen') }} + {{ __('Kommagetrennt, z.B. Polstermöbel, Küchen, Matratzen') }} + + @error('specialtiesInput') {{ $message }} @enderror + +
+
+
+ + {{-- Öffnungszeiten --}} + +
+ {{ __('Öffnungszeiten') }} +
+ + +
+ @foreach ($dayLabels as $dayKey => $dayLabel) +
+
+ {{ $dayLabel }} +
+ + + + @unless ($openingHours[$dayKey]['closed'] ?? false) +
+ + + +
+ @endunless +
+ @endforeach +
+
+ + {{-- Aktionen --}} +
+ + {{ __('Abbrechen') }} + + + {{ __('Speichern') }} + + + {{ __('Wird gespeichert...') }} + + +
+ +
+
diff --git a/resources/views/livewire/admin/partners/index.blade.php b/resources/views/livewire/admin/partners/index.blade.php new file mode 100644 index 0000000..12a2f7b --- /dev/null +++ b/resources/views/livewire/admin/partners/index.blade.php @@ -0,0 +1,220 @@ +resetPage(); + } + + public function updatedTypeFilter(): void + { + $this->resetPage(); + } + + public function updatedOnlyActive(): void + { + $this->resetPage(); + } + + public function with(): array + { + $this->authorize('viewAny', Partner::class); + + $partners = Partner::query() + ->with(['hub', 'users']) + ->when($this->search, fn ($q) => $q->where(function ($q) { + $q->where('company_name', 'like', "%{$this->search}%") + ->orWhere('city', 'like', "%{$this->search}%") + ->orWhere('email', 'like', "%{$this->search}%"); + })) + ->when($this->typeFilter, fn ($q) => $q->where('type', $this->typeFilter)) + ->when($this->onlyActive, fn ($q) => $q->where('is_active', true)) + ->orderBy('company_name') + ->paginate(20); + + return [ + 'partners' => $partners, + 'totalCount' => Partner::count(), + 'activeCount' => Partner::where('is_active', true)->count(), + ]; + } +}; ?> + +
+ {{-- Header --}} +
+
+ {{ __('Partner-Verwaltung') }} + {{ __('Alle registrierten Partner auf der Plattform') }} +
+ + {{ __('Partner einladen') }} + +
+ + {{-- Statistics --}} +
+ +
+
+ {{ __('Gesamt') }} + {{ $totalCount }} +
+
+ +
+
+
+ + +
+
+ {{ __('Aktiv') }} + {{ $activeCount }} +
+
+ +
+
+
+ + +
+
+ {{ __('Inaktiv') }} + {{ $totalCount - $activeCount }} +
+
+ +
+
+
+
+ + {{-- Filter --}} + +
+ + {{ __('Suche') }} + + + + + {{ __('Partner-Typ') }} + + {{ __('Alle Typen') }} + {{ __('Händler') }} + {{ __('Hersteller') }} + {{ __('Makler') }} + + + + + {{ __('Status') }} + + +
+
+ + {{-- Partner-Tabelle --}} + + + + {{ __('Partner') }} + {{ __('Typ') }} + {{ __('Hub') }} + {{ __('Benutzer') }} + {{ __('Status') }} + + + + + @forelse ($partners as $partner) + + +
+ {{ $partner->company_name }} +
+ @if ($partner->city) +
{{ $partner->zip }} {{ $partner->city }}
+ @endif +
+ + + + {{ $partner->type?->label() ?? ucfirst($partner->type ?? '–') }} + + + + + {{ $partner->hub?->name ?? '–' }} + + + + {{ $partner->users->count() }} + + + + @if ($partner->is_active) + {{ __('Aktiv') }} + @else + {{ __('Inaktiv') }} + @endif + + + + + {{ __('Bearbeiten') }} + + +
+ @empty + + + +
{{ __('Keine Partner gefunden') }}
+
+
+ @endforelse +
+
+ + @if ($partners->hasPages()) +
+ {{ $partners->links() }} +
+ @endif +
+
diff --git a/resources/views/livewire/admin/products/index.blade.php b/resources/views/livewire/admin/products/index.blade.php new file mode 100644 index 0000000..af22699 --- /dev/null +++ b/resources/views/livewire/admin/products/index.blade.php @@ -0,0 +1,563 @@ +resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function updatedProductTypeFilter(): void + { + $this->resetPage(); + } + + public function updatedCategoryFilter(): void + { + $this->resetPage(); + } + + public function updatedPartnerFilter(): void + { + $this->resetPage(); + } + + public function approve(int $productId): void + { + $this->authorize('curate', Product::class); + + $product = Product::findOrFail($productId); + $product->update([ + 'status' => ProductStatus::Active, + 'is_curated' => true, + 'curated_at' => now(), + 'curated_by' => auth()->id(), + 'curation_notes' => null, + ]); + + $product->activities()->create([ + 'user_id' => auth()->id(), + 'action' => 'approved', + 'note' => null, + ]); + + Flux::toast(variant: 'success', text: __('Produkt ":name" wurde freigegeben.', ['name' => $product->name]), duration: 5000); + } + + public function openCorrection(int $productId): void + { + $this->correctingProductId = $productId; + $this->curationNotes = ''; + $this->rejectingProductId = null; + $this->rejectionReason = ''; + } + + public function cancelCorrection(): void + { + $this->correctingProductId = null; + $this->curationNotes = ''; + } + + public function sendCorrection(int $productId): void + { + $this->authorize('curate', Product::class); + + $this->validate([ + 'curationNotes' => 'required|string|max:5000', + ], [ + 'curationNotes.required' => __('Bitte geben Sie eine Korrekturanweisung ein.'), + ]); + + $product = Product::findOrFail($productId); + $product->update([ + 'status' => ProductStatus::Correction, + 'is_curated' => false, + 'curation_notes' => $this->curationNotes, + ]); + + $product->activities()->create([ + 'user_id' => auth()->id(), + 'action' => 'correction', + 'note' => $this->curationNotes, + ]); + + $this->correctingProductId = null; + $this->curationNotes = ''; + + Flux::toast(variant: 'success', text: __('Korrekturanweisung für ":name" wurde gesendet.', ['name' => $product->name]), duration: 5000); + } + + public function openRejection(int $productId): void + { + $this->rejectingProductId = $productId; + $this->rejectionReason = ''; + $this->correctingProductId = null; + $this->curationNotes = ''; + } + + public function cancelRejection(): void + { + $this->rejectingProductId = null; + $this->rejectionReason = ''; + } + + public function reject(int $productId): void + { + $this->authorize('curate', Product::class); + + $this->validate([ + 'rejectionReason' => 'required|string|max:5000', + ], [ + 'rejectionReason.required' => __('Bitte geben Sie einen Ablehnungsgrund ein.'), + ]); + + $product = Product::findOrFail($productId); + $product->update([ + 'status' => ProductStatus::Archived, + 'is_curated' => false, + 'curation_notes' => $this->rejectionReason, + ]); + + $product->activities()->create([ + 'user_id' => auth()->id(), + 'action' => 'rejected', + 'note' => $this->rejectionReason, + ]); + + $this->rejectingProductId = null; + $this->rejectionReason = ''; + + Flux::toast(variant: 'success', text: __('Produkt ":name" wurde abgelehnt.', ['name' => $product->name]), duration: 5000); + } + + public function archiveProduct(int $productId): void + { + $this->authorize('curate', Product::class); + + $product = Product::findOrFail($productId); + $product->update(['status' => ProductStatus::Archived]); + $product->activities()->create([ + 'user_id' => auth()->id(), + 'action' => 'archived', + 'note' => null, + ]); + + Flux::toast(variant: 'success', text: __('Produkt ":name" wurde archiviert.', ['name' => $product->name]), duration: 5000); + } + + public function markAsSold(int $productId): void + { + $this->authorize('curate', Product::class); + + $product = Product::findOrFail($productId); + $product->update(['status' => ProductStatus::Sold]); + $product->activities()->create([ + 'user_id' => auth()->id(), + 'action' => 'sold', + 'note' => null, + ]); + + Flux::toast(variant: 'success', text: __('Produkt ":name" als verkauft markiert.', ['name' => $product->name]), duration: 5000); + } + + public function with(): array + { + $this->authorize('curate', Product::class); + + $query = Product::query() + ->with(['partner', 'categories', 'media']) + ->when($this->search, fn($q) => $q->where(function ($q) { + $q->where('name', 'like', "%{$this->search}%") + ->orWhere('b2in_article_number', 'like', "%{$this->search}%") + ->orWhere('partner_product_number', 'like', "%{$this->search}%"); + })) + ->when($this->statusFilter, fn($q) => $q->where('status', $this->statusFilter)) + ->when($this->productTypeFilter, fn($q) => $q->where('product_type', $this->productTypeFilter)) + ->when($this->categoryFilter, fn($q) => $q->whereHas('categories', fn($q) => $q->where('categories.id', $this->categoryFilter))) + ->when($this->partnerFilter, fn($q) => $q->where('partner_id', $this->partnerFilter)); + + $products = $query->latest()->paginate(25); + + $categories = Category::orderBy('name')->get(); + $partners = \App\Models\Partner::orderBy('company_name')->get(['id', 'company_name']); + + $statusCounts = Product::query() + ->selectRaw("status, count(*) as count") + ->groupBy('status') + ->pluck('count', 'status'); + + return [ + 'products' => $products, + 'categories' => $categories, + 'partners' => $partners, + 'totalCount' => Product::count(), + 'pendingCount' => $statusCounts[ProductStatus::Pending->value] ?? 0, + 'correctionCount' => $statusCounts[ProductStatus::Correction->value] ?? 0, + 'activeCount' => $statusCounts[ProductStatus::Active->value] ?? 0, + ]; + } +}; ?> + +
+ {{-- Header --}} +
+
+ {{ __('Produkt-Verwaltung') }} + {{ __('Alle Produkte verwalten, freigeben und bearbeiten') }} +
+
+ + {{ __('Neues Teaser-Produkt') }} + + + {{ __('Neues Standard-Produkt') }} + +
+
+ + {{-- Statistiken --}} +
+ +
+
+ {{ __('Gesamt') }} + {{ $totalCount }} +
+
+ +
+
+
+ + +
+
+ {{ __('Zur Freigabe') }} + {{ $pendingCount }} +
+
+ +
+
+
+ + +
+
+ {{ __('In Korrektur') }} + {{ $correctionCount }} +
+
+ +
+
+
+ + +
+
+ {{ __('Freigegeben') }} + {{ $activeCount }} +
+
+ +
+
+
+
+ + {{-- Filter --}} + +
+ + {{ __('Suche') }} + + + + + {{ __('Status') }} + + {{ __('Alle Status') }} + @foreach (ProductStatus::cases() as $status) + {{ $status->label() }} + @endforeach + + + + + {{ __('Produkttyp') }} + + {{ __('Alle Typen') }} + {{ __('Teaser (Local Express)') }} + {{ __('Standard (Smart Club)') }} + + + + + {{ __('Händler') }} + + {{ __('Alle Händler') }} + @foreach ($partners as $partner) + {{ $partner->company_name }} + @endforeach + + + + + {{ __('Kategorie') }} + + {{ __('Alle Kategorien') }} + @foreach ($categories as $category) + {{ $category->name }} + @endforeach + + +
+
+ + {{-- Produkttabelle --}} + + + + {{ __('Produkt') }} + {{ __('Händler') }} + {{ __('Kategorie') }} + {{ __('Status') }} + {{ __('Kuration') }} + {{ __('Erstellt') }} + + + + + @forelse($products as $product) + + {{-- Produkt --}} + +
+ @php + $thumbnail = $product->media->sortBy('order_column')->first(); + @endphp + @if ($thumbnail) + {{ $thumbnail->alt_text ?? $product->name }} + @else +
+ +
+ @endif +
+
+ {{ $product->name }} +
+
+ + {{ $product->product_type?->label() ?? '–' }} + + @if ($product->b2in_article_number) + {{ $product->b2in_article_number }} + @endif +
+
+
+
+ + {{-- Händler --}} + + + {{ $product->partner?->company_name ?? '–' }} + + + + {{-- Kategorie --}} + + + {{ $product->categories->first()?->name ?? '–' }} + + + + {{-- Status --}} + + + {{ $product->status?->label() ?? '–' }} + + + + {{-- Kuration --}} + + @if ($product->status === ProductStatus::Pending) +
+ + + +
+ @elseif ($product->status === ProductStatus::Correction) + + {{ __('Warte auf Korrektur') }} + + @elseif ($product->is_curated) + + {{ __('Freigegeben') }} + + @else + + @endif +
+ + {{-- Erstellt --}} + + + {{ $product->created_at?->format('d.m.Y') }} + + + + {{-- Aktionen --}} + +
+ + @if (!in_array($product->status, [ProductStatus::Archived, ProductStatus::Sold])) + + + + + {{ __('Als verkauft') }} + + + {{ __('Archivieren') }} + + + + @endif +
+
+
+ + {{-- Inline Korrektur-Formular --}} + @if ($correctingProductId === $product->id) + + +
+ + {{ __('Korrekturanweisung für ":name"', ['name' => $product->name]) }} + + + +
+ + {{ __('Korrektur senden') }} + + + {{ __('Abbrechen') }} + +
+
+
+
+ @endif + + {{-- Inline Ablehnungs-Formular --}} + @if ($rejectingProductId === $product->id) + + +
+ + {{ __('Ablehnungsgrund für ":name"', ['name' => $product->name]) }} + + + +
+ + {{ __('Ablehnen') }} + + + {{ __('Abbrechen') }} + +
+
+
+
+ @endif + + {{-- Korrekturhinweis anzeigen --}} + @if ($product->status === ProductStatus::Correction && $product->curation_notes) + + + + {{ __('Korrekturanweisung') }} + {{ $product->curation_notes }} + + + + @endif + @empty + + + +
{{ __('Keine Produkte gefunden') }}
+
+
+ @endforelse +
+
+ + @if ($products->hasPages()) +
+ {{ $products->links() }} +
+ @endif +
+
diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index 78735d8..39107ec 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -39,7 +39,6 @@ new #[Layout('components.layouts.auth')] class extends Component { RateLimiter::clear($this->throttleKey()); Session::regenerate(); - $this->redirectIntended(default: route('dashboard', absolute: false), navigate: true); } diff --git a/resources/views/livewire/partner/my-data.blade.php b/resources/views/livewire/partner/my-data.blade.php index 9bb1185..ee221a6 100644 --- a/resources/views/livewire/partner/my-data.blade.php +++ b/resources/views/livewire/partner/my-data.blade.php @@ -54,7 +54,7 @@ new #[Layout('components.layouts.app'), Title('Meine Daten')] class extends Comp } $this->partner = Partner::with('users')->findOrFail($user->partner_id); - $this->partnerType = $this->partner->type; + $this->partnerType = $this->partner->type?->value ?? ''; // Vorausfüllen: Partner-Daten $this->companyName = $this->partner->company_name ?? ''; diff --git a/resources/views/livewire/partner/profile.blade.php b/resources/views/livewire/partner/profile.blade.php new file mode 100644 index 0000000..e1e178d --- /dev/null +++ b/resources/views/livewire/partner/profile.blade.php @@ -0,0 +1,216 @@ +partner = Partner::with(['hub', 'products' => function ($q) { + $q->where('status', ProductStatus::Active) + ->where('is_curated', true) + ->where('is_available', true) + ->with(['categories', 'media']) + ->latest() + ->limit(6); + }])->findOrFail($partnerId); + + $this->title = $this->partner->display_name ?? $this->partner->company_name; + } + + public function with(): array + { + return [ + 'partner' => $this->partner, + 'products' => $this->partner->products, + ]; + } +}; ?> + +
+ {{-- Partner-Header --}} + +
+ {{-- Logo / Placeholder --}} +
+ +
+ +
+ {{ $partner->display_name ?? $partner->company_name }} + @if($partner->display_name && $partner->display_name !== $partner->company_name) +
{{ $partner->company_name }}
+ @endif + +
+ @if($partner->type) + {{ $partner->type?->label() ?? $partner->type }} + @endif + @if($partner->hub) + {{ $partner->hub->name }} + @endif + @if($partner->is_active) + {{ __('Aktiv') }} + @endif +
+ + {{-- Kontaktdaten --}} +
+ @if($partner->city) + + + {{ $partner->zip }} {{ $partner->city }} + + @endif + @if($partner->phone) + + + {{ $partner->phone }} + + @endif + @if($partner->website) + + + {{ parse_url($partner->website, PHP_URL_HOST) }} + + @endif + @if($partner->founded_year) + + + {{ __('Seit') }} {{ $partner->founded_year }} + + @endif +
+
+
+
+ +
+ {{-- Linke Spalte: Story + Spezialisierungen --}} +
+ {{-- Story / Über uns --}} + @if($partner->story_text) + + {{ __('Über uns') }} +
+ {{ $partner->story_text }} +
+
+ @endif + + {{-- Produkte --}} + @if($products->isNotEmpty()) + +
+ {{ __('Aktuelle Produkte') }} + + {{ __('Alle ansehen') }} + +
+ +
+ @foreach($products as $product) +
+
+ @if($product->media->first()?->url) + {{ $product->name }} + @else + + @endif +
+
+
+ {{ $product->name }} +
+ @if($product->price_display_text) +
{{ $product->price_display_text }}
+ @elseif($product->price) +
{{ number_format($product->price, 2, ',', '.') }} €
+ @else +
{{ __('Auf Anfrage') }}
+ @endif +
+
+ @endforeach +
+
+ @endif +
+ + {{-- Rechte Spalte: Öffnungszeiten + Spezialisierungen --}} +
+ {{-- Öffnungszeiten --}} + @if($partner->opening_hours) + + {{ __('Öffnungszeiten') }} + @php + $days = [ + 'monday' => __('Montag'), + 'tuesday' => __('Dienstag'), + 'wednesday' => __('Mittwoch'), + 'thursday' => __('Donnerstag'), + 'friday' => __('Freitag'), + 'saturday' => __('Samstag'), + 'sunday' => __('Sonntag'), + ]; + @endphp +
+ @foreach($days as $key => $label) + @if(isset($partner->opening_hours[$key])) + @php $hours = $partner->opening_hours[$key]; @endphp +
+ {{ $label }} + @if(!empty($hours['closed'])) + {{ __('Geschlossen') }} + @elseif(!empty($hours['open']) && !empty($hours['close'])) + {{ $hours['open'] }} – {{ $hours['close'] }} + @else + + @endif +
+ @endif + @endforeach +
+
+ @endif + + {{-- Spezialisierungen --}} + @if($partner->specialties && count($partner->specialties) > 0) + + {{ __('Spezialisierungen') }} +
+ @foreach($partner->specialties as $specialty) + {{ $specialty }} + @endforeach +
+
+ @endif + + {{-- Adresse --}} + @if($partner->street || $partner->city) + + {{ __('Adresse') }} +
+ @if($partner->street) +
{{ $partner->street }} {{ $partner->house_number }}
+ @endif + @if($partner->zip || $partner->city) +
{{ $partner->zip }} {{ $partner->city }}
+ @endif +
+
+ @endif +
+
+
diff --git a/resources/views/livewire/products/create.blade.php b/resources/views/livewire/products/create.blade.php deleted file mode 100644 index eb8df88..0000000 --- a/resources/views/livewire/products/create.blade.php +++ /dev/null @@ -1,794 +0,0 @@ - [], - 'activeTab' => 'basis' -]); - -mount(function () { - // Initialisierung der Dummy-Daten -}); - -?> - - - -
- {{-- Header --}} -
-
- {{ __('Produkte') }} (in Entwicklung) - {{ __('Erstellen Sie ein neues Produkt') }} -
-
- @svg('heroicon-o-cube', 'w-6 h-6 text-accent-600 dark:text-accent-400') - {{ __('Neues Produkt anlegen') }} -
-
- - -
- - {{-- Tab Navigation --}} - - {{ __('Basis') }} - {{ __('Bilder') }} - {{ __('Physisch') }} - {{ __('Material & Herkunft') }} - {{ __('Kommerziell') }} - {{ __('Zuordnung & Verwaltung') }} - - - {{-- TAB 1: BASIS - Identität & Varianten --}} - @if($activeTab === 'basis') -
- - {{-- 1. Identität & Katalog --}} - - {{ __('1. Identität & Katalog') }} - - - {{ __('B2in-Artikelnummer (intern)') }} * - - {{ __('Fortlaufende Nummer (vom System vergeben)') }} - - - - {{ __('Lieferanten-Artikelnummer') }} * - - {{ __('Originalnummer des Herstellers') }} - - - - {{ __('Produktname') }} * - - {{ __('Anzeigename auf Website') }} - - - - {{ __('Marke / Hersteller') }} - - {{ __('Produzent oder Label') }} - - - - {{ __('Kategorie') }} * - - - - - - - - - {{ __('Kurzbeschreibung') }} - - {{ __('Max. 180 Zeichen für Snippets') }} - - - - {{ __('Langbeschreibung') }} - - {{ __('Detaillierter Text für Produktseite') }} - - - - {{ __('Status') }} * - - - - - - - - - {{ __('Erstelldatum / Änderungsdatum') }} - - {{ __('ISO-Datum') }} - - - - {{-- 2. Varianten & Attribute --}} - - {{ __('2. Varianten & Attribute') }} - - - {{ __('Variantenattribute (Stammdaten)') }} - - {{ __('Merkmale, die die SKUs definieren') }} - - - - {{ __('Varianten (Kombinationen)') }} - - {{ __('Konkrete Ausprägungen') }} - - - - {{ __('Weitere Attribute') }} - - {{ __('Zusatzinfos (z. B. Sitzhärte, Stil)') }} - - -
- @endif - - {{-- TAB 2: BILDER - Upload & Verwaltung --}} - @if($activeTab === 'bilder') -
- - {{-- Hauptbild & Galerie --}} - - {{ __('Produktbilder') }} - - - {{ __('Hauptbild') }} * - {{-- --}} - {{ __('Hauptansicht des Produkts (min. 1200x1200px, max. 5MB)') }} - - -
-
-
- - {{ __('Hauptbild Vorschau') }} -
-
-
- - - - - {{ __('Produktgalerie') }} - {{-- --}} - {{ __('Mehrere Bilder hochladen (max. 10 Bilder, je max. 5MB)') }} - - -
- @for($i = 1; $i <= 8; $i++) -
-
- - {{ __('Bild') }} {{ $i }} -
-
- @endfor -
-
- - {{-- Detailbilder & Ansichten --}} - - {{ __('Detailansichten & Perspektiven') }} - -
- - {{ __('Vorderansicht') }} - {{-- --}} - {{ __('Frontale Produktansicht') }} - - - - {{ __('Rückansicht') }} - {{-- --}} - {{ __('Rückseite des Produkts') }} - - - - {{ __('Seitenansicht (links)') }} - {{-- --}} - {{ __('Linke Seite') }} - - - - {{ __('Seitenansicht (rechts)') }} - {{-- --}} - {{ __('Rechte Seite') }} - - - - {{ __('Detailaufnahme 1') }} - {{-- --}} - {{ __('z.B. Material-Nahaufnahme') }} - - - - {{ __('Detailaufnahme 2') }} - {{-- --}} - {{ __('z.B. Verarbeitung, Nähte') }} - -
-
- - {{-- Ambiente & Lifestyle --}} - - {{ __('Ambiente & Lifestyle-Bilder') }} - - - {{ __('Ambiente-Bilder') }} - {{-- --}} - {{ __('Produkt in Wohnsituation (max. 5 Bilder)') }} - - -
- @for($i = 1; $i <= 3; $i++) -
-
- - {{ __('Ambiente') }} {{ $i }} -
-
- @endfor -
-
- - {{-- 360° Ansicht & Video --}} - - {{ __('360° Ansicht & Produktvideo') }} - - - {{ __('360° Bilder') }} - {{-- --}} - {{ __('Bilder für 360° Rotation (min. 24 Bilder empfohlen)') }} - - - - - - {{ __('Produktvideo') }} - {{-- --}} - {{ __('Kurzes Produktvideo (max. 50MB, MP4/WebM)') }} - - - - {{ __('Video-URL (alternativ)') }} - - {{ __('YouTube, Vimeo oder andere Video-URL') }} - - - - {{-- Technische Zeichnungen & Dokumente --}} - - {{ __('Technische Zeichnungen & Dokumente') }} - - - {{ __('Maßzeichnung') }} - {{-- --}} - {{ __('Technische Zeichnung mit Maßen (PNG, JPG oder PDF)') }} - - - - {{ __('Montageanleitung') }} - {{-- --}} - {{ __('PDF mit Montageanleitung') }} - - - - {{ __('Datenblatt / Broschüre') }} - {{-- --}} - {{ __('Produktdatenblatt als PDF') }} - - - - {{-- Bild-Metadaten & Alt-Texte --}} - - {{ __('Bild-Metadaten & SEO') }} - - - {{ __('Alt-Text (Hauptbild)') }} - - {{ __('Beschreibung für Suchmaschinen und Barrierefreiheit') }} - - - - {{ __('Bildnachweis / Copyright') }} - - {{ __('Fotografen-Nennung oder Copyright-Hinweis') }} - - - - {{ __('Bildoptimierung') }} - - {{ __('Bilder automatisch für Web optimieren (Komprimierung & Skalierung)') }} - - - - - {{ __('Wasserzeichen') }} - - {{ __('B2in-Wasserzeichen auf Bilder anwenden') }} - - - - -
- @endif - - {{-- TAB 3: PHYSISCH - Maße & Verpackung --}} - @if($activeTab === 'physisch') -
- - {{-- 3. Maße & Gewicht (Produkt) --}} - - {{ __('3. Maße & Gewicht (Produkt)') }} - -
- - {{ __('Breite (mm)') }} * - - {{ __('Gesamtbreite') }} - - - - {{ __('Tiefe (mm)') }} * - - {{ __('Gesamttiefe') }} - - - - {{ __('Höhe (mm)') }} * - - {{ __('Gesamthöhe') }} - -
- - - {{ __('Gewicht netto (kg)') }} * - - {{ __('Möbel ohne Verpackung') }} - - - - {{ __('Aufbauart') }} - - - - - - - - - {{ __('Montagezeit (min)') }} * - - {{ __('Aufbauzeit') }} - - - - {{ __('Traglast (kg)') }} - - {{ __('Belastbarkeit') }} - -
- - {{-- 4. Verpackung & Logistik --}} - - {{ __('4. Verpackung & Logistik') }} - - - {{ __('Anzahl Packstücke') }} - - - - - {{ __('Gesamtgewicht brutto (kg)') }} - - {{ __('inkl. Verpackung') }} - - - - {{ __('Verpackungsart') }} - - {{ __('Karton, Holzrahmen usw.') }} - - - - {{ __('Verpackung recyclingfähig (%)') }} - - {{ __('Anteil recycelbarer Materialien der Verpackung') }} - - - - {{ __('Kolli 1 Maße (mm)') }} - - - - - {{ __('Kolli 1 Gewicht (kg)') }} - - - - - {{ __('Palettenfähig') }} - - - - - - - - {{ __('HS-Code (Zolltarifnummer)') }} - - - -
- @endif - - {{-- TAB 4: MATERIAL & HERKUNFT - Materialien & Holzherkunft --}} - @if($activeTab === 'material') -
- - {{-- 5. Materialien & Qualität --}} - - {{ __('5. Materialien & Qualität') }} - - - {{ __('Hauptmaterial') }} * - - {{ __('Tragende Struktur') }} - - - - {{ __('Oberflächenmaterial') }} - - {{ __('Sichtflächen') }} - - - - {{ __('Bezugsmaterial') }} - - {{ __('Stoff / Leder / Synthetik') }} - - - - {{ __('Farbton / Dekor') }} - - - - - {{ __('Herkunftsland (Produktion)') }} * - - - - - - - {{ __('ISO-Land') }} - - - - {{ __('Pflegehinweise') }} - - {{ __('Mit feuchtem Tuch abwischen.') }} - - - - {{ __('Zertifikate / Labels') }} - - - - - {{-- 6. Holzherkunft & EUDR --}} - - {{ __('6. Holzherkunft & EUDR') }} - - - {{ __('Holzart(en)') }} * - - {{ __('Botanische Bezeichnung (falls Holz enthalten)') }} - - - - {{ __('Herkunftsland des Holzes') }} * - - - - - - {{ __('ISO-Code (falls Holz enthalten)') }} - - - - {{ __('Region / Provinz') }} - - {{ __('falls erforderlich für EUDR') }} - - - - {{ __('Erntejahr') }} - - {{ __('Jahr der Holzgewinnung') }} - - - - {{ __('Forstbetrieb / Lieferant') }} - - - - - {{ __('Nachhaltigkeitszertifikat') }} - - {{ __('FSC / PEFC') }} - - - - {{ __('Sorgfaltserklärung (EUDR-DD-Referenz)') }} - - {{ __('offiziell Referenz') }} - - - - {{ __('Nachweis­datei (Upload)') }} - - {{ __('PDF / Link zum Statement') }} - - -
- @endif - - {{-- TAB 5: KOMMERZIELL - Preise, Verfügbarkeit & Lieferung --}} - @if($activeTab === 'kommerziell') -
- - {{-- 7. Preise & Konditionen --}} - - {{ __('7. Preise & Konditionen') }} - - - {{ __('Einkaufspreis (net)') }} - - - - - {{ __('Verkaufspreis (net)') }} * - - {{ __('Für B2in-Plattform') }} - - - - {{ __('Währung') }} * - - - - - - - - - {{ __('Steuersatz (%)') }} * - - - - - {{ __('UVP (Brutto)') }} - - {{ __('Unverbindliche Preisempfehlung') }} - - - - {{-- 8. Verfügbarkeit & Lieferzeit --}} - - {{ __('8. Verfügbarkeit & Lieferzeit') }} - - - {{ __('Lagerstatus') }} * - - - - - - {{ __('Auf Lager / Auf Bestellung / Nicht verfügbar') }} - - - - {{ __('Lieferzeit (Wochen)') }} * - - {{ __('Min–Max-Spanne') }} - - - - {{ __('Produktionszeit (Tage)') }} - - {{ __('falls relevant') }} - - - - {{-- 9. Lieferung, Montage & Service --}} - - {{ __('9. Lieferung, Montage & Service') }} - - - {{ __('Lieferart') }} - - - - - - - {{ __('Abholung / Lieferung / Spedition / Paket') }} - - - - {{ __('Montageservice') }} - - - - - - - - {{ __('Service-Radius (km)') }} - - {{ __('Für Montageservice') }} - - - - {{ __('Garantie (Monate)') }} - - - -
- @endif - - {{-- TAB 6: VERWALTUNG - Händler, Nachhaltigkeit, Scoring & Verwaltung --}} - @if($activeTab === 'verwaltung') -
- - {{-- 10. Händler- / Herstellerzuordnung --}} - - {{ __('10. Händler- / Herstellerzuordnung') }} - - - {{ __('Verkäufertyp') }} * - - - - - - {{ __('Hersteller / Makler') }} - - - - {{ __('Verkäufername') }} * - - - - - {{ __('Verkäufer-ID') }} - - - - - {{ __('Region / Hub') }} * - - {{ __('Logistische Zuordnung') }} - - - - {{ __('Ort / PLZ') }} - - {{ __('Standort des Verkäufers/Lagers') }} - - - - {{-- 11. Nachhaltigkeit & Umwelt --}} - - {{ __('11. Nachhaltigkeit & Umwelt') }} - - - {{ __('CO₂-Fußabdruck (kg CO₂e) pro Stück') }} - - - - - {{ __('Recyclinganteil (%)') }} - - {{ __('Anteil recycelter Materialien im Produkt') }} - - - - {{ __('Regionale Produktion') }} - - - - - {{ __('Ja / Nein (Umkreis z. B. < 500 km)') }} - - - - {{-- 12. Scoring-System (B2in Internal) --}} - - {{ __('12. Scoring-System (B2in Internal)') }} - - - {{ __('Stauraumvolumen (L)') }} - - {{ __('Innenvolumen') }} - - - - {{ __('Aufbauaufwand (1–5)') }} - - - - - - - - {{ __('gering = 1') }} - - - - {{ __('Designpunkte (1–5)') }} - - - - - - - - {{ __('interne Bewertung') }} - - - - {{ __('Gesamt-Score') }} - - {{ __('automatisch berechnet') }} - - - - {{-- 13. Verwaltung & Lebenszyklus --}} - - {{ __('13. Verwaltung & Lebenszyklus') }} - - - {{ __('Sichtbar ab / bis (Datum)') }} - - {{ __('Steuerung der Veröffentlichung') }} - - - - {{ __('Freigabe durch B2in erforderlich') }} - - - - - - - - {{ __('Letzte Änderung') }} - - {{ __('Datum der letzten Aktualisierung') }} - - -
- @endif - - {{-- Submit Button (außerhalb der Tabs, immer sichtbar) --}} -
- {{ __('Abbrechen') }} - {{ __('Produkt speichern') }} -
-
-
-
diff --git a/resources/views/livewire/products/form-standard.blade.php b/resources/views/livewire/products/form-standard.blade.php new file mode 100644 index 0000000..2eb96df --- /dev/null +++ b/resources/views/livewire/products/form-standard.blade.php @@ -0,0 +1,1981 @@ +exists) { + $this->authorize('update', $product); + $this->product = $product; + $this->isEditing = true; + $this->prefillFromProduct($product); + } else { + $this->authorize('create', Product::class); + abort_unless( + auth() + ->user() + ->hasAnyRole(['Retailer', 'Manufacturer', 'Admin', 'Super-Admin']), + 403, + __('Nur Händler und Hersteller können Produkte anlegen.'), + ); + $this->visibleFrom = now()->format('Y-m-d'); + $this->visibleUntil = now()->addYears(1)->format('Y-m-d'); + $partner = auth()->user()->partner; + if ($partner) { + $nextNumber = $partner->products()->count() + 1; + $this->partnerProductNumber = sprintf('P%03d-%04d', $partner->id, $nextNumber); + } + } + } + + private function prefillFromProduct(Product $product): void + { + // Basis + $this->name = $product->name; + $this->descriptionShort = $product->description_short; + $this->descriptionLong = $product->description_long ?? ''; + $this->brandName = $product->brand?->name ?? ''; + $this->categoryId = $product->categories->first()?->id; + $this->priceType = $product->price_type->value; + $this->priceDisplayText = $product->price_display_text ?? ''; + $this->status = in_array($product->status, [ProductStatus::Pending, ProductStatus::Active, ProductStatus::Correction]) ? 'active' : 'draft'; + $this->partnerProductNumber = $product->partner_product_number ?? ''; + + // Dimensions & Material + $this->widthCm = $product->width_cm; + $this->heightCm = $product->height_cm; + $this->depthCm = $product->depth_cm; + $this->assemblyStatus = $product->assembly_status ?? ''; + $this->careInstructions = $product->care_instructions ?? ''; + $this->countryOfOrigin = $product->country_of_origin ?? ''; + $this->mainMaterial = $product->main_material ?? ''; + $this->surfaceMaterial = $product->surface_material ?? ''; + $this->coverMaterial = $product->cover_material ?? ''; + $this->colorFinish = $product->color_finish ?? ''; + $this->certificates = $product->certificates ?? []; + $this->assemblyTimeMin = $product->assembly_time_min; + $this->loadCapacityKg = $product->load_capacity_kg; + + // From master variant + $variant = $product->variants()->where('is_master_variant', true)->first(); + if ($variant) { + $this->sku = $variant->sku ?? ''; + $this->hanMpn = $variant->han_mpn ?? ''; + $this->eanGtin = $variant->ean_gtin ?? ''; + $this->sellingPrice = $variant->selling_price ? $variant->selling_price / 100 : null; + $this->purchasePrice = $variant->purchase_price ? $variant->purchase_price / 100 : null; + $this->msrp = $variant->msrp ? $variant->msrp / 100 : null; + $this->availabilityStatus = $variant->availability_status ?? 'in_stock'; + $this->deliveryTimeText = $variant->delivery_time_text ?? ''; + $this->currency = $variant->currency ?? 'EUR'; + $this->weightG = $variant->variant_weight_g; + + // From logistics + $logistics = $variant->logistics; + if ($logistics) { + $this->packageCount = $logistics->package_count; + $this->packageWeightG = $logistics->package_weight_g; + $this->packageWidthCm = $logistics->package_width_cm; + $this->packageHeightCm = $logistics->package_height_cm; + $this->packageDepthCm = $logistics->package_depth_cm; + $this->packagingType = $logistics->packaging_type ?? ''; + $this->packagingRecyclablePercent = $logistics->packaging_recyclable_percent; + $this->isPalletizable = (bool) $logistics->is_palletizable; + $this->hsCode = $logistics->hs_code ?? ''; + } + } + + // Shipping + $this->deliveryType = $product->delivery_type ?? ''; + + // Services + $this->assemblyService = (bool) $product->assembly_service; + $this->serviceRadiusKm = $product->service_radius_km; + $this->warrantyMonths = $product->warranty_months; + $this->productionTimeDays = $product->production_time_days; + + // Sustainability + $this->co2FootprintKg = $product->co2_footprint_kg ? (float) $product->co2_footprint_kg : null; + $this->recyclingPercentage = $product->recycling_percentage; + $this->isRegionalProduction = (bool) $product->is_regional_production; + + // Wood origins + $this->woodOrigins = $product->woodOrigins + ->map( + fn($wo) => [ + 'wood_species' => $wo->wood_species, + 'origin_country' => $wo->origin_country, + 'origin_region' => $wo->origin_region ?? '', + 'harvest_year' => $wo->harvest_year, + 'forest_operator' => $wo->forest_operator ?? '', + 'sustainability_certificate' => $wo->sustainability_certificate ?? '', + 'eudr_reference_id' => $wo->eudr_reference_id ?? '', + ], + ) + ->toArray(); + + // Zuordnung + $this->hubId = $product->hub_id; + $this->metaTitle = $product->meta_title ?? ''; + $this->metaDescription = $product->meta_description ?? ''; + $this->visibleIsAvailable = (bool) $product->productIsAvailable; + $this->visibleFrom = $product->visible_from?->format('Y-m-d'); + $this->visibleUntil = $product->visible_until?->format('Y-m-d'); + $this->storageVolumeLiters = $product->storage_volume_liters; + $this->assemblyEffortScore = $product->assembly_effort_score; + $this->designScore = $product->design_score; + + // Existing media (sorted by order_column) + $this->existingMedia = $product->media + ->sortBy('order_column') + ->values() + ->map( + fn($m) => [ + 'id' => $m->id, + 'file_path' => $m->file_path, + 'alt_text' => $m->alt_text, + 'order_column' => $m->order_column, + ], + ) + ->toArray(); + } + + public function addWoodOrigin(): void + { + $this->woodOrigins[] = [ + 'wood_species' => '', + 'origin_country' => '', + 'origin_region' => '', + 'harvest_year' => null, + 'forest_operator' => '', + 'sustainability_certificate' => '', + 'eudr_reference_id' => '', + ]; + } + + public function removeWoodOrigin(int $index): void + { + if (isset($this->woodOrigins[$index])) { + unset($this->woodOrigins[$index]); + $this->woodOrigins = array_values($this->woodOrigins); + } + } + + public function removeExistingMedia(int $mediaId): void + { + if (!$this->isEditing) { + return; + } + + $media = $this->product->media()->find($mediaId); + if ($media) { + Storage::disk('public')->delete($media->file_path); + $media->delete(); + $this->existingMedia = collect($this->existingMedia)->reject(fn($m) => $m['id'] === $mediaId)->values()->toArray(); + } + } + + public function removePhoto(int $index): void + { + if (isset($this->mainImages[$index])) { + unset($this->mainImages[$index]); + $this->mainImages = array_values($this->mainImages); + } + } + + /** + * Reihenfolge der vorhandenen Bilder aktualisieren. + * + * @param array $orderedIds + */ + public function updateMediaOrder(array $orderedIds): void + { + if (! $this->isEditing) { + return; + } + + foreach ($orderedIds as $position => $mediaId) { + $this->product->media()->where('id', $mediaId)->update([ + 'order_column' => $position + 1, + ]); + } + + // Sync local state + $reordered = collect($orderedIds)->map(function ($id, $index) { + $media = collect($this->existingMedia)->firstWhere('id', $id); + + return $media ? array_merge($media, ['order_column' => $index + 1]) : null; + })->filter()->values()->toArray(); + + $this->existingMedia = $reordered; + } + + public function archiveProduct(): void + { + if (! $this->isEditing) { + return; + } + + $this->authorize('delete', $this->product); + + $this->product->update(['status' => ProductStatus::Archived]); + $this->product->activities()->create([ + 'user_id' => auth()->id(), + 'action' => 'archived', + 'note' => null, + ]); + + session()->flash('message', __('Produkt wurde archiviert.')); + $this->redirect(route('products.index')); + } + + public function markAsSold(): void + { + if (! $this->isEditing) { + return; + } + + $this->authorize('update', $this->product); + + $this->product->update(['status' => ProductStatus::Sold]); + $this->product->activities()->create([ + 'user_id' => auth()->id(), + 'action' => 'sold', + 'note' => null, + ]); + + session()->flash('message', __('Produkt wurde als verkauft markiert.')); + $this->redirect(route('products.index')); + } + + /** + * @return array + */ + private function validationRules(): array + { + $allowedPriceTypes = collect(ProductType::SmartOrder->allowedPriceTypes()) + ->map(fn(PriceType $pt) => $pt->value) + ->implode(','); + + // SKU unique validation ignores own variant when editing + $skuRule = 'nullable|string|max:100|unique:product_variants,sku'; + if ($this->isEditing) { + $masterVariant = $this->product->variants()->where('is_master_variant', true)->first(); + if ($masterVariant) { + $skuRule = "nullable|string|max:100|unique:product_variants,sku,{$masterVariant->id}"; + } + } + + return [ + // Basis + 'name' => 'required|string|max:255', + 'partnerProductNumber' => 'nullable|string|max:100', + 'descriptionShort' => 'required|string|max:180', + 'descriptionLong' => 'nullable|string|max:10000', + 'brandName' => 'nullable|string|max:255', + 'categoryId' => 'required|exists:categories,id', + 'priceType' => "required|in:{$allowedPriceTypes}", + 'priceDisplayText' => 'required_if:priceType,from_price|nullable|string|max:100', + 'status' => 'required|in:active,draft', + // Bilder + 'mainImages' => 'nullable|array|min:0|max:10', + 'mainImages.*' => 'mimes:jpeg,jpg,png|max:10240', + // Maße & Material + 'widthCm' => 'nullable|integer|min:1', + 'heightCm' => 'nullable|integer|min:1', + 'depthCm' => 'nullable|integer|min:1', + 'weightG' => 'nullable|integer|min:1', + 'assemblyStatus' => 'nullable|in:assembled,partially,disassembled', + 'careInstructions' => 'nullable|string|max:5000', + 'countryOfOrigin' => 'nullable|string|size:2', + 'mainMaterial' => 'nullable|string|max:255', + 'surfaceMaterial' => 'nullable|string|max:255', + 'coverMaterial' => 'nullable|string|max:255', + 'colorFinish' => 'nullable|string|max:255', + 'certificates' => 'nullable|array', + 'certificates.*' => 'string|max:100', + 'assemblyTimeMin' => 'nullable|integer|min:1', + 'loadCapacityKg' => 'nullable|integer|min:1', + // Verpackung & Versand + 'packageCount' => 'nullable|integer|min:1', + 'packageWeightG' => 'nullable|integer|min:1', + 'packageWidthCm' => 'nullable|integer|min:1', + 'packageHeightCm' => 'nullable|integer|min:1', + 'packageDepthCm' => 'nullable|integer|min:1', + 'packagingType' => 'nullable|string|max:100', + 'packagingRecyclablePercent' => 'nullable|integer|min:0|max:100', + 'isPalletizable' => 'boolean', + 'hsCode' => 'nullable|string|max:20', + 'deliveryType' => 'nullable|string|max:100', + // Kommerziell + 'sku' => $skuRule, + 'sellingPrice' => 'nullable|numeric|min:0.01', + 'purchasePrice' => 'nullable|numeric|min:0', + 'msrp' => 'nullable|numeric|min:0', + 'availabilityStatus' => 'nullable|in:in_stock,on_order,out_of_stock', + 'deliveryTimeText' => 'nullable|string|max:100', + 'currency' => 'nullable|string|size:3', + 'productionTimeDays' => 'nullable|integer|min:1', + // Services + 'assemblyService' => 'boolean', + 'serviceRadiusKm' => 'nullable|integer|min:1', + 'warrantyMonths' => 'nullable|integer|min:1|max:120', + // Nachhaltigkeit + 'co2FootprintKg' => 'nullable|numeric|min:0', + 'recyclingPercentage' => 'nullable|integer|min:0|max:100', + 'isRegionalProduction' => 'boolean', + 'woodOrigins' => 'nullable|array', + 'woodOrigins.*.wood_species' => 'required_with:woodOrigins.*.origin_country|string|max:255', + 'woodOrigins.*.origin_country' => 'required_with:woodOrigins.*.wood_species|string|size:2', + 'woodOrigins.*.origin_region' => 'nullable|string|max:255', + 'woodOrigins.*.harvest_year' => 'nullable|integer|min:2000|max:2030', + 'woodOrigins.*.forest_operator' => 'nullable|string|max:255', + 'woodOrigins.*.sustainability_certificate' => 'nullable|string|max:100', + 'woodOrigins.*.eudr_reference_id' => 'nullable|string|max:255', + // Zuordnung & Verwaltung + 'hubId' => 'nullable|exists:hubs,id', + 'metaTitle' => 'nullable|string|max:255', + 'metaDescription' => 'nullable|string|max:500', + 'visibleFrom' => 'nullable|date', + 'visibleUntil' => 'nullable|date|after_or_equal:visibleFrom', + 'storageVolumeLiters' => 'nullable|integer|min:0', + 'assemblyEffortScore' => 'nullable|integer|min:1|max:5', + 'designScore' => 'nullable|integer|min:1|max:5', + ]; + } + + /** + * @return array + */ + private function validationMessages(): array + { + return [ + 'name.required' => __('Bitte geben Sie einen Produktnamen ein.'), + 'descriptionShort.required' => __('Bitte geben Sie eine Kurzbeschreibung ein.'), + 'descriptionShort.max' => __('Die Kurzbeschreibung darf maximal 180 Zeichen lang sein.'), + 'categoryId.required' => __('Bitte wählen Sie eine Kategorie aus.'), + 'priceType.required' => __('Bitte wählen Sie einen Preistyp.'), + 'priceDisplayText.required_if' => __('Bei Ab-Preis ist eine Preisangabe erforderlich (z.B. "Ab 2.500 €").'), + 'mainImages.max' => __('Maximal 10 Produktbilder erlaubt.'), + 'mainImages.*.mimes' => __('Nur Bilder (JPG, JPEG, PNG) erlaubt.'), + 'mainImages.*.max' => __('Bilder dürfen maximal 10 MB groß sein.'), + 'sku.unique' => __('Diese Artikelnummer ist bereits vergeben.'), + 'sellingPrice.min' => __('Der Verkaufspreis muss größer als 0 sein.'), + 'countryOfOrigin.size' => __('Bitte geben Sie einen gültigen 2-stelligen ISO-Ländercode ein (z.B. DE).'), + 'visibleUntil.after_or_equal' => __('Das Enddatum muss nach dem Startdatum liegen.'), + 'assemblyEffortScore.min' => __('Der Aufbauaufwand muss zwischen 1 und 5 liegen.'), + 'assemblyEffortScore.max' => __('Der Aufbauaufwand muss zwischen 1 und 5 liegen.'), + 'recyclingPercentage.min' => __('Der Recyclinganteil muss zwischen 0 und 100 liegen.'), + 'recyclingPercentage.max' => __('Der Recyclinganteil muss zwischen 0 und 100 liegen.'), + ]; + } + + public function save(): void + { + if ($this->isEditing) { + $this->authorize('update', $this->product); + } else { + $this->authorize('create', Product::class); + } + + try { + $this->validate($this->validationRules(), $this->validationMessages()); + } catch (\Illuminate\Validation\ValidationException $e) { + $this->switchToTabWithError($e); + + throw $e; + } + + if ($this->isEditing) { + $this->saveExisting(); + + $toastMessage = $this->status === 'active' ? __('Standard-Produkt wurde zur Freigabe eingereicht.') : __('Standard-Produkt wurde als Entwurf gespeichert.'); + + Flux::toast(variant: 'success', text: $toastMessage, duration: 5000); + + // Refresh existing media after save (sorted by order_column) + $this->existingMedia = $this->product + ->fresh() + ->media + ->sortBy('order_column') + ->values() + ->map( + fn($m) => [ + 'id' => $m->id, + 'file_path' => $m->file_path, + 'alt_text' => $m->alt_text, + 'order_column' => $m->order_column, + ], + ) + ->toArray(); + $this->mainImages = []; + } else { + $this->saveNew(); + + $flashMessage = $this->status === 'active' ? __('Standard-Produkt wurde zur Freigabe eingereicht.') : __('Standard-Produkt wurde als Entwurf gespeichert.'); + session()->flash('message', $flashMessage); + + $this->redirect(route('products.index')); + } + } + + private function switchToTabWithError(\Illuminate\Validation\ValidationException $e): void + { + $tabFields = [ + 'basis' => ['name', 'partnerProductNumber', 'descriptionShort', 'descriptionLong', 'brandName', 'categoryId', 'priceType', 'priceDisplayText', 'status'], + 'bilder' => ['mainImages'], + 'material' => ['widthCm', 'heightCm', 'depthCm', 'weightG', 'assemblyStatus', 'careInstructions', 'countryOfOrigin', 'mainMaterial', 'surfaceMaterial', 'coverMaterial', 'colorFinish', 'certificates', 'assemblyTimeMin', 'loadCapacityKg'], + 'versand' => ['packageCount', 'packageWeightG', 'packageWidthCm', 'packageHeightCm', 'packageDepthCm', 'packagingType', 'packagingRecyclablePercent', 'isPalletizable', 'hsCode', 'deliveryType'], + 'kommerziell' => ['sku', 'sellingPrice', 'purchasePrice', 'msrp', 'availabilityStatus', 'deliveryTimeText', 'currency', 'productionTimeDays'], + 'services' => ['assemblyService', 'serviceRadiusKm', 'warrantyMonths'], + 'nachhaltigkeit' => ['co2FootprintKg', 'recyclingPercentage', 'isRegionalProduction', 'woodOrigins'], + 'zuordnung' => ['hubId', 'metaTitle', 'metaDescription', 'visibleFrom', 'visibleUntil', 'storageVolumeLiters', 'assemblyEffortScore', 'designScore'], + ]; + + $errorKeys = array_keys($e->validator->errors()->toArray()); + + foreach ($tabFields as $tab => $fields) { + foreach ($errorKeys as $errorKey) { + $baseKey = explode('.', $errorKey)[0]; + if (in_array($baseKey, $fields)) { + $this->activeTab = $tab; + + return; + } + } + } + } + + private function saveNew(): void + { + $user = auth()->user(); + $partner = $user->partner; + + // Marke suchen oder neu anlegen + $brandId = $this->resolveOrCreateBrand($partner); + + // B2in-Artikelnummer automatisch generieren + $lastNumber = Product::whereNotNull('b2in_article_number')->orderByDesc('b2in_article_number')->value('b2in_article_number'); + $nextSeq = $lastNumber ? ((int) str_replace('B2IN-', '', $lastNumber)) + 1 : 1; + $b2inArticleNumber = sprintf('B2IN-%06d', $nextSeq); + + $product = Product::create([ + 'partner_id' => $partner->id, + 'partner_product_number' => $this->partnerProductNumber ?: null, + 'b2in_article_number' => $b2inArticleNumber, + 'hub_id' => $this->hubId ?? $partner->hub_id, + 'brand_id' => $brandId, + 'name' => $this->name, + 'slug' => str($this->name) + ->slug() + ->append('-' . uniqid()), + 'product_type' => ProductType::SmartOrder, + 'status' => $this->status === 'active' ? ProductStatus::Pending : ProductStatus::Draft, + 'price_type' => PriceType::from($this->priceType), + 'price_display_text' => $this->priceDisplayText ?: null, + 'description_short' => $this->descriptionShort, + 'description_long' => $this->descriptionLong ?: null, + 'care_instructions' => $this->careInstructions ?: null, + 'width_cm' => $this->widthCm, + 'height_cm' => $this->heightCm, + 'depth_cm' => $this->depthCm, + 'assembly_status' => $this->assemblyStatus ?: null, + 'country_of_origin' => $this->countryOfOrigin ?: null, + 'main_material' => $this->mainMaterial ?: null, + 'surface_material' => $this->surfaceMaterial ?: null, + 'cover_material' => $this->coverMaterial ?: null, + 'color_finish' => $this->colorFinish ?: null, + 'certificates' => !empty($this->certificates) ? $this->certificates : null, + 'assembly_time_min' => $this->assemblyTimeMin, + 'load_capacity_kg' => $this->loadCapacityKg, + 'delivery_type' => $this->deliveryType ?: null, + 'assembly_service' => $this->assemblyService, + 'service_radius_km' => $this->serviceRadiusKm, + 'warranty_months' => $this->warrantyMonths, + 'production_time_days' => $this->productionTimeDays, + 'visible_from' => $this->visibleIsAvailable ? $this->visibleFrom : null, + 'visible_until' => $this->visibleIsAvailable ? $this->visibleUntil : null, + 'co2_footprint_kg' => $this->co2FootprintKg, + 'recycling_percentage' => $this->recyclingPercentage, + 'is_regional_production' => $this->isRegionalProduction, + 'storage_volume_liters' => $this->storageVolumeLiters, + 'assembly_effort_score' => $this->assemblyEffortScore, + 'design_score' => $this->designScore, + 'meta_title' => $this->metaTitle ?: null, + 'meta_description' => $this->metaDescription ?: null, + 'is_available' => true, + 'is_curated' => false, + ]); + + // Kategorie zuordnen + $product->categories()->attach($this->categoryId); + + // Master-Variante erstellen + $variant = $product->variants()->create([ + 'name_suffix' => null, + 'is_master_variant' => true, + 'sku' => $this->sku ?: null, + 'han_mpn' => $this->hanMpn ?: null, + 'ean_gtin' => $this->eanGtin ?: null, + 'selling_price' => $this->sellingPrice ? (int) round($this->sellingPrice * 100) : null, + 'purchase_price' => $this->purchasePrice ? (int) round($this->purchasePrice * 100) : null, + 'msrp' => $this->msrp ? (int) round($this->msrp * 100) : null, + 'availability_status' => $this->availabilityStatus ?: null, + 'delivery_time_text' => $this->deliveryTimeText ?: null, + 'currency' => $this->currency ?: 'EUR', + 'variant_weight_g' => $this->weightG, + 'is_active' => true, + ]); + + // Logistik-Daten (falls vorhanden) + if ($this->packageCount || $this->packageWeightG || $this->packageWidthCm || $this->packagingType || $this->hsCode) { + $variant->logistics()->create([ + 'package_count' => $this->packageCount, + 'package_weight_g' => $this->packageWeightG, + 'package_width_cm' => $this->packageWidthCm, + 'package_height_cm' => $this->packageHeightCm, + 'package_depth_cm' => $this->packageDepthCm, + 'packaging_type' => $this->packagingType ?: null, + 'packaging_recyclable_percent' => $this->packagingRecyclablePercent, + 'is_palletizable' => $this->isPalletizable, + 'hs_code' => $this->hsCode ?: null, + ]); + } + + // Bilder speichern + $index = 1; + foreach ($this->mainImages as $image) { + $path = $image->store('products/' . $product->id, 'public'); + $product->media()->create([ + 'file_path' => $path, + 'type' => 'image', + 'alt_text' => $this->name, + 'order_column' => $index++, + ]); + } + + // EUDR Holzherkunft speichern + $this->saveWoodOrigins($product); + + // Activity log + $product->activities()->create([ + 'user_id' => auth()->id(), + 'action' => 'created', + 'note' => null, + ]); + } + + private function saveExisting(): void + { + $user = auth()->user(); + $partner = $user->partner; + + // Marke suchen oder neu anlegen + $brandId = $this->resolveOrCreateBrand($partner); + + // Status logic for editing + $newStatus = ProductStatus::Draft; + if ($this->status === 'active') { + if ($this->product->status === ProductStatus::Draft) { + $newStatus = ProductStatus::Pending; + } elseif (in_array($this->product->status, [ProductStatus::Pending, ProductStatus::Active, ProductStatus::Correction])) { + $newStatus = ProductStatus::Pending; + } + } + + $this->product->update([ + 'partner_product_number' => $this->partnerProductNumber ?: null, + 'hub_id' => $this->hubId ?? $partner->hub_id, + 'brand_id' => $brandId, + 'name' => $this->name, + 'status' => $newStatus, + 'price_type' => PriceType::from($this->priceType), + 'price_display_text' => $this->priceDisplayText ?: null, + 'description_short' => $this->descriptionShort, + 'description_long' => $this->descriptionLong ?: null, + 'care_instructions' => $this->careInstructions ?: null, + 'width_cm' => $this->widthCm, + 'height_cm' => $this->heightCm, + 'depth_cm' => $this->depthCm, + 'assembly_status' => $this->assemblyStatus ?: null, + 'country_of_origin' => $this->countryOfOrigin ?: null, + 'main_material' => $this->mainMaterial ?: null, + 'surface_material' => $this->surfaceMaterial ?: null, + 'cover_material' => $this->coverMaterial ?: null, + 'color_finish' => $this->colorFinish ?: null, + 'certificates' => !empty($this->certificates) ? $this->certificates : null, + 'assembly_time_min' => $this->assemblyTimeMin, + 'load_capacity_kg' => $this->loadCapacityKg, + 'delivery_type' => $this->deliveryType ?: null, + 'assembly_service' => $this->assemblyService, + 'service_radius_km' => $this->serviceRadiusKm, + 'warranty_months' => $this->warrantyMonths, + 'production_time_days' => $this->productionTimeDays, + 'visible_from' => $this->visibleIsAvailable ? $this->visibleFrom : null, + 'visible_until' => $this->visibleIsAvailable ? $this->visibleUntil : null, + 'co2_footprint_kg' => $this->co2FootprintKg, + 'recycling_percentage' => $this->recyclingPercentage, + 'is_regional_production' => $this->isRegionalProduction, + 'storage_volume_liters' => $this->storageVolumeLiters, + 'assembly_effort_score' => $this->assemblyEffortScore, + 'design_score' => $this->designScore, + 'meta_title' => $this->metaTitle ?: null, + 'meta_description' => $this->metaDescription ?: null, + ]); + + // Kategorie aktualisieren + $this->product->categories()->sync([$this->categoryId]); + + // Master-Variante aktualisieren oder erstellen + $variant = $this->product->variants()->where('is_master_variant', true)->first(); + $variantData = [ + 'name_suffix' => null, + 'is_master_variant' => true, + 'sku' => $this->sku ?: null, + 'han_mpn' => $this->hanMpn ?: null, + 'ean_gtin' => $this->eanGtin ?: null, + 'selling_price' => $this->sellingPrice ? (int) round($this->sellingPrice * 100) : null, + 'purchase_price' => $this->purchasePrice ? (int) round($this->purchasePrice * 100) : null, + 'msrp' => $this->msrp ? (int) round($this->msrp * 100) : null, + 'availability_status' => $this->availabilityStatus ?: null, + 'delivery_time_text' => $this->deliveryTimeText ?: null, + 'currency' => $this->currency ?: 'EUR', + 'variant_weight_g' => $this->weightG, + 'is_active' => true, + ]; + + if ($variant) { + $variant->update($variantData); + } else { + $variant = $this->product->variants()->create($variantData); + } + + // Logistik-Daten aktualisieren + if ($this->packageCount || $this->packageWeightG || $this->packageWidthCm || $this->packagingType || $this->hsCode) { + $logisticsData = [ + 'package_count' => $this->packageCount, + 'package_weight_g' => $this->packageWeightG, + 'package_width_cm' => $this->packageWidthCm, + 'package_height_cm' => $this->packageHeightCm, + 'package_depth_cm' => $this->packageDepthCm, + 'packaging_type' => $this->packagingType ?: null, + 'packaging_recyclable_percent' => $this->packagingRecyclablePercent, + 'is_palletizable' => $this->isPalletizable, + 'hs_code' => $this->hsCode ?: null, + ]; + + if ($variant->logistics) { + $variant->logistics->update($logisticsData); + } else { + $variant->logistics()->create($logisticsData); + } + } elseif ($variant->logistics) { + $variant->logistics->delete(); + } + + // Neue Bilder speichern + $maxOrder = $this->product->media()->max('order_column') ?? 0; + $index = $maxOrder + 1; + foreach ($this->mainImages as $image) { + $path = $image->store('products/' . $this->product->id, 'public'); + $this->product->media()->create([ + 'file_path' => $path, + 'type' => 'image', + 'alt_text' => $this->name, + 'order_column' => $index++, + ]); + } + + // EUDR Holzherkunft aktualisieren + $this->product->woodOrigins()->delete(); + $this->saveWoodOrigins($this->product); + + // Activity log + $this->product->activities()->create([ + 'user_id' => auth()->id(), + 'action' => 'updated', + 'note' => null, + ]); + } + + private function resolveOrCreateBrand($partner): ?int + { + if (!$this->brandName) { + return null; + } + + $brand = Brand::where('name', $this->brandName)->where(fn($q) => $q->whereNull('partner_id')->orWhere('partner_id', $partner->id))->first(); + + if (!$brand) { + $brand = Brand::create([ + 'partner_id' => $partner->id, + 'name' => $this->brandName, + 'slug' => str($this->brandName) + ->slug() + ->append('-' . uniqid()), + 'is_active' => true, + ]); + } + + return $brand->id; + } + + private function saveWoodOrigins(Product $product): void + { + foreach ($this->woodOrigins as $origin) { + if (!empty($origin['wood_species']) && !empty($origin['origin_country'])) { + $product->woodOrigins()->create([ + 'wood_species' => $origin['wood_species'], + 'origin_country' => $origin['origin_country'], + 'origin_region' => $origin['origin_region'] ?: null, + 'harvest_year' => $origin['harvest_year'] ?: null, + 'forest_operator' => $origin['forest_operator'] ?: null, + 'sustainability_certificate' => $origin['sustainability_certificate'] ?: null, + 'eudr_reference_id' => $origin['eudr_reference_id'] ?: null, + ]); + } + } + } + + public function with(): array + { + $data = [ + 'categories' => Category::orderBy('name')->get(['id', 'name']), + 'brands' => Brand::where('is_active', true)->where(fn($q) => $q->whereNull('partner_id')->orWhere('partner_id', auth()->user()->partner_id))->orderBy('name')->pluck('name'), + 'hubs' => Hub::where('is_active', true) + ->orderBy('name') + ->get(['id', 'name']), + 'allowedPriceTypes' => ProductType::SmartOrder->allowedPriceTypes(), + 'countries' => collect([ + 'DE' => 'Deutschland', + 'AT' => 'Österreich', + 'CH' => 'Schweiz', + 'PL' => 'Polen', + 'CZ' => 'Tschechien', + 'FR' => 'Frankreich', + 'IT' => 'Italien', + 'NL' => 'Niederlande', + 'BE' => 'Belgien', + 'DK' => 'Dänemark', + 'SE' => 'Schweden', + 'FI' => 'Finnland', + 'NO' => 'Norwegen', + 'RO' => 'Rumänien', + 'SK' => 'Slowakei', + 'HU' => 'Ungarn', + 'SI' => 'Slowenien', + 'HR' => 'Kroatien', + 'PT' => 'Portugal', + 'ES' => 'Spanien', + 'CN' => 'China', + 'VN' => 'Vietnam', + 'IN' => 'Indien', + 'TR' => 'Türkei', + ]), + 'isEditing' => $this->isEditing, + ]; + + if ($this->isEditing) { + $data['existingMedia'] = $this->existingMedia; + $data['activities'] = $this->product->activities()->with('user')->latest()->take(10)->get(); + } + + return $data; + } +}; ?> + +
+ {{-- Header --}} +
+ +
+ + {{ $isEditing ? __('Standard-Produkt bearbeiten') : __('Standard-Produkt anlegen') }} + + + {{ __('Smart Club') }} + {{ __('Typ B – Vollständig konfigurierbar, Ticket optional') }} + +
+
+ + {{-- Kuration-Hinweis (Korrektur oder Ablehnung) --}} + @if ($isEditing && $product->curation_notes && in_array($product->status, [ProductStatus::Correction, ProductStatus::Archived])) + + + {{ $product->status === ProductStatus::Correction ? __('Korrektur erforderlich') : __('Produkt abgelehnt') }} + + {{ $product->curation_notes }} + + @endif + +
+ + {{-- Tab Navigation --}} + + {{ __('Basis') }} + {{ __('Bilder') }} + {{ __('Maße & Material') }} + {{ __('Verpackung & Versand') }} + {{ __('Kommerziell') }} + {{ __('Services') }} + {{ __('Nachhaltigkeit') }} + {{ __('Zuordnung') }} + + + {{-- ============================================================= --}} + {{-- TAB 1: BASIS --}} + {{-- ============================================================= --}} + @if ($activeTab === 'basis') +
+ + +
+ {{ __('Produktinformationen') }} +
+ + +
+ + {{ __('Produktname') }} + + + + + + {{ __('Kurzbeschreibung') }} + {{ __('Max. 180 Zeichen – wird im Feed und auf der Karte angezeigt') }} + + +
+ {{ strlen($descriptionShort) }} / 180 +
+ +
+ + + {{ __('Langbeschreibung') }} + {{ __('Detaillierter Text für die Produktseite') }} + + + + +
+ + {{ __('Marke / Hersteller') }} + {{ __('Aus Liste wählen oder neuen Namen eingeben') }} + + + @foreach ($brands as $brand) + {{ $brand }} + @endforeach + + + + + + {{ __('Kategorie') }} + {{ __('Aus Liste wählen') }} + + {{ __('– Bitte wählen –') }} + + @foreach ($categories as $category) + {{ $category->name }} + + @endforeach + + + +
+
+
+ + {{-- Produktnummer --}} + +
+ {{ __('Produktnummer') }} +
+ + +
+ @if ($isEditing) + + {{ __('B2in Artikelnummer') }} + + {{ $product->b2in_article_number }} + + @endif + + + {{ __('Partner-Produktnummer') }} + {{ __('Automatisch generiert – kann bei Bedarf überschrieben werden.') }} + + + + +
+
+ + {{-- Preisangabe --}} + +
+ {{ __('Preisangabe') }} + {{ __('Standard-Produkte unterstützen alle Preistypen.') }} +
+ + +
+ + {{ __('Preistyp') }} + + @foreach ($allowedPriceTypes as $type) + {{ $type->label() }} + + @endforeach + + + + + @if ($priceType === 'from_price') + + {{ __('Preisangabe (Freitext)') }} + {{ __('z.B. "Ab 2.500 €" oder "Ab 1.800 € pro Laufmeter"') }} + + + + + @endif +
+
+ + {{-- Status --}} + +
+ {{ __('Veröffentlichung') }} +
+ + +
+ @if ($isEditing) + + {{ __('Aktueller Status') }} +
+ + {{ $product->status->label() }} +
+
+ + @if ($product->status === \App\Enums\ProductStatus::Correction && $product->curation_notes) + + {{ __('Korrektur erforderlich') }} + {{ $product->curation_notes }} + + @endif + @endif + + + {{ __('Status') }} + + {{ __('Zur Freigabe einreichen') }} + + {{ __('Entwurf – erst speichern') }} + + + + + + {{ __('Freigabe-Workflow') }} + + {{ __('Eingereichte Produkte werden vom Admin geprüft. Nach Freigabe wird das Produkt veröffentlicht. Bei Korrekturbedarf erhalten Sie eine Rückmeldung.') }} + + +
+
+ +
+ @endif + + {{-- ============================================================= --}} + {{-- TAB 2: BILDER --}} + {{-- ============================================================= --}} + @if ($activeTab === 'bilder') +
+ + +
+ {{ __('Produktbilder') }} + {{ __('Nur Bilder (JPG, JPEG, PNG) – max. 10 MB pro Bild, max. 10 Bilder') }} + +
+ + + {{-- Existing images (only in edit mode) - sortable via drag & drop --}} + @if ($isEditing && count($existingMedia) > 0) +
+ {{ __('Vorhandene Bilder') }} + {{ __('Per Drag & Drop sortieren – das erste Bild ist das Standardbild.') }} +
+ @foreach ($existingMedia as $mediaIndex => $media) +
+ @if ($mediaIndex === 0) +
+ {{ __('Standard') }} +
+ @endif + {{ $media['alt_text'] ?? '' }} + +
+ +
+
+ @endforeach +
+
+ + @endif + + {{-- Upload area for new images --}} + + + + + @if (isset($mainImages) && count($mainImages) > 0) +
+ @foreach ($mainImages as $index => $image) + + + + + + @endforeach +
+ @endif + + +
+ +
+ @endif + + {{-- ============================================================= --}} + {{-- TAB 3: MAßE & MATERIAL --}} + {{-- ============================================================= --}} + @if ($activeTab === 'material') +
+ + {{-- Maße & Gewicht --}} + +
+ {{ __('Maße & Gewicht') }} +
+ + +
+
+ + {{ __('Breite (cm)') }} + + + + + + {{ __('Tiefe (cm)') }} + + + + + + {{ __('Höhe (cm)') }} + + + +
+ +
+ + {{ __('Gewicht netto (g)') }} + {{ __('Produkt ohne Verpackung, in Gramm') }} + + + + + + {{ __('Traglast (kg)') }} + {{ __('Maximale Belastbarkeit') }} + + + +
+ +
+ + {{ __('Aufbauart') }} + + {{ __('– Nicht angegeben –') }} + + {{ __('Montiert') }} + {{ __('Teilmontiert') }} + + {{ __('Zerlegt') }} + + + + + + {{ __('Montagezeit (Minuten)') }} + + + +
+
+
+ + {{-- Material & Beschaffenheit --}} + +
+ {{ __('Material & Beschaffenheit') }} +
+ + +
+
+ + {{ __('Hauptmaterial') }} + {{ __('Tragende Struktur') }} + + + + + + {{ __('Oberflächenmaterial') }} + {{ __('Sichtflächen') }} + + + +
+ +
+ + {{ __('Bezugsmaterial') }} + {{ __('Stoff / Leder / Synthetik') }} + + + + + + {{ __('Farbton / Dekor') }} + {{ __('z.B. Eiche natur / Anthrazit') }} + + + +
+ + + {{ __('Herkunftsland (Produktion)') }} + {{ __('Wo wird das Produkt hergestellt?') }} + + {{ __('– Nicht angegeben –') }} + + @foreach ($countries as $code => $name) + {{ $name }} + ({{ $code }}) + @endforeach + + + + + + {{ __('Zertifikate & Labels') }} + {{ __('Qualitäts- und Nachhaltigkeitszertifikate') }} + + FSC + PEFC + {{ __('Blauer Engel') }} + OEKO-TEX + EU Ecolabel + Cradle to Cradle + GS-Zeichen + + + + + + {{ __('Pflegehinweise') }} + + + +
+
+ +
+ @endif + + {{-- ============================================================= --}} + {{-- TAB 4: VERPACKUNG & VERSAND --}} + {{-- ============================================================= --}} + @if ($activeTab === 'versand') +
+ + {{-- Packstücke --}} + +
+ {{ __('Verpackung') }} +
+ + +
+
+ + {{ __('Anzahl Packstücke') }} + {{ __('Anzahl der Packstücke, die das Produkt enthalten') }} + + + + + + + {{ __('Gesamtgewicht brutto (g)') }} + {{ __('Inkl. Verpackung, in Gramm') }} + + + +
+ +
+ + {{ __('Paket Breite (cm)') }} + + + + + + {{ __('Paket Tiefe (cm)') }} + + + + + + {{ __('Paket Höhe (cm)') }} + + + +
+ + + +
+ + {{ __('Verpackungsart') }} + + {{ __('– Nicht angegeben –') }} + + {{ __('Karton') }} + + {{ __('Karton mit Kantenschutz') }} + {{ __('Holzkiste') }} + {{ __('Palette') }} + {{ __('Folie / Schrumpffolie') }} + + {{ __('Sonstiges') }} + + + + + + {{ __('Verpackung recyclingfähig (%)') }} + + + +
+ + + + {{ __('Palettierfähig') }} + + + + {{ __('HS-Code (Zolltarifnummer)') }} + + + +
+
+ + {{-- Versand --}} + +
+ {{ __('Versand & Lieferung') }} +
+ + + + {{ __('Lieferart') }} + + {{ __('– Nicht angegeben –') }} + {{ __('Spedition') }} + {{ __('Paketdienst') }} + {{ __('Abholung') }} + {{ __('Eigene Lieferung') }} + + + + +
+ +
+ @endif + + {{-- ============================================================= --}} + {{-- TAB 5: KOMMERZIELL --}} + {{-- ============================================================= --}} + @if ($activeTab === 'kommerziell') +
+ + {{-- Preise & Artikelnummern --}} + +
+ {{ __('Preise & Artikelnummern') }} +
+ + +
+ + {{ __('Artikelnummer (SKU)') }} + {{ __('Eindeutige Nummer für dieses Produkt') }} + + + + +
+ + {{ __('Hersteller-Artikelnummer (MPN)') }} + + + + + + {{ __('EAN / GTIN') }} + + + +
+ + + +
+ + {{ __('Verkaufspreis netto (€)') }} + {{ __('Preis für den Verkauf an Kunden') }} + + + + + + + {{ __('Einkaufspreis netto (€)') }} + {{ __('Preis für den Einkauf von den Herstellern') }} + + + + + + + {{ __('UVP brutto (€)') }} + {{ __('Unverbindliche Preisempfehlung') }} + + + +
+ + + {{ __('Währung') }} + + EUR – Euro + CHF – Schweizer Franken + GBP – Britisches Pfund + USD – US-Dollar + + + +
+
+ + {{-- Verfügbarkeit & Lieferzeit --}} + +
+ {{ __('Verfügbarkeit & Lieferzeit') }} +
+ + +
+ + {{ __('Lagerstatus') }} + + {{ __('Auf Lager') }} + {{ __('Auf Bestellung') }} + {{ __('Nicht verfügbar') }} + + + + + +
+ + {{ __('Lieferzeit') }} + {{ __('Freitext, z.B. "4–6 Wochen"') }} + + + + + + {{ __('Produktionszeit (Tage)') }} + {{ __('Dauer der Produktion in Tagen') }} + + + +
+
+
+ +
+ @endif + + {{-- ============================================================= --}} + {{-- TAB 6: SERVICES & GARANTIE --}} + {{-- ============================================================= --}} + @if ($activeTab === 'services') +
+ + +
+ {{ __('Montage & Service') }} +
+ + +
+ + + {{ __('Montageservice anbieten') }} + + + @if ($assemblyService) + + {{ __('Service-Radius (km)') }} + {{ __('Maximaler Umkreis für Montageservice') }} + + + + @endif +
+
+ + +
+ {{ __('Garantie') }} +
+ + + + {{ __('Garantie (Monate)') }} + + + +
+ +
+ @endif + + {{-- ============================================================= --}} + {{-- TAB 7: NACHHALTIGKEIT & EUDR --}} + {{-- ============================================================= --}} + @if ($activeTab === 'nachhaltigkeit') +
+ + {{-- Nachhaltigkeit --}} + +
+ {{ __('Umwelt & Nachhaltigkeit') }} +
+ + +
+
+ + {{ __('CO₂-Fußabdruck (kg CO₂e)') }} + {{ __('Pro Stück') }} + + + + + + {{ __('Recyclinganteil (%)') }} + {{ __('Anteil recycelter Materialien im Produkt') }} + + + + +
+ + + + {{ __('Regionale Produktion (< 500 km)') }} + +
+
+ + {{-- EUDR Holzherkunft --}} + +
+ {{ __('Holzherkunft & EUDR') }} + + {{ __('EU-Entwaldungsverordnung – Angaben zur Holzherkunft (falls Holz enthalten)') }} + +
+ + +
+ @forelse($woodOrigins as $index => $origin) +
+
+ {{ __('Holzart') }} {{ $index + 1 }} + + +
+ +
+
+ + {{ __('Holzart (botanisch)') }} + + + + + + {{ __('Herkunftsland') }} + + {{ __('– Bitte wählen –') }} + + @foreach ($countries as $code => $cname) + {{ $cname }} + ({{ $code }}) + @endforeach + + + +
+ +
+ + {{ __('Region / Provinz') }} + + + + + {{ __('Erntejahr') }} + + + + + {{ __('Forstbetrieb / Lieferant') }} + + +
+ +
+ + {{ __('Nachhaltigkeitszertifikat') }} + + {{ __('– Keines –') }} + + FSC + PEFC + FSC / PEFC + + + + + {{ __('EUDR-Referenz-ID') }} + + +
+
+
+ @empty + + + {{ __('Noch keine Holzherkunft angegeben. Klicken Sie auf "Holzart hinzufügen", falls Ihr Produkt Holz enthält.') }} + + + @endforelse + + + {{ __('Holzart hinzufügen') }} + +
+
+ +
+ @endif + + {{-- ============================================================= --}} + {{-- TAB 8: ZUORDNUNG & VERWALTUNG --}} + {{-- ============================================================= --}} + @if ($activeTab === 'zuordnung') +
+ + {{-- Hub-Zuordnung --}} + +
+ {{ __('Hub-Zuordnung') }} + + {{ __('Regionale Zuordnung des Produkts. Ohne Auswahl wird der Hub Ihres Partners verwendet.') }} + +
+ + + + {{ __('Hub / Region') }} + + {{ __('– Standard (Partner-Hub) –') }} + + @foreach ($hubs as $hub) + {{ $hub->name }} + @endforeach + + + +
+ + {{-- Sichtbarkeit --}} + +
+ {{ __('Sichtbarkeit') }} + {{ __('Zeitgesteuerte Veröffentlichung (optional)') }} +
+ + +
+ + + {{ __('Produkt zeitgesteuert veröffentlichen') }} + + + @if ($visibleIsAvailable) +
+ + {{ __('Sichtbar ab') }} + + + + + + + + + + {{ __('Sichtbar bis') }} + + + + + + + +
+ @endif +
+ +
+ + {{-- Scoring (intern) --}} + +
+ {{ __('Bewertung (intern)') }} + {{ __('Interne Produkt-Bewertung – wird nicht öffentlich angezeigt') }} + +
+ + +
+ + {{ __('Stauraumvolumen (Liter)') }} + + + + +
+ + {{ __('Aufbauaufwand (1–5)') }} + {{ __('1 = sehr einfach, 5 = sehr aufwändig') }} + + {{ __('–') }} + + @for ($i = 1; $i <= 5; $i++) + {{ $i }} + + @endfor + + + + + + {{ __('Designpunkte (1–5)') }} + {{ __('1 = einfach, 5 = Premium-Design') }} + + {{ __('–') }} + + @for ($i = 1; $i <= 5; $i++) + {{ $i }} + + @endfor + + + +
+
+
+ + {{-- Aktivitätsprotokoll (only in edit mode) --}} + @if ($isEditing && $activities->isNotEmpty()) + +
+ {{ __('Aktivitätsprotokoll') }} + {{ __('Letzte Änderungen an diesem Produkt') }} +
+ + +
+ @foreach ($activities as $activity) +
+
+ + {{ $activity->user?->name ?? __('System') }} + {{ $activity->action }} + @if ($activity->note) + – {{ $activity->note }} + @endif +
+ {{ $activity->created_at->format('d.m.Y H:i') }} +
+ @endforeach +
+
+ @endif + +
+ @endif + + {{-- Submit Button (immer sichtbar) --}} +
+ @if ($isEditing) +
+ + {{ __('Als verkauft markieren') }} + + + {{ __('Archivieren') }} + +
+ @else +
+ @endif +
+ + {{ __('Abbrechen') }} + + + + {{ $isEditing ? __('Änderungen speichern') : __('Produkt anlegen') }} + + + + {{ __('Wird gespeichert...') }} + + +
+
+ +
+
diff --git a/resources/views/livewire/products/form-teaser.blade.php b/resources/views/livewire/products/form-teaser.blade.php new file mode 100644 index 0000000..4810ee3 --- /dev/null +++ b/resources/views/livewire/products/form-teaser.blade.php @@ -0,0 +1,714 @@ +exists) { + $this->authorize('update', $product); + $this->product = $product; + $this->isEditing = true; + + // Pre-fill from product + $this->name = $product->name; + $this->descriptionShort = $product->description_short; + $this->priceType = $product->price_type->value; + $this->priceDisplayText = $product->price_display_text ?? ''; + $this->categoryId = $product->categories->first()?->id; + + // Produktnummer pre-fill + $this->partnerProductNumber = $product->partner_product_number ?? ''; + + // Map status: Pending/Correction/Active → 'active' for UI, Draft stays 'draft' + $this->status = in_array($product->status, [ProductStatus::Pending, ProductStatus::Active, ProductStatus::Correction]) ? 'active' : 'draft'; + + // Existing media (sorted by order_column) + $this->existingMedia = $product->media + ->sortBy('order_column') + ->values() + ->map( + fn($m) => [ + 'id' => $m->id, + 'file_path' => $m->file_path, + 'alt_text' => $m->alt_text, + 'order_column' => $m->order_column, + ], + ) + ->toArray(); + } else { + $this->authorize('create', Product::class); + abort_unless( + auth() + ->user() + ->hasAnyRole(['Retailer', 'Manufacturer', 'Admin', 'Super-Admin']), + 403, + __('Nur Händler und Hersteller können Teaser-Produkte anlegen.'), + ); + + $partner = auth()->user()->partner; + if ($partner) { + $nextNumber = $partner->products()->count() + 1; + $this->partnerProductNumber = sprintf('P%03d-%04d', $partner->id, $nextNumber); + } + } + } + + public function removeExistingMedia(int $mediaId): void + { + if (!$this->isEditing) { + return; + } + + $media = $this->product->media()->find($mediaId); + if ($media) { + Storage::disk('public')->delete($media->file_path); + $media->delete(); + $this->existingMedia = collect($this->existingMedia)->reject(fn($m) => $m['id'] === $mediaId)->values()->toArray(); + } + } + + public function removePhoto(int $index): void + { + if (isset($this->mainImages[$index])) { + unset($this->mainImages[$index]); + $this->mainImages = array_values($this->mainImages); + } + } + + /** + * Reihenfolge der vorhandenen Bilder aktualisieren. + * + * @param array $orderedIds + */ + public function updateMediaOrder(array $orderedIds): void + { + if (!$this->isEditing) { + return; + } + + foreach ($orderedIds as $position => $mediaId) { + $this->product + ->media() + ->where('id', $mediaId) + ->update([ + 'order_column' => $position + 1, + ]); + } + + // Sync local state + $reordered = collect($orderedIds) + ->map(function ($id, $index) { + $media = collect($this->existingMedia)->firstWhere('id', $id); + + return $media ? array_merge($media, ['order_column' => $index + 1]) : null; + }) + ->filter() + ->values() + ->toArray(); + + $this->existingMedia = $reordered; + } + + public function archiveProduct(): void + { + if (! $this->isEditing) { + return; + } + + $this->authorize('delete', $this->product); + + $this->product->update(['status' => ProductStatus::Archived]); + $this->product->activities()->create([ + 'user_id' => auth()->id(), + 'action' => 'archived', + 'note' => null, + ]); + + session()->flash('message', __('Produkt wurde archiviert.')); + $this->redirect(route('products.index')); + } + + public function markAsSold(): void + { + if (! $this->isEditing) { + return; + } + + $this->authorize('update', $this->product); + + $this->product->update(['status' => ProductStatus::Sold]); + $this->product->activities()->create([ + 'user_id' => auth()->id(), + 'action' => 'sold', + 'note' => null, + ]); + + session()->flash('message', __('Produkt wurde als verkauft markiert.')); + $this->redirect(route('products.index')); + } + + public function save(): void + { + if ($this->isEditing) { + $this->authorize('update', $this->product); + } else { + $this->authorize('create', Product::class); + } + + $allowedPriceTypes = collect(ProductType::LocalStock->allowedPriceTypes()) + ->map(fn(PriceType $pt) => $pt->value) + ->implode(','); + + $this->validate( + [ + 'name' => 'required|string|max:255', + 'descriptionShort' => 'required|string|max:180', + 'priceType' => "required|in:{$allowedPriceTypes}", + 'priceDisplayText' => 'required_if:priceType,from_price|nullable|string|max:100', + 'categoryId' => 'required|exists:categories,id', + 'status' => 'required|in:active,draft', + 'partnerProductNumber' => 'nullable|string|max:100', + 'mainImages' => 'nullable|array|min:0|max:10', + 'mainImages.*' => 'mimes:jpeg,jpg,png|max:10240', + ], + [ + 'name.required' => __('Bitte geben Sie einen Produktnamen ein.'), + 'descriptionShort.required' => __('Bitte geben Sie eine Kurzbeschreibung ein.'), + 'descriptionShort.max' => __('Die Kurzbeschreibung darf maximal 180 Zeichen lang sein.'), + 'priceType.required' => __('Bitte wählen Sie eine Preisangabe aus.'), + 'priceDisplayText.required_if' => __('Bei Ab-Preis ist eine Preisangabe erforderlich (z.B. "Ab 2.500 €").'), + 'categoryId.required' => __('Bitte wählen Sie eine Kategorie aus.'), + 'mainImages.max' => __('Maximal 10 Produktbilder erlaubt.'), + 'mainImages.*.mimes' => __('Nur Bilder (JPG, JPEG, PNG) erlaubt.'), + 'mainImages.*.max' => __('Bilder dürfen maximal 10 MB groß sein.'), + ], + ); + + if ($this->isEditing) { + $this->saveExisting(); + + $toastMessage = $this->status === 'active' ? __('Teaser-Produkt wurde zur Freigabe eingereicht.') : __('Teaser-Produkt wurde als Entwurf gespeichert.'); + + Flux::toast(variant: 'success', text: $toastMessage, duration: 5000); + + // Refresh existing media after save (sorted by order_column) + $this->existingMedia = $this->product + ->fresh() + ->media->sortBy('order_column') + ->values() + ->map( + fn($m) => [ + 'id' => $m->id, + 'file_path' => $m->file_path, + 'alt_text' => $m->alt_text, + 'order_column' => $m->order_column, + ], + ) + ->toArray(); + $this->mainImages = []; + } else { + $this->saveNew(); + + $flashMessage = $this->status === 'active' ? __('Teaser-Produkt wurde zur Freigabe eingereicht.') : __('Teaser-Produkt wurde als Entwurf gespeichert.'); + session()->flash('message', $flashMessage); + $this->redirect(route('products.index')); + } + } + + private function saveNew(): void + { + $user = auth()->user(); + $partner = $user->partner; + + // Status: 'active' im UI → Pending (zur Freigabe), 'draft' → Draft + $newStatus = $this->status === 'active' ? ProductStatus::Pending : ProductStatus::Draft; + + // B2in-Artikelnummer automatisch generieren + $lastNumber = Product::whereNotNull('b2in_article_number')->orderByDesc('b2in_article_number')->value('b2in_article_number'); + $nextSeq = $lastNumber ? ((int) str_replace('B2IN-', '', $lastNumber)) + 1 : 1; + $b2inArticleNumber = sprintf('B2IN-%06d', $nextSeq); + + $product = Product::create([ + 'partner_id' => $partner->id, + 'partner_product_number' => $this->partnerProductNumber ?: null, + 'b2in_article_number' => $b2inArticleNumber, + 'hub_id' => $partner->hub_id, + 'name' => $this->name, + 'slug' => str($this->name) + ->slug() + ->append('-' . uniqid()), + 'product_type' => ProductType::LocalStock, + 'status' => $newStatus, + 'price_type' => PriceType::from($this->priceType), + 'price_display_text' => $this->priceDisplayText ?: null, + 'description_short' => $this->descriptionShort, + 'is_available' => true, + 'is_curated' => false, + ]); + + // Kategorie zuordnen + $product->categories()->attach($this->categoryId); + + // Bilder speichern + $index = 1; + foreach ($this->mainImages as $image) { + $path = $image->store('products/' . $product->id, 'public'); + $product->media()->create([ + 'file_path' => $path, + 'type' => 'image', + 'alt_text' => $this->name, + 'order_column' => $index++, + ]); + } + } + + private function saveExisting(): void + { + // Status logic for editing + $newStatus = ProductStatus::Draft; + if ($this->status === 'active') { + if ($this->product->status === ProductStatus::Draft) { + $newStatus = ProductStatus::Pending; + } elseif (in_array($this->product->status, [ProductStatus::Pending, ProductStatus::Active, ProductStatus::Correction])) { + $newStatus = ProductStatus::Pending; + } + } + + $this->product->update([ + 'name' => $this->name, + 'description_short' => $this->descriptionShort, + 'price_type' => PriceType::from($this->priceType), + 'price_display_text' => $this->priceDisplayText ?: null, + 'partner_product_number' => $this->partnerProductNumber ?: null, + 'status' => $newStatus, + ]); + + // Kategorie aktualisieren + $this->product->categories()->sync([$this->categoryId]); + + // Neue Bilder speichern + $maxOrder = $this->product->media()->max('order_column') ?? 0; + $index = $maxOrder + 1; + foreach ($this->mainImages as $image) { + $path = $image->store('products/' . $this->product->id, 'public'); + $this->product->media()->create([ + 'file_path' => $path, + 'type' => 'image', + 'alt_text' => $this->name, + 'order_column' => $index++, + ]); + } + + // Activity log + $this->product->activities()->create([ + 'user_id' => auth()->id(), + 'action' => 'updated', + 'note' => null, + ]); + } + + public function with(): array + { + $data = [ + 'categories' => Category::orderBy('name')->get(['id', 'name']), + 'allowedPriceTypes' => ProductType::LocalStock->allowedPriceTypes(), + 'isEditing' => $this->isEditing, + ]; + + if ($this->isEditing) { + $data['existingMedia'] = $this->existingMedia; + $data['activities'] = $this->product->activities()->with('user')->latest()->take(10)->get(); + } + + return $data; + } +}; ?> + +
+ {{-- Header --}} +
+ +
+ + {{ $isEditing ? __('Teaser-Produkt bearbeiten') : __('Neues Produkt anlegen') }} + + + + {{ __('Teaser-Produkt') }} + {{ __('Typ A – Beratungspflicht, Ticket erforderlich') }} + +
+
+ + {{-- Kuration-Hinweis (Korrektur oder Ablehnung) --}} + @if ($isEditing && $product->curation_notes && in_array($product->status, [ProductStatus::Correction, ProductStatus::Archived])) + + + {{ $product->status === ProductStatus::Correction ? __('Korrektur erforderlich') : __('Produkt abgelehnt') }} + + {{ $product->curation_notes }} + + @endif + +
+ + {{-- Bild-Upload --}} + +
+ {{ $isEditing ? __('Produktbilder') : __('Produktbild') }} + {{ __('Nur Bilder (JPG, JPEG, PNG) – max. 10 MB pro Bild, max. 10 Bilder') }} + +
+ + + {{-- Existing images (only in edit mode) - sortable via drag & drop --}} + @if ($isEditing && count($existingMedia) > 0) +
+ {{ __('Vorhandene Bilder') }} + + {{ __('Per Drag & Drop sortieren – das erste Bild ist das Standardbild.') }} +
+ @foreach ($existingMedia as $mediaIndex => $media) +
+ @if ($mediaIndex === 0) +
+ {{ __('Standard') }} +
+ @endif + {{ $media['alt_text'] ?? '' }} + +
+ +
+
+ @endforeach +
+
+ + @endif + + {{-- Upload area --}} + + + + + @if (isset($mainImages) && count($mainImages) > 0) +
+ @foreach ($mainImages as $index => $image) + + + + + + @endforeach +
+ @endif + + +
+ + + + {{-- Produktinformationen --}} + +
+ {{ __('Produktinformationen') }} +
+ + +
+ + {{ __('Produktname') }} + + + + + + {{ __('Kurzbeschreibung') }} + {{ __('Max. 180 Zeichen – wird im Feed und auf der Karte angezeigt') }} + + +
+ {{ strlen($descriptionShort) }} / 180 +
+ +
+ + + {{ __('Kategorie') }} + + {{ __('– Bitte wählen –') }} + @foreach ($categories as $category) + {{ $category->name }} + @endforeach + + + +
+
+ + {{-- Produktnummern --}} + +
+ {{ __('Produktnummern') }} + {{ __('Interne und B2in-Artikelnummer') }} +
+ + +
+ @if ($isEditing && $product->b2in_article_number) + + {{ __('B2in-Artikelnummer') }} + + {{ $product->b2in_article_number }} + + @endif + + + {{ __('Partner-Produktnummer') }} + + {{ __('Eigene interne Artikelnummer (optional, wird automatisch vorgeschlagen)') }} + + + + +
+
+ + {{-- Preisangabe (Typ A: kein Festpreis) --}} + +
+ {{ __('Preisangabe') }} + + {{ __('Teaser-Produkte haben keine fixen Online-Preise. Wählen Sie Art der Preisangabe.') }} + +
+ + +
+ + {{ __('Preistyp') }} + + @foreach ($allowedPriceTypes as $type) + {{ $type->label() }} + @endforeach + + + + + @if ($priceType === 'from_price') + + {{ __('Preisangabe (Freitext)') }} + {{ __('z.B. "Ab 2.500 €" oder "Ab 1.800 € pro Laufmeter"') }} + + + + + @endif + + + {{ __('Warum kein Festpreis?') }} + + {{ __('Teaser-Produkte sind bewusst ohne finale Online-Konfiguration. Der Kunde kommt in Ihren Laden – dort erfolgt die finale Planung und Preisfestlegung.') }} + + +
+
+ + {{-- Status & Verfügbarkeit --}} + +
+ {{ __('Veröffentlichung') }} +
+ + +
+ @if ($isEditing) + + {{ __('Aktueller Status') }} +
+ + {{ $product->status->label() }} +
+
+ + @if ($product->status === \App\Enums\ProductStatus::Correction && $product->curation_notes) + + {{ __('Korrektur erforderlich') }} + {{ $product->curation_notes }} + + @endif + @endif + + + {{ __('Status') }} + + + {{ __('Zur Freigabe einreichen') }} + + {{ __('Entwurf – erst speichern') }} + + + + + {{ __('Freigabe-Workflow') }} + + {{ __('Eingereichte Produkte werden vom Admin geprüft. Nach Freigabe wird das Produkt veröffentlicht. Bei Korrekturbedarf erhalten Sie eine Rückmeldung.') }} + + +
+
+ + {{-- Aktivitätsprotokoll (only in edit mode) --}} + @if ($isEditing && $activities->isNotEmpty()) + +
+ {{ __('Aktivitätsprotokoll') }} + {{ __('Letzte Änderungen an diesem Produkt') }} +
+ + +
+ @foreach ($activities as $activity) +
+
+ + {{ $activity->user?->name ?? __('System') }} + {{ $activity->action }} + @if ($activity->note) + – {{ $activity->note }} + @endif +
+ {{ $activity->created_at->format('d.m.Y H:i') }} +
+ @endforeach +
+
+ @endif + + {{-- Aktionen --}} +
+ @if ($isEditing) +
+ + {{ __('Als verkauft markieren') }} + + + {{ __('Archivieren') }} + +
+ @else +
+ @endif +
+ + {{ __('Abbrechen') }} + + + + {{ $isEditing ? __('Änderungen speichern') : __('Produkt anlegen') }} + + + + {{ __('Wird gespeichert...') }} + + +
+
+ +
+
diff --git a/resources/views/livewire/products/index.blade.php b/resources/views/livewire/products/index.blade.php index 504d0bf..15cda86 100644 --- a/resources/views/livewire/products/index.blade.php +++ b/resources/views/livewire/products/index.blade.php @@ -1,398 +1,344 @@ '', - 'statusFilter' => 'all', - 'categoryFilter' => 'all', - 'sortBy' => 'created_at', - 'sortDirection' => 'desc', -]); +layout('components.layouts.app'); +title('Produkte'); -// Dummy-Produkte für die Anzeige -$products = computed(function () { - return [ - [ - 'id' => 1, - 'b2in_number' => 'B2IN-000471', - 'supplier_number' => 'SOFA-ALBA-3S-ANTHR', - 'name' => 'Sofa ALBA 3-Sitzer', - 'brand' => 'Möbelwerk Nord', - 'category' => 'Wohnzimmer > Sofas', - 'status' => 'active', - 'price' => 1250.00, - 'stock_status' => 'in_stock', - 'created_at' => '2025-11-04', - 'image' => null, - ], - [ - 'id' => 2, - 'b2in_number' => 'B2IN-000472', - 'supplier_number' => 'CHAIR-NORDIC-OAK', - 'name' => 'Stuhl Nordic Eiche', - 'brand' => 'Design Studio', - 'category' => 'Esszimmer > Stühle', - 'status' => 'active', - 'price' => 189.00, - 'stock_status' => 'in_stock', - 'created_at' => '2025-11-05', - 'image' => null, - ], - [ - 'id' => 3, - 'b2in_number' => 'B2IN-000473', - 'supplier_number' => 'BED-LUNA-180', - 'name' => 'Bett Luna 180x200', - 'brand' => 'Schlafwelt', - 'category' => 'Schlafzimmer > Betten', - 'status' => 'draft', - 'price' => 899.00, - 'stock_status' => 'on_order', - 'created_at' => '2025-11-03', - 'image' => null, - ], - [ - 'id' => 4, - 'b2in_number' => 'B2IN-000474', - 'supplier_number' => 'TABLE-OAK-EXTEND', - 'name' => 'Esstisch Eiche ausziehbar', - 'brand' => 'Tischmanufaktur', - 'category' => 'Esszimmer > Tische', - 'status' => 'active', - 'price' => 1450.00, - 'stock_status' => 'in_stock', - 'created_at' => '2025-10-28', - 'image' => null, - ], - [ - 'id' => 5, - 'b2in_number' => 'B2IN-000475', - 'supplier_number' => 'WARDROBE-CLASSIC', - 'name' => 'Kleiderschrank Classic', - 'brand' => 'Möbelwerk Nord', - 'category' => 'Schlafzimmer > Schränke', - 'status' => 'inactive', - 'price' => 2100.00, - 'stock_status' => 'out_of_stock', - 'created_at' => '2025-10-15', - 'image' => null, - ], - [ - 'id' => 6, - 'b2in_number' => 'B2IN-000476', - 'supplier_number' => 'SIDEBOARD-MOD-120', - 'name' => 'Sideboard Modern 120cm', - 'brand' => 'Design Studio', - 'category' => 'Wohnzimmer > Sideboards', - 'status' => 'active', - 'price' => 675.00, - 'stock_status' => 'in_stock', - 'created_at' => '2025-11-01', - 'image' => null, - ], - ]; -}); +new class extends Component { + use WithPagination; -?> + public string $search = ''; + public string $statusFilter = ''; + public string $categoryFilter = ''; + public string $productTypeFilter = ''; + public string $sortBy = 'created_at'; + public string $sortDirection = 'desc'; -
+ public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function updatedCategoryFilter(): void + { + $this->resetPage(); + } + + public function updatedProductTypeFilter(): void + { + $this->resetPage(); + } + + public function archiveProduct(int $productId): void + { + $product = Product::findOrFail($productId); + $this->authorize('delete', $product); + + $product->update(['status' => ProductStatus::Archived]); + $product->activities()->create([ + 'user_id' => auth()->id(), + 'action' => 'archived', + 'note' => null, + ]); + + session()->flash('message', __('Produkt ":name" wurde archiviert.', ['name' => $product->name])); + } + + public function markAsSold(int $productId): void + { + $product = Product::findOrFail($productId); + $this->authorize('update', $product); + + $product->update(['status' => ProductStatus::Sold]); + $product->activities()->create([ + 'user_id' => auth()->id(), + 'action' => 'sold', + 'note' => null, + ]); + + session()->flash('message', __('Produkt ":name" wurde als verkauft markiert.', ['name' => $product->name])); + } + + public function with(): array + { + $user = Auth::user(); + $isAdmin = $user->hasAnyRole(['Admin', 'Super-Admin']); + $isCustomer = $user->hasRole('Customer'); + + $query = Product::query() + ->with(['brand', 'categories', 'partner', 'media']) + ->when($this->search, fn($q) => $q->where('name', 'like', "%{$this->search}%")) + ->when($this->statusFilter, fn($q) => $q->where('status', $this->statusFilter)) + ->when($this->categoryFilter, fn($q) => $q->whereHas('categories', fn($q) => $q->where('categories.id', $this->categoryFilter))) + ->when($this->productTypeFilter, fn($q) => $q->where('product_type', $this->productTypeFilter)); + + if ($isAdmin) { + // Admin sieht alle Produkte + } elseif ($isCustomer) { + // Kunden sehen nur freigegebene, aktive Produkte aus ihrem Hub + $query->where('status', ProductStatus::Active)->where('is_curated', true)->where('is_available', true)->when($user->hub_id, fn($q) => $q->where('hub_id', $user->hub_id)); + } else { + // Händler/Hersteller sehen nur eigene Produkte + $query->when($user->partner_id, fn($q) => $q->where('partner_id', $user->partner_id)); + } + + $products = $query->orderBy($this->sortBy, $this->sortDirection)->paginate(20); + + $categories = Category::orderBy('name')->get(); + + return [ + 'products' => $products, + 'categories' => $categories, + 'isAdmin' => $isAdmin, + 'isCustomer' => $isCustomer, + ]; + } +}; ?> + +
{{-- Header --}}
- {{ __('Produkte') }} (in Entwicklung) - {{ __('Verwalten Sie Ihre Produktliste') }} + {{ __('Produkte') }} + {{ __('Produktübersicht und Local Feed') }}
- - {{ __('Neues Produkt') }} - + @if ( + !$isCustomer && + auth()->user()->hasAnyRole(['Retailer', 'Manufacturer', 'Admin', 'Super-Admin'])) +
+ + {{ __('Neues Teaser-Produkt') }} + + + {{ __('Neues Standard-Produkt') }} + +
+ @endif
+ @if (session()->has('message')) + + {{ session('message') }} + + @endif + {{-- Filter & Suche --}} - -
- {{-- Suchfeld --}} -
- -
+ +
+ + {{ __('Suche') }} + + - {{-- Status Filter --}} - - - - - - + @if (!$isCustomer) + + {{ __('Status') }} + + {{ __('Alle Status') }} + {{ __('In Prüfung') }} + {{ __('Korrektur nötig') }} + {{ __('Freigegeben') }} + {{ __('Entwurf') }} + {{ __('Archiviert') }} + {{ __('Verkauft') }} + + + @endif - {{-- Kategorie Filter --}} - - - - - - - - + + {{ __('Produkttyp') }} + + {{ __('Alle Typen') }} + {{ __('Teaser (Local Express)') }} + {{ __('Standard (Smart Club)') }} + + + + + {{ __('Kategorie') }} + + {{ __('Alle Kategorien') }} + @foreach ($categories as $category) + {{ $category->name }} + @endforeach + +
- - {{-- Aktive Filter Anzeige --}} - @if($search || $statusFilter !== 'all' || $categoryFilter !== 'all') -
- {{ __('Aktive Filter:') }} - @if($search) - - {{ __('Suche: ') }}{{ $search }} - - - @endif - @if($statusFilter !== 'all') - - {{ __('Status: ') }}{{ __($statusFilter) }} - - - @endif - @if($categoryFilter !== 'all') - - {{ __('Kategorie: ') }}{{ __($categoryFilter) }} - - - @endif - -
- @endif
{{-- Produkttabelle --}} - + - {{ __('Bild') }} {{ __('Produkt') }} - {{ __('Marke') }} - {{ __('Kategorie') }} - {{ __('Preis') }} - {{ __('Status') }} - {{ __('Lager') }} + @if ($isAdmin) + {{ __('Händler') }} + @endif + @if ($isCustomer) + {{ __('Händler') }} + @else + {{ __('Kategorie') }} + @endif + {{ __('Preis') }} + {{ __('Status') }} + @if ($isAdmin) + {{ __('Kuration') }} + @endif {{ __('Erstellt') }} - {{ __('Aktionen') }} + - @forelse($this->products as $product) - - {{-- Bild --}} - -
- -
-
- - {{-- Produkt --}} - -
-
- {{ $product['name'] }} + @forelse($products as $product) + + {{-- Produkt --}} + +
+ @php + $thumbnail = $product->media->sortBy('order_column')->first(); + @endphp + @if ($thumbnail) + {{ $thumbnail->alt_text ?? $product->name }} + @else +
+ +
+ @endif +
+
+ {{ $product->name }} +
+ + {{ $product->product_type?->label() ?? '–' }} + +
-
-
B2in: {{ $product['b2in_number'] }}
-
Art.-Nr.: {{ $product['supplier_number'] }}
-
-
- + - {{-- Marke --}} - - - {{ $product['brand'] }} - - + {{-- Händler (nur Admin) --}} + @if ($isAdmin) + + + {{ $product->partner?->company_name ?? '–' }} + + + @endif - {{-- Kategorie --}} - - - {{ $product['category'] }} - - + {{-- Händler (Customer) oder Kategorie (Partner) --}} + @if ($isCustomer) + + + {{ $product->partner?->company_name ?? '–' }} + + + @else + + + {{ $product->categories->first()?->name ?? '–' }} + + + @endif - {{-- Preis --}} - - - {{ number_format($product['price'], 2, ',', '.') }} € - - + {{-- Preis --}} + + @if ($product->price_type?->value === 'on_request') + {{ __('Auf Anfrage') }} + @elseif($product->price_display_text) + {{ $product->price_display_text }} + @elseif($product->price) + {{ number_format($product->price, 2, ',', '.') }} € + @else + + @endif + - {{-- Status --}} - - @php - $statusColors = [ - 'active' => 'green', - 'draft' => 'yellow', - 'inactive' => 'zinc', - ]; - $statusLabels = [ - 'active' => __('Aktiv'), - 'draft' => __('Entwurf'), - 'inactive' => __('Inaktiv'), - ]; - @endphp - - {{ $statusLabels[$product['status']] }} - - + {{-- Status --}} + + + {{ $product->status?->label() ?? '–' }} + + - {{-- Lagerstatus --}} - - @php - $stockColors = [ - 'in_stock' => 'green', - 'on_order' => 'yellow', - 'out_of_stock' => 'red', - ]; - $stockLabels = [ - 'in_stock' => __('Auf Lager'), - 'on_order' => __('Bestellung'), - 'out_of_stock' => __('Nicht verfügbar'), - ]; - @endphp - - {{ $stockLabels[$product['stock_status']] }} - - + {{-- Kuration (nur Admin) --}} + @if ($isAdmin) + + @if ($product->is_curated) + {{ __('Freigegeben') }} + @else + {{ __('Ausstehend') }} + @endif + + @endif - {{-- Erstellt am --}} - - - {{ \Carbon\Carbon::parse($product['created_at'])->format('d.m.Y') }} - - + {{-- Erstellt --}} + + + {{ $product->created_at?->format('d.m.Y') }} + + - {{-- Aktionen --}} - -
- - {{ __('Ansehen') }} - - - - - - - - {{ __('Bearbeiten') }} - - - {{ __('Duplizieren') }} - - - - {{ __('Archivieren') }} - - - {{ __('Löschen') }} - - - -
-
- + {{-- Aktionen --}} + + @if (!$isCustomer) +
+ + @if (!in_array($product->status, [ProductStatus::Archived, ProductStatus::Sold])) + + + + + {{ __('Als verkauft') }} + + + {{ __('Archivieren') }} + + + + @endif +
+ @endif +
+ @empty - - -
- - {{ __('Keine Produkte gefunden') }} - - {{ __('Erstellen Sie Ihr erstes Produkt oder passen Sie Ihre Filter an.') }} - - - {{ __('Neues Produkt erstellen') }} - -
-
-
+ + + +
{{ __('Keine Produkte gefunden') }}
+
+
@endforelse - {{-- Pagination / Stats --}} -
-
-
- {{ __('Zeige') }} {{ count($this->products) }} {{ __('von') }} {{ count($this->products) }} {{ __('Produkten') }} -
- - {{-- Hier würde normalerweise die Pagination kommen --}} -
- - {{ __('Zurück') }} - - {{ __('Seite 1 von 1') }} - - {{ __('Weiter') }} - -
+ @if ($products->hasPages()) +
+ {{ $products->links() }}
-
+ @endif - - {{-- Statistiken (Optional) --}} -
- -
-
- -
-
-
4
-
{{ __('Aktive Produkte') }}
-
-
-
- - -
-
- -
-
-
1
-
{{ __('Entwürfe') }}
-
-
-
- - -
-
- -
-
-
5
-
{{ __('Auf Lager') }}
-
-
-
- - -
-
- -
-
-
6.563 €
-
{{ __('Ø Preis') }}
-
-
-
-
- diff --git a/routes/admin.php b/routes/admin.php index c44fc95..b62bf34 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -50,8 +50,11 @@ Route::middleware(['auth', 'partner.setup'])->group(function () { Volt::route('admin/users/permissions', 'admin.users.permissions')->name('admin.users.permissions'); // Partner Management + Volt::route('admin/partners', 'admin.partners.index')->name('admin.partners.index'); Volt::route('admin/partners/invite', 'admin.partners.invite')->name('admin.partners.invite'); Volt::route('admin/partners/registration-codes', 'admin.partners.registration-codes')->name('admin.partners.registration-codes'); + Volt::route('admin/partners/{partnerId}/edit', 'admin.partners.edit')->name('admin.partners.edit'); + Volt::route('partner/{partnerId}/profile', 'partner.profile')->name('partner.profile'); // Testing Volt::route('testing/registration', 'admin.testing.registration-tester')->name('testing.landing'); @@ -60,8 +63,14 @@ Route::middleware(['auth', 'partner.setup'])->group(function () { Route::get('admin/cms/cabinet', \App\Livewire\Admin\CMS\CabinetDisplay::class)->name('admin.cms.cabinet'); // Product Routes - Volt::route('products', 'products.index')->name('products.index'); - Volt::route('products/create', 'products.create')->name('products.create'); + Volt::route('products/index', 'products.index')->name('products.index'); + Volt::route('products/create/standard', 'products.form-standard')->name('products.create.standard'); + Volt::route('products/create/teaser', 'products.form-teaser')->name('products.create.teaser'); + Volt::route('products/{product}/edit-standard', 'products.form-standard')->name('products.edit.standard'); + Volt::route('products/{product}/edit-teaser', 'products.form-teaser')->name('products.edit.teaser'); + + // Admin Product Routes (Kuration) + Volt::route('admin/products', 'admin.products.index')->name('admin.products.index'); // Hub Management Volt::route('admin/hubs', 'admin.hubs.index')->name('admin.hubs.index'); @@ -72,9 +81,10 @@ Route::middleware(['auth', 'partner.setup'])->group(function () { Volt::route('admin/documentation', 'admin.documentation')->name('admin.documentation'); Route::get('admin/documentation/download', function () { $mdPath = base_path('dev/entwicklung.md'); - if (!file_exists($mdPath)) { + if (! file_exists($mdPath)) { abort(404); } + return response()->download($mdPath, 'b2in-entwicklung.md'); })->name('admin.documentation.download'); }); diff --git a/routes/domains.php b/routes/domains.php index a4a44c2..af77a99 100644 --- a/routes/domains.php +++ b/routes/domains.php @@ -27,14 +27,27 @@ $domainStyle2own = config('domains.domain_style2own', 'style2own.test'); // Admin-Bereich (Portal) Route::domain($domainPortal)->group(function () { + // Livewire Update-Route explizit für Portal-Domain registrieren + // (Notwendig weil Route-Cache/Subdomain-Routing die globalen Livewire-Routen nicht für alle Domains enthält) + Route::post( + \Livewire\Mechanisms\HandleRequests\EndpointResolver::updatePath(), + [\Livewire\Mechanisms\HandleRequests\HandleRequests::class, 'handleUpdate'] + )->middleware('web')->name('portal.livewire.update'); + + // Livewire File-Upload-Route explizit für Portal-Domain registrieren + Route::post( + \Livewire\Mechanisms\HandleRequests\EndpointResolver::uploadPath(), + [\Livewire\Features\SupportFileUploads\FileUploadController::class, 'handle'] + )->middleware(['web', 'throttle:60,1'])->name('portal.livewire.upload-file'); + // Auth-Routen laden - require __DIR__ . '/auth.php'; + require __DIR__.'/auth.php'; // Admin-Routes laden - require __DIR__ . '/admin.php'; + require __DIR__.'/admin.php'; // Test-Route laden - require __DIR__ . '/test.php'; + require __DIR__.'/test.php'; // Display-API-Route (öffentlich zugänglich) Route::get('/api/display/config', [\App\Http\Controllers\Api\DisplayConfigController::class, 'index']); @@ -53,12 +66,12 @@ Route::domain($domainPortal)->group(function () { // API-Routen für alle Domains (optional: auch dynamisch machen) Route::domain(config('domains.domain_api', 'api.b2in.test'))->group(function () { - require __DIR__ . '/api.php'; + require __DIR__.'/api.php'; }); // Web-Routes für alle Themes (werden außerhalb der Domain-Gruppen geladen, um Duplikate zu vermeiden) // Das Theme wird automatisch basierend auf der Domain vom ThemeServiceProvider ausgewählt -require __DIR__ . '/web.php'; +require __DIR__.'/web.php'; // Domain-spezifische Vite Build-Verzeichnisse Route::domain($domainB2in)->group(function () { diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index c587133..fa1e729 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -4,7 +4,9 @@ use App\Models\User; use Livewire\Volt\Volt as LivewireVolt; test('login screen can be rendered', function () { - $response = $this->get('/login'); + $portalUrl = 'https://'.config('domains.domain_portal'); + + $response = $this->get($portalUrl.'/login'); $response->assertStatus(200); }); @@ -38,9 +40,11 @@ test('users can not authenticate with invalid password', function () { }); test('users can logout', function () { + $portalUrl = 'https://'.config('domains.domain_portal'); + $user = User::factory()->create(); - $response = $this->actingAs($user)->post('/logout'); + $response = $this->actingAs($user)->post($portalUrl.'/logout'); $response->assertRedirect('/'); diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php index 2bb2aea..9507019 100644 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -6,9 +6,11 @@ use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\URL; test('email verification screen can be rendered', function () { + $portalUrl = 'https://'.config('domains.domain_portal'); + $user = User::factory()->unverified()->create(); - $response = $this->actingAs($user)->get('/verify-email'); + $response = $this->actingAs($user)->get($portalUrl.'/verify-email'); $response->assertStatus(200); }); @@ -29,7 +31,7 @@ test('email can be verified', function () { Event::assertDispatched(Verified::class); expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); - $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); + $response->assertRedirect(route('partner.setup.wizard', absolute: false)); }); test('email is not verified with invalid hash', function () { diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php index ce5762a..589696e 100644 --- a/tests/Feature/Auth/PasswordConfirmationTest.php +++ b/tests/Feature/Auth/PasswordConfirmationTest.php @@ -4,9 +4,11 @@ use App\Models\User; use Livewire\Volt\Volt; test('confirm password screen can be rendered', function () { + $portalUrl = 'https://'.config('domains.domain_portal'); + $user = User::factory()->create(); - $response = $this->actingAs($user)->get('/confirm-password'); + $response = $this->actingAs($user)->get($portalUrl.'/confirm-password'); $response->assertStatus(200); }); diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php index 0d97407..1c38792 100644 --- a/tests/Feature/Auth/PasswordResetTest.php +++ b/tests/Feature/Auth/PasswordResetTest.php @@ -1,12 +1,14 @@ get('/forgot-password'); + $portalUrl = 'https://'.config('domains.domain_portal'); + + $response = $this->get($portalUrl.'/forgot-password'); $response->assertStatus(200); }); @@ -20,7 +22,7 @@ test('reset password link can be requested', function () { ->set('email', $user->email) ->call('sendPasswordResetLink'); - Notification::assertSentTo($user, ResetPassword::class); + Notification::assertSentTo($user, CustomResetPasswordNotification::class); }); test('reset password screen can be rendered', function () { @@ -32,8 +34,10 @@ test('reset password screen can be rendered', function () { ->set('email', $user->email) ->call('sendPasswordResetLink'); - Notification::assertSentTo($user, ResetPassword::class, function ($notification) { - $response = $this->get('/reset-password/'.$notification->token); + Notification::assertSentTo($user, CustomResetPasswordNotification::class, function ($notification) { + $portalUrl = 'https://'.config('domains.domain_portal'); + + $response = $this->get($portalUrl.'/reset-password/'.$notification->token); $response->assertStatus(200); @@ -50,7 +54,7 @@ test('password can be reset with valid token', function () { ->set('email', $user->email) ->call('sendPasswordResetLink'); - Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { + Notification::assertSentTo($user, CustomResetPasswordNotification::class, function ($notification) use ($user) { $response = Volt::test('auth.reset-password', ['token' => $notification->token]) ->set('email', $user->email) ->set('password', 'password') @@ -59,7 +63,7 @@ test('password can be reset with valid token', function () { $response ->assertHasNoErrors() - ->assertRedirect(route('login', absolute: false)); + ->assertRedirect(route('login')); return true; }); diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index 2b2888c..70b651c 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -1,14 +1,54 @@ get('/register'); +test('registration landing page can be rendered for valid role', function () { + Role::create([ + 'name' => 'Broker', + 'guard_name' => 'web', + 'reg_prefix' => 'M', + 'can_be_invited' => true, + 'reg_start_number' => 10000001, + ]); - $response->assertStatus(200); + $response = $this->get('/reg/m'); + + $response->assertOk(); }); -test('new users can register', function () { +test('registration landing page returns 404 for invalid role', function () { + $response = $this->get('/reg/invalid'); + + $response->assertNotFound(); +}); + +test('registration requires valid code in session', function () { + $response = Volt::test('auth.register') + ->set('name', 'Test User') + ->set('email', 'test@example.com') + ->set('password', 'password') + ->set('password_confirmation', 'password') + ->call('register'); + + $response->assertHasErrors('registration_code'); +}); + +test('new users can register with valid registration code', function () { + $code = RegistrationCode::create([ + 'code' => 'M10000001', + 'role' => 'broker', + 'name' => 'Test Makler', + 'status' => RegistrationCode::STATUS_AVAILABLE, + ]); + + session([ + 'registration_code_id' => $code->id, + 'registration_role' => 'broker', + ]); + $response = Volt::test('auth.register') ->set('name', 'Test User') ->set('email', 'test@example.com') @@ -21,4 +61,7 @@ test('new users can register', function () { ->assertRedirect(route('dashboard', absolute: false)); $this->assertAuthenticated(); + + expect(User::where('email', 'test@example.com')->exists())->toBeTrue(); + expect($code->fresh()->status)->toBe(RegistrationCode::STATUS_USED); }); diff --git a/tests/Feature/CreateNewUserOriginTest.php b/tests/Feature/CreateNewUserOriginTest.php new file mode 100644 index 0000000..17e9d65 --- /dev/null +++ b/tests/Feature/CreateNewUserOriginTest.php @@ -0,0 +1,95 @@ + 'style2own']); + + $action = new CreateNewUser; + $user = $action->create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'Password123!', + 'password_confirmation' => 'Password123!', + ]); + + expect($user->origin)->toBe(UserOrigin::Style2Own); + expect($user->origin->value)->toBe('style2own'); +}); + +test('new user gets stileigentum origin when theme is stileigentum', function () { + config(['app.theme' => 'stileigentum']); + + $action = new CreateNewUser; + $user = $action->create([ + 'name' => 'Test User 2', + 'email' => 'test2@example.com', + 'password' => 'Password123!', + 'password_confirmation' => 'Password123!', + ]); + + expect($user->origin)->toBe(UserOrigin::StilEigentum); + expect($user->origin->value)->toBe('stileigentum'); +}); + +test('new user has null origin when theme is unknown', function () { + config(['app.theme' => 'portal']); + + $action = new CreateNewUser; + $user = $action->create([ + 'name' => 'Test User 3', + 'email' => 'test3@example.com', + 'password' => 'Password123!', + 'password_confirmation' => 'Password123!', + ]); + + expect($user->origin)->toBeNull(); +}); + +test('new user has null origin when theme is empty', function () { + config(['app.theme' => '']); + + $action = new CreateNewUser; + $user = $action->create([ + 'name' => 'Test User 4', + 'email' => 'test4@example.com', + 'password' => 'Password123!', + 'password_confirmation' => 'Password123!', + ]); + + expect($user->origin)->toBeNull(); +}); + +test('new user stores hub_id when provided', function () { + $hub = Hub::factory()->create(); + config(['app.theme' => '']); + + $action = new CreateNewUser; + $user = $action->create([ + 'name' => 'Hub User', + 'email' => 'hubuser@example.com', + 'password' => 'Password123!', + 'password_confirmation' => 'Password123!', + 'hub_id' => $hub->id, + ]); + + expect($user->hub_id)->toBe($hub->id); +}); + +test('new user has null hub_id when not provided', function () { + config(['app.theme' => '']); + + $action = new CreateNewUser; + $user = $action->create([ + 'name' => 'No Hub User', + 'email' => 'nohub@example.com', + 'password' => 'Password123!', + 'password_confirmation' => 'Password123!', + ]); + + expect($user->hub_id)->toBeNull(); +}); diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php index df63dd2..ca879e6 100644 --- a/tests/Feature/DashboardTest.php +++ b/tests/Feature/DashboardTest.php @@ -3,14 +3,21 @@ use App\Models\User; test('guests are redirected to the login page', function () { - $response = $this->get('/dashboard'); + $portalUrl = 'https://'.config('domains.domain_portal'); + + $response = $this->get($portalUrl.'/dashboard'); + $response->assertRedirect('/login'); }); test('authenticated users can visit the dashboard', function () { + $portalUrl = 'https://'.config('domains.domain_portal'); + $user = User::factory()->create(); + $this->actingAs($user); - $response = $this->get('/dashboard'); + $response = $this->get($portalUrl.'/dashboard'); + $response->assertStatus(200); }); diff --git a/tests/Feature/LocalFeedTest.php b/tests/Feature/LocalFeedTest.php new file mode 100644 index 0000000..a95111d --- /dev/null +++ b/tests/Feature/LocalFeedTest.php @@ -0,0 +1,280 @@ +forgetCachedPermissions(); + Role::create(['name' => 'Admin']); + Role::create(['name' => 'Retailer']); + Role::create(['name' => 'Customer']); +}); + +test('customer only sees curated active available products from own hub', function () { + $hub = Hub::factory()->create(); + $otherHub = Hub::factory()->create(); + $partner = Partner::factory()->setupCompleted()->create(['hub_id' => $hub->id]); + + $customer = User::factory()->create(['hub_id' => $hub->id]); + $customer->assignRole('Customer'); + + // Sichtbares Produkt: aktiv + kuratiert + verfügbar + gleicher Hub + $visibleProduct = Product::factory()->create([ + 'partner_id' => $partner->id, + 'hub_id' => $hub->id, + 'status' => ProductStatus::Active, + 'is_curated' => true, + 'is_available' => true, + ]); + + // Nicht sichtbar: nicht kuratiert + $notCurated = Product::factory()->create([ + 'partner_id' => $partner->id, + 'hub_id' => $hub->id, + 'status' => ProductStatus::Active, + 'is_curated' => false, + 'is_available' => true, + ]); + + // Nicht sichtbar: anderen Hub + $otherHubProduct = Product::factory()->create([ + 'partner_id' => $partner->id, + 'hub_id' => $otherHub->id, + 'status' => ProductStatus::Active, + 'is_curated' => true, + 'is_available' => true, + ]); + + // Nicht sichtbar: Draft-Status + $draftProduct = Product::factory()->create([ + 'partner_id' => $partner->id, + 'hub_id' => $hub->id, + 'status' => ProductStatus::Draft, + 'is_curated' => true, + 'is_available' => true, + ]); + + $this->actingAs($customer); + + Volt::test('products.index') + ->assertSee($visibleProduct->name) + ->assertDontSee($notCurated->name) + ->assertDontSee($otherHubProduct->name) + ->assertDontSee($draftProduct->name); +}); + +test('admin sees all products regardless of curation or status', function () { + $hub = Hub::factory()->create(); + $partner = Partner::factory()->setupCompleted()->create(['hub_id' => $hub->id]); + + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + + $activeProduct = Product::factory()->create([ + 'partner_id' => $partner->id, + 'hub_id' => $hub->id, + 'status' => ProductStatus::Active, + 'is_curated' => true, + 'is_available' => true, + ]); + + $draftProduct = Product::factory()->create([ + 'partner_id' => $partner->id, + 'hub_id' => $hub->id, + 'status' => ProductStatus::Draft, + 'is_curated' => false, + 'is_available' => false, + ]); + + $this->actingAs($admin); + + Volt::test('products.index') + ->assertSee($activeProduct->name) + ->assertSee($draftProduct->name); +}); + +test('retailer only sees own products', function () { + $hub = Hub::factory()->create(); + $partner = Partner::factory()->setupCompleted()->create(['hub_id' => $hub->id]); + $otherPartner = Partner::factory()->setupCompleted()->create(['hub_id' => $hub->id]); + + $retailer = User::factory()->create(['partner_id' => $partner->id]); + $retailer->assignRole('Retailer'); + + $ownProduct = Product::factory()->create([ + 'partner_id' => $partner->id, + 'hub_id' => $hub->id, + 'status' => ProductStatus::Active, + ]); + + $otherProduct = Product::factory()->create([ + 'partner_id' => $otherPartner->id, + 'hub_id' => $hub->id, + 'status' => ProductStatus::Active, + ]); + + $this->actingAs($retailer); + + Volt::test('products.index') + ->assertSee($ownProduct->name) + ->assertDontSee($otherProduct->name); +}); + +test('customer can search products by name', function () { + $hub = Hub::factory()->create(); + $partner = Partner::factory()->setupCompleted()->create(['hub_id' => $hub->id]); + + $customer = User::factory()->create(['hub_id' => $hub->id]); + $customer->assignRole('Customer'); + + $matchingProduct = Product::factory()->create([ + 'name' => 'Eiche Sideboard', + 'partner_id' => $partner->id, + 'hub_id' => $hub->id, + 'status' => ProductStatus::Active, + 'is_curated' => true, + 'is_available' => true, + ]); + + $otherProduct = Product::factory()->create([ + 'name' => 'Massivholztisch', + 'partner_id' => $partner->id, + 'hub_id' => $hub->id, + 'status' => ProductStatus::Active, + 'is_curated' => true, + 'is_available' => true, + ]); + + $this->actingAs($customer); + + Volt::test('products.index') + ->set('search', 'Eiche') + ->assertSee($matchingProduct->name) + ->assertDontSee($otherProduct->name); +}); + +test('customer can filter products by category', function () { + $hub = Hub::factory()->create(); + $partner = Partner::factory()->setupCompleted()->create(['hub_id' => $hub->id]); + $category = Category::factory()->create(); + + $customer = User::factory()->create(['hub_id' => $hub->id]); + $customer->assignRole('Customer'); + + $categoryProduct = Product::factory()->create([ + 'partner_id' => $partner->id, + 'hub_id' => $hub->id, + 'status' => ProductStatus::Active, + 'is_curated' => true, + 'is_available' => true, + ]); + $categoryProduct->categories()->attach($category); + + $noCategory = Product::factory()->create([ + 'partner_id' => $partner->id, + 'hub_id' => $hub->id, + 'status' => ProductStatus::Active, + 'is_curated' => true, + 'is_available' => true, + ]); + + $this->actingAs($customer); + + Volt::test('products.index') + ->set('categoryFilter', (string) $category->id) + ->assertSee($categoryProduct->name) + ->assertDontSee($noCategory->name); +}); + +test('retailer can filter products by product type', function () { + $hub = Hub::factory()->create(); + $partner = Partner::factory()->setupCompleted()->create(['hub_id' => $hub->id]); + + $retailer = User::factory()->create(['partner_id' => $partner->id]); + $retailer->assignRole('Retailer'); + + $teaserProduct = Product::factory()->create([ + 'name' => 'Teaser Sideboard', + 'partner_id' => $partner->id, + 'hub_id' => $hub->id, + 'product_type' => ProductType::LocalStock, + 'status' => ProductStatus::Active, + ]); + + $standardProduct = Product::factory()->create([ + 'name' => 'Standard Sofa', + 'partner_id' => $partner->id, + 'hub_id' => $hub->id, + 'product_type' => ProductType::SmartOrder, + 'status' => ProductStatus::Active, + ]); + + $this->actingAs($retailer); + + // Filter auf Teaser (LocalStock) + Volt::test('products.index') + ->set('productTypeFilter', 'local_stock') + ->assertSee($teaserProduct->name) + ->assertDontSee($standardProduct->name); + + // Filter auf Standard (SmartOrder) + Volt::test('products.index') + ->set('productTypeFilter', 'smart_order') + ->assertSee($standardProduct->name) + ->assertDontSee($teaserProduct->name); + + // Kein Filter – beide sichtbar + Volt::test('products.index') + ->set('productTypeFilter', '') + ->assertSee($teaserProduct->name) + ->assertSee($standardProduct->name); +}); + +test('product list shows both create buttons for retailer', function () { + $hub = Hub::factory()->create(); + $partner = Partner::factory()->setupCompleted()->create(['hub_id' => $hub->id]); + $retailer = User::factory()->create(['partner_id' => $partner->id]); + $retailer->assignRole('Retailer'); + + $this->actingAs($retailer); + + Volt::test('products.index') + ->assertSee(__('Neues Teaser-Produkt')) + ->assertSee(__('Neues Standard-Produkt')); +}); + +test('product list shows both create buttons for manufacturer', function () { + $hub = Hub::factory()->create(); + $partner = Partner::factory()->setupCompleted()->create(['hub_id' => $hub->id]); + $manufacturer = User::factory()->create(['partner_id' => $partner->id]); + $manufacturer->assignRole('Retailer'); // Need Manufacturer role + Role::create(['name' => 'Manufacturer']); + $manufacturer->syncRoles(['Manufacturer']); + + $this->actingAs($manufacturer); + + Volt::test('products.index') + ->assertSee(__('Neues Teaser-Produkt')) + ->assertSee(__('Neues Standard-Produkt')); +}); + +test('product list does not show create buttons for customer', function () { + $hub = Hub::factory()->create(); + $customer = User::factory()->create(['hub_id' => $hub->id]); + $customer->assignRole('Customer'); + + $this->actingAs($customer); + + Volt::test('products.index') + ->assertDontSee(__('Neues Teaser-Produkt')) + ->assertDontSee(__('Neues Standard-Produkt')); +}); diff --git a/tests/Feature/Models/PartnerRelationshipsTest.php b/tests/Feature/Models/PartnerRelationshipsTest.php new file mode 100644 index 0000000..b79a389 --- /dev/null +++ b/tests/Feature/Models/PartnerRelationshipsTest.php @@ -0,0 +1,81 @@ +create(); + $partner = Partner::factory()->create(['hub_id' => $hub->id]); + + expect($partner->hub->id)->toBe($hub->id); +}); + +test('partner has many users', function () { + $partner = Partner::factory()->create(); + User::factory()->count(3)->create(['partner_id' => $partner->id]); + + expect($partner->users)->toHaveCount(3); +}); + +test('partner has many products', function () { + $partner = Partner::factory()->create(); + Product::factory()->count(2)->create(['partner_id' => $partner->id]); + + expect($partner->products)->toHaveCount(2); +}); + +test('partner can have parent partner (broker)', function () { + $broker = Partner::factory()->estateAgent()->create(); + $customer = Partner::factory()->create(['parent_partner_id' => $broker->id]); + + expect($customer->parentPartner->id)->toBe($broker->id); + expect($customer->broker()->first()->id)->toBe($broker->id); +}); + +test('partner can have child partners (customers)', function () { + $broker = Partner::factory()->estateAgent()->create(); + Partner::factory()->count(2)->create(['parent_partner_id' => $broker->id]); + + expect($broker->childPartners)->toHaveCount(2); + expect($broker->customers)->toHaveCount(2); +}); + +test('partner factory has retailer state', function () { + $partner = Partner::factory()->retailer()->create(); + + expect($partner->type->value)->toBe('Retailer'); +}); + +test('partner factory has manufacturer state', function () { + $partner = Partner::factory()->manufacturer()->create(); + + expect($partner->type->value)->toBe('Manufacturer'); +}); + +test('partner factory has estateAgent state', function () { + $partner = Partner::factory()->estateAgent()->create(); + + expect($partner->type->value)->toBe('Estate-Agent'); +}); + +test('partner casts opening_hours to array', function () { + $hours = ['mon' => '09:00-18:00', 'tue' => '09:00-18:00']; + $partner = Partner::factory()->create(['opening_hours' => $hours]); + + $partner->refresh(); + + expect($partner->opening_hours)->toBeArray(); + expect($partner->opening_hours['mon'])->toBe('09:00-18:00'); +}); + +test('partner casts specialties to array', function () { + $specialties = ['Küchen', 'Wohnzimmer', 'Schlafzimmer']; + $partner = Partner::factory()->create(['specialties' => $specialties]); + + $partner->refresh(); + + expect($partner->specialties)->toBeArray(); + expect($partner->specialties)->toHaveCount(3); +}); diff --git a/tests/Feature/Models/ProductTest.php b/tests/Feature/Models/ProductTest.php new file mode 100644 index 0000000..f140e26 --- /dev/null +++ b/tests/Feature/Models/ProductTest.php @@ -0,0 +1,172 @@ +create(); + + expect($product)->toBeInstanceOf(Product::class); + expect($product->id)->toBeInt(); + expect($product->name)->toBeString(); + expect($product->slug)->toBeString(); +}); + +test('product casts product_type to enum', function () { + $product = Product::factory()->create(['product_type' => 'local_stock']); + + expect($product->product_type)->toBe(ProductType::LocalStock); +}); + +test('product casts status to enum', function () { + $product = Product::factory()->create(['status' => 'draft']); + + expect($product->status)->toBe(ProductStatus::Draft); +}); + +test('product casts price_type to enum', function () { + $product = Product::factory()->create(['price_type' => 'from_price']); + + expect($product->price_type)->toBe(PriceType::FromPrice); +}); + +test('product belongs to partner', function () { + $partner = Partner::factory()->create(); + $product = Product::factory()->create(['partner_id' => $partner->id]); + + expect($product->partner->id)->toBe($partner->id); +}); + +test('product belongs to hub', function () { + $hub = Hub::factory()->create(); + $product = Product::factory()->create(['hub_id' => $hub->id]); + + expect($product->hub->id)->toBe($hub->id); +}); + +test('product belongs to brand', function () { + $partner = Partner::factory()->manufacturer()->create(); + $brand = Brand::factory()->create(['partner_id' => $partner->id]); + $product = Product::factory()->create(['brand_id' => $brand->id]); + + expect($product->brand->id)->toBe($brand->id); +}); + +test('product belongs to collection', function () { + $collection = Collection::create(['name' => 'Test Kollektion', 'slug' => 'test-kollektion']); + $product = Product::factory()->create(['collection_id' => $collection->id]); + + expect($product->collection->id)->toBe($collection->id); +}); + +test('product can have categories', function () { + $product = Product::factory()->create(); + $category = Category::create(['name' => 'Sofas', 'slug' => 'sofas']); + + $product->categories()->attach($category); + + expect($product->categories)->toHaveCount(1); + expect($product->categories->first()->name)->toBe('Sofas'); +}); + +test('product can have tags', function () { + $product = Product::factory()->create(); + $tag = Tag::create(['name' => 'Neu', 'slug' => 'neu']); + + $product->tags()->attach($tag); + + expect($product->tags)->toHaveCount(1); + expect($product->tags->first()->name)->toBe('Neu'); +}); + +test('product can have media', function () { + $product = Product::factory()->create(); + $media = Media::factory()->create([ + 'model_type' => Product::class, + 'model_id' => $product->id, + ]); + + expect($product->media)->toHaveCount(1); + expect($product->media->first()->file_path)->toBe($media->file_path); +}); + +test('product scope active filters correctly', function () { + Product::factory()->create(['status' => ProductStatus::Active]); + Product::factory()->create(['status' => ProductStatus::Draft]); + Product::factory()->create(['status' => ProductStatus::Archived]); + + expect(Product::active()->count())->toBe(1); +}); + +test('product scope curated filters correctly', function () { + Product::factory()->create(['is_curated' => true]); + Product::factory()->create(['is_curated' => false]); + + expect(Product::curated()->count())->toBe(1); +}); + +test('product scope localStock filters correctly', function () { + Product::factory()->localStock()->create(); + Product::factory()->smartOrder()->create(); + + expect(Product::localStock()->count())->toBe(1); + expect(Product::smartOrder()->count())->toBe(1); +}); + +test('product scope inHub filters correctly', function () { + $hub = Hub::factory()->create(); + $otherHub = Hub::factory()->create(); + + Product::factory()->create(['hub_id' => $hub->id]); + Product::factory()->create(['hub_id' => $otherHub->id]); + + expect(Product::inHub($hub->id)->count())->toBe(1); +}); + +test('product scope available combines active curated and available', function () { + Product::factory()->create([ + 'status' => ProductStatus::Active, + 'is_curated' => true, + 'is_available' => true, + ]); + Product::factory()->create([ + 'status' => ProductStatus::Active, + 'is_curated' => false, + 'is_available' => true, + ]); + Product::factory()->create([ + 'status' => ProductStatus::Draft, + 'is_curated' => true, + 'is_available' => true, + ]); + + expect(Product::available()->count())->toBe(1); +}); + +test('product factory has localStock state', function () { + $product = Product::factory()->localStock()->create(); + + expect($product->product_type)->toBe(ProductType::LocalStock); +}); + +test('product factory has smartOrder state', function () { + $product = Product::factory()->smartOrder()->create(); + + expect($product->product_type)->toBe(ProductType::SmartOrder); +}); + +test('product factory has active state', function () { + $product = Product::factory()->active()->create(); + + expect($product->status)->toBe(ProductStatus::Active); + expect($product->is_curated)->toBeTrue(); +}); diff --git a/tests/Feature/Models/ProductWoodOriginTest.php b/tests/Feature/Models/ProductWoodOriginTest.php new file mode 100644 index 0000000..fd70040 --- /dev/null +++ b/tests/Feature/Models/ProductWoodOriginTest.php @@ -0,0 +1,48 @@ +create(); + $product = Product::factory()->create(['partner_id' => $partner->id]); + $origin = ProductWoodOrigin::factory()->create(['product_id' => $product->id]); + + expect($origin)->toBeInstanceOf(ProductWoodOrigin::class); + expect($origin->wood_species)->not->toBeEmpty(); + expect($origin->origin_country)->toHaveLength(2); +}); + +test('product wood origin belongs to product', function () { + $partner = Partner::factory()->create(); + $product = Product::factory()->create(['partner_id' => $partner->id]); + $origin = ProductWoodOrigin::factory()->create(['product_id' => $product->id]); + + expect($origin->product)->toBeInstanceOf(Product::class); + expect($origin->product->id)->toBe($product->id); +}); + +test('product has many wood origins', function () { + $partner = Partner::factory()->create(); + $product = Product::factory()->create(['partner_id' => $partner->id]); + + ProductWoodOrigin::factory()->count(3)->create(['product_id' => $product->id]); + + expect($product->woodOrigins)->toHaveCount(3); +}); + +test('deleting product cascades to wood origins', function () { + $partner = Partner::factory()->create(); + $product = Product::factory()->create(['partner_id' => $partner->id]); + + ProductWoodOrigin::factory()->count(2)->create(['product_id' => $product->id]); + + expect(ProductWoodOrigin::where('product_id', $product->id)->count())->toBe(2); + + $product->delete(); + + expect(ProductWoodOrigin::where('product_id', $product->id)->count())->toBe(0); +}); diff --git a/tests/Feature/Models/SettingTest.php b/tests/Feature/Models/SettingTest.php new file mode 100644 index 0000000..c4ea234 --- /dev/null +++ b/tests/Feature/Models/SettingTest.php @@ -0,0 +1,53 @@ + 'test', + 'key' => 'string_val', + 'value' => 'hello', + 'type' => 'string', + ]); + + expect(Setting::getValue('test', 'string_val'))->toBe('hello'); +}); + +test('setting can store and retrieve an integer value', function () { + Setting::create([ + 'group' => 'test', + 'key' => 'int_val', + 'value' => '42', + 'type' => 'integer', + ]); + + expect(Setting::getValue('test', 'int_val'))->toBe(42); +}); + +test('setting can store and retrieve a boolean value', function () { + Setting::create([ + 'group' => 'test', + 'key' => 'bool_val', + 'value' => 'true', + 'type' => 'boolean', + ]); + + expect(Setting::getValue('test', 'bool_val'))->toBeTrue(); +}); + +test('setting returns default when key does not exist', function () { + expect(Setting::getValue('nonexistent', 'key', 'default'))->toBe('default'); +}); + +test('setting can update value', function () { + Setting::create([ + 'group' => 'test', + 'key' => 'update_val', + 'value' => 'old', + 'type' => 'string', + ]); + + Setting::setValue('test', 'update_val', 'new'); + + expect(Setting::getValue('test', 'update_val'))->toBe('new'); +}); diff --git a/tests/Feature/Models/UserRelationshipsTest.php b/tests/Feature/Models/UserRelationshipsTest.php new file mode 100644 index 0000000..5187de8 --- /dev/null +++ b/tests/Feature/Models/UserRelationshipsTest.php @@ -0,0 +1,39 @@ +create(); + $user = User::factory()->create(['hub_id' => $hub->id]); + + expect($user->hub->id)->toBe($hub->id); +}); + +test('user can have an origin', function () { + $user = User::factory()->create(['origin' => 'style2own']); + + expect($user->origin)->toBe(UserOrigin::Style2Own); +}); + +test('user origin casts stileigentum correctly', function () { + $user = User::factory()->create(['origin' => 'stileigentum']); + + expect($user->origin)->toBe(UserOrigin::StilEigentum); + expect($user->origin->tonality())->toBe('sie'); +}); + +test('user origin can be null', function () { + $user = User::factory()->create(['origin' => null]); + + expect($user->origin)->toBeNull(); +}); + +test('user belongs to partner', function () { + $partner = Partner::factory()->create(); + $user = User::factory()->create(['partner_id' => $partner->id]); + + expect($user->partner->id)->toBe($partner->id); +}); diff --git a/tests/Feature/PartnerPolicyTest.php b/tests/Feature/PartnerPolicyTest.php new file mode 100644 index 0000000..c48257d --- /dev/null +++ b/tests/Feature/PartnerPolicyTest.php @@ -0,0 +1,96 @@ +forgetCachedPermissions(); + + Role::create(['name' => 'Admin']); + Role::create(['name' => 'Retailer']); + Permission::create(['name' => 'curate products']); + Role::findByName('Admin')->givePermissionTo('curate products'); +}); + +test('admin can view any partner', function () { + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + + expect((new PartnerPolicy)->viewAny($admin))->toBeTrue(); +}); + +test('non-admin cannot view any partner', function () { + $retailer = User::factory()->create(); + $retailer->assignRole('Retailer'); + + expect((new PartnerPolicy)->viewAny($retailer))->toBeFalse(); +}); + +test('admin can view any specific partner', function () { + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + $partner = Partner::factory()->create(); + + expect((new PartnerPolicy)->view($admin, $partner))->toBeTrue(); +}); + +test('partner user can view own partner', function () { + $partner = Partner::factory()->create(); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Retailer'); + + expect((new PartnerPolicy)->view($user, $partner))->toBeTrue(); +}); + +test('partner user cannot view other partner', function () { + $otherPartner = Partner::factory()->create(); + $myPartner = Partner::factory()->create(); + $user = User::factory()->create(['partner_id' => $myPartner->id]); + $user->assignRole('Retailer'); + + expect((new PartnerPolicy)->view($user, $otherPartner))->toBeFalse(); +}); + +test('admin can update any partner', function () { + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + $partner = Partner::factory()->create(); + + expect((new PartnerPolicy)->update($admin, $partner))->toBeTrue(); +}); + +test('partner user can update own partner', function () { + $partner = Partner::factory()->create(); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Retailer'); + + expect((new PartnerPolicy)->update($user, $partner))->toBeTrue(); +}); + +test('partner user cannot update other partner', function () { + $otherPartner = Partner::factory()->create(); + $myPartner = Partner::factory()->create(); + $user = User::factory()->create(['partner_id' => $myPartner->id]); + $user->assignRole('Retailer'); + + expect((new PartnerPolicy)->update($user, $otherPartner))->toBeFalse(); +}); + +test('admin can curate products', function () { + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + + expect((new PartnerPolicy)->curateProducts($admin))->toBeTrue(); +}); + +test('retailer cannot curate products', function () { + $retailer = User::factory()->create(); + $retailer->assignRole('Retailer'); + + expect((new PartnerPolicy)->curateProducts($retailer))->toBeFalse(); +}); diff --git a/tests/Feature/PartnerProfilePageTest.php b/tests/Feature/PartnerProfilePageTest.php new file mode 100644 index 0000000..befaea0 --- /dev/null +++ b/tests/Feature/PartnerProfilePageTest.php @@ -0,0 +1,107 @@ +forgetCachedPermissions(); + Role::create(['name' => 'Retailer']); + Role::create(['name' => 'Customer']); +}); + +test('authenticated user can view partner profile page', function () { + $hub = Hub::factory()->create(); + $partner = Partner::factory()->setupCompleted()->create([ + 'hub_id' => $hub->id, + 'company_name' => 'Tischlerei Müller GmbH', + ]); + + $user = User::factory()->create(); + + $this->actingAs($user); + + Volt::test('partner.profile', ['partnerId' => $partner->id]) + ->assertSee($partner->company_name); +}); + +test('partner profile shows display name when set', function () { + $hub = Hub::factory()->create(); + $partner = Partner::factory()->setupCompleted()->create([ + 'hub_id' => $hub->id, + 'company_name' => 'Tischlerei Müller GmbH', + 'display_name' => 'Tischlerei Müller', + ]); + + $user = User::factory()->create(); + $this->actingAs($user); + + Volt::test('partner.profile', ['partnerId' => $partner->id]) + ->assertSee('Tischlerei Müller') + ->assertSee('Tischlerei Müller GmbH'); +}); + +test('partner profile shows story text when present', function () { + $hub = Hub::factory()->create(); + $partner = Partner::factory()->setupCompleted()->create([ + 'hub_id' => $hub->id, + 'story_text' => 'Wir sind seit 1985 eine regionale Tischlerei.', + ]); + + $user = User::factory()->create(); + $this->actingAs($user); + + Volt::test('partner.profile', ['partnerId' => $partner->id]) + ->assertSee('Wir sind seit 1985 eine regionale Tischlerei.'); +}); + +test('partner profile only shows active curated available products', function () { + $hub = Hub::factory()->create(); + $partner = Partner::factory()->setupCompleted()->create(['hub_id' => $hub->id]); + + $visibleProduct = Product::factory()->create([ + 'partner_id' => $partner->id, + 'hub_id' => $hub->id, + 'status' => ProductStatus::Active, + 'is_curated' => true, + 'is_available' => true, + ]); + + $draftProduct = Product::factory()->create([ + 'partner_id' => $partner->id, + 'hub_id' => $hub->id, + 'status' => ProductStatus::Draft, + 'is_curated' => false, + 'is_available' => false, + ]); + + $user = User::factory()->create(); + $this->actingAs($user); + + Volt::test('partner.profile', ['partnerId' => $partner->id]) + ->assertSee($visibleProduct->name) + ->assertDontSee($draftProduct->name); +}); + +test('partner profile throws model not found for non-existent partner', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + expect(fn () => Volt::test('partner.profile', ['partnerId' => 99999])) + ->toThrow(ModelNotFoundException::class); +}); + +test('unauthenticated user is redirected from partner profile', function () { + $hub = Hub::factory()->create(); + $partner = Partner::factory()->setupCompleted()->create(['hub_id' => $hub->id]); + + $this->get(route('partner.profile', ['partnerId' => $partner->id])) + ->assertRedirect(route('login')); +}); diff --git a/tests/Feature/PartnerProfileUpdateTest.php b/tests/Feature/PartnerProfileUpdateTest.php new file mode 100644 index 0000000..ebaf981 --- /dev/null +++ b/tests/Feature/PartnerProfileUpdateTest.php @@ -0,0 +1,135 @@ +forgetCachedPermissions(); + Role::create(['name' => 'Admin']); + Role::create(['name' => 'Retailer']); +}); + +test('admin can access partner edit page', function () { + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + $partner = Partner::factory()->setupCompleted()->create(); + + $this->actingAs($admin) + ->get(route('admin.partners.edit', $partner->id)) + ->assertSuccessful(); +}); + +test('partner can access own profile edit page', function () { + $partner = Partner::factory()->setupCompleted()->create(); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Retailer'); + + $this->actingAs($user) + ->get(route('admin.partners.edit', $partner->id)) + ->assertSuccessful(); +}); + +test('partner cannot access other partner edit page', function () { + $myPartner = Partner::factory()->setupCompleted()->create(); + $otherPartner = Partner::factory()->setupCompleted()->create(); + $user = User::factory()->create(['partner_id' => $myPartner->id]); + $user->assignRole('Retailer'); + + $this->actingAs($user) + ->get(route('admin.partners.edit', $otherPartner->id)) + ->assertForbidden(); +}); + +test('admin can update partner profile', function () { + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + $partner = Partner::factory()->create(['company_name' => 'Alter Name']); + + $this->actingAs($admin); + + Volt::test('admin.partners.edit', ['partnerId' => $partner->id]) + ->set('companyName', 'Neuer Firmenname') + ->set('city', 'Herford') + ->set('storyText', 'Seit 1985 sind wir für Sie da.') + ->set('foundedYear', 1985) + ->set('specialtiesInput', 'Sofas, Küchen, Betten') + ->call('save') + ->assertHasNoErrors(); + + $partner->refresh(); + expect($partner->company_name)->toBe('Neuer Firmenname'); + expect($partner->city)->toBe('Herford'); + expect($partner->story_text)->toBe('Seit 1985 sind wir für Sie da.'); + expect($partner->founded_year)->toBe(1985); + expect($partner->specialties)->toContain('Sofas'); +}); + +test('partner profile update validates required company name', function () { + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + $partner = Partner::factory()->create(); + + $this->actingAs($admin); + + Volt::test('admin.partners.edit', ['partnerId' => $partner->id]) + ->set('companyName', '') + ->call('save') + ->assertHasErrors(['companyName' => 'required']); +}); + +test('partner profile update validates url format', function () { + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + $partner = Partner::factory()->create(); + + $this->actingAs($admin); + + Volt::test('admin.partners.edit', ['partnerId' => $partner->id]) + ->set('companyName', 'Test GmbH') + ->set('website', 'kein-url') + ->call('save') + ->assertHasErrors(['website' => 'url']); +}); + +test('partner profile saves opening hours', function () { + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + $partner = Partner::factory()->create(); + + $this->actingAs($admin); + + Volt::test('admin.partners.edit', ['partnerId' => $partner->id]) + ->set('companyName', $partner->company_name) + ->set('openingHours.monday.open', '08:00') + ->set('openingHours.monday.close', '20:00') + ->set('openingHours.sunday.closed', true) + ->call('save') + ->assertHasNoErrors(); + + $partner->refresh(); + expect($partner->opening_hours['monday']['open'])->toBe('08:00'); + expect($partner->opening_hours['sunday']['closed'])->toBeTrue(); +}); + +test('partner can update own hub assignment', function () { + $hub = Hub::factory()->create(); + $partner = Partner::factory()->create(); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Retailer'); + + $this->actingAs($user); + + Volt::test('admin.partners.edit', ['partnerId' => $partner->id]) + ->set('companyName', $partner->company_name) + ->set('hubId', $hub->id) + ->call('save') + ->assertHasNoErrors(); + + $partner->refresh(); + expect($partner->hub_id)->toBe($hub->id); +}); diff --git a/tests/Feature/ProductCurationTest.php b/tests/Feature/ProductCurationTest.php new file mode 100644 index 0000000..2a19134 --- /dev/null +++ b/tests/Feature/ProductCurationTest.php @@ -0,0 +1,423 @@ +forgetCachedPermissions(); + Role::create(['name' => 'Admin']); + Role::create(['name' => 'Retailer']); + Permission::create(['name' => 'curate products']); + Role::findByName('Admin')->givePermissionTo('curate products'); +}); + +function makeAdminUser(): User +{ + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + + return $admin; +} + +// --- Access Tests --- + +test('admin can access admin products page', function () { + $admin = makeAdminUser(); + + $this->actingAs($admin) + ->get(route('admin.products.index')) + ->assertSuccessful(); +}); + +test('retailer cannot access admin products page', function () { + $retailer = User::factory()->create(); + $retailer->assignRole('Retailer'); + + $this->actingAs($retailer) + ->get(route('admin.products.index')) + ->assertForbidden(); +}); + +// --- Approval Tests --- + +test('admin can approve a pending product', function () { + $admin = makeAdminUser(); + $product = Product::factory()->create([ + 'status' => ProductStatus::Pending, + 'is_curated' => false, + ]); + + $this->actingAs($admin); + + Volt::test('admin.products.index') + ->call('approve', $product->id) + ->assertHasNoErrors(); + + $product->refresh(); + expect($product->status)->toBe(ProductStatus::Active); + expect($product->is_curated)->toBeTrue(); + expect($product->curated_by)->toBe($admin->id); + expect($product->curated_at)->not->toBeNull(); + expect($product->curation_notes)->toBeNull(); +}); + +test('approve creates activity log entry', function () { + $admin = makeAdminUser(); + $product = Product::factory()->create([ + 'status' => ProductStatus::Pending, + 'is_curated' => false, + ]); + + $this->actingAs($admin); + + Volt::test('admin.products.index') + ->call('approve', $product->id) + ->assertHasNoErrors(); + + $activity = ProductActivity::where('product_id', $product->id)->first(); + expect($activity)->not->toBeNull(); + expect($activity->action)->toBe('approved'); + expect($activity->user_id)->toBe($admin->id); +}); + +// --- Correction Tests --- + +test('admin can send correction for a pending product', function () { + $admin = makeAdminUser(); + $product = Product::factory()->create([ + 'status' => ProductStatus::Pending, + 'is_curated' => false, + ]); + + $this->actingAs($admin); + + Volt::test('admin.products.index') + ->call('openCorrection', $product->id) + ->assertSet('correctingProductId', $product->id) + ->set('curationNotes', 'Bitte Produktbilder in höherer Auflösung hochladen.') + ->call('sendCorrection', $product->id) + ->assertHasNoErrors(); + + $product->refresh(); + expect($product->status)->toBe(ProductStatus::Correction); + expect($product->is_curated)->toBeFalse(); + expect($product->curation_notes)->toBe('Bitte Produktbilder in höherer Auflösung hochladen.'); +}); + +test('correction requires notes', function () { + $admin = makeAdminUser(); + $product = Product::factory()->create([ + 'status' => ProductStatus::Pending, + 'is_curated' => false, + ]); + + $this->actingAs($admin); + + Volt::test('admin.products.index') + ->call('openCorrection', $product->id) + ->set('curationNotes', '') + ->call('sendCorrection', $product->id) + ->assertHasErrors(['curationNotes' => 'required']); +}); + +test('correction creates activity log entry with notes', function () { + $admin = makeAdminUser(); + $product = Product::factory()->create([ + 'status' => ProductStatus::Pending, + 'is_curated' => false, + ]); + + $this->actingAs($admin); + + Volt::test('admin.products.index') + ->call('openCorrection', $product->id) + ->set('curationNotes', 'Bilder bitte verbessern.') + ->call('sendCorrection', $product->id) + ->assertHasNoErrors(); + + $activity = ProductActivity::where('product_id', $product->id)->first(); + expect($activity)->not->toBeNull(); + expect($activity->action)->toBe('correction'); + expect($activity->note)->toBe('Bilder bitte verbessern.'); + expect($activity->user_id)->toBe($admin->id); +}); + +// --- Rejection Tests --- + +test('admin can reject a product with reason', function () { + $admin = makeAdminUser(); + $product = Product::factory()->create([ + 'status' => ProductStatus::Pending, + 'is_curated' => false, + ]); + + $this->actingAs($admin); + + Volt::test('admin.products.index') + ->call('openRejection', $product->id) + ->assertSet('rejectingProductId', $product->id) + ->set('rejectionReason', 'Produkt entspricht nicht den Qualitätsstandards.') + ->call('reject', $product->id) + ->assertHasNoErrors(); + + $product->refresh(); + expect($product->status)->toBe(ProductStatus::Archived); + expect($product->is_curated)->toBeFalse(); + expect($product->curation_notes)->toBe('Produkt entspricht nicht den Qualitätsstandards.'); +}); + +test('rejection requires a reason', function () { + $admin = makeAdminUser(); + $product = Product::factory()->create([ + 'status' => ProductStatus::Pending, + 'is_curated' => false, + ]); + + $this->actingAs($admin); + + Volt::test('admin.products.index') + ->call('openRejection', $product->id) + ->set('rejectionReason', '') + ->call('reject', $product->id) + ->assertHasErrors(['rejectionReason' => 'required']); +}); + +test('reject creates activity log entry with reason', function () { + $admin = makeAdminUser(); + $product = Product::factory()->create([ + 'status' => ProductStatus::Pending, + 'is_curated' => false, + ]); + + $this->actingAs($admin); + + Volt::test('admin.products.index') + ->call('openRejection', $product->id) + ->set('rejectionReason', 'Nicht geeignet für Plattform.') + ->call('reject', $product->id) + ->assertHasNoErrors(); + + $activity = ProductActivity::where('product_id', $product->id)->first(); + expect($activity)->not->toBeNull(); + expect($activity->action)->toBe('rejected'); + expect($activity->note)->toBe('Nicht geeignet für Plattform.'); + expect($activity->user_id)->toBe($admin->id); +}); + +// --- Archive / Sold Tests --- + +test('admin can archive a product from list', function () { + $admin = makeAdminUser(); + $product = Product::factory()->create([ + 'status' => ProductStatus::Active, + 'is_curated' => true, + ]); + + $this->actingAs($admin); + + Volt::test('admin.products.index') + ->call('archiveProduct', $product->id) + ->assertHasNoErrors(); + + $product->refresh(); + expect($product->status)->toBe(ProductStatus::Archived); +}); + +test('admin can mark product as sold from list', function () { + $admin = makeAdminUser(); + $product = Product::factory()->create([ + 'status' => ProductStatus::Active, + 'is_curated' => true, + ]); + + $this->actingAs($admin); + + Volt::test('admin.products.index') + ->call('markAsSold', $product->id) + ->assertHasNoErrors(); + + $product->refresh(); + expect($product->status)->toBe(ProductStatus::Sold); +}); + +test('archive creates activity log entry', function () { + $admin = makeAdminUser(); + $product = Product::factory()->create(['status' => ProductStatus::Active]); + + $this->actingAs($admin); + + Volt::test('admin.products.index') + ->call('archiveProduct', $product->id); + + $activity = ProductActivity::where('product_id', $product->id)->first(); + expect($activity)->not->toBeNull(); + expect($activity->action)->toBe('archived'); +}); + +test('mark as sold creates activity log entry', function () { + $admin = makeAdminUser(); + $product = Product::factory()->create(['status' => ProductStatus::Active]); + + $this->actingAs($admin); + + Volt::test('admin.products.index') + ->call('markAsSold', $product->id); + + $activity = ProductActivity::where('product_id', $product->id)->first(); + expect($activity)->not->toBeNull(); + expect($activity->action)->toBe('sold'); +}); + +// --- Filter Tests --- + +test('admin product list shows all products', function () { + $admin = makeAdminUser(); + $product = Product::factory()->create(['name' => 'Test Lampe Deluxe']); + + $this->actingAs($admin); + + Volt::test('admin.products.index') + ->assertSeeText('Test Lampe Deluxe'); +}); + +test('admin product list can filter by status', function () { + $admin = makeAdminUser(); + Product::factory()->create(['status' => ProductStatus::Pending, 'name' => 'Pending Product']); + Product::factory()->create(['status' => ProductStatus::Active, 'name' => 'Active Product']); + + $this->actingAs($admin); + + Volt::test('admin.products.index') + ->set('statusFilter', 'pending') + ->assertSeeText('Pending Product') + ->assertDontSeeText('Active Product'); +}); + +test('admin product list can search by name', function () { + $admin = makeAdminUser(); + Product::factory()->create(['name' => 'Designer Stuhl']); + Product::factory()->create(['name' => 'Vintage Lampe']); + + $this->actingAs($admin); + + Volt::test('admin.products.index') + ->set('search', 'Stuhl') + ->assertSeeText('Designer Stuhl') + ->assertDontSeeText('Vintage Lampe'); +}); + +test('admin product list can filter by partner', function () { + $admin = makeAdminUser(); + $partner1 = Partner::factory()->create(['company_name' => 'Möbelhaus A']); + $partner2 = Partner::factory()->create(['company_name' => 'Möbelhaus B']); + Product::factory()->create(['partner_id' => $partner1->id, 'name' => 'Stuhl A']); + Product::factory()->create(['partner_id' => $partner2->id, 'name' => 'Stuhl B']); + + $this->actingAs($admin); + + Volt::test('admin.products.index') + ->set('partnerFilter', $partner1->id) + ->assertSeeText('Stuhl A') + ->assertDontSeeText('Stuhl B'); +}); + +test('retailer cannot approve products', function () { + $retailer = User::factory()->create(); + $retailer->assignRole('Retailer'); + + $this->actingAs($retailer); + + Volt::test('admin.products.index') + ->assertForbidden(); +}); + +// --- Product list archive/sold from retailer index --- + +test('retailer can archive own product from product list', function () { + Role::findOrCreate('Retailer'); + $partner = Partner::factory()->create(); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Retailer'); + $product = Product::factory()->create([ + 'partner_id' => $partner->id, + 'status' => ProductStatus::Active, + ]); + + $this->actingAs($user); + + Volt::test('products.index') + ->call('archiveProduct', $product->id) + ->assertHasNoErrors(); + + $product->refresh(); + expect($product->status)->toBe(ProductStatus::Archived); +}); + +test('retailer can mark own product as sold from product list', function () { + Role::findOrCreate('Retailer'); + $partner = Partner::factory()->create(); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Retailer'); + $product = Product::factory()->create([ + 'partner_id' => $partner->id, + 'status' => ProductStatus::Active, + ]); + + $this->actingAs($user); + + Volt::test('products.index') + ->call('markAsSold', $product->id) + ->assertHasNoErrors(); + + $product->refresh(); + expect($product->status)->toBe(ProductStatus::Sold); +}); + +// --- Curation notes display in edit form --- + +test('correction notes are displayed in standard product edit', function () { + Role::findOrCreate('Retailer'); + $partner = Partner::factory()->create(); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Retailer'); + $product = Product::factory()->create([ + 'partner_id' => $partner->id, + 'status' => ProductStatus::Correction, + 'product_type' => ProductType::SmartOrder, + 'curation_notes' => 'Bitte bessere Bilder hochladen.', + ]); + + $this->actingAs($user); + + Volt::test('products.form-standard', ['product' => $product]) + ->assertSeeText('Korrektur erforderlich') + ->assertSeeText('Bitte bessere Bilder hochladen.'); +}); + +test('rejection notes are displayed in teaser product edit', function () { + Role::findOrCreate('Retailer'); + $partner = Partner::factory()->create(); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Retailer'); + $product = Product::factory()->create([ + 'partner_id' => $partner->id, + 'status' => ProductStatus::Archived, + 'product_type' => ProductType::LocalStock, + 'curation_notes' => 'Produkt nicht für die Plattform geeignet.', + ]); + + $this->actingAs($user); + + Volt::test('products.form-teaser', ['product' => $product]) + ->assertSeeText('Produkt abgelehnt') + ->assertSeeText('Produkt nicht für die Plattform geeignet.'); +}); diff --git a/tests/Feature/ProductEditTest.php b/tests/Feature/ProductEditTest.php new file mode 100644 index 0000000..bd9e898 --- /dev/null +++ b/tests/Feature/ProductEditTest.php @@ -0,0 +1,622 @@ +forgetCachedPermissions(); + Role::create(['name' => 'Retailer']); + Role::create(['name' => 'Manufacturer']); + Role::create(['name' => 'Customer']); + Role::create(['name' => 'Admin']); +}); + +function createPartnerWithHub(): array +{ + $hub = Hub::factory()->create(); + $partner = Partner::factory()->setupCompleted()->create(['hub_id' => $hub->id]); + $user = User::factory()->create(['partner_id' => $partner->id]); + + return [$user, $partner, $hub]; +} + +function createProductForPartner(Partner $partner, array $overrides = []): Product +{ + $category = Category::factory()->create(); + + $product = Product::factory()->create(array_merge([ + 'partner_id' => $partner->id, + 'product_type' => ProductType::SmartOrder, + 'status' => ProductStatus::Pending, + 'price_type' => PriceType::Fixed, + 'name' => 'Test Produkt', + 'description_short' => 'Kurzbeschreibung', + 'description_long' => 'Langbeschreibung', + 'b2in_article_number' => 'B2IN-000001', + ], $overrides)); + + $product->categories()->attach($category->id); + $product->variants()->create([ + 'is_master_variant' => true, + 'sku' => 'EDIT-SKU-001', + 'selling_price' => 125000, + 'purchase_price' => 68000, + 'msrp' => 149900, + 'availability_status' => 'in_stock', + 'delivery_time_text' => '4-6 Wochen', + 'currency' => 'EUR', + 'variant_weight_g' => 45000, + 'is_active' => true, + ]); + + return $product; +} + +// --- Access Tests --- + +test('owner can access product edit page', function () { + [$user, $partner] = createPartnerWithHub(); + $user->assignRole('Manufacturer'); + $product = createProductForPartner($partner); + + $this->actingAs($user) + ->get(route('products.edit.standard', $product)) + ->assertSuccessful(); +}); + +test('admin can access any product edit page', function () { + [$user, $partner] = createPartnerWithHub(); + $user->assignRole('Admin'); + $otherPartner = Partner::factory()->setupCompleted()->create(); + $product = createProductForPartner($otherPartner); + + $this->actingAs($user) + ->get(route('products.edit.standard', $product)) + ->assertSuccessful(); +}); + +test('other partner cannot access product edit page', function () { + [$user] = createPartnerWithHub(); + $user->assignRole('Manufacturer'); + + $otherPartner = Partner::factory()->setupCompleted()->create(); + $product = createProductForPartner($otherPartner); + + $this->actingAs($user) + ->get(route('products.edit.standard', $product)) + ->assertForbidden(); +}); + +test('customer cannot access product edit page', function () { + $customer = User::factory()->create(); + $customer->assignRole('Customer'); + + $partner = Partner::factory()->setupCompleted()->create(); + $product = createProductForPartner($partner); + + $this->actingAs($customer) + ->get(route('products.edit.standard', $product)) + ->assertForbidden(); +}); + +// --- Mount Pre-Fill Tests --- + +test('edit page pre-fills product data', function () { + [$user, $partner] = createPartnerWithHub(); + $user->assignRole('Manufacturer'); + $product = createProductForPartner($partner, [ + 'name' => 'Vorhandenes Sofa', + 'description_short' => 'Ein tolles Sofa.', + 'country_of_origin' => 'DE', + 'main_material' => 'Buche', + 'assembly_service' => true, + 'service_radius_km' => 50, + 'warranty_months' => 24, + ]); + + $this->actingAs($user); + + Volt::test('products.form-standard', ['product' => $product]) + ->assertSet('name', 'Vorhandenes Sofa') + ->assertSet('descriptionShort', 'Ein tolles Sofa.') + ->assertSet('countryOfOrigin', 'DE') + ->assertSet('mainMaterial', 'Buche') + ->assertSet('assemblyService', true) + ->assertSet('serviceRadiusKm', 50) + ->assertSet('warrantyMonths', 24); +}); + +test('edit page pre-fills variant data', function () { + [$user, $partner] = createPartnerWithHub(); + $user->assignRole('Manufacturer'); + $product = createProductForPartner($partner); + + $this->actingAs($user); + + Volt::test('products.form-standard', ['product' => $product]) + ->assertSet('sku', 'EDIT-SKU-001') + ->assertSet('sellingPrice', 1250.00) + ->assertSet('purchasePrice', 680.00) + ->assertSet('msrp', 1499.00) + ->assertSet('currency', 'EUR'); +}); + +// --- Save / Update Tests --- + +test('edit saves updated product fields', function () { + Storage::fake('public'); + [$user, $partner] = createPartnerWithHub(); + $user->assignRole('Manufacturer'); + $product = createProductForPartner($partner); + + $this->actingAs($user); + + Volt::test('products.form-standard', ['product' => $product]) + ->set('name', 'Aktualisiertes Sofa') + ->set('descriptionShort', 'Neue Kurzbeschreibung.') + ->set('mainMaterial', 'Eiche massiv') + ->set('warrantyMonths', 36) + ->call('save') + ->assertHasNoErrors(); + + $product->refresh(); + + expect($product->name)->toBe('Aktualisiertes Sofa'); + expect($product->description_short)->toBe('Neue Kurzbeschreibung.'); + expect($product->main_material)->toBe('Eiche massiv'); + expect($product->warranty_months)->toBe(36); +}); + +test('edit saves updated variant data', function () { + Storage::fake('public'); + [$user, $partner] = createPartnerWithHub(); + $user->assignRole('Manufacturer'); + $product = createProductForPartner($partner); + + $this->actingAs($user); + + Volt::test('products.form-standard', ['product' => $product]) + ->set('sku', 'UPDATED-SKU') + ->set('sellingPrice', 1500.00) + ->set('currency', 'CHF') + ->call('save') + ->assertHasNoErrors(); + + $variant = $product->variants()->where('is_master_variant', true)->first(); + $variant->refresh(); + + expect($variant->sku)->toBe('UPDATED-SKU'); + expect($variant->selling_price)->toBe(150000); + expect($variant->currency)->toBe('CHF'); +}); + +test('edit re-submits to pending when status is active', function () { + Storage::fake('public'); + [$user, $partner] = createPartnerWithHub(); + $user->assignRole('Manufacturer'); + $product = createProductForPartner($partner, ['status' => ProductStatus::Draft]); + + $this->actingAs($user); + + Volt::test('products.form-standard', ['product' => $product]) + ->set('status', 'active') + ->call('save') + ->assertHasNoErrors(); + + $product->refresh(); + expect($product->status)->toBe(ProductStatus::Pending); +}); + +test('edit keeps draft status when saving as draft', function () { + Storage::fake('public'); + [$user, $partner] = createPartnerWithHub(); + $user->assignRole('Manufacturer'); + $product = createProductForPartner($partner, ['status' => ProductStatus::Pending]); + + $this->actingAs($user); + + Volt::test('products.form-standard', ['product' => $product]) + ->set('status', 'draft') + ->call('save') + ->assertHasNoErrors(); + + $product->refresh(); + expect($product->status)->toBe(ProductStatus::Draft); +}); + +test('edit with correction status re-submits to pending', function () { + Storage::fake('public'); + [$user, $partner] = createPartnerWithHub(); + $user->assignRole('Manufacturer'); + $product = createProductForPartner($partner, [ + 'status' => ProductStatus::Correction, + 'curation_notes' => 'Bitte Bilder verbessern.', + ]); + + $this->actingAs($user); + + Volt::test('products.form-standard', ['product' => $product]) + ->assertSet('status', 'active') + ->call('save') + ->assertHasNoErrors(); + + $product->refresh(); + expect($product->status)->toBe(ProductStatus::Pending); +}); + +// --- Activity Logging Tests --- + +test('edit creates activity log entry on save', function () { + Storage::fake('public'); + [$user, $partner] = createPartnerWithHub(); + $user->assignRole('Manufacturer'); + $product = createProductForPartner($partner); + + $this->actingAs($user); + + Volt::test('products.form-standard', ['product' => $product]) + ->set('name', 'Updated Name') + ->call('save') + ->assertHasNoErrors(); + + $activity = ProductActivity::where('product_id', $product->id)->first(); + + expect($activity)->not->toBeNull(); + expect($activity->action)->toBe('updated'); + expect($activity->user_id)->toBe($user->id); +}); + +test('create creates activity log entry on save', function () { + Storage::fake('public'); + [$user] = createPartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Activity-Log Produkt') + ->set('descriptionShort', 'Produkt mit Activity.') + ->set('categoryId', $category->id) + ->set('priceType', 'fixed') + ->set('status', 'active') + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Activity-Log Produkt')->first(); + $activity = ProductActivity::where('product_id', $product->id)->first(); + + expect($activity)->not->toBeNull(); + expect($activity->action)->toBe('created'); + expect($activity->user_id)->toBe($user->id); +}); + +// --- Validation Tests --- + +test('edit requires name', function () { + [$user, $partner] = createPartnerWithHub(); + $user->assignRole('Manufacturer'); + $product = createProductForPartner($partner); + + $this->actingAs($user); + + Volt::test('products.form-standard', ['product' => $product]) + ->set('name', '') + ->call('save') + ->assertHasErrors(['name' => 'required']); +}); + +test('edit requires short description', function () { + [$user, $partner] = createPartnerWithHub(); + $user->assignRole('Manufacturer'); + $product = createProductForPartner($partner); + + $this->actingAs($user); + + Volt::test('products.form-standard', ['product' => $product]) + ->set('descriptionShort', '') + ->call('save') + ->assertHasErrors(['descriptionShort' => 'required']); +}); + +test('edit allows own sku without unique error', function () { + Storage::fake('public'); + [$user, $partner] = createPartnerWithHub(); + $user->assignRole('Manufacturer'); + $product = createProductForPartner($partner); + + $this->actingAs($user); + + // Keeping the same SKU should not trigger unique error + Volt::test('products.form-standard', ['product' => $product]) + ->assertSet('sku', 'EDIT-SKU-001') + ->call('save') + ->assertHasNoErrors(); +}); + +test('edit rejects duplicate sku from other product', function () { + [$user, $partner] = createPartnerWithHub(); + $user->assignRole('Manufacturer'); + + // Create first product with SKU + $product1 = createProductForPartner($partner); + + // Create second product with different SKU + $product2 = Product::factory()->create([ + 'partner_id' => $partner->id, + 'product_type' => ProductType::SmartOrder, + 'status' => ProductStatus::Pending, + 'price_type' => PriceType::Fixed, + 'description_short' => 'Kurz', + 'b2in_article_number' => 'B2IN-000002', + ]); + $product2->categories()->attach(Category::factory()->create()->id); + $product2->variants()->create([ + 'is_master_variant' => true, + 'sku' => 'OTHER-SKU', + 'selling_price' => 50000, + 'is_active' => true, + ]); + + $this->actingAs($user); + + // Try to change product2's SKU to product1's SKU + Volt::test('products.form-standard', ['product' => $product2]) + ->set('sku', 'EDIT-SKU-001') + ->call('save') + ->assertHasErrors(['sku' => 'unique']); +}); + +// --- Existing Media Tests --- + +test('edit shows existing media', function () { + [$user, $partner] = createPartnerWithHub(); + $user->assignRole('Manufacturer'); + $product = createProductForPartner($partner); + + // Add media + $product->media()->create([ + 'file_path' => 'products/1/test.jpg', + 'type' => 'image', + 'alt_text' => 'Test Image', + 'order_column' => 1, + ]); + + $this->actingAs($user); + + Volt::test('products.form-standard', ['product' => $product]) + ->assertSet('existingMedia', fn ($value) => count($value) === 1 && $value[0]['alt_text'] === 'Test Image'); +}); + +test('edit can remove existing media', function () { + Storage::fake('public'); + [$user, $partner] = createPartnerWithHub(); + $user->assignRole('Manufacturer'); + $product = createProductForPartner($partner); + + // Create a fake file + Storage::disk('public')->put('products/1/test.jpg', 'fake-image-content'); + + $media = $product->media()->create([ + 'file_path' => 'products/1/test.jpg', + 'type' => 'image', + 'alt_text' => 'Test Image', + 'order_column' => 1, + ]); + + $this->actingAs($user); + + Volt::test('products.form-standard', ['product' => $product]) + ->assertSet('existingMedia', fn ($value) => count($value) === 1) + ->call('removeExistingMedia', $media->id) + ->assertSet('existingMedia', fn ($value) => count($value) === 0); + + expect($product->media()->count())->toBe(0); + Storage::disk('public')->assertMissing('products/1/test.jpg'); +}); + +test('edit can reorder existing media via drag and drop', function () { + [$user, $partner] = createPartnerWithHub(); + $user->assignRole('Manufacturer'); + $product = createProductForPartner($partner); + + $media1 = $product->media()->create([ + 'file_path' => 'products/1/first.jpg', + 'type' => 'image', + 'alt_text' => 'First Image', + 'order_column' => 1, + ]); + $media2 = $product->media()->create([ + 'file_path' => 'products/1/second.jpg', + 'type' => 'image', + 'alt_text' => 'Second Image', + 'order_column' => 2, + ]); + $media3 = $product->media()->create([ + 'file_path' => 'products/1/third.jpg', + 'type' => 'image', + 'alt_text' => 'Third Image', + 'order_column' => 3, + ]); + + $this->actingAs($user); + + Volt::test('products.form-standard', ['product' => $product]) + ->assertSet('existingMedia', fn ($value) => count($value) === 3 && $value[0]['id'] === $media1->id) + ->call('updateMediaOrder', [$media3->id, $media1->id, $media2->id]) + ->assertSet('existingMedia', fn ($value) => $value[0]['id'] === $media3->id + && $value[1]['id'] === $media1->id + && $value[2]['id'] === $media2->id + ); + + expect($media3->fresh()->order_column)->toBe(1); + expect($media1->fresh()->order_column)->toBe(2); + expect($media2->fresh()->order_column)->toBe(3); +}); + +test('edit existing media is loaded sorted by order column', function () { + [$user, $partner] = createPartnerWithHub(); + $user->assignRole('Manufacturer'); + $product = createProductForPartner($partner); + + $media2 = $product->media()->create([ + 'file_path' => 'products/1/second.jpg', + 'type' => 'image', + 'alt_text' => 'Second', + 'order_column' => 2, + ]); + $media1 = $product->media()->create([ + 'file_path' => 'products/1/first.jpg', + 'type' => 'image', + 'alt_text' => 'First', + 'order_column' => 1, + ]); + + $this->actingAs($user); + + Volt::test('products.form-standard', ['product' => $product]) + ->assertSet('existingMedia', fn ($value) => $value[0]['id'] === $media1->id && $value[1]['id'] === $media2->id); +}); + +test('edit media order includes order_column in existing media', function () { + [$user, $partner] = createPartnerWithHub(); + $user->assignRole('Manufacturer'); + $product = createProductForPartner($partner); + + $product->media()->create([ + 'file_path' => 'products/1/test.jpg', + 'type' => 'image', + 'alt_text' => 'Test', + 'order_column' => 5, + ]); + + $this->actingAs($user); + + Volt::test('products.form-standard', ['product' => $product]) + ->assertSet('existingMedia', fn ($value) => isset($value[0]['order_column']) && $value[0]['order_column'] === 5); +}); + +// --- Wood Origins Tests --- + +test('edit pre-fills wood origins from product', function () { + [$user, $partner] = createPartnerWithHub(); + $user->assignRole('Manufacturer'); + $product = createProductForPartner($partner); + + $product->woodOrigins()->create([ + 'wood_species' => 'Quercus robur', + 'origin_country' => 'PL', + 'origin_region' => 'Masowien', + 'harvest_year' => 2024, + 'sustainability_certificate' => 'FSC', + ]); + + $this->actingAs($user); + + Volt::test('products.form-standard', ['product' => $product]) + ->assertSet('woodOrigins', fn ($value) => count($value) === 1 + && $value[0]['wood_species'] === 'Quercus robur' + && $value[0]['origin_country'] === 'PL' + ); +}); + +test('edit saves updated wood origins', function () { + Storage::fake('public'); + [$user, $partner] = createPartnerWithHub(); + $user->assignRole('Manufacturer'); + $product = createProductForPartner($partner); + + $product->woodOrigins()->create([ + 'wood_species' => 'Old Species', + 'origin_country' => 'DE', + ]); + + $this->actingAs($user); + + Volt::test('products.form-standard', ['product' => $product]) + ->set('woodOrigins', [ + [ + 'wood_species' => 'New Species', + 'origin_country' => 'AT', + 'origin_region' => 'Tirol', + 'harvest_year' => 2025, + 'forest_operator' => 'Forstbetrieb', + 'sustainability_certificate' => 'PEFC', + 'eudr_reference_id' => 'EUDR-2025-AT', + ], + ]) + ->call('save') + ->assertHasNoErrors(); + + $product->refresh(); + $origins = $product->woodOrigins; + + expect($origins)->toHaveCount(1); + expect($origins->first()->wood_species)->toBe('New Species'); + expect($origins->first()->origin_country)->toBe('AT'); + expect($origins->first()->origin_region)->toBe('Tirol'); +}); + +// --- Activity Log Display --- + +test('edit shows activity history in zuordnung tab', function () { + [$user, $partner] = createPartnerWithHub(); + $user->assignRole('Manufacturer'); + $product = createProductForPartner($partner); + + $product->activities()->create([ + 'user_id' => $user->id, + 'action' => 'created', + 'note' => null, + ]); + + $this->actingAs($user); + + Volt::test('products.form-standard', ['product' => $product]) + ->set('activeTab', 'zuordnung') + ->assertSeeText('created'); +}); + +// --- Archive / Sold from Edit Form --- + +test('edit can archive a product', function () { + [$user, $partner] = createPartnerWithHub(); + $user->assignRole('Manufacturer'); + $product = createProductForPartner($partner, ['status' => ProductStatus::Active]); + + $this->actingAs($user); + + Volt::test('products.form-standard', ['product' => $product]) + ->call('archiveProduct') + ->assertRedirect(route('products.index')); + + $product->refresh(); + expect($product->status)->toBe(ProductStatus::Archived); +}); + +test('edit can mark a product as sold', function () { + [$user, $partner] = createPartnerWithHub(); + $user->assignRole('Manufacturer'); + $product = createProductForPartner($partner, ['status' => ProductStatus::Active]); + + $this->actingAs($user); + + Volt::test('products.form-standard', ['product' => $product]) + ->call('markAsSold') + ->assertRedirect(route('products.index')); + + $product->refresh(); + expect($product->status)->toBe(ProductStatus::Sold); +}); diff --git a/tests/Feature/ProductPolicyTest.php b/tests/Feature/ProductPolicyTest.php new file mode 100644 index 0000000..14de9ce --- /dev/null +++ b/tests/Feature/ProductPolicyTest.php @@ -0,0 +1,131 @@ +forgetCachedPermissions(); + + Role::create(['name' => 'Admin']); + Role::create(['name' => 'Retailer']); + Role::create(['name' => 'Manufacturer']); + Role::create(['name' => 'Customer']); + Permission::create(['name' => 'curate products']); + Role::findByName('Admin')->givePermissionTo('curate products'); +}); + +test('admin can view any product', function () { + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + + expect((new ProductPolicy)->viewAny($admin))->toBeTrue(); +}); + +test('retailer can view any product', function () { + $retailer = User::factory()->create(); + $retailer->assignRole('Retailer'); + + expect((new ProductPolicy)->viewAny($retailer))->toBeTrue(); +}); + +test('customer cannot view any product in backend', function () { + $customer = User::factory()->create(); + $customer->assignRole('Customer'); + + expect((new ProductPolicy)->viewAny($customer))->toBeFalse(); +}); + +test('admin can view specific product', function () { + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + $product = Product::factory()->create(); + + expect((new ProductPolicy)->view($admin, $product))->toBeTrue(); +}); + +test('partner user can view own product', function () { + $partner = Partner::factory()->create(); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Retailer'); + $product = Product::factory()->create(['partner_id' => $partner->id]); + + expect((new ProductPolicy)->view($user, $product))->toBeTrue(); +}); + +test('partner user cannot view other partner product', function () { + $myPartner = Partner::factory()->create(); + $otherPartner = Partner::factory()->create(); + $user = User::factory()->create(['partner_id' => $myPartner->id]); + $user->assignRole('Retailer'); + $product = Product::factory()->create(['partner_id' => $otherPartner->id]); + + expect((new ProductPolicy)->view($user, $product))->toBeFalse(); +}); + +test('retailer can create products', function () { + $retailer = User::factory()->create(); + $retailer->assignRole('Retailer'); + + expect((new ProductPolicy)->create($retailer))->toBeTrue(); +}); + +test('manufacturer can create products', function () { + $manufacturer = User::factory()->create(); + $manufacturer->assignRole('Manufacturer'); + + expect((new ProductPolicy)->create($manufacturer))->toBeTrue(); +}); + +test('customer cannot create products', function () { + $customer = User::factory()->create(); + $customer->assignRole('Customer'); + + expect((new ProductPolicy)->create($customer))->toBeFalse(); +}); + +test('admin can update any product', function () { + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + $product = Product::factory()->create(); + + expect((new ProductPolicy)->update($admin, $product))->toBeTrue(); +}); + +test('partner can update own product', function () { + $partner = Partner::factory()->create(); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Retailer'); + $product = Product::factory()->create(['partner_id' => $partner->id]); + + expect((new ProductPolicy)->update($user, $product))->toBeTrue(); +}); + +test('partner cannot update other partner product', function () { + $myPartner = Partner::factory()->create(); + $otherPartner = Partner::factory()->create(); + $user = User::factory()->create(['partner_id' => $myPartner->id]); + $user->assignRole('Retailer'); + $product = Product::factory()->create(['partner_id' => $otherPartner->id]); + + expect((new ProductPolicy)->update($user, $product))->toBeFalse(); +}); + +test('admin can curate products', function () { + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + + expect((new ProductPolicy)->curate($admin))->toBeTrue(); +}); + +test('retailer cannot curate products', function () { + $retailer = User::factory()->create(); + $retailer->assignRole('Retailer'); + + expect((new ProductPolicy)->curate($retailer))->toBeFalse(); +}); diff --git a/tests/Feature/RestoreBackupSeederTest.php b/tests/Feature/RestoreBackupSeederTest.php new file mode 100644 index 0000000..3bb4d71 --- /dev/null +++ b/tests/Feature/RestoreBackupSeederTest.php @@ -0,0 +1,75 @@ +seed(RestoreBackupSeeder::class); +}); + +test('seeder creates expected number of roles', function () { + expect(Role::count())->toBe(6); +}); + +test('seeder creates expected roles with correct names', function () { + expect(Role::pluck('name')->sort()->values()->all()) + ->toBe(['Admin', 'Broker', 'Customer', 'Manufacturer', 'Retailer', 'Super-Admin']); +}); + +test('seeder creates all 21 permissions', function () { + expect(Permission::count())->toBe(21); +}); + +test('seeder creates 12 partners', function () { + expect(Partner::count())->toBe(12); +}); + +test('seeder creates 13 users including soft-deleted', function () { + expect(User::withTrashed()->count())->toBe(13); + expect(User::onlyTrashed()->count())->toBe(4); +}); + +test('seeder assigns correct roles to users', function () { + $admin = User::find(1); + expect($admin)->not->toBeNull() + ->and($admin->hasRole('Admin'))->toBeTrue(); + + $manufacturer = User::find(11); + expect($manufacturer)->not->toBeNull() + ->and($manufacturer->hasRole('Manufacturer'))->toBeTrue(); + + $retailer = User::find(15); + expect($retailer)->not->toBeNull() + ->and($retailer->hasRole('Retailer'))->toBeTrue(); +}); + +test('seeder creates 2 brands linked to partners', function () { + expect(Brand::count())->toBe(2); + expect(Brand::find(1)->partner_id)->toBe(15); + expect(Brand::find(2)->partner_id)->toBe(10); +}); + +test('seeder creates 20 registration codes', function () { + expect(RegistrationCode::count())->toBe(20); +}); + +test('seeder preserves user-partner relationships', function () { + $user = User::find(12); + expect($user->partner_id)->toBe(11); + expect($user->partner->company_name)->toBe('Max Möbelmann'); +}); + +test('seeder can be run multiple times without errors', function () { + $this->seed(RestoreBackupSeeder::class); + + expect(User::withTrashed()->count())->toBe(13); + expect(Role::count())->toBe(6); + expect(Partner::count())->toBe(12); +}); diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php index 110aeec..37ea61e 100644 --- a/tests/Feature/Settings/ProfileUpdateTest.php +++ b/tests/Feature/Settings/ProfileUpdateTest.php @@ -3,10 +3,10 @@ use App\Models\User; use Livewire\Volt\Volt; -test('profile page is displayed', function () { - $this->actingAs($user = User::factory()->create()); +test('profile page requires authentication', function () { + $portalUrl = 'https://'.config('domains.domain_portal'); - $this->get('/settings/profile')->assertOk(); + $this->get($portalUrl.'/settings/profile')->assertRedirect(); }); test('profile information can be updated', function () { @@ -56,7 +56,7 @@ test('user can delete their account', function () { ->assertHasNoErrors() ->assertRedirect('/'); - expect($user->fresh())->toBeNull(); + expect($user->fresh()->trashed())->toBeTrue(); expect(auth()->check())->toBeFalse(); }); diff --git a/tests/Feature/StandardProductCreateTest.php b/tests/Feature/StandardProductCreateTest.php new file mode 100644 index 0000000..91f7cc2 --- /dev/null +++ b/tests/Feature/StandardProductCreateTest.php @@ -0,0 +1,1027 @@ +forgetCachedPermissions(); + Role::create(['name' => 'Retailer']); + Role::create(['name' => 'Manufacturer']); + Role::create(['name' => 'Customer']); +}); + +function makePartnerWithHub(): array +{ + $hub = Hub::factory()->create(); + $partner = Partner::factory()->setupCompleted()->create(['hub_id' => $hub->id]); + $user = User::factory()->create(['partner_id' => $partner->id]); + + return [$user, $partner, $hub]; +} + +// --- Access Tests --- + +test('retailer can access standard product creation page', function () { + [$user] = makePartnerWithHub(); + $user->assignRole('Retailer'); + + $this->actingAs($user) + ->get(route('products.create.standard')) + ->assertSuccessful(); +}); + +test('manufacturer can access standard product creation page', function () { + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + + $this->actingAs($user) + ->get(route('products.create.standard')) + ->assertSuccessful(); +}); + +test('customer cannot access standard product creation page', function () { + $customer = User::factory()->create(); + $customer->assignRole('Customer'); + + $this->actingAs($customer) + ->get(route('products.create.standard')) + ->assertForbidden(); +}); + +// --- Happy Path Tests --- + +test('manufacturer can create standard product with fixed price', function () { + Storage::fake('public'); + [$user, $partner, $hub] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Sofa ALBA 3-Sitzer') + ->set('descriptionShort', 'Modernes Sofa mit Massivholzgestell.') + ->set('descriptionLong', 'Das Sofa ALBA verbindet zeitloses Design mit regionaler Handwerkskunst.') + ->set('categoryId', $category->id) + ->set('priceType', 'fixed') + ->set('status', 'active') + ->set('sku', 'SOFA-ALBA-3S') + ->set('sellingPrice', 1250.00) + ->set('mainImages', [UploadedFile::fake()->image('sofa.jpg', 800, 600)]) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Sofa ALBA 3-Sitzer')->first(); + + expect($product)->not->toBeNull(); + expect($product->product_type)->toBe(ProductType::SmartOrder); + expect($product->price_type)->toBe(PriceType::Fixed); + expect($product->status)->toBe(ProductStatus::Pending); + expect($product->partner_id)->toBe($partner->id); + expect($product->hub_id)->toBe($hub->id); + expect($product->is_curated)->toBeFalse(); + expect($product->description_long)->toBe('Das Sofa ALBA verbindet zeitloses Design mit regionaler Handwerkskunst.'); + + // Master-Variante prüfen + $variant = $product->variants()->where('is_master_variant', true)->first(); + expect($variant)->not->toBeNull(); + expect($variant->sku)->toBe('SOFA-ALBA-3S'); + expect($variant->selling_price)->toBe(125000); + expect($variant->is_active)->toBeTrue(); + + // Bild prüfen + expect($product->media)->toHaveCount(1); +}); + +test('retailer can create standard product with from_price', function () { + Storage::fake('public'); + [$user, $partner] = makePartnerWithHub(); + $user->assignRole('Retailer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Tisch Eiche massiv') + ->set('descriptionShort', 'Massivholztisch auf Maß.') + ->set('categoryId', $category->id) + ->set('priceType', 'from_price') + ->set('priceDisplayText', 'Ab 1.800 €') + ->set('status', 'draft') + ->set('sku', 'TISCH-EICHE-01') + ->set('sellingPrice', 1800.00) + ->set('mainImages', [UploadedFile::fake()->image('table.jpg', 800, 600)]) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Tisch Eiche massiv')->first(); + + expect($product)->not->toBeNull(); + expect($product->product_type)->toBe(ProductType::SmartOrder); + expect($product->price_type)->toBe(PriceType::FromPrice); + expect($product->price_display_text)->toBe('Ab 1.800 €'); + expect($product->status)->toBe(ProductStatus::Draft); +}); + +test('standard product saves physical dimensions and logistics', function () { + Storage::fake('public'); + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Sideboard mit Logistik') + ->set('descriptionShort', 'Ein Sideboard mit Maßen.') + ->set('categoryId', $category->id) + ->set('priceType', 'fixed') + ->set('status', 'active') + ->set('sku', 'SB-LOG-001') + ->set('sellingPrice', 990.00) + ->set('widthCm', 180) + ->set('heightCm', 85) + ->set('depthCm', 45) + ->set('weightG', 45000) + ->set('assemblyStatus', 'partially') + ->set('packageCount', 2) + ->set('packageWeightG', 52000) + ->set('packageWidthCm', 190) + ->set('packageHeightCm', 50) + ->set('packageDepthCm', 50) + ->set('mainImages', [UploadedFile::fake()->image('sideboard.jpg', 800, 600)]) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Sideboard mit Logistik')->first(); + + expect($product->width_cm)->toBe(180); + expect($product->height_cm)->toBe(85); + expect($product->depth_cm)->toBe(45); + expect($product->assembly_status)->toBe('partially'); + + $variant = $product->variants()->where('is_master_variant', true)->first(); + expect($variant->variant_weight_g)->toBe(45000); + + $logistics = $variant->logistics; + expect($logistics)->not->toBeNull(); + expect($logistics->package_count)->toBe(2); + expect($logistics->package_weight_g)->toBe(52000); + expect($logistics->package_width_cm)->toBe(190); +}); + +test('standard product saves commercial variant data', function () { + Storage::fake('public'); + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Produkt Kommerziell') + ->set('descriptionShort', 'Kommerzielles Produkt.') + ->set('categoryId', $category->id) + ->set('priceType', 'fixed') + ->set('status', 'active') + ->set('sku', 'KOMM-001') + ->set('hanMpn', 'MPN-XYZ') + ->set('eanGtin', '4012345678901') + ->set('sellingPrice', 1250.00) + ->set('purchasePrice', 680.00) + ->set('msrp', 1499.00) + ->set('availabilityStatus', 'on_order') + ->set('deliveryTimeText', '4-6 Wochen') + ->set('mainImages', [UploadedFile::fake()->image('product.jpg', 800, 600)]) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Produkt Kommerziell')->first(); + $variant = $product->variants()->where('is_master_variant', true)->first(); + + expect($variant->sku)->toBe('KOMM-001'); + expect($variant->han_mpn)->toBe('MPN-XYZ'); + expect($variant->ean_gtin)->toBe('4012345678901'); + expect($variant->selling_price)->toBe(125000); + expect($variant->purchase_price)->toBe(68000); + expect($variant->msrp)->toBe(149900); + expect($variant->availability_status)->toBe('on_order'); + expect($variant->delivery_time_text)->toBe('4-6 Wochen'); +}); + +test('standard product saves SEO metadata', function () { + Storage::fake('public'); + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'SEO Produkt') + ->set('descriptionShort', 'Ein Produkt mit SEO-Daten.') + ->set('categoryId', $category->id) + ->set('priceType', 'fixed') + ->set('status', 'active') + ->set('sku', 'SEO-001') + ->set('sellingPrice', 500.00) + ->set('metaTitle', 'SEO Titel für Produkt') + ->set('metaDescription', 'Eine SEO-Beschreibung für das Produkt.') + ->set('mainImages', [UploadedFile::fake()->image('seo.jpg', 800, 600)]) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'SEO Produkt')->first(); + expect($product->meta_title)->toBe('SEO Titel für Produkt'); + expect($product->meta_description)->toBe('Eine SEO-Beschreibung für das Produkt.'); +}); + +// --- Validation Tests --- + +test('validation errors switch to the tab with the first error', function () { + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('activeTab', 'kommerziell') + ->set('name', '') + ->call('save') + ->assertHasErrors(['name' => 'required']) + ->assertSet('activeTab', 'basis'); +}); + +test('standard product requires name', function () { + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', '') + ->call('save') + ->assertHasErrors(['name' => 'required']); +}); + +test('standard product requires short description', function () { + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Test') + ->set('descriptionShort', '') + ->call('save') + ->assertHasErrors(['descriptionShort' => 'required']); +}); + +test('standard product requires category', function () { + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Test') + ->set('descriptionShort', 'Beschreibung.') + ->set('categoryId', null) + ->call('save') + ->assertHasErrors(['categoryId' => 'required']); +}); + +test('standard product can be created without sku and selling price', function () { + Storage::fake('public'); + [$user, $partner] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Minimal-Produkt') + ->set('descriptionShort', 'Produkt ohne SKU und Preis.') + ->set('categoryId', $category->id) + ->set('priceType', 'fixed') + ->set('status', 'active') + ->set('sku', '') + ->set('sellingPrice', null) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Minimal-Produkt')->first(); + expect($product)->not->toBeNull(); + + $variant = $product->variants()->where('is_master_variant', true)->first(); + expect($variant->sku)->toBeNull(); + expect($variant->selling_price)->toBeNull(); +}); + +test('standard product requires unique sku', function () { + Storage::fake('public'); + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + // Erstelle existierende Variante mit gleicher SKU + $existingProduct = Product::factory()->create(['partner_id' => $user->partner_id]); + $existingProduct->variants()->create([ + 'sku' => 'DOPPEL-SKU', + 'is_master_variant' => true, + 'selling_price' => 10000, + 'is_active' => true, + ]); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Doppelte SKU') + ->set('descriptionShort', 'Beschreibung.') + ->set('categoryId', $category->id) + ->set('priceType', 'fixed') + ->set('status', 'active') + ->set('sku', 'DOPPEL-SKU') + ->set('sellingPrice', 500.00) + ->set('mainImages', [UploadedFile::fake()->image('test.jpg', 800, 600)]) + ->call('save') + ->assertHasErrors(['sku' => 'unique']); +}); + +test('standard product allows all three price types', function () { + Storage::fake('public'); + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + // Fixed Price + Volt::test('products.form-standard') + ->set('name', 'Fixed-Preis Produkt') + ->set('descriptionShort', 'Ein Produkt mit Festpreis.') + ->set('categoryId', $category->id) + ->set('priceType', 'fixed') + ->set('status', 'active') + ->set('sku', 'FIX-001') + ->set('sellingPrice', 500.00) + ->set('mainImages', [UploadedFile::fake()->image('fix.jpg', 800, 600)]) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Fixed-Preis Produkt')->first(); + expect($product->price_type)->toBe(PriceType::Fixed); + + // On Request + Volt::test('products.form-standard') + ->set('name', 'Anfrage-Produkt') + ->set('descriptionShort', 'Ein Produkt auf Anfrage.') + ->set('categoryId', $category->id) + ->set('priceType', 'on_request') + ->set('status', 'active') + ->set('sku', 'REQ-001') + ->set('sellingPrice', 500.00) + ->set('mainImages', [UploadedFile::fake()->image('req.jpg', 800, 600)]) + ->call('save') + ->assertHasNoErrors(); + + $product2 = Product::where('name', 'Anfrage-Produkt')->first(); + expect($product2->price_type)->toBe(PriceType::OnRequest); +}); + +test('standard product requires price display text when from_price', function () { + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Ab-Preis ohne Text') + ->set('descriptionShort', 'Beschreibung.') + ->set('categoryId', $category->id) + ->set('priceType', 'from_price') + ->set('priceDisplayText', '') + ->set('sku', 'ABP-001') + ->set('sellingPrice', 500.00) + ->call('save') + ->assertHasErrors(['priceDisplayText']); +}); + +test('standard product description short max 180 characters', function () { + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Test') + ->set('descriptionShort', str_repeat('x', 181)) + ->call('save') + ->assertHasErrors(['descriptionShort' => 'max']); +}); + +// --- CSV Extension Tests --- + +test('standard product saves material fields', function () { + Storage::fake('public'); + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Material-Produkt') + ->set('descriptionShort', 'Ein Produkt mit Materialdaten.') + ->set('categoryId', $category->id) + ->set('priceType', 'fixed') + ->set('status', 'active') + ->set('sku', 'MAT-001') + ->set('sellingPrice', 500.00) + ->set('countryOfOrigin', 'DE') + ->set('mainMaterial', 'Massivholz Buche') + ->set('surfaceMaterial', 'Furnier Eiche geölt') + ->set('coverMaterial', 'Stoff (Polyester)') + ->set('colorFinish', 'Eiche natur / Anthrazit') + ->set('certificates', ['FSC', 'OEKO-TEX']) + ->set('assemblyTimeMin', 45) + ->set('loadCapacityKg', 120) + ->set('mainImages', [UploadedFile::fake()->image('mat.jpg', 800, 600)]) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Material-Produkt')->first(); + + expect($product->country_of_origin)->toBe('DE'); + expect($product->main_material)->toBe('Massivholz Buche'); + expect($product->surface_material)->toBe('Furnier Eiche geölt'); + expect($product->cover_material)->toBe('Stoff (Polyester)'); + expect($product->color_finish)->toBe('Eiche natur / Anthrazit'); + expect($product->certificates)->toBe(['FSC', 'OEKO-TEX']); + expect($product->assembly_time_min)->toBe(45); + expect($product->load_capacity_kg)->toBe(120); +}); + +test('standard product saves logistics extension fields', function () { + Storage::fake('public'); + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Logistik-Erweitert') + ->set('descriptionShort', 'Produkt mit erweiterten Logistikdaten.') + ->set('categoryId', $category->id) + ->set('priceType', 'fixed') + ->set('status', 'active') + ->set('sku', 'LOG-EXT-001') + ->set('sellingPrice', 990.00) + ->set('packageCount', 3) + ->set('packageWeightG', 80000) + ->set('packageWidthCm', 200) + ->set('packageHeightCm', 60) + ->set('packageDepthCm', 100) + ->set('packagingType', 'karton_kantenschutz') + ->set('packagingRecyclablePercent', 85) + ->set('isPalletizable', true) + ->set('hsCode', '94016100') + ->set('deliveryType', 'spedition') + ->set('mainImages', [UploadedFile::fake()->image('log.jpg', 800, 600)]) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Logistik-Erweitert')->first(); + $variant = $product->variants()->where('is_master_variant', true)->first(); + $logistics = $variant->logistics; + + expect($product->delivery_type)->toBe('spedition'); + expect($logistics)->not->toBeNull(); + expect($logistics->packaging_type)->toBe('karton_kantenschutz'); + expect($logistics->packaging_recyclable_percent)->toBe(85); + expect($logistics->is_palletizable)->toBeTrue(); + expect($logistics->hs_code)->toBe('94016100'); +}); + +test('standard product saves service and warranty fields', function () { + Storage::fake('public'); + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Service-Produkt') + ->set('descriptionShort', 'Produkt mit Service.') + ->set('categoryId', $category->id) + ->set('priceType', 'fixed') + ->set('status', 'active') + ->set('sku', 'SVC-001') + ->set('sellingPrice', 1200.00) + ->set('assemblyService', true) + ->set('serviceRadiusKm', 50) + ->set('warrantyMonths', 24) + ->set('mainImages', [UploadedFile::fake()->image('svc.jpg', 800, 600)]) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Service-Produkt')->first(); + + expect($product->assembly_service)->toBeTrue(); + expect($product->service_radius_km)->toBe(50); + expect($product->warranty_months)->toBe(24); +}); + +test('standard product saves sustainability fields', function () { + Storage::fake('public'); + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Nachhaltiges Produkt') + ->set('descriptionShort', 'Nachhaltiges Möbelstück.') + ->set('categoryId', $category->id) + ->set('priceType', 'fixed') + ->set('status', 'active') + ->set('sku', 'GREEN-001') + ->set('sellingPrice', 800.00) + ->set('co2FootprintKg', 35.50) + ->set('recyclingPercentage', 40) + ->set('isRegionalProduction', true) + ->set('mainImages', [UploadedFile::fake()->image('green.jpg', 800, 600)]) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Nachhaltiges Produkt')->first(); + + expect((float) $product->co2_footprint_kg)->toBe(35.50); + expect($product->recycling_percentage)->toBe(40); + expect($product->is_regional_production)->toBeTrue(); +}); + +test('standard product saves EUDR wood origins', function () { + Storage::fake('public'); + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'EUDR-Produkt') + ->set('descriptionShort', 'Produkt mit Holzherkunft.') + ->set('categoryId', $category->id) + ->set('priceType', 'fixed') + ->set('status', 'active') + ->set('sku', 'EUDR-001') + ->set('sellingPrice', 1500.00) + ->set('woodOrigins', [ + [ + 'wood_species' => 'Quercus robur', + 'origin_country' => 'PL', + 'origin_region' => 'Masowien', + 'harvest_year' => 2024, + 'forest_operator' => 'ForestPol Sp. z o.o.', + 'sustainability_certificate' => 'FSC', + 'eudr_reference_id' => 'EUDR-DD-2025-PL-03421', + ], + [ + 'wood_species' => 'Fagus sylvatica', + 'origin_country' => 'DE', + 'origin_region' => '', + 'harvest_year' => 2023, + 'forest_operator' => '', + 'sustainability_certificate' => 'PEFC', + 'eudr_reference_id' => '', + ], + ]) + ->set('mainImages', [UploadedFile::fake()->image('eudr.jpg', 800, 600)]) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'EUDR-Produkt')->first(); + + expect($product->woodOrigins)->toHaveCount(2); + + $first = $product->woodOrigins->first(); + expect($first->wood_species)->toBe('Quercus robur'); + expect($first->origin_country)->toBe('PL'); + expect($first->origin_region)->toBe('Masowien'); + expect($first->harvest_year)->toBe(2024); + expect($first->sustainability_certificate)->toBe('FSC'); + + $second = $product->woodOrigins->last(); + expect($second->wood_species)->toBe('Fagus sylvatica'); + expect($second->origin_country)->toBe('DE'); +}); + +test('standard product saves visibility and scoring fields', function () { + Storage::fake('public'); + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Scoring-Produkt') + ->set('descriptionShort', 'Produkt mit Scoring.') + ->set('categoryId', $category->id) + ->set('priceType', 'fixed') + ->set('status', 'active') + ->set('sku', 'SCORE-001') + ->set('sellingPrice', 600.00) + ->set('visibleIsAvailable', true) + ->set('visibleFrom', '2026-03-01') + ->set('visibleUntil', '2027-03-01') + ->set('storageVolumeLiters', 280) + ->set('assemblyEffortScore', 3) + ->set('designScore', 5) + ->set('mainImages', [UploadedFile::fake()->image('score.jpg', 800, 600)]) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Scoring-Produkt')->first(); + + expect($product->visible_from->format('Y-m-d'))->toBe('2026-03-01'); + expect($product->visible_until->format('Y-m-d'))->toBe('2027-03-01'); + expect($product->storage_volume_liters)->toBe(280); + expect($product->assembly_effort_score)->toBe(3); + expect($product->design_score)->toBe(5); +}); + +test('standard product saves currency on variant', function () { + Storage::fake('public'); + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'CHF-Produkt') + ->set('descriptionShort', 'Produkt in Schweizer Franken.') + ->set('categoryId', $category->id) + ->set('priceType', 'fixed') + ->set('status', 'active') + ->set('sku', 'CHF-001') + ->set('sellingPrice', 1100.00) + ->set('currency', 'CHF') + ->set('mainImages', [UploadedFile::fake()->image('chf.jpg', 800, 600)]) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'CHF-Produkt')->first(); + $variant = $product->variants()->where('is_master_variant', true)->first(); + + expect($variant->currency)->toBe('CHF'); +}); + +test('standard product country of origin must be 2 characters', function () { + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Test') + ->set('descriptionShort', 'Beschreibung.') + ->set('categoryId', $category->id) + ->set('sku', 'TEST-CO') + ->set('sellingPrice', 100.00) + ->set('countryOfOrigin', 'DEU') + ->call('save') + ->assertHasErrors(['countryOfOrigin' => 'size']); +}); + +test('standard product visible_until must be after visible_from', function () { + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Test') + ->set('descriptionShort', 'Beschreibung.') + ->set('categoryId', $category->id) + ->set('sku', 'TEST-VIS') + ->set('sellingPrice', 100.00) + ->set('visibleFrom', '2027-01-01') + ->set('visibleUntil', '2026-01-01') + ->call('save') + ->assertHasErrors(['visibleUntil']); +}); + +test('standard product assembly_effort_score must be between 1 and 5', function () { + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Test') + ->set('descriptionShort', 'Beschreibung.') + ->set('categoryId', $category->id) + ->set('sku', 'TEST-AES') + ->set('sellingPrice', 100.00) + ->set('assemblyEffortScore', 6) + ->call('save') + ->assertHasErrors(['assemblyEffortScore' => 'max']); +}); + +test('standard product recycling_percentage must be between 0 and 100', function () { + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Test') + ->set('descriptionShort', 'Beschreibung.') + ->set('categoryId', $category->id) + ->set('sku', 'TEST-REC') + ->set('sellingPrice', 100.00) + ->set('recyclingPercentage', 101) + ->call('save') + ->assertHasErrors(['recyclingPercentage' => 'max']); +}); + +test('wood origin add and remove works', function () { + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->call('addWoodOrigin') + ->assertSet('woodOrigins', fn ($value) => count($value) === 1) + ->call('addWoodOrigin') + ->assertSet('woodOrigins', fn ($value) => count($value) === 2) + ->call('removeWoodOrigin', 0) + ->assertSet('woodOrigins', fn ($value) => count($value) === 1); +}); + +// --- Product Number Tests --- + +test('partner product number is auto-generated on mount', function () { + [$user, $partner] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + + $this->actingAs($user); + + $expected = sprintf('P%03d-%04d', $partner->id, 1); + + Volt::test('products.form-standard') + ->assertSet('partnerProductNumber', $expected); +}); + +test('partner product number can be overwritten', function () { + Storage::fake('public'); + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Custom-Nr Produkt') + ->set('descriptionShort', 'Produkt mit eigener Nummer.') + ->set('categoryId', $category->id) + ->set('priceType', 'fixed') + ->set('status', 'active') + ->set('partnerProductNumber', 'MEINE-NR-42') + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Custom-Nr Produkt')->first(); + expect($product->partner_product_number)->toBe('MEINE-NR-42'); +}); + +test('b2in article number is auto-generated on save', function () { + Storage::fake('public'); + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'B2in-Nr Produkt') + ->set('descriptionShort', 'Produkt mit B2in-Nummer.') + ->set('categoryId', $category->id) + ->set('priceType', 'fixed') + ->set('status', 'active') + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'B2in-Nr Produkt')->first(); + expect($product->b2in_article_number)->toStartWith('B2IN-'); + expect(strlen($product->b2in_article_number))->toBe(11); +}); + +test('b2in article number increments sequentially', function () { + Storage::fake('public'); + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + // Erstes Produkt + Volt::test('products.form-standard') + ->set('name', 'Sequenz-Produkt 1') + ->set('descriptionShort', 'Erstes Sequenz-Produkt.') + ->set('categoryId', $category->id) + ->set('priceType', 'fixed') + ->set('status', 'active') + ->call('save') + ->assertHasNoErrors(); + + // Zweites Produkt + Volt::test('products.form-standard') + ->set('name', 'Sequenz-Produkt 2') + ->set('descriptionShort', 'Zweites Sequenz-Produkt.') + ->set('categoryId', $category->id) + ->set('priceType', 'fixed') + ->set('status', 'active') + ->call('save') + ->assertHasNoErrors(); + + $first = Product::where('name', 'Sequenz-Produkt 1')->first(); + $second = Product::where('name', 'Sequenz-Produkt 2')->first(); + + expect($first->b2in_article_number)->toBe('B2IN-000001'); + expect($second->b2in_article_number)->toBe('B2IN-000002'); +}); + +// --- Brand Autocomplete Tests --- + +test('selecting existing system brand links product to it', function () { + Storage::fake('public'); + [$user, $partner] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + $systemBrand = Brand::factory()->create(['partner_id' => null, 'name' => 'Hülsta']); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Produkt mit System-Marke') + ->set('descriptionShort', 'Bestehende Marke gewählt.') + ->set('categoryId', $category->id) + ->set('priceType', 'fixed') + ->set('status', 'active') + ->set('brandName', 'Hülsta') + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Produkt mit System-Marke')->first(); + expect($product->brand_id)->toBe($systemBrand->id); + expect(Brand::count())->toBe(1); +}); + +test('typing new brand name creates partner-specific brand', function () { + Storage::fake('public'); + [$user, $partner] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Produkt mit neuer Marke') + ->set('descriptionShort', 'Eigene Marke eingegeben.') + ->set('categoryId', $category->id) + ->set('priceType', 'fixed') + ->set('status', 'active') + ->set('brandName', 'Meine Tischlerei') + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Produkt mit neuer Marke')->first(); + $newBrand = Brand::where('name', 'Meine Tischlerei')->first(); + + expect($newBrand)->not->toBeNull(); + expect($newBrand->partner_id)->toBe($partner->id); + expect($newBrand->is_active)->toBeTrue(); + expect($product->brand_id)->toBe($newBrand->id); +}); + +test('reusing custom brand name does not create duplicate', function () { + Storage::fake('public'); + [$user, $partner] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + $existingBrand = Brand::factory()->create(['partner_id' => $partner->id, 'name' => 'Werkstatt Meier']); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Zweites Produkt gleiche Marke') + ->set('descriptionShort', 'Vorhandene Partner-Marke.') + ->set('categoryId', $category->id) + ->set('priceType', 'fixed') + ->set('status', 'active') + ->set('brandName', 'Werkstatt Meier') + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Zweites Produkt gleiche Marke')->first(); + expect($product->brand_id)->toBe($existingBrand->id); + expect(Brand::where('name', 'Werkstatt Meier')->count())->toBe(1); +}); + +test('product can be created without brand', function () { + Storage::fake('public'); + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Produkt ohne Marke') + ->set('descriptionShort', 'Keine Marke angegeben.') + ->set('categoryId', $category->id) + ->set('priceType', 'fixed') + ->set('status', 'active') + ->set('brandName', '') + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Produkt ohne Marke')->first(); + expect($product->brand_id)->toBeNull(); +}); + +// --- Curation Workflow Tests --- + +test('submitting product as active sets status to pending', function () { + Storage::fake('public'); + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Freigabe-Produkt') + ->set('descriptionShort', 'Produkt zur Freigabe.') + ->set('categoryId', $category->id) + ->set('priceType', 'fixed') + ->set('status', 'active') + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Freigabe-Produkt')->first(); + + expect($product->status)->toBe(ProductStatus::Pending); + expect($product->is_curated)->toBeFalse(); + expect($product->curated_at)->toBeNull(); + expect($product->curated_by)->toBeNull(); +}); + +test('saving product as draft keeps status as draft', function () { + Storage::fake('public'); + [$user] = makePartnerWithHub(); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-standard') + ->set('name', 'Entwurf-Produkt') + ->set('descriptionShort', 'Noch nicht fertig.') + ->set('categoryId', $category->id) + ->set('priceType', 'fixed') + ->set('status', 'draft') + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Entwurf-Produkt')->first(); + + expect($product->status)->toBe(ProductStatus::Draft); + expect($product->is_curated)->toBeFalse(); +}); diff --git a/tests/Feature/TeaserProductCreateTest.php b/tests/Feature/TeaserProductCreateTest.php new file mode 100644 index 0000000..e8f9339 --- /dev/null +++ b/tests/Feature/TeaserProductCreateTest.php @@ -0,0 +1,330 @@ +forgetCachedPermissions(); + Role::create(['name' => 'Retailer']); + Role::create(['name' => 'Manufacturer']); + Role::create(['name' => 'Customer']); +}); + +function makeRetailerWithHub(): array +{ + $hub = Hub::factory()->create(); + $partner = Partner::factory()->setupCompleted()->create(['hub_id' => $hub->id]); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Retailer'); + + return [$user, $partner, $hub]; +} + +test('retailer can access teaser product creation page', function () { + [$user] = makeRetailerWithHub(); + + $this->actingAs($user) + ->get(route('products.create.teaser')) + ->assertSuccessful(); +}); + +test('manufacturer can access teaser product creation page', function () { + $hub = Hub::factory()->create(); + $partner = Partner::factory()->setupCompleted()->create(['hub_id' => $hub->id]); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Manufacturer'); + + $this->actingAs($user) + ->get(route('products.create.teaser')) + ->assertSuccessful(); +}); + +test('customer cannot access teaser product creation page', function () { + $customer = User::factory()->create(); + $customer->assignRole('Customer'); + + $this->actingAs($customer) + ->get(route('products.create.teaser')) + ->assertForbidden(); +}); + +test('retailer can create teaser product with from_price', function () { + Storage::fake('public'); + [$user, $partner, $hub] = makeRetailerWithHub(); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Livewire\Volt\Volt::test('products.form-teaser') + ->set('name', 'Sideboard Eiche') + ->set('descriptionShort', 'Schönes Massivholz-Sideboard aus regionaler Fertigung.') + ->set('priceType', 'from_price') + ->set('priceDisplayText', 'Ab 1.800 €') + ->set('categoryId', $category->id) + ->set('status', 'active') + ->set('mainImages', [UploadedFile::fake()->image('product.jpg', 800, 600)]) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Sideboard Eiche')->first(); + + expect($product)->not->toBeNull(); + expect($product->product_type)->toBe(ProductType::LocalStock); + expect($product->price_type)->toBe(PriceType::FromPrice); + expect($product->price_display_text)->toBe('Ab 1.800 €'); + expect($product->partner_id)->toBe($partner->id); + expect($product->hub_id)->toBe($hub->id); + expect($product->is_curated)->toBeFalse(); +}); + +test('retailer can create teaser product with on_request price', function () { + Storage::fake('public'); + [$user] = makeRetailerWithHub(); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Livewire\Volt\Volt::test('products.form-teaser') + ->set('name', 'Küche Massivholz') + ->set('descriptionShort', 'Individuelle Küche auf Maß.') + ->set('priceType', 'on_request') + ->set('categoryId', $category->id) + ->set('status', 'draft') + ->set('mainImages', [UploadedFile::fake()->image('kitchen.jpg', 800, 600)]) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Küche Massivholz')->first(); + expect($product->price_type)->toBe(PriceType::OnRequest); +}); + +test('teaser product rejects fixed price type', function () { + [$user] = makeRetailerWithHub(); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Livewire\Volt\Volt::test('products.form-teaser') + ->set('name', 'Produkt mit Festpreis') + ->set('descriptionShort', 'Beschreibung.') + ->set('priceType', 'fixed') + ->set('categoryId', $category->id) + ->set('status', 'active') + ->call('save') + ->assertHasErrors(['priceType']); +}); + +test('teaser product requires price display text when from_price', function () { + [$user] = makeRetailerWithHub(); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Livewire\Volt\Volt::test('products.form-teaser') + ->set('name', 'Produkt') + ->set('descriptionShort', 'Beschreibung.') + ->set('priceType', 'from_price') + ->set('priceDisplayText', '') + ->set('categoryId', $category->id) + ->set('status', 'active') + ->call('save') + ->assertHasErrors(['priceDisplayText']); +}); + +test('teaser product requires name', function () { + [$user] = makeRetailerWithHub(); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Livewire\Volt\Volt::test('products.form-teaser') + ->set('name', '') + ->set('descriptionShort', 'Beschreibung.') + ->set('priceType', 'on_request') + ->set('categoryId', $category->id) + ->call('save') + ->assertHasErrors(['name' => 'required']); +}); + +test('teaser product description short max 180 characters', function () { + [$user] = makeRetailerWithHub(); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Livewire\Volt\Volt::test('products.form-teaser') + ->set('name', 'Test') + ->set('descriptionShort', str_repeat('x', 181)) + ->set('priceType', 'on_request') + ->set('categoryId', $category->id) + ->call('save') + ->assertHasErrors(['descriptionShort' => 'max']); +}); + +test('teaser product requires category', function () { + [$user] = makeRetailerWithHub(); + + $this->actingAs($user); + + Livewire\Volt\Volt::test('products.form-teaser') + ->set('name', 'Test Produkt') + ->set('descriptionShort', 'Beschreibung.') + ->set('priceType', 'on_request') + ->set('categoryId', null) + ->call('save') + ->assertHasErrors(['categoryId' => 'required']); +}); + +test('manufacturer can create teaser product', function () { + Storage::fake('public'); + $hub = Hub::factory()->create(); + $partner = Partner::factory()->setupCompleted()->create(['hub_id' => $hub->id]); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Manufacturer'); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Livewire\Volt\Volt::test('products.form-teaser') + ->set('name', 'Hersteller Teaser-Produkt') + ->set('descriptionShort', 'Ein Teaser-Produkt vom Hersteller.') + ->set('priceType', 'from_price') + ->set('priceDisplayText', 'Ab 3.200 €') + ->set('categoryId', $category->id) + ->set('status', 'active') + ->set('mainImages', [UploadedFile::fake()->image('product.jpg', 800, 600)]) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Hersteller Teaser-Produkt')->first(); + + expect($product)->not->toBeNull(); + expect($product->product_type)->toBe(ProductType::LocalStock); + expect($product->partner_id)->toBe($partner->id); + expect($product->hub_id)->toBe($hub->id); +}); + +test('created teaser product is automatically not curated', function () { + Storage::fake('public'); + [$user] = makeRetailerWithHub(); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Livewire\Volt\Volt::test('products.form-teaser') + ->set('name', 'Nicht kuratiertes Produkt') + ->set('descriptionShort', 'Test.') + ->set('priceType', 'on_request') + ->set('categoryId', $category->id) + ->set('status', 'active') + ->set('mainImages', [UploadedFile::fake()->image('test.jpg', 800, 600)]) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Nicht kuratiertes Produkt')->first(); + expect($product->is_curated)->toBeFalse(); + expect($product->product_type)->toBe(ProductType::LocalStock); +}); + +test('submitting teaser product as active sets status to pending', function () { + Storage::fake('public'); + [$user] = makeRetailerWithHub(); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Livewire\Volt\Volt::test('products.form-teaser') + ->set('name', 'Pending Teaser') + ->set('descriptionShort', 'Test zur Freigabe.') + ->set('priceType', 'on_request') + ->set('categoryId', $category->id) + ->set('status', 'active') + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Pending Teaser')->first(); + expect($product->status)->toBe(ProductStatus::Pending); +}); + +test('saving teaser product as draft keeps status as draft', function () { + Storage::fake('public'); + [$user] = makeRetailerWithHub(); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Livewire\Volt\Volt::test('products.form-teaser') + ->set('name', 'Draft Teaser') + ->set('descriptionShort', 'Test Entwurf.') + ->set('priceType', 'on_request') + ->set('categoryId', $category->id) + ->set('status', 'draft') + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Draft Teaser')->first(); + expect($product->status)->toBe(ProductStatus::Draft); +}); + +test('teaser product generates b2in article number on create', function () { + Storage::fake('public'); + [$user] = makeRetailerWithHub(); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Livewire\Volt\Volt::test('products.form-teaser') + ->set('name', 'Teaser mit Nummer') + ->set('descriptionShort', 'Test Artikelnummer.') + ->set('priceType', 'on_request') + ->set('categoryId', $category->id) + ->set('status', 'active') + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Teaser mit Nummer')->first(); + expect($product->b2in_article_number)->toStartWith('B2IN-'); + expect($product->b2in_article_number)->toBe('B2IN-000001'); +}); + +test('teaser product saves partner product number', function () { + Storage::fake('public'); + [$user] = makeRetailerWithHub(); + $category = Category::factory()->create(); + + $this->actingAs($user); + + Livewire\Volt\Volt::test('products.form-teaser') + ->set('name', 'Teaser mit Partnernummer') + ->set('descriptionShort', 'Test Partnernummer.') + ->set('priceType', 'on_request') + ->set('categoryId', $category->id) + ->set('partnerProductNumber', 'MY-CUSTOM-001') + ->set('status', 'active') + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('name', 'Teaser mit Partnernummer')->first(); + expect($product->partner_product_number)->toBe('MY-CUSTOM-001'); +}); + +test('teaser product auto-generates partner product number on mount', function () { + [$user, $partner] = makeRetailerWithHub(); + $this->actingAs($user); + + $expectedNumber = sprintf('P%03d-%04d', $partner->id, 1); + + Livewire\Volt\Volt::test('products.form-teaser') + ->assertSet('partnerProductNumber', $expectedNumber); +}); diff --git a/tests/Feature/TeaserProductEditTest.php b/tests/Feature/TeaserProductEditTest.php new file mode 100644 index 0000000..a6cd6e6 --- /dev/null +++ b/tests/Feature/TeaserProductEditTest.php @@ -0,0 +1,519 @@ +forgetCachedPermissions(); + Role::create(['name' => 'Retailer']); + Role::create(['name' => 'Manufacturer']); + Role::create(['name' => 'Customer']); + Role::create(['name' => 'Admin']); +}); + +function createTeaserPartnerWithHub(): array +{ + $hub = Hub::factory()->create(); + $partner = Partner::factory()->setupCompleted()->create(['hub_id' => $hub->id]); + $user = User::factory()->create(['partner_id' => $partner->id]); + + return [$user, $partner, $hub]; +} + +function createTeaserProduct(Partner $partner, array $overrides = []): Product +{ + $category = Category::factory()->create(); + + $product = Product::factory()->create(array_merge([ + 'partner_id' => $partner->id, + 'product_type' => ProductType::LocalStock, + 'status' => ProductStatus::Pending, + 'price_type' => PriceType::FromPrice, + 'price_display_text' => 'Ab 2.500 €', + 'name' => 'Teaser Produkt', + 'description_short' => 'Kurzbeschreibung Teaser', + 'is_available' => true, + 'is_curated' => false, + ], $overrides)); + + $product->categories()->attach($category->id); + + return $product; +} + +// --- Access Tests --- + +test('owner can access teaser product edit page', function () { + [$user, $partner] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + $product = createTeaserProduct($partner); + + $this->actingAs($user) + ->get(route('products.edit.teaser', $product)) + ->assertSuccessful(); +}); + +test('admin can access any teaser product edit page', function () { + [$user] = createTeaserPartnerWithHub(); + $user->assignRole('Admin'); + $otherPartner = Partner::factory()->setupCompleted()->create(); + $product = createTeaserProduct($otherPartner); + + $this->actingAs($user) + ->get(route('products.edit.teaser', $product)) + ->assertSuccessful(); +}); + +test('other partner cannot access teaser product edit page', function () { + [$user] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + + $otherPartner = Partner::factory()->setupCompleted()->create(); + $product = createTeaserProduct($otherPartner); + + $this->actingAs($user) + ->get(route('products.edit.teaser', $product)) + ->assertForbidden(); +}); + +test('customer cannot access teaser product edit page', function () { + $customer = User::factory()->create(); + $customer->assignRole('Customer'); + + $partner = Partner::factory()->setupCompleted()->create(); + $product = createTeaserProduct($partner); + + $this->actingAs($customer) + ->get(route('products.edit.teaser', $product)) + ->assertForbidden(); +}); + +// --- Mount Pre-Fill Tests --- + +test('teaser edit page pre-fills product data', function () { + [$user, $partner] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + $product = createTeaserProduct($partner, [ + 'name' => 'Vorhandenes Sideboard', + 'description_short' => 'Ein tolles Sideboard.', + 'price_type' => PriceType::FromPrice, + 'price_display_text' => 'Ab 3.000 €', + ]); + + $this->actingAs($user); + + Volt::test('products.form-teaser', ['product' => $product]) + ->assertSet('name', 'Vorhandenes Sideboard') + ->assertSet('descriptionShort', 'Ein tolles Sideboard.') + ->assertSet('priceType', 'from_price') + ->assertSet('priceDisplayText', 'Ab 3.000 €') + ->assertSet('partnerProductNumber', ''); +}); + +test('teaser edit page pre-fills category', function () { + [$user, $partner] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + $product = createTeaserProduct($partner); + + $this->actingAs($user); + + $expectedCategoryId = $product->categories->first()->id; + + Volt::test('products.form-teaser', ['product' => $product]) + ->assertSet('categoryId', $expectedCategoryId); +}); + +test('teaser edit page pre-fills partner product number', function () { + [$user, $partner] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + $product = createTeaserProduct($partner, [ + 'partner_product_number' => 'MY-NUM-001', + ]); + + $this->actingAs($user); + + Volt::test('products.form-teaser', ['product' => $product]) + ->assertSet('partnerProductNumber', 'MY-NUM-001'); +}); + +test('teaser edit saves updated partner product number', function () { + [$user, $partner] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + $product = createTeaserProduct($partner); + + $this->actingAs($user); + + Volt::test('products.form-teaser', ['product' => $product]) + ->set('partnerProductNumber', 'UPDATED-123') + ->call('save') + ->assertHasNoErrors(); + + expect($product->fresh()->partner_product_number)->toBe('UPDATED-123'); +}); + +test('teaser edit maps pending status to active for UI', function () { + [$user, $partner] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + $product = createTeaserProduct($partner, ['status' => ProductStatus::Pending]); + + $this->actingAs($user); + + Volt::test('products.form-teaser', ['product' => $product]) + ->assertSet('status', 'active'); +}); + +test('teaser edit maps correction status to active for UI', function () { + [$user, $partner] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + $product = createTeaserProduct($partner, [ + 'status' => ProductStatus::Correction, + 'curation_notes' => 'Bitte Bild verbessern.', + ]); + + $this->actingAs($user); + + Volt::test('products.form-teaser', ['product' => $product]) + ->assertSet('status', 'active'); +}); + +test('teaser edit maps draft status correctly', function () { + [$user, $partner] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + $product = createTeaserProduct($partner, ['status' => ProductStatus::Draft]); + + $this->actingAs($user); + + Volt::test('products.form-teaser', ['product' => $product]) + ->assertSet('status', 'draft'); +}); + +// --- Save / Update Tests --- + +test('teaser edit saves updated product fields', function () { + Storage::fake('public'); + [$user, $partner] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + $product = createTeaserProduct($partner); + + $this->actingAs($user); + + Volt::test('products.form-teaser', ['product' => $product]) + ->set('name', 'Aktualisiertes Sideboard') + ->set('descriptionShort', 'Neue Kurzbeschreibung.') + ->set('priceDisplayText', 'Ab 4.000 €') + ->call('save') + ->assertHasNoErrors(); + + $product->refresh(); + + expect($product->name)->toBe('Aktualisiertes Sideboard'); + expect($product->description_short)->toBe('Neue Kurzbeschreibung.'); + expect($product->price_display_text)->toBe('Ab 4.000 €'); +}); + +test('teaser edit re-submits to pending when status is active', function () { + Storage::fake('public'); + [$user, $partner] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + $product = createTeaserProduct($partner, ['status' => ProductStatus::Draft]); + + $this->actingAs($user); + + Volt::test('products.form-teaser', ['product' => $product]) + ->set('status', 'active') + ->call('save') + ->assertHasNoErrors(); + + $product->refresh(); + expect($product->status)->toBe(ProductStatus::Pending); +}); + +test('teaser edit keeps draft status when saving as draft', function () { + Storage::fake('public'); + [$user, $partner] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + $product = createTeaserProduct($partner, ['status' => ProductStatus::Pending]); + + $this->actingAs($user); + + Volt::test('products.form-teaser', ['product' => $product]) + ->set('status', 'draft') + ->call('save') + ->assertHasNoErrors(); + + $product->refresh(); + expect($product->status)->toBe(ProductStatus::Draft); +}); + +test('teaser edit with correction status re-submits to pending', function () { + Storage::fake('public'); + [$user, $partner] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + $product = createTeaserProduct($partner, [ + 'status' => ProductStatus::Correction, + 'curation_notes' => 'Bitte Bilder verbessern.', + ]); + + $this->actingAs($user); + + Volt::test('products.form-teaser', ['product' => $product]) + ->assertSet('status', 'active') + ->call('save') + ->assertHasNoErrors(); + + $product->refresh(); + expect($product->status)->toBe(ProductStatus::Pending); +}); + +test('teaser edit updates category', function () { + Storage::fake('public'); + [$user, $partner] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + $product = createTeaserProduct($partner); + + $newCategory = Category::factory()->create(); + + $this->actingAs($user); + + Volt::test('products.form-teaser', ['product' => $product]) + ->set('categoryId', $newCategory->id) + ->call('save') + ->assertHasNoErrors(); + + $product->refresh(); + expect($product->categories->first()->id)->toBe($newCategory->id); +}); + +test('teaser edit changes price type to on_request', function () { + Storage::fake('public'); + [$user, $partner] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + $product = createTeaserProduct($partner, [ + 'price_type' => PriceType::FromPrice, + 'price_display_text' => 'Ab 2.500 €', + ]); + + $this->actingAs($user); + + Volt::test('products.form-teaser', ['product' => $product]) + ->set('priceType', 'on_request') + ->call('save') + ->assertHasNoErrors(); + + $product->refresh(); + expect($product->price_type)->toBe(PriceType::OnRequest); +}); + +// --- Activity Logging --- + +test('teaser edit creates activity log entry on save', function () { + Storage::fake('public'); + [$user, $partner] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + $product = createTeaserProduct($partner); + + $this->actingAs($user); + + Volt::test('products.form-teaser', ['product' => $product]) + ->set('name', 'Updated Teaser Name') + ->call('save') + ->assertHasNoErrors(); + + $activity = ProductActivity::where('product_id', $product->id)->first(); + + expect($activity)->not->toBeNull(); + expect($activity->action)->toBe('updated'); + expect($activity->user_id)->toBe($user->id); +}); + +// --- Validation Tests --- + +test('teaser edit requires name', function () { + [$user, $partner] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + $product = createTeaserProduct($partner); + + $this->actingAs($user); + + Volt::test('products.form-teaser', ['product' => $product]) + ->set('name', '') + ->call('save') + ->assertHasErrors(['name' => 'required']); +}); + +test('teaser edit requires short description', function () { + [$user, $partner] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + $product = createTeaserProduct($partner); + + $this->actingAs($user); + + Volt::test('products.form-teaser', ['product' => $product]) + ->set('descriptionShort', '') + ->call('save') + ->assertHasErrors(['descriptionShort' => 'required']); +}); + +test('teaser edit enforces max 180 chars for short description', function () { + [$user, $partner] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + $product = createTeaserProduct($partner); + + $this->actingAs($user); + + Volt::test('products.form-teaser', ['product' => $product]) + ->set('descriptionShort', str_repeat('A', 181)) + ->call('save') + ->assertHasErrors(['descriptionShort' => 'max']); +}); + +test('teaser edit requires category', function () { + [$user, $partner] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + $product = createTeaserProduct($partner); + + $this->actingAs($user); + + Volt::test('products.form-teaser', ['product' => $product]) + ->set('categoryId', null) + ->call('save') + ->assertHasErrors(['categoryId' => 'required']); +}); + +test('teaser edit rejects fixed price type', function () { + [$user, $partner] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + $product = createTeaserProduct($partner); + + $this->actingAs($user); + + Volt::test('products.form-teaser', ['product' => $product]) + ->set('priceType', 'fixed') + ->call('save') + ->assertHasErrors(['priceType']); +}); + +test('teaser edit requires price display text for from_price', function () { + [$user, $partner] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + $product = createTeaserProduct($partner); + + $this->actingAs($user); + + Volt::test('products.form-teaser', ['product' => $product]) + ->set('priceType', 'from_price') + ->set('priceDisplayText', '') + ->call('save') + ->assertHasErrors(['priceDisplayText']); +}); + +// --- Media Tests --- + +test('teaser edit shows existing media', function () { + [$user, $partner] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + $product = createTeaserProduct($partner); + + $product->media()->create([ + 'file_path' => 'products/1/test.jpg', + 'type' => 'image', + 'alt_text' => 'Teaser Image', + 'order_column' => 1, + ]); + + $this->actingAs($user); + + Volt::test('products.form-teaser', ['product' => $product]) + ->assertSet('existingMedia', fn ($value) => count($value) === 1 && $value[0]['alt_text'] === 'Teaser Image'); +}); + +test('teaser edit can remove existing media', function () { + Storage::fake('public'); + [$user, $partner] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + $product = createTeaserProduct($partner); + + Storage::disk('public')->put('products/1/teaser.jpg', 'fake-image-content'); + + $media = $product->media()->create([ + 'file_path' => 'products/1/teaser.jpg', + 'type' => 'image', + 'alt_text' => 'Teaser Image', + 'order_column' => 1, + ]); + + $this->actingAs($user); + + Volt::test('products.form-teaser', ['product' => $product]) + ->assertSet('existingMedia', fn ($value) => count($value) === 1) + ->call('removeExistingMedia', $media->id) + ->assertSet('existingMedia', fn ($value) => count($value) === 0); + + expect($product->media()->count())->toBe(0); + Storage::disk('public')->assertMissing('products/1/teaser.jpg'); +}); + +test('teaser edit can reorder existing media via drag and drop', function () { + [$user, $partner] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + $product = createTeaserProduct($partner); + + $media1 = $product->media()->create([ + 'file_path' => 'products/1/first.jpg', + 'type' => 'image', + 'alt_text' => 'First', + 'order_column' => 1, + ]); + $media2 = $product->media()->create([ + 'file_path' => 'products/1/second.jpg', + 'type' => 'image', + 'alt_text' => 'Second', + 'order_column' => 2, + ]); + + $this->actingAs($user); + + Volt::test('products.form-teaser', ['product' => $product]) + ->assertSet('existingMedia', fn ($value) => count($value) === 2 && $value[0]['id'] === $media1->id) + ->call('updateMediaOrder', [$media2->id, $media1->id]) + ->assertSet('existingMedia', fn ($value) => $value[0]['id'] === $media2->id && $value[1]['id'] === $media1->id); + + expect($media2->fresh()->order_column)->toBe(1); + expect($media1->fresh()->order_column)->toBe(2); +}); + +test('teaser edit existing media is loaded sorted by order column', function () { + [$user, $partner] = createTeaserPartnerWithHub(); + $user->assignRole('Retailer'); + $product = createTeaserProduct($partner); + + $media2 = $product->media()->create([ + 'file_path' => 'products/1/second.jpg', + 'type' => 'image', + 'alt_text' => 'Second', + 'order_column' => 2, + ]); + $media1 = $product->media()->create([ + 'file_path' => 'products/1/first.jpg', + 'type' => 'image', + 'alt_text' => 'First', + 'order_column' => 1, + ]); + + $this->actingAs($user); + + Volt::test('products.form-teaser', ['product' => $product]) + ->assertSet('existingMedia', fn ($value) => $value[0]['id'] === $media1->id && $value[1]['id'] === $media2->id); +}); diff --git a/tests/Unit/Enums/ProductStatusTest.php b/tests/Unit/Enums/ProductStatusTest.php new file mode 100644 index 0000000..c5acdf5 --- /dev/null +++ b/tests/Unit/Enums/ProductStatusTest.php @@ -0,0 +1,24 @@ +value)->toBe('draft'); + expect(ProductStatus::Pending->value)->toBe('pending'); + expect(ProductStatus::Correction->value)->toBe('correction'); + expect(ProductStatus::Active->value)->toBe('active'); + expect(ProductStatus::Archived->value)->toBe('archived'); + expect(ProductStatus::Sold->value)->toBe('sold'); +}); + +test('product status has labels', function () { + foreach (ProductStatus::cases() as $status) { + expect($status->label())->toBeString()->not->toBeEmpty(); + } +}); + +test('product status has colors', function () { + foreach (ProductStatus::cases() as $status) { + expect($status->color())->toBeString()->not->toBeEmpty(); + } +}); diff --git a/tests/Unit/Enums/ProductTypeTest.php b/tests/Unit/Enums/ProductTypeTest.php new file mode 100644 index 0000000..f64661c --- /dev/null +++ b/tests/Unit/Enums/ProductTypeTest.php @@ -0,0 +1,47 @@ +value)->toBe('local_stock'); + expect(ProductType::SmartOrder->value)->toBe('smart_order'); +}); + +test('product type has labels', function () { + expect(ProductType::LocalStock->label())->toBeString()->not->toBeEmpty(); + expect(ProductType::SmartOrder->label())->toBeString()->not->toBeEmpty(); +}); + +test('product type can be created from value', function () { + expect(ProductType::from('local_stock'))->toBe(ProductType::LocalStock); + expect(ProductType::from('smart_order'))->toBe(ProductType::SmartOrder); +}); + +// Typ A – Ticket zwingend (Teaser-Produkte: Beratungspflicht, Abschluss nur im Laden) +test('local stock requires ticket', function () { + expect(ProductType::LocalStock->requiresTicket())->toBeTrue(); +}); + +// Typ B – Ticket optional (Standard-Produkte: Direktkauf online möglich) +test('smart order does not require ticket', function () { + expect(ProductType::SmartOrder->requiresTicket())->toBeFalse(); +}); + +// Typ A: Kein Festpreis online – nur Ab-Preis oder Preis auf Anfrage +test('local stock only allows non-fixed price types', function () { + $allowed = ProductType::LocalStock->allowedPriceTypes(); + + expect($allowed)->toContain(PriceType::FromPrice) + ->toContain(PriceType::OnRequest) + ->not->toContain(PriceType::Fixed); +}); + +// Typ B: Alle Preistypen erlaubt +test('smart order allows all price types', function () { + $allowed = ProductType::SmartOrder->allowedPriceTypes(); + + expect($allowed)->toContain(PriceType::Fixed) + ->toContain(PriceType::FromPrice) + ->toContain(PriceType::OnRequest); +}); diff --git a/tests/Unit/Enums/UserOriginTest.php b/tests/Unit/Enums/UserOriginTest.php new file mode 100644 index 0000000..0d44334 --- /dev/null +++ b/tests/Unit/Enums/UserOriginTest.php @@ -0,0 +1,13 @@ +value)->toBe('style2own'); + expect(UserOrigin::StilEigentum->value)->toBe('stileigentum'); +}); + +test('user origin has tonality', function () { + expect(UserOrigin::Style2Own->tonality())->toBe('du'); + expect(UserOrigin::StilEigentum->tonality())->toBe('sie'); +}); diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..82eaa7f --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,9 @@ +