20-02-2026
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
27
.mcp.json
Normal file
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
547
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
|
||||
|
||||
===
|
||||
|
||||
<laravel-boost-guidelines>
|
||||
=== 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()`.
|
||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||
- 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.
|
||||
|
||||
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
||||
protected function isAccessible(User $user, ?string $path = null): bool
|
||||
{
|
||||
...
|
||||
}
|
||||
</code-snippet>
|
||||
|
||||
## 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:
|
||||
|
||||
<code-snippet name="Flux UI Component Example" lang="blade">
|
||||
<flux:button variant="primary"/>
|
||||
</code-snippet>
|
||||
|
||||
### Available Components
|
||||
This is correct as of Boost installation, but there may be additional components within the codebase.
|
||||
|
||||
<available-flux-components>
|
||||
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
|
||||
</available-flux-components>
|
||||
|
||||
=== 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)
|
||||
<div wire:key="item-{{ $item->id }}">
|
||||
{{ $item->name }}
|
||||
</div>
|
||||
@endforeach
|
||||
```
|
||||
|
||||
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
|
||||
|
||||
<code-snippet name="Lifecycle Hook Examples" lang="php">
|
||||
public function mount(User $user) { $this->user = $user; }
|
||||
public function updatedSearch() { $this->resetPage(); }
|
||||
</code-snippet>
|
||||
|
||||
## Testing Livewire
|
||||
|
||||
<code-snippet name="Example Livewire Component Test" lang="php">
|
||||
Livewire::test(Counter::class)
|
||||
->assertSet('count', 0)
|
||||
->call('increment')
|
||||
->assertSet('count', 1)
|
||||
->assertSee(1)
|
||||
->assertStatus(200);
|
||||
</code-snippet>
|
||||
|
||||
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
|
||||
$this->get('/posts/create')
|
||||
->assertSeeLivewire(CreatePost::class);
|
||||
</code-snippet>
|
||||
|
||||
=== 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
|
||||
|
||||
<code-snippet name="Volt Functional Component Example" lang="php">
|
||||
@volt
|
||||
<?php
|
||||
use function Livewire\Volt\{state, computed};
|
||||
|
||||
state(['count' => 0]);
|
||||
|
||||
$increment = fn () => $this->count++;
|
||||
$decrement = fn () => $this->count--;
|
||||
|
||||
$double = computed(fn () => $this->count * 2);
|
||||
?>
|
||||
|
||||
<div>
|
||||
<h1>Count: {{ $count }}</h1>
|
||||
<h2>Double: {{ $this->double }}</h2>
|
||||
<button wire:click="increment">+</button>
|
||||
<button wire:click="decrement">-</button>
|
||||
</div>
|
||||
@endvolt
|
||||
</code-snippet>
|
||||
|
||||
### 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:
|
||||
|
||||
<code-snippet name="Volt Class-based Volt Component Example" lang="php">
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component {
|
||||
public $count = 0;
|
||||
|
||||
public function increment()
|
||||
{
|
||||
$this->count++;
|
||||
}
|
||||
} ?>
|
||||
|
||||
<div>
|
||||
<h1>{{ $count }}</h1>
|
||||
<button wire:click="increment">+</button>
|
||||
</div>
|
||||
</code-snippet>
|
||||
|
||||
### Testing Volt & Volt Components
|
||||
- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`.
|
||||
|
||||
<code-snippet name="Livewire Test Example" lang="php">
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
test('counter increments', function () {
|
||||
Volt::test('counter')
|
||||
->assertSee('Count: 0')
|
||||
->call('increment')
|
||||
->assertSee('Count: 1');
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
<code-snippet name="Volt Component Test Using Pest" lang="php">
|
||||
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();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Common Patterns
|
||||
|
||||
<code-snippet name="CRUD With Volt" lang="php">
|
||||
<?php
|
||||
|
||||
use App\Models\Product;
|
||||
use function Livewire\Volt\{state, computed};
|
||||
|
||||
state(['editing' => 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();
|
||||
|
||||
?>
|
||||
|
||||
<!-- HTML / UI Here -->
|
||||
</code-snippet>
|
||||
|
||||
<code-snippet name="Real-Time Search With Volt" lang="php">
|
||||
<flux:input
|
||||
wire:model.live.debounce.300ms="search"
|
||||
placeholder="Search..."
|
||||
/>
|
||||
</code-snippet>
|
||||
|
||||
<code-snippet name="Loading States With Volt" lang="php">
|
||||
<flux:button wire:click="save" wire:loading.attr="disabled">
|
||||
<span wire:loading.remove>Save</span>
|
||||
<span wire:loading>Saving...</span>
|
||||
</flux:button>
|
||||
</code-snippet>
|
||||
|
||||
=== 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:
|
||||
<code-snippet name="Basic Pest Test Example" lang="php">
|
||||
it('is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### 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.:
|
||||
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
|
||||
it('returns all', function () {
|
||||
$response = $this->postJson('/api/docs', []);
|
||||
|
||||
$response->assertSuccessful();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### 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.
|
||||
|
||||
<code-snippet name="Pest Dataset Example" lang="php">
|
||||
it('has emails', function (string $email) {
|
||||
expect($email)->not->toBeEmpty();
|
||||
})->with([
|
||||
'james' => 'james@laravel.com',
|
||||
'taylor' => 'taylor@laravel.com',
|
||||
]);
|
||||
</code-snippet>
|
||||
|
||||
=== 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.
|
||||
|
||||
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
|
||||
<div class="flex gap-8">
|
||||
<div>Superior</div>
|
||||
<div>Michigan</div>
|
||||
<div>Erie</div>
|
||||
</div>
|
||||
</code-snippet>
|
||||
|
||||
### 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.
|
||||
|
||||
<code-snippet name="Extending Theme in CSS" lang="css">
|
||||
@theme {
|
||||
--color-brand: oklch(0.72 0.11 178);
|
||||
}
|
||||
</code-snippet>
|
||||
|
||||
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
|
||||
|
||||
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
|
||||
- @tailwind base;
|
||||
- @tailwind components;
|
||||
- @tailwind utilities;
|
||||
+ @import "tailwindcss";
|
||||
</code-snippet>
|
||||
|
||||
### 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 |
|
||||
</laravel-boost-guidelines>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
44
app/Casts/PartnerTypeCast.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace App\Casts;
|
||||
|
||||
use App\Enums\PartnerType;
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PartnerTypeCast implements CastsAttributes
|
||||
{
|
||||
/**
|
||||
* Normalisiert DB-Werte (z.B. "retailer", "broker") zu gültigen Enum-Backing-Values.
|
||||
*
|
||||
* @param array<string, mixed> $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<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
28
app/Enums/CurationStatus.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum CurationStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Approved = 'approved';
|
||||
case Rejected = 'rejected';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Pending => 'Ausstehend',
|
||||
self::Approved => 'Freigegeben',
|
||||
self::Rejected => 'Abgelehnt',
|
||||
};
|
||||
}
|
||||
|
||||
public function color(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Pending => 'yellow',
|
||||
self::Approved => 'green',
|
||||
self::Rejected => 'red',
|
||||
};
|
||||
}
|
||||
}
|
||||
21
app/Enums/PartnerType.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum PartnerType: string
|
||||
{
|
||||
case Retailer = 'Retailer';
|
||||
case Manufacturer = 'Manufacturer';
|
||||
case EstateAgent = 'Estate-Agent';
|
||||
case Customer = 'Customer';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Retailer => 'Händler',
|
||||
self::Manufacturer => 'Hersteller',
|
||||
self::EstateAgent => 'Makler',
|
||||
self::Customer => 'Kunde',
|
||||
};
|
||||
}
|
||||
}
|
||||
19
app/Enums/PriceType.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum PriceType: string
|
||||
{
|
||||
case Fixed = 'fixed';
|
||||
case FromPrice = 'from_price';
|
||||
case OnRequest = 'on_request';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Fixed => 'Festpreis',
|
||||
self::FromPrice => 'Ab-Preis',
|
||||
self::OnRequest => 'Preis auf Anfrage',
|
||||
};
|
||||
}
|
||||
}
|
||||
37
app/Enums/ProductStatus.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ProductStatus: string
|
||||
{
|
||||
case Draft = 'draft';
|
||||
case Pending = 'pending';
|
||||
case Correction = 'correction';
|
||||
case Active = 'active';
|
||||
case Archived = 'archived';
|
||||
case Sold = 'sold';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Draft => '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',
|
||||
};
|
||||
}
|
||||
}
|
||||
54
app/Enums/ProductType.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ProductType: string
|
||||
{
|
||||
/**
|
||||
* Typ A – Teaser-Produkte: Beratungspflicht, Ticket zwingend.
|
||||
* Komplexe Konfiguration (Maße, Module, Materialien) – Abschluss nur im Laden.
|
||||
*/
|
||||
case LocalStock = 'local_stock';
|
||||
|
||||
/**
|
||||
* Typ B – Standard-Produkte: Einfache Varianten, skalierbar, Ticket optional.
|
||||
* Online vollständig konfigurierbar und direkt kaufbar.
|
||||
*/
|
||||
case SmartOrder = 'smart_order';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::LocalStock => '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<int, PriceType>
|
||||
*/
|
||||
public function allowedPriceTypes(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::LocalStock => [PriceType::FromPrice, PriceType::OnRequest],
|
||||
self::SmartOrder => [PriceType::Fixed, PriceType::FromPrice, PriceType::OnRequest],
|
||||
};
|
||||
}
|
||||
}
|
||||
28
app/Enums/UserOrigin.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum UserOrigin: string
|
||||
{
|
||||
case Style2Own = 'style2own';
|
||||
case StilEigentum = 'stileigentum';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Style2Own => 'Style2Own',
|
||||
self::StilEigentum => 'StilEigentum',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return 'du'|'sie'
|
||||
*/
|
||||
public function tonality(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Style2Own => 'du',
|
||||
self::StilEigentum => 'sie',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
|
|
|||
36
app/Models/Media.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class Media extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'model_type',
|
||||
'model_id',
|
||||
'file_path',
|
||||
'type',
|
||||
'alt_text',
|
||||
'order_column',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'order_column' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Polymorphe Beziehung zum Eltern-Model (Product, Partner, etc.).
|
||||
*/
|
||||
public function model(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
246
app/Models/Product.php
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\PriceType;
|
||||
use App\Enums\ProductStatus;
|
||||
use App\Enums\ProductType;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
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;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
|
||||
class Product extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'partner_id',
|
||||
'partner_product_number',
|
||||
'b2in_article_number',
|
||||
'brand_id',
|
||||
'collection_id',
|
||||
'hub_id',
|
||||
'name',
|
||||
'slug',
|
||||
'product_type',
|
||||
'status',
|
||||
'price_type',
|
||||
'price_display_text',
|
||||
'description_short',
|
||||
'description_long',
|
||||
'care_instructions',
|
||||
'width_cm',
|
||||
'height_cm',
|
||||
'depth_cm',
|
||||
'dimensions_specific',
|
||||
'assembly_status',
|
||||
'meta_title',
|
||||
'meta_description',
|
||||
'is_curated',
|
||||
'curated_at',
|
||||
'curated_by',
|
||||
'curation_notes',
|
||||
'is_available',
|
||||
'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',
|
||||
'productIsAvailable',
|
||||
'visible_from',
|
||||
'visible_until',
|
||||
'co2_footprint_kg',
|
||||
'recycling_percentage',
|
||||
'is_regional_production',
|
||||
'storage_volume_liters',
|
||||
'assembly_effort_score',
|
||||
'design_score',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
29
app/Models/ProductActivity.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProductActivity extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'product_id',
|
||||
'user_id',
|
||||
'action',
|
||||
'note',
|
||||
];
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
50
app/Models/ProductLogistics.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProductLogistics extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'product_variant_id',
|
||||
'shipping_class_id',
|
||||
'package_width_cm',
|
||||
'package_height_cm',
|
||||
'package_depth_cm',
|
||||
'package_weight_g',
|
||||
'package_count',
|
||||
'location_bin',
|
||||
'packaging_type',
|
||||
'packaging_recyclable_percent',
|
||||
'is_palletizable',
|
||||
'hs_code',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'package_count' => '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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
48
app/Models/ProductWoodOrigin.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
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\MorphMany;
|
||||
|
||||
class ProductWoodOrigin extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\ProductWoodOriginFactory> */
|
||||
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');
|
||||
}
|
||||
}
|
||||
55
app/Models/Setting.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Setting extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'group',
|
||||
'key',
|
||||
'value',
|
||||
'type',
|
||||
'description',
|
||||
];
|
||||
|
||||
/**
|
||||
* Hole einen Setting-Wert anhand von Gruppe und Key.
|
||||
*/
|
||||
public static function getValue(string $group, string $key, mixed $default = null): mixed
|
||||
{
|
||||
$setting = self::query()
|
||||
->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
67
app/Policies/PartnerPolicy.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Partner;
|
||||
use App\Models\User;
|
||||
|
||||
class PartnerPolicy
|
||||
{
|
||||
/**
|
||||
* Admins und Super-Admins können alle Partner sehen.
|
||||
* Partner können nur ihren eigenen Eintrag sehen.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->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');
|
||||
}
|
||||
}
|
||||
73
app/Policies/ProductPolicy.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Product;
|
||||
use App\Models\User;
|
||||
|
||||
class ProductPolicy
|
||||
{
|
||||
/**
|
||||
* Admins, Retailer und Manufacturer können Produkte sehen.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->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');
|
||||
}
|
||||
}
|
||||
12
boost.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"agents": [
|
||||
"claude_code",
|
||||
"cursor"
|
||||
],
|
||||
"editors": [
|
||||
"claude_code",
|
||||
"cursor"
|
||||
],
|
||||
"guidelines": [],
|
||||
"sail": true
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');\""
|
||||
|
|
|
|||
1199
composer.lock
generated
|
|
@ -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',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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...
|
||||
|
|
|
|||
28
database/factories/BrandFactory.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Partner;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<Brand>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
35
database/factories/CategoryFactory.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Category;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @template TModel of \App\Models\Category
|
||||
*
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<TModel>
|
||||
*/
|
||||
class CategoryFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The name of the factory's corresponding model.
|
||||
*
|
||||
* @var class-string<TModel>
|
||||
*/
|
||||
protected $model = Category::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->unique()->word(),
|
||||
'slug' => fake()->unique()->slug(2),
|
||||
'description' => fake()->sentence(),
|
||||
];
|
||||
}
|
||||
}
|
||||
36
database/factories/HubFactory.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Hub;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<Hub>
|
||||
*/
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
49
database/factories/MediaFactory.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Media;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Media>
|
||||
*/
|
||||
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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
79
database/factories/PartnerFactory.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Partner;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<Partner>
|
||||
*/
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
26
database/factories/ProductActivityFactory.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductActivity;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<ProductActivity>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
93
database/factories/ProductFactory.php
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\PriceType;
|
||||
use App\Enums\ProductStatus;
|
||||
use App\Enums\ProductType;
|
||||
use App\Models\Hub;
|
||||
use App\Models\Partner;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<Product>
|
||||
*/
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
29
database/factories/ProductWoodOriginFactory.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductWoodOrigin;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<ProductWoodOrigin>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('partners', function (Blueprint $table) {
|
||||
$table->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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('settings', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('product_variants', function (Blueprint $table) {
|
||||
$table->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();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
// Materialien & Beschaffenheit
|
||||
$table->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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('product_logistics', function (Blueprint $table) {
|
||||
$table->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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('product_variants', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('product_wood_origins', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Product numbers on products table
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->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();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->text('curation_notes')->nullable()->after('curated_by');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->dropColumn('curation_notes');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('product_activities', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
41
database/seeders/CategorySeeder.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CategorySeeder extends Seeder
|
||||
{
|
||||
public function run(): 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(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
292
database/seeders/RestoreBackupSeeder.php
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Stellt den Testdaten-Stand vom 12.02.2026 wieder her.
|
||||
* Basierend auf: dev/12-01-2026/db-backup.sql
|
||||
*
|
||||
* Nutzung: php artisan db:seed --class=RestoreBackupSeeder
|
||||
* Voraussetzung: Migrationen müssen gelaufen sein (php artisan migrate)
|
||||
*/
|
||||
class RestoreBackupSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$isMysql = DB::connection()->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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
67
database/seeders/SettingsSeeder.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class SettingsSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$settings = [
|
||||
// Ticket-Einstellungen
|
||||
[
|
||||
'group' => '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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
70
dev/12-01-2026/Moebeldatenliste Stand 4.11.2025.csv
Normal file
|
|
@ -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
|
||||
|
941
dev/12-01-2026/db-backup.sql
Normal file
|
|
@ -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 */;
|
||||
983
dev/12-01-2026/entwicklungsplan.md
Normal file
|
|
@ -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)
|
||||
- `<flux:toast />` 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, `<flux:toast />` |
|
||||
| **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 `<flux:columns>` → `<flux:table.columns>` 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 (`<flux:textarea>`)
|
||||
- [x] Öffnungszeiten-Eingabe (strukturiertes JSON-Formular, 7 Wochentage)
|
||||
- [ ] Team-Fotos Upload (mehrere Bilder via `<flux:file-upload>`) – 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.*
|
||||
318
dev/12-01-2026/konzeption.md
Normal file
|
|
@ -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).
|
||||
0
dev/core-module.md
Normal file
|
|
@ -1,7 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
colors="true"
|
||||
>
|
||||
<testsuites>
|
||||
|
|
@ -22,11 +22,15 @@
|
|||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_DATABASE" value="testing"/>
|
||||
<env name="DB_CONNECTION" value="sqlite" force="true"/>
|
||||
<env name="DB_DATABASE" value=":memory:" force="true"/>
|
||||
<server name="DB_CONNECTION" value="sqlite" force="true"/>
|
||||
<server name="DB_DATABASE" value=":memory:" force="true"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="BASIC_AUTH_ENABLED" value="false"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
|
|
|||
BIN
public/_cabinet/assets/cabinet-intro.jpg
Normal file
|
After Width: | Height: | Size: 894 KiB |
BIN
public/_cabinet/assets/goya.jpg
Normal file
|
After Width: | Height: | Size: 788 KiB |
BIN
public/_cabinet/assets/goya1.jpg
Normal file
|
After Width: | Height: | Size: 487 KiB |
BIN
public/_cabinet/assets/goya2.jpg
Normal file
|
After Width: | Height: | Size: 449 KiB |
BIN
public/_cabinet/assets/tango.jpg
Normal file
|
After Width: | Height: | Size: 837 KiB |
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- FULLSCREEN BUTTON -->
|
||||
<button id="fullscreen-btn" title="Vollbildmodus aktivieren">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle;">
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>
|
||||
</svg>
|
||||
<span style="vertical-align: middle;">V 1.3</span>
|
||||
</button>
|
||||
|
||||
<div id="main-container">
|
||||
<!-- VIDEO BEREICH -->
|
||||
|
|
@ -376,99 +318,6 @@
|
|||
</div>
|
||||
|
||||
<script>
|
||||
/* ==============================================
|
||||
FULLSCREEN BUTTON LOGIC MIT AUTO-REMINDER
|
||||
============================================== */
|
||||
|
||||
const fullscreenBtn = document.getElementById('fullscreen-btn');
|
||||
const FULLSCREEN_STATE_KEY = 'cabinet_fullscreen_was_active';
|
||||
|
||||
// Fullscreen aktivieren
|
||||
function enterFullscreen() {
|
||||
const elem = document.documentElement;
|
||||
|
||||
// Merken dass Fullscreen aktiviert wurde (für nach Reload)
|
||||
localStorage.setItem(FULLSCREEN_STATE_KEY, 'true');
|
||||
|
||||
if (elem.requestFullscreen) {
|
||||
elem.requestFullscreen();
|
||||
} else if (elem.webkitRequestFullscreen) { // Safari/Chrome
|
||||
elem.webkitRequestFullscreen();
|
||||
} else if (elem.mozRequestFullScreen) { // Firefox
|
||||
elem.mozRequestFullScreen();
|
||||
} else if (elem.msRequestFullscreen) { // IE/Edge
|
||||
elem.msRequestFullscreen();
|
||||
}
|
||||
|
||||
window.displayLogger?.log('Fullscreen aktiviert');
|
||||
}
|
||||
|
||||
// Button Event Listener
|
||||
fullscreenBtn.addEventListener('click', () => {
|
||||
enterFullscreen();
|
||||
// Reminder-Klasse entfernen falls vorhanden
|
||||
fullscreenBtn.classList.remove('reminder');
|
||||
});
|
||||
|
||||
// Prüfen ob Fullscreen vorher aktiv war (nach Reload)
|
||||
function checkFullscreenRestore() {
|
||||
const wasFullscreen = localStorage.getItem(FULLSCREEN_STATE_KEY);
|
||||
|
||||
if (wasFullscreen === 'true') {
|
||||
// Fullscreen war vorher aktiv → Auffälliger Reminder
|
||||
fullscreenBtn.classList.add('reminder');
|
||||
fullscreenBtn.innerHTML = `
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle;">
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>
|
||||
</svg>
|
||||
<span style="vertical-align: middle;">⚠️ Vollbild aktivieren!</span>
|
||||
`;
|
||||
|
||||
window.displayLogger?.warn('Fullscreen-Reminder angezeigt (war vorher aktiv)', {
|
||||
reason: 'Page reload',
|
||||
previousState: 'fullscreen'
|
||||
});
|
||||
|
||||
// Nach 30 Sekunden automatisch versuchen (falls Kiosk-Mode)
|
||||
setTimeout(() => {
|
||||
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
|
||||
window.displayLogger?.log('Versuche Auto-Fullscreen (Kiosk-Mode?)');
|
||||
enterFullscreen();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
}
|
||||
|
||||
// Button ausblenden wenn bereits im Fullscreen
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
if (document.fullscreenElement) {
|
||||
fullscreenBtn.classList.add('hidden');
|
||||
fullscreenBtn.classList.remove('reminder');
|
||||
localStorage.setItem(FULLSCREEN_STATE_KEY, 'true');
|
||||
} else {
|
||||
fullscreenBtn.classList.remove('hidden');
|
||||
// Fullscreen wurde verlassen → State clearen
|
||||
localStorage.removeItem(FULLSCREEN_STATE_KEY);
|
||||
window.displayLogger?.log('Fullscreen verlassen');
|
||||
}
|
||||
});
|
||||
|
||||
// Webkit Fullscreen Change (Chrome/Safari)
|
||||
document.addEventListener('webkitfullscreenchange', () => {
|
||||
if (document.webkitFullscreenElement) {
|
||||
fullscreenBtn.classList.add('hidden');
|
||||
fullscreenBtn.classList.remove('reminder');
|
||||
localStorage.setItem(FULLSCREEN_STATE_KEY, 'true');
|
||||
} else {
|
||||
fullscreenBtn.classList.remove('hidden');
|
||||
localStorage.removeItem(FULLSCREEN_STATE_KEY);
|
||||
window.displayLogger?.log('Fullscreen verlassen (webkit)');
|
||||
}
|
||||
});
|
||||
|
||||
// Check beim Laden der Seite
|
||||
checkFullscreenRestore();
|
||||
|
||||
/* ==============================================
|
||||
KONFIGURATION WIRD DYNAMISCH GELADEN
|
||||
============================================== */
|
||||
|
|
|
|||
991
public/_cabinet/index_2.html
Normal file
|
|
@ -0,0 +1,991 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cabinet Digital Signage Bielefeld</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||||
<script>
|
||||
(function() {
|
||||
const LOG_URL = 'https://cabinet.b2in.eu/logger.php';
|
||||
|
||||
// Kontext-Informationen für besseres Debugging
|
||||
let appContext = {
|
||||
currentVideo: null,
|
||||
currentFooter: null,
|
||||
videoPlaylistLength: 0,
|
||||
footerContentLength: 0,
|
||||
lastActivity: Date.now()
|
||||
};
|
||||
|
||||
// Logging-Funktion mit Kontext
|
||||
function sendLog(level, message, additionalData = {}) {
|
||||
try {
|
||||
const logData = {
|
||||
level: level,
|
||||
message: String(message),
|
||||
timestamp: new Date().toISOString(),
|
||||
context: {
|
||||
...appContext,
|
||||
...additionalData
|
||||
},
|
||||
viewport: `${window.innerWidth}x${window.innerHeight}`,
|
||||
connection: navigator.onLine ? 'online' : 'offline'
|
||||
};
|
||||
|
||||
fetch(LOG_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(logData),
|
||||
keepalive: true // Wichtig für Logs beim Verlassen der Seite
|
||||
}).catch(() => {}); // Fehler beim Loggen ignorieren
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Globale Fehler abfangen
|
||||
window.onerror = function(msg, url, line, col, error) {
|
||||
sendLog('FATAL', `JavaScript Error: ${msg}`, {
|
||||
file: url,
|
||||
line: line,
|
||||
column: col,
|
||||
stack: error?.stack
|
||||
});
|
||||
return false;
|
||||
};
|
||||
|
||||
// Unhandled Promise Rejections (sehr wichtig für async/await!)
|
||||
window.addEventListener('unhandledrejection', function(event) {
|
||||
sendLog('ERROR', `Unhandled Promise Rejection: ${event.reason}`, {
|
||||
promise: event.promise?.toString()
|
||||
});
|
||||
});
|
||||
|
||||
// Console.error überschreiben
|
||||
const originalError = console.error;
|
||||
console.error = function(...args) {
|
||||
sendLog('ERROR', `Console Error: ${args.join(' ')}`);
|
||||
originalError.apply(console, args);
|
||||
};
|
||||
|
||||
// Console.warn überschreiben (für Warnungen)
|
||||
const originalWarn = console.warn;
|
||||
console.warn = function(...args) {
|
||||
sendLog('WARNING', `Console Warning: ${args.join(' ')}`);
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
|
||||
// Resource Loading Errors (z.B. Videos, Bilder)
|
||||
window.addEventListener('error', function(event) {
|
||||
if (event.target !== window) {
|
||||
const element = event.target;
|
||||
const tagName = element.tagName;
|
||||
const src = element.src || element.href;
|
||||
|
||||
sendLog('ERROR', `Resource Failed to Load: ${tagName}`, {
|
||||
src: src,
|
||||
type: tagName
|
||||
});
|
||||
}
|
||||
}, true); // useCapture = true, um alle Events zu fangen
|
||||
|
||||
// Online/Offline Status überwachen
|
||||
window.addEventListener('online', () => {
|
||||
sendLog('INFO', 'Connection restored');
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
sendLog('WARNING', 'Connection lost');
|
||||
});
|
||||
|
||||
// Heartbeat: Alle 5 Minuten ein "alive" Signal senden
|
||||
setInterval(() => {
|
||||
sendLog('INFO', 'Heartbeat - Display is running', {
|
||||
uptime: Math.floor((Date.now() - appContext.lastActivity) / 1000) + 's'
|
||||
});
|
||||
}, 5 * 60 * 1000); // Alle 5 Minuten
|
||||
|
||||
// Initial Log beim Start
|
||||
sendLog('INFO', 'Display started', {
|
||||
userAgent: navigator.userAgent,
|
||||
screen: `${screen.width}x${screen.height}`,
|
||||
url: window.location.href
|
||||
});
|
||||
|
||||
// Export für andere Scripts
|
||||
window.displayLogger = {
|
||||
log: (msg, data) => sendLog('INFO', msg, data),
|
||||
warn: (msg, data) => sendLog('WARNING', msg, data),
|
||||
error: (msg, data) => sendLog('ERROR', msg, data),
|
||||
setContext: (key, value) => { appContext[key] = value; }
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
/* --- GRUNDGERÜST --- */
|
||||
body, html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background-color: #000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#main-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
/* Seitenverhältnis 9:16 (1080:1920) beibehalten */
|
||||
aspect-ratio: 9 / 16;
|
||||
}
|
||||
|
||||
/* Wenn Bildschirm breiter als 9:16 ist, nach Höhe skalieren */
|
||||
@media (min-aspect-ratio: 9/16) {
|
||||
#main-container {
|
||||
width: auto;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Wenn Bildschirm höher als 9:16 ist, nach Breite skalieren */
|
||||
@media (max-aspect-ratio: 9/16) {
|
||||
#main-container {
|
||||
width: 100vw;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- VIDEO BEREICH (Oben) --- */
|
||||
#video-wrapper {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#video-player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover; /* Video füllt den Bereich randlos */
|
||||
object-position: center 15%; /* Fallback-Position, wird per JavaScript überschrieben */
|
||||
display: block;
|
||||
|
||||
/* Performance-Optimierungen für Video */
|
||||
will-change: transform; /* Hint für Browser-Optimierung */
|
||||
transform: translateZ(0); /* Hardware-Beschleunigung aktivieren */
|
||||
backface-visibility: hidden; /* Reduziert Rendering-Last */
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* --- FOOTER BEREICH (Unten - ca. 16.67% der Höhe = 320px bei 1920px) --- */
|
||||
#footer {
|
||||
height: 9.67vh;
|
||||
min-height: 100px;
|
||||
background-color: #1a1a1a; /* Dunkelgrau */
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px; /* 60px bei 1080px Breite */
|
||||
box-sizing: border-box;
|
||||
font-size: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Progress Bar am oberen Rand des Footers */
|
||||
#progress-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
background-color: #009FE3; /* Cabinet Blau */
|
||||
width: 0%;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
#progress-bar.animate {
|
||||
animation: progressAnimation 30s linear;
|
||||
}
|
||||
|
||||
@keyframes progressAnimation {
|
||||
from {
|
||||
width: 0%;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- INHALTE IM FOOTER --- */
|
||||
.cta-text-container {
|
||||
width: 75%;
|
||||
opacity: 1;
|
||||
transition: opacity 1s ease-in-out;
|
||||
}
|
||||
|
||||
.cta-headline {
|
||||
font-size: 2.0em; /* Relativ zur Footer-Schriftgröße */
|
||||
font-weight: 300;
|
||||
margin-bottom: 0.3em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.cta-subline {
|
||||
font-size: 2.4em; /* Relativ zur Footer-Schriftgröße */
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
width: 25%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
opacity: 1;
|
||||
transition: opacity 1s ease-in-out;
|
||||
}
|
||||
|
||||
.qr-code-img {
|
||||
width: 8em; /* Relativ zur Footer-Schriftgröße */
|
||||
height: auto;
|
||||
max-width: 100px;
|
||||
aspect-ratio: 1 / 1; /* Erzwingt quadratische Form */
|
||||
object-fit: contain;
|
||||
background-color: white; /* Weißer Hintergrund für Lesbarkeit */
|
||||
padding: 0.4em;
|
||||
border-radius: 0.6em;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.scan-hint {
|
||||
margin-top: 0.8em;
|
||||
font-size: 1.3em; /* Relativ zur Footer-Schriftgröße */
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
color: #009FE3; /* Akzentfarbe */
|
||||
}
|
||||
|
||||
/* Hilfsklasse für den Überblend-Effekt */
|
||||
.fade-out {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Loading Indicator */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2em;
|
||||
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);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- FULLSCREEN BUTTON -->
|
||||
<button id="fullscreen-btn" title="Vollbildmodus aktivieren">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle;">
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>
|
||||
</svg>
|
||||
<span style="vertical-align: middle;">V 1.3</span>
|
||||
</button>
|
||||
|
||||
<div id="main-container">
|
||||
<!-- VIDEO BEREICH -->
|
||||
<div id="video-wrapper">
|
||||
<!-- Videos werden hier geladen. 'muted' ist oft nötig für Autoplay -->
|
||||
<video id="video-player" autoplay muted playsinline></video>
|
||||
</div>
|
||||
|
||||
<!-- FOOTER BEREICH -->
|
||||
<div id="footer">
|
||||
<div id="progress-bar"></div>
|
||||
<div class="cta-text-container" id="text-area">
|
||||
<div class="cta-headline" id="headline">LADEN...</div>
|
||||
<div class="cta-subline" id="subline">Inhalte werden geladen</div>
|
||||
</div>
|
||||
|
||||
<div class="qr-container" id="qr-area">
|
||||
<img src="" id="qr-image" class="qr-code-img" alt="QR Code">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ==============================================
|
||||
FULLSCREEN BUTTON LOGIC MIT AUTO-REMINDER
|
||||
============================================== */
|
||||
|
||||
const fullscreenBtn = document.getElementById('fullscreen-btn');
|
||||
const FULLSCREEN_STATE_KEY = 'cabinet_fullscreen_was_active';
|
||||
|
||||
// Fullscreen aktivieren
|
||||
function enterFullscreen() {
|
||||
const elem = document.documentElement;
|
||||
|
||||
// Merken dass Fullscreen aktiviert wurde (für nach Reload)
|
||||
localStorage.setItem(FULLSCREEN_STATE_KEY, 'true');
|
||||
|
||||
if (elem.requestFullscreen) {
|
||||
elem.requestFullscreen();
|
||||
} else if (elem.webkitRequestFullscreen) { // Safari/Chrome
|
||||
elem.webkitRequestFullscreen();
|
||||
} else if (elem.mozRequestFullScreen) { // Firefox
|
||||
elem.mozRequestFullScreen();
|
||||
} else if (elem.msRequestFullscreen) { // IE/Edge
|
||||
elem.msRequestFullscreen();
|
||||
}
|
||||
|
||||
window.displayLogger?.log('Fullscreen aktiviert');
|
||||
}
|
||||
|
||||
// Button Event Listener
|
||||
fullscreenBtn.addEventListener('click', () => {
|
||||
enterFullscreen();
|
||||
// Reminder-Klasse entfernen falls vorhanden
|
||||
fullscreenBtn.classList.remove('reminder');
|
||||
});
|
||||
|
||||
// Prüfen ob Fullscreen vorher aktiv war (nach Reload)
|
||||
function checkFullscreenRestore() {
|
||||
const wasFullscreen = localStorage.getItem(FULLSCREEN_STATE_KEY);
|
||||
|
||||
if (wasFullscreen === 'true') {
|
||||
// Fullscreen war vorher aktiv → Auffälliger Reminder
|
||||
fullscreenBtn.classList.add('reminder');
|
||||
fullscreenBtn.innerHTML = `
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle;">
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>
|
||||
</svg>
|
||||
<span style="vertical-align: middle;">⚠️ Vollbild aktivieren!</span>
|
||||
`;
|
||||
|
||||
window.displayLogger?.warn('Fullscreen-Reminder angezeigt (war vorher aktiv)', {
|
||||
reason: 'Page reload',
|
||||
previousState: 'fullscreen'
|
||||
});
|
||||
|
||||
// Nach 30 Sekunden automatisch versuchen (falls Kiosk-Mode)
|
||||
setTimeout(() => {
|
||||
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
|
||||
window.displayLogger?.log('Versuche Auto-Fullscreen (Kiosk-Mode?)');
|
||||
enterFullscreen();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
}
|
||||
|
||||
// Button ausblenden wenn bereits im Fullscreen
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
if (document.fullscreenElement) {
|
||||
fullscreenBtn.classList.add('hidden');
|
||||
fullscreenBtn.classList.remove('reminder');
|
||||
localStorage.setItem(FULLSCREEN_STATE_KEY, 'true');
|
||||
} else {
|
||||
fullscreenBtn.classList.remove('hidden');
|
||||
// Fullscreen wurde verlassen → State clearen
|
||||
localStorage.removeItem(FULLSCREEN_STATE_KEY);
|
||||
window.displayLogger?.log('Fullscreen verlassen');
|
||||
}
|
||||
});
|
||||
|
||||
// Webkit Fullscreen Change (Chrome/Safari)
|
||||
document.addEventListener('webkitfullscreenchange', () => {
|
||||
if (document.webkitFullscreenElement) {
|
||||
fullscreenBtn.classList.add('hidden');
|
||||
fullscreenBtn.classList.remove('reminder');
|
||||
localStorage.setItem(FULLSCREEN_STATE_KEY, 'true');
|
||||
} else {
|
||||
fullscreenBtn.classList.remove('hidden');
|
||||
localStorage.removeItem(FULLSCREEN_STATE_KEY);
|
||||
window.displayLogger?.log('Fullscreen verlassen (webkit)');
|
||||
}
|
||||
});
|
||||
|
||||
// Check beim Laden der Seite
|
||||
checkFullscreenRestore();
|
||||
|
||||
/* ==============================================
|
||||
KONFIGURATION WIRD DYNAMISCH GELADEN
|
||||
============================================== */
|
||||
|
||||
let videoPlaylist = [];
|
||||
let footerContent = [];
|
||||
let footerContentLength = 0;
|
||||
|
||||
// Basis-URL für Assets und API (b2in.eu Server)
|
||||
const BASE_URL = 'https://b2in.eu';
|
||||
|
||||
// API-URL für die Konfiguration (CORS ist aktiviert für cabinet.b2in.eu)
|
||||
const API_URL = BASE_URL + '/api/display/config';
|
||||
|
||||
/* ==============================================
|
||||
KONFIGURATION LADEN
|
||||
============================================== */
|
||||
|
||||
async function loadConfiguration() {
|
||||
try {
|
||||
window.displayLogger?.log('Lade Konfiguration...', { url: API_URL });
|
||||
|
||||
const response = await fetch(API_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const config = await response.json();
|
||||
videoPlaylist = config.videoPlaylist || [];
|
||||
footerContent = config.footerContent || [];
|
||||
|
||||
window.displayLogger?.setContext('videoPlaylistLength', videoPlaylist.length);
|
||||
window.displayLogger?.setContext('footerContentLength', footerContent.length);
|
||||
window.displayLogger?.log('Konfiguration erfolgreich geladen', {
|
||||
videos: videoPlaylist.length,
|
||||
footerItems: footerContent.length
|
||||
});
|
||||
|
||||
console.log('Konfiguration geladen:', config);
|
||||
|
||||
// Überprüfe, ob Videos vorhanden sind
|
||||
if (videoPlaylist.length === 0) {
|
||||
console.warn('Keine Videos in der Playlist vorhanden');
|
||||
window.displayLogger?.warn('Keine Videos in Playlist');
|
||||
document.getElementById('headline').innerText = 'KEINE VIDEOS';
|
||||
document.getElementById('subline').innerText = 'Bitte fügen Sie Videos im CMS hinzu';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Überprüfe, ob Footer-Inhalte vorhanden sind
|
||||
if (footerContent.length === 0) {
|
||||
console.warn('Keine Footer-Inhalte vorhanden - Footer wird ausgeblendet');
|
||||
footerContentLength = 0;
|
||||
// Footer ausblenden
|
||||
const footer = document.getElementById('footer');
|
||||
if (footer) {
|
||||
footer.style.display = 'none';
|
||||
}
|
||||
// Video-Wrapper auf 100% Höhe setzen
|
||||
const videoWrapper = document.getElementById('video-wrapper');
|
||||
if (videoWrapper) {
|
||||
videoWrapper.style.flexGrow = '1';
|
||||
videoWrapper.style.height = '100%';
|
||||
}
|
||||
} else {
|
||||
// Footer anzeigen, falls er zuvor ausgeblendet wurde
|
||||
const footer = document.getElementById('footer');
|
||||
if (footer) {
|
||||
footer.style.display = 'flex';
|
||||
}
|
||||
footerContentLength = 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Konfiguration:', error);
|
||||
window.displayLogger?.error('Konfiguration konnte nicht geladen werden', {
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
document.getElementById('headline').innerText = 'FEHLER';
|
||||
document.getElementById('subline').innerText = 'Konfiguration konnte nicht geladen werden';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==============================================
|
||||
PROGRAMM-LOGIK
|
||||
============================================== */
|
||||
|
||||
// --- ROBUSTER VIDEO PLAYER MIT MEMORY-MANAGEMENT ---
|
||||
const videoElement = document.getElementById('video-player');
|
||||
let currentVideoIndex = 0;
|
||||
let videoStartTimeout = null;
|
||||
let videoWatchdogInterval = null;
|
||||
let lastVideoTime = 0;
|
||||
let videoStuckCount = 0;
|
||||
let consecutiveErrors = 0;
|
||||
const MAX_CONSECUTIVE_ERRORS = 3;
|
||||
const VIDEO_START_TIMEOUT = 10000; // 10 Sekunden
|
||||
const VIDEO_WATCHDOG_INTERVAL = 5000; // Alle 5 Sekunden prüfen
|
||||
|
||||
// Video-Element optimieren für Memory-Management
|
||||
videoElement.setAttribute('preload', 'metadata'); // Nur Metadaten vorladen, nicht ganzes Video
|
||||
|
||||
function cleanupVideo() {
|
||||
// Wichtig: Stoppt Video und gibt Speicher frei
|
||||
try {
|
||||
videoElement.pause();
|
||||
videoElement.removeAttribute('src');
|
||||
videoElement.load(); // Triggert Garbage Collection des alten Videos
|
||||
|
||||
// Timeouts clearen
|
||||
if (videoStartTimeout) {
|
||||
clearTimeout(videoStartTimeout);
|
||||
videoStartTimeout = null;
|
||||
}
|
||||
|
||||
window.displayLogger?.log('Video cleanup durchgeführt');
|
||||
} catch (e) {
|
||||
window.displayLogger?.error('Video cleanup error', { error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
function playNextVideo() {
|
||||
if (videoPlaylist.length === 0) return;
|
||||
|
||||
// Watchdog zurücksetzen
|
||||
lastVideoTime = 0;
|
||||
videoStuckCount = 0;
|
||||
|
||||
const video = videoPlaylist[currentVideoIndex];
|
||||
const videoSrc = BASE_URL + "/_cabinet/" + video.src;
|
||||
|
||||
// Kontext aktualisieren
|
||||
window.displayLogger?.setContext('currentVideo', video.src);
|
||||
window.displayLogger?.setContext('currentVideoIndex', currentVideoIndex);
|
||||
|
||||
// WICHTIG: Altes Video cleanup BEVOR neues geladen wird
|
||||
cleanupVideo();
|
||||
|
||||
// Kleiner Delay um Cleanup abzuschließen
|
||||
setTimeout(() => {
|
||||
try {
|
||||
// Neues Video laden
|
||||
videoElement.src = videoSrc;
|
||||
|
||||
if(footerContentLength !== 0 && video.position !== undefined) {
|
||||
videoElement.style.objectPosition = `center ${video.position}%`;
|
||||
}
|
||||
|
||||
// Timeout für Video-Start
|
||||
videoStartTimeout = setTimeout(() => {
|
||||
window.displayLogger?.error('Video start timeout', {
|
||||
video: video.src,
|
||||
timeout: VIDEO_START_TIMEOUT
|
||||
});
|
||||
// Nächstes Video probieren
|
||||
skipToNextVideo('timeout');
|
||||
}, VIDEO_START_TIMEOUT);
|
||||
|
||||
// Video abspielen
|
||||
videoElement.play()
|
||||
.then(() => {
|
||||
window.displayLogger?.log(`Video started: ${video.src}`);
|
||||
consecutiveErrors = 0; // Erfolg → Error-Counter zurücksetzen
|
||||
|
||||
// Start-Timeout clearen
|
||||
if (videoStartTimeout) {
|
||||
clearTimeout(videoStartTimeout);
|
||||
videoStartTimeout = null;
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
console.log("Autoplay blocked/failed", e);
|
||||
window.displayLogger?.error(`Video play failed: ${video.src}`, {
|
||||
error: e.message
|
||||
});
|
||||
|
||||
consecutiveErrors++;
|
||||
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
||||
window.displayLogger?.error('Zu viele aufeinanderfolgende Fehler', {
|
||||
count: consecutiveErrors
|
||||
});
|
||||
// Seite nach 30 Sekunden neu laden
|
||||
setTimeout(() => location.reload(), 30000);
|
||||
} else {
|
||||
// Nächstes Video probieren
|
||||
skipToNextVideo('play_failed');
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
window.displayLogger?.error('Exception beim Video-Laden', {
|
||||
error: e.message,
|
||||
stack: e.stack
|
||||
});
|
||||
skipToNextVideo('exception');
|
||||
}
|
||||
}, 100); // 100ms Delay für Cleanup
|
||||
|
||||
// Index weiterschalten
|
||||
currentVideoIndex++;
|
||||
if (currentVideoIndex >= videoPlaylist.length) {
|
||||
currentVideoIndex = 0;
|
||||
// Playlist-Loop abgeschlossen → Log für Monitoring
|
||||
window.displayLogger?.log('Playlist-Loop abgeschlossen, starte von vorne');
|
||||
}
|
||||
}
|
||||
|
||||
function skipToNextVideo(reason) {
|
||||
window.displayLogger?.warn('Überspringe zum nächsten Video', { reason: reason });
|
||||
playNextVideo();
|
||||
}
|
||||
|
||||
// Video Watchdog: Prüft ob Video wirklich läuft
|
||||
function startVideoWatchdog() {
|
||||
if (videoWatchdogInterval) {
|
||||
clearInterval(videoWatchdogInterval);
|
||||
}
|
||||
|
||||
videoWatchdogInterval = setInterval(() => {
|
||||
if (videoPlaylist.length === 0) return;
|
||||
|
||||
const currentTime = videoElement.currentTime;
|
||||
const isPaused = videoElement.paused;
|
||||
const hasEnded = videoElement.ended;
|
||||
const isStuck = (currentTime === lastVideoTime && !isPaused && !hasEnded);
|
||||
|
||||
// Debug-Log
|
||||
if (isStuck) {
|
||||
videoStuckCount++;
|
||||
window.displayLogger?.warn('Video scheint stecken geblieben zu sein', {
|
||||
currentTime: currentTime,
|
||||
isPaused: isPaused,
|
||||
hasEnded: hasEnded,
|
||||
stuckCount: videoStuckCount,
|
||||
src: videoElement.src
|
||||
});
|
||||
|
||||
// Wenn 2x hintereinander stuck → Recovery
|
||||
if (videoStuckCount >= 2) {
|
||||
window.displayLogger?.error('Video definitiv stuck - starte nächstes', {
|
||||
currentTime: currentTime,
|
||||
src: videoElement.src
|
||||
});
|
||||
skipToNextVideo('watchdog_stuck');
|
||||
}
|
||||
} else {
|
||||
// Video läuft normal → Counter zurücksetzen
|
||||
if (videoStuckCount > 0) {
|
||||
window.displayLogger?.log('Video läuft wieder normal');
|
||||
}
|
||||
videoStuckCount = 0;
|
||||
}
|
||||
|
||||
lastVideoTime = currentTime;
|
||||
}, VIDEO_WATCHDOG_INTERVAL);
|
||||
}
|
||||
|
||||
// Video Events
|
||||
videoElement.addEventListener('ended', () => {
|
||||
window.displayLogger?.log('Video ended', {
|
||||
src: videoElement.src
|
||||
});
|
||||
playNextVideo();
|
||||
});
|
||||
|
||||
videoElement.addEventListener('error', (e) => {
|
||||
const error = videoElement.error;
|
||||
const errorCode = error?.code;
|
||||
const errorMessage = {
|
||||
1: 'MEDIA_ERR_ABORTED',
|
||||
2: 'MEDIA_ERR_NETWORK',
|
||||
3: 'MEDIA_ERR_DECODE',
|
||||
4: 'MEDIA_ERR_SRC_NOT_SUPPORTED'
|
||||
}[errorCode] || 'UNKNOWN';
|
||||
|
||||
window.displayLogger?.error('Video Error Event', {
|
||||
code: errorCode,
|
||||
message: error?.message,
|
||||
src: videoElement.src,
|
||||
mediaError: errorMessage
|
||||
});
|
||||
|
||||
// Bei Fehler → Nächstes Video
|
||||
consecutiveErrors++;
|
||||
skipToNextVideo(`error_${errorMessage}`);
|
||||
});
|
||||
|
||||
videoElement.addEventListener('stalled', () => {
|
||||
window.displayLogger?.warn('Video stalled (buffering)', {
|
||||
src: videoElement.src,
|
||||
currentTime: videoElement.currentTime
|
||||
});
|
||||
});
|
||||
|
||||
videoElement.addEventListener('waiting', () => {
|
||||
window.displayLogger?.warn('Video waiting (buffering)', {
|
||||
src: videoElement.src,
|
||||
currentTime: videoElement.currentTime
|
||||
});
|
||||
});
|
||||
|
||||
videoElement.addEventListener('playing', () => {
|
||||
window.displayLogger?.log('Video playing event', {
|
||||
src: videoElement.src,
|
||||
currentTime: videoElement.currentTime
|
||||
});
|
||||
});
|
||||
|
||||
videoElement.addEventListener('canplay', () => {
|
||||
window.displayLogger?.log('Video canplay event', {
|
||||
src: videoElement.src
|
||||
});
|
||||
});
|
||||
|
||||
// --- FOOTER ROTATION LOGIC ---
|
||||
let currentFooterIndex = 0;
|
||||
const textArea = document.getElementById('text-area');
|
||||
const qrArea = document.getElementById('qr-area');
|
||||
const headlineEl = document.getElementById('headline');
|
||||
const sublineEl = document.getElementById('subline');
|
||||
const qrImageEl = document.getElementById('qr-image');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
|
||||
function restartProgressBar() {
|
||||
// Animation zurücksetzen und neu starten
|
||||
progressBar.classList.remove('animate');
|
||||
void progressBar.offsetWidth; // Force reflow
|
||||
progressBar.classList.add('animate');
|
||||
}
|
||||
|
||||
function updateFooter() {
|
||||
if (footerContent.length === 0) return;
|
||||
|
||||
// 1. Ausblenden
|
||||
textArea.classList.add('fade-out');
|
||||
qrArea.classList.add('fade-out');
|
||||
|
||||
// Progress Bar neu starten
|
||||
restartProgressBar();
|
||||
|
||||
// 2. Warten, Inhalt tauschen, Einblenden
|
||||
setTimeout(() => {
|
||||
const content = footerContent[currentFooterIndex];
|
||||
|
||||
// Kontext aktualisieren
|
||||
window.displayLogger?.setContext('currentFooter', currentFooterIndex);
|
||||
|
||||
// Text setzen
|
||||
headlineEl.innerText = content.headline;
|
||||
sublineEl.innerText = content.subline;
|
||||
|
||||
// QR Code nur generieren wenn URL vorhanden
|
||||
if (content.url) {
|
||||
// QR Code generieren (API Aufruf)
|
||||
const qrSize = "300x300";
|
||||
const qrColor = "000000"; // Schwarz
|
||||
const qrBg = "ffffff"; // Weiß
|
||||
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${qrSize}&color=${qrColor}&bgcolor=${qrBg}&margin=10&data=${encodeURIComponent(content.url)}`;
|
||||
|
||||
qrImageEl.src = qrUrl;
|
||||
qrArea.style.display = 'flex'; // QR-Bereich anzeigen
|
||||
} else {
|
||||
// Kein QR-Code - QR-Bereich ausblenden
|
||||
qrArea.style.display = 'none';
|
||||
// Text-Container auf volle Breite
|
||||
textArea.style.width = '100%';
|
||||
}
|
||||
|
||||
// Index weiterschalten
|
||||
currentFooterIndex++;
|
||||
if (currentFooterIndex >= footerContent.length) {
|
||||
currentFooterIndex = 0;
|
||||
}
|
||||
|
||||
// 3. Einblenden
|
||||
if (content.url) {
|
||||
qrImageEl.onload = () => {
|
||||
textArea.classList.remove('fade-out');
|
||||
qrArea.classList.remove('fade-out');
|
||||
};
|
||||
}
|
||||
// Fallback falls Bild sofort da ist (Cache) oder kein QR-Code
|
||||
setTimeout(() => {
|
||||
textArea.classList.remove('fade-out');
|
||||
if (content.url) {
|
||||
qrArea.classList.remove('fade-out');
|
||||
}
|
||||
}, 100);
|
||||
|
||||
}, 1000); // 1 Sekunde für Fade-Out Animation
|
||||
}
|
||||
|
||||
/* ==============================================
|
||||
MEMORY MANAGEMENT & PERFORMANCE
|
||||
============================================== */
|
||||
|
||||
// Memory-Optimierung: Regelmäßig Browser aufräumen
|
||||
function performMemoryOptimization() {
|
||||
try {
|
||||
// Performance-Metriken loggen falls verfügbar
|
||||
if (performance.memory) {
|
||||
const memUsed = Math.round(performance.memory.usedJSHeapSize / 1048576);
|
||||
const memLimit = Math.round(performance.memory.jsHeapSizeLimit / 1048576);
|
||||
const memPercent = Math.round((memUsed / memLimit) * 100);
|
||||
|
||||
window.displayLogger?.log('Memory Status', {
|
||||
usedMB: memUsed,
|
||||
limitMB: memLimit,
|
||||
percentUsed: memPercent
|
||||
});
|
||||
|
||||
// Warnung wenn Speicher über 80%
|
||||
if (memPercent > 80) {
|
||||
window.displayLogger?.warn('Hohe Speicherauslastung', {
|
||||
percentUsed: memPercent,
|
||||
usedMB: memUsed
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Cache-Infos loggen
|
||||
const cacheInfo = {
|
||||
videoBuffered: videoElement.buffered.length,
|
||||
videoDuration: videoElement.duration,
|
||||
videoReadyState: videoElement.readyState
|
||||
};
|
||||
|
||||
window.displayLogger?.log('Video Cache Status', cacheInfo);
|
||||
|
||||
} catch (e) {
|
||||
window.displayLogger?.error('Memory optimization error', {
|
||||
error: e.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Automatischer Page-Reload bei kritischen Problemen (Failsafe)
|
||||
let criticalErrorCount = 0;
|
||||
function checkCriticalErrors() {
|
||||
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
||||
criticalErrorCount++;
|
||||
window.displayLogger?.error('Kritischer Zustand erkannt', {
|
||||
consecutiveErrors: consecutiveErrors,
|
||||
criticalErrorCount: criticalErrorCount
|
||||
});
|
||||
|
||||
if (criticalErrorCount >= 3) {
|
||||
window.displayLogger?.error('Zu viele kritische Fehler - Seite wird neu geladen');
|
||||
setTimeout(() => location.reload(), 5000);
|
||||
}
|
||||
} else {
|
||||
criticalErrorCount = 0; // Zurücksetzen wenn alles normal läuft
|
||||
}
|
||||
}
|
||||
|
||||
/* ==============================================
|
||||
INITIALISIERUNG
|
||||
============================================== */
|
||||
|
||||
async function initialize() {
|
||||
const success = await loadConfiguration();
|
||||
|
||||
if (success && videoPlaylist.length > 0) {
|
||||
// Start Video
|
||||
playNextVideo();
|
||||
|
||||
// Start Video Watchdog (überwacht ob Videos laufen)
|
||||
startVideoWatchdog();
|
||||
window.displayLogger?.log('Video Watchdog gestartet');
|
||||
|
||||
// Start Footer Loop
|
||||
if (footerContent.length > 0) {
|
||||
updateFooter();
|
||||
setInterval(updateFooter, 30000); // Alle 30.000 ms (30 sek) wechseln
|
||||
|
||||
// Progress Bar initial starten
|
||||
restartProgressBar();
|
||||
}
|
||||
|
||||
// Memory-Optimierung alle 10 Minuten
|
||||
setInterval(performMemoryOptimization, 10 * 60 * 1000);
|
||||
window.displayLogger?.log('Memory Optimizer gestartet (alle 10 Min)');
|
||||
|
||||
// Critical Error Check alle 30 Sekunden
|
||||
setInterval(checkCriticalErrors, 30 * 1000);
|
||||
|
||||
// Initial Memory Check nach 30 Sekunden
|
||||
setTimeout(performMemoryOptimization, 30000);
|
||||
}
|
||||
}
|
||||
|
||||
// Beim Laden der Seite initialisieren
|
||||
initialize();
|
||||
|
||||
// Auto-Reload alle 5 Minuten, um neue Inhalte zu laden
|
||||
setInterval(async () => {
|
||||
console.log('Prüfe auf neue Konfiguration...');
|
||||
const oldFooterCount = footerContent.length;
|
||||
await loadConfiguration();
|
||||
|
||||
// Wenn Footer-Inhalte hinzugefügt oder entfernt wurden, Seite neu laden
|
||||
if ((oldFooterCount === 0 && footerContent.length > 0) ||
|
||||
(oldFooterCount > 0 && footerContent.length === 0)) {
|
||||
console.log('Footer-Status hat sich geändert - Seite wird neu geladen');
|
||||
location.reload();
|
||||
}
|
||||
}, 5 * 60 * 1000); // 5 Minuten
|
||||
|
||||
// Präventiver Page-Reload alle 6 Stunden (verhindert Memory-Leaks über lange Zeit)
|
||||
setTimeout(() => {
|
||||
window.displayLogger?.log('Präventiver Reload nach 6 Stunden');
|
||||
location.reload();
|
||||
}, 6 * 60 * 60 * 1000); // 6 Stunden
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
BIN
public/_cabinet/logo-cabinet-300.png
Normal file
|
After Width: | Height: | Size: 359 KiB |
BIN
public/_cabinet/logo-cabinet.png.webp
Normal file
|
After Width: | Height: | Size: 136 KiB |
494
public/_cabinet/offer.html
Normal file
|
|
@ -0,0 +1,494 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>CABINET Display – 9:16</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#ffffff;
|
||||
--fg:#111111;
|
||||
--muted:#6b6b6b;
|
||||
--line:#e9e9e9;
|
||||
--card:#f7f7f7;
|
||||
--radius:28px;
|
||||
--pad:64px; /* Safe-Area */
|
||||
--maxw:1080px;
|
||||
--maxh:1920px;
|
||||
--shadow:0 18px 50px rgba(0,0,0,.08);
|
||||
}
|
||||
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
body{
|
||||
margin:0;
|
||||
background:#0f0f10;
|
||||
color:var(--fg);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
padding:24px;
|
||||
}
|
||||
|
||||
/* 9:16 Display-Rahmen */
|
||||
.screen{
|
||||
width:min(92vw, var(--maxw));
|
||||
aspect-ratio: 9 / 16;
|
||||
background:var(--bg);
|
||||
border-radius:var(--radius);
|
||||
box-shadow:var(--shadow);
|
||||
overflow:hidden;
|
||||
position:relative;
|
||||
}
|
||||
|
||||
/* Slides */
|
||||
.slide{
|
||||
position:absolute;
|
||||
inset:0;
|
||||
padding:var(--pad);
|
||||
display:grid;
|
||||
grid-template-rows: 156px 1fr 360px; /* Header / Hero / Info+Footer */
|
||||
gap:24px;
|
||||
opacity:0;
|
||||
transform:scale(1.01);
|
||||
transition:opacity .6s ease, transform .6s ease;
|
||||
}
|
||||
.slide.active{
|
||||
opacity:1;
|
||||
transform:scale(1);
|
||||
z-index:2;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header{
|
||||
display:flex;
|
||||
align-items:flex-end;
|
||||
justify-content:space-between;
|
||||
padding-bottom:18px;
|
||||
border-bottom:1px solid var(--line);
|
||||
}
|
||||
.brand{
|
||||
font-size:34px;
|
||||
letter-spacing:.08em;
|
||||
text-transform:uppercase;
|
||||
font-weight:600;
|
||||
}
|
||||
.tagline{
|
||||
font-size:26px;
|
||||
color:var(--muted);
|
||||
font-weight:400;
|
||||
text-align:right;
|
||||
line-height:1.2;
|
||||
}
|
||||
|
||||
/* Hero Bildbereich (Platzhalter, später durch echtes Bild ersetzen) */
|
||||
.hero{
|
||||
border-radius:24px;
|
||||
background:
|
||||
radial-gradient(1200px 800px at 20% 15%, rgba(0,0,0,.06), rgba(0,0,0,0) 55%),
|
||||
linear-gradient(135deg, #f2f2f2, #fbfbfb);
|
||||
border:1px solid var(--line);
|
||||
overflow:hidden;
|
||||
position:relative;
|
||||
display:flex;
|
||||
align-items:flex-end;
|
||||
padding:36px;
|
||||
}
|
||||
.hero .hero-note{
|
||||
font-size:22px;
|
||||
color:rgba(0,0,0,.55);
|
||||
background:rgba(255,255,255,.75);
|
||||
border:1px solid var(--line);
|
||||
border-radius:999px;
|
||||
padding:10px 16px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
/* Optional: wenn ihr ein echtes Bild nutzen wollt */
|
||||
.hero.has-image{
|
||||
background-size:cover;
|
||||
background-position:center;
|
||||
}
|
||||
|
||||
/* Info + Footer */
|
||||
.bottom{
|
||||
display:grid;
|
||||
grid-template-columns: 1fr 320px; /* Textblock / QR */
|
||||
gap:28px;
|
||||
align-items:stretch;
|
||||
}
|
||||
|
||||
.info{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
justify-content:space-between;
|
||||
border-radius:24px;
|
||||
border:1px solid var(--line);
|
||||
background:linear-gradient(180deg, #ffffff, #fafafa);
|
||||
padding:28px;
|
||||
min-height:360px;
|
||||
}
|
||||
|
||||
.eyebrow{
|
||||
font-size:22px;
|
||||
color:var(--muted);
|
||||
letter-spacing:.08em;
|
||||
text-transform:uppercase;
|
||||
margin-bottom:10px;
|
||||
}
|
||||
.title{
|
||||
font-size:58px;
|
||||
line-height:1.05;
|
||||
font-weight:650;
|
||||
margin:0 0 10px 0;
|
||||
}
|
||||
.subline{
|
||||
font-size:28px;
|
||||
color:var(--muted);
|
||||
margin:0 0 16px 0;
|
||||
}
|
||||
|
||||
.price-row{
|
||||
display:flex;
|
||||
align-items:baseline;
|
||||
justify-content:space-between;
|
||||
gap:16px;
|
||||
margin-top:10px;
|
||||
padding-top:16px;
|
||||
border-top:1px solid var(--line);
|
||||
}
|
||||
.price{
|
||||
font-size:86px;
|
||||
font-weight:700;
|
||||
letter-spacing:-.02em;
|
||||
margin:0;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.uvp{
|
||||
font-size:24px;
|
||||
color:var(--muted);
|
||||
text-align:right;
|
||||
line-height:1.15;
|
||||
}
|
||||
|
||||
.bullets{
|
||||
margin:18px 0 0 0;
|
||||
padding:0;
|
||||
list-style:none;
|
||||
display:grid;
|
||||
gap:10px;
|
||||
}
|
||||
.bullets li{
|
||||
font-size:30px;
|
||||
line-height:1.25;
|
||||
display:flex;
|
||||
gap:12px;
|
||||
align-items:flex-start;
|
||||
}
|
||||
.dot{
|
||||
width:10px;height:10px;border-radius:50%;
|
||||
background:#111;
|
||||
margin-top:14px;
|
||||
flex:0 0 auto;
|
||||
opacity:.8;
|
||||
}
|
||||
|
||||
/* Footer CTA + QR */
|
||||
.qr{
|
||||
border-radius:24px;
|
||||
border:1px solid var(--line);
|
||||
background:var(--card);
|
||||
padding:18px;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
justify-content:space-between;
|
||||
gap:14px;
|
||||
}
|
||||
.qr .cta{
|
||||
font-size:26px;
|
||||
line-height:1.2;
|
||||
color:var(--fg);
|
||||
font-weight:600;
|
||||
}
|
||||
.qr .contact{
|
||||
font-size:22px;
|
||||
color:var(--muted);
|
||||
line-height:1.2;
|
||||
}
|
||||
.qr .code{
|
||||
flex:1;
|
||||
border-radius:18px;
|
||||
background:#fff;
|
||||
border:1px dashed #d8d8d8;
|
||||
display:grid;
|
||||
place-items:center;
|
||||
overflow:hidden;
|
||||
}
|
||||
/* Einfacher QR-Platzhalter (kein echter QR) */
|
||||
.fake-qr{
|
||||
width:88%;
|
||||
aspect-ratio:1/1;
|
||||
background:
|
||||
linear-gradient(90deg, #000 10px, transparent 10px) 0 0 / 26px 26px,
|
||||
linear-gradient(#000 10px, transparent 10px) 0 0 / 26px 26px;
|
||||
opacity:.13;
|
||||
position:relative;
|
||||
}
|
||||
.fake-qr::before,.fake-qr::after{
|
||||
content:"";
|
||||
position:absolute;
|
||||
inset:0;
|
||||
background:
|
||||
radial-gradient(circle at 18% 18%, #000 0 28%, transparent 29% 100%),
|
||||
radial-gradient(circle at 82% 18%, #000 0 28%, transparent 29% 100%),
|
||||
radial-gradient(circle at 18% 82%, #000 0 28%, transparent 29% 100%);
|
||||
opacity:.28;
|
||||
mix-blend-mode:multiply;
|
||||
}
|
||||
|
||||
.disclaimer{
|
||||
font-size:18px;
|
||||
color:var(--muted);
|
||||
margin-top:10px;
|
||||
}
|
||||
|
||||
/* Kleiner Slide-Indikator (optional) */
|
||||
.progress{
|
||||
position:absolute;
|
||||
left:0; right:0; bottom:0;
|
||||
height:6px;
|
||||
background:rgba(255,255,255,.0);
|
||||
}
|
||||
.bar{
|
||||
height:100%;
|
||||
width:0%;
|
||||
background:#111;
|
||||
opacity:.22;
|
||||
transition:width linear;
|
||||
}
|
||||
|
||||
@media (max-width: 560px){
|
||||
:root{ --pad:28px; }
|
||||
.slide{ grid-template-rows: 120px 1fr 380px; }
|
||||
.bottom{ grid-template-columns: 1fr; }
|
||||
.qr{ min-height:240px; }
|
||||
.price{ font-size:72px; }
|
||||
.title{ font-size:46px; }
|
||||
.bullets li{ font-size:24px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main class="screen" aria-label="9:16 Display">
|
||||
<!-- SLIDE 0: Intro -->
|
||||
<section class="slide active" data-duration="8000" id="slide-0">
|
||||
<header class="header">
|
||||
<div class="brand">CABINET Bielefeld</div>
|
||||
<div class="tagline">Planung • Beratung<br>Lieferung/Montage</div>
|
||||
</header>
|
||||
|
||||
<div class="hero">
|
||||
<div class="hero-note">Einzelstücke & Ausstellungsdeals – nur solange verfügbar</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom">
|
||||
<div class="info">
|
||||
<div>
|
||||
<div class="eyebrow">Heute im Fokus</div>
|
||||
<h1 class="title" style="margin-bottom:12px;">Kuratiert. Hochwertig. Sofort.</h1>
|
||||
<p class="subline">Scannen Sie den QR-Code für Kontakt & Store-Infos.</p>
|
||||
</div>
|
||||
<div class="price-row" style="border-top:none; padding-top:0; margin-top:0;">
|
||||
<div style="font-size:26px;color:var(--muted);">
|
||||
CABINET Bielefeld – im Store ansprechen oder direkt reservieren
|
||||
</div>
|
||||
<div style="font-size:22px;color:var(--muted);text-align:right;">
|
||||
Zwischenverkauf vorbehalten
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="qr">
|
||||
<div>
|
||||
<div class="cta">Kontakt & Store-Infos</div>
|
||||
<div class="contact">QR scannen</div>
|
||||
</div>
|
||||
<div class="code" aria-label="QR Code Platzhalter">
|
||||
<div class="fake-qr"></div>
|
||||
</div>
|
||||
<div class="contact">WhatsApp: … · Tel.: …</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SLIDE 1: GOYA – Variante B -->
|
||||
<section class="slide" data-duration="10000" id="slide-1">
|
||||
<header class="header">
|
||||
<div class="brand">CABINET</div>
|
||||
<div class="tagline">Ausstellungsware<br>Einzelstück</div>
|
||||
</header>
|
||||
|
||||
<div class="hero">
|
||||
<div class="hero-note">Bildplatzhalter: GOYA Sideboard (Hero-Foto)</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom">
|
||||
<div class="info">
|
||||
<div>
|
||||
<div class="eyebrow">Ausstellungsware</div>
|
||||
<h2 class="title">GOYA Sideboard</h2>
|
||||
<p class="subline">Marke: Sudbrock</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="price-row">
|
||||
<p class="price">489 €</p>
|
||||
<div class="uvp">
|
||||
brutto<br>
|
||||
UVP neu: 4.744 €*
|
||||
</div>
|
||||
</div>
|
||||
<div class="disclaimer">*UVP nur, sofern belegbar. Zwischenverkauf vorbehalten.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="qr">
|
||||
<div>
|
||||
<div class="cta">Infos & Reservierung</div>
|
||||
<div class="contact">QR scannen</div>
|
||||
</div>
|
||||
<div class="code">
|
||||
<div class="fake-qr"></div>
|
||||
</div>
|
||||
<div class="contact">WhatsApp: … · Tel.: …</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SLIDE 2: GOYA – Variante B -->
|
||||
<section class="slide" data-duration="12000" id="slide-2">
|
||||
<header class="header">
|
||||
<div class="brand">CABINET</div>
|
||||
<div class="tagline">GOYA<br>Konditionen</div>
|
||||
</header>
|
||||
|
||||
<div class="hero">
|
||||
<div class="hero-note">Bildplatzhalter: GOYA Detail/2. Ansicht</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom">
|
||||
<div class="info">
|
||||
<div>
|
||||
<div class="eyebrow">GOYA | Konditionen</div>
|
||||
<h2 class="title" style="font-size:52px;">Details auf einen Blick</h2>
|
||||
<ul class="bullets">
|
||||
<li><span class="dot"></span><span>Eingelagertes Einzelstück</span></li>
|
||||
<li><span class="dot"></span><span>Abholung: Lager Rheda-Wiedenbrück</span></li>
|
||||
<li><span class="dot"></span><span>Lieferung/Montage optional</span></li>
|
||||
<li><span class="dot"></span><span>Preis gilt ohne Lieferung/Montage</span></li>
|
||||
<li><span class="dot"></span><span>Deckel: Weiß matt (erneuert)</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="price-row">
|
||||
<div style="font-size:26px;color:var(--muted);">
|
||||
Details & Reservierung
|
||||
</div>
|
||||
<div style="font-size:26px;font-weight:650;">
|
||||
QR scannen
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="qr">
|
||||
<div>
|
||||
<div class="cta">Details: QR scannen</div>
|
||||
<div class="contact">Reservierung & Kontakt</div>
|
||||
</div>
|
||||
<div class="code">
|
||||
<div class="fake-qr"></div>
|
||||
</div>
|
||||
<div class="contact">Zwischenverkauf vorbehalten</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SLIDE 3: TANDO – Variante C -->
|
||||
<section class="slide" data-duration="10000" id="slide-3">
|
||||
<header class="header">
|
||||
<div class="brand">CABINET</div>
|
||||
<div class="tagline">Nur 1× im Store<br>Sofort</div>
|
||||
</header>
|
||||
|
||||
<div class="hero">
|
||||
<div class="hero-note">Bildplatzhalter: TANDO Spiegel (Foto im Laden)</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom">
|
||||
<div class="info">
|
||||
<div>
|
||||
<div class="eyebrow">Nur 1× im Store</div>
|
||||
<h2 class="title">TANDO Spiegel</h2>
|
||||
<p class="subline">Ansehen & mitnehmen – heute</p>
|
||||
</div>
|
||||
|
||||
<div class="price-row">
|
||||
<p class="price">199 €</p>
|
||||
<div class="uvp">
|
||||
brutto<br>
|
||||
Jetzt sichern
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="qr">
|
||||
<div>
|
||||
<div class="cta">Jetzt sichern</div>
|
||||
<div class="contact">QR scannen oder Team ansprechen</div>
|
||||
</div>
|
||||
<div class="code">
|
||||
<div class="fake-qr"></div>
|
||||
</div>
|
||||
<div class="contact">WhatsApp: … · Tel.: …</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="progress" aria-hidden="true">
|
||||
<div class="bar" id="bar"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Simple Slide-Rotation
|
||||
const slides = Array.from(document.querySelectorAll(".slide"));
|
||||
const bar = document.getElementById("bar");
|
||||
|
||||
let idx = 0;
|
||||
let timer = null;
|
||||
|
||||
function show(i){
|
||||
slides.forEach((s, n) => s.classList.toggle("active", n === i));
|
||||
const dur = Number(slides[i].dataset.duration || 9000);
|
||||
|
||||
// progress animation
|
||||
bar.style.transition = "none";
|
||||
bar.style.width = "0%";
|
||||
// force reflow
|
||||
void bar.offsetWidth;
|
||||
bar.style.transition = `width ${dur}ms linear`;
|
||||
bar.style.width = "100%";
|
||||
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
idx = (idx + 1) % slides.length;
|
||||
show(idx);
|
||||
}, dur);
|
||||
}
|
||||
|
||||
show(idx);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
149
public/_cabinet/offer.md
Normal file
|
|
@ -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).
|
||||
126
public/_cabinet/offers/config.json
Normal file
|
|
@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
408
public/_cabinet/offers/player.html
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CABINET – Angebote Display</title>
|
||||
<style>
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
/* Container für 9:16 Aspect Ratio */
|
||||
.player-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.player-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 1080px;
|
||||
max-height: 1920px;
|
||||
aspect-ratio: 9 / 16;
|
||||
position: relative;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-aspect-ratio: 9/16) {
|
||||
.player-frame {
|
||||
width: auto;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-aspect-ratio: 9/16) {
|
||||
.player-frame {
|
||||
width: 100vw;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* iFrames für Slides */
|
||||
.slide-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.6s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slide-frame.active {
|
||||
opacity: 1;
|
||||
z-index: 2;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.slide-frame.preload {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Progress Indicator */
|
||||
.progress-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: #009FE3;
|
||||
transition: width linear;
|
||||
}
|
||||
|
||||
/* Slide Indicators */
|
||||
.slide-indicators {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 100;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.indicator.active {
|
||||
background: #009FE3;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.loading-overlay.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-family: 'IBM Plex Sans', sans-serif;
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Debug Info (optional, ausblendbar) */
|
||||
.debug-info {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(0,0,0,0.7);
|
||||
color: #fff;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.debug-info.visible {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="player-container">
|
||||
<div class="player-frame" id="player">
|
||||
<!-- Loading Overlay -->
|
||||
<div class="loading-overlay" id="loading">
|
||||
<span class="loading-text">Lädt Angebote...</span>
|
||||
</div>
|
||||
|
||||
<!-- Slides werden hier dynamisch eingefügt -->
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="progress"></div>
|
||||
</div>
|
||||
|
||||
<!-- Slide Indicators (optional) -->
|
||||
<div class="slide-indicators" id="indicators"></div>
|
||||
|
||||
<!-- Debug Info -->
|
||||
<div class="debug-info" id="debug">
|
||||
Slide: <span id="debug-slide">0</span> / <span id="debug-total">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* CABINET Offer Slide Player
|
||||
* Lädt und rotiert durch Slides basierend auf config.json
|
||||
*/
|
||||
|
||||
const CONFIG_URL = 'config.json';
|
||||
const DEBUG_MODE = false; // Auf true setzen für Debug-Infos
|
||||
|
||||
class SlidePlayer {
|
||||
constructor() {
|
||||
this.config = null;
|
||||
this.slides = [];
|
||||
this.currentIndex = 0;
|
||||
this.frames = [];
|
||||
this.timer = null;
|
||||
this.isPlaying = false;
|
||||
|
||||
// DOM Elements
|
||||
this.player = document.getElementById('player');
|
||||
this.loading = document.getElementById('loading');
|
||||
this.progress = document.getElementById('progress');
|
||||
this.indicators = document.getElementById('indicators');
|
||||
this.debug = document.getElementById('debug');
|
||||
this.debugSlide = document.getElementById('debug-slide');
|
||||
this.debugTotal = document.getElementById('debug-total');
|
||||
|
||||
if (DEBUG_MODE) {
|
||||
this.debug.classList.add('visible');
|
||||
}
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
console.log('[Player] Initializing...');
|
||||
|
||||
// Lade Konfiguration
|
||||
await this.loadConfig();
|
||||
|
||||
// Erstelle iFrames für alle Slides
|
||||
this.createFrames();
|
||||
|
||||
// Erstelle Indikatoren
|
||||
this.createIndicators();
|
||||
|
||||
// Warte bis erster Slide geladen ist
|
||||
await this.preloadSlide(0);
|
||||
|
||||
// Verstecke Loading
|
||||
this.loading.classList.add('hidden');
|
||||
|
||||
// Starte Rotation
|
||||
this.play();
|
||||
|
||||
console.log('[Player] Ready!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Player] Init error:', error);
|
||||
this.showError('Fehler beim Laden der Angebote');
|
||||
}
|
||||
}
|
||||
|
||||
async loadConfig() {
|
||||
const response = await fetch(CONFIG_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Config load failed: ${response.status}`);
|
||||
}
|
||||
this.config = await response.json();
|
||||
this.slides = this.config.slides || [];
|
||||
|
||||
console.log(`[Player] Loaded ${this.slides.length} slides`);
|
||||
this.debugTotal.textContent = this.slides.length;
|
||||
}
|
||||
|
||||
createFrames() {
|
||||
this.slides.forEach((slide, index) => {
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.className = 'slide-frame';
|
||||
iframe.id = `frame-${index}`;
|
||||
iframe.setAttribute('loading', 'lazy');
|
||||
iframe.setAttribute('data-src', slide.file);
|
||||
|
||||
// Füge vor Progress Bar ein
|
||||
this.player.insertBefore(iframe, this.player.querySelector('.progress-bar'));
|
||||
this.frames.push(iframe);
|
||||
});
|
||||
}
|
||||
|
||||
createIndicators() {
|
||||
this.slides.forEach((_, index) => {
|
||||
const dot = document.createElement('div');
|
||||
dot.className = 'indicator';
|
||||
dot.dataset.index = index;
|
||||
this.indicators.appendChild(dot);
|
||||
});
|
||||
}
|
||||
|
||||
async preloadSlide(index) {
|
||||
const frame = this.frames[index];
|
||||
if (!frame) return;
|
||||
|
||||
// Wenn noch nicht geladen, lade jetzt
|
||||
if (!frame.src) {
|
||||
const src = frame.getAttribute('data-src');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
frame.onload = () => {
|
||||
console.log(`[Player] Slide ${index} loaded`);
|
||||
resolve();
|
||||
};
|
||||
frame.onerror = () => {
|
||||
console.error(`[Player] Slide ${index} failed to load`);
|
||||
resolve(); // Trotzdem weiter
|
||||
};
|
||||
frame.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
showSlide(index) {
|
||||
// Deaktiviere alle Frames
|
||||
this.frames.forEach((frame, i) => {
|
||||
frame.classList.remove('active', 'preload');
|
||||
});
|
||||
|
||||
// Aktiviere aktuellen Frame
|
||||
const currentFrame = this.frames[index];
|
||||
if (currentFrame) {
|
||||
currentFrame.classList.add('active');
|
||||
}
|
||||
|
||||
// Update Indikatoren
|
||||
const dots = this.indicators.querySelectorAll('.indicator');
|
||||
dots.forEach((dot, i) => {
|
||||
dot.classList.toggle('active', i === index);
|
||||
});
|
||||
|
||||
// Update Debug
|
||||
this.debugSlide.textContent = index + 1;
|
||||
|
||||
// Preload nächsten Slide
|
||||
const nextIndex = (index + 1) % this.slides.length;
|
||||
this.preloadSlide(nextIndex);
|
||||
|
||||
console.log(`[Player] Showing slide ${index}: ${this.slides[index].id}`);
|
||||
}
|
||||
|
||||
startProgress(duration) {
|
||||
// Reset progress
|
||||
this.progress.style.transition = 'none';
|
||||
this.progress.style.width = '0%';
|
||||
|
||||
// Force reflow
|
||||
void this.progress.offsetWidth;
|
||||
|
||||
// Animate
|
||||
this.progress.style.transition = `width ${duration}ms linear`;
|
||||
this.progress.style.width = '100%';
|
||||
}
|
||||
|
||||
play() {
|
||||
if (this.isPlaying) return;
|
||||
this.isPlaying = true;
|
||||
|
||||
this.showCurrentSlide();
|
||||
}
|
||||
|
||||
showCurrentSlide() {
|
||||
const slide = this.slides[this.currentIndex];
|
||||
const duration = slide.duration || 8000;
|
||||
|
||||
// Zeige Slide
|
||||
this.showSlide(this.currentIndex);
|
||||
|
||||
// Starte Progress
|
||||
this.startProgress(duration);
|
||||
|
||||
// Timer für nächsten Slide
|
||||
clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => {
|
||||
this.nextSlide();
|
||||
}, duration);
|
||||
}
|
||||
|
||||
nextSlide() {
|
||||
this.currentIndex = (this.currentIndex + 1) % this.slides.length;
|
||||
this.showCurrentSlide();
|
||||
}
|
||||
|
||||
prevSlide() {
|
||||
this.currentIndex = (this.currentIndex - 1 + this.slides.length) % this.slides.length;
|
||||
this.showCurrentSlide();
|
||||
}
|
||||
|
||||
pause() {
|
||||
this.isPlaying = false;
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
this.loading.innerHTML = `<span class="loading-text" style="color: #c00;">${message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Player
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const player = new SlidePlayer();
|
||||
player.init();
|
||||
|
||||
// Optional: Keyboard Controls (für Testing)
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowRight') player.nextSlide();
|
||||
if (e.key === 'ArrowLeft') player.prevSlide();
|
||||
if (e.key === ' ') player.isPlaying ? player.pause() : player.play();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
493
public/_cabinet/offers/shared-styles.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
100
public/_cabinet/offers/slide-0-intro.html
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CABINET – Intro</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="./shared-styles.css">
|
||||
<style>
|
||||
/* Slide-spezifische Anpassungen */
|
||||
.hero {
|
||||
background:
|
||||
radial-gradient(ellipse at 30% 40%, rgba(0, 159, 227, 0.05), transparent 60%),
|
||||
linear-gradient(165deg, #f8f8f8, #ffffff);
|
||||
}
|
||||
.hero.has-image {
|
||||
background: url('../assets/cabinet-intro.jpg') center/cover no-repeat;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 500;
|
||||
padding: 16px 28px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="screen">
|
||||
<article class="slide" data-duration="8000">
|
||||
|
||||
<!-- HEADER -->
|
||||
<header class="header">
|
||||
<div class="brand">
|
||||
<img src="./logo-cabinet-300.png" alt="CABINET" class="brand-logo">
|
||||
<span class="brand-text">Bielefeld</span>
|
||||
</div>
|
||||
<div class="tagline">
|
||||
Planung • Beratung<br>
|
||||
Lieferung & Montage
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- HERO -->
|
||||
<section class="hero has-image">
|
||||
<span class="hero-badge">Ausstellungsdeals – solange verfügbar</span>
|
||||
</section>
|
||||
|
||||
<!-- BOTTOM: Info + QR -->
|
||||
<section class="bottom">
|
||||
<div class="info">
|
||||
<div class="info-content">
|
||||
<p class="eyebrow">Heute im Fokus</p>
|
||||
<h1 class="title large">Kuratiert.<br>Hochwertig.<br>Sofort.</h1>
|
||||
</div>
|
||||
|
||||
<div class="info-footer">
|
||||
<span class="footer-text disclaimer">Zwischenverkauf vorbehalten</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="qr-box">
|
||||
<div class="qr-header">
|
||||
<p class="qr-title">Kontakt</p>
|
||||
<p class="qr-subtitle">QR scannen</p>
|
||||
</div>
|
||||
<div class="qr-code-wrapper">
|
||||
<img id="qr-code" src="" alt="QR Code">
|
||||
</div>
|
||||
<p class="qr-contact">0521 98620100<br>Tel. oder WhatsApp</p>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// QR-Code Konfiguration
|
||||
const QR_URL = 'https://cabinet-bielefeld.de'; // Ziel-URL anpassen
|
||||
|
||||
// QR-Code generieren
|
||||
function generateQR(targetUrl) {
|
||||
const size = '300x300';
|
||||
const color = '000000';
|
||||
const bg = 'ffffff';
|
||||
const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${size}&color=${color}&bgcolor=${bg}&margin=8&data=${encodeURIComponent(targetUrl)}`;
|
||||
|
||||
const qrImg = document.getElementById('qr-code');
|
||||
if (qrImg) {
|
||||
qrImg.src = qrApiUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Bei Seitenaufruf QR generieren
|
||||
document.addEventListener('DOMContentLoaded', () => generateQR(QR_URL));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
105
public/_cabinet/offers/slide-1-goya-hero.html
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CABINET – GOYA Sideboard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="./shared-styles.css">
|
||||
<style>
|
||||
/* Slide-spezifische Anpassungen */
|
||||
.hero {
|
||||
background:
|
||||
radial-gradient(ellipse at 30% 40%, rgba(0, 159, 227, 0.05), transparent 60%),
|
||||
linear-gradient(165deg, #f8f8f8, #ffffff);
|
||||
}
|
||||
.hero.has-image {
|
||||
background: url('../assets/goya1.jpg') center/cover no-repeat;
|
||||
}
|
||||
|
||||
.price {
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.uvp-strike {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
padding: 16px 28px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="screen">
|
||||
<article class="slide" data-duration="10000">
|
||||
|
||||
<!-- HEADER -->
|
||||
<header class="header">
|
||||
<div class="brand">
|
||||
<img src="./logo-cabinet-300.png" alt="CABINET" class="brand-logo">
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
<!-- HERO -->
|
||||
<section class="hero has-image">
|
||||
<span class="hero-badge">Einzelstück</span>
|
||||
</section>
|
||||
|
||||
<!-- BOTTOM: Info + QR -->
|
||||
<section class="bottom">
|
||||
<div class="info">
|
||||
<div class="info-content">
|
||||
<p class="eyebrow">Hersteller: Sudbrock</p>
|
||||
<h1 class="title large">GOYA Sideboard</h1>
|
||||
</div>
|
||||
|
||||
<div class="price-block">
|
||||
<div class="price-row">
|
||||
<span class="price">489 €</span>
|
||||
<div class="price-note">
|
||||
statt 4.744 €
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="qr-box">
|
||||
<div class="qr-header">
|
||||
<p class="qr-title">Reservieren</p>
|
||||
<p class="qr-subtitle">QR scannen</p>
|
||||
</div>
|
||||
<div class="qr-code-wrapper">
|
||||
<img id="qr-code" src="" alt="QR Code">
|
||||
</div>
|
||||
<p class="qr-contact">0521 98620100<br>Tel. oder WhatsApp</p>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// QR-Code Konfiguration – GOYA Produkt-/Reservierungsseite
|
||||
const QR_URL = 'https://cabinet-bielefeld.de';
|
||||
|
||||
function generateQR(targetUrl) {
|
||||
const size = '300x300';
|
||||
const color = '000000';
|
||||
const bg = 'ffffff';
|
||||
const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${size}&color=${color}&bgcolor=${bg}&margin=8&data=${encodeURIComponent(targetUrl)}`;
|
||||
|
||||
const qrImg = document.getElementById('qr-code');
|
||||
if (qrImg) qrImg.src = qrApiUrl;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => generateQR(QR_URL));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
135
public/_cabinet/offers/slide-2-goya-details.html
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CABINET – GOYA Konditionen</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="./shared-styles.css">
|
||||
<style>
|
||||
/* Slide-spezifische Anpassungen */
|
||||
.hero {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(245,245,245,0.9) 0%, rgba(250,250,250,0.95) 100%);
|
||||
}
|
||||
.hero.has-image {
|
||||
background: url('../assets/goya2.jpg') center/cover no-repeat;
|
||||
}
|
||||
.bullets {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.bullets li {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.cta-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.cta-text {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.cta-action {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--fg-strong);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
padding: 16px 28px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="screen">
|
||||
<article class="slide" data-duration="12000">
|
||||
|
||||
<!-- HEADER -->
|
||||
<header class="header">
|
||||
<div class="brand">
|
||||
<img src="./logo-cabinet-300.png" alt="CABINET" class="brand-logo">
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
<!-- HERO -->
|
||||
<section class="hero has-image">
|
||||
<span class="hero-badge">Einzelstück</span>
|
||||
</section>
|
||||
|
||||
<!-- BOTTOM: Info + QR -->
|
||||
<section class="bottom">
|
||||
<div class="info">
|
||||
<div class="info-content">
|
||||
<p class="eyebrow">Auf einen Blick<</p>
|
||||
<h1 class="title medium">GOYA Sideboard</h1>
|
||||
|
||||
<ul class="bullets">
|
||||
<li>
|
||||
<span class="dot"></span>
|
||||
<span>Eingelagertes Einzelstück</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="dot"></span>
|
||||
<span>Abholung in Rheda-Wiedenbrück</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="dot"></span>
|
||||
<span>Lieferung optional</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="dot"></span>
|
||||
<span>Deckel weiß matt (neu)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<aside class="qr-box">
|
||||
<div class="qr-header">
|
||||
<p class="qr-title">Reservieren</p>
|
||||
<p class="qr-subtitle">QR scannen</p>
|
||||
</div>
|
||||
<div class="qr-code-wrapper">
|
||||
<img id="qr-code" src="" alt="QR Code">
|
||||
</div>
|
||||
<p class="qr-contact">0521 98620100<br>Tel. oder WhatsApp</p>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// QR-Code Konfiguration – GOYA Details (gleiche URL wie Slide 1)
|
||||
const QR_URL = 'https://cabinet-bielefeld.de';
|
||||
|
||||
function generateQR(targetUrl) {
|
||||
const size = '300x300';
|
||||
const color = '000000';
|
||||
const bg = 'ffffff';
|
||||
const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${size}&color=${color}&bgcolor=${bg}&margin=8&data=${encodeURIComponent(targetUrl)}`;
|
||||
|
||||
const qrImg = document.getElementById('qr-code');
|
||||
if (qrImg) qrImg.src = qrApiUrl;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => generateQR(QR_URL));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
109
public/_cabinet/offers/slide-3-tando.html
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CABINET – TANDO Spiegel</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="./shared-styles.css">
|
||||
<style>
|
||||
/* Slide-spezifische Anpassungen */
|
||||
.hero {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(245,245,245,0.9) 0%, rgba(250,250,250,0.95) 100%);
|
||||
}
|
||||
|
||||
.hero.has-image {
|
||||
background: url('../assets/tango.jpg') center/cover no-repeat;
|
||||
}
|
||||
.impulse-tag {
|
||||
display: inline-block;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
padding: 10px 18px;
|
||||
border-radius: 10px;
|
||||
margin-top: 12px;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
padding: 16px 28px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="screen">
|
||||
<article class="slide" data-duration="10000">
|
||||
|
||||
<!-- HEADER -->
|
||||
<header class="header">
|
||||
<div class="brand">
|
||||
<img src="./logo-cabinet-300.png" alt="CABINET" class="brand-logo">
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
<!-- HERO -->
|
||||
<section class="hero has-image">
|
||||
<span class="hero-badge">Ausstellungsstück</span>
|
||||
</section>
|
||||
|
||||
<!-- BOTTOM: Info + QR -->
|
||||
<section class="bottom">
|
||||
<div class="info">
|
||||
<div class="info-content">
|
||||
<p class="eyebrow">Nur 1×</p>
|
||||
<h1 class="title large">TANDO Spiegel</h1>
|
||||
<p class="subline">Heute mitnehmen</p>
|
||||
</div>
|
||||
|
||||
<div class="price-block">
|
||||
<div class="price-row">
|
||||
<span class="price">199 €</span>
|
||||
<div class="price-note">
|
||||
<span class="impulse-tag">Im Store verfügbar</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="qr-box">
|
||||
<div class="qr-header">
|
||||
<p class="qr-title">Sichern</p>
|
||||
<p class="qr-subtitle">QR scannen</p>
|
||||
</div>
|
||||
<div class="qr-code-wrapper">
|
||||
<img id="qr-code" src="" alt="QR Code">
|
||||
</div>
|
||||
<p class="qr-contact">0521 98620100<br>Tel. oder WhatsApp</p>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// QR-Code Konfiguration – TANDO Produkt
|
||||
const QR_URL = 'https://cabinet-bielefeld.de';
|
||||
|
||||
function generateQR(targetUrl) {
|
||||
const size = '300x300';
|
||||
const color = '000000';
|
||||
const bg = 'ffffff';
|
||||
const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${size}&color=${color}&bgcolor=${bg}&margin=8&data=${encodeURIComponent(targetUrl)}`;
|
||||
|
||||
const qrImg = document.getElementById('qr-code');
|
||||
if (qrImg) qrImg.src = qrApiUrl;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => generateQR(QR_URL));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ProductStatus;
|
||||
use App\Models\Partner;
|
||||
use App\Models\Product;
|
||||
use App\Models\User;
|
||||
use App\Models\RegistrationCode;
|
||||
use App\Helpers\ThemeHelper;
|
||||
|
|
@ -189,8 +191,32 @@ new class extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
// Top-Angebote laden (Platzhalter - später aus Product-Tabelle)
|
||||
$this->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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,136 +1,204 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
||||
<head>
|
||||
@include('partials.head')
|
||||
</head>
|
||||
<body class="min-h-screen bg-zinc-50 dark:bg-zinc-800 antialiased">
|
||||
<flux:sidebar sticky stashable class="border-e border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<flux:sidebar.toggle class="lg:hidden" icon="x-mark" />
|
||||
<a href="{{ config('domains.domain_main_url') }}" class="me-5 flex items-center space-x-2 rtl:space-x-reverse">
|
||||
<x-app-logo />
|
||||
</a>
|
||||
|
||||
@if(session('impersonate_from'))
|
||||
<div class="mb-4 rounded-lg bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 p-3">
|
||||
<div class="flex items-start gap-3 mb-2">
|
||||
<flux:icon.exclamation-triangle class="h-5 w-5 text-orange-600 dark:text-orange-400 flex-shrink-0 mt-0.5" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-semibold text-orange-900 dark:text-orange-100">
|
||||
{{ __('Als :name eingeloggt', ['name' => Auth::user()->name]) }}
|
||||
</div>
|
||||
<div class="text-xs text-orange-700 dark:text-orange-300 mt-0.5">
|
||||
{{ __('Sie sind temporär als dieser User angemeldet') }}
|
||||
</div>
|
||||
<head>
|
||||
@include('partials.head')
|
||||
</head>
|
||||
|
||||
<body class="min-h-screen bg-zinc-50 dark:bg-zinc-800 antialiased">
|
||||
<flux:sidebar sticky stashable class="border-e border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<flux:sidebar.toggle class="lg:hidden" icon="x-mark" />
|
||||
<a href="{{ config('domains.domain_main_url') }}" class="me-5 flex items-center space-x-2 rtl:space-x-reverse">
|
||||
<x-app-logo />
|
||||
</a>
|
||||
|
||||
@if (session('impersonate_from'))
|
||||
<div
|
||||
class="mb-4 rounded-lg bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 p-3">
|
||||
<div class="flex items-start gap-3 mb-2">
|
||||
<flux:icon.exclamation-triangle
|
||||
class="h-5 w-5 text-orange-600 dark:text-orange-400 flex-shrink-0 mt-0.5" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-semibold text-orange-900 dark:text-orange-100">
|
||||
{{ __('Als :name eingeloggt', ['name' => Auth::user()->name]) }}
|
||||
</div>
|
||||
<div class="text-xs text-orange-700 dark:text-orange-300 mt-0.5">
|
||||
{{ __('Sie sind temporär als dieser User angemeldet') }}
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('admin.impersonate.leave') }}" class="w-full">
|
||||
@csrf
|
||||
<flux:button type="submit" variant="primary" size="sm" icon="arrow-left-start-on-rectangle" class="w-full">
|
||||
{{ __('Zurück zum Admin') }}
|
||||
</flux:button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
<form method="POST" action="{{ route('admin.impersonate.leave') }}" class="w-full">
|
||||
@csrf
|
||||
<flux:button type="submit" variant="primary" size="sm" icon="arrow-left-start-on-rectangle"
|
||||
class="w-full">
|
||||
{{ __('Zurück zum Admin') }}
|
||||
</flux:button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<flux:navlist variant="outline">
|
||||
@hasrole('Customer')
|
||||
<flux:navlist variant="outline">
|
||||
@hasrole('Customer')
|
||||
<flux:navlist.group :heading="__('Customer')" class="grid mb-4">
|
||||
<flux:navlist.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')"
|
||||
wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
<flux:navlist.group :heading="__('Daten')" class="grid mb-4">
|
||||
<flux:navlist.item icon="building-office" :href="route('partner.my-data')" :current="request()->routeIs('partner.my-data')" wire:navigate>{{ __('Meine Daten') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="building-office" :href="route('partner.my-data')"
|
||||
:current="request()->routeIs('partner.my-data')" wire:navigate>{{ __('Meine Daten') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
@endhasrole
|
||||
@hasrole('Retailer')
|
||||
@endhasrole
|
||||
@hasrole('Retailer')
|
||||
<flux:navlist.group :heading="__('Retailer')" class="grid mb-4">
|
||||
<flux:navlist.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')"
|
||||
wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
<flux:navlist.group :heading="__('Produkte')" class="grid mb-4">
|
||||
<flux:navlist.item icon="cube" :href="route('products.index')" :current="request()->routeIs('products.index')" wire:navigate>{{ __('Produktliste') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="plus-circle" :href="route('products.create')" :current="request()->routeIs('products.create')" wire:navigate>{{ __('Neues Produkt') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="cube" :href="route('products.index')"
|
||||
:current="request()->routeIs('products.index')" wire:navigate>{{ __('Produktliste') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="plus-circle" :href="route('products.create.teaser')"
|
||||
:current="request()->routeIs('products.create.teaser')" wire:navigate>
|
||||
{{ __('Neues Teaser-Produkt') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="plus-circle" :href="route('products.create.standard')"
|
||||
:current="request()->routeIs('products.create.standard')" wire:navigate>
|
||||
{{ __('Neues Standard-Produkt') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
<flux:navlist.group :heading="__('Daten')" class="grid mb-4">
|
||||
<flux:navlist.item icon="building-office" :href="route('partner.my-data')" :current="request()->routeIs('partner.my-data')" wire:navigate>{{ __('Meine Daten') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="building-office" :href="route('partner.my-data')"
|
||||
:current="request()->routeIs('partner.my-data')" wire:navigate>{{ __('Meine Daten') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
@endhasrole
|
||||
@hasrole('Manufacturer')
|
||||
@endhasrole
|
||||
@hasrole('Manufacturer')
|
||||
<flux:navlist.group :heading="__('Manufacturer')" class="grid mb-4">
|
||||
<flux:navlist.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="home" :href="route('dashboard')"
|
||||
:current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
<flux:navlist.group :heading="__('Produkte')" class="grid mb-4">
|
||||
<flux:navlist.item icon="cube" :href="route('products.index')" :current="request()->routeIs('products.index')" wire:navigate>{{ __('Produktliste') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="plus-circle" :href="route('products.create')" :current="request()->routeIs('products.create')" wire:navigate>{{ __('Neues Produkt') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
<flux:navlist.group :heading="__('Daten')" class="grid mb-4">
|
||||
<flux:navlist.item icon="building-office" :href="route('partner.my-data')" :current="request()->routeIs('partner.my-data')" wire:navigate>{{ __('Meine Daten') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
@endhasrole
|
||||
@hasrole('Broker')
|
||||
<flux:navlist.group :heading="__('Broker')" class="grid mb-4">
|
||||
<flux:navlist.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
<flux:navlist.group :heading="__('Daten')" class="grid mb-4">
|
||||
<flux:navlist.item icon="building-office" :href="route('partner.my-data')" :current="request()->routeIs('partner.my-data')" wire:navigate>{{ __('Meine Daten') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
@endhasrole
|
||||
@hasrole('Admin|Super-Admin')
|
||||
<flux:navlist.group :heading="__('Info')" class="grid mb-4">
|
||||
<flux:navlist.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
@endhasrole
|
||||
<flux:navlist.item icon="cube" :href="route('products.index')"
|
||||
:current="request()->routeIs('products.index')" wire:navigate>{{ __('Produktliste') }}
|
||||
</flux:navlist.item>
|
||||
|
||||
@hasrole('Super-Admin|Admin')
|
||||
<flux:navlist.item icon="plus-circle" :href="route('products.create.teaser')"
|
||||
:current="request()->routeIs('products.create.teaser')" wire:navigate>
|
||||
{{ __('Neues Teaser-Produkt') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="plus-circle" :href="route('products.create.standard')"
|
||||
:current="request()->routeIs('products.create.standard')" wire:navigate>
|
||||
{{ __('Neues Standard-Produkt') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
<flux:navlist.group :heading="__('Daten')" class="grid mb-4">
|
||||
<flux:navlist.item icon="building-office" :href="route('partner.my-data')"
|
||||
:current="request()->routeIs('partner.my-data')" wire:navigate>{{ __('Meine Daten') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
@endhasrole
|
||||
@hasrole('Broker')
|
||||
<flux:navlist.group :heading="__('Broker')" class="grid mb-4">
|
||||
<flux:navlist.item icon="home" :href="route('dashboard')"
|
||||
:current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
<flux:navlist.group :heading="__('Daten')" class="grid mb-4">
|
||||
<flux:navlist.item icon="building-office" :href="route('partner.my-data')"
|
||||
:current="request()->routeIs('partner.my-data')" wire:navigate>{{ __('Meine Daten') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
@endhasrole
|
||||
@hasrole('Admin|Super-Admin')
|
||||
<flux:navlist.group :heading="__('Info')" class="grid mb-4">
|
||||
<flux:navlist.item icon="home" :href="route('dashboard')"
|
||||
:current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
@endhasrole
|
||||
|
||||
@hasrole('Super-Admin|Admin')
|
||||
<flux:navlist.group :heading="__('Admin')" class="grid mb-4">
|
||||
<flux:navlist.group expandable :expanded="request()->routeIs(['admin.users', 'admin.users.permissions', 'admin.partners.invite', 'admin.partners.registration-codes'])" heading="Partner" class="grid">
|
||||
<flux:navlist.item icon="user-group" :href="route('admin.users')" :current="request()->routeIs('admin.users')" wire:navigate>{{ __('Benutzer') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="envelope" :href="route('admin.partners.invite')" :current="request()->routeIs('admin.partners.invite')" wire:navigate>{{ __('Partner einladen') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="key" :href="route('admin.partners.registration-codes')" :current="request()->routeIs('admin.partners.registration-codes')" wire:navigate>{{ __('Registrierungscodes') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="shield-check" :href="route('admin.users.permissions')" :current="request()->routeIs('admin.users.permissions')" wire:navigate>{{ __('Permissions') }}</flux:navlist.item>
|
||||
<flux:navlist.group expandable
|
||||
:expanded="request()->routeIs(['admin.users', 'admin.users.permissions', 'admin.partners.invite', 'admin.partners.registration-codes'])"
|
||||
heading="Partner" class="grid">
|
||||
<flux:navlist.item icon="user-group" :href="route('admin.users')"
|
||||
:current="request()->routeIs('admin.users')" wire:navigate>{{ __('Benutzer') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="envelope" :href="route('admin.partners.invite')"
|
||||
:current="request()->routeIs('admin.partners.invite')" wire:navigate>
|
||||
{{ __('Partner einladen') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="key" :href="route('admin.partners.registration-codes')"
|
||||
:current="request()->routeIs('admin.partners.registration-codes')" wire:navigate>
|
||||
{{ __('Registrierungscodes') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="shield-check" :href="route('admin.users.permissions')"
|
||||
:current="request()->routeIs('admin.users.permissions')" wire:navigate>{{ __('Permissions') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
<flux:navlist.group expandable :expanded="request()->routeIs('testing')" heading="Testing" class="hidden lg:grid mt-2">
|
||||
<flux:navlist.item icon="user-group" :href="route('testing.landing')" :current="request()->routeIs('testing.landing')" wire:navigate>{{ __('Register') }}</flux:navlist.item>
|
||||
<flux:navlist.group expandable :expanded="request()->routeIs('testing')" heading="Testing"
|
||||
class="hidden lg:grid mt-2">
|
||||
<flux:navlist.item icon="user-group" :href="route('testing.landing')"
|
||||
:current="request()->routeIs('testing.landing')" wire:navigate>{{ __('Register') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
</flux:navlist.group>
|
||||
|
||||
<flux:navlist.group :heading="__('Produkte')" class="grid mb-4">
|
||||
<flux:navlist.item icon="clipboard-document-check" :href="route('admin.products.index')"
|
||||
:current="request()->routeIs('admin.products.*')" wire:navigate>
|
||||
{{ __('Produkt-Verwaltung') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="cube" :href="route('products.index')"
|
||||
:current="request()->routeIs('products.index')" wire:navigate>
|
||||
{{ __('Alle Produkte') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
|
||||
<flux:navlist.group :heading="__('Regionen')" class="grid mb-4">
|
||||
<flux:navlist.item icon="map" :href="route('admin.hubs.index')" :current="request()->routeIs('admin.hubs.*')" wire:navigate>{{ __('Hub-Verwaltung') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="map" :href="route('admin.hubs.index')"
|
||||
:current="request()->routeIs('admin.hubs.*')" wire:navigate>{{ __('Hub-Verwaltung') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
|
||||
<flux:navlist.group :heading="__('CMS')" class="grid mb-4">
|
||||
<flux:navlist.item icon="home" :href="route('admin.cms.cabinet')" :current="request()->routeIs('admin.cms.cabinet')" wire:navigate>{{ __('Cabinet') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="home" :href="route('admin.cms.cabinet')"
|
||||
:current="request()->routeIs('admin.cms.cabinet')" wire:navigate>{{ __('Cabinet') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
|
||||
|
||||
@endhasrole
|
||||
|
||||
@hasrole('Super-Admin')
|
||||
<flux:navlist.group :heading="__('Superadmin')" class="grid mb-4">
|
||||
<flux:navlist.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
@endhasrole
|
||||
</flux:navlist>
|
||||
|
||||
<flux:spacer />
|
||||
|
||||
@hasrole('Super-Admin|Admin')
|
||||
<flux:navlist.group :heading="__('Dokumentation')" class="grid mb-4">
|
||||
<flux:navlist.item icon="document-text" :href="route('admin.documentation')" :current="request()->routeIs('admin.documentation')" wire:navigate>{{ __('Projekt-Dokumentation') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
@endhasrole
|
||||
|
||||
@hasrole('Super-Admin')
|
||||
<flux:navlist.group :heading="__('Superadmin')" class="grid mb-4">
|
||||
<flux:navlist.item icon="home" :href="route('dashboard')"
|
||||
:current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
@endhasrole
|
||||
</flux:navlist>
|
||||
|
||||
<flux:spacer />
|
||||
|
||||
@hasrole('Super-Admin|Admin')
|
||||
<flux:navlist.group :heading="__('Dokumentation')" class="grid mb-4">
|
||||
<flux:navlist.item icon="document-text" :href="route('admin.documentation')"
|
||||
:current="request()->routeIs('admin.documentation')" wire:navigate>{{ __('Projekt-Dokumentation') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
@endhasrole
|
||||
@hasrole('Super-Admin')
|
||||
<flux:navlist variant="outline">
|
||||
<flux:navlist.group :heading="__('Resources')">
|
||||
<flux:navlist.item icon="pencil" href="https://tailwindcss.com/docs/installation/using-vite" target="_blank">
|
||||
{{ __('Tailwind CSS') }}
|
||||
<flux:navlist.item icon="pencil" href="https://tailwindcss.com/docs/installation/using-vite"
|
||||
target="_blank">
|
||||
{{ __('Tailwind CSS') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="shield-check" href="https://heroicons.com" target="_blank">
|
||||
{{ __('Hero Icons') }}
|
||||
{{ __('Hero Icons') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="bolt" href="https://fluxui.dev/docs/installation" target="_blank">
|
||||
{{ __('Flux UI') }}
|
||||
{{ __('Flux UI') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="folder-git-2" href="https://github.com/laravel/livewire-starter-kit" target="_blank">
|
||||
{{ __('Repository') }}
|
||||
<flux:navlist.item icon="folder-git-2" href="https://github.com/laravel/livewire-starter-kit"
|
||||
target="_blank">
|
||||
{{ __('Repository') }}
|
||||
</flux:navlist.item>
|
||||
|
||||
<flux:navlist.item icon="book-open-text" href="https://laravel.com/docs/starter-kits" target="_blank">
|
||||
|
|
@ -138,159 +206,143 @@
|
|||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
</flux:navlist>
|
||||
@endhasrole
|
||||
@endhasrole
|
||||
|
||||
<!-- Desktop User Menu -->
|
||||
<flux:dropdown position="bottom" align="start">
|
||||
<flux:profile
|
||||
:name="auth()->user()->name"
|
||||
:initials="auth()->user()->initials()"
|
||||
icon-trailing="chevrons-up-down"
|
||||
/>
|
||||
<!-- Desktop User Menu -->
|
||||
<flux:dropdown position="bottom" align="start">
|
||||
<flux:profile :name="auth()->user()->name" :initials="auth()->user()->initials()"
|
||||
icon-trailing="chevrons-up-down" />
|
||||
|
||||
<flux:menu class="w-[220px]">
|
||||
<flux:menu.radio.group>
|
||||
<div class="p-0 text-sm font-normal">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
|
||||
<span class="relative flex h-8 w-8 shrink-0 overflow-hidden rounded-lg">
|
||||
<span
|
||||
class="flex h-full w-full items-center justify-center rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white"
|
||||
>
|
||||
{{ auth()->user()->initials() }}
|
||||
</span>
|
||||
<flux:menu class="w-[220px]">
|
||||
<flux:menu.radio.group>
|
||||
<div class="p-0 text-sm font-normal">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
|
||||
<span class="relative flex h-8 w-8 shrink-0 overflow-hidden rounded-lg">
|
||||
<span
|
||||
class="flex h-full w-full items-center justify-center rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white">
|
||||
{{ auth()->user()->initials() }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<div class="grid flex-1 text-start text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ auth()->user()->name }}</span>
|
||||
<span class="truncate text-xs">{{ auth()->user()->email }}</span>
|
||||
</div>
|
||||
<div class="grid flex-1 text-start text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ auth()->user()->name }}</span>
|
||||
<span class="truncate text-xs">{{ auth()->user()->email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</flux:menu.radio.group>
|
||||
</div>
|
||||
</flux:menu.radio.group>
|
||||
|
||||
<flux:menu.separator />
|
||||
<flux:menu.separator />
|
||||
|
||||
<flux:menu.radio.group>
|
||||
<flux:menu.item :href="route('settings.profile')" icon="cog" wire:navigate>{{ __('Settings') }}</flux:menu.item>
|
||||
</flux:menu.radio.group>
|
||||
<flux:menu.separator />
|
||||
<flux:menu.radio.group>
|
||||
<flux:menu.item :href="route('settings.profile')" icon="cog" wire:navigate>
|
||||
{{ __('Settings') }}</flux:menu.item>
|
||||
</flux:menu.radio.group>
|
||||
<flux:menu.separator />
|
||||
|
||||
<flux:menu.radio.group>
|
||||
<div x-data="{
|
||||
toggle() {
|
||||
$flux.appearance = $flux.appearance === 'dark' ? 'light' : 'dark';
|
||||
}
|
||||
}">
|
||||
<flux:menu.item
|
||||
x-show="$flux.appearance !== 'dark'"
|
||||
@click="toggle()"
|
||||
icon="moon"
|
||||
>
|
||||
{{ __('Dunkel') }}
|
||||
</flux:menu.item>
|
||||
<flux:menu.item
|
||||
x-show="$flux.appearance === 'dark'"
|
||||
x-cloak
|
||||
@click="toggle()"
|
||||
icon="sun"
|
||||
>
|
||||
{{ __('Hell') }}
|
||||
</flux:menu.item>
|
||||
</div>
|
||||
</flux:menu.radio.group>
|
||||
|
||||
<flux:menu.separator />
|
||||
|
||||
<form method="POST" action="{{ route('auth.logout') }}" class="w-full">
|
||||
@csrf
|
||||
<flux:menu.item as="button" type="submit" icon="arrow-right-start-on-rectangle" class="w-full">
|
||||
{{ __('Log Out') }}
|
||||
<flux:menu.radio.group>
|
||||
<div x-data="{
|
||||
toggle() {
|
||||
$flux.appearance = $flux.appearance === 'dark' ? 'light' : 'dark';
|
||||
}
|
||||
}">
|
||||
<flux:menu.item x-show="$flux.appearance !== 'dark'" @click="toggle()" icon="moon">
|
||||
{{ __('Dunkel') }}
|
||||
</flux:menu.item>
|
||||
</form>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
</flux:sidebar>
|
||||
<flux:menu.item x-show="$flux.appearance === 'dark'" x-cloak @click="toggle()"
|
||||
icon="sun">
|
||||
{{ __('Hell') }}
|
||||
</flux:menu.item>
|
||||
</div>
|
||||
</flux:menu.radio.group>
|
||||
|
||||
<!-- Mobile User Menu -->
|
||||
<flux:header class="lg:hidden">
|
||||
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
|
||||
<flux:menu.separator />
|
||||
|
||||
<a href="{{ config('domains.domain_main_url') }}" class="me-5 ml-2 flex items-center space-x-2 rtl:space-x-reverse">
|
||||
<x-app-logo />
|
||||
</a>
|
||||
<flux:spacer />
|
||||
<form method="POST" action="{{ route('auth.logout') }}" class="w-full">
|
||||
@csrf
|
||||
<flux:menu.item as="button" type="submit" icon="arrow-right-start-on-rectangle"
|
||||
class="w-full">
|
||||
{{ __('Log Out') }}
|
||||
</flux:menu.item>
|
||||
</form>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
</flux:sidebar>
|
||||
|
||||
<flux:dropdown position="top" align="end">
|
||||
<flux:profile
|
||||
:initials="auth()->user()->initials()"
|
||||
icon-trailing="chevron-down"
|
||||
/>
|
||||
<!-- Mobile User Menu -->
|
||||
<flux:header class="lg:hidden">
|
||||
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
|
||||
|
||||
<flux:menu>
|
||||
<flux:menu.radio.group>
|
||||
<div class="p-0 text-sm font-normal">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
|
||||
<span class="relative flex h-8 w-8 shrink-0 overflow-hidden rounded-lg">
|
||||
<span
|
||||
class="flex h-full w-full items-center justify-center rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white"
|
||||
>
|
||||
{{ auth()->user()->initials() }}
|
||||
</span>
|
||||
<a href="{{ config('domains.domain_main_url') }}"
|
||||
class="me-5 ml-2 flex items-center space-x-2 rtl:space-x-reverse">
|
||||
<x-app-logo />
|
||||
</a>
|
||||
<flux:spacer />
|
||||
|
||||
<flux:dropdown position="top" align="end">
|
||||
<flux:profile :initials="auth()->user()->initials()" icon-trailing="chevron-down" />
|
||||
|
||||
<flux:menu>
|
||||
<flux:menu.radio.group>
|
||||
<div class="p-0 text-sm font-normal">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
|
||||
<span class="relative flex h-8 w-8 shrink-0 overflow-hidden rounded-lg">
|
||||
<span
|
||||
class="flex h-full w-full items-center justify-center rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white">
|
||||
{{ auth()->user()->initials() }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<div class="grid flex-1 text-start text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ auth()->user()->name }}</span>
|
||||
<span class="truncate text-xs">{{ auth()->user()->email }}</span>
|
||||
</div>
|
||||
<div class="grid flex-1 text-start text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ auth()->user()->name }}</span>
|
||||
<span class="truncate text-xs">{{ auth()->user()->email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</flux:menu.radio.group>
|
||||
</div>
|
||||
</flux:menu.radio.group>
|
||||
|
||||
<flux:menu.separator />
|
||||
<flux:menu.separator />
|
||||
|
||||
<flux:menu.radio.group>
|
||||
<flux:menu.item :href="route('settings.profile')" icon="cog" wire:navigate>{{ __('Settings') }}</flux:menu.item>
|
||||
</flux:menu.radio.group>
|
||||
<flux:menu.separator />
|
||||
<flux:menu.radio.group>
|
||||
<flux:menu.item :href="route('settings.profile')" icon="cog" wire:navigate>
|
||||
{{ __('Settings') }}</flux:menu.item>
|
||||
</flux:menu.radio.group>
|
||||
<flux:menu.separator />
|
||||
|
||||
<flux:menu.radio.group>
|
||||
<div x-data="{
|
||||
toggle() {
|
||||
$flux.appearance = $flux.appearance === 'dark' ? 'light' : 'dark';
|
||||
}
|
||||
}">
|
||||
<flux:menu.item
|
||||
x-show="$flux.appearance !== 'dark'"
|
||||
@click="toggle()"
|
||||
icon="moon"
|
||||
>
|
||||
{{ __('Dunkel') }}
|
||||
</flux:menu.item>
|
||||
<flux:menu.item
|
||||
x-show="$flux.appearance === 'dark'"
|
||||
x-cloak
|
||||
@click="toggle()"
|
||||
icon="sun"
|
||||
>
|
||||
{{ __('Hell') }}
|
||||
</flux:menu.item>
|
||||
</div>
|
||||
</flux:menu.radio.group>
|
||||
|
||||
<flux:menu.separator />
|
||||
|
||||
<form method="POST" action="{{ route('auth.logout') }}" class="w-full">
|
||||
@csrf
|
||||
<flux:menu.item as="button" type="submit" icon="arrow-right-start-on-rectangle" class="w-full">
|
||||
{{ __('Log Out') }}
|
||||
<flux:menu.radio.group>
|
||||
<div x-data="{
|
||||
toggle() {
|
||||
$flux.appearance = $flux.appearance === 'dark' ? 'light' : 'dark';
|
||||
}
|
||||
}">
|
||||
<flux:menu.item x-show="$flux.appearance !== 'dark'" @click="toggle()" icon="moon">
|
||||
{{ __('Dunkel') }}
|
||||
</flux:menu.item>
|
||||
</form>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
</flux:header>
|
||||
<flux:menu.item x-show="$flux.appearance === 'dark'" x-cloak @click="toggle()"
|
||||
icon="sun">
|
||||
{{ __('Hell') }}
|
||||
</flux:menu.item>
|
||||
</div>
|
||||
</flux:menu.radio.group>
|
||||
|
||||
{{ $slot }}
|
||||
<flux:menu.separator />
|
||||
|
||||
<form method="POST" action="{{ route('auth.logout') }}" class="w-full">
|
||||
@csrf
|
||||
<flux:menu.item as="button" type="submit" icon="arrow-right-start-on-rectangle"
|
||||
class="w-full">
|
||||
{{ __('Log Out') }}
|
||||
</flux:menu.item>
|
||||
</form>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
</flux:header>
|
||||
|
||||
{{ $slot }}
|
||||
|
||||
<flux:toast />
|
||||
|
||||
@fluxScripts
|
||||
</body>
|
||||
|
||||
@fluxScripts
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
355
resources/views/livewire/admin/partners/edit.blade.php
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Partner;
|
||||
use App\Models\Hub;
|
||||
use Livewire\Volt\Component;
|
||||
use function Livewire\Volt\{layout, title};
|
||||
|
||||
layout('components.layouts.app');
|
||||
title('Partner bearbeiten');
|
||||
|
||||
new class extends Component {
|
||||
public Partner $partner;
|
||||
|
||||
// Basis-Felder
|
||||
public string $companyName = '';
|
||||
public string $displayName = '';
|
||||
public string $street = '';
|
||||
public string $houseNumber = '';
|
||||
public string $zip = '';
|
||||
public string $city = '';
|
||||
public string $phone = '';
|
||||
public string $website = '';
|
||||
public ?int $hubId = null;
|
||||
public bool $isActive = true;
|
||||
|
||||
// Profil-Felder (Phase 1 Migrationen)
|
||||
public string $storyText = '';
|
||||
public int|string $foundedYear = '';
|
||||
public string $specialtiesInput = '';
|
||||
|
||||
/**
|
||||
* Öffnungszeiten als strukturiertes Array.
|
||||
*
|
||||
* @var array<string, array{open: string, close: string, closed: bool}>
|
||||
*/
|
||||
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<string, string> */
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-6 p-6">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center gap-4">
|
||||
<flux:button href="{{ route('admin.partners.index') }}" variant="ghost" icon="arrow-left" />
|
||||
<div>
|
||||
<flux:heading size="xl" class="mb-1">{{ $partner->company_name }}</flux:heading>
|
||||
<flux:subheading>{{ __('Partner-Profil bearbeiten') }}</flux:subheading>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (session()->has('message'))
|
||||
<flux:callout variant="success" icon="check-circle">
|
||||
{{ session('message') }}
|
||||
</flux:callout>
|
||||
@endif
|
||||
|
||||
<form wire:submit="save" class="space-y-6">
|
||||
|
||||
{{-- Basisdaten --}}
|
||||
<flux:card class="shadow-elegant">
|
||||
<div class="mb-4">
|
||||
<flux:heading size="lg">{{ __('Basisdaten') }}</flux:heading>
|
||||
</div>
|
||||
<flux:separator class="mb-6" />
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Firmenname') }}</flux:label>
|
||||
<flux:input wire:model="companyName" icon="building-office" />
|
||||
@error('companyName') <flux:error>{{ $message }}</flux:error> @enderror
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Anzeigename (optional)') }}</flux:label>
|
||||
<flux:description>{{ __('Öffentlich sichtbarer Name, falls abweichend') }}</flux:description>
|
||||
<flux:input wire:model="displayName" />
|
||||
@error('displayName') <flux:error>{{ $message }}</flux:error> @enderror
|
||||
</flux:field>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<flux:field class="md:col-span-2">
|
||||
<flux:label>{{ __('Straße') }}</flux:label>
|
||||
<flux:input wire:model="street" />
|
||||
@error('street') <flux:error>{{ $message }}</flux:error> @enderror
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Hausnummer') }}</flux:label>
|
||||
<flux:input wire:model="houseNumber" />
|
||||
@error('houseNumber') <flux:error>{{ $message }}</flux:error> @enderror
|
||||
</flux:field>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('PLZ') }}</flux:label>
|
||||
<flux:input wire:model="zip" />
|
||||
@error('zip') <flux:error>{{ $message }}</flux:error> @enderror
|
||||
</flux:field>
|
||||
|
||||
<flux:field class="md:col-span-2">
|
||||
<flux:label>{{ __('Stadt') }}</flux:label>
|
||||
<flux:input wire:model="city" />
|
||||
@error('city') <flux:error>{{ $message }}</flux:error> @enderror
|
||||
</flux:field>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Telefon') }}</flux:label>
|
||||
<flux:input wire:model="phone" type="tel" icon="phone" />
|
||||
@error('phone') <flux:error>{{ $message }}</flux:error> @enderror
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Website') }}</flux:label>
|
||||
<flux:input wire:model="website" type="url" placeholder="https://example.de" icon="globe-alt" />
|
||||
@error('website') <flux:error>{{ $message }}</flux:error> @enderror
|
||||
</flux:field>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Hub / Region') }}</flux:label>
|
||||
<flux:select wire:model="hubId">
|
||||
<flux:select.option :value="null">{{ __('– Kein Hub –') }}</flux:select.option>
|
||||
@foreach ($hubs as $hub)
|
||||
<flux:select.option :value="$hub->id">{{ $hub->name }}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
@error('hubId') <flux:error>{{ $message }}</flux:error> @enderror
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Status') }}</flux:label>
|
||||
<flux:switch wire:model="isActive" label="{{ __('Partner ist aktiv') }}" />
|
||||
</flux:field>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Story & Profil --}}
|
||||
<flux:card class="shadow-elegant">
|
||||
<div class="mb-4">
|
||||
<flux:heading size="lg">{{ __('Story & Profil') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Erzählen Sie die Geschichte des Partners') }}</flux:subheading>
|
||||
</div>
|
||||
<flux:separator class="mb-6" />
|
||||
|
||||
<div class="space-y-4">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Story-Text') }}</flux:label>
|
||||
<flux:description>{{ __('Kurze Geschichte des Unternehmens – max. 2.000 Zeichen') }}</flux:description>
|
||||
<flux:textarea
|
||||
wire:model="storyText"
|
||||
placeholder="{{ __('Seit 1950 steht unser Haus für...') }}"
|
||||
rows="5"
|
||||
/>
|
||||
<div class="mt-1 text-right text-xs text-zinc-400">
|
||||
{{ strlen($storyText) }} / 2000
|
||||
</div>
|
||||
@error('storyText') <flux:error>{{ $message }}</flux:error> @enderror
|
||||
</flux:field>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Gründungsjahr') }}</flux:label>
|
||||
<flux:input
|
||||
wire:model="foundedYear"
|
||||
type="number"
|
||||
min="1800"
|
||||
:max="date('Y')"
|
||||
placeholder="{{ __('z.B. 1985') }}"
|
||||
icon="calendar"
|
||||
/>
|
||||
@error('foundedYear') <flux:error>{{ $message }}</flux:error> @enderror
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Fachgebiete / Spezialisierungen') }}</flux:label>
|
||||
<flux:description>{{ __('Kommagetrennt, z.B. Polstermöbel, Küchen, Matratzen') }}</flux:description>
|
||||
<flux:input
|
||||
wire:model="specialtiesInput"
|
||||
placeholder="{{ __('Polstermöbel, Küchen, Matratzen') }}"
|
||||
/>
|
||||
@error('specialtiesInput') <flux:error>{{ $message }}</flux:error> @enderror
|
||||
</flux:field>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Öffnungszeiten --}}
|
||||
<flux:card class="shadow-elegant">
|
||||
<div class="mb-4">
|
||||
<flux:heading size="lg">{{ __('Öffnungszeiten') }}</flux:heading>
|
||||
</div>
|
||||
<flux:separator class="mb-6" />
|
||||
|
||||
<div class="space-y-3">
|
||||
@foreach ($dayLabels as $dayKey => $dayLabel)
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-28 text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
{{ $dayLabel }}
|
||||
</div>
|
||||
|
||||
<flux:switch
|
||||
wire:model.live="openingHours.{{ $dayKey }}.closed"
|
||||
label="{{ __('Geschlossen') }}"
|
||||
/>
|
||||
|
||||
@unless ($openingHours[$dayKey]['closed'] ?? false)
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:input
|
||||
wire:model="openingHours.{{ $dayKey }}.open"
|
||||
type="time"
|
||||
class="w-28"
|
||||
/>
|
||||
<span class="text-zinc-500">–</span>
|
||||
<flux:input
|
||||
wire:model="openingHours.{{ $dayKey }}.close"
|
||||
type="time"
|
||||
class="w-28"
|
||||
/>
|
||||
</div>
|
||||
@endunless
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Aktionen --}}
|
||||
<div class="flex justify-end gap-3">
|
||||
<flux:button href="{{ route('admin.partners.index') }}" variant="ghost">
|
||||
{{ __('Abbrechen') }}
|
||||
</flux:button>
|
||||
<flux:button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
icon="check"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="save"
|
||||
>
|
||||
<span wire:loading.remove wire:target="save">{{ __('Speichern') }}</span>
|
||||
<span wire:loading wire:target="save">
|
||||
<flux:icon.arrow-path class="animate-spin inline-block mr-1 h-4 w-4" />
|
||||
{{ __('Wird gespeichert...') }}
|
||||
</span>
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
220
resources/views/livewire/admin/partners/index.blade.php
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Partner;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithPagination;
|
||||
use function Livewire\Volt\{layout, title};
|
||||
|
||||
layout('components.layouts.app');
|
||||
title('Partner-Verwaltung');
|
||||
|
||||
new class extends Component {
|
||||
use WithPagination;
|
||||
|
||||
public string $search = '';
|
||||
public string $typeFilter = '';
|
||||
public bool $onlyActive = false;
|
||||
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
$this->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(),
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-6 p-6">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<flux:heading size="xl" class="mb-2">{{ __('Partner-Verwaltung') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Alle registrierten Partner auf der Plattform') }}</flux:subheading>
|
||||
</div>
|
||||
<flux:button
|
||||
href="{{ route('admin.partners.invite') }}"
|
||||
variant="primary"
|
||||
icon="plus"
|
||||
>
|
||||
{{ __('Partner einladen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
{{-- Statistics --}}
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<flux:card class="shadow-elegant">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<flux:subheading>{{ __('Gesamt') }}</flux:subheading>
|
||||
<flux:heading size="2xl" class="mt-2">{{ $totalCount }}</flux:heading>
|
||||
</div>
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/20">
|
||||
<flux:icon.building-office class="h-6 w-6 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:card class="shadow-elegant">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<flux:subheading>{{ __('Aktiv') }}</flux:subheading>
|
||||
<flux:heading size="2xl" class="mt-2">{{ $activeCount }}</flux:heading>
|
||||
</div>
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-green-100 dark:bg-green-900/20">
|
||||
<flux:icon.check-circle class="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:card class="shadow-elegant">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<flux:subheading>{{ __('Inaktiv') }}</flux:subheading>
|
||||
<flux:heading size="2xl" class="mt-2">{{ $totalCount - $activeCount }}</flux:heading>
|
||||
</div>
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-zinc-100 dark:bg-zinc-800">
|
||||
<flux:icon.x-circle class="h-6 w-6 text-zinc-600 dark:text-zinc-400" />
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
{{-- Filter --}}
|
||||
<flux:card class="shadow-elegant">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<flux:field class="flex-1">
|
||||
<flux:label>{{ __('Suche') }}</flux:label>
|
||||
<flux:input
|
||||
wire:model.live.debounce.300ms="search"
|
||||
placeholder="{{ __('Firmenname, Stadt oder E-Mail...') }}"
|
||||
icon="magnifying-glass"
|
||||
/>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Partner-Typ') }}</flux:label>
|
||||
<flux:select wire:model.live="typeFilter">
|
||||
<flux:select.option value="">{{ __('Alle Typen') }}</flux:select.option>
|
||||
<flux:select.option value="retailer">{{ __('Händler') }}</flux:select.option>
|
||||
<flux:select.option value="manufacturer">{{ __('Hersteller') }}</flux:select.option>
|
||||
<flux:select.option value="estate_agent">{{ __('Makler') }}</flux:select.option>
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Status') }}</flux:label>
|
||||
<flux:switch wire:model.live="onlyActive" label="{{ __('Nur aktive') }}" />
|
||||
</flux:field>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Partner-Tabelle --}}
|
||||
<flux:card class="shadow-elegant">
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>{{ __('Partner') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Typ') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Hub') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Benutzer') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Status') }}</flux:table.column>
|
||||
<flux:table.column></flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
<flux:table.rows>
|
||||
@forelse ($partners as $partner)
|
||||
<flux:table.row>
|
||||
<flux:table.cell>
|
||||
<div class="font-medium text-zinc-900 dark:text-white">
|
||||
{{ $partner->company_name }}
|
||||
</div>
|
||||
@if ($partner->city)
|
||||
<div class="text-sm text-zinc-500">{{ $partner->zip }} {{ $partner->city }}</div>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
|
||||
<flux:table.cell>
|
||||
<flux:badge size="sm" color="{{ match($partner->type?->value ?? $partner->type) {
|
||||
'retailer' => 'blue',
|
||||
'manufacturer' => 'purple',
|
||||
'estate_agent' => 'amber',
|
||||
default => 'zinc'
|
||||
} }}">
|
||||
{{ $partner->type?->label() ?? ucfirst($partner->type ?? '–') }}
|
||||
</flux:badge>
|
||||
</flux:table.cell>
|
||||
|
||||
<flux:table.cell>
|
||||
{{ $partner->hub?->name ?? '–' }}
|
||||
</flux:table.cell>
|
||||
|
||||
<flux:table.cell>
|
||||
{{ $partner->users->count() }}
|
||||
</flux:table.cell>
|
||||
|
||||
<flux:table.cell>
|
||||
@if ($partner->is_active)
|
||||
<flux:badge size="sm" color="green">{{ __('Aktiv') }}</flux:badge>
|
||||
@else
|
||||
<flux:badge size="sm" color="zinc">{{ __('Inaktiv') }}</flux:badge>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
|
||||
<flux:table.cell>
|
||||
<flux:button
|
||||
href="{{ route('admin.partners.edit', $partner->id) }}"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="pencil"
|
||||
>
|
||||
{{ __('Bearbeiten') }}
|
||||
</flux:button>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="6" class="py-12 text-center text-zinc-500">
|
||||
<flux:icon.building-office class="mx-auto mb-2 h-12 w-12 text-zinc-400" />
|
||||
<div>{{ __('Keine Partner gefunden') }}</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
|
||||
@if ($partners->hasPages())
|
||||
<div class="mt-4">
|
||||
{{ $partners->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
</div>
|
||||
563
resources/views/livewire/admin/products/index.blade.php
Normal file
|
|
@ -0,0 +1,563 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ProductStatus;
|
||||
use App\Enums\ProductType;
|
||||
use App\Models\Category;
|
||||
use App\Models\Product;
|
||||
use Flux\Flux;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithPagination;
|
||||
use function Livewire\Volt\{layout, title};
|
||||
|
||||
layout('components.layouts.app');
|
||||
title('Produkt-Verwaltung');
|
||||
|
||||
new class extends Component {
|
||||
use WithPagination;
|
||||
|
||||
public string $search = '';
|
||||
public string $statusFilter = '';
|
||||
public string $productTypeFilter = '';
|
||||
public string $categoryFilter = '';
|
||||
public string $partnerFilter = '';
|
||||
public string $curationNotes = '';
|
||||
public string $rejectionReason = '';
|
||||
public ?int $correctingProductId = null;
|
||||
public ?int $rejectingProductId = null;
|
||||
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
$this->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,
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-6 p-6">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<flux:heading size="xl" class="mb-2">{{ __('Produkt-Verwaltung') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Alle Produkte verwalten, freigeben und bearbeiten') }}</flux:subheading>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:button variant="primary" icon="plus" href="{{ route('products.create.teaser') }}">
|
||||
{{ __('Neues Teaser-Produkt') }}
|
||||
</flux:button>
|
||||
<flux:button variant="primary" icon="plus" href="{{ route('products.create.standard') }}">
|
||||
{{ __('Neues Standard-Produkt') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Statistiken --}}
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<flux:card class="shadow-elegant">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<flux:subheading>{{ __('Gesamt') }}</flux:subheading>
|
||||
<flux:heading size="2xl" class="mt-2">{{ $totalCount }}</flux:heading>
|
||||
</div>
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/20">
|
||||
<flux:icon.shopping-bag class="h-6 w-6 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:card class="shadow-elegant cursor-pointer" wire:click="$set('statusFilter', 'pending')">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<flux:subheading>{{ __('Zur Freigabe') }}</flux:subheading>
|
||||
<flux:heading size="2xl" class="mt-2">{{ $pendingCount }}</flux:heading>
|
||||
</div>
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-amber-100 dark:bg-amber-900/20">
|
||||
<flux:icon.clock class="h-6 w-6 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:card class="shadow-elegant cursor-pointer" wire:click="$set('statusFilter', 'correction')">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<flux:subheading>{{ __('In Korrektur') }}</flux:subheading>
|
||||
<flux:heading size="2xl" class="mt-2">{{ $correctionCount }}</flux:heading>
|
||||
</div>
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-orange-100 dark:bg-orange-900/20">
|
||||
<flux:icon.arrow-uturn-left class="h-6 w-6 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:card class="shadow-elegant cursor-pointer" wire:click="$set('statusFilter', 'active')">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<flux:subheading>{{ __('Freigegeben') }}</flux:subheading>
|
||||
<flux:heading size="2xl" class="mt-2">{{ $activeCount }}</flux:heading>
|
||||
</div>
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-green-100 dark:bg-green-900/20">
|
||||
<flux:icon.check-circle class="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
{{-- Filter --}}
|
||||
<flux:card class="shadow-elegant">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<flux:field class="flex-1">
|
||||
<flux:label>{{ __('Suche') }}</flux:label>
|
||||
<flux:input wire:model.live.debounce.300ms="search"
|
||||
placeholder="{{ __('Name, Artikelnummer...') }}" icon="magnifying-glass" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Status') }}</flux:label>
|
||||
<flux:select wire:model.live="statusFilter">
|
||||
<flux:select.option value="">{{ __('Alle Status') }}</flux:select.option>
|
||||
@foreach (ProductStatus::cases() as $status)
|
||||
<flux:select.option value="{{ $status->value }}">{{ $status->label() }}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Produkttyp') }}</flux:label>
|
||||
<flux:select wire:model.live="productTypeFilter">
|
||||
<flux:select.option value="">{{ __('Alle Typen') }}</flux:select.option>
|
||||
<flux:select.option value="local_stock">{{ __('Teaser (Local Express)') }}</flux:select.option>
|
||||
<flux:select.option value="smart_order">{{ __('Standard (Smart Club)') }}</flux:select.option>
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Händler') }}</flux:label>
|
||||
<flux:select wire:model.live="partnerFilter">
|
||||
<flux:select.option value="">{{ __('Alle Händler') }}</flux:select.option>
|
||||
@foreach ($partners as $partner)
|
||||
<flux:select.option :value="$partner->id">{{ $partner->company_name }}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Kategorie') }}</flux:label>
|
||||
<flux:select wire:model.live="categoryFilter">
|
||||
<flux:select.option value="">{{ __('Alle Kategorien') }}</flux:select.option>
|
||||
@foreach ($categories as $category)
|
||||
<flux:select.option :value="$category->id">{{ $category->name }}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Produkttabelle --}}
|
||||
<flux:card class="shadow-elegant">
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>{{ __('Produkt') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Händler') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Kategorie') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Status') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Kuration') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Erstellt') }}</flux:table.column>
|
||||
<flux:table.column></flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
<flux:table.rows>
|
||||
@forelse($products as $product)
|
||||
<flux:table.row wire:key="product-{{ $product->id }}">
|
||||
{{-- Produkt --}}
|
||||
<flux:table.cell>
|
||||
<div class="flex items-center gap-3">
|
||||
@php
|
||||
$thumbnail = $product->media->sortBy('order_column')->first();
|
||||
@endphp
|
||||
@if ($thumbnail)
|
||||
<img src="{{ Storage::url($thumbnail->file_path) }}"
|
||||
alt="{{ $thumbnail->alt_text ?? $product->name }}"
|
||||
class="h-12 w-12 shrink-0 rounded-lg border border-zinc-200 object-cover dark:border-zinc-700" />
|
||||
@else
|
||||
<div
|
||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg border border-zinc-200 bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<flux:icon.photo class="h-5 w-5 text-zinc-400" />
|
||||
</div>
|
||||
@endif
|
||||
<div>
|
||||
<div class="font-medium text-zinc-900 dark:text-white">
|
||||
{{ $product->name }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<flux:badge size="sm"
|
||||
color="{{ $product->product_type?->value === 'local_stock' ? 'amber' : 'blue' }}">
|
||||
{{ $product->product_type?->label() ?? '–' }}
|
||||
</flux:badge>
|
||||
@if ($product->b2in_article_number)
|
||||
<span class="text-xs text-zinc-400">{{ $product->b2in_article_number }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
|
||||
{{-- Händler --}}
|
||||
<flux:table.cell>
|
||||
<span class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{{ $product->partner?->company_name ?? '–' }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
|
||||
{{-- Kategorie --}}
|
||||
<flux:table.cell>
|
||||
<span class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{{ $product->categories->first()?->name ?? '–' }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
|
||||
{{-- Status --}}
|
||||
<flux:table.cell>
|
||||
<flux:badge size="sm" color="{{ $product->status?->color() ?? 'zinc' }}">
|
||||
{{ $product->status?->label() ?? '–' }}
|
||||
</flux:badge>
|
||||
</flux:table.cell>
|
||||
|
||||
{{-- Kuration --}}
|
||||
<flux:table.cell>
|
||||
@if ($product->status === ProductStatus::Pending)
|
||||
<div class="flex items-center gap-1">
|
||||
<flux:button wire:click="approve({{ $product->id }})"
|
||||
wire:loading.attr="disabled"
|
||||
size="xs" variant="primary" icon="check"
|
||||
title="{{ __('Freigeben') }}" />
|
||||
<flux:button wire:click="openCorrection({{ $product->id }})"
|
||||
wire:loading.attr="disabled"
|
||||
size="xs" variant="filled" icon="arrow-uturn-left"
|
||||
title="{{ __('Korrektur') }}" />
|
||||
<flux:button wire:click="openRejection({{ $product->id }})"
|
||||
wire:loading.attr="disabled"
|
||||
size="xs" variant="danger" icon="x-mark"
|
||||
title="{{ __('Ablehnen') }}" />
|
||||
</div>
|
||||
@elseif ($product->status === ProductStatus::Correction)
|
||||
<flux:badge size="sm" color="orange" icon="arrow-uturn-left">
|
||||
{{ __('Warte auf Korrektur') }}
|
||||
</flux:badge>
|
||||
@elseif ($product->is_curated)
|
||||
<flux:badge size="sm" color="green" icon="check-circle">
|
||||
{{ __('Freigegeben') }}
|
||||
</flux:badge>
|
||||
@else
|
||||
<span class="text-xs text-zinc-400">–</span>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
|
||||
{{-- Erstellt --}}
|
||||
<flux:table.cell>
|
||||
<span class="text-sm text-zinc-500">
|
||||
{{ $product->created_at?->format('d.m.Y') }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
|
||||
{{-- Aktionen --}}
|
||||
<flux:table.cell>
|
||||
<div class="flex items-center gap-1">
|
||||
<flux:button
|
||||
href="{{ $product->product_type === ProductType::LocalStock ? route('products.edit.teaser', $product) : route('products.edit.standard', $product) }}"
|
||||
size="sm" variant="ghost" icon="pencil"
|
||||
title="{{ __('Bearbeiten') }}" />
|
||||
@if (!in_array($product->status, [ProductStatus::Archived, ProductStatus::Sold]))
|
||||
<flux:dropdown>
|
||||
<flux:button size="sm" variant="ghost" icon="ellipsis-vertical" />
|
||||
<flux:menu>
|
||||
<flux:menu.item wire:click="markAsSold({{ $product->id }})"
|
||||
wire:confirm="{{ __('Produkt wirklich als verkauft markieren?') }}"
|
||||
icon="check-badge">
|
||||
{{ __('Als verkauft') }}
|
||||
</flux:menu.item>
|
||||
<flux:menu.item wire:click="archiveProduct({{ $product->id }})"
|
||||
wire:confirm="{{ __('Produkt wirklich archivieren?') }}"
|
||||
icon="archive-box" variant="danger">
|
||||
{{ __('Archivieren') }}
|
||||
</flux:menu.item>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
@endif
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
|
||||
{{-- Inline Korrektur-Formular --}}
|
||||
@if ($correctingProductId === $product->id)
|
||||
<flux:table.row wire:key="correction-{{ $product->id }}">
|
||||
<flux:table.cell colspan="7">
|
||||
<div class="rounded-lg border border-orange-200 bg-orange-50 p-4 dark:border-orange-800 dark:bg-orange-950/30">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Korrekturanweisung für ":name"', ['name' => $product->name]) }}</flux:label>
|
||||
<flux:textarea wire:model="curationNotes" rows="3"
|
||||
placeholder="{{ __('Bitte beschreiben Sie, was der Partner korrigieren soll...') }}" />
|
||||
<flux:error name="curationNotes" />
|
||||
</flux:field>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<flux:button wire:click="sendCorrection({{ $product->id }})"
|
||||
wire:loading.attr="disabled" variant="primary" size="sm"
|
||||
icon="paper-airplane">
|
||||
{{ __('Korrektur senden') }}
|
||||
</flux:button>
|
||||
<flux:button wire:click="cancelCorrection" variant="ghost" size="sm">
|
||||
{{ __('Abbrechen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endif
|
||||
|
||||
{{-- Inline Ablehnungs-Formular --}}
|
||||
@if ($rejectingProductId === $product->id)
|
||||
<flux:table.row wire:key="rejection-{{ $product->id }}">
|
||||
<flux:table.cell colspan="7">
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-950/30">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Ablehnungsgrund für ":name"', ['name' => $product->name]) }}</flux:label>
|
||||
<flux:textarea wire:model="rejectionReason" rows="3"
|
||||
placeholder="{{ __('Bitte geben Sie den Grund für die Ablehnung an...') }}" />
|
||||
<flux:error name="rejectionReason" />
|
||||
</flux:field>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<flux:button wire:click="reject({{ $product->id }})"
|
||||
wire:loading.attr="disabled" variant="danger" size="sm"
|
||||
icon="x-mark">
|
||||
{{ __('Ablehnen') }}
|
||||
</flux:button>
|
||||
<flux:button wire:click="cancelRejection" variant="ghost" size="sm">
|
||||
{{ __('Abbrechen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endif
|
||||
|
||||
{{-- Korrekturhinweis anzeigen --}}
|
||||
@if ($product->status === ProductStatus::Correction && $product->curation_notes)
|
||||
<flux:table.row wire:key="correction-notes-{{ $product->id }}">
|
||||
<flux:table.cell colspan="7">
|
||||
<flux:callout variant="warning" icon="exclamation-triangle">
|
||||
<flux:callout.heading>{{ __('Korrekturanweisung') }}</flux:callout.heading>
|
||||
<flux:callout.text>{{ $product->curation_notes }}</flux:callout.text>
|
||||
</flux:callout>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endif
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="7" class="py-12 text-center text-zinc-500">
|
||||
<flux:icon.shopping-bag class="mx-auto mb-2 h-12 w-12 text-zinc-400" />
|
||||
<div>{{ __('Keine Produkte gefunden') }}</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
|
||||
@if ($products->hasPages())
|
||||
<div class="mt-4">
|
||||
{{ $products->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
</div>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? '';
|
||||
|
|
|
|||
216
resources/views/livewire/partner/profile.blade.php
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ProductStatus;
|
||||
use App\Models\Partner;
|
||||
use App\Models\Product;
|
||||
use Livewire\Volt\Component;
|
||||
use function Livewire\Volt\{layout, title};
|
||||
|
||||
layout('components.layouts.app');
|
||||
|
||||
new class extends Component {
|
||||
public Partner $partner;
|
||||
public string $title = '';
|
||||
|
||||
public function mount(int $partnerId): void
|
||||
{
|
||||
$this->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,
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-8 p-6">
|
||||
{{-- Partner-Header --}}
|
||||
<flux:card class="shadow-elegant">
|
||||
<div class="flex flex-col md:flex-row items-start gap-6">
|
||||
{{-- Logo / Placeholder --}}
|
||||
<div class="flex h-24 w-24 shrink-0 items-center justify-center rounded-xl bg-zinc-100 dark:bg-zinc-800">
|
||||
<flux:icon.building-office class="h-12 w-12 text-zinc-400" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<flux:heading size="2xl">{{ $partner->display_name ?? $partner->company_name }}</flux:heading>
|
||||
@if($partner->display_name && $partner->display_name !== $partner->company_name)
|
||||
<div class="text-sm text-zinc-500 dark:text-zinc-400 mt-1">{{ $partner->company_name }}</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-3">
|
||||
@if($partner->type)
|
||||
<flux:badge color="blue">{{ $partner->type?->label() ?? $partner->type }}</flux:badge>
|
||||
@endif
|
||||
@if($partner->hub)
|
||||
<flux:badge color="zinc" icon="map-pin">{{ $partner->hub->name }}</flux:badge>
|
||||
@endif
|
||||
@if($partner->is_active)
|
||||
<flux:badge color="green">{{ __('Aktiv') }}</flux:badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Kontaktdaten --}}
|
||||
<div class="mt-4 flex flex-wrap gap-x-6 gap-y-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
@if($partner->city)
|
||||
<span class="flex items-center gap-1">
|
||||
<flux:icon.map-pin class="h-4 w-4" />
|
||||
{{ $partner->zip }} {{ $partner->city }}
|
||||
</span>
|
||||
@endif
|
||||
@if($partner->phone)
|
||||
<span class="flex items-center gap-1">
|
||||
<flux:icon.phone class="h-4 w-4" />
|
||||
{{ $partner->phone }}
|
||||
</span>
|
||||
@endif
|
||||
@if($partner->website)
|
||||
<a href="{{ $partner->website }}" target="_blank" rel="noopener noreferrer"
|
||||
class="flex items-center gap-1 hover:text-zinc-900 dark:hover:text-zinc-100">
|
||||
<flux:icon.globe-alt class="h-4 w-4" />
|
||||
{{ parse_url($partner->website, PHP_URL_HOST) }}
|
||||
</a>
|
||||
@endif
|
||||
@if($partner->founded_year)
|
||||
<span class="flex items-center gap-1">
|
||||
<flux:icon.calendar class="h-4 w-4" />
|
||||
{{ __('Seit') }} {{ $partner->founded_year }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<div class="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
{{-- Linke Spalte: Story + Spezialisierungen --}}
|
||||
<div class="space-y-6 lg:col-span-2">
|
||||
{{-- Story / Über uns --}}
|
||||
@if($partner->story_text)
|
||||
<flux:card class="shadow-elegant">
|
||||
<flux:heading size="lg" class="mb-4">{{ __('Über uns') }}</flux:heading>
|
||||
<div class="text-zinc-700 dark:text-zinc-300 leading-relaxed whitespace-pre-line">
|
||||
{{ $partner->story_text }}
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
|
||||
{{-- Produkte --}}
|
||||
@if($products->isNotEmpty())
|
||||
<flux:card class="shadow-elegant">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<flux:heading size="lg">{{ __('Aktuelle Produkte') }}</flux:heading>
|
||||
<flux:button variant="ghost" size="sm" href="{{ route('products.index') }}" icon="arrow-right">
|
||||
{{ __('Alle ansehen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
@foreach($products as $product)
|
||||
<div class="rounded-lg border border-zinc-200 dark:border-zinc-700 overflow-hidden">
|
||||
<div class="flex h-32 items-center justify-center bg-zinc-100 dark:bg-zinc-800">
|
||||
@if($product->media->first()?->url)
|
||||
<img src="{{ $product->media->first()->url }}"
|
||||
alt="{{ $product->name }}"
|
||||
class="h-full w-full object-cover">
|
||||
@else
|
||||
<flux:icon.photo class="h-10 w-10 text-zinc-400" />
|
||||
@endif
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<div class="font-medium text-sm text-zinc-900 dark:text-zinc-100">
|
||||
{{ $product->name }}
|
||||
</div>
|
||||
@if($product->price_display_text)
|
||||
<div class="text-sm text-zinc-500 mt-1">{{ $product->price_display_text }}</div>
|
||||
@elseif($product->price)
|
||||
<div class="text-sm font-semibold mt-1">{{ number_format($product->price, 2, ',', '.') }} €</div>
|
||||
@else
|
||||
<div class="text-sm text-zinc-400 mt-1">{{ __('Auf Anfrage') }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Rechte Spalte: Öffnungszeiten + Spezialisierungen --}}
|
||||
<div class="space-y-6">
|
||||
{{-- Öffnungszeiten --}}
|
||||
@if($partner->opening_hours)
|
||||
<flux:card class="shadow-elegant">
|
||||
<flux:heading size="lg" class="mb-4">{{ __('Öffnungszeiten') }}</flux:heading>
|
||||
@php
|
||||
$days = [
|
||||
'monday' => __('Montag'),
|
||||
'tuesday' => __('Dienstag'),
|
||||
'wednesday' => __('Mittwoch'),
|
||||
'thursday' => __('Donnerstag'),
|
||||
'friday' => __('Freitag'),
|
||||
'saturday' => __('Samstag'),
|
||||
'sunday' => __('Sonntag'),
|
||||
];
|
||||
@endphp
|
||||
<div class="space-y-2">
|
||||
@foreach($days as $key => $label)
|
||||
@if(isset($partner->opening_hours[$key]))
|
||||
@php $hours = $partner->opening_hours[$key]; @endphp
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-zinc-600 dark:text-zinc-400">{{ $label }}</span>
|
||||
@if(!empty($hours['closed']))
|
||||
<span class="text-zinc-400">{{ __('Geschlossen') }}</span>
|
||||
@elseif(!empty($hours['open']) && !empty($hours['close']))
|
||||
<span class="font-medium">{{ $hours['open'] }} – {{ $hours['close'] }}</span>
|
||||
@else
|
||||
<span class="text-zinc-400">–</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
|
||||
{{-- Spezialisierungen --}}
|
||||
@if($partner->specialties && count($partner->specialties) > 0)
|
||||
<flux:card class="shadow-elegant">
|
||||
<flux:heading size="lg" class="mb-4">{{ __('Spezialisierungen') }}</flux:heading>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach($partner->specialties as $specialty)
|
||||
<flux:badge color="zinc">{{ $specialty }}</flux:badge>
|
||||
@endforeach
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
|
||||
{{-- Adresse --}}
|
||||
@if($partner->street || $partner->city)
|
||||
<flux:card class="shadow-elegant">
|
||||
<flux:heading size="lg" class="mb-4">{{ __('Adresse') }}</flux:heading>
|
||||
<address class="not-italic text-sm text-zinc-700 dark:text-zinc-300 space-y-1">
|
||||
@if($partner->street)
|
||||
<div>{{ $partner->street }} {{ $partner->house_number }}</div>
|
||||
@endif
|
||||
@if($partner->zip || $partner->city)
|
||||
<div>{{ $partner->zip }} {{ $partner->city }}</div>
|
||||
@endif
|
||||
</address>
|
||||
</flux:card>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,794 +0,0 @@
|
|||
<?php
|
||||
|
||||
use function Livewire\Volt\{state, mount};
|
||||
|
||||
state([
|
||||
'product' => [],
|
||||
'activeTab' => 'basis'
|
||||
]);
|
||||
|
||||
mount(function () {
|
||||
// Initialisierung der Dummy-Daten
|
||||
});
|
||||
|
||||
?>
|
||||
|
||||
|
||||
|
||||
<div class="space-y-6">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<flux:heading size="xl">{{ __('Produkte') }} (in Entwicklung)</flux:heading>
|
||||
<flux:subheading>{{ __('Erstellen Sie ein neues Produkt') }}</flux:subheading>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@svg('heroicon-o-cube', 'w-6 h-6 text-accent-600 dark:text-accent-400')
|
||||
<span class="text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ __('Neues Produkt anlegen') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<form wire:submit="save" class="space-y-6">
|
||||
|
||||
{{-- Tab Navigation --}}
|
||||
<flux:tabs wire:model.live="activeTab" variant="segmented">
|
||||
<flux:tab name="basis" icon="identification">{{ __('Basis') }}</flux:tab>
|
||||
<flux:tab name="bilder" icon="photo">{{ __('Bilder') }}</flux:tab>
|
||||
<flux:tab name="physisch" icon="cube">{{ __('Physisch') }}</flux:tab>
|
||||
<flux:tab name="material" icon="beaker">{{ __('Material & Herkunft') }}</flux:tab>
|
||||
<flux:tab name="kommerziell" icon="currency-euro">{{ __('Kommerziell') }}</flux:tab>
|
||||
<flux:tab name="verwaltung" icon="cog">{{ __('Zuordnung & Verwaltung') }}</flux:tab>
|
||||
</flux:tabs>
|
||||
|
||||
{{-- TAB 1: BASIS - Identität & Varianten --}}
|
||||
@if($activeTab === 'basis')
|
||||
<div class="space-y-8">
|
||||
|
||||
{{-- 1. Identität & Katalog --}}
|
||||
<flux:card class="space-y-6">
|
||||
<flux:heading size="lg" class="border-b pb-3">{{ __('1. Identität & Katalog') }}</flux:heading>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('B2in-Artikelnummer (intern)') }} <span class="text-red-500">*</span></flux:label>
|
||||
<flux:input wire:model="product.b2in_article_number" placeholder="B2IN-000471" />
|
||||
<flux:description>{{ __('Fortlaufende Nummer (vom System vergeben)') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Lieferanten-Artikelnummer') }} <span class="text-red-500">*</span></flux:label>
|
||||
<flux:input wire:model="product.supplier_article_number" placeholder="SOFA-ALBA-3S-ANTHR" />
|
||||
<flux:description>{{ __('Originalnummer des Herstellers') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Produktname') }} <span class="text-red-500">*</span></flux:label>
|
||||
<flux:input wire:model="product.product_name" placeholder="Sofa ALBA 3-Sitzer" />
|
||||
<flux:description>{{ __('Anzeigename auf Website') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Marke / Hersteller') }}</flux:label>
|
||||
<flux:input wire:model="product.brand" placeholder="Möbelwerk Nord" />
|
||||
<flux:description>{{ __('Produzent oder Label') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Kategorie') }} <span class="text-red-500">*</span></flux:label>
|
||||
<flux:select wire:model="product.category" placeholder="{{ __('Bitte wählen...') }}">
|
||||
<option value="sofas">{{ __('Wohnzimmer > Sofas') }}</option>
|
||||
<option value="chairs">{{ __('Esszimmer > Stühle') }}</option>
|
||||
<option value="beds">{{ __('Schlafzimmer > Betten') }}</option>
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Kurzbeschreibung') }}</flux:label>
|
||||
<flux:textarea wire:model="product.short_description" rows="3" placeholder="Modernes Sofa mit Holzrahmen und Stoff"></flux:textarea>
|
||||
<flux:description>{{ __('Max. 180 Zeichen für Snippets') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Langbeschreibung') }}</flux:label>
|
||||
<flux:textarea wire:model="product.long_description" rows="6" placeholder="Das Sofa ALBA verbindet zeitloses Design..."></flux:textarea>
|
||||
<flux:description>{{ __('Detaillierter Text für Produktseite') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Status') }} <span class="text-red-500">*</span></flux:label>
|
||||
<flux:select wire:model="product.status">
|
||||
<option value="active">{{ __('Aktiv') }}</option>
|
||||
<option value="draft">{{ __('Entwurf') }}</option>
|
||||
<option value="inactive">{{ __('Inaktiv') }}</option>
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Erstelldatum / Änderungsdatum') }}</flux:label>
|
||||
<flux:input type="date" wire:model="product.created_at" value="2025-11-04" />
|
||||
<flux:description>{{ __('ISO-Datum') }}</flux:description>
|
||||
</flux:field>
|
||||
</flux:card>
|
||||
|
||||
{{-- 2. Varianten & Attribute --}}
|
||||
<flux:card class="space-y-6">
|
||||
<flux:heading size="lg" class="border-b pb-3">{{ __('2. Varianten & Attribute') }}</flux:heading>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Variantenattribute (Stammdaten)') }}</flux:label>
|
||||
<flux:input wire:model="product.variant_attributes" placeholder="Farbe, Bezug, Gestellfarbe" />
|
||||
<flux:description>{{ __('Merkmale, die die SKUs definieren') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Varianten (Kombinationen)') }}</flux:label>
|
||||
<flux:input wire:model="product.variants" placeholder="Anthrazit / Stoff A / Eiche hell" />
|
||||
<flux:description>{{ __('Konkrete Ausprägungen') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Weitere Attribute') }}</flux:label>
|
||||
<flux:input wire:model="product.additional_attributes" placeholder="Sitzhärte: mittel" />
|
||||
<flux:description>{{ __('Zusatzinfos (z. B. Sitzhärte, Stil)') }}</flux:description>
|
||||
</flux:field>
|
||||
</flux:card>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- TAB 2: BILDER - Upload & Verwaltung --}}
|
||||
@if($activeTab === 'bilder')
|
||||
<div class="space-y-8">
|
||||
|
||||
{{-- Hauptbild & Galerie --}}
|
||||
<flux:card class="space-y-6">
|
||||
<flux:heading size="lg" class="border-b pb-3">{{ __('Produktbilder') }}</flux:heading>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Hauptbild') }} <span class="text-red-500">*</span></flux:label>
|
||||
{{-- <flux:input type="file" wire:model="product.main_image" accept="image/*" /> --}}
|
||||
<flux:description>{{ __('Hauptansicht des Produkts (min. 1200x1200px, max. 5MB)') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="border-2 border-dashed border-zinc-300 dark:border-zinc-600 rounded-lg p-4 flex items-center justify-center h-40">
|
||||
<div class="text-center">
|
||||
<flux:icon.photo class="h-12 w-12 mx-auto text-zinc-400 mb-2" />
|
||||
<span class="text-sm text-zinc-500">{{ __('Hauptbild Vorschau') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<flux:separator />
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Produktgalerie') }}</flux:label>
|
||||
{{-- <flux:input type="file" wire:model="product.gallery_images" accept="image/*" multiple /> --}}
|
||||
<flux:description>{{ __('Mehrere Bilder hochladen (max. 10 Bilder, je max. 5MB)') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
@for($i = 1; $i <= 8; $i++)
|
||||
<div class="border-2 border-dashed border-zinc-300 dark:border-zinc-600 rounded-lg p-4 flex items-center justify-center h-40">
|
||||
<div class="text-center">
|
||||
<flux:icon.photo class="h-8 w-8 mx-auto text-zinc-400 mb-1" />
|
||||
<span class="text-xs text-zinc-500">{{ __('Bild') }} {{ $i }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@endfor
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Detailbilder & Ansichten --}}
|
||||
<flux:card class="space-y-6">
|
||||
<flux:heading size="lg" class="border-b pb-3">{{ __('Detailansichten & Perspektiven') }}</flux:heading>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Vorderansicht') }}</flux:label>
|
||||
{{-- <flux:input type="file" wire:model="product.view_front" accept="image/*" /> --}}
|
||||
<flux:description>{{ __('Frontale Produktansicht') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Rückansicht') }}</flux:label>
|
||||
{{-- <flux:input type="file" wire:model="product.view_back" accept="image/*" /> --}}
|
||||
<flux:description>{{ __('Rückseite des Produkts') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Seitenansicht (links)') }}</flux:label>
|
||||
{{-- <flux:input type="file" wire:model="product.view_left" accept="image/*" /> --}}
|
||||
<flux:description>{{ __('Linke Seite') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Seitenansicht (rechts)') }}</flux:label>
|
||||
{{-- <flux:input type="file" wire:model="product.view_right" accept="image/*" /> --}}
|
||||
<flux:description>{{ __('Rechte Seite') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Detailaufnahme 1') }}</flux:label>
|
||||
{{-- <flux:input type="file" wire:model="product.detail_1" accept="image/*" /> --}}
|
||||
<flux:description>{{ __('z.B. Material-Nahaufnahme') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Detailaufnahme 2') }}</flux:label>
|
||||
{{-- <flux:input type="file" wire:model="product.detail_2" accept="image/*" /> --}}
|
||||
<flux:description>{{ __('z.B. Verarbeitung, Nähte') }}</flux:description>
|
||||
</flux:field>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Ambiente & Lifestyle --}}
|
||||
<flux:card class="space-y-6">
|
||||
<flux:heading size="lg" class="border-b pb-3">{{ __('Ambiente & Lifestyle-Bilder') }}</flux:heading>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Ambiente-Bilder') }}</flux:label>
|
||||
{{-- <flux:input type="file" wire:model="product.lifestyle_images" accept="image/*" multiple /> --}}
|
||||
<flux:description>{{ __('Produkt in Wohnsituation (max. 5 Bilder)') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
@for($i = 1; $i <= 3; $i++)
|
||||
<div class="border-2 border-dashed border-zinc-300 dark:border-zinc-600 rounded-lg p-4 flex items-center justify-center h-48">
|
||||
<div class="text-center">
|
||||
<flux:icon.home class="h-10 w-10 mx-auto text-zinc-400 mb-2" />
|
||||
<span class="text-sm text-zinc-500">{{ __('Ambiente') }} {{ $i }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@endfor
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- 360° Ansicht & Video --}}
|
||||
<flux:card class="space-y-6">
|
||||
<flux:heading size="lg" class="border-b pb-3">{{ __('360° Ansicht & Produktvideo') }}</flux:heading>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('360° Bilder') }}</flux:label>
|
||||
{{-- <flux:input type="file" wire:model="product.rotation_images" accept="image/*" multiple /> --}}
|
||||
<flux:description>{{ __('Bilder für 360° Rotation (min. 24 Bilder empfohlen)') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:separator />
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Produktvideo') }}</flux:label>
|
||||
{{-- <flux:input type="file" wire:model="product.product_video" accept="video/*" /> --}}
|
||||
<flux:description>{{ __('Kurzes Produktvideo (max. 50MB, MP4/WebM)') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Video-URL (alternativ)') }}</flux:label>
|
||||
<flux:input wire:model="product.video_url" placeholder="https://youtube.com/watch?v=..." />
|
||||
<flux:description>{{ __('YouTube, Vimeo oder andere Video-URL') }}</flux:description>
|
||||
</flux:field>
|
||||
</flux:card>
|
||||
|
||||
{{-- Technische Zeichnungen & Dokumente --}}
|
||||
<flux:card class="space-y-6">
|
||||
<flux:heading size="lg" class="border-b pb-3">{{ __('Technische Zeichnungen & Dokumente') }}</flux:heading>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Maßzeichnung') }}</flux:label>
|
||||
{{-- <flux:input type="file" wire:model="product.dimension_drawing" accept="image/*,application/pdf" /> --}}
|
||||
<flux:description>{{ __('Technische Zeichnung mit Maßen (PNG, JPG oder PDF)') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Montageanleitung') }}</flux:label>
|
||||
{{-- <flux:input type="file" wire:model="product.assembly_manual" accept="application/pdf" /> --}}
|
||||
<flux:description>{{ __('PDF mit Montageanleitung') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Datenblatt / Broschüre') }}</flux:label>
|
||||
{{-- <flux:input type="file" wire:model="product.datasheet" accept="application/pdf" /> --}}
|
||||
<flux:description>{{ __('Produktdatenblatt als PDF') }}</flux:description>
|
||||
</flux:field>
|
||||
</flux:card>
|
||||
|
||||
{{-- Bild-Metadaten & Alt-Texte --}}
|
||||
<flux:card class="space-y-6">
|
||||
<flux:heading size="lg" class="border-b pb-3">{{ __('Bild-Metadaten & SEO') }}</flux:heading>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Alt-Text (Hauptbild)') }}</flux:label>
|
||||
<flux:input wire:model="product.main_image_alt" placeholder="Sofa ALBA 3-Sitzer in Anthrazit" />
|
||||
<flux:description>{{ __('Beschreibung für Suchmaschinen und Barrierefreiheit') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Bildnachweis / Copyright') }}</flux:label>
|
||||
<flux:input wire:model="product.image_credits" placeholder="© Möbelwerk Nord 2024" />
|
||||
<flux:description>{{ __('Fotografen-Nennung oder Copyright-Hinweis') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Bildoptimierung') }}</flux:label>
|
||||
<flux:checkbox wire:model="product.auto_optimize_images">
|
||||
{{ __('Bilder automatisch für Web optimieren (Komprimierung & Skalierung)') }}
|
||||
</flux:checkbox>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Wasserzeichen') }}</flux:label>
|
||||
<flux:checkbox wire:model="product.add_watermark">
|
||||
{{ __('B2in-Wasserzeichen auf Bilder anwenden') }}
|
||||
</flux:checkbox>
|
||||
</flux:field>
|
||||
</flux:card>
|
||||
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- TAB 3: PHYSISCH - Maße & Verpackung --}}
|
||||
@if($activeTab === 'physisch')
|
||||
<div class="space-y-8">
|
||||
|
||||
{{-- 3. Maße & Gewicht (Produkt) --}}
|
||||
<flux:card class="space-y-6">
|
||||
<flux:heading size="lg" class="border-b pb-3">{{ __('3. Maße & Gewicht (Produkt)') }}</flux:heading>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Breite (mm)') }} <span class="text-red-500">*</span></flux:label>
|
||||
<flux:input type="number" wire:model="product.width" placeholder="2200" />
|
||||
<flux:description>{{ __('Gesamtbreite') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Tiefe (mm)') }} <span class="text-red-500">*</span></flux:label>
|
||||
<flux:input type="number" wire:model="product.depth" placeholder="950" />
|
||||
<flux:description>{{ __('Gesamttiefe') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Höhe (mm)') }} <span class="text-red-500">*</span></flux:label>
|
||||
<flux:input type="number" wire:model="product.height" placeholder="830" />
|
||||
<flux:description>{{ __('Gesamthöhe') }}</flux:description>
|
||||
</flux:field>
|
||||
</div>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Gewicht netto (kg)') }} <span class="text-red-500">*</span></flux:label>
|
||||
<flux:input type="number" step="0.1" wire:model="product.weight" placeholder="68" />
|
||||
<flux:description>{{ __('Möbel ohne Verpackung') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Aufbauart') }}</flux:label>
|
||||
<flux:select wire:model="product.assembly_type">
|
||||
<option value="assembled">{{ __('montiert') }}</option>
|
||||
<option value="partially">{{ __('teilmontiert') }}</option>
|
||||
<option value="disassembled">{{ __('zerlegt') }}</option>
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Montagezeit (min)') }} <span class="text-red-500">*</span></flux:label>
|
||||
<flux:input type="number" wire:model="product.assembly_time" placeholder="45" />
|
||||
<flux:description>{{ __('Aufbauzeit') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Traglast (kg)') }}</flux:label>
|
||||
<flux:input type="number" wire:model="product.load_capacity" placeholder="120" />
|
||||
<flux:description>{{ __('Belastbarkeit') }}</flux:description>
|
||||
</flux:field>
|
||||
</flux:card>
|
||||
|
||||
{{-- 4. Verpackung & Logistik --}}
|
||||
<flux:card class="space-y-6">
|
||||
<flux:heading size="lg" class="border-b pb-3">{{ __('4. Verpackung & Logistik') }}</flux:heading>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Anzahl Packstücke') }}</flux:label>
|
||||
<flux:input type="number" wire:model="product.package_count" placeholder="2" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Gesamtgewicht brutto (kg)') }}</flux:label>
|
||||
<flux:input type="number" step="0.1" wire:model="product.gross_weight" placeholder="75" />
|
||||
<flux:description>{{ __('inkl. Verpackung') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Verpackungsart') }}</flux:label>
|
||||
<flux:input wire:model="product.packaging_type" placeholder="Karton mit Kantenschutz" />
|
||||
<flux:description>{{ __('Karton, Holzrahmen usw.') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Verpackung recyclingfähig (%)') }}</flux:label>
|
||||
<flux:input type="number" wire:model="product.packaging_recyclable" placeholder="85" />
|
||||
<flux:description>{{ __('Anteil recycelbarer Materialien der Verpackung') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Kolli 1 Maße (mm)') }}</flux:label>
|
||||
<flux:input wire:model="product.package_1_dimensions" placeholder="L × B × H: 1500 × 950 × 600" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Kolli 1 Gewicht (kg)') }}</flux:label>
|
||||
<flux:input type="number" step="0.1" wire:model="product.package_1_weight" placeholder="45" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Palettenfähig') }}</flux:label>
|
||||
<flux:select wire:model="product.palletizable">
|
||||
<option value="yes">{{ __('Ja') }}</option>
|
||||
<option value="no">{{ __('Nein') }}</option>
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('HS-Code (Zolltarifnummer)') }}</flux:label>
|
||||
<flux:input wire:model="product.hs_code" placeholder="94016100" />
|
||||
</flux:field>
|
||||
</flux:card>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- TAB 4: MATERIAL & HERKUNFT - Materialien & Holzherkunft --}}
|
||||
@if($activeTab === 'material')
|
||||
<div class="space-y-8">
|
||||
|
||||
{{-- 5. Materialien & Qualität --}}
|
||||
<flux:card class="space-y-6">
|
||||
<flux:heading size="lg" class="border-b pb-3">{{ __('5. Materialien & Qualität') }}</flux:heading>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Hauptmaterial') }} <span class="text-red-500">*</span></flux:label>
|
||||
<flux:input wire:model="product.main_material" placeholder="Massivholz Buche" />
|
||||
<flux:description>{{ __('Tragende Struktur') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Oberflächenmaterial') }}</flux:label>
|
||||
<flux:input wire:model="product.surface_material" placeholder="Furnier Eiche geölt" />
|
||||
<flux:description>{{ __('Sichtflächen') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Bezugsmaterial') }}</flux:label>
|
||||
<flux:input wire:model="product.upholstery_material" placeholder="Stoff (Polyester)" />
|
||||
<flux:description>{{ __('Stoff / Leder / Synthetik') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Farbton / Dekor') }}</flux:label>
|
||||
<flux:input wire:model="product.color" placeholder="Eiche natur / Anthrazit" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Herkunftsland (Produktion)') }} <span class="text-red-500">*</span></flux:label>
|
||||
<flux:select wire:model="product.country_of_origin">
|
||||
<option value="DE">{{ __('Deutschland') }}</option>
|
||||
<option value="PL">{{ __('Polen') }}</option>
|
||||
<option value="IT">{{ __('Italien') }}</option>
|
||||
<option value="AT">{{ __('Österreich') }}</option>
|
||||
</flux:select>
|
||||
<flux:description>{{ __('ISO-Land') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Pflegehinweise') }}</flux:label>
|
||||
<flux:textarea wire:model="product.care_instructions" rows="3" placeholder="Reinigung & Pflege"></flux:textarea>
|
||||
<flux:description>{{ __('Mit feuchtem Tuch abwischen.') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Zertifikate / Labels') }}</flux:label>
|
||||
<flux:input wire:model="product.certificates" placeholder="FSC, OEKO-TEX, Blauer Engel etc." />
|
||||
</flux:field>
|
||||
</flux:card>
|
||||
|
||||
{{-- 6. Holzherkunft & EUDR --}}
|
||||
<flux:card class="space-y-6">
|
||||
<flux:heading size="lg" class="border-b pb-3">{{ __('6. Holzherkunft & EUDR') }}</flux:heading>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Holzart(en)') }} <span class="text-red-500">*</span></flux:label>
|
||||
<flux:input wire:model="product.wood_type" placeholder="Quercus robur (Eiche)" />
|
||||
<flux:description>{{ __('Botanische Bezeichnung (falls Holz enthalten)') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Herkunftsland des Holzes') }} <span class="text-red-500">*</span></flux:label>
|
||||
<flux:select wire:model="product.wood_origin_country">
|
||||
<option value="PL">{{ __('Polen') }}</option>
|
||||
<option value="DE">{{ __('Deutschland') }}</option>
|
||||
<option value="RO">{{ __('Rumänien') }}</option>
|
||||
</flux:select>
|
||||
<flux:description>{{ __('ISO-Code (falls Holz enthalten)') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Region / Provinz') }}</flux:label>
|
||||
<flux:input wire:model="product.wood_region" placeholder="Masowien" />
|
||||
<flux:description>{{ __('falls erforderlich für EUDR') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Erntejahr') }}</flux:label>
|
||||
<flux:input type="number" wire:model="product.harvest_year" placeholder="2024" />
|
||||
<flux:description>{{ __('Jahr der Holzgewinnung') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Forstbetrieb / Lieferant') }}</flux:label>
|
||||
<flux:input wire:model="product.forest_operator" placeholder="ForestPol Sp. z o.o." />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Nachhaltigkeitszertifikat') }}</flux:label>
|
||||
<flux:input wire:model="product.sustainability_certificate" placeholder="FSC C123456" />
|
||||
<flux:description>{{ __('FSC / PEFC') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Sorgfaltserklärung (EUDR-DD-Referenz)') }}</flux:label>
|
||||
<flux:input wire:model="product.eudr_reference" placeholder="EUDR-DD-2025-PL-03421" />
|
||||
<flux:description>{{ __('offiziell Referenz') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Nachweisdatei (Upload)') }}</flux:label>
|
||||
<flux:input type="file" wire:model="product.eudr_document" />
|
||||
<flux:description>{{ __('PDF / Link zum Statement') }}</flux:description>
|
||||
</flux:field>
|
||||
</flux:card>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- TAB 5: KOMMERZIELL - Preise, Verfügbarkeit & Lieferung --}}
|
||||
@if($activeTab === 'kommerziell')
|
||||
<div class="space-y-8">
|
||||
|
||||
{{-- 7. Preise & Konditionen --}}
|
||||
<flux:card class="space-y-6">
|
||||
<flux:heading size="lg" class="border-b pb-3">{{ __('7. Preise & Konditionen') }}</flux:heading>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Einkaufspreis (net)') }}</flux:label>
|
||||
<flux:input type="number" step="0.01" wire:model="product.purchase_price" placeholder="680.00" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Verkaufspreis (net)') }} <span class="text-red-500">*</span></flux:label>
|
||||
<flux:input type="number" step="0.01" wire:model="product.selling_price" placeholder="1,250.00" />
|
||||
<flux:description>{{ __('Für B2in-Plattform') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Währung') }} <span class="text-red-500">*</span></flux:label>
|
||||
<flux:select wire:model="product.currency">
|
||||
<option value="EUR">{{ __('EUR') }}</option>
|
||||
<option value="USD">{{ __('USD') }}</option>
|
||||
<option value="CHF">{{ __('CHF') }}</option>
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Steuersatz (%)') }} <span class="text-red-500">*</span></flux:label>
|
||||
<flux:input type="number" wire:model="product.tax_rate" placeholder="19" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('UVP (Brutto)') }}</flux:label>
|
||||
<flux:input type="number" step="0.01" wire:model="product.rrp" placeholder="1,499.00" />
|
||||
<flux:description>{{ __('Unverbindliche Preisempfehlung') }}</flux:description>
|
||||
</flux:field>
|
||||
</flux:card>
|
||||
|
||||
{{-- 8. Verfügbarkeit & Lieferzeit --}}
|
||||
<flux:card class="space-y-6">
|
||||
<flux:heading size="lg" class="border-b pb-3">{{ __('8. Verfügbarkeit & Lieferzeit') }}</flux:heading>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Lagerstatus') }} <span class="text-red-500">*</span></flux:label>
|
||||
<flux:select wire:model="product.stock_status">
|
||||
<option value="in_stock">{{ __('Auf Lager') }}</option>
|
||||
<option value="on_order">{{ __('Auf Bestellung') }}</option>
|
||||
<option value="out_of_stock">{{ __('Nicht verfügbar') }}</option>
|
||||
</flux:select>
|
||||
<flux:description>{{ __('Auf Lager / Auf Bestellung / Nicht verfügbar') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Lieferzeit (Wochen)') }} <span class="text-red-500">*</span></flux:label>
|
||||
<flux:input type="number" wire:model="product.delivery_time" placeholder="4-6" />
|
||||
<flux:description>{{ __('Min–Max-Spanne') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Produktionszeit (Tage)') }}</flux:label>
|
||||
<flux:input type="number" wire:model="product.production_time" placeholder="21" />
|
||||
<flux:description>{{ __('falls relevant') }}</flux:description>
|
||||
</flux:field>
|
||||
</flux:card>
|
||||
|
||||
{{-- 9. Lieferung, Montage & Service --}}
|
||||
<flux:card class="space-y-6">
|
||||
<flux:heading size="lg" class="border-b pb-3">{{ __('9. Lieferung, Montage & Service') }}</flux:heading>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Lieferart') }}</flux:label>
|
||||
<flux:select wire:model="product.delivery_type">
|
||||
<option value="pickup">{{ __('Abholung') }}</option>
|
||||
<option value="delivery">{{ __('Lieferung') }}</option>
|
||||
<option value="expedition">{{ __('Spedition') }}</option>
|
||||
<option value="parcel">{{ __('Paket') }}</option>
|
||||
</flux:select>
|
||||
<flux:description>{{ __('Abholung / Lieferung / Spedition / Paket') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Montageservice') }}</flux:label>
|
||||
<flux:select wire:model="product.assembly_service">
|
||||
<option value="yes">{{ __('Ja') }}</option>
|
||||
<option value="no">{{ __('Nein') }}</option>
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Service-Radius (km)') }}</flux:label>
|
||||
<flux:input type="number" wire:model="product.service_radius" placeholder="50" />
|
||||
<flux:description>{{ __('Für Montageservice') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Garantie (Monate)') }}</flux:label>
|
||||
<flux:input type="number" wire:model="product.warranty" placeholder="24" />
|
||||
</flux:field>
|
||||
</flux:card>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- TAB 6: VERWALTUNG - Händler, Nachhaltigkeit, Scoring & Verwaltung --}}
|
||||
@if($activeTab === 'verwaltung')
|
||||
<div class="space-y-8">
|
||||
|
||||
{{-- 10. Händler- / Herstellerzuordnung --}}
|
||||
<flux:card class="space-y-6">
|
||||
<flux:heading size="lg" class="border-b pb-3">{{ __('10. Händler- / Herstellerzuordnung') }}</flux:heading>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Verkäufertyp') }} <span class="text-red-500">*</span></flux:label>
|
||||
<flux:select wire:model="product.seller_type">
|
||||
<option value="retailer">{{ __('Händler') }}</option>
|
||||
<option value="manufacturer">{{ __('Hersteller') }}</option>
|
||||
<option value="broker">{{ __('Makler') }}</option>
|
||||
</flux:select>
|
||||
<flux:description>{{ __('Hersteller / Makler') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Verkäufername') }} <span class="text-red-500">*</span></flux:label>
|
||||
<flux:input wire:model="product.seller_name" placeholder="WohnDesign Bielefeld" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Verkäufer-ID') }}</flux:label>
|
||||
<flux:input wire:model="product.seller_id" placeholder="SELLER_xyz" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Region / Hub') }} <span class="text-red-500">*</span></flux:label>
|
||||
<flux:input wire:model="product.region" placeholder="OWL" />
|
||||
<flux:description>{{ __('Logistische Zuordnung') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Ort / PLZ') }}</flux:label>
|
||||
<flux:input wire:model="product.location" placeholder="33602 Bielefeld" />
|
||||
<flux:description>{{ __('Standort des Verkäufers/Lagers') }}</flux:description>
|
||||
</flux:field>
|
||||
</flux:card>
|
||||
|
||||
{{-- 11. Nachhaltigkeit & Umwelt --}}
|
||||
<flux:card class="space-y-6">
|
||||
<flux:heading size="lg" class="border-b pb-3">{{ __('11. Nachhaltigkeit & Umwelt') }}</flux:heading>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('CO₂-Fußabdruck (kg CO₂e) pro Stück') }}</flux:label>
|
||||
<flux:input type="number" step="0.1" wire:model="product.co2_footprint" placeholder="35" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Recyclinganteil (%)') }}</flux:label>
|
||||
<flux:input type="number" wire:model="product.recycling_rate" placeholder="40" />
|
||||
<flux:description>{{ __('Anteil recycelter Materialien im Produkt') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Regionale Produktion') }}</flux:label>
|
||||
<flux:select wire:model="product.regional_production">
|
||||
<option value="yes">{{ __('Ja') }}</option>
|
||||
<option value="no">{{ __('Nein') }}</option>
|
||||
</flux:select>
|
||||
<flux:description>{{ __('Ja / Nein (Umkreis z. B. < 500 km)') }}</flux:description>
|
||||
</flux:field>
|
||||
</flux:card>
|
||||
|
||||
{{-- 12. Scoring-System (B2in Internal) --}}
|
||||
<flux:card class="space-y-6">
|
||||
<flux:heading size="lg" class="border-b pb-3">{{ __('12. Scoring-System (B2in Internal)') }}</flux:heading>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Stauraumvolumen (L)') }}</flux:label>
|
||||
<flux:input type="number" wire:model="product.storage_volume" placeholder="280" />
|
||||
<flux:description>{{ __('Innenvolumen') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Aufbauaufwand (1–5)') }}</flux:label>
|
||||
<flux:select wire:model="product.assembly_effort">
|
||||
<option value="1">1 - {{ __('Sehr einfach') }}</option>
|
||||
<option value="2">2 - {{ __('Einfach') }}</option>
|
||||
<option value="3">3 - {{ __('Mittel') }}</option>
|
||||
<option value="4">4 - {{ __('Anspruchsvoll') }}</option>
|
||||
<option value="5">5 - {{ __('Sehr anspruchsvoll') }}</option>
|
||||
</flux:select>
|
||||
<flux:description>{{ __('gering = 1') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Designpunkte (1–5)') }}</flux:label>
|
||||
<flux:select wire:model="product.design_score">
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5">5</option>
|
||||
</flux:select>
|
||||
<flux:description>{{ __('interne Bewertung') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Gesamt-Score') }}</flux:label>
|
||||
<flux:input type="number" step="0.1" wire:model="product.total_score" placeholder="4.2" disabled />
|
||||
<flux:description>{{ __('automatisch berechnet') }}</flux:description>
|
||||
</flux:field>
|
||||
</flux:card>
|
||||
|
||||
{{-- 13. Verwaltung & Lebenszyklus --}}
|
||||
<flux:card class="space-y-6">
|
||||
<flux:heading size="lg" class="border-b pb-3">{{ __('13. Verwaltung & Lebenszyklus') }}</flux:heading>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Sichtbar ab / bis (Datum)') }}</flux:label>
|
||||
<flux:input type="date" wire:model="product.visible_from" placeholder="2025-01-01 / 2026-01-01" />
|
||||
<flux:description>{{ __('Steuerung der Veröffentlichung') }}</flux:description>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Freigabe durch B2in erforderlich') }}</flux:label>
|
||||
<flux:select wire:model="product.requires_approval">
|
||||
<option value="yes">{{ __('Ja') }}</option>
|
||||
<option value="no">{{ __('Nein') }}</option>
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Letzte Änderung') }}</flux:label>
|
||||
<flux:input type="date" wire:model="product.last_modified" value="2025-11-04" disabled />
|
||||
<flux:description>{{ __('Datum der letzten Aktualisierung') }}</flux:description>
|
||||
</flux:field>
|
||||
</flux:card>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Submit Button (außerhalb der Tabs, immer sichtbar) --}}
|
||||
<div class="flex justify-end gap-4 pt-6 border-t">
|
||||
<flux:button variant="ghost" href="{{ route('dashboard') }}">{{ __('Abbrechen') }}</flux:button>
|
||||
<flux:button type="submit" variant="primary" icon="check">{{ __('Produkt speichern') }}</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
1981
resources/views/livewire/products/form-standard.blade.php
Normal file
714
resources/views/livewire/products/form-teaser.blade.php
Normal file
|
|
@ -0,0 +1,714 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\PriceType;
|
||||
use App\Enums\ProductStatus;
|
||||
use App\Enums\ProductType;
|
||||
use App\Models\Category;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductVariant;
|
||||
use Flux\Flux;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use function Livewire\Volt\{layout, title};
|
||||
|
||||
layout('components.layouts.app');
|
||||
title('Teaser-Produkt');
|
||||
|
||||
new class extends Component {
|
||||
use WithFileUploads;
|
||||
|
||||
public ?Product $product = null;
|
||||
public bool $isEditing = false;
|
||||
public array $existingMedia = [];
|
||||
|
||||
// Produkt-Felder (Typ A – Teaser / Local Stock)
|
||||
public string $name = '';
|
||||
public string $descriptionShort = '';
|
||||
public string $priceType = 'from_price';
|
||||
public string $priceDisplayText = '';
|
||||
public ?int $categoryId = null;
|
||||
public string $status = 'active';
|
||||
public string $partnerProductNumber = '';
|
||||
|
||||
// Bildupload
|
||||
public array $mainImages = [];
|
||||
|
||||
public function mount(?Product $product = null): void
|
||||
{
|
||||
if ($product && $product->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<int> $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;
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-6 p-6">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center gap-4">
|
||||
<flux:button href="{{ route('products.index') }}" variant="ghost" icon="arrow-left" />
|
||||
<div>
|
||||
<flux:heading size="xl" class="mb-1">
|
||||
{{ $isEditing ? __('Teaser-Produkt bearbeiten') : __('Neues Produkt anlegen') }}
|
||||
</flux:heading>
|
||||
<flux:subheading>
|
||||
<flux:badge :color="$isEditing ? 'amber' : 'blue'" size="sm" icon="tag">
|
||||
{{ __('Teaser-Produkt') }}</flux:badge>
|
||||
{{ __('Typ A – Beratungspflicht, Ticket erforderlich') }}
|
||||
</flux:subheading>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Kuration-Hinweis (Korrektur oder Ablehnung) --}}
|
||||
@if ($isEditing && $product->curation_notes && in_array($product->status, [ProductStatus::Correction, ProductStatus::Archived]))
|
||||
<flux:callout
|
||||
variant="{{ $product->status === ProductStatus::Correction ? 'warning' : 'danger' }}"
|
||||
icon="{{ $product->status === ProductStatus::Correction ? 'exclamation-triangle' : 'x-circle' }}">
|
||||
<flux:callout.heading>
|
||||
{{ $product->status === ProductStatus::Correction ? __('Korrektur erforderlich') : __('Produkt abgelehnt') }}
|
||||
</flux:callout.heading>
|
||||
<flux:callout.text>{{ $product->curation_notes }}</flux:callout.text>
|
||||
</flux:callout>
|
||||
@endif
|
||||
|
||||
<form wire:submit="save" class="space-y-6">
|
||||
|
||||
{{-- Bild-Upload --}}
|
||||
<flux:card class="shadow-elegant">
|
||||
<div class="mb-4">
|
||||
<flux:heading size="lg">{{ $isEditing ? __('Produktbilder') : __('Produktbild') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Nur Bilder (JPG, JPEG, PNG) – max. 10 MB pro Bild, max. 10 Bilder') }}
|
||||
</flux:subheading>
|
||||
</div>
|
||||
<flux:separator class="mb-6" />
|
||||
|
||||
{{-- Existing images (only in edit mode) - sortable via drag & drop --}}
|
||||
@if ($isEditing && count($existingMedia) > 0)
|
||||
<div class="mb-6">
|
||||
<flux:heading size="sm" class="mb-3">{{ __('Vorhandene Bilder') }}</flux:heading>
|
||||
<flux:text size="sm" class="mb-3 text-zinc-500">
|
||||
{{ __('Per Drag & Drop sortieren – das erste Bild ist das Standardbild.') }}</flux:text>
|
||||
<div x-data="{
|
||||
dragging: null,
|
||||
dragOver: null,
|
||||
items: @js(collect($existingMedia)->pluck('id')->toArray()),
|
||||
onDragStart(e, id) {
|
||||
this.dragging = id;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', id);
|
||||
},
|
||||
onDragOver(e, id) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
this.dragOver = id;
|
||||
},
|
||||
onDrop(e, targetId) {
|
||||
e.preventDefault();
|
||||
if (this.dragging === targetId) { this.dragOver = null; return; }
|
||||
const fromIdx = this.items.indexOf(this.dragging);
|
||||
const toIdx = this.items.indexOf(targetId);
|
||||
this.items.splice(fromIdx, 1);
|
||||
this.items.splice(toIdx, 0, this.dragging);
|
||||
this.dragging = null;
|
||||
this.dragOver = null;
|
||||
$wire.updateMediaOrder(this.items);
|
||||
},
|
||||
onDragEnd() {
|
||||
this.dragging = null;
|
||||
this.dragOver = null;
|
||||
}
|
||||
}" class="flex flex-wrap items-start gap-3">
|
||||
@foreach ($existingMedia as $mediaIndex => $media)
|
||||
<div wire:key="existing-media-{{ $media['id'] }}" draggable="true"
|
||||
x-on:dragstart="onDragStart($event, {{ $media['id'] }})"
|
||||
x-on:dragover="onDragOver($event, {{ $media['id'] }})"
|
||||
x-on:drop="onDrop($event, {{ $media['id'] }})" x-on:dragend="onDragEnd()"
|
||||
:class="{
|
||||
'opacity-50 scale-95': dragging === {{ $media['id'] }},
|
||||
'ring-2 ring-blue-400 ring-offset-2 dark:ring-offset-zinc-800': dragOver ===
|
||||
{{ $media['id'] }} && dragging !== {{ $media['id'] }}
|
||||
}"
|
||||
class="group relative cursor-grab transition-all duration-150 active:cursor-grabbing">
|
||||
@if ($mediaIndex === 0)
|
||||
<div
|
||||
class="absolute -left-1 -top-1 z-10 rounded-md bg-blue-600 px-1.5 py-0.5 text-[10px] font-bold text-white shadow">
|
||||
{{ __('Standard') }}
|
||||
</div>
|
||||
@endif
|
||||
<img src="{{ Storage::url($media['file_path']) }}"
|
||||
alt="{{ $media['alt_text'] ?? '' }}"
|
||||
class="h-24 w-24 rounded-lg border border-zinc-200 object-cover dark:border-zinc-700 {{ $mediaIndex === 0 ? 'ring-2 ring-blue-500' : '' }}" />
|
||||
<flux:button wire:click="removeExistingMedia({{ $media['id'] }})"
|
||||
wire:confirm="{{ __('Bild wirklich löschen?') }}" variant="filled" size="xs"
|
||||
icon="trash"
|
||||
class="absolute -right-2 -top-2 !bg-red-500 !text-white hover:!bg-red-600" />
|
||||
<div
|
||||
class="absolute bottom-1 left-1/2 -translate-x-1/2 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<flux:icon.arrows-up-down class="h-4 w-4 text-white drop-shadow-md" />
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
<flux:separator class="mb-6" />
|
||||
@endif
|
||||
|
||||
{{-- Upload area --}}
|
||||
<flux:file-upload wire:model="mainImages" label="Upload files" multiple
|
||||
accept="image/jpeg,image/png,.jpg,.jpeg,.png">
|
||||
<flux:file-upload.dropzone heading="{{ __('Bilder hochladen') }}"
|
||||
text="{{ __('Nur JPEG oder PNG – max. 10 MB') }}" with-progress />
|
||||
</flux:file-upload>
|
||||
|
||||
@if (isset($mainImages) && count($mainImages) > 0)
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||
@foreach ($mainImages as $index => $image)
|
||||
<flux:file-item :heading="$image->getClientOriginalName()"
|
||||
:image="(str_starts_with($image->getMimeType() ?? '', 'image/') && $image->isPreviewable()) ? $image->temporaryUrl() : null"
|
||||
:size="$image->getSize()">
|
||||
<x-slot name="actions">
|
||||
<flux:file-item.remove wire:click="removePhoto({{ $index }})"
|
||||
aria-label="{{ 'Remove file: ' . $image->getClientOriginalName() }}" />
|
||||
</x-slot>
|
||||
</flux:file-item>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<flux:error name="mainImages" />
|
||||
</flux:card>
|
||||
|
||||
|
||||
|
||||
{{-- Produktinformationen --}}
|
||||
<flux:card class="shadow-elegant">
|
||||
<div class="mb-4">
|
||||
<flux:heading size="lg">{{ __('Produktinformationen') }}</flux:heading>
|
||||
</div>
|
||||
<flux:separator class="mb-6" />
|
||||
|
||||
<div class="space-y-4">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Produktname') }}</flux:label>
|
||||
<flux:input wire:model="name" placeholder="{{ __('z.B. Sideboard Eiche massiv') }}"
|
||||
icon="tag" />
|
||||
<flux:error name="name" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Kurzbeschreibung') }}</flux:label>
|
||||
<flux:description>{{ __('Max. 180 Zeichen – wird im Feed und auf der Karte angezeigt') }}
|
||||
</flux:description>
|
||||
<flux:textarea wire:model.live="descriptionShort"
|
||||
placeholder="{{ __('Massivholz-Sideboard aus OWL, individuell auf Maß gefertigbar...') }}"
|
||||
rows="3" maxlength="180" />
|
||||
<div class="mt-1 flex justify-between text-xs text-zinc-400">
|
||||
<span>{{ strlen($descriptionShort) }} / 180</span>
|
||||
</div>
|
||||
<flux:error name="descriptionShort" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Kategorie') }}</flux:label>
|
||||
<flux:select wire:model="categoryId">
|
||||
<flux:select.option :value="null">{{ __('– Bitte wählen –') }}</flux:select.option>
|
||||
@foreach ($categories as $category)
|
||||
<flux:select.option :value="$category->id">{{ $category->name }}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:error name="categoryId" />
|
||||
</flux:field>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Produktnummern --}}
|
||||
<flux:card class="shadow-elegant">
|
||||
<div class="mb-4">
|
||||
<flux:heading size="lg">{{ __('Produktnummern') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Interne und B2in-Artikelnummer') }}</flux:subheading>
|
||||
</div>
|
||||
<flux:separator class="mb-6" />
|
||||
|
||||
<div class="space-y-4">
|
||||
@if ($isEditing && $product->b2in_article_number)
|
||||
<flux:field>
|
||||
<flux:label>{{ __('B2in-Artikelnummer') }}</flux:label>
|
||||
<flux:badge color="blue" size="sm" icon="hashtag">
|
||||
{{ $product->b2in_article_number }}</flux:badge>
|
||||
</flux:field>
|
||||
@endif
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Partner-Produktnummer') }}</flux:label>
|
||||
<flux:description>
|
||||
{{ __('Eigene interne Artikelnummer (optional, wird automatisch vorgeschlagen)') }}
|
||||
</flux:description>
|
||||
<flux:input wire:model="partnerProductNumber" placeholder="P001-0001" icon="hashtag" />
|
||||
<flux:error name="partnerProductNumber" />
|
||||
</flux:field>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Preisangabe (Typ A: kein Festpreis) --}}
|
||||
<flux:card class="shadow-elegant">
|
||||
<div class="mb-4">
|
||||
<flux:heading size="lg">{{ __('Preisangabe') }}</flux:heading>
|
||||
<flux:subheading>
|
||||
{{ __('Teaser-Produkte haben keine fixen Online-Preise. Wählen Sie Art der Preisangabe.') }}
|
||||
</flux:subheading>
|
||||
</div>
|
||||
<flux:separator class="mb-6" />
|
||||
|
||||
<div class="space-y-4">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Preistyp') }}</flux:label>
|
||||
<flux:select wire:model.live="priceType">
|
||||
@foreach ($allowedPriceTypes as $type)
|
||||
<flux:select.option :value="$type->value">{{ $type->label() }}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:error name="priceType" />
|
||||
</flux:field>
|
||||
|
||||
@if ($priceType === 'from_price')
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Preisangabe (Freitext)') }}</flux:label>
|
||||
<flux:description>{{ __('z.B. "Ab 2.500 €" oder "Ab 1.800 € pro Laufmeter"') }}
|
||||
</flux:description>
|
||||
<flux:input wire:model="priceDisplayText" placeholder="{{ __('Ab 2.500 €') }}"
|
||||
icon="currency-euro" />
|
||||
<flux:error name="priceDisplayText" />
|
||||
</flux:field>
|
||||
@endif
|
||||
|
||||
<flux:callout variant="info" icon="information-circle">
|
||||
<flux:callout.heading>{{ __('Warum kein Festpreis?') }}</flux:callout.heading>
|
||||
<flux:callout.text>
|
||||
{{ __('Teaser-Produkte sind bewusst ohne finale Online-Konfiguration. Der Kunde kommt in Ihren Laden – dort erfolgt die finale Planung und Preisfestlegung.') }}
|
||||
</flux:callout.text>
|
||||
</flux:callout>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Status & Verfügbarkeit --}}
|
||||
<flux:card class="shadow-elegant">
|
||||
<div class="mb-4">
|
||||
<flux:heading size="lg">{{ __('Veröffentlichung') }}</flux:heading>
|
||||
</div>
|
||||
<flux:separator class="mb-6" />
|
||||
|
||||
<div class="space-y-4">
|
||||
@if ($isEditing)
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Aktueller Status') }}</flux:label>
|
||||
<div>
|
||||
<flux:badge :color="$product->status->color()" size="sm">
|
||||
{{ $product->status->label() }}</flux:badge>
|
||||
</div>
|
||||
</flux:field>
|
||||
|
||||
@if ($product->status === \App\Enums\ProductStatus::Correction && $product->curation_notes)
|
||||
<flux:callout variant="warning" icon="exclamation-triangle">
|
||||
<flux:callout.heading>{{ __('Korrektur erforderlich') }}</flux:callout.heading>
|
||||
<flux:callout.text>{{ $product->curation_notes }}</flux:callout.text>
|
||||
</flux:callout>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Status') }}</flux:label>
|
||||
<flux:select wire:model="status">
|
||||
<flux:select.option value="active">
|
||||
{{ __('Zur Freigabe einreichen') }}
|
||||
</flux:select.option>
|
||||
<flux:select.option value="draft">{{ __('Entwurf – erst speichern') }}</flux:select.option>
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
|
||||
<flux:callout variant="info" icon="shield-check">
|
||||
<flux:callout.heading>{{ __('Freigabe-Workflow') }}</flux:callout.heading>
|
||||
<flux:callout.text>
|
||||
{{ __('Eingereichte Produkte werden vom Admin geprüft. Nach Freigabe wird das Produkt veröffentlicht. Bei Korrekturbedarf erhalten Sie eine Rückmeldung.') }}
|
||||
</flux:callout.text>
|
||||
</flux:callout>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Aktivitätsprotokoll (only in edit mode) --}}
|
||||
@if ($isEditing && $activities->isNotEmpty())
|
||||
<flux:card class="shadow-elegant">
|
||||
<div class="mb-4">
|
||||
<flux:heading size="lg">{{ __('Aktivitätsprotokoll') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Letzte Änderungen an diesem Produkt') }}</flux:subheading>
|
||||
</div>
|
||||
<flux:separator class="mb-6" />
|
||||
|
||||
<div class="space-y-2">
|
||||
@foreach ($activities as $activity)
|
||||
<div wire:key="activity-{{ $activity->id }}"
|
||||
class="flex items-center justify-between rounded-lg border border-zinc-100 px-4 py-2 text-sm dark:border-zinc-700">
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:icon.user-circle class="h-4 w-4 text-zinc-400" />
|
||||
<span
|
||||
class="font-medium text-zinc-700 dark:text-zinc-300">{{ $activity->user?->name ?? __('System') }}</span>
|
||||
<span class="text-zinc-500 dark:text-zinc-400">{{ $activity->action }}</span>
|
||||
@if ($activity->note)
|
||||
<span class="text-zinc-400">– {{ $activity->note }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<span
|
||||
class="text-xs text-zinc-400">{{ $activity->created_at->format('d.m.Y H:i') }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
|
||||
{{-- Aktionen --}}
|
||||
<div class="flex items-center justify-between">
|
||||
@if ($isEditing)
|
||||
<div class="flex gap-2">
|
||||
<flux:button wire:click="markAsSold" wire:confirm="{{ __('Produkt wirklich als verkauft markieren?') }}"
|
||||
variant="filled" size="sm" icon="check-badge">
|
||||
{{ __('Als verkauft markieren') }}
|
||||
</flux:button>
|
||||
<flux:button wire:click="archiveProduct" wire:confirm="{{ __('Produkt wirklich archivieren? Diese Aktion kann nicht rückgängig gemacht werden.') }}"
|
||||
variant="danger" size="sm" icon="archive-box">
|
||||
{{ __('Archivieren') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
@else
|
||||
<div></div>
|
||||
@endif
|
||||
<div class="flex gap-3">
|
||||
<flux:button href="{{ route('products.index') }}" variant="ghost">
|
||||
{{ __('Abbrechen') }}
|
||||
</flux:button>
|
||||
<flux:button type="submit" variant="primary" :icon="$isEditing ? 'check' : 'plus'"
|
||||
wire:loading.attr="disabled" wire:target="save">
|
||||
<span wire:loading.remove wire:target="save">
|
||||
{{ $isEditing ? __('Änderungen speichern') : __('Produkt anlegen') }}
|
||||
</span>
|
||||
<span wire:loading wire:target="save">
|
||||
<flux:icon.arrow-path class="animate-spin inline-block mr-1 h-4 w-4" />
|
||||
{{ __('Wird gespeichert...') }}
|
||||
</span>
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -1,398 +1,344 @@
|
|||
<?php
|
||||
|
||||
use function Livewire\Volt\{state, mount, computed};
|
||||
use App\Enums\ProductStatus;
|
||||
use App\Models\Category;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithPagination;
|
||||
use function Livewire\Volt\{layout, title};
|
||||
|
||||
state([
|
||||
'search' => '',
|
||||
'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';
|
||||
|
||||
<div class="space-y-6">
|
||||
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,
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-6 p-6">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<flux:heading size="xl">{{ __('Produkte') }} (in Entwicklung)</flux:heading>
|
||||
<flux:subheading>{{ __('Verwalten Sie Ihre Produktliste') }}</flux:subheading>
|
||||
<flux:heading size="xl">{{ __('Produkte') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Produktübersicht und Local Feed') }}</flux:subheading>
|
||||
</div>
|
||||
<flux:button variant="primary" icon="plus" :href="route('products.create')" wire:navigate>
|
||||
{{ __('Neues Produkt') }}
|
||||
</flux:button>
|
||||
@if (
|
||||
!$isCustomer &&
|
||||
auth()->user()->hasAnyRole(['Retailer', 'Manufacturer', 'Admin', 'Super-Admin']))
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:button variant="primary" icon="plus" href="{{ route('products.create.teaser') }}">
|
||||
{{ __('Neues Teaser-Produkt') }}
|
||||
</flux:button>
|
||||
<flux:button variant="primary" icon="plus" href="{{ route('products.create.standard') }}">
|
||||
{{ __('Neues Standard-Produkt') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (session()->has('message'))
|
||||
<flux:callout variant="success" icon="check-circle">
|
||||
{{ session('message') }}
|
||||
</flux:callout>
|
||||
@endif
|
||||
|
||||
{{-- Filter & Suche --}}
|
||||
<flux:card class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{{-- Suchfeld --}}
|
||||
<div class="md:col-span-2">
|
||||
<flux:input
|
||||
wire:model.live.debounce.300ms="search"
|
||||
placeholder="{{ __('Suche nach Name, Artikelnummer...') }}"
|
||||
icon="magnifying-glass"
|
||||
/>
|
||||
</div>
|
||||
<flux:card class="shadow-elegant">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<flux:field class="flex-1">
|
||||
<flux:label>{{ __('Suche') }}</flux:label>
|
||||
<flux:input wire:model.live.debounce.300ms="search" placeholder="{{ __('Name, Artikelnummer...') }}"
|
||||
icon="magnifying-glass" />
|
||||
</flux:field>
|
||||
|
||||
{{-- Status Filter --}}
|
||||
<flux:select wire:model.live="statusFilter">
|
||||
<option value="all">{{ __('Alle Status') }}</option>
|
||||
<option value="active">{{ __('Aktiv') }}</option>
|
||||
<option value="draft">{{ __('Entwurf') }}</option>
|
||||
<option value="inactive">{{ __('Inaktiv') }}</option>
|
||||
</flux:select>
|
||||
@if (!$isCustomer)
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Status') }}</flux:label>
|
||||
<flux:select wire:model.live="statusFilter">
|
||||
<flux:select.option value="">{{ __('Alle Status') }}</flux:select.option>
|
||||
<flux:select.option value="pending">{{ __('In Prüfung') }}</flux:select.option>
|
||||
<flux:select.option value="correction">{{ __('Korrektur nötig') }}</flux:select.option>
|
||||
<flux:select.option value="active">{{ __('Freigegeben') }}</flux:select.option>
|
||||
<flux:select.option value="draft">{{ __('Entwurf') }}</flux:select.option>
|
||||
<flux:select.option value="archived">{{ __('Archiviert') }}</flux:select.option>
|
||||
<flux:select.option value="sold">{{ __('Verkauft') }}</flux:select.option>
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
@endif
|
||||
|
||||
{{-- Kategorie Filter --}}
|
||||
<flux:select wire:model.live="categoryFilter">
|
||||
<option value="all">{{ __('Alle Kategorien') }}</option>
|
||||
<option value="sofas">{{ __('Sofas') }}</option>
|
||||
<option value="chairs">{{ __('Stühle') }}</option>
|
||||
<option value="tables">{{ __('Tische') }}</option>
|
||||
<option value="beds">{{ __('Betten') }}</option>
|
||||
<option value="wardrobes">{{ __('Schränke') }}</option>
|
||||
</flux:select>
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Produkttyp') }}</flux:label>
|
||||
<flux:select wire:model.live="productTypeFilter">
|
||||
<flux:select.option value="">{{ __('Alle Typen') }}</flux:select.option>
|
||||
<flux:select.option value="local_stock">{{ __('Teaser (Local Express)') }}</flux:select.option>
|
||||
<flux:select.option value="smart_order">{{ __('Standard (Smart Club)') }}</flux:select.option>
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Kategorie') }}</flux:label>
|
||||
<flux:select wire:model.live="categoryFilter">
|
||||
<flux:select.option value="">{{ __('Alle Kategorien') }}</flux:select.option>
|
||||
@foreach ($categories as $category)
|
||||
<flux:select.option :value="$category->id">{{ $category->name }}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
</div>
|
||||
|
||||
{{-- Aktive Filter Anzeige --}}
|
||||
@if($search || $statusFilter !== 'all' || $categoryFilter !== 'all')
|
||||
<div class="flex items-center gap-2 mt-4 pt-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||
<span class="text-sm text-zinc-600 dark:text-zinc-400">{{ __('Aktive Filter:') }}</span>
|
||||
@if($search)
|
||||
<flux:badge color="blue">
|
||||
{{ __('Suche: ') }}{{ $search }}
|
||||
<button wire:click="$set('search', '')" class="ml-1">×</button>
|
||||
</flux:badge>
|
||||
@endif
|
||||
@if($statusFilter !== 'all')
|
||||
<flux:badge color="green">
|
||||
{{ __('Status: ') }}{{ __($statusFilter) }}
|
||||
<button wire:click="$set('statusFilter', 'all')" class="ml-1">×</button>
|
||||
</flux:badge>
|
||||
@endif
|
||||
@if($categoryFilter !== 'all')
|
||||
<flux:badge color="purple">
|
||||
{{ __('Kategorie: ') }}{{ __($categoryFilter) }}
|
||||
<button wire:click="$set('categoryFilter', 'all')" class="ml-1">×</button>
|
||||
</flux:badge>
|
||||
@endif
|
||||
<button
|
||||
wire:click="$set('search', ''); $set('statusFilter', 'all'); $set('categoryFilter', 'all')"
|
||||
class="text-sm text-accent-600 hover:text-accent-700 dark:text-accent-400 ml-2"
|
||||
>
|
||||
{{ __('Alle Filter zurücksetzen') }}
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
|
||||
{{-- Produkttabelle --}}
|
||||
<flux:card>
|
||||
<flux:card class="shadow-elegant">
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column class="w-20">{{ __('Bild') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Produkt') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Marke') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Kategorie') }}</flux:table.column>
|
||||
<flux:table.column class="text-right">{{ __('Preis') }}</flux:table.column>
|
||||
<flux:table.column class="text-center">{{ __('Status') }}</flux:table.column>
|
||||
<flux:table.column class="text-center">{{ __('Lager') }}</flux:table.column>
|
||||
@if ($isAdmin)
|
||||
<flux:table.column>{{ __('Händler') }}</flux:table.column>
|
||||
@endif
|
||||
@if ($isCustomer)
|
||||
<flux:table.column>{{ __('Händler') }}</flux:table.column>
|
||||
@else
|
||||
<flux:table.column>{{ __('Kategorie') }}</flux:table.column>
|
||||
@endif
|
||||
<flux:table.column>{{ __('Preis') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Status') }}</flux:table.column>
|
||||
@if ($isAdmin)
|
||||
<flux:table.column>{{ __('Kuration') }}</flux:table.column>
|
||||
@endif
|
||||
<flux:table.column>{{ __('Erstellt') }}</flux:table.column>
|
||||
<flux:table.column class="text-right w-32">{{ __('Aktionen') }}</flux:table.column>
|
||||
<flux:table.column></flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
<flux:table.rows>
|
||||
@forelse($this->products as $product)
|
||||
<flux:table.row :key="$product['id']" class="odd:bg-zinc-50 dark:odd:bg-zinc-800/30">
|
||||
{{-- Bild --}}
|
||||
<flux:table.cell>
|
||||
<div class="w-12 h-12 bg-zinc-200 dark:bg-zinc-700 rounded flex items-center justify-center">
|
||||
<flux:icon.photo class="w-6 h-6 text-zinc-400" />
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
|
||||
{{-- Produkt --}}
|
||||
<flux:table.cell>
|
||||
<div>
|
||||
<div class="font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{{ $product['name'] }}
|
||||
@forelse($products as $product)
|
||||
<flux:table.row wire:key="product-{{ $product->id }}">
|
||||
{{-- Produkt --}}
|
||||
<flux:table.cell>
|
||||
<div class="flex items-center gap-3">
|
||||
@php
|
||||
$thumbnail = $product->media->sortBy('order_column')->first();
|
||||
@endphp
|
||||
@if ($thumbnail)
|
||||
<img src="{{ Storage::url($thumbnail->file_path) }}"
|
||||
alt="{{ $thumbnail->alt_text ?? $product->name }}"
|
||||
class="h-14 w-14 shrink-0 rounded-lg border border-zinc-200 object-cover dark:border-zinc-700" />
|
||||
@else
|
||||
<div
|
||||
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border border-zinc-200 bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<flux:icon.photo class="h-5 w-5 text-zinc-400" />
|
||||
</div>
|
||||
@endif
|
||||
<div>
|
||||
<div class="font-medium text-zinc-900 dark:text-white">
|
||||
{{ $product->name }}
|
||||
</div>
|
||||
<flux:badge size="sm"
|
||||
color="{{ $product->product_type?->value === 'local_stock' ? 'amber' : 'blue' }}"
|
||||
class="mt-1">
|
||||
{{ $product->product_type?->label() ?? '–' }}
|
||||
</flux:badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400 space-y-0.5 mt-1">
|
||||
<div>B2in: {{ $product['b2in_number'] }}</div>
|
||||
<div>Art.-Nr.: {{ $product['supplier_number'] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.cell>
|
||||
|
||||
{{-- Marke --}}
|
||||
<flux:table.cell>
|
||||
<span class="text-sm text-zinc-700 dark:text-zinc-300">
|
||||
{{ $product['brand'] }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
{{-- Händler (nur Admin) --}}
|
||||
@if ($isAdmin)
|
||||
<flux:table.cell>
|
||||
<span class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{{ $product->partner?->company_name ?? '–' }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
@endif
|
||||
|
||||
{{-- Kategorie --}}
|
||||
<flux:table.cell>
|
||||
<span class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{{ $product['category'] }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
{{-- Händler (Customer) oder Kategorie (Partner) --}}
|
||||
@if ($isCustomer)
|
||||
<flux:table.cell>
|
||||
<span class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{{ $product->partner?->company_name ?? '–' }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
@else
|
||||
<flux:table.cell>
|
||||
<span class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{{ $product->categories->first()?->name ?? '–' }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
@endif
|
||||
|
||||
{{-- Preis --}}
|
||||
<flux:table.cell class="text-right">
|
||||
<span class="font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{{ number_format($product['price'], 2, ',', '.') }} €
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
{{-- Preis --}}
|
||||
<flux:table.cell>
|
||||
@if ($product->price_type?->value === 'on_request')
|
||||
<span class="text-sm text-zinc-500">{{ __('Auf Anfrage') }}</span>
|
||||
@elseif($product->price_display_text)
|
||||
<span class="text-sm font-medium">{{ $product->price_display_text }}</span>
|
||||
@elseif($product->price)
|
||||
<span class="font-semibold">{{ number_format($product->price, 2, ',', '.') }} €</span>
|
||||
@else
|
||||
<span class="text-zinc-400">–</span>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
|
||||
{{-- Status --}}
|
||||
<flux:table.cell class="text-center">
|
||||
@php
|
||||
$statusColors = [
|
||||
'active' => 'green',
|
||||
'draft' => 'yellow',
|
||||
'inactive' => 'zinc',
|
||||
];
|
||||
$statusLabels = [
|
||||
'active' => __('Aktiv'),
|
||||
'draft' => __('Entwurf'),
|
||||
'inactive' => __('Inaktiv'),
|
||||
];
|
||||
@endphp
|
||||
<flux:badge :color="$statusColors[$product['status']]" size="sm">
|
||||
{{ $statusLabels[$product['status']] }}
|
||||
</flux:badge>
|
||||
</flux:table.cell>
|
||||
{{-- Status --}}
|
||||
<flux:table.cell>
|
||||
<flux:badge size="sm" color="{{ $product->status?->color() ?? 'zinc' }}">
|
||||
{{ $product->status?->label() ?? '–' }}
|
||||
</flux:badge>
|
||||
</flux:table.cell>
|
||||
|
||||
{{-- Lagerstatus --}}
|
||||
<flux:table.cell class="text-center">
|
||||
@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
|
||||
<flux:badge :color="$stockColors[$product['stock_status']]" size="sm" variant="outline">
|
||||
{{ $stockLabels[$product['stock_status']] }}
|
||||
</flux:badge>
|
||||
</flux:table.cell>
|
||||
{{-- Kuration (nur Admin) --}}
|
||||
@if ($isAdmin)
|
||||
<flux:table.cell>
|
||||
@if ($product->is_curated)
|
||||
<flux:badge size="sm" color="green">{{ __('Freigegeben') }}</flux:badge>
|
||||
@else
|
||||
<flux:badge size="sm" color="amber">{{ __('Ausstehend') }}</flux:badge>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
@endif
|
||||
|
||||
{{-- Erstellt am --}}
|
||||
<flux:table.cell>
|
||||
<span class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{{ \Carbon\Carbon::parse($product['created_at'])->format('d.m.Y') }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
{{-- Erstellt --}}
|
||||
<flux:table.cell>
|
||||
<span class="text-sm text-zinc-500">
|
||||
{{ $product->created_at?->format('d.m.Y') }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
|
||||
{{-- Aktionen --}}
|
||||
<flux:table.cell class="text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<flux:button variant="ghost" size="sm" icon="eye">
|
||||
{{ __('Ansehen') }}
|
||||
</flux:button>
|
||||
|
||||
<flux:dropdown position="bottom" align="end">
|
||||
<flux:button variant="ghost" size="sm" icon="ellipsis-horizontal" icon-trailing />
|
||||
|
||||
<flux:menu class="w-48">
|
||||
<flux:menu.item icon="pencil">
|
||||
{{ __('Bearbeiten') }}
|
||||
</flux:menu.item>
|
||||
<flux:menu.item icon="document-duplicate">
|
||||
{{ __('Duplizieren') }}
|
||||
</flux:menu.item>
|
||||
<flux:menu.separator />
|
||||
<flux:menu.item icon="archive-box">
|
||||
{{ __('Archivieren') }}
|
||||
</flux:menu.item>
|
||||
<flux:menu.item icon="trash" variant="danger">
|
||||
{{ __('Löschen') }}
|
||||
</flux:menu.item>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
{{-- Aktionen --}}
|
||||
<flux:table.cell>
|
||||
@if (!$isCustomer)
|
||||
<div class="flex items-center gap-1">
|
||||
<flux:button
|
||||
href="{{ $product->product_type === \App\Enums\ProductType::LocalStock ? route('products.edit.teaser', $product) : route('products.edit.standard', $product) }}"
|
||||
size="sm" variant="ghost" icon="pencil" />
|
||||
@if (!in_array($product->status, [ProductStatus::Archived, ProductStatus::Sold]))
|
||||
<flux:dropdown>
|
||||
<flux:button size="sm" variant="ghost" icon="ellipsis-vertical" />
|
||||
<flux:menu>
|
||||
<flux:menu.item wire:click="markAsSold({{ $product->id }})"
|
||||
wire:confirm="{{ __('Produkt wirklich als verkauft markieren?') }}"
|
||||
icon="check-badge">
|
||||
{{ __('Als verkauft') }}
|
||||
</flux:menu.item>
|
||||
<flux:menu.item wire:click="archiveProduct({{ $product->id }})"
|
||||
wire:confirm="{{ __('Produkt wirklich archivieren?') }}"
|
||||
icon="archive-box" variant="danger">
|
||||
{{ __('Archivieren') }}
|
||||
</flux:menu.item>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="9" class="text-center py-12">
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<flux:icon.cube class="w-16 h-16 text-zinc-400 mb-4" />
|
||||
<flux:heading size="lg" class="mb-2">{{ __('Keine Produkte gefunden') }}</flux:heading>
|
||||
<flux:subheading class="mb-4">
|
||||
{{ __('Erstellen Sie Ihr erstes Produkt oder passen Sie Ihre Filter an.') }}
|
||||
</flux:subheading>
|
||||
<flux:button variant="primary" icon="plus" :href="route('products.create')" wire:navigate>
|
||||
{{ __('Neues Produkt erstellen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="8" class="py-12 text-center text-zinc-500">
|
||||
<flux:icon.shopping-bag class="mx-auto mb-2 h-12 w-12 text-zinc-400" />
|
||||
<div>{{ __('Keine Produkte gefunden') }}</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
|
||||
{{-- Pagination / Stats --}}
|
||||
<div class="px-6 py-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{{ __('Zeige') }} <span class="font-semibold">{{ count($this->products) }}</span> {{ __('von') }} <span class="font-semibold">{{ count($this->products) }}</span> {{ __('Produkten') }}
|
||||
</div>
|
||||
|
||||
{{-- Hier würde normalerweise die Pagination kommen --}}
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:button variant="ghost" size="sm" icon="chevron-left" disabled>
|
||||
{{ __('Zurück') }}
|
||||
</flux:button>
|
||||
<span class="text-sm text-zinc-600 dark:text-zinc-400">{{ __('Seite 1 von 1') }}</span>
|
||||
<flux:button variant="ghost" size="sm" icon="chevron-right" icon-trailing disabled>
|
||||
{{ __('Weiter') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
@if ($products->hasPages())
|
||||
<div class="mt-4">
|
||||
{{ $products->links() }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
|
||||
{{-- Statistiken (Optional) --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<flux:card class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-3 bg-green-100 dark:bg-green-900/20 rounded-lg">
|
||||
<flux:icon.check-circle class="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-zinc-900 dark:text-zinc-100">4</div>
|
||||
<div class="text-sm text-zinc-600 dark:text-zinc-400">{{ __('Aktive Produkte') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:card class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-3 bg-yellow-100 dark:bg-yellow-900/20 rounded-lg">
|
||||
<flux:icon.document class="w-6 h-6 text-yellow-600 dark:text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-zinc-900 dark:text-zinc-100">1</div>
|
||||
<div class="text-sm text-zinc-600 dark:text-zinc-400">{{ __('Entwürfe') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:card class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-3 bg-blue-100 dark:bg-blue-900/20 rounded-lg">
|
||||
<flux:icon.cube class="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-zinc-900 dark:text-zinc-100">5</div>
|
||||
<div class="text-sm text-zinc-600 dark:text-zinc-400">{{ __('Auf Lager') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:card class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-3 bg-zinc-100 dark:bg-zinc-700 rounded-lg">
|
||||
<flux:icon.currency-euro class="w-6 h-6 text-zinc-600 dark:text-zinc-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-zinc-900 dark:text-zinc-100">6.563 €</div>
|
||||
<div class="text-sm text-zinc-600 dark:text-zinc-400">{{ __('Ø Preis') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||