diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 45d40a5..e6d7327 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,24 +1,12 @@ { - "name": "B2In (Dev Container)", - // 1. DIES IST DER WICHTIGSTE TEIL: - // Wir verwenden Docker Compose für alle Services + "name": "B2in (Dev Container)", "dockerComposeFile": [ "../docker-compose.yml" ], "service": "laravel.test", - // 3. WIR DEFINIEREN DEN ARBEITSBEREICH: - // Das ist der Pfad, in dem Ihr Code *innerhalb* des Containers liegt. "workspaceFolder": "/var/www/html", - // 4. WIR LEGEN DEN BENUTZER FEST: - // Laravel Sail führt Befehle standardmäßig als 'sail'-Benutzer aus, um Berechtigungsprobleme zu vermeiden. "remoteUser": "sail", - // 5. ZUSÄTZLICHE ENTWICKLER-TOOLS (FEATURES): - // Features werden über postCreateCommand installiert um Kompatibilitätsprobleme zu vermeiden "features": {}, - // 6. BEFEHLE NACH DEM ERSTELLEN: - // Installiert nur die Tools die ohne Root-Rechte funktionieren - //"postCreateCommand": "composer install --no-interaction --prefer-dist --optimize-autoloader", - // 7. EDITOR-ANPASSUNGEN (Optional, aber sehr empfohlen): "customizations": { "vscode": { "extensions": [ @@ -33,34 +21,22 @@ ] } }, - // 8. ZU STARTENDE DIENSTE: - // Legt fest, welche Dienste aus der docker-compose.yml gestartet werden sollen. + // WICHTIG: Nur noch der Haupt-Container bleibt drin "runServices": [ - "laravel.test", - "mysql", - "redis", - "mailpit" + "laravel.test" ], - // 9. ZUSÄTZLICHE KONFIGURATION: - // Umgebungsvariablen für den DevContainer "containerEnv": { "WWWUSER": "501", "WWWGROUP": "20", "LARAVEL_SAIL": "1" }, - // 10. MOUNT-KONFIGURATION: - // Stellt sicher, dass der Code korrekt gemountet wird "mounts": [ "source=${localWorkspaceFolder},target=/var/www/html,type=bind,consistency=cached" ], - // 11. FORWARD PORTS: - // Ports die automatisch weitergeleitet werden sollen + // WICHTIG: Nur noch die beiden Vite-Ports weiterleiten "forwardPorts": [ 5174, - 5175, - 33067, - 6381, - 8026 + 5175 ], "portsAttributes": { "5174": { @@ -70,18 +46,6 @@ "5175": { "label": "Vite Dev Server (Web)", "onAutoForward": "notify" - }, - "33067": { - "label": "MySQL", - "onAutoForward": "silent" - }, - "6381": { - "label": "Redis", - "onAutoForward": "silent" - }, - "8026": { - "label": "Mailpit Dashboard", - "onAutoForward": "notify" } } } diff --git a/.devcontainer/docker-compose.dev.yml b/.devcontainer/docker-compose.dev.yml index a90a09a..d205a7f 100644 --- a/.devcontainer/docker-compose.dev.yml +++ b/.devcontainer/docker-compose.dev.yml @@ -30,7 +30,7 @@ services: DB_USERNAME: sail DB_PASSWORD: password # Application Configuration - APP_NAME: B2In + APP_NAME: B2in APP_ENV: local APP_DEBUG: true APP_URL: http://localhost @@ -42,7 +42,7 @@ services: MAIL_PASSWORD: null MAIL_ENCRYPTION: null MAIL_FROM_ADDRESS: hello@example.com - MAIL_FROM_NAME: B2In + MAIL_FROM_NAME: B2in # Redis Configuration REDIS_HOST: redis REDIS_PASSWORD: null diff --git a/.env.example b/.env.example index 3ca587d..ed229cd 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,12 @@ AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" +# Cookie Consent & Google Analytics (acme/cookie-consent) +# GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX +# GOOGLE_TAG_MANAGER_ID=GTM-XXXXXXXX +# COOKIE_CONSENT_ENABLED=true +# COOKIE_CONSENT_LIFETIME=365 + # 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 diff --git a/CLAUDE.md b/CLAUDE.md index eb0312a..ed7a30e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,7 +160,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for ## 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 +- php - 8.4.18 - laravel/fortify (FORTIFY) - v1 - laravel/framework (LARAVEL) - v12 - laravel/prompts (PROMPTS) - v0 @@ -178,10 +178,6 @@ This application is a Laravel application and its main Laravel ecosystems packag - 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()`. @@ -555,8 +551,8 @@ $delete = fn(Product $product) => $product->delete(); ## 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. +- You must run `vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/sail bin pint --test --format agent`, simply run `vendor/bin/sail bin pint --format agent` to fix any formatting issues. === pest/core rules === diff --git a/Readme.md b/Readme.md index e36faaa..8fa254e 100644 --- a/Readme.md +++ b/Readme.md @@ -56,7 +56,7 @@ php artisan serve Diese Anwendung verwendet Vite mit verschiedenen Konfigurationen: - **Hauptkompilierung**: `npm run dev` oder `npm run build` -- **Admin-Assets**: `npm run build:admin` +- **Admin-Assets**: `npm run build:portal` - **Web-Assets**: `npm run build:web` ## Domain-Simulation diff --git a/app/Console/Commands/ConvertImagesToWebP.php b/app/Console/Commands/ConvertImagesToWebP.php new file mode 100644 index 0000000..145f5e9 --- /dev/null +++ b/app/Console/Commands/ConvertImagesToWebP.php @@ -0,0 +1,124 @@ +option('path'); + $quality = (int) $this->option('quality'); + $force = (bool) $this->option('force'); + $dryRun = (bool) $this->option('dry-run'); + $basePath = public_path($relativePath); + + if (! File::isDirectory($basePath)) { + $this->error("Directory not found: {$basePath}"); + + return self::FAILURE; + } + + $extensions = ['jpg', 'jpeg', 'png']; + $files = collect(File::allFiles($basePath)) + ->filter(fn ($file) => in_array(strtolower($file->getExtension()), $extensions)); + + if ($files->isEmpty()) { + $this->info('No images found to convert.'); + + return self::SUCCESS; + } + + $this->info(sprintf('Found %d images in %s', $files->count(), $relativePath)); + + $converted = 0; + $skipped = 0; + $savedBytes = 0; + + foreach ($files as $file) { + $webpPath = preg_replace('/\.(jpe?g|png)$/i', '.webp', $file->getPathname()); + + if (File::exists($webpPath) && ! $force) { + $skipped++; + + continue; + } + + if ($dryRun) { + $this->line(" Would convert: {$file->getRelativePathname()}"); + $converted++; + + continue; + } + + $result = $this->convertToWebP($file->getPathname(), $webpPath, $quality); + + if ($result) { + $originalSize = $file->getSize(); + $webpSize = filesize($webpPath); + $saving = $originalSize - $webpSize; + $savedBytes += $saving; + $percent = $originalSize > 0 ? round(($saving / $originalSize) * 100, 1) : 0; + + $this->line(sprintf( + ' %s → %s KB → %s KB (-%s%%)', + $file->getRelativePathname(), + round($originalSize / 1024, 1), + round($webpSize / 1024, 1), + $percent + )); + $converted++; + } else { + $this->warn(" ✗ Failed: {$file->getRelativePathname()}"); + } + } + + $this->newLine(); + $this->info(sprintf( + 'Done: %d converted, %d skipped, %.1f KB saved', + $converted, + $skipped, + $savedBytes / 1024 + )); + + return self::SUCCESS; + } + + private function convertToWebP(string $sourcePath, string $destPath, int $quality): bool + { + $extension = strtolower(pathinfo($sourcePath, PATHINFO_EXTENSION)); + + $image = match ($extension) { + 'jpg', 'jpeg' => @imagecreatefromjpeg($sourcePath), + 'png' => @imagecreatefrompng($sourcePath), + default => false, + }; + + if ($image === false) { + return false; + } + + if ($extension === 'png') { + imagepalettetotruecolor($image); + imagealphablending($image, true); + imagesavealpha($image, true); + } + + $result = imagewebp($image, $destPath, $quality); + imagedestroy($image); + + return $result; + } +} diff --git a/app/Console/Commands/MigrateLegacyDisplays.php b/app/Console/Commands/MigrateLegacyDisplays.php new file mode 100644 index 0000000..2ea2dc0 --- /dev/null +++ b/app/Console/Commands/MigrateLegacyDisplays.php @@ -0,0 +1,86 @@ +exists()) { + $this->warn('Legacy migration already executed. Skipping.'); + + return self::SUCCESS; + } + + $videos = DisplayVideo::orderBy('sort_order')->get(); + $footers = DisplayFooterContent::orderBy('sort_order')->get(); + + if ($videos->isEmpty() && $footers->isEmpty()) { + $this->info('No legacy data found. Nothing to migrate.'); + + return self::SUCCESS; + } + + $version = DisplayVersion::create([ + 'name' => 'Video-Display (Legacy)', + 'type' => 'video-display', + 'settings' => [], + 'is_active' => true, + ]); + + $sortOrder = 0; + foreach ($videos as $video) { + DisplayVersionItem::create([ + 'display_version_id' => $version->id, + 'item_type' => 'video', + 'content' => [ + 'filename' => $video->filename, + 'title' => $video->title, + 'position' => $video->position, + ], + 'sort_order' => $sortOrder++, + 'is_active' => $video->is_active, + ]); + } + + $sortOrder = 0; + foreach ($footers as $footer) { + DisplayVersionItem::create([ + 'display_version_id' => $version->id, + 'item_type' => 'footer', + 'content' => [ + 'headline' => $footer->headline, + 'subline' => $footer->subline, + 'url' => $footer->url, + ], + 'sort_order' => $sortOrder++, + 'is_active' => $footer->is_active, + ]); + } + + $display = Display::create([ + 'name' => 'Hauptdisplay', + 'location' => 'Schaufenster', + 'is_active' => true, + ]); + + $display->versions()->attach($version->id, ['sort_order' => 0]); + + $this->info("Migrated {$videos->count()} videos and {$footers->count()} footer items."); + $this->info("Created version: {$version->name} (ID: {$version->id})"); + $this->info("Created display: {$display->name} (ID: {$display->id})"); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/ResetCabinetTabletOverrides.php b/app/Console/Commands/ResetCabinetTabletOverrides.php new file mode 100644 index 0000000..4498125 --- /dev/null +++ b/app/Console/Commands/ResetCabinetTabletOverrides.php @@ -0,0 +1,22 @@ +clearOverrides(); + + $this->info('Cabinet-Tablet Sonderöffnungszeiten wurden zurückgesetzt.'); + + return self::SUCCESS; + } +} diff --git a/app/Enums/DisplayVersionType.php b/app/Enums/DisplayVersionType.php new file mode 100644 index 0000000..e779f6a --- /dev/null +++ b/app/Enums/DisplayVersionType.php @@ -0,0 +1,31 @@ + 'Video-Display', + self::B2in => 'B2in Display', + self::Offers => 'Angebote', + }; + } + + /** + * @return array + */ + public function allowedItemTypes(): array + { + return match ($this) { + self::VideoDisplay => ['video', 'footer'], + self::B2in => ['media'], + self::Offers => ['slide'], + }; + } +} diff --git a/app/Helpers/PriceHelper.php b/app/Helpers/PriceHelper.php new file mode 100644 index 0000000..a95566d --- /dev/null +++ b/app/Helpers/PriceHelper.php @@ -0,0 +1,31 @@ +computeStatus(); + + return response()->json([ + 'store_status' => $computed['status'], + 'today_close' => $computed['today_close'], + 'next_open' => $computed['next_open'], + 'notice_headline' => $settings->notice_headline, + 'notice_subtext' => $settings->notice_subtext, + 'override_open_today' => $settings->override_open_today, + 'override_close_today' => $settings->override_close_today, + 'next_appointment' => [ + 'date' => $settings->next_appointment_date?->format('Y-m-d'), + 'time' => $settings->next_appointment_time, + ], + 'hours' => $settings->getHoursArray(), + 'contact' => [ + 'phone' => $settings->contact_phone, + 'email' => $settings->contact_email, + ], + 'updated_at' => $settings->updated_at?->toIso8601String(), + ]); + } + + /** + * Lightweight check – returns the timestamp and current computed status + * so the tablet can detect both settings changes and time-based open/close transitions. + */ + public function check(): JsonResponse + { + $settings = CabinetTabletSetting::current(); + $computed = $settings->computeStatus(); + + return response()->json([ + 'updated_at' => $settings->updated_at?->toIso8601String(), + 'store_status' => $computed['status'], + ]); + } +} diff --git a/app/Http/Controllers/Api/DisplayVersionApiController.php b/app/Http/Controllers/Api/DisplayVersionApiController.php new file mode 100644 index 0000000..9ef43ec --- /dev/null +++ b/app/Http/Controllers/Api/DisplayVersionApiController.php @@ -0,0 +1,151 @@ +is_active) { + return response()->json(['error' => 'Display not configured'], 404); + } + + $display->load('versions'); + + if ($display->versions->isEmpty()) { + return response()->json(['error' => 'Display not configured'], 404); + } + + $playlist = []; + + foreach ($display->versions as $version) { + $items = $version->activeItems()->get(); + + $entry = match ($version->type->value) { + 'video-display' => $this->videoDisplayData($version, $items), + 'b2in' => $this->b2inData($version, $items), + 'offers' => $this->offersData($version, $items), + default => null, + }; + + if ($entry) { + $playlist[] = $entry; + } + } + + return response()->json([ + 'playlist' => $playlist, + 'updated_at' => $display->versions->max('updated_at')?->toIso8601String(), + ]); + } + + public function check(Display $display): JsonResponse + { + if (! $display->is_active) { + return response()->json(['error' => 'Display not configured'], 404); + } + + $display->load('versions'); + + if ($display->versions->isEmpty()) { + return response()->json(['error' => 'Display not configured'], 404); + } + + return response()->json([ + 'updated_at' => $display->versions->max('updated_at')?->toIso8601String(), + ]); + } + + /** + * @return array + */ + private function videoDisplayData($version, $items): array + { + $videos = $items->where('item_type', 'video')->values()->map(fn ($item) => [ + 'src' => 'assets/'.($item->content['filename'] ?? ''), + 'position' => $item->content['position'] ?? 25, + ]); + + $footerContent = $items->where('item_type', 'footer')->values()->map(function ($item) { + $data = [ + 'headline' => $item->content['headline'] ?? '', + 'subline' => $item->content['subline'] ?? '', + ]; + + if (! empty($item->content['url'])) { + $data['url'] = $item->content['url']; + } + + return $data; + }); + + return [ + 'type' => 'video-display', + 'version_name' => $version->name, + 'videoPlaylist' => $videos, + 'footerContent' => $footerContent, + ]; + } + + /** + * @return array + */ + private function b2inData($version, $items): array + { + $mediaItems = $items->where('item_type', 'media')->values()->map(fn ($item) => [ + 'id' => $item->id, + 'category' => $item->content['category'] ?? 'immobilien', + 'media_type' => $item->content['media_type'] ?? 'image', + 'media_url' => $item->content['media_url'] ?? '', + 'headline' => $item->content['headline'] ?? '', + 'subline' => $item->content['subline'] ?? '', + 'duration_seconds' => $item->content['duration_seconds'] ?? 10, + 'sort_order' => $item->sort_order, + 'is_active' => true, + ]); + + return [ + 'type' => 'b2in', + 'version_name' => $version->name, + 'settings' => $version->settings ?? [], + 'items' => $mediaItems, + ]; + } + + /** + * @return array + */ + private function offersData($version, $items): array + { + $slides = $items->where('item_type', 'slide')->values()->map(fn ($item) => [ + 'type' => $item->content['type'] ?? 'product-hero', + 'duration' => $item->content['duration'] ?? 8000, + 'image_url' => $item->content['image_url'] ?? '', + 'badge_text' => $item->content['badge_text'] ?? '', + 'eyebrow' => $item->content['eyebrow'] ?? '', + 'title' => $item->content['title'] ?? '', + 'subline' => $item->content['subline'] ?? '', + 'price' => $item->content['price'] ?? '', + 'original_price' => $item->content['original_price'] ?? '', + 'tag_text' => $item->content['tag_text'] ?? '', + 'bullets' => $item->content['bullets'] ?? [], + 'disclaimer' => $item->content['disclaimer'] ?? '', + 'qr_url' => $item->content['qr_url'] ?? '', + 'qr_title' => $item->content['qr_title'] ?? '', + 'contact' => $item->content['contact'] ?? '', + 'show_brand_text' => $item->content['show_brand_text'] ?? false, + 'brand_tagline' => $item->content['brand_tagline'] ?? '', + ]); + + return [ + 'type' => 'offers', + 'version_name' => $version->name, + 'settings' => $version->settings ?? [], + 'slides' => $slides, + ]; + } +} diff --git a/app/Http/Middleware/BasicAuthMiddleware.php b/app/Http/Middleware/BasicAuthMiddleware.php index 72b8350..8fc7584 100644 --- a/app/Http/Middleware/BasicAuthMiddleware.php +++ b/app/Http/Middleware/BasicAuthMiddleware.php @@ -37,8 +37,14 @@ class BasicAuthMiddleware return $next($request); } - // Skip Basic Auth für Display-API und Short-Links (öffentlicher Zugriff für Display-Seite) - if ($request->is('api/display/*') || $request->is('_cabinet/*')) { + // Skip Basic Auth für Display-API, Cabinet-Tablet-API und Short-Links (öffentlicher Zugriff für Display-Seiten) + // Skip Basic Auth für Display-API, Cabinet-Tablet-API und Short-Links (öffentlicher Zugriff für Display-Seiten) + if ( + $request->is('api/display/*') || $request->is('api/cabinet-tablet/*') || $request->is('_cabinet/*') || + str_contains($request->url(), 'portal.b2in.test') || str_contains($request->url(), 'portal.b2in.eu') || + str_contains($request->url(), 'b2in.test') || str_contains($request->url(), 'b2in.eu') + + ) { return $next($request); } diff --git a/app/Http/Middleware/SetLocale.php b/app/Http/Middleware/SetLocale.php new file mode 100644 index 0000000..b13a9f1 --- /dev/null +++ b/app/Http/Middleware/SetLocale.php @@ -0,0 +1,21 @@ +setLocale($locale); + } + + return $next($request); + } +} diff --git a/app/Livewire/Admin/CMS/CabinetDisplay.php b/app/Livewire/Admin/Cms/CabinetDisplay.php similarity index 99% rename from app/Livewire/Admin/CMS/CabinetDisplay.php rename to app/Livewire/Admin/Cms/CabinetDisplay.php index 78e86db..f1f25f0 100644 --- a/app/Livewire/Admin/CMS/CabinetDisplay.php +++ b/app/Livewire/Admin/Cms/CabinetDisplay.php @@ -1,6 +1,6 @@ get(); $footerContents = DisplayFooterContent::orderBy('sort_order')->get(); - return view('livewire.admin.c-m-s.cabinet-display', [ + return view('livewire.admin.cms.cabinet-display', [ 'videos' => $videos, 'footerContents' => $footerContents, ]); diff --git a/app/Livewire/Admin/Cms/CabinetInfoTablet.php b/app/Livewire/Admin/Cms/CabinetInfoTablet.php new file mode 100644 index 0000000..7971137 --- /dev/null +++ b/app/Livewire/Admin/Cms/CabinetInfoTablet.php @@ -0,0 +1,189 @@ +storeStatus = $s->store_status ?? 'auto'; + $this->noticeHeadline = $s->notice_headline ?? ''; + $this->noticeSubtext = $s->notice_subtext ?? ''; + $this->overrideOpenToday = $s->override_open_today ?? ''; + $this->overrideCloseToday = $s->override_close_today ?? ''; + $this->nextAppointmentDate = $s->next_appointment_date?->format('Y-m-d'); + $this->nextAppointmentTime = $s->next_appointment_time ?? ''; + $this->hoursMondayOpen = $s->hours_monday_open ?? ''; + $this->hoursMondayClose = $s->hours_monday_close ?? ''; + $this->hoursTuesdayOpen = $s->hours_tuesday_open ?? ''; + $this->hoursTuesdayClose = $s->hours_tuesday_close ?? ''; + $this->hoursWednesdayOpen = $s->hours_wednesday_open ?? ''; + $this->hoursWednesdayClose = $s->hours_wednesday_close ?? ''; + $this->hoursThursdayOpen = $s->hours_thursday_open ?? ''; + $this->hoursThursdayClose = $s->hours_thursday_close ?? ''; + $this->hoursFridayOpen = $s->hours_friday_open ?? ''; + $this->hoursFridayClose = $s->hours_friday_close ?? ''; + $this->hoursSaturdayOpen = $s->hours_saturday_open ?? ''; + $this->hoursSaturdayClose = $s->hours_saturday_close ?? ''; + $this->hoursSundayOpen = $s->hours_sunday_open ?? ''; + $this->hoursSundayClose = $s->hours_sunday_close ?? ''; + $this->contactPhone = $s->contact_phone ?? ''; + $this->contactEmail = $s->contact_email ?? ''; + } + + private function timeRule(): array + { + return ['nullable', 'string', 'regex:/^(\d{2}:\d{2})?$/']; + } + + private function toNullIfEmpty(?string $value): ?string + { + return $value !== null && trim($value) !== '' ? trim($value) : null; + } + + /** + * @param array $hours Optional: Time picker values from DOM (bypasses wire:model sync issues) + */ + public function save(array $hours = []): void + { + foreach ($hours as $prop => $value) { + if (property_exists($this, $prop)) { + $this->{$prop} = $value ?? ''; + } + } + + $timeRule = $this->timeRule(); + + $this->validate([ + 'storeStatus' => 'required|in:auto,notice,warning,closed', + 'noticeHeadline' => 'nullable|string|max:40', + 'noticeSubtext' => 'nullable|string|max:80', + 'overrideOpenToday' => $timeRule, + 'overrideCloseToday' => $timeRule, + 'nextAppointmentDate' => 'nullable|date', + 'nextAppointmentTime' => $timeRule, + 'hoursMondayOpen' => $timeRule, + 'hoursMondayClose' => $timeRule, + 'hoursTuesdayOpen' => $timeRule, + 'hoursTuesdayClose' => $timeRule, + 'hoursWednesdayOpen' => $timeRule, + 'hoursWednesdayClose' => $timeRule, + 'hoursThursdayOpen' => $timeRule, + 'hoursThursdayClose' => $timeRule, + 'hoursFridayOpen' => $timeRule, + 'hoursFridayClose' => $timeRule, + 'hoursSaturdayOpen' => $timeRule, + 'hoursSaturdayClose' => $timeRule, + 'hoursSundayOpen' => $timeRule, + 'hoursSundayClose' => $timeRule, + 'contactPhone' => 'nullable|string|max:50', + 'contactEmail' => 'nullable|email|max:100', + ], [ + 'storeStatus.required' => 'Der Store-Status ist erforderlich.', + 'storeStatus.in' => 'Ungültiger Status. Erlaubt: auto, notice, warning, closed.', + 'noticeHeadline.max' => 'Die Headline darf maximal 40 Zeichen haben.', + 'noticeSubtext.max' => 'Der Subtext darf maximal 80 Zeichen haben.', + 'overrideOpenToday.regex' => 'Bitte im Format HH:MM eingeben.', + 'overrideCloseToday.regex' => 'Bitte im Format HH:MM eingeben.', + 'nextAppointmentTime.regex' => 'Bitte im Format HH:MM eingeben.', + 'contactEmail.email' => 'Bitte eine gültige E-Mail-Adresse eingeben.', + ]); + CabinetTabletSetting::current()->update([ + 'store_status' => $this->storeStatus, + 'notice_headline' => $this->toNullIfEmpty($this->noticeHeadline), + 'notice_subtext' => $this->toNullIfEmpty($this->noticeSubtext), + 'override_open_today' => $this->toNullIfEmpty($this->overrideOpenToday), + 'override_close_today' => $this->toNullIfEmpty($this->overrideCloseToday), + 'next_appointment_date' => $this->toNullIfEmpty($this->nextAppointmentDate), + 'next_appointment_time' => $this->toNullIfEmpty($this->nextAppointmentTime), + 'hours_monday_open' => $this->toNullIfEmpty($this->hoursMondayOpen), + 'hours_monday_close' => $this->toNullIfEmpty($this->hoursMondayClose), + 'hours_tuesday_open' => $this->toNullIfEmpty($this->hoursTuesdayOpen), + 'hours_tuesday_close' => $this->toNullIfEmpty($this->hoursTuesdayClose), + 'hours_wednesday_open' => $this->toNullIfEmpty($this->hoursWednesdayOpen), + 'hours_wednesday_close' => $this->toNullIfEmpty($this->hoursWednesdayClose), + 'hours_thursday_open' => $this->toNullIfEmpty($this->hoursThursdayOpen), + 'hours_thursday_close' => $this->toNullIfEmpty($this->hoursThursdayClose), + 'hours_friday_open' => $this->toNullIfEmpty($this->hoursFridayOpen), + 'hours_friday_close' => $this->toNullIfEmpty($this->hoursFridayClose), + 'hours_saturday_open' => $this->toNullIfEmpty($this->hoursSaturdayOpen), + 'hours_saturday_close' => $this->toNullIfEmpty($this->hoursSaturdayClose), + 'hours_sunday_open' => $this->toNullIfEmpty($this->hoursSundayOpen), + 'hours_sunday_close' => $this->toNullIfEmpty($this->hoursSundayClose), + 'contact_phone' => $this->toNullIfEmpty($this->contactPhone), + 'contact_email' => $this->toNullIfEmpty($this->contactEmail), + ]); + + session()->flash('success', 'Info-Tablet Einstellungen gespeichert!'); + } + + public function clearOverrides(): void + { + CabinetTabletSetting::current()->clearOverrides(); + $this->overrideOpenToday = ''; + $this->overrideCloseToday = ''; + + session()->flash('success', 'Sonderöffnungszeiten wurden zurückgesetzt!'); + } + + public function render(): \Illuminate\View\View + { + return view('livewire.admin.cms.cabinet-info-tablet'); + } +} diff --git a/app/Livewire/Admin/Cms/DisplayList.php b/app/Livewire/Admin/Cms/DisplayList.php new file mode 100644 index 0000000..1e908b9 --- /dev/null +++ b/app/Livewire/Admin/Cms/DisplayList.php @@ -0,0 +1,153 @@ + */ + public $selectedVersionIds = []; + + public $displayIsActive = true; + + public $addVersionSelect = null; + + public function openModal(?int $id = null): void + { + if ($id) { + $display = Display::with('versions')->findOrFail($id); + $this->displayId = $display->id; + $this->displayName = $display->name; + $this->displayLocation = $display->location ?? ''; + $this->selectedVersionIds = $display->versions->pluck('id')->toArray(); + $this->displayIsActive = $display->is_active; + } else { + $this->resetForm(); + } + + $this->showModal = true; + } + + public function addVersion(?int $versionId = null): void + { + $id = $versionId ?? $this->addVersionSelect; + + if ($id && ! in_array((int) $id, $this->selectedVersionIds)) { + $this->selectedVersionIds[] = (int) $id; + } + + $this->addVersionSelect = null; + } + + public function removeVersion(int $index): void + { + array_splice($this->selectedVersionIds, $index, 1); + } + + public function moveVersion(int $index, string $direction): void + { + $newIndex = $direction === 'up' ? $index - 1 : $index + 1; + + if ($newIndex < 0 || $newIndex >= count($this->selectedVersionIds)) { + return; + } + + $temp = $this->selectedVersionIds[$index]; + $this->selectedVersionIds[$index] = $this->selectedVersionIds[$newIndex]; + $this->selectedVersionIds[$newIndex] = $temp; + } + + public function save(): void + { + $this->validate([ + 'displayName' => 'required|string|max:255', + 'displayLocation' => 'nullable|string|max:255', + 'selectedVersionIds' => 'array', + 'selectedVersionIds.*' => 'exists:display_versions,id', + ], [ + 'displayName.required' => 'Bitte geben Sie einen Namen ein.', + ]); + + $data = [ + 'name' => $this->displayName, + 'location' => $this->displayLocation ?: null, + 'is_active' => $this->displayIsActive, + ]; + + if ($this->displayId) { + $display = Display::findOrFail($this->displayId); + $display->update($data); + session()->flash('success', 'Display erfolgreich aktualisiert!'); + } else { + $display = Display::create($data); + session()->flash('success', 'Display erfolgreich erstellt!'); + } + + // Sync versions with sort_order + $syncData = []; + foreach ($this->selectedVersionIds as $sortOrder => $versionId) { + $syncData[$versionId] = ['sort_order' => $sortOrder]; + } + $display->versions()->sync($syncData); + + $this->closeModal(); + } + + public function deleteDisplay(int $id): void + { + $display = Display::findOrFail($id); + $name = $display->name; + $display->delete(); + + session()->flash('success', 'Display "'.$name.'" wurde gelöscht!'); + } + + public function toggleActive(int $id): void + { + $display = Display::findOrFail($id); + $display->update(['is_active' => ! $display->is_active]); + } + + public function closeModal(): void + { + $this->showModal = false; + $this->resetForm(); + } + + public function resetForm(): void + { + $this->displayId = null; + $this->displayName = ''; + $this->displayLocation = ''; + $this->selectedVersionIds = []; + $this->displayIsActive = true; + $this->addVersionSelect = null; + } + + public function render() + { + $displays = Display::with('versions') + ->orderBy('name') + ->get(); + + $versions = DisplayVersion::active() + ->orderBy('name') + ->get(); + + return view('livewire.admin.cms.display-list', [ + 'displays' => $displays, + 'versions' => $versions, + ]); + } +} diff --git a/app/Livewire/Admin/Cms/DisplayMediaPicker.php b/app/Livewire/Admin/Cms/DisplayMediaPicker.php new file mode 100644 index 0000000..a223114 --- /dev/null +++ b/app/Livewire/Admin/Cms/DisplayMediaPicker.php @@ -0,0 +1,125 @@ + */ + public array $quickUploads = []; + + public function mount(?int $value = null): void + { + $this->value = $value; + } + + public function openPicker(): void + { + $this->showModal = true; + } + + public function selectMedia(int $id): void + { + $media = DisplayMedia::find($id); + if (! $media) { + return; + } + + $this->value = $media->id; + $this->showModal = false; + + $this->dispatch('display-media-selected', field: $this->field, mediaId: $media->id, url: $media->getUrl()); + } + + public function clearSelection(): void + { + $this->value = null; + $this->dispatch('display-media-selected', field: $this->field, mediaId: null, url: null); + } + + public function updatedQuickUploads(): void + { + $this->validate([ + 'quickUploads' => 'nullable|array|max:5', + 'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,mp4,webm,mov|max:51200', + ]); + + $service = app(DisplayMediaService::class); + $lastMedia = null; + + foreach ($this->quickUploads as $file) { + $lastMedia = $service->storeUpload($file); + } + + $this->quickUploads = []; + + if ($lastMedia) { + $this->value = $lastMedia->id; + $this->showModal = false; + + $this->dispatch('display-media-selected', field: $this->field, mediaId: $lastMedia->id, url: $lastMedia->getUrl()); + } + } + + public function removeQuickUpload(int $index): void + { + if (isset($this->quickUploads[$index])) { + unset($this->quickUploads[$index]); + $this->quickUploads = array_values($this->quickUploads); + } + } + + public function render(): View + { + return view('livewire.admin.cms.display-media-picker', [ + 'selectedMedia' => $this->resolveSelectedMedia(), + 'mediaItems' => $this->resolveMediaItems(), + ]); + } + + private function resolveSelectedMedia(): ?DisplayMedia + { + if (! $this->value) { + return null; + } + + return DisplayMedia::find($this->value); + } + + /** + * @return LengthAwarePaginator + */ + private function resolveMediaItems(): LengthAwarePaginator + { + return DisplayMedia::query() + ->active() + ->when($this->type === 'image', fn ($q) => $q->images()) + ->when($this->type === 'video', fn ($q) => $q->videos()) + ->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%") + ->orWhere('title', 'like', "%{$this->search}%")) + ->orderByDesc('created_at') + ->paginate(18); + } +} diff --git a/app/Livewire/Admin/Cms/DisplayVersionEditor.php b/app/Livewire/Admin/Cms/DisplayVersionEditor.php new file mode 100644 index 0000000..98e0c86 --- /dev/null +++ b/app/Livewire/Admin/Cms/DisplayVersionEditor.php @@ -0,0 +1,453 @@ + */ + public array $slideBullets = []; + + public string $slideDisclaimer = ''; + + public string $slideQrUrl = ''; + + public string $slideQrTitle = ''; + + public string $slideContact = ''; + + public bool $slideShowBrandText = false; + + public string $slideBrandTagline = ''; + + public bool $slideIsActive = true; + + // Settings Modal + public bool $showSettingsModal = false; + + public array $settings = []; + + /** @var array */ + public array $availableVideos = []; + + public function mount(DisplayVersion $displayVersion): void + { + $this->version = $displayVersion; + $this->versionName = $displayVersion->name; + $this->settings = $displayVersion->settings ?? []; + + if ($this->version->type === DisplayVersionType::VideoDisplay) { + $this->loadAvailableVideos(); + } + } + + public function loadAvailableVideos(): void + { + $assetsPath = public_path('_cabinet/assets'); + + if (File::exists($assetsPath)) { + $this->availableVideos = collect(File::files($assetsPath)) + ->map(fn ($file) => $file->getFilename()) + ->filter(fn ($filename) => in_array(pathinfo($filename, PATHINFO_EXTENSION), ['mp4', 'webm', 'mov'])) + ->values() + ->toArray(); + } + } + + public function toggleTheme(): void + { + $settings = $this->version->settings ?? []; + $settings['theme'] = ($settings['theme'] ?? 'dark') === 'dark' ? 'light' : 'dark'; + $this->version->update(['settings' => $settings]); + $this->settings = $settings; + } + + public function saveName(): void + { + $this->validate([ + 'versionName' => 'required|string|max:255', + ]); + + $this->version->update(['name' => $this->versionName]); + session()->flash('success', 'Name aktualisiert!'); + } + + // ======================================== + // SETTINGS + // ======================================== + + public function openSettingsModal(): void + { + $this->settings = $this->version->settings ?? []; + $this->showSettingsModal = true; + } + + public function saveSettings(): void + { + $this->version->update(['settings' => $this->settings]); + $this->showSettingsModal = false; + session()->flash('success', 'Einstellungen gespeichert!'); + } + + // ======================================== + // ITEM CRUD + // ======================================== + + public function openItemModal(?int $id = null, string $type = ''): void + { + $this->resetItemForm(); + + if ($id) { + $item = DisplayVersionItem::findOrFail($id); + $this->itemId = $item->id; + $this->itemType = $item->item_type; + $this->loadItemContent($item); + } else { + $this->itemType = $type ?: $this->defaultItemType(); + } + + $this->showItemModal = true; + } + + public function saveItem(): void + { + $content = $this->buildItemContent(); + $isActive = $this->getActiveFlag(); + + if ($this->itemId) { + $item = DisplayVersionItem::findOrFail($this->itemId); + $item->update([ + 'content' => $content, + 'is_active' => $isActive, + ]); + session()->flash('success', 'Inhalt aktualisiert!'); + } else { + $maxSort = DisplayVersionItem::where('display_version_id', $this->version->id) + ->where('item_type', $this->itemType) + ->max('sort_order') ?? -1; + + DisplayVersionItem::create([ + 'display_version_id' => $this->version->id, + 'item_type' => $this->itemType, + 'content' => $content, + 'sort_order' => $maxSort + 1, + 'is_active' => $isActive, + ]); + session()->flash('success', 'Inhalt hinzugefügt!'); + } + + $this->closeItemModal(); + } + + public function deleteItem(int $id): void + { + DisplayVersionItem::findOrFail($id)->delete(); + session()->flash('success', 'Inhalt gelöscht!'); + } + + public function toggleItemStatus(int $id): void + { + $item = DisplayVersionItem::findOrFail($id); + $item->update(['is_active' => ! $item->is_active]); + } + + public function moveItem(int $id, string $direction): void + { + $item = DisplayVersionItem::findOrFail($id); + $currentOrder = $item->sort_order; + + $swapItem = DisplayVersionItem::where('display_version_id', $this->version->id) + ->where('item_type', $item->item_type) + ->where('sort_order', $direction === 'up' ? $currentOrder - 1 : $currentOrder + 1) + ->first(); + + if ($swapItem) { + $item->update(['sort_order' => $swapItem->sort_order]); + $swapItem->update(['sort_order' => $currentOrder]); + } + } + + #[On('display-media-selected')] + public function onDisplayMediaSelected(string $field, ?int $mediaId, ?string $url): void + { + if (! $url) { + return; + } + + match ($field) { + 'videoFilename' => $this->videoFilename = $url, + 'mediaUrl' => $this->mediaUrl = $url, + 'slideImageUrl' => $this->slideImageUrl = $url, + default => null, + }; + } + + public function addBullet(): void + { + $this->slideBullets[] = ''; + } + + public function removeBullet(int $index): void + { + unset($this->slideBullets[$index]); + $this->slideBullets = array_values($this->slideBullets); + } + + public function closeItemModal(): void + { + $this->showItemModal = false; + $this->resetItemForm(); + } + + // ======================================== + // HELPERS + // ======================================== + + private function loadItemContent(DisplayVersionItem $item): void + { + $content = $item->content; + + match ($item->item_type) { + 'video' => $this->loadVideoContent($content), + 'footer' => $this->loadFooterContent($content), + 'media' => $this->loadMediaContent($content), + 'slide' => $this->loadSlideContent($content), + default => null, + }; + } + + private function loadVideoContent(array $content): void + { + $this->videoFilename = $content['filename'] ?? ''; + $this->videoTitle = $content['title'] ?? ''; + $this->videoPosition = $content['position'] ?? 25; + $this->videoIsActive = true; + } + + private function loadFooterContent(array $content): void + { + $this->footerHeadline = $content['headline'] ?? ''; + $this->footerSubline = $content['subline'] ?? ''; + $this->footerUrl = $content['url'] ?? ''; + $this->footerIsActive = true; + } + + private function loadMediaContent(array $content): void + { + $this->mediaType = $content['media_type'] ?? 'image'; + $this->mediaCategory = $content['category'] ?? 'immobilien'; + $this->mediaUrl = $content['media_url'] ?? ''; + $this->mediaHeadline = $content['headline'] ?? ''; + $this->mediaSubline = $content['subline'] ?? ''; + $this->mediaDuration = $content['duration_seconds'] ?? 10; + $this->mediaIsActive = true; + } + + private function loadSlideContent(array $content): void + { + $this->slideType = $content['type'] ?? 'product-hero'; + $this->slideDuration = $content['duration'] ?? 8000; + $this->slideImageUrl = $content['image_url'] ?? ''; + $this->slideBadge = $content['badge_text'] ?? ''; + $this->slideEyebrow = $content['eyebrow'] ?? ''; + $this->slideTitle = $content['title'] ?? ''; + $this->slideSubline = $content['subline'] ?? ''; + $this->slidePrice = $content['price'] ?? ''; + $this->slideOriginalPrice = $content['original_price'] ?? ''; + $this->slideTagText = $content['tag_text'] ?? ''; + $this->slideBullets = $content['bullets'] ?? []; + $this->slideDisclaimer = $content['disclaimer'] ?? ''; + $this->slideQrUrl = $content['qr_url'] ?? ''; + $this->slideQrTitle = $content['qr_title'] ?? ''; + $this->slideContact = $content['contact'] ?? ''; + $this->slideShowBrandText = $content['show_brand_text'] ?? false; + $this->slideBrandTagline = $content['brand_tagline'] ?? ''; + $this->slideIsActive = true; + } + + /** + * @return array + */ + private function buildItemContent(): array + { + return match ($this->itemType) { + 'video' => [ + 'filename' => $this->videoFilename, + 'title' => $this->videoTitle, + 'position' => $this->videoPosition, + ], + 'footer' => [ + 'headline' => $this->footerHeadline, + 'subline' => $this->footerSubline, + 'url' => $this->footerUrl ?: null, + ], + 'media' => [ + 'media_type' => $this->mediaType, + 'category' => $this->mediaCategory, + 'media_url' => $this->mediaUrl, + 'headline' => $this->mediaHeadline, + 'subline' => $this->mediaSubline, + 'duration_seconds' => $this->mediaDuration, + ], + 'slide' => [ + 'type' => $this->slideType, + 'duration' => $this->slideDuration, + 'image_url' => $this->slideImageUrl, + 'badge_text' => $this->slideBadge, + 'eyebrow' => $this->slideEyebrow, + 'title' => $this->slideTitle, + 'subline' => $this->slideSubline, + 'price' => $this->slidePrice, + 'original_price' => $this->slideOriginalPrice, + 'tag_text' => $this->slideTagText, + 'bullets' => $this->slideBullets, + 'disclaimer' => $this->slideDisclaimer, + 'qr_url' => $this->slideQrUrl, + 'qr_title' => $this->slideQrTitle, + 'contact' => $this->slideContact, + 'show_brand_text' => $this->slideShowBrandText, + 'brand_tagline' => $this->slideBrandTagline, + ], + default => [], + }; + } + + private function getActiveFlag(): bool + { + return match ($this->itemType) { + 'video' => $this->videoIsActive, + 'footer' => $this->footerIsActive, + 'media' => $this->mediaIsActive, + 'slide' => $this->slideIsActive, + default => true, + }; + } + + private function defaultItemType(): string + { + return match ($this->version->type) { + DisplayVersionType::VideoDisplay => 'video', + DisplayVersionType::B2in => 'media', + DisplayVersionType::Offers => 'slide', + }; + } + + private function resetItemForm(): void + { + $this->itemId = null; + $this->itemType = ''; + $this->videoFilename = ''; + $this->videoTitle = ''; + $this->videoPosition = 25; + $this->videoIsActive = true; + $this->footerHeadline = ''; + $this->footerSubline = ''; + $this->footerUrl = ''; + $this->footerIsActive = true; + $this->mediaType = 'image'; + $this->mediaCategory = 'immobilien'; + $this->mediaUrl = ''; + $this->mediaHeadline = ''; + $this->mediaSubline = ''; + $this->mediaDuration = 10; + $this->mediaIsActive = true; + $this->slideType = 'product-hero'; + $this->slideDuration = 8000; + $this->slideImageUrl = ''; + $this->slideBadge = ''; + $this->slideEyebrow = ''; + $this->slideTitle = ''; + $this->slideSubline = ''; + $this->slidePrice = ''; + $this->slideOriginalPrice = ''; + $this->slideTagText = ''; + $this->slideBullets = []; + $this->slideDisclaimer = ''; + $this->slideQrUrl = ''; + $this->slideQrTitle = ''; + $this->slideContact = ''; + $this->slideShowBrandText = false; + $this->slideBrandTagline = ''; + $this->slideIsActive = true; + } + + public function render() + { + $items = $this->version->items()->get()->groupBy('item_type'); + + return view('livewire.admin.cms.display-version-editor', [ + 'items' => $items, + ]); + } +} diff --git a/app/Livewire/Admin/Cms/DisplayVersionList.php b/app/Livewire/Admin/Cms/DisplayVersionList.php new file mode 100644 index 0000000..5eb1c97 --- /dev/null +++ b/app/Livewire/Admin/Cms/DisplayVersionList.php @@ -0,0 +1,102 @@ +newName = ''; + $this->newType = ''; + $this->showCreateModal = true; + } + + public function createVersion(): void + { + $this->validate([ + 'newName' => 'required|string|max:255', + 'newType' => 'required|string|in:video-display,b2in,offers', + ], [ + 'newName.required' => 'Bitte geben Sie einen Namen ein.', + 'newType.required' => 'Bitte wählen Sie einen Typ aus.', + ]); + + $version = DisplayVersion::create([ + 'name' => $this->newName, + 'type' => $this->newType, + 'settings' => $this->defaultSettingsForType($this->newType), + 'is_active' => true, + ]); + + $this->showCreateModal = false; + $this->newName = ''; + $this->newType = ''; + + session()->flash('success', 'Version "'.$version->name.'" wurde erstellt!'); + + $this->redirect( + route('admin.cms.display-version-edit', $version), + navigate: true + ); + } + + public function deleteVersion(int $id): void + { + $version = DisplayVersion::findOrFail($id); + $name = $version->name; + $version->delete(); + + session()->flash('success', 'Version "'.$name.'" wurde gelöscht!'); + } + + public function toggleActive(int $id): void + { + $version = DisplayVersion::findOrFail($id); + $version->update(['is_active' => ! $version->is_active]); + } + + /** + * @return array + */ + private function defaultSettingsForType(string $type): array + { + return match ($type) { + 'b2in' => [ + 'theme' => 'dark', + 'footer_name' => '', + 'footer_url' => '', + 'transition' => ['type' => 'crossfade', 'duration_ms' => 800], + 'default_image_duration' => 10, + 'rotation_weights' => ['immobilien' => 70, 'moebel' => 30], + 'display_active' => true, + ], + 'offers' => [ + 'loop' => true, + 'transition' => ['type' => 'fade', 'duration' => 600], + ], + default => [], + }; + } + + public function render() + { + $versions = DisplayVersion::withCount(['items', 'displays']) + ->orderBy('name') + ->get(); + + return view('livewire.admin.cms.display-version-list', [ + 'versions' => $versions, + 'types' => DisplayVersionType::cases(), + ]); + } +} diff --git a/app/Livewire/Admin/Cms/MediaLibraryUploader.php b/app/Livewire/Admin/Cms/MediaLibraryUploader.php new file mode 100644 index 0000000..8b495b6 --- /dev/null +++ b/app/Livewire/Admin/Cms/MediaLibraryUploader.php @@ -0,0 +1,45 @@ + */ + public array $uploads = []; + + public function updatedUploads(): void + { + $this->validate([ + 'uploads' => 'nullable|array|max:20', + 'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240', + ]); + + $service = app(MediaConversionService::class); + + foreach ($this->uploads as $file) { + $media = $service->storeUpload($file); + $this->dispatch('media-library-uploaded', mediaId: $media->id); + } + + $this->uploads = []; + } + + public function removeUpload(int $index): void + { + if (isset($this->uploads[$index])) { + unset($this->uploads[$index]); + $this->uploads = array_values($this->uploads); + } + } + + public function render() + { + return view('livewire.admin.cms.media-library-uploader'); + } +} diff --git a/app/Livewire/Admin/Cms/MediaPicker.php b/app/Livewire/Admin/Cms/MediaPicker.php new file mode 100644 index 0000000..14eb146 --- /dev/null +++ b/app/Livewire/Admin/Cms/MediaPicker.php @@ -0,0 +1,139 @@ + */ + public array $quickUploads = []; + + public function mount(?int $value = null): void + { + $this->value = $value; + } + + public function openPicker(): void + { + $this->showModal = true; + } + + public function selectMedia(int $id): void + { + $media = CmsMedia::find($id); + if (! $media) { + return; + } + + if ($media->isImage() && $this->profile) { + $service = app(MediaConversionService::class); + if (! $media->hasConversion($this->profile)) { + $service->convert($media, $this->profile); + $media->refresh(); + } + } + + $this->value = $media->id; + $this->showModal = false; + + $this->dispatch('media-selected', field: $this->field, mediaId: $media->id, url: $media->getConversionUrl($this->profile)); + } + + public function clearSelection(): void + { + $this->value = null; + $this->dispatch('media-selected', field: $this->field, mediaId: null, url: null); + } + + public function updatedQuickUploads(): void + { + $this->validate([ + 'quickUploads' => 'nullable|array|max:5', + 'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240', + ]); + + $service = app(MediaConversionService::class); + $lastMedia = null; + + foreach ($this->quickUploads as $file) { + $lastMedia = $service->storeUpload($file); + + if ($lastMedia->isImage() && $this->profile) { + $service->convert($lastMedia, $this->profile); + $lastMedia->refresh(); + } + } + + $this->quickUploads = []; + + if ($lastMedia) { + $this->value = $lastMedia->id; + $this->showModal = false; + + $this->dispatch('media-selected', field: $this->field, mediaId: $lastMedia->id, url: $lastMedia->getConversionUrl($this->profile)); + } + } + + public function removeQuickUpload(int $index): void + { + if (isset($this->quickUploads[$index])) { + unset($this->quickUploads[$index]); + $this->quickUploads = array_values($this->quickUploads); + } + } + + public function render(): View + { + return view('livewire.admin.cms.media-picker', [ + 'selectedMedia' => $this->resolveSelectedMedia(), + 'mediaItems' => $this->resolveMediaItems(), + ]); + } + + private function resolveSelectedMedia(): ?CmsMedia + { + if (! $this->value) { + return null; + } + + return CmsMedia::find($this->value); + } + + /** + * @return LengthAwarePaginator + */ + private function resolveMediaItems(): LengthAwarePaginator + { + return CmsMedia::query() + ->when($this->type === 'image', fn ($q) => $q->images()) + ->when($this->type === 'pdf', fn ($q) => $q->pdfs()) + ->when($this->type === 'document', fn ($q) => $q->documents()) + ->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%")) + ->orderByDesc('created_at') + ->paginate(18); + } +} diff --git a/app/Livewire/Cabinet/QuickStatus.php b/app/Livewire/Cabinet/QuickStatus.php new file mode 100644 index 0000000..47dcd6a --- /dev/null +++ b/app/Livewire/Cabinet/QuickStatus.php @@ -0,0 +1,109 @@ + */ + public array $statusOptions = [ + 'auto' => [ + 'label' => 'Automatisch', + 'color' => 'green', + 'icon' => '✓', + 'description' => 'Status aus Öffnungszeiten', + ], + 'closed' => [ + 'label' => 'Geschlossen', + 'color' => 'yellow', + 'icon' => '–', + 'description' => 'Manuell geschlossen', + ], + 'notice' => [ + 'label' => 'Hinweis', + 'color' => 'orange', + 'icon' => '!', + 'description' => 'Info-Nachricht anzeigen', + ], + 'warning' => [ + 'label' => 'Warnung', + 'color' => 'red', + 'icon' => '!', + 'description' => 'Dringende Warnung', + ], + ]; + + public function mount(): void + { + $validKey = config('domains.cabinet_status_key'); + $key = request()->get('key'); + if (! $validKey || $key !== $validKey) { + $this->authorized = false; + + return; + } + + $this->authorized = true; + $settings = CabinetTabletSetting::current(); + $this->storeStatus = $settings->store_status ?? 'auto'; + $this->noticeHeadline = $settings->notice_headline ?? ''; + $this->noticeSubtext = $settings->notice_subtext ?? ''; + } + + public function selectStatus(string $status): void + { + if (! $this->authorized) { + return; + } + + if (! array_key_exists($status, $this->statusOptions)) { + return; + } + + $this->storeStatus = $status; + $this->saved = false; + } + + public function save(): void + { + if (! $this->authorized) { + return; + } + + $this->validate([ + 'storeStatus' => 'required|in:auto,notice,warning,closed', + 'noticeHeadline' => 'nullable|string|max:40', + 'noticeSubtext' => 'nullable|string|max:80', + ], [ + 'storeStatus.in' => 'Ungültiger Status.', + 'noticeHeadline.max' => 'Headline max. 40 Zeichen.', + 'noticeSubtext.max' => 'Subtext max. 80 Zeichen.', + ]); + + CabinetTabletSetting::current()->update([ + 'store_status' => $this->storeStatus, + 'notice_headline' => $this->noticeHeadline ?: null, + 'notice_subtext' => $this->noticeSubtext ?: null, + ]); + + $this->saved = true; + } + + public function render(): \Illuminate\View\View + { + return view('livewire.cabinet.quick-status') + ->layout('layouts.cabinet-quick'); + } +} diff --git a/app/Livewire/Web/Components/Sections/AboutHero.php b/app/Livewire/Web/Components/Sections/AboutHero.php index 38bd6ba..c0aaa1a 100644 --- a/app/Livewire/Web/Components/Sections/AboutHero.php +++ b/app/Livewire/Web/Components/Sections/AboutHero.php @@ -10,8 +10,7 @@ class AboutHero extends Component public function mount() { - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.about_hero", []); + $this->content = cms_theme_section('about_hero'); } public function render() diff --git a/app/Livewire/Web/Components/Sections/BenefitsSection.php b/app/Livewire/Web/Components/Sections/BenefitsSection.php index f4e2649..4b8abb6 100644 --- a/app/Livewire/Web/Components/Sections/BenefitsSection.php +++ b/app/Livewire/Web/Components/Sections/BenefitsSection.php @@ -7,8 +7,11 @@ use Livewire\Component; class BenefitsSection extends Component { public $content = []; + public $layout = 'left'; // Standard-Layout + public $bg = 'bg-background'; + public $section = 'card_section'; public function mount($layout = 'left', $bg = 'bg-background', $section = 'card_section') @@ -16,11 +19,9 @@ class BenefitsSection extends Component $this->layout = $layout; $this->bg = $bg; $this->section = $section; - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.{$this->section}", []); + $this->content = cms_theme_section($this->section); } - public function render() { return view('livewire.web.components.sections.benefits-section'); diff --git a/app/Livewire/Web/Components/Sections/BrandWorlds.php b/app/Livewire/Web/Components/Sections/BrandWorlds.php index aa3223d..fa54c1b 100644 --- a/app/Livewire/Web/Components/Sections/BrandWorlds.php +++ b/app/Livewire/Web/Components/Sections/BrandWorlds.php @@ -7,14 +7,16 @@ use Livewire\Component; class BrandWorlds extends Component { public $title; + public $subtitle; + public $worlds = []; + public $bg = 'bg-background'; public function mount($bg = 'bg-background') { - $theme = config('app.theme', 'b2in'); - $content = config("content.themes.{$theme}.brand_worlds"); + $content = cms_theme_section('brand_worlds'); $this->title = $content['title'] ?? ''; $this->subtitle = $content['subtitle'] ?? ''; diff --git a/app/Livewire/Web/Components/Sections/BrokerSection.php b/app/Livewire/Web/Components/Sections/BrokerSection.php index e2f018c..b6444bc 100644 --- a/app/Livewire/Web/Components/Sections/BrokerSection.php +++ b/app/Livewire/Web/Components/Sections/BrokerSection.php @@ -10,8 +10,7 @@ class BrokerSection extends Component public function mount() { - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.broker_section", []); + $this->content = cms_theme_section('broker_section'); } public function render() diff --git a/app/Livewire/Web/Components/Sections/CTASection.php b/app/Livewire/Web/Components/Sections/CTASection.php index 43ff12f..6fc5c45 100644 --- a/app/Livewire/Web/Components/Sections/CTASection.php +++ b/app/Livewire/Web/Components/Sections/CTASection.php @@ -7,14 +7,15 @@ use Livewire\Component; class CTASection extends Component { public $content = []; + public $bg = ''; + public $section = ''; public function mount($bg = 'bg-secondary', $section = 'cta_section') { $this->section = $section; - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.{$this->section}", []); + $this->content = cms_theme_section($this->section); $this->bg = $bg; } diff --git a/app/Livewire/Web/Components/Sections/CardSection.php b/app/Livewire/Web/Components/Sections/CardSection.php index 6cefb69..5c98404 100644 --- a/app/Livewire/Web/Components/Sections/CardSection.php +++ b/app/Livewire/Web/Components/Sections/CardSection.php @@ -7,8 +7,11 @@ use Livewire\Component; class CardSection extends Component { public $content = []; + public $layout = 'left'; // Standard-Layout + public $bg = 'bg-background'; + public $section = 'card_section'; public function mount($layout = 'left', $bg = 'bg-background', $section = 'card_section') @@ -16,8 +19,7 @@ class CardSection extends Component $this->layout = $layout; $this->bg = $bg; $this->section = $section; - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.{$this->section}", []); + $this->content = cms_theme_section($this->section); } public function render() diff --git a/app/Livewire/Web/Components/Sections/CommitmentSection.php b/app/Livewire/Web/Components/Sections/CommitmentSection.php index 4e7bd48..c4c7588 100644 --- a/app/Livewire/Web/Components/Sections/CommitmentSection.php +++ b/app/Livewire/Web/Components/Sections/CommitmentSection.php @@ -10,8 +10,7 @@ class CommitmentSection extends Component public function mount() { - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.commitment_section", []); + $this->content = cms_theme_section('commitment_section'); } public function render() diff --git a/app/Livewire/Web/Components/Sections/ContentSection.php b/app/Livewire/Web/Components/Sections/ContentSection.php index 40a877c..c6b609a 100644 --- a/app/Livewire/Web/Components/Sections/ContentSection.php +++ b/app/Livewire/Web/Components/Sections/ContentSection.php @@ -7,8 +7,11 @@ use Livewire\Component; class ContentSection extends Component { public $content = []; + public $layout = 'left'; // Standard-Layout + public $bg = 'bg-background'; + public $section = 'content_section_left'; public function mount($layout = 'left', $bg = 'bg-background', $section = 'content_section') @@ -16,8 +19,7 @@ class ContentSection extends Component $this->layout = $layout; $this->bg = $bg; $this->section = $section; - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.{$this->section}", []); + $this->content = cms_theme_section($this->section); } public function render() diff --git a/app/Livewire/Web/Components/Sections/DarkStatsSection.php b/app/Livewire/Web/Components/Sections/DarkStatsSection.php index 05b3278..937a79b 100644 --- a/app/Livewire/Web/Components/Sections/DarkStatsSection.php +++ b/app/Livewire/Web/Components/Sections/DarkStatsSection.php @@ -10,8 +10,7 @@ class DarkStatsSection extends Component public function mount() { - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.dark_stats_section", []); + $this->content = cms_theme_section('dark_stats_section'); } public function render() diff --git a/app/Livewire/Web/Components/Sections/DigitalCore.php b/app/Livewire/Web/Components/Sections/DigitalCore.php index e15c6a5..66f5709 100644 --- a/app/Livewire/Web/Components/Sections/DigitalCore.php +++ b/app/Livewire/Web/Components/Sections/DigitalCore.php @@ -10,8 +10,7 @@ class DigitalCore extends Component public function mount() { - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.digital_core", []); + $this->content = cms_theme_section('digital_core'); } public function render() diff --git a/app/Livewire/Web/Components/Sections/EcosystemCore.php b/app/Livewire/Web/Components/Sections/EcosystemCore.php index eb33226..c553ff8 100644 --- a/app/Livewire/Web/Components/Sections/EcosystemCore.php +++ b/app/Livewire/Web/Components/Sections/EcosystemCore.php @@ -7,7 +7,9 @@ use Livewire\Component; class EcosystemCore extends Component { public $content = []; + public $bg = 'bg-background'; + public $section = 'ecosystem_core'; public function mount($bg = 'bg-background', $section = 'ecosystem_core') @@ -15,8 +17,7 @@ class EcosystemCore extends Component $this->bg = $bg; $this->section = $section; - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.{$this->section}", []); + $this->content = cms_theme_section($this->section); } public function render() diff --git a/app/Livewire/Web/Components/Sections/EcosystemHero.php b/app/Livewire/Web/Components/Sections/EcosystemHero.php index 6741b84..605b8f2 100644 --- a/app/Livewire/Web/Components/Sections/EcosystemHero.php +++ b/app/Livewire/Web/Components/Sections/EcosystemHero.php @@ -10,12 +10,11 @@ class EcosystemHero extends Component public function mount() { - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.ecosystem_hero", []); + $this->content = cms_theme_section('ecosystem_hero'); } public function render() { return view('livewire.web.components.sections.ecosystem-hero'); } -} \ No newline at end of file +} diff --git a/app/Livewire/Web/Components/Sections/EcosystemStats.php b/app/Livewire/Web/Components/Sections/EcosystemStats.php index c8f84e5..423f50f 100644 --- a/app/Livewire/Web/Components/Sections/EcosystemStats.php +++ b/app/Livewire/Web/Components/Sections/EcosystemStats.php @@ -10,8 +10,7 @@ class EcosystemStats extends Component public function mount() { - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.ecosystem_stats", []); + $this->content = cms_theme_section('ecosystem_stats'); } public function render() diff --git a/app/Livewire/Web/Components/Sections/EndCustomerSection.php b/app/Livewire/Web/Components/Sections/EndCustomerSection.php index 84ce06e..94e2ff3 100644 --- a/app/Livewire/Web/Components/Sections/EndCustomerSection.php +++ b/app/Livewire/Web/Components/Sections/EndCustomerSection.php @@ -7,15 +7,16 @@ use Livewire\Component; class EndCustomerSection extends Component { public $content = []; + public $bg = ''; + public $section = ''; public function mount($bg = 'bg-accent', $section = 'end_customer_section') { - $theme = config('app.theme', 'b2in'); $this->section = $section; $this->bg = $bg; - $this->content = config("content.themes.{$theme}.end_customer_section", []); + $this->content = cms_theme_section('end_customer_section'); } public function render() diff --git a/app/Livewire/Web/Components/Sections/FAQ.php b/app/Livewire/Web/Components/Sections/FAQ.php index e72c3ee..44c71e2 100644 --- a/app/Livewire/Web/Components/Sections/FAQ.php +++ b/app/Livewire/Web/Components/Sections/FAQ.php @@ -7,7 +7,9 @@ use Livewire\Component; class FAQ extends Component { public $content = []; + public $bg = ''; + public $section = ''; public function mount($bg = 'bg-background', $section = 'faq') @@ -15,8 +17,7 @@ class FAQ extends Component $this->bg = $bg; $this->section = $section; - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.{$this->section}", []); + $this->content = cms_theme_section($this->section); } public function render() diff --git a/app/Livewire/Web/Components/Sections/FinalCommitment.php b/app/Livewire/Web/Components/Sections/FinalCommitment.php index 1525def..6964596 100644 --- a/app/Livewire/Web/Components/Sections/FinalCommitment.php +++ b/app/Livewire/Web/Components/Sections/FinalCommitment.php @@ -10,8 +10,7 @@ class FinalCommitment extends Component public function mount() { - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.final_commitment", []); + $this->content = cms_theme_section('final_commitment'); } public function render() diff --git a/app/Livewire/Web/Components/Sections/FounderBar.php b/app/Livewire/Web/Components/Sections/FounderBar.php new file mode 100644 index 0000000..bb4cc8f --- /dev/null +++ b/app/Livewire/Web/Components/Sections/FounderBar.php @@ -0,0 +1,20 @@ +content = cms_theme_section('founder_bar'); + } + + public function render() + { + return view('livewire.web.components.sections.founder-bar'); + } +} diff --git a/app/Livewire/Web/Components/Sections/Hero.php b/app/Livewire/Web/Components/Sections/Hero.php index 141bb85..fe889d9 100644 --- a/app/Livewire/Web/Components/Sections/Hero.php +++ b/app/Livewire/Web/Components/Sections/Hero.php @@ -10,8 +10,7 @@ class Hero extends Component public function mount() { - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.hero", []); + $this->content = cms_theme_section('hero'); } public function render() diff --git a/app/Livewire/Web/Components/Sections/HeroImage.php b/app/Livewire/Web/Components/Sections/HeroImage.php index 04cd8ce..bc8c8a2 100644 --- a/app/Livewire/Web/Components/Sections/HeroImage.php +++ b/app/Livewire/Web/Components/Sections/HeroImage.php @@ -10,8 +10,7 @@ class HeroImage extends Component public function mount() { - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.hero_image", []); + $this->content = cms_theme_section('hero_image'); } public function render() diff --git a/app/Livewire/Web/Components/Sections/HeroSlider.php b/app/Livewire/Web/Components/Sections/HeroSlider.php index 7a768c6..738e0de 100644 --- a/app/Livewire/Web/Components/Sections/HeroSlider.php +++ b/app/Livewire/Web/Components/Sections/HeroSlider.php @@ -7,24 +7,24 @@ use Livewire\Component; class HeroSlider extends Component { public $content = []; + public $currentSlide = 0; public function mount() { - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.hero_slider", []); + $this->content = cms_theme_section('hero_slider'); } public function nextSlide() { - if (!empty($this->content['slides'])) { + if (! empty($this->content['slides'])) { $this->currentSlide = ($this->currentSlide + 1) % count($this->content['slides']); } } public function previousSlide() { - if (!empty($this->content['slides'])) { + if (! empty($this->content['slides'])) { $totalSlides = count($this->content['slides']); $this->currentSlide = ($this->currentSlide - 1 + $totalSlides) % $totalSlides; } @@ -32,7 +32,7 @@ class HeroSlider extends Component public function setSlide($index) { - if (!empty($this->content['slides']) && $index >= 0 && $index < count($this->content['slides'])) { + if (! empty($this->content['slides']) && $index >= 0 && $index < count($this->content['slides'])) { $this->currentSlide = $index; } } diff --git a/app/Livewire/Web/Components/Sections/HeroTiles.php b/app/Livewire/Web/Components/Sections/HeroTiles.php index e3f2aa4..984ff29 100644 --- a/app/Livewire/Web/Components/Sections/HeroTiles.php +++ b/app/Livewire/Web/Components/Sections/HeroTiles.php @@ -10,8 +10,7 @@ class HeroTiles extends Component public function mount() { - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.hero_tiles", []); + $this->content = cms_theme_section('hero_tiles'); } public function render() diff --git a/app/Livewire/Web/Components/Sections/ImageBreak.php b/app/Livewire/Web/Components/Sections/ImageBreak.php new file mode 100644 index 0000000..3d39843 --- /dev/null +++ b/app/Livewire/Web/Components/Sections/ImageBreak.php @@ -0,0 +1,23 @@ +section = $section; + $this->content = cms_theme_section($this->section); + } + + public function render() + { + return view('livewire.web.components.sections.image-break'); + } +} diff --git a/app/Livewire/Web/Components/Sections/ImmobilienContactForm.php b/app/Livewire/Web/Components/Sections/ImmobilienContactForm.php new file mode 100644 index 0000000..44c4daa --- /dev/null +++ b/app/Livewire/Web/Components/Sections/ImmobilienContactForm.php @@ -0,0 +1,100 @@ + */ + public array $interestOptions = []; + + public string $interest = ''; + + public string $firstName = ''; + + public string $lastName = ''; + + public string $email = ''; + + public string $phone = ''; + + public string $message = ''; + + public bool $privacy = false; + + public bool $success = false; + + /** @var string Hidden honeypot field */ + public string $website = ''; + + public ?int $formLoadedAt = null; + + /** + * @param array $interestOptions + */ + public function mount(string $projectSlug = '', string $projectTitle = '', array $interestOptions = []): void + { + $this->projectSlug = $projectSlug; + $this->projectTitle = $projectTitle; + $this->interestOptions = $interestOptions; + $this->formLoadedAt = time(); + } + + public function submit(ContactFormService $service): void + { + $this->validate([ + 'firstName' => ['required', 'string', 'max:255'], + 'lastName' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email:rfc', 'max:255'], + 'phone' => ['nullable', 'string', 'max:80'], + 'interest' => ['nullable', 'string', 'max:255'], + 'message' => ['nullable', 'string', 'max:2000'], + 'privacy' => ['accepted'], + 'website' => ['nullable', 'string', 'max:0'], + ]); + + $spamDetector = SpamDetector::fromConfig(); + $payload = [ + 'project' => $this->projectSlug, + 'project_title' => $this->projectTitle, + 'interest' => $this->interest, + 'first_name' => $this->firstName, + 'last_name' => $this->lastName, + 'email' => $this->email, + 'phone' => $this->phone, + 'message' => $this->message, + 'website' => $this->website, + 'ip' => request()->ip(), + 'user_agent' => request()->userAgent(), + 'is_spam' => false, + ]; + + $payload['is_spam'] = $spamDetector->detect($payload, $this->formLoadedAt); + + $subject = 'Immobilien-Anfrage: '.($this->projectTitle ?: $this->projectSlug); + $service->handle($payload, $subject, 'immobilien-contact-form'); + + $this->success = true; + $this->interest = ''; + $this->firstName = ''; + $this->lastName = ''; + $this->email = ''; + $this->phone = ''; + $this->message = ''; + $this->privacy = false; + $this->website = ''; + $this->formLoadedAt = time(); + } + + public function render(): \Illuminate\View\View + { + return view('livewire.web.components.sections.immobilien-contact-form'); + } +} diff --git a/app/Livewire/Web/Components/Sections/LeadershipTeam.php b/app/Livewire/Web/Components/Sections/LeadershipTeam.php index f222a2d..0b27974 100644 --- a/app/Livewire/Web/Components/Sections/LeadershipTeam.php +++ b/app/Livewire/Web/Components/Sections/LeadershipTeam.php @@ -10,8 +10,7 @@ class LeadershipTeam extends Component public function mount() { - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.leadership_team", []); + $this->content = cms_theme_section('leadership_team'); } public function render() diff --git a/app/Livewire/Web/Components/Sections/MagazinDetail.php b/app/Livewire/Web/Components/Sections/MagazinDetail.php index 81a2cd4..0ef295b 100644 --- a/app/Livewire/Web/Components/Sections/MagazinDetail.php +++ b/app/Livewire/Web/Components/Sections/MagazinDetail.php @@ -7,8 +7,11 @@ use Livewire\Component; class MagazinDetail extends Component { public $articleId; + public $article; + public $relatedArticles; + public $content; public function mount($id = 1) @@ -29,7 +32,7 @@ class MagazinDetail extends Component { $articles = $this->getArticlesData(); $this->relatedArticles = collect($articles) - ->filter(fn($article, $key) => $key != $this->articleId) + ->filter(fn ($article, $key) => $key != $this->articleId) ->take(2) ->values() ->toArray(); @@ -37,20 +40,19 @@ class MagazinDetail extends Component private function loadThemeContent() { - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.magazin_detail", []); + $this->content = cms_theme_section('magazin_detail'); } private function getArticlesData() { - return config('content.articles', []); + return trans('b2in.articles'); } public function render() { return view('livewire.web.components.sections.magazin-detail', [ 'article' => $this->article, - 'relatedArticles' => $this->relatedArticles + 'relatedArticles' => $this->relatedArticles, ]); } } diff --git a/app/Livewire/Web/Components/Sections/MagazinList.php b/app/Livewire/Web/Components/Sections/MagazinList.php index 7c7838d..ecd1e80 100644 --- a/app/Livewire/Web/Components/Sections/MagazinList.php +++ b/app/Livewire/Web/Components/Sections/MagazinList.php @@ -7,6 +7,7 @@ use Livewire\Component; class MagazinList extends Component { public $posts = []; + public $content = []; public function mount() @@ -17,7 +18,7 @@ class MagazinList extends Component private function loadPosts() { - $articles = config('content.articles', []); + $articles = trans('b2in.articles'); $this->posts = collect($articles)->map(function ($article) { return [ 'id' => $article['id'], @@ -32,8 +33,7 @@ class MagazinList extends Component private function loadThemeContent() { - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.magazin_list", []); + $this->content = cms_theme_section('magazin_list'); } public function render() diff --git a/app/Livewire/Web/Components/Sections/OurStory.php b/app/Livewire/Web/Components/Sections/OurStory.php index 59d2b20..7792678 100644 --- a/app/Livewire/Web/Components/Sections/OurStory.php +++ b/app/Livewire/Web/Components/Sections/OurStory.php @@ -10,8 +10,7 @@ class OurStory extends Component public function mount() { - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.our_story", []); + $this->content = cms_theme_section('our_story'); } public function render() diff --git a/app/Livewire/Web/Components/Sections/OurValues.php b/app/Livewire/Web/Components/Sections/OurValues.php index ea3b21f..d6979b6 100644 --- a/app/Livewire/Web/Components/Sections/OurValues.php +++ b/app/Livewire/Web/Components/Sections/OurValues.php @@ -10,8 +10,7 @@ class OurValues extends Component public function mount() { - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.our_values", []); + $this->content = cms_theme_section('our_values'); } public function render() diff --git a/app/Livewire/Web/Components/Sections/PartnerBenefits.php b/app/Livewire/Web/Components/Sections/PartnerBenefits.php index 89738f6..79ebc40 100644 --- a/app/Livewire/Web/Components/Sections/PartnerBenefits.php +++ b/app/Livewire/Web/Components/Sections/PartnerBenefits.php @@ -10,8 +10,7 @@ class PartnerBenefits extends Component public function mount() { - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.partner_benefits", []); + $this->content = cms_theme_section('partner_benefits'); } public function render() diff --git a/app/Livewire/Web/Components/Sections/PartnerCTA.php b/app/Livewire/Web/Components/Sections/PartnerCTA.php index 7d9ae46..a6c80bf 100644 --- a/app/Livewire/Web/Components/Sections/PartnerCTA.php +++ b/app/Livewire/Web/Components/Sections/PartnerCTA.php @@ -7,14 +7,15 @@ use Livewire\Component; class PartnerCTA extends Component { public $content = []; + public $bg = ''; + public $section = ''; public function mount($bg = 'bg-secondary', $section = 'partner_cta') { $this->section = $section; - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.{$this->section}", []); + $this->content = cms_theme_section($this->section); $this->bg = $bg; } diff --git a/app/Livewire/Web/Components/Sections/PartnerHero.php b/app/Livewire/Web/Components/Sections/PartnerHero.php index 4e68440..a571f49 100644 --- a/app/Livewire/Web/Components/Sections/PartnerHero.php +++ b/app/Livewire/Web/Components/Sections/PartnerHero.php @@ -28,20 +28,21 @@ class PartnerHero extends Component 'icon' => 'award', ], ]; + public $section = 'partner_hero'; public $content = []; + public function mount($section = 'partner_hero') { $this->section = $section; - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.{$this->section}", []); + $this->content = cms_theme_section($this->section); } public function render() { return view('livewire.web.components.sections.partner-hero', [ - 'partnerTypes' => $this->partnerTypes + 'partnerTypes' => $this->partnerTypes, ]); } } diff --git a/app/Livewire/Web/Components/Sections/PartnerProcess.php b/app/Livewire/Web/Components/Sections/PartnerProcess.php index 7331761..cb4eb3a 100644 --- a/app/Livewire/Web/Components/Sections/PartnerProcess.php +++ b/app/Livewire/Web/Components/Sections/PartnerProcess.php @@ -10,8 +10,7 @@ class PartnerProcess extends Component public function mount() { - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.partner_process", []); + $this->content = cms_theme_section('partner_process'); } public function render() diff --git a/app/Livewire/Web/Components/Sections/Portfolio.php b/app/Livewire/Web/Components/Sections/Portfolio.php index cff568d..88f0679 100644 --- a/app/Livewire/Web/Components/Sections/Portfolio.php +++ b/app/Livewire/Web/Components/Sections/Portfolio.php @@ -7,10 +7,15 @@ use Livewire\Component; class Portfolio extends Component { public string $activeFilter = 'alle'; + public ?array $selectedProject = null; + public bool $showModal = false; + public $content = []; + public $theme = ''; + public $filters = []; public function mount() @@ -19,13 +24,17 @@ class Portfolio extends Component 'alle' => 'Alle', 'villen' => 'Villen', 'penthouse' => 'Penthouse', - 'loft' => 'Loft' + 'loft' => 'Loft', ]; $this->filters = $filters; $this->activeFilter = 'alle'; $this->theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$this->theme}.portfolio", []); - $this->filters = config("content.themes.{$this->theme}.portfolio.filters", $filters); + $portfolio = cms_theme_section('portfolio', $this->theme); + if (! is_array($portfolio)) { + $portfolio = []; + } + $this->content = $portfolio; + $this->filters = $portfolio['filters'] ?? $filters; } public function filterBy(string $category): void @@ -47,8 +56,7 @@ class Portfolio extends Component public function getFilteredProjects(): array { - $projects = config("content.themes.{$this->theme}.portfolio.projects", []); - + $projects = $this->content['projects'] ?? []; if ($this->activeFilter === 'alle') { return $projects; diff --git a/app/Livewire/Web/Components/Sections/SpotlightsSection.php b/app/Livewire/Web/Components/Sections/SpotlightsSection.php index 4939ba0..f1e6d36 100644 --- a/app/Livewire/Web/Components/Sections/SpotlightsSection.php +++ b/app/Livewire/Web/Components/Sections/SpotlightsSection.php @@ -10,8 +10,7 @@ class SpotlightsSection extends Component public function mount() { - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.spotlights_section", []); + $this->content = cms_theme_section('spotlights_section'); } public function render() diff --git a/app/Livewire/Web/Components/Sections/SupplierSection.php b/app/Livewire/Web/Components/Sections/SupplierSection.php index b7f3d43..373df5c 100644 --- a/app/Livewire/Web/Components/Sections/SupplierSection.php +++ b/app/Livewire/Web/Components/Sections/SupplierSection.php @@ -10,8 +10,7 @@ class SupplierSection extends Component public function mount() { - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.supplier_section", []); + $this->content = cms_theme_section('supplier_section'); } public function render() diff --git a/app/Livewire/Web/Components/Sections/VisionSection.php b/app/Livewire/Web/Components/Sections/VisionSection.php index f94bfc4..d7757a8 100644 --- a/app/Livewire/Web/Components/Sections/VisionSection.php +++ b/app/Livewire/Web/Components/Sections/VisionSection.php @@ -7,13 +7,13 @@ use Livewire\Component; class VisionSection extends Component { public $content = []; + public $bg = 'bg-background'; public function mount($bg = 'bg-background') { $this->bg = $bg; - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.vision_section", []); + $this->content = cms_theme_section('vision_section'); } public function render() diff --git a/app/Livewire/Web/Components/UI/AnnouncementBar.php b/app/Livewire/Web/Components/UI/AnnouncementBar.php new file mode 100644 index 0000000..610b0ca --- /dev/null +++ b/app/Livewire/Web/Components/UI/AnnouncementBar.php @@ -0,0 +1,20 @@ +content = cms_theme_section('announcement_bar'); + } + + public function render() + { + return view('livewire.web.components.ui.announcement-bar'); + } +} diff --git a/app/Livewire/Web/Components/UI/ContactForm.php b/app/Livewire/Web/Components/UI/ContactForm.php index 2259e5e..b117205 100644 --- a/app/Livewire/Web/Components/UI/ContactForm.php +++ b/app/Livewire/Web/Components/UI/ContactForm.php @@ -2,63 +2,113 @@ namespace App\Livewire\Web\Components\Ui; +use Acme\ContactForm\ContactFormService; +use Acme\ContactForm\SpamDetector; use Livewire\Component; class ContactForm extends Component { - public $firstName = ''; - public $lastName = ''; - public $company = ''; - public $email = ''; - public $phone = ''; - public $subject = ''; - public $message = ''; + public string $firstName = ''; - public $content = []; + public string $lastName = ''; - public function mount() + public string $company = ''; + + public string $email = ''; + + public string $phone = ''; + + public string $subject = ''; + + public string $message = ''; + + public bool $privacy = false; + + public bool $success = false; + + /** @var string Hidden honeypot field */ + public string $website = ''; + + public ?int $formLoadedAt = null; + + /** @var array */ + public array $content = []; + + public function mount(): void { - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.contact_form", []); + $this->content = cms_theme_section('contact_form'); + $this->formLoadedAt = time(); } - public function getSubjectsProperty() + /** @return array */ + public function getSubjectsProperty(): array { return $this->content['form']['subjects'] ?? []; } - public function getContactInfoProperty() + /** @return array> */ + public function getContactInfoProperty(): array { return $this->content['contact_info'] ?? []; } - public function getSocialMediaProperty() + /** @return array> */ + public function getSocialMediaProperty(): array { return $this->content['social_media']['platforms'] ?? []; } - public function submit() + public function submit(ContactFormService $service): void { $this->validate([ - 'firstName' => 'required|string|max:255', - 'lastName' => 'required|string|max:255', - 'email' => 'required|email|max:255', - 'subject' => 'required|string', - 'message' => 'required|string|max:2000', - 'company' => 'nullable|string|max:255', - 'phone' => 'nullable|string|max:255', + 'firstName' => ['required', 'string', 'max:255'], + 'lastName' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email:rfc', 'max:255'], + 'subject' => ['required', 'string'], + 'message' => ['required', 'string', 'max:2000'], + 'company' => ['nullable', 'string', 'max:255'], + 'phone' => ['nullable', 'string', 'max:255'], + 'privacy' => ['accepted'], + 'website' => ['nullable', 'string', 'max:0'], ]); - // Here you would typically save to database or send email - // For now, we'll just show a success message + $spamDetector = SpamDetector::fromConfig(); + $payload = [ + 'first_name' => $this->firstName, + 'last_name' => $this->lastName, + 'email' => $this->email, + 'subject' => $this->subject, + 'message' => $this->message, + 'company' => $this->company, + 'phone' => $this->phone, + 'website' => $this->website, + 'ip' => request()->ip(), + 'user_agent' => request()->userAgent(), + 'is_spam' => false, + ]; - $successMessage = $this->content['form']['success_message'] ?? 'Vielen Dank für Ihre Nachricht! Wir werden uns schnellstmöglich bei Ihnen melden.'; - session()->flash('message', $successMessage); + $payload['is_spam'] = $spamDetector->detect($payload, $this->formLoadedAt); - $this->reset(['firstName', 'lastName', 'company', 'email', 'phone', 'subject', 'message']); + $subjectLabel = $this->getSubjectsProperty()[$this->subject] ?? $this->subject; + $subjectLine = $subjectLabel + ? 'Kontaktanfrage B2in: ' . $subjectLabel + : __('contact-form::contact-form.default_subject'); + $service->handle($payload, $subjectLine, 'contact-form'); + + $this->success = true; + $this->firstName = ''; + $this->lastName = ''; + $this->company = ''; + $this->email = ''; + $this->phone = ''; + $this->subject = ''; + $this->message = ''; + $this->privacy = false; + $this->website = ''; + $this->formLoadedAt = time(); } - public function render() + public function render(): \Illuminate\View\View { return view('livewire.web.components.ui.contact-form'); } diff --git a/app/Livewire/Web/Components/UI/Header.php b/app/Livewire/Web/Components/UI/Header.php index c3916e9..e59a0ff 100644 --- a/app/Livewire/Web/Components/UI/Header.php +++ b/app/Livewire/Web/Components/UI/Header.php @@ -10,17 +10,20 @@ class Header extends Component public $domainName; public $domainUrl; public $content = []; + public $currentLocale; + public $availableLocales = [ + 'de' => 'DE', + 'en' => 'EN', + ]; - public function mount() + public function mount(): void { $this->domainName = \App\Helpers\ThemeHelper::getDomainName(); $this->domainUrl = \App\Helpers\ThemeHelper::getDomainUrl(); - $theme = config('app.theme', 'b2in'); - $this->content = config("content.themes.{$theme}.header", [ - 'portal_login' => 'Portal Login', - 'navigation' => [] - ]); - + $this->currentLocale = app()->getLocale(); + + $this->content = cms_theme_section('header'); + // Ensure required keys exist if (!isset($this->content['portal_login'])) { $this->content['portal_login'] = 'Portal Login'; @@ -30,12 +33,27 @@ class Header extends Component } } - public function toggleMobileMenu() + public function switchLanguage(string $locale): mixed + { + if (array_key_exists($locale, $this->availableLocales)) { + app()->setLocale($locale); + session(['locale' => $locale]); + $this->currentLocale = $locale; + + $referer = request()->header('Referer') ?? '/'; + + return redirect()->to($referer); + } + + return null; + } + + public function toggleMobileMenu(): void { $this->isMobileMenuOpen = ! $this->isMobileMenuOpen; } - public function closeMobileMenu() + public function closeMobileMenu(): void { $this->isMobileMenuOpen = false; } diff --git a/app/Mail/PartnerInvitationMail.php b/app/Mail/PartnerInvitationMail.php index 9081776..619a705 100644 --- a/app/Mail/PartnerInvitationMail.php +++ b/app/Mail/PartnerInvitationMail.php @@ -27,7 +27,7 @@ class PartnerInvitationMail extends Mailable public function envelope(): Envelope { return new Envelope( - subject: 'Einladung: ' . $this->invitation->company_name . ' - B2In Platform', + subject: 'Einladung: '.$this->invitation->company_name.' - B2in Platform', ); } diff --git a/app/Models/CabinetTabletSetting.php b/app/Models/CabinetTabletSetting.php new file mode 100644 index 0000000..35d1419 --- /dev/null +++ b/app/Models/CabinetTabletSetting.php @@ -0,0 +1,155 @@ + */ + use HasFactory; + + protected $fillable = [ + 'store_status', + 'notice_headline', + 'notice_subtext', + 'override_open_today', + 'override_close_today', + 'next_appointment_date', + 'next_appointment_time', + 'hours_monday_open', 'hours_monday_close', + 'hours_tuesday_open', 'hours_tuesday_close', + 'hours_wednesday_open', 'hours_wednesday_close', + 'hours_thursday_open', 'hours_thursday_close', + 'hours_friday_open', 'hours_friday_close', + 'hours_saturday_open', 'hours_saturday_close', + 'hours_sunday_open', 'hours_sunday_close', + 'contact_phone', + 'contact_email', + ]; + + protected function casts(): array + { + return [ + 'next_appointment_date' => 'date', + ]; + } + + private const DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; + + private const GERMAN_DAY_LABELS = [ + 'monday' => 'Montag', + 'tuesday' => 'Dienstag', + 'wednesday' => 'Mittwoch', + 'thursday' => 'Donnerstag', + 'friday' => 'Freitag', + 'saturday' => 'Samstag', + 'sunday' => 'Sonntag', + ]; + + /** + * Get or create the singleton settings row. + */ + public static function current(): self + { + return static::firstOrCreate(['id' => 1]); + } + + /** + * Get opening hours as display strings for the frontend (e.g. "10:00 – 18:00" or "Geschlossen"). + * + * @return array + */ + public function getHoursArray(): array + { + $result = []; + foreach (self::DAYS as $day) { + $open = $this->{"hours_{$day}_open"}; + $close = $this->{"hours_{$day}_close"}; + $result[$day] = ($open && $close) ? "{$open} – {$close}" : 'Geschlossen'; + } + + return $result; + } + + /** + * Compute the effective store status based on opening hours and current Berlin time. + * + * Returns the display status ('open', 'closed', 'notice'), the next opening time when + * closed, and today's closing time when open. + * + * @return array{status: string, today_close: string|null, next_open: array{label: string, time: string}|null} + */ + public function computeStatus(): array + { + if ($this->store_status === 'notice' || $this->store_status === 'warning') { + return ['status' => $this->store_status, 'today_close' => null, 'next_open' => null]; + } + + if ($this->store_status === 'closed') { + $now = Carbon::now('Europe/Berlin'); + + return ['status' => 'closed', 'today_close' => null, 'next_open' => $this->findNextOpenTime($now, true)]; + } + + // Auto mode: compute from opening hours + $now = Carbon::now('Europe/Berlin'); + $dayKey = strtolower($now->englishDayOfWeek); + + $openTime = $this->override_open_today ?: $this->{"hours_{$dayKey}_open"}; + $closeTime = $this->override_close_today ?: $this->{"hours_{$dayKey}_close"}; + $currentHHMM = $now->format('H:i'); + + if ($openTime && $closeTime && $currentHHMM >= $openTime && $currentHHMM < $closeTime) { + return ['status' => 'open', 'today_close' => $closeTime, 'next_open' => null]; + } + + return ['status' => 'closed', 'today_close' => null, 'next_open' => $this->findNextOpenTime($now, false)]; + } + + /** + * Find the next upcoming opening time from the given Berlin datetime. + * + * @return array{label: string, time: string}|null + */ + private function findNextOpenTime(Carbon $now, bool $skipToday): ?array + { + // Check if today still has an upcoming opening (we might be before opening time) + if (! $skipToday) { + $dayKey = strtolower($now->englishDayOfWeek); + $todayOpen = $this->override_open_today ?: $this->{"hours_{$dayKey}_open"}; + + if ($todayOpen && $now->format('H:i') < $todayOpen) { + return ['label' => 'Heute', 'time' => $todayOpen]; + } + } + + // Look ahead up to 7 days + for ($i = 1; $i <= 7; $i++) { + $checkDate = $now->copy()->addDays($i); + $dayKey = strtolower($checkDate->englishDayOfWeek); + $openTime = $this->{"hours_{$dayKey}_open"}; + + if ($openTime) { + $label = $i === 1 ? 'Morgen' : self::GERMAN_DAY_LABELS[$dayKey]; + + return ['label' => $label, 'time' => $openTime]; + } + } + + return null; + } + + /** + * Clear override times (called by midnight scheduler). + */ + public function clearOverrides(): void + { + $this->update([ + 'override_open_today' => null, + 'override_close_today' => null, + ]); + } +} diff --git a/app/Models/CmsArticle.php b/app/Models/CmsArticle.php new file mode 100644 index 0000000..28c3fa1 --- /dev/null +++ b/app/Models/CmsArticle.php @@ -0,0 +1,74 @@ + */ + public array $translatable = [ + 'title', + 'subtitle', + 'content', + ]; + + protected function casts(): array + { + return [ + 'author' => 'array', + 'is_published' => 'boolean', + 'order' => 'integer', + ]; + } + + public function scopePublished(Builder $query): Builder + { + return $query->where('is_published', true); + } + + public function scopeOrdered(Builder $query): Builder + { + return $query->orderBy('order')->orderByDesc('created_at'); + } + + /** + * @return array + */ + public function toFrontendArray(): array + { + return [ + 'id' => $this->id, + 'slug' => $this->slug, + 'title' => $this->title, + 'subtitle' => $this->subtitle, + 'image' => $this->image, + 'category' => $this->category, + 'date' => $this->date_label, + 'readTime' => $this->read_time, + 'author' => $this->author ?? [], + 'content' => $this->content ?? [], + ]; + } +} diff --git a/app/Models/CmsProject.php b/app/Models/CmsProject.php new file mode 100644 index 0000000..d3cb921 --- /dev/null +++ b/app/Models/CmsProject.php @@ -0,0 +1,111 @@ + */ + public array $translatable = [ + 'title', + 'location', + 'highlights', + 'investment_case', + 'location_info', + 'contact', + 'investor_trust', + 'furniture_benefit', + ]; + + protected function casts(): array + { + return [ + 'launch_date' => 'date', + 'price_from_aed' => 'integer', + 'highlights' => 'array', + 'quick_facts' => 'array', + 'investment_case' => 'array', + 'gallery' => 'array', + 'location_info' => 'array', + 'contact' => 'array', + 'is_published' => 'boolean', + 'order' => 'integer', + ]; + } + + public function scopePublished(Builder $query): Builder + { + return $query->where('is_published', true); + } + + public function scopeOrdered(Builder $query): Builder + { + return $query->orderBy('order')->orderByDesc('launch_date'); + } + + public function getFormattedPrice(string $prefix = 'ab'): string + { + if (! $this->price_from_aed) { + return ''; + } + + return PriceHelper::formatAed($this->price_from_aed, $prefix); + } + + /** + * Returns an array compatible with the existing Blade views + * (immobilien.blade.php and immobilien-show.blade.php). + * + * @return array + */ + public function toFrontendArray(): array + { + return [ + 'slug' => $this->slug, + 'title' => $this->title, + 'location' => $this->location, + 'status' => $this->status, + 'launch_date' => $this->launch_date?->format('d.m.Y'), + 'price_from' => $this->getFormattedPrice(), + 'image' => $this->image, + 'highlights' => $this->highlights ?? [], + 'quick_facts' => $this->quick_facts ?? [], + 'investment_case' => $this->investment_case ?? [], + 'gallery' => $this->gallery ?? [], + 'location_info' => $this->location_info ?? [], + 'contact' => $this->contact ?? [], + 'investor_trust' => $this->investor_trust ?? [], + 'furniture_benefit' => $this->furniture_benefit ?? [], + ]; + } +} diff --git a/app/Models/Display.php b/app/Models/Display.php new file mode 100644 index 0000000..a871fb7 --- /dev/null +++ b/app/Models/Display.php @@ -0,0 +1,33 @@ + */ + use HasFactory; + + protected $fillable = [ + 'name', + 'location', + 'is_active', + ]; + + protected function casts(): array + { + return [ + 'is_active' => 'boolean', + ]; + } + + public function versions(): BelongsToMany + { + return $this->belongsToMany(DisplayVersion::class, 'display_display_version') + ->withPivot('sort_order') + ->orderByPivot('sort_order'); + } +} diff --git a/app/Models/DisplayMedia.php b/app/Models/DisplayMedia.php new file mode 100644 index 0000000..3cf2397 --- /dev/null +++ b/app/Models/DisplayMedia.php @@ -0,0 +1,153 @@ + */ + use HasFactory; + + protected $table = 'display_media'; + + protected $fillable = [ + 'filename', + 'disk', + 'path', + 'external_url', + 'source_type', + 'type', + 'mime_type', + 'file_size', + 'thumbnail_path', + 'alt_text', + 'title', + 'collection', + 'metadata', + 'is_active', + ]; + + protected function casts(): array + { + return [ + 'metadata' => 'array', + 'file_size' => 'integer', + 'is_active' => 'boolean', + ]; + } + + // ======================================== + // ACCESSORS + // ======================================== + + public function getUrl(): string + { + if ($this->isExternal()) { + return $this->external_url; + } + + return Storage::disk($this->disk)->url($this->path); + } + + public function getThumbnailUrl(): ?string + { + if ($this->thumbnail_path) { + return Storage::disk($this->disk)->url($this->thumbnail_path); + } + + if ($this->isUpload() && $this->isImage()) { + return $this->getUrl(); + } + + return null; + } + + // ======================================== + // TYPE CHECKS + // ======================================== + + public function isUpload(): bool + { + return $this->source_type === 'upload'; + } + + public function isExternal(): bool + { + return $this->source_type === 'external'; + } + + public function isImage(): bool + { + return $this->type === 'image'; + } + + public function isVideo(): bool + { + return $this->type === 'video'; + } + + // ======================================== + // DISPLAY HELPERS + // ======================================== + + public function getHumanFileSize(): string + { + if ($this->file_size === 0) { + return $this->isExternal() ? 'Extern' : '0 B'; + } + + $units = ['B', 'KB', 'MB', 'GB']; + $size = $this->file_size; + $unitIndex = 0; + + while ($size >= 1024 && $unitIndex < count($units) - 1) { + $size /= 1024; + $unitIndex++; + } + + return round($size, 1).' '.$units[$unitIndex]; + } + + public function getDisplayName(): string + { + return $this->title ?: $this->filename; + } + + // ======================================== + // SCOPES + // ======================================== + + public function scopeImages(Builder $query): Builder + { + return $query->where('type', 'image'); + } + + public function scopeVideos(Builder $query): Builder + { + return $query->where('type', 'video'); + } + + public function scopeUploads(Builder $query): Builder + { + return $query->where('source_type', 'upload'); + } + + public function scopeExternals(Builder $query): Builder + { + return $query->where('source_type', 'external'); + } + + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } + + public function scopeInCollection(Builder $query, string $collection): Builder + { + return $query->where('collection', $collection); + } +} diff --git a/app/Models/DisplayVersion.php b/app/Models/DisplayVersion.php new file mode 100644 index 0000000..20cf372 --- /dev/null +++ b/app/Models/DisplayVersion.php @@ -0,0 +1,67 @@ + */ + use HasFactory; + + protected $fillable = [ + 'name', + 'type', + 'settings', + 'is_active', + ]; + + protected function casts(): array + { + return [ + 'type' => DisplayVersionType::class, + 'settings' => 'array', + 'is_active' => 'boolean', + ]; + } + + public function items(): HasMany + { + return $this->hasMany(DisplayVersionItem::class)->orderBy('sort_order'); + } + + public function displays(): BelongsToMany + { + return $this->belongsToMany(Display::class, 'display_display_version') + ->withPivot('sort_order'); + } + + /** + * @return HasMany + */ + public function activeItems(?string $itemType = null): HasMany + { + $query = $this->items()->where('is_active', true); + + if ($itemType) { + $query->where('item_type', $itemType); + } + + return $query; + } + + public function scopeOfType(Builder $query, DisplayVersionType $type): Builder + { + return $query->where('type', $type); + } + + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } +} diff --git a/app/Models/DisplayVersionItem.php b/app/Models/DisplayVersionItem.php new file mode 100644 index 0000000..dd738a4 --- /dev/null +++ b/app/Models/DisplayVersionItem.php @@ -0,0 +1,41 @@ + */ + use HasFactory; + + protected $fillable = [ + 'display_version_id', + 'item_type', + 'content', + 'sort_order', + 'is_active', + ]; + + protected function casts(): array + { + return [ + 'content' => 'array', + 'is_active' => 'boolean', + 'sort_order' => 'integer', + ]; + } + + public function version(): BelongsTo + { + return $this->belongsTo(DisplayVersion::class, 'display_version_id'); + } + + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true)->orderBy('sort_order'); + } +} diff --git a/app/Services/CmsFluxEditorHtmlTransformer.php b/app/Services/CmsFluxEditorHtmlTransformer.php new file mode 100644 index 0000000..4e8d5d8 --- /dev/null +++ b/app/Services/CmsFluxEditorHtmlTransformer.php @@ -0,0 +1,197 @@ + + */ + public const RICH_TEXT_JSON_FIELDS = [ + 'description', + 'text', + 'content', + 'help', + 'answer', + 'quote', + ]; + + public static function toEditor(string $html): string + { + if ($html === '' || ! str_contains($html, 'text-secondary')) { + return $html; + } + + return self::spansWithClassToMarks($html); + } + + public static function fromEditor(string $html): string + { + if ($html === '' || ! str_contains($html, '|array{_value: string}> $items + * @return array|array{_value: string}> + */ + public static function toEditorJsonItems(array $items, bool $isStringArray): array + { + if ($isStringArray) { + return $items; + } + + return array_map(function ($item) { + if (! is_array($item)) { + return $item; + } + $out = []; + foreach ($item as $key => $value) { + if (in_array($key, self::RICH_TEXT_JSON_FIELDS, true) && is_string($value)) { + $out[$key] = self::toEditor($value); + } else { + $out[$key] = $value; + } + } + + return $out; + }, $items); + } + + /** + * @param array|array{_value: string}> $items + * @return array|array{_value: string}> + */ + public static function fromEditorJsonItems(array $items, bool $isStringArray): array + { + if ($isStringArray) { + return $items; + } + + return array_map(function ($item) { + if (! is_array($item)) { + return $item; + } + $out = []; + foreach ($item as $key => $value) { + if (in_array($key, self::RICH_TEXT_JSON_FIELDS, true) && is_string($value)) { + $out[$key] = self::fromEditor($value); + } else { + $out[$key] = $value; + } + } + + return $out; + }, $items); + } + + private static function spansWithClassToMarks(string $html): string + { + libxml_use_internal_errors(true); + $dom = new DOMDocument; + $wrapped = '
'.$html.'
'; + $dom->loadHTML($wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + libxml_clear_errors(); + + $root = $dom->getElementById('cms-flux-root'); + if (! $root instanceof DOMElement) { + return $html; + } + + $xpath = new DOMXPath($dom); + $expression = '//span[contains(concat(" ", normalize-space(@class), " "), " text-secondary ")]'; + $nodes = []; + foreach ($xpath->query($expression) ?? [] as $node) { + $nodes[] = $node; + } + + usort($nodes, fn ($a, $b) => self::nodeDepth($b) <=> self::nodeDepth($a)); + + foreach ($nodes as $span) { + if (! $span instanceof DOMElement || $span->tagName !== 'span') { + continue; + } + $mark = $dom->createElement('mark'); + while ($span->firstChild) { + $mark->appendChild($span->firstChild); + } + $span->parentNode?->replaceChild($mark, $span); + } + + return self::extractInnerHtml($dom, $root); + } + + private static function marksToSecondarySpans(string $html): string + { + libxml_use_internal_errors(true); + $dom = new DOMDocument; + $wrapped = '
'.$html.'
'; + $dom->loadHTML($wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + libxml_clear_errors(); + + $root = $dom->getElementById('cms-flux-root'); + if (! $root instanceof DOMElement) { + return $html; + } + + $xpath = new DOMXPath($dom); + $nodes = []; + foreach ($xpath->query('//mark') ?? [] as $node) { + $nodes[] = $node; + } + + usort($nodes, fn ($a, $b) => self::nodeDepth($b) <=> self::nodeDepth($a)); + + foreach ($nodes as $mark) { + if (! $mark instanceof DOMElement) { + continue; + } + $span = $dom->createElement('span'); + $span->setAttribute('class', 'text-secondary'); + while ($mark->firstChild) { + $span->appendChild($mark->firstChild); + } + $mark->parentNode?->replaceChild($span, $mark); + } + + return self::extractInnerHtml($dom, $root); + } + + private static function nodeDepth(DOMNode $node): int + { + $d = 0; + while ($node->parentNode) { + $d++; + $node = $node->parentNode; + } + + return $d; + } + + private static function extractInnerHtml(DOMDocument $dom, DOMElement $root): string + { + $html = ''; + foreach ($root->childNodes as $child) { + $html .= $dom->saveHTML($child); + } + + return $html; + } +} diff --git a/app/Services/DisplayMediaService.php b/app/Services/DisplayMediaService.php new file mode 100644 index 0000000..bc08115 --- /dev/null +++ b/app/Services/DisplayMediaService.php @@ -0,0 +1,119 @@ +getClientOriginalName(); + $extension = strtolower($file->getClientOriginalExtension()); + $storageName = Str::uuid().'.'.$extension; + $datePath = now()->format('Y/m'); + $relativePath = "display-media/{$datePath}/{$storageName}"; + + Storage::disk('public')->putFileAs("display-media/{$datePath}", $file, $storageName); + + $type = in_array($extension, ['mp4', 'webm', 'mov']) ? 'video' : 'image'; + + $metadata = []; + if ($type === 'image') { + $dimensions = @getimagesize($file->getRealPath()); + if ($dimensions) { + $metadata['width'] = $dimensions[0]; + $metadata['height'] = $dimensions[1]; + } + } + + return DisplayMedia::create([ + 'filename' => $filename, + 'disk' => 'public', + 'path' => $relativePath, + 'source_type' => 'upload', + 'type' => $type, + 'mime_type' => $file->getMimeType(), + 'file_size' => $file->getSize(), + 'collection' => $collection, + 'metadata' => ! empty($metadata) ? $metadata : null, + ]); + } + + /** + * Create a DisplayMedia record from an external URL. + */ + public function createFromUrl(string $url, string $type = 'video', ?string $title = null, ?string $collection = null): DisplayMedia + { + $filename = $title ?: $this->extractFilenameFromUrl($url); + + return DisplayMedia::create([ + 'filename' => $filename, + 'disk' => 'public', + 'path' => null, + 'external_url' => $url, + 'source_type' => 'external', + 'type' => $type, + 'mime_type' => null, + 'file_size' => 0, + 'title' => $title, + 'collection' => $collection, + ]); + } + + /** + * Validate that an external URL is accessible. + */ + public function validateExternalUrl(string $url): bool + { + try { + $response = Http::timeout(10) + ->withOptions(['allow_redirects' => true]) + ->head($url); + + return $response->successful() || $response->status() === 302 || $response->status() === 301; + } catch (\Throwable) { + // Some services block HEAD requests, try GET with stream + try { + $response = Http::timeout(10) + ->withOptions(['allow_redirects' => true, 'stream' => true]) + ->get($url); + + return $response->successful(); + } catch (\Throwable) { + return false; + } + } + } + + /** + * Delete a DisplayMedia record and its associated files. + */ + public function delete(DisplayMedia $media): void + { + if ($media->isUpload() && $media->path) { + Storage::disk($media->disk)->delete($media->path); + } + + if ($media->thumbnail_path) { + Storage::disk($media->disk)->delete($media->thumbnail_path); + } + + $media->delete(); + } + + private function extractFilenameFromUrl(string $url): string + { + $parsed = parse_url($url, PHP_URL_PATH); + $basename = $parsed ? basename($parsed) : 'external-media'; + + return Str::limit($basename, 100); + } +} diff --git a/app/Services/ProjectDocumentationContent.php b/app/Services/ProjectDocumentationContent.php new file mode 100644 index 0000000..e487f43 --- /dev/null +++ b/app/Services/ProjectDocumentationContent.php @@ -0,0 +1,90 @@ +Dokumentation nicht gefunden.

'; + } + + $markdown = file_get_contents($mdPath); + + $environment = new Environment([ + 'html_input' => 'allow', + 'allow_unsafe_links' => false, + ]); + + $environment->addExtension(new CommonMarkCoreExtension); + $environment->addExtension(new GithubFlavoredMarkdownExtension); + + $converter = new MarkdownConverter($environment); + + return $converter->convert($markdown)->getContent(); + } + + /** + * @return list + */ + public static function tableOfContents(): array + { + $mdPath = self::markdownPath(); + + if (! file_exists($mdPath)) { + return []; + } + + $markdown = file_get_contents($mdPath); + $toc = []; + + preg_match_all('/^(#{2,3})\s+(.+)$/m', $markdown, $matches, PREG_SET_ORDER); + + foreach ($matches as $match) { + $level = strlen($match[1]); + $title = trim($match[2]); + $slug = Str::slug($title); + + $toc[] = [ + 'level' => $level, + 'title' => $title, + 'slug' => $slug, + ]; + } + + return $toc; + } + + /** + * @return array{size: string, modified: string, lines: int}|null + */ + public static function fileInfo(): ?array + { + $mdPath = self::markdownPath(); + + if (! file_exists($mdPath)) { + return null; + } + + return [ + 'size' => round(filesize($mdPath) / 1024, 1).' KB', + 'modified' => Carbon::parse(filemtime($mdPath))->format('d.m.Y H:i'), + 'lines' => count(file($mdPath)), + ]; + } +} diff --git a/app/View/Components/WebPicture.php b/app/View/Components/WebPicture.php new file mode 100644 index 0000000..25301f9 --- /dev/null +++ b/app/View/Components/WebPicture.php @@ -0,0 +1,34 @@ +webpSrc = preg_replace('/\.(jpe?g|png)$/i', '.webp', $this->src); + $this->hasWebp = file_exists(public_path( + str_replace(asset(''), '', $this->webpSrc) + )); + } + + public function render(): View + { + return view('components.web-picture'); + } +} diff --git a/app/helpers.php b/app/helpers.php new file mode 100644 index 0000000..d97fc4d --- /dev/null +++ b/app/helpers.php @@ -0,0 +1,171 @@ + + */ + function legal_page(string $key): array + { + $fromCms = app(CmsContentService::class)->get('legal.'.$key); + + if (is_array($fromCms) && isset($fromCms['content'])) { + return $fromCms; + } + + return trans('b2in_legal.'.$key); + } +} + +if (! function_exists('cms')) { + /** + * Resolve a CMS content value by dot-notation key. + * + * First segment is the group, rest is the key: "home.hero.title" + * Falls back to __() if no DB entry exists. + * + * @param array $replace + */ + function cms(string $key, array $replace = [], ?string $locale = null): mixed + { + return app(CmsContentService::class)->get($key, $replace, $locale); + } +} + +if (! function_exists('cms_theme_section')) { + /** + * Theme-Sektion: zuerst CMS (gemäß cms_section_map), sonst Lang b2in.themes.{theme}.{section}. + * + * @return array|string|mixed + */ + function cms_theme_section(string $sectionKey, ?string $theme = null): mixed + { + $theme = $theme ?? config('app.theme', 'b2in'); + + /** @var array $sections */ + $sections = config('cms_section_map.sections', []); + /** @var array $langKeys */ + $langKeys = config('cms_section_map.lang_keys', []); + + if (isset($sections[$sectionKey])) { + [$group, $key] = $sections[$sectionKey]; + $fromCms = app(CmsContentService::class)->getIfExists("{$group}.{$key}"); + + if ($fromCms !== null) { + return $fromCms; + } + } + + $langKey = $langKeys[$sectionKey] ?? $sectionKey; + + return trans("b2in.themes.{$theme}.{$langKey}"); + } +} + +if (! function_exists('theme_image_url')) { + /** + * Bild-URL für Theme-/CMS-Inhalte: zuerst Medienbibliothek (CmsMedia, oft nur Dateiname z. B. *.webp), + * sonst statische Assets unter public/img/assets/ (Legacy-Pfade wie b2in/…). + */ + function theme_image_url(?string $value): string + { + if ($value === null) { + return ''; + } + + $trimmed = ltrim(trim($value), '/'); + if ($trimmed === '') { + return ''; + } + + if (str_starts_with($trimmed, 'http://') || str_starts_with($trimmed, 'https://')) { + return $trimmed; + } + + $candidates = array_values(array_unique(array_filter([$trimmed, basename($trimmed)]))); + foreach ($candidates as $candidate) { + if (CmsMedia::query()->where('filename', $candidate)->exists()) { + return media_url($candidate); + } + } + + if (str_contains($trimmed, '/')) { + return asset('img/assets/'.$trimmed); + } + + return media_url($trimmed); + } +} + +if (! function_exists('tcms')) { + /** + * Typed CMS – always returns a string. + * + * @param array $replace + */ + function tcms(string $key, array $replace = [], ?string $locale = null): string + { + $text = cms($key, $replace, $locale); + + return is_string($text) ? $text : (string) $text; + } +} + +if (! function_exists('cms_media_url')) { + /** + * Resolve a CMS content key (type=image) to a full media URL. + * Falls back to asset('assets/images/...') if not found in media library. + */ + function cms_media_url(string $key, string $profile = ''): string + { + $filename = cms($key); + + if (! $filename || ! is_string($filename) || $filename === $key) { + $fallback = str_replace('.', '/', $key); + + return asset('assets/images/'.basename($fallback)); + } + + return media_url($filename, $profile); + } +} + +if (! function_exists('media_url')) { + /** + * Resolve a CmsMedia filename to its full storage URL. + * Uses in-memory cache to avoid repeated DB queries. + */ + function media_url(?string $filename, string $profile = ''): string + { + if (! $filename || $filename === '') { + return ''; + } + + static $resolved = []; + $cacheKey = $filename.'|'.$profile; + + if (isset($resolved[$cacheKey])) { + return $resolved[$cacheKey]; + } + + $media = CmsMedia::where('filename', $filename)->first(); + + if (! $media) { + $resolved[$cacheKey] = asset('assets/images/'.$filename); + + return $resolved[$cacheKey]; + } + + if ($profile && $media->hasConversion($profile)) { + $resolved[$cacheKey] = $media->getConversionUrl($profile); + } else { + $resolved[$cacheKey] = $media->getUrl(); + } + + return $resolved[$cacheKey]; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 078ae11..788e523 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -19,6 +19,9 @@ return Application::configure(basePath: dirname(__DIR__)) // um sicherzustellen, dass url() und asset() die richtige Domain verwenden $middleware->prepend(\App\Http\Middleware\SetDomainUrl::class); + // Sprachwechsel aus Session anwenden + $middleware->appendToGroup('web', \App\Http\Middleware\SetLocale::class); + // Partner Setup-Zwang für eingeloggte User $middleware->alias([ 'partner.setup' => \App\Http\Middleware\EnsurePartnerSetupCompleted::class, diff --git a/composer.json b/composer.json index f8f0d53..30bae6c 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,11 @@ "license": "MIT", "require": { "php": "^8.2", + "acme/contact-form": "*", + "acme/cookie-consent": "@dev", "blade-ui-kit/blade-heroicons": "^2.6", + "flux-cms/core": "@dev", + "intervention/image": "*", "laravel/fortify": "^1.27", "laravel/framework": "^12.0", "laravel/sanctum": "^4.1", @@ -37,6 +41,9 @@ "reliese/laravel": "^1.4" }, "autoload": { + "files": [ + "app/helpers.php" + ], "psr-4": { "App\\": "app/", "Database\\Factories\\": "database/factories/", @@ -55,7 +62,8 @@ ], "post-update-cmd": [ "@php artisan vendor:publish --tag=laravel-assets --ansi --force", - "@php artisan boost:update --ansi" + "@php artisan livewire:publish --assets --ansi", + "@php artisan boost:update --ansi || true" ], "post-root-package-install": [ "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" diff --git a/composer.lock b/composer.lock index a959fe3..e314e90 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,103 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "821f090295ee13d40f6e4692d39690d6", + "content-hash": "9e07d437a063526b426b920d400db8b8", "packages": [ + { + "name": "acme/contact-form", + "version": "dev-master", + "dist": { + "type": "path", + "url": "packages/acme/contact-form", + "reference": "e8dd7c282de17593b66e109358e134b67f4ae271" + }, + "require": { + "illuminate/support": "^10.0|^11.0|^12.0", + "livewire/livewire": "^3.0|^4.0", + "php": "^8.2" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Acme\\ContactForm\\ContactFormServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Acme\\ContactForm\\": "src/" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Acme", + "email": "info@acme.de" + } + ], + "description": "Ein wiederverwendbares, spam-geschütztes Kontaktformular-Package für Laravel mit Livewire", + "keywords": [ + "contact-form", + "honeypot", + "laravel", + "livewire", + "spam-protection" + ], + "transport-options": { + "relative": true + } + }, + { + "name": "acme/cookie-consent", + "version": "dev-master", + "dist": { + "type": "path", + "url": "packages/acme/CookieConsent", + "reference": "965114131d06d22b9ee0abf19ce6a85dd9df28df" + }, + "require": { + "illuminate/support": "^10.0|^11.0|^12.0", + "php": "^8.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Acme\\CookieConsent\\CookieConsentServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Acme\\CookieConsent\\": "src/" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Acme", + "email": "info@acme.de" + } + ], + "description": "Ein DSGVO-konformer Cookie Consent Manager für Laravel mit Alpine.js und Google Analytics Support", + "keywords": [ + "alpine.js", + "consent", + "cookie", + "dsgvo", + "gdpr", + "google-analytics", + "laravel" + ], + "transport-options": { + "relative": true + } + }, { "name": "bacon/bacon-qr-code", "version": "v3.0.3", @@ -132,30 +227,30 @@ }, { "name": "blade-ui-kit/blade-icons", - "version": "1.8.1", + "version": "1.9.0", "source": { "type": "git", "url": "https://github.com/driesvints/blade-icons.git", - "reference": "47e7b6f43250e6404e4224db8229219cd42b543c" + "reference": "caa92fde675d7a651c38bf73ca582ddada56f318" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/driesvints/blade-icons/zipball/47e7b6f43250e6404e4224db8229219cd42b543c", - "reference": "47e7b6f43250e6404e4224db8229219cd42b543c", + "url": "https://api.github.com/repos/driesvints/blade-icons/zipball/caa92fde675d7a651c38bf73ca582ddada56f318", + "reference": "caa92fde675d7a651c38bf73ca582ddada56f318", "shasum": "" }, "require": { - "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/filesystem": "^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/view": "^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/filesystem": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/view": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", "php": "^7.4|^8.0", - "symfony/console": "^5.3|^6.0|^7.0", - "symfony/finder": "^5.3|^6.0|^7.0" + "symfony/console": "^5.3|^6.0|^7.0|^8.0", + "symfony/finder": "^5.3|^6.0|^7.0|^8.0" }, "require-dev": { "mockery/mockery": "^1.5.1", - "orchestra/testbench": "^6.0|^7.0|^8.0|^9.0|^10.0", + "orchestra/testbench": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", "phpunit/phpunit": "^9.0|^10.5|^11.0" }, "bin": [ @@ -209,7 +304,7 @@ "type": "paypal" } ], - "time": "2026-01-20T09:46:32+00:00" + "time": "2026-02-23T10:42:23+00:00" }, { "name": "brick/math", @@ -763,6 +858,63 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "flux-cms/core", + "version": "dev-master", + "dist": { + "type": "path", + "url": "packages/flux-cms/core", + "reference": "4b109a707b4f99e1d4fb89df4325b293f65d3010" + }, + "require": { + "laravel/framework": "^11.0|^12.0", + "php": "^8.2", + "spatie/laravel-translatable": "^6.0" + }, + "require-dev": { + "orchestra/testbench": "^9.0", + "pestphp/pest": "^3.8", + "pestphp/pest-plugin-laravel": "^3.2" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "FluxCms\\Core\\FluxCmsServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "FluxCms\\Core\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "FluxCms\\Core\\Tests\\": "tests/" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Flux CMS Contributors", + "email": "contributors@flux-cms.com" + } + ], + "description": "Flux CMS Core Package - Multi-domain, component-first CMS for Laravel", + "keywords": [ + "cms", + "components", + "laravel", + "livewire", + "multi-domain" + ], + "transport-options": { + "relative": true + } + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", @@ -1308,17 +1460,161 @@ "time": "2025-08-22T14:27:06+00:00" }, { - "name": "laravel/fortify", - "version": "v1.34.1", + "name": "intervention/gif", + "version": "4.2.4", "source": { "type": "git", - "url": "https://github.com/laravel/fortify.git", - "reference": "412575e9c0cb21d49a30b7045ad4902019f538c2" + "url": "https://github.com/Intervention/gif.git", + "reference": "c3598a16ebe7690cd55640c44144a9df383ea73c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/fortify/zipball/412575e9c0cb21d49a30b7045ad4902019f538c2", - "reference": "412575e9c0cb21d49a30b7045ad4902019f538c2", + "url": "https://api.github.com/repos/Intervention/gif/zipball/c3598a16ebe7690cd55640c44144a9df383ea73c", + "reference": "c3598a16ebe7690cd55640c44144a9df383ea73c", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "slevomat/coding-standard": "~8.0", + "squizlabs/php_codesniffer": "^3.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Intervention\\Gif\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io/" + } + ], + "description": "Native PHP GIF Encoder/Decoder", + "homepage": "https://github.com/intervention/gif", + "keywords": [ + "animation", + "gd", + "gif", + "image" + ], + "support": { + "issues": "https://github.com/Intervention/gif/issues", + "source": "https://github.com/Intervention/gif/tree/4.2.4" + }, + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + }, + { + "url": "https://ko-fi.com/interventionphp", + "type": "ko_fi" + } + ], + "time": "2026-01-04T09:27:23+00:00" + }, + { + "name": "intervention/image", + "version": "3.11.7", + "source": { + "type": "git", + "url": "https://github.com/Intervention/image.git", + "reference": "2159bcccff18f09d2a392679b81a82c5a003f9bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/image/zipball/2159bcccff18f09d2a392679b81a82c5a003f9bb", + "reference": "2159bcccff18f09d2a392679b81a82c5a003f9bb", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "intervention/gif": "^4.2", + "php": "^8.1" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "slevomat/coding-standard": "~8.0", + "squizlabs/php_codesniffer": "^3.8" + }, + "suggest": { + "ext-exif": "Recommended to be able to read EXIF data properly." + }, + "type": "library", + "autoload": { + "psr-4": { + "Intervention\\Image\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io" + } + ], + "description": "PHP Image Processing", + "homepage": "https://image.intervention.io", + "keywords": [ + "gd", + "image", + "imagick", + "resize", + "thumbnail", + "watermark" + ], + "support": { + "issues": "https://github.com/Intervention/image/issues", + "source": "https://github.com/Intervention/image/tree/3.11.7" + }, + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + }, + { + "url": "https://ko-fi.com/interventionphp", + "type": "ko_fi" + } + ], + "time": "2026-02-19T13:11:17+00:00" + }, + { + "name": "laravel/fortify", + "version": "v1.35.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/fortify.git", + "reference": "24c5bb81ea4787e0865c4a62f054ed7d1cb7a093" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/fortify/zipball/24c5bb81ea4787e0865c4a62f054ed7d1cb7a093", + "reference": "24c5bb81ea4787e0865c4a62f054ed7d1cb7a093", "shasum": "" }, "require": { @@ -1368,20 +1664,20 @@ "issues": "https://github.com/laravel/fortify/issues", "source": "https://github.com/laravel/fortify" }, - "time": "2026-02-03T06:55:55+00:00" + "time": "2026-02-24T14:00:44+00:00" }, { "name": "laravel/framework", - "version": "v12.51.0", + "version": "v12.53.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "ce4de3feb211e47c4f959d309ccf8a2733b1bc16" + "reference": "f57f035c0d34503d9ff30be76159bb35a003cd1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/ce4de3feb211e47c4f959d309ccf8a2733b1bc16", - "reference": "ce4de3feb211e47c4f959d309ccf8a2733b1bc16", + "url": "https://api.github.com/repos/laravel/framework/zipball/f57f035c0d34503d9ff30be76159bb35a003cd1f", + "reference": "f57f035c0d34503d9ff30be76159bb35a003cd1f", "shasum": "" }, "require": { @@ -1590,7 +1886,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-02-10T18:20:19+00:00" + "time": "2026-02-24T14:35:15+00:00" }, { "name": "laravel/prompts", @@ -1716,16 +2012,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v2.0.9", + "version": "v2.0.10", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "8f631589ab07b7b52fead814965f5a800459cb3e" + "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/8f631589ab07b7b52fead814965f5a800459cb3e", - "reference": "8f631589ab07b7b52fead814965f5a800459cb3e", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/870fc81d2f879903dfc5b60bf8a0f94a1609e669", + "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669", "shasum": "" }, "require": { @@ -1773,7 +2069,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-02-03T06:55:34+00:00" + "time": "2026-02-20T19:59:49+00:00" }, { "name": "laravel/tinker", @@ -2032,16 +2328,16 @@ }, { "name": "league/flysystem", - "version": "3.31.0", + "version": "3.32.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "1717e0b3642b0df65ecb0cc89cdd99fa840672ff" + "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/1717e0b3642b0df65ecb0cc89cdd99fa840672ff", - "reference": "1717e0b3642b0df65ecb0cc89cdd99fa840672ff", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/254b1595b16b22dbddaaef9ed6ca9fdac4956725", + "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725", "shasum": "" }, "require": { @@ -2109,9 +2405,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.31.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.32.0" }, - "time": "2026-01-23T15:38:47+00:00" + "time": "2026-02-25T17:01:41+00:00" }, { "name": "league/flysystem-local", @@ -2402,29 +2698,29 @@ }, { "name": "livewire/flux", - "version": "v2.12.0", + "version": "v2.12.2", "source": { "type": "git", "url": "https://github.com/livewire/flux.git", - "reference": "78bc26f54a29c28ff916751b9f796f4ce1592003" + "reference": "68a3b06b62b23bae82e02d6be39722cf2a8770ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/flux/zipball/78bc26f54a29c28ff916751b9f796f4ce1592003", - "reference": "78bc26f54a29c28ff916751b9f796f4ce1592003", + "url": "https://api.github.com/repos/livewire/flux/zipball/68a3b06b62b23bae82e02d6be39722cf2a8770ff", + "reference": "68a3b06b62b23bae82e02d6be39722cf2a8770ff", "shasum": "" }, "require": { - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "illuminate/view": "^10.0|^11.0|^12.0", + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "illuminate/view": "^10.0|^11.0|^12.0|^13.0", "laravel/prompts": "^0.1|^0.2|^0.3", "livewire/livewire": "^3.7.4|^4.0", "php": "^8.1", - "symfony/console": "^6.0|^7.0" + "symfony/console": "^6.0|^7.0|^8.0" }, "conflict": { - "livewire/blaze": "<1.0.0" + "livewire/blaze": "<1.0.0-beta.2" }, "type": "library", "extra": { @@ -2462,32 +2758,32 @@ ], "support": { "issues": "https://github.com/livewire/flux/issues", - "source": "https://github.com/livewire/flux/tree/v2.12.0" + "source": "https://github.com/livewire/flux/tree/v2.12.2" }, - "time": "2026-02-09T23:35:27+00:00" + "time": "2026-02-24T16:29:56+00:00" }, { "name": "livewire/flux-pro", - "version": "2.12.0", + "version": "2.12.2", "dist": { "type": "zip", - "url": "https://composer.fluxui.dev/download/a10af361-cb0b-48fd-8bc5-5fb4dda59618/flux-pro-2.12.0.zip", - "reference": "15e43b7d8f96914195432b18b7b64b3e30a96288", - "shasum": "42d5b2f496b6b126d3e26186d355c6c9a92623e1" + "url": "https://composer.fluxui.dev/download/a129da01-0b6c-4769-9430-e9156c8da2ba/flux-pro-2.12.2.zip", + "reference": "f2fdc138527e51f628d296199e8f9241cbdcd645", + "shasum": "2b7c84785b253a90522fae68d962f61c53259cb7" }, "require": { - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "illuminate/view": "^10.0|^11.0|^12.0", + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "illuminate/view": "^10.0|^11.0|^12.0|^13.0", "laravel/prompts": "^0.1.24|^0.2|^0.3", - "livewire/flux": "2.12.0|dev-main", + "livewire/flux": "2.12.2|dev-main", "livewire/livewire": "^3.7.4|^4.0", "php": "^8.1", - "symfony/console": "^6.0|^7.0" + "symfony/console": "^6.0|^7.0|^8.0" }, "require-dev": { "livewire/volt": "*", - "orchestra/testbench": "^10.8" + "orchestra/testbench": "^10.8|^11.0" }, "type": "library", "extra": { @@ -2537,40 +2833,40 @@ "livewire", "ui" ], - "time": "2026-02-10T00:40:52+00:00" + "time": "2026-02-25T09:20:33+00:00" }, { "name": "livewire/livewire", - "version": "v4.1.4", + "version": "v4.2.1", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "4697085e02a1f5f11410a1b5962400e3539f8843" + "reference": "93e972fa42c1b34fff1550093ab94f778d81ea5a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/4697085e02a1f5f11410a1b5962400e3539f8843", - "reference": "4697085e02a1f5f11410a1b5962400e3539f8843", + "url": "https://api.github.com/repos/livewire/livewire/zipball/93e972fa42c1b34fff1550093ab94f778d81ea5a", + "reference": "93e972fa42c1b34fff1550093ab94f778d81ea5a", "shasum": "" }, "require": { - "illuminate/database": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "illuminate/validation": "^10.0|^11.0|^12.0", + "illuminate/database": "^10.0|^11.0|^12.0|^13.0", + "illuminate/routing": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "illuminate/validation": "^10.0|^11.0|^12.0|^13.0", "laravel/prompts": "^0.1.24|^0.2|^0.3", "league/mime-type-detection": "^1.9", "php": "^8.1", - "symfony/console": "^6.0|^7.0", - "symfony/http-kernel": "^6.2|^7.0" + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/http-kernel": "^6.2|^7.0|^8.0" }, "require-dev": { "calebporzio/sushi": "^2.1", - "laravel/framework": "^10.15.0|^11.0|^12.0", + "laravel/framework": "^10.15.0|^11.0|^12.0|^13.0", "mockery/mockery": "^1.3.1", - "orchestra/testbench": "^8.21.0|^9.0|^10.0", - "orchestra/testbench-dusk": "^8.24|^9.1|^10.0", - "phpunit/phpunit": "^10.4|^11.5", + "orchestra/testbench": "^8.21.0|^9.0|^10.0|^11.0", + "orchestra/testbench-dusk": "^8.24|^9.1|^10.0|^11.0", + "phpunit/phpunit": "^10.4|^11.5|^12.5", "psy/psysh": "^0.11.22|^0.12" }, "type": "library", @@ -2605,7 +2901,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v4.1.4" + "source": "https://github.com/livewire/livewire/tree/v4.2.1" }, "funding": [ { @@ -2613,20 +2909,20 @@ "type": "github" } ], - "time": "2026-02-09T22:59:54+00:00" + "time": "2026-02-28T00:01:19+00:00" }, { "name": "livewire/volt", - "version": "v1.10.2", + "version": "v1.10.3", "source": { "type": "git", "url": "https://github.com/livewire/volt.git", - "reference": "4aa52b9adbdcb0f58af9cdb1ebabfbcbee32fac9" + "reference": "40e56558614bb25b0651255613b7a66b9d12df31" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/volt/zipball/4aa52b9adbdcb0f58af9cdb1ebabfbcbee32fac9", - "reference": "4aa52b9adbdcb0f58af9cdb1ebabfbcbee32fac9", + "url": "https://api.github.com/repos/livewire/volt/zipball/40e56558614bb25b0651255613b7a66b9d12df31", + "reference": "40e56558614bb25b0651255613b7a66b9d12df31", "shasum": "" }, "require": { @@ -2684,7 +2980,7 @@ "issues": "https://github.com/livewire/volt/issues", "source": "https://github.com/livewire/volt" }, - "time": "2026-01-28T03:03:30+00:00" + "time": "2026-02-20T19:56:55+00:00" }, { "name": "monolog/monolog", @@ -2896,16 +3192,16 @@ }, { "name": "nette/schema", - "version": "v1.3.4", + "version": "v1.3.5", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "086497a2f34b82fede9b5a41cc8e131d087cd8f7" + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/086497a2f34b82fede9b5a41cc8e131d087cd8f7", - "reference": "086497a2f34b82fede9b5a41cc8e131d087cd8f7", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", "shasum": "" }, "require": { @@ -2913,8 +3209,10 @@ "php": "8.1 - 8.5" }, "require-dev": { + "nette/phpstan-rules": "^1.0", "nette/tester": "^2.6", - "phpstan/phpstan": "^2.0@stable", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", "tracy/tracy": "^2.8" }, "type": "library", @@ -2955,22 +3253,22 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.4" + "source": "https://github.com/nette/schema/tree/v1.3.5" }, - "time": "2026-02-08T02:54:00+00:00" + "time": "2026-02-23T03:47:12+00:00" }, { "name": "nette/utils", - "version": "v4.1.2", + "version": "v4.1.3", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5" + "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", - "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", + "url": "https://api.github.com/repos/nette/utils/zipball/bb3ea637e3d131d72acc033cfc2746ee893349fe", + "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe", "shasum": "" }, "require": { @@ -2982,8 +3280,10 @@ }, "require-dev": { "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", "nette/tester": "^2.5", - "phpstan/phpstan": "^2.0@stable", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", "tracy/tracy": "^2.9" }, "suggest": { @@ -3044,9 +3344,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.2" + "source": "https://github.com/nette/utils/tree/v4.1.3" }, - "time": "2026-02-03T17:21:09+00:00" + "time": "2026-02-13T03:05:33+00:00" }, { "name": "nikic/php-parser", @@ -3108,31 +3408,31 @@ }, { "name": "nunomaduro/termwind", - "version": "v2.3.3", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017" + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/6fb2a640ff502caace8e05fd7be3b503a7e1c017", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", "shasum": "" }, "require": { "ext-mbstring": "*", "php": "^8.2", - "symfony/console": "^7.3.6" + "symfony/console": "^7.4.4 || ^8.0.4" }, "require-dev": { - "illuminate/console": "^11.46.1", - "laravel/pint": "^1.25.1", + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.1.3", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", "phpstan/phpstan": "^1.12.32", "phpstan/phpstan-strict-rules": "^1.6.2", - "symfony/var-dumper": "^7.3.5", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -3164,7 +3464,7 @@ "email": "enunomaduro@gmail.com" } ], - "description": "Its like Tailwind CSS, but for the console.", + "description": "It's like Tailwind CSS, but for the console.", "keywords": [ "cli", "console", @@ -3175,7 +3475,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v2.3.3" + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" }, "funding": [ { @@ -3191,7 +3491,7 @@ "type": "github" } ], - "time": "2025-11-20T02:34:59+00:00" + "time": "2026-02-16T23:10:27+00:00" }, { "name": "paragonie/constant_time_encoding", @@ -4078,6 +4378,67 @@ }, "time": "2025-12-14T04:43:48+00:00" }, + { + "name": "spatie/laravel-package-tools", + "version": "1.93.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-package-tools.git", + "reference": "0d097bce95b2bf6802fb1d83e1e753b0f5a948e7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/0d097bce95b2bf6802fb1d83e1e753b0f5a948e7", + "reference": "0d097bce95b2bf6802fb1d83e1e753b0f5a948e7", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.1" + }, + "require-dev": { + "mockery/mockery": "^1.5", + "orchestra/testbench": "^8.0|^9.2|^10.0|^11.0", + "pestphp/pest": "^2.1|^3.1|^4.0", + "phpunit/php-code-coverage": "^10.0|^11.0|^12.0", + "phpunit/phpunit": "^10.5|^11.5|^12.5", + "spatie/pest-plugin-test-time": "^2.2|^3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\LaravelPackageTools\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "Tools for creating Laravel packages", + "homepage": "https://github.com/spatie/laravel-package-tools", + "keywords": [ + "laravel-package-tools", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-package-tools/issues", + "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.0" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-02-21T12:49:54+00:00" + }, { "name": "spatie/laravel-permission", "version": "6.24.1", @@ -4161,6 +4522,89 @@ ], "time": "2026-02-09T21:10:03+00:00" }, + { + "name": "spatie/laravel-translatable", + "version": "6.13.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-translatable.git", + "reference": "f2c5b8805a2dd22799c9aa8ce66cd98ce3170dcb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-translatable/zipball/f2c5b8805a2dd22799c9aa8ce66cd98ce3170dcb", + "reference": "f2c5b8805a2dd22799c9aa8ce66cd98ce3170dcb", + "shasum": "" + }, + "require": { + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.3", + "spatie/laravel-package-tools": "^1.93.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.90", + "mockery/mockery": "^1.6.12", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^4.0.0" + }, + "type": "library", + "extra": { + "aliases": { + "Translatable": "Spatie\\Translatable\\Facades\\Translatable" + }, + "laravel": { + "providers": [ + "Spatie\\Translatable\\TranslatableServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\Translatable\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Sebastian De Deyne", + "email": "sebastian@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "A trait to make an Eloquent model hold translations", + "homepage": "https://github.com/spatie/laravel-translatable", + "keywords": [ + "eloquent", + "i8n", + "laravel-translatable", + "model", + "multilingual", + "spatie", + "translate" + ], + "support": { + "issues": "https://github.com/spatie/laravel-translatable/issues", + "source": "https://github.com/spatie/laravel-translatable/tree/6.13.0" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-02-21T14:20:19+00:00" + }, { "name": "symfony/clock", "version": "v8.0.0", @@ -4240,16 +4684,16 @@ }, { "name": "symfony/console", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894" + "reference": "6d643a93b47398599124022eb24d97c153c12f27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894", + "url": "https://api.github.com/repos/symfony/console/zipball/6d643a93b47398599124022eb24d97c153c12f27", + "reference": "6d643a93b47398599124022eb24d97c153c12f27", "shasum": "" }, "require": { @@ -4314,7 +4758,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.4" + "source": "https://github.com/symfony/console/tree/v7.4.6" }, "funding": [ { @@ -4334,20 +4778,20 @@ "type": "tidelift" } ], - "time": "2026-01-13T11:36:38+00:00" + "time": "2026-02-25T17:02:47+00:00" }, { "name": "symfony/css-selector", - "version": "v8.0.0", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b" + "reference": "2a178bf80f05dbbe469a337730eba79d61315262" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/6225bd458c53ecdee056214cb4a2ffaf58bd592b", - "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/2a178bf80f05dbbe469a337730eba79d61315262", + "reference": "2a178bf80f05dbbe469a337730eba79d61315262", "shasum": "" }, "require": { @@ -4383,7 +4827,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.0.0" + "source": "https://github.com/symfony/css-selector/tree/v8.0.6" }, "funding": [ { @@ -4403,7 +4847,7 @@ "type": "tidelift" } ], - "time": "2025-10-30T14:17:19+00:00" + "time": "2026-02-17T13:07:04+00:00" }, { "name": "symfony/deprecation-contracts", @@ -4717,16 +5161,16 @@ }, { "name": "symfony/finder", - "version": "v7.4.5", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb" + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", "shasum": "" }, "require": { @@ -4761,7 +5205,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.5" + "source": "https://github.com/symfony/finder/tree/v7.4.6" }, "funding": [ { @@ -4781,20 +5225,20 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-01-29T09:40:50+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.5", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "446d0db2b1f21575f1284b74533e425096abdfb6" + "reference": "fd97d5e926e988a363cef56fbbf88c5c528e9065" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/446d0db2b1f21575f1284b74533e425096abdfb6", - "reference": "446d0db2b1f21575f1284b74533e425096abdfb6", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/fd97d5e926e988a363cef56fbbf88c5c528e9065", + "reference": "fd97d5e926e988a363cef56fbbf88c5c528e9065", "shasum": "" }, "require": { @@ -4843,7 +5287,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.5" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.6" }, "funding": [ { @@ -4863,20 +5307,20 @@ "type": "tidelift" } ], - "time": "2026-01-27T16:16:02+00:00" + "time": "2026-02-21T16:25:55+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.5", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a" + "reference": "002ac0cf4cd972a7fd0912dcd513a95e8a81ce83" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/229eda477017f92bd2ce7615d06222ec0c19e82a", - "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/002ac0cf4cd972a7fd0912dcd513a95e8a81ce83", + "reference": "002ac0cf4cd972a7fd0912dcd513a95e8a81ce83", "shasum": "" }, "require": { @@ -4918,7 +5362,7 @@ "symfony/config": "^6.4|^7.0|^8.0", "symfony/console": "^6.4|^7.0|^8.0", "symfony/css-selector": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", "symfony/dom-crawler": "^6.4|^7.0|^8.0", "symfony/expression-language": "^6.4|^7.0|^8.0", "symfony/finder": "^6.4|^7.0|^8.0", @@ -4962,7 +5406,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.5" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.6" }, "funding": [ { @@ -4982,20 +5426,20 @@ "type": "tidelift" } ], - "time": "2026-01-28T10:33:42+00:00" + "time": "2026-02-26T08:30:57+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6" + "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/7b750074c40c694ceb34cb926d6dffee231c5cd6", - "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6", + "url": "https://api.github.com/repos/symfony/mailer/zipball/b02726f39a20bc65e30364f5c750c4ddbf1f58e9", + "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9", "shasum": "" }, "require": { @@ -5046,7 +5490,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.4" + "source": "https://github.com/symfony/mailer/tree/v7.4.6" }, "funding": [ { @@ -5066,20 +5510,20 @@ "type": "tidelift" } ], - "time": "2026-01-08T08:25:11+00:00" + "time": "2026-02-25T16:50:00+00:00" }, { "name": "symfony/mime", - "version": "v7.4.5", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148" + "reference": "9fc881d95feae4c6c48678cb6372bd8a7ba04f5f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/b18c7e6e9eee1e19958138df10412f3c4c316148", - "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148", + "url": "https://api.github.com/repos/symfony/mime/zipball/9fc881d95feae4c6c48678cb6372bd8a7ba04f5f", + "reference": "9fc881d95feae4c6c48678cb6372bd8a7ba04f5f", "shasum": "" }, "require": { @@ -5090,7 +5534,7 @@ }, "conflict": { "egulias/email-validator": "~3.0.0", - "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", "symfony/mailer": "<6.4", "symfony/serializer": "<6.4.3|>7.0,<7.0.3" @@ -5098,7 +5542,7 @@ "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^5.2", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", "symfony/dependency-injection": "^6.4|^7.0|^8.0", "symfony/process": "^6.4|^7.0|^8.0", "symfony/property-access": "^6.4|^7.0|^8.0", @@ -5135,7 +5579,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.5" + "source": "https://github.com/symfony/mime/tree/v7.4.6" }, "funding": [ { @@ -5155,7 +5599,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T08:59:58+00:00" + "time": "2026-02-05T15:57:06+00:00" }, { "name": "symfony/polyfill-ctype", @@ -6053,16 +6497,16 @@ }, { "name": "symfony/routing", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "0798827fe2c79caeed41d70b680c2c3507d10147" + "reference": "238d749c56b804b31a9bf3e26519d93b65a60938" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/0798827fe2c79caeed41d70b680c2c3507d10147", - "reference": "0798827fe2c79caeed41d70b680c2c3507d10147", + "url": "https://api.github.com/repos/symfony/routing/zipball/238d749c56b804b31a9bf3e26519d93b65a60938", + "reference": "238d749c56b804b31a9bf3e26519d93b65a60938", "shasum": "" }, "require": { @@ -6114,7 +6558,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.4" + "source": "https://github.com/symfony/routing/tree/v7.4.6" }, "funding": [ { @@ -6134,7 +6578,7 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:19:02+00:00" + "time": "2026-02-25T16:50:00+00:00" }, { "name": "symfony/service-contracts", @@ -6225,16 +6669,16 @@ }, { "name": "symfony/string", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "758b372d6882506821ed666032e43020c4f57194" + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194", - "reference": "758b372d6882506821ed666032e43020c4f57194", + "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", "shasum": "" }, "require": { @@ -6291,7 +6735,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.4" + "source": "https://github.com/symfony/string/tree/v8.0.6" }, "funding": [ { @@ -6311,20 +6755,20 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:37:40+00:00" + "time": "2026-02-09T10:14:57+00:00" }, { "name": "symfony/translation", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "db70c8ce7db74fd2da7b1d268db46b2a8ce32c10" + "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/db70c8ce7db74fd2da7b1d268db46b2a8ce32c10", - "reference": "db70c8ce7db74fd2da7b1d268db46b2a8ce32c10", + "url": "https://api.github.com/repos/symfony/translation/zipball/13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", + "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", "shasum": "" }, "require": { @@ -6384,7 +6828,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.4" + "source": "https://github.com/symfony/translation/tree/v8.0.6" }, "funding": [ { @@ -6404,7 +6848,7 @@ "type": "tidelift" } ], - "time": "2026-01-13T13:06:50+00:00" + "time": "2026-02-17T13:07:04+00:00" }, { "name": "symfony/translation-contracts", @@ -6568,16 +7012,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "0e4769b46a0c3c62390d124635ce59f66874b282" + "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/0e4769b46a0c3c62390d124635ce59f66874b282", - "reference": "0e4769b46a0c3c62390d124635ce59f66874b282", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/045321c440ac18347b136c63d2e9bf28a2dc0291", + "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291", "shasum": "" }, "require": { @@ -6631,7 +7075,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.4" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.6" }, "funding": [ { @@ -6651,7 +7095,7 @@ "type": "tidelift" } ], - "time": "2026-01-01T22:13:48+00:00" + "time": "2026-02-15T10:53:20+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -7125,16 +7569,16 @@ }, { "name": "doctrine/dbal", - "version": "4.4.1", + "version": "4.4.2", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c" + "reference": "476f7f0fa6ea4aa5364926db7fabdf6049075722" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c", - "reference": "3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/476f7f0fa6ea4aa5364926db7fabdf6049075722", + "reference": "476f7f0fa6ea4aa5364926db7fabdf6049075722", "shasum": "" }, "require": { @@ -7150,9 +7594,9 @@ "phpstan/phpstan": "2.1.30", "phpstan/phpstan-phpunit": "2.0.7", "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "11.5.23", - "slevomat/coding-standard": "8.24.0", - "squizlabs/php_codesniffer": "4.0.0", + "phpunit/phpunit": "11.5.50", + "slevomat/coding-standard": "8.27.1", + "squizlabs/php_codesniffer": "4.0.1", "symfony/cache": "^6.3.8|^7.0|^8.0", "symfony/console": "^5.4|^6.3|^7.0|^8.0" }, @@ -7211,7 +7655,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/4.4.1" + "source": "https://github.com/doctrine/dbal/tree/4.4.2" }, "funding": [ { @@ -7227,7 +7671,7 @@ "type": "tidelift" } ], - "time": "2025-12-04T10:11:03+00:00" + "time": "2026-02-26T12:12:19+00:00" }, { "name": "doctrine/deprecations", @@ -7585,16 +8029,16 @@ }, { "name": "laravel/boost", - "version": "v1.8.10", + "version": "v1.8.11", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "aad8b2a423b0a886c2ce7ee92abbfde69992ff32" + "reference": "485dd7c834bde865a8a174249fc6ffc56e79e63c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/aad8b2a423b0a886c2ce7ee92abbfde69992ff32", - "reference": "aad8b2a423b0a886c2ce7ee92abbfde69992ff32", + "url": "https://api.github.com/repos/laravel/boost/zipball/485dd7c834bde865a8a174249fc6ffc56e79e63c", + "reference": "485dd7c834bde865a8a174249fc6ffc56e79e63c", "shasum": "" }, "require": { @@ -7647,7 +8091,7 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-01-14T14:51:16+00:00" + "time": "2026-02-20T07:28:22+00:00" }, { "name": "laravel/dusk", @@ -7725,16 +8169,16 @@ }, { "name": "laravel/mcp", - "version": "v0.5.6", + "version": "v0.5.9", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "87905978bf2a230d6c01f8d03e172249e37917f7" + "reference": "39e8da60eb7bce4737c5d868d35a3fe78938c129" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/87905978bf2a230d6c01f8d03e172249e37917f7", - "reference": "87905978bf2a230d6c01f8d03e172249e37917f7", + "url": "https://api.github.com/repos/laravel/mcp/zipball/39e8da60eb7bce4737c5d868d35a3fe78938c129", + "reference": "39e8da60eb7bce4737c5d868d35a3fe78938c129", "shasum": "" }, "require": { @@ -7794,7 +8238,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2026-02-09T22:08:43+00:00" + "time": "2026-02-17T19:05:53+00:00" }, { "name": "laravel/pail", @@ -8212,39 +8656,36 @@ }, { "name": "nunomaduro/collision", - "version": "v8.8.3", + "version": "v8.9.1", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4" + "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/1dc9e88d105699d0fee8bb18890f41b274f6b4c4", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", + "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", "shasum": "" }, "require": { - "filp/whoops": "^2.18.1", - "nunomaduro/termwind": "^2.3.1", + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.3.0" + "symfony/console": "^7.4.4 || ^8.0.4" }, "conflict": { - "laravel/framework": "<11.44.2 || >=13.0.0", - "phpunit/phpunit": "<11.5.15 || >=13.0.0" + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" }, "require-dev": { - "brianium/paratest": "^7.8.3", - "larastan/larastan": "^3.4.2", - "laravel/framework": "^11.44.2 || ^12.18", - "laravel/pint": "^1.22.1", - "laravel/sail": "^1.43.1", - "laravel/sanctum": "^4.1.1", - "laravel/tinker": "^2.10.1", - "orchestra/testbench-core": "^9.12.0 || ^10.4", - "pestphp/pest": "^3.8.2 || ^4.0.0", - "sebastian/environment": "^7.2.1 || ^8.0" + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.2", + "laravel/framework": "^11.48.0 || ^12.52.0", + "laravel/pint": "^1.27.1", + "orchestra/testbench-core": "^9.12.0 || ^10.9.0", + "pestphp/pest": "^3.8.5 || ^4.4.1 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.3 || ^9.0.0" }, "type": "library", "extra": { @@ -8307,7 +8748,7 @@ "type": "patreon" } ], - "time": "2025-11-20T02:55:25+00:00" + "time": "2026-02-17T17:33:08+00:00" }, { "name": "orchestra/canvas", @@ -9438,16 +9879,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.6", + "version": "6.0.2", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8" + "reference": "897b5986ece6b4f9d8413fea345c7d49c757d6bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8", - "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/897b5986ece6b4f9d8413fea345c7d49c757d6bf", + "reference": "897b5986ece6b4f9d8413fea345c7d49c757d6bf", "shasum": "" }, "require": { @@ -9455,8 +9896,8 @@ "ext-filter": "*", "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.2", - "phpdocumentor/type-resolver": "^1.7", - "phpstan/phpdoc-parser": "^1.7|^2.0", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", "webmozart/assert": "^1.9.1 || ^2" }, "require-dev": { @@ -9466,7 +9907,8 @@ "phpstan/phpstan-mockery": "^1.1", "phpstan/phpstan-webmozart-assert": "^1.2", "phpunit/phpunit": "^9.5", - "psalm/phar": "^5.26" + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" }, "type": "library", "extra": { @@ -9496,44 +9938,44 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.2" }, - "time": "2025-12-22T21:13:58+00:00" + "time": "2026-03-01T18:43:49+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "1.12.0", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", "shasum": "" }, "require": { "doctrine/deprecations": "^1.0", - "php": "^7.3 || ^8.0", + "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^1.18|^2.0" + "phpstan/phpdoc-parser": "^2.0" }, "require-dev": { "ext-tokenizer": "*", "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^9.5", - "rector/rector": "^0.13.9", - "vimeo/psalm": "^4.25" + "psalm/phar": "^4" }, "type": "library", "extra": { "branch-alias": { - "dev-1.x": "1.x-dev" + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" } }, "autoload": { @@ -9554,9 +9996,9 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" }, - "time": "2025-11-21T15:09:14+00:00" + "time": "2026-01-06T21:53:42+00:00" }, { "name": "phpstan/phpdoc-parser", @@ -11213,16 +11655,16 @@ }, { "name": "symfony/yaml", - "version": "v7.4.1", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "24dd4de28d2e3988b311751ac49e684d783e2345" + "reference": "58751048de17bae71c5aa0d13cb19d79bca26391" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345", - "reference": "24dd4de28d2e3988b311751ac49e684d783e2345", + "url": "https://api.github.com/repos/symfony/yaml/zipball/58751048de17bae71c5aa0d13cb19d79bca26391", + "reference": "58751048de17bae71c5aa0d13cb19d79bca26391", "shasum": "" }, "require": { @@ -11265,7 +11707,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.1" + "source": "https://github.com/symfony/yaml/tree/v7.4.6" }, "funding": [ { @@ -11285,27 +11727,27 @@ "type": "tidelift" } ], - "time": "2025-12-04T18:11:45+00:00" + "time": "2026-02-09T09:33:46+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", - "version": "0.8.6", + "version": "0.8.7", "source": { "type": "git", "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git", - "reference": "ad48430b92901fd7d003fdaf2d7b139f96c0906e" + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/ad48430b92901fd7d003fdaf2d7b139f96c0906e", - "reference": "ad48430b92901fd7d003fdaf2d7b139f96c0906e", + "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/1248f3f506ca9641d4f68cebcd538fa489754db8", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8", "shasum": "" }, "require": { "nikic/php-parser": "^4.18.0 || ^5.0.0", "php": "^8.1.0", "phpdocumentor/reflection-docblock": "^5.3.0 || ^6.0.0", - "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0", + "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0 || ^13.0.0", "symfony/finder": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "require-dev": { @@ -11342,9 +11784,9 @@ ], "support": { "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues", - "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.6" + "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.7" }, - "time": "2026-01-30T07:16:00+00:00" + "time": "2026-02-17T17:25:14+00:00" }, { "name": "theseer/tokenizer", @@ -11398,16 +11840,16 @@ }, { "name": "webmozart/assert", - "version": "2.1.2", + "version": "2.1.6", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649" + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/ff31ad6efc62e66e518fbab1cde3453d389bcdc8", + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8", "shasum": "" }, "require": { @@ -11454,19 +11896,22 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.2" + "source": "https://github.com/webmozarts/assert/tree/2.1.6" }, - "time": "2026-01-13T14:02:24+00:00" + "time": "2026-02-27T10:28:38+00:00" } ], "aliases": [], "minimum-stability": "dev", - "stability-flags": {}, + "stability-flags": { + "acme/cookie-consent": 20, + "flux-cms/core": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config/cms_section_map.php b/config/cms_section_map.php new file mode 100644 index 0000000..a2035af --- /dev/null +++ b/config/cms_section_map.php @@ -0,0 +1,93 @@ + [ + 'card_section' => 'partner_card_section', + ], + + 'sections' => [ + 'announcement_bar' => ['global', 'announcement_bar'], + 'header' => ['global', 'header'], + 'cta_section' => ['global', 'cta_section'], + 'cta_section_portfolio' => ['global', 'cta_section_portfolio'], + + 'hero' => ['home', 'hero'], + 'founder_bar' => ['home', 'founder_bar'], + 'synergie_section' => ['home', 'synergie_section'], + 'ecosystem_core' => ['home', 'ecosystem_core'], + 'vision_section' => ['home', 'vision_section'], + 'brand_worlds' => ['home', 'brand_worlds'], + 'integriertes_modell_b2in' => ['home', 'integriertes_modell_b2in'], + + 'immobilien_hero' => ['immobilien', 'hero'], + 'immobilien_hero_v2' => ['immobilien', 'hero_v2'], + 'immobilien_projects' => ['immobilien', 'projects_meta'], + 'immobilien_moebel_vorteil' => ['immobilien', 'moebel_vorteil'], + 'immobilien_trust' => ['immobilien', 'trust'], + 'immobilien_warum_dubai' => ['immobilien', 'warum_dubai'], + 'immobilien_image_break' => ['immobilien', 'image_break'], + 'immobilien_kaufprozess' => ['immobilien', 'kaufprozess'], + 'immobilien_bruecke' => ['immobilien', 'bruecke'], + 'immobilien_mindset' => ['immobilien', 'mindset'], + 'portfolio' => ['immobilien', 'portfolio'], + + 'netzwerk_hero' => ['netzwerk', 'hero'], + 'netzwerk_image_break' => ['netzwerk', 'image_break'], + 'netzwerk_teasers' => ['netzwerk', 'teasers'], + 'netzwerk_cta' => ['netzwerk', 'cta'], + 'netzwerk_cabinet_partner' => ['netzwerk', 'cabinet_partner'], + 'netzwerk_immobilien_hint' => ['netzwerk', 'immobilien_hint'], + + 'interior_hero' => ['netzwerk', 'interior_hero'], + 'interior_concept' => ['netzwerk', 'interior_concept'], + 'interior_brands' => ['netzwerk', 'interior_brands'], + 'interior_zielgruppen' => ['netzwerk', 'interior_zielgruppen'], + 'interior_process' => ['netzwerk', 'interior_process'], + 'interior_trust' => ['netzwerk', 'interior_trust'], + + 'faq' => ['faq', 'faq'], + + 'contact_form' => ['contact', 'contact_form'], + + 'about_hero' => ['about', 'hero'], + 'our_story' => ['about', 'our_story'], + 'about_image_break' => ['about', 'image_break'], + 'our_values' => ['about', 'our_values'], + 'leadership_team' => ['about', 'leadership_team'], + + 'broker_section' => ['netzwerk', 'broker_section'], + 'commitment_section' => ['netzwerk', 'commitment_section'], + 'dark_stats_section' => ['netzwerk', 'dark_stats_section'], + 'ecosystem_hero' => ['netzwerk', 'ecosystem_hero'], + 'ecosystem_stats' => ['netzwerk', 'ecosystem_stats'], + 'ecosystem_start' => ['netzwerk', 'ecosystem_start'], + 'ecosystem_hub' => ['netzwerk', 'ecosystem_hub'], + 'ecosystem_result' => ['netzwerk', 'ecosystem_result'], + 'end_customer_section' => ['netzwerk', 'end_customer_section'], + 'final_commitment' => ['netzwerk', 'final_commitment'], + 'digital_core' => ['netzwerk', 'digital_core'], + 'supply_chain_intro' => ['netzwerk', 'supply_chain_intro'], + + 'partner_hero' => ['netzwerk', 'partner_hero'], + 'partner_benefits' => ['netzwerk', 'partner_benefits'], + 'partner_cta' => ['netzwerk', 'partner_cta'], + 'partner_card_section' => ['netzwerk', 'partner_card_section'], + 'card_section' => ['netzwerk', 'partner_card_section'], + 'partner_benefits_developer' => ['netzwerk', 'partner_benefits_developer'], + 'partner_benefits_retailer' => ['netzwerk', 'partner_benefits_retailer'], + 'partner_benefits_supplier' => ['netzwerk', 'partner_benefits_supplier'], + 'partner_benefits_broker' => ['netzwerk', 'partner_benefits_broker'], + 'partner_process' => ['netzwerk', 'partner_process'], + 'supplier_section' => ['netzwerk', 'supplier_section'], + + 'magazin_detail' => ['magazin', 'detail'], + 'magazin_list' => ['magazin', 'list'], + ], +]; diff --git a/config/content.php b/config/content.php index 62d278a..9355715 100644 --- a/config/content.php +++ b/config/content.php @@ -1,191 +1,674 @@ [ 'b2in' => [ + 'announcement_bar' => [ + 'enabled' => true, + 'id' => 'azizi-launch-2026', + 'badge' => 'NEW LAUNCH', + 'text' => 'Azizi Creek Views 4 – Exklusives Off-Market-Projekt in Dubai '.\App\Helpers\PriceHelper::formatAed(1_125_000, 'ab'), + 'link_text' => 'Exposé ansehen', + 'link_url' => '/immobilien/azizi-creek-views-4', + ], 'header' => [ - 'portal_login' => 'Portal Login', + 'portal_login' => 'Partner-Login', 'navigation' => [ ['label' => 'Home', 'url' => '/'], - ['label' => 'Partner', 'url' => '/partner'], - ['label' => 'Ecosystem', 'url' => '/ecosystem'], + ['label' => 'Immobilien', 'url' => '/immobilien'], + ['label' => 'Netzwerk', 'url' => '/netzwerk'], ['label' => 'Magazin', 'url' => '/magazin'], - ['label' => 'About', 'url' => '/about'], - ['label' => 'Contact', 'url' => '/contact'], - ] + ['label' => 'FAQ', 'url' => '/faq'], + ['label' => 'Über B2in', 'url' => '/about'], + ], ], 'hero' => [ - 'title' => 'B2in – Brücken zwischen lokalen Kunden, lokalem Handel und inspirierenden Designs.', - 'subtitle' => 'Wo lokaler Handel und namhafte Lieferanten digital, aber lokal, in einem Netzwerk zusammenarbeiten.', + 'title' => 'B2in – Wo exklusive Immobilien-Investments auf smartes Interior treffen.', + 'subtitle' => 'Ihr Partner für renditestarke internationale Immobilien und globales Supply-Chain-Management im Interior-Bereich.', 'image' => 'b2in/hero-room.jpg', - 'image_alt' => 'Modern international skyline showcasing architectural design', - 'cta1_text' => 'Für lokale Händler', - 'cta1_link' => '/services', - 'cta2_text' => 'Für Hersteller & Marken', - 'cta2_link' => '/beratung', + 'image_alt' => 'B2in – Internationale Immobilien und exklusive Einrichtungskonzepte', + 'cta1_text' => 'Zu den Immobilien-Projekten', + 'cta1_link' => '/immobilien', + 'cta2_text' => 'Unser Netzwerk', + 'cta2_link' => '/netzwerk', 'stats' => [ - 'Exklusive Auswahl', - 'Persönlicher Service', - 'Werte die bleiben' + 'Internationale Immobilien', + 'Exklusive Einrichtung', + 'Persönliche Beratung', ], 'card_title' => 'B2in', - 'card_text' => 'Connecting Design and Property' + 'card_text' => 'Connecting Design and Property', + ], + 'founder_bar' => [ + 'image' => 'b2in/marcel-scheibe.jpg', + 'name' => 'Marcel Scheibe', + 'title' => 'Gründer & CEO, B2in', + 'statement' => 'B2in by Marcel Scheibe – Ihr persönlicher Partner für internationale Immobilien und exklusive Einrichtungskonzepte.', + ], + 'synergie_section' => [ + 'title' => 'Zwei Welten. Ein Netzwerk.', + 'paragraphs' => [ + 'Wir verbinden den Immobilienkauf mit der perfekten Einrichtung. Immobilien-Investoren profitieren von unserem exklusiven Möbel-Netzwerk – Projektentwickler von unserer deutschen Vertragssicherheit im Supply-Chain-Management.', + ], + 'image' => 'b2in/best-of-two-worlds.jpg', + 'image_alt' => 'Das B2in-Ökosystem – Immobilien und Einrichtung', + 'image_caption' => 'Das B2in-Ökosystem', ], 'ecosystem_core' => [ - 'title' => 'Ein Ökosystem, drei Stärken', - 'subtitle' => 'Wir schaffen Synergien, die den Markt revolutionieren.', + 'title' => 'Ein Ökosystem, drei Säulen', + 'subtitle' => '', 'pillars' => [ [ - 'icon' => 'cube-transparent', - 'title' => 'Kuratierter Marktplatz', - 'description' => 'Präsentieren Sie Ihre Möbel auf einer exklusiven Plattform, die gezielt designaffine Endkunden und Immobilienprofis anspricht – regional und überregional.', + 'icon' => 'building-office-2', + 'title' => 'Immobilien & Investments', + 'description' => 'Exklusive Off-Market-Projekte & High-Yield Renditeobjekte.', + 'link' => '/immobilien', ], [ - 'icon' => 'truck', - 'title' => 'Intelligente Logistik & Service', - 'description' => 'Profitieren Sie von unserer Bündel-Logistik für Hersteller oder unserer direkten Anbindung an lokale Montageteams für Händler.', + 'icon' => 'squares-2x2', + 'title' => 'Local-for-Local Marktplatz', + 'description' => 'Das Netzwerk für den regionalen Möbelfachhandel und Makler.', + 'link' => '/netzwerk', ], [ - 'icon' => 'user-group', - 'title' => ' Ein starkes Verkaufsnetzwerk', - 'description' => 'Werden Sie Teil eines Ökosystems aus Maklern, Händlern und Marken, das kontinuierlich neue, qualifizierte Verkaufschancen für Ihr Sortiment generiert.', + 'icon' => 'clipboard-document-check', + 'title' => 'Supply-Chain-Management', + 'description' => 'Deutsche Vertragssicherheit für internationale Immobilienentwickler.', + 'link' => '/netzwerk', ], - ] + ], ], 'vision_section' => [ - 'title' => 'Gebaut auf Vertrauen', + 'title' => 'Gebaut auf Expertise und Vertrauen', 'paragraphs' => [ - 'B2in (Bridges2international) verbindet Immobilienmakler, Möbelfachhändler, Möbelhersteller und Markenpartner auf einer gemeinsamen Plattform.', - 'Unser Ziel: den Kunden zu Hause abzuholen, den lokalen Handel zu stärken, partnerschaftliche Kooperationen zu fördern und alle Welten digital miteinander zu verbinden – ohne dabei die lokalen Wurzeln aus den Augen zu verlieren.', - 'So entsteht ein Netzwerk, das Nähe schafft – regional verwurzelt, europaweit vernetzt und auf nachhaltigen Erfolg ausgerichtet ist.', + 'B2in (Bridges2international) verbindet zwei Welten: internationale Immobilien und exklusive Einrichtungskonzepte. Als Ihr persönlicher Partner navigiere ich Sie durch beide Bereiche – mit Expertise, Netzwerk und dem Anspruch, dass jede Entscheidung auf Vertrauen basiert.', + 'Ob ein Investment in Dubai, eine Villa in Lissabon oder die maßgeschneiderte Einrichtung Ihres neuen Zuhauses durch lokale Fachexperten – bei B2in laufen alle Fäden zusammen.', + 'Regional verwurzelt, international vernetzt – das ist B2in.', ], - 'image' => 'b2in/marcel-scheibe.jpg', - 'image_alt' => 'Professionelles Team in kollaborativem Meeting', - 'image_caption' => 'Marcel Scheibe, Gründer & CEO' + 'image' => 'b2in/marcel-scheibe-about.jpg', + 'image_alt' => 'Marcel Scheibe, Gründer und CEO von B2in', + 'image_caption' => 'Marcel Scheibe, Gründer & CEO', ], 'brand_worlds' => [ - 'title' => 'Unsere Markenwelten:
Qualifizierte Kunden für Ihr Möbel-Sortiment', - 'subtitle' => 'Unsere Endkunden-Plattformen style2own und stileigentum schaffen einen konstanten Strom an Kauf- und Mietabsichten. Diese qualifizierten Leads leiten wir in unser Ökosystem, direkt zu den passenden Möbel-Angeboten – Ihren.', + 'title' => 'Unsere Welten:
Design, Immobilien und internationaler Handel', + 'subtitle' => 'Von internationalen Immobilien-Investments über exklusive Einrichtungskonzepte bis zum transatlantischen Handel – B2in verbindet die Welten, die zusammengehören.', 'worlds' => [ - [ - 'image' => 'b2in/b2a.jpg', - 'title' => 'B2A', - 'description' => 'Unsere Logistik-Power für den US-Markt. Wir ermöglichen Herstellern den Zugang zum transatlantischen Handel.', - 'link' => '/b2a', - 'logo' => 'img/logos/b2a-logo-positiv.svg', - 'logo_width' => 'w-18', - ], [ 'image' => 'b2in/stileigentum.jpg', 'title' => 'Stileigentum', - 'description' => 'Der Magnet für das Premium-Segment. Diese Marke zieht kaufkräftige Kunden an, die exklusive und hochwertige Möbel suchen – Ihre.', - 'link' => '/stileigentum', + 'description' => 'Das Premium-Segment: Exklusive und hochwertige Einrichtungskonzepte für anspruchsvolle Kunden, die Qualität und Tradition schätzen.', + 'link' => env('DOMAIN_STILEIGENTUM_URL', 'https://stileigentum.test'), 'logo' => 'img/logos/stileigentum-logo-positiv.svg', 'logo_width' => 'w-35', + 'external' => true, ], [ 'image' => 'b2in/style2own.jpg', 'title' => 'Style2own', - 'description' => 'Der Motor für den breiten Markt. Diese Marke begeistert lifestyle-orientierte Kunden und schafft durch flexible Mietmodelle eine hohe Nachfrage.', - 'link' => '/style2own', + 'description' => 'Der Lifestyle-Kanal: Moderne Einrichtungskonzepte für Young Professionals und trend-orientierte Kunden – urban, flexibel, inspirierend.', + 'link' => env('DOMAIN_STYLE2OWN_URL', 'https://style2own.test'), 'logo' => 'img/logos/style2own-logo-positiv.svg', 'logo_width' => 'w-28', + 'external' => true, + ], + [ + 'image' => 'b2in/b2a.jpg', + 'title' => 'B2A', + 'description' => 'Unsere Logistik-Power für den transatlantischen Handel. Wir ermöglichen Herstellern den direkten Zugang zu internationalen Märkten.', + 'link' => env('DOMAIN_B2A_URL', 'https://b2a.test'), + 'logo' => 'img/logos/b2a-logo-positiv.svg', + 'logo_width' => 'w-18', + 'external' => true, ], ], ], 'integriertes_modell_b2in' => [ - 'title' => 'Das Beste aus zwei Welten:
in jeder Region', + 'title' => 'Das Beste aus zwei Welten:
Immobilien und Einrichtung', 'paragraphs' => [ - 'Unser einzigartiges Modell schafft einen Marktplatz, den es so vorher nicht gab. Der Kunde wählt seine Region und erhält eine integrierte Ansicht: Zuerst die Angebote der lokalen Fachexperten, ergänzt durch das exklusive Sortiment unserer europäischen Hersteller.', - 'Das Ergebnis ist maximale Auswahl für den Kunden und maximaler Erfolg für unsere Partner.', + 'B2in verbindet, was zusammengehört: Wer eine Immobilie erwirbt, braucht die passende Einrichtung. Wer exklusiv einrichten möchte, findet über unser Netzwerk die besten lokalen Fachexperten – ergänzt durch das Sortiment europäischer Hersteller.', + 'Das Ergebnis: Ein nahtloses Erlebnis für den Kunden und neue Ertragsquellen für unsere Partner – ob Makler, Händler oder Entwickler.', ], 'image' => 'b2in/best-of-two-worlds.jpg', - 'image_alt' => 'Das Ergebnis für den Kunden, das perfekte Zuhause', - 'image_caption' => 'Das Ergebnis für den Kunden, das perfekte Zuhause' + 'image_alt' => 'Das Ergebnis für den Kunden – Immobilie und Einrichtung aus einer Hand', + 'image_caption' => 'Immobilie und Einrichtung – aus einer Hand', ], 'cta_section' => [ - 'title' => ' Werden Sie Partner
im führenden Möbel-Netzwerk', - 'subtitle' => 'Starten Sie jetzt und erschließen Sie neue Vertriebskanäle – ob als lokaler Händler oder als visionäre Herstellermarke.', - 'button_text' => 'Jetzt Partner werden', - 'button_link' => '/contact' + 'title' => 'Ihr nächster Schritt', + 'subtitle' => 'Ob Immobilien-Investment, Supply-Chain-Partnerschaft oder Einrichtungs-Netzwerk – sprechen Sie direkt mit uns.', + 'button_text' => 'Kontakt aufnehmen', + 'button_link' => '/contact', + ], + 'immobilien_hero' => [ + 'title' => 'Investieren Sie in die Zukunft – Dubai, Lissabon & mehr.', + 'subtitle' => 'Exklusive Off-Market-Projekte und High-Yield-Investments. Persönlich kuratiert und begleitet von Marcel Scheibe.', + 'features' => [ + [ + 'title' => 'Off-Market', + 'description' => 'Exklusive Projekte', + 'icon' => 'lock-closed', + ], + [ + 'title' => 'High-Yield', + 'description' => 'Renditestarke Investments', + 'icon' => 'arrow-trending-up', + ], + [ + 'title' => 'Persönlich', + 'description' => 'Begleitung durch Marcel Scheibe', + 'icon' => 'user', + ], + [ + 'title' => 'International', + 'description' => 'Dubai, Lissabon & mehr', + 'icon' => 'globe-alt', + ], + ], + 'image' => 'b2in/hero-immobilien.jpg', + 'image_alt' => 'Internationale Immobilien-Investments', + 'card_title' => 'B2in Immobilien', + 'card_text' => 'Exklusive internationale Investments', + 'hub' => [ + 'title' => 'B2in Immobilien', + 'subtitle' => 'Exklusive internationale Investments', + ], + 'stats' => [ + 'Off-Market Zugang', + 'Persönliche Begleitung', + 'Renditestarke Objekte', + ], + ], + 'immobilien_projects' => [ + 'title' => 'Aktuelle Launches & Projekte', + 'subtitle' => 'Entdecken Sie unsere aktuellen Immobilien-Projekte auf internationalen Märkten.', + 'projects' => [ + 'azizi-creek-views-4' => [ + 'slug' => 'azizi-creek-views-4', + 'title' => 'Azizi Developments: Creek Views 4', + 'location' => 'Al Jaddaf, Dubai', + 'status' => 'NEW LAUNCH', + 'launch_date' => '03.03.2026', + 'price_from' => \App\Helpers\PriceHelper::formatAed(1_125_000, 'ab'), + 'image' => 'expose/a1/image-4.jpeg', + 'highlights' => [ + 'Prime Waterfront Views', + '1BR: '.\App\Helpers\PriceHelper::formatAed(1_125_000, 'ab'), + 'Exklusives 3BR Penthouse (Single Inventory)', + 'High Rental Demand & Capital Appreciation', + ], + 'quick_facts' => [ + ['icon' => 'home-modern', 'label' => 'Typen', 'value' => '1BR & 3BR Penthouse'], + ['icon' => 'squares-2x2', 'label' => 'Größe', 'value' => '543 – 2.346 sqft'], + ['icon' => 'building-office-2', 'label' => 'Einheiten', 'value' => 'Nur 132 (Limited)'], + ['icon' => 'user', 'label' => 'Entwickler', 'value' => 'Azizi Developments'], + ], + 'investment_case' => [ + 'title' => 'Starkes Investment, hohe Nachfrage.', + 'text' => 'Creek Views 4 bietet eine strategische Top-Lage in Al Jaddaf. Die Kombination aus limitiertem Angebot (nur 132 Einheiten) und Premium-Ausstattung macht dieses Projekt zur idealen Wahl für Investoren, die auf Wertsteigerung (Capital Appreciation) und hohe Mietnachfrage abzielen.', + 'views' => [ + 'Road View', + 'Sitting & Play Area View', + 'Neighbour View', + ], + ], + 'gallery' => [ + 'expose/a1/image-4.jpeg', + 'expose/a1/image-3.jpeg', + 'expose/a1/image-2.jpeg', + 'expose/a1/image-5.jpeg', + 'expose/a1/image-6.jpeg', + 'expose/a1/image-7.jpeg', + ], + 'location_info' => [ + 'title' => 'Strategische Location: Al Jaddaf', + 'map_url' => 'https://maps.google.com/?q=Al+Jaddaf+Dubai', + 'points' => [ + 'Direkte Anbindung an Dubai Creek und Waterfront', + 'Wenige Minuten bis Downtown Dubai & Burj Khalifa', + 'Exzellente Infrastruktur und wachsender Stadtteil', + ], + ], + 'contact' => [ + 'title' => 'Sichern Sie sich eine der 132 Einheiten.', + 'subtitle' => 'Ihr Ansprechpartner: Marcel Scheibe', + 'options' => [ + '' => 'Ich interessiere mich für...', + '1br' => '1 Bedroom Apartment', + '3br_penthouse' => '3BR Penthouse (Single Unit)', + 'general' => 'Allgemeine Beratung', + ], + ], + ], + ], + 'cta_text' => 'Jetzt Exposé & Verfügbarkeit anfragen', + 'cta_link' => '/contact', + ], + 'immobilien_moebel_vorteil' => [ + 'title' => 'Ihr Investment, Ihr Vorteil', + 'text' => 'Als Käufer einer B2in-Immobilie erhalten Sie exklusiven Insider-Zugang zu unserem B2in-Möbelnetzwerk. Richten Sie Ihre Immobilie zu unschlagbaren Partner-Konditionen ein.', + 'button_text' => 'Mehr zum B2in-Netzwerk', + 'button_link' => '/netzwerk', + ], + 'immobilien_trust' => [ + 'title' => 'Persönliche Begleitung', + 'paragraphs' => [ + 'Hinter jedem B2in-Investment steht Marcel Scheibe als persönlicher Ansprechpartner. Keine anonyme Plattform – sondern ein Gesicht mit Expertise, Netzwerk und dem Anspruch, dass Ihr Investment in den besten Händen ist.', + ], + 'image' => 'b2in/marcel-scheibe-about.jpg', + 'image_alt' => 'Marcel Scheibe – Persönliche Investmentbegleitung', + 'image_caption' => 'Marcel Scheibe, Gründer & CEO', + 'list' => [ + [ + 'icon' => 'calendar', + 'title' => 'Investor Evenings – Exklusive Quartals-Events im kleinen Kreis', + ], + [ + 'icon' => 'phone', + 'title' => 'Direkter Draht – Persönliches Gespräch und Terminvereinbarung', + ], + [ + 'icon' => 'shield-check', + 'title' => 'Transparenz – Von der Marktanalyse bis zum Closing begleitet', + ], + ], + ], + // ============================================================ + // IMMOBILIEN SOFT LAUNCH (v2) - Neue Sektionen + // ============================================================ + 'immobilien_hero_v2' => [ + 'title' => 'Investieren in globale Dynamik. Mit deutscher Verlässlichkeit.', + 'subtitle' => 'Exklusive Off-Market-Projekte, attraktive Renditen und eine Begleitung, die weit über den Kaufvertrag hinausgeht. Entdecken Sie den Immobilienmarkt in Dubai.', + 'cta_text' => 'Aktuelle Projekte ansehen', + 'cta_link' => '#projekte', + 'image' => 'b2in/hero-immobilien.jpg', + 'image_alt' => 'Dubai Skyline – Premium Immobilien-Investments', + ], + 'immobilien_warum_dubai' => [ + 'title' => 'Warum sich ein Investment in Dubai lohnt', + 'intro' => 'Dubai ist nicht nur eine finanzielle Entscheidung, sondern der Zugang zu einem der wachstumsstärksten Märkte der Welt. Investoren schätzen die klaren rechtlichen Strukturen und Rahmenbedingungen, die weltweit nahezu einzigartig sind:', + 'facts' => [ + [ + 'icon' => 'banknotes', + 'title' => '0 % Steuern', + 'description' => 'Keine Einkommensteuer auf Mieteinnahmen, keine Kapitalertragsteuer beim Verkauf.', + ], + [ + 'icon' => 'arrow-trending-up', + 'title' => 'Starke Renditen', + 'description' => 'Attraktive Mietrenditen von historisch 6 % bis 9 % jährlich.', + ], + [ + 'icon' => 'shield-check', + 'title' => 'Hohe Sicherheit', + 'description' => 'Ein staatlich regulierter Markt mit dem sicheren Escrow-Treuhand-System.', + ], + [ + 'icon' => 'currency-dollar', + 'title' => 'Stabile Währung', + 'description' => 'Der Dirham (AED) ist fest an den US-Dollar gekoppelt.', + ], + [ + 'icon' => 'identification', + 'title' => 'Golden Visa', + 'description' => 'Sichern Sie sich Aufenthaltsgenehmigungen über attraktive Investor-Programme.', + ], + [ + 'icon' => 'globe-alt', + 'title' => 'Hohe Nachfrage', + 'description' => 'Internationale Zuwanderung und begrenztes Angebot treiben die Nachfrage nach Wohnraum stetig nach oben.', + ], + ], + ], + 'immobilien_image_break' => [ + 'image' => 'b2in/hero-immobilien.jpg', + 'image_alt' => 'Dubai Skyline – Immobilien-Investments', + 'quote' => 'Dubai hat sich zu einem der dynamischsten Immobilienmärkte der Welt entwickelt.', + 'author' => 'Marcel Scheibe', + ], + 'immobilien_kaufprozess' => [ + 'title' => 'Klar strukturiert: Der Kaufprozess in Dubai', + 'intro' => 'Der Markt in Dubai ist schneller, digitaler und projektbezogen organisiert. Jeder Schritt ist durch das staatliche Escrow-System (Treuhandkonten nach Baufortschritt) maximal abgesichert.', + 'steps' => [ + [ + 'number' => '1', + 'title' => 'Reservierung (Booking Fee)', + 'description' => 'Mit einer Gebühr von ca. 3–10 % wird Ihre Wunsch-Einheit offiziell aus dem Verkauf genommen und für Sie blockiert.', + ], + [ + 'number' => '2', + 'title' => 'Anzahlung & Vertrag (SPA)', + 'description' => 'Nach einer ersten Anzahlung (meist 10 %) wird der offizielle Kaufvertrag, das Sales & Purchase Agreement (SPA), erstellt.', + ], + [ + 'number' => '3', + 'title' => 'Staatliche Registrierung (DLD)', + 'description' => 'Durch die Zahlung der Registrierungsgebühr (4 %) an das Dubai Land Department wird Ihr Eigentum offiziell im staatlichen Register verankert.', + ], + [ + 'number' => '4', + 'title' => 'Finaler Kaufvertrag', + 'description' => 'Ihr Eigentumsrecht ist offiziell gesichert. Die weiteren Zahlungen erfolgen streng nach Baufortschritt auf sichere Treuhandkonten.', + ], + ], + ], + 'immobilien_bruecke' => [ + 'title' => '"Der Markt spricht für sich. Meine Aufgabe ist eine andere."', + 'paragraphs' => [ + 'Für viele deutsche Investoren wirkt der Immobilienkauf in Dubai zunächst ungewohnt. In Deutschland sind wir Notare, Grundbücher und hochbürokratische Prozesse gewohnt. Dubai ist dynamischer.', + 'Ich bin nicht hier, um Ihnen den Markt zu verkaufen – die Qualität der Projekte spricht für sich. Meine Aufgabe ist es, Ihre Brücke zu sein. Als Investor, der selbst in Dubai gekauft hat, kenne ich die Praxis. Ich bin regelmäßig vor Ort, stehe im permanenten Austausch mit Bauträgern und begleite Sie durch den kompletten Prozess. Ich übersetze die internationale Geschwindigkeit in deutsche Verlässlichkeit.', + ], + 'advantage_title' => 'Ihr B2in-Vorteil', + 'advantage_text' => 'Meine Begleitung endet nicht beim Kauf. Planen Sie die lukrative Kurzzeitvermietung (z.B. Airbnb)? Über unser B2in-Netzwerk und meine Wurzeln in der Möbelbranche realisieren wir für Sie komplette Einrichtungskonzepte – für maximale Rendite bei minimalem Aufwand.', + 'cta_text' => 'Persönliches Beratungsgespräch vereinbaren', + 'cta_link' => '/contact', + 'image' => 'b2in/marcel-scheibe-about.jpg', + 'image_alt' => 'Marcel Scheibe – Persönliche Investmentbegleitung', + 'image_caption' => 'Marcel Scheibe, Gründer & CEO', + ], + 'immobilien_mindset' => [ + 'title' => 'Sind Sie der richtige Investor für Dubai?', + 'text_positive' => 'Dubai richtet sich an Menschen, die international denken. Der Staat priorisiert Effizienz, wirtschaftliche Entwicklung und enorme Geschwindigkeit. Wenn Sie an die Verschiebung weltweiter Wirtschaftszentren glauben und ein dynamisches System schätzen, ist Dubai der strategisch perfekte Baustein für Ihr Portfolio. Dann sind Sie bei uns genau richtig.', + 'text_negative' => 'Wenn Sie jedoch ein System bevorzugen, das auf langsamen Entscheidungswegen und maximaler Bürokratie aufbaut, wird dieser Markt nicht zu Ihren Erwartungen passen.', + 'closing' => 'Der Schritt ist kleiner, als Sie denken. Lassen Sie uns gemeinsam herausfinden, ob ein Investment in Dubai in Ihre Strategie passt.', + 'cta_text' => 'Unverbindliches Gespräch vereinbaren', + 'cta_link' => '/contact', + ], + + // ============================================================ + // NETZWERK SOFT LAUNCH - Kombinierte Teaser-Seite + // ============================================================ + 'netzwerk_hero' => [ + 'title' => 'Das B2in Ökosystem – Immobilien trifft Interior.', + 'subtitle' => 'Wir bauen aktuell das intelligenteste Local-for-Local Einrichtungsnetzwerk. Als Immobilienkunde von B2in profitieren Sie in Zukunft exklusiv von unserem Closed-Shop.', + 'image' => 'b2in/ecosystem-hero.jpg', + 'image_alt' => 'B2in Ökosystem – Einrichtung und Immobilien', + ], + 'netzwerk_image_break' => [ + 'image' => 'b2in/ecosystem-hero.jpg', + 'image_alt' => 'B2in Ökosystem – Einrichtung und Immobilien', + 'quote' => 'Wir verbinden, was zusammengehört: Immobilien und Einrichtung.', + 'author' => 'Marcel Scheibe', + ], + 'netzwerk_teasers' => [ + 'title' => 'Was wir aufbauen', + 'cards' => [ + [ + 'icon' => 'squares-2x2', + 'title' => 'Einrichtungsnetzwerk', + 'description' => 'Persönliche Beratung durch lokale Fachexperten, kuratierte europäische Hersteller und exklusive Konditionen für B2in-Immobilienkunden. Einrichtung, die man anfassen kann – nicht nur online bestellen.', + 'status' => 'In Entwicklung', + ], + [ + 'icon' => 'building-storefront', + 'title' => 'Für Händler & Fachhändler', + 'description' => 'Sie sind lokaler Einrichtungsexperte und möchten Teil eines Premium-Netzwerks werden? Wir verbinden Sie mit Kunden, die persönliche Beratung und Qualität schätzen.', + 'status' => 'Vorab-Registrierung möglich', + ], + [ + 'icon' => 'globe-alt', + 'title' => 'Für Entwickler & Marken', + 'description' => 'Deutsche Vertragssicherheit für internationale Immobilienentwickler. Supply-Chain-Management mit Fokus auf Termintreue, Qualitätskontrolle und Durchsetzungskraft.', + 'status' => 'Vorab-Registrierung möglich', + ], + ], + ], + 'netzwerk_cta' => [ + 'title' => 'Interesse an einer Partnerschaft?', + 'text' => 'Ob als Fachhändler, Hersteller, Makler oder Entwickler – kontaktieren Sie uns für eine Vorab-Registrierung und erfahren Sie als Erste, wenn unser Netzwerk live geht.', + 'button_text' => 'Kontakt aufnehmen', + 'button_link' => '/contact', + ], + + 'interior_hero' => [ + 'title' => 'Exklusive Einrichtung. Lokal gedacht, international vernetzt.', + 'subtitle' => 'Das B2in-Einrichtungsnetzwerk verbindet lokale Fachexpertise mit internationalen Herstellern – persönlich, greifbar und in Ihrer Nähe.', + 'features' => [ + [ + 'title' => 'Local-for-Local', + 'description' => 'Ihr Fachhändler vor Ort', + 'icon' => 'map-pin', + ], + [ + 'title' => 'Premium-Netzwerk', + 'description' => 'Kuratierte Hersteller & Marken', + 'icon' => 'star', + ], + [ + 'title' => 'Zwei Marken', + 'description' => 'stileigentum & style2own', + 'icon' => 'squares-2x2', + ], + [ + 'title' => 'Persönlich', + 'description' => 'Beratung statt Algorithmus', + 'icon' => 'user', + ], + ], + 'image' => 'b2in/ecosystem-hero.jpg', + 'image_alt' => 'B2in Einrichtungsnetzwerk – Local-for-Local', + 'card_title' => 'B2in Interior', + 'card_text' => 'Local-for-Local Einrichtungsnetzwerk', + 'hub' => [ + 'title' => 'B2in Interior', + 'subtitle' => 'Das Einrichtungsnetzwerk', + ], + 'stats' => [ + 'Lokale Fachexperten', + 'Europäische Hersteller', + 'Persönliche Beratung', + ], + ], + 'interior_concept' => [ + 'title' => 'Was bedeutet Local-for-Local?', + 'paragraphs' => [ + 'Unser Einrichtungsnetzwerk setzt auf das, was online nicht geht: echte Beratung, echtes Anfassen, echte Expertise. Statt anonymer Plattformen vermitteln wir den direkten Kontakt zu lokalen Fachhändlern – Menschen, die ihr Handwerk verstehen und Sie persönlich begleiten.', + 'B2in verbindet diese lokalen Experten mit einem kuratierten Netzwerk europäischer Hersteller. Das Ergebnis: Zugang zu exklusiven Marken und Sortimenten, die Sie so nicht im Internet finden – aber mit der Beratungsqualität, die nur Ihr Fachhändler vor Ort bieten kann.', + ], + 'image' => 'b2in/ecosystem_result.jpg', + 'image_alt' => 'Local-for-Local – Persönliche Beratung statt anonyme Plattform', + 'image_caption' => 'Persönliche Einrichtungsberatung vor Ort', + ], + 'interior_brands' => [ + 'title' => 'Zwei Marken, ein Netzwerk', + 'subtitle' => 'Je nach Stil und Anspruch finden Sie Ihren Zugang über eine unserer zwei Einrichtungsmarken.', + 'brands' => [ + [ + 'name' => 'stileigentum', + 'tagline' => 'Premium-Einrichtung', + 'description' => 'Exklusive und hochwertige Einrichtungskonzepte für anspruchsvolle Kunden, die Qualität und Tradition schätzen. Handverlesene Hersteller, zeitlose Materialien, individuelle Beratung.', + 'audience' => 'Für Kunden, die das Besondere suchen.', + 'logo' => 'img/logos/stileigentum-logo-positiv.svg', + 'logo_width' => 'w-35', + 'link' => 'https://stileigentum.test', + ], + [ + 'name' => 'style2own', + 'tagline' => 'Design-Lifestyle', + 'description' => 'Moderne Einrichtungskonzepte für Young Professionals und trend-orientierte Kunden – urban, flexibel, inspirierend. Aktuelle Trends, smarte Lösungen, faire Preise.', + 'audience' => 'Für alle, die Design lieben.', + 'logo' => 'img/logos/style2own-logo-positiv.svg', + 'logo_width' => 'w-28', + 'link' => 'https://style2own.test', + ], + ], + ], + 'interior_zielgruppen' => [ + 'title' => 'Für wen ist das Einrichtungsnetzwerk?', + 'groups' => [ + [ + 'icon' => 'home', + 'title' => 'Privatpersonen', + 'description' => 'Sie richten Ihr neues Zuhause ein und wünschen sich persönliche Beratung statt endlosem Online-Scrollen? Über unser Netzwerk finden Sie lokale Fachexperten, die Ihren Stil verstehen.', + ], + [ + 'icon' => 'building-office-2', + 'title' => 'Immobilien-Investoren', + 'description' => 'Sie haben über B2in ein Investment getätigt? Als Immobilienkäufer erhalten Sie exklusiven Zugang zu Partner-Konditionen für die komplette Einrichtung – aus einer Hand.', + ], + [ + 'icon' => 'clipboard-document-check', + 'title' => 'Entwickler & Makler', + 'description' => 'Sie brauchen Mustereinrichtungen, Home-Staging oder eine schlüsselfertige Ausstattung für Ihre Projekte? B2in koordiniert von der Planung bis zur Lieferung.', + ], + ], + ], + 'interior_process' => [ + 'title' => 'So einfach funktioniert es', + 'steps' => [ + [ + 'number' => '01', + 'title' => 'Beratung', + 'description' => 'Kontaktieren Sie uns oder besuchen Sie einen unserer lokalen Partner. Gemeinsam definieren wir Ihren Stil, Ihr Budget und Ihre Wünsche.', + ], + [ + 'number' => '02', + 'title' => 'Auswahl', + 'description' => 'Ihr Fachhändler zeigt Ihnen kuratierte Kollektionen europäischer Hersteller – zum Anfassen, nicht nur auf dem Bildschirm.', + ], + [ + 'number' => '03', + 'title' => 'Lieferung & Einrichtung', + 'description' => 'B2in koordiniert Logistik und Lieferung. Ihr lokaler Partner begleitet die Einrichtung bis zum letzten Detail.', + ], + ], + ], + 'interior_trust' => [ + 'title' => 'Persönlich statt anonym', + 'paragraphs' => [ + 'Hinter dem B2in-Einrichtungsnetzwerk steht Marcel Scheibe mit der Überzeugung, dass gute Einrichtung persönliche Beratung braucht. Kein Algorithmus ersetzt das Gespräch mit einem Experten, der Materialien kennt, Räume versteht und Ihren Stil trifft.', + ], + 'image' => 'b2in/marcel-scheibe-about.jpg', + 'image_alt' => 'Marcel Scheibe – Das Gesicht hinter dem Einrichtungsnetzwerk', + 'image_caption' => 'Marcel Scheibe, Gründer & CEO', + 'list' => [ + [ + 'icon' => 'map-pin', + 'title' => 'Lokale Experten – Fachhändler in Ihrer Region, die Sie persönlich beraten', + ], + [ + 'icon' => 'star', + 'title' => 'Kuratierte Hersteller – Nur geprüfte europäische Qualitätsmarken', + ], + [ + 'icon' => 'shield-check', + 'title' => 'Transparente Preise – Faire Konditionen ohne versteckte Aufschläge', + ], + ], ], 'faq' => [ 'title' => 'Häufig gestellte Fragen', - 'subtitle' => 'Hier finden Sie Antworten auf die häufigsten Fragen zu unserem B2in Ökosystem.', + 'subtitle' => 'Hier finden Sie Antworten auf die häufigsten Fragen zu B2in und unserem Ökosystem.', 'questions' => [ [ 'question' => 'Was ist B2in und welche Services bieten Sie an?', - 'answer' => 'B2in (Bridges 2 International) ist ein globales Ökosystem für Immobilieninvestoren, Makler und Designliebhaber. Wir bieten drei Hauptbereiche: B2A für Business-to-Administration, Stileigentum für exklusive Immobilien und Style2Own für modernes Interior Design.' + 'answer' => 'B2in ist die Brücke zwischen internationalen Premium-Immobilien und exklusiven Einrichtungskonzepten. Wir bieten Immobilieninvestoren Zugang zu Off-Market-Projekten (Fokus: Dubai) und begleiten sie durch den gesamten Kaufprozess. Gleichzeitig bieten wir Bauträgern ein knallhartes Supply-Chain-Management für die Beschaffung deutscher Qualitätsmöbel und bauen ein innovatives Einrichtungsnetzwerk für lokale Händler auf.', + ], + [ + 'question' => 'Was bedeutet Supply-Chain-Management bei B2in?', + 'answer' => 'Für internationale Immobilienentwickler fungieren wir als verlängerter Arm in Deutschland. Wir übernehmen die operative und strategische Steuerung der Möbel- und Innenausstattungsbeschaffung. Das bedeutet: Wir sichern Lieferverträge ab, überwachen Meilensteine direkt bei den Herstellern und eskalieren bei Abweichungen sofort auf Managementebene. Unser Ziel ist absolute Vertragssicherheit und termingerechte Lieferung ohne Reibungsverluste.', ], [ 'question' => 'Wie kann ich Partner bei B2in werden?', - 'answer' => 'Partner können Sie über unser Partnerportal werden. Wir bieten verschiedene Partnerschaftsmodelle für Makler, Lieferanten und andere Branchenexperten. Kontaktieren Sie uns für eine persönliche Beratung über die Vorteile einer strategischen Partnerschaft.' + 'answer' => 'Unser Netzwerk richtet sich an Immobilienentwickler, Makler und den regionalen Möbelfachhandel. Wenn Sie als Entwickler deutsche Beschaffungssicherheit suchen oder als Händler Teil unseres künftigen "Local for Local"-Netzwerks werden möchten, um qualifizierte Leads von Immobilienkäufern zu erhalten, nutzen Sie einfach unser Kontaktformular. Wir prüfen jede Partnerschaft individuell auf Qualität und Passgenauigkeit.', ], [ - 'question' => 'Welche Vorteile bietet das B2in Ökosystem?', - 'answer' => 'Unser Ökosystem bietet direkten Zugang zu exklusiven Investments, professionelle Begleitung bei internationalen Märkten, kuratierte Wohnkonzepte für Wertsteigerung und ein starkes Partnernetzwerk mit fairen Konditionen und digitalem Support.' - ], - [ - 'question' => 'Wie funktioniert der globale Immobilienhandel bei B2in?', - 'answer' => 'Wir bieten direkten Zugang zu exklusiven Immobilieninvestments auf internationalen Märkten mit professioneller Begleitung. Unsere Expertise umfasst Marktanalysen, rechtliche Unterstützung und langfristige Betreuung Ihrer Investments.' + 'question' => 'Wie funktioniert das Immobilien-Investment über B2in?', + 'answer' => 'Wir vermitteln nicht nur, wir begleiten Sie. Marcel Scheibe ist Ihr persönlicher Berater, der den Markt (insbesondere Dubai) aus eigener Investorensicht kennt. Nach einer Bedarfsanalyse stellen wir Ihnen exklusive Projekte vor (z. B. von Azizi Developments). Der Kaufprozess selbst ist durch das staatliche Escrow-System in Dubai maximal abgesichert. Ihr besonderer Vorteil: Als B2in-Kunde erhalten Sie im Anschluss exklusiven Zugang zu unserem Netzwerk, um Ihr Investment schlüsselfertig (Turnkey) und renditeoptimiert einrichten zu lassen.', ], [ 'question' => 'Was macht B2in zu einem vertrauenswürdigen Partner?', - 'answer' => 'Unsere Basis ist Vertrauen, angetrieben von Technologie und Innovation. B2in ist nicht nur eine Holding, sondern ein aktiver Gestalter der Immobilienzukunft mit einer zentralen digitalen Plattform für Transparenz und Qualität.' - ] - ] + 'answer' => 'Vertrauen entsteht durch Transparenz und eigene Markterfahrung. B2in-Gründer Marcel Scheibe investiert selbst in die Märkte, die wir anbieten, und ist regelmäßig vor Ort, um Baufortschritte zu prüfen. Wir verbinden die immense Dynamik internationaler Märkte (wie Dubai) mit deutscher Verlässlichkeit, striktem Vertragsmanagement und einem kuratierten Netzwerk von Premiumpartnern wie CABINET. Bei uns haben Sie immer einen persönlichen Ansprechpartner, der Ihre Interessen vertritt.', + ], + ], + 'sections' => [ + [ + 'title' => 'Fokus: Immobilien-Investoren (B2C)', + 'icon' => 'home-modern', + 'questions' => [ + [ + 'question' => 'Muss ich für den Immobilienkauf persönlich nach Dubai reisen?', + 'answer' => 'Nein, der gesamte Kaufprozess kann vollständig digital und rechtsverbindlich aus der Ferne abgewickelt werden. Das Dubai Land Department bietet hierfür hochsichere Prozesse. Wir empfehlen unseren Investoren zwar gerne, sich vor Ort ein Bild von der Dynamik der Stadt zu machen, für den rechtssicheren Erwerb ist Ihre physische Anwesenheit jedoch nicht zwingend erforderlich.', + ], + [ + 'question' => 'Was passiert nach dem Immobilienkauf? Helfen Sie bei der Einrichtung?', + 'answer' => 'Genau hier liegt der große Vorteil des B2in-Ökosystems. Unser Service endet nicht mit dem Kaufvertrag. Als B2in-Kunde erhalten Sie exklusiven Zugang zu unserem Einrichtungsnetzwerk. Wir unterstützen Sie dabei, Ihre Immobilie renditeoptimiert und schlüsselfertig („Turnkey") mit Qualitätsmöbeln auszustatten – die perfekte Grundlage für eine lukrative Kurzzeitvermietung.', + ], + [ + 'question' => 'Welche steuerlichen Vorteile bietet ein Investment in Dubai?', + 'answer' => 'Dubai bietet ein weltweit einzigartiges wirtschaftliches Umfeld. Es fallen weder Einkommensteuer auf Mieteinnahmen noch Kapitalertragsteuer beim Verkauf der Immobilie an. In Kombination mit den starken Mietrenditen macht dies den Standort für internationale Anleger strategisch so wertvoll.', + ], + ], + ], + [ + 'title' => 'Fokus: B2B-Partner (Makler & Händler)', + 'icon' => 'briefcase', + 'questions' => [ + [ + 'question' => 'Wie profitieren Immobilienmakler von einer Partnerschaft mit B2in?', + 'answer' => 'B2in bietet Maklern ein exklusives Werkzeug zur Kundenbindung: Sie können Ihren Immobilienkäufern den Zugang zu unserem geschlossenen Einrichtungsnetzwerk als exklusives „Closing-Geschenk" überreichen. Gleichzeitig profitieren Sie als Makler durch unser technisches Clearing-System von einer passiven Lifetime-Vergütung an den Möbelumsätzen Ihrer Kunden.', + ], + [ + 'question' => 'Was genau ist der „Local for Local" Marktplatz?', + 'answer' => 'Unser „Local for Local"-Prinzip ist der Gegenentwurf zu anonymen Online-Möbelgiganten. Wir machen die sofort verfügbaren Bestände und Ausstellungsstücke regionaler Fachhändler (Säule „Local Express") für einen geschlossenen Kundenkreis transparent. So stärken wir den lokalen Handel vor Ort und bieten Käufern gleichzeitig Insider-Konditionen für Premium-Einrichtung.', + ], + [ + 'question' => 'Ist das Einrichtungsnetzwerk für jeden öffentlich zugänglich?', + 'answer' => 'Nein. Um die exklusiven Insider-Konditionen und Rabatte unserer Hersteller und lokalen Händler zu schützen, agiert der B2in-Möbelmarktplatz als „Closed Shop". Der Zugang erfolgt ausschließlich über Einladungen – beispielsweise durch unsere Partner-Makler beim Erwerb einer Immobilie.', + ], + ], + ], + ], ], 'contact_form' => [ 'hero' => [ 'title' => 'Senden Sie uns eine
Nachricht.', - 'subtitle' => 'Wir freuen uns auf Ihre Nachricht und werden uns schnellstmöglich bei Ihnen melden.' + 'subtitle' => 'Wir freuen uns auf Ihre Nachricht und werden uns schnellstmöglich bei Ihnen melden.', ], 'form' => [ 'labels' => [ - 'first_name' => 'First name *', - 'last_name' => 'Last name *', - 'company' => 'Company', - 'email' => 'Email *', - 'phone' => 'Phone', - 'subject' => 'Subject *', - 'message' => 'Message *' + 'first_name' => 'Vorname *', + 'last_name' => 'Nachname *', + 'company' => 'Firma (optional)', + 'email' => 'E-Mail *', + 'phone' => 'Telefon (optional)', + 'subject' => 'Betreff *', + 'message' => 'Ihre Nachricht *', ], 'subjects' => [ '' => 'Wählen Sie einen Betreff', + 'immobilien' => 'Internationale Immobilien', + 'supply_chain' => 'Supply-Chain-Management', 'general' => 'Allgemeine Anfrage', - 'press' => 'Presse', 'partnership' => 'Partnerschaft', + 'press' => 'Presse', 'career' => 'Karriere', ], 'placeholders' => [ - 'message' => 'Ihre Nachricht...' + 'message' => 'Ihre Nachricht...', ], 'button_text' => 'Senden', 'button_loading' => 'Wird gesendet...', - 'success_message' => 'Vielen Dank für Ihre Nachricht! Wir werden uns schnellstmöglich bei Ihnen melden.' + 'success_message' => 'Vielen Dank für Ihre Nachricht! Wir werden uns schnellstmöglich bei Ihnen melden.', ], 'contact_info' => [ [ - 'title' => 'Our Office Location', + 'title' => 'Unser Büro-Standort', 'info' => [ - 'Musterstraße 123', - '12345 Berlin, Deutschland', + 'Rathausstraße 11', + '33602 Bielefeld', ], 'icon' => 'map-pin', ], [ - 'title' => 'Our Email Address', + 'title' => 'Unsere E-Mail-Adresse', 'info' => [ - 'info@b2in.com', + 'info@b2in.eu', ], 'icon' => 'mail', ], [ - 'title' => 'Our Contact Numbers', + 'title' => 'Unser Kontakt-Nummer', 'info' => [ - '+49 30 12345678', + '+49 (0) 5221 9255055', ], 'icon' => 'phone', ], @@ -199,19 +682,19 @@ return [ ['name' => 'Pinterest', 'handle' => 'B2IN Inspiration', 'url' => 'https://pinterest.com/b2in'], ['name' => 'Facebook', 'handle' => 'B2IN Deutschland', 'url' => 'https://facebook.com/b2in'], ['name' => 'LinkedIn', 'handle' => 'B2IN Company', 'url' => 'https://linkedin.com/company/b2in'], - ] - ] + ], + ], ], 'about_hero' => [ 'title' => 'Über B2in: Unsere Mission', - 'quote' => '"Unsere Mission ist es, die Zukunft des lokalen Möbelhandels zu sichern. Wir geben dem Fachexperten vor Ort die digitalen Werkzeuge, um gegen die Dominanz der Online-Giganten zu bestehen.

Bei B2in bauen wir nicht nur Verbindungen – wir bauen Brücken zwischen europäischem Design, regionaler Expertise und dem Zuhause der Menschen."', + 'quote' => '"Meine Mission ist es, zwei Welten zu verbinden, die zusammengehören: internationale Immobilien und exklusive Einrichtung. B2in gibt dem lokalen Fachexperten die digitalen Werkzeuge und den Immobilienentwickler den operativen Partner vor Ort.

Wir bauen Brücken – zwischen europäischem Design, internationalen Märkten und dem Zuhause der Menschen."', 'founder_name' => 'Marcel Scheibe', 'founder_title' => 'Gründer & CEO, B2in', 'image' => 'b2in/about-hero.jpg', 'image_alt' => 'Marcel Scheibe, Gründer und CEO von B2in', 'card_title' => 'B2in', - 'card_text' => 'Connecting Design and Property' + 'card_text' => 'Connecting Design and Property', ], 'broker_section' => [ @@ -220,7 +703,7 @@ return [ 'card_title' => 'Lifetime-Vergütung', 'compensation' => [ 'initial_sale' => '3.5%', - 'follow_up' => '1.5%' + 'follow_up' => '1.5%', ], 'compensation_text' => 'Kontinuierliche Erträge über die gesamte Kundenbeziehung', 'benefits' => [ @@ -244,7 +727,7 @@ return [ 'description' => 'Exklusive Vermarktung hochwertiger Wohnkonzepte für anspruchsvolle Zielgruppen', 'icon' => 'award', ], - ] + ], ], 'commitment_section' => [ 'title' => 'Das Vertrauen unserer Partner', @@ -255,62 +738,62 @@ return [ 'rating' => 5, 'quote' => 'Die Zusammenarbeit mit B2in hat unsere Erwartungen übertroffen. Professionell, effizient und immer lösungsorientiert.', 'author' => 'Max Mustermann', - 'author_title' => 'Möbelhersteller' + 'author_title' => 'Möbelhersteller', ], [ 'image' => 'b2in/testo-2.jpg', 'rating' => 5, 'quote' => 'Dank der B2in-Plattform konnten wir unsere Reichweite signifikant erhöhen und neue Märkte erschließen.', 'author' => 'Erika Mustermann', - 'author_title' => 'lokaler Möbelhändler' + 'author_title' => 'lokaler Möbelhändler', ], [ 'image' => 'b2in/testo-3.jpg', 'rating' => 5, 'quote' => 'Das B2in-Portal hat die Art, wie ich Immobilien vermarkte, revolutioniert. Das Staging wertet meine Objekte auf, und die Möbelprovision ist ein extrem attraktiver Zusatzverdienst.', 'author' => 'John Doe', - 'author_title' => 'Immobilienmakler' - ] - ] + 'author_title' => 'Immobilienmakler', + ], + ], ], 'dark_stats_section' => [ 'stats' => [ ['number' => '17+', 'text' => 'Years of Experience'], - ['number' => '2M', 'text' => 'Happy Guests'] + ['number' => '2M', 'text' => 'Happy Guests'], ], 'title' => 'Economically Sound and Well-
Friendly Service for
Families and Their
Precious Belongings', 'description' => 'We understand that every family is unique, which is why we offer personalized services tailored to meet your specific needs. From luxury amenities to budget-friendly options, we ensure every guest feels valued and comfortable.', 'features' => [ ['title' => 'Top Consultation', 'subtitle' => 'Expert guidance'], ['title' => 'Finest Meal', 'subtitle' => 'Gourmet dining'], - ['title' => 'Easy Tax Reduction', 'subtitle' => 'Cost-effective'] + ['title' => 'Easy Tax Reduction', 'subtitle' => 'Cost-effective'], ], 'image' => 'b2in/accommodation-2.jpg', - 'image_alt' => 'Luxury interior design' + 'image_alt' => 'Luxury interior design', ], 'ecosystem_hero' => [ 'title' => 'Wie unser Ökosystem Wachstum für alle Partner generiert', - 'subtitle' => 'Ein intelligentes Netzwerk, das Endkunden, Händler, Lieferanten, Makler und Technologie nahtlos miteinander verbindet. Jeder Teilnehmer profitiert vom gesamten System und schafft gemeinsam außergewöhnliche Möbel- und Immobilienerlebnisse.', + 'subtitle' => 'Ein intelligentes Netzwerk, das Immobilienkäufer, lokale Fachexperten, internationale Hersteller, Makler und Entwickler nahtlos miteinander verbindet – für außergewöhnliche Immobilien- und Einrichtungserlebnisse.', 'features' => [ [ - 'title' => 'Endkunden', - 'description' => 'Exklusive Erlebnisse', - 'icon' => 'users', + 'title' => 'Immobilien', + 'description' => 'Internationale Investments', + 'icon' => 'globe-alt', ], [ - 'title' => 'Makler', - 'description' => 'Lifetime-Vergütung', - 'icon' => 'building-2', + 'title' => 'Einrichtung', + 'description' => 'Local for Local', + 'icon' => 'cube-transparent', ], [ - 'title' => 'Lieferanten', - 'description' => 'Kuratierte Plattform', - 'icon' => 'network', + 'title' => 'Supply Chain', + 'description' => 'Beschaffung & Kontrolle', + 'icon' => 'clipboard-document-check', ], [ 'title' => 'Technologie', 'description' => 'Digitales Herzstück', - 'icon' => 'zap', + 'icon' => 'cpu-chip', ], ], 'image' => 'b2in/ecosystem-hero.jpg', @@ -319,13 +802,13 @@ return [ 'card_text' => 'Die Technologie dahinter, die das Ökosystem ermöglicht', 'hub' => [ 'title' => 'B2in Portal', - 'subtitle' => 'Die Technologie dahinter, die das Ökosystem ermöglicht' + 'subtitle' => 'Die Technologie dahinter, die das Ökosystem ermöglicht', ], 'stats' => [ 'Exklusive Auswahl', 'Persönlicher Service', - 'Werte die bleiben' - ] + 'Werte die bleiben', + ], ], 'ecosystem_stats' => [ 'title' => 'Unser Ecosystem in Zahlen', @@ -334,64 +817,64 @@ return [ [ 'number' => '1,7K+', 'label' => 'Partner & Experten im Netzwerk', - 'description' => 'Wachsende Community von Endkunden, Maklern und Lieferanten', + 'description' => 'Wachsende Community von Entwicklern, Maklern, Händlern und Herstellern', ], [ 'number' => '510+', - 'label' => 'Realisierte Möbel-Projekte', - 'description' => 'Realisierte Immobilienprojekte durch unser Netzwerk', + 'label' => 'Realisierte Projekte', + 'description' => 'Immobilien- und Einrichtungsprojekte durch unser Netzwerk', ], [ 'number' => '98%', - 'label' => ' Partner-Zufriedenheit', - 'description' => 'Kundenzufriedenheit across alle Ecosystem-Teilnehmer', + 'label' => 'Partner-Zufriedenheit', + 'description' => 'Zufriedenheit über alle Ecosystem-Teilnehmer hinweg', ], [ 'number' => '24/7', 'label' => 'Partner-Support', 'description' => 'Kontinuierliche Verfügbarkeit der digitalen Infrastruktur', ], - ] + ], ], 'ecosystem_start' => [ - 'title' => 'Alles beginnt mit dem Kundenwunsch:', + 'title' => 'Alles beginnt mit dem Moment of Need:', 'paragraphs' => [ - 'Unser Ökosystem startet nicht bei Ihnen, sondern beim Endkunden.', - 'Unsere reichweitenstarken Marken style2own und stileigentum schaffen durch Inspiration und exklusive Konzepte eine kontinuierliche, kaufkräftige Nachfrage nach hochwertigen Möbeln.', + 'Unser Ökosystem startet beim Kunden – genau dann, wenn er ihn braucht: beim Immobilienkauf.', + 'Über den Makler erhält der Kunde Zugang zum B2in-Ökosystem. Unsere Marken style2own und stileigentum schaffen den passenden Rahmen – je nach Zielgruppe und Lebensstil.', ], 'image' => 'b2in/ecosystem_start.jpg', - 'image_alt' => 'Die Marken für den Endkunden', - 'image_caption' => 'Die Marken für den Endkunden', - 'image_description' => 'Eine stilisierte Grafik, die zeigt, wie von den Logos style2own und stileigentum Pfeile in den nächsten Abschnitt führen.', + 'image_alt' => 'Der Einstieg ins Ökosystem – über den Immobilienkauf', + 'image_caption' => 'Der Einstieg: Immobilienkauf als Trigger', + 'image_description' => 'Eine stilisierte Grafik, die zeigt, wie der Immobilienkauf den Einstieg ins B2in-Ökosystem auslöst.', ], 'ecosystem_hub' => [ - 'title' => ' Im Hub trifft das Beste aus zwei Welten aufeinander', + 'title' => 'Im Hub trifft Local for Local auf internationale Expertise', 'paragraphs' => [ - 'Sobald ein Kunde seine Region wählt (z.B. Bielefeld), spielt unsere Plattform ihre Stärke aus. Dank der "Local First"-Logik werden die Angebote unserer lokalen Händler prominent platziert.', - 'Gleichzeitig wird das Sortiment durch die exklusiven Produkte unserer europäischen Hersteller ergänzt. So entsteht eine unschlagbare Auswahl.', + 'Sobald ein Kunde seine Region wählt, spielt unsere Plattform ihre Stärke aus: Die "Local First"-Logik zeigt die Angebote der lokalen Fachexperten prominent an.', + 'Ergänzt wird das Sortiment durch europäische Hersteller. Und für Immobilienentwickler liefern wir die operative Beschaffung gleich mit – vom Vertrag bis zur Qualitätskontrolle.', ], 'image' => 'b2in/ecosystem_hub.jpg', - 'image_alt' => 'Die Synergie zwischen lokalem und überregionalem Angebot', - 'image_caption' => 'Die Synergie zwischen lokalem und überregionalem Angebot', - 'image_description' => 'Eine klare Infografik, die zeigt: [Lokales Angebot] + [Überregionales Angebot] = Die Synergie zwischen lokalem und überregionalem Angebot.', + 'image_alt' => 'Local for Local – lokale Expertise trifft internationale Beschaffung', + 'image_caption' => 'Local for Local: Lokale Expertise, internationale Reichweite', + 'image_description' => 'Eine klare Infografik, die zeigt: [Lokales Angebot] + [Internationale Beschaffung] = Das B2in-Ökosystem.', ], 'ecosystem_result' => [ 'title' => 'Ein Kreislauf, in dem jeder gewinnt', 'paragraphs' => [ - 'In diesem perfekten Zusammenspiel entstehen klare Vorteile für jeden Teilnehmer:', + 'In diesem Zusammenspiel entstehen klare Vorteile für jeden Teilnehmer:', ], 'list' => [ [ - 'icon' => 'building-storefront', - 'title' => 'Der lokale Händler gewinnt einen Online-Kunden, den er sonst nicht erreicht hätte, und stärkt seine Position vor Ort.' + 'icon' => 'globe-alt', + 'title' => 'Der Immobilienentwickler erhält einen verlässlichen Partner für Beschaffung und Qualitätskontrolle in Deutschland – transparent und termingerecht.', ], [ - 'icon' => 'building-office-2', - 'title' => 'Lokale Händler erhalten neue Kunden und erhöhen ihren Umsatz' + 'icon' => 'building-storefront', + 'title' => 'Der lokale Händler gewinnt qualifizierte Kunden, die er alleine nicht erreicht hätte, und stärkt seine Position vor Ort.', ], [ 'icon' => 'home-modern', - 'title' => 'Der Makler, der den Kunden ursprünglich vermittelt hat, erhält eine faire Provision und hat seinem Kunden einen unschätzbaren Mehrwert geboten.' + 'title' => 'Der Makler bietet seinen Kunden einen einzigartigen Mehrwert nach dem Abschluss und profitiert von attraktiven Zusatzprovisionen.', ], ], 'image' => 'b2in/ecosystem_result.jpg', @@ -433,13 +916,13 @@ return [ 'title' => 'Login-Karte', 'subtitle' => 'Ihr Schlüssel zu exklusiven Immobilienerlebnissen', 'member_number_label' => 'Mitgliedsnummer', - 'member_number' => 'B2IN-2024-VIP' - ] + 'member_number' => 'B2IN-2024-VIP', + ], ], 'final_commitment' => [ 'title' => 'Were committed to
your comfort and
satisfaction for
unforgettable
experiences', 'author' => 'Robert Wilson', - 'author_title' => 'General Manager' + 'author_title' => 'General Manager', ], 'digital_core' => [ 'title' => 'Die Technologie, die diesen Kreislauf ermöglicht', @@ -478,76 +961,87 @@ return [ 'icon' => 'users', 'icon_style' => 'solid', ], - ] + ], ], 'magazin_detail' => [ 'back_to_magazine' => 'Zurück zum Magazin', 'share_article' => 'Artikel teilen', - 'cta_title' => 'Entdecken Sie mehr über Luxus und Komfort für unvergessliche Erlebnisse.', - 'cta_button' => 'Weitere Artikel entdecken' + 'cta_title' => 'Entdecken Sie mehr über Immobilien-Investments und das B2in-Ökosystem.', + 'cta_button' => 'Weitere Artikel entdecken', ], 'magazin_list' => [ 'title' => 'B2in Magazin', - 'subtitle' => 'Entdecken Sie die neuesten Trends, Insights und Geschichten aus der Welt der Business-Konnektivität und Innovation.', + 'subtitle' => 'Insights, Marktanalysen und Praxiswissen rund um Immobilien-Investments in Dubai, Einrichtungskonzepte und das B2in-Ökosystem.', 'read_more' => 'Weiterlesen', - 'load_more' => 'Weitere Artikel laden' + 'load_more' => 'Weitere Artikel laden', ], 'our_story' => [ 'title' => 'Unsere Geschichte', 'timeline' => [ [ 'title' => 'Die Idee', - 'description' => '2024 erkannten wir eine entscheidende Lücke im Möbelmarkt: Während Online-Riesen wachsen, kämpft der lokale Fachhandel um seine digitale Sichtbarkeit. Gleichzeitig suchen Kunden nach kuratierter Qualität und persönlichem Service.', + 'description' => '2024 erkannten wir eine entscheidende Lücke: Der lokale Fachhandel braucht digitale Sichtbarkeit und Immobilienkäufer suchen nach nahtlosen Einrichtungslösungen. Diese beiden Welten gehören zusammen.', 'icon' => 'light-bulb', ], [ - 'title' => 'Die Mission', - 'description' => 'Wir entwickeln eine Plattform, die das Beste aus beiden Welten vereint: die Stärke des lokalen Handels und die Vielfalt des europäischen Designs. Unser Ziel ist es, faire, regionale Ökosysteme zu schaffen, in denen Technologie dem Menschen dient.', + 'title' => 'Das Fundament', + 'description' => 'Wir entwickeln die B2in-Plattform: ein Ökosystem, das lokale Einrichtungsexperten, europäische Hersteller und Immobilienprofis auf einer gemeinsamen Plattform verbindet – fair, transparent und technologiegestützt.', 'icon' => 'rocket-launch', ], [ - 'title' => 'Die Zukunft', - 'description' => 'Heute bauen wir ein wachsendes Netzwerk regionaler Hubs auf. Unsere Vision ist es, in jeder größeren Region Europas der führende digitale Partner für den lokalen Möbel- und Designhandel zu werden.', + 'title' => 'Die Erweiterung', + 'description' => '2025/2026 erweitert B2in sein Ökosystem: Internationale Immobilien und Supply-Chain-Management für Entwickler werden zur dominanten Säule. Der lokale Möbelmarktplatz bleibt als starker ergänzender Bereich bestehen.', 'icon' => 'globe-alt', ], + [ + 'title' => 'Die Vision', + 'description' => 'B2in wird zum zentralen Netzwerk für "Design & Property" – regional verwurzelt, international vernetzt. In jeder Region der verlässliche Partner für Immobilien-Investments und exklusive Einrichtungskonzepte.', + 'icon' => 'star', + ], ], - 'summary' => 'Was als Vision begann, den lokalen Handel zu stärken, ist heute die zentrale B2B-Plattform für den kuratierten Möbelhandel. Wir schließen die Lücke zwischen Online-Nachfrage und Offline-Expertise und schaffen so nachhaltiges Wachstum für unsere Partner.' + 'summary' => 'Was als Vision begann, den lokalen Handel zu stärken, ist heute ein Netzwerk, das Immobilien und Einrichtung nahtlos verbindet. B2in schließt die Lücke zwischen internationalen Investments und lokaler Expertise – mit Marcel Scheibe als Gesicht und Ansprechpartner.', + ], + 'about_image_break' => [ + 'image' => 'b2in/best-of-two-worlds.jpg', + 'image_alt' => 'B2in – Immobilien und Einrichtung verbinden', + 'quote' => 'Regional verwurzelt, international vernetzt – das ist B2in.', + 'author' => 'Marcel Scheibe', ], 'our_values' => [ 'title' => 'Unsere Werte', - 'subtitle' => 'Diese fünf Grundpfeiler leiten unser tägliches Handeln und definieren, wer wir als Unternehmen sind und wofür wir stehen.', + 'subtitle' => 'Diese sechs Grundpfeiler leiten unser tägliches Handeln und definieren, wer wir als Unternehmen sind und wofür wir stehen.', 'values' => [ [ 'title' => 'Innovation', - 'description' => 'Wir entwickeln digitale Lösungen, die dem lokalen Möbelhandel einen echten Wettbewerbsvorteil in einer sich schnell verändernden Welt verschaffen.', + 'description' => 'Wir entwickeln digitale Lösungen, die Immobilienprofis und lokale Einrichtungsexperten gleichermaßen einen echten Wettbewerbsvorteil verschaffen.', 'icon' => 'light-bulb', ], [ 'title' => 'Konnektivität', - 'description' => 'Wir verbinden nicht nur Systeme – wir verbinden den Online-Kunden wieder mit dem Fachexperten in seiner Stadt und europäische Manufakturen mit neuen Märkten.', + 'description' => 'Wir verbinden internationale Immobilienmärkte mit lokaler Expertise und europäische Manufakturen mit den Menschen, die ihre Produkte schätzen.', 'icon' => 'globe-alt', ], [ 'title' => 'Qualität', - 'description' => 'Wir setzen kompromisslose Standards – bei der Auswahl unserer Partner, der Kuration der Möbel und der Technologie, die alles zusammenhält.', + 'description' => 'Kompromisslose Standards – bei der Auswahl unserer Partner, der Kuration der Einrichtung, der Überwachung von Lieferketten und der Technologie, die alles zusammenhält.', 'icon' => 'check-badge', ], [ 'title' => 'Vertrauen', - 'description' => 'Transparente Provisionsmodelle und verlässliche Partnerschaften sind das Fundament unseres Ökosystems. Wir wachsen nur, wenn unsere Partner wachsen.', + 'description' => 'Transparente Modelle und verlässliche Partnerschaften sind unser Fundament. Hinter B2in steht ein Gesicht – und das Versprechen, dass wir liefern, was wir zusagen.', 'icon' => 'user-group', ], [ 'title' => 'Nachhaltigkeit', - 'description' => 'Wir übernehmen Verantwortung, indem wir durch unsere Bündel-Logistik Transportwege optimieren und den lokalen Handel stärken, um lebendige Innenstädte zu erhalten.', + 'description' => 'Wir stärken den lokalen Handel, optimieren Transportwege und sorgen dafür, dass internationale Beschaffung verantwortungsvoll und effizient abläuft.', 'icon' => 'arrow-path', ], [ 'title' => 'Design-Exzellenz', - 'description' => 'Design ist der Kern unserer Wertschöpfung – von der kuratierten Auswahl an Möbeln bis zur intuitiven Gestaltung unserer digitalen Plattform.', + 'description' => 'Design ist der Kern unserer Wertschöpfung – von internationaler Architektur über kuratierte Einrichtung bis zur intuitiven Gestaltung unserer digitalen Plattform.', 'icon' => 'cube-transparent', ], - ] + ], ], 'partner_benefits' => [ 'title' => 'Warum Partner werden?', @@ -559,23 +1053,23 @@ return [ [ 'icon' => 'trending-up', 'title' => 'Lifetime-Provisionsmodell', - 'description' => 'Profitieren Sie von kontinuierlichen Einnahmen durch unser innovatives Vergütungssystem' + 'description' => 'Profitieren Sie von kontinuierlichen Einnahmen durch unser innovatives Vergütungssystem', ], [ 'icon' => 'target', 'title' => 'Schnellere Vermarktung', - 'description' => 'Durchdachte Wohnkonzepte verkürzen Vermarktungszeiten und erhöhen Ihre Erfolgsquote' + 'description' => 'Durchdachte Wohnkonzepte verkürzen Vermarktungszeiten und erhöhen Ihre Erfolgsquote', ], [ 'icon' => 'award', 'title' => 'Mehrwert für Ihre Kunden', - 'description' => 'Bieten Sie Ihren Kunden exklusive, kuratierte Immobilienerlebnisse' - ] + 'description' => 'Bieten Sie Ihren Kunden exklusive, kuratierte Immobilienerlebnisse', + ], ], 'highlight' => [ 'value' => '3.5% - ∞', - 'text' => 'Erstprovision bis Lifetime-Vergütung' - ] + 'text' => 'Erstprovision bis Lifetime-Vergütung', + ], ], 'supplier' => [ 'tag' => 'Für Lieferanten', @@ -584,30 +1078,53 @@ return [ [ 'icon' => 'globe', 'title' => 'Zugang zu internationalen Märkten', - 'description' => 'Erweitern Sie Ihre Reichweite über Grenzen hinweg mit unserem globalen Netzwerk' + 'description' => 'Erweitern Sie Ihre Reichweite über Grenzen hinweg mit unserem globalen Netzwerk', ], [ 'icon' => 'handshake', 'title' => 'Faire Konditionen', - 'description' => 'Transparente und partnerschaftliche Geschäftsbedingungen für nachhaltigen Erfolg' + 'description' => 'Transparente und partnerschaftliche Geschäftsbedingungen für nachhaltigen Erfolg', ], [ 'icon' => 'settings', 'title' => 'Einfache Produktverwaltung', - 'description' => 'Intuitive Plattform für die Verwaltung und Präsentation Ihrer Produkte' - ] + 'description' => 'Intuitive Plattform für die Verwaltung und Präsentation Ihrer Produkte', + ], ], 'highlight' => [ 'image' => 'b2in/accommodation-1.jpg', 'alt' => 'Partner success visualization', 'value' => '500+', - 'text' => 'Erfolgreiche Partner' - ] - ] + 'text' => 'Erfolgreiche Partner', + ], + ], + ], + 'supply_chain_intro' => [ + 'title' => 'Supply-Chain-Management: Ihr verlängerter Arm in Deutschland.', + 'paragraphs' => [ + 'Für Immobilienentwickler, die Möbel oder Innenausstattung aus Deutschland beziehen möchten, fungieren wir als verlängerter Arm vor Ort – mit klarem Fokus auf Vertragssicherheit, Termintreue und Durchsetzungskraft.', + ], + 'list' => [ + [ + 'icon' => 'document-check', + 'title' => 'Vertragsmanagement – Ausarbeitung, Strukturierung und Absicherung von Lieferverträgen', + ], + [ + 'icon' => 'shield-check', + 'title' => 'Vertragssicherung & Durchsetzung – Meilenstein-Überwachung, Eskalation, Nachverfolgung', + ], + [ + 'icon' => 'magnifying-glass-circle', + 'title' => 'Tracking & Qualitätskontrolle – Laufende Überwachung, persönliche Kontrolle, termingerechte Lieferung', + ], + ], + 'image' => 'b2in/marcel-scheibe-about.jpg', + 'image_alt' => 'Marcel Scheibe – Supply-Chain-Management', + 'image_caption' => 'Marcel Scheibe, Gründer & CEO', ], 'partner_cta' => [ 'title' => 'Wachsen Sie mit uns', - 'subtitle' => 'Werden Sie Teil des B2in-Partnernetzwerks und erschließen Sie neue Geschäftsmöglichkeiten durch innovative Konnektivitätslösungen.', + 'subtitle' => 'Werden Sie Teil des B2in-Netzwerks – ob als Immobilienentwickler, Einrichtungsexperte, Hersteller oder Makler. Wir verbinden die Welten, die zusammengehören.', 'stats' => [ [ 'number' => '500+', @@ -624,12 +1141,17 @@ return [ ], 'button_text' => 'Werden Sie B2in Partner', 'button_link' => '/contact', - 'small_text' => 'Entdecken Sie die Vorteile einer strategischen Partnerschaft mit B2in' + 'small_text' => 'Entdecken Sie die Vorteile einer strategischen Partnerschaft mit B2in', ], 'partner_hero' => [ - 'title' => 'Das Ökosystem für Ihren Erfolg.', - 'subtitle' => 'Ob lokaler Möbel-Händler, europäische Marke oder Immobilienprofi – B2in ist die Plattform, die Ihr Geschäft mit designaffinen Kunden verbindet und neue Ertragsquellen erschließt.', + 'title' => 'Für Entwickler & Partner', + 'subtitle' => 'Ob Immobilienentwickler, lokaler Einrichtungsexperte, europäische Marke oder Makler – B2in ist das Netzwerk, das Ihr Geschäft mit den richtigen Partnern und Kunden verbindet.', 'partner_types' => [ + [ + 'title' => 'Immobilienentwickler', + 'description' => 'Supply-Chain-Management aus Deutschland', + 'icon' => 'globe-alt', + ], [ 'title' => 'Hersteller & Marken', 'description' => 'Kuratierter Marktzugang, intelligente Logistik', @@ -645,11 +1167,6 @@ return [ 'description' => 'Mehrwert für Kunden, Zusatzertrag für Sie', 'icon' => 'home-modern', ], - [ - 'title' => 'Unser Netzwerk', - 'description' => 'Gemeinsam wachsen, den Markt gestalten', - 'icon' => 'sparkles', - ], ], 'image' => 'b2in/partner-hero.jpg', 'image_alt' => 'Partner Hero Image', @@ -657,19 +1174,26 @@ return [ 'card_text' => 'Werden Sie Teil unseres Ecosystems', 'hub' => [ 'title' => 'Partner Network', - 'subtitle' => 'Werden Sie Teil unseres Ecosystems' + 'subtitle' => 'Werden Sie Teil unseres Ecosystems', ], 'connection_points' => [ ['name' => 'Makler', 'subtext' => 'Lifetime-Modell'], ['name' => 'Lieferanten', 'subtext' => 'Global Markets'], ['name' => 'Erfolg', 'subtext' => 'Messbare Ziele'], - ['name' => 'Qualität', 'subtext' => 'Premium Standards'] - ] + ['name' => 'Qualität', 'subtext' => 'Premium Standards'], + ], ], 'partner_card_section' => [ 'title' => 'Welcher Partner-Typ sind Sie?', - 'subtitle' => 'Entdecken Sie die Vorteile einer Partnerschaft mit B2in und wie Sie von unserem innovativen Ecosystem profitieren können.', + 'subtitle' => 'Entdecken Sie die Vorteile einer Partnerschaft mit B2in – ob im Bereich Immobilien, Einrichtung oder Supply Chain.', 'cards' => [ + [ + 'title' => 'Für Immobilienentwickler', + 'description' => 'Operative Steuerung Ihrer Beschaffung aus Deutschland – Vertragsmanagement, Qualitätskontrolle und termingerechte Lieferung.', + 'icon' => 'globe-alt', + 'button' => '#partner-benefits-developer', + 'button_text' => 'Ihre Vorteile als Entwickler', + ], [ 'title' => 'Für lokale Händler & Fachexperten', 'description' => 'Stärken Sie Ihr Geschäft vor Ort. Erhalten Sie Zugang zu Online-Kunden und einem exklusiven, überregionalen Sortiment.', @@ -686,12 +1210,46 @@ return [ ], [ 'title' => 'Für Immobilienmakler & Bauträger', - 'description' => 'Bieten Sie Ihren Kunden einen einzigartigen Mehrwert, beschleunigen Sie die Vermarktung Ihrer Objekte und sichern Sie sich attraktive Zusatzprovisionen.', + 'description' => 'Bieten Sie Ihren Kunden einen einzigartigen Mehrwert, beschleunigen Sie die Vermarktung und sichern Sie sich attraktive Zusatzprovisionen.', 'icon' => 'home-modern', 'button' => '#partner-benefits-broker', 'button_text' => 'Ihre Vorteile als Makler', ], - ] + ], + ], + 'partner_benefits_developer' => [ + 'id' => 'partner-benefits-developer', + 'tag' => 'Ihre Vorteile als Immobilienentwickler', + 'tag_icon' => 'globe-alt', + 'tag_title' => 'Ihr verlängerter Arm in Deutschland.', + 'features' => [ + [ + 'title' => 'Vertragsmanagement', + 'description' => 'Unterstützung bei der Ausarbeitung und Strukturierung von Lieferverträgen. Definition klarer Leistungs- und Qualitätsparameter, Absicherung von Zahlungs- und Lieferbedingungen.', + 'icon' => 'document-check', + ], + [ + 'title' => 'Vertragssicherung & Durchsetzung', + 'description' => 'Aktive Überwachung der vereinbarten Meilensteine, Eskalation auf Managementebene bei Abweichungen und konsequente Nachverfolgung offener Punkte.', + 'icon' => 'shield-check', + ], + [ + 'title' => 'Tracking & Qualitätskontrolle', + 'description' => 'Laufende Produktions- und Lieferüberwachung, persönliche Kontrolle bei Bedarf und Sicherstellung termingerechter Auslieferung.', + 'icon' => 'magnifying-glass-circle', + ], + [ + 'title' => 'Netzwerk & Marktkenntnis', + 'description' => 'Direkte Anbindung an Hersteller und Entscheider in Deutschland. Wir sorgen dafür, dass Vereinbarungen nicht nur auf dem Papier bestehen, sondern tatsächlich umgesetzt werden.', + 'icon' => 'link', + ], + ], + 'highlight' => [ + 'value' => '100%', + 'text' => 'Transparenz, Verlässlichkeit und planbare Lieferung – ohne operative Reibungsverluste', + ], + 'image' => 'b2in/partner-benefits-developer.jpg', + 'image_alt' => 'Supply-Chain-Management für Immobilienentwickler', ], 'partner_benefits_retailer' => [ 'id' => 'partner-benefits-retailer', @@ -722,7 +1280,7 @@ return [ ], 'highlight' => [ 'value' => '+35%', - 'text' => 'Digitale Reichweite im ersten Jahr (Durchschnitt unserer Partner)' + 'text' => 'Digitale Reichweite im ersten Jahr (Durchschnitt unserer Partner)', ], 'image' => 'b2in/partner-benefits-retailer.jpg', 'image_alt' => 'Partner Benefits Retailer', @@ -756,7 +1314,7 @@ return [ ], 'highlight' => [ 'value' => '> 20', - 'text' => 'Kuratierte regionale Hubs als neue Vertriebskanäle in Europa' + 'text' => 'Kuratierte regionale Hubs als neue Vertriebskanäle in Europa', ], 'image' => 'b2in/partner-benefits-supplier.jpg', 'image_alt' => 'Partner Benefits Supplier', @@ -787,11 +1345,11 @@ return [ 'title' => 'Einfaches Handling', 'description' => 'Unser digitales Makler-Portal macht es Ihnen leicht: Kunden einladen, Aktivitäten verfolgen und Provisionen transparent einsehen.', 'icon' => 'finger-print', - ] + ], ], 'highlight' => [ 'value' => '-25%', - 'text' => 'Kürzere Vermarktungszeit für mit B2in inszenierte Objekte' + 'text' => 'Kürzere Vermarktungszeit für mit B2in inszenierte Objekte', ], 'image' => 'b2in/partner-benefits-broker.jpg', 'image_alt' => 'Partner Benefits Broker', @@ -805,33 +1363,33 @@ return [ 'title' => 'Bewerben', 'description' => 'Erzählen Sie uns Ihre Geschichte. Füllen Sie unser kurzes Kontaktformular aus und zeigen Sie uns, was Ihre Produkte oder Ihr Geschäft auszeichnet.', 'icon' => 'envelope', - 'image' => 'b2in/room-1.jpg' + 'image' => 'b2in/room-1.jpg', ], [ 'step' => '2', 'title' => 'Prüfung', 'description' => 'Wir prüfen jede Anfrage persönlich. Unser Ziel ist es, ein hochwertiges und komplementäres Netzwerk aufzubauen, von dem alle profitieren.', 'icon' => 'check-circle', - 'image' => 'b2in/room-2.jpg' + 'image' => 'b2in/room-2.jpg', ], [ 'step' => '3', 'title' => 'Onboarding', 'description' => 'Willkommen an Bord! Wir schulen Sie persönlich im Umgang mit unserem Partner-Portal und stellen sicher, dass Sie vom ersten Tag an erfolgreich sind.', 'icon' => 'rocket-launch', - 'image' => 'b2in/room-3.jpg' - ] + 'image' => 'b2in/room-3.jpg', + ], ], 'cta' => [ 'button_text' => ' Zum Partner-Portal', - 'button_link' => '/contact' + 'button_link' => '/contact', ], 'cta' => [ 'title' => 'Bereit für den nächsten Schritt?', 'subtitle' => 'Werden Sie noch heute Teil des B2in Ecosystems und profitieren Sie von innovativen Geschäftsmodellen und nachhaltigen Erfolgsstrategien.', 'button_text' => 'Jetzt Partner werden', - 'button_link' => '/contact' - ] + 'button_link' => '/contact', + ], ], 'supplier_section' => [ @@ -865,14 +1423,14 @@ return [ 'stats' => [ [ 'label' => 'Produktsichtbarkeit', - 'value' => '94%' + 'value' => '94%', ], [ 'label' => 'Qualitätsbewertung', - 'value' => '98%' - ] - ] - ] + 'value' => '98%', + ], + ], + ], ], 'leadership_team' => [ 'title' => 'Das Führungsteam', @@ -897,7 +1455,55 @@ return [ 'expertise' => 'Technologieführer mit Fokus auf eine intuitive, skalierbare Plattform-Architektur, die Händler und Hersteller nahtlos verbindet.', 'image' => 'b2in/thomas-weber.jpg', ], - ] + ], + ], + 'portfolio' => [ + 'filters' => [ + 'alle' => 'Alle', + 'villen' => 'Villen', + 'penthouse' => 'Penthouse', + 'loft' => 'Loft', + ], + 'title' => 'Ausgewählte Immobilien', + 'subtitle' => 'Ein Einblick in exklusive Objekte in Dubai.', + 'projects' => [ + [ + 'id' => 1, + 'title' => 'Palm Jumeirah Villa', + 'subtitle' => 'Beachfront', + 'description' => 'Großzügige Villa mit privatem Strandzugang und Panoramablick.', + 'category' => 'Villen', + 'image' => '/b2in/room-1.jpg', + 'location' => 'Dubai', + 'price' => '', + 'size' => '', + 'features' => [ + 'Privatstrand', + 'Smart Home', + ], + ], + [ + 'id' => 2, + 'title' => 'Downtown Penthouse', + 'subtitle' => 'Skyline Views', + 'description' => 'Penthouse mit Floor-to-Ceiling-Fenstern und Blick auf Burj Khalifa.', + 'category' => 'Penthouse', + 'image' => '/b2in/room-2.jpg', + 'location' => 'Dubai', + 'price' => '', + 'size' => '', + 'features' => [ + 'Concierge', + 'Wellness', + ], + ], + ], + ], + 'cta_section_portfolio' => [ + 'title' => 'Bereit für den nächsten Schritt?', + 'subtitle' => 'Kontaktieren Sie uns für eine persönliche Beratung zu ausgewählten Objekten.', + 'button_text' => 'Kontakt aufnehmen', + 'button_link' => '/contact', ], // Weitere Komponenten für b2in ], @@ -910,7 +1516,7 @@ return [ ['label' => 'Our Brands', 'url' => '/portfolio'], ['label' => 'FAQ', 'url' => '/faq'], ['label' => 'Contact', 'url' => '/contact'], - ] + ], ], 'hero_slider' => [ 'title' => 'Connecting European Design &
American Markets
', @@ -923,7 +1529,7 @@ return [ 'stats' => [ 'Curated Collections', 'Reliable Logistics', - 'Exclusive Access' + 'Exclusive Access', ], 'slides' => [ [ @@ -933,18 +1539,18 @@ return [ [ 'image' => 'b2a/hero-slider-2.jpg', 'image_alt' => 'Efficient and clean logistics warehouse with neatly packed furniture', - ] + ], ], ], 'vision_section' => [ 'title' => 'The B2A Difference.', 'paragraphs' => [ 'International sourcing is complex. We make it simple.', - 'B2A acts as your strategic partner and decision-making coach, removing the complexities of transatlantic trade so you can focus on what you do best: selling exceptional furniture.' + 'B2A acts as your strategic partner and decision-making coach, removing the complexities of transatlantic trade so you can focus on what you do best: selling exceptional furniture.', ], 'image' => 'b2a/vision.jpg', 'image_alt' => 'Professionals shaking hands over architectural blueprints', - 'image_caption' => 'Your Bridge to European Design' + 'image_caption' => 'Your Bridge to European Design', ], 'ecosystem_core' => [ 'title' => 'A Partnership That Delivers.', @@ -965,38 +1571,38 @@ return [ 'title' => 'Dedicated Support', 'description' => 'Our experts are your single point of contact, ensuring smooth communication and reliable service.', ], - ] + ], ], 'about_philosophie' => [ 'title' => 'Our Expertise is Your Advantage.', 'paragraphs' => [ 'B2A was founded to bridge the gap between Europe\'s finest design manufacturers and the dynamic US market.', 'Our team consists of seasoned experts in international logistics, interior design curation, and B2B market strategy.', - 'We don\'t just move boxes; we build lasting, profitable partnerships based on trust and a deep understanding of the design industry.' + 'We don\'t just move boxes; we build lasting, profitable partnerships based on trust and a deep understanding of the design industry.', ], 'image' => 'b2a/portrait.jpg', 'image_alt' => 'The professional B2A team in a modern office meeting', - 'image_caption' => 'The B2A Leadership Team' + 'image_caption' => 'The B2A Leadership Team', ], 'content_section_left' => [ 'title' => 'Curation & Selection', 'paragraphs' => [ 'It all starts with our online catalog. As an approved partner, you will have access to our constantly updated portfolio of curated European furniture brands on our portal.', - 'We provide you with all the necessary product information, prices, and marketing materials so that you can make informed purchasing decisions for your customers.' + 'We provide you with all the necessary product information, prices, and marketing materials so that you can make informed purchasing decisions for your customers.', ], 'image' => 'b2a/content-left.jpg', 'image_alt' => 'A designer reviewing a catalog of high-end furniture', - 'image_caption' => 'Access to Excellence' + 'image_caption' => 'Access to Excellence', ], 'content_section_right' => [ 'title' => 'Consolidation & Shipping', 'paragraphs' => [ 'Once you place an order, our logistics team takes over. We consolidate products from various European suppliers at our central hub.', - 'This approach significantly reduces shipping costs and complexity for you. We manage all export documentation and ensure your furniture order is efficiently prepared for transatlantic shipping.' + 'This approach significantly reduces shipping costs and complexity for you. We manage all export documentation and ensure your furniture order is efficiently prepared for transatlantic shipping.', ], 'image' => 'b2a/content-right.jpg', 'image_alt' => 'Neatly organized warehouse showing furniture ready for shipping', - 'image_caption' => 'Efficiency in Motion' + 'image_caption' => 'Efficiency in Motion', ], 'brand_worlds' => [ 'title' => 'Explore Our Curated Categories', @@ -1026,7 +1632,7 @@ return [ 'title' => 'Elevate Your Portfolio with
European Design.', 'subtitle' => 'Apply to become a B2A partner today and gain exclusive access to a world of exceptional furniture.', 'button_text' => 'Become a Partner', - 'button_link' => '/b2a/contact' + 'button_link' => '/b2a/contact', ], 'hero_image' => [ 'title' => 'Our Seamless
Transatlantic Process', @@ -1036,7 +1642,7 @@ return [ 'stats' => [ 'Sourcing & Curation', 'Consolidation & Shipping', - 'Customs & Delivery' + 'Customs & Delivery', ], ], 'portfolio' => [ @@ -1064,8 +1670,8 @@ return [ 'Solid Oak Construction', 'Hand-finished Details', 'High-Density Foam', - 'Multiple Color Options' - ] + 'Multiple Color Options', + ], ], [ 'id' => 2, @@ -1082,8 +1688,8 @@ return [ 'Brushed Brass Finish', 'Adjustable Height', 'LED Compatible', - 'Minimalist Design' - ] + 'Minimalist Design', + ], ], [ 'id' => 3, @@ -1100,16 +1706,16 @@ return [ 'Powder-Coated Steel Base', 'Seats 8 Persons', 'Durable & Stain-Resistant', - 'Architectural Presence' - ] - ] - ] + 'Architectural Presence', + ], + ], + ], ], 'cta_section_portfolio' => [ 'title' => 'Ready to Differentiate
Your Offerings?', 'subtitle' => 'Request our full digital catalog to explore all available collections and brands.', 'button_text' => 'Request to Portal Access', - 'button_link' => '/b2a/contact' + 'button_link' => '/b2a/contact', ], 'commitment_section' => [ 'title' => 'Excellence, Confirmed by Our Partners', @@ -1120,23 +1726,23 @@ return [ 'rating' => 5, 'quote' => 'B2A has transformed our sourcing. Their logistics are seamless, and the product quality gives us a real edge in the market.', 'author' => 'John D., Boston Dealer', - 'author_title' => 'Owner, Urban Living Co.' + 'author_title' => 'Owner, Urban Living Co.', ], [ 'image' => 'b2a/testimonial-2.jpg', 'rating' => 5, 'quote' => 'Access to this level of curated European design was a game-changer for my interior design projects. B2A is an invaluable partner.', 'author' => 'Maria R., Miami Designer', - 'author_title' => 'Founder, Roche Designs' + 'author_title' => 'Founder, Roche Designs', ], [ 'image' => 'b2a/testimonial-3.jpg', 'rating' => 5, 'quote' => 'Their professionalism and clear communication make international trade feel effortless. Highly recommended.', 'author' => 'David L., Chicago Architect', - 'author_title' => 'Principal, Lakefront Architects' - ] - ] + 'author_title' => 'Principal, Lakefront Architects', + ], + ], ], 'ecosystem_stats' => [ 'title' => 'The B2A Advantage in Numbers', @@ -1162,7 +1768,7 @@ return [ 'label' => 'Single Point of Contact', 'description' => 'One dedicated partner for all your needs, from ordering and logistics to support.', ], - ] + ], ], 'faq' => [ 'title' => 'Frequently Asked Questions', @@ -1170,53 +1776,53 @@ return [ 'questions' => [ [ 'question' => 'Who are your European partner brands?', - 'answer' => 'We partner with a curated selection of over 20 high-quality manufacturers from Italy, Germany, Scandinavia, and other European design hubs. Our full brand list is available to approved partners upon request.' + 'answer' => 'We partner with a curated selection of over 20 high-quality manufacturers from Italy, Germany, Scandinavia, and other European design hubs. Our full brand list is available to approved partners upon request.', ], [ 'question' => 'What are the minimum order quantities (MOQs)?', - 'answer' => 'MOQs vary by brand and product. However, our consolidation model allows you to combine smaller orders from different brands into one cost-effective shipment, making European design more accessible.' + 'answer' => 'MOQs vary by brand and product. However, our consolidation model allows you to combine smaller orders from different brands into one cost-effective shipment, making European design more accessible.', ], [ 'question' => 'How does B2A handle shipping and customs?', - 'answer' => 'We handle everything. Our service includes export documentation, transatlantic shipping, US customs clearance, and last-mile delivery to your warehouse or showroom. It\'s a true door-to-door service.' + 'answer' => 'We handle everything. Our service includes export documentation, transatlantic shipping, US customs clearance, and last-mile delivery to your warehouse or showroom. It\'s a true door-to-door service.', ], [ 'question' => 'What are the payment terms for partners?', - 'answer' => 'Our standard payment terms are outlined in our partnership agreement. We typically require a deposit upon order confirmation with the balance due before final delivery. We strive to offer competitive and fair terms.' + 'answer' => 'Our standard payment terms are outlined in our partnership agreement. We typically require a deposit upon order confirmation with the balance due before final delivery. We strive to offer competitive and fair terms.', ], [ 'question' => 'How do I become a B2A partner?', - 'answer' => 'The first step is to fill out our contact form with your business details. Our partnership team will review your application and get in touch to discuss the next steps, including our partnership agreement and catalog access.' - ] - ] + 'answer' => 'The first step is to fill out our contact form with your business details. Our partnership team will review your application and get in touch to discuss the next steps, including our partnership agreement and catalog access.', + ], + ], ], 'contact_form' => [ 'hero' => [ 'title' => 'Become a Partner', - 'subtitle' => 'Fill out the form below to start the conversation. We look forward to learning more about your business.' + 'subtitle' => 'Fill out the form below to start the conversation. We look forward to learning more about your business.', ], 'form' => [ 'labels' => [ - 'first_name' => 'First Name *', - 'last_name' => 'Last Name *', - 'company' => 'Company Name *', - 'email' => 'Business Email *', - 'phone' => 'Phone Number', - 'subject' => 'Subject *', - 'message' => 'Tell us about your business *' + 'first_name' => 'Vorname *', + 'last_name' => 'Nachname *', + 'company' => 'Firma (optional)', + 'email' => 'E-Mail *', + 'phone' => 'Telefon (optional)', + 'subject' => 'Betreff *', + 'message' => 'Ihre Nachricht *', ], 'subjects' => [ '' => 'Select a Subject', - 'partnership' => 'Partnership Inquiry', - 'catalog' => 'Product Catalog Request', - 'logistics' => 'Logistics Question', + 'partnership' => 'Partnerschafts-Anfrage', + 'catalog' => 'Produkt-Katalog-Anfrage', + 'logistics' => 'Logistik-Frage', ], 'placeholders' => [ - 'message' => 'e.g., your type of business, main customer base, brands of interest...' + 'message' => 'e.g., your type of business, main customer base, brands of interest...', ], 'button_text' => 'Submit Inquiry', 'button_loading' => 'Submitting...', - 'success_message' => 'Thank you for your inquiry! Our partnership team will be in touch with you shortly.' + 'success_message' => 'Thank you for your inquiry! Our partnership team will be in touch with you shortly.', ], 'contact_info' => [ [ @@ -1247,8 +1853,8 @@ return [ 'subtitle' => 'Stay updated with European design trends and B2B news.', 'platforms' => [ ['name' => 'LinkedIn', 'handle' => 'B2in Company Page', 'url' => 'https://linkedin.com/company/b2in'], - ] - ] + ], + ], ], ], 'stileigentum' => [ @@ -1260,7 +1866,7 @@ return [ ['label' => 'Portfolio', 'url' => '/portfolio'], ['label' => 'FAQ', 'url' => '/faq'], ['label' => 'Contact', 'url' => '/contact'], - ] + ], ], 'hero_slider' => [ 'title' => 'Connecting Design &
Timeless Luxury
', @@ -1273,7 +1879,7 @@ return [ 'stats' => [ 'Exklusive Auswahl', 'Persönlicher Service', - 'Werte die bleiben' + 'Werte die bleiben', ], 'slides' => [ [ @@ -1283,21 +1889,21 @@ return [ [ 'image' => 'stileigentum/hero-slider-2.jpg', 'image_alt' => 'Moderne Architektur mit stilvollem Design', - ] + ], ], 'card_title' => 'B2in Ecosystem', - 'card_text' => 'Global vernetzt' + 'card_text' => 'Global vernetzt', ], 'vision_section' => [ 'title' => 'Eine Investition in Eleganz und Wert', 'paragraphs' => [ 'Bei stileigentum geht es um mehr als nur Einrichtung', 'Es geht darum, das Wesen einer Premium-Immobilie zu erfassen und durch zeitloses Design und höchste Handwerkskunst ihren Wert zu steigern.', - 'Wir schaffen repräsentative Ambiente, die nicht nur heute beeindrucken, sondern auch morgen Bestand haben.' + 'Wir schaffen repräsentative Ambiente, die nicht nur heute beeindrucken, sondern auch morgen Bestand haben.', ], 'image' => 'stileigentum/vision.jpg', 'image_alt' => 'Luxuriöses Anwesen mit Pool', - 'image_caption' => 'Stileigentum – Timeless Luxury' + 'image_caption' => 'Stileigentum – Timeless Luxury', ], 'ecosystem_core' => [ 'title' => 'Unsere Service-Exzellenz', @@ -1318,17 +1924,17 @@ return [ 'title' => 'Material & Art Curation', 'description' => 'Auswahl feinster Materialien und Kunstobjekte.', ], - ] + ], ], 'about_philosophie' => [ 'title' => 'Unsere Hilosophie', 'paragraphs' => [ 'Wahrer Luxus liegt nicht im Besitz, sondern im perfekten Einklang von Raum, Persönlichkeit und Qualität.', - 'Unsere Mission ist es, diesen Einklang zu schaffen – mit Diskretion, Leidenschaft und einem kompromisslosen Auge fürs Detail.' + 'Unsere Mission ist es, diesen Einklang zu schaffen – mit Diskretion, Leidenschaft und einem kompromisslosen Auge fürs Detail.', ], 'image' => 'stileigentum/portrait.jpg', 'image_alt' => 'Moderne Arbeitsplätze mit innovativer Technologie', - 'image_caption' => 'Marcel Scheibe, Gründer & CEO' + 'image_caption' => 'Marcel Scheibe, Gründer & CEO', ], 'content_section_left' => [ 'title' => 'Die Kunst der perfekten Inszenierung', @@ -1338,17 +1944,17 @@ return [ ], 'image' => 'stileigentum/content-left.jpg', 'image_alt' => 'Moderne Arbeitsplätze mit innovativer Technologie', - 'image_caption' => 'Ambiente schafft Werte' + 'image_caption' => 'Ambiente schafft Werte', ], 'content_section_right' => [ 'title' => 'Ihr Zuhause, ein Unikat', 'paragraphs' => [ 'Wir entwickeln ganzheitliche Einrichtungskonzepte, die Ihre Persönlichkeit und Ihren Lebensstil widerspiegeln. Von der ersten Skizze bis zum letzten Detail begleiten wir Sie auf dem Weg zu einem Zuhause, das in Ästhetik, Funktion und Qualität keine Kompromisse eingeht.', - 'Unser Prozess umfasst: Bedarfsanalyse, Konzeptentwicklung, Materialauswahl, Koordination der Gewerke und finale Umsetzung.' + 'Unser Prozess umfasst: Bedarfsanalyse, Konzeptentwicklung, Materialauswahl, Koordination der Gewerke und finale Umsetzung.', ], 'image' => 'stileigentum/content-right.jpg', 'image_alt' => 'Kreative Arbeitsumgebung mit modernem Design', - 'image_caption' => 'Kreativität trifft Funktionalität' + 'image_caption' => 'Kreativität trifft Funktionalität', ], 'brand_worlds' => [ 'title' => 'Ein Einblick in unsere Arbeit', @@ -1379,7 +1985,7 @@ return [ 'title' => 'Beginnen Sie Ihre Reise zu einem
perfekten Zuhause.', 'subtitle' => 'Lassen Sie sich von unserem exklusiven Portfolio inspirieren und vereinbaren Sie eine vertrauliche und unverbindliche Erstberatung.', 'button_text' => 'Beratungstermin vereinbaren', - 'button_link' => '/contact' + 'button_link' => '/contact', ], 'hero_image' => [ 'title' => 'Maßgeschneiderte Exzellenz', @@ -1390,7 +1996,7 @@ return [ 'stats' => [ 'Exklusive Auswahl', 'Persönlicher Service', - 'Werte die bleiben' + 'Werte die bleiben', ], ], @@ -1413,8 +2019,8 @@ return [ 'Maßanfertigungen', 'Premium Textilien', 'Kunstobjekte', - 'Smart Home Integration' - ] + 'Smart Home Integration', + ], ], [ 'id' => 2, @@ -1431,8 +2037,8 @@ return [ 'Maßgefertigte Einbauschränke', 'Designer-Beleuchtung', 'Hochwertige Böden', - 'Farbkonzept' - ] + 'Farbkonzept', + ], ], [ 'id' => 3, @@ -1449,8 +2055,8 @@ return [ 'Outdoor-Möblierung', 'Exklusive Kunstwerke', 'Premium Accessoires', - 'Maßgeschneiderte Lösungen' - ] + 'Maßgeschneiderte Lösungen', + ], ], [ 'id' => 4, @@ -1467,8 +2073,8 @@ return [ 'Maßgefertigte Stahlträger', 'Künstlerische Beleuchtung', 'Authentische Materialien', - 'Stilechte Accessoires' - ] + 'Stilechte Accessoires', + ], ], [ 'id' => 5, @@ -1485,8 +2091,8 @@ return [ 'Edle Naturmaterialien', 'Antike Kunstobjekte', 'Wellness-Bereich Design', - 'Terrassenmöblierung' - ] + 'Terrassenmöblierung', + ], ], [ 'id' => 6, @@ -1503,8 +2109,8 @@ return [ 'Naturbelassene Materialien', 'Outdoor-Living-Konzept', 'Panorama-Raumgestaltung', - 'Wasserfeste Materialien' - ] + 'Wasserfeste Materialien', + ], ], [ 'id' => 7, @@ -1521,8 +2127,8 @@ return [ 'Multifunktionale Möbel', 'Tech-Integration', 'Modulare Systeme', - 'Urbane Materialien' - ] + 'Urbane Materialien', + ], ], [ 'id' => 8, @@ -1539,8 +2145,8 @@ return [ 'Spa-Design Konzept', 'Weinkeller-Ausstattung', 'Handgefertigte Unikate', - 'Luxus-Textilien' - ] + 'Luxus-Textilien', + ], ], [ 'id' => 9, @@ -1557,26 +2163,26 @@ return [ 'Biophile Gestaltung', 'LED-Lichttechnik', 'Recycelte Designstücke', - 'Indoor-Garten Konzept' - ] - ] - ] + 'Indoor-Garten Konzept', + ], + ], + ], ], 'cta_section_portfolio' => [ 'title' => 'Überzeugt von unserer
Arbeit?', 'subtitle' => 'Lassen Sie uns darüber sprechen, wie wir auch Ihre Vision Wirklichkeit werden lassen können.', 'button_text' => 'Beratungstermin vereinbaren', - 'button_link' => '/contact' + 'button_link' => '/contact', ], 'about_hero' => [ 'title' => 'Über B2in', 'quote' => '"Unsere Vision ist es, Unternehmen durch innovative Konnektivitätslösungen zu verbinden und nachhaltiges Wachstum in der digitalen Welt zu ermöglichen. Bei B2in schaffen wir nicht nur Verbindungen – wir bauen Brücken in die Zukunft."', 'founder_name' => 'Marcel Scheibe', 'founder_title' => 'Gründer & CEO, B2in', - 'image' => 'b2in/marcel-scheibe.jpg', + 'image' => 'b2in/marcel-scheibe-about.jpg', 'image_alt' => 'Marcel Scheibe, Gründer und CEO von B2in', 'year' => '2024', - 'year_text' => 'Gründungsjahr' + 'year_text' => 'Gründungsjahr', ], 'broker_section' => [ 'title' => 'Diskrete Vermarktung
auf höchstem Niveau', @@ -1584,7 +2190,7 @@ return [ 'card_title' => 'Exklusivität', 'compensation' => [ 'initial_sale' => 'Vertraulich', - 'follow_up' => 'Langfristige Partnerschaften' + 'follow_up' => 'Langfristige Partnerschaften', ], 'compensation_text' => 'Attraktive Konditionen für exklusive Partner', 'benefits' => [ @@ -1608,7 +2214,7 @@ return [ 'description' => 'Profitieren Sie von unserem Know-how und unseren Ressourcen.', 'icon' => 'award', ], - ] + ], ], 'commitment_section' => [ 'title' => 'Exzellenz, von unseren Kunden bestätigt', @@ -1619,23 +2225,23 @@ return [ 'rating' => 5, 'quote' => 'Stileigentum hat für uns ein Zuhause gefunden, das unsere kühnsten Träume übertrifft. Ein Service der Extraklasse.', 'author' => 'Familie Exklusiv', - 'author_title' => 'Zufriedene Kunden' + 'author_title' => 'Zufriedene Kunden', ], [ 'image' => 'stileigentum/testimonial-2.jpg', 'rating' => 5, 'quote' => 'Die diskrete und professionelle Abwicklung beim Verkauf unserer Villa war beeindruckend. Absolute Empfehlung.', 'author' => 'Dr. von Adel', - 'author_title' => 'Unternehmer' + 'author_title' => 'Unternehmer', ], [ 'image' => 'stileigentum/testimonial-3.jpg', 'rating' => 5, 'quote' => 'Ein Makler mit Stil, Netzwerk und dem nötigen Feingefühl für besondere Immobilien und deren Eigentümer.', 'author' => 'Anspruchsvoller Investor', - 'author_title' => 'Investor' - ] - ] + 'author_title' => 'Investor', + ], + ], ], 'ecosystem_stats' => [ 'title' => 'Unser Weg zur Perfektion', @@ -1661,22 +2267,22 @@ return [ 'label' => 'Realization', 'description' => 'Wir setzen Ihr Projekt mit höchster Präzision um.', ], - ] + ], ], 'contact_form' => [ 'hero' => [ 'title' => 'Ihr Kontakt
zu uns.', - 'subtitle' => 'Wir freuen uns auf Ihre Anfrage und sichern Ihnen absolute Diskretion zu.' + 'subtitle' => 'Wir freuen uns auf Ihre Anfrage und sichern Ihnen absolute Diskretion zu.', ], 'form' => [ 'labels' => [ - 'first_name' => 'First name *', - 'last_name' => 'Last name *', - 'company' => 'Company', - 'email' => 'Email *', - 'phone' => 'Phone', - 'subject' => 'Subject *', - 'message' => 'Message *' + 'first_name' => 'Vorname *', + 'last_name' => 'Nachname *', + 'company' => 'Firma (optional)', + 'email' => 'E-Mail *', + 'phone' => 'Telefon (optional)', + 'subject' => 'Betreff *', + 'message' => 'Ihre Nachricht *', ], 'subjects' => [ '' => 'Wählen Sie einen Betreff', @@ -1686,18 +2292,18 @@ return [ 'career' => 'Karriere', ], 'placeholders' => [ - 'message' => 'Ihre Nachricht...' + 'message' => 'Ihre Nachricht...', ], 'button_text' => 'Senden', 'button_loading' => 'Wird gesendet...', - 'success_message' => 'Vielen Dank für Ihre Nachricht! Wir werden uns schnellstmöglich bei Ihnen melden.' + 'success_message' => 'Vielen Dank für Ihre Nachricht! Wir werden uns schnellstmöglich bei Ihnen melden.', ], 'contact_info' => [ [ 'title' => 'Our Office Location', 'info' => [ - 'Musterstraße 123', - '12345 Berlin, Deutschland', + 'Rathausstraße 11', + '33602 Bielefeld', ], 'icon' => 'map-pin', ], @@ -1725,8 +2331,8 @@ return [ ['name' => 'Pinterest', 'handle' => 'stileigentum Inspiration', 'url' => 'https://pinterest.com/stileigentum'], ['name' => 'Facebook', 'handle' => 'stileigentum Deutschland', 'url' => 'https://facebook.com/stileigentum'], ['name' => 'LinkedIn', 'handle' => 'stileigentum Company', 'url' => 'https://linkedin.com/company/stileigentum'], - ] - ] + ], + ], ], 'faq' => [ 'title' => 'Häufig gestellte Fragen', @@ -1734,25 +2340,25 @@ return [ 'questions' => [ [ 'question' => 'Was genau ist stileigentum und was unterscheidet Sie von klassischer Innenarchitektur?', - 'answer' => 'stileigentum ist mehr als nur Innenarchitektur; wir sind Ihr diskreter Partner für die Wertsteigerung und Veredelung von Premium-Immobilien. Unser Fokus liegt nicht allein auf Ästhetik, sondern auf der Schaffung eines bleibenden Wertes. Wir spezialisieren uns auf zwei Kernbereiche: das strategische "Premium Staging" zur optimalen Vermarktung und die Realisierung von "Bespoke Interiors" für ein vollendetes, persönliches Zuhause.' + 'answer' => 'stileigentum ist mehr als nur Innenarchitektur; wir sind Ihr diskreter Partner für die Wertsteigerung und Veredelung von Premium-Immobilien. Unser Fokus liegt nicht allein auf Ästhetik, sondern auf der Schaffung eines bleibenden Wertes. Wir spezialisieren uns auf zwei Kernbereiche: das strategische "Premium Staging" zur optimalen Vermarktung und die Realisierung von "Bespoke Interiors" für ein vollendetes, persönliches Zuhause.', ], [ 'question' => 'Für wen ist der Service von stileigentum ideal geeignet?', - 'answer' => 'Unser Service richtet sich an anspruchsvolle Eigentümer, Investoren, Bauträger und Luxusmakler, die den Wert ihrer Immobilie entweder für den eigenen, repräsentativen Lebensraum perfektionieren oder für den Verkauf die maximale Attraktivität und den Marktwert steigern möchten. Wir sind der richtige Partner, wenn Kompromisse in Qualität und Ausführung keine Option sind.' + 'answer' => 'Unser Service richtet sich an anspruchsvolle Eigentümer, Investoren, Bauträger und Luxusmakler, die den Wert ihrer Immobilie entweder für den eigenen, repräsentativen Lebensraum perfektionieren oder für den Verkauf die maximale Attraktivität und den Marktwert steigern möchten. Wir sind der richtige Partner, wenn Kompromisse in Qualität und Ausführung keine Option sind.', ], [ 'question' => 'Wie gestaltet sich der Prozess einer Zusammenarbeit?', - 'answer' => 'Jedes Projekt ist ein Unikat und beginnt mit einer vertraulichen Erstberatung, in der wir Ihre Vision und Ziele verstehen. Darauf folgt eine detaillierte Konzeption, die sorgfältige Kuration von Materialien und Objekten sowie die präzise Umsetzung durch unser erlesenes Netzwerk an Handwerkern und Lieferanten. Während des gesamten Prozesses steht Ihnen ein persönlicher Ansprechpartner zur Seite.' + 'answer' => 'Jedes Projekt ist ein Unikat und beginnt mit einer vertraulichen Erstberatung, in der wir Ihre Vision und Ziele verstehen. Darauf folgt eine detaillierte Konzeption, die sorgfältige Kuration von Materialien und Objekten sowie die präzise Umsetzung durch unser erlesenes Netzwerk an Handwerkern und Lieferanten. Während des gesamten Prozesses steht Ihnen ein persönlicher Ansprechpartner zur Seite.', ], [ 'question' => 'Wie gewährleisten Sie Diskretion und Vertrauen?', - 'answer' => 'Diskretion ist der Grundpfeiler unserer Arbeit. Wir garantieren absolute Vertraulichkeit in allen Phasen des Projekts und schützen die Privatsphäre unserer Klienten kompromisslos. Unser Ruf basiert auf langjährigem Vertrauen und der sorgfältigen Handhabung sensibler Informationen und exklusiver Immobilien.' + 'answer' => 'Diskretion ist der Grundpfeiler unserer Arbeit. Wir garantieren absolute Vertraulichkeit in allen Phasen des Projekts und schützen die Privatsphäre unserer Klienten kompromisslos. Unser Ruf basiert auf langjährigem Vertrauen und der sorgfältigen Handhabung sensibler Informationen und exklusiver Immobilien.', ], [ 'question' => 'Handelt es sich bei Ihren Dienstleistungen um eine Ausgabe oder eine Investition?', - 'answer' => 'Wir betrachten unsere Arbeit klar als eine Investition in Ihre Immobilie. Ein professionelles Staging oder ein maßgeschneidertes Interieur-Konzept steigert nachweislich den Wert und die Begehrlichkeit Ihrer Immobilie, beschleunigt die Vermarktung und schafft bleibende Werte, die über kurzfristige Trends hinausgehen. Es ist eine Investition in Qualität, Ästhetik und letztlich auch in die Rendite.' - ] - ] + 'answer' => 'Wir betrachten unsere Arbeit klar als eine Investition in Ihre Immobilie. Ein professionelles Staging oder ein maßgeschneidertes Interieur-Konzept steigert nachweislich den Wert und die Begehrlichkeit Ihrer Immobilie, beschleunigt die Vermarktung und schafft bleibende Werte, die über kurzfristige Trends hinausgehen. Es ist eine Investition in Qualität, Ästhetik und letztlich auch in die Rendite.', + ], + ], ], // Weitere Komponenten für stileigentum ], @@ -1765,7 +2371,7 @@ return [ ['label' => 'Inspiration', 'url' => '/portfolio'], ['label' => 'FAQ', 'url' => '/faq'], ['label' => 'Contact', 'url' => '/contact'], - ] + ], ], 'hero_slider' => [ 'title' => 'Connecting Design &
Your Home
', @@ -1778,7 +2384,7 @@ return [ 'stats' => [ 'Exklusive Auswahl', 'Persönlicher Service', - 'Werte die bleiben' + 'Werte die bleiben', ], 'slides' => [ [ @@ -1788,18 +2394,18 @@ return [ [ 'image' => 'style2own/hero-slider-2.jpg', 'image_alt' => 'Junges Paar in einem modernen Wohnzimmer', - ] + ], ], ], 'vision_section' => [ 'title' => 'Einrichtung? Machen wir einfach.', 'paragraphs' => [ 'Vergiss komplizierte Entscheidungen und endlose Möbelhaus-Besuche.', - 'Wir sind dein "Entscheidungs-Coach" und bieten dir kuratierte Stilwelten, flexible Mietoptionen und alles, was du für ein Zuhause brauchst, das sich wirklich wie deins anfühlt.' + 'Wir sind dein "Entscheidungs-Coach" und bieten dir kuratierte Stilwelten, flexible Mietoptionen und alles, was du für ein Zuhause brauchst, das sich wirklich wie deins anfühlt.', ], 'image' => 'style2own/vision.jpg', 'image_alt' => 'Kreative Wohnkonzepte', - 'image_caption' => 'Style2own – Your Home' + 'image_caption' => 'Style2own – Your Home', ], 'ecosystem_core' => [ 'title' => 'Dein Weg zum Wohntraum.', @@ -1820,7 +2426,7 @@ return [ 'title' => 'Schnell & Smart', 'description' => 'In wenigen Klicks zum fertigen Konzept. Einfacher gehts nicht.', ], - ] + ], ], 'about_philosophie' => [ @@ -1832,7 +2438,7 @@ return [ ], 'image' => 'style2own/portrait.jpg', 'image_alt' => 'Moderne Arbeitsplätze mit innovativer Technologie', - 'image_caption' => 'Das Team von Style2own' + 'image_caption' => 'Das Team von Style2own', ], 'content_section_left' => [ 'title' => 'Erhalte deinen exklusiven Zugang', @@ -1842,17 +2448,17 @@ return [ ], 'image' => 'style2own/content-left.jpg', 'image_alt' => 'exklusiver Zugang mit persönlicher Login-Karte', - 'image_caption' => 'exklusiver Zugang' + 'image_caption' => 'exklusiver Zugang', ], 'content_section_right' => [ 'title' => 'Entdecke deine Style-Welten', 'paragraphs' => [ 'Logge dich in unser Portal ein und lass dich inspirieren! Wir präsentieren dir komplette Raumkonzepte und personalisierte Möbel-Dashboards, die auf dein neues Zuhause zugeschnitten sind.', - 'Speichere deine Favoriten, spiele mit verschiedenen Looks und finde ganz einfach heraus, was dir wirklich gefällt. Dein Stil, deine Entscheidung – wir machen es dir leicht.' + 'Speichere deine Favoriten, spiele mit verschiedenen Looks und finde ganz einfach heraus, was dir wirklich gefällt. Dein Stil, deine Entscheidung – wir machen es dir leicht.', ], 'image' => 'style2own/content-right.jpg', 'image_alt' => 'Kreative Arbeitsumgebung mit modernem Design', - 'image_caption' => 'Kreativität trifft Funktionalität' + 'image_caption' => 'Kreativität trifft Funktionalität', ], 'brand_worlds' => [ 'title' => 'Lass dich inspirieren', @@ -1883,7 +2489,7 @@ return [ 'title' => 'Bereit für dein
neues Zuhause?', 'subtitle' => 'Fordere jetzt deinen exklusiven und kostenlosen Zugang zu unserem Portal an und starte dein Einrichtungsprojekt.', 'button_text' => 'Jetzt Zugang anfragen', - 'button_link' => '/contact' + 'button_link' => '/contact', ], 'hero_image' => [ 'title' => 'Dein Zuhause in
3 einfachen Schritten', @@ -1894,7 +2500,7 @@ return [ 'stats' => [ 'Exklusiver Service', 'Flexible Möbellösungen', - 'Modernes Design' + 'Modernes Design', ], ], 'portfolio' => [ @@ -1916,8 +2522,8 @@ return [ 'Maßanfertigungen', 'Premium Textilien', 'Kunstobjekte', - 'Smart Home Integration' - ] + 'Smart Home Integration', + ], ], [ 'id' => 2, @@ -1934,8 +2540,8 @@ return [ 'Maßgefertigte Einbauschränke', 'Designer-Beleuchtung', 'Hochwertige Böden', - 'Farbkonzept' - ] + 'Farbkonzept', + ], ], [ 'id' => 3, @@ -1952,8 +2558,8 @@ return [ 'Outdoor-Möblierung', 'Exklusive Kunstwerke', 'Premium Accessoires', - 'Maßgeschneiderte Lösungen' - ] + 'Maßgeschneiderte Lösungen', + ], ], [ 'id' => 4, @@ -1970,8 +2576,8 @@ return [ 'Maßgefertigte Stahlträger', 'Künstlerische Beleuchtung', 'Authentische Materialien', - 'Stilechte Accessoires' - ] + 'Stilechte Accessoires', + ], ], [ 'id' => 5, @@ -1988,8 +2594,8 @@ return [ 'Edle Naturmaterialien', 'Antike Kunstobjekte', 'Wellness-Bereich Design', - 'Terrassenmöblierung' - ] + 'Terrassenmöblierung', + ], ], [ 'id' => 6, @@ -2006,8 +2612,8 @@ return [ 'Naturbelassene Materialien', 'Outdoor-Living-Konzept', 'Panorama-Raumgestaltung', - 'Wasserfeste Materialien' - ] + 'Wasserfeste Materialien', + ], ], [ 'id' => 7, @@ -2024,8 +2630,8 @@ return [ 'Multifunktionale Möbel', 'Tech-Integration', 'Modulare Systeme', - 'Urbane Materialien' - ] + 'Urbane Materialien', + ], ], [ 'id' => 8, @@ -2042,8 +2648,8 @@ return [ 'Spa-Design Konzept', 'Weinkeller-Ausstattung', 'Handgefertigte Unikate', - 'Luxus-Textilien' - ] + 'Luxus-Textilien', + ], ], [ 'id' => 9, @@ -2060,16 +2666,16 @@ return [ 'Biophile Gestaltung', 'LED-Lichttechnik', 'Recycelte Designstücke', - 'Indoor-Garten Konzept' - ] - ] - ] + 'Indoor-Garten Konzept', + ], + ], + ], ], 'cta_section_portfolio' => [ 'title' => 'Inspiriert?
Dann leg jetzt los!', 'subtitle' => 'Hol dir deinen Zugang und beginne, dein eigenes Traumzuhause zu gestalten.', 'button_text' => 'Jetzt loslegen', - 'button_link' => '/contact' + 'button_link' => '/contact', ], 'commitment_section' => [ @@ -2081,23 +2687,23 @@ return [ 'rating' => 5, 'quote' => 'Stileigentum hat für uns ein Zuhause gefunden, das unsere kühnsten Träume übertrifft. Ein Service der Extraklasse.', 'author' => 'Familie Exklusiv', - 'author_title' => 'Zufriedene Kunden' + 'author_title' => 'Zufriedene Kunden', ], [ 'image' => 'style2own/testimonial-2.jpg', 'rating' => 5, 'quote' => 'Die diskrete und professionelle Abwicklung beim Verkauf unserer Villa war beeindruckend. Absolute Empfehlung.', 'author' => 'Dr. von Adel', - 'author_title' => 'Unternehmer' + 'author_title' => 'Unternehmer', ], [ 'image' => 'style2own/testimonial-3.jpg', 'rating' => 5, 'quote' => 'Ein Makler mit Stil, Netzwerk und dem nötigen Feingefühl für besondere Immobilien und deren Eigentümer.', 'author' => 'Anspruchsvoller Investor', - 'author_title' => 'Investor' - ] - ] + 'author_title' => 'Investor', + ], + ], ], 'ecosystem_stats' => [ 'title' => 'Deine Vorteile mit style2own', @@ -2123,7 +2729,7 @@ return [ 'label' => 'Exklusiver Service', 'description' => 'Genieße einen einzigartigen Vorteil, den du nur als Kunde unserer ausgewählten Partner-Makler erhältst.', ], - ] + ], ], 'faq' => [ 'title' => 'Häufig gestellte Fragen', @@ -2131,40 +2737,40 @@ return [ 'questions' => [ [ 'question' => 'Was ist style2own eigentlich?', - 'answer' => 'style2own ist dein persönlicher Einrichtungs-Coach als Online-Portal! Wir machen das Einrichten deines neuen Zuhauses super einfach und inspirierend. Statt dich durch unzählige Möbelhäuser zu quälen, bieten wir dir über unser exklusives Portal komplette, von Designern kuratierte Wohnkonzepte und flexible Möbellösungen, die perfekt zu dir und deinem Leben passen.' + 'answer' => 'style2own ist dein persönlicher Einrichtungs-Coach als Online-Portal! Wir machen das Einrichten deines neuen Zuhauses super einfach und inspirierend. Statt dich durch unzählige Möbelhäuser zu quälen, bieten wir dir über unser exklusives Portal komplette, von Designern kuratierte Wohnkonzepte und flexible Möbellösungen, die perfekt zu dir und deinem Leben passen.', ], [ 'question' => 'Wie bekomme ich Zugang zum Portal?', - 'answer' => 'Den Zugang zu style2own erhältst du exklusiv über einen unserer Partner-Makler. Nach dem Abschluss deines Miet- oder Kaufvertrags bekommst du von ihm deine persönliche Login-Karte – dein kostenloses Ticket in unsere Design-Welt. Es ist ein exklusiver Service, um dir den Start im neuen Zuhause zu verschönern.' + 'answer' => 'Den Zugang zu style2own erhältst du exklusiv über einen unserer Partner-Makler. Nach dem Abschluss deines Miet- oder Kaufvertrags bekommst du von ihm deine persönliche Login-Karte – dein kostenloses Ticket in unsere Design-Welt. Es ist ein exklusiver Service, um dir den Start im neuen Zuhause zu verschönern.', ], [ 'question' => 'Kostet die Nutzung des Portals etwas?', - 'answer' => 'Nein! Der Zugang zum Portal und das Stöbern durch alle unsere Stilwelten und Konzepte ist für dich als Kunde unserer Partner-Makler komplett kostenlos und unverbindlich. Du zahlst nur für die Möbel oder Services, die du am Ende auch wirklich bestellst.' + 'answer' => 'Nein! Der Zugang zum Portal und das Stöbern durch alle unsere Stilwelten und Konzepte ist für dich als Kunde unserer Partner-Makler komplett kostenlos und unverbindlich. Du zahlst nur für die Möbel oder Services, die du am Ende auch wirklich bestellst.', ], [ 'question' => 'Muss ich die Möbel kaufen? Was bedeutet "Erst mieten, später kaufen"?', - 'answer' => 'Du hast die volle Flexibilität! Das ist das Herzstück von style2own. Du kannst unsere Möbel direkt kaufen ODER sie erst einmal für eine bestimmte Zeit mieten. So kannst du einen Look einfach ausprobieren. Und wenn du dich in dein neues Sofa verliebst, kannst du es später kaufen, wobei wir dir einen Teil der bereits gezahlten Miete anrechnen. Kein Risiko, volle Freiheit!' + 'answer' => 'Du hast die volle Flexibilität! Das ist das Herzstück von style2own. Du kannst unsere Möbel direkt kaufen ODER sie erst einmal für eine bestimmte Zeit mieten. So kannst du einen Look einfach ausprobieren. Und wenn du dich in dein neues Sofa verliebst, kannst du es später kaufen, wobei wir dir einen Teil der bereits gezahlten Miete anrechnen. Kein Risiko, volle Freiheit!', ], [ 'question' => 'Wer wählt die Möbel aus? Passen die Vorschläge auch wirklich zu mir?', - 'answer' => 'Unser Portal ist smart und persönlich! Unsere Interior-Experten stellen komplette, harmonische Stilwelten für dich zusammen – von "Urban Jungle" bis "Scandi Chic". Innerhalb dieser Welten kannst du aber alles nach deinem Geschmack anpassen, Favoriten speichern und dir dein individuelles Paket zusammenstellen. Am Ende entscheidest immer du, was in dein Zuhause einzieht.' - ] - ] + 'answer' => 'Unser Portal ist smart und persönlich! Unsere Interior-Experten stellen komplette, harmonische Stilwelten für dich zusammen – von "Urban Jungle" bis "Scandi Chic". Innerhalb dieser Welten kannst du aber alles nach deinem Geschmack anpassen, Favoriten speichern und dir dein individuelles Paket zusammenstellen. Am Ende entscheidest immer du, was in dein Zuhause einzieht.', + ], + ], ], 'contact_form' => [ 'hero' => [ 'title' => 'Sag Hallo!', - 'subtitle' => 'Wir freuen uns riesig, von dir zu hören. Egal ob du eine Frage, eine Idee oder einfach nur Feedback für uns hast – immer her damit!' + 'subtitle' => 'Wir freuen uns riesig, von dir zu hören. Egal ob du eine Frage, eine Idee oder einfach nur Feedback für uns hast – immer her damit!', ], 'form' => [ 'labels' => [ - 'first_name' => 'First name *', + 'first_name' => 'Vorname *', 'last_name' => 'Last name *', - 'company' => 'Company', - 'email' => 'Email *', - 'phone' => 'Phone', - 'subject' => 'Subject *', - 'message' => 'Message *' + 'company' => 'Firma (optional)', + 'email' => 'E-Mail *', + 'phone' => 'Telefon (optional)', + 'subject' => 'Betreff *', + 'message' => 'Ihre Nachricht *', ], 'subjects' => [ '' => 'Wählen Sie einen Betreff', @@ -2174,11 +2780,11 @@ return [ 'career' => 'Karriere', ], 'placeholders' => [ - 'message' => 'Ihre Nachricht...' + 'message' => 'Ihre Nachricht...', ], 'button_text' => 'Senden', 'button_loading' => 'Wird gesendet...', - 'success_message' => 'Vielen Dank für Ihre Nachricht! Wir werden uns schnellstmöglich bei Ihnen melden.' + 'success_message' => 'Vielen Dank für Ihre Nachricht! Wir werden uns schnellstmöglich bei Ihnen melden.', ], 'contact_info' => [ [ @@ -2213,8 +2819,8 @@ return [ ['name' => 'Pinterest', 'handle' => 'style2own Inspiration', 'url' => 'https://pinterest.com/style2own'], ['name' => 'Facebook', 'handle' => 'style2own Deutschland', 'url' => 'https://facebook.com/style2own'], ['name' => 'LinkedIn', 'handle' => 'style2own Company', 'url' => 'https://linkedin.com/company/b2in'], - ] - ] + ], + ], ], // Weitere Komponenten für style2own ], @@ -2222,97 +2828,159 @@ return [ 'articles' => [ 1 => [ 'id' => 1, - 'title' => ' Die Zukunft des Home Staging:
Mehr als nur Möbelrücken', - 'subtitle' => 'Wie Technologie, Nachhaltigkeit und personalisierte Konzepte die Immobilienvermarktung revolutionieren und den Wert Ihrer Objekte maximieren.', + 'title' => 'Sicherheit statt Bürokratie:
Warum das Dubai Escrow-System deutsche Investoren überrascht', + 'subtitle' => 'Wie das staatlich regulierte Treuhandsystem in Dubai Investorengelder konsequent schützt – und warum es internationalen Standards weit voraus ist.', 'image' => 'b2in/magazin-1.jpg', - 'category' => 'Immobilien-Marketing', - 'date' => 'Oktober 26, 2025', + 'category' => 'Dubai Investment', + 'date' => 'März 10, 2026', 'readTime' => '6 min read', 'author' => [ - 'name' => 'Dr. Elena Richter', - 'bio' => 'Unsere leitende Expertin für Interior Concepts und Immobilienpsychologie. Sie verbindet Designtrends mit datengestützten Vermarktungsstrategien.', - 'avatar' => 'author-richter.jpg', + 'name' => 'Marcel Scheibe', + 'bio' => 'Gründer & CEO von B2in. Marcel ist selbst Immobilien-Investor in Dubai und begleitet deutsche Käufer persönlich durch den gesamten Kaufprozess.', + 'avatar' => 'b2in/marcel-scheibe.jpg', ], 'content' => [ - 'intro' => 'Home Staging war einst die Kunst, eine Immobilie wohnlich zu präsentieren. Heute ist es eine datengestützte Wissenschaft, die entscheidend zum Verkaufserfolg beiträgt. Die bloße Platzierung von Möbeln reicht nicht mehr aus, um sich in einem anspruchsvollen Markt abzuheben. Die Zukunft gehört Konzepten, die smart, nachhaltig und hochgradig personalisiert sind.', + 'intro' => 'Deutsche Investoren schätzen beim Immobilienkauf vor allem eines: maximale Sicherheit. Der vertraute Prozess über Notar und Grundbuchamt vermittelt ein Gefühl von Kontrolle, ist jedoch oft mit langen Wartezeiten und enormer Bürokratie verbunden. Wer zum ersten Mal auf den Immobilienmarkt in Dubai blickt, ist oft überrascht von der unglaublichen Dynamik und Geschwindigkeit. Doch bedeutet dieses Tempo ein höheres Risiko? Im Gegenteil: Dubai hat mit dem Escrow-System einen der sichersten und transparentesten regulatorischen Rahmenwerke der Welt geschaffen, der Investorengelder konsequent schützt.', 'sections' => [ [ - 'title' => 'Digitales Staging: Virtual & Augmented Reality', - 'content' => 'Noch vor dem ersten Spatenstich können potenzielle Käufer durch ihr zukünftiges Zuhause wandeln. Virtuelles Staging ermöglicht es, verschiedene Einrichtungsstile per Klick zu testen und schafft eine emotionale Bindung, lange bevor die Wände stehen. Dies senkt Kosten und beschleunigt den Verkaufsprozess enorm.', + 'title' => 'Das Escrow-System: Sicherheit durch staatlich regulierte Treuhandkonten', + 'content' => 'Der wichtigste Schutzmechanismus beim Kauf einer Off-Plan-Immobilie in Dubai ist das gesetzlich vorgeschriebene Escrow-System. Käuferzahlungen fließen nicht direkt an den Bauträger, sondern werden auf ein projektbezogenes Treuhandkonto bei einer zugelassenen Bank eingezahlt. Dieses Konto wird von der Real Estate Regulatory Agency (RERA), einer Regulierungsbehörde des Dubai Land Department (DLD), überwacht. Der Entwickler erhält Zugriff auf die Gelder nur entsprechend dem tatsächlichen Baufortschritt, der von zertifizierten Ingenieuren bestätigt werden muss.', ], [ - 'title' => 'Nachhaltigkeit als entscheidendes Verkaufsargument', - 'content' => 'Die moderne Käuferschicht legt Wert auf ökologische Verantwortung. Ein Staging-Konzept, das auf nachhaltige Materialien, recycelte Möbel und energieeffiziente Beleuchtung setzt, ist nicht nur ethisch, sondern auch ein starkes Verkaufsargument. Es signalisiert Qualität, Langlebigkeit und ein zukunftsorientiertes Denken.', + 'title' => 'Strenge Kontrolle durch das Dubai Land Department (DLD)', + 'content' => 'Das Dubai Land Department ist die zentrale staatliche Institution für Immobilien in Dubai. Über seine Regulierungsbehörde RERA überwacht es sämtliche Off-Plan-Projekte und sorgt dafür, dass Bauträger strenge gesetzliche Vorgaben einhalten. Jeder Kaufvertrag (SPA) wird offiziell registriert, und Bauprojekte müssen im sogenannten Oqood-System erfasst werden. Zahlungen der Käufer fließen auf projektbezogene Escrow-Konten und dürfen nur entsprechend dem bestätigten Baufortschritt freigegeben werden.', ], [ - 'title' => 'Personalisierung ist der Schlüssel', - 'content' => 'One-size-fits-all war gestern. Intelligente Plattformen wie das B2in-Portal ermöglichen es, Staging-Konzepte gezielt auf die demografische Zielgruppe des Käufers zuzuschneiden. Ob minimalistisch für den jungen Urbanisten oder elegant für die anspruchsvolle Familie – personalisiertes Staging trifft den Nerv und maximiert die Identifikation mit der Immobilie.', + 'title' => 'Transparenz und hohe Planbarkeit', + 'content' => 'Durch die enge Verknüpfung von Käuferzahlungen mit dem tatsächlichen Baufortschritt entsteht ein hohes Maß an Transparenz für Investoren. Bauträger dürfen Mittel aus dem Escrow-Konto nur entsprechend dem bestätigten Baufortschritt abrufen. Dadurch wird sichergestellt, dass Investorengelder projektgebunden eingesetzt werden und nicht für andere Bauvorhaben verwendet werden können. Für Käufer bedeutet dies: Die Dynamik des Immobilienmarktes in Dubai wird mit einem klar regulierten System kombiniert, das international als vergleichsweise investorenfreundlich gilt.', ], ], ], ], 2 => [ 'id' => 2, - 'title' => 'Jenseits der Lage:
Warum technologiegetriebene Immobilien die Zukunft des Investments sind', - 'subtitle' => 'Während "Lage, Lage, Lage" ein Klassiker bleibt, definieren Daten, Konnektivität und flexible Nutzungskonzepte heute die wahre Rendite eines Objekts.', + 'title' => 'Spotlight Al Jaddaf:
Warum smarte Investoren jetzt auf diesen aufstrebenden Hotspot setzen', + 'subtitle' => 'Strategische Waterfront-Location, hohe Mietnachfrage und enormes Potenzial für Wertsteigerung – Al Jaddaf ist der Hidden Champion unter Dubais Vierteln.', 'image' => 'b2in/magazin-2.jpg', - 'category' => 'Investment & Trends', - 'date' => 'Oktober 18, 2025', - 'readTime' => '8 min read', + 'category' => 'Dubai Investment', + 'date' => 'März 5, 2026', + 'readTime' => '7 min read', 'author' => [ - 'name' => 'David Chen', - 'bio' => 'Head of Global Market Analytics bei B2in. David analysiert internationale Marktdaten, um die Investment-Chancen von morgen zu identifizieren.', - 'avatar' => 'author-chen.jpg', + 'name' => 'Marcel Scheibe', + 'bio' => 'Gründer & CEO von B2in. Marcel ist selbst Immobilien-Investor in Dubai und begleitet deutsche Käufer persönlich durch den gesamten Kaufprozess.', + 'avatar' => 'b2in/marcel-scheibe.jpg', ], 'content' => [ - 'intro' => 'Für Jahrzehnte war die Lage der unangefochtene König der Immobilienbewertung. Doch in einer digitalisierten Welt treten neue, ebenso mächtige Werttreiber auf den Plan. Intelligente Investoren schauen heute nicht nur auf die Postleitzahl, sondern auch auf die technologische Infrastruktur und die Anpassungsfähigkeit einer Immobilie an die Bedürfnisse der Zukunft.', + 'intro' => 'Die Wahl der richtigen Lage ist der entscheidende Faktor für eine hohe Rendite und langfristige Wertsteigerung. Während weltbekannte Areale wie Downtown Dubai oder die Palm Jumeirah hohe Einstiegspreise aufrufen, suchen smarte Investoren nach den "Hidden Champions" – Vierteln, die vor einer massiven Aufwertung stehen. Eines der spannendsten Entwicklungsgebiete der Stadt ist aktuell Al Jaddaf. Historisch als Werft-Viertel am Dubai Creek bekannt, transformiert sich Al Jaddaf rasend schnell zu einem modernen, strategisch extrem wichtigen Knotenpunkt für exklusives Wohnen und Lifestyle.', 'sections' => [ [ - 'title' => 'Smart Homes & IoT: Der neue Standard', - 'content' => 'Immobilien, die von vornherein mit einer intelligenten Vernetzung (Internet of Things) ausgestattet sind, erzielen höhere Mieteinnahmen und Verkaufspreise. Energieeffizienz, Sicherheit und Komfort sind keine Luxus-Features mehr, sondern knallharte Kriterien für die Zukunftsfähigkeit eines Investments.', + 'title' => 'Die strategische Waterfront-Location', + 'content' => 'Al Jaddaf bietet eine seltene Kombination aus direkter Wasserlage (Waterfront) und zentraler Anbindung. Eingebettet zwischen dem historischen Dubai Creek und der modernen Erweiterung, bietet es unverbaubare Blicke auf die Skyline. Gleichzeitig sind Hotspots wie Downtown Dubai, der Burj Khalifa sowie der Dubai International Airport in nur wenigen Autominuten erreichbar. Diese Logistik macht den Standort unvergleichlich.', ], [ - 'title' => 'Datenanalyse für präzise Renditeprognosen', - 'content' => 'Moderne Plattformen nutzen Big Data, um die potenzielle Wertentwicklung einer Immobilie weitaus genauer vorherzusagen. Faktoren wie demografische Entwicklung, zukünftige Infrastrukturprojekte und sozioökonomische Trends fließen in Algorithmen ein und ermöglichen datengestützte statt bauchgefühlbasierte Investmententscheidungen.', + 'title' => 'Hohe Mietnachfrage durch perfekte Infrastruktur', + 'content' => 'Das Viertel zieht zunehmend Young Professionals, Expats und Familien an, die zentral, aber dennoch ruhig und exklusiv leben möchten. Die Nähe zur Healthcare City, zu kulturellen Highlights wie dem Jameel Arts Centre und die Anbindung an die Metro sorgen für eine exzellente Infrastruktur. Dies garantiert Investoren eine kontinuierlich hohe Mietnachfrage und minimale Leerstandsquoten.', ], [ - 'title' => 'Flexible Wohnkonzepte als Werthebel', - 'content' => 'Die Nachfrage nach Flexibilität steigt. Immobilien, die sich leicht an verschiedene Lebensphasen oder Arbeitsmodelle (Home-Office) anpassen lassen und durch Services wie "Furniture-as-a-Service" ergänzt werden, sind resilienter und attraktiver für eine breitere Zielgruppe. Diese Flexibilität ist ein direkter Treiber für eine langfristig stabile Rendite.', + 'title' => 'Enormes Potenzial für Capital Appreciation (Wertsteigerung)', + 'content' => 'Aktuell bietet Al Jaddaf noch Einstiegspreise, die ein exzellentes Preis-Leistungs-Verhältnis darstellen – insbesondere im Vergleich zu bereits etablierten Premium-Vierteln. Mit neuen High-End-Projekten (wie etwa den Azizi Creek Views) wird das Viertel massiv aufgewertet. Wer jetzt investiert, profitiert in den kommenden Jahren nicht nur von attraktiven Mietrenditen, sondern vor allem von einer signifikanten Wertsteigerung der Immobilie.', ], ], ], ], 3 => [ 'id' => 3, - 'title' => 'Europäisches Design erobert den US-Markt:
Eine Chance für visionäre Händler', - 'subtitle' => 'Minimalismus, Handwerkskunst und Nachhaltigkeit – warum amerikanische Konsumenten sich zunehmend für europäische Möbel begeistern und wie Händler davon profitieren können.', + 'title' => 'Turnkey-Investments:
Wie die richtige Möblierung Ihre Mietrendite in Dubai maximiert', + 'subtitle' => 'Warum schlüsselfertige Einrichtungskonzepte den Unterschied zwischen durchschnittlicher und Premium-Rendite ausmachen.', 'image' => 'b2in/magazin-3.jpg', - 'category' => 'B2B & Handel', - 'date' => 'Oktober 11, 2025', - 'readTime' => '5 min read', + 'category' => 'Rendite & Einrichtung', + 'date' => 'Februar 25, 2026', + 'readTime' => '6 min read', 'author' => [ - 'name' => 'Frank Miller', - 'bio' => 'CEO der Bridges2America Corp. (B2A), dem spezialisierten US-Möbel-Exportarm von B2in. Er ist ein Experte für transatlantische Handelsbeziehungen.', - 'avatar' => 'author-miller.jpg', + 'name' => 'Marcel Scheibe', + 'bio' => 'Gründer & CEO von B2in. Mit Wurzeln in der Möbelbranche verbindet Marcel Immobilien-Expertise mit Einrichtungskompetenz.', + 'avatar' => 'b2in/marcel-scheibe.jpg', ], 'content' => [ - 'intro' => 'Ein signifikanter Wandel vollzieht sich in den Wohnzimmern Amerikas. Weg von "bigger is better", hin zu einem kuratierten, bewussten Stil. Europäisches Design trifft mit seinem Fokus auf Qualität, Funktionalität und zeitlose Ästhetik genau diesen Nerv. Für US-Händler und Interior Designer eröffnet sich dadurch eine enorme Marktchance – wenn die größte Hürde überwunden wird: die Logistik.', + 'intro' => 'Der Kauf einer Premium-Immobilie in Dubai ist der erste Schritt zu einem erfolgreichen Investment. Doch erst die richtige Nutzung entscheidet über die tatsächliche Rendite. Besonders lukrativ ist die Kurzzeitvermietung (Short-Term-Rental, z. B. via Airbnb), die in Dubai florierende Umsätze generiert. Die Herausforderung für viele internationale Investoren: Wie richtet man eine Wohnung über Tausende Kilometer Entfernung so ein, dass sie aus der Masse heraussticht und Premium-Preise erzielt? Die Antwort lautet "Turnkey" (schlüsselfertig) – intelligente Einrichtungskonzepte, die Ästhetik, Langlebigkeit und Effizienz vereinen.', 'sections' => [ [ - 'title' => 'Der Wandel im amerikanischen Geschmack', - 'content' => 'Jüngere, designaffine Zielgruppen in den USA suchen nach Authentizität und Langlebigkeit statt nach Massenware. Marken aus Skandinavien, Italien und Deutschland stehen für genau diese Werte und werden zunehmend zu Statussymbolen. Die Nachfrage nach kuratierten, exklusiven Kollektionen wächst stetig.', + 'title' => 'Premium-Look für höhere Übernachtungspreise', + 'content' => 'Der erste Eindruck auf Buchungsplattformen entscheidet. Ein durchschnittlich eingerichtetes Apartment erzielt durchschnittliche Preise. Maßgeschneiderte Design-Konzepte, die perfekt auf die Architektur der Immobilie und die demografische Zielgruppe (Business-Reisende oder Urlauber) abgestimmt sind, erlauben es Ihnen, sich im Premium-Segment zu positionieren und die Mieteinnahmen signifikant zu steigern.', ], [ - 'title' => 'Die Herausforderung: Der transatlantische Handel', - 'content' => 'Der Import von Möbeln aus verschiedenen europäischen Ländern ist für einzelne Händler oft komplex und kostspielig. Unterschiedliche Lieferanten, Zollabwicklungen und die Konsolidierung von Waren stellen eine hohe logistische und administrative Hürde dar, die viele davon abhält, dieses profitable Segment zu erschließen.', + 'title' => 'Der "Turnkey"-Vorteil aus der Ferne', + 'content' => 'Niemand möchte sich aus Europa heraus um Lieferverzögerungen, fehlende Schrauben oder Handwerkertermine in Dubai kümmern. Ein intelligentes Turnkey-Konzept nimmt Ihnen diesen gesamten Prozess ab. Über das exklusive B2in-Netzwerk erhalten Käufer Zugang zu einem Service, der von der ersten Design-Skizze bis zum fertig bezogenen Bett alles abdeckt – komplett gesteuert durch deutsches Projektmanagement.', ], [ - 'title' => 'Partnerschaft als strategischer Erfolgsfaktor', - 'content' => 'Spezialisierte B2B-Partner wie B2A fungieren als Brückenbauer. Sie bündeln den Einkauf, managen die gesamte Logistikkette und bieten US-Händlern einen kuratierten, einfachen Zugang zu den besten europäischen Designmarken. Ein solches Modell minimiert das Risiko und maximiert die Marge – ein entscheidender Wettbewerbsvorteil.', + 'title' => 'Langlebigkeit durch deutsche Qualitätsstandards', + 'content' => 'Bei einer hohen Auslastung in der Kurzzeitvermietung werden Möbel stark beansprucht. Billige Ausstattungen müssen oft schon nach kurzer Zeit ersetzt werden, was die Rendite schmälert. Der Fokus auf langlebige Materialien und erstklassige Verarbeitungsqualität – oft gesichert durch Zugänge zu deutschen und europäischen Premium-Herstellern – reduziert die Instandhaltungskosten auf ein Minimum und erhält den Wert Ihres Investments.', ], ], ], - ] - ] + ], + 4 => [ + 'id' => 4, + 'title' => 'Supply-Chain-Management für Entwickler:
Warum Vertragssicherheit den Unterschied macht', + 'subtitle' => 'Wie professionelles Beschaffungsmanagement aus Deutschland Bauverzögerungen verhindert und Millionen spart.', + 'image' => 'b2in/magazin-4.jpg', + 'category' => 'B2B & Partner', + 'date' => 'Februar 15, 2026', + 'readTime' => '7 min read', + 'author' => [ + 'name' => 'Marcel Scheibe', + 'bio' => 'Gründer & CEO von B2in. Als Brücke zwischen europäischen Herstellern und internationalen Entwicklern sorgt Marcel für Vertragssicherheit und Termintreue.', + 'avatar' => 'b2in/marcel-scheibe.jpg', + ], + 'content' => [ + 'intro' => 'Internationale Immobilienentwickler kennen das Problem: Ein Luxusprojekt ist nahezu fertiggestellt, doch die Innenausstattung aus Europa verzögert sich. Mangelhafte Kommunikation, versteckte Klauseln oder logistische Engpässe führen zu Bauverzögerungen, die Millionen kosten können. In einer globalisierten Welt reicht es nicht aus, Möbel und Materialien nur zu bestellen – man muss ihre Ankunft garantieren. Genau hier setzt professionelles Supply-Chain-Management an. Als verlängerter Arm vor Ort in Deutschland sorgt B2in dafür, dass Verträge nicht nur auf dem Papier existieren, sondern in der Realität pünktlich erfüllt werden.', + 'sections' => [ + [ + 'title' => 'Eskalation auf Managementebene', + 'content' => 'Wenn Lieferungen ins Stocken geraten, verpuffen E-Mails oft im Kundenservice. Effektives Supply-Chain-Management erfordert Durchsetzungskraft und die richtigen Kontakte. Durch ein tief verankertes Netzwerk in der europäischen Einrichtungsbranche greifen wir bei Abweichungen sofort ein und eskalieren Probleme direkt auf Managementebene der Hersteller, um sofortige Lösungen zu erzwingen.', + ], + [ + 'title' => 'Aktives Vertragsmanagement statt Hoffnungsprinzip', + 'content' => 'Vertragssicherheit beginnt vor der Unterschrift. Es geht darum, klare Leistungs- und Qualitätsparameter sowie harte Meilensteine zu definieren. Ein proaktives Management überwacht diese Parameter laufend und sichert Zahlungs- sowie Lieferbedingungen so ab, dass der Entwickler zu jedem Zeitpunkt die volle Kontrolle über den Prozess behält, ohne selbst operative Reibungsverluste zu erleiden.', + ], + [ + 'title' => 'Lückenloses Tracking und Qualitätskontrolle', + 'content' => 'Vertrauen ist gut, Kontrolle vor Ort ist besser. Um Ausfälle zu vermeiden, werden Produktionsfortschritte direkt an der Quelle überwacht. Durch regelmäßige, persönliche Qualitätskontrollen bei den Herstellern stellen wir sicher, dass die Ware nicht nur pünktlich verladen wird, sondern exakt den geforderten Premium-Standards der Immobilien-Projektentwickler entspricht.', + ], + ], + ], + ], + 5 => [ + 'id' => 5, + 'title' => 'Local for Local:
Wie der regionale Möbelhandel die Zukunft des Wohnens prägt', + 'subtitle' => 'Warum Konsumenten sich wieder nach Haptik und persönlicher Beratung sehnen – und wie digitale Marktplätze den lokalen Fachhandel stärken.', + 'image' => 'b2in/magazin-5.jpg', + 'category' => 'Einrichtung & Netzwerk', + 'date' => 'Februar 5, 2026', + 'readTime' => '5 min read', + 'author' => [ + 'name' => 'Marcel Scheibe', + 'bio' => 'Gründer & CEO von B2in. Marcel baut mit dem Local-for-Local-Konzept die Brücke zwischen digitalem Komfort und regionaler Fachhandels-Stärke.', + 'avatar' => 'b2in/marcel-scheibe.jpg', + ], + 'content' => [ + 'intro' => 'In den letzten Jahren schien der Trend unaufhaltsam: Gigantische Online-Plattformen dominierten den Möbelmarkt. Doch der Markt wandelt sich. Konsumenten sehnen sich wieder nach Haptik, persönlicher Beratung und sofortiger Verfügbarkeit. Gleichzeitig schlummern in den regionalen Möbelhäusern echte Schätze ("Hidden Gems"), die online oft unsichtbar bleiben. Das Konzept "Local for Local" setzt genau hier an: Es ist ein digitaler Marktplatz-Ansatz, der nicht den anonymen Großhandel, sondern den lokalen Fachhandel stärkt – und damit eine Brücke zwischen digitaler Bequemlichkeit und regionaler Stärke baut.', + 'sections' => [ + [ + 'title' => 'Digitale Sichtbarkeit für lokale Bestände', + 'content' => 'Die Frage "Was ist heute in meiner Nähe verfügbar?" konnte der lokale Handel digital oft nicht beantworten. Moderne Marktplatz-Technologien ändern das. Sie geben regionalen Händlern die Werkzeuge an die Hand, ihre sofort verfügbare Ausstellungs- und Lagerware einem breiten, kaufkräftigen Publikum (wie etwa neuen Immobilienbesitzern) sichtbar zu machen – ganz ohne komplexe eigene IT-Infrastruktur.', + ], + [ + 'title' => 'Support your Locals – Ein Gewinn für alle', + 'content' => 'Das "David gegen Goliath"-Prinzip bringt die Kunden zurück in die Geschäfte. Käufer profitieren von exklusiven Preisen für Ausstellungsstücke und Markenware, die oft günstiger ist als im Großmarkt. Der Händler wiederum steigert seine Frequenz vor Ort, baut Liquidität durch schnellen Abverkauf auf und gewinnt Neukunden, die den Wert echter, physischer Beratung schätzen.', + ], + [ + 'title' => 'Smarte Vernetzung statt anonymer Plattform', + 'content' => 'Die Zukunft gehört nicht den geschlossenen Online-Shops, sondern vernetzten Ökosystemen. Wenn Immobilienmakler, Kunden und regionale Händler auf einer Plattform zusammenkommen, entsteht ein Kreislauf des Vertrauens. Der Immobilienkauf wird zum Auslöser für den Möbelkauf, und der lokale Fachhandel wird zum verlässlichen Partner für die perfekte Einrichtung – lokal gedacht, intelligent vernetzt.', + ], + ], + ], + ], + ], ]; diff --git a/config/cookie-consent.php b/config/cookie-consent.php new file mode 100644 index 0000000..2af4d75 --- /dev/null +++ b/config/cookie-consent.php @@ -0,0 +1,127 @@ + env('COOKIE_CONSENT_ENABLED', true), + + /* + |-------------------------------------------------------------------------- + | Google Analytics ID + |-------------------------------------------------------------------------- + | + | Hier trägst du deine Tracking ID ein (z.B. G-XXXXXXXXXX). + | Wenn der Wert leer ist, wird der Analytics-Toggle nicht angezeigt. + | + */ + 'analytics_id' => env('GOOGLE_ANALYTICS_ID', ''), + + /* + |-------------------------------------------------------------------------- + | Google Tag Manager ID + |-------------------------------------------------------------------------- + | + | Optional: Wenn du den Google Tag Manager statt direktem Analytics nutzt, + | trage hier deine GTM-ID ein (z.B. GTM-XXXXXXXX). + | + | Wenn sowohl GTM als auch Analytics-ID gesetzt sind, wird GTM bevorzugt. + | Der Tag Manager lädt dann alle weiteren Tags (inkl. GA4) selbst. + | + */ + 'gtm_id' => env('GOOGLE_TAG_MANAGER_ID', ''), + + /* + |-------------------------------------------------------------------------- + | Cookie Lebensdauer + |-------------------------------------------------------------------------- + | + | Wie viele Tage soll die Entscheidung des Nutzers gespeichert bleiben? + | Standard: 365 Tage (1 Jahr). DSGVO empfiehlt max. 12 Monate. + | + */ + 'cookie_lifetime' => env('COOKIE_CONSENT_LIFETIME', 365), + + /* + |-------------------------------------------------------------------------- + | Cookie Name + |-------------------------------------------------------------------------- + | + | Der Name des Cookies, in dem die Einwilligung gespeichert wird. + | + */ + 'cookie_name' => env('COOKIE_CONSENT_NAME', 'cookie_consent'), + + /* + |-------------------------------------------------------------------------- + | Links + |-------------------------------------------------------------------------- + | + | URLs für Datenschutz und Impressum. + | + */ + 'links' => [ + 'privacy' => '/privacy', + 'imprint' => '/impressum', + ], + + /* + |-------------------------------------------------------------------------- + | Position des Floating Buttons + |-------------------------------------------------------------------------- + | + | Wo soll der Cookie-Settings-Button angezeigt werden? + | Optionen: 'bottom-left', 'bottom-right' + | + */ + 'button_position' => 'bottom-left', + + /* + |-------------------------------------------------------------------------- + | IP-Anonymisierung + |-------------------------------------------------------------------------- + | + | Google Analytics IP-Anonymisierung aktivieren (empfohlen für DSGVO). + | + */ + 'anonymize_ip' => true, + + /* + |-------------------------------------------------------------------------- + | Farben (Theme) + |-------------------------------------------------------------------------- + | + | Passe die Farben an dein Projekt-Design an. + | Du kannst Tailwind-Klassen oder CSS-Farbwerte verwenden. + | + | Für Tailwind-Projekte: Nutze deine definierten Farben wie 'primary', 'secondary' + | Für Standard-CSS: Nutze HEX-Werte wie '#009bdd' + | + */ + 'colors' => [ + // Primärfarbe für Akzente, aktive Toggles, Icons + 'primary' => env('COOKIE_CONSENT_COLOR_PRIMARY', '#0088cc'), + + // Sekundärfarbe / Hover-Zustand der Primärfarbe + 'primary_hover' => env('COOKIE_CONSENT_COLOR_PRIMARY_HOVER', '#006699'), + + // Akzeptieren-Button (grün) + 'accept' => env('COOKIE_CONSENT_COLOR_ACCEPT', '#16a34a'), + 'accept_hover' => env('COOKIE_CONSENT_COLOR_ACCEPT_HOVER', '#15803d'), + + // Einstellungen speichern Button + 'save' => env('COOKIE_CONSENT_COLOR_SAVE', '#a5a5a5'), + 'save_hover' => env('COOKIE_CONSENT_COLOR_SAVE_HOVER', '#b3b3b3'), + + // Floating Button + 'button_bg' => env('COOKIE_CONSENT_COLOR_BUTTON_BG', '#0088cc'), + 'button_hover' => env('COOKIE_CONSENT_COLOR_BUTTON_HOVER', '#006699'), + ], + +]; diff --git a/config/domains.php b/config/domains.php index 9219998..c7bdac5 100644 --- a/config/domains.php +++ b/config/domains.php @@ -20,6 +20,14 @@ return [ */ 'protocol' => env('APP_PROTOCOL', 'https://'), + /* + |-------------------------------------------------------------------------- + | Cabinet Quick-Status Key + |-------------------------------------------------------------------------- + | Secret key for the keyless-login quick status page at /info/status?k=... + */ + 'cabinet_status_key' => env('CABINET_STATUS_KEY', null), + /* |-------------------------------------------------------------------------- | Domain-Namen (ohne Protokoll) diff --git a/config/flux-cms.php b/config/flux-cms.php new file mode 100644 index 0000000..1fc2340 --- /dev/null +++ b/config/flux-cms.php @@ -0,0 +1,356 @@ + env('FLUX_CMS_DEFAULT_LOCALE', 'de'), + + /* + |-------------------------------------------------------------------------- + | Available Locales + |-------------------------------------------------------------------------- + | + | The available locales for the CMS content. + | + */ + 'locales' => [ + 'de' => 'Deutsch', + 'en' => 'English', + ], + + /* + |-------------------------------------------------------------------------- + | Component Paths + |-------------------------------------------------------------------------- + | + | The namespaces where the CMS will scan for components. + | + */ + 'component_paths' => [ + 'App\\Livewire\\Components', + 'FluxCms\\StarterComponents\\Components', + ], + + /* + |-------------------------------------------------------------------------- + | Cache Configuration + |-------------------------------------------------------------------------- + | + | Configuration for caching component registry and other CMS data. + | + */ + 'cache' => [ + 'enabled' => env('FLUX_CMS_CACHE_ENABLED', true), + 'ttl' => env('FLUX_CMS_CACHE_TTL', 3600), // 1 hour + 'key_prefix' => 'flux_cms', + 'store' => env('FLUX_CMS_CACHE_STORE', null), // null = default cache store + ], + + /* + |-------------------------------------------------------------------------- + | Database Configuration + |-------------------------------------------------------------------------- + | + | Database table configuration for Flux CMS. + | + */ + 'database' => [ + 'table_prefix' => 'flux_cms_', + 'connection' => env('FLUX_CMS_DB_CONNECTION', null), // null = default connection + ], + + /* + |-------------------------------------------------------------------------- + | Authentication & Authorization + |-------------------------------------------------------------------------- + | + | Configuration for user authentication and authorization. + | + */ + 'auth' => [ + 'guard' => env('FLUX_CMS_AUTH_GUARD', 'web'), + 'default_access' => env('FLUX_CMS_DEFAULT_ACCESS', false), + 'super_admin_role' => 'admin', + 'cms_role' => 'flux-cms', + 'permissions' => [ + 'view' => 'flux-cms.view', + 'edit' => 'flux-cms.edit', + 'publish' => 'flux-cms.publish', + 'delete' => 'flux-cms.delete', + 'admin' => 'flux-cms.admin', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Routes Configuration + |-------------------------------------------------------------------------- + | + | Configuration for CMS routes. + | + */ + 'routes' => [ + 'enabled' => env('FLUX_CMS_ROUTES_ENABLED', false), + 'prefix' => env('FLUX_CMS_ROUTE_PREFIX', ''), + 'middleware' => ['web'], + 'admin_prefix' => env('FLUX_CMS_ADMIN_PREFIX', 'admin/cms'), + 'admin_middleware' => ['web', 'auth'], + ], + + /* + |-------------------------------------------------------------------------- + | SEO Configuration + |-------------------------------------------------------------------------- + | + | SEO-related configuration options. + | + */ + 'seo' => [ + 'site_name' => env('FLUX_CMS_SITE_NAME', config('app.name')), + 'separator' => env('FLUX_CMS_SEO_SEPARATOR', ' - '), + 'meta_keywords_limit' => 10, + 'meta_description_limit' => 160, + 'auto_sitemap' => true, + 'auto_robots' => true, + 'canonical_urls' => true, + 'og_image_default' => null, + ], + + /* + |-------------------------------------------------------------------------- + | Media Configuration + |-------------------------------------------------------------------------- + | + | Configuration for media handling. + | + */ + 'media' => [ + 'disk' => env('FLUX_CMS_MEDIA_DISK', 'public'), + 'max_file_size' => env('FLUX_CMS_MAX_FILE_SIZE', 10240), // 10MB in KB + 'originals_path' => 'cms/media/originals', + 'conversions_path' => 'cms/media/conversions', + 'allowed_extensions' => [ + 'images' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'], + 'documents' => ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'csv'], + ], + 'profiles' => [ + 'thumb' => [ + 'width' => 300, + 'height' => 300, + 'format' => 'webp', + 'quality' => 80, + 'fit' => 'cover', + ], + 'hero' => [ + 'width' => 1920, + 'height' => 800, + 'format' => 'webp', + 'quality' => 85, + 'fit' => 'cover', + ], + 'card' => [ + 'width' => 768, + 'height' => 512, + 'format' => 'webp', + 'quality' => 80, + 'fit' => 'cover', + ], + 'gallery' => [ + 'width' => 1200, + 'height' => 900, + 'format' => 'webp', + 'quality' => 85, + 'fit' => 'cover', + ], + 'service' => [ + 'width' => 800, + 'height' => 600, + 'format' => 'webp', + 'quality' => 85, + 'fit' => 'cover', + ], + 'avatar' => [ + 'width' => 400, + 'height' => 400, + 'format' => 'webp', + 'quality' => 85, + 'fit' => 'cover', + ], + 'thumbnail' => [ + 'width' => 600, + 'height' => 400, + 'format' => 'webp', + 'quality' => 80, + 'fit' => 'cover', + ], + 'og_image' => [ + 'width' => 1200, + 'height' => 630, + 'format' => 'jpg', + 'quality' => 90, + 'fit' => 'cover', + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Editor Configuration + |-------------------------------------------------------------------------- + | + | Configuration for WYSIWYG editors. + | + */ + 'editor' => [ + 'default' => env('FLUX_CMS_EDITOR', 'tiptap'), // tiptap, tinymce, quill + 'upload_images' => true, + 'max_image_size' => 2048, // 2MB in KB + 'toolbar_presets' => [ + 'basic' => ['bold', 'italic'], + 'standard' => ['bold', 'italic', 'link', 'bulletList', 'orderedList'], + 'full' => [ + 'bold', + 'italic', + 'underline', + 'strike', + 'heading1', + 'heading2', + 'heading3', + 'bulletList', + 'orderedList', + 'link', + 'image', + 'table', + 'code', + 'codeBlock', + 'quote', + 'rule', + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Versioning Configuration + |-------------------------------------------------------------------------- + | + | Configuration for content versioning. + | + */ + 'versioning' => [ + 'enabled' => env('FLUX_CMS_VERSIONING_ENABLED', true), + 'auto_version' => env('FLUX_CMS_AUTO_VERSION', true), + 'max_versions' => env('FLUX_CMS_MAX_VERSIONS', 50), + 'cleanup_old_versions' => env('FLUX_CMS_CLEANUP_VERSIONS', true), + 'cleanup_after_days' => env('FLUX_CMS_CLEANUP_AFTER_DAYS', 365), + ], + + /* + |-------------------------------------------------------------------------- + | Performance Configuration + |-------------------------------------------------------------------------- + | + | Performance-related configuration options. + | + */ + 'performance' => [ + 'eager_load_relations' => ['components', 'media'], + 'pagination_size' => 20, + 'component_registry_cache' => true, + 'query_cache_ttl' => 300, // 5 minutes + ], + + /* + |-------------------------------------------------------------------------- + | Development Configuration + |-------------------------------------------------------------------------- + | + | Configuration options for development mode. + | + */ + 'development' => [ + 'debug_mode' => env('FLUX_CMS_DEBUG', false), + 'show_component_info' => env('FLUX_CMS_SHOW_COMPONENT_INFO', false), + 'log_queries' => env('FLUX_CMS_LOG_QUERIES', false), + 'hot_reload_components' => env('FLUX_CMS_HOT_RELOAD', false), + ], + + /* + |-------------------------------------------------------------------------- + | Frontend Configuration + |-------------------------------------------------------------------------- + | + | Configuration for frontend rendering. + | + */ + 'frontend' => [ + 'layout' => env('FLUX_CMS_LAYOUT', 'layouts.app'), + 'theme' => env('FLUX_CMS_THEME', 'default'), + 'css_framework' => env('FLUX_CMS_CSS_FRAMEWORK', 'tailwind'), // tailwind, bootstrap + 'component_wrapper' => env('FLUX_CMS_COMPONENT_WRAPPER', true), + 'preview_mode' => env('FLUX_CMS_PREVIEW_MODE', true), + ], + + /* + |-------------------------------------------------------------------------- + | API Configuration + |-------------------------------------------------------------------------- + | + | Configuration for CMS API endpoints. + | + */ + 'api' => [ + 'enabled' => env('FLUX_CMS_API_ENABLED', false), + 'prefix' => env('FLUX_CMS_API_PREFIX', 'api/cms'), + 'middleware' => ['api', 'auth:sanctum'], + 'rate_limiting' => env('FLUX_CMS_API_RATE_LIMIT', '60,1'), + ], + + /* + |-------------------------------------------------------------------------- + | Backup Configuration + |-------------------------------------------------------------------------- + | + | Configuration for content backups. + | + */ + 'backup' => [ + 'enabled' => env('FLUX_CMS_BACKUP_ENABLED', true), + 'disk' => env('FLUX_CMS_BACKUP_DISK', 'local'), + 'auto_backup' => env('FLUX_CMS_AUTO_BACKUP', true), + 'backup_schedule' => env('FLUX_CMS_BACKUP_SCHEDULE', 'daily'), + 'keep_backups' => env('FLUX_CMS_KEEP_BACKUPS', 30), + ], + + /* + |-------------------------------------------------------------------------- + | Domain Configuration + |-------------------------------------------------------------------------- + | + | Multi-domain support configuration. + | + */ + 'domains' => [ + 'enabled' => env('FLUX_CMS_MULTI_DOMAIN', true), + 'config_source' => 'domains', // 'domains' config key or 'database' + 'default_domain' => env('FLUX_CMS_DEFAULT_DOMAIN', 'default'), + 'auto_detect' => env('FLUX_CMS_AUTO_DETECT_DOMAIN', true), + ], + +]; diff --git a/database/data/b2in-en/about_hero.json b/database/data/b2in-en/about_hero.json new file mode 100644 index 0000000..6d83967 --- /dev/null +++ b/database/data/b2in-en/about_hero.json @@ -0,0 +1,10 @@ +{ + "title": "About B2in: Our Mission", + "quote": "“My mission is to connect two worlds that belong together: international real estate and exclusive furnishings. B2in gives the local subject matter expert the digital tools and the real estate developer the operational partner on site.

We build bridges – between European design, international markets and people's homes.\"", + "founder_name": "Marcel Scheibe", + "founder_title": "Founder & CEO, B2in", + "image": "b2in/about-hero.jpg", + "image_alt": "Marcel Scheibe, founder and CEO of B2in", + "card_title": "B2in", + "card_text": "Connecting Design and Property" +} diff --git a/database/data/b2in-en/about_image_break.json b/database/data/b2in-en/about_image_break.json new file mode 100644 index 0000000..7b8425e --- /dev/null +++ b/database/data/b2in-en/about_image_break.json @@ -0,0 +1,6 @@ +{ + "image": "b2in/best-of-two-worlds.jpg", + "image_alt": "B2in – Connecting real estate and furnishings", + "quote": "Regionally rooted, internationally networked – that's B2in.", + "author": "Marcel Scheibe" +} diff --git a/database/data/b2in-en/announcement_bar.json b/database/data/b2in-en/announcement_bar.json new file mode 100644 index 0000000..b969eaf --- /dev/null +++ b/database/data/b2in-en/announcement_bar.json @@ -0,0 +1,8 @@ +{ + "enabled": true, + "id": "azizi-launch-2026", + "badge": "NEW LAUNCH", + "text": "Azizi Creek Views 4 – Exclusive off-market project in Dubai from AED 1,125,000 (approx. EUR 283,000/USD 306,000)", + "link_text": "View Exposé", + "link_url": "/immobilien/azizi-creek-views-4" +} diff --git a/database/data/b2in-en/brand_worlds.json b/database/data/b2in-en/brand_worlds.json new file mode 100644 index 0000000..5b603ae --- /dev/null +++ b/database/data/b2in-en/brand_worlds.json @@ -0,0 +1,33 @@ +{ + "title": "Our worlds:
design, real estate and international trade", + "subtitle": "From international real estate investments to exclusive furnishing concepts to transatlantic trade – B2in connects the worlds that belong together.", + "worlds": [ + { + "image": "b2in/stileigentum.jpg", + "title": "Style Property", + "description": "The premium segment: exclusive and high-quality furnishing concepts for demanding customers who value quality and tradition.", + "link": "https://stileigentum.test", + "logo": "img/logos/style-ownership-logo-positive.svg", + "logo_width": "w-35", + "external": true + }, + { + "image": "b2in/style2own.jpg", + "title": "Style2own", + "description": "The lifestyle channel: Modern furnishing concepts for young professionals and trend-oriented customers – urban, flexible, inspiring.", + "link": "https://style2own.test", + "logo": "img/logos/style2own-logo-positive.svg", + "logo_width": "W 28", + "external": true + }, + { + "image": "b2in/b2a.jpg", + "title": "B2A", + "description": "Our logistics power for transatlantic trade. We give manufacturers direct access to international markets.", + "link": "https://b2a.test", + "logo": "img/logos/b2a-logo-positive.svg", + "logo_width": "W.18.", + "external": true + } + ] +} diff --git a/database/data/b2in-en/broker_section.json b/database/data/b2in-en/broker_section.json new file mode 100644 index 0000000..34e2c86 --- /dev/null +++ b/database/data/b2in-en/broker_section.json @@ -0,0 +1,32 @@ +{ + "title": "Lifetime remuneration for brokers", + "subtitle": "Benefit from a revolutionary compensation model that goes beyond one-time sales. Build long-term customer relationships and generate continuous revenue.", + "card_title": "Lifetime Compensation", + "compensation": { + "initial_sale": "3.5%", + "follow_up": "1.5%" + }, + "compensation_text": "Continuous revenue across the entire customer relationship", + "benefits": [ + { + "title": "Lifetime Compensation Model", + "description": "Continuous commissions through long-term customer relationships and recurring business", + "icon": "trending-up" + }, + { + "title": "Faster time to market", + "description": "Thoughtful living concepts reduce sales time and increase the chances of success", + "icon": "clock" + }, + { + "title": "Qualified leads", + "description": "Pre-filtered, interested customers through the B2in portal and premium memberships", + "icon": "target" + }, + { + "title": "Premium positioning", + "description": "Exclusive marketing of high-quality living concepts for demanding target groups", + "icon": "German Comedy Award" + } + ] +} diff --git a/database/data/b2in-en/commitment_section.json b/database/data/b2in-en/commitment_section.json new file mode 100644 index 0000000..c74681c --- /dev/null +++ b/database/data/b2in-en/commitment_section.json @@ -0,0 +1,27 @@ +{ + "title": "The trust of our partners", + "subtitle": "Real opinions from real partners. Your success is our greatest incentive.", + "testimonials": [ + { + "image": "b2in/testo-1.jpg", + "rating": 5, + "quote": "Working with B2in exceeded our expectations. Professional, efficient and always solution-oriented.", + "author": "John Smith", + "author_title": ": Furniture manufacturer" + }, + { + "image": "b2in/testo-2.jpg", + "rating": 5, + "quote": "Thanks to the B2in platform, we were able to significantly increase our reach and open up new markets.", + "author": "John Doe", + "author_title": "local furniture retailer" + }, + { + "image": "b2in/testo-3.jpg", + "rating": 5, + "quote": "The B2in portal has revolutionized the way I market real estate. The staging enhances my properties, and the furniture commission is an extremely attractive additional income.", + "author": "John Doe", + "author_title": "Real estate agent" + } + ] +} diff --git a/database/data/b2in-en/contact_form.json b/database/data/b2in-en/contact_form.json new file mode 100644 index 0000000..ceef114 --- /dev/null +++ b/database/data/b2in-en/contact_form.json @@ -0,0 +1,87 @@ +{ + "hero": { + "title": "Send us a Message", + "subtitle": "We look forward to hearing from you and will be in touch as soon as possible.Your query" + }, + "form": { + "labels": { + "first_name": "First name *", + "last_name": "Last name *", + "company": "Company (optional)", + "email": "Email *", + "phone": "Phone (optional)", + "subject": "Subject *", + "message": "Your message *" + }, + "subjects": { + "": "please choose a subject", + "immobilien": "International", + "supply_chain": "Supply chain management", + "general": "General enquiry", + "partnership": "Partnership", + "press": "Press", + "career": "Career" + }, + "placeholders": { + "message": "Your message" + }, + "button_text": "Submit", + "button_loading": "Submitting…", + "success_message": "General Terms Sincere thanks for your message. We will reply to you soon." + }, + "contact_info": [ + { + "title": "Our office location", + "info": [ + "Rathausstraße 11", + "33602 Bielefeld" + ], + "icon": "map-pin" + }, + { + "title": "Our Email", + "info": [ + "info@b2in.eu" + ], + "icon": "mail:" + }, + { + "title": "Our contact number", + "info": [ + "+49 (0) 5221 9255055" + ], + "icon": "phone" + } + ], + "social_media": { + "title": "Follow for
exclusives", + "subtitle": "Stay up to date with exclusive offers and news.", + "platforms": [ + { + "name": "Instagram", + "handle": "@b2in_official", + "url": "https://instagram.com/b2in_official" + }, + { + "name": "YouTube", + "handle": "B2IN Channel", + "url": "https://youtube.com/@b2in" + }, + { + "name": "pinterest", + "handle": "B2IN Inspiration", + "url": "https://pinterest.com/b2in" + }, + { + "name": "Facebook", + "handle": "B2IN Germany", + "url": "https://facebook.com/b2in" + }, + { + "name": "LinkedIn", + "handle": "B2IN Company", + "url": "https://linkedin.com/company/b2in" + } + ] + } +} diff --git a/database/data/b2in-en/cta_section.json b/database/data/b2in-en/cta_section.json new file mode 100644 index 0000000..5ec163d --- /dev/null +++ b/database/data/b2in-en/cta_section.json @@ -0,0 +1,6 @@ +{ + "title": "Your next step", + "subtitle": "Whether real estate investment, supply chain partnership or furnishing network – talk to us directly.", + "button_text": "Get in touch", + "button_link": "/contact" +} diff --git a/database/data/b2in-en/dark_stats_section.json b/database/data/b2in-en/dark_stats_section.json new file mode 100644 index 0000000..8343f55 --- /dev/null +++ b/database/data/b2in-en/dark_stats_section.json @@ -0,0 +1,30 @@ +{ + "stats": [ + { + "number": "17+", + "text": "Years of Experience" + }, + { + "number": "2M", + "text": "Happy Guests" + } + ], + "title": "Economically Sound and
Well- Friendly Service for
Families and Their
Precious Belongings", + "description": "We understand that every family is unique, which is why we offer personalized services tailored to meet your specific needs. From luxury amenities to budget-friendly options, we ensure every guest feels valued and comfortable.", + "features": [ + { + "title": "Top Consultation", + "subtitle": "Expert Guidance/" + }, + { + "title": "Finest Meal", + "subtitle": "Gourmet Dining." + }, + { + "title": "Easy Tax Reduction", + "subtitle": "• Cost effective." + } + ], + "image": "b2in/accommodation-2.jpg", + "image_alt": "Luxury interior design" +} diff --git a/database/data/b2in-en/digital_core.json b/database/data/b2in-en/digital_core.json new file mode 100644 index 0000000..ab9493e --- /dev/null +++ b/database/data/b2in-en/digital_core.json @@ -0,0 +1,39 @@ +{ + "title": "The technology that enables this cycle", + "subtitle": "Our central platform is more than just technology. It is the digital heart of transparent processes, data-driven decisions and seamless collaboration across the ecosystem.", + "features": [ + { + "title": "Maximize delivery reliability", + "description": "Our platform is accessible anytime, anywhere. It grows with your success and guarantees a stable operation you can rely on.", + "icon": "cloud", + "icon_style": "solid" + }, + { + "title": "Safety without compromise!", + "description": "Your data and that of your customers is our greatest asset. We protect them with state-of-the-art security architectures and guarantee the fullest data protection.", + "icon": "shield-check" + }, + { + "title": "Sustainable integration", + "description": "The B2in portal is open for the future. It integrates seamlessly with other systems and is ready for future enhancements and technologies.", + "icon": "squares-2x2" + }, + { + "title": "Data-driven insights", + "description": "As a broker, track your customers' activities or as a supplier, track the performance of your products – all in real time. Make better decisions based on valid data.", + "icon": "chart-bar", + "icon_style": "solid" + }, + { + "title": "Intelligent personalisation", + "description": "Artificial intelligence supports you and your customers – from personalized set-up suggestions to automating processes for maximum efficiency.", + "icon": "cpu chip" + }, + { + "title": "Excellent user experience", + "description": "Our platform is optimized for maximum speed and offers intuitive and fluid operation on any device – from desktop to smartphone.", + "icon": "users", + "icon_style": "solid" + } + ] +} diff --git a/database/data/b2in-en/ecosystem_core.json b/database/data/b2in-en/ecosystem_core.json new file mode 100644 index 0000000..2f66b01 --- /dev/null +++ b/database/data/b2in-en/ecosystem_core.json @@ -0,0 +1,24 @@ +{ + "title": "One ecosystem, three pillars", + "subtitle": "", + "pillars": [ + { + "icon": "building-office-2", + "title": "Real Estate & Investments", + "description": "Exclusive off-market projects & high-yield investment properties.", + "link": "/immobilien" + }, + { + "icon": "squares-2x2", + "title": "Local-for-Local Marketplace", + "description": "The network for regional furniture retailers and brokers.", + "link": "/netzwerk" + }, + { + "icon": "clipboard-document-check", + "title": "Supply chain management", + "description": "German contract security for international real estate developers.", + "link": "/netzwerk" + } + ] +} diff --git a/database/data/b2in-en/ecosystem_hero.json b/database/data/b2in-en/ecosystem_hero.json new file mode 100644 index 0000000..0c67b06 --- /dev/null +++ b/database/data/b2in-en/ecosystem_hero.json @@ -0,0 +1,39 @@ +{ + "title": "How our ecosystem generates growth for all partners", + "subtitle": "An intelligent network that seamlessly connects real estate buyers, local experts, international manufacturers, brokers and developers – for exceptional real estate and furnishing experiences.", + "features": [ + { + "title": "Real estate", + "description": "International Investments", + "icon": "globe-alt" + }, + { + "title": "setup", + "description": "Local for Local", + "icon": "cube-transparent" + }, + { + "title": "Supply Chain", + "description": "Procurement & Control", + "icon": "clipboard-document-check" + }, + { + "title": "Technology", + "description": "Digital centerpiece", + "icon": "cpu chip" + } + ], + "image": "b2in/ecosystem-hero.jpg", + "image_alt": "Ecosystem Hero Image", + "card_title": "B2in Portal", + "card_text": "The technology behind it that enables the ecosystem", + "hub": { + "title": "B2in Portal", + "subtitle": "The technology behind it that enables the ecosystem" + }, + "stats": [ + "Exclusive Selection", + "Service that is personal", + "Werte, die bleiben." + ] +} diff --git a/database/data/b2in-en/ecosystem_hub.json b/database/data/b2in-en/ecosystem_hub.json new file mode 100644 index 0000000..0a97081 --- /dev/null +++ b/database/data/b2in-en/ecosystem_hub.json @@ -0,0 +1,11 @@ +{ + "title": "Local for Local meets international expertise at the hub", + "paragraphs": [ + "As soon as a customer chooses their region, our platform plays to its strength: the \"Local First\"logic prominently displays the offers of the local subject matter experts.", + "The range is supplemented by European manufacturers. And for real estate developers, we deliver the operational procurement right away – from the contract to quality control." + ], + "image": "b2in/ecosystem_hub.jpg", + "image_alt": "Local for Local – local expertise meets international procurement", + "image_caption": "Local for Local: Local expertise, international reach", + "image_description": "A clear infographic showing: [Local Supply] + [International Procurement] = The B2in Ecosystem." +} diff --git a/database/data/b2in-en/ecosystem_result.json b/database/data/b2in-en/ecosystem_result.json new file mode 100644 index 0000000..2abd12d --- /dev/null +++ b/database/data/b2in-en/ecosystem_result.json @@ -0,0 +1,24 @@ +{ + "title": "A cycle where everyone wins", + "paragraphs": [ + "This interaction creates clear advantages for each participant:" + ], + "list": [ + { + "icon": "globe-alt", + "title": "The real estate developer receives a reliable partner for procurement and quality control in Germany – transparently and on time." + }, + { + "icon": "building-storefront", + "title": "The local trader gains qualified customers he would not have reached on his own and strengthens his position on the ground." + }, + { + "icon": "home-modern", + "title": "The broker offers its customers unique added value after closing and benefits from attractive additional commissions." + } + ], + "image": "b2in/ecosystem_result.jpg", + "image_alt": "Your success and partnership with B2in", + "image_caption": "Your success and partnership with B2in", + "image_description": "A stylized graphic that shows how your success and partnership are related to B2in." +} diff --git a/database/data/b2in-en/ecosystem_start.json b/database/data/b2in-en/ecosystem_start.json new file mode 100644 index 0000000..bef6795 --- /dev/null +++ b/database/data/b2in-en/ecosystem_start.json @@ -0,0 +1,11 @@ +{ + "title": "It all starts with the moment of need:", + "paragraphs": [ + "Our ecosystem starts with the customer – exactly when they need it: when buying real estate.", + "The broker gives the customer access to the B2in ecosystem. Our style2own and style property brands create the right framework – depending on the target group and lifestyle." + ], + "image": "b2in/ecosystem_start.jpg", + "image_alt": "The entry into the ecosystem – via the purchase of real estate", + "image_caption": "Getting Started: Buying Real Estate as a Trigger", + "image_description": "A stylized graphic showing how real estate buying triggers entry into the B2in ecosystem." +} diff --git a/database/data/b2in-en/ecosystem_stats.json b/database/data/b2in-en/ecosystem_stats.json new file mode 100644 index 0000000..c68ed1d --- /dev/null +++ b/database/data/b2in-en/ecosystem_stats.json @@ -0,0 +1,26 @@ +{ + "title": "Our Ecosystem in Numbers", + "subtitle": "Figures that reflect the strength and confidence in our connected business model.", + "stats": [ + { + "number": "1.7K+", + "label": "Partners & experts in the network", + "description": "Growing community of developers, brokers, dealers and manufacturers" + }, + { + "number": "510+", + "label": "Projects completed", + "description": "Real estate and furnishing projects through our network" + }, + { + "number": "98%", + "label": "Partner Satisfaction", + "description": "Satisfaction across all ecosystem participants" + }, + { + "number": "All day", + "label": "Partner Support", + "description": "Continuous availability of digital infrastructure" + } + ] +} diff --git a/database/data/b2in-en/end_customer_section.json b/database/data/b2in-en/end_customer_section.json new file mode 100644 index 0000000..07473ba --- /dev/null +++ b/database/data/b2in-en/end_customer_section.json @@ -0,0 +1,37 @@ +{ + "tag": "For end customers", + "title": "Exclusive experiences for you", + "subtitle": "With your personal login card, you get access to a unique world of experiences that is specially tailored to your living wishes and lifestyle. Discover curated properties and services that are otherwise unavailable.", + "benefits": [ + { + "title": "Exclusive Login Card", + "description": "Personalized access to select real estate experiences and premium services", + "icon": "- Credit Card" + }, + { + "title": "Personalised experience", + "description": "Tailored property offers based on individual preferences and needs", + "icon": "star" + }, + { + "title": "Curated living concepts", + "description": "High-quality, well thought-out real estate solutions from verified partners", + "icon": "home" + }, + { + "title": "Quality guarantee", + "description": "Tested vendors and standardized quality processes for maximum safety", + "icon": "shield" + } + ], + "image": "b2in/end-customer-section.jpg", + "image_alt": "End Customer Section Image", + "card_title": "Login Card", + "card_text": "Your key to exclusive real estate experiences", + "card": { + "title": "Login Card", + "subtitle": "Your key to exclusive real estate experiences", + "member_number_label": "Membership number", + "member_number": "B2IN-2024-VIP" + } +} diff --git a/database/data/b2in-en/faq.json b/database/data/b2in-en/faq.json new file mode 100644 index 0000000..f749e30 --- /dev/null +++ b/database/data/b2in-en/faq.json @@ -0,0 +1,64 @@ +{ + "title": "Frequently Asked Questions", + "subtitle": "Here you will find answers to the most common questions about B2in and our ecosystem.", + "questions": [ + { + "question": "What is B2in and what services do you offer?", + "answer": "B2in is the bridge between international premium real estate and exclusive furnishing concepts. We offer real estate investors access to off-market projects (focus: Dubai) and guide them through the entire purchase process. At the same time, we offer developers a tough supply chain management for the procurement of German quality furniture and build an innovative furnishing network for local dealers." + }, + { + "question": "What does supply chain management mean at B2in?", + "answer": "For international real estate developers, we act as an extended arm in Germany. We take over the operational and strategic control of furniture and interior design procurement. This means: we secure supply contracts, monitor milestones directly with the manufacturers and immediately escalate to the management level in the event of deviations. Our goal is absolute contract security and on-time delivery without friction." + }, + { + "question": "How can I become a partner at B2in?", + "answer": "Our network is aimed at real estate developers, brokers and regional furniture retailers. If you are a developer looking for German procurement security or if you would like to become part of our future \"Local for Local\"network as a dealer in order to receive qualified leads from property buyers, simply use our contact form. We check each partnership individually for quality and accuracy of fit." + }, + { + "question": "How does real estate investment work via B2in?", + "answer": "Wir vermitteln nicht nur, wir begleiten Sie. Marcel Scheibe ist Ihr persönlicher Berater, der den Markt (insbesondere Dubai) aus eigener Investorensicht kennt. Nach einer Bedarfsanalyse stellen wir Ihnen exklusive Projekte vor (z. B. von Azizi Developments). Der Kaufprozess selbst ist durch das staatliche Escrow-System in Dubai maximal abgesichert. Ihr besonderer Vorteil: Als B2in-Kunde erhalten Sie im Anschluss exklusiven Zugang zu unserem Netzwerk, um Ihr Investment schlüsselfertig (Turnkey) und renditeoptimiert einrichten zu lassen." + }, + { + "question": "What makes B2in a trusted partner?", + "answer": "Trust is created through transparency and own market experience. B2in founder Marcel Scheibe himself invests in the markets we offer and is regularly on site to check construction progress. We combine the immense dynamism of international markets (such as Dubai) with German reliability, strict contract management and a curated network of premium partners such as CABINET. With us, you always have a personal contact person who represents your interests." + } + ], + "sections": [ + { + "title": "Focus: Real estate investors (B2C)", + "icon": "home-modern", + "questions": [ + { + "question": "Do I have to travel to Dubai in person to buy property?", + "answer": "No, the entire purchase process can be handled completely digitally and legally binding remotely. The Dubai Land Department offers highly secure processes for this. Although we would like to recommend our investors to get an idea of the dynamics of the city on site, your physical presence is not absolutely necessary for the legally compliant acquisition." + }, + { + "question": "What happens after buying a property? Will you help set it up?", + "answer": "This is precisely where the great advantage of the B2in ecosystem lies. Our service does not end with the purchase contract. As a B2in customer, you get exclusive access to our furnishing network. We support you in equipping your property with high-quality furniture in a return-optimised and turnkey manner – the perfect basis for a lucrative short-term rental." + }, + { + "question": "What are the tax benefits of investing in Dubai?", + "answer": "Dubai offers a unique economic environment in the world. There is no income tax on rental income or capital gains tax on the sale of the property. In combination with the strong rental yields, this makes the location so strategically valuable for international investors." + } + ] + }, + { + "title": "Focus: B2B partners (brokers & dealers)", + "icon": "BRIEFCASE", + "questions": [ + { + "question": "How do real estate agents benefit from a partnership with B2in?", + "answer": "B2in offers agents an exclusive tool for customer loyalty: you can give your property buyers access to our closed furnishing network as an exclusive \"closing gift\". At the same time, as a broker, you benefit from a passive lifetime remuneration on your customers' furniture sales through our technical clearing system." + }, + { + "question": "What exactly is the \"Local for Local\" marketplace?", + "answer": "Unser „Local for Local\"-Prinzip ist der Gegenentwurf zu anonymen Online-Möbelgiganten. Wir machen die sofort verfügbaren Bestände und Ausstellungsstücke regionaler Fachhändler (Säule „Local Express\") für einen geschlossenen Kundenkreis transparent. So stärken wir den lokalen Handel vor Ort und bieten Käufern gleichzeitig Insider-Konditionen für Premium-Einrichtung." + }, + { + "question": "Ist das Einrichtungsnetzwerk für jeden öffentlich zugänglich?", + "answer": "Nein. Um die exklusiven Insider-Konditionen und Rabatte unserer Hersteller und lokalen Händler zu schützen, agiert der B2in-Möbelmarktplatz als „Closed Shop\". Der Zugang erfolgt ausschließlich über Einladungen – beispielsweise durch unsere Partner-Makler beim Erwerb einer Immobilie." + } + ] + } + ] +} diff --git a/database/data/b2in-en/final_commitment.json b/database/data/b2in-en/final_commitment.json new file mode 100644 index 0000000..cd92c96 --- /dev/null +++ b/database/data/b2in-en/final_commitment.json @@ -0,0 +1,5 @@ +{ + "title": "Were committed to
your comfort and
satisfaction for
unforgettable
experiences", + "author": "Robert Wilson", + "author_title": "General Manager" +} diff --git a/database/data/b2in-en/founder_bar.json b/database/data/b2in-en/founder_bar.json new file mode 100644 index 0000000..2669458 --- /dev/null +++ b/database/data/b2in-en/founder_bar.json @@ -0,0 +1,6 @@ +{ + "image": "b2in/marcel-scheibe.jpg", + "name": "Marcel Scheibe", + "title": "Founder & CEO, B2in", + "statement": "B2in by Marcel Scheibe – Ihr persönlicher Partner für internationale Immobilien und exklusive Einrichtungskonzepte." +} diff --git a/database/data/b2in-en/header.json b/database/data/b2in-en/header.json new file mode 100644 index 0000000..fc0a1c2 --- /dev/null +++ b/database/data/b2in-en/header.json @@ -0,0 +1,29 @@ +{ + "portal_login": "Partner-Login", + "navigation": [ + { + "label": "Home", + "url": "/" + }, + { + "label": "Real estate", + "url": "/immobilien" + }, + { + "label": "Netzwerk", + "url": "/netzwerk" + }, + { + "label": "Magazin", + "url": "/magazin" + }, + { + "label": "FAQ", + "url": "/faq" + }, + { + "label": "Über B2in", + "url": "/about" + } + ] +} diff --git a/database/data/b2in-en/hero.json b/database/data/b2in-en/hero.json new file mode 100644 index 0000000..55d2d61 --- /dev/null +++ b/database/data/b2in-en/hero.json @@ -0,0 +1,17 @@ +{ + "title": "B2in – Wo exklusive Immobilien-Investments auf smartes Interior treffen.", + "subtitle": "Ihr Partner für renditestarke internationale Immobilien und globales Supply-Chain-Management im Interior-Bereich.", + "image": "b2in/hero-room.jpg", + "image_alt": "B2in – Internationale Immobilien und exklusive Einrichtungskonzepte", + "cta1_text": "Zu den Immobilien-Projekten", + "cta1_link": "/immobilien", + "cta2_text": "Unser Netzwerk", + "cta2_link": "/netzwerk", + "stats": [ + "International", + "Exklusive Einrichtung", + "Persönliche Beratung" + ], + "card_title": "B2in", + "card_text": "Connecting Design and Property" +} diff --git a/database/data/b2in-en/immobilien_bruecke.json b/database/data/b2in-en/immobilien_bruecke.json new file mode 100644 index 0000000..fd3d0c8 --- /dev/null +++ b/database/data/b2in-en/immobilien_bruecke.json @@ -0,0 +1,14 @@ +{ + "title": "\"Der Markt spricht für sich. Meine Aufgabe ist eine andere.\"", + "paragraphs": [ + "Für viele deutsche Investoren wirkt der Immobilienkauf in Dubai zunächst ungewohnt. In Deutschland sind wir Notare, Grundbücher und hochbürokratische Prozesse gewohnt. Dubai ist dynamischer.", + "Ich bin nicht hier, um Ihnen den Markt zu verkaufen – die Qualität der Projekte spricht für sich. Meine Aufgabe ist es, Ihre Brücke zu sein. Als Investor, der selbst in Dubai gekauft hat, kenne ich die Praxis. Ich bin regelmäßig vor Ort, stehe im permanenten Austausch mit Bauträgern und begleite Sie durch den kompletten Prozess. Ich übersetze die internationale Geschwindigkeit in deutsche Verlässlichkeit." + ], + "advantage_title": "Ihr B2in-Vorteil", + "advantage_text": "Meine Begleitung endet nicht beim Kauf. Planen Sie die lukrative Kurzzeitvermietung (z.B. Airbnb)? Über unser B2in-Netzwerk und meine Wurzeln in der Möbelbranche realisieren wir für Sie komplette Einrichtungskonzepte – für maximale Rendite bei minimalem Aufwand.", + "cta_text": "Persönliches Beratungsgespräch vereinbaren", + "cta_link": "/contact", + "image": "b2in/marcel-scheibe-about.jpg", + "image_alt": "Marcel Scheibe – Persönliche Investmentbegleitung", + "image_caption": "Marcel Scheibe, Gründer & CEO" +} diff --git a/database/data/b2in-en/immobilien_hero.json b/database/data/b2in-en/immobilien_hero.json new file mode 100644 index 0000000..17f450e --- /dev/null +++ b/database/data/b2in-en/immobilien_hero.json @@ -0,0 +1,39 @@ +{ + "title": "Investieren Sie in die Zukunft – Dubai, Lissabon & mehr.", + "subtitle": "Exklusive Off-Market-Projekte und High-Yield-Investments. Persönlich kuratiert und begleitet von Marcel Scheibe.", + "features": [ + { + "title": "Off-Market", + "description": "Exklusive Projekte", + "icon": "lock-closed" + }, + { + "title": "High-Yield", + "description": "Renditestarke Investments", + "icon": "arrow-trending-up" + }, + { + "title": "Persönlich", + "description": "Begleitung durch Marcel Scheibe", + "icon": "user" + }, + { + "title": "International", + "description": "Dubai, Lissabon & mehr", + "icon": "globe-alt" + } + ], + "image": "b2in/hero-immobilien.jpg", + "image_alt": "Internationale Immobilien-Investments", + "card_title": "B2in Immobilien", + "card_text": "Exklusive internationale Investments", + "hub": { + "title": "B2in Immobilien", + "subtitle": "Exklusive internationale Investments" + }, + "stats": [ + "Off-Market Zugang", + "Persönliche Begleitung", + "Renditestarke Objekte" + ] +} diff --git a/database/data/b2in-en/immobilien_hero_v2.json b/database/data/b2in-en/immobilien_hero_v2.json new file mode 100644 index 0000000..0eebf8e --- /dev/null +++ b/database/data/b2in-en/immobilien_hero_v2.json @@ -0,0 +1,8 @@ +{ + "title": "Investieren in globale Dynamik. Mit deutscher Verlässlichkeit.", + "subtitle": "Exklusive Off-Market-Projekte, attraktive Renditen und eine Begleitung, die weit über den Kaufvertrag hinausgeht. Entdecken Sie den Immobilienmarkt in Dubai.", + "cta_text": "Aktuelle Projekte ansehen", + "cta_link": "#projekte", + "image": "b2in/hero-immobilien.jpg", + "image_alt": "Dubai Skyline – Premium Immobilien-Investments" +} \ No newline at end of file diff --git a/database/data/b2in-en/immobilien_image_break.json b/database/data/b2in-en/immobilien_image_break.json new file mode 100644 index 0000000..2789692 --- /dev/null +++ b/database/data/b2in-en/immobilien_image_break.json @@ -0,0 +1,6 @@ +{ + "image": "b2in/hero-immobilien.jpg", + "image_alt": "Dubai Skyline – Immobilien-Investments", + "quote": "Dubai hat sich zu einem der dynamischsten Immobilienmärkte der Welt entwickelt.", + "author": "Marcel Scheibe" +} \ No newline at end of file diff --git a/database/data/b2in-en/immobilien_kaufprozess.json b/database/data/b2in-en/immobilien_kaufprozess.json new file mode 100644 index 0000000..160d67b --- /dev/null +++ b/database/data/b2in-en/immobilien_kaufprozess.json @@ -0,0 +1,26 @@ +{ + "title": "Klar strukturiert: Der Kaufprozess in Dubai", + "intro": "Der Markt in Dubai ist schneller, digitaler und projektbezogen organisiert. Jeder Schritt ist durch das staatliche Escrow-System (Treuhandkonten nach Baufortschritt) maximal abgesichert.", + "steps": [ + { + "number": "1", + "title": "Reservierung (Booking Fee)", + "description": "Mit einer Gebühr von ca. 3–10 % wird Ihre Wunsch-Einheit offiziell aus dem Verkauf genommen und für Sie blockiert." + }, + { + "number": "2", + "title": "Anzahlung & Vertrag (SPA)", + "description": "Nach einer ersten Anzahlung (meist 10 %) wird der offizielle Kaufvertrag, das Sales & Purchase Agreement (SPA), erstellt." + }, + { + "number": "3", + "title": "Staatliche Registrierung (DLD)", + "description": "Durch die Zahlung der Registrierungsgebühr (4 %) an das Dubai Land Department wird Ihr Eigentum offiziell im staatlichen Register verankert." + }, + { + "number": "4", + "title": "Finaler Kaufvertrag", + "description": "Ihr Eigentumsrecht ist offiziell gesichert. Die weiteren Zahlungen erfolgen streng nach Baufortschritt auf sichere Treuhandkonten." + } + ] +} \ No newline at end of file diff --git a/database/data/b2in-en/immobilien_mindset.json b/database/data/b2in-en/immobilien_mindset.json new file mode 100644 index 0000000..16d4024 --- /dev/null +++ b/database/data/b2in-en/immobilien_mindset.json @@ -0,0 +1,8 @@ +{ + "title": "Sind Sie der richtige Investor für Dubai?", + "text_positive": "Dubai richtet sich an Menschen, die international denken. Der Staat priorisiert Effizienz, wirtschaftliche Entwicklung und enorme Geschwindigkeit. Wenn Sie an die Verschiebung weltweiter Wirtschaftszentren glauben und ein dynamisches System schätzen, ist Dubai der strategisch perfekte Baustein für Ihr Portfolio. Dann sind Sie bei uns genau richtig.", + "text_negative": "Wenn Sie jedoch ein System bevorzugen, das auf langsamen Entscheidungswegen und maximaler Bürokratie aufbaut, wird dieser Markt nicht zu Ihren Erwartungen passen.", + "closing": "Der Schritt ist kleiner, als Sie denken. Lassen Sie uns gemeinsam herausfinden, ob ein Investment in Dubai in Ihre Strategie passt.", + "cta_text": "Unverbindliches Gespräch vereinbaren", + "cta_link": "/contact" +} \ No newline at end of file diff --git a/database/data/b2in-en/immobilien_moebel_vorteil.json b/database/data/b2in-en/immobilien_moebel_vorteil.json new file mode 100644 index 0000000..d60e540 --- /dev/null +++ b/database/data/b2in-en/immobilien_moebel_vorteil.json @@ -0,0 +1,6 @@ +{ + "title": "Ihr Investment, Ihr Vorteil", + "text": "Als Käufer einer B2in-Immobilie erhalten Sie exklusiven Insider-Zugang zu unserem B2in-Möbelnetzwerk. Richten Sie Ihre Immobilie zu unschlagbaren Partner-Konditionen ein.", + "button_text": "Mehr zum B2in-Netzwerk", + "button_link": "/netzwerk" +} \ No newline at end of file diff --git a/database/data/b2in-en/immobilien_projects.json b/database/data/b2in-en/immobilien_projects.json new file mode 100644 index 0000000..e2d4437 --- /dev/null +++ b/database/data/b2in-en/immobilien_projects.json @@ -0,0 +1,81 @@ +{ + "title": "Aktuelle Launches & Projekte", + "subtitle": "Entdecken Sie unsere aktuellen Immobilien-Projekte auf internationalen Märkten.", + "projects": { + "azizi-creek-views-4": { + "slug": "azizi-creek-views-4", + "title": "Azizi Developments: Creek Views 4", + "location": "Al Jaddaf, Dubai", + "status": "NEW LAUNCH", + "launch_date": "03.03.2026", + "price_from": "ab 1.125.000 AED (ca. 283.000 EUR / 306.000 USD)", + "image": "expose/a1/image-4.jpeg", + "highlights": [ + "Prime Waterfront Views", + "1BR: ab 1.125.000 AED (ca. 283.000 EUR / 306.000 USD)", + "Exklusives 3BR Penthouse (Single Inventory)", + "High Rental Demand & Capital Appreciation" + ], + "quick_facts": [ + { + "icon": "home-modern", + "label": "Typen", + "value": "1BR & 3BR Penthouse" + }, + { + "icon": "squares-2x2", + "label": "Größe", + "value": "543 – 2.346 sqft" + }, + { + "icon": "building-office-2", + "label": "Einheiten", + "value": "Nur 132 (Limited)" + }, + { + "icon": "user", + "label": "Entwickler", + "value": "Azizi Developments" + } + ], + "investment_case": { + "title": "Starkes Investment, hohe Nachfrage.", + "text": "Creek Views 4 bietet eine strategische Top-Lage in Al Jaddaf. Die Kombination aus limitiertem Angebot (nur 132 Einheiten) und Premium-Ausstattung macht dieses Projekt zur idealen Wahl für Investoren, die auf Wertsteigerung (Capital Appreciation) und hohe Mietnachfrage abzielen.", + "views": [ + "Road View", + "Sitting & Play Area View", + "Neighbour View" + ] + }, + "gallery": [ + "expose/a1/image-4.jpeg", + "expose/a1/image-3.jpeg", + "expose/a1/image-2.jpeg", + "expose/a1/image-5.jpeg", + "expose/a1/image-6.jpeg", + "expose/a1/image-7.jpeg" + ], + "location_info": { + "title": "Strategische Location: Al Jaddaf", + "map_url": "https://maps.google.com/?q=Al+Jaddaf+Dubai", + "points": [ + "Direkte Anbindung an Dubai Creek und Waterfront", + "Wenige Minuten bis Downtown Dubai & Burj Khalifa", + "Exzellente Infrastruktur und wachsender Stadtteil" + ] + }, + "contact": { + "title": "Sichern Sie sich eine der 132 Einheiten.", + "subtitle": "Ihr Ansprechpartner: Marcel Scheibe", + "options": { + "": "Ich interessiere mich für...", + "1br": "1 Bedroom Apartment", + "3br_penthouse": "3BR Penthouse (Single Unit)", + "general": "Allgemeine Beratung" + } + } + } + }, + "cta_text": "Jetzt Exposé & Verfügbarkeit anfragen", + "cta_link": "/contact" +} \ No newline at end of file diff --git a/database/data/b2in-en/immobilien_trust.json b/database/data/b2in-en/immobilien_trust.json new file mode 100644 index 0000000..6b442a3 --- /dev/null +++ b/database/data/b2in-en/immobilien_trust.json @@ -0,0 +1,23 @@ +{ + "title": "Persönliche Begleitung", + "paragraphs": [ + "Hinter jedem B2in-Investment steht Marcel Scheibe als persönlicher Ansprechpartner. Keine anonyme Plattform – sondern ein Gesicht mit Expertise, Netzwerk und dem Anspruch, dass Ihr Investment in den besten Händen ist." + ], + "image": "b2in/marcel-scheibe-about.jpg", + "image_alt": "Marcel Scheibe – Persönliche Investmentbegleitung", + "image_caption": "Marcel Scheibe, Gründer & CEO", + "list": [ + { + "icon": "calendar", + "title": "Investor Evenings – Exklusive Quartals-Events im kleinen Kreis" + }, + { + "icon": "phone", + "title": "Direkter Draht – Persönliches Gespräch und Terminvereinbarung" + }, + { + "icon": "shield-check", + "title": "Transparenz – Von der Marktanalyse bis zum Closing begleitet" + } + ] +} \ No newline at end of file diff --git a/database/data/b2in-en/immobilien_warum_dubai.json b/database/data/b2in-en/immobilien_warum_dubai.json new file mode 100644 index 0000000..cd341f7 --- /dev/null +++ b/database/data/b2in-en/immobilien_warum_dubai.json @@ -0,0 +1,36 @@ +{ + "title": "Warum sich ein Investment in Dubai lohnt", + "intro": "Dubai ist nicht nur eine finanzielle Entscheidung, sondern der Zugang zu einem der wachstumsstärksten Märkte der Welt. Investoren schätzen die klaren rechtlichen Strukturen und Rahmenbedingungen, die weltweit nahezu einzigartig sind:", + "facts": [ + { + "icon": "banknotes", + "title": "0 % Steuern", + "description": "Keine Einkommensteuer auf Mieteinnahmen, keine Kapitalertragsteuer beim Verkauf." + }, + { + "icon": "arrow-trending-up", + "title": "Starke Renditen", + "description": "Attraktive Mietrenditen von historisch 6 % bis 9 % jährlich." + }, + { + "icon": "shield-check", + "title": "Hohe Sicherheit", + "description": "Ein staatlich regulierter Markt mit dem sicheren Escrow-Treuhand-System." + }, + { + "icon": "currency-dollar", + "title": "Stabile Währung", + "description": "Der Dirham (AED) ist fest an den US-Dollar gekoppelt." + }, + { + "icon": "identification", + "title": "Golden Visa", + "description": "Sichern Sie sich Aufenthaltsgenehmigungen über attraktive Investor-Programme." + }, + { + "icon": "globe-alt", + "title": "Hohe Nachfrage", + "description": "Internationale Zuwanderung und begrenztes Angebot treiben die Nachfrage nach Wohnraum stetig nach oben." + } + ] +} \ No newline at end of file diff --git a/database/data/b2in-en/integriertes_modell_b2in.json b/database/data/b2in-en/integriertes_modell_b2in.json new file mode 100644 index 0000000..1ec29e8 --- /dev/null +++ b/database/data/b2in-en/integriertes_modell_b2in.json @@ -0,0 +1,10 @@ +{ + "title": "Das Beste aus zwei Welten:
Immobilien und Einrichtung", + "paragraphs": [ + "B2in verbindet, was zusammengehört: Wer eine Immobilie erwirbt, braucht die passende Einrichtung. Wer exklusiv einrichten möchte, findet über unser Netzwerk die besten lokalen Fachexperten – ergänzt durch das Sortiment europäischer Hersteller.", + "Das Ergebnis: Ein nahtloses Erlebnis für den Kunden und neue Ertragsquellen für unsere Partner – ob Makler, Händler oder Entwickler." + ], + "image": "b2in/best-of-two-worlds.jpg", + "image_alt": "Das Ergebnis für den Kunden – Immobilie und Einrichtung aus einer Hand", + "image_caption": "Immobilie und Einrichtung – aus einer Hand" +} \ No newline at end of file diff --git a/database/data/b2in-en/interior_brands.json b/database/data/b2in-en/interior_brands.json new file mode 100644 index 0000000..f845d8f --- /dev/null +++ b/database/data/b2in-en/interior_brands.json @@ -0,0 +1,24 @@ +{ + "title": "Zwei Marken, ein Netzwerk", + "subtitle": "Je nach Stil und Anspruch finden Sie Ihren Zugang über eine unserer zwei Einrichtungsmarken.", + "brands": [ + { + "name": "stileigentum", + "tagline": "Premium-Einrichtung", + "description": "Exklusive und hochwertige Einrichtungskonzepte für anspruchsvolle Kunden, die Qualität und Tradition schätzen. Handverlesene Hersteller, zeitlose Materialien, individuelle Beratung.", + "audience": "Für Kunden, die das Besondere suchen.", + "logo": "img/logos/stileigentum-logo-positiv.svg", + "logo_width": "w-35", + "link": "https://stileigentum.test" + }, + { + "name": "style2own", + "tagline": "Design-Lifestyle", + "description": "Moderne Einrichtungskonzepte für Young Professionals und trend-orientierte Kunden – urban, flexibel, inspirierend. Aktuelle Trends, smarte Lösungen, faire Preise.", + "audience": "Für alle, die Design lieben.", + "logo": "img/logos/style2own-logo-positiv.svg", + "logo_width": "w-28", + "link": "https://style2own.test" + } + ] +} \ No newline at end of file diff --git a/database/data/b2in-en/interior_concept.json b/database/data/b2in-en/interior_concept.json new file mode 100644 index 0000000..977a60f --- /dev/null +++ b/database/data/b2in-en/interior_concept.json @@ -0,0 +1,10 @@ +{ + "title": "Was bedeutet Local-for-Local?", + "paragraphs": [ + "Unser Einrichtungsnetzwerk setzt auf das, was online nicht geht: echte Beratung, echtes Anfassen, echte Expertise. Statt anonymer Plattformen vermitteln wir den direkten Kontakt zu lokalen Fachhändlern – Menschen, die ihr Handwerk verstehen und Sie persönlich begleiten.", + "B2in verbindet diese lokalen Experten mit einem kuratierten Netzwerk europäischer Hersteller. Das Ergebnis: Zugang zu exklusiven Marken und Sortimenten, die Sie so nicht im Internet finden – aber mit der Beratungsqualität, die nur Ihr Fachhändler vor Ort bieten kann." + ], + "image": "b2in/ecosystem_result.jpg", + "image_alt": "Local-for-Local – Persönliche Beratung statt anonyme Plattform", + "image_caption": "Persönliche Einrichtungsberatung vor Ort" +} \ No newline at end of file diff --git a/database/data/b2in-en/interior_hero.json b/database/data/b2in-en/interior_hero.json new file mode 100644 index 0000000..8fd95c6 --- /dev/null +++ b/database/data/b2in-en/interior_hero.json @@ -0,0 +1,39 @@ +{ + "title": "Exklusive Einrichtung. Lokal gedacht, international vernetzt.", + "subtitle": "Das B2in-Einrichtungsnetzwerk verbindet lokale Fachexpertise mit internationalen Herstellern – persönlich, greifbar und in Ihrer Nähe.", + "features": [ + { + "title": "Local-for-Local", + "description": "Ihr Fachhändler vor Ort", + "icon": "map-pin" + }, + { + "title": "Premium-Netzwerk", + "description": "Kuratierte Hersteller & Marken", + "icon": "star" + }, + { + "title": "Zwei Marken", + "description": "stileigentum & style2own", + "icon": "squares-2x2" + }, + { + "title": "Persönlich", + "description": "Beratung statt Algorithmus", + "icon": "user" + } + ], + "image": "b2in/ecosystem-hero.jpg", + "image_alt": "B2in Einrichtungsnetzwerk – Local-for-Local", + "card_title": "B2in Interior", + "card_text": "Local-for-Local Einrichtungsnetzwerk", + "hub": { + "title": "B2in Interior", + "subtitle": "Das Einrichtungsnetzwerk" + }, + "stats": [ + "Lokale Fachexperten", + "Europäische Hersteller", + "Persönliche Beratung" + ] +} \ No newline at end of file diff --git a/database/data/b2in-en/interior_process.json b/database/data/b2in-en/interior_process.json new file mode 100644 index 0000000..2164058 --- /dev/null +++ b/database/data/b2in-en/interior_process.json @@ -0,0 +1,20 @@ +{ + "title": "So einfach funktioniert es", + "steps": [ + { + "number": "01", + "title": "Beratung", + "description": "Kontaktieren Sie uns oder besuchen Sie einen unserer lokalen Partner. Gemeinsam definieren wir Ihren Stil, Ihr Budget und Ihre Wünsche." + }, + { + "number": "02", + "title": "Auswahl", + "description": "Ihr Fachhändler zeigt Ihnen kuratierte Kollektionen europäischer Hersteller – zum Anfassen, nicht nur auf dem Bildschirm." + }, + { + "number": "03", + "title": "Lieferung & Einrichtung", + "description": "B2in koordiniert Logistik und Lieferung. Ihr lokaler Partner begleitet die Einrichtung bis zum letzten Detail." + } + ] +} \ No newline at end of file diff --git a/database/data/b2in-en/interior_trust.json b/database/data/b2in-en/interior_trust.json new file mode 100644 index 0000000..1f36b64 --- /dev/null +++ b/database/data/b2in-en/interior_trust.json @@ -0,0 +1,23 @@ +{ + "title": "Persönlich statt anonym", + "paragraphs": [ + "Hinter dem B2in-Einrichtungsnetzwerk steht Marcel Scheibe mit der Überzeugung, dass gute Einrichtung persönliche Beratung braucht. Kein Algorithmus ersetzt das Gespräch mit einem Experten, der Materialien kennt, Räume versteht und Ihren Stil trifft." + ], + "image": "b2in/marcel-scheibe-about.jpg", + "image_alt": "Marcel Scheibe – Das Gesicht hinter dem Einrichtungsnetzwerk", + "image_caption": "Marcel Scheibe, Gründer & CEO", + "list": [ + { + "icon": "map-pin", + "title": "Lokale Experten – Fachhändler in Ihrer Region, die Sie persönlich beraten" + }, + { + "icon": "star", + "title": "Kuratierte Hersteller – Nur geprüfte europäische Qualitätsmarken" + }, + { + "icon": "shield-check", + "title": "Transparente Preise – Faire Konditionen ohne versteckte Aufschläge" + } + ] +} \ No newline at end of file diff --git a/database/data/b2in-en/interior_zielgruppen.json b/database/data/b2in-en/interior_zielgruppen.json new file mode 100644 index 0000000..0851bf0 --- /dev/null +++ b/database/data/b2in-en/interior_zielgruppen.json @@ -0,0 +1,20 @@ +{ + "title": "Für wen ist das Einrichtungsnetzwerk?", + "groups": [ + { + "icon": "home", + "title": "Privatpersonen", + "description": "Sie richten Ihr neues Zuhause ein und wünschen sich persönliche Beratung statt endlosem Online-Scrollen? Über unser Netzwerk finden Sie lokale Fachexperten, die Ihren Stil verstehen." + }, + { + "icon": "building-office-2", + "title": "Immobilien-Investoren", + "description": "Sie haben über B2in ein Investment getätigt? Als Immobilienkäufer erhalten Sie exklusiven Zugang zu Partner-Konditionen für die komplette Einrichtung – aus einer Hand." + }, + { + "icon": "clipboard-document-check", + "title": "Entwickler & Makler", + "description": "Sie brauchen Mustereinrichtungen, Home-Staging oder eine schlüsselfertige Ausstattung für Ihre Projekte? B2in koordiniert von der Planung bis zur Lieferung." + } + ] +} \ No newline at end of file diff --git a/database/data/b2in-en/leadership_team.json b/database/data/b2in-en/leadership_team.json new file mode 100644 index 0000000..edd5623 --- /dev/null +++ b/database/data/b2in-en/leadership_team.json @@ -0,0 +1,25 @@ +{ + "title": "Das Führungsteam", + "subtitle": "Unser erfahrenes Team bringt jahrzehntelange Expertise in den Bereichen Technologie, Operations und Geschäftsentwicklung mit.", + "team_tag": "B2IN TEAM", + "team": [ + { + "name": "Marcel Scheibe", + "position": "Gründer & CEO", + "expertise": "Visionär für die digitale Zukunft des lokalen Handels und strategischer Brückenbauer zwischen den USA und Europa.", + "image": "b2in/marcel-scheibe.jpg" + }, + { + "name": "Sarah Müller", + "position": "Head of Operations", + "expertise": "Expertin für die Optimierung unserer europaweiten Logistikprozesse und die operative Exzellenz unserer regionalen Hubs.", + "image": "b2in/sarah-mueller.jpg" + }, + { + "name": "Thomas Weber", + "position": "Head of Technology", + "expertise": "Technologieführer mit Fokus auf eine intuitive, skalierbare Plattform-Architektur, die Händler und Hersteller nahtlos verbindet.", + "image": "b2in/thomas-weber.jpg" + } + ] +} \ No newline at end of file diff --git a/database/data/b2in-en/magazin_detail.json b/database/data/b2in-en/magazin_detail.json new file mode 100644 index 0000000..6c7b651 --- /dev/null +++ b/database/data/b2in-en/magazin_detail.json @@ -0,0 +1,6 @@ +{ + "back_to_magazine": "Zurück zum Magazin", + "share_article": "Artikel teilen", + "cta_title": "Entdecken Sie mehr über Immobilien-Investments und das B2in-Ökosystem.", + "cta_button": "Weitere Artikel entdecken" +} \ No newline at end of file diff --git a/database/data/b2in-en/magazin_list.json b/database/data/b2in-en/magazin_list.json new file mode 100644 index 0000000..3dc77af --- /dev/null +++ b/database/data/b2in-en/magazin_list.json @@ -0,0 +1,6 @@ +{ + "title": "B2in Magazin", + "subtitle": "Insights, Marktanalysen und Praxiswissen rund um Immobilien-Investments in Dubai, Einrichtungskonzepte und das B2in-Ökosystem.", + "read_more": "Weiterlesen", + "load_more": "Weitere Artikel laden" +} \ No newline at end of file diff --git a/database/data/b2in-en/netzwerk_cta.json b/database/data/b2in-en/netzwerk_cta.json new file mode 100644 index 0000000..528ccdc --- /dev/null +++ b/database/data/b2in-en/netzwerk_cta.json @@ -0,0 +1,6 @@ +{ + "title": "Interesse an einer Partnerschaft?", + "text": "Ob als Fachhändler, Hersteller, Makler oder Entwickler – kontaktieren Sie uns für eine Vorab-Registrierung und erfahren Sie als Erste, wenn unser Netzwerk live geht.", + "button_text": "Kontakt aufnehmen", + "button_link": "/contact" +} \ No newline at end of file diff --git a/database/data/b2in-en/netzwerk_hero.json b/database/data/b2in-en/netzwerk_hero.json new file mode 100644 index 0000000..94118e6 --- /dev/null +++ b/database/data/b2in-en/netzwerk_hero.json @@ -0,0 +1,6 @@ +{ + "title": "Das B2in Ökosystem – Immobilien trifft Interior.", + "subtitle": "Wir bauen aktuell das intelligenteste Local-for-Local Einrichtungsnetzwerk. Als Immobilienkunde von B2in profitieren Sie in Zukunft exklusiv von unserem Closed-Shop.", + "image": "b2in/ecosystem-hero.jpg", + "image_alt": "B2in Ökosystem – Einrichtung und Immobilien" +} \ No newline at end of file diff --git a/database/data/b2in-en/netzwerk_image_break.json b/database/data/b2in-en/netzwerk_image_break.json new file mode 100644 index 0000000..fc99081 --- /dev/null +++ b/database/data/b2in-en/netzwerk_image_break.json @@ -0,0 +1,6 @@ +{ + "image": "b2in/ecosystem-hero.jpg", + "image_alt": "B2in Ökosystem – Einrichtung und Immobilien", + "quote": "Wir verbinden, was zusammengehört: Immobilien und Einrichtung.", + "author": "Marcel Scheibe" +} \ No newline at end of file diff --git a/database/data/b2in-en/netzwerk_teasers.json b/database/data/b2in-en/netzwerk_teasers.json new file mode 100644 index 0000000..c0f9c6b --- /dev/null +++ b/database/data/b2in-en/netzwerk_teasers.json @@ -0,0 +1,23 @@ +{ + "title": "Was wir aufbauen", + "cards": [ + { + "icon": "squares-2x2", + "title": "Einrichtungsnetzwerk", + "description": "Persönliche Beratung durch lokale Fachexperten, kuratierte europäische Hersteller und exklusive Konditionen für B2in-Immobilienkunden. Einrichtung, die man anfassen kann – nicht nur online bestellen.", + "status": "In Entwicklung" + }, + { + "icon": "building-storefront", + "title": "Für Händler & Fachhändler", + "description": "Sie sind lokaler Einrichtungsexperte und möchten Teil eines Premium-Netzwerks werden? Wir verbinden Sie mit Kunden, die persönliche Beratung und Qualität schätzen.", + "status": "Vorab-Registrierung möglich" + }, + { + "icon": "globe-alt", + "title": "Für Entwickler & Marken", + "description": "Deutsche Vertragssicherheit für internationale Immobilienentwickler. Supply-Chain-Management mit Fokus auf Termintreue, Qualitätskontrolle und Durchsetzungskraft.", + "status": "Vorab-Registrierung möglich" + } + ] +} \ No newline at end of file diff --git a/database/data/b2in-en/our_story.json b/database/data/b2in-en/our_story.json new file mode 100644 index 0000000..cddb456 --- /dev/null +++ b/database/data/b2in-en/our_story.json @@ -0,0 +1,26 @@ +{ + "title": "Unsere Geschichte", + "timeline": [ + { + "title": "Die Idee", + "description": "2024 erkannten wir eine entscheidende Lücke: Der lokale Fachhandel braucht digitale Sichtbarkeit und Immobilienkäufer suchen nach nahtlosen Einrichtungslösungen. Diese beiden Welten gehören zusammen.", + "icon": "light-bulb" + }, + { + "title": "Das Fundament", + "description": "Wir entwickeln die B2in-Plattform: ein Ökosystem, das lokale Einrichtungsexperten, europäische Hersteller und Immobilienprofis auf einer gemeinsamen Plattform verbindet – fair, transparent und technologiegestützt.", + "icon": "rocket-launch" + }, + { + "title": "Die Erweiterung", + "description": "2025/2026 erweitert B2in sein Ökosystem: Internationale Immobilien und Supply-Chain-Management für Entwickler werden zur dominanten Säule. Der lokale Möbelmarktplatz bleibt als starker ergänzender Bereich bestehen.", + "icon": "globe-alt" + }, + { + "title": "Die Vision", + "description": "B2in wird zum zentralen Netzwerk für \"Design & Property\" – regional verwurzelt, international vernetzt. In jeder Region der verlässliche Partner für Immobilien-Investments und exklusive Einrichtungskonzepte.", + "icon": "star" + } + ], + "summary": "Was als Vision begann, den lokalen Handel zu stärken, ist heute ein Netzwerk, das Immobilien und Einrichtung nahtlos verbindet. B2in schließt die Lücke zwischen internationalen Investments und lokaler Expertise – mit Marcel Scheibe als Gesicht und Ansprechpartner." +} \ No newline at end of file diff --git a/database/data/b2in-en/our_values.json b/database/data/b2in-en/our_values.json new file mode 100644 index 0000000..42b693c --- /dev/null +++ b/database/data/b2in-en/our_values.json @@ -0,0 +1,36 @@ +{ + "title": "Unsere Werte", + "subtitle": "Diese sechs Grundpfeiler leiten unser tägliches Handeln und definieren, wer wir als Unternehmen sind und wofür wir stehen.", + "values": [ + { + "title": "Innovation", + "description": "Wir entwickeln digitale Lösungen, die Immobilienprofis und lokale Einrichtungsexperten gleichermaßen einen echten Wettbewerbsvorteil verschaffen.", + "icon": "light-bulb" + }, + { + "title": "Konnektivität", + "description": "Wir verbinden internationale Immobilienmärkte mit lokaler Expertise und europäische Manufakturen mit den Menschen, die ihre Produkte schätzen.", + "icon": "globe-alt" + }, + { + "title": "Qualität", + "description": "Kompromisslose Standards – bei der Auswahl unserer Partner, der Kuration der Einrichtung, der Überwachung von Lieferketten und der Technologie, die alles zusammenhält.", + "icon": "check-badge" + }, + { + "title": "Vertrauen", + "description": "Transparente Modelle und verlässliche Partnerschaften sind unser Fundament. Hinter B2in steht ein Gesicht – und das Versprechen, dass wir liefern, was wir zusagen.", + "icon": "user-group" + }, + { + "title": "Nachhaltigkeit", + "description": "Wir stärken den lokalen Handel, optimieren Transportwege und sorgen dafür, dass internationale Beschaffung verantwortungsvoll und effizient abläuft.", + "icon": "arrow-path" + }, + { + "title": "Design-Exzellenz", + "description": "Design ist der Kern unserer Wertschöpfung – von internationaler Architektur über kuratierte Einrichtung bis zur intuitiven Gestaltung unserer digitalen Plattform.", + "icon": "cube-transparent" + } + ] +} \ No newline at end of file diff --git a/database/data/b2in-en/partner_benefits.json b/database/data/b2in-en/partner_benefits.json new file mode 100644 index 0000000..03e6254 --- /dev/null +++ b/database/data/b2in-en/partner_benefits.json @@ -0,0 +1,56 @@ +{ + "title": "Warum Partner werden?", + "subtitle": "Entdecken Sie die Vorteile einer Partnerschaft mit B2in und wie Sie von unserem innovativen Ecosystem profitieren können.", + "broker": { + "tag": "Für Makler", + "title": "Revolutionäres Provisionsmodell", + "benefits": [ + { + "icon": "trending-up", + "title": "Lifetime-Provisionsmodell", + "description": "Profitieren Sie von kontinuierlichen Einnahmen durch unser innovatives Vergütungssystem" + }, + { + "icon": "target", + "title": "Schnellere Vermarktung", + "description": "Durchdachte Wohnkonzepte verkürzen Vermarktungszeiten und erhöhen Ihre Erfolgsquote" + }, + { + "icon": "award", + "title": "Mehrwert für Ihre Kunden", + "description": "Bieten Sie Ihren Kunden exklusive, kuratierte Immobilienerlebnisse" + } + ], + "highlight": { + "value": "3.5% - ∞", + "text": "Erstprovision bis Lifetime-Vergütung" + } + }, + "supplier": { + "tag": "Für Lieferanten", + "title": "Globale Marktchancen", + "benefits": [ + { + "icon": "globe", + "title": "Zugang zu internationalen Märkten", + "description": "Erweitern Sie Ihre Reichweite über Grenzen hinweg mit unserem globalen Netzwerk" + }, + { + "icon": "handshake", + "title": "Faire Konditionen", + "description": "Transparente und partnerschaftliche Geschäftsbedingungen für nachhaltigen Erfolg" + }, + { + "icon": "settings", + "title": "Einfache Produktverwaltung", + "description": "Intuitive Plattform für die Verwaltung und Präsentation Ihrer Produkte" + } + ], + "highlight": { + "image": "b2in/accommodation-1.jpg", + "alt": "Partner success visualization", + "value": "500+", + "text": "Erfolgreiche Partner" + } + } +} \ No newline at end of file diff --git a/database/data/b2in-en/partner_benefits_broker.json b/database/data/b2in-en/partner_benefits_broker.json new file mode 100644 index 0000000..47fbd37 --- /dev/null +++ b/database/data/b2in-en/partner_benefits_broker.json @@ -0,0 +1,34 @@ +{ + "id": "partner-benefits-broker", + "tag": "Ihre Vorteile als Makler & Immobilienprofi", + "tag_icon": "home-modern", + "tag_title": "Mehrwert für Ihre Kunden – Mehr Ertrag für Sie.", + "features": [ + { + "title": "Schnellere Vermarktung durch Home Staging", + "description": "Nutzen Sie unsere kuratierten Möbel-Pakete (stileigentum&style2own), um Ihre Immobilien professionell zu inszenieren. Beschleunigen Sie den Verkaufsprozess und erzielen Sie höhere Preise.", + "icon": "rocket-launch" + }, + { + "title": "Einzigartiger Service nach dem Abschluss", + "description": "Bieten Sie Ihren Käufern oder Mietern nach Vertragsunterzeichnung über unsere exklusive Login-Karte direkten Zugang zu einem kompletten Einrichtungsservice.", + "icon": "gift" + }, + { + "title": "Attraktives Provisionsmodell", + "description": "Profitieren Sie doppelt: Neben Ihrer klassischen Courtage erhalten Sie eine faire, lebenslange Provision auf alle Möbelumsätze, die Ihre Kunden über die Plattform generieren.", + "icon": "currency-euro" + }, + { + "title": "Einfaches Handling", + "description": "Unser digitales Makler-Portal macht es Ihnen leicht: Kunden einladen, Aktivitäten verfolgen und Provisionen transparent einsehen.", + "icon": "finger-print" + } + ], + "highlight": { + "value": "-25%", + "text": "Kürzere Vermarktungszeit für mit B2in inszenierte Objekte" + }, + "image": "b2in/partner-benefits-broker.jpg", + "image_alt": "Partner Benefits Broker" +} \ No newline at end of file diff --git a/database/data/b2in-en/partner_benefits_developer.json b/database/data/b2in-en/partner_benefits_developer.json new file mode 100644 index 0000000..5c01c6a --- /dev/null +++ b/database/data/b2in-en/partner_benefits_developer.json @@ -0,0 +1,34 @@ +{ + "id": "partner-benefits-developer", + "tag": "Ihre Vorteile als Immobilienentwickler", + "tag_icon": "globe-alt", + "tag_title": "Ihr verlängerter Arm in Deutschland.", + "features": [ + { + "title": "Vertragsmanagement", + "description": "Unterstützung bei der Ausarbeitung und Strukturierung von Lieferverträgen. Definition klarer Leistungs- und Qualitätsparameter, Absicherung von Zahlungs- und Lieferbedingungen.", + "icon": "document-check" + }, + { + "title": "Vertragssicherung & Durchsetzung", + "description": "Aktive Überwachung der vereinbarten Meilensteine, Eskalation auf Managementebene bei Abweichungen und konsequente Nachverfolgung offener Punkte.", + "icon": "shield-check" + }, + { + "title": "Tracking & Qualitätskontrolle", + "description": "Laufende Produktions- und Lieferüberwachung, persönliche Kontrolle bei Bedarf und Sicherstellung termingerechter Auslieferung.", + "icon": "magnifying-glass-circle" + }, + { + "title": "Netzwerk & Marktkenntnis", + "description": "Direkte Anbindung an Hersteller und Entscheider in Deutschland. Wir sorgen dafür, dass Vereinbarungen nicht nur auf dem Papier bestehen, sondern tatsächlich umgesetzt werden.", + "icon": "link" + } + ], + "highlight": { + "value": "100%", + "text": "Transparenz, Verlässlichkeit und planbare Lieferung – ohne operative Reibungsverluste" + }, + "image": "b2in/partner-benefits-developer.jpg", + "image_alt": "Supply-Chain-Management für Immobilienentwickler" +} \ No newline at end of file diff --git a/database/data/b2in-en/partner_benefits_retailer.json b/database/data/b2in-en/partner_benefits_retailer.json new file mode 100644 index 0000000..eef776e --- /dev/null +++ b/database/data/b2in-en/partner_benefits_retailer.json @@ -0,0 +1,34 @@ +{ + "id": "partner-benefits-retailer", + "tag": "Ihre Vorteile als lokaler Händler", + "tag_icon": "building-storefront", + "tag_title": "Werden Sie zum digitalen Champion in Ihrer Region.", + "features": [ + { + "title": "Digitale Reichweite", + "description": "Wir bringen Ihnen die Online-Kunden, die Sie alleine nicht erreichen. Profitieren Sie von unseren reichweitenstarken Endkunden-Marken.", + "icon": "signal" + }, + { + "title": "Sortiments-Erweiterung", + "description": "Ergänzen Sie Ihr Angebot mit exklusiven Herstellermarken aus unserem Portfolio – ganz ohne eigenes Lagerrisiko.", + "icon": "squares-plus" + }, + { + "title": "Stärkung gegen Online-Riesen", + "description": "Mit \"Local First\" stärken wir gezielt Ihre Position im Markt. Der Kunde sieht Ihr Angebot immer zuerst.", + "icon": "shield-check" + }, + { + "title": "Faire Konditionen", + "description": "Unser Provisionsmodell ist transparent und darauf ausgelegt, dass wir nur dann verdienen, wenn Sie es auch tun.", + "icon": "scale" + } + ], + "highlight": { + "value": "+35%", + "text": "Digitale Reichweite im ersten Jahr (Durchschnitt unserer Partner)" + }, + "image": "b2in/partner-benefits-retailer.jpg", + "image_alt": "Partner Benefits Retailer" +} \ No newline at end of file diff --git a/database/data/b2in-en/partner_benefits_supplier.json b/database/data/b2in-en/partner_benefits_supplier.json new file mode 100644 index 0000000..b5904da --- /dev/null +++ b/database/data/b2in-en/partner_benefits_supplier.json @@ -0,0 +1,34 @@ +{ + "id": "partner-benefits-supplier", + "tag": "Ihre Vorteile als Hersteller & Marke", + "tag_icon": "building-office-2", + "tag_title": "Erschließen Sie neue Märkte – intelligent und kuratiert.", + "features": [ + { + "title": "Kuratierter Vertriebskanal", + "description": "Statt sich im Rauschen großer Marktplätze zu verlieren, wird Ihre Marke gezielt designaffinen Kunden in kaufkräftigen Regionen präsentiert.", + "icon": "eye" + }, + { + "title": "Effiziente Logistik", + "description": "Unsere Bündel-Logistik senkt Ihre Vertriebskosten. Wir managen den Sammeltransport und die komplette Abwicklung.", + "icon": "truck" + }, + { + "title": "Direkter Marktzugang", + "description": "Überspringen Sie den klassischen Großhandel und bauen Sie eine direkte Beziehung zu regionalen Märkten und Endkunden auf.", + "icon": "map-pin" + }, + { + "title": "Skalierbares Wachstum", + "description": "Starten Sie mit uns in einer Region und wachsen Sie schrittweise in weitere europäische Hubs hinein.", + "icon": "chart-bar-square" + } + ], + "highlight": { + "value": "> 20", + "text": "Kuratierte regionale Hubs als neue Vertriebskanäle in Europa" + }, + "image": "b2in/partner-benefits-supplier.jpg", + "image_alt": "Partner Benefits Supplier" +} \ No newline at end of file diff --git a/database/data/b2in-en/partner_card_section.json b/database/data/b2in-en/partner_card_section.json new file mode 100644 index 0000000..2b5ee5d --- /dev/null +++ b/database/data/b2in-en/partner_card_section.json @@ -0,0 +1,34 @@ +{ + "title": "Welcher Partner-Typ sind Sie?", + "subtitle": "Entdecken Sie die Vorteile einer Partnerschaft mit B2in – ob im Bereich Immobilien, Einrichtung oder Supply Chain.", + "cards": [ + { + "title": "Für Immobilienentwickler", + "description": "Operative Steuerung Ihrer Beschaffung aus Deutschland – Vertragsmanagement, Qualitätskontrolle und termingerechte Lieferung.", + "icon": "globe-alt", + "button": "#partner-benefits-developer", + "button_text": "Ihre Vorteile als Entwickler" + }, + { + "title": "Für lokale Händler & Fachexperten", + "description": "Stärken Sie Ihr Geschäft vor Ort. Erhalten Sie Zugang zu Online-Kunden und einem exklusiven, überregionalen Sortiment.", + "icon": "building-storefront", + "button": "#partner-benefits-retailer", + "button_text": "Ihre Vorteile als Händler" + }, + { + "title": "Für Hersteller & europäische Marken", + "description": "Erschließen Sie neue, kuratierte Vertriebskanäle in regionalen Märkten und profitieren Sie von unserer intelligenten Logistik.", + "icon": "building-office-2", + "button": "#partner-benefits-supplier", + "button_text": "Ihre Vorteile als Hersteller" + }, + { + "title": "Für Immobilienmakler & Bauträger", + "description": "Bieten Sie Ihren Kunden einen einzigartigen Mehrwert, beschleunigen Sie die Vermarktung und sichern Sie sich attraktive Zusatzprovisionen.", + "icon": "home-modern", + "button": "#partner-benefits-broker", + "button_text": "Ihre Vorteile als Makler" + } + ] +} \ No newline at end of file diff --git a/database/data/b2in-en/partner_cta.json b/database/data/b2in-en/partner_cta.json new file mode 100644 index 0000000..e6d0f81 --- /dev/null +++ b/database/data/b2in-en/partner_cta.json @@ -0,0 +1,21 @@ +{ + "title": "Wachsen Sie mit uns", + "subtitle": "Werden Sie Teil des B2in-Netzwerks – ob als Immobilienentwickler, Einrichtungsexperte, Hersteller oder Makler. Wir verbinden die Welten, die zusammengehören.", + "stats": [ + { + "number": "500+", + "label": "Aktive Partner" + }, + { + "number": "98%", + "label": "Zufriedenheitsrate" + }, + { + "number": "24/7", + "label": "Partner-Support" + } + ], + "button_text": "Werden Sie B2in Partner", + "button_link": "/contact", + "small_text": "Entdecken Sie die Vorteile einer strategischen Partnerschaft mit B2in" +} \ No newline at end of file diff --git a/database/data/b2in-en/partner_hero.json b/database/data/b2in-en/partner_hero.json new file mode 100644 index 0000000..d75e83a --- /dev/null +++ b/database/data/b2in-en/partner_hero.json @@ -0,0 +1,52 @@ +{ + "title": "Für Entwickler & Partner", + "subtitle": "Ob Immobilienentwickler, lokaler Einrichtungsexperte, europäische Marke oder Makler – B2in ist das Netzwerk, das Ihr Geschäft mit den richtigen Partnern und Kunden verbindet.", + "partner_types": [ + { + "title": "Immobilienentwickler", + "description": "Supply-Chain-Management aus Deutschland", + "icon": "globe-alt" + }, + { + "title": "Hersteller & Marken", + "description": "Kuratierter Marktzugang, intelligente Logistik", + "icon": "building-office-2" + }, + { + "title": "Lokale Händler", + "description": "Digitale Reichweite, exklusives Sortiment", + "icon": "building-storefront" + }, + { + "title": "Makler & Bauträger", + "description": "Mehrwert für Kunden, Zusatzertrag für Sie", + "icon": "home-modern" + } + ], + "image": "b2in/partner-hero.jpg", + "image_alt": "Partner Hero Image", + "card_title": "Partner Network", + "card_text": "Werden Sie Teil unseres Ecosystems", + "hub": { + "title": "Partner Network", + "subtitle": "Werden Sie Teil unseres Ecosystems" + }, + "connection_points": [ + { + "name": "Makler", + "subtext": "Lifetime-Modell" + }, + { + "name": "Lieferanten", + "subtext": "Global Markets" + }, + { + "name": "Erfolg", + "subtext": "Messbare Ziele" + }, + { + "name": "Qualität", + "subtext": "Premium Standards" + } + ] +} \ No newline at end of file diff --git a/database/data/b2in-en/partner_process.json b/database/data/b2in-en/partner_process.json new file mode 100644 index 0000000..fc4122e --- /dev/null +++ b/database/data/b2in-en/partner_process.json @@ -0,0 +1,33 @@ +{ + "title": "So werden Sie Partner", + "subtitle": "In nur drei einfachen Schritten werden Sie Teil des B2in Ecosystems und können von allen Vorteilen unserer Partnerschaft profitieren.", + "steps": [ + { + "step": "1", + "title": "Bewerben", + "description": "Erzählen Sie uns Ihre Geschichte. Füllen Sie unser kurzes Kontaktformular aus und zeigen Sie uns, was Ihre Produkte oder Ihr Geschäft auszeichnet.", + "icon": "envelope", + "image": "b2in/room-1.jpg" + }, + { + "step": "2", + "title": "Prüfung", + "description": "Wir prüfen jede Anfrage persönlich. Unser Ziel ist es, ein hochwertiges und komplementäres Netzwerk aufzubauen, von dem alle profitieren.", + "icon": "check-circle", + "image": "b2in/room-2.jpg" + }, + { + "step": "3", + "title": "Onboarding", + "description": "Willkommen an Bord! Wir schulen Sie persönlich im Umgang mit unserem Partner-Portal und stellen sicher, dass Sie vom ersten Tag an erfolgreich sind.", + "icon": "rocket-launch", + "image": "b2in/room-3.jpg" + } + ], + "cta": { + "title": "Bereit für den nächsten Schritt?", + "subtitle": "Werden Sie noch heute Teil des B2in Ecosystems und profitieren Sie von innovativen Geschäftsmodellen und nachhaltigen Erfolgsstrategien.", + "button_text": "Jetzt Partner werden", + "button_link": "/contact" + } +} \ No newline at end of file diff --git a/database/data/b2in-en/supplier_section.json b/database/data/b2in-en/supplier_section.json new file mode 100644 index 0000000..66536dc --- /dev/null +++ b/database/data/b2in-en/supplier_section.json @@ -0,0 +1,40 @@ +{ + "tag": "Für Lieferanten", + "title": "Kuratierte Plattform für Anbieter", + "subtitle": "Werden Sie Teil eines exklusiven Netzwerks hochwertiger Anbieter. Präsentieren Sie Ihre Produkte und Services einer vorqualifizierten, kaufkräftigen Zielgruppe mit höchsten Qualitätsansprüchen.", + "benefits": [ + { + "title": "Kuratierter Vertriebskanal", + "description": "Zugang zu einer exklusiven, vorqualifizierten Kundenbasis mit hohem Qualitätsanspruch", + "icon": "store" + }, + { + "title": "Selbstverwaltung", + "description": "Vollständige Kontrolle über Produktpräsentation, Preisgestaltung und Verfügbarkeit", + "icon": "settings" + }, + { + "title": "Zentrale Qualitätssicherung", + "description": "Standardisierte Prozesse und Qualitätskontrollen für maximales Kundenvertrauen", + "icon": "check-circle" + }, + { + "title": "Analytics & Insights", + "description": "Detaillierte Verkaufsanalysen und Markteinblicke für optimierte Geschäftsentscheidungen", + "icon": "bar-chart" + } + ], + "dashboard": { + "title": "Anbieter-Dashboard", + "stats": [ + { + "label": "Produktsichtbarkeit", + "value": "94%" + }, + { + "label": "Qualitätsbewertung", + "value": "98%" + } + ] + } +} \ No newline at end of file diff --git a/database/data/b2in-en/supply_chain_intro.json b/database/data/b2in-en/supply_chain_intro.json new file mode 100644 index 0000000..39b2560 --- /dev/null +++ b/database/data/b2in-en/supply_chain_intro.json @@ -0,0 +1,23 @@ +{ + "title": "Supply-Chain-Management: Ihr verlängerter Arm in Deutschland.", + "paragraphs": [ + "Für Immobilienentwickler, die Möbel oder Innenausstattung aus Deutschland beziehen möchten, fungieren wir als verlängerter Arm vor Ort – mit klarem Fokus auf Vertragssicherheit, Termintreue und Durchsetzungskraft." + ], + "list": [ + { + "icon": "document-check", + "title": "Vertragsmanagement – Ausarbeitung, Strukturierung und Absicherung von Lieferverträgen" + }, + { + "icon": "shield-check", + "title": "Vertragssicherung & Durchsetzung – Meilenstein-Überwachung, Eskalation, Nachverfolgung" + }, + { + "icon": "magnifying-glass-circle", + "title": "Tracking & Qualitätskontrolle – Laufende Überwachung, persönliche Kontrolle, termingerechte Lieferung" + } + ], + "image": "b2in/marcel-scheibe-about.jpg", + "image_alt": "Marcel Scheibe – Supply-Chain-Management", + "image_caption": "Marcel Scheibe, Gründer & CEO" +} \ No newline at end of file diff --git a/database/data/b2in-en/synergie_section.json b/database/data/b2in-en/synergie_section.json new file mode 100644 index 0000000..117dee4 --- /dev/null +++ b/database/data/b2in-en/synergie_section.json @@ -0,0 +1,9 @@ +{ + "title": "Zwei Welten. Ein Netzwerk.", + "paragraphs": [ + "Wir verbinden den Immobilienkauf mit der perfekten Einrichtung. Immobilien-Investoren profitieren von unserem exklusiven Möbel-Netzwerk – Projektentwickler von unserer deutschen Vertragssicherheit im Supply-Chain-Management." + ], + "image": "b2in/best-of-two-worlds.jpg", + "image_alt": "Das B2in-Ökosystem – Immobilien und Einrichtung", + "image_caption": "Das B2in-Ökosystem" +} \ No newline at end of file diff --git a/database/data/b2in-en/vision_section.json b/database/data/b2in-en/vision_section.json new file mode 100644 index 0000000..3314d63 --- /dev/null +++ b/database/data/b2in-en/vision_section.json @@ -0,0 +1,11 @@ +{ + "title": "Gebaut auf Expertise und Vertrauen", + "paragraphs": [ + "B2in (Bridges2international) verbindet zwei Welten: internationale Immobilien und exklusive Einrichtungskonzepte. Als Ihr persönlicher Partner navigiere ich Sie durch beide Bereiche – mit Expertise, Netzwerk und dem Anspruch, dass jede Entscheidung auf Vertrauen basiert.", + "Ob ein Investment in Dubai, eine Villa in Lissabon oder die maßgeschneiderte Einrichtung Ihres neuen Zuhauses durch lokale Fachexperten – bei B2in laufen alle Fäden zusammen.", + "Regional verwurzelt, international vernetzt – das ist B2in." + ], + "image": "b2in/marcel-scheibe-about.jpg", + "image_alt": "Marcel Scheibe, Gründer und CEO von B2in", + "image_caption": "Marcel Scheibe, Gründer & CEO" +} \ No newline at end of file diff --git a/database/factories/CabinetTabletSettingFactory.php b/database/factories/CabinetTabletSettingFactory.php new file mode 100644 index 0000000..2f19550 --- /dev/null +++ b/database/factories/CabinetTabletSettingFactory.php @@ -0,0 +1,48 @@ + + */ +class CabinetTabletSettingFactory extends Factory +{ + protected $model = CabinetTabletSetting::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_status' => 'auto', + 'notice_headline' => null, + 'notice_subtext' => null, + 'override_open_today' => null, + 'override_close_today' => null, + 'next_appointment_date' => fake()->dateTimeBetween('now', '+7 days'), + 'next_appointment_time' => '14:00', + 'hours_monday_open' => '10:00', + 'hours_monday_close' => '18:00', + 'hours_tuesday_open' => '10:00', + 'hours_tuesday_close' => '18:00', + 'hours_wednesday_open' => '10:00', + 'hours_wednesday_close' => '18:00', + 'hours_thursday_open' => '10:00', + 'hours_thursday_close' => '18:00', + 'hours_friday_open' => '10:00', + 'hours_friday_close' => '18:00', + 'hours_saturday_open' => '10:00', + 'hours_saturday_close' => '14:00', + 'hours_sunday_open' => null, + 'hours_sunday_close' => null, + 'contact_phone' => '0521 98620100', + 'contact_email' => 'info@cabinet-bielefeld.de', + ]; + } +} diff --git a/database/factories/CmsArticleFactory.php b/database/factories/CmsArticleFactory.php new file mode 100644 index 0000000..53bcd56 --- /dev/null +++ b/database/factories/CmsArticleFactory.php @@ -0,0 +1,59 @@ + + */ +class CmsArticleFactory extends Factory +{ + protected $model = CmsArticle::class; + + public function definition(): array + { + $title = fake()->sentence(6); + + return [ + 'slug' => Str::slug($title).'-'.fake()->unique()->numberBetween(100, 999), + 'title' => $title, + 'subtitle' => fake()->sentence(12), + 'image' => 'b2in/magazin-'.fake()->numberBetween(1, 5).'.jpg', + 'category' => fake()->randomElement(['Dubai Investment', 'Rendite & Einrichtung', 'B2B & Partner', 'Einrichtung & Netzwerk']), + 'date_label' => fake()->date('F j, Y'), + 'read_time' => fake()->numberBetween(3, 10).' min read', + 'author' => [ + 'name' => fake()->name(), + 'bio' => fake()->sentence(15), + 'avatar' => 'b2in/marcel-scheibe.jpg', + ], + 'content' => ['de' => [ + 'intro' => fake()->paragraph(3), + 'sections' => [ + ['title' => fake()->sentence(5), 'content' => fake()->paragraph(4)], + ['title' => fake()->sentence(5), 'content' => fake()->paragraph(4)], + ['title' => fake()->sentence(5), 'content' => fake()->paragraph(4)], + ], + ]], + 'is_published' => true, + 'order' => 0, + ]; + } + + public function published(): static + { + return $this->state(fn (array $attributes) => [ + 'is_published' => true, + ]); + } + + public function unpublished(): static + { + return $this->state(fn (array $attributes) => [ + 'is_published' => false, + ]); + } +} diff --git a/database/factories/CmsProjectFactory.php b/database/factories/CmsProjectFactory.php new file mode 100644 index 0000000..a2ebcfc --- /dev/null +++ b/database/factories/CmsProjectFactory.php @@ -0,0 +1,80 @@ + + */ +class CmsProjectFactory extends Factory +{ + protected $model = CmsProject::class; + + public function definition(): array + { + $title = fake()->words(3, true); + + return [ + 'slug' => Str::slug($title).'-'.fake()->unique()->numberBetween(100, 999), + 'title' => ucfirst($title), + 'location' => fake()->city().', '.fake()->country(), + 'status' => fake()->randomElement(['NEW LAUNCH', 'SOLD OUT', 'AVAILABLE', null]), + 'launch_date' => fake()->dateTimeBetween('-1 year', '+1 year'), + 'price_from_aed' => fake()->numberBetween(500_000, 10_000_000), + 'currency' => 'AED', + 'image' => 'expose/placeholder.jpg', + 'highlights' => [ + fake()->sentence(4), + fake()->sentence(4), + fake()->sentence(4), + ], + 'quick_facts' => [ + ['icon' => 'home-modern', 'label' => 'Typen', 'value' => '1BR & 2BR'], + ['icon' => 'squares-2x2', 'label' => 'Größe', 'value' => fake()->numberBetween(400, 2000).' sqft'], + ['icon' => 'building-office-2', 'label' => 'Einheiten', 'value' => fake()->numberBetween(50, 500)], + ['icon' => 'user', 'label' => 'Entwickler', 'value' => fake()->company()], + ], + 'investment_case' => [ + 'title' => fake()->sentence(5), + 'text' => fake()->paragraph(3), + 'views' => [fake()->word(), fake()->word()], + ], + 'gallery' => [], + 'location_info' => [ + 'title' => 'Location: '.fake()->city(), + 'map_url' => 'https://maps.google.com/?q='.urlencode(fake()->city()), + 'points' => [ + fake()->sentence(6), + fake()->sentence(6), + ], + ], + 'contact' => [ + 'title' => fake()->sentence(5), + 'subtitle' => fake()->sentence(3), + 'options' => [ + '' => 'Ich interessiere mich für...', + 'general' => 'Allgemeine Beratung', + ], + ], + 'is_published' => true, + 'order' => 0, + ]; + } + + public function published(): static + { + return $this->state(fn (array $attributes) => [ + 'is_published' => true, + ]); + } + + public function unpublished(): static + { + return $this->state(fn (array $attributes) => [ + 'is_published' => false, + ]); + } +} diff --git a/database/factories/DisplayFactory.php b/database/factories/DisplayFactory.php new file mode 100644 index 0000000..fd3a2a6 --- /dev/null +++ b/database/factories/DisplayFactory.php @@ -0,0 +1,35 @@ + + */ +class DisplayFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected $model = Display::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->words(2, true), + 'location' => fake()->optional()->words(3, true), + 'is_active' => true, + ]; + } +} diff --git a/database/factories/DisplayMediaFactory.php b/database/factories/DisplayMediaFactory.php new file mode 100644 index 0000000..e557a30 --- /dev/null +++ b/database/factories/DisplayMediaFactory.php @@ -0,0 +1,66 @@ + + */ +class DisplayMediaFactory extends Factory +{ + protected $model = DisplayMedia::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'filename' => fake()->word().'.webp', + 'disk' => 'public', + 'path' => 'display-media/test/'.fake()->uuid().'.webp', + 'external_url' => null, + 'source_type' => 'upload', + 'type' => 'image', + 'mime_type' => 'image/webp', + 'file_size' => fake()->numberBetween(10000, 5000000), + 'title' => fake()->sentence(3), + 'collection' => fake()->randomElement(['immobilien', 'moebel', 'brand', null]), + 'is_active' => true, + ]; + } + + public function video(): static + { + return $this->state(fn () => [ + 'filename' => fake()->word().'.mp4', + 'path' => 'display-media/test/'.fake()->uuid().'.mp4', + 'type' => 'video', + 'mime_type' => 'video/mp4', + 'file_size' => fake()->numberBetween(1000000, 50000000), + ]); + } + + public function external(): static + { + return $this->state(fn () => [ + 'filename' => fake()->word().'.jpg', + 'disk' => 'public', + 'path' => null, + 'external_url' => 'https://drive.google.com/file/d/'.fake()->uuid().'/view', + 'source_type' => 'external', + 'file_size' => 0, + 'mime_type' => null, + ]); + } + + public function externalVideo(): static + { + return $this->external()->state(fn () => [ + 'filename' => fake()->word().'.mp4', + 'type' => 'video', + ]); + } +} diff --git a/database/factories/DisplayVersionFactory.php b/database/factories/DisplayVersionFactory.php new file mode 100644 index 0000000..ed8f3aa --- /dev/null +++ b/database/factories/DisplayVersionFactory.php @@ -0,0 +1,37 @@ + + */ +class DisplayVersionFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected $model = DisplayVersion::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->words(3, true), + 'type' => DisplayVersionType::VideoDisplay, + 'settings' => [], + 'is_active' => true, + ]; + } +} diff --git a/database/factories/DisplayVersionItemFactory.php b/database/factories/DisplayVersionItemFactory.php new file mode 100644 index 0000000..d256e6e --- /dev/null +++ b/database/factories/DisplayVersionItemFactory.php @@ -0,0 +1,42 @@ + + */ +class DisplayVersionItemFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected $model = DisplayVersionItem::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'display_version_id' => DisplayVersion::factory(), + 'item_type' => 'video', + 'content' => [ + 'filename' => 'test-video.mp4', + 'title' => fake()->sentence(3), + 'position' => 25, + ], + 'sort_order' => 0, + 'is_active' => true, + ]; + } +} diff --git a/database/migrations/2026_02_27_085218_create_cabinet_tablet_settings_table.php b/database/migrations/2026_02_27_085218_create_cabinet_tablet_settings_table.php new file mode 100644 index 0000000..0c425a3 --- /dev/null +++ b/database/migrations/2026_02_27_085218_create_cabinet_tablet_settings_table.php @@ -0,0 +1,54 @@ +id(); + + // Store Status + $table->enum('store_status', ['open', 'notice', 'closed'])->default('open'); + $table->string('notice_headline', 40)->nullable(); + $table->string('notice_subtext', 80)->nullable(); + + // Override opening hours for today (auto-reset at midnight) + $table->string('override_open_today', 5)->nullable(); + $table->string('override_close_today', 5)->nullable(); + + // Next appointment + $table->date('next_appointment_date')->nullable(); + $table->string('next_appointment_time', 5)->nullable(); + + // Standard opening hours per weekday + $table->string('hours_monday', 30)->default('10:00 – 18:00'); + $table->string('hours_tuesday', 30)->default('10:00 – 18:00'); + $table->string('hours_wednesday', 30)->default('10:00 – 18:00'); + $table->string('hours_thursday', 30)->default('10:00 – 18:00'); + $table->string('hours_friday', 30)->default('10:00 – 18:00'); + $table->string('hours_saturday', 30)->default('10:00 – 14:00'); + $table->string('hours_sunday', 30)->default('Geschlossen'); + + // Contact + $table->string('contact_phone', 50)->nullable(); + $table->string('contact_email', 100)->nullable(); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cabinet_tablet_settings'); + } +}; diff --git a/database/migrations/2026_02_27_125200_create_display_versions_table.php b/database/migrations/2026_02_27_125200_create_display_versions_table.php new file mode 100644 index 0000000..7f7088b --- /dev/null +++ b/database/migrations/2026_02_27_125200_create_display_versions_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('name'); + $table->string('type'); + $table->json('settings')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('display_versions'); + } +}; diff --git a/database/migrations/2026_02_27_125201_create_display_version_items_table.php b/database/migrations/2026_02_27_125201_create_display_version_items_table.php new file mode 100644 index 0000000..31e5677 --- /dev/null +++ b/database/migrations/2026_02_27_125201_create_display_version_items_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('display_version_id')->constrained()->cascadeOnDelete(); + $table->string('item_type'); + $table->json('content'); + $table->integer('sort_order')->default(0); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('display_version_items'); + } +}; diff --git a/database/migrations/2026_02_27_125202_create_displays_table.php b/database/migrations/2026_02_27_125202_create_displays_table.php new file mode 100644 index 0000000..d266da4 --- /dev/null +++ b/database/migrations/2026_02_27_125202_create_displays_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('name'); + $table->string('location')->nullable(); + $table->foreignId('display_version_id')->nullable()->constrained()->nullOnDelete(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('displays'); + } +}; diff --git a/database/migrations/2026_02_27_140000_create_display_display_version_table.php b/database/migrations/2026_02_27_140000_create_display_display_version_table.php new file mode 100644 index 0000000..c25523f --- /dev/null +++ b/database/migrations/2026_02_27_140000_create_display_display_version_table.php @@ -0,0 +1,62 @@ +id(); + $table->foreignId('display_id')->constrained()->cascadeOnDelete(); + $table->foreignId('display_version_id')->constrained()->cascadeOnDelete(); + $table->integer('sort_order')->default(0); + }); + + // Migrate existing single-version assignments to pivot table + $displays = DB::table('displays')->whereNotNull('display_version_id')->get(); + foreach ($displays as $display) { + DB::table('display_display_version')->insert([ + 'display_id' => $display->id, + 'display_version_id' => $display->display_version_id, + 'sort_order' => 0, + ]); + } + + Schema::table('displays', function (Blueprint $table) { + $table->dropForeign(['display_version_id']); + $table->dropColumn('display_version_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('displays', function (Blueprint $table) { + $table->foreignId('display_version_id')->nullable()->constrained()->nullOnDelete(); + }); + + // Migrate first pivot entry back to single FK + $pivots = DB::table('display_display_version') + ->orderBy('display_id') + ->orderBy('sort_order') + ->get() + ->groupBy('display_id'); + + foreach ($pivots as $displayId => $entries) { + DB::table('displays') + ->where('id', $displayId) + ->update(['display_version_id' => $entries->first()->display_version_id]); + } + + Schema::dropIfExists('display_display_version'); + } +}; diff --git a/database/migrations/2026_02_27_154145_add_curate_products_permission.php b/database/migrations/2026_02_27_154145_add_curate_products_permission.php new file mode 100644 index 0000000..7c19423 --- /dev/null +++ b/database/migrations/2026_02_27_154145_add_curate_products_permission.php @@ -0,0 +1,32 @@ +forgetCachedPermissions(); + + $permission = Permission::firstOrCreate(['name' => 'curate products', 'guard_name' => 'web']); + + $adminRole = Role::where('name', 'Admin')->where('guard_name', 'web')->first(); + if ($adminRole && ! $adminRole->hasPermissionTo($permission)) { + $adminRole->givePermissionTo($permission); + } + } + + public function down(): void + { + app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions(); + + $permission = Permission::where('name', 'curate products')->where('guard_name', 'web')->first(); + + if ($permission) { + $permission->roles()->detach(); + $permission->delete(); + } + } +}; diff --git a/database/migrations/2026_03_05_115531_restructure_cabinet_tablet_hours.php b/database/migrations/2026_03_05_115531_restructure_cabinet_tablet_hours.php new file mode 100644 index 0000000..66db694 --- /dev/null +++ b/database/migrations/2026_03_05_115531_restructure_cabinet_tablet_hours.php @@ -0,0 +1,105 @@ +string('store_status_new', 10)->default('auto')->after('id'); + }); + + // Migrate existing status values: 'open' → 'auto', keep 'notice'/'closed' + DB::table('cabinet_tablet_settings')->update([ + 'store_status_new' => DB::raw("CASE WHEN store_status = 'notice' THEN 'notice' WHEN store_status = 'closed' THEN 'closed' ELSE 'auto' END"), + ]); + + Schema::table('cabinet_tablet_settings', function (Blueprint $table) { + $table->dropColumn('store_status'); + }); + + Schema::table('cabinet_tablet_settings', function (Blueprint $table) { + $table->renameColumn('store_status_new', 'store_status'); + }); + + // Add structured time columns for each weekday + Schema::table('cabinet_tablet_settings', function (Blueprint $table) { + foreach ($this->days as $day) { + $table->string("hours_{$day}_open", 5)->nullable()->after("hours_{$day}"); + $table->string("hours_{$day}_close", 5)->nullable()->after("hours_{$day}_open"); + } + }); + + // Migrate existing text values to structured columns (best-effort parse of "HH:MM – HH:MM") + foreach (DB::table('cabinet_tablet_settings')->get() as $row) { + $updates = []; + foreach ($this->days as $day) { + $text = $row->{"hours_{$day}"} ?? ''; + if (preg_match('/^(\d{2}:\d{2})\s*[–-]\s*(\d{2}:\d{2})$/', $text, $m)) { + $updates["hours_{$day}_open"] = $m[1]; + $updates["hours_{$day}_close"] = $m[2]; + } + // If "Geschlossen" or no match: both remain null (closed) + } + if ($updates) { + DB::table('cabinet_tablet_settings')->where('id', $row->id)->update($updates); + } + } + + // Drop old free-text hour columns + Schema::table('cabinet_tablet_settings', function (Blueprint $table) { + $table->dropColumn(array_map(fn ($d) => "hours_{$d}", $this->days)); + }); + } + + public function down(): void + { + Schema::table('cabinet_tablet_settings', function (Blueprint $table) { + // Restore free-text columns + foreach (array_reverse($this->days) as $day) { + $table->string("hours_{$day}", 30)->default('Geschlossen')->after('next_appointment_time'); + } + }); + + // Reconstruct display strings + foreach (DB::table('cabinet_tablet_settings')->get() as $row) { + $updates = []; + foreach ($this->days as $day) { + $open = $row->{"hours_{$day}_open"}; + $close = $row->{"hours_{$day}_close"}; + $updates["hours_{$day}"] = ($open && $close) ? "{$open} – {$close}" : 'Geschlossen'; + } + DB::table('cabinet_tablet_settings')->where('id', $row->id)->update($updates); + } + + Schema::table('cabinet_tablet_settings', function (Blueprint $table) { + $columns = []; + foreach ($this->days as $day) { + $columns[] = "hours_{$day}_open"; + $columns[] = "hours_{$day}_close"; + } + $table->dropColumn($columns); + + // Restore enum column + $table->string('store_status_old', 10)->default('open')->after('id'); + }); + + DB::table('cabinet_tablet_settings')->update([ + 'store_status_old' => DB::raw("CASE WHEN store_status = 'notice' THEN 'notice' WHEN store_status = 'closed' THEN 'closed' ELSE 'open' END"), + ]); + + Schema::table('cabinet_tablet_settings', function (Blueprint $table) { + $table->dropColumn('store_status'); + }); + + Schema::table('cabinet_tablet_settings', function (Blueprint $table) { + $table->renameColumn('store_status_old', 'store_status'); + }); + } +}; diff --git a/database/migrations/2026_03_18_134359_create_cms_projects_table.php b/database/migrations/2026_03_18_134359_create_cms_projects_table.php new file mode 100644 index 0000000..f75a412 --- /dev/null +++ b/database/migrations/2026_03_18_134359_create_cms_projects_table.php @@ -0,0 +1,37 @@ +id(); + $table->string('slug')->unique(); + $table->json('title'); + $table->json('location')->nullable(); + $table->string('status')->nullable(); + $table->date('launch_date')->nullable(); + $table->unsignedInteger('price_from_aed')->nullable(); + $table->string('currency')->default('AED'); + $table->string('image')->nullable(); + $table->json('highlights')->nullable(); + $table->json('quick_facts')->nullable(); + $table->json('investment_case')->nullable(); + $table->json('gallery')->nullable(); + $table->json('location_info')->nullable(); + $table->json('contact')->nullable(); + $table->boolean('is_published')->default(false); + $table->unsignedInteger('order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('cms_projects'); + } +}; diff --git a/database/migrations/2026_03_23_104558_create_cms_articles_table.php b/database/migrations/2026_03_23_104558_create_cms_articles_table.php new file mode 100644 index 0000000..cf22f91 --- /dev/null +++ b/database/migrations/2026_03_23_104558_create_cms_articles_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('slug')->unique(); + $table->json('title'); + $table->json('subtitle')->nullable(); + $table->string('image')->nullable(); + $table->string('category')->nullable(); + $table->string('date_label')->nullable(); + $table->string('read_time')->nullable(); + $table->json('author')->nullable(); + $table->json('content')->nullable(); + $table->boolean('is_published')->default(true); + $table->integer('order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('cms_articles'); + } +}; diff --git a/database/migrations/2026_03_23_125633_add_investor_trust_and_furniture_benefit_to_cms_projects_table.php b/database/migrations/2026_03_23_125633_add_investor_trust_and_furniture_benefit_to_cms_projects_table.php new file mode 100644 index 0000000..aa68b84 --- /dev/null +++ b/database/migrations/2026_03_23_125633_add_investor_trust_and_furniture_benefit_to_cms_projects_table.php @@ -0,0 +1,23 @@ +json('investor_trust')->nullable()->after('contact'); + $table->json('furniture_benefit')->nullable()->after('investor_trust'); + }); + } + + public function down(): void + { + Schema::table('cms_projects', function (Blueprint $table) { + $table->dropColumn(['investor_trust', 'furniture_benefit']); + }); + } +}; diff --git a/database/migrations/2026_03_26_124539_create_display_media_table.php b/database/migrations/2026_03_26_124539_create_display_media_table.php new file mode 100644 index 0000000..691a9f7 --- /dev/null +++ b/database/migrations/2026_03_26_124539_create_display_media_table.php @@ -0,0 +1,45 @@ +id(); + $table->string('filename'); + $table->string('disk')->default('public'); + $table->string('path')->nullable(); + $table->string('external_url', 2048)->nullable(); + $table->string('source_type')->default('upload'); + $table->string('type')->default('image'); + $table->string('mime_type')->nullable(); + $table->unsignedBigInteger('file_size')->default(0); + $table->string('thumbnail_path')->nullable(); + $table->string('alt_text')->nullable(); + $table->string('title')->nullable(); + $table->string('collection')->nullable()->index(); + $table->json('metadata')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->index(['type', 'collection']); + $table->index('source_type'); + $table->index('is_active'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('display_media'); + } +}; diff --git a/database/scripts/build-b2in-en-from-de.php b/database/scripts/build-b2in-en-from-de.php new file mode 100644 index 0000000..3c91880 --- /dev/null +++ b/database/scripts/build-b2in-en-from-de.php @@ -0,0 +1,116 @@ + $text, + 'langpair' => 'de|en', + ]); + + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'timeout' => 60, + 'header' => "Content-Type: application/x-www-form-urlencoded\r\nUser-Agent: B2inCmsBuild/1.0\r\n", + 'content' => $body, + ], + ]); + + $raw = @file_get_contents('https://api.mymemory.translated.net/get', false, $ctx); + if ($raw === false) { + return $text; + } + + $data = json_decode($raw, true); + $out = $data['responseData']['translatedText'] ?? null; + if (! is_string($out) || $out === '') { + return $text; + } + + if (($data['responseStatus'] ?? 200) !== 200) { + return $text; + } + + $cache[$text] = $out; + usleep(150000); + + return $out; +} + +function walk(mixed $v, callable $fn): mixed +{ + if (is_string($v)) { + return $fn($v); + } + if (is_array($v)) { + $out = []; + foreach ($v as $k => $x) { + $out[$k] = walk($x, $fn); + } + + return $out; + } + + return $v; +} + +$translateFn = function (string $s) use (&$cache): string { + return translate($s, $cache); +}; + +foreach (glob($dir.'/*.json') ?: [] as $path) { + $name = basename($path); + $data = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); + $out = walk($data, $translateFn); + file_put_contents($path, json_encode($out, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)."\n"); + echo "Updated {$name}\n"; +} + +file_put_contents($cacheFile, json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + +echo 'Done. Cache: '.$cacheFile."\n"; diff --git a/database/seeders/CmsArticleSeeder.php b/database/seeders/CmsArticleSeeder.php new file mode 100644 index 0000000..5ca7bcd --- /dev/null +++ b/database/seeders/CmsArticleSeeder.php @@ -0,0 +1,90 @@ + + */ + private const ARTICLE_ID_TO_SLUG = [ + 1 => 'escrow-system-dubai-investoren', + 2 => 'spotlight-al-jaddaf-hotspot', + 3 => 'turnkey-investments-moeblierung-rendite', + 4 => 'supply-chain-management-vertragssicherheit', + 5 => 'local-for-local-regionaler-moebelhandel', + ]; + + public function run(): void + { + foreach ($this->getArticles() as $i => $data) { + CmsArticle::query()->updateOrCreate( + ['slug' => $data['slug']], + array_merge($data, ['order' => $i + 1]), + ); + } + } + + /** + * @return array> + */ + private function getArticles(): array + { + /** @var array> $articlesDe */ + $articlesDe = Lang::get('b2in.articles', [], 'de'); + /** @var array> $articlesEn */ + $articlesEn = Lang::get('b2in.articles', [], 'en'); + + if (! is_array($articlesDe) || $articlesDe === []) { + return []; + } + + if (! is_array($articlesEn)) { + $articlesEn = []; + } + + ksort($articlesDe); + + $out = []; + + foreach ($articlesDe as $id => $rowDe) { + $id = (int) $id; + $slug = self::ARTICLE_ID_TO_SLUG[$id] ?? null; + if ($slug === null) { + continue; + } + + $rowEn = $articlesEn[$id] ?? $rowDe; + + $out[] = [ + 'slug' => $slug, + 'title' => [ + 'de' => (string) ($rowDe['title'] ?? ''), + 'en' => (string) ($rowEn['title'] ?? ''), + ], + 'subtitle' => [ + 'de' => (string) ($rowDe['subtitle'] ?? ''), + 'en' => (string) ($rowEn['subtitle'] ?? ''), + ], + 'image' => (string) ($rowDe['image'] ?? ''), + 'category' => (string) ($rowDe['category'] ?? ''), + 'date_label' => (string) ($rowDe['date'] ?? ''), + 'read_time' => (string) ($rowDe['readTime'] ?? ''), + 'author' => is_array($rowDe['author'] ?? null) ? $rowDe['author'] : [], + 'content' => [ + 'de' => is_array($rowDe['content'] ?? null) ? $rowDe['content'] : [], + 'en' => is_array($rowEn['content'] ?? null) ? $rowEn['content'] : [], + ], + 'is_published' => true, + ]; + } + + return $out; + } +} diff --git a/database/seeders/CmsContentSeeder.php b/database/seeders/CmsContentSeeder.php new file mode 100644 index 0000000..3384386 --- /dev/null +++ b/database/seeders/CmsContentSeeder.php @@ -0,0 +1,118 @@ + + */ + protected array $skipSections = [ + 'netzwerk_cabinet_partner', + 'netzwerk_immobilien_hint', + ]; + + public function run(): void + { + /** @var array $sectionMap */ + $sectionMap = config('cms_section_map.sections', []); + /** @var array $langKeys */ + $langKeys = config('cms_section_map.lang_keys', []); + + /** @var array $configTheme */ + $configTheme = config('content.themes.b2in', []); + /** @var array $langDe */ + $langDe = Lang::get('b2in.themes.b2in', [], 'de'); + /** @var array $langEn */ + $langEn = Lang::get('b2in.themes.b2in', [], 'en'); + + if (! is_array($langDe) || empty($langDe)) { + $this->command?->warn('No b2in theme found in resources/lang/de/b2in.php (key: b2in.themes.b2in) – skipping.'); + + return; + } + + if (! is_array($langEn)) { + $langEn = []; + } + + /** @var array $themeDe */ + $themeDe = array_merge($configTheme, $langDe); + /** @var array $themeEn */ + $themeEn = array_merge($configTheme, $langEn); + + $created = 0; + $updated = 0; + + foreach ($sectionMap as $sectionKey => $groupKeyPair) { + if (in_array($sectionKey, $this->skipSections, true)) { + continue; + } + + $langSectionKey = $langKeys[$sectionKey] ?? $sectionKey; + + if (! array_key_exists($langSectionKey, $themeDe)) { + $this->command?->warn("Unmapped or missing theme data for section: {$sectionKey} (lang key: {$langSectionKey}) – skipping."); + + continue; + } + + $sectionDataDe = $themeDe[$langSectionKey]; + $sectionDataEn = $themeEn[$langSectionKey] ?? $sectionDataDe; + + [$group, $key] = $groupKeyPair; + $type = $this->detectType($sectionDataDe); + + $content = CmsContent::query() + ->where('group', $group) + ->where('key', $key) + ->first(); + + $value = [ + 'de' => $sectionDataDe, + 'en' => $sectionDataEn, + ]; + + if ($content) { + $content->update([ + 'type' => $type, + 'value' => $value, + ]); + $updated++; + } else { + CmsContent::query()->create([ + 'group' => $group, + 'key' => $key, + 'type' => $type, + 'value' => $value, + 'order' => 0, + ]); + $created++; + } + } + + $this->command?->info("CmsContent: {$created} created, {$updated} updated."); + } + + private function detectType(mixed $data): string + { + if (is_array($data)) { + return 'json'; + } + + if (is_string($data) && preg_match('/<[^>]+>/', $data)) { + return 'html'; + } + + return 'text'; + } +} diff --git a/database/seeders/CmsLegalSeeder.php b/database/seeders/CmsLegalSeeder.php new file mode 100644 index 0000000..c425493 --- /dev/null +++ b/database/seeders/CmsLegalSeeder.php @@ -0,0 +1,59 @@ + + */ + private const PAGE_KEYS = [ + 'impressum', + 'privacy', + 'terms', + 'cookie_policy', + ]; + + public function run(): void + { + foreach (self::PAGE_KEYS as $order => $key) { + /** @var array $de */ + $de = Lang::get('b2in_legal.'.$key, [], 'de'); + /** @var array $en */ + $en = Lang::get('b2in_legal.'.$key, [], 'en'); + + if (! is_array($de) || $de === []) { + $this->command?->warn("CmsLegal: b2in_legal.{$key} (de) fehlt – übersprungen."); + + continue; + } + + if (! is_array($en)) { + $en = $de; + } + + CmsContent::query()->updateOrCreate( + [ + 'group' => 'legal', + 'key' => $key, + ], + [ + 'type' => 'json', + 'value' => [ + 'de' => $de, + 'en' => $en, + ], + 'order' => $order, + ], + ); + } + + $this->command?->info('CmsLegal: '.count(self::PAGE_KEYS).' Seiten gespeichert (Gruppe legal).'); + } +} diff --git a/database/seeders/CmsProjectSeeder.php b/database/seeders/CmsProjectSeeder.php new file mode 100644 index 0000000..d9ca179 --- /dev/null +++ b/database/seeders/CmsProjectSeeder.php @@ -0,0 +1,169 @@ +getProjects() as $data) { + CmsProject::query()->updateOrCreate( + ['slug' => $data['slug']], + $data, + ); + } + } + + /** + * @return array> + */ + private function getProjects(): array + { + $key = 'b2in.themes.b2in.immobilien_projects.projects.'.self::PROJECT_SLUG; + + /** @var array $de */ + $de = Lang::get($key, [], 'de'); + /** @var array $en */ + $en = Lang::get($key, [], 'en'); + + if ($de === [] || ! is_array($de)) { + return []; + } + + if (! is_array($en)) { + $en = []; + } + + $launchDate = isset($de['launch_date']) && is_string($de['launch_date']) + ? Carbon::createFromFormat('d.m.Y', $de['launch_date']) + : Carbon::create(2026, 3, 3); + + $extras = $this->projectTrustAndFurnitureBlocks(); + + return [ + [ + 'slug' => self::PROJECT_SLUG, + 'title' => [ + 'de' => (string) ($de['title'] ?? ''), + 'en' => (string) ($en['title'] ?? $de['title'] ?? ''), + ], + 'location' => [ + 'de' => (string) ($de['location'] ?? ''), + 'en' => (string) ($en['location'] ?? $de['location'] ?? ''), + ], + 'status' => (string) ($de['status'] ?? 'NEW LAUNCH'), + 'launch_date' => $launchDate, + 'price_from_aed' => 1_125_000, + 'currency' => 'AED', + 'image' => (string) ($de['image'] ?? ''), + 'highlights' => [ + 'de' => is_array($de['highlights'] ?? null) ? $de['highlights'] : [], + 'en' => is_array($en['highlights'] ?? null) ? $en['highlights'] : [], + ], + 'quick_facts' => is_array($de['quick_facts'] ?? null) ? $de['quick_facts'] : [], + 'investment_case' => [ + 'de' => is_array($de['investment_case'] ?? null) ? $de['investment_case'] : [], + 'en' => is_array($en['investment_case'] ?? null) ? $en['investment_case'] : [], + ], + 'gallery' => is_array($de['gallery'] ?? null) ? $de['gallery'] : [], + 'location_info' => [ + 'de' => is_array($de['location_info'] ?? null) ? $de['location_info'] : [], + 'en' => is_array($en['location_info'] ?? null) ? $en['location_info'] : [], + ], + 'contact' => [ + 'de' => is_array($de['contact'] ?? null) ? $de['contact'] : [], + 'en' => is_array($en['contact'] ?? null) ? $en['contact'] : [], + ], + 'investor_trust' => $extras['investor_trust'], + 'furniture_benefit' => $extras['furniture_benefit'], + 'is_published' => true, + 'order' => 0, + ], + ]; + } + + /** + * @return array{investor_trust: array>, furniture_benefit: array>} + */ + private function projectTrustAndFurnitureBlocks(): array + { + return [ + 'investor_trust' => [ + 'de' => [ + 'title' => 'Maximale Sicherheit für Ihr Investment', + 'intro' => 'Der Kaufprozess in Dubai ist durch strenge staatliche Regularien international führend im Investorenschutz.', + 'columns' => [ + [ + 'icon' => 'heroicon-o-lock-closed', + 'title' => 'Staatliches Escrow-System', + 'text' => 'Ihre Zahlungen fließen nicht an den Bauträger, sondern auf speziell regulierte Treuhandkonten der RERA (Real Estate Regulatory Agency).', + ], + [ + 'icon' => 'heroicon-o-building-library', + 'title' => 'Strenge DLD-Kontrolle', + 'text' => 'Jeder Kaufvertrag (SPA) wird offiziell beim Dubai Land Department registriert. Lückenlose behördliche Überwachung jedes Bauprojekts.', + ], + [ + 'icon' => 'heroicon-o-chart-bar', + 'title' => 'Transparente Planbarkeit', + 'text' => 'Gelder werden nur streng nach zertifiziertem Baufortschritt freigegeben. Eine Zweckentfremdung Ihres Kapitals ist gesetzlich ausgeschlossen.', + ], + ], + 'cta_url' => '/magazin/1', + 'cta_label' => 'Deep Dive: Erfahren Sie im Magazin im Detail, wie das Escrow-System in Dubai Sie als Käufer schützt', + ], + 'en' => [ + 'title' => 'Maximum security for your investment', + 'intro' => 'The purchase process in Dubai is among the world’s most investor-protective, thanks to strict government regulation.', + 'columns' => [ + [ + 'icon' => 'heroicon-o-lock-closed', + 'title' => 'Government Escrow system', + 'text' => 'Your payments do not go to the developer; they are held in specially regulated trust accounts with the RERA (Real Estate Regulatory Agency).', + ], + [ + 'icon' => 'heroicon-o-building-library', + 'title' => 'Strict DLD oversight', + 'text' => 'Every purchase contract (SPA) is officially registered with the Dubai Land Department. Every construction project is monitored end-to-end.', + ], + [ + 'icon' => 'heroicon-o-chart-bar', + 'title' => 'Transparent planning', + 'text' => 'Funds are released only according to certified construction progress. Misuse of your capital is legally ruled out.', + ], + ], + 'cta_url' => '/magazin/1', + 'cta_label' => 'Deep dive: read in the magazine how Dubai’s Escrow system protects you as a buyer', + ], + ], + 'furniture_benefit' => [ + 'de' => [ + 'title' => 'Ihr Investment, Ihr Vorteil', + 'text' => 'Als Käufer einer B2in-Immobilie erhalten Sie exklusiven Insider-Zugang zu unserem B2in-Möbelnetzwerk. Richten Sie Ihre Immobilie zu unschlagbaren Partner-Konditionen ein.', + 'button_text' => 'Mehr zum B2in-Netzwerk', + 'button_link' => '/netzwerk', + ], + 'en' => [ + 'title' => 'Your investment, your advantage', + 'text' => 'As a buyer of a B2in property, you receive exclusive insider access to our B2in furniture network. Furnish your property at unbeatable partner conditions.', + 'button_text' => 'Learn more about the B2in network', + 'button_link' => '/netzwerk', + ], + ], + ]; + } +} diff --git a/database/seeders/DisplayVersionSeeder.php b/database/seeders/DisplayVersionSeeder.php new file mode 100644 index 0000000..d6a3357 --- /dev/null +++ b/database/seeders/DisplayVersionSeeder.php @@ -0,0 +1,291 @@ + 'Schaufenster Video', + 'type' => 'video-display', + 'settings' => [], + 'is_active' => true, + ]); + + $videos = [ + ['filename' => 'herbst_2025.mp4', 'title' => 'Herbst Kollektion 2025', 'position' => 25], + ['filename' => 'fruehjahr_2025.mp4', 'title' => 'Frühjahr Kollektion 2025', 'position' => 30], + ['filename' => 'herbst_2024.mp4', 'title' => 'Herbst Kollektion 2024', 'position' => 25], + ['filename' => 'fruehjahr_2024.mp4', 'title' => 'Frühjahr Kollektion 2024', 'position' => 25], + ]; + + foreach ($videos as $i => $video) { + DisplayVersionItem::create([ + 'display_version_id' => $videoVersion->id, + 'item_type' => 'video', + 'content' => $video, + 'sort_order' => $i, + 'is_active' => true, + ]); + } + + $footers = [ + ['headline' => 'Beratung & Termin', 'subline' => 'Jetzt Termin vereinbaren.', 'url' => 'https://b2in.de/termin'], + ['headline' => 'Neue Kollektion', 'subline' => 'Entdecken Sie die aktuellen Trends.', 'url' => null], + ]; + + foreach ($footers as $i => $footer) { + DisplayVersionItem::create([ + 'display_version_id' => $videoVersion->id, + 'item_type' => 'footer', + 'content' => $footer, + 'sort_order' => $i, + 'is_active' => true, + ]); + } + + // ======================================== + // 2. B2in Version (Dark) + // ======================================== + $b2inDark = DisplayVersion::create([ + 'name' => 'B2in Immobilien Dark', + 'type' => 'b2in', + 'settings' => [ + 'theme' => 'dark', + 'footer_name' => 'Marcel Scheibe', + 'footer_url' => 'b2in.de', + 'transition' => ['type' => 'crossfade', 'duration_ms' => 800], + 'default_image_duration' => 10, + 'rotation_weights' => ['immobilien' => 70, 'moebel' => 30], + 'display_active' => true, + ], + 'is_active' => true, + ]); + + $b2inItems = [ + [ + 'category' => 'immobilien', + 'media_type' => 'video', + 'media_url' => '../assets/334716_medium.mp4', + 'headline' => 'Internationale Immobilien — Ihr Einstieg.', + 'subline' => 'Beratung, Begleitung und Vermittlung. Persönlich. Transparent.', + 'duration_seconds' => 10, + ], + [ + 'category' => 'immobilien', + 'media_type' => 'video', + 'media_url' => '../assets/48504-454713939_medium.mp4', + 'headline' => 'Ihr Zuhause. Weltweit.', + 'subline' => 'Von Dubai bis Europa – wir finden Ihre Immobilie.', + 'duration_seconds' => 10, + ], + [ + 'category' => 'moebel', + 'media_type' => 'image', + 'media_url' => 'https://images.unsplash.com/photo-1618221195710-dd6b41faaea6?w=1920&h=1080&fit=crop', + 'headline' => 'Exklusive Einrichtung — Lokal. Für Sie.', + 'subline' => 'Kuratierte Möbelkonzepte von lokalen Fachhändlern.', + 'duration_seconds' => 10, + ], + ]; + + foreach ($b2inItems as $i => $item) { + DisplayVersionItem::create([ + 'display_version_id' => $b2inDark->id, + 'item_type' => 'media', + 'content' => $item, + 'sort_order' => $i, + 'is_active' => true, + ]); + } + + // ======================================== + // 3. B2in Version (Light) + // ======================================== + $b2inLight = DisplayVersion::create([ + 'name' => 'B2in Immobilien Light', + 'type' => 'b2in', + 'settings' => [ + 'theme' => 'light', + 'footer_name' => 'Marcel Scheibe', + 'footer_url' => 'b2in.de', + 'transition' => ['type' => 'crossfade', 'duration_ms' => 800], + 'default_image_duration' => 10, + 'rotation_weights' => ['immobilien' => 70, 'moebel' => 30], + 'display_active' => true, + ], + 'is_active' => true, + ]); + + foreach ($b2inItems as $i => $item) { + DisplayVersionItem::create([ + 'display_version_id' => $b2inLight->id, + 'item_type' => 'media', + 'content' => $item, + 'sort_order' => $i, + 'is_active' => true, + ]); + } + + // ======================================== + // 4. Offers Version + // ======================================== + $offersVersion = DisplayVersion::create([ + 'name' => 'Angebote Schauraum', + 'type' => 'offers', + 'settings' => [ + 'loop' => true, + 'transition' => ['type' => 'fade', 'duration' => 600], + ], + 'is_active' => true, + ]); + + $slides = [ + [ + 'type' => 'intro', + 'duration' => 8000, + 'image_url' => '../assets/cabinet-intro.jpg', + 'badge_text' => 'Ausstellungsdeals – solange verfügbar', + 'eyebrow' => 'Heute im Fokus', + 'title' => "Kuratiert.\nHochwertig.\nSofort.", + 'subline' => '', + 'price' => '', + 'original_price' => '', + 'tag_text' => '', + 'bullets' => [], + 'disclaimer' => 'Zwischenverkauf vorbehalten', + 'qr_url' => 'https://cabinet-bielefeld.de', + 'qr_title' => 'Kontakt', + 'contact' => "0521 98620100\nTel. oder WhatsApp", + 'show_brand_text' => true, + 'brand_tagline' => "Planung • Beratung\nLieferung & Montage", + ], + [ + 'type' => 'product-hero', + 'duration' => 10000, + 'image_url' => '../assets/goya1.jpg', + 'badge_text' => 'Einzelstück', + 'eyebrow' => 'Hersteller: Sudbrock', + 'title' => 'GOYA Sideboard', + 'subline' => '', + 'price' => '489 €', + 'original_price' => 'statt 4.744 €', + 'tag_text' => '', + 'bullets' => [], + 'disclaimer' => '', + 'qr_url' => 'https://cabinet-bielefeld.de', + 'qr_title' => 'Reservieren', + 'contact' => "0521 98620100\nTel. oder WhatsApp", + 'show_brand_text' => false, + 'brand_tagline' => '', + ], + [ + 'type' => 'product-details', + 'duration' => 12000, + 'image_url' => '../assets/goya2.jpg', + 'badge_text' => 'Einzelstück', + 'eyebrow' => 'Auf einen Blick', + 'title' => 'GOYA Sideboard', + 'subline' => '', + 'price' => '', + 'original_price' => '', + 'tag_text' => '', + 'bullets' => [ + 'Eingelagertes Einzelstück', + 'Abholung in Rheda-Wiedenbrück', + 'Lieferung optional', + 'Deckel weiß matt (neu)', + ], + 'disclaimer' => '', + 'qr_url' => 'https://cabinet-bielefeld.de', + 'qr_title' => 'Reservieren', + 'contact' => "0521 98620100\nTel. oder WhatsApp", + 'show_brand_text' => false, + 'brand_tagline' => '', + ], + [ + 'type' => 'product-impulse', + 'duration' => 10000, + 'image_url' => '../assets/tango.jpg', + 'badge_text' => 'Ausstellungsstück', + 'eyebrow' => 'Nur 1×', + 'title' => 'TANDO Spiegel', + 'subline' => 'Heute mitnehmen', + 'price' => '199 €', + 'original_price' => '', + 'tag_text' => 'Im Store verfügbar', + 'bullets' => [], + 'disclaimer' => '', + 'qr_url' => 'https://cabinet-bielefeld.de', + 'qr_title' => 'Sichern', + 'contact' => "0521 98620100\nTel. oder WhatsApp", + 'show_brand_text' => false, + 'brand_tagline' => '', + ], + ]; + + foreach ($slides as $i => $slide) { + DisplayVersionItem::create([ + 'display_version_id' => $offersVersion->id, + 'item_type' => 'slide', + 'content' => $slide, + 'sort_order' => $i, + 'is_active' => true, + ]); + } + + // ======================================== + // Displays + // ======================================== + $display1 = Display::create([ + 'name' => 'Display 1 – Eingang', + 'location' => 'Schaufenster links', + 'is_active' => true, + ]); + $display1->versions()->attach([ + $videoVersion->id => ['sort_order' => 0], + $b2inDark->id => ['sort_order' => 1], + ]); + + $display2 = Display::create([ + 'name' => 'Display 2 – Mitte', + 'location' => 'Schaufenster Mitte', + 'is_active' => true, + ]); + $display2->versions()->attach([ + $offersVersion->id => ['sort_order' => 0], + ]); + + $display3 = Display::create([ + 'name' => 'Display 3 – Rechts', + 'location' => 'Schaufenster rechts', + 'is_active' => true, + ]); + $display3->versions()->attach([ + $b2inLight->id => ['sort_order' => 0], + $offersVersion->id => ['sort_order' => 1], + $videoVersion->id => ['sort_order' => 2], + ]); + + $display4 = Display::create([ + 'name' => 'Display 4 – Innen', + 'location' => 'Schauraum', + 'is_active' => true, + ]); + $display4->versions()->attach([ + $b2inDark->id => ['sort_order' => 0], + ]); + } +} diff --git a/database/seeders/NetzwerkPageCmsSeeder.php b/database/seeders/NetzwerkPageCmsSeeder.php new file mode 100644 index 0000000..7f9f8de --- /dev/null +++ b/database/seeders/NetzwerkPageCmsSeeder.php @@ -0,0 +1,90 @@ + + */ + protected array $sections = [ + 'netzwerk_cabinet_partner' => ['netzwerk', 'cabinet_partner'], + 'netzwerk_immobilien_hint' => ['netzwerk', 'immobilien_hint'], + ]; + + public function run(): void + { + /** @var array $langDe */ + $langDe = Lang::get('b2in.themes.b2in', [], 'de'); + /** @var array $langEn */ + $langEn = Lang::get('b2in.themes.b2in', [], 'en'); + + if (! is_array($langDe)) { + $this->command?->warn('NetzwerkPageCmsSeeder: b2in.themes.b2in (de) fehlt.'); + + return; + } + + if (! is_array($langEn)) { + $langEn = []; + } + + $created = 0; + + foreach ($this->sections as $sectionKey => [$group, $key]) { + if (CmsContent::query()->where('group', $group)->where('key', $key)->exists()) { + $this->command?->info("CMS {$group}.{$key} existiert bereits – übersprungen."); + + continue; + } + + $sectionDataDe = $langDe[$sectionKey] ?? null; + if ($sectionDataDe === null) { + $this->command?->warn("Lang-Block {$sectionKey} fehlt – übersprungen."); + + continue; + } + + $sectionDataEn = $langEn[$sectionKey] ?? $sectionDataDe; + + CmsContent::query()->create([ + 'group' => $group, + 'key' => $key, + 'type' => $this->detectType($sectionDataDe), + 'value' => [ + 'de' => $sectionDataDe, + 'en' => $sectionDataEn, + ], + 'order' => 0, + ]); + + $created++; + $this->command?->info("CMS {$group}.{$key} angelegt."); + } + + if ($created > 0) { + app(CmsContentService::class)->clearCache(); + } + } + + private function detectType(mixed $data): string + { + if (is_array($data)) { + return 'json'; + } + + if (is_string($data) && preg_match('/<[^>]+>/', $data)) { + return 'html'; + } + + return 'text'; + } +} diff --git a/database/seeders/RoleSeeder.php b/database/seeders/RoleSeeder.php index 96f7256..8e9c78b 100644 --- a/database/seeders/RoleSeeder.php +++ b/database/seeders/RoleSeeder.php @@ -139,7 +139,7 @@ class RoleSeeder extends Seeder 'manage orders', // Eigene Bestellungen ]); - // 5. Admin (B2In Management / Marcel) + // 5. Admin (B2in Management / Marcel) $adminRole = Role::create([ 'name' => 'Admin', 'display_name' => 'Admin (Administrator)', diff --git a/dev/12-01-2026/entwicklungsplan.md b/dev/12-01-2026/entwicklungsplan.md index 47091c0..c39bd98 100644 --- a/dev/12-01-2026/entwicklungsplan.md +++ b/dev/12-01-2026/entwicklungsplan.md @@ -1,9 +1,9 @@ -# Entwicklungsplan: B2In / Local for Local Marktplatz-Ökosystem +# 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 ✅ +**Status:** Phase 1 ✅, Phase 2 ✅, Phase 2.5 Produkt-Bearbeitung ✅, Phase 2.6 Refactoring & UX ✅, Phase 2.7 Admin-Produktverwaltung & Freigabe ✅, Phase 3 Kern ✅, Phase 2.8 Partner-Präsentation ✅ **Docker** Projekt läuft in Docker, root /var/www/html nutze php artisan ... nicht vendor/bin/sail artisan ... --- @@ -581,6 +581,31 @@ Alle Preistypen erlaubt; Standard-Varianten mit Festpreis möglich. - [ ] Notification an Händler/Hersteller bei Freigabe/Ablehnung – ausstehend - [ ] Admin kann `product_type` bei Bedarf nachträglich ändern – ausstehend +#### 2.4a Nachbesserungen Phase 2 – ✅ Abgeschlossen (27.02.2026) + +**Fix: `curate products` Permission fehlte in der Datenbank** +- Die Permission war im `RoleSeeder` definiert, aber nie in die Produktions-DB eingespielt +- `Attempt to read property "id" on null` beim Öffnen von `/admin/products` +- Lösung: Migration `2026_02_27_154145_add_curate_products_permission.php` + - `Permission::firstOrCreate(['name' => 'curate products', 'guard_name' => 'web'])` + - Admin-Rolle bekommt Permission via `Role::where('name', 'Admin')->first()` (kein `findByName` = würde Exception werfen wenn Rolle fehlt) +- Tests (`ProductCurationTest`, `ProductPolicyTest`, `PartnerPolicyTest`): `Permission::create()` → `Permission::firstOrCreate()` (verhindert Fehler wenn Migration die Permission bereits angelegt hat) + +**Fix: Admin kann Produkte anlegen (Partner-Selector)** +- Admin hat keine eigene `partner_id` → `$user->partner` war `null` → Crash beim Speichern +- Lösung in `form-teaser.blade.php` und `form-standard.blade.php`: + - Neues Property `$selectedPartnerId` + `resolvePartner()` Methode + - Für Admins: Partner-Auswahl-Karte erscheint vor dem Formular (nur bei Neuanlage) + - `updatedSelectedPartnerId()` aktualisiert Produktnummer automatisch nach Partnerwahl + - Validierung: `selectedPartnerId` Pflichtfeld für Admins bei Neuanlage +- Alle bestehenden 158 Produkt-Tests weiterhin grün ✅ + +**Fix: Portal Textarea Dark-Mode Kontrast** +- Korrektur-Textfeld in der Kuration zeigte weißen Text auf weißem Hintergrund +- Ursache: `@apply dark:text-zinc-500` innerhalb von `::placeholder` Pseudo-Elementen kompiliert in Tailwind v4 zu ungültigem `:is():where(.dark,.dark *)` Selektor +- Lösung in `resources/css/portal.css`: Explizites CSS mit `:where(.dark,.dark *)` als Vorfahren-Selektor statt `@apply dark:` +- Portal-Build (`npm run build:portal`) aktualisiert + #### 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 @@ -596,6 +621,59 @@ Alle Preistypen erlaubt; Standard-Varianten mit Festpreis möglich. --- +### Phase 2.8: Partner-Präsentation & Selbst-Service-Profil – ✅ ABGESCHLOSSEN (27.02.2026) +**Geschätzter Aufwand:** 2-3 Tage | **Tatsächlich:** 1 Sitzung + +#### 2.8.1 Händler-Profil (Retailer) – Präsentation +- [x] `my-data.blade.php` erweitert um: + - [x] **Story-Text** – Freitext max. 2000 Zeichen mit Live-Zeichenzähler, Feld: `story_text` + - [x] **Öffnungszeiten** – 7-Tage-Eingabe (Mo–So), `open`/`close`-Felder + "Geschlossen"-Checkbox, Feld: `opening_hours` (JSON) + - [x] **Spezialisierungen** – kommagetrennte Eingabe → gespeichert als JSON-Array, Feld: `specialties` + - [x] **Gründungsjahr** – Zahlfeld mit Validierung (1800–heute), Feld: `founded_year` + - [x] **Team-Fotos** – Mehrfach-Upload via `WithFileUploads`, gespeichert als `Media.type = 'team_photo'` + - [x] **Showroom-Galerie** – Mehrfach-Upload, `Media.type = 'showroom'` + - [x] Foto-Löschen per Klick (mit `wire:confirm`) +- [x] Formular in 4 separate `flux:card`-Abschnitte gegliedert: Stammdaten, Präsentation & Story, Öffnungszeiten, Fotos + +#### 2.8.2 Hersteller-Profil (Manufacturer) – Firma & Marke +- [x] `my-data.blade.php` erweitert um: + - [x] **Story-Text** – Unternehmensgeschichte, Feld: `story_text` + - [x] **Gründungsjahr** – Feld: `founded_year` + - [x] **Spezialisierungen** – Feld: `specialties` + - [x] **Marken-Bilder** – Mehrfach-Upload, `Media.type = 'brand_image'` +- [x] Abschnitte: Stammdaten, Über das Unternehmen, Marke (mit Markenname, Beschreibung, Bilder) + +#### 2.8.3 Öffentliches Partnerprofil (`livewire/partner/profile.blade.php`) +- [x] Story-Text anzeigen wenn vorhanden (bereits vorhanden) +- [x] Öffnungszeiten anzeigen (bereits vorhanden) +- [x] Spezialisierungen + Gründungsjahr anzeigen (bereits vorhanden) +- [x] **Showroom-Galerie** – 3-spaltige Bild-Galerie unterhalb der Story +- [x] **Marken-Bilder** (Hersteller) – 3-spaltige Galerie unter der Story +- [x] **Team-Fotos** – 2-spaltige Galerie in der rechten Sidebar + +#### 2.8.4 Datenbank +- [x] Alle Felder vorhanden: `story_text`, `opening_hours`, `specialties`, `founded_year` (Migration `2026_02_12_000003_add_profile_fields_to_partners_table`) +- [x] Media-System: Custom `Media` Model mit `type`-Feld (kein Spatie) – `Partner::media()` morph-Relation bereits vorhanden + +#### 2.8.5 Tests Phase 2.8 +- [x] `PartnerSelfServiceProfileTest` (15 Tests) – alle bestanden ✅ + - Händler kann Story, Öffnungszeiten, Spezialisierungen, Gründungsjahr speichern + - Händler kann Team-Fotos und Showroom-Fotos hochladen + - Händler kann Foto löschen + - Hersteller kann Story, Markendaten, Gründungsjahr speichern + - Hersteller kann Marken-Bilder hochladen + - Öffentliches Profil zeigt Story und Spezialisierungen an + - Validierung: Story-Text max. 2000 Zeichen, Gründungsjahr-Grenzen + +**Erstellte/geänderte Dateien (2):** + +| Datei | Änderungen | +|-------|-----------| +| `resources/views/livewire/partner/my-data.blade.php` | Komplett überarbeitet: 4 Karten-Abschnitte, neue Profil-Felder, Foto-Upload mit `WithFileUploads` | +| `resources/views/livewire/partner/profile.blade.php` | Showroom-Galerie, Marken-Bilder, Team-Fotos hinzugefügt; `media` eager-loaded | + +--- + ### 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 @@ -721,7 +799,7 @@ Enum: App\Enums\TransactionStatus - [ ] `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 +- [ ] `Paid` – Händler hat Provision an B2in überwiesen - [ ] `Distributed` – Provisionen an Makler/Kunde ausgeschüttet - [ ] `Rejected` – Händler hat abgelehnt - [ ] `Disputed` – Streitfall @@ -778,7 +856,7 @@ Migration: create_ledger_entries_table #### 6.2 Provisions-Berechnung - [ ] Service: `App\Services\CommissionService` - `calculateSplit(Transaction $transaction): CommissionSplit` - - Berechnet: Makler-Anteil, Kunden-Cashback, B2In-Marge + - 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 @@ -856,7 +934,7 @@ Migration: create_ledger_entries_table > **Entscheidung:** Individuell pro Partner. Admin-Settings-Seite mit Feldern für: > - Makler-Provision (%) > - Kunden-Cashback (%) -> - B2In-Marge (Rest) +> - B2in-Marge (Rest) > Felder werden pro Partner im Admin-Backend konfiguriert. **Frage 8: Ticket-Gültigkeit** ✅ diff --git a/dev/12-01-2026/konzeption.md b/dev/12-01-2026/konzeption.md index 53c27c4..372094a 100644 --- a/dev/12-01-2026/konzeption.md +++ b/dev/12-01-2026/konzeption.md @@ -2,9 +2,9 @@ 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. +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"). +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. @@ -83,11 +83,11 @@ Anstatt eines klassischen "Warenkorbs" bauen wir für Typ A Produkte einen "Tick 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 +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. +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. @@ -95,7 +95,7 @@ Der USP liegt in der Transparenz lokaler Verfügbarkeit (Säule A: Local Express Wir unterscheiden strikt zwischen dem B2B-Zugang (Partner) und den B2C-Einstiegen (Endkunden). A. Der B2B-Kanal (Die Dachmarke) -Marke: B2In +Marke: B2in Zielgruppe: Immobilienmakler, Händler, Hersteller. @@ -163,9 +163,9 @@ 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. +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. +Clearing: Händler bestätigt Umsatz im Backend -> Händler zahlt Gesamt-Provision an B2in. Ausschüttung: Sobald Geld eingeht, verteilt das System automatisch: @@ -173,7 +173,7 @@ Provision an Makler (Lead-Vergütung). Cashback an Kunden (Motivation & Datentreue). -Marge an B2In. +Marge an B2in. @@ -240,7 +240,7 @@ pending_merchant: Händler muss bestätigen ("Ja, Kunde war da und hat für 3.00 confirmed: Händler hat bestätigt -> Rechnung an Händler wird generiert. -paid: Händler hat Provision an B2In überwiesen. +paid: Händler hat Provision an B2in überwiesen. distributed: Provision wurde an Makler/Kunde ausgeschüttet. @@ -264,7 +264,7 @@ 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). +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. diff --git a/dev/12-03-2026/content.md b/dev/12-03-2026/content.md new file mode 100644 index 0000000..8d3126d --- /dev/null +++ b/dev/12-03-2026/content.md @@ -0,0 +1,95 @@ +### 1. Nachschärfung des Escrow-Textes (Magazin-Artikel 1) +Wir ersetzen die bisherigen drei Punkte im ersten Magazin-Artikel resources/views/livewire/web/components/sections/magazin-list.blade.php („Sicherheit statt Bürokratie“) exakt durch den vom Kunden gelieferten Text. Das macht den Artikel rechtlich und fachlich wasserdicht. + +So sieht der aktualisierte Block für den Artikel aus: + +1. Das Escrow-System: Sicherheit durch staatlich regulierte Treuhandkonten +Der wichtigste Schutzmechanismus beim Kauf einer Off-Plan-Immobilie in Dubai ist das gesetzlich vorgeschriebene Escrow-System. Käuferzahlungen fließen nicht direkt an den Bauträger, sondern werden auf ein projektbezogenes Treuhandkonto bei einer zugelassenen Bank eingezahlt. Dieses Konto wird von der Real Estate Regulatory Agency (RERA), einer Regulierungsbehörde des Dubai Land Department (DLD), überwacht. Der Entwickler erhält Zugriff auf die Gelder nur entsprechend dem tatsächlichen Baufortschritt, der von zertifizierten Ingenieuren bestätigt werden muss. + +2. Strenge Kontrolle durch das Dubai Land Department (DLD) +Das Dubai Land Department ist die zentrale staatliche Institution für Immobilien in Dubai. Über seine Regulierungsbehörde RERA überwacht es sämtliche Off-Plan-Projekte und sorgt dafür, dass Bauträger strenge gesetzliche Vorgaben einhalten. Jeder Kaufvertrag (SPA) wird offiziell registriert, und Bauprojekte müssen im sogenannten Oqood-System erfasst werden. Zahlungen der Käufer fließen auf projektbezogene Escrow-Konten und dürfen nur entsprechend dem bestätigten Baufortschritt freigegeben werden. + +3. Transparenz und hohe Planbarkeit +Durch die enge Verknüpfung von Käuferzahlungen mit dem tatsächlichen Baufortschritt entsteht ein hohes Maß an Transparenz für Investoren. Bauträger dürfen Mittel aus dem Escrow-Konto nur entsprechend dem bestätigten Baufortschritt abrufen. Dadurch wird sichergestellt, dass Investorengelder projektgebunden eingesetzt werden und nicht für andere Bauvorhaben verwendet werden können. Für Käufer bedeutet dies: Die Dynamik des Immobilienmarktes in Dubai wird mit einem klar regulierten System kombiniert, das international als vergleichsweise investorenfreundlich gilt. + + +### 2. Integration von CABINET auf der "Netzwerk"-Seite + +resources/views/web/netzwerk.blade.php +Netzwerk-Seite hier sieht man, dass die Seite aktuell noch sehr leer ist ("Was wir aufbauen"). Hier ziehen wir CABINET als starkes Zugpferd (Anchor-Partner) hinein. + +Neuer Text-Block für die Netzwerk-Seite: + +Headline: Unser Premiumpartner: CABINET Einbauschränke +Subline: Maßgefertigte Exzellenz für höchste Ansprüche. + +Text: +Im B2in-Ökosystem setzen wir auf Qualität, die Bestand hat. Wir sind stolz darauf, CABINET als Premiumpartner in unserem Netzwerk zu präsentieren. Der CABINET Store in Bielefeld (unser physischer Ankerpunkt) steht für maßgefertigte Einbaulösungen, die Design, Funktionalität und handwerkliche Präzision perfekt vereinen. + +Ob begehbare Kleiderschränke, smarte Raumlösungen oder exklusive Innenausbauten – CABINET liefert den Premium-Standard, den unsere Immobilien-Investoren und Entwickler für ihre High-End-Projekte fordern. Diese Partnerschaft ist ein lebendiger Beweis für unsere Philosophie: Wir verbinden internationale Immobilien mit deutscher Einrichtungsexzellenz. + +hier liegt das Logo public/img/assets/b2in/cabinet_logo.png +(Visuell: Hierzu am besten ein sehr hochwertiges Foto aus dem CABINET-Showroom in Bielefeld oder ein Detailbild eines maßgefertigten Schranks einbauen.) + +### 3. Die Inhalte für die FAQ-Seite +Damit ihr die Seite direkt live nehmen könnt, habe ich hier die perfekten, verkaufspsychologischen Antworten formuliert, die exakt zu unserer neuen Ausrichtung passen: + +1. Was ist B2in und welche Services bieten Sie an? + +B2in ist die Brücke zwischen internationalen Premium-Immobilien und exklusiven Einrichtungskonzepten. Wir bieten Immobilieninvestoren Zugang zu Off-Market-Projekten (Fokus: Dubai) und begleiten sie durch den gesamten Kaufprozess. Gleichzeitig bieten wir Bauträgern ein knallhartes Supply-Chain-Management für die Beschaffung deutscher Qualitätsmöbel und bauen ein innovatives Einrichtungsnetzwerk für lokale Händler auf. + +2. Was bedeutet Supply-Chain-Management bei B2in? + +Für internationale Immobilienentwickler fungieren wir als verlängerter Arm in Deutschland. Wir übernehmen die operative und strategische Steuerung der Möbel- und Innenausstattungsbeschaffung. Das bedeutet: Wir sichern Lieferverträge ab, überwachen Meilensteine direkt bei den Herstellern und eskalieren bei Abweichungen sofort auf Managementebene. Unser Ziel ist absolute Vertragssicherheit und termingerechte Lieferung ohne Reibungsverluste. + +3. Wie kann ich Partner bei B2in werden? + +Unser Netzwerk richtet sich an Immobilienentwickler, Makler und den regionalen Möbelfachhandel. Wenn Sie als Entwickler deutsche Beschaffungssicherheit suchen oder als Händler Teil unseres künftigen "Local for Local"-Netzwerks werden möchten, um qualifizierte Leads von Immobilienkäufern zu erhalten, nutzen Sie einfach unser Kontaktformular. Wir prüfen jede Partnerschaft individuell auf Qualität und Passgenauigkeit. + +4. Wie funktioniert das Immobilien-Investment über B2in? + +Wir vermitteln nicht nur, wir begleiten Sie. Marcel Scheibe ist Ihr persönlicher Berater, der den Markt (insbesondere Dubai) aus eigener Investorensicht kennt. Nach einer Bedarfsanalyse stellen wir Ihnen exklusive Projekte vor (z. B. von Azizi Developments). Der Kaufprozess selbst ist durch das staatliche Escrow-System in Dubai maximal abgesichert. Ihr besonderer Vorteil: Als B2in-Kunde erhalten Sie im Anschluss exklusiven Zugang zu unserem Netzwerk, um Ihr Investment schlüsselfertig (Turnkey) und renditeoptimiert einrichten zu lassen. + +5. Was macht B2in zu einem vertrauenswürdigen Partner? + +Vertrauen entsteht durch Transparenz und eigene Markterfahrung. B2in-Gründer Marcel Scheibe investiert selbst in die Märkte, die wir anbieten, und ist regelmäßig vor Ort, um Baufortschritte zu prüfen. Wir verbinden die immense Dynamik internationaler Märkte (wie Dubai) mit deutscher Verlässlichkeit, striktem Vertragsmanagement und einem kuratierten Netzwerk von Premiumpartnern wie CABINET. Bei uns haben Sie immer einen persönlichen Ansprechpartner, der Ihre Interessen vertritt. + +Die weite FAQ direkt mit in die Navigation aufnehmen. +resources/views/web/faq.blade.php + +### 3. Die Inhalte für die FAQ-Seite +resources/views/web/immobilien.blade.php + +Auf einer Immobilien-Detailseite resources/views/web/immobilien-show.blade.php +(wie beim Azizi Creek Views 4) geht es um viel Geld. Hier ist die größte Conversion-Hürde das Thema Vertrauen und Risiko. Wenn wir dort einen endlosen Fließtext über das Escrow-System einbauen, zerstören wir die emotionale Verkaufs-Ästhetik der Bilder. Wenn wir es ganz weglassen, springen vorsichtige deutsche Käufer ab. + +Drei kleine Info-Boxen (Cards) mit einem "Deep-Dive"-Link ins Magazin sind die absolute Best Practice im E-Commerce und bei High-End-Investments. So halten wir die Seite optisch clean (Snackable Content), fangen aber die Skeptiker perfekt ab. + +Hier ist mein Konzept, wie ihr diesen "Trust-Block" direkt auf der Detailseite ( unter der Bildergalerie) einbauen solltet: + +UI-Konzept für die Detailseite: Der "Trust-Block" +Sektions-Headline: Maximale Sicherheit für Ihr Investment +Sektions-Subline: Der Kaufprozess in Dubai ist durch strenge staatliche Regularien international führend im Investorenschutz. + +(Darunter ein sauberes Grid mit 3 kleinen Kacheln/Boxen) + +Box 1 🔒 + +Titel: Staatliches Escrow-System + +Kurztext: Ihre Zahlungen fließen nicht an den Bauträger, sondern auf speziell regulierte Treuhandkonten der RERA (Real Estate Regulatory Agency). + +Box 2 🏛️ + +Titel: Strenge DLD-Kontrolle + +Kurztext: Jeder Kaufvertrag (SPA) wird offiziell beim Dubai Land Department registriert. Lückenlose behördliche Überwachung jedes Bauprojekts. + +Box 3 📊 + +Titel: Transparente Planbarkeit + +Kurztext: Gelder werden nur streng nach zertifiziertem Baufortschritt freigegeben. Eine Zweckentfremdung Ihres Kapitals ist gesetzlich ausgeschlossen. + +(Direkt unter den 3 Boxen zentriert als Text-Link Button) +Link/CTA: 📖 Deep Dive: Erfahren Sie im Magazin im Detail, wie das Escrow-System in Dubai Sie als Käufer schützt → [Link zum Magazin-Artikel] diff --git a/dev/12-03-2026/magazin.md b/dev/12-03-2026/magazin.md new file mode 100644 index 0000000..41c7ea4 --- /dev/null +++ b/dev/12-03-2026/magazin.md @@ -0,0 +1,102 @@ +Das ist eine hervorragende Herangehensweise. Wenn wir direkt alle fünf Artikel im passenden Format bereithalten, kann der Kunde frei wählen und das Magazin wirkt vom Start weg professionell und gut gefüllt. + +Ich habe die Struktur exakt an den Screenshot angepasst: **Ein starker Einleitungstext (ohne eigene Überschrift), gefolgt von durchnummerierten, in sich geschlossenen Textblöcken.** Hier sind die fünf fertigen Magazin-Artikel für euren Live-Gang: + +--- + +### Artikel 1: Sicherheit statt Bürokratie: Warum das Dubai Escrow-System deutsche Investoren überrascht + +**[Einleitungstext - Fließtext oben]** +Deutsche Investoren schätzen beim Immobilienkauf vor allem eines: maximale Sicherheit. Der vertraute Prozess über Notar und Grundbuchamt vermittelt ein Gefühl von Kontrolle, ist jedoch oft mit langen Wartezeiten und enormer Bürokratie verbunden. Wer zum ersten Mal auf den Immobilienmarkt in Dubai blickt, ist oft überrascht von der unglaublichen Dynamik und Geschwindigkeit. Doch bedeutet dieses Tempo ein höheres Risiko? Im Gegenteil: Dubai hat mit dem Escrow-System einen der sichersten und transparentesten regulatorischen Rahmenwerke der Welt geschaffen, der Investorengelder konsequent schützt. + +**1. Das Escrow-System: Sicherheit durch Treuhand** +Der wohl wichtigste Schutzmechanismus in Dubai ist das staatlich regulierte Escrow-System (Treuhandkonten). Wenn Sie eine Off-Plan-Immobilie (Neubau) erwerben, fließt Ihr Geld niemals direkt an den Bauträger. Stattdessen wird es auf ein unabhängiges, von der Regierung überwachtes Treuhandkonto eingezahlt. Der Entwickler erhält erst dann Zugriff auf diese Mittel, wenn nachweislich bestimmte Baufortschritte erreicht wurden. + +**2. Strenge Kontrolle durch das Dubai Land Department (DLD)** +Das Dubai Land Department agiert als zentrale Kontrollinstanz und garantiert, dass alle Transaktionen rechtmäßig ablaufen. Jeder Kaufvertrag (SPA) muss offiziell beim DLD registriert werden. Das DLD entsendet regelmäßig eigene Ingenieure auf die Baustellen, um den tatsächlichen Baufortschritt zu verifizieren, bevor Gelder vom Escrow-Konto an den Bauträger freigegeben werden. + +**3. Transparenz und absolute Planbarkeit** +Durch diese strikte Koppelung von Zahlungen an den physischen Baufortschritt haben Investoren jederzeit absolute Transparenz. Bauträger werden gezwungen, termingerecht und in hoher Qualität zu liefern. Das System eliminiert das Risiko, dass Entwickler Investorengelder für andere Projekte abzweigen. Für Käufer bedeutet das: Die Dynamik Dubais wird mit einer Verlässlichkeit kombiniert, die internationalen Standards weit voraus ist. + +**[CTA-Bereich]** +*Möchten Sie mehr über sichere Investments in Dubai erfahren? Vereinbaren Sie jetzt ein persönliches Beratungsgespräch mit Marcel Scheibe.* + +--- + +### Artikel 2: Spotlight Al Jaddaf: Warum smarte Investoren jetzt auf diesen aufstrebenden Hotspot setzen + +**[Einleitungstext - Fließtext oben]** +Die Wahl der richtigen Lage ist der entscheidende Faktor für eine hohe Rendite und langfristige Wertsteigerung. Während weltbekannte Areale wie Downtown Dubai oder die Palm Jumeirah hohe Einstiegspreise aufrufen, suchen smarte Investoren nach den "Hidden Champions" – Vierteln, die vor einer massiven Aufwertung stehen. Eines der spannendsten Entwicklungsgebiete der Stadt ist aktuell Al Jaddaf. Historisch als Werft-Viertel am Dubai Creek bekannt, transformiert sich Al Jaddaf rasend schnell zu einem modernen, strategisch extrem wichtigen Knotenpunkt für exklusives Wohnen und Lifestyle. + +**1. Die strategische Waterfront-Location** +Al Jaddaf bietet eine seltene Kombination aus direkter Wasserlage (Waterfront) und zentraler Anbindung. Eingebettet zwischen dem historischen Dubai Creek und der modernen Erweiterung, bietet es unverbaubare Blicke auf die Skyline. Gleichzeitig sind Hotspots wie Downtown Dubai, der Burj Khalifa sowie der Dubai International Airport in nur wenigen Autominuten erreichbar. Diese Logistik macht den Standort unvergleichlich. + +**2. Hohe Mietnachfrage durch perfekte Infrastruktur** +Das Viertel zieht zunehmend Young Professionals, Expats und Familien an, die zentral, aber dennoch ruhig und exklusiv leben möchten. Die Nähe zur Healthcare City, zu kulturellen Highlights wie dem Jameel Arts Centre und die Anbindung an die Metro sorgen für eine exzellente Infrastruktur. Dies garantiert Investoren eine kontinuierlich hohe Mietnachfrage und minimale Leerstandsquoten. + +**3. Enormes Potenzial für Capital Appreciation (Wertsteigerung)** +Aktuell bietet Al Jaddaf noch Einstiegspreise, die ein exzellentes Preis-Leistungs-Verhältnis darstellen – insbesondere im Vergleich zu bereits etablierten Premium-Vierteln. Mit neuen High-End-Projekten (wie etwa den Azizi Creek Views) wird das Viertel massiv aufgewertet. Wer jetzt investiert, profitiert in den kommenden Jahren nicht nur von attraktiven Mietrenditen, sondern vor allem von einer signifikanten Wertsteigerung der Immobilie. + +**[CTA-Bereich]** +*Entdecken Sie unsere aktuellen Off-Market-Projekte in Al Jaddaf und sichern Sie sich Ihre Einheit.* + +--- + +### Artikel 3: Turnkey-Investments: Wie die richtige Möblierung Ihre Mietrendite in Dubai maximiert + +**[Einleitungstext - Fließtext oben]** +Der Kauf einer Premium-Immobilie in Dubai ist der erste Schritt zu einem erfolgreichen Investment. Doch erst die richtige Nutzung entscheidet über die tatsächliche Rendite. Besonders lukrativ ist die Kurzzeitvermietung (Short-Term-Rental, z. B. via Airbnb), die in Dubai florierende Umsätze generiert. Die Herausforderung für viele internationale Investoren: Wie richtet man eine Wohnung über Tausende Kilometer Entfernung so ein, dass sie aus der Masse heraussticht und Premium-Preise erzielt? Die Antwort lautet "Turnkey" (schlüsselfertig) – intelligente Einrichtungskonzepte, die Ästhetik, Langlebigkeit und Effizienz vereinen. + +**1. Premium-Look für höhere Übernachtungspreise** +Der erste Eindruck auf Buchungsplattformen entscheidet. Ein durchschnittlich eingerichtetes Apartment erzielt durchschnittliche Preise. Maßgeschneiderte Design-Konzepte, die perfekt auf die Architektur der Immobilie und die demografische Zielgruppe (Business-Reisende oder Urlauber) abgestimmt sind, erlauben es Ihnen, sich im Premium-Segment zu positionieren und die Mieteinnahmen signifikant zu steigern. + +**2. Der "Turnkey"-Vorteil aus der Ferne** +Niemand möchte sich aus Europa heraus um Lieferverzögerungen, fehlende Schrauben oder Handwerkertermine in Dubai kümmern. Ein intelligentes Turnkey-Konzept nimmt Ihnen diesen gesamten Prozess ab. Über das exklusive B2in-Netzwerk erhalten Käufer Zugang zu einem Service, der von der ersten Design-Skizze bis zum fertig bezogenen Bett alles abdeckt – komplett gesteuert durch deutsches Projektmanagement. + +**3. Langlebigkeit durch deutsche Qualitätsstandards** +Bei einer hohen Auslastung in der Kurzzeitvermietung werden Möbel stark beansprucht. Billige Ausstattungen müssen oft schon nach kurzer Zeit ersetzt werden, was die Rendite schmälert. Der Fokus auf langlebige Materialien und erstklassige Verarbeitungsqualität – oft gesichert durch Zugänge zu deutschen und europäischen Premium-Herstellern – reduziert die Instandhaltungskosten auf ein Minimum und erhält den Wert Ihres Investments. + +**[CTA-Bereich]** +*Als B2in-Immobilienkunde profitieren Sie exklusiv von unserem Einrichtungsnetzwerk. Sprechen Sie uns darauf an!* + +--- + +### Artikel 4: Supply-Chain-Management für Entwickler: Warum Vertragssicherheit den Unterschied macht + +**[Einleitungstext - Fließtext oben]** +Internationale Immobilienentwickler kennen das Problem: Ein Luxusprojekt ist nahezu fertiggestellt, doch die Innenausstattung aus Europa verzögert sich. Mangelhafte Kommunikation, versteckte Klauseln oder logistische Engpässe führen zu Bauverzögerungen, die Millionen kosten können. In einer globalisierten Welt reicht es nicht aus, Möbel und Materialien nur zu bestellen – man muss ihre Ankunft garantieren. Genau hier setzt professionelles Supply-Chain-Management an. Als verlängerter Arm vor Ort in Deutschland sorgt B2in dafür, dass Verträge nicht nur auf dem Papier existieren, sondern in der Realität pünktlich erfüllt werden. + +**1. Eskalation auf Managementebene** +Wenn Lieferungen ins Stocken geraten, verpuffen E-Mails oft im Kundenservice. Effektives Supply-Chain-Management erfordert Durchsetzungskraft und die richtigen Kontakte. Durch ein tief verankertes Netzwerk in der europäischen Einrichtungsbranche greifen wir bei Abweichungen sofort ein und eskalieren Probleme direkt auf Managementebene der Hersteller, um sofortige Lösungen zu erzwingen. + +**2. Aktives Vertragsmanagement statt Hoffnungsprinzip** +Vertragssicherheit beginnt vor der Unterschrift. Es geht darum, klare Leistungs- und Qualitätsparameter sowie harte Meilensteine zu definieren. Ein proaktives Management überwacht diese Parameter laufend und sichert Zahlungs- sowie Lieferbedingungen so ab, dass der Entwickler zu jedem Zeitpunkt die volle Kontrolle über den Prozess behält, ohne selbst operative Reibungsverluste zu erleiden. + +**3. Lückenloses Tracking und Qualitätskontrolle** +Vertrauen ist gut, Kontrolle vor Ort ist besser. Um Ausfälle zu vermeiden, werden Produktionsfortschritte direkt an der Quelle überwacht. Durch regelmäßige, persönliche Qualitätskontrollen bei den Herstellern stellen wir sicher, dass die Ware nicht nur pünktlich verladen wird, sondern exakt den geforderten Premium-Standards der Immobilien-Projektentwickler entspricht. + +**[CTA-Bereich]** +*Suchen Sie einen starken Partner für Ihre Beschaffung aus Deutschland? Kontaktieren Sie unser B2B-Team.* + +--- + +### Artikel 5: Local for Local: Wie der regionale Möbelhandel die Zukunft des Wohnens prägt + +**[Einleitungstext - Fließtext oben]** +In den letzten Jahren schien der Trend unaufhaltsam: Gigantische Online-Plattformen dominierten den Möbelmarkt. Doch der Markt wandelt sich. Konsumenten sehnen sich wieder nach Haptik, persönlicher Beratung und sofortiger Verfügbarkeit. Gleichzeitig schlummern in den regionalen Möbelhäusern echte Schätze ("Hidden Gems"), die online oft unsichtbar bleiben. Das Konzept "Local for Local" setzt genau hier an: Es ist ein digitaler Marktplatz-Ansatz, der nicht den anonymen Großhandel, sondern den lokalen Fachhandel stärkt – und damit eine Brücke zwischen digitaler Bequemlichkeit und regionaler Stärke baut. + +**1. Digitale Sichtbarkeit für lokale Bestände** +Die Frage "Was ist heute in meiner Nähe verfügbar?" konnte der lokale Handel digital oft nicht beantworten. Moderne Marktplatz-Technologien ändern das. Sie geben regionalen Händlern die Werkzeuge an die Hand, ihre sofort verfügbare Ausstellungs- und Lagerware einem breiten, kaufkräftigen Publikum (wie etwa neuen Immobilienbesitzern) sichtbar zu machen – ganz ohne komplexe eigene IT-Infrastruktur. + +**2. Support your Locals – Ein Gewinn für alle** +Das "David gegen Goliath"-Prinzip bringt die Kunden zurück in die Geschäfte. Käufer profitieren von exklusiven Preisen für Ausstellungsstücke und Markenware, die oft günstiger ist als im Großmarkt. Der Händler wiederum steigert seine Frequenz vor Ort, baut Liquidität durch schnellen Abverkauf auf und gewinnt Neukunden, die den Wert echter, physischer Beratung schätzen. + +**3. Smarte Vernetzung statt anonymer Plattform** +Die Zukunft gehört nicht den geschlossenen Online-Shops, sondern vernetzten Ökosystemen. Wenn Immobilienmakler, Kunden und regionale Händler auf einer Plattform zusammenkommen, entsteht ein Kreislauf des Vertrauens. Der Immobilienkauf wird zum Auslöser für den Möbelkauf, und der lokale Fachhandel wird zum verlässlichen Partner für die perfekte Einrichtung – lokal gedacht, intelligent vernetzt. + +**[CTA-Bereich]** +*Das B2in-Einrichtungsnetzwerk startet bald. Möchten Sie als Händler von Anfang an dabei sein? Sprechen Sie uns an.* + +--- + + diff --git a/dev/12-03-2026/tasks.md b/dev/12-03-2026/tasks.md new file mode 100644 index 0000000..0938bd1 --- /dev/null +++ b/dev/12-03-2026/tasks.md @@ -0,0 +1,210 @@ +### Briefing + +Der aktuelle Stand der Webseite mit den Inhalten wurde für sehr gut befunden. Jedoch gilt es jetzt vorab eine Version online zu schalten, die schon mal hauptsächlich den Immobilienbereich abgedeckt. D.h. der Immobilienbereich soll noch etwas mehr erklärt werden. Die Möbel Inhalte müssen etwas zurück rücken beziehungsweise erst mal nur teasern. + +### Wichtig +Alle bestehenden Inhalte, die jetzt vorliegen, sollen erhalten bleiben und als Back-up im System zurückbleiben. Diese sollen nicht gelöscht oder überschrieben werden, sondern wenn wir jetzt gerade im Möbelbereich neue Inhalte definieren, gilt es die alten zu konservieren, so dass wir auch schnell diese zugänglich haben. Am besten wäre es, wenn wir hier direkt subseiten erstellen, die wir über einen Link mit einer Site Map erreichen können. So können wir die Webseite online stellen mit den nötigen Inhalten und gleichzeitig aber auch an neuen Inhalten arbeiten, ohne dass sie Direkt zugänglich sind. Es gibt ja auch eine Theme Seite, so ähnlich können wir es mit aufbauen oder am besten einfach mit einbauen. + +### Konzept +Das ist ein hervorragender Plan für den "Soft Launch". Wenn das Möbel-Portal noch in der Entwicklung ist, wäre es ein strategischer Fehler, jetzt schon zu tief in die technischen Details (wie Cashback-Prozesse oder Portal-Logins) zu gehen. Wir teasern die Vision an, aber legen den **vollen Fokus und die Conversion-Power auf den Immobilien-Part**, der ab sofort Geld bringt. + +Die Screenshots der aktuellen Seite sehen extrem clean, hochwertig und vertrauenserweckend aus. Das Design trägt die Premium-Positionierung perfekt. + +Und jetzt zu Marcels E-Mail: **Dieser Text ist verkaufspsychologisch pures Gold.** Er nutzt einen genialen Ansatz: Er spricht die typisch deutsche Skepsis direkt an, nimmt sie ernst ("Ich bin selbst so") und positioniert sich nicht als typischer Makler, sondern als "Brückenbauer" und Insider. Das schafft massiv Vertrauen. + +Damit dieser starke Text auf der Website aber nicht als ermüdende "Textwüste" wahrgenommen wird, müssen wir ihn für das Web **scannbar strukturieren**. + +Hier ist mein strategischer Umsetzungsplan für den Live-Gang: + +### 1. Fokus-Bereich: Immobilien (Die Vertrauens-Bühne) + +Wir nutzen Marcels Text als zentrales Element auf der `/immobilien`-Seite. Ich empfehle, die Seite in klare Informations-Häppchen aufzuteilen: + +* **Sektion 1: Hero & Aktuelle Projekte (Bleibt wie im Screenshot)** +* Der User sieht sofort: Hier gibt es konkrete, hochkarätige Off-Market-Deals (wie das Azizi Creek Views 4). + + +* **Sektion 2: Warum Dubai? (Fakten & Rendite)** +* *Format:* Ein cleanes Grid oder eine Icon-Liste. +* *Inhalt aus Marcels Text:* Keine Einkommensteuer, keine Kapitalertragsteuer, Escrow-System (Sicherheit!), 6-9% Rendite, Golden Visa. + + +* **Sektion 3: Der Kaufprozess in Dubai (Transparenz)** +* *Format:* Ein visuelles Stufen-Modell (Schritt 1 bis 4) oder ein Akkordeon (Aufklapp-Menü). +* *Inhalt:* Reservierung (3-10%) -> Anzahlung & SPA (10%) -> DLD Registrierung (4%) -> Finaler Kaufvertrag. + + +* **Sektion 4: Persönliche Begleitung & "Die Brücke" (Der stärkste Part!)** +* *Format:* Ein großes Bild von Marcel (vielleicht vor Ort in Dubai) mit einem direkten Zitat. +* *Inhalt:* Hier bringen wir seinen "Anti-Sales-Pitch" unter. *"Der Markt spricht für sich – dafür brauchen Sie mich nicht. Meine Aufgabe ist eine andere: Ich baue die Brücke zwischen der deutschen Denkweise und dem Tempo in Dubai."* Hier erwähnen wir auch zwingend die **Möblierungs-Synergie** (sein B2in-Möbel-Netzwerk für die spätere Vermietung). + + +* **Sektion 5: Passt ein Dubai-Investment zu Ihnen? (Der Mindset-Check)** +* *Format:* Eine farblich abgesetzte Box ("Wer hier richtig ist – und wer nicht"). Das qualifiziert die Leads extrem gut vor und spart Marcel Zeit mit den falschen Kunden. + + + +### 2. Reduktions-Bereich: Möbel & B2B (Die Vision teasern) + +Da das Portal noch in der Entwicklung ist, müssen wir auf den Seiten **"Einrichtungsnetzwerk"** und **"Für Entwickler & Partner"** den Fuß vom Gas nehmen. + +* **Was raus muss:** Streiche alle Erklärungen, die nach sofortiger Handlung klingen, die noch nicht möglich ist (z.B. tiefe Erklärungen zum Beleg-Upload, genaue Cashback-Prozentsätze oder Login-Aufforderungen). +* **Was bleibt (Das Wording):** Wir wandeln diese Seiten in "Visions- und Wartelisten-Seiten" um. +* *Neuer Fokus:* "Wir bauen aktuell das intelligenteste Local-for-Local Einrichtungsnetzwerk. Als Immobilienkunde von B2in profitieren Sie in Zukunft exklusiv von unserem Closed-Shop. Sind Sie lokaler Händler? Kontaktieren Sie uns für eine Vorab-Registrierung." +* Das hält die Seiten relevant, erklärt die Synergie (Möbel + Immobilien), aber erzeugt keinen Frust über fehlende Features. + + + +--- +--- +--- + + +### Inhaltlicher Vorschlag für die Seiten + +Textvorschlag vom Kunden + +Warum sich eine Immobilieninvestition in Dubai lohnt +Dubai hat sich in den letzten Jahren zu einem der dynamischsten Immobilienmärkte der Welt entwickelt. Investoren aus Europa, Asien und Amerika schätzen nicht nur die attraktiven Renditen, sondern vor allem die klaren rechtlichen Strukturen, die internationale Investitionen ermöglichen und schützen. +Doch eine Investition in Dubai ist mehr als nur eine finanzielle Entscheidung. Sie ist auch eine Entscheidung für einen internationalen Lebensstil, wirtschaftliche Stabilität und Zugang zu einem der wachstumsstärksten Märkte der Welt. +Wie der Kaufprozess in Dubai typischerweise abläuft +Der Immobilienkauf in Dubai folgt einem klar strukturierten Ablauf. +1. Reservierung der Wohnung +Sobald Sie sich für eine Wohnung entschieden haben, wird diese zunächst für Sie reserviert. Dafür wird üblicherweise eine Reservierungsgebühr (Booking Fee) gezahlt, die häufig zwischen etwa 3 % und 10 % des Kaufpreises liegt. Mit dieser Zahlung wird die gewünschte Einheit vorübergehend aus dem Verkauf genommen und für Sie blockiert. +2. Anzahlung und Vertragsvorbereitung +Anschließend wird in der Regel eine erste Anzahlung von etwa 10 % des Kaufpreises geleistet. Auf dieser Grundlage wird der offizielle Kaufvertrag vorbereitet – der sogenannte Sales & Purchase Agreement (SPA). +3. Registrierung beim Dubai Land Department +Zusätzlich wird eine staatliche Registrierungsgebühr von 4 % des Kaufpreises an das Dubai Land Department (DLD) gezahlt. Erst mit dieser Registrierung wird der Immobilienkauf offiziell im staatlichen Register verankert. +4. Finaler Kaufvertrag +Nach Abschluss dieser Schritte wird der endgültige Kaufvertrag ausgestellt und beim Dubai Land Department registriert. Damit ist Ihr Eigentumsrecht offiziell gesichert. +Ein wichtiger Sicherheitsmechanismus in Dubai ist dabei das Escrow-System: Zahlungen werden auf Treuhandkonten geleistet, sodass Bauträger Gelder nur entsprechend dem Baufortschritt erhalten. +Warum Dubai für Investoren so attraktiv ist +Dubai bietet eine Reihe von Rahmenbedingungen, die weltweit nahezu einzigartig sind: +keine Einkommensteuer auf Mieteinnahmen +keine Kapitalertragsteuer beim Verkauf +ein staatlich regulierter Immobilienmarkt +hohe internationale Nachfrage nach Wohnraum +eine stabile Währung (AED an den US-Dollar gekoppelt) +attraktive Mietrenditen im internationalen Vergleich +Viele Investoren erzielen jährliche Renditen zwischen 6 % und 9 %, je nach Lage und Nutzung der Immobilie. +Darüber hinaus bietet der Besitz einer Immobilie in Dubai häufig auch Vorteile bei Aufenthaltsgenehmigungen, beispielsweise über sogenannte Investor- oder Golden-Visa-Programme. +Warum gerade deutsche Käufer Unterstützung schätzen +Für viele deutsche Investoren wirkt der Immobilienkauf in Dubai zunächst ungewohnt. +In Deutschland ist man gewohnt, dass ein Immobilienkauf über Notar, Grundbuch und einen stark formalisierten Prozess abgewickelt wird. Der Markt in Dubai funktioniert anders: Die Abläufe sind deutlich schneller, digitaler und stärker projektbezogen organisiert. +Gerade diese Unterschiede führen häufig zu Unsicherheiten. +Der Markt und die Qualität vieler Projekte sprechen in der Regel für sich – dafür brauchen Sie mich im Grunde nicht. Meine Aufgabe ist eine andere: Ich begleite Sie durch die Abwicklungen und sorge dafür, dass Sie jederzeit verstehen, wie der Prozess funktioniert und welche Schritte als nächstes folgen. +Persönliche Begleitung statt reiner Vermittlung +Ich habe selbst eine Wohnung in Dubai erworben und kenne daher nicht nur die Theorie, sondern auch die praktischen Abläufe eines Immobilienkaufs vor Ort. +Ich stehe im permanenten Austausch mit Bauträgern und Projektentwicklern und verfolge die Entwicklungen der einzelnen Projekte sehr genau. Mehrmals im Jahr reise ich persönlich nach Dubai, um mir vor Ort ein Bild vom Baufortschritt zu machen und mit den Projektpartnern zu sprechen. +Diese Nähe zum Markt ermöglicht es mir, meine Kunden nicht nur bei der Auswahl einer passenden Immobilie zu unterstützen, sondern sie auch durch den gesamten Kaufprozess zu begleiten. +Meine Unterstützung endet jedoch nicht mit dem Kaufvertrag. Viele Käufer interessieren sich auch dafür, ihre Immobilie später zu vermieten, beispielsweise über Kurzzeitvermietungsmodelle wie Airbnb, oder möchten ihre Wohnung professionell möblieren, um eine attraktive Rendite zu erzielen. +Auch bei diesen Themen stehe ich Ihnen zur Seite – von der Möblierung über Einrichtungskonzepte bis hin zur Vorbereitung der Vermietung. Gerade hier kann meine Erfahrung aus der Möbelbranche und meine internationalen Kontakte ein zusätzlicher Vorteil sein. +Mein Ziel ist nicht, möglichst viele Immobilien zu verkaufen. Mein Ziel ist es, dass Sie eine fundierte Entscheidung treffen und Ihr Investment langfristig erfolgreich ist. +Sind Sie der richtige Kunde für ein Investment in Dubai? +Eine Investition in Dubai ist nicht für jeden die richtige Entscheidung. Sie richtet sich an Menschen, die international denken, Chancen erkennen und bereit sind, neue Wege zu gehen. +Dubai steht für Dynamik, Geschwindigkeit und globale Vernetzung. Der Staat setzt auf hohe Sicherheitsstandards im öffentlichen Raum, auf moderne Infrastruktur und auf eine klare wirtschaftliche Wachstumsstrategie. Gleichzeitig bedeutet das auch, dass der Umgang mit Themen wie Datenschutz und Regulierung anders organisiert ist als in Deutschland. +Wer in Dubai investiert, entscheidet sich bewusst für ein System, das Effizienz, wirtschaftliche Entwicklung und internationale Offenheit priorisiert, auch wenn die Rahmenbedingungen sich von den in Deutschland gewohnten Strukturen unterscheiden. +Wenn Sie daran glauben, dass sich wirtschaftliche Machtzentren in der Welt verschieben – und dass Städte wie Dubai in den kommenden Jahrzehnten eine noch größere Rolle spielen werden –, dann kann eine Immobilie dort ein strategisch sinnvoller Baustein sein. Viele Beobachter gehen davon aus, dass Dubai in den nächsten 20 Jahren eine Bedeutung erreichen könnte, die heute mit Metropolen wie New York, London oder Singapur verbunden wird. +Dann sind Sie bei uns genau richtig. +Wenn Sie hingegen ein System bevorzugen, das maximale formale Sicherheit verspricht, das jedoch häufig mit langen Entscheidungswegen, umfangreicher Bürokratie und sehr komplexen Abläufen verbunden ist, dann wird der Immobilienmarkt in Dubai möglicherweise nicht zu Ihren Erwartungen passen. +Diese Haltung ist übrigens sehr typisch für viele deutsche Investoren – und ich selbst gehöre im Grunde auch zu dieser Denkweise. Genau deshalb weiß ich aus eigener Erfahrung, wie groß dieser Schritt zunächst wirken kann, selbst wenn die wirtschaftlichen Argumente eigentlich klar erscheinen. +In der Praxis zeigt sich jedoch häufig, dass dieser Schritt gar nicht so groß ist, wie man ihn sich zunächst vorstellt. +Meine Aufgabe ist es nicht, Ihr Mindset zu verändern – das möchte und kann ich auch gar nicht. Meine Aufgabe ist vielmehr, eine Brücke zu bauen: zwischen der deutschen Denkweise und den Abläufen eines internationalen Immobilienmarktes wie in Dubai. +Ich begleite Sie durch die Prozesse, erkläre die Unterschiede und sorge dafür, dass Sie sich bei jedem Schritt sicher fühlen. Ob Sie diese Brücke am Ende überqueren möchten, entscheiden selbstverständlich Sie selbst. + + +--- + +### Sektion 1: Hero-Bereich (Ganz oben) + +*Visuell: Großes, hochwertiges Hintergrundbild (z.B. Dubai Skyline oder Premium-Immobilie).* + +**Headline:** Investieren in globale Dynamik. Mit deutscher Verlässlichkeit. + +**Subline:** Exklusive Off-Market-Projekte, attraktive Renditen und eine Begleitung, die weit über den Kaufvertrag hinausgeht. Entdecken Sie den Immobilienmarkt in Dubai. + +**Button:** [ Aktuelle Projekte ansehen ] *(Scrollt zu den Projekt-Kacheln, z.B. Azizi)* + +--- + +### Sektion 2: Warum Dubai? (Die harten Fakten) + +*Visuell: Ein cleanes Grid mit Icons für schnelle Lesbarkeit.* + +**Headline:** Warum sich ein Investment in Dubai lohnt + +**Einleitungstext (Kurz):** Dubai ist nicht nur eine finanzielle Entscheidung, sondern der Zugang zu einem der wachstumsstärksten Märkte der Welt. Investoren schätzen die klaren rechtlichen Strukturen und Rahmenbedingungen, die weltweit nahezu einzigartig sind: + +**Bulletpoints (mit Icons):** + +* **0 % Steuern:** Keine Einkommensteuer auf Mieteinnahmen, keine Kapitalertragsteuer beim Verkauf. +* **Starke Renditen:** Attraktive Mietrenditen von historisch 6 % bis 9 % jährlich. +* **Hohe Sicherheit:** Ein staatlich regulierter Markt mit dem sicheren Escrow-Treuhand-System. +* **Stabile Währung:** Der Dirham (AED) ist fest an den US-Dollar gekoppelt. +* **Golden Visa:** Sichern Sie sich Aufenthaltsgenehmigungen über attraktive Investor-Programme. + +--- + +### Sektion 3: Der Kaufprozess (Transparenz schaffen) + +*Visuell: Eine Zeitleiste, 4 nummerierte Kacheln oder ein Aufklapp-Menü (Akkordeon).* + +**Headline:** Klar strukturiert: Der Kaufprozess in Dubai + +**Einleitung:** Der Markt in Dubai ist schneller, digitaler und projektbezogen organisiert. Jeder Schritt ist durch das staatliche Escrow-System (Treuhandkonten nach Baufortschritt) maximal abgesichert. + +* **1. Reservierung (Booking Fee):** Mit einer Gebühr von ca. 3–10 % wird Ihre Wunsch-Einheit offiziell aus dem Verkauf genommen und für Sie blockiert. +* **2. Anzahlung & Vertrag (SPA):** Nach einer ersten Anzahlung (meist 10 %) wird der offizielle Kaufvertrag, das Sales & Purchase Agreement (SPA), erstellt. +* **3. Staatliche Registrierung (DLD):** Durch die Zahlung der Registrierungsgebühr (4 %) an das Dubai Land Department wird Ihr Eigentum offiziell im staatlichen Register verankert. +* **4. Finaler Kaufvertrag:** Ihr Eigentumsrecht ist offiziell gesichert. Die weiteren Zahlungen erfolgen streng nach Baufortschritt auf sichere Treuhandkonten. + +--- + +### Sektion 4: "Die Brücke" & Der Möbel-USP (Marcels Pitch) + +*Visuell: Hochwertiges Foto von Marcel Scheibe (am besten vor Ort / Business-Look). Text daneben.* + +**Headline:** "Der Markt spricht für sich. Meine Aufgabe ist eine andere." + +**Text (Zitatformat oder direkter Fließtext):** +"Für viele deutsche Investoren wirkt der Immobilienkauf in Dubai zunächst ungewohnt. In Deutschland sind wir Notare, Grundbücher und hochbürokratische Prozesse gewohnt. Dubai ist dynamischer. + +*Ich bin nicht hier, um Ihnen den Markt zu verkaufen – die Qualität der Projekte spricht für sich. Meine Aufgabe ist es, Ihre Brücke zu sein.* Als Investor, der selbst in Dubai gekauft hat, kenne ich die Praxis. Ich bin regelmäßig vor Ort, stehe im permanenten Austausch mit Bauträgern und begleite Sie durch den kompletten Prozess. Ich übersetze die internationale Geschwindigkeit in deutsche Verlässlichkeit. + +**Ihr B2in-Vorteil:** Meine Begleitung endet nicht beim Kauf. Planen Sie die lukrative Kurzzeitvermietung (z.B. Airbnb)? Über unser B2in-Netzwerk und meine Wurzeln in der Möbelbranche realisieren wir für Sie komplette Einrichtungskonzepte – für maximale Rendite bei minimalem Aufwand." + +**Button:** +[ Persönliches Beratungsgespräch vereinbaren ] + +--- + +### Sektion 5: Der Mindset-Check (Leads qualifizieren) + +*Visuell: Leicht farblich abgesetzte Box am Ende der Seite.* + +**Headline:** Sind Sie der richtige Investor für Dubai? + +**Text:** +Dubai richtet sich an Menschen, die international denken. Der Staat priorisiert Effizienz, wirtschaftliche Entwicklung und enorme Geschwindigkeit. + +Wenn Sie an die Verschiebung weltweiter Wirtschaftszentren glauben und ein dynamisches System schätzen, ist Dubai der strategisch perfekte Baustein für Ihr Portfolio. Dann sind Sie bei uns genau richtig. + +Wenn Sie jedoch ein System bevorzugen, das auf langsamen Entscheidungswegen und maximaler Bürokratie aufbaut, wird dieser Markt nicht zu Ihren Erwartungen passen. + +**Der Schritt ist kleiner, als Sie denken.** Lassen Sie uns gemeinsam herausfinden, ob ein Investment in Dubai in Ihre Strategie passt. + +--- + +### Wie geht es weiter? + +Mit diesen Texten ist die Immobilien-Seite **inhaltlich sofort startklar**. + +Damit der Rollout perfekt wird: Soll ich für die Seiten **"Einrichtungsnetzwerk"** und **"Für Entwickler & Partner"** (die ja jetzt im "Soft Launch" bleiben und nicht zu viel versprechen dürfen) noch jeweils 2-3 anpassende Sätze schreiben, damit dort keine "Baustellen-Texte" oder falschen Versprechungen stehen? + + + + +Anpassung an den Inhalten und Optik der Seite + +Unter about +- Das Führungsteam erst mal rausnehmen, Den grauen Hintergrund dann bei unsere Werte hinterlegen, diff --git a/dev/27-02-2026/B2in Website-Architektur.md b/dev/27-02-2026/B2in Website-Architektur.md new file mode 100644 index 0000000..794d9c6 --- /dev/null +++ b/dev/27-02-2026/B2in Website-Architektur.md @@ -0,0 +1,121 @@ + + +# 🏗️ Umsetzungskonzept: B2in Website-Architektur 02.03.2026 + +## 1. Anpassungen auf der Startseite (`home.blade.php` / `b2in.de`) + +**Ziel:** Die Startseite wird zur "Weiche" (Triage). Sie darf nicht mehr versuchen, alle Details zu erklären, sondern muss den Besucher sofort in seine richtige "Welt" (Immobilien oder B2B/Möbel) leiten. + +* **1.1. Hero-Bereich (Ganz oben)** +* **Änderung:** Der aktuelle Text wird durch eine übergeordnete "Klammer" ersetzt. +* **Neue Headline:** *B2in – Wo exklusive Immobilien-Investments auf smartes Interior treffen.* +* **Neue Subline:** *Ihr Partner für renditestarke internationale Immobilien und globales Supply-Chain-Management im Interior-Bereich.* +* **Neue Call-to-Actions (Buttons):** Es müssen zwingend zwei gleichwertige Buttons nebeneinander (oder optisch stark getrennt) platziert werden: +* Button 1 (Gold/Premium-Look): **"Zu den Immobilien-Projekten"** -> *Verlinkt auf `/immobilien*` +* Button 2 (Corporate/Clean-Look): **"Für Entwickler & Partner"** -> *Verlinkt auf `/supply-chain*` + + + + +* **1.2. FounderBar (Bereits umgesetzt)** +* **Behalten:** Bleibt direkt unter dem Hero-Bereich als Vertrauensanker ("B2in by Marcel Scheibe"). + + +* **1.3. Die "Synergie-Sektion" (NEU auf der Startseite)** +* **Inhalt:** Ein kurzer, starker Block, der erklärt, *warum* wir beides machen. +* **Text-Idee:** *"Das B2in-Ökosystem: Wir verbinden den Immobilienkauf mit der perfekten Einrichtung. Immobilien-Investoren profitieren von unserem exklusiven Möbel-Netzwerk – Projektentwickler von unserer deutschen Vertragssicherheit im Supply-Chain-Management."* + + +* **1.4. Was von der Startseite VERSCHWINDEN muss (Aufräumen!)** +* Alle tiefgreifenden Details zum "Local for Local" Möbel-Marktplatz, Cashback-Regeln oder das harte B2B-Supply-Chain-Wording müssen von der Startseite runter. Diese ziehen um auf die Unterseiten. + + + +--- + +## 2. NEUE Landingpage: Immobilien (`/immobilien`) + +**Ziel:** Emotionale Verkaufsbühne für B2C-Kunden und Investoren. Vertrauen, Rendite, Exklusivität. + +* **2.1. Hero-Bereich** +* **Headline:** *Investieren Sie in die Zukunft – Dubai, Lissabon & mehr.* +* **Subline:** *Exklusive Off-Market-Projekte und High-Yield-Investments. Persönlich kuratiert und begleitet von Marcel Scheibe.* + + +* **2.2. Aktuelle Launches & Projekte (Hier kommt der neue Text rein!)** +* **Design:** Hochwertige Kachel- oder Listenansicht. +* **Inhalt (Beispiel Azizi):** +* *Label:* 🚀 NEW LAUNCH (03.03.2026) +* *Titel:* **Azizi Developments: Creek Views 4 (Al Jaddaf, Dubai)** +* *Highlights:* Prime Waterfront Views, 1BR ab 1,125,000 AED, Exklusives 3BR Penthouse (Single Inventory!). +* *Fokus:* High Rental Demand & Capital Appreciation. +* *CTA:* "Jetzt Exposé & Verfügbarkeit anfragen" -> *Öffnet Kontaktformular an Marcel.* + + + + +* **2.3. Der "B2in Möbel-Vorteil" (Der Synergie-Hack)** +* **Inhalt:** Ein auffälliges Banner auf dieser Seite. +* **Text:** *"Ihr Investment, Ihr Vorteil: Als Käufer einer B2in-Immobilie erhalten Sie exklusiven Insider-Zugang zu unserem B2in-Möbelnetzwerk. Richten Sie Ihre Immobilie zu unschlagbaren Partner-Konditionen ein."* + + +* **2.4. Trust & Kontakt** +* **Inhalt:** Fokus auf "Investor Evenings" und direkte Terminbuchung (Calendly-Link o.ä.) mit Marcel Scheibe. + + + +--- + +## 3. NEUE Landingpage: Supply-Chain & B2B-Netzwerk (`/supply-chain` oder `/b2b`) + +**Ziel:** Rationale, prozessorientierte Verkaufsseite für Projektentwickler (Developer), Händler und Makler. Fokus auf "Durchsetzungskraft" und "Netzwerk". + +* **3.1. Hero-Bereich** +* **Headline:** *Globales Supply-Chain-Management & Local for Local Netzwerk.* +* **Subline:** *Wir sind der verlängerte Arm für Immobilienentwickler und das stärkste Netzwerk für den lokalen Möbelfachhandel.* + + +* **3.2. Sektion 1: Für Immobilienentwickler (Der Text vom Kunden)** +* **Design:** Klare Struktur, evtl. mit Icons für die 3 Schritte. +* **Inhalt (Direkt übernommen):** +* Einleitung: *"Wir übernehmen die operative und strategische Steuerung Ihrer Beschaffung in Deutschland..."* +* Punkt 1: *Vertragsmanagement* (Absicherung, Qualitätsdefinition). +* Punkt 2: *Vertragssicherung & Durchsetzung* (Eskalation auf Managementebene!). +* Punkt 3: *Tracking & Qualitätskontrolle.* + + + + +* **3.3. Sektion 2: Für Makler & Händler (Das Möbel-Ökosystem)** +* **Inhalt:** Hier wird das "Local for Local"-Konzept kurz angeteasert. +* **Text für Makler:** *"Nutzen Sie unser Möbelnetzwerk als Closing-Geschenk für Ihre Immobilienkunden."* +* **Text für Händler:** *"Werden Sie Teil unseres Netzwerks und erhalten Sie qualifizierte Leads von Immobilienkäufern."* +* **CTA:** "Partner-Zugang anfragen" oder "Login für Partner". + + + +--- + +## 4. Navigation & Menüstruktur (Header & Footer) + +Das Hauptmenü (Navigation) muss die neue Struktur widerspiegeln, damit sich niemand verläuft. + +**Altes/Bisheriges Menü (gedanklich):** +Oft vermischt (Über uns, Magazin, Marktplatz, Partner, FAQ). + +**Neues Hauptmenü (Vorschlag):** + +1. **Immobilien** (Verlinkt auf `/immobilien`) +2. **Supply-Chain & B2B** (Verlinkt auf `/supply-chain`) +3. **Magazin** (Bleibt) +4. **Über B2in** (Führt zu einer "About"-Sektion mit der Vision) +5. **[Button: Partner-Login]** (Ganz rechts, hervorgehoben für das Möbel-Clearing/Portal) + +--- + +### Nächste Schritte für dich: + +Mit diesem Dokument kannst du (oder der Webdesigner) exakt sehen, welche Texte wohin gehören. + +1. **Abnahme:** Passt diese Aufteilung für dich und den Kunden? +2. **Texte:** Fehlen dir für einen dieser Blöcke (z.B. die "Synergie-Sektion" auf der Startseite) noch die final ausformulierten, werblichen Sätze? Wenn ja, schreibe ich dir die sofort passgenau auf. diff --git a/dev/27-02-2026/b2in-local-for-local.md b/dev/27-02-2026/b2in-local-for-local.md new file mode 100644 index 0000000..84d93b3 --- /dev/null +++ b/dev/27-02-2026/b2in-local-for-local.md @@ -0,0 +1,267 @@ +Konzeptpapier: B2in / Local for Local Marktplatz-Ökosystem + +**Status:** Final | **Version:** 1.1 (Update: Marken-Hierarchie) 27.02.2026 + +## 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:** + +1. **Ticket:** Kunde zieht im Portal einen QR-Code für Händler X. +2. **Kauf:** Kunde kauft vor Ort, verhandelt Preise individuell. +3. **Upload (Der Trigger):** Kunde lädt Kaufbeleg im B2in-Portal hoch, um sein **Cashback** anzufordern. +4. **Clearing:** Händler bestätigt Umsatz im Backend -> Händler zahlt Gesamt-Provision an B2in. +5. **Ausschüttung:** Sobald Geld eingeht, verteilt das System automatisch: + - Provision an **Makler** (Lead-Vergütung). + - Cashback an **Kunden** (Motivation & Datentreue). + - Marge an **B2in**. + +--- + +## 5. Quality Management & Content (Das Setup) + +Um einen professionellen Look zu garantieren, wird das Onboarding als Marketing-Dienstleistung verkauft. + +**Das "Launch-Paket" (ca. 399 € einmalig):** + +- **Leistung:** Professioneller Fotograf kommt zum Händler (Laden, Team, Top-10 Produkte). +- **Service:** B2in pflegt die Daten initial ein. +- **Marketing-Synergie:** Die entstandenen Bilder werden für eine **Social-Media-Kampagne** auf den B2in-Kanälen genutzt. +- **Effekt:** Händler zahlt nicht für "Verwaltung", sondern für "Content & Reichweite". + +--- + +## 6. Rollen & Synergien im Ökosystem + +| **Rolle** | **Aufgabe** | **Motivation ("What's in it for me?")** | +| --- | --- | --- | +| **Makler** | Verteilt Zugang (Voucher) beim Hauskauf. | 1. Exklusives Closing-Geschenk für Kunden. +2. Passive Provision an Möbelumsätzen. | +| **Händler** | Präsentiert Ware (Local Express), bietet Service. | 1. Qualifizierte Leads (Hausbesitzer). +2. Marketing-Content (Fotos) & Reichweite. | +| **Kunde** | Nutzt Portal via Style2Own/StilEigentum. | 1. Geldwerter Vorteil (Cashback). +2. Transparenz über lokale Bestände. | +| **Hersteller** | (Später) Liefert Dropshipping-Ware. | 1. Zugang zu kaufkräftiger Zielgruppe ohne Streuverlust. +2. Preisschutz durch Closed Shop. | + +--- + +## 7. System-Architektur (Technik) + +Das Backend (**B2in Core**) steuert zentral alle Frontends: + +1. **Mandantenfähigkeit/Hubs:** Regionale Zuordnung (z.B. User aus Bielefeld sieht nur Hub OWL). +2. **Rollen-System:** Unterschiedliche Dashboards für Makler (Provisions-Übersicht), Händler (Produkt-Upload & Clearing), Kunden (Cashback & Shopping). +3. **Ticket-Engine:** Generierung einzigartiger QR-Codes pro Händler-Besuch. +4. **Frontend-Weiche:** Das System erkennt, ob der User über `style2own` oder `stileigentum` kommt, und passt das Branding im Header leicht an, während der Inhalt (Local for Local Produkte) identisch bleibt. + +--- + + +## Zu dieser Logik gibt es folgende E-Mail an den Kunden + +Hallo Marcel, + +ich habe mir Gedanken gemacht, wie wir die Außendarstellung am CABINET Store und die B2in konzeptionell sauber aufsetzen – ohne die Marken zu verwässern, aber so, dass am Ende auch der Endkunde versteht, wofür B2in steht. + +--- + +DAS GRUNDPRINZIP: KEINE VERMISCHUNG, KLARE POSITIONIERUNG + +CABINET bleibt CABINET – das Fachgeschäft für maßgefertigte Einbaulösungen. Daran ändert sich nichts. Was dazukommt, ist B2in als eigenständige Marke, die dich als Person und Experten positioniert. + +Der Ansatz, den ich empfehle: "B2in by Marcel Scheibe" – also B2in als professionelle Marke, die skalierbar ist und international funktioniert, aber mit dir als sichtbarem Kopf dahinter. + +Warum genau dieser Ansatz? + +B2in allein ist ein Markenname, der Seriosität und Professionalität ausstrahlt. Aber gerade im Immobiliengeschäft kauft niemand von einem anonymen Portal – man kauft von jemandem, dem man vertraut. Die Marke gibt die professionelle Hülle, du als Person gibst das Vertrauen. + +Das funktioniert wie ein Architekturbüro: Der Name des Gründers steht dahinter, aber die Marke funktioniert eigenständig. Und wenn du irgendwann skalierst, Mitarbeiter einstellst oder weitere Partner mit reinnimmst, ist die Marke B2in nicht an deinen Namen gekettet – du bleibst aber als Gründer und Gesicht sichtbar. + +Auf der Website bedeutet das: B2in steht groß mit dem Claim im Hero, und direkt darunter oder daneben bist du mit Bild sichtbar – nicht versteckt auf einer "Über uns"-Seite, sondern als Teil der Markenaussage. Der Besucher soll sofort verstehen: Hinter B2in steht eine echte Person mit Expertise. + +--- + +CABINET STORE: BESCHILDERUNG & DISPLAYS + +1. Außenbeschilderung +Ergänzend zum CABINET-Schild kommt ein zweites Schild: "B2in" mit einer Unterzeile wie "Ihr Partner für Immobilien & Wohnen". Das Schild soll neugierig machen, ohne alles zu erklären. Wer mehr wissen will, fragt oder googelt – und findet die B2in-Welt. + +2. Displays Schaufenster (2 Screens) +Hier empfehle ich einen der beiden Screens für B2in zu nutzen. Laufkundschaft hat maximal 3–5 Sekunden Aufmerksamkeit. Deshalb arbeiten wir mit einem festen Frame-System: + +- Oben: B2in-Logo + Claim "Connecting Design & Property" (steht permanent) +- Mitte: Dein 16:9-Videomaterial läuft hier im Querformat, mit Gradient-Übergängen oben und unten +- Darunter: Kurzes Textfeld, das zum jeweiligen Video rotiert +- Unten: "Marcel Scheibe" + B2in.de + QR-Code (steht permanent) + +Die Rotation: ca. 70% Immobilien-Content, 30% Möbel/Einrichtung. Textbeispiele: +- Immobilien: "Internationale Immobilien – Ihr Einstieg." / "Dubai. Lissabon. Und morgen?" / "Ihr Zuhause. Weltweit." +- Möbel: "Exklusive Einrichtung – Lokal. Für Sie." / "Lokale Händler. Echte Stücke." + +3. Displays innen (2 Screens) +Rotationsmodus: ca. 80% CABINET-Content, ca. 20% B2in-Welt (gleicher Frame wie Schaufenster). + +4. Lead-Qualifizierung +Die spannendste Zielgruppe sind deine CABINET-Kunden. Nach erfolgreicher Beratung kannst du beiläufig auf internationale Immobilien hinweisen – z.B. mit einer Einladung zu einem "Investor Evening". + +5. Event-Konzept: "Investor Evenings" +Quartalsweise kleine, exklusive Veranstaltungen im Store. CABINET-Ambiente nutzen, ausgewählter Kreis, du präsentierst internationale Projekte persönlich. + +--- + +B2in WEBSITE: HERO-ANPASSUNG + +Der aktuelle Hero-Text beschreibt nur die Möbel-/(Local-for-Local)-Seite: +"Brücken zwischen lokalen Kunden, lokalem Handel und inspirierenden Designs. Wo lokaler Handel und namhafte Lieferanten digital, aber lokal, in einem Netzwerk zusammenarbeiten." + +Das passt nicht mehr, weil Immobilien jetzt die dominante Seite von B2in sind. Der Claim "B2in – Connecting Design and Property" funktioniert weiterhin sehr gut. + +Mein Vorschlag für den neuen Hero: + +B2in – Connecting Design and Property +Ihr Partner für exklusive Einrichtungskonzepte und internationale Immobilien. + +Darunter: Dein Name und Bild als Teil der Markenaussage. + +Die Immobilien-Seite wird der dominante Bereich der Website. Der Möbelmarktplatz läuft als ergänzender Bereich mit. + +--- + +WORAUF WIR ACHTEN MÜSSEN (WICHTIG) + +Damit die Außendarstellung funktioniert, brauchen wir Disziplin in der Kommunikation. Eine Marke wird nicht stärker, indem man mehr draufpackt – sondern indem man konsequent weniger, aber das Richtige zeigt. Ein Passant am Schaufenster, ein Website-Besucher, ein potenzieller Investor hat immer nur wenige Sekunden. In diesen Sekunden muss eine einzige Botschaft ankommen – nicht drei. + +Konkret heißt das: + +JA, SO MACHEN WIR ES: +- Ein Display, eine Botschaft. Immobilien ODER Möbel, nie beides gleichzeitig auf einem Screen. +- Nur B2in nach außen kommunizieren. Keine Partnerlogos (kein Azizi, kein Herstellername) auf Displays, Schildern oder im Hero. Nur max im Video, wenn es sich nicht ausblenden lässt) +- Texte kurz halten. Maximal ein Satz als Headline, ein Satz als Subline. Wer mehr wissen will, scannt den QR-Code. +- Marcel Scheibe als Person sichtbar, aber nicht als Werbefigur. Dein Name steht dezent im Footer, dein Bild auf der Website – nicht auf jedem Display in Großformat. +- Konsistenz: Der Frame, das Gerüst (Logo, Claim, Footer) bleibt vom Aufbau auf allen Displays identisch. Nur der Content in der Mitte wechselt. + +DAS VERMEIDEN WIR: +- Zu viele Informationen auf einem Screen. Keine Grundrisse, keine Preislisten, keine Aufzählung aller Services auf einem Display. +- Marken mischen. Kein "CABINET + B2in + Azizi + Style2Own" auf einem Schild oder Screen. Das verwirrt und wirkt unseriös. +- Alles erklären wollen. Die Displays sollen Neugier wecken, nicht informieren. Die Information kommt auf der Website oder im persönlichen Gespräch. +- Zu viele verschiedene Botschaften gleichzeitig. Nicht auf dem einen Display Dubai, auf dem nächsten Local-for-Local, auf dem dritten CABINET-Referenzen und auf dem vierten B2A. Das versteht keiner. +- Spontan Content ändern ohne Konzept. Jede Änderung an den Displays sollte sich an diesem Framework orientieren. + +Der rote Faden: Jeder Touchpoint (Display, Schild, Website) muss in 3 Sekunden eine einzige klare Aussage vermitteln. Wenn jemand nach dem Vorbeigehen gefragt wird "Was war das?", soll die Antwort sein: "B2in – irgendwas mit internationalen Immobilien, sah hochwertig aus." Nicht: "Keine Ahnung, da war ganz viel drauf." + +--- + +ZUSAMMENGEFASST: +- CABINET Store = CABINET. Sauber getrennt. +- B2in = Deine Marke. Am Store als zweites Schild + ein Schaufenster-Display, auf der Website als Hauptmarke mit dir als Gesicht. +- Ein Frame, zwei Content-Varianten (Immobilien dominant, Möbel ergänzend). +- Weniger ist mehr: Eine Botschaft pro Touchpoint, keine Marken-Vermischung, Neugier statt Erklärung. + +Lass mich wissen, wie du das siehst – dann gehen wir in die Detail-Umsetzung. + + + +###### +## Folgendes muss bitte noch integriert werden Anforderungen von Kunden + +Supply-Chain-Management für Immobilienentwickler + +Wir übernehmen die operative und strategische Steuerung Ihrer Beschaffung in Deutschland. + +Für Immobilienentwickler, die Möbel oder Innenausstattung aus Deutschland beziehen möchten, fungieren wir als verlängerter Arm vor Ort – mit klarem Fokus auf Vertragssicherheit, Termintreue und Durchsetzungskraft. + +Unsere Leistungen im Überblick + +1.⁠ ⁠Vertragsmanagement + • Unterstützung bei der Ausarbeitung und Strukturierung von Lieferverträgen + • Definition klarer Leistungs- und Qualitätsparameter + • Absicherung von Zahlungs- und Lieferbedingungen + +2.⁠ ⁠Vertragssicherung & Durchsetzung + • Aktive Überwachung der vereinbarten Meilensteine + • Eskalation auf Managementebene bei Abweichungen + • Konsequente Nachverfolgung offener Punkte + +3.⁠ ⁠Tracking & Qualitätskontrolle + • Laufende Produktions- und Lieferüberwachung + • Persönliche Kontrolle bei Bedarf + • Sicherstellung termingerechter Auslieferung + +Unser Ansatz + +Wir kombinieren Marktkenntnis, Netzwerk und operative Erfahrung. +Durch unsere direkte Anbindung an Hersteller und Entscheider sorgen wir dafür, dass Vereinbarungen nicht nur auf dem Papier bestehen, sondern tatsächlich umgesetzt werden. + +Unser Anspruch: +Transparenz, Verlässlichkeit und planbare Lieferung – ohne operative Reibungsverluste für den Entwickler. diff --git a/dev/27-02-2026/b2in-umsetzung-changelog.md b/dev/27-02-2026/b2in-umsetzung-changelog.md new file mode 100644 index 0000000..c5ef364 --- /dev/null +++ b/dev/27-02-2026/b2in-umsetzung-changelog.md @@ -0,0 +1,168 @@ +# B2in Website – Umsetzung Changelog + +**Datum:** 27.02.2026 +**Basis:** `b2in-website-umsetzungsplan.md` +**Status:** Abgeschlossen – alle Tests bestanden (336/336) + +--- + +## Backups + +Alle Original-Dateien vor der Änderung liegen in: +`dev/27-02-2026/backup-before-relaunch/` + +| Datei | Original-Pfad | +|-------|---------------| +| `content.php` | `config/content.php` | +| `home.blade.php` | `resources/views/web/home.blade.php` | +| `b2in.blade.php` | `resources/views/web/b2in.blade.php` | +| `partner.blade.php` | `resources/views/web/partner.blade.php` | +| `ecosystem.blade.php` | `resources/views/web/ecosystem.blade.php` | +| `about.blade.php` | `resources/views/web/about.blade.php` | +| `contact.blade.php` | `resources/views/web/contact.blade.php` | +| `magazin.blade.php` | `resources/views/web/magazin.blade.php` | +| `footer.blade.php` | `resources/views/livewire/web/components/ui/footer.blade.php` | +| `hero.blade.php` | `resources/views/livewire/web/components/sections/hero.blade.php` | +| `web.php` | `routes/web.php` | + +--- + +## Durchgeführte Änderungen + +### Phase 1 – Homepage-Neuausrichtung + +#### 1.1 Hero-Sektion (`config/content.php` → `themes.b2in.hero`) +- **Title:** Von "Brücken zwischen lokalen Kunden..." → "B2in – Connecting Design and Property" +- **Subtitle:** Von Möbel-Fokus → "Ihr Partner für exklusive Einrichtungskonzepte und internationale Immobilien." +- **CTAs:** "Für lokale Händler / Für Hersteller" → "Internationale Immobilien / Einrichtungskonzepte" +- **Stats:** → "Internationale Immobilien", "Exklusive Einrichtung", "Persönliche Beratung" + +#### 1.2 Vision-Sektion (`config/content.php` → `themes.b2in.vision_section`) +- **Title:** "Gebaut auf Vertrauen" → "Gebaut auf Expertise und Vertrauen" +- **Text:** Komplett neu – jetzt Dual-Fokus (Immobilien + Einrichtung), persönliche Ansprache durch Marcel Scheibe, Nennung konkreter Märkte (Dubai, Lissabon) + +#### 1.3 Ecosystem-Core (`config/content.php` → `themes.b2in.ecosystem_core`) +- **Title:** "Ein Ökosystem, drei Stärken" → "Ein Ökosystem, drei Säulen" +- **Säule 1:** Kuratierter Marktplatz → **Internationale Immobilien** (Icon: globe-alt) +- **Säule 2:** Logistik & Service → **Exklusive Einrichtungskonzepte** (Icon: cube-transparent) +- **Säule 3:** Verkaufsnetzwerk → **Supply-Chain-Management** (Icon: clipboard-document-check) + +#### 1.4 CTA-Sektion (`config/content.php` → `themes.b2in.cta_section`) +- **Title:** "Werden Sie Partner im führenden Möbel-Netzwerk" → "Ihr nächster Schritt – ob Investment oder Einrichtung" +- **Subtitle:** Erweitert um Immobilien, Supply Chain und Einrichtungs-Netzwerk + +### Phase 2 – Content-Erweiterung + +#### 2.1 Brand-Worlds (`config/content.php` → `themes.b2in.brand_worlds`) +- **Title:** "Unsere Markenwelten: Qualifizierte Kunden für Ihr Möbel-Sortiment" → "Unsere Welten: Design, Immobilien und internationaler Handel" +- **Subtitle:** Komplett neu – Immobilien + Einrichtung + Handel +- **Reihenfolge:** Stileigentum → Style2Own → B2A (vorher B2A zuerst) +- **Beschreibungen:** Angepasst an neue Positionierung + +#### 2.2 Content-Sektion (`config/content.php` → `themes.b2in.integriertes_modell_b2in`) +- **Title:** "Das Beste aus zwei Welten: in jeder Region" → "...Immobilien und Einrichtung" +- **Text:** Neu – verbindet Immobilienkauf mit Einrichtungsservice + +#### 2.3 Footer (`resources/views/livewire/web/components/ui/footer.blade.php`) +- **Neu:** "Marcel Scheibe – Gründer & CEO" dezent unter dem Claim ergänzt + +#### 2.4 Contact (`config/content.php` → `themes.b2in.contact_form`) +- **Neue Betreff-Optionen:** "Internationale Immobilien" und "Supply-Chain-Management" hinzugefügt + +### Phase 3 – Partner, Ecosystem, About, Magazin + +#### 3.1 Partner-Seite + +**Partner-Hero** (`themes.b2in.partner_hero`): +- Title: "Das Ökosystem für Ihren Erfolg" → "Das Netzwerk für Immobilien & Einrichtung" +- Neuer Partner-Typ: "Immobilienentwickler" (Supply-Chain-Management aus Deutschland) + +**Partner-Card-Section** (`themes.b2in.partner_card_section`): +- Neue Karte: "Für Immobilienentwickler" an erster Position +- Subtitle angepasst + +**Neue Config-Sektion:** `partner_benefits_developer` (komplett neu) +- 4 Features: Vertragsmanagement, Vertragssicherung, Tracking & QK, Netzwerk & Marktkenntnis +- Highlight: "100% – Transparenz, Verlässlichkeit und planbare Lieferung" + +**Partner-Blade** (`resources/views/web/partner.blade.php`): +- Neue Benefits-Section `partner_benefits_developer` eingefügt +- Layout der 4 Sektionen mit abwechselndem Left/Right-Layout und Hintergründen + +**Partner-CTA** (`themes.b2in.partner_cta`): +- Subtitle angepasst an Immobilienentwickler + Einrichtung + +#### 3.2 Ecosystem-Seite + +**Ecosystem-Hero** (`themes.b2in.ecosystem_hero`): +- Subtitle: Erweitert um Immobilien und Entwickler +- Features: Endkunden → Immobilien, Makler → Einrichtung, Lieferanten → Supply Chain, Technologie bleibt + +**Ecosystem-Stats** (`themes.b2in.ecosystem_stats`): +- "Möbel-Projekte" → "Realisierte Projekte" (Immobilien + Einrichtung) +- Beschreibungen angepasst + +**Ecosystem-Start** (`themes.b2in.ecosystem_start`): +- "Kundenwunsch" → "Moment of Need" (Immobilienkauf als Trigger) +- Text: Makler als Einstiegspunkt, Marken als Rahmen + +**Ecosystem-Hub** (`themes.b2in.ecosystem_hub`): +- Erweitert um Supply-Chain-Perspektive für Entwickler + +**Ecosystem-Result** (`themes.b2in.ecosystem_result`): +- Neuer Punkt: Immobilienentwickler als Gewinner des Kreislaufs +- Bestehende Punkte angepasst + +#### 3.3 About-Seite + +**About-Hero** (`themes.b2in.about_hero`): +- Zitat komplett neu: Dual-Fokus, persönliche "Meine Mission"-Ansprache + +**Our Story** (`themes.b2in.our_story`): +- Timeline: 3 → 4 Einträge (+ "Die Erweiterung" 2025/2026) +- Summary: Neu – Immobilien + Einrichtung + Marcel Scheibe als Gesicht + +**Our Values** (`themes.b2in.our_values`): +- Alle 6 Werte-Beschreibungen angepasst (Immobilien + Lieferketten integriert) + +#### 3.4 Magazin + +**Magazin-List** (`themes.b2in.magazin_list`): +- Subtitle: "Business-Konnektivität" → "Immobilien, des Designs und der Business-Konnektivität" + +#### 3.5 FAQ + +**FAQ** (`themes.b2in.faq`): +- 5 Fragen komplett überarbeitet +- Neue Frage: "Was bedeutet Supply-Chain-Management bei B2in?" +- Immobilien-Investment mit Marcel Scheibe als persönlichem Begleiter +- Vertrauens-Frage: Marcel Scheibe als Gesicht statt anonyme Plattform + +### Neue Komponente + +**FounderBar** – neue Livewire-Sektion +- `app/Livewire/Web/Components/Sections/FounderBar.php` +- `resources/views/livewire/web/components/sections/founder-bar.blade.php` +- Config: `themes.b2in.founder_bar` (Bild, Name, Titel, Statement) +- Eingebunden in `home.blade.php` und `b2in.blade.php` direkt nach dem Hero +- Zeigt Marcel Scheibe mit Profilbild und "B2in by Marcel Scheibe" Statement + +--- + +## Nicht geändert + +- Keine Blade-Komponenten-Templates geändert (außer Footer + Partner-Layout) +- Keine CSS-/Styling-Änderungen +- Keine Änderungen an anderen Themes (b2a, stileigentum, style2own) +- Keine Route-Änderungen +- Keine Datenbank-Änderungen +- Admin-Portal unberührt + +--- + +## Offene Punkte (aus Umsetzungsplan) + +- [ ] Eigene `/immobilien`-Landingpage (optional – muss mit Kunde geklärt werden) +- [ ] Investor Evenings Event-Sektion (optional) +- [ ] Hochwertiges Marcel Scheibe Porträtfoto für Founder-Bar (aktuell nutzt es `marcel-scheibe.jpg`) +- [ ] B2A-Markenwelt: Bleibt vorerst erhalten – Klärung ob durch Immobilien-Karte ersetzt werden soll diff --git a/dev/27-02-2026/b2in-website-umsetzungsplan.md b/dev/27-02-2026/b2in-website-umsetzungsplan.md new file mode 100644 index 0000000..8cd9903 --- /dev/null +++ b/dev/27-02-2026/b2in-website-umsetzungsplan.md @@ -0,0 +1,333 @@ +# B2in Website – Umsetzungsplan: Konzeptionelle Neuausrichtung + +**Datum:** 27.02.2026 +**Basis:** `b2in-local-for-local.md` (Konzeptpapier v1.1) + Ist-Analyse aller b2in-Seiten +**Ziel:** B2in von einer reinen Möbel-/Local-for-Local-Plattform hin zu **"B2in – Connecting Design and Property"** transformieren, wobei **Immobilien die dominante Seite** und der **Möbelmarktplatz der ergänzende Bereich** wird. + +--- + +## 0. Zusammenfassung: IST vs. SOLL + +| Aspekt | IST (aktuell) | SOLL (neu) | +|--------|---------------|------------| +| **Positionierung** | Reine B2B-Möbelplattform, Local-for-Local | Doppelfokus: Internationale Immobilien (dominant) + Einrichtungskonzepte (ergänzend) | +| **Hero-Botschaft** | "Brücken zwischen lokalen Kunden, lokalem Handel und inspirierenden Designs" | "Ihr Partner für exklusive Einrichtungskonzepte und internationale Immobilien" | +| **Persönliche Marke** | Marcel Scheibe versteckt auf About-Seite | Marcel Scheibe als Gesicht der Marke, prominent im Hero | +| **Markenwelten** | B2A, Stileigentum, Style2Own (alle Möbel-fokussiert) | Immobilien-Bereich (dominant) + Möbel-Markenwelten (ergänzend) | +| **Partner-Typen** | Händler, Hersteller, Makler (alle Möbel-Kontext) | + Immobilienentwickler, Investoren, Supply-Chain-Kunden | +| **Neue Inhalte** | — | Supply-Chain-Management, Investor Evenings, Immobilien-Services | + +--- + +## 1. Homepage (`/`) – `web/home.blade.php` + `web/b2in.blade.php` + +### 1.1 Hero-Sektion (KRITISCH – höchste Priorität) + +**Datei:** `config/content.php` → `themes.b2in.hero` + +| Feld | Alt | Neu | +|------|-----|-----| +| `title` | "B2in – Brücken zwischen lokalen Kunden, lokalem Handel und inspirierenden Designs." | `"B2in – Connecting Design and Property"` | +| `subtitle` | "Wo lokaler Handel und namhafte Lieferanten digital, aber lokal, in einem Netzwerk zusammenarbeiten." | "Ihr Partner für exklusive Einrichtungskonzepte und internationale Immobilien." | +| `cta1_text` | "Für lokale Händler" | "Internationale Immobilien" | +| `cta1_link` | `/services` | `/immobilien` (neue Seite) oder Anchor `#immobilien` | +| `cta2_text` | "Für Hersteller & Marken" | "Einrichtungskonzepte" | +| `cta2_link` | `/beratung` | `/partner` oder `#einrichtung` | +| `stats` | Exklusive Auswahl, Persönlicher Service, Werte die bleiben | "Internationale Immobilien", "Exklusive Einrichtung", "Persönliche Beratung" | + +**Neue Anforderung:** Marcel Scheibe muss direkt im Hero oder unmittelbar darunter sichtbar sein – mit Bild und Name als Teil der Markenaussage. Optionen: + +- **Option A:** Hero-Sektion um ein Porträtbild + "Marcel Scheibe, Gründer" erweitern (neben oder unter dem Subtitle) +- **Option B:** Neue schmale Sektion direkt nach dem Hero: "B2in by Marcel Scheibe" – Bild links, Kurztext rechts +- **Empfehlung:** Option B, da der Hero clean bleibt und trotzdem die persönliche Marke sofort sichtbar ist + +### 1.2 Vision-Sektion + +**Datei:** `config/content.php` → `themes.b2in.vision_section` + +Die aktuelle Vision-Sektion fokussiert auf Möbelhandel. **Neuer Vorschlag:** + +``` +title: "Gebaut auf Expertise und Vertrauen" +paragraphs: + - "B2in (Bridges2international) verbindet zwei Welten: internationale Immobilien + und exklusive Einrichtungskonzepte. Als Ihr persönlicher Partner navigiere ich + Sie durch beide Bereiche – mit Expertise, Netzwerk und dem Anspruch, dass jede + Entscheidung auf Vertrauen basiert." + - "Ob ein Investment in Dubai, eine Villa in Lissabon oder die maßgeschneiderte + Einrichtung Ihres neuen Zuhauses – bei B2in laufen alle Fäden zusammen." + - "Regional verwurzelt, international vernetzt – das ist B2in." +image_caption: "Marcel Scheibe, Gründer & CEO" +``` + +→ Die Sektion behält Marcel Scheibe als Bild, aber der Text wird vom reinen Möbelfokus auf den Dual-Fokus umgestellt. + +### 1.3 Ecosystem-Core-Sektion + +**Datei:** `config/content.php` → `themes.b2in.ecosystem_core` + +Aktuell 3 Säulen (alle Möbel). **Neuer Vorschlag – 3 Säulen mit neuem Fokus:** + +| Säule | Alt | Neu | +|-------|-----|-----| +| 1 | Kuratierter Marktplatz (Möbel) | **Internationale Immobilien** – "Zugang zu exklusiven Immobilien-Investments weltweit – von Dubai bis Lissabon. Professionelle Begleitung vom ersten Interesse bis zum Closing." | +| 2 | Intelligente Logistik & Service | **Exklusive Einrichtungskonzepte** – "Über unser Local-for-Local-Netzwerk verbinden wir Sie mit den besten lokalen Fachexperten und europäischen Design-Marken für Ihr neues Zuhause." | +| 3 | Starkes Verkaufsnetzwerk | **Supply-Chain-Management** – "Für Immobilienentwickler: Operative Steuerung der Beschaffung aus Deutschland – Vertragsmanagement, Qualitätskontrolle und termingerechte Lieferung." | + +### 1.4 Brand-Worlds-Sektion + +Aktuell: B2A, Stileigentum, Style2Own (alle Möbel-fokussiert). + +**Neuer Vorschlag:** Titel und Subtitle anpassen + Karten umstrukturieren: + +``` +title: "Unsere Welten" +subtitle: "Von internationalen Immobilien-Investments über exklusive Einrichtungskonzepte + bis zum transatlantischen Handel – B2in verbindet die Welten, die zusammengehören." +``` + +Karten-Reihenfolge und Content anpassen: +1. **B2in Immobilien** (NEU) – "Internationale Immobilien-Investments. Ihr Einstieg in Märkte wie Dubai, Lissabon und darüber hinaus." +2. **Stileigentum** – Bestehendes beibehalten (Premium-Einrichtung) +3. **Style2Own** – Bestehendes beibehalten (Lifestyle-Einrichtung) +4. **B2A** – Bestehendes beibehalten (Transatlantischer Handel) + +**Alternative:** B2A rausnehmen und stattdessen den Supply-Chain-Service als eigene Karte zeigen. + +### 1.5 Content-Sektion ("Das Beste aus zwei Welten") + +**Datei:** `config/content.php` → `themes.b2in.integriertes_modell_b2in` + +Text anpassen, um beide Welten (Immobilien + Einrichtung) zu reflektieren statt nur Möbel. + +### 1.6 CTA-Sektion + +**Datei:** `config/content.php` → `themes.b2in.cta_section` + +``` +title: "Ihr nächster Schritt – ob Investment oder Einrichtung" +subtitle: "Ob Sie internationale Immobilien entdecken, Ihre Supply Chain optimieren oder + Teil unseres Einrichtungs-Netzwerks werden möchten – sprechen Sie mit uns." +button_text: "Kontakt aufnehmen" +``` + +--- + +## 2. Partner-Seite (`/partner`) – `web/partner.blade.php` + +### 2.1 Partner-Hero + +Aktuell fokussiert auf Möbel-Ökosystem. **Anpassung:** + +- Title: "Das Netzwerk für Immobilien & Einrichtung" +- Subtitle: Erweitern um Immobilien-Kontext +- Partner-Types: **Neuen Typ hinzufügen:** "Immobilienentwickler" mit Icon `globe-alt` und Description "Supply-Chain-Management, Beschaffung aus Deutschland" + +### 2.2 Partner-Card-Section + +**Neuen vierten Karten-Typ hinzufügen:** + +```php +[ + 'title' => 'Für Immobilienentwickler', + 'description' => 'Operative Steuerung Ihrer Beschaffung aus Deutschland – + Vertragsmanagement, Qualitätskontrolle und termingerechte Lieferung.', + 'icon' => 'globe-alt', + 'button' => '#partner-benefits-developer', + 'button_text' => 'Ihre Vorteile als Entwickler', +] +``` + +### 2.3 Neue Benefits-Sektion: Immobilienentwickler + +**Neue Config-Sektion:** `partner_benefits_developer` + +```php +'partner_benefits_developer' => [ + 'id' => 'partner-benefits-developer', + 'tag' => 'Ihre Vorteile als Immobilienentwickler', + 'tag_icon' => 'globe-alt', + 'tag_title' => 'Ihr verlängerter Arm in Deutschland.', + 'features' => [ + ['title' => 'Vertragsmanagement', 'description' => 'Unterstützung bei Ausarbeitung und Strukturierung von Lieferverträgen, Definition klarer Leistungs- und Qualitätsparameter.', 'icon' => 'document-check'], + ['title' => 'Vertragssicherung & Durchsetzung', 'description' => 'Aktive Überwachung vereinbarter Meilensteine, Eskalation bei Abweichungen, konsequente Nachverfolgung.', 'icon' => 'shield-check'], + ['title' => 'Tracking & Qualitätskontrolle', 'description' => 'Laufende Produktions- und Lieferüberwachung, persönliche Kontrolle bei Bedarf, termingerechte Auslieferung.', 'icon' => 'magnifying-glass'], + ['title' => 'Netzwerk & Marktkenntnis', 'description' => 'Direkte Anbindung an Hersteller und Entscheider. Vereinbarungen werden umgesetzt, nicht nur auf Papier festgehalten.', 'icon' => 'link'], + ], +], +``` + +**In `web/partner.blade.php` einbinden:** + +```blade + +``` + +--- + +## 3. Ecosystem-Seite (`/ecosystem`) – `web/ecosystem.blade.php` + +### 3.1 Ecosystem-Hero + +Aktuell: Reiner Möbel-/Netzwerk-Fokus. + +**Anpassen:** Immobilien als Teil des Ökosystems einbinden. Der Ecosystem-Fluss wird: +1. **Einstieg:** Kunde kommt über Immobilienkauf (Makler) oder direkt über style2own/stileigentum +2. **Hub:** Lokale Händler + europäische Hersteller + internationale Immobilien +3. **Ergebnis:** Jeder profitiert (inkl. Immobilienentwickler) + +### 3.2 Ecosystem-Stats + +Zahlen um Immobilien-KPIs ergänzen (z.B. "Internationale Märkte", "Realisierte Projekte"). + +### 3.3 Content-Sektionen + +Die 3 Content-Sektionen (ecosystem_start, ecosystem_hub, ecosystem_result) inhaltlich erweitern, um den Immobilien-Fluss mit einzubeziehen. + +--- + +## 4. About-Seite (`/about`) – `web/about.blade.php` + +### 4.1 About-Hero + +**Anpassen:** +- Zitat aktualisieren: Von reinem Möbelfokus auf "Design & Property" +- Marcel Scheibe stärker als Gesicht beider Welten positionieren + +### 4.2 Our Story + +**Timeline erweitern:** +- Aktuell 3 Schritte (Idee → Mission → Zukunft), alle Möbel-fokussiert +- **Immobilien-Pivot** als neuen Timeline-Punkt einfügen oder in "Die Zukunft" integrieren: + "2025/2026 erweitert B2in sein Ökosystem um internationale Immobilien und Supply-Chain-Services für Entwickler." + +### 4.3 Our Values + +Werte sind aktuell Möbel-fokussiert (Innovation, Konnektivität, Qualität, Vertrauen, Nachhaltigkeit, Design-Exzellenz). Diese sind generisch genug, um auch für den Dual-Fokus zu funktionieren. **Minimale Textanpassungen** in den Beschreibungen, um Immobilien mit einzubeziehen. + +--- + +## 5. Magazin-Seite (`/magazin`) + +**Minimal-Änderung:** Subtitle kann erweitert werden um "...aus der Welt der Immobilien, des Designs und der Business-Konnektivität." + +--- + +## 6. Contact-Seite (`/contact`) + +### 6.1 Betreff-Auswahl erweitern + +```php +'subjects' => [ + '' => 'Wählen Sie einen Betreff', + 'immobilien' => 'Internationale Immobilien', + 'supply_chain' => 'Supply-Chain-Management', + 'general' => 'Allgemeine Anfrage', + 'partnership' => 'Partnerschaft', + 'press' => 'Presse', + 'career' => 'Karriere', +], +``` + +--- + +## 7. Footer + +### 7.1 Marcel Scheibe sichtbar machen + +Gemäß Konzept: "Dein Name steht dezent im Footer". Vorschlag: +- Unter dem Logo/Claim: "Marcel Scheibe – Gründer & CEO" +- Klein, dezent, aber sichtbar + +--- + +## 8. Neue Seite: Immobilien / Services (OPTIONAL) + +Wenn eine eigene Immobilien-Landingpage gewünscht ist: + +### Route +```php +Route::get('/immobilien', fn() => view('web.immobilien'))->name('immobilien'); +``` + +### Navigation erweitern +```php +'navigation' => [ + ['label' => 'Home', 'url' => '/'], + ['label' => 'Immobilien', 'url' => '/immobilien'], // NEU + ['label' => 'Partner', 'url' => '/partner'], + ['label' => 'Ecosystem', 'url' => '/ecosystem'], + ['label' => 'Magazin', 'url' => '/magazin'], + ['label' => 'About', 'url' => '/about'], + ['label' => 'Contact', 'url' => '/contact'], +] +``` + +### Mögliche Sektionen +1. Hero: Internationale Immobilien-Investments +2. Content: Märkte (Dubai, Lissabon, etc.) +3. Supply-Chain-Management als Service-Sektion +4. Investor Evenings (Event-Konzept) +5. CTA: Kontakt / Persönliches Gespräch + +--- + +## 9. Umsetzungs-Reihenfolge (Priorisierung) + +### Phase 1 – Sofort (Homepage-Neuausrichtung) +1. **Hero-Text** in `config/content.php` anpassen (30 min) +2. **Marcel Scheibe im Hero-Bereich** sichtbar machen – neue Mini-Sektion oder Hero-Erweiterung (1-2h) +3. **Vision-Sektion** Text aktualisieren (30 min) +4. **Ecosystem-Core** Säulen neu texten (30 min) +5. **CTA-Sektion** aktualisieren (15 min) + +### Phase 2 – Kurzfristig (Content-Erweiterung) +6. **Brand-Worlds** Karten und Texte anpassen (1h) +7. **Content-Sektion** "Beste aus zwei Welten" anpassen (30 min) +8. **Footer** Marcel Scheibe dezent ergänzen (15 min) +9. **Contact** Betreff-Optionen erweitern (15 min) + +### Phase 3 – Mittelfristig (Partner & Ecosystem) +10. **Partner-Seite:** Neue Karte "Immobilienentwickler" + Benefits-Sektion (2h) +11. **Ecosystem-Seite:** Texte um Immobilien-Dimension erweitern (1-2h) +12. **About-Seite:** Story/Timeline + Werte-Texte anpassen (1h) + +### Phase 4 – Optional (Neue Seiten) +13. **Immobilien-Landingpage** als eigene Route + View + Content (4-6h) +14. **Supply-Chain-Service-Seite** oder eigene Sektion (2-3h) +15. **Navigation** erweitern (15 min) + +--- + +## 10. Technische Änderungen (Übersicht) + +| Datei | Änderung | Aufwand | +|-------|----------|---------| +| `config/content.php` | Hero, Vision, Ecosystem-Core, Brand-Worlds, CTA, Contact-Subjects, About, Partner, Magazin | Hoch (zentrale Datei) | +| `resources/views/web/home.blade.php` | Ggf. neue Sektion nach Hero für Marcel Scheibe | Gering | +| `resources/views/web/b2in.blade.php` | Ggf. neue Sektion nach Hero für Marcel Scheibe | Gering | +| `resources/views/web/partner.blade.php` | Neue benefits-section für Immobilienentwickler einfügen | Gering | +| `resources/views/livewire/web/components/ui/footer.blade.php` | Marcel Scheibe Name ergänzen | Gering | +| `routes/web.php` | Ggf. neue Route `/immobilien` | Gering | +| `resources/views/web/immobilien.blade.php` | Neue Seite (optional) | Mittel | + +--- + +## 11. Was NICHT geändert wird + +- **Blade-Komponenten-Struktur:** Alle bestehenden Livewire-Sections bleiben erhalten, nur der Content (aus config) ändert sich. +- **Styling/CSS:** Kein Redesign, nur Content-Änderungen. +- **Andere Themes:** b2a, stileigentum, style2own bleiben unverändert. +- **Admin-Portal:** Kein Einfluss auf portal.b2in.test. +- **CABINET-Bezug:** CABINET bleibt komplett getrennt, wird auf der b2in-Website nicht erwähnt. + +--- + +## 12. Offene Fragen an den Kunden + +1. **Eigene Immobilien-Seite?** Soll `/immobilien` als eigenständige Seite existieren oder reicht die Homepage-Integration? +2. **Supply-Chain-Management:** Eigene Seite oder Sektion auf der Partner-Seite? +3. **Investor Evenings:** Soll das Event-Konzept auf der Website abgebildet werden (z.B. als Sektion oder eigene Seite)? +4. **B2A-Markenwelt:** Bleibt B2A als Brand-World-Karte erhalten oder wird sie durch "Immobilien" ersetzt? +5. **Marcel Scheibe Foto:** Ist ein hochwertiges Porträtfoto vorhanden, das im Hero-Bereich verwendet werden kann? +6. **Konkreter Immobilien-Content:** Welche Märkte/Projekte sollen initial gezeigt werden (Dubai, Lissabon, etc.)? diff --git a/dev/27-02-2026/backup-before-relaunch/about.blade.php b/dev/27-02-2026/backup-before-relaunch/about.blade.php new file mode 100644 index 0000000..99edd7a --- /dev/null +++ b/dev/27-02-2026/backup-before-relaunch/about.blade.php @@ -0,0 +1,31 @@ +@extends('web.layouts.web-master') + +@section('title', 'Über B2IN - Unser Team & Geschichte') + +@section('content') +
+ + +
+ + + + + +
+ + +
+@endsection + +@push('styles') + +@endpush + +@push('scripts') + {{-- Alpine.js wird zentral im Layout geladen --}} +@endpush diff --git a/dev/27-02-2026/backup-before-relaunch/b2in.blade.php b/dev/27-02-2026/backup-before-relaunch/b2in.blade.php new file mode 100644 index 0000000..0b40a65 --- /dev/null +++ b/dev/27-02-2026/backup-before-relaunch/b2in.blade.php @@ -0,0 +1,34 @@ +@extends('web.layouts.web-master') + +@section('title', 'B2IN - Connecting Design and Property') + +@section('content') + +
+ + +
+ + + + + + +
+ + +
+@endsection + +@push('styles') + +@endpush + +@push('scripts') + {{-- Alpine.js wird zentral im Layout geladen --}} +@endpush diff --git a/dev/27-02-2026/backup-before-relaunch/contact.blade.php b/dev/27-02-2026/backup-before-relaunch/contact.blade.php new file mode 100644 index 0000000..ded62ad --- /dev/null +++ b/dev/27-02-2026/backup-before-relaunch/contact.blade.php @@ -0,0 +1,27 @@ +@extends('web.layouts.web-master') + +@section('title', 'Kontakt - B2in') + +@section('content') +
+ + +
+ +
+ + +
+@endsection + +@push('styles') + +@endpush + +@push('scripts') + {{-- Alpine.js wird zentral im Layout geladen --}} +@endpush diff --git a/dev/27-02-2026/backup-before-relaunch/content.php b/dev/27-02-2026/backup-before-relaunch/content.php new file mode 100644 index 0000000..d66a7a6 --- /dev/null +++ b/dev/27-02-2026/backup-before-relaunch/content.php @@ -0,0 +1,2316 @@ + [ + 'b2in' => [ + 'header' => [ + 'portal_login' => 'Portal Login', + 'navigation' => [ + ['label' => 'Home', 'url' => '/'], + ['label' => 'Partner', 'url' => '/partner'], + ['label' => 'Ecosystem', 'url' => '/ecosystem'], + ['label' => 'Magazin', 'url' => '/magazin'], + ['label' => 'About', 'url' => '/about'], + ['label' => 'Contact', 'url' => '/contact'], + ], + ], + 'hero' => [ + 'title' => 'B2in – Brücken zwischen lokalen Kunden, lokalem Handel und inspirierenden Designs.', + 'subtitle' => 'Wo lokaler Handel und namhafte Lieferanten digital, aber lokal, in einem Netzwerk zusammenarbeiten.', + 'image' => 'b2in/hero-room.jpg', + 'image_alt' => 'Modern international skyline showcasing architectural design', + 'cta1_text' => 'Für lokale Händler', + 'cta1_link' => '/services', + 'cta2_text' => 'Für Hersteller & Marken', + 'cta2_link' => '/beratung', + 'stats' => [ + 'Exklusive Auswahl', + 'Persönlicher Service', + 'Werte die bleiben', + ], + 'card_title' => 'B2in', + 'card_text' => 'Connecting Design and Property', + ], + 'ecosystem_core' => [ + 'title' => 'Ein Ökosystem, drei Stärken', + 'subtitle' => 'Wir schaffen Synergien, die den Markt revolutionieren.', + 'pillars' => [ + [ + 'icon' => 'cube-transparent', + 'title' => 'Kuratierter Marktplatz', + 'description' => 'Präsentieren Sie Ihre Möbel auf einer exklusiven Plattform, die gezielt designaffine Endkunden und Immobilienprofis anspricht – regional und überregional.', + ], + [ + 'icon' => 'truck', + 'title' => 'Intelligente Logistik & Service', + 'description' => 'Profitieren Sie von unserer Bündel-Logistik für Hersteller oder unserer direkten Anbindung an lokale Montageteams für Händler.', + ], + [ + 'icon' => 'user-group', + 'title' => ' Ein starkes Verkaufsnetzwerk', + 'description' => 'Werden Sie Teil eines Ökosystems aus Maklern, Händlern und Marken, das kontinuierlich neue, qualifizierte Verkaufschancen für Ihr Sortiment generiert.', + ], + ], + ], + 'vision_section' => [ + 'title' => 'Gebaut auf Vertrauen', + 'paragraphs' => [ + 'B2in (Bridges2international) verbindet Immobilienmakler, Möbelfachhändler, Möbelhersteller und Markenpartner auf einer gemeinsamen Plattform.', + 'Unser Ziel: den Kunden zu Hause abzuholen, den lokalen Handel zu stärken, partnerschaftliche Kooperationen zu fördern und alle Welten digital miteinander zu verbinden – ohne dabei die lokalen Wurzeln aus den Augen zu verlieren.', + 'So entsteht ein Netzwerk, das Nähe schafft – regional verwurzelt, europaweit vernetzt und auf nachhaltigen Erfolg ausgerichtet ist.', + ], + 'image' => 'b2in/marcel-scheibe.jpg', + 'image_alt' => 'Professionelles Team in kollaborativem Meeting', + 'image_caption' => 'Marcel Scheibe, Gründer & CEO', + ], + 'brand_worlds' => [ + 'title' => 'Unsere Markenwelten:
Qualifizierte Kunden für Ihr Möbel-Sortiment', + 'subtitle' => 'Unsere Endkunden-Plattformen style2own und stileigentum schaffen einen konstanten Strom an Kauf- und Mietabsichten. Diese qualifizierten Leads leiten wir in unser Ökosystem, direkt zu den passenden Möbel-Angeboten – Ihren.', + 'worlds' => [ + [ + 'image' => 'b2in/b2a.jpg', + 'title' => 'B2A', + 'description' => 'Unsere Logistik-Power für den US-Markt. Wir ermöglichen Herstellern den Zugang zum transatlantischen Handel.', + 'link' => '/b2a', + 'logo' => 'img/logos/b2a-logo-positiv.svg', + 'logo_width' => 'w-18', + ], + [ + 'image' => 'b2in/stileigentum.jpg', + 'title' => 'Stileigentum', + 'description' => 'Der Magnet für das Premium-Segment. Diese Marke zieht kaufkräftige Kunden an, die exklusive und hochwertige Möbel suchen – Ihre.', + 'link' => '/stileigentum', + 'logo' => 'img/logos/stileigentum-logo-positiv.svg', + 'logo_width' => 'w-35', + ], + [ + 'image' => 'b2in/style2own.jpg', + 'title' => 'Style2own', + 'description' => 'Der Motor für den breiten Markt. Diese Marke begeistert lifestyle-orientierte Kunden und schafft durch flexible Mietmodelle eine hohe Nachfrage.', + 'link' => '/style2own', + 'logo' => 'img/logos/style2own-logo-positiv.svg', + 'logo_width' => 'w-28', + ], + ], + ], + 'integriertes_modell_b2in' => [ + 'title' => 'Das Beste aus zwei Welten:
in jeder Region', + 'paragraphs' => [ + 'Unser einzigartiges Modell schafft einen Marktplatz, den es so vorher nicht gab. Der Kunde wählt seine Region und erhält eine integrierte Ansicht: Zuerst die Angebote der lokalen Fachexperten, ergänzt durch das exklusive Sortiment unserer europäischen Hersteller.', + 'Das Ergebnis ist maximale Auswahl für den Kunden und maximaler Erfolg für unsere Partner.', + ], + 'image' => 'b2in/best-of-two-worlds.jpg', + 'image_alt' => 'Das Ergebnis für den Kunden, das perfekte Zuhause', + 'image_caption' => 'Das Ergebnis für den Kunden, das perfekte Zuhause', + ], + 'cta_section' => [ + 'title' => ' Werden Sie Partner
im führenden Möbel-Netzwerk', + 'subtitle' => 'Starten Sie jetzt und erschließen Sie neue Vertriebskanäle – ob als lokaler Händler oder als visionäre Herstellermarke.', + 'button_text' => 'Jetzt Partner werden', + 'button_link' => '/contact', + ], + 'faq' => [ + 'title' => 'Häufig gestellte Fragen', + 'subtitle' => 'Hier finden Sie Antworten auf die häufigsten Fragen zu unserem B2in Ökosystem.', + 'questions' => [ + [ + 'question' => 'Was ist B2in und welche Services bieten Sie an?', + 'answer' => 'B2in (Bridges 2 International) ist ein globales Ökosystem für Immobilieninvestoren, Makler und Designliebhaber. Wir bieten drei Hauptbereiche: B2A für Business-to-Administration, Stileigentum für exklusive Immobilien und Style2Own für modernes Interior Design.', + ], + [ + 'question' => 'Wie kann ich Partner bei B2in werden?', + 'answer' => 'Partner können Sie über unser Partnerportal werden. Wir bieten verschiedene Partnerschaftsmodelle für Makler, Lieferanten und andere Branchenexperten. Kontaktieren Sie uns für eine persönliche Beratung über die Vorteile einer strategischen Partnerschaft.', + ], + [ + 'question' => 'Welche Vorteile bietet das B2in Ökosystem?', + 'answer' => 'Unser Ökosystem bietet direkten Zugang zu exklusiven Investments, professionelle Begleitung bei internationalen Märkten, kuratierte Wohnkonzepte für Wertsteigerung und ein starkes Partnernetzwerk mit fairen Konditionen und digitalem Support.', + ], + [ + 'question' => 'Wie funktioniert der globale Immobilienhandel bei B2in?', + 'answer' => 'Wir bieten direkten Zugang zu exklusiven Immobilieninvestments auf internationalen Märkten mit professioneller Begleitung. Unsere Expertise umfasst Marktanalysen, rechtliche Unterstützung und langfristige Betreuung Ihrer Investments.', + ], + [ + 'question' => 'Was macht B2in zu einem vertrauenswürdigen Partner?', + 'answer' => 'Unsere Basis ist Vertrauen, angetrieben von Technologie und Innovation. B2in ist nicht nur eine Holding, sondern ein aktiver Gestalter der Immobilienzukunft mit einer zentralen digitalen Plattform für Transparenz und Qualität.', + ], + ], + ], + 'contact_form' => [ + 'hero' => [ + 'title' => 'Senden Sie uns eine
Nachricht.', + 'subtitle' => 'Wir freuen uns auf Ihre Nachricht und werden uns schnellstmöglich bei Ihnen melden.', + ], + 'form' => [ + 'labels' => [ + 'first_name' => 'First name *', + 'last_name' => 'Last name *', + 'company' => 'Company', + 'email' => 'Email *', + 'phone' => 'Phone', + 'subject' => 'Subject *', + 'message' => 'Message *', + ], + 'subjects' => [ + '' => 'Wählen Sie einen Betreff', + 'general' => 'Allgemeine Anfrage', + 'press' => 'Presse', + 'partnership' => 'Partnerschaft', + 'career' => 'Karriere', + ], + 'placeholders' => [ + 'message' => 'Ihre Nachricht...', + ], + 'button_text' => 'Senden', + 'button_loading' => 'Wird gesendet...', + 'success_message' => 'Vielen Dank für Ihre Nachricht! Wir werden uns schnellstmöglich bei Ihnen melden.', + ], + 'contact_info' => [ + [ + 'title' => 'Our Office Location', + 'info' => [ + 'Musterstraße 123', + '12345 Berlin, Deutschland', + ], + 'icon' => 'map-pin', + ], + [ + 'title' => 'Our Email Address', + 'info' => [ + 'info@b2in.com', + ], + 'icon' => 'mail', + ], + [ + 'title' => 'Our Contact Numbers', + 'info' => [ + '+49 30 12345678', + ], + 'icon' => 'phone', + ], + ], + 'social_media' => [ + 'title' => 'Follow for
exclusives', + 'subtitle' => 'Bleiben Sie auf dem Laufenden mit exklusiven Angeboten und News.', + 'platforms' => [ + ['name' => 'Instagram', 'handle' => '@b2in_official', 'url' => 'https://instagram.com/b2in_official'], + ['name' => 'Youtube', 'handle' => 'B2IN Channel', 'url' => 'https://youtube.com/@b2in'], + ['name' => 'Pinterest', 'handle' => 'B2IN Inspiration', 'url' => 'https://pinterest.com/b2in'], + ['name' => 'Facebook', 'handle' => 'B2IN Deutschland', 'url' => 'https://facebook.com/b2in'], + ['name' => 'LinkedIn', 'handle' => 'B2IN Company', 'url' => 'https://linkedin.com/company/b2in'], + ], + ], + ], + 'about_hero' => [ + 'title' => 'Über B2in: Unsere Mission', + 'quote' => '"Unsere Mission ist es, die Zukunft des lokalen Möbelhandels zu sichern. Wir geben dem Fachexperten vor Ort die digitalen Werkzeuge, um gegen die Dominanz der Online-Giganten zu bestehen.

Bei B2in bauen wir nicht nur Verbindungen – wir bauen Brücken zwischen europäischem Design, regionaler Expertise und dem Zuhause der Menschen."', + 'founder_name' => 'Marcel Scheibe', + 'founder_title' => 'Gründer & CEO, B2in', + 'image' => 'b2in/about-hero.jpg', + 'image_alt' => 'Marcel Scheibe, Gründer und CEO von B2in', + + 'card_title' => 'B2in', + 'card_text' => 'Connecting Design and Property', + + ], + 'broker_section' => [ + 'title' => 'Lifetime Vergütung für Makler', + 'subtitle' => 'Profitieren Sie von einem revolutionären Vergütungsmodell, das über den einmaligen Verkauf hinausgeht. Bauen Sie langfristige Kundenbeziehungen auf und generieren Sie kontinuierliche Erträge.', + 'card_title' => 'Lifetime-Vergütung', + 'compensation' => [ + 'initial_sale' => '3.5%', + 'follow_up' => '1.5%', + ], + 'compensation_text' => 'Kontinuierliche Erträge über die gesamte Kundenbeziehung', + 'benefits' => [ + [ + 'title' => 'Lifetime-Vergütungsmodell', + 'description' => 'Kontinuierliche Provisionen durch langfristige Kundenbeziehungen und wiederkehrende Geschäfte', + 'icon' => 'trending-up', + ], + [ + 'title' => 'Schnellere Vermarktung', + 'description' => 'Durchdachte Wohnkonzepte reduzieren die Verkaufszeit und erhöhen die Erfolgschancen', + 'icon' => 'clock', + ], + [ + 'title' => 'Qualifizierte Leads', + 'description' => 'Vorgefilterte, interessierte Kunden durch das B2in-Portal und Premium-Mitgliedschaften', + 'icon' => 'target', + ], + [ + 'title' => 'Premium-Positioning', + 'description' => 'Exklusive Vermarktung hochwertiger Wohnkonzepte für anspruchsvolle Zielgruppen', + 'icon' => 'award', + ], + ], + ], + 'commitment_section' => [ + 'title' => 'Das Vertrauen unserer Partner', + 'subtitle' => 'Echte Meinungen von echten Partnern. Ihr Erfolg ist unser größter Ansporn.', + 'testimonials' => [ + [ + 'image' => 'b2in/testo-1.jpg', + 'rating' => 5, + 'quote' => 'Die Zusammenarbeit mit B2in hat unsere Erwartungen übertroffen. Professionell, effizient und immer lösungsorientiert.', + 'author' => 'Max Mustermann', + 'author_title' => 'Möbelhersteller', + ], + [ + 'image' => 'b2in/testo-2.jpg', + 'rating' => 5, + 'quote' => 'Dank der B2in-Plattform konnten wir unsere Reichweite signifikant erhöhen und neue Märkte erschließen.', + 'author' => 'Erika Mustermann', + 'author_title' => 'lokaler Möbelhändler', + ], + [ + 'image' => 'b2in/testo-3.jpg', + 'rating' => 5, + 'quote' => 'Das B2in-Portal hat die Art, wie ich Immobilien vermarkte, revolutioniert. Das Staging wertet meine Objekte auf, und die Möbelprovision ist ein extrem attraktiver Zusatzverdienst.', + 'author' => 'John Doe', + 'author_title' => 'Immobilienmakler', + ], + ], + ], + 'dark_stats_section' => [ + 'stats' => [ + ['number' => '17+', 'text' => 'Years of Experience'], + ['number' => '2M', 'text' => 'Happy Guests'], + ], + 'title' => 'Economically Sound and Well-
Friendly Service for
Families and Their
Precious Belongings', + 'description' => 'We understand that every family is unique, which is why we offer personalized services tailored to meet your specific needs. From luxury amenities to budget-friendly options, we ensure every guest feels valued and comfortable.', + 'features' => [ + ['title' => 'Top Consultation', 'subtitle' => 'Expert guidance'], + ['title' => 'Finest Meal', 'subtitle' => 'Gourmet dining'], + ['title' => 'Easy Tax Reduction', 'subtitle' => 'Cost-effective'], + ], + 'image' => 'b2in/accommodation-2.jpg', + 'image_alt' => 'Luxury interior design', + ], + 'ecosystem_hero' => [ + 'title' => 'Wie unser Ökosystem Wachstum für alle Partner generiert', + 'subtitle' => 'Ein intelligentes Netzwerk, das Endkunden, Händler, Lieferanten, Makler und Technologie nahtlos miteinander verbindet. Jeder Teilnehmer profitiert vom gesamten System und schafft gemeinsam außergewöhnliche Möbel- und Immobilienerlebnisse.', + 'features' => [ + [ + 'title' => 'Endkunden', + 'description' => 'Exklusive Erlebnisse', + 'icon' => 'users', + ], + [ + 'title' => 'Makler', + 'description' => 'Lifetime-Vergütung', + 'icon' => 'building-2', + ], + [ + 'title' => 'Lieferanten', + 'description' => 'Kuratierte Plattform', + 'icon' => 'network', + ], + [ + 'title' => 'Technologie', + 'description' => 'Digitales Herzstück', + 'icon' => 'zap', + ], + ], + 'image' => 'b2in/ecosystem-hero.jpg', + 'image_alt' => 'Ecosystem Hero Image', + 'card_title' => 'B2in Portal', + 'card_text' => 'Die Technologie dahinter, die das Ökosystem ermöglicht', + 'hub' => [ + 'title' => 'B2in Portal', + 'subtitle' => 'Die Technologie dahinter, die das Ökosystem ermöglicht', + ], + 'stats' => [ + 'Exklusive Auswahl', + 'Persönlicher Service', + 'Werte die bleiben', + ], + ], + 'ecosystem_stats' => [ + 'title' => 'Unser Ecosystem in Zahlen', + 'subtitle' => 'Zahlen, die die Stärke und das Vertrauen in unser vernetztes Geschäftsmodell widerspiegeln.', + 'stats' => [ + [ + 'number' => '1,7K+', + 'label' => 'Partner & Experten im Netzwerk', + 'description' => 'Wachsende Community von Endkunden, Maklern und Lieferanten', + ], + [ + 'number' => '510+', + 'label' => 'Realisierte Möbel-Projekte', + 'description' => 'Realisierte Immobilienprojekte durch unser Netzwerk', + ], + [ + 'number' => '98%', + 'label' => ' Partner-Zufriedenheit', + 'description' => 'Kundenzufriedenheit across alle Ecosystem-Teilnehmer', + ], + [ + 'number' => '24/7', + 'label' => 'Partner-Support', + 'description' => 'Kontinuierliche Verfügbarkeit der digitalen Infrastruktur', + ], + ], + ], + 'ecosystem_start' => [ + 'title' => 'Alles beginnt mit dem Kundenwunsch:', + 'paragraphs' => [ + 'Unser Ökosystem startet nicht bei Ihnen, sondern beim Endkunden.', + 'Unsere reichweitenstarken Marken style2own und stileigentum schaffen durch Inspiration und exklusive Konzepte eine kontinuierliche, kaufkräftige Nachfrage nach hochwertigen Möbeln.', + ], + 'image' => 'b2in/ecosystem_start.jpg', + 'image_alt' => 'Die Marken für den Endkunden', + 'image_caption' => 'Die Marken für den Endkunden', + 'image_description' => 'Eine stilisierte Grafik, die zeigt, wie von den Logos style2own und stileigentum Pfeile in den nächsten Abschnitt führen.', + ], + 'ecosystem_hub' => [ + 'title' => ' Im Hub trifft das Beste aus zwei Welten aufeinander', + 'paragraphs' => [ + 'Sobald ein Kunde seine Region wählt (z.B. Bielefeld), spielt unsere Plattform ihre Stärke aus. Dank der "Local First"-Logik werden die Angebote unserer lokalen Händler prominent platziert.', + 'Gleichzeitig wird das Sortiment durch die exklusiven Produkte unserer europäischen Hersteller ergänzt. So entsteht eine unschlagbare Auswahl.', + ], + 'image' => 'b2in/ecosystem_hub.jpg', + 'image_alt' => 'Die Synergie zwischen lokalem und überregionalem Angebot', + 'image_caption' => 'Die Synergie zwischen lokalem und überregionalem Angebot', + 'image_description' => 'Eine klare Infografik, die zeigt: [Lokales Angebot] + [Überregionales Angebot] = Die Synergie zwischen lokalem und überregionalem Angebot.', + ], + 'ecosystem_result' => [ + 'title' => 'Ein Kreislauf, in dem jeder gewinnt', + 'paragraphs' => [ + 'In diesem perfekten Zusammenspiel entstehen klare Vorteile für jeden Teilnehmer:', + ], + 'list' => [ + [ + 'icon' => 'building-storefront', + 'title' => 'Der lokale Händler gewinnt einen Online-Kunden, den er sonst nicht erreicht hätte, und stärkt seine Position vor Ort.', + ], + [ + 'icon' => 'building-office-2', + 'title' => 'Lokale Händler erhalten neue Kunden und erhöhen ihren Umsatz', + ], + [ + 'icon' => 'home-modern', + 'title' => 'Der Makler, der den Kunden ursprünglich vermittelt hat, erhält eine faire Provision und hat seinem Kunden einen unschätzbaren Mehrwert geboten.', + ], + ], + 'image' => 'b2in/ecosystem_result.jpg', + 'image_alt' => 'Ihr Erfolg und die Partnerschaft mit B2in', + 'image_caption' => 'Ihr Erfolg und die Partnerschaft mit B2in', + 'image_description' => 'Eine stilisierte Grafik, die zeigt, wie Ihr Erfolg und die Partnerschaft mit B2in zusammenhängen.', + ], + 'end_customer_section' => [ + 'tag' => 'Für Endkunden', + 'title' => 'Exklusive Erlebnisse für Sie', + 'subtitle' => 'Mit Ihrer persönlichen Login-Karte erhalten Sie Zugang zu einer einzigartigen Erlebniswelt, die speziell auf Ihre Wohnwünsche und Lebensstil abgestimmt ist. Entdecken Sie kuratierte Immobilien und Services, die sonst nicht verfügbar sind.', + 'benefits' => [ + [ + 'title' => 'Exklusive Login-Karte', + 'description' => 'Personalisierter Zugang zu ausgewählten Immobilienerlebnissen und Premium-Services', + 'icon' => 'credit-card', + ], + [ + 'title' => 'Personalisierte Erlebniswelt', + 'description' => 'Maßgeschneiderte Immobilienangebote basierend auf individuellen Präferenzen und Bedürfnissen', + 'icon' => 'star', + ], + [ + 'title' => 'Kuratierte Wohnkonzepte', + 'description' => 'Hochwertige, durchdachte Immobilienlösungen von verifizierten Partnern', + 'icon' => 'home', + ], + [ + 'title' => 'Qualitätsgarantie', + 'description' => 'Geprüfte Anbieter und standardisierte Qualitätsprozesse für maximale Sicherheit', + 'icon' => 'shield', + ], + ], + 'image' => 'b2in/end-customer-section.jpg', + 'image_alt' => 'End Customer Section Image', + 'card_title' => 'Login-Karte', + 'card_text' => 'Ihr Schlüssel zu exklusiven Immobilienerlebnissen', + 'card' => [ + 'title' => 'Login-Karte', + 'subtitle' => 'Ihr Schlüssel zu exklusiven Immobilienerlebnissen', + 'member_number_label' => 'Mitgliedsnummer', + 'member_number' => 'B2IN-2024-VIP', + ], + ], + 'final_commitment' => [ + 'title' => 'Were committed to
your comfort and
satisfaction for
unforgettable
experiences', + 'author' => 'Robert Wilson', + 'author_title' => 'General Manager', + ], + 'digital_core' => [ + 'title' => 'Die Technologie, die diesen Kreislauf ermöglicht', + 'subtitle' => 'Unsere zentrale Plattform ist mehr als nur Technologie. Sie ist das digitale Herzstück für transparente Prozesse, datengestützte Entscheidungen und nahtlose Zusammenarbeit im gesamten Ökosystem.', + 'features' => [ + [ + 'title' => 'Maximale Zuverlässigkeit', + 'description' => 'Unsere Plattform ist jederzeit und von überall erreichbar. Sie wächst mit Ihrem Erfolg und garantiert einen stabilen Betrieb, auf den Sie sich verlassen können.', + 'icon' => 'cloud', + 'icon_style' => 'solid', + ], + [ + 'title' => 'Kompromisslose Sicherheit', + 'description' => 'Ihre Daten und die Ihrer Kunden sind unser höchstes Gut. Wir schützen sie mit modernsten Sicherheitsarchitekturen und garantieren vollsten Datenschutz.', + 'icon' => 'shield-check', + ], + [ + 'title' => 'Zukunftsfähige Integration', + 'description' => 'Das B2in-Portal ist offen für die Zukunft. Es lässt sich nahtlos in andere Systeme integrieren und ist bereit für zukünftige Erweiterungen und Technologien.', + 'icon' => 'squares-2x2', + ], + [ + 'title' => 'Datenbasierte Einblicke', + 'description' => 'Verfolgen Sie als Makler die Aktivitäten Ihrer Kunden oder als Lieferant die Performance Ihrer Produkte – alles in Echtzeit. Treffen Sie bessere Entscheidungen auf Basis valider Daten.', + 'icon' => 'chart-bar', + 'icon_style' => 'solid', + ], + [ + 'title' => 'Intelligente Personalisierung', + 'description' => 'Künstliche Intelligenz unterstützt Sie und Ihre Kunden – von personalisierten Einrichtungsvorschlägen bis zur Automatisierung von Prozessen für maximale Effizienz.', + 'icon' => 'cpu-chip', + ], + [ + 'title' => 'Exzellente User Experience', + 'description' => 'Unsere Plattform ist für maximale Geschwindigkeit optimiert und bietet auf jedem Gerät – vom Desktop bis zum Smartphone – eine intuitive und flüssige Bedienung.', + 'icon' => 'users', + 'icon_style' => 'solid', + ], + ], + ], + 'magazin_detail' => [ + 'back_to_magazine' => 'Zurück zum Magazin', + 'share_article' => 'Artikel teilen', + 'cta_title' => 'Entdecken Sie mehr über Luxus und Komfort für unvergessliche Erlebnisse.', + 'cta_button' => 'Weitere Artikel entdecken', + ], + 'magazin_list' => [ + 'title' => 'B2in Magazin', + 'subtitle' => 'Entdecken Sie die neuesten Trends, Insights und Geschichten aus der Welt der Business-Konnektivität und Innovation.', + 'read_more' => 'Weiterlesen', + 'load_more' => 'Weitere Artikel laden', + ], + 'our_story' => [ + 'title' => 'Unsere Geschichte', + 'timeline' => [ + [ + 'title' => 'Die Idee', + 'description' => '2024 erkannten wir eine entscheidende Lücke im Möbelmarkt: Während Online-Riesen wachsen, kämpft der lokale Fachhandel um seine digitale Sichtbarkeit. Gleichzeitig suchen Kunden nach kuratierter Qualität und persönlichem Service.', + 'icon' => 'light-bulb', + ], + [ + 'title' => 'Die Mission', + 'description' => 'Wir entwickeln eine Plattform, die das Beste aus beiden Welten vereint: die Stärke des lokalen Handels und die Vielfalt des europäischen Designs. Unser Ziel ist es, faire, regionale Ökosysteme zu schaffen, in denen Technologie dem Menschen dient.', + 'icon' => 'rocket-launch', + ], + [ + 'title' => 'Die Zukunft', + 'description' => 'Heute bauen wir ein wachsendes Netzwerk regionaler Hubs auf. Unsere Vision ist es, in jeder größeren Region Europas der führende digitale Partner für den lokalen Möbel- und Designhandel zu werden.', + 'icon' => 'globe-alt', + ], + ], + 'summary' => 'Was als Vision begann, den lokalen Handel zu stärken, ist heute die zentrale B2B-Plattform für den kuratierten Möbelhandel. Wir schließen die Lücke zwischen Online-Nachfrage und Offline-Expertise und schaffen so nachhaltiges Wachstum für unsere Partner.', + ], + 'our_values' => [ + 'title' => 'Unsere Werte', + 'subtitle' => 'Diese fünf Grundpfeiler leiten unser tägliches Handeln und definieren, wer wir als Unternehmen sind und wofür wir stehen.', + 'values' => [ + [ + 'title' => 'Innovation', + 'description' => 'Wir entwickeln digitale Lösungen, die dem lokalen Möbelhandel einen echten Wettbewerbsvorteil in einer sich schnell verändernden Welt verschaffen.', + 'icon' => 'light-bulb', + ], + [ + 'title' => 'Konnektivität', + 'description' => 'Wir verbinden nicht nur Systeme – wir verbinden den Online-Kunden wieder mit dem Fachexperten in seiner Stadt und europäische Manufakturen mit neuen Märkten.', + 'icon' => 'globe-alt', + ], + [ + 'title' => 'Qualität', + 'description' => 'Wir setzen kompromisslose Standards – bei der Auswahl unserer Partner, der Kuration der Möbel und der Technologie, die alles zusammenhält.', + 'icon' => 'check-badge', + ], + [ + 'title' => 'Vertrauen', + 'description' => 'Transparente Provisionsmodelle und verlässliche Partnerschaften sind das Fundament unseres Ökosystems. Wir wachsen nur, wenn unsere Partner wachsen.', + 'icon' => 'user-group', + ], + [ + 'title' => 'Nachhaltigkeit', + 'description' => 'Wir übernehmen Verantwortung, indem wir durch unsere Bündel-Logistik Transportwege optimieren und den lokalen Handel stärken, um lebendige Innenstädte zu erhalten.', + 'icon' => 'arrow-path', + ], + [ + 'title' => 'Design-Exzellenz', + 'description' => 'Design ist der Kern unserer Wertschöpfung – von der kuratierten Auswahl an Möbeln bis zur intuitiven Gestaltung unserer digitalen Plattform.', + 'icon' => 'cube-transparent', + ], + ], + ], + 'partner_benefits' => [ + 'title' => 'Warum Partner werden?', + 'subtitle' => 'Entdecken Sie die Vorteile einer Partnerschaft mit B2in und wie Sie von unserem innovativen Ecosystem profitieren können.', + 'broker' => [ + 'tag' => 'Für Makler', + 'title' => 'Revolutionäres Provisionsmodell', + 'benefits' => [ + [ + 'icon' => 'trending-up', + 'title' => 'Lifetime-Provisionsmodell', + 'description' => 'Profitieren Sie von kontinuierlichen Einnahmen durch unser innovatives Vergütungssystem', + ], + [ + 'icon' => 'target', + 'title' => 'Schnellere Vermarktung', + 'description' => 'Durchdachte Wohnkonzepte verkürzen Vermarktungszeiten und erhöhen Ihre Erfolgsquote', + ], + [ + 'icon' => 'award', + 'title' => 'Mehrwert für Ihre Kunden', + 'description' => 'Bieten Sie Ihren Kunden exklusive, kuratierte Immobilienerlebnisse', + ], + ], + 'highlight' => [ + 'value' => '3.5% - ∞', + 'text' => 'Erstprovision bis Lifetime-Vergütung', + ], + ], + 'supplier' => [ + 'tag' => 'Für Lieferanten', + 'title' => 'Globale Marktchancen', + 'benefits' => [ + [ + 'icon' => 'globe', + 'title' => 'Zugang zu internationalen Märkten', + 'description' => 'Erweitern Sie Ihre Reichweite über Grenzen hinweg mit unserem globalen Netzwerk', + ], + [ + 'icon' => 'handshake', + 'title' => 'Faire Konditionen', + 'description' => 'Transparente und partnerschaftliche Geschäftsbedingungen für nachhaltigen Erfolg', + ], + [ + 'icon' => 'settings', + 'title' => 'Einfache Produktverwaltung', + 'description' => 'Intuitive Plattform für die Verwaltung und Präsentation Ihrer Produkte', + ], + ], + 'highlight' => [ + 'image' => 'b2in/accommodation-1.jpg', + 'alt' => 'Partner success visualization', + 'value' => '500+', + 'text' => 'Erfolgreiche Partner', + ], + ], + ], + 'partner_cta' => [ + 'title' => 'Wachsen Sie mit uns', + 'subtitle' => 'Werden Sie Teil des B2in-Partnernetzwerks und erschließen Sie neue Geschäftsmöglichkeiten durch innovative Konnektivitätslösungen.', + 'stats' => [ + [ + 'number' => '500+', + 'label' => 'Aktive Partner', + ], + [ + 'number' => '98%', + 'label' => 'Zufriedenheitsrate', + ], + [ + 'number' => '24/7', + 'label' => 'Partner-Support', + ], + ], + 'button_text' => 'Werden Sie B2in Partner', + 'button_link' => '/contact', + 'small_text' => 'Entdecken Sie die Vorteile einer strategischen Partnerschaft mit B2in', + ], + 'partner_hero' => [ + 'title' => 'Das Ökosystem für Ihren Erfolg.', + 'subtitle' => 'Ob lokaler Möbel-Händler, europäische Marke oder Immobilienprofi – B2in ist die Plattform, die Ihr Geschäft mit designaffinen Kunden verbindet und neue Ertragsquellen erschließt.', + 'partner_types' => [ + [ + 'title' => 'Hersteller & Marken', + 'description' => 'Kuratierter Marktzugang, intelligente Logistik', + 'icon' => 'building-office-2', + ], + [ + 'title' => 'Lokale Händler', + 'description' => 'Digitale Reichweite, exklusives Sortiment', + 'icon' => 'building-storefront', + ], + [ + 'title' => 'Makler & Bauträger', + 'description' => 'Mehrwert für Kunden, Zusatzertrag für Sie', + 'icon' => 'home-modern', + ], + [ + 'title' => 'Unser Netzwerk', + 'description' => 'Gemeinsam wachsen, den Markt gestalten', + 'icon' => 'sparkles', + ], + ], + 'image' => 'b2in/partner-hero.jpg', + 'image_alt' => 'Partner Hero Image', + 'card_title' => 'Partner Network', + 'card_text' => 'Werden Sie Teil unseres Ecosystems', + 'hub' => [ + 'title' => 'Partner Network', + 'subtitle' => 'Werden Sie Teil unseres Ecosystems', + ], + 'connection_points' => [ + ['name' => 'Makler', 'subtext' => 'Lifetime-Modell'], + ['name' => 'Lieferanten', 'subtext' => 'Global Markets'], + ['name' => 'Erfolg', 'subtext' => 'Messbare Ziele'], + ['name' => 'Qualität', 'subtext' => 'Premium Standards'], + ], + ], + 'partner_card_section' => [ + 'title' => 'Welcher Partner-Typ sind Sie?', + 'subtitle' => 'Entdecken Sie die Vorteile einer Partnerschaft mit B2in und wie Sie von unserem innovativen Ecosystem profitieren können.', + 'cards' => [ + [ + 'title' => 'Für lokale Händler & Fachexperten', + 'description' => 'Stärken Sie Ihr Geschäft vor Ort. Erhalten Sie Zugang zu Online-Kunden und einem exklusiven, überregionalen Sortiment.', + 'icon' => 'building-storefront', + 'button' => '#partner-benefits-retailer', + 'button_text' => 'Ihre Vorteile als Händler', + ], + [ + 'title' => 'Für Hersteller & europäische Marken', + 'description' => 'Erschließen Sie neue, kuratierte Vertriebskanäle in regionalen Märkten und profitieren Sie von unserer intelligenten Logistik.', + 'icon' => 'building-office-2', + 'button' => '#partner-benefits-supplier', + 'button_text' => 'Ihre Vorteile als Hersteller', + ], + [ + 'title' => 'Für Immobilienmakler & Bauträger', + 'description' => 'Bieten Sie Ihren Kunden einen einzigartigen Mehrwert, beschleunigen Sie die Vermarktung Ihrer Objekte und sichern Sie sich attraktive Zusatzprovisionen.', + 'icon' => 'home-modern', + 'button' => '#partner-benefits-broker', + 'button_text' => 'Ihre Vorteile als Makler', + ], + ], + ], + 'partner_benefits_retailer' => [ + 'id' => 'partner-benefits-retailer', + 'tag' => 'Ihre Vorteile als lokaler Händler', + 'tag_icon' => 'building-storefront', + 'tag_title' => 'Werden Sie zum digitalen Champion in Ihrer Region.', + 'features' => [ + [ + 'title' => 'Digitale Reichweite', + 'description' => 'Wir bringen Ihnen die Online-Kunden, die Sie alleine nicht erreichen. Profitieren Sie von unseren reichweitenstarken Endkunden-Marken.', + 'icon' => 'signal', + ], + [ + 'title' => 'Sortiments-Erweiterung', + 'description' => 'Ergänzen Sie Ihr Angebot mit exklusiven Herstellermarken aus unserem Portfolio – ganz ohne eigenes Lagerrisiko.', + 'icon' => 'squares-plus', + ], + [ + 'title' => 'Stärkung gegen Online-Riesen', + 'description' => 'Mit "Local First" stärken wir gezielt Ihre Position im Markt. Der Kunde sieht Ihr Angebot immer zuerst.', + 'icon' => 'shield-check', + ], + [ + 'title' => 'Faire Konditionen', + 'description' => 'Unser Provisionsmodell ist transparent und darauf ausgelegt, dass wir nur dann verdienen, wenn Sie es auch tun.', + 'icon' => 'scale', + ], + ], + 'highlight' => [ + 'value' => '+35%', + 'text' => 'Digitale Reichweite im ersten Jahr (Durchschnitt unserer Partner)', + ], + 'image' => 'b2in/partner-benefits-retailer.jpg', + 'image_alt' => 'Partner Benefits Retailer', + ], + 'partner_benefits_supplier' => [ + 'id' => 'partner-benefits-supplier', + 'tag' => 'Ihre Vorteile als Hersteller & Marke', + 'tag_icon' => 'building-office-2', + 'tag_title' => 'Erschließen Sie neue Märkte – intelligent und kuratiert.', + 'features' => [ + [ + 'title' => 'Kuratierter Vertriebskanal', + 'description' => 'Statt sich im Rauschen großer Marktplätze zu verlieren, wird Ihre Marke gezielt designaffinen Kunden in kaufkräftigen Regionen präsentiert.', + 'icon' => 'eye', + ], + [ + 'title' => 'Effiziente Logistik', + 'description' => 'Unsere Bündel-Logistik senkt Ihre Vertriebskosten. Wir managen den Sammeltransport und die komplette Abwicklung.', + 'icon' => 'truck', + ], + [ + 'title' => 'Direkter Marktzugang', + 'description' => 'Überspringen Sie den klassischen Großhandel und bauen Sie eine direkte Beziehung zu regionalen Märkten und Endkunden auf.', + 'icon' => 'map-pin', + ], + [ + 'title' => 'Skalierbares Wachstum', + 'description' => 'Starten Sie mit uns in einer Region und wachsen Sie schrittweise in weitere europäische Hubs hinein.', + 'icon' => 'chart-bar-square', + ], + ], + 'highlight' => [ + 'value' => '> 20', + 'text' => 'Kuratierte regionale Hubs als neue Vertriebskanäle in Europa', + ], + 'image' => 'b2in/partner-benefits-supplier.jpg', + 'image_alt' => 'Partner Benefits Supplier', + ], + 'partner_benefits_broker' => [ + 'id' => 'partner-benefits-broker', + 'tag' => 'Ihre Vorteile als Makler & Immobilienprofi', + 'tag_icon' => 'home-modern', + 'tag_title' => 'Mehrwert für Ihre Kunden – Mehr Ertrag für Sie.', + 'features' => [ + + [ + 'title' => 'Schnellere Vermarktung durch Home Staging', + 'description' => 'Nutzen Sie unsere kuratierten Möbel-Pakete (stileigentum&style2own), um Ihre Immobilien professionell zu inszenieren. Beschleunigen Sie den Verkaufsprozess und erzielen Sie höhere Preise.', + 'icon' => 'rocket-launch', + ], + [ + 'title' => 'Einzigartiger Service nach dem Abschluss', + 'description' => 'Bieten Sie Ihren Käufern oder Mietern nach Vertragsunterzeichnung über unsere exklusive Login-Karte direkten Zugang zu einem kompletten Einrichtungsservice.', + 'icon' => 'gift', + ], + [ + 'title' => 'Attraktives Provisionsmodell', + 'description' => 'Profitieren Sie doppelt: Neben Ihrer klassischen Courtage erhalten Sie eine faire, lebenslange Provision auf alle Möbelumsätze, die Ihre Kunden über die Plattform generieren.', + 'icon' => 'currency-euro', + ], + [ + 'title' => 'Einfaches Handling', + 'description' => 'Unser digitales Makler-Portal macht es Ihnen leicht: Kunden einladen, Aktivitäten verfolgen und Provisionen transparent einsehen.', + 'icon' => 'finger-print', + ], + ], + 'highlight' => [ + 'value' => '-25%', + 'text' => 'Kürzere Vermarktungszeit für mit B2in inszenierte Objekte', + ], + 'image' => 'b2in/partner-benefits-broker.jpg', + 'image_alt' => 'Partner Benefits Broker', + ], + 'partner_process' => [ + 'title' => 'So werden Sie Partner', + 'subtitle' => 'In nur drei einfachen Schritten werden Sie Teil des B2in Ecosystems und können von allen Vorteilen unserer Partnerschaft profitieren.', + 'steps' => [ + [ + 'step' => '1', + 'title' => 'Bewerben', + 'description' => 'Erzählen Sie uns Ihre Geschichte. Füllen Sie unser kurzes Kontaktformular aus und zeigen Sie uns, was Ihre Produkte oder Ihr Geschäft auszeichnet.', + 'icon' => 'envelope', + 'image' => 'b2in/room-1.jpg', + ], + [ + 'step' => '2', + 'title' => 'Prüfung', + 'description' => 'Wir prüfen jede Anfrage persönlich. Unser Ziel ist es, ein hochwertiges und komplementäres Netzwerk aufzubauen, von dem alle profitieren.', + 'icon' => 'check-circle', + 'image' => 'b2in/room-2.jpg', + ], + [ + 'step' => '3', + 'title' => 'Onboarding', + 'description' => 'Willkommen an Bord! Wir schulen Sie persönlich im Umgang mit unserem Partner-Portal und stellen sicher, dass Sie vom ersten Tag an erfolgreich sind.', + 'icon' => 'rocket-launch', + 'image' => 'b2in/room-3.jpg', + ], + ], + 'cta' => [ + 'button_text' => ' Zum Partner-Portal', + 'button_link' => '/contact', + ], + 'cta' => [ + 'title' => 'Bereit für den nächsten Schritt?', + 'subtitle' => 'Werden Sie noch heute Teil des B2in Ecosystems und profitieren Sie von innovativen Geschäftsmodellen und nachhaltigen Erfolgsstrategien.', + 'button_text' => 'Jetzt Partner werden', + 'button_link' => '/contact', + ], + ], + + 'supplier_section' => [ + 'tag' => 'Für Lieferanten', + 'title' => 'Kuratierte Plattform für Anbieter', + 'subtitle' => 'Werden Sie Teil eines exklusiven Netzwerks hochwertiger Anbieter. Präsentieren Sie Ihre Produkte und Services einer vorqualifizierten, kaufkräftigen Zielgruppe mit höchsten Qualitätsansprüchen.', + 'benefits' => [ + [ + 'title' => 'Kuratierter Vertriebskanal', + 'description' => 'Zugang zu einer exklusiven, vorqualifizierten Kundenbasis mit hohem Qualitätsanspruch', + 'icon' => 'store', + ], + [ + 'title' => 'Selbstverwaltung', + 'description' => 'Vollständige Kontrolle über Produktpräsentation, Preisgestaltung und Verfügbarkeit', + 'icon' => 'settings', + ], + [ + 'title' => 'Zentrale Qualitätssicherung', + 'description' => 'Standardisierte Prozesse und Qualitätskontrollen für maximales Kundenvertrauen', + 'icon' => 'check-circle', + ], + [ + 'title' => 'Analytics & Insights', + 'description' => 'Detaillierte Verkaufsanalysen und Markteinblicke für optimierte Geschäftsentscheidungen', + 'icon' => 'bar-chart', + ], + ], + 'dashboard' => [ + 'title' => 'Anbieter-Dashboard', + 'stats' => [ + [ + 'label' => 'Produktsichtbarkeit', + 'value' => '94%', + ], + [ + 'label' => 'Qualitätsbewertung', + 'value' => '98%', + ], + ], + ], + ], + 'leadership_team' => [ + 'title' => 'Das Führungsteam', + 'subtitle' => 'Unser erfahrenes Team bringt jahrzehntelange Expertise in den Bereichen Technologie, Operations und Geschäftsentwicklung mit.', + 'team_tag' => 'B2IN TEAM', + 'team' => [ + [ + 'name' => 'Marcel Scheibe', + 'position' => 'Gründer & CEO', + 'expertise' => 'Visionär für die digitale Zukunft des lokalen Handels und strategischer Brückenbauer zwischen den USA und Europa.', + 'image' => 'b2in/marcel-scheibe.jpg', + ], + [ + 'name' => 'Sarah Müller', + 'position' => 'Head of Operations', + 'expertise' => 'Expertin für die Optimierung unserer europaweiten Logistikprozesse und die operative Exzellenz unserer regionalen Hubs.', + 'image' => 'b2in/sarah-mueller.jpg', + ], + [ + 'name' => 'Thomas Weber', + 'position' => 'Head of Technology', + 'expertise' => 'Technologieführer mit Fokus auf eine intuitive, skalierbare Plattform-Architektur, die Händler und Hersteller nahtlos verbindet.', + 'image' => 'b2in/thomas-weber.jpg', + ], + ], + ], + // Weitere Komponenten für b2in + ], + 'b2a' => [ + 'header' => [ + 'portal_login' => 'Partner Login', + 'navigation' => [ + ['label' => 'Home', 'url' => '/'], + ['label' => 'Our Process', 'url' => '/service'], + ['label' => 'Our Brands', 'url' => '/portfolio'], + ['label' => 'FAQ', 'url' => '/faq'], + ['label' => 'Contact', 'url' => '/contact'], + ], + ], + 'hero_slider' => [ + 'title' => 'Connecting European Design &
American Markets
', + 'subtitle' => 'Your exclusive B2B partner for curated collections, transparent processes, and seamless transatlantic logistics.', + + 'cta1_text' => 'Request to Portal Access', + 'cta1_link' => '/b2a/contact', + 'cta2_text' => 'Become a Partner', + 'cta2_link' => '/b2a/contact', + 'stats' => [ + 'Curated Collections', + 'Reliable Logistics', + 'Exclusive Access', + ], + 'slides' => [ + [ + 'image' => 'b2a/hero-slider-1.jpg', + 'image_alt' => 'Modern furniture showroom with European design pieces', + ], + [ + 'image' => 'b2a/hero-slider-2.jpg', + 'image_alt' => 'Efficient and clean logistics warehouse with neatly packed furniture', + ], + ], + ], + 'vision_section' => [ + 'title' => 'The B2A Difference.', + 'paragraphs' => [ + 'International sourcing is complex. We make it simple.', + 'B2A acts as your strategic partner and decision-making coach, removing the complexities of transatlantic trade so you can focus on what you do best: selling exceptional furniture.', + ], + 'image' => 'b2a/vision.jpg', + 'image_alt' => 'Professionals shaking hands over architectural blueprints', + 'image_caption' => 'Your Bridge to European Design', + ], + 'ecosystem_core' => [ + 'title' => 'A Partnership That Delivers.', + 'subtitle' => 'Our entire process is designed to provide you with a competitive advantage through efficiency, exclusivity, and unwavering support.', + 'pillars' => [ + [ + 'icon' => 'sparkles', + 'title' => 'Curated Collections', + 'description' => 'Gain access to an exclusive portfolio of high-margin European furniture, selected for the US market.', + ], + [ + 'icon' => 'truck', + 'title' => 'Seamless Logistics', + 'description' => 'We handle the entire process from order to your door, including customs and consolidation.', + ], + [ + 'icon' => 'chat-bubble-left-right', + 'title' => 'Dedicated Support', + 'description' => 'Our experts are your single point of contact, ensuring smooth communication and reliable service.', + ], + ], + ], + 'about_philosophie' => [ + 'title' => 'Our Expertise is Your Advantage.', + 'paragraphs' => [ + 'B2A was founded to bridge the gap between Europe\'s finest design manufacturers and the dynamic US market.', + 'Our team consists of seasoned experts in international logistics, interior design curation, and B2B market strategy.', + 'We don\'t just move boxes; we build lasting, profitable partnerships based on trust and a deep understanding of the design industry.', + ], + 'image' => 'b2a/portrait.jpg', + 'image_alt' => 'The professional B2A team in a modern office meeting', + 'image_caption' => 'The B2A Leadership Team', + ], + 'content_section_left' => [ + 'title' => 'Curation & Selection', + 'paragraphs' => [ + 'It all starts with our online catalog. As an approved partner, you will have access to our constantly updated portfolio of curated European furniture brands on our portal.', + 'We provide you with all the necessary product information, prices, and marketing materials so that you can make informed purchasing decisions for your customers.', + ], + 'image' => 'b2a/content-left.jpg', + 'image_alt' => 'A designer reviewing a catalog of high-end furniture', + 'image_caption' => 'Access to Excellence', + ], + 'content_section_right' => [ + 'title' => 'Consolidation & Shipping', + 'paragraphs' => [ + 'Once you place an order, our logistics team takes over. We consolidate products from various European suppliers at our central hub.', + 'This approach significantly reduces shipping costs and complexity for you. We manage all export documentation and ensure your furniture order is efficiently prepared for transatlantic shipping.', + ], + 'image' => 'b2a/content-right.jpg', + 'image_alt' => 'Neatly organized warehouse showing furniture ready for shipping', + 'image_caption' => 'Efficiency in Motion', + ], + 'brand_worlds' => [ + 'title' => 'Explore Our Curated Categories', + 'subtitle' => 'From iconic seating to artisanal lighting, discover products that will differentiate your offerings and delight your customers.', + 'worlds' => [ + [ + 'image' => 'b2a/world-1.jpg', + 'title' => 'Contemporary Seating', + 'description' => 'Sofas, armchairs, and lounge chairs from leading European designers.', + 'link' => '/b2a/portfolio', + ], + [ + 'image' => 'b2a/world-2.jpg', + 'title' => 'Minimalist Lighting', + 'description' => 'Architectural and decorative lighting that makes a statement.', + 'link' => '/b2a/portfolio', + ], + [ + 'image' => 'b2a/world-3.jpg', + 'title' => 'Artisan Tables & Storage', + 'description' => 'Dining tables, coffee tables, and storage solutions crafted with exceptional materials.', + 'link' => '/b2a/portfolio', + ], + ], + ], + 'cta_section' => [ + 'title' => 'Elevate Your Portfolio with
European Design.', + 'subtitle' => 'Apply to become a B2A partner today and gain exclusive access to a world of exceptional furniture.', + 'button_text' => 'Become a Partner', + 'button_link' => '/b2a/contact', + ], + 'hero_image' => [ + 'title' => 'Our Seamless
Transatlantic Process', + 'subtitle' => 'We\'ve engineered our supply chain to be as simple and reliable as possible, giving you peace of mind.', + 'hero_image' => 'b2a/hero-image.jpg', + 'hero_image_alt' => '3D render of a global supply chain', + 'stats' => [ + 'Sourcing & Curation', + 'Consolidation & Shipping', + 'Customs & Delivery', + ], + ], + 'portfolio' => [ + 'filters' => [ + 'alle' => 'Alle', + 'seating' => 'Seating', + 'lighting' => 'Lighting', + 'tables' => 'Tables', + ], + 'title' => 'Product Highlights', + 'subtitle' => 'A glimpse into the curated collections you can offer your clients.', + 'projects' => [ + [ + 'id' => 1, + 'title' => 'The "Aura" Lounge Chair', + 'subtitle' => 'Brand: Massimo Design, Italy', + 'description' => 'An iconic armchair combining minimalist aesthetics with exceptional comfort. Upholstered in premium bouclé fabric with a solid oak frame.', + 'category' => 'Seating', + 'image' => '/b2a/product-1.jpg', + 'location' => '', + 'price' => '', + 'size' => '', + 'features' => [ + 'Italian Bouclé Fabric', + 'Solid Oak Construction', + 'Hand-finished Details', + 'High-Density Foam', + 'Multiple Color Options', + ], + ], + [ + 'id' => 2, + 'title' => 'The "Stellan" Pendant Light', + 'subtitle' => 'Brand: Nordic Lightworks, Denmark', + 'description' => 'A Scandinavian-inspired pendant light crafted from hand-blown glass and brushed brass. Provides warm, ambient illumination.', + 'category' => 'Lighting', + 'image' => '/b2a/product-2.jpg', + 'location' => '', + 'price' => '', + 'size' => '', + 'features' => [ + 'Hand-Blown Glass', + 'Brushed Brass Finish', + 'Adjustable Height', + 'LED Compatible', + 'Minimalist Design', + ], + ], + [ + 'id' => 3, + 'title' => 'The "Tectonic" Dining Table', + 'subtitle' => 'Brand: Bauhaus Form, Germany', + 'description' => 'A statement dining table featuring a solid marble top and a geometrically sculpted steel base. Seats up to eight people.', + 'category' => 'Tables', + 'image' => '/b2a/product-3.jpg', + 'location' => '', + 'price' => '', + 'size' => '', + 'features' => [ + 'Italian Carrara Marble', + 'Powder-Coated Steel Base', + 'Seats 8 Persons', + 'Durable & Stain-Resistant', + 'Architectural Presence', + ], + ], + ], + ], + 'cta_section_portfolio' => [ + 'title' => 'Ready to Differentiate
Your Offerings?', + 'subtitle' => 'Request our full digital catalog to explore all available collections and brands.', + 'button_text' => 'Request to Portal Access', + 'button_link' => '/b2a/contact', + ], + 'commitment_section' => [ + 'title' => 'Excellence, Confirmed by Our Partners', + 'subtitle' => 'Reliability and success speak for themselves. Here are some voices from our satisfied US partners.', + 'testimonials' => [ + [ + 'image' => 'b2a/testimonial-1.jpg', + 'rating' => 5, + 'quote' => 'B2A has transformed our sourcing. Their logistics are seamless, and the product quality gives us a real edge in the market.', + 'author' => 'John D., Boston Dealer', + 'author_title' => 'Owner, Urban Living Co.', + ], + [ + 'image' => 'b2a/testimonial-2.jpg', + 'rating' => 5, + 'quote' => 'Access to this level of curated European design was a game-changer for my interior design projects. B2A is an invaluable partner.', + 'author' => 'Maria R., Miami Designer', + 'author_title' => 'Founder, Roche Designs', + ], + [ + 'image' => 'b2a/testimonial-3.jpg', + 'rating' => 5, + 'quote' => 'Their professionalism and clear communication make international trade feel effortless. Highly recommended.', + 'author' => 'David L., Chicago Architect', + 'author_title' => 'Principal, Lakefront Architects', + ], + ], + ], + 'ecosystem_stats' => [ + 'title' => 'The B2A Advantage in Numbers', + 'subtitle' => 'We deliver tangible results that impact your bottom line and streamline your operations.', + 'stats' => [ + [ + 'number' => '20+', + 'label' => 'Curated European Brands', + 'description' => 'One single point of contact gives you access to a wide range of exclusive manufacturers.', + ], + [ + 'number' => '95%', + 'label' => 'On-Time Delivery Rate', + 'description' => 'Our fine-tuned logistics process ensures reliability you can build your business on.', + ], + [ + 'number' => '30%', + 'label' => 'Faster Sourcing Time', + 'description' => 'Reduce your administrative overhead and get products to your showroom faster than the competition.', + ], + [ + 'number' => '01', + 'label' => 'Single Point of Contact', + 'description' => 'One dedicated partner for all your needs, from ordering and logistics to support.', + ], + ], + ], + 'faq' => [ + 'title' => 'Frequently Asked Questions', + 'subtitle' => 'Here you will find answers to common questions about partnering with B2A.', + 'questions' => [ + [ + 'question' => 'Who are your European partner brands?', + 'answer' => 'We partner with a curated selection of over 20 high-quality manufacturers from Italy, Germany, Scandinavia, and other European design hubs. Our full brand list is available to approved partners upon request.', + ], + [ + 'question' => 'What are the minimum order quantities (MOQs)?', + 'answer' => 'MOQs vary by brand and product. However, our consolidation model allows you to combine smaller orders from different brands into one cost-effective shipment, making European design more accessible.', + ], + [ + 'question' => 'How does B2A handle shipping and customs?', + 'answer' => 'We handle everything. Our service includes export documentation, transatlantic shipping, US customs clearance, and last-mile delivery to your warehouse or showroom. It\'s a true door-to-door service.', + ], + [ + 'question' => 'What are the payment terms for partners?', + 'answer' => 'Our standard payment terms are outlined in our partnership agreement. We typically require a deposit upon order confirmation with the balance due before final delivery. We strive to offer competitive and fair terms.', + ], + [ + 'question' => 'How do I become a B2A partner?', + 'answer' => 'The first step is to fill out our contact form with your business details. Our partnership team will review your application and get in touch to discuss the next steps, including our partnership agreement and catalog access.', + ], + ], + ], + 'contact_form' => [ + 'hero' => [ + 'title' => 'Become a Partner', + 'subtitle' => 'Fill out the form below to start the conversation. We look forward to learning more about your business.', + ], + 'form' => [ + 'labels' => [ + 'first_name' => 'First Name *', + 'last_name' => 'Last Name *', + 'company' => 'Company Name *', + 'email' => 'Business Email *', + 'phone' => 'Phone Number', + 'subject' => 'Subject *', + 'message' => 'Tell us about your business *', + ], + 'subjects' => [ + '' => 'Select a Subject', + 'partnership' => 'Partnership Inquiry', + 'catalog' => 'Product Catalog Request', + 'logistics' => 'Logistics Question', + ], + 'placeholders' => [ + 'message' => 'e.g., your type of business, main customer base, brands of interest...', + ], + 'button_text' => 'Submit Inquiry', + 'button_loading' => 'Submitting...', + 'success_message' => 'Thank you for your inquiry! Our partnership team will be in touch with you shortly.', + ], + 'contact_info' => [ + [ + 'title' => 'Our US Office', + 'info' => [ + '123 Design Drive', + 'New York, NY 10001, USA', + ], + 'icon' => 'map-pin', + ], + [ + 'title' => 'Partner Inquiries', + 'info' => [ + 'partners@bridges2america.com', + ], + 'icon' => 'mail', + ], + [ + 'title' => 'Contact Number', + 'info' => [ + '+1 (212) 123-4567', + ], + 'icon' => 'phone', + ], + ], + 'social_media' => [ + 'title' => 'Follow us for
Industry Insights', + 'subtitle' => 'Stay updated with European design trends and B2B news.', + 'platforms' => [ + ['name' => 'LinkedIn', 'handle' => 'B2in Company Page', 'url' => 'https://linkedin.com/company/b2in'], + ], + ], + ], + ], + 'stileigentum' => [ + 'header' => [ + 'portal_login' => 'Portal Login', + 'navigation' => [ + ['label' => 'Home', 'url' => '/'], + ['label' => 'Service', 'url' => '/service'], + ['label' => 'Portfolio', 'url' => '/portfolio'], + ['label' => 'FAQ', 'url' => '/faq'], + ['label' => 'Contact', 'url' => '/contact'], + ], + ], + 'hero_slider' => [ + 'title' => 'Connecting Design &
Timeless Luxury
', + 'subtitle' => 'Wir begleiten Sie mit maßgeschneiderten Lösungen und absoluter Diskretion zu Ihrem perfekten Zuhause.', + + 'cta1_text' => 'Unsere Services entdecken', + 'cta1_link' => '/services', + 'cta2_text' => 'Beratung', + 'cta2_link' => '/beratung', + 'stats' => [ + 'Exklusive Auswahl', + 'Persönlicher Service', + 'Werte die bleiben', + ], + 'slides' => [ + [ + 'image' => 'stileigentum/hero-slider-1.jpg', + 'image_alt' => 'Luxuriöses Anwesen mit eleganter Einrichtung', + ], + [ + 'image' => 'stileigentum/hero-slider-2.jpg', + 'image_alt' => 'Moderne Architektur mit stilvollem Design', + ], + ], + 'card_title' => 'B2in Ecosystem', + 'card_text' => 'Global vernetzt', + ], + 'vision_section' => [ + 'title' => 'Eine Investition in Eleganz und Wert', + 'paragraphs' => [ + 'Bei stileigentum geht es um mehr als nur Einrichtung', + 'Es geht darum, das Wesen einer Premium-Immobilie zu erfassen und durch zeitloses Design und höchste Handwerkskunst ihren Wert zu steigern.', + 'Wir schaffen repräsentative Ambiente, die nicht nur heute beeindrucken, sondern auch morgen Bestand haben.', + ], + 'image' => 'stileigentum/vision.jpg', + 'image_alt' => 'Luxuriöses Anwesen mit Pool', + 'image_caption' => 'Stileigentum – Timeless Luxury', + ], + 'ecosystem_core' => [ + 'title' => 'Unsere Service-Exzellenz', + 'subtitle' => 'Vom perfekten Staging für die Vermarktung bis hin zum vollendeten, maßgeschneiderten Interieur – unsere Leistungen sind so exklusiv wie Ihre Immobilie.', + 'pillars' => [ + [ + 'icon' => 'sparkles', + 'title' => 'Premium Staging', + 'description' => 'Perfekte Inszenierung für anspruchsvolle Käufer.', + ], + [ + 'icon' => 'users', + 'title' => 'Bespoke Interiors', + 'description' => 'Einzigartige Konzepte, die Ihre Persönlichkeit widerspiegeln.', + ], + [ + 'icon' => 'paint-brush', + 'title' => 'Material & Art Curation', + 'description' => 'Auswahl feinster Materialien und Kunstobjekte.', + ], + ], + ], + 'about_philosophie' => [ + 'title' => 'Unsere Hilosophie', + 'paragraphs' => [ + 'Wahrer Luxus liegt nicht im Besitz, sondern im perfekten Einklang von Raum, Persönlichkeit und Qualität.', + 'Unsere Mission ist es, diesen Einklang zu schaffen – mit Diskretion, Leidenschaft und einem kompromisslosen Auge fürs Detail.', + ], + 'image' => 'stileigentum/portrait.jpg', + 'image_alt' => 'Moderne Arbeitsplätze mit innovativer Technologie', + 'image_caption' => 'Marcel Scheibe, Gründer & CEO', + ], + 'content_section_left' => [ + 'title' => 'Die Kunst der perfekten Inszenierung', + 'paragraphs' => [ + 'Wir steigern die Attraktivität und den Marktwert Ihrer Immobilie, indem wir ein Ambiente schaffen, das potenzielle Käufer emotional anspricht. Unser Staging verwandelt Räume in begehrenswerte Wohnwelten und beschleunigt die Vermarktung signifikant.', + 'Ideal für Bauträger, Makler und Eigentümer im Luxussegment.', + ], + 'image' => 'stileigentum/content-left.jpg', + 'image_alt' => 'Moderne Arbeitsplätze mit innovativer Technologie', + 'image_caption' => 'Ambiente schafft Werte', + ], + 'content_section_right' => [ + 'title' => 'Ihr Zuhause, ein Unikat', + 'paragraphs' => [ + 'Wir entwickeln ganzheitliche Einrichtungskonzepte, die Ihre Persönlichkeit und Ihren Lebensstil widerspiegeln. Von der ersten Skizze bis zum letzten Detail begleiten wir Sie auf dem Weg zu einem Zuhause, das in Ästhetik, Funktion und Qualität keine Kompromisse eingeht.', + 'Unser Prozess umfasst: Bedarfsanalyse, Konzeptentwicklung, Materialauswahl, Koordination der Gewerke und finale Umsetzung.', + ], + 'image' => 'stileigentum/content-right.jpg', + 'image_alt' => 'Kreative Arbeitsumgebung mit modernem Design', + 'image_caption' => 'Kreativität trifft Funktionalität', + ], + 'brand_worlds' => [ + 'title' => 'Ein Einblick in unsere Arbeit', + 'subtitle' => 'Jedes Projekt ist ein Zeugnis unseres Anspruchs an Perfektion und Ästhetik. Sehen Sie selbst.', + 'worlds' => [ + // Markenspezifische Welten für stileigentum + [ + 'image' => 'stileigentum/world-1.jpg', + 'title' => 'Stileigentum', + 'description' => 'Exklusive Einrichtung und anspruchsvolles Design.', + 'link' => '/stileigentum/portfolio', + ], + [ + 'image' => 'stileigentum/world-2.jpg', + 'title' => 'Stileigentum', + 'description' => 'Exklusive Einrichtung und anspruchsvolles Design.', + 'link' => '/stileigentum/portfolio', + ], + [ + 'image' => 'stileigentum/world-3.jpg', + 'title' => 'Stileigentum', + 'description' => 'Exklusive Einrichtung und anspruchsvolles Design.', + 'link' => '/stileigentum/portfolio', + ], + ], + ], + 'cta_section' => [ + 'title' => 'Beginnen Sie Ihre Reise zu einem
perfekten Zuhause.', + 'subtitle' => 'Lassen Sie sich von unserem exklusiven Portfolio inspirieren und vereinbaren Sie eine vertrauliche und unverbindliche Erstberatung.', + 'button_text' => 'Beratungstermin vereinbaren', + 'button_link' => '/contact', + ], + 'hero_image' => [ + 'title' => 'Maßgeschneiderte Exzellenz', + 'subtitle' => 'Unser Verständnis von Service bedeutet, Ihre Vision mit Perfektion und Diskretion zu realisieren.', + 'hero_image' => 'stileigentum/hero-image.jpg', + 'hero_image_alt' => 'Luxuriöse Inneneinrichtung von Stileigentum', + + 'stats' => [ + 'Exklusive Auswahl', + 'Persönlicher Service', + 'Werte die bleiben', + ], + ], + + 'portfolio' => [ + 'title' => 'Portfolio stileigentum', + 'subtitle' => 'Eine kuratierte Auswahl unserer jüngsten Projekte.', + 'projects' => [ + [ + 'id' => 1, + 'title' => 'Neubau Schwabing Staging', + 'subtitle' => 'Contemporary Luxury Design', + 'description' => 'Exklusives Möblierungskonzept für Neubau-Villa in München-Schwabing. Moderne Einrichtung mit warmen Naturtönen und hochwertigen Designermöbeln steigert den Immobilienwert um 15%.', + 'category' => 'Villen', + 'location' => 'München-Schwabing', + 'price' => '18.500 € Möblierungskonzept', + 'size' => '280 m² Wohnfläche', + 'image' => '/stileigentum/accommodation-1.jpg', + 'features' => [ + 'Italienische Designermöbel', + 'Maßanfertigungen', + 'Premium Textilien', + 'Kunstobjekte', + 'Smart Home Integration', + ], + ], + [ + 'id' => 2, + 'title' => 'Maxvorstadt Interior Design', + 'subtitle' => 'Urban Living Excellence', + 'description' => 'Zeitloses Raumkonzept für Stadtvilla in der Maxvorstadt. Skandinavisch inspiriertes Design mit nachhaltigen Materialien und funktionaler Eleganz.', + 'category' => 'Villen', + 'location' => 'München-Maxvorstadt', + 'price' => '12.000 € Komplettausstattung', + 'size' => '220 m² Wohnfläche', + 'image' => '/stileigentum/accommodation-2.jpg', + 'features' => [ + 'Nachhaltige Materialien', + 'Maßgefertigte Einbauschränke', + 'Designer-Beleuchtung', + 'Hochwertige Böden', + 'Farbkonzept', + ], + ], + [ + 'id' => 3, + 'title' => 'Penthouse Premium Staging', + 'subtitle' => 'Sky-high Luxury', + 'description' => 'Atemberaubendes Staging-Konzept für Luxus-Penthouse. 360°-Design mit italienischen Luxusmöbeln und exklusiven Kunstobjekten für maximale Verkaufsattraktivität.', + 'category' => 'Penthouse', + 'location' => 'München-Lehel', + 'price' => '32.000 € Premium Staging', + 'size' => '220 m² + 150 m² Terrasse', + 'image' => '/stileigentum/accommodation-3.jpg', + 'features' => [ + 'Italienische Luxusmöbel', + 'Outdoor-Möblierung', + 'Exklusive Kunstwerke', + 'Premium Accessoires', + 'Maßgeschneiderte Lösungen', + ], + ], + [ + 'id' => 4, + 'title' => 'Industrial Loft Konzept', + 'subtitle' => 'Industrial Chic', + 'description' => 'Charakterstarkes Möblierungskonzept für historisches Loft. Industrial Design mit modernen Akzenten, hochwertigen Vintage-Elementen und künstlerischen Details.', + 'category' => 'Loft', + 'location' => 'München-Glockenbach', + 'price' => '95.000 € Industrial Staging', + 'size' => '160 m² Loft-Fläche', + 'image' => '/stileigentum/accommodation-3.jpg', + 'features' => [ + 'Vintage Industrial Möbel', + 'Maßgefertigte Stahlträger', + 'Künstlerische Beleuchtung', + 'Authentische Materialien', + 'Stilechte Accessoires', + ], + ], + [ + 'id' => 5, + 'title' => 'Bogenhausen Luxus Interior', + 'subtitle' => 'Prestige & Elegance', + 'description' => 'Maßgeschneidertes Luxus-Interieur für prestigeträchtiges Penthouse. Klassische Eleganz trifft auf moderne Funktionalität mit exklusiven Materialien und Handwerkskunst.', + 'category' => 'Penthouse', + 'location' => 'München-Bogenhausen', + 'price' => 'ab 45.000 € Bespoke Design', + 'size' => '350 m² + 200 m² Terrasse', + 'image' => '/stileigentum/accommodation-1.jpg', + 'features' => [ + 'Maßgefertigte Möbel', + 'Edle Naturmaterialien', + 'Antike Kunstobjekte', + 'Wellness-Bereich Design', + 'Terrassenmöblierung', + ], + ], + [ + 'id' => 6, + 'title' => 'Seevilla Interior Konzept', + 'subtitle' => 'Lakeside Living', + 'description' => 'Exklusives Möblierungskonzept für Seevilla am Starnberger See. Naturnahe Materialien, maritime Akzente und großzügige Raumgestaltung mit Blick auf den See.', + 'category' => 'Villen', + 'location' => 'Starnberger See', + 'price' => '28.000 € Seevilla-Design', + 'size' => '400 m² Villa + Garten', + 'image' => '/stileigentum/accommodation-2.jpg', + 'features' => [ + 'Maritime Designelemente', + 'Naturbelassene Materialien', + 'Outdoor-Living-Konzept', + 'Panorama-Raumgestaltung', + 'Wasserfeste Materialien', + ], + ], + [ + 'id' => 7, + 'title' => 'Werksviertel Loft Staging', + 'subtitle' => 'Modern Heritage', + 'description' => 'Innovatives Staging für urbanes Loft im Werksviertel. Moderne Einrichtung mit industriellen Elementen und smarten Wohnlösungen für junge Zielgruppe.', + 'category' => 'Loft', + 'location' => 'München-Werksviertel', + 'price' => '75.000 € Urban Staging', + 'size' => '140 m² Loft-Fläche', + 'image' => '/stileigentum/accommodation-1.jpg', + 'features' => [ + 'Flexible Raumteiler', + 'Multifunktionale Möbel', + 'Tech-Integration', + 'Modulare Systeme', + 'Urbane Materialien', + ], + ], + [ + 'id' => 8, + 'title' => 'Maximilianstraße Penthouse', + 'subtitle' => 'Ultimate Luxury', + 'description' => 'Das ultimative Interior Design für Luxus-Penthouse in Münchens nobelster Adresse. Zwei-Etagen Konzept mit privatem Spa, Weinkeller und erlesenen Antiquitäten.', + 'category' => 'Penthouse', + 'location' => 'München-Altstadt', + 'price' => 'ab 10.000 € Luxury Interior', + 'size' => '450 m² über 2 Etagen', + 'image' => '/stileigentum/accommodation-3.jpg', + 'features' => [ + 'Antike Möbelstücke', + 'Spa-Design Konzept', + 'Weinkeller-Ausstattung', + 'Handgefertigte Unikate', + 'Luxus-Textilien', + ], + ], + [ + 'id' => 9, + 'title' => 'Grünwald Architektenvilla Design', + 'subtitle' => 'Architectural Excellence', + 'description' => 'Nachhaltiges Interior Design für preisgekrönte Architektenvilla. Ökologische Materialien, energieeffiziente Beleuchtung und biophile Gestaltungselemente.', + 'category' => 'Villen', + 'location' => 'Grünwald', + 'price' => '19.000 € Eco-Luxury Design', + 'size' => '320 m² + 1.200 m² Garten', + 'image' => '/stileigentum/accommodation-2.jpg', + 'features' => [ + 'Nachhaltige Materialien', + 'Biophile Gestaltung', + 'LED-Lichttechnik', + 'Recycelte Designstücke', + 'Indoor-Garten Konzept', + ], + ], + ], + ], + 'cta_section_portfolio' => [ + 'title' => 'Überzeugt von unserer
Arbeit?', + 'subtitle' => 'Lassen Sie uns darüber sprechen, wie wir auch Ihre Vision Wirklichkeit werden lassen können.', + 'button_text' => 'Beratungstermin vereinbaren', + 'button_link' => '/contact', + ], + 'about_hero' => [ + 'title' => 'Über B2in', + 'quote' => '"Unsere Vision ist es, Unternehmen durch innovative Konnektivitätslösungen zu verbinden und nachhaltiges Wachstum in der digitalen Welt zu ermöglichen. Bei B2in schaffen wir nicht nur Verbindungen – wir bauen Brücken in die Zukunft."', + 'founder_name' => 'Marcel Scheibe', + 'founder_title' => 'Gründer & CEO, B2in', + 'image' => 'b2in/marcel-scheibe.jpg', + 'image_alt' => 'Marcel Scheibe, Gründer und CEO von B2in', + 'year' => '2024', + 'year_text' => 'Gründungsjahr', + ], + 'broker_section' => [ + 'title' => 'Diskrete Vermarktung
auf höchstem Niveau', + 'subtitle' => 'Als Partner von Stileigentum erhalten Sie Zugang zu einem exklusiven Kundenkreis und erstklassigen Immobilien.', + 'card_title' => 'Exklusivität', + 'compensation' => [ + 'initial_sale' => 'Vertraulich', + 'follow_up' => 'Langfristige Partnerschaften', + ], + 'compensation_text' => 'Attraktive Konditionen für exklusive Partner', + 'benefits' => [ + [ + 'title' => 'Exklusiver Kundenkreis', + 'description' => 'Zugang zu einem internationalen Netzwerk von vermögenden Privatkunden.', + 'icon' => 'trending-up', + ], + [ + 'title' => 'Erstklassige Immobilien', + 'description' => 'Vermarktung von einzigartigen Objekten, die nicht öffentlich zugänglich sind.', + 'icon' => 'clock', + ], + [ + 'title' => 'Höchste Diskretion', + 'description' => 'Wir garantieren einen vertraulichen und professionellen Vermarktungsprozess.', + 'icon' => 'target', + ], + [ + 'title' => 'Professionelle Unterstützung', + 'description' => 'Profitieren Sie von unserem Know-how und unseren Ressourcen.', + 'icon' => 'award', + ], + ], + ], + 'commitment_section' => [ + 'title' => 'Exzellenz, von unseren Kunden bestätigt', + 'subtitle' => 'Diskretion und Erfolg sprechen für sich. Hier sind einige Stimmen unserer zufriedenen Klienten.', + 'testimonials' => [ + [ + 'image' => 'stileigentum/testimonial-1.jpg', + 'rating' => 5, + 'quote' => 'Stileigentum hat für uns ein Zuhause gefunden, das unsere kühnsten Träume übertrifft. Ein Service der Extraklasse.', + 'author' => 'Familie Exklusiv', + 'author_title' => 'Zufriedene Kunden', + ], + [ + 'image' => 'stileigentum/testimonial-2.jpg', + 'rating' => 5, + 'quote' => 'Die diskrete und professionelle Abwicklung beim Verkauf unserer Villa war beeindruckend. Absolute Empfehlung.', + 'author' => 'Dr. von Adel', + 'author_title' => 'Unternehmer', + ], + [ + 'image' => 'stileigentum/testimonial-3.jpg', + 'rating' => 5, + 'quote' => 'Ein Makler mit Stil, Netzwerk und dem nötigen Feingefühl für besondere Immobilien und deren Eigentümer.', + 'author' => 'Anspruchsvoller Investor', + 'author_title' => 'Investor', + ], + ], + ], + 'ecosystem_stats' => [ + 'title' => 'Unser Weg zur Perfektion', + 'subtitle' => 'Wir verbinden Endkunden, Makler und Lieferanten in einem intelligenten Netzwerk.', + 'stats' => [ + [ + 'number' => '01', + 'label' => 'Consultation', + 'description' => 'Wir verstehen Ihre Vision.', + ], + [ + 'number' => '02', + 'label' => 'Conception', + 'description' => 'Wir entwerfen Ihr einzigartiges Konzept.', + ], + [ + 'number' => '03', + 'label' => 'Curation', + 'description' => 'Wir wählen die feinsten Materialien und Objekte aus.', + ], + [ + 'number' => '04', + 'label' => 'Realization', + 'description' => 'Wir setzen Ihr Projekt mit höchster Präzision um.', + ], + ], + ], + 'contact_form' => [ + 'hero' => [ + 'title' => 'Ihr Kontakt
zu uns.', + 'subtitle' => 'Wir freuen uns auf Ihre Anfrage und sichern Ihnen absolute Diskretion zu.', + ], + 'form' => [ + 'labels' => [ + 'first_name' => 'First name *', + 'last_name' => 'Last name *', + 'company' => 'Company', + 'email' => 'Email *', + 'phone' => 'Phone', + 'subject' => 'Subject *', + 'message' => 'Message *', + ], + 'subjects' => [ + '' => 'Wählen Sie einen Betreff', + 'general' => 'Allgemeine Anfrage', + 'press' => 'Presse', + 'partnership' => 'Partnerschaft', + 'career' => 'Karriere', + ], + 'placeholders' => [ + 'message' => 'Ihre Nachricht...', + ], + 'button_text' => 'Senden', + 'button_loading' => 'Wird gesendet...', + 'success_message' => 'Vielen Dank für Ihre Nachricht! Wir werden uns schnellstmöglich bei Ihnen melden.', + ], + 'contact_info' => [ + [ + 'title' => 'Our Office Location', + 'info' => [ + 'Musterstraße 123', + '12345 Berlin, Deutschland', + ], + 'icon' => 'map-pin', + ], + [ + 'title' => 'Our Email Address', + 'info' => [ + 'info@stileigentum.com', + ], + 'icon' => 'mail', + ], + [ + 'title' => 'Our Contact Numbers', + 'info' => [ + '+49 30 12345678', + ], + 'icon' => 'phone', + ], + ], + 'social_media' => [ + 'title' => 'Follow for
exclusives', + 'subtitle' => 'Bleiben Sie auf dem Laufenden mit exklusiven Angeboten und News.', + 'platforms' => [ + ['name' => 'Instagram', 'handle' => '@stileigentum_official', 'url' => 'https://instagram.com/stileigentum_official'], + ['name' => 'Youtube', 'handle' => 'stileigentum Channel', 'url' => 'https://youtube.com/@stileigentum'], + ['name' => 'Pinterest', 'handle' => 'stileigentum Inspiration', 'url' => 'https://pinterest.com/stileigentum'], + ['name' => 'Facebook', 'handle' => 'stileigentum Deutschland', 'url' => 'https://facebook.com/stileigentum'], + ['name' => 'LinkedIn', 'handle' => 'stileigentum Company', 'url' => 'https://linkedin.com/company/stileigentum'], + ], + ], + ], + 'faq' => [ + 'title' => 'Häufig gestellte Fragen', + 'subtitle' => 'Hier finden Sie Antworten auf die häufigsten Fragen zu stileigentum.', + 'questions' => [ + [ + 'question' => 'Was genau ist stileigentum und was unterscheidet Sie von klassischer Innenarchitektur?', + 'answer' => 'stileigentum ist mehr als nur Innenarchitektur; wir sind Ihr diskreter Partner für die Wertsteigerung und Veredelung von Premium-Immobilien. Unser Fokus liegt nicht allein auf Ästhetik, sondern auf der Schaffung eines bleibenden Wertes. Wir spezialisieren uns auf zwei Kernbereiche: das strategische "Premium Staging" zur optimalen Vermarktung und die Realisierung von "Bespoke Interiors" für ein vollendetes, persönliches Zuhause.', + ], + [ + 'question' => 'Für wen ist der Service von stileigentum ideal geeignet?', + 'answer' => 'Unser Service richtet sich an anspruchsvolle Eigentümer, Investoren, Bauträger und Luxusmakler, die den Wert ihrer Immobilie entweder für den eigenen, repräsentativen Lebensraum perfektionieren oder für den Verkauf die maximale Attraktivität und den Marktwert steigern möchten. Wir sind der richtige Partner, wenn Kompromisse in Qualität und Ausführung keine Option sind.', + ], + [ + 'question' => 'Wie gestaltet sich der Prozess einer Zusammenarbeit?', + 'answer' => 'Jedes Projekt ist ein Unikat und beginnt mit einer vertraulichen Erstberatung, in der wir Ihre Vision und Ziele verstehen. Darauf folgt eine detaillierte Konzeption, die sorgfältige Kuration von Materialien und Objekten sowie die präzise Umsetzung durch unser erlesenes Netzwerk an Handwerkern und Lieferanten. Während des gesamten Prozesses steht Ihnen ein persönlicher Ansprechpartner zur Seite.', + ], + [ + 'question' => 'Wie gewährleisten Sie Diskretion und Vertrauen?', + 'answer' => 'Diskretion ist der Grundpfeiler unserer Arbeit. Wir garantieren absolute Vertraulichkeit in allen Phasen des Projekts und schützen die Privatsphäre unserer Klienten kompromisslos. Unser Ruf basiert auf langjährigem Vertrauen und der sorgfältigen Handhabung sensibler Informationen und exklusiver Immobilien.', + ], + [ + 'question' => 'Handelt es sich bei Ihren Dienstleistungen um eine Ausgabe oder eine Investition?', + 'answer' => 'Wir betrachten unsere Arbeit klar als eine Investition in Ihre Immobilie. Ein professionelles Staging oder ein maßgeschneidertes Interieur-Konzept steigert nachweislich den Wert und die Begehrlichkeit Ihrer Immobilie, beschleunigt die Vermarktung und schafft bleibende Werte, die über kurzfristige Trends hinausgehen. Es ist eine Investition in Qualität, Ästhetik und letztlich auch in die Rendite.', + ], + ], + ], + // Weitere Komponenten für stileigentum + ], + 'style2own' => [ + 'header' => [ + 'portal_login' => 'Portal Login', + 'navigation' => [ + ['label' => 'Home', 'url' => '/'], + ['label' => 'Service', 'url' => '/service'], + ['label' => 'Inspiration', 'url' => '/portfolio'], + ['label' => 'FAQ', 'url' => '/faq'], + ['label' => 'Contact', 'url' => '/contact'], + ], + ], + 'hero_slider' => [ + 'title' => 'Connecting Design &
Your Home
', + 'subtitle' => 'Entdecke kreative und flexible Wohnkonzepte, die perfekt zu dir und deinem Leben passen.', + + 'cta1_text' => 'Unsere Services entdecken', + 'cta1_link' => '/services', + 'cta2_text' => 'Beratung', + 'cta2_link' => '/beratung', + 'stats' => [ + 'Exklusive Auswahl', + 'Persönlicher Service', + 'Werte die bleiben', + ], + 'slides' => [ + [ + 'image' => 'style2own/hero-slider-1.jpg', + 'image_alt' => 'Modernes Wohnzimmer mit stylischer Einrichtung', + ], + [ + 'image' => 'style2own/hero-slider-2.jpg', + 'image_alt' => 'Junges Paar in einem modernen Wohnzimmer', + ], + ], + ], + 'vision_section' => [ + 'title' => 'Einrichtung? Machen wir einfach.', + 'paragraphs' => [ + 'Vergiss komplizierte Entscheidungen und endlose Möbelhaus-Besuche.', + 'Wir sind dein "Entscheidungs-Coach" und bieten dir kuratierte Stilwelten, flexible Mietoptionen und alles, was du für ein Zuhause brauchst, das sich wirklich wie deins anfühlt.', + ], + 'image' => 'style2own/vision.jpg', + 'image_alt' => 'Kreative Wohnkonzepte', + 'image_caption' => 'Style2own – Your Home', + ], + 'ecosystem_core' => [ + 'title' => 'Dein Weg zum Wohntraum.', + 'subtitle' => 'Wir haben einen Prozess entwickelt, der dir die ganze Komplexität abnimmt. Entdecke, wie wir dich in nur drei einfachen Schritten zu deinem perfekt eingerichteten Zuhause begleiten.', + 'pillars' => [ + [ + 'icon' => 'heart', + 'title' => 'Kreativ & Persönlich', + 'description' => 'Finde Styles, die zu dir passen und deine Persönlichkeit widerspiegeln.', + ], + [ + 'icon' => 'scale', + 'title' => 'Flexibel & Fair', + 'description' => 'Erst mieten, später kaufen. So flexibel wie dein Leben.', + ], + [ + 'icon' => 'zap', + 'title' => 'Schnell & Smart', + 'description' => 'In wenigen Klicks zum fertigen Konzept. Einfacher gehts nicht.', + ], + ], + ], + + 'about_philosophie' => [ + 'title' => 'Wer wir sind? Design-Fans, genau wie du!', + 'paragraphs' => [ + 'style2own wurde aus einer einfachen Idee geboren: Jeder verdient ein Zuhause, das inspiriert und glücklich macht.', + 'Wir sind ein Team aus Interior-Designern, Logistik-Profis und Technik-Begeisterten, die es sich zur Aufgabe gemacht haben, den Einrichtungsprozess einfach, spaßig und für jeden zugänglich zu machen.', + 'Wir glauben nicht an komplizierte Regeln, sondern an kreative Lösungen, die zu deinem Leben passen.', + ], + 'image' => 'style2own/portrait.jpg', + 'image_alt' => 'Moderne Arbeitsplätze mit innovativer Technologie', + 'image_caption' => 'Das Team von Style2own', + ], + 'content_section_left' => [ + 'title' => 'Erhalte deinen exklusiven Zugang', + 'paragraphs' => [ + 'Alles beginnt mit deiner persönlichen Login-Karte. Diese erhältst du exklusiv und kostenlos von deinem Partner-Makler nach Abschluss deines Miet- oder Kaufvertrags.', + 'Sie ist dein goldenes Ticket in eine Welt voller Design-Inspiration, die nur darauf wartet, von dir entdeckt zu werden.', + ], + 'image' => 'style2own/content-left.jpg', + 'image_alt' => 'exklusiver Zugang mit persönlicher Login-Karte', + 'image_caption' => 'exklusiver Zugang', + ], + 'content_section_right' => [ + 'title' => 'Entdecke deine Style-Welten', + 'paragraphs' => [ + 'Logge dich in unser Portal ein und lass dich inspirieren! Wir präsentieren dir komplette Raumkonzepte und personalisierte Möbel-Dashboards, die auf dein neues Zuhause zugeschnitten sind.', + 'Speichere deine Favoriten, spiele mit verschiedenen Looks und finde ganz einfach heraus, was dir wirklich gefällt. Dein Stil, deine Entscheidung – wir machen es dir leicht.', + ], + 'image' => 'style2own/content-right.jpg', + 'image_alt' => 'Kreative Arbeitsumgebung mit modernem Design', + 'image_caption' => 'Kreativität trifft Funktionalität', + ], + 'brand_worlds' => [ + 'title' => 'Lass dich inspirieren', + 'subtitle' => 'Stöbere durch unsere Stilwelten, entdecke Vorher-Nachher-Transformationen und finde den Look, der dein Herz höherschlagen lässt.', + 'worlds' => [ + // Markenspezifische Welten für stileigentum + [ + 'image' => 'style2own/world-1.jpg', + 'title' => 'Style2own', + 'description' => 'Modernes Design und flexible Möbellösungen.', + 'link' => '/style2own/portfolio', + ], + [ + 'image' => 'style2own/world-2.jpg', + 'title' => 'Stileigentum', + 'description' => 'Modernes Design und flexible Möbellösungen.', + 'link' => '/style2own/portfolio', + ], + [ + 'image' => 'style2own/world-3.jpg', + 'title' => 'Stileigentum', + 'description' => 'Modernes Design und flexible Möbellösungen.', + 'link' => '/style2own/portfolio', + ], + ], + ], + 'cta_section' => [ + 'title' => 'Bereit für dein
neues Zuhause?', + 'subtitle' => 'Fordere jetzt deinen exklusiven und kostenlosen Zugang zu unserem Portal an und starte dein Einrichtungsprojekt.', + 'button_text' => 'Jetzt Zugang anfragen', + 'button_link' => '/contact', + ], + 'hero_image' => [ + 'title' => 'Dein Zuhause in
3 einfachen Schritten', + 'subtitle' => 'Vergiss den Einrichtungsstress! Wir zeigen dir, wie einfach und spaßig der Weg zu deinem Traumzuhause sein kann.', + 'hero_image' => 'style2own/hero-image.jpg', + 'hero_image_alt' => 'Inneneinrichtung von style2own', + + 'stats' => [ + 'Exklusiver Service', + 'Flexible Möbellösungen', + 'Modernes Design', + ], + ], + 'portfolio' => [ + 'title' => 'Finde deine Style', + 'subtitle' => 'Von Urban Jungle bis Scandi Chic – entdecke Looks, die dein Zuhause zum Leben erwecken.', + 'projects' => [ + [ + 'id' => 1, + 'title' => 'Neubau Schwabing Staging', + 'subtitle' => 'Contemporary Luxury Design', + 'description' => 'Exklusives Möblierungskonzept für Neubau-Villa in München-Schwabing. Moderne Einrichtung mit warmen Naturtönen und hochwertigen Designermöbeln steigert den Immobilienwert um 15%.', + 'category' => 'Villen', + 'location' => 'München-Schwabing', + 'price' => '18.500 € Möblierungskonzept', + 'size' => '280 m² Wohnfläche', + 'image' => '/style2own/accommodation-1.jpg', + 'features' => [ + 'Italienische Designermöbel', + 'Maßanfertigungen', + 'Premium Textilien', + 'Kunstobjekte', + 'Smart Home Integration', + ], + ], + [ + 'id' => 2, + 'title' => 'Maxvorstadt Interior Design', + 'subtitle' => 'Urban Living Excellence', + 'description' => 'Zeitloses Raumkonzept für Stadtvilla in der Maxvorstadt. Skandinavisch inspiriertes Design mit nachhaltigen Materialien und funktionaler Eleganz.', + 'category' => 'Villen', + 'location' => 'München-Maxvorstadt', + 'price' => '12.000 € Komplettausstattung', + 'size' => '220 m² Wohnfläche', + 'image' => '/style2own/accommodation-2.jpg', + 'features' => [ + 'Nachhaltige Materialien', + 'Maßgefertigte Einbauschränke', + 'Designer-Beleuchtung', + 'Hochwertige Böden', + 'Farbkonzept', + ], + ], + [ + 'id' => 3, + 'title' => 'Penthouse Premium Staging', + 'subtitle' => 'Sky-high Luxury', + 'description' => 'Atemberaubendes Staging-Konzept für Luxus-Penthouse. 360°-Design mit italienischen Luxusmöbeln und exklusiven Kunstobjekten für maximale Verkaufsattraktivität.', + 'category' => 'Penthouse', + 'location' => 'München-Lehel', + 'price' => '32.000 € Premium Staging', + 'size' => '220 m² + 150 m² Terrasse', + 'image' => '/style2own/accommodation-3.jpg', + 'features' => [ + 'Italienische Luxusmöbel', + 'Outdoor-Möblierung', + 'Exklusive Kunstwerke', + 'Premium Accessoires', + 'Maßgeschneiderte Lösungen', + ], + ], + [ + 'id' => 4, + 'title' => 'Industrial Loft Konzept', + 'subtitle' => 'Industrial Chic', + 'description' => 'Charakterstarkes Möblierungskonzept für historisches Loft. Industrial Design mit modernen Akzenten, hochwertigen Vintage-Elementen und künstlerischen Details.', + 'category' => 'Loft', + 'location' => 'München-Glockenbach', + 'price' => '95.000 € Industrial Staging', + 'size' => '160 m² Loft-Fläche', + 'image' => '/style2own/accommodation-3.jpg', + 'features' => [ + 'Vintage Industrial Möbel', + 'Maßgefertigte Stahlträger', + 'Künstlerische Beleuchtung', + 'Authentische Materialien', + 'Stilechte Accessoires', + ], + ], + [ + 'id' => 5, + 'title' => 'Bogenhausen Luxus Interior', + 'subtitle' => 'Prestige & Elegance', + 'description' => 'Maßgeschneidertes Luxus-Interieur für prestigeträchtiges Penthouse. Klassische Eleganz trifft auf moderne Funktionalität mit exklusiven Materialien und Handwerkskunst.', + 'category' => 'Penthouse', + 'location' => 'München-Bogenhausen', + 'price' => 'ab 45.000 € Bespoke Design', + 'size' => '350 m² + 200 m² Terrasse', + 'image' => '/style2own/accommodation-1.jpg', + 'features' => [ + 'Maßgefertigte Möbel', + 'Edle Naturmaterialien', + 'Antike Kunstobjekte', + 'Wellness-Bereich Design', + 'Terrassenmöblierung', + ], + ], + [ + 'id' => 6, + 'title' => 'Seevilla Interior Konzept', + 'subtitle' => 'Lakeside Living', + 'description' => 'Exklusives Möblierungskonzept für Seevilla am Starnberger See. Naturnahe Materialien, maritime Akzente und großzügige Raumgestaltung mit Blick auf den See.', + 'category' => 'Villen', + 'location' => 'Starnberger See', + 'price' => '28.000 € Seevilla-Design', + 'size' => '400 m² Villa + Garten', + 'image' => '/style2own/accommodation-2.jpg', + 'features' => [ + 'Maritime Designelemente', + 'Naturbelassene Materialien', + 'Outdoor-Living-Konzept', + 'Panorama-Raumgestaltung', + 'Wasserfeste Materialien', + ], + ], + [ + 'id' => 7, + 'title' => 'Werksviertel Loft Staging', + 'subtitle' => 'Modern Heritage', + 'description' => 'Innovatives Staging für urbanes Loft im Werksviertel. Moderne Einrichtung mit industriellen Elementen und smarten Wohnlösungen für junge Zielgruppe.', + 'category' => 'Loft', + 'location' => 'München-Werksviertel', + 'price' => '75.000 € Urban Staging', + 'size' => '140 m² Loft-Fläche', + 'image' => '/style2own/accommodation-1.jpg', + 'features' => [ + 'Flexible Raumteiler', + 'Multifunktionale Möbel', + 'Tech-Integration', + 'Modulare Systeme', + 'Urbane Materialien', + ], + ], + [ + 'id' => 8, + 'title' => 'Maximilianstraße Penthouse', + 'subtitle' => 'Ultimate Luxury', + 'description' => 'Das ultimative Interior Design für Luxus-Penthouse in Münchens nobelster Adresse. Zwei-Etagen Konzept mit privatem Spa, Weinkeller und erlesenen Antiquitäten.', + 'category' => 'Penthouse', + 'location' => 'München-Altstadt', + 'price' => 'ab 10.000 € Luxury Interior', + 'size' => '450 m² über 2 Etagen', + 'image' => '/style2own/accommodation-3.jpg', + 'features' => [ + 'Antike Möbelstücke', + 'Spa-Design Konzept', + 'Weinkeller-Ausstattung', + 'Handgefertigte Unikate', + 'Luxus-Textilien', + ], + ], + [ + 'id' => 9, + 'title' => 'Grünwald Architektenvilla Design', + 'subtitle' => 'Architectural Excellence', + 'description' => 'Nachhaltiges Interior Design für preisgekrönte Architektenvilla. Ökologische Materialien, energieeffiziente Beleuchtung und biophile Gestaltungselemente.', + 'category' => 'Villen', + 'location' => 'Grünwald', + 'price' => '19.000 € Eco-Luxury Design', + 'size' => '320 m² + 1.200 m² Garten', + 'image' => '/style2own/accommodation-2.jpg', + 'features' => [ + 'Nachhaltige Materialien', + 'Biophile Gestaltung', + 'LED-Lichttechnik', + 'Recycelte Designstücke', + 'Indoor-Garten Konzept', + ], + ], + ], + ], + 'cta_section_portfolio' => [ + 'title' => 'Inspiriert?
Dann leg jetzt los!', + 'subtitle' => 'Hol dir deinen Zugang und beginne, dein eigenes Traumzuhause zu gestalten.', + 'button_text' => 'Jetzt loslegen', + 'button_link' => '/contact', + ], + + 'commitment_section' => [ + 'title' => 'Exzellenz, von unseren Kunden bestätigt', + 'subtitle' => 'Diskretion und Erfolg sprechen für sich. Hier sind einige Stimmen unserer zufriedenen Klienten.', + 'testimonials' => [ + [ + 'image' => 'style2own/testimonial-1.jpg', + 'rating' => 5, + 'quote' => 'Stileigentum hat für uns ein Zuhause gefunden, das unsere kühnsten Träume übertrifft. Ein Service der Extraklasse.', + 'author' => 'Familie Exklusiv', + 'author_title' => 'Zufriedene Kunden', + ], + [ + 'image' => 'style2own/testimonial-2.jpg', + 'rating' => 5, + 'quote' => 'Die diskrete und professionelle Abwicklung beim Verkauf unserer Villa war beeindruckend. Absolute Empfehlung.', + 'author' => 'Dr. von Adel', + 'author_title' => 'Unternehmer', + ], + [ + 'image' => 'style2own/testimonial-3.jpg', + 'rating' => 5, + 'quote' => 'Ein Makler mit Stil, Netzwerk und dem nötigen Feingefühl für besondere Immobilien und deren Eigentümer.', + 'author' => 'Anspruchsvoller Investor', + 'author_title' => 'Investor', + ], + ], + ], + 'ecosystem_stats' => [ + 'title' => 'Deine Vorteile mit style2own', + 'subtitle' => 'Wir bieten dir mehr als nur Möbel – entdecke das Rundum-Sorglos-Paket für dein neues Zuhause.', + 'stats' => [ + [ + 'number' => '01', + 'label' => 'Volle Flexibilität', + 'description' => 'Mit unserem "Erst mieten, später kaufen"-Modell bleibst du ungebunden und kannst deinen Stil risikofrei ausprobieren.', + ], + [ + 'number' => '02', + 'label' => 'Design ohne Stress', + 'description' => 'Keine endlosen Suchen mehr! Unsere Experten haben bereits komplette, harmonische Looks für dich zusammengestellt.', + ], + [ + 'number' => '03', + 'label' => 'Alles aus einer Hand', + 'description' => 'Von der Auswahl über die Lieferung bis zum Aufbau – wir kümmern uns darum, damit du dich zurücklehnen kannst.', + ], + [ + 'number' => '04', + 'label' => 'Exklusiver Service', + 'description' => 'Genieße einen einzigartigen Vorteil, den du nur als Kunde unserer ausgewählten Partner-Makler erhältst.', + ], + ], + ], + 'faq' => [ + 'title' => 'Häufig gestellte Fragen', + 'subtitle' => 'Hier findest du Antworten auf die häufigsten Fragen zu style2own.', + 'questions' => [ + [ + 'question' => 'Was ist style2own eigentlich?', + 'answer' => 'style2own ist dein persönlicher Einrichtungs-Coach als Online-Portal! Wir machen das Einrichten deines neuen Zuhauses super einfach und inspirierend. Statt dich durch unzählige Möbelhäuser zu quälen, bieten wir dir über unser exklusives Portal komplette, von Designern kuratierte Wohnkonzepte und flexible Möbellösungen, die perfekt zu dir und deinem Leben passen.', + ], + [ + 'question' => 'Wie bekomme ich Zugang zum Portal?', + 'answer' => 'Den Zugang zu style2own erhältst du exklusiv über einen unserer Partner-Makler. Nach dem Abschluss deines Miet- oder Kaufvertrags bekommst du von ihm deine persönliche Login-Karte – dein kostenloses Ticket in unsere Design-Welt. Es ist ein exklusiver Service, um dir den Start im neuen Zuhause zu verschönern.', + ], + [ + 'question' => 'Kostet die Nutzung des Portals etwas?', + 'answer' => 'Nein! Der Zugang zum Portal und das Stöbern durch alle unsere Stilwelten und Konzepte ist für dich als Kunde unserer Partner-Makler komplett kostenlos und unverbindlich. Du zahlst nur für die Möbel oder Services, die du am Ende auch wirklich bestellst.', + ], + [ + 'question' => 'Muss ich die Möbel kaufen? Was bedeutet "Erst mieten, später kaufen"?', + 'answer' => 'Du hast die volle Flexibilität! Das ist das Herzstück von style2own. Du kannst unsere Möbel direkt kaufen ODER sie erst einmal für eine bestimmte Zeit mieten. So kannst du einen Look einfach ausprobieren. Und wenn du dich in dein neues Sofa verliebst, kannst du es später kaufen, wobei wir dir einen Teil der bereits gezahlten Miete anrechnen. Kein Risiko, volle Freiheit!', + ], + [ + 'question' => 'Wer wählt die Möbel aus? Passen die Vorschläge auch wirklich zu mir?', + 'answer' => 'Unser Portal ist smart und persönlich! Unsere Interior-Experten stellen komplette, harmonische Stilwelten für dich zusammen – von "Urban Jungle" bis "Scandi Chic". Innerhalb dieser Welten kannst du aber alles nach deinem Geschmack anpassen, Favoriten speichern und dir dein individuelles Paket zusammenstellen. Am Ende entscheidest immer du, was in dein Zuhause einzieht.', + ], + ], + ], + 'contact_form' => [ + 'hero' => [ + 'title' => 'Sag Hallo!', + 'subtitle' => 'Wir freuen uns riesig, von dir zu hören. Egal ob du eine Frage, eine Idee oder einfach nur Feedback für uns hast – immer her damit!', + ], + 'form' => [ + 'labels' => [ + 'first_name' => 'First name *', + 'last_name' => 'Last name *', + 'company' => 'Company', + 'email' => 'Email *', + 'phone' => 'Phone', + 'subject' => 'Subject *', + 'message' => 'Message *', + ], + 'subjects' => [ + '' => 'Wählen Sie einen Betreff', + 'general' => 'Allgemeine Anfrage', + 'press' => 'Presse', + 'partnership' => 'Partnerschaft', + 'career' => 'Karriere', + ], + 'placeholders' => [ + 'message' => 'Ihre Nachricht...', + ], + 'button_text' => 'Senden', + 'button_loading' => 'Wird gesendet...', + 'success_message' => 'Vielen Dank für Ihre Nachricht! Wir werden uns schnellstmöglich bei Ihnen melden.', + ], + 'contact_info' => [ + [ + 'title' => 'Our Office Location', + 'info' => [ + 'Musterstraße 123', + '12345 Berlin, Deutschland', + ], + 'icon' => 'map-pin', + ], + [ + 'title' => 'Our Email Address', + 'info' => [ + 'info@style2own.de', + ], + 'icon' => 'mail', + ], + [ + 'title' => 'Our Contact Numbers', + 'info' => [ + '+49 30 12345678', + ], + 'icon' => 'phone', + ], + ], + 'social_media' => [ + 'title' => 'Follow for
exclusives', + 'subtitle' => 'Bleiben Sie auf dem Laufenden mit exklusiven Angeboten und News.', + 'platforms' => [ + ['name' => 'Instagram', 'handle' => '@style2own_official', 'url' => 'https://instagram.com/style2own_official'], + ['name' => 'Youtube', 'handle' => 'style2own Channel', 'url' => 'https://youtube.com/@style2own'], + ['name' => 'Pinterest', 'handle' => 'style2own Inspiration', 'url' => 'https://pinterest.com/style2own'], + ['name' => 'Facebook', 'handle' => 'style2own Deutschland', 'url' => 'https://facebook.com/style2own'], + ['name' => 'LinkedIn', 'handle' => 'style2own Company', 'url' => 'https://linkedin.com/company/b2in'], + ], + ], + ], + // Weitere Komponenten für style2own + ], + ], + 'articles' => [ + 1 => [ + 'id' => 1, + 'title' => ' Die Zukunft des Home Staging:
Mehr als nur Möbelrücken', + 'subtitle' => 'Wie Technologie, Nachhaltigkeit und personalisierte Konzepte die Immobilienvermarktung revolutionieren und den Wert Ihrer Objekte maximieren.', + 'image' => 'b2in/magazin-1.jpg', + 'category' => 'Immobilien-Marketing', + 'date' => 'Oktober 26, 2025', + 'readTime' => '6 min read', + 'author' => [ + 'name' => 'Dr. Elena Richter', + 'bio' => 'Unsere leitende Expertin für Interior Concepts und Immobilienpsychologie. Sie verbindet Designtrends mit datengestützten Vermarktungsstrategien.', + 'avatar' => 'author-richter.jpg', + ], + 'content' => [ + 'intro' => 'Home Staging war einst die Kunst, eine Immobilie wohnlich zu präsentieren. Heute ist es eine datengestützte Wissenschaft, die entscheidend zum Verkaufserfolg beiträgt. Die bloße Platzierung von Möbeln reicht nicht mehr aus, um sich in einem anspruchsvollen Markt abzuheben. Die Zukunft gehört Konzepten, die smart, nachhaltig und hochgradig personalisiert sind.', + 'sections' => [ + [ + 'title' => 'Digitales Staging: Virtual & Augmented Reality', + 'content' => 'Noch vor dem ersten Spatenstich können potenzielle Käufer durch ihr zukünftiges Zuhause wandeln. Virtuelles Staging ermöglicht es, verschiedene Einrichtungsstile per Klick zu testen und schafft eine emotionale Bindung, lange bevor die Wände stehen. Dies senkt Kosten und beschleunigt den Verkaufsprozess enorm.', + ], + [ + 'title' => 'Nachhaltigkeit als entscheidendes Verkaufsargument', + 'content' => 'Die moderne Käuferschicht legt Wert auf ökologische Verantwortung. Ein Staging-Konzept, das auf nachhaltige Materialien, recycelte Möbel und energieeffiziente Beleuchtung setzt, ist nicht nur ethisch, sondern auch ein starkes Verkaufsargument. Es signalisiert Qualität, Langlebigkeit und ein zukunftsorientiertes Denken.', + ], + [ + 'title' => 'Personalisierung ist der Schlüssel', + 'content' => 'One-size-fits-all war gestern. Intelligente Plattformen wie das B2in-Portal ermöglichen es, Staging-Konzepte gezielt auf die demografische Zielgruppe des Käufers zuzuschneiden. Ob minimalistisch für den jungen Urbanisten oder elegant für die anspruchsvolle Familie – personalisiertes Staging trifft den Nerv und maximiert die Identifikation mit der Immobilie.', + ], + ], + ], + ], + 2 => [ + 'id' => 2, + 'title' => 'Jenseits der Lage:
Warum technologiegetriebene Immobilien die Zukunft des Investments sind', + 'subtitle' => 'Während "Lage, Lage, Lage" ein Klassiker bleibt, definieren Daten, Konnektivität und flexible Nutzungskonzepte heute die wahre Rendite eines Objekts.', + 'image' => 'b2in/magazin-2.jpg', + 'category' => 'Investment & Trends', + 'date' => 'Oktober 18, 2025', + 'readTime' => '8 min read', + 'author' => [ + 'name' => 'David Chen', + 'bio' => 'Head of Global Market Analytics bei B2in. David analysiert internationale Marktdaten, um die Investment-Chancen von morgen zu identifizieren.', + 'avatar' => 'author-chen.jpg', + ], + 'content' => [ + 'intro' => 'Für Jahrzehnte war die Lage der unangefochtene König der Immobilienbewertung. Doch in einer digitalisierten Welt treten neue, ebenso mächtige Werttreiber auf den Plan. Intelligente Investoren schauen heute nicht nur auf die Postleitzahl, sondern auch auf die technologische Infrastruktur und die Anpassungsfähigkeit einer Immobilie an die Bedürfnisse der Zukunft.', + 'sections' => [ + [ + 'title' => 'Smart Homes & IoT: Der neue Standard', + 'content' => 'Immobilien, die von vornherein mit einer intelligenten Vernetzung (Internet of Things) ausgestattet sind, erzielen höhere Mieteinnahmen und Verkaufspreise. Energieeffizienz, Sicherheit und Komfort sind keine Luxus-Features mehr, sondern knallharte Kriterien für die Zukunftsfähigkeit eines Investments.', + ], + [ + 'title' => 'Datenanalyse für präzise Renditeprognosen', + 'content' => 'Moderne Plattformen nutzen Big Data, um die potenzielle Wertentwicklung einer Immobilie weitaus genauer vorherzusagen. Faktoren wie demografische Entwicklung, zukünftige Infrastrukturprojekte und sozioökonomische Trends fließen in Algorithmen ein und ermöglichen datengestützte statt bauchgefühlbasierte Investmententscheidungen.', + ], + [ + 'title' => 'Flexible Wohnkonzepte als Werthebel', + 'content' => 'Die Nachfrage nach Flexibilität steigt. Immobilien, die sich leicht an verschiedene Lebensphasen oder Arbeitsmodelle (Home-Office) anpassen lassen und durch Services wie "Furniture-as-a-Service" ergänzt werden, sind resilienter und attraktiver für eine breitere Zielgruppe. Diese Flexibilität ist ein direkter Treiber für eine langfristig stabile Rendite.', + ], + ], + ], + ], + 3 => [ + 'id' => 3, + 'title' => 'Europäisches Design erobert den US-Markt:
Eine Chance für visionäre Händler', + 'subtitle' => 'Minimalismus, Handwerkskunst und Nachhaltigkeit – warum amerikanische Konsumenten sich zunehmend für europäische Möbel begeistern und wie Händler davon profitieren können.', + 'image' => 'b2in/magazin-3.jpg', + 'category' => 'B2B & Handel', + 'date' => 'Oktober 11, 2025', + 'readTime' => '5 min read', + 'author' => [ + 'name' => 'Frank Miller', + 'bio' => 'CEO der Bridges2America Corp. (B2A), dem spezialisierten US-Möbel-Exportarm von B2in. Er ist ein Experte für transatlantische Handelsbeziehungen.', + 'avatar' => 'author-miller.jpg', + ], + 'content' => [ + 'intro' => 'Ein signifikanter Wandel vollzieht sich in den Wohnzimmern Amerikas. Weg von "bigger is better", hin zu einem kuratierten, bewussten Stil. Europäisches Design trifft mit seinem Fokus auf Qualität, Funktionalität und zeitlose Ästhetik genau diesen Nerv. Für US-Händler und Interior Designer eröffnet sich dadurch eine enorme Marktchance – wenn die größte Hürde überwunden wird: die Logistik.', + 'sections' => [ + [ + 'title' => 'Der Wandel im amerikanischen Geschmack', + 'content' => 'Jüngere, designaffine Zielgruppen in den USA suchen nach Authentizität und Langlebigkeit statt nach Massenware. Marken aus Skandinavien, Italien und Deutschland stehen für genau diese Werte und werden zunehmend zu Statussymbolen. Die Nachfrage nach kuratierten, exklusiven Kollektionen wächst stetig.', + ], + [ + 'title' => 'Die Herausforderung: Der transatlantische Handel', + 'content' => 'Der Import von Möbeln aus verschiedenen europäischen Ländern ist für einzelne Händler oft komplex und kostspielig. Unterschiedliche Lieferanten, Zollabwicklungen und die Konsolidierung von Waren stellen eine hohe logistische und administrative Hürde dar, die viele davon abhält, dieses profitable Segment zu erschließen.', + ], + [ + 'title' => 'Partnerschaft als strategischer Erfolgsfaktor', + 'content' => 'Spezialisierte B2B-Partner wie B2A fungieren als Brückenbauer. Sie bündeln den Einkauf, managen die gesamte Logistikkette und bieten US-Händlern einen kuratierten, einfachen Zugang zu den besten europäischen Designmarken. Ein solches Modell minimiert das Risiko und maximiert die Marge – ein entscheidender Wettbewerbsvorteil.', + ], + ], + ], + ], + ], + +]; diff --git a/dev/27-02-2026/backup-before-relaunch/ecosystem.blade.php b/dev/27-02-2026/backup-before-relaunch/ecosystem.blade.php new file mode 100644 index 0000000..4f6a407 --- /dev/null +++ b/dev/27-02-2026/backup-before-relaunch/ecosystem.blade.php @@ -0,0 +1,36 @@ +@extends('web.layouts.web-master') + +@section('title', 'B2in Ecosystem - Intelligentes Netzwerk') + +@section('content') +
+ + +
+ + + + + + + +
+ + +
+@endsection + +@push('styles') + +@endpush + +@push('scripts') + {{-- Alpine.js wird zentral im Layout geladen --}} +@endpush diff --git a/dev/27-02-2026/backup-before-relaunch/footer.blade.php b/dev/27-02-2026/backup-before-relaunch/footer.blade.php new file mode 100644 index 0000000..4b9a428 --- /dev/null +++ b/dev/27-02-2026/backup-before-relaunch/footer.blade.php @@ -0,0 +1,74 @@ + diff --git a/dev/27-02-2026/backup-before-relaunch/hero.blade.php b/dev/27-02-2026/backup-before-relaunch/hero.blade.php new file mode 100644 index 0000000..ef8e3f2 --- /dev/null +++ b/dev/27-02-2026/backup-before-relaunch/hero.blade.php @@ -0,0 +1,54 @@ +
+
+
+ {{-- Left Content --}} +
+
+

+ {!! $content['title'] !!} +

+

+ {!! $content['subtitle'] !!} +

+
+ + + + @if(isset($content['stats'])) +
+ @foreach ($content['stats'] as $stat) +
+ @svg('heroicon-o-check-circle', 'w-6 h-6 text-secondary') + {{ $stat }} +
+ @endforeach +
+ @endif + +
+ + {{-- Right Image --}} +
+
+ {{ $content['image_alt'] }} +
+
+ + {{-- Floating info card --}} +
+
{{ $content['card_title'] }}
+
{!! $content['card_text'] !!}
+
+
+
+
+
diff --git a/dev/27-02-2026/backup-before-relaunch/home.blade.php b/dev/27-02-2026/backup-before-relaunch/home.blade.php new file mode 100644 index 0000000..6ab1811 --- /dev/null +++ b/dev/27-02-2026/backup-before-relaunch/home.blade.php @@ -0,0 +1,34 @@ +@extends('web.layouts.web-master') + +@section('title', 'B2IN - Connecting Design and Property') + +@section('content') +
+ + +
+ + + + + + + +
+ + +
+@endsection + +@push('styles') + +@endpush + +@push('scripts') + {{-- Alpine.js wird zentral im Layout geladen --}} +@endpush diff --git a/dev/27-02-2026/backup-before-relaunch/magazin.blade.php b/dev/27-02-2026/backup-before-relaunch/magazin.blade.php new file mode 100644 index 0000000..4681225 --- /dev/null +++ b/dev/27-02-2026/backup-before-relaunch/magazin.blade.php @@ -0,0 +1,29 @@ +@extends('web.layouts.web-master') + +@section('title', 'B2in Magazin - Insights & Trends') + +@section('content') +
+ + +
+ +
+ + +
+@endsection + +@push('styles') + +@endpush + +@push('scripts') + {{-- Alpine.js wird zentral im Layout geladen --}} +@endpush diff --git a/dev/27-02-2026/backup-before-relaunch/partner.blade.php b/dev/27-02-2026/backup-before-relaunch/partner.blade.php new file mode 100644 index 0000000..03ed409 --- /dev/null +++ b/dev/27-02-2026/backup-before-relaunch/partner.blade.php @@ -0,0 +1,35 @@ +@extends('web.layouts.web-master') + +@section('title', 'Partner werden - B2in Ecosystem') + +@section('content') +
+ + +
+ + + + + + + + + +
+ + +
+@endsection + +@push('styles') + +@endpush + +@push('scripts') + {{-- Alpine.js wird zentral im Layout geladen --}} +@endpush diff --git a/dev/27-02-2026/backup-before-relaunch/web.php b/dev/27-02-2026/backup-before-relaunch/web.php new file mode 100644 index 0000000..5031942 --- /dev/null +++ b/dev/27-02-2026/backup-before-relaunch/web.php @@ -0,0 +1,103 @@ +exists('web.'.$theme)) { + return view('web.'.$theme); + } + + // Fallback to the default home view + return view('web.home'); +})->name('home'); + +// Willkommensseite +Route::get('/welcome', function () { + return view('web.welcome'); +})->name('welcome'); + +// Weitere gemeinsame Webseiten hier... +Route::get('/about', function () { + return view('web.about'); +})->name('about'); +Route::get('/ecosystem', function () { + return view('web.ecosystem'); +})->name('ecosystem'); +Route::get('/partner', function () { + return view('web.partner'); +})->name('partner'); +Route::get('/magazin', function () { + return view('web.magazin'); +})->name('magazin'); +Route::get('/magazin/{id}', function ($id) { + return view('web.magazin-detail', compact('id')); +})->name('magazin.detail'); +Route::get('/contact', function () { + return view('web.contact'); +})->name('contact'); + +Route::get('/service', function () { + return view('web.service'); +})->name('service'); + +Route::get('/portfolio', function () { + return view('web.portfolio'); +})->name('portfolio'); + +Route::get('/faq', function () { + return view('web.faq'); +})->name('faq'); + +// Theme Demo Route +Route::get('/theme-demo', function () { + return view('web.theme-demo'); +})->name('theme-demo'); + +// Pfad-basierte Theme-Routen für lokale Entwicklung wurden entfernt +// Die Themensauswahl wird nun über den ThemeServiceProvider gesteuert (Domain oder ?theme=... GET-Parameter) + +Route::get('/partner/invitation/expired/{token}', function (string $token) { + $invitation = \App\Models\PartnerInvitation::with('role')->where('token', $token)->firstOrFail(); + + return view('partner.invitation-expired', compact('invitation')); +})->name('partner.invitation.expired'); + +Route::get('/partner/invitation/used/{token}', function (string $token) { + $invitation = \App\Models\PartnerInvitation::with('role')->where('token', $token)->firstOrFail(); + + return view('partner.invitation-used', compact('invitation')); +})->name('partner.invitation.used'); + +Volt::route('/partner/invitation/{token}', 'partner.invitation-accept') + ->name('partner.invitation.accept'); + +Volt::route('/partner/create-account', 'partner.create-account') + ->name('partner.create.account'); + +// Öffentliche Registrierung per QR-/Code (Landing, code-check) +Volt::route('/reg/{role}', 'reg.landing') + ->name('registration.landing'); + +Volt::route('/registration/thank-you', 'reg.thank-you') + ->name('registration.thank-you'); + +// Partner Setup Wizard & Daten +Route::middleware('auth')->group(function () { + Volt::route('/partner/setup', 'partner.setup-wizard') + ->name('partner.setup.wizard'); +}); + +// Authentifizierungs-Routen werden in domains.php eingebunden +// require __DIR__ . '/auth.php'; diff --git a/dev/27-02-2026/review-architektur-02-03-2026.md b/dev/27-02-2026/review-architektur-02-03-2026.md new file mode 100644 index 0000000..db7b283 --- /dev/null +++ b/dev/27-02-2026/review-architektur-02-03-2026.md @@ -0,0 +1,196 @@ +# Review & Finaler Umsetzungsplan: B2in Website-Architektur (02.03.2026) + +**Reviewer:** Claude Code +**Datum:** 02.03.2026 +**Status:** Finale Entscheidungen getroffen + +**Basis-Dokumente:** +- `B2in Website-Architektur.md` (Neues Konzept vom 02.03.2026) +- `b2in-website-umsetzungsplan.md` (Originaler Umsetzungsplan vom 27.02.2026) +- `b2in-umsetzung-changelog.md` (Was bereits umgesetzt wurde) +- `b2in-local-for-local.md` (Konzeptpapier v1.1) + +--- + +## 1. Was bereits umgesetzt ist (Stand 27.02.2026) + +Die erste Umsetzungsphase hat die b2in-Website erfolgreich vom reinen Möbel-Fokus auf den Dual-Fokus (Immobilien + Einrichtung) umgestellt: + +| Bereich | Status | Details | +|---------|--------|---------| +| Hero-Text | Umgesetzt | "Connecting Design and Property" + neue CTAs | +| FounderBar-Komponente | Umgesetzt | Marcel Scheibe als Vertrauensanker nach Hero | +| Vision-Sektion | Umgesetzt | Dual-Fokus Text (Immobilien + Einrichtung) | +| Ecosystem-Core (3 Säulen) | Umgesetzt | Immobilien / Einrichtung / Supply-Chain | +| Brand Worlds | Umgesetzt | Reihenfolge angepasst, Texte aktualisiert | +| CTA-Sektion | Umgesetzt | "Investment oder Einrichtung" | +| Partner-Seite | Umgesetzt | Neue Developer-Benefits-Sektion (Supply-Chain) | +| Footer | Umgesetzt | "Marcel Scheibe – Gründer & CEO" | +| Contact-Formular | Umgesetzt | Neue Betreffs: Immobilien, Supply-Chain | +| About-Seite | Umgesetzt | Timeline erweitert, Werte angepasst | +| Ecosystem-Seite | Umgesetzt | Immobilien-Dimension integriert | +| FAQ | Umgesetzt | 5 Fragen komplett überarbeitet | + +**Backups aller Original-Dateien:** `dev/27-02-2026/backup-before-relaunch/` + +--- + +## 2. Finale Entscheidungen (02.03.2026) + +Nach Review des Architektur-Konzepts und Abstimmung wurden folgende Entscheidungen getroffen: + +### 2.1 Homepage: Weiche mit visuellem Gewicht + +Die Homepage wird zur "Triage"-Seite, behält aber visuelles Gewicht durch 3 reduzierte Ecosystem-Kacheln. + +**Seitenstruktur (von oben nach unten):** + +1. **Hero-Bereich** — Neuer Text + 2 gleichwertige CTA-Buttons + - Headline: "B2in – Wo exklusive Immobilien-Investments auf smartes Interior treffen." + - Subline: "Ihr Partner für renditestarke internationale Immobilien und globales Supply-Chain-Management im Interior-Bereich." + - Button 1 (Gold/Premium): "Zu den Immobilien-Projekten" → `/immobilien` + - Button 2 (Corporate/Clean): "Für Entwickler & Partner" → `/partner` + +2. **FounderBar** — Bleibt unverändert (Vertrauensanker) + +3. **Synergie-Sektion (NEU)** — Kurzer, starker Block + - Text-Idee: "Das B2in-Ökosystem: Wir verbinden den Immobilienkauf mit der perfekten Einrichtung..." + +4. **3 Ecosystem-Kacheln (VEREINFACHT)** — Nur Icon + Titel + 1 Satz + - Kachel 1 (Icon: Gebäude/Schlüssel): **Immobilien & Investments** — "Exklusive Off-Market-Projekte & High-Yield Renditeobjekte." + - Kachel 2 (Icon: Sofa/Netzwerk): **Local-for-Local Marktplatz** — "Das Netzwerk für den regionalen Möbelfachhandel und Makler." + - Kachel 3 (Icon: Zahnrad/Vertrag): **Supply-Chain-Management** — "Deutsche Vertragssicherheit für internationale Immobilienentwickler." + +5. **CTA-Sektion** — Kontakt / Nächster Schritt + +**Was von der Homepage ENTFERNT wird:** +- Brand Worlds (detaillierte Karten) +- Content-Sektion ("Beste aus zwei Welten" — zu detailliert) +- Vision-Sektion (ersetzt durch Synergie-Sektion) +- Detaillierte Ecosystem-Texte (nur noch Kacheln) + +### 2.2 Navigation + +**Neues Hauptmenü:** + +| Position | Label | Ziel | +|----------|-------|------| +| 1 | Immobilien | `/immobilien` | +| 2 | Für Entwickler & Partner | `/partner` | +| 3 | Magazin | `/magazin` | +| 4 | Über B2in | `/about` | +| 5 (Button, rechts) | Partner-Login | Externer Link → `portal.b2in.test` (target="_blank") | + +**Entscheidung:** "Supply-Chain & B2B" verworfen zugunsten von "Für Entwickler & Partner". + +### 2.3 Seitenstruktur (Was bleibt, was geht, was kommt) + +| Seite | Entscheidung | Details | +|-------|-------------|---------| +| `/` (Homepage) | **Umbauen** | Weiche/Triage mit visuellem Gewicht (siehe 2.1) | +| `/immobilien` | **NEU erstellen** | Emotionale Landingpage für Investoren (Phase B — braucht CMS) | +| `/partner` | **Erweitern** | Supply-Chain als Top-Sektion integrieren, 4 Rollen darunter behalten | +| `/about` | **Behalten** | Unverändert — zwingend für Trust im Investment-Bereich | +| `/ecosystem` | **Absorbieren** | Content migriert nach `/partner` und `/about`, Route als Redirect auf `/partner` | +| `/magazin` | **Behalten** | Unverändert | +| `/contact` | **Behalten** | Unverändert (Betreffs bereits aktualisiert) | + +### 2.4 Partner-Seite als B2B-Hub + +Die Partner-Seite wird zum zentralen B2B-Einstiegspunkt. Neue Struktur: + +1. **Neuer Hero** — "Für Entwickler & Partner" (Menüpunkt-Ziel) +2. **Supply-Chain-Management Top-Sektion (NEU)** — Text aus Kundenanforderungen: + - Einleitung: "Wir übernehmen die operative und strategische Steuerung..." + - 3 Punkte: Vertragsmanagement, Vertragssicherung & Durchsetzung, Tracking & QK +3. **Makler & Händler Teaser** — Local-for-Local kurz angeteasert +4. **Bestehende 4 Rollen-Sektionen** — Developer, Retailer, Supplier, Broker (bleiben) +5. **Partner-CTA** — Kontakt / Partnerschaft anfragen + +### 2.5 Immobilien-Seite mit Mini-CMS + +**Entscheidung:** Zwingend dynamisch über Admin-Panel verwaltbar. + +**Datenstruktur (Model: `Property` / `Immobilie`):** + +| Feld | Typ | Beispiel | +|------|-----|---------| +| `title` | string | "Azizi Developments: Creek Views 4" | +| `subtitle` | string | "Al Jaddaf, Dubai" | +| `status` | enum | NEW_LAUNCH / AVAILABLE / SOLD | +| `price_from` | string | "ab 1,125,000 AED" | +| `highlights` | json/text | Bulletpoints (Prime Waterfront Views, etc.) | +| `image` | string/media | Projekt-Bild | +| `launch_date` | date | 2026-03-03 | +| `description` | text | Langtext | +| `sort_order` | integer | Sortierung auf der Seite | +| `is_published` | boolean | Sichtbar auf Frontend | + +**Frontend (`/immobilien`):** +1. Hero: "Investieren Sie in die Zukunft – Dubai, Lissabon & mehr." +2. Aktuelle Projekte: Kachel-Ansicht (dynamisch aus DB) +3. "B2in Möbel-Vorteil" Banner (Synergie-Hack) +4. Trust & Kontakt (Investor Evenings, Terminbuchung) + +**Admin-Panel:** CRUD-Verwaltung im bestehenden Portal (portal.b2in.test) + +### 2.6 Partner-Login (Cross-Domain) + +**Entscheidung:** Einfacher externer Link. + +```html +Partner-Login +``` + +Kein SSO, kein Session-Sharing — sauberer Absprung ins eigenständige Portal. + +--- + +## 3. Umsetzungs-Phasen + +### Phase A – Sofort umsetzbar (kein neuer Content vom Kunden nötig) + +| # | Aufgabe | Dateien | Aufwand | +|---|---------|---------|--------| +| A1 | Homepage zur Weiche umbauen | `home.blade.php`, `b2in.blade.php`, `config/content.php` | 2-3h | +| A2 | Synergie-Sektion erstellen | Neue Livewire-Komponente oder Config-Sektion | 1h | +| A3 | 3 Kacheln vereinfachen | `config/content.php` (ecosystem_core) | 30min | +| A4 | Navigation anpassen | `config/content.php` (navigation) + Header-Komponente | 1h | +| A5 | Partner-Seite erweitern | `partner.blade.php`, `config/content.php` | 2h | +| A6 | `/ecosystem` → Redirect auf `/partner` | Routes | 15min | +| A7 | Partner-Login Button im Header | Header-Komponente | 30min | + +**Gesamt Phase A: ~7-8h** + +### Phase B – Braucht Content/Technik vom Kunden + +| # | Aufgabe | Dateien | Aufwand | +|---|---------|---------|--------| +| B1 | Property/Immobilie Model + Migration + Factory | Model, Migration, Factory, Seeder | 2h | +| B2 | Admin-CRUD für Immobilien | Livewire-Komponenten im Admin-Portal | 4-6h | +| B3 | `/immobilien` Frontend-Landingpage | Blade-View, Route, Livewire-Komponenten | 3-4h | +| B4 | Investor Evenings / Terminbuchung | Sektion auf /immobilien | 1-2h | + +**Gesamt Phase B: ~10-14h** + +--- + +## 4. Offene Punkte (an Kunden) + +1. **Immobilien-Content:** Welche Projekte initial? Bilder vorhanden? +2. **Calendly vs. Kontaktformular** für Investor Evenings +3. **B2A-Markenwelt:** Bleibt erhalten oder wird entfernt? +4. **Porträtfoto Marcel Scheibe:** Hochwertiges Bild für Hero/Founder-Bar vorhanden? + +--- + +## 5. Dateien in diesem Ordner + +| Datei | Beschreibung | +|-------|-------------| +| `B2in Website-Architektur.md` | Neues Architektur-Konzept (02.03.2026) — Grundlage für die Überarbeitung | +| `b2in-local-for-local.md` | Konzeptpapier v1.1 — Local for Local Marktplatz-Ökosystem + E-Mail an Kunden | +| `b2in-website-umsetzungsplan.md` | Originaler Umsetzungsplan (27.02.2026) — IST vs. SOLL der ersten Phase | +| `b2in-umsetzung-changelog.md` | Changelog der ersten Umsetzung — was wurde am 27.02. geändert | +| `review-architektur-02-03-2026.md` | **Dieses Dokument** — Finaler Review mit Entscheidungen und Umsetzungsplan | +| `backup-before-relaunch/` | Backups aller Original-Dateien vor der ersten Umsetzung | diff --git a/dev/b2in-layout-v10/src/components/BrandWorlds.tsx b/dev/b2in-layout-v10/src/components/BrandWorlds.tsx index caaada9..1f254cf 100644 --- a/dev/b2in-layout-v10/src/components/BrandWorlds.tsx +++ b/dev/b2in-layout-v10/src/components/BrandWorlds.tsx @@ -1,6 +1,6 @@ import { ArrowRight } from "lucide-react"; import room1 from "../assets/room-1.jpg"; -import room2 from "../assets/room-2.jpg"; +import room2 from "../assets/room-2.jpg"; import room3 from "../assets/room-3.jpg"; const BrandWorlds = () => { @@ -15,7 +15,7 @@ const BrandWorlds = () => { { id: 2, image: room2, - title: "stileigentum", + title: "stileigentum", description: "Für exklusive Premium-Immobilien und zeitlose Eleganz mit höchsten Ansprüchen.", link: "/rooms" }, @@ -35,30 +35,30 @@ const BrandWorlds = () => {

Unsere Markenwelten

- Entdecken Sie die Welten von B2In – drei Bereiche, ein Ökosystem. + Entdecken Sie die Welten von B2in – drei Bereiche, ein Ökosystem.

- + {/* Brand Cards */}
{worlds.map((world) => (
- {world.title}
- +

{world.title}

{world.description}

- - @@ -74,4 +74,4 @@ const BrandWorlds = () => { ); }; -export default BrandWorlds; \ No newline at end of file +export default BrandWorlds; diff --git a/dev/b2in-layout-v10/src/components/BrokerSection.tsx b/dev/b2in-layout-v10/src/components/BrokerSection.tsx index 4c212ab..13acca0 100644 --- a/dev/b2in-layout-v10/src/components/BrokerSection.tsx +++ b/dev/b2in-layout-v10/src/components/BrokerSection.tsx @@ -15,7 +15,7 @@ const BrokerSection = () => { { icon: Target, title: "Qualifizierte Leads", - description: "Vorgefilterte, interessierte Kunden durch das B2In-Portal und Premium-Mitgliedschaften" + description: "Vorgefilterte, interessierte Kunden durch das B2in-Portal und Premium-Mitgliedschaften" }, { icon: Award, @@ -38,7 +38,7 @@ const BrokerSection = () => { Lifetime-Vergütung
- +
@@ -49,7 +49,7 @@ const BrokerSection = () => {
- +
Folgegeschäfte @@ -59,7 +59,7 @@ const BrokerSection = () => {
- +
Lifetime Value @@ -72,26 +72,26 @@ const BrokerSection = () => {
- +
Für Makler
- +

Nachhaltiger Erfolg durch Innovation

- +

- Unser revolutionäres Lifetime-Vergütungsmodell belohnt langfristige - Kundenbeziehungen. Durch durchdachte Wohnkonzepte vermarkten Sie - Immobilien nicht nur schneller, sondern bauen nachhaltige + Unser revolutionäres Lifetime-Vergütungsmodell belohnt langfristige + Kundenbeziehungen. Durch durchdachte Wohnkonzepte vermarkten Sie + Immobilien nicht nur schneller, sondern bauen nachhaltige Einnahmequellen auf.

- +
{benefits.map((benefit, index) => (
@@ -116,4 +116,4 @@ const BrokerSection = () => { ); }; -export default BrokerSection; \ No newline at end of file +export default BrokerSection; diff --git a/dev/b2in-layout-v10/src/components/DigitalCore.tsx b/dev/b2in-layout-v10/src/components/DigitalCore.tsx index b3708ba..09e0832 100644 --- a/dev/b2in-layout-v10/src/components/DigitalCore.tsx +++ b/dev/b2in-layout-v10/src/components/DigitalCore.tsx @@ -42,14 +42,14 @@ const DigitalCore = () => { Das digitale Herzstück
- +

- B2In Portal + B2in Portal

- +

- Unsere zentrale technologische Plattform verbindet alle Ecosystem-Teilnehmer - nahtlos miteinander. Modernste Technologie trifft auf intuitive Bedienung + Unsere zentrale technologische Plattform verbindet alle Ecosystem-Teilnehmer + nahtlos miteinander. Modernste Technologie trifft auf intuitive Bedienung und schafft einzigartige digitale Erlebnisse.

@@ -66,7 +66,7 @@ const DigitalCore = () => { Zentrale Plattform
- +
@@ -77,7 +77,7 @@ const DigitalCore = () => {

Online & Verfügbar

- +
@@ -87,7 +87,7 @@ const DigitalCore = () => {

Online & Verfügbar

- +
@@ -97,7 +97,7 @@ const DigitalCore = () => {

Online & Verfügbar

- +
@@ -111,21 +111,21 @@ const DigitalCore = () => {
- +

Technische Excellence

- +

- Das B2In-Portal ist mehr als nur eine Software – es ist das - technologische Rückgrat unseres gesamten Ecosystems. Entwickelt - mit modernsten Standards für Sicherheit, Performance und + Das B2in-Portal ist mehr als nur eine Software – es ist das + technologische Rückgrat unseres gesamten Ecosystems. Entwickelt + mit modernsten Standards für Sicherheit, Performance und Benutzerfreundlichkeit.

- +
@@ -134,7 +134,7 @@ const DigitalCore = () => {

Garantierte Verfügbarkeit

- +
@@ -142,7 +142,7 @@ const DigitalCore = () => {

Blitzschnelle Performance

- +
@@ -153,18 +153,18 @@ const DigitalCore = () => {
- +
{features.map((feature, index) => (
- +

{feature.title}

- +

{feature.description}

@@ -176,4 +176,4 @@ const DigitalCore = () => { ); }; -export default DigitalCore; \ No newline at end of file +export default DigitalCore; diff --git a/dev/b2in-layout-v10/src/components/EcosystemHero.tsx b/dev/b2in-layout-v10/src/components/EcosystemHero.tsx index 0d92044..1381f08 100644 --- a/dev/b2in-layout-v10/src/components/EcosystemHero.tsx +++ b/dev/b2in-layout-v10/src/components/EcosystemHero.tsx @@ -7,15 +7,15 @@ const EcosystemHero = () => {

- B2In Ecosystem + B2in Ecosystem

- +

- Ein intelligentes Netzwerk, das Endkunden, Makler, Lieferanten und - Technologie nahtlos miteinander verbindet. Jeder Teilnehmer profitiert + Ein intelligentes Netzwerk, das Endkunden, Makler, Lieferanten und + Technologie nahtlos miteinander verbindet. Jeder Teilnehmer profitiert vom gesamten System und schafft gemeinsam außergewöhnliche Immobilienerlebnisse.

- +
@@ -26,7 +26,7 @@ const EcosystemHero = () => {

Exklusive Erlebnisse

- +
@@ -36,7 +36,7 @@ const EcosystemHero = () => {

Lifetime-Vergütung

- +
@@ -46,7 +46,7 @@ const EcosystemHero = () => {

Kuratierte Plattform

- +
@@ -58,7 +58,7 @@ const EcosystemHero = () => {
- +
@@ -67,10 +67,10 @@ const EcosystemHero = () => {
-

B2In Portal

+

B2in Portal

Zentrale Plattform

- + {/* Connection Lines */}
@@ -79,21 +79,21 @@ const EcosystemHero = () => {

Endkunden

- +

Makler

- +

Lieferanten

- +
@@ -110,4 +110,4 @@ const EcosystemHero = () => { ); }; -export default EcosystemHero; \ No newline at end of file +export default EcosystemHero; diff --git a/dev/b2in-layout-v10/src/components/Footer.tsx b/dev/b2in-layout-v10/src/components/Footer.tsx index 0efeefc..9dff8b7 100644 --- a/dev/b2in-layout-v10/src/components/Footer.tsx +++ b/dev/b2in-layout-v10/src/components/Footer.tsx @@ -8,27 +8,27 @@ const Footer = () => { - + {/* Bottom Bar */}
- © 2024 B2In. All rights reserved. + © 2024 B2in. All rights reserved.
English @@ -95,4 +95,4 @@ const Footer = () => { ); }; -export default Footer; \ No newline at end of file +export default Footer; diff --git a/dev/b2in-layout-v10/src/components/Hero.tsx b/dev/b2in-layout-v10/src/components/Hero.tsx index 202a3b7..a2f34df 100644 --- a/dev/b2in-layout-v10/src/components/Hero.tsx +++ b/dev/b2in-layout-v10/src/components/Hero.tsx @@ -16,7 +16,7 @@ const Hero = () => { Das globale Ökosystem für Immobilieninvestoren, Makler und Designliebhaber.

- +
- +
1.7M+ Nutzer @@ -34,21 +34,21 @@ const Hero = () => { 24/7 Platform
- + {/* Right Image */}
- Modern international skyline showcasing architectural design
- + {/* Floating info card */}
-
B2In Ecosystem
+
B2in Ecosystem
Global vernetzt
@@ -58,4 +58,4 @@ const Hero = () => { ); }; -export default Hero; \ No newline at end of file +export default Hero; diff --git a/dev/b2in-layout-v10/src/components/NewAboutHero.tsx b/dev/b2in-layout-v10/src/components/NewAboutHero.tsx index c2f5500..d8b6ea5 100644 --- a/dev/b2in-layout-v10/src/components/NewAboutHero.tsx +++ b/dev/b2in-layout-v10/src/components/NewAboutHero.tsx @@ -7,29 +7,29 @@ const NewAboutHero = () => {

- Über B2In + Über B2in

- +
- "Unsere Vision ist es, Unternehmen durch innovative Konnektivitätslösungen zu verbinden - und nachhaltiges Wachstum in der digitalen Welt zu ermöglichen. Bei B2In schaffen wir + "Unsere Vision ist es, Unternehmen durch innovative Konnektivitätslösungen zu verbinden + und nachhaltiges Wachstum in der digitalen Welt zu ermöglichen. Bei B2in schaffen wir nicht nur Verbindungen – wir bauen Brücken in die Zukunft."
- +

Marcel Scheibe

-

Gründer & CEO, B2In

+

Gründer & CEO, B2in

- +
- Marcel Scheibe, Gründer und CEO von B2In
@@ -44,4 +44,4 @@ const NewAboutHero = () => { ); }; -export default NewAboutHero; \ No newline at end of file +export default NewAboutHero; diff --git a/dev/b2in-layout-v10/src/components/OurStory.tsx b/dev/b2in-layout-v10/src/components/OurStory.tsx index b86ebbf..2336bab 100644 --- a/dev/b2in-layout-v10/src/components/OurStory.tsx +++ b/dev/b2in-layout-v10/src/components/OurStory.tsx @@ -5,7 +5,7 @@ const OurStory = () => {

Unsere Geschichte

- +
@@ -13,38 +13,38 @@ const OurStory = () => {

Die Idee

- 2019 erkannten wir eine kritische Marktlücke: Unternehmen benötigten intelligente, + 2019 erkannten wir eine kritische Marktlücke: Unternehmen benötigten intelligente, nachhaltige Konnektivitätslösungen für die digitale Transformation.

- +

Die Mission

- Wir entwickelten innovative B2B-Lösungen, die Unternehmen dabei unterstützen, + Wir entwickelten innovative B2B-Lösungen, die Unternehmen dabei unterstützen, effizienter zu arbeiten und nachhaltiges Wachstum zu erzielen.

- +

Die Zukunft

- Heute sind wir stolz darauf, hunderte Unternehmen dabei zu unterstützen, + Heute sind wir stolz darauf, hunderte Unternehmen dabei zu unterstützen, ihre digitalen Ziele zu erreichen und neue Märkte zu erschließen.

- +

- Was als Vision begann, traditionelle Geschäftsprozesse zu revolutionieren, ist heute eine - bewährte Plattform für digitale Innovation. B2In schließt die Lücke zwischen - traditionellen Unternehmen und modernen, digitalen Lösungen durch maßgeschneiderte + Was als Vision begann, traditionelle Geschäftsprozesse zu revolutionieren, ist heute eine + bewährte Plattform für digitale Innovation. B2in schließt die Lücke zwischen + traditionellen Unternehmen und modernen, digitalen Lösungen durch maßgeschneiderte Konnektivitätsservices, die Effizienz steigern und nachhaltiges Wachstum fördern.

@@ -52,4 +52,4 @@ const OurStory = () => { ); }; -export default OurStory; \ No newline at end of file +export default OurStory; diff --git a/dev/b2in-layout-v10/src/components/PartnerBenefits.tsx b/dev/b2in-layout-v10/src/components/PartnerBenefits.tsx index a56184b..2487d5e 100644 --- a/dev/b2in-layout-v10/src/components/PartnerBenefits.tsx +++ b/dev/b2in-layout-v10/src/components/PartnerBenefits.tsx @@ -46,7 +46,7 @@ const PartnerBenefits = () => { Warum Partner werden?

- Entdecken Sie die Vorteile einer Partnerschaft mit B2In und + Entdecken Sie die Vorteile einer Partnerschaft mit B2in und wie Sie von unserem innovativen Ecosystem profitieren können.

@@ -128,8 +128,8 @@ const PartnerBenefits = () => {
- Partner success visualization @@ -148,4 +148,4 @@ const PartnerBenefits = () => { ); }; -export default PartnerBenefits; \ No newline at end of file +export default PartnerBenefits; diff --git a/dev/b2in-layout-v10/src/components/PartnerCTA.tsx b/dev/b2in-layout-v10/src/components/PartnerCTA.tsx index f2cffb0..4e8a87e 100644 --- a/dev/b2in-layout-v10/src/components/PartnerCTA.tsx +++ b/dev/b2in-layout-v10/src/components/PartnerCTA.tsx @@ -10,14 +10,14 @@ const PartnerCTA = () => { Wachsen Sie
mit uns - +
- +

- Werden Sie Teil des B2In-Partnernetzwerks und erschließen Sie neue + Werden Sie Teil des B2in-Partnernetzwerks und erschließen Sie neue Geschäftsmöglichkeiten durch innovative Konnektivitätslösungen.

- +
500+
@@ -32,16 +32,16 @@ const PartnerCTA = () => {

Partner-Support

- +
- +

- Entdecken Sie die Vorteile einer strategischen Partnerschaft mit B2In + Entdecken Sie die Vorteile einer strategischen Partnerschaft mit B2in

@@ -50,4 +50,4 @@ const PartnerCTA = () => { ); }; -export default PartnerCTA; \ No newline at end of file +export default PartnerCTA; diff --git a/dev/b2in-layout-v10/src/components/PartnerHero.tsx b/dev/b2in-layout-v10/src/components/PartnerHero.tsx index c113932..2cdfbf2 100644 --- a/dev/b2in-layout-v10/src/components/PartnerHero.tsx +++ b/dev/b2in-layout-v10/src/components/PartnerHero.tsx @@ -8,15 +8,15 @@ const PartnerHero = () => {

Wachsen Sie mit uns.
- Werden Sie B2In Partner. + Werden Sie B2in Partner.

- +

- Werden Sie Teil des B2In Ecosystems und profitieren Sie von innovativen - Geschäftsmodellen, die nachhaltiges Wachstum und langfristigen Erfolg ermöglichen. + Werden Sie Teil des B2in Ecosystems und profitieren Sie von innovativen + Geschäftsmodellen, die nachhaltiges Wachstum und langfristigen Erfolg ermöglichen. Gemeinsam gestalten wir die Zukunft der Immobilienbranche.

- +
@@ -27,7 +27,7 @@ const PartnerHero = () => {

Lifetime-Vergütung

- +
@@ -37,7 +37,7 @@ const PartnerHero = () => {

Globale Märkte

- +
@@ -47,7 +47,7 @@ const PartnerHero = () => {

Faire Konditionen

- +
@@ -59,7 +59,7 @@ const PartnerHero = () => {
- +
@@ -71,7 +71,7 @@ const PartnerHero = () => {

Partner Network

Werden Sie Teil unseres Ecosystems

- + {/* Partner Types */}
@@ -81,7 +81,7 @@ const PartnerHero = () => {

Makler

Lifetime-Modell

- +
@@ -89,7 +89,7 @@ const PartnerHero = () => {

Lieferanten

Global Markets

- +
@@ -97,7 +97,7 @@ const PartnerHero = () => {

Erfolg

Messbare Ziele

- +
@@ -115,4 +115,4 @@ const PartnerHero = () => { ); }; -export default PartnerHero; \ No newline at end of file +export default PartnerHero; diff --git a/dev/b2in-layout-v10/src/components/PartnerProcess.tsx b/dev/b2in-layout-v10/src/components/PartnerProcess.tsx index 738cef8..588b138 100644 --- a/dev/b2in-layout-v10/src/components/PartnerProcess.tsx +++ b/dev/b2in-layout-v10/src/components/PartnerProcess.tsx @@ -14,7 +14,7 @@ const PartnerProcess = () => { image: room1Image }, { - step: "2", + step: "2", title: "Prüfung", description: "Unser Expertenteam überprüft Ihre Bewerbung sorgfältig. Bei positivem Ergebnis laden wir Sie zu einem persönlichen Gespräch ein.", icon: Search, @@ -37,7 +37,7 @@ const PartnerProcess = () => { So werden Sie Partner

- In nur drei einfachen Schritten werden Sie Teil des B2In Ecosystems + In nur drei einfachen Schritten werden Sie Teil des B2in Ecosystems und können von allen Vorteilen unserer Partnerschaft profitieren.

@@ -46,19 +46,19 @@ const PartnerProcess = () => { {steps.map((step, index) => (
- {step.title}
- + {/* Step Number Badge */}
{step.step}
- +
@@ -68,11 +68,11 @@ const PartnerProcess = () => { {step.title}
- +

{step.description}

- + {index === steps.length - 1 && (
- + {/* Image */}
- Professionelles Team in kollaborativem Meeting @@ -45,4 +45,4 @@ const VisionSection = () => { ); }; -export default VisionSection; \ No newline at end of file +export default VisionSection; diff --git a/dev/entwicklung copy.md b/dev/entwicklung copy.md new file mode 100644 index 0000000..6962a26 --- /dev/null +++ b/dev/entwicklung copy.md @@ -0,0 +1,222 @@ +# B2IN – Projekt-Entwicklungsstand + +**Stand:** März 2026 · **Laravel** 12 · **Livewire** 4 · **Volt** · **Flux UI** (Pro) + +--- + +## Projektübersicht + +Die Anwendung ist eine **Multi-Domain-Laravel-Plattform** für Partner-Onboarding, Produkt- und Hub-Verwaltung, **öffentliche Marketing-Websites** (mehrere Marken/Domains) und **Digital Signage** (Cabinet/Displays). Das **Flux-CMS** (`packages/flux-cms`) ist als Paket eingebunden und steuert zunehmend **Content und Medien** für die Web-Auftritte; klassische Display-Verwaltung (Videos, Footer, Versionen) bleibt über **eigene Admin-Livewire-Komponenten** erreichbar. + +--- + +## Wichtige ToDos + +- **System-E-Mail / Zustellung:** Teilweise Ausfälle oder Blocklisten (z. B. Gmail mit SPF/DKIM-Themen bei united-domains). Langfristig: saubere Domain-Auth, Bounce-Handling, Monitoring. +- **Bestellsystem / Orders:** Berechtigungen sind im `RoleSeeder` vorbereitet (`view orders`, `manage orders`); fachliche Umsetzung und UI folgen. + +--- + +## Kern-Technologien + +| Bereich | Technologie | +|--------|-------------| +| Backend | Laravel 12, PHP ^8.2 (in der Praxis oft 8.4 in der Dev-Umgebung) | +| Frontend (Portal) | Livewire 4, Volt (Single-File-Komponenten), Flux UI Pro | +| Frontend (öffentliche Sites) | Livewire-Sections, Tailwind CSS 4, Alpine.js | +| Auth | Laravel Fortify, Sanctum (API), E-Mail-Verifizierung, 2FA möglich | +| Rechte | Spatie Laravel Permission | +| Tests | Pest, PHPUnit 11 | +| Assets | Vite (getrennte Builds z. B. Portal / Web) | +| Dev | Laravel Sail **oder** Dev Container – in `CLAUDE.md` sind direkte `php`/`composer`/`npm`-Kommandos beschrieben | + +**Hinweis Volt + Livewire 4:** Funktionale Volt-Dateien sollten im PHP-Block **kein erstes `new …`** enthalten (Compiler-Einschränkung); objektorientierte Hilfsklassen liegen z. B. unter `app/Services/`. + +--- + +## Rollen & Berechtigungen (Ist-Zustand) + +Definiert u. a. in `database/seeders/RoleSeeder.php` (Namen exakt so in der Datenbank): + +| Rolle | Kurzbeschreibung | +|-------|------------------| +| **Customer** | Endkunde, Produkt-/Order-sichtbar (eigene Orders) | +| **Estate-Agent** | Makler – Dashboard, Partner-Übersicht, Hubs einsehen | +| **Retailer** | Händler – eigene Produkte anlegen/bearbeiten, Miet-Optionen | +| **Manufacturer** | Hersteller – analog Retailer | +| **Admin** | Voller operativer Zugriff inkl. Partner, Hubs, **Produkt-Kuration**, User | +| **Super-Admin** | für erweiterte Systemzugriffe vorgesehen (Gate/Policy-Pattern) | + +Die ältere Doku sprach teils von „Broker“ – im Code heißt die Rolle **Estate-Agent**. + +--- + +## Entwickelte Module & Funktionen (Auswahl) + +### 1. Multi-Domain-System + +**Status:** produktiv genutzt + +- Domains konfigurierbar (`config/domains.php`, siehe `dev/DOMAINS-CONFIG.md`) +- **Portal:** z. B. `portal.b2in.test` – Admin-Backend +- **Öffentliche Sites:** z. B. `b2in.test`, weitere Marken-Domains mit Theme-Wechsel +- **ThemeServiceProvider**, domainbezogene Vite-Build-Verzeichnisse +- Optional: Simulation einer Domain per `.env` für lokales Testen + +--- + +### 2. Partner-Management & Onboarding + +**Status:** stabil im Einsatz + +- Einladungen (`PartnerInvitationMail`), Token, Ablauf +- **Partner-Setup-Wizard** (`EnsurePartnerSetupCompleted` – unvollständiges Setup leitet zum Wizard) +- Registrierung über Codes, rollenspezifische Landing-Routes (`/reg/...`) +- Hierarchien und Provisionen: **Datenmodell und Berechtigungen** vorhanden; Ausbaustufen für Abrechnung siehe Roadmap + +--- + +### 3. Hub-Management + +**Status:** CRUD und Verknüpfungen nutzbar + +- Admin-Routen für Hubs und Standorte (`hub_locations`) +- Detailausbauten (Analytics, automatische Zuweisung) roadmap + +--- + +### 4. Produkt-Management + +**Status:** deutlich über „Grundgerüst“ hinaus + +- Partner können u. a. **Standard-** und **Teaser-Produkte** anlegen/bearbeiten (Volt-Formulare) +- **Admin-Kuration:** Rolle `Admin`, Permission `curate products` – Freigabe/Status (Tests in `tests/Feature/ProductCurationTest.php`) +- Katalog-Modell: Produkte, Varianten, Kategorien, Marken, Tags, Logistik u. a. + +--- + +### 5. Digital Signage (Cabinet / Displays) + +**Status:** betrieben; technische Doku unter `dev/DISPLAY_CMS_README.md`, `dev/DISPLAY_SETUP_LIVE.md` + +- Admin: Display-Versionen, Playlists, Footer-Links, Shortlink/Tracking (siehe bestehende README) +- Öffentliche/Showroom-Assets u. a. unter `public/_cabinet/` +- APIs z. B. Display-Konfiguration (`routes/domains.php`) + +Parallel existiert die **Flux-CMS-Medienverwaltung** für Website-Assets – beide Welten können koexistieren (unterschiedliche Einsatzgebiete). + +--- + +### 6. Flux CMS (Package-Integration) + +**Status:** **eingebunden und im Portal bedienbar** – kontinuierliche Content-Pflege und Ausbau + +- Paketpfad: `packages/flux-cms/` (`core`, `components`, `starter-components`) +- Composer: `flux-cms/core` (path repository) +- **Portal-Routen (Auszug):** u. a. `admin/flux-cms` (Dashboard), Content, Projekte, Medien, Artikel – Namen wie `cms.dashboard`, `cms.content.index`, … +- **App-Integration:** Hilfsfunktionen (`cms()`, `media_url()`, … in `app/helpers.php`), Models wie `CmsContent`, `CmsProject`, `CmsArticle` (Migrationen u. a. `cms_projects`, `cms_articles`) +- Architekturüberblick: `packages/flux-cms/ARCHITECTURE.md` +- **Ziel laut `dev/flux-cms/tasks.md`:** bestehende B2IN-Website schrittweise ins CMS überführen; weitere Subseiten später + +--- + +### 7. Öffentliche Website & Content-Strategie + +**Status:** laufende inhaltliche Ausrichtung + +- Viele Seiten als **Livewire-Section-Komponenten** unter `app/Livewire/Web/Components/Sections/` +- **Immobilien-Fokus (Soft Launch):** strategische Ausrichtung und Text-Briefings liegen in `dev/12-03-2026/tasks.md` (Fokus Dubai/Immobilien, Möbel/Einrichtung zunächst nur Teaser; bestehende Inhalte konservieren, ggf. über ergänzende/„versteckte“ Pfade wie bei Theme-Demos) +- Routen u. a. `/immobilien`, `/immobilien/{slug}` – Sektionen u. a. über `cms_theme_section('…')` pro Theme-Key (siehe `resources/views/web/immobilien.blade.php`) + +--- + +### 8. Authentifizierung & Einstellungen + +**Status:** produktiv + +- Fortify-Features (Login, Register, Reset, Verify, 2FA) +- Profil, Passwort, Erscheinungsbild (Volt) + +--- + +### 9. Admin-Extras + +- **Projekt-Dokumentation** im Portal: Route `admin/documentation` rendert diese Datei `dev/entwicklung.md` (Service `App\Services\ProjectDocumentationContent`) +- **Impersonation** für Admins +- **CMS/Display-Admin:** getrennte Oberflächen für Flux-CMS und klassische Display-Pflege (siehe Routen oben) + +--- + +## Datenbank (Auszug) + +Neben den Kern-Tabellen (User, Partner, Produkte, Hubs, Display-Tabellen, …) u. a.: + +- **`cms_projects`**, **`cms_articles`** – projekt-/artikelbezogene CMS-Daten (App-Models) +- Flux-CMS-Paket kann je nach Migration weitere `flux_cms_*`-Tabellen mitbringen – bei Deploy/Tests Migrationen aus Core + App berücksichtigen + +--- + +## Dokumentation im Repository + +| Pfad | Inhalt | +|------|--------| +| `Readme.md` | Projektüberblick | +| `CLAUDE.md` | Dev-Container, Befehle, Tests (SQLite) | +| `dev/DOMAINS-CONFIG.md` | Domains & `.env` | +| `dev/LOCAL-DEVELOPMENT.md` | Lokales Setup | +| `dev/PARTNER-SETUP-WIZARD.md` | Partner-Wizard | +| `dev/DISPLAY_*.md` | Display/Cabinet | +| `dev/THEME-SWITCHING.md` | Themes | +| `dev/12-03-2026/tasks.md` | Aktuelle Web-/Immobilien-Briefings | +| `packages/flux-cms/ARCHITECTURE.md` | CMS-Architektur | + +*(Es gibt kein `dev/HERO-ICONS-USAGE.md` mehr – Icons: Flux/Heroicons nach Projektstandard.)* + +--- + +## Tests & Qualität + +- **Pest**-Feature-Tests u. a. für Auth, Partner, Produkte, CMS-Admin, Display-APIs +- Vor Releases sinnvoll: `php artisan test` (bzw. im Container/Sail das Projekt-Äquivalent) +- **Laravel Pint** für PHP-Code-Stil + +--- + +## Roadmap (Kurz) + +**Kurzfristig** + +- E-Mail-Zustellung / Domain-Reputation stabilisieren +- Immobilien- und Teaser-Content gemäß `dev/12-03-2026/tasks.md` weiterziehen +- Flux-CMS: bestehende Startseite/Kernseiten vollständig pflegbar machen + +**Mittelfristig** + +- Bestellprozess end-to-end +- Provisionslogik +- Reporting + +**Langfristig** + +- API-Erweiterungen, ggf. Mobile-Clients über Sanctum +- Internationalisierung dort, wo fachlich nötig + +--- + +## Projektstatus – Kompakt + +| Bereich | Einordnung | +|---------|------------| +| Multi-Domain & Themes | stabil | +| Auth & Rollen | stabil | +| Partner & Wizard | stabil | +| Produkte inkl. Kuration | weit fortgeschritten | +| Hubs | nutzbar, Ausbau möglich | +| Display/Cabinet | stabil | +| Flux CMS | **integriert**, Content-Ausbau läuft | +| Öffentliche Website | live-tauglich, Schwerpunkt Immobilien/Soft Launch | +| Bestellsystem | vorbereitet, nicht abgeschlossen | + +--- + +*Diese Datei ist die Referenz für die interne Projekt-Dokumentation (`admin/documentation`) und soll bei größeren Meilensteinen angepasst werden.* diff --git a/dev/entwicklung.md b/dev/entwicklung.md index 72831d3..ab046f4 100644 --- a/dev/entwicklung.md +++ b/dev/entwicklung.md @@ -2,7 +2,7 @@ ## Projektübersicht -Das B2IN-Projekt ist eine umfassende Multi-Domain-Laravel-Anwendung, die als zentrale Plattform für Partner-Management, Produkt-Verwaltung und Digital Signage dient. Die Anwendung basiert auf Laravel 12, Livewire 3 und Flux UI und bietet ein modernes, rollenbasiertes System für verschiedene Geschäftspartner. +Das B2IN-Projekt ist eine umfassende Multi-Domain-Laravel-Anwendung, die als zentrale Plattform für Partner-Management, Produkt-Verwaltung, Digital Signage und Immobilien-Investment dient. Die Anwendung basiert auf Laravel 12, Livewire 3 und Flux UI und bietet ein modernes, rollenbasiertes System für verschiedene Geschäftspartner. Seit Q1 2026 wurde die Plattform strategisch um den Bereich **Dubai Real Estate Investment** als primären Geschäftszweig erweitert – B2in positioniert sich nun als "International Real Estate + Design Ecosystem". --- ## 🎯 Wichtige ToDos @@ -16,13 +16,15 @@ In der Zukunft benötigen wir einen response bounce etc. ## 🎯 Kern-Technologien -- **Framework**: Laravel 12 mit PHP 8.2+ +- **Framework**: Laravel 12 mit PHP 8.4+ - **Frontend**: Livewire 3 mit Volt (Single-File Components) -- **UI-Framework**: Flux UI (Pro-Version) mit Tailwind CSS +- **UI-Framework**: Flux UI (Pro-Version v2) mit Tailwind CSS v4 - **Authentifizierung**: Laravel Fortify mit Sanctum - **Berechtigungen**: Spatie Laravel-Permission +- **Mehrsprachigkeit**: Spatie Laravel-Translatable (für CMS-Inhalte) - **Icons**: Heroicons (Blade-Integration) -- **Entwicklungsumgebung**: Laravel Sail (Docker) +- **Entwicklungsumgebung**: Dev Container (Docker) – Befehle direkt ohne Sail-Prefix +- **MCP-Integration**: Laravel Boost MCP-Server für Entwicklungsunterstützung --- @@ -229,9 +231,9 @@ Rollenbasiertes Dashboard mit individuellen KPIs: --- -### 7. Digital Signage / Display-CMS +### 7. Digital Signage / Display-CMS (Legacy) -**Status**: ✅ Vollständig implementiert +**Status**: ✅ Vollständig implementiert (Legacy-System → siehe auch Abschnitt 14 für Neuarchitektur) Professionelles CMS-System für Digital Signage im Cabinet Showroom Bielefeld: @@ -309,16 +311,24 @@ Modernes Auth-System basierend auf Laravel Fortify & Sanctum: Moderne, responsive Website mit zahlreichen Sections: #### Implementierte Seiten: -- **Home** - Hauptseite mit Hero-Slider -- **About** - Über uns -- **Ecosystem** - Ökosystem-Darstellung -- **Partner** - Partner-Informationen +- **Home** - Hauptseite mit Hero-Slider (Q1 2026: Repositionierung "Connecting Design and Property") +- **About** - Über uns (Q1 2026: Neue Timeline "Die Erweiterung 2025/2026") +- **Ecosystem** - Ökosystem-Darstellung (Q1 2026: Drei-Säulen-Modell) +- **Partner** - Partner-Informationen (Q1 2026: "Für Immobilienentwickler"-Bereich) - **Portfolio** - Produkt-Portfolio -- **Magazin** - Blog/Magazin mit Detail-Ansichten -- **Contact** - Kontaktformular +- **Magazin** - Blog/Magazin mit Detail-Ansichten (Q1 2026: 5 vollständige Artikel de/en) +- **Contact** - Kontaktformular (Q1 2026: Neue Betreff-Optionen Immobilien/Supply-Chain) - **Service** - Service-Seite -- **FAQ** - Häufig gestellte Fragen +- **FAQ** - Häufig gestellte Fragen (Q1 2026: Immobilien & Supply-Chain Fokus) - **Theme-Demo** - Theme-Vorschau +- **Immobilien** *(NEU)* - Dubai Immobilien-Übersicht +- **Immobilien Detail** *(NEU)* - Projekt-Einzelansicht mit Investment-Case +- **Interior** *(NEU)* - Interior Design / Einrichtungs-Showcase +- **Netzwerk** *(NEU)* - Partner-Netzwerk mit Cabinet-Integration +- **Impressum** *(NEU)* - Rechtliches Impressum +- **Datenschutz** *(NEU)* - Datenschutzerklärung +- **AGB** *(NEU)* - Allgemeine Geschäftsbedingungen +- **Cookie-Policy** *(NEU)* - Cookie-Richtlinie #### Wiederverwendbare Sections: - Hero (Standard, mit Bild, Slider, Tiles) @@ -345,6 +355,9 @@ Moderne, responsive Website mit zahlreichen Sections: - Spotlights-Section - Supplier-Section - Vision-Section +- **FounderBar** *(NEU)* - Gründer/CEO-Vorstellung mit Statement +- **ImageBreak** *(NEU)* - Wiederverwendbare Bildtrennung (konfigurierbar per Section-Name) +- **ImmobilienContactForm** *(NEU)* - Spezialisiertes Kontaktformular für Immobilien #### UI-Komponenten: - Header mit Navigation @@ -352,6 +365,8 @@ Moderne, responsive Website mit zahlreichen Sections: - Top-Bar - Kontaktformular - Theme-Switcher (für Demos) +- **AnnouncementBar** *(NEU)* - Ankündigungsleiste +- **web-picture** *(NEU)* - Blade-Komponente für responsive Bilder --- @@ -397,6 +412,193 @@ Eigenes CMS-Package in `packages/flux-cms/`: - `components/` - Livewire Backend & Frontend Components - `starter-components/` - Vorgefertigte Starter-Components +#### Neue CMS-Models (seit Q1 2026): +- `CmsContent` - Gruppen/Key-basierter Content mit übersetzbaren Werten +- `CmsDownload` - Mediendatei-Verwaltung +- `CmsFaq` - FAQ mit übersetzbaren Frage-Antwort-Paaren +- `CmsIndustry` - Branchen-Taxonomie +- `CmsLinkedinPost` - Social-Media-Integration +- `CmsMedia` - Media-Library mit Konvertierungs-Tracking +- `CmsNewsItem` - News/Artikel +- `CmsSearchIndex` - Volltextsuche-Index + +#### Neue Services: +- `CmsContentService` - Content-Abruf und -Verwaltung +- `HeroiconOutlineList` - Icon-Referenz-Service +- `MediaConversionService` - Bild/Video-Konvertierungs-Pipeline + +--- + +### 13. Immobilien-Plattform (Dubai Real Estate) + +**Status**: ✅ Soft-Launch implementiert (seit März 2026) + +Vollständige Dubai-Immobilien-Investitionsplattform als neuer Hauptgeschäftszweig: + +#### Seiten: +- **Immobilien-Übersicht** (`/immobilien`) - Listing mit Hero, Fakten, Kaufprozess, Trust-Blocks, Mindset-Check +- **Projekt-Detail** (`/immobilien/{slug}`) - Einzelansicht mit Galerie, Investment-Case, Trust/Möbel-Vorteile + +#### Datenmodelle: +- **CmsProject** - Immobilienprojekte mit mehrsprachigen Feldern (Spatie Translatable) + - Slug, Titel, Standort, Beschreibung, Features (JSON) + - Preis in AED mit automatischer EUR/USD-Umrechnung + - Investor-Trust-Block (Escrow, DLD-Kontrolle, Transparenz) + - Möbel-Vorteil-Block (exklusives B2in-Einrichtungsnetzwerk) +- **CmsArticle** - Magazin-Artikel mit Mehrsprachigkeit (de/en) + - Kategorie, Autor, Lesezeit, Veröffentlichungsstatus + - Content als JSON-Struktur (Sections mit Intro, Text, Listen, Zitaten) + +#### Magazin-Artikel (5 vollständige Artikel): +1. Escrow-System Dubai – Wie Käufer geschützt werden +2. Spotlight: Al Jaddaf – Dubais aufstrebender Kreativdistrikt +3. Turnkey-Investments – Schlüsselfertig vom Plan bis zur Einrichtung +4. Supply-Chain-Management – Deutsche Qualität für internationale Projekte +5. Local-for-Local – Wie B2in lokale Händler mit internationalen Investoren verbindet + +#### PriceHelper: +- Statische Preisformatierung AED → EUR/USD +- Feste Wechselkurse: AED/USD 3.6725, USD/EUR 1.08 +- Format: "ab 1.125.000 AED (ca. 284.000 EUR / 306.000 USD)" + +#### Seeders: +- `CmsProjectSeeder` - Immobilienprojekte aus Lang-Dateien +- `CmsArticleSeeder` - Magazin-Artikel aus Lang-Dateien (de/en) + +--- + +### 14. Display-Versions-System (Neuarchitektur) + +**Status**: ✅ Vollständig implementiert (Februar 2026) + +Komplett überarbeitetes Display-Management mit Versions-basiertem Content: + +#### Drei-Tabellen-Architektur: +- **Display** - Physische Display-Geräte (Name, Standort, Aktiv-Status) +- **DisplayVersion** - Content-Versionen mit Typ-basiertem System +- **DisplayVersionItem** - Einzelne Content-Items (Video, Footer, Media, Slides) mit JSON-Content + +#### DisplayVersionType Enum: +- `video-display` - Video-Playlists +- `b2in` - B2in-Marken-Content +- `offers` - Angebots-Slides + +#### API-Endpunkte: +- `GET /api/display/config` - Vollständige Playlist-Konfiguration als JSON +- `GET /api/display/check` - Lightweight Update-Check (Timestamp + Status) + +#### Legacy-Migration: +- `MigrateLegacyDisplays`-Kommando konvertiert alte DisplayVideo/DisplayFooterContent-Daten in neues System + +--- + +### 15. Cabinet Tablet-Management + +**Status**: ✅ Vollständig implementiert (Februar/März 2026) + +Verwaltungssystem für Tablet-Displays im Cabinet Showroom Bielefeld: + +#### CabinetTabletSetting (Singleton-Pattern): +- Store-Status-Modi: open, notice, closed, warning +- Tägliche Override-Zeiten (override_open_today, override_close_today) mit Mitternacht-Auto-Reset +- 7-Tage-Öffnungszeiten mit deutschen Labels (Montag–Sonntag) +- Nächster Termin (Datum + Uhrzeit) +- Kontaktdaten (Telefon, E-Mail) +- Status-Berechnung basierend auf Berliner Zeitzone + +#### API-Endpunkte: +- `GET /api/cabinet-tablet/status` - Vollständige Settings (Öffnungszeiten, Kontakt, Termine, Hinweise) +- `GET /api/cabinet-tablet/check` - Schnell-Poll für Status-Änderungen + +#### Admin-Oberfläche: +- `CabinetInfoTablet` - Livewire-Komponente für Öffnungszeiten und Kontaktdaten +- `QuickStatus` - Livewire-Komponente für schnelle Status-Änderungen (Auto/Geschlossen/Hinweis/Warnung) +- Key-basierte Autorisierung über Query-Parameter + +--- + +### 16. Homepage & Brand-Repositionierung + +**Status**: ✅ Umgesetzt (Februar/März 2026) + +Komplette Neuausrichtung der B2in-Marke: + +#### Strategische Änderungen: +- **Neuer Hero**: "B2in – Connecting Design and Property" mit dualem Positioning (Immobilien + Einrichtung) +- **Founder Bar**: Marcel Scheibe als CEO/Founder mit persönlichem Statement auf allen relevanten Seiten +- **Ecosystem-Drei-Säulen-Modell**: + 1. Internationale Immobilien + 2. Exklusive Einrichtung + 3. Supply-Chain-Management +- **Partner-Section**: Neuer "Für Immobilienentwickler"-Bereich (Supply-Chain-Fokus) +- **FAQ-Update**: 5 Fragen mit Immobilien- und Supply-Chain-Fokus +- **Brand Worlds Neuordnung**: Stileigentum → Style2Own → B2A + +#### Aktualisierte Seiten: +- Home, About (neue Timeline: "Die Erweiterung 2025/2026"), Ecosystem, Partner +- Contact (neue Betreff-Optionen für Immobilien/Supply-Chain) +- Magazin (mit Immobilien-Artikeln) + +--- + +### 17. Neue Webseiten & Rechtliches + +**Status**: ✅ Implementiert (März 2026) + +#### Neue Seiten: +- `/immobilien` - Dubai Immobilien-Plattform +- `/immobilien/{slug}` - Projekt-Detailseite +- `/interior` - Interior Design / Einrichtungs-Showcase +- `/netzwerk` - Partner-Netzwerk (mit Cabinet-Integration) +- `/impressum` - Impressum +- `/privacy` - Datenschutzerklärung +- `/terms` - AGB +- `/cookie-policy` - Cookie-Richtlinie + +#### Neue Livewire-Sections: +- `FounderBar` - Gründer/CEO-Vorstellung (Marcel Scheibe) +- `ImageBreak` - Wiederverwendbare Bildtrennungs-Komponente +- `ImmobilienContactForm` - Spezialisiertes Kontaktformular für Immobilien-Anfragen + +--- + +### 18. Mehrsprachigkeit & Lokalisierung + +**Status**: ✅ Implementiert (März 2026) + +#### SetLocale-Middleware: +- Neue Middleware in `bootstrap/app.php` registriert +- Erkennung über `session('locale')`, setzt App-Locale (de/en) +- Integriert in Web-Middleware-Gruppe + +#### Sprachdateien: +- `resources/lang/de/b2in.php` - Deutsche Übersetzungen (Immobilien, Magazin, UI) +- `resources/lang/en/b2in.php` - Englische Übersetzungen +- `resources/lang/de/b2in_legal.php` / `en/b2in_legal.php` - Rechtliche Texte +- `resources/lang/de/ui.php` / `en/ui.php` - UI-Elemente + +--- + +### 19. Artisan-Kommandos + +**Status**: ✅ Implementiert + +#### Neue Kommandos: +- **ConvertImagesToWebP** - Batch-Konvertierung JPG/PNG → WebP + - Optionen: `--path`, `--quality` (Standard 85%), `--force`, `--dry-run` + - Zeigt Kompressions-Statistiken (Dateigrößen-Reduktion %) +- **MigrateLegacyDisplays** - Einmalige Migration von altem DisplayVideo/DisplayFooterContent ins neue System +- **ResetCabinetTabletOverrides** - Geplante Aufgabe zum Zurücksetzen täglicher Zeitüberschreibungen um Mitternacht + +--- + +### 20. Produkt-Kuration + +**Status**: 🔄 Erweitert (Februar 2026) + +- Neue Berechtigung `curate_products` hinzugefügt (Migration `2026_02_27_154145`) +- Ermöglicht spezifische Produktkurations-Rechte unabhängig von allgemeinem Produkt-Management + --- ## 🗄️ Datenbank-Struktur @@ -435,9 +637,32 @@ Eigenes CMS-Package in `packages/flux-cms/`: - `tax_rates` - Steuersätze - `shipping_classes` - Versandklassen -#### Display/CMS: -- `display_videos` - Video-Playlist für Digital Signage -- `display_footer_contents` - Footer-Inhalte mit Short-Links & Tracking +#### Display/CMS (Legacy): +- `display_videos` - Video-Playlist für Digital Signage (Legacy) +- `display_footer_contents` - Footer-Inhalte mit Short-Links & Tracking (Legacy) + +#### Display-Versions-System (Neu, Feb 2026): +- `display_versions` - Content-Versionen (Name, Typ, Settings JSON, Aktiv-Status) +- `display_version_items` - Content-Items (FK, Item-Typ, Content JSON, Sortierung) +- `displays` - Physische Display-Geräte (Name, Standort, Aktiv-Status) +- `display_display_version` - Pivot-Tabelle (Many-to-Many mit Sortierung) + +#### Cabinet Tablet (Neu, Feb/März 2026): +- `cabinet_tablet_settings` - Showroom-Status, Öffnungszeiten (7 Tage), Kontaktdaten, Overrides + +#### CMS-Inhalte (Neu, März 2026): +- `cms_projects` - Immobilienprojekte (JSON-Felder für Mehrsprachigkeit, Preis in AED) +- `cms_articles` - Magazin-Artikel (JSON für Titel/Untertitel/Content, Kategorie, Autor) + +#### Flux CMS Package (Neu, 2026): +- `flux_cms_contents` - Gruppen/Key-basierter Content +- `flux_cms_downloads` - Downloads +- `flux_cms_linkedin_posts` - LinkedIn-Posts +- `flux_cms_faqs` - FAQ-Einträge +- `flux_cms_news_items` - News +- `flux_cms_industries` - Branchen +- `flux_cms_media` - Media-Library +- `flux_cms_search_index` - Volltextsuche #### System: - `media` - Media-Verwaltung @@ -489,6 +714,11 @@ Umfangreiche Entwicklungs-Dokumentation: - `dev/ENV_VARIABLES_DISPLAY.md` - Display-Umgebungsvariablen - `dev/THEME-SWITCHING.md` - Theme-System - `dev/THEME-DEMO-COMPONENTS.md` - Theme-Demo +- `dev/27-02-2026/` - Entwicklungsdokumentation Relaunch Februar 2026 +- `dev/12-03-2026/` - Entwicklungsdokumentation Immobilien-Launch März 2026 +- `packages/flux-cms/ARCHITECTURE.md` - CMS-Package Architektur +- `packages/flux-cms/MIGRATION.md` - CMS-Migrations-Leitfaden +- `packages/flux-cms/SETUP.md` - CMS-Setup-Anleitung --- @@ -536,8 +766,42 @@ composer dev # Startet Server, Queue, Logs & Vite parallel - TestCase-Struktur vorhanden - Browser-Testing mit Laravel Dusk - Feature & Unit Tests vorbereitet +- SQLite in-memory für Tests (MySQL wird nicht berührt) +- Automatischer Config-Cache-Reset vor jedem Test-Run -**Hinweis**: Test-Implementierung kann bei Bedarf erweitert werden. +### Neue Tests (seit Q1 2026): + +#### Display & Tablet Tests: +- `DisplayVersionTest` - Model-Beziehungen, activeItems() +- `DisplayVersionApiTest` - API-Config/Check-Endpunkte +- `DisplayListTest` - Display CRUD-Operationen +- `CabinetInfoTabletTest` - Info-Tablet-Display-Integration +- `CabinetTabletApiTest` - API-Endpunkte (Status/Check) +- `CabinetQuickStatusTest` - Livewire-Komponenten-Interaktion +- `ResetCabinetTabletOverridesTest` - Mitternacht-Reset-Logik + +#### Immobilien/CMS Tests: +- `ImmobilienShowTest` - Projekt-Detailseite-Rendering +- `CmsLegalSeederTest` - Rechtliche Inhalte (Privacy/Terms/Impressum) +- `MagazinPageTest` - Magazin-Seiten +- `ContactFormTest` - Kontaktformular + +#### Weitere Tests: +- `AboutPageTest` - About-Seite +- `AnnouncementBarTest` - Announcement-Bar +- `InteriorPageTest` - Interior-Seite +- `SoftLaunchDevPagesTest` - Dev-Seiten im Soft-Launch +- `PartnerSelfServiceProfileTest` - Partner Self-Service +- `CmsFluxEditorHtmlTransformerTest` (Unit) - HTML-Transformation +- `CabinetTabletSettingTest` (Unit) - Status-Berechnung, Öffnungszeiten + +#### Factories: +- `CabinetTabletSettingFactory` +- `CmsArticleFactory` (mehrsprachig) +- `CmsProjectFactory` (mehrsprachig) +- `DisplayFactory` +- `DisplayVersionFactory` (mit Typ) +- `DisplayVersionItemFactory` --- @@ -566,7 +830,9 @@ composer dev # Startet Server, Queue, Logs & Vite parallel - `livewire/flux-pro` ^2.6 - `livewire/volt` ^1.7.0 - `spatie/laravel-permission` ^6.17 +- `spatie/laravel-translatable` (NEU - für CMS-Inhalte) - `blade-ui-kit/blade-heroicons` ^2.6 +- `laravel/mcp` ^0.x (NEU - MCP-Server-Integration) ### Dev-Abhängigkeiten: - `laravel/sail` ^1.41 @@ -612,11 +878,21 @@ composer dev # Startet Server, Queue, Logs & Vite parallel - Authentifizierung & Sicherheit - Website-Frontend (Grundstruktur) - Dokumentation +- **NEU**: Immobilien-Plattform (Dubai Real Estate) mit Soft-Launch +- **NEU**: Display-Versions-System (Neuarchitektur) +- **NEU**: Cabinet Tablet-Management +- **NEU**: Homepage & Brand-Repositionierung +- **NEU**: Mehrsprachigkeit (de/en) mit SetLocale-Middleware +- **NEU**: Rechtliche Seiten (Impressum, Datenschutz, AGB, Cookie-Policy) +- **NEU**: Magazin mit 5 vollständigen Artikeln (de/en) +- **NEU**: Interior- und Netzwerk-Seiten +- **NEU**: WebP-Konvertierungs-Tool +- **NEU**: Umfangreiche Test-Suite mit Factories ### 🔄 In Arbeit: -- Flux-CMS-Package (Architektur steht) -- Erweiterte Produkt-Features -- Test-Abdeckung +- Flux-CMS-Package (Architektur steht, neue Models und Services hinzugefügt) +- Erweiterte Produkt-Features + Produkt-Kuration +- Weitere Immobilienprojekte (aktuell: Azizi Creek Views 4) ### 📋 Bereit für Umsetzung: - Bestellsystem @@ -629,11 +905,13 @@ composer dev # Startet Server, Queue, Logs & Vite parallel ## 💡 Besonderheiten & Highlights ### Technische Exzellenz: -- **Modern Stack** - Neueste Laravel/Livewire-Versionen +- **Modern Stack** - Neueste Laravel 12 / Livewire 3 / PHP 8.4+ / Tailwind CSS v4 - **Component-Architecture** - Wiederverwendbare Livewire-Komponenten -- **Type-Safety** - PHP 8.2+ Features -- **Performance** - Optimierte Queries, Caching +- **Type-Safety** - PHP 8.4+ Features inkl. Enums +- **Performance** - Optimierte Queries, Caching, WebP-Bildkonvertierung - **Skalierbarkeit** - Modularer Aufbau für Wachstum +- **API-First Display-System** - JSON-basierte Konfiguration für Signage-Geräte +- **Mehrsprachigkeit** - DE/EN mit Spatie Translatable und Laravel-Lang-Dateien ### Business-Features: - **Multi-Tenant-Ready** - Verschiedene Partner-Typen @@ -641,6 +919,9 @@ composer dev # Startet Server, Queue, Logs & Vite parallel - **Flexibles Provisionsmodell** - Prozentsatz oder Festbetrag - **Umfangreiches Tracking** - Display-Klicks, User-Aktivitäten - **White-Label-Fähigkeit** - Domain-spezifisches Branding +- **Immobilien-Investment** - Vollständige Dubai Real Estate-Plattform mit Preisumrechnung +- **Content-Management** - Magazin-Artikel, Projekte, rechtliche Inhalte +- **Showroom-Management** - Cabinet Tablet mit Öffnungszeiten und Live-Status ### User-Experience: - **Intuitives Onboarding** - Setup-Wizard für neue Partner @@ -648,6 +929,8 @@ composer dev # Startet Server, Queue, Logs & Vite parallel - **Real-time Updates** - Livewire für nahtlose Interaktion - **Responsive Design** - Funktioniert auf allen Geräten - **Dark Mode** - Moderne UI-Präferenzen +- **Founder-Vertrauen** - CEO-Positioning auf relevanten Seiten +- **Trust-Building** - Escrow, DLD-Kontrolle, Investoren-Transparenz --- @@ -664,13 +947,14 @@ Das System ist produktionsbereit und kann bei Bedarf mit folgenden Services erwe --- -**Entwicklungsstand**: Dezember 2025 -**Version**: 1.0 (Production-Ready) -**Framework**: Laravel 12 -**PHP**: 8.2+ +**Entwicklungsstand**: März 2026 +**Version**: 2.0 +**Framework**: Laravel 12 +**PHP**: 8.4+ **Lizenz**: MIT --- -*Diese Dokumentation wurde automatisch generiert und gibt den aktuellen Stand der Entwicklung wieder.* +*Diese Dokumentation wird fortlaufend aktualisiert und gibt den aktuellen Stand der Entwicklung wieder.* +*Letzte Aktualisierung: März 2026* diff --git a/dev/file-upload/README.md b/dev/file-upload/README.md new file mode 100644 index 0000000..4d0cc87 --- /dev/null +++ b/dev/file-upload/README.md @@ -0,0 +1,458 @@ +# File Upload mit Livewire Volt + Flux UI + +Vollständige Referenz für den Bild-Upload, wie er in `resources/views/livewire/products/form-teaser.blade.php` implementiert ist. + +--- + +## Überblick + +Der Upload nutzt: +- **Livewire `WithFileUploads`** – verwaltet temporäre Uploads via signierter URL +- **Flux UI `flux:file-upload`** – UI-Komponente (Dropzone + Vorschau) +- **Laravel `Storage::disk('public')`** – Permanente Speicherung +- **Polymorphe `media`-Tabelle** – Zuordnung von Dateien zu beliebigen Models +- **Alpine.js** – Drag-&-Drop-Sortierung der vorhandenen Bilder + +--- + +## 1. PHP / Livewire Volt – Komponentenlogik + +### Trait einbinden + +```php +use Livewire\WithFileUploads; + +new class extends Component { + use WithFileUploads; + + /** @var array<\Livewire\Features\SupportFileUploads\TemporaryUploadedFile> */ + public array $mainImages = []; +``` + +`WithFileUploads` muss zwingend eingebunden sein. Ohne ihn reagiert `wire:model` nicht auf Datei-Inputs. + +### Validierung + +```php +'mainImages' => 'nullable|array|min:0|max:10', +'mainImages.*' => 'mimes:jpeg,jpg,png|max:10240', +``` + +- `mainImages` ist ein **Array** (wegen `multiple`-Upload) +- `mainImages.*` validiert jede einzelne Datei +- `max:10240` = 10 MB in Kilobyte +- Livewire hat intern ein Default-Limit von 12 MB – das greift, bevor die eigene Validierung läuft (Achtung: Wert muss ≤ 12288 KB sein oder `livewire.temporary_file_upload.rules` anpassen) + +### Einzelnes Bild entfernen (Vorschauliste) + +```php +public function removePhoto(int $index): void +{ + if (isset($this->mainImages[$index])) { + unset($this->mainImages[$index]); + $this->mainImages = array_values($this->mainImages); + } +} +``` + +Nach `unset()` unbedingt `array_values()` aufrufen, damit die Array-Indizes wieder bei 0 beginnen – sonst bricht `@foreach` mit `$index` im Template. + +### Vorhandenes Bild aus der DB löschen + +```php +public function removeExistingMedia(int $mediaId): void +{ + $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(); + } +} +``` + +Immer erst die **Datei** vom Disk löschen, dann den **DB-Eintrag**. Anschließend `$this->existingMedia` synchronisieren, damit Livewire den State neu rendert. + +### Reihenfolge aktualisieren (Drag & Drop) + +```php +public function updateMediaOrder(array $orderedIds): void +{ + foreach ($orderedIds as $position => $mediaId) { + $this->product->media() + ->where('id', $mediaId) + ->update(['order_column' => $position + 1]); + } + + // Lokalen State synchronisieren + $this->existingMedia = collect($orderedIds) + ->map(fn ($id, $index) => collect($this->existingMedia)->firstWhere('id', $id) + ? array_merge(collect($this->existingMedia)->firstWhere('id', $id), ['order_column' => $index + 1]) + : null + ) + ->filter() + ->values() + ->toArray(); +} +``` + +### Bilder permanent speichern (Neu-Anlage) + +```php +$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++, + ]); +} +``` + +`$image->store(...)` verschiebt die temporäre Livewire-Datei in den endgültigen Pfad auf dem `public`-Disk. + +### Bilder permanent speichern (Bearbeiten – neue Bilder hinzufügen) + +```php +$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++, + ]); +} +``` + +`order_column` an bestehenden Max-Wert anhängen, nicht von 1 neu beginnen. + +### Nach dem Speichern zurücksetzen + +```php +// Neue Bilder leeren +$this->mainImages = []; + +// Vorhandene Bilder aus DB neu laden (mit sortBy) +$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(); +``` + +--- + +## 2. Blade / Flux UI – Template + +### Upload-Dropzone + +```blade + + + +``` + +- `wire:model="mainImages"` – bindet an das Array-Property +- `multiple` – erlaubt Mehrfachauswahl +- `accept` – schränkt den Browser-Dateidialog ein (kein serverseitiger Schutz!) +- `with-progress` – zeigt Upload-Fortschrittsbalken + +### Vorschauliste der neu hinzugefügten Bilder + +```blade +@if (isset($mainImages) && count($mainImages) > 0) +
+ @foreach ($mainImages as $index => $image) + + + + + + @endforeach +
+@endif +``` + +**Wichtig bei `temporaryUrl()`:** +Die Methode gibt nur dann eine URL zurück, wenn die Datei auch vorschaubar ist (`isPreviewable()` prüft die MIME-Type-Whitelist in `config/livewire.php`). Immer beide Bedingungen prüfen, sonst Fehler. + +### Fehleranzeige + +```blade + +``` + +Zeigt Validierungsfehler für das gesamte Array an (z. B. „Maximal 10 Bilder erlaubt."). +Für Fehler auf einzelnen Dateien würde `name="mainImages.0"` etc. verwendet. + +### Drag-&-Drop-Sortierung vorhandener Bilder + +```blade +
+ + @foreach ($existingMedia as $mediaIndex => $media) +
+ + @if ($mediaIndex === 0) +
+ Standard +
+ @endif + + {{ $media['alt_text'] ?? '' }} + + +
+ @endforeach +
+``` + +**Wichtig:** `wire:key="existing-media-{{ $media['id'] }}"` ist zwingend, damit Livewire beim Re-Render die DOM-Elemente korrekt zuordnet. + +--- + +## 3. Datenbank – Media-Tabelle + +```php +// Migration: database/migrations/xxxx_create_media_table.php + +Schema::create('media', function (Blueprint $table) { + $table->id(); + $table->string('model_type'); // z. B. "App\Models\Product" + $table->unsignedBigInteger('model_id'); + $table->string('file_path'); // relativer Pfad auf dem public-Disk + $table->string('type')->default('image'); // 'image', 'video', 'pdf', '3d_model' + $table->string('alt_text')->nullable(); + $table->integer('order_column')->default(0); + $table->timestamps(); + + $table->index(['model_type', 'model_id']); +}); +``` + +### Media-Model (`app/Models/Media.php`) + +```php +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 */ + public function model(): MorphTo + { + return $this->morphTo(); + } +} +``` + +### Beziehung im Parent-Model (`app/Models/Product.php`) + +```php +use Illuminate\Database\Eloquent\Relations\MorphMany; + +public function media(): MorphMany +{ + return $this->morphMany(Media::class, 'model'); +} +``` + +--- + +## 4. Filesystem-Konfiguration (`config/filesystems.php`) + +```php +'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => env('APP_URL') . '/storage', + 'visibility' => 'public', + 'throw' => false, +], +``` + +### Symlink anlegen + +```bash +php artisan storage:link +``` + +Erstellt `public/storage` → `storage/app/public`. **Ohne diesen Symlink sind hochgeladene Bilder nicht über den Browser erreichbar.** + +--- + +## 5. Kritische System-Anpassungen + +### 5a. `bootstrap/app.php` – Reverse-Proxy / HTTPS + +```php +->withMiddleware(function (Middleware $middleware) { + // Traefik/nginx Proxy: X-Forwarded-Proto-Header vertrauen + // ZWINGEND für korrekte signierte Upload-URLs hinter einem HTTPS-Proxy + $middleware->trustProxies(at: '*'); +}) +``` + +**Warum?** +Livewire generiert für temporäre Uploads **signierte URLs**. Wenn die App hinter einem Reverse-Proxy (Traefik, nginx, Load Balancer) läuft und der Proxy HTTPS terminiert, glaubt Laravel intern, es sei HTTP. Die signierte URL wird dann mit `http://` generiert, der Browser sendet aber `https://` – die Signatur stimmt nicht, Upload schlägt fehl mit `403`. + +### 5b. `app/Providers/AppServiceProvider.php` – Schema erzwingen + +```php +public function boot(): void +{ + // X-Forwarded-Proto auswerten und Schema erzwingen + // Nötig für Livewire Upload-URLs hinter Traefik + $scheme = request()->header('X-Forwarded-Proto') + ?? request()->server('HTTP_X_FORWARDED_PROTO') + ?? (request()->secure() ? 'https' : 'http'); + + if ($scheme === 'https') { + URL::forceScheme('https'); + } +} +``` + +**Warum zusätzlich zum `trustProxies`?** +`trustProxies` reicht in manchen Proxy-Setups nicht aus, wenn der Header-Name variiert. `URL::forceScheme('https')` ist die sichere Ergänzung, die sicherstellt, dass alle generierten URLs das korrekte Schema haben. + +**Ohne diese beiden Maßnahmen** scheitert der Upload mit einer `403 Signature mismatch`-Fehlermeldung in der Browser-Console – besonders frustrierend, weil kein PHP-Fehler erscheint. + +--- + +## 6. Livewire-Konfiguration (`config/livewire.php`) + +```php +'temporary_file_upload' => [ + 'disk' => null, // null = default-Disk (meist 'local') + 'rules' => null, // null = ['required', 'file', 'max:12288'] (12 MB Default) + 'directory' => null, // null = 'livewire-tmp' + 'middleware' => null, // null = 'throttle:60,1' + 'preview_mimes' => [ // Diese MIME-Types erlauben temporaryUrl() + 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp', + 'pdf', 'mp4', 'mov', 'avi', 'wmv', 'mp3', ... + ], + 'max_upload_time' => 5, // Minuten bis Upload ungültig wird + 'cleanup' => true, // Tmp-Dateien > 24h automatisch löschen +], +``` + +**Wichtig:** Das interne Default-Limit ist **12 MB** (`max:12288`). Eigene Validierungsregeln wie `max:10240` müssen immer unter diesem Wert liegen. Soll das Limit erhöht werden, muss `rules` hier überschrieben werden. + +--- + +## 7. Checkliste für ein neues Projekt + +| Schritt | Was | Wo | +|---------|-----|----| +| ✅ | `use WithFileUploads` im Volt/Livewire-Component | Komponentenklasse | +| ✅ | `public array $images = []` Property anlegen | Komponentenklasse | +| ✅ | `'images.*' => 'mimes:jpeg,png\|max:10240'` Validierung | `save()`-Methode | +| ✅ | `$image->store('pfad', 'public')` beim Speichern | `save()`-Methode | +| ✅ | `$this->images = []` nach dem Speichern leeren | `save()`-Methode | +| ✅ | `php artisan storage:link` ausführen | Terminal / Deploy | +| ✅ | `$middleware->trustProxies(at: '*')` | `bootstrap/app.php` | +| ✅ | `URL::forceScheme('https')` bei HTTPS-Proxy | `AppServiceProvider.php` | +| ✅ | `wire:key` in Foreach-Schleifen | Blade-Template | +| ✅ | `array_values()` nach `unset()` auf dem Array | `removePhoto()` | +| ✅ | `isPreviewable()` vor `temporaryUrl()` prüfen | Blade-Template | + +--- + +## 8. Häufige Fallstricke + +### Upload schlägt fehl mit 403 (Signature mismatch) +→ Reverse-Proxy-Problem. Siehe Punkt 5a und 5b. + +### Vorschau-Thumbnail zeigt nichts an +→ `isPreviewable()` gibt `false` zurück, wenn der MIME-Type nicht in `preview_mimes` steht. In der Livewire-Config prüfen. + +### Nach `removePhoto()` stimmen die Indizes nicht +→ `array_values()` vergessen. Livewire sendet den Index als Parameter – ohne Reindizierung kommt es zu Off-by-One-Fehlern. + +### Upload-Limit-Fehler vor der Validierung +→ PHP `upload_max_filesize` und `post_max_size` in `php.ini` überprüfen. Auch Livewires internes `max:12288`-Limit beachten. + +### `temporaryUrl()` wirft eine Exception +→ Bei lokalen Disks ohne `serve: true` in `filesystems.php` funktioniert `temporaryUrl()` nicht. Entweder `serve: true` setzen oder S3 verwenden. Im Template immer mit `isPreviewable()` absichern. + +### Bilder nach Deploy nicht sichtbar +→ `php artisan storage:link` auf dem Produktionssystem ausführen. Im Docker-Container nach jedem `down/up` prüfen, ob der Symlink noch existiert. diff --git a/dev/file-upload/cms/CabinetDisplay.php b/dev/file-upload/cms/CabinetDisplay.php new file mode 100644 index 0000000..c2f271f --- /dev/null +++ b/dev/file-upload/cms/CabinetDisplay.php @@ -0,0 +1,302 @@ +loadAvailableVideos(); + } + + /** + * Lädt alle verfügbaren Video-Dateien aus dem assets-Ordner + */ + public function loadAvailableVideos() + { + $assetsPath = public_path('_cabinet/assets'); + + if (File::exists($assetsPath)) { + $files = File::files($assetsPath); + $this->availableVideos = collect($files) + ->map(fn ($file) => $file->getFilename()) + ->filter(fn ($filename) => in_array(pathinfo($filename, PATHINFO_EXTENSION), ['mp4', 'webm', 'mov'])) + ->values() + ->toArray(); + } + } + + // ======================================== + // VIDEO-VERWALTUNG + // ======================================== + + public function openVideoModal($id = null) + { + if ($id) { + $video = DisplayVideo::findOrFail($id); + $this->videoId = $video->id; + $this->videoFilename = $video->filename; + $this->videoTitle = $video->title ?? ''; + $this->videoPosition = $video->position; + $this->videoIsActive = $video->is_active; + } else { + $this->resetVideoForm(); + } + $this->showVideoModal = true; + } + + public function saveVideo() + { + $this->validate([ + 'videoFilename' => 'required|string', + 'videoPosition' => 'required|integer|min:0|max:100', + ], [ + 'videoFilename.required' => 'Bitte wählen Sie ein Video aus.', + 'videoPosition.required' => 'Die Position ist erforderlich.', + 'videoPosition.min' => 'Die Position muss zwischen 0 und 100 liegen.', + 'videoPosition.max' => 'Die Position muss zwischen 0 und 100 liegen.', + ]); + + $data = [ + 'filename' => $this->videoFilename, + 'title' => $this->videoTitle, + 'position' => $this->videoPosition, + 'is_active' => $this->videoIsActive, + ]; + + if ($this->videoId) { + $video = DisplayVideo::findOrFail($this->videoId); + $video->update($data); + session()->flash('success', 'Video erfolgreich aktualisiert!'); + } else { + $maxSortOrder = DisplayVideo::max('sort_order') ?? 0; + $data['sort_order'] = $maxSortOrder + 1; + DisplayVideo::create($data); + session()->flash('success', 'Video erfolgreich hinzugefügt!'); + } + + $this->closeVideoModal(); + } + + public function deleteVideo($id) + { + DisplayVideo::findOrFail($id)->delete(); + session()->flash('success', 'Video erfolgreich gelöscht!'); + } + + public function toggleVideoStatus($id) + { + $video = DisplayVideo::findOrFail($id); + $video->update(['is_active' => ! $video->is_active]); + } + + public function moveVideo($id, $direction) + { + $video = DisplayVideo::findOrFail($id); + $currentOrder = $video->sort_order; + + if ($direction === 'up' && $currentOrder > 0) { + $swapVideo = DisplayVideo::where('sort_order', $currentOrder - 1)->first(); + if ($swapVideo) { + $video->update(['sort_order' => $currentOrder - 1]); + $swapVideo->update(['sort_order' => $currentOrder]); + } + } elseif ($direction === 'down') { + $swapVideo = DisplayVideo::where('sort_order', $currentOrder + 1)->first(); + if ($swapVideo) { + $video->update(['sort_order' => $currentOrder + 1]); + $swapVideo->update(['sort_order' => $currentOrder]); + } + } + } + + public function resetVideoForm() + { + $this->videoId = null; + $this->videoFilename = ''; + $this->videoTitle = ''; + $this->videoPosition = 25; + $this->videoIsActive = true; + } + + public function closeVideoModal() + { + $this->showVideoModal = false; + $this->resetVideoForm(); + } + + // ======================================== + // FOOTER-CONTENT-VERWALTUNG + // ======================================== + + public function openFooterModal($id = null) + { + if ($id) { + $footer = DisplayFooterContent::findOrFail($id); + $this->footerId = $footer->id; + $this->footerHeadline = $footer->headline; + $this->footerSubline = $footer->subline; + $this->footerUrl = $footer->url; + $this->footerIsActive = $footer->is_active; + } else { + $this->resetFooterForm(); + } + $this->showFooterModal = true; + } + + public function saveFooter() + { + $this->validate([ + 'footerHeadline' => 'required|string|max:255', + 'footerSubline' => 'required|string|max:255', + 'footerUrl' => 'nullable|url', + ], [ + 'footerHeadline.required' => 'Die Überschrift ist erforderlich.', + 'footerSubline.required' => 'Die Unterzeile ist erforderlich.', + 'footerUrl.url' => 'Bitte geben Sie eine gültige URL ein.', + ]); + + $data = [ + 'headline' => $this->footerHeadline, + 'subline' => $this->footerSubline, + 'url' => $this->footerUrl ?: null, + 'is_active' => $this->footerIsActive, + ]; + + if ($this->footerId) { + $footer = DisplayFooterContent::findOrFail($this->footerId); + $footer->update($data); + + // Short-Code generieren falls URL vorhanden aber noch kein Short-Code + if ($footer->url && ! $footer->short_code) { + $footer->short_code = DisplayFooterContent::generateUniqueShortCode(); + $footer->save(); + } + + session()->flash('success', 'Footer-Inhalt erfolgreich aktualisiert!'); + } else { + $maxSortOrder = DisplayFooterContent::max('sort_order') ?? 0; + $data['sort_order'] = $maxSortOrder + 1; + + $footer = DisplayFooterContent::create($data); + + // Short-Code nur generieren wenn URL vorhanden + if ($footer->url) { + $footer->short_code = DisplayFooterContent::generateUniqueShortCode(); + $footer->save(); + session()->flash('success', 'Footer-Inhalt erfolgreich hinzugefügt! Short-Link: '.$footer->short_url); + } else { + session()->flash('success', 'Footer-Inhalt erfolgreich hinzugefügt! (Ohne QR-Code)'); + } + } + + $this->closeFooterModal(); + } + + public function regenerateShortCode($id) + { + $footer = DisplayFooterContent::findOrFail($id); + $footer->short_code = DisplayFooterContent::generateUniqueShortCode(); + $footer->save(); + session()->flash('success', 'Short-Code wurde neu generiert!'); + } + + public function resetClicks($id) + { + $footer = DisplayFooterContent::findOrFail($id); + $footer->clicks = 0; + $footer->save(); + session()->flash('success', 'Klick-Zähler wurde zurückgesetzt!'); + } + + public function deleteFooter($id) + { + DisplayFooterContent::findOrFail($id)->delete(); + session()->flash('success', 'Footer-Inhalt erfolgreich gelöscht!'); + } + + public function toggleFooterStatus($id) + { + $footer = DisplayFooterContent::findOrFail($id); + $footer->update(['is_active' => ! $footer->is_active]); + } + + public function moveFooter($id, $direction) + { + $footer = DisplayFooterContent::findOrFail($id); + $currentOrder = $footer->sort_order; + + if ($direction === 'up' && $currentOrder > 0) { + $swapFooter = DisplayFooterContent::where('sort_order', $currentOrder - 1)->first(); + if ($swapFooter) { + $footer->update(['sort_order' => $currentOrder - 1]); + $swapFooter->update(['sort_order' => $currentOrder]); + } + } elseif ($direction === 'down') { + $swapFooter = DisplayFooterContent::where('sort_order', $currentOrder + 1)->first(); + if ($swapFooter) { + $footer->update(['sort_order' => $currentOrder + 1]); + $swapFooter->update(['sort_order' => $currentOrder]); + } + } + } + + public function resetFooterForm() + { + $this->footerId = null; + $this->footerHeadline = ''; + $this->footerSubline = ''; + $this->footerUrl = ''; + $this->footerIsActive = true; + } + + public function closeFooterModal() + { + $this->showFooterModal = false; + $this->resetFooterForm(); + } + + public function render() + { + $videos = DisplayVideo::orderBy('sort_order')->get(); + $footerContents = DisplayFooterContent::orderBy('sort_order')->get(); + + return view('livewire.admin.cms.cabinet-display', [ + 'videos' => $videos, + 'footerContents' => $footerContents, + ]); + } +} diff --git a/dev/file-upload/cms/CabinetInfoTablet.php b/dev/file-upload/cms/CabinetInfoTablet.php new file mode 100644 index 0000000..7971137 --- /dev/null +++ b/dev/file-upload/cms/CabinetInfoTablet.php @@ -0,0 +1,189 @@ +storeStatus = $s->store_status ?? 'auto'; + $this->noticeHeadline = $s->notice_headline ?? ''; + $this->noticeSubtext = $s->notice_subtext ?? ''; + $this->overrideOpenToday = $s->override_open_today ?? ''; + $this->overrideCloseToday = $s->override_close_today ?? ''; + $this->nextAppointmentDate = $s->next_appointment_date?->format('Y-m-d'); + $this->nextAppointmentTime = $s->next_appointment_time ?? ''; + $this->hoursMondayOpen = $s->hours_monday_open ?? ''; + $this->hoursMondayClose = $s->hours_monday_close ?? ''; + $this->hoursTuesdayOpen = $s->hours_tuesday_open ?? ''; + $this->hoursTuesdayClose = $s->hours_tuesday_close ?? ''; + $this->hoursWednesdayOpen = $s->hours_wednesday_open ?? ''; + $this->hoursWednesdayClose = $s->hours_wednesday_close ?? ''; + $this->hoursThursdayOpen = $s->hours_thursday_open ?? ''; + $this->hoursThursdayClose = $s->hours_thursday_close ?? ''; + $this->hoursFridayOpen = $s->hours_friday_open ?? ''; + $this->hoursFridayClose = $s->hours_friday_close ?? ''; + $this->hoursSaturdayOpen = $s->hours_saturday_open ?? ''; + $this->hoursSaturdayClose = $s->hours_saturday_close ?? ''; + $this->hoursSundayOpen = $s->hours_sunday_open ?? ''; + $this->hoursSundayClose = $s->hours_sunday_close ?? ''; + $this->contactPhone = $s->contact_phone ?? ''; + $this->contactEmail = $s->contact_email ?? ''; + } + + private function timeRule(): array + { + return ['nullable', 'string', 'regex:/^(\d{2}:\d{2})?$/']; + } + + private function toNullIfEmpty(?string $value): ?string + { + return $value !== null && trim($value) !== '' ? trim($value) : null; + } + + /** + * @param array $hours Optional: Time picker values from DOM (bypasses wire:model sync issues) + */ + public function save(array $hours = []): void + { + foreach ($hours as $prop => $value) { + if (property_exists($this, $prop)) { + $this->{$prop} = $value ?? ''; + } + } + + $timeRule = $this->timeRule(); + + $this->validate([ + 'storeStatus' => 'required|in:auto,notice,warning,closed', + 'noticeHeadline' => 'nullable|string|max:40', + 'noticeSubtext' => 'nullable|string|max:80', + 'overrideOpenToday' => $timeRule, + 'overrideCloseToday' => $timeRule, + 'nextAppointmentDate' => 'nullable|date', + 'nextAppointmentTime' => $timeRule, + 'hoursMondayOpen' => $timeRule, + 'hoursMondayClose' => $timeRule, + 'hoursTuesdayOpen' => $timeRule, + 'hoursTuesdayClose' => $timeRule, + 'hoursWednesdayOpen' => $timeRule, + 'hoursWednesdayClose' => $timeRule, + 'hoursThursdayOpen' => $timeRule, + 'hoursThursdayClose' => $timeRule, + 'hoursFridayOpen' => $timeRule, + 'hoursFridayClose' => $timeRule, + 'hoursSaturdayOpen' => $timeRule, + 'hoursSaturdayClose' => $timeRule, + 'hoursSundayOpen' => $timeRule, + 'hoursSundayClose' => $timeRule, + 'contactPhone' => 'nullable|string|max:50', + 'contactEmail' => 'nullable|email|max:100', + ], [ + 'storeStatus.required' => 'Der Store-Status ist erforderlich.', + 'storeStatus.in' => 'Ungültiger Status. Erlaubt: auto, notice, warning, closed.', + 'noticeHeadline.max' => 'Die Headline darf maximal 40 Zeichen haben.', + 'noticeSubtext.max' => 'Der Subtext darf maximal 80 Zeichen haben.', + 'overrideOpenToday.regex' => 'Bitte im Format HH:MM eingeben.', + 'overrideCloseToday.regex' => 'Bitte im Format HH:MM eingeben.', + 'nextAppointmentTime.regex' => 'Bitte im Format HH:MM eingeben.', + 'contactEmail.email' => 'Bitte eine gültige E-Mail-Adresse eingeben.', + ]); + CabinetTabletSetting::current()->update([ + 'store_status' => $this->storeStatus, + 'notice_headline' => $this->toNullIfEmpty($this->noticeHeadline), + 'notice_subtext' => $this->toNullIfEmpty($this->noticeSubtext), + 'override_open_today' => $this->toNullIfEmpty($this->overrideOpenToday), + 'override_close_today' => $this->toNullIfEmpty($this->overrideCloseToday), + 'next_appointment_date' => $this->toNullIfEmpty($this->nextAppointmentDate), + 'next_appointment_time' => $this->toNullIfEmpty($this->nextAppointmentTime), + 'hours_monday_open' => $this->toNullIfEmpty($this->hoursMondayOpen), + 'hours_monday_close' => $this->toNullIfEmpty($this->hoursMondayClose), + 'hours_tuesday_open' => $this->toNullIfEmpty($this->hoursTuesdayOpen), + 'hours_tuesday_close' => $this->toNullIfEmpty($this->hoursTuesdayClose), + 'hours_wednesday_open' => $this->toNullIfEmpty($this->hoursWednesdayOpen), + 'hours_wednesday_close' => $this->toNullIfEmpty($this->hoursWednesdayClose), + 'hours_thursday_open' => $this->toNullIfEmpty($this->hoursThursdayOpen), + 'hours_thursday_close' => $this->toNullIfEmpty($this->hoursThursdayClose), + 'hours_friday_open' => $this->toNullIfEmpty($this->hoursFridayOpen), + 'hours_friday_close' => $this->toNullIfEmpty($this->hoursFridayClose), + 'hours_saturday_open' => $this->toNullIfEmpty($this->hoursSaturdayOpen), + 'hours_saturday_close' => $this->toNullIfEmpty($this->hoursSaturdayClose), + 'hours_sunday_open' => $this->toNullIfEmpty($this->hoursSundayOpen), + 'hours_sunday_close' => $this->toNullIfEmpty($this->hoursSundayClose), + 'contact_phone' => $this->toNullIfEmpty($this->contactPhone), + 'contact_email' => $this->toNullIfEmpty($this->contactEmail), + ]); + + session()->flash('success', 'Info-Tablet Einstellungen gespeichert!'); + } + + public function clearOverrides(): void + { + CabinetTabletSetting::current()->clearOverrides(); + $this->overrideOpenToday = ''; + $this->overrideCloseToday = ''; + + session()->flash('success', 'Sonderöffnungszeiten wurden zurückgesetzt!'); + } + + public function render(): \Illuminate\View\View + { + return view('livewire.admin.cms.cabinet-info-tablet'); + } +} diff --git a/dev/file-upload/cms/DisplayList.php b/dev/file-upload/cms/DisplayList.php new file mode 100644 index 0000000..1e908b9 --- /dev/null +++ b/dev/file-upload/cms/DisplayList.php @@ -0,0 +1,153 @@ + */ + public $selectedVersionIds = []; + + public $displayIsActive = true; + + public $addVersionSelect = null; + + public function openModal(?int $id = null): void + { + if ($id) { + $display = Display::with('versions')->findOrFail($id); + $this->displayId = $display->id; + $this->displayName = $display->name; + $this->displayLocation = $display->location ?? ''; + $this->selectedVersionIds = $display->versions->pluck('id')->toArray(); + $this->displayIsActive = $display->is_active; + } else { + $this->resetForm(); + } + + $this->showModal = true; + } + + public function addVersion(?int $versionId = null): void + { + $id = $versionId ?? $this->addVersionSelect; + + if ($id && ! in_array((int) $id, $this->selectedVersionIds)) { + $this->selectedVersionIds[] = (int) $id; + } + + $this->addVersionSelect = null; + } + + public function removeVersion(int $index): void + { + array_splice($this->selectedVersionIds, $index, 1); + } + + public function moveVersion(int $index, string $direction): void + { + $newIndex = $direction === 'up' ? $index - 1 : $index + 1; + + if ($newIndex < 0 || $newIndex >= count($this->selectedVersionIds)) { + return; + } + + $temp = $this->selectedVersionIds[$index]; + $this->selectedVersionIds[$index] = $this->selectedVersionIds[$newIndex]; + $this->selectedVersionIds[$newIndex] = $temp; + } + + public function save(): void + { + $this->validate([ + 'displayName' => 'required|string|max:255', + 'displayLocation' => 'nullable|string|max:255', + 'selectedVersionIds' => 'array', + 'selectedVersionIds.*' => 'exists:display_versions,id', + ], [ + 'displayName.required' => 'Bitte geben Sie einen Namen ein.', + ]); + + $data = [ + 'name' => $this->displayName, + 'location' => $this->displayLocation ?: null, + 'is_active' => $this->displayIsActive, + ]; + + if ($this->displayId) { + $display = Display::findOrFail($this->displayId); + $display->update($data); + session()->flash('success', 'Display erfolgreich aktualisiert!'); + } else { + $display = Display::create($data); + session()->flash('success', 'Display erfolgreich erstellt!'); + } + + // Sync versions with sort_order + $syncData = []; + foreach ($this->selectedVersionIds as $sortOrder => $versionId) { + $syncData[$versionId] = ['sort_order' => $sortOrder]; + } + $display->versions()->sync($syncData); + + $this->closeModal(); + } + + public function deleteDisplay(int $id): void + { + $display = Display::findOrFail($id); + $name = $display->name; + $display->delete(); + + session()->flash('success', 'Display "'.$name.'" wurde gelöscht!'); + } + + public function toggleActive(int $id): void + { + $display = Display::findOrFail($id); + $display->update(['is_active' => ! $display->is_active]); + } + + public function closeModal(): void + { + $this->showModal = false; + $this->resetForm(); + } + + public function resetForm(): void + { + $this->displayId = null; + $this->displayName = ''; + $this->displayLocation = ''; + $this->selectedVersionIds = []; + $this->displayIsActive = true; + $this->addVersionSelect = null; + } + + public function render() + { + $displays = Display::with('versions') + ->orderBy('name') + ->get(); + + $versions = DisplayVersion::active() + ->orderBy('name') + ->get(); + + return view('livewire.admin.cms.display-list', [ + 'displays' => $displays, + 'versions' => $versions, + ]); + } +} diff --git a/dev/file-upload/cms/DisplayVersionEditor.php b/dev/file-upload/cms/DisplayVersionEditor.php new file mode 100644 index 0000000..7349d9b --- /dev/null +++ b/dev/file-upload/cms/DisplayVersionEditor.php @@ -0,0 +1,437 @@ + */ + public array $slideBullets = []; + + public string $slideDisclaimer = ''; + + public string $slideQrUrl = ''; + + public string $slideQrTitle = ''; + + public string $slideContact = ''; + + public bool $slideShowBrandText = false; + + public string $slideBrandTagline = ''; + + public bool $slideIsActive = true; + + // Settings Modal + public bool $showSettingsModal = false; + + public array $settings = []; + + /** @var array */ + public array $availableVideos = []; + + public function mount(DisplayVersion $displayVersion): void + { + $this->version = $displayVersion; + $this->versionName = $displayVersion->name; + $this->settings = $displayVersion->settings ?? []; + + if ($this->version->type === DisplayVersionType::VideoDisplay) { + $this->loadAvailableVideos(); + } + } + + public function loadAvailableVideos(): void + { + $assetsPath = public_path('_cabinet/assets'); + + if (File::exists($assetsPath)) { + $this->availableVideos = collect(File::files($assetsPath)) + ->map(fn ($file) => $file->getFilename()) + ->filter(fn ($filename) => in_array(pathinfo($filename, PATHINFO_EXTENSION), ['mp4', 'webm', 'mov'])) + ->values() + ->toArray(); + } + } + + public function toggleTheme(): void + { + $settings = $this->version->settings ?? []; + $settings['theme'] = ($settings['theme'] ?? 'dark') === 'dark' ? 'light' : 'dark'; + $this->version->update(['settings' => $settings]); + $this->settings = $settings; + } + + public function saveName(): void + { + $this->validate([ + 'versionName' => 'required|string|max:255', + ]); + + $this->version->update(['name' => $this->versionName]); + session()->flash('success', 'Name aktualisiert!'); + } + + // ======================================== + // SETTINGS + // ======================================== + + public function openSettingsModal(): void + { + $this->settings = $this->version->settings ?? []; + $this->showSettingsModal = true; + } + + public function saveSettings(): void + { + $this->version->update(['settings' => $this->settings]); + $this->showSettingsModal = false; + session()->flash('success', 'Einstellungen gespeichert!'); + } + + // ======================================== + // ITEM CRUD + // ======================================== + + public function openItemModal(?int $id = null, string $type = ''): void + { + $this->resetItemForm(); + + if ($id) { + $item = DisplayVersionItem::findOrFail($id); + $this->itemId = $item->id; + $this->itemType = $item->item_type; + $this->loadItemContent($item); + } else { + $this->itemType = $type ?: $this->defaultItemType(); + } + + $this->showItemModal = true; + } + + public function saveItem(): void + { + $content = $this->buildItemContent(); + $isActive = $this->getActiveFlag(); + + if ($this->itemId) { + $item = DisplayVersionItem::findOrFail($this->itemId); + $item->update([ + 'content' => $content, + 'is_active' => $isActive, + ]); + session()->flash('success', 'Inhalt aktualisiert!'); + } else { + $maxSort = DisplayVersionItem::where('display_version_id', $this->version->id) + ->where('item_type', $this->itemType) + ->max('sort_order') ?? -1; + + DisplayVersionItem::create([ + 'display_version_id' => $this->version->id, + 'item_type' => $this->itemType, + 'content' => $content, + 'sort_order' => $maxSort + 1, + 'is_active' => $isActive, + ]); + session()->flash('success', 'Inhalt hinzugefügt!'); + } + + $this->closeItemModal(); + } + + public function deleteItem(int $id): void + { + DisplayVersionItem::findOrFail($id)->delete(); + session()->flash('success', 'Inhalt gelöscht!'); + } + + public function toggleItemStatus(int $id): void + { + $item = DisplayVersionItem::findOrFail($id); + $item->update(['is_active' => ! $item->is_active]); + } + + public function moveItem(int $id, string $direction): void + { + $item = DisplayVersionItem::findOrFail($id); + $currentOrder = $item->sort_order; + + $swapItem = DisplayVersionItem::where('display_version_id', $this->version->id) + ->where('item_type', $item->item_type) + ->where('sort_order', $direction === 'up' ? $currentOrder - 1 : $currentOrder + 1) + ->first(); + + if ($swapItem) { + $item->update(['sort_order' => $swapItem->sort_order]); + $swapItem->update(['sort_order' => $currentOrder]); + } + } + + public function addBullet(): void + { + $this->slideBullets[] = ''; + } + + public function removeBullet(int $index): void + { + unset($this->slideBullets[$index]); + $this->slideBullets = array_values($this->slideBullets); + } + + public function closeItemModal(): void + { + $this->showItemModal = false; + $this->resetItemForm(); + } + + // ======================================== + // HELPERS + // ======================================== + + private function loadItemContent(DisplayVersionItem $item): void + { + $content = $item->content; + + match ($item->item_type) { + 'video' => $this->loadVideoContent($content), + 'footer' => $this->loadFooterContent($content), + 'media' => $this->loadMediaContent($content), + 'slide' => $this->loadSlideContent($content), + default => null, + }; + } + + private function loadVideoContent(array $content): void + { + $this->videoFilename = $content['filename'] ?? ''; + $this->videoTitle = $content['title'] ?? ''; + $this->videoPosition = $content['position'] ?? 25; + $this->videoIsActive = true; + } + + private function loadFooterContent(array $content): void + { + $this->footerHeadline = $content['headline'] ?? ''; + $this->footerSubline = $content['subline'] ?? ''; + $this->footerUrl = $content['url'] ?? ''; + $this->footerIsActive = true; + } + + private function loadMediaContent(array $content): void + { + $this->mediaType = $content['media_type'] ?? 'image'; + $this->mediaCategory = $content['category'] ?? 'immobilien'; + $this->mediaUrl = $content['media_url'] ?? ''; + $this->mediaHeadline = $content['headline'] ?? ''; + $this->mediaSubline = $content['subline'] ?? ''; + $this->mediaDuration = $content['duration_seconds'] ?? 10; + $this->mediaIsActive = true; + } + + private function loadSlideContent(array $content): void + { + $this->slideType = $content['type'] ?? 'product-hero'; + $this->slideDuration = $content['duration'] ?? 8000; + $this->slideImageUrl = $content['image_url'] ?? ''; + $this->slideBadge = $content['badge_text'] ?? ''; + $this->slideEyebrow = $content['eyebrow'] ?? ''; + $this->slideTitle = $content['title'] ?? ''; + $this->slideSubline = $content['subline'] ?? ''; + $this->slidePrice = $content['price'] ?? ''; + $this->slideOriginalPrice = $content['original_price'] ?? ''; + $this->slideTagText = $content['tag_text'] ?? ''; + $this->slideBullets = $content['bullets'] ?? []; + $this->slideDisclaimer = $content['disclaimer'] ?? ''; + $this->slideQrUrl = $content['qr_url'] ?? ''; + $this->slideQrTitle = $content['qr_title'] ?? ''; + $this->slideContact = $content['contact'] ?? ''; + $this->slideShowBrandText = $content['show_brand_text'] ?? false; + $this->slideBrandTagline = $content['brand_tagline'] ?? ''; + $this->slideIsActive = true; + } + + /** + * @return array + */ + private function buildItemContent(): array + { + return match ($this->itemType) { + 'video' => [ + 'filename' => $this->videoFilename, + 'title' => $this->videoTitle, + 'position' => $this->videoPosition, + ], + 'footer' => [ + 'headline' => $this->footerHeadline, + 'subline' => $this->footerSubline, + 'url' => $this->footerUrl ?: null, + ], + 'media' => [ + 'media_type' => $this->mediaType, + 'category' => $this->mediaCategory, + 'media_url' => $this->mediaUrl, + 'headline' => $this->mediaHeadline, + 'subline' => $this->mediaSubline, + 'duration_seconds' => $this->mediaDuration, + ], + 'slide' => [ + 'type' => $this->slideType, + 'duration' => $this->slideDuration, + 'image_url' => $this->slideImageUrl, + 'badge_text' => $this->slideBadge, + 'eyebrow' => $this->slideEyebrow, + 'title' => $this->slideTitle, + 'subline' => $this->slideSubline, + 'price' => $this->slidePrice, + 'original_price' => $this->slideOriginalPrice, + 'tag_text' => $this->slideTagText, + 'bullets' => $this->slideBullets, + 'disclaimer' => $this->slideDisclaimer, + 'qr_url' => $this->slideQrUrl, + 'qr_title' => $this->slideQrTitle, + 'contact' => $this->slideContact, + 'show_brand_text' => $this->slideShowBrandText, + 'brand_tagline' => $this->slideBrandTagline, + ], + default => [], + }; + } + + private function getActiveFlag(): bool + { + return match ($this->itemType) { + 'video' => $this->videoIsActive, + 'footer' => $this->footerIsActive, + 'media' => $this->mediaIsActive, + 'slide' => $this->slideIsActive, + default => true, + }; + } + + private function defaultItemType(): string + { + return match ($this->version->type) { + DisplayVersionType::VideoDisplay => 'video', + DisplayVersionType::B2in => 'media', + DisplayVersionType::Offers => 'slide', + }; + } + + private function resetItemForm(): void + { + $this->itemId = null; + $this->itemType = ''; + $this->videoFilename = ''; + $this->videoTitle = ''; + $this->videoPosition = 25; + $this->videoIsActive = true; + $this->footerHeadline = ''; + $this->footerSubline = ''; + $this->footerUrl = ''; + $this->footerIsActive = true; + $this->mediaType = 'image'; + $this->mediaCategory = 'immobilien'; + $this->mediaUrl = ''; + $this->mediaHeadline = ''; + $this->mediaSubline = ''; + $this->mediaDuration = 10; + $this->mediaIsActive = true; + $this->slideType = 'product-hero'; + $this->slideDuration = 8000; + $this->slideImageUrl = ''; + $this->slideBadge = ''; + $this->slideEyebrow = ''; + $this->slideTitle = ''; + $this->slideSubline = ''; + $this->slidePrice = ''; + $this->slideOriginalPrice = ''; + $this->slideTagText = ''; + $this->slideBullets = []; + $this->slideDisclaimer = ''; + $this->slideQrUrl = ''; + $this->slideQrTitle = ''; + $this->slideContact = ''; + $this->slideShowBrandText = false; + $this->slideBrandTagline = ''; + $this->slideIsActive = true; + } + + public function render() + { + $items = $this->version->items()->get()->groupBy('item_type'); + + return view('livewire.admin.cms.display-version-editor', [ + 'items' => $items, + ]); + } +} diff --git a/dev/file-upload/cms/DisplayVersionList.php b/dev/file-upload/cms/DisplayVersionList.php new file mode 100644 index 0000000..5eb1c97 --- /dev/null +++ b/dev/file-upload/cms/DisplayVersionList.php @@ -0,0 +1,102 @@ +newName = ''; + $this->newType = ''; + $this->showCreateModal = true; + } + + public function createVersion(): void + { + $this->validate([ + 'newName' => 'required|string|max:255', + 'newType' => 'required|string|in:video-display,b2in,offers', + ], [ + 'newName.required' => 'Bitte geben Sie einen Namen ein.', + 'newType.required' => 'Bitte wählen Sie einen Typ aus.', + ]); + + $version = DisplayVersion::create([ + 'name' => $this->newName, + 'type' => $this->newType, + 'settings' => $this->defaultSettingsForType($this->newType), + 'is_active' => true, + ]); + + $this->showCreateModal = false; + $this->newName = ''; + $this->newType = ''; + + session()->flash('success', 'Version "'.$version->name.'" wurde erstellt!'); + + $this->redirect( + route('admin.cms.display-version-edit', $version), + navigate: true + ); + } + + public function deleteVersion(int $id): void + { + $version = DisplayVersion::findOrFail($id); + $name = $version->name; + $version->delete(); + + session()->flash('success', 'Version "'.$name.'" wurde gelöscht!'); + } + + public function toggleActive(int $id): void + { + $version = DisplayVersion::findOrFail($id); + $version->update(['is_active' => ! $version->is_active]); + } + + /** + * @return array + */ + private function defaultSettingsForType(string $type): array + { + return match ($type) { + 'b2in' => [ + 'theme' => 'dark', + 'footer_name' => '', + 'footer_url' => '', + 'transition' => ['type' => 'crossfade', 'duration_ms' => 800], + 'default_image_duration' => 10, + 'rotation_weights' => ['immobilien' => 70, 'moebel' => 30], + 'display_active' => true, + ], + 'offers' => [ + 'loop' => true, + 'transition' => ['type' => 'fade', 'duration' => 600], + ], + default => [], + }; + } + + public function render() + { + $versions = DisplayVersion::withCount(['items', 'displays']) + ->orderBy('name') + ->get(); + + return view('livewire.admin.cms.display-version-list', [ + 'versions' => $versions, + 'types' => DisplayVersionType::cases(), + ]); + } +} diff --git a/dev/file-upload/cms/MediaLibraryUploader.php b/dev/file-upload/cms/MediaLibraryUploader.php new file mode 100644 index 0000000..8b495b6 --- /dev/null +++ b/dev/file-upload/cms/MediaLibraryUploader.php @@ -0,0 +1,45 @@ + */ + public array $uploads = []; + + public function updatedUploads(): void + { + $this->validate([ + 'uploads' => 'nullable|array|max:20', + 'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240', + ]); + + $service = app(MediaConversionService::class); + + foreach ($this->uploads as $file) { + $media = $service->storeUpload($file); + $this->dispatch('media-library-uploaded', mediaId: $media->id); + } + + $this->uploads = []; + } + + public function removeUpload(int $index): void + { + if (isset($this->uploads[$index])) { + unset($this->uploads[$index]); + $this->uploads = array_values($this->uploads); + } + } + + public function render() + { + return view('livewire.admin.cms.media-library-uploader'); + } +} diff --git a/dev/file-upload/cms/MediaPicker.php b/dev/file-upload/cms/MediaPicker.php new file mode 100644 index 0000000..14eb146 --- /dev/null +++ b/dev/file-upload/cms/MediaPicker.php @@ -0,0 +1,139 @@ + */ + public array $quickUploads = []; + + public function mount(?int $value = null): void + { + $this->value = $value; + } + + public function openPicker(): void + { + $this->showModal = true; + } + + public function selectMedia(int $id): void + { + $media = CmsMedia::find($id); + if (! $media) { + return; + } + + if ($media->isImage() && $this->profile) { + $service = app(MediaConversionService::class); + if (! $media->hasConversion($this->profile)) { + $service->convert($media, $this->profile); + $media->refresh(); + } + } + + $this->value = $media->id; + $this->showModal = false; + + $this->dispatch('media-selected', field: $this->field, mediaId: $media->id, url: $media->getConversionUrl($this->profile)); + } + + public function clearSelection(): void + { + $this->value = null; + $this->dispatch('media-selected', field: $this->field, mediaId: null, url: null); + } + + public function updatedQuickUploads(): void + { + $this->validate([ + 'quickUploads' => 'nullable|array|max:5', + 'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240', + ]); + + $service = app(MediaConversionService::class); + $lastMedia = null; + + foreach ($this->quickUploads as $file) { + $lastMedia = $service->storeUpload($file); + + if ($lastMedia->isImage() && $this->profile) { + $service->convert($lastMedia, $this->profile); + $lastMedia->refresh(); + } + } + + $this->quickUploads = []; + + if ($lastMedia) { + $this->value = $lastMedia->id; + $this->showModal = false; + + $this->dispatch('media-selected', field: $this->field, mediaId: $lastMedia->id, url: $lastMedia->getConversionUrl($this->profile)); + } + } + + public function removeQuickUpload(int $index): void + { + if (isset($this->quickUploads[$index])) { + unset($this->quickUploads[$index]); + $this->quickUploads = array_values($this->quickUploads); + } + } + + public function render(): View + { + return view('livewire.admin.cms.media-picker', [ + 'selectedMedia' => $this->resolveSelectedMedia(), + 'mediaItems' => $this->resolveMediaItems(), + ]); + } + + private function resolveSelectedMedia(): ?CmsMedia + { + if (! $this->value) { + return null; + } + + return CmsMedia::find($this->value); + } + + /** + * @return LengthAwarePaginator + */ + private function resolveMediaItems(): LengthAwarePaginator + { + return CmsMedia::query() + ->when($this->type === 'image', fn ($q) => $q->images()) + ->when($this->type === 'pdf', fn ($q) => $q->pdfs()) + ->when($this->type === 'document', fn ($q) => $q->documents()) + ->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%")) + ->orderByDesc('created_at') + ->paginate(18); + } +} diff --git a/dev/flux-cms/Admin/Cms/MediaLibraryUploader.php b/dev/flux-cms/Admin/Cms/MediaLibraryUploader.php new file mode 100644 index 0000000..8b495b6 --- /dev/null +++ b/dev/flux-cms/Admin/Cms/MediaLibraryUploader.php @@ -0,0 +1,45 @@ + */ + public array $uploads = []; + + public function updatedUploads(): void + { + $this->validate([ + 'uploads' => 'nullable|array|max:20', + 'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240', + ]); + + $service = app(MediaConversionService::class); + + foreach ($this->uploads as $file) { + $media = $service->storeUpload($file); + $this->dispatch('media-library-uploaded', mediaId: $media->id); + } + + $this->uploads = []; + } + + public function removeUpload(int $index): void + { + if (isset($this->uploads[$index])) { + unset($this->uploads[$index]); + $this->uploads = array_values($this->uploads); + } + } + + public function render() + { + return view('livewire.admin.cms.media-library-uploader'); + } +} diff --git a/dev/flux-cms/Admin/Cms/MediaPicker.php b/dev/flux-cms/Admin/Cms/MediaPicker.php new file mode 100644 index 0000000..14eb146 --- /dev/null +++ b/dev/flux-cms/Admin/Cms/MediaPicker.php @@ -0,0 +1,139 @@ + */ + public array $quickUploads = []; + + public function mount(?int $value = null): void + { + $this->value = $value; + } + + public function openPicker(): void + { + $this->showModal = true; + } + + public function selectMedia(int $id): void + { + $media = CmsMedia::find($id); + if (! $media) { + return; + } + + if ($media->isImage() && $this->profile) { + $service = app(MediaConversionService::class); + if (! $media->hasConversion($this->profile)) { + $service->convert($media, $this->profile); + $media->refresh(); + } + } + + $this->value = $media->id; + $this->showModal = false; + + $this->dispatch('media-selected', field: $this->field, mediaId: $media->id, url: $media->getConversionUrl($this->profile)); + } + + public function clearSelection(): void + { + $this->value = null; + $this->dispatch('media-selected', field: $this->field, mediaId: null, url: null); + } + + public function updatedQuickUploads(): void + { + $this->validate([ + 'quickUploads' => 'nullable|array|max:5', + 'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240', + ]); + + $service = app(MediaConversionService::class); + $lastMedia = null; + + foreach ($this->quickUploads as $file) { + $lastMedia = $service->storeUpload($file); + + if ($lastMedia->isImage() && $this->profile) { + $service->convert($lastMedia, $this->profile); + $lastMedia->refresh(); + } + } + + $this->quickUploads = []; + + if ($lastMedia) { + $this->value = $lastMedia->id; + $this->showModal = false; + + $this->dispatch('media-selected', field: $this->field, mediaId: $lastMedia->id, url: $lastMedia->getConversionUrl($this->profile)); + } + } + + public function removeQuickUpload(int $index): void + { + if (isset($this->quickUploads[$index])) { + unset($this->quickUploads[$index]); + $this->quickUploads = array_values($this->quickUploads); + } + } + + public function render(): View + { + return view('livewire.admin.cms.media-picker', [ + 'selectedMedia' => $this->resolveSelectedMedia(), + 'mediaItems' => $this->resolveMediaItems(), + ]); + } + + private function resolveSelectedMedia(): ?CmsMedia + { + if (! $this->value) { + return null; + } + + return CmsMedia::find($this->value); + } + + /** + * @return LengthAwarePaginator + */ + private function resolveMediaItems(): LengthAwarePaginator + { + return CmsMedia::query() + ->when($this->type === 'image', fn ($q) => $q->images()) + ->when($this->type === 'pdf', fn ($q) => $q->pdfs()) + ->when($this->type === 'document', fn ($q) => $q->documents()) + ->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%")) + ->orderByDesc('created_at') + ->paginate(18); + } +} diff --git a/dev/flux-cms/Admin/Cms/MediaUploader.php b/dev/flux-cms/Admin/Cms/MediaUploader.php new file mode 100644 index 0000000..c06737d --- /dev/null +++ b/dev/flux-cms/Admin/Cms/MediaUploader.php @@ -0,0 +1,39 @@ +validate(); + + $path = $this->file->store($this->directory, $this->disk); + + $this->dispatch('media-uploaded', field: $this->field, path: '/storage/'.$path); + + $this->file = null; + } + + public function render() + { + return view('livewire.admin.cms.media-uploader'); + } +} diff --git a/dev/flux-cms/Cms/CabinetDisplay.php b/dev/flux-cms/Cms/CabinetDisplay.php new file mode 100644 index 0000000..c2f271f --- /dev/null +++ b/dev/flux-cms/Cms/CabinetDisplay.php @@ -0,0 +1,302 @@ +loadAvailableVideos(); + } + + /** + * Lädt alle verfügbaren Video-Dateien aus dem assets-Ordner + */ + public function loadAvailableVideos() + { + $assetsPath = public_path('_cabinet/assets'); + + if (File::exists($assetsPath)) { + $files = File::files($assetsPath); + $this->availableVideos = collect($files) + ->map(fn ($file) => $file->getFilename()) + ->filter(fn ($filename) => in_array(pathinfo($filename, PATHINFO_EXTENSION), ['mp4', 'webm', 'mov'])) + ->values() + ->toArray(); + } + } + + // ======================================== + // VIDEO-VERWALTUNG + // ======================================== + + public function openVideoModal($id = null) + { + if ($id) { + $video = DisplayVideo::findOrFail($id); + $this->videoId = $video->id; + $this->videoFilename = $video->filename; + $this->videoTitle = $video->title ?? ''; + $this->videoPosition = $video->position; + $this->videoIsActive = $video->is_active; + } else { + $this->resetVideoForm(); + } + $this->showVideoModal = true; + } + + public function saveVideo() + { + $this->validate([ + 'videoFilename' => 'required|string', + 'videoPosition' => 'required|integer|min:0|max:100', + ], [ + 'videoFilename.required' => 'Bitte wählen Sie ein Video aus.', + 'videoPosition.required' => 'Die Position ist erforderlich.', + 'videoPosition.min' => 'Die Position muss zwischen 0 und 100 liegen.', + 'videoPosition.max' => 'Die Position muss zwischen 0 und 100 liegen.', + ]); + + $data = [ + 'filename' => $this->videoFilename, + 'title' => $this->videoTitle, + 'position' => $this->videoPosition, + 'is_active' => $this->videoIsActive, + ]; + + if ($this->videoId) { + $video = DisplayVideo::findOrFail($this->videoId); + $video->update($data); + session()->flash('success', 'Video erfolgreich aktualisiert!'); + } else { + $maxSortOrder = DisplayVideo::max('sort_order') ?? 0; + $data['sort_order'] = $maxSortOrder + 1; + DisplayVideo::create($data); + session()->flash('success', 'Video erfolgreich hinzugefügt!'); + } + + $this->closeVideoModal(); + } + + public function deleteVideo($id) + { + DisplayVideo::findOrFail($id)->delete(); + session()->flash('success', 'Video erfolgreich gelöscht!'); + } + + public function toggleVideoStatus($id) + { + $video = DisplayVideo::findOrFail($id); + $video->update(['is_active' => ! $video->is_active]); + } + + public function moveVideo($id, $direction) + { + $video = DisplayVideo::findOrFail($id); + $currentOrder = $video->sort_order; + + if ($direction === 'up' && $currentOrder > 0) { + $swapVideo = DisplayVideo::where('sort_order', $currentOrder - 1)->first(); + if ($swapVideo) { + $video->update(['sort_order' => $currentOrder - 1]); + $swapVideo->update(['sort_order' => $currentOrder]); + } + } elseif ($direction === 'down') { + $swapVideo = DisplayVideo::where('sort_order', $currentOrder + 1)->first(); + if ($swapVideo) { + $video->update(['sort_order' => $currentOrder + 1]); + $swapVideo->update(['sort_order' => $currentOrder]); + } + } + } + + public function resetVideoForm() + { + $this->videoId = null; + $this->videoFilename = ''; + $this->videoTitle = ''; + $this->videoPosition = 25; + $this->videoIsActive = true; + } + + public function closeVideoModal() + { + $this->showVideoModal = false; + $this->resetVideoForm(); + } + + // ======================================== + // FOOTER-CONTENT-VERWALTUNG + // ======================================== + + public function openFooterModal($id = null) + { + if ($id) { + $footer = DisplayFooterContent::findOrFail($id); + $this->footerId = $footer->id; + $this->footerHeadline = $footer->headline; + $this->footerSubline = $footer->subline; + $this->footerUrl = $footer->url; + $this->footerIsActive = $footer->is_active; + } else { + $this->resetFooterForm(); + } + $this->showFooterModal = true; + } + + public function saveFooter() + { + $this->validate([ + 'footerHeadline' => 'required|string|max:255', + 'footerSubline' => 'required|string|max:255', + 'footerUrl' => 'nullable|url', + ], [ + 'footerHeadline.required' => 'Die Überschrift ist erforderlich.', + 'footerSubline.required' => 'Die Unterzeile ist erforderlich.', + 'footerUrl.url' => 'Bitte geben Sie eine gültige URL ein.', + ]); + + $data = [ + 'headline' => $this->footerHeadline, + 'subline' => $this->footerSubline, + 'url' => $this->footerUrl ?: null, + 'is_active' => $this->footerIsActive, + ]; + + if ($this->footerId) { + $footer = DisplayFooterContent::findOrFail($this->footerId); + $footer->update($data); + + // Short-Code generieren falls URL vorhanden aber noch kein Short-Code + if ($footer->url && ! $footer->short_code) { + $footer->short_code = DisplayFooterContent::generateUniqueShortCode(); + $footer->save(); + } + + session()->flash('success', 'Footer-Inhalt erfolgreich aktualisiert!'); + } else { + $maxSortOrder = DisplayFooterContent::max('sort_order') ?? 0; + $data['sort_order'] = $maxSortOrder + 1; + + $footer = DisplayFooterContent::create($data); + + // Short-Code nur generieren wenn URL vorhanden + if ($footer->url) { + $footer->short_code = DisplayFooterContent::generateUniqueShortCode(); + $footer->save(); + session()->flash('success', 'Footer-Inhalt erfolgreich hinzugefügt! Short-Link: '.$footer->short_url); + } else { + session()->flash('success', 'Footer-Inhalt erfolgreich hinzugefügt! (Ohne QR-Code)'); + } + } + + $this->closeFooterModal(); + } + + public function regenerateShortCode($id) + { + $footer = DisplayFooterContent::findOrFail($id); + $footer->short_code = DisplayFooterContent::generateUniqueShortCode(); + $footer->save(); + session()->flash('success', 'Short-Code wurde neu generiert!'); + } + + public function resetClicks($id) + { + $footer = DisplayFooterContent::findOrFail($id); + $footer->clicks = 0; + $footer->save(); + session()->flash('success', 'Klick-Zähler wurde zurückgesetzt!'); + } + + public function deleteFooter($id) + { + DisplayFooterContent::findOrFail($id)->delete(); + session()->flash('success', 'Footer-Inhalt erfolgreich gelöscht!'); + } + + public function toggleFooterStatus($id) + { + $footer = DisplayFooterContent::findOrFail($id); + $footer->update(['is_active' => ! $footer->is_active]); + } + + public function moveFooter($id, $direction) + { + $footer = DisplayFooterContent::findOrFail($id); + $currentOrder = $footer->sort_order; + + if ($direction === 'up' && $currentOrder > 0) { + $swapFooter = DisplayFooterContent::where('sort_order', $currentOrder - 1)->first(); + if ($swapFooter) { + $footer->update(['sort_order' => $currentOrder - 1]); + $swapFooter->update(['sort_order' => $currentOrder]); + } + } elseif ($direction === 'down') { + $swapFooter = DisplayFooterContent::where('sort_order', $currentOrder + 1)->first(); + if ($swapFooter) { + $footer->update(['sort_order' => $currentOrder + 1]); + $swapFooter->update(['sort_order' => $currentOrder]); + } + } + } + + public function resetFooterForm() + { + $this->footerId = null; + $this->footerHeadline = ''; + $this->footerSubline = ''; + $this->footerUrl = ''; + $this->footerIsActive = true; + } + + public function closeFooterModal() + { + $this->showFooterModal = false; + $this->resetFooterForm(); + } + + public function render() + { + $videos = DisplayVideo::orderBy('sort_order')->get(); + $footerContents = DisplayFooterContent::orderBy('sort_order')->get(); + + return view('livewire.admin.cms.cabinet-display', [ + 'videos' => $videos, + 'footerContents' => $footerContents, + ]); + } +} diff --git a/dev/flux-cms/Cms/CabinetInfoTablet.php b/dev/flux-cms/Cms/CabinetInfoTablet.php new file mode 100644 index 0000000..7971137 --- /dev/null +++ b/dev/flux-cms/Cms/CabinetInfoTablet.php @@ -0,0 +1,189 @@ +storeStatus = $s->store_status ?? 'auto'; + $this->noticeHeadline = $s->notice_headline ?? ''; + $this->noticeSubtext = $s->notice_subtext ?? ''; + $this->overrideOpenToday = $s->override_open_today ?? ''; + $this->overrideCloseToday = $s->override_close_today ?? ''; + $this->nextAppointmentDate = $s->next_appointment_date?->format('Y-m-d'); + $this->nextAppointmentTime = $s->next_appointment_time ?? ''; + $this->hoursMondayOpen = $s->hours_monday_open ?? ''; + $this->hoursMondayClose = $s->hours_monday_close ?? ''; + $this->hoursTuesdayOpen = $s->hours_tuesday_open ?? ''; + $this->hoursTuesdayClose = $s->hours_tuesday_close ?? ''; + $this->hoursWednesdayOpen = $s->hours_wednesday_open ?? ''; + $this->hoursWednesdayClose = $s->hours_wednesday_close ?? ''; + $this->hoursThursdayOpen = $s->hours_thursday_open ?? ''; + $this->hoursThursdayClose = $s->hours_thursday_close ?? ''; + $this->hoursFridayOpen = $s->hours_friday_open ?? ''; + $this->hoursFridayClose = $s->hours_friday_close ?? ''; + $this->hoursSaturdayOpen = $s->hours_saturday_open ?? ''; + $this->hoursSaturdayClose = $s->hours_saturday_close ?? ''; + $this->hoursSundayOpen = $s->hours_sunday_open ?? ''; + $this->hoursSundayClose = $s->hours_sunday_close ?? ''; + $this->contactPhone = $s->contact_phone ?? ''; + $this->contactEmail = $s->contact_email ?? ''; + } + + private function timeRule(): array + { + return ['nullable', 'string', 'regex:/^(\d{2}:\d{2})?$/']; + } + + private function toNullIfEmpty(?string $value): ?string + { + return $value !== null && trim($value) !== '' ? trim($value) : null; + } + + /** + * @param array $hours Optional: Time picker values from DOM (bypasses wire:model sync issues) + */ + public function save(array $hours = []): void + { + foreach ($hours as $prop => $value) { + if (property_exists($this, $prop)) { + $this->{$prop} = $value ?? ''; + } + } + + $timeRule = $this->timeRule(); + + $this->validate([ + 'storeStatus' => 'required|in:auto,notice,warning,closed', + 'noticeHeadline' => 'nullable|string|max:40', + 'noticeSubtext' => 'nullable|string|max:80', + 'overrideOpenToday' => $timeRule, + 'overrideCloseToday' => $timeRule, + 'nextAppointmentDate' => 'nullable|date', + 'nextAppointmentTime' => $timeRule, + 'hoursMondayOpen' => $timeRule, + 'hoursMondayClose' => $timeRule, + 'hoursTuesdayOpen' => $timeRule, + 'hoursTuesdayClose' => $timeRule, + 'hoursWednesdayOpen' => $timeRule, + 'hoursWednesdayClose' => $timeRule, + 'hoursThursdayOpen' => $timeRule, + 'hoursThursdayClose' => $timeRule, + 'hoursFridayOpen' => $timeRule, + 'hoursFridayClose' => $timeRule, + 'hoursSaturdayOpen' => $timeRule, + 'hoursSaturdayClose' => $timeRule, + 'hoursSundayOpen' => $timeRule, + 'hoursSundayClose' => $timeRule, + 'contactPhone' => 'nullable|string|max:50', + 'contactEmail' => 'nullable|email|max:100', + ], [ + 'storeStatus.required' => 'Der Store-Status ist erforderlich.', + 'storeStatus.in' => 'Ungültiger Status. Erlaubt: auto, notice, warning, closed.', + 'noticeHeadline.max' => 'Die Headline darf maximal 40 Zeichen haben.', + 'noticeSubtext.max' => 'Der Subtext darf maximal 80 Zeichen haben.', + 'overrideOpenToday.regex' => 'Bitte im Format HH:MM eingeben.', + 'overrideCloseToday.regex' => 'Bitte im Format HH:MM eingeben.', + 'nextAppointmentTime.regex' => 'Bitte im Format HH:MM eingeben.', + 'contactEmail.email' => 'Bitte eine gültige E-Mail-Adresse eingeben.', + ]); + CabinetTabletSetting::current()->update([ + 'store_status' => $this->storeStatus, + 'notice_headline' => $this->toNullIfEmpty($this->noticeHeadline), + 'notice_subtext' => $this->toNullIfEmpty($this->noticeSubtext), + 'override_open_today' => $this->toNullIfEmpty($this->overrideOpenToday), + 'override_close_today' => $this->toNullIfEmpty($this->overrideCloseToday), + 'next_appointment_date' => $this->toNullIfEmpty($this->nextAppointmentDate), + 'next_appointment_time' => $this->toNullIfEmpty($this->nextAppointmentTime), + 'hours_monday_open' => $this->toNullIfEmpty($this->hoursMondayOpen), + 'hours_monday_close' => $this->toNullIfEmpty($this->hoursMondayClose), + 'hours_tuesday_open' => $this->toNullIfEmpty($this->hoursTuesdayOpen), + 'hours_tuesday_close' => $this->toNullIfEmpty($this->hoursTuesdayClose), + 'hours_wednesday_open' => $this->toNullIfEmpty($this->hoursWednesdayOpen), + 'hours_wednesday_close' => $this->toNullIfEmpty($this->hoursWednesdayClose), + 'hours_thursday_open' => $this->toNullIfEmpty($this->hoursThursdayOpen), + 'hours_thursday_close' => $this->toNullIfEmpty($this->hoursThursdayClose), + 'hours_friday_open' => $this->toNullIfEmpty($this->hoursFridayOpen), + 'hours_friday_close' => $this->toNullIfEmpty($this->hoursFridayClose), + 'hours_saturday_open' => $this->toNullIfEmpty($this->hoursSaturdayOpen), + 'hours_saturday_close' => $this->toNullIfEmpty($this->hoursSaturdayClose), + 'hours_sunday_open' => $this->toNullIfEmpty($this->hoursSundayOpen), + 'hours_sunday_close' => $this->toNullIfEmpty($this->hoursSundayClose), + 'contact_phone' => $this->toNullIfEmpty($this->contactPhone), + 'contact_email' => $this->toNullIfEmpty($this->contactEmail), + ]); + + session()->flash('success', 'Info-Tablet Einstellungen gespeichert!'); + } + + public function clearOverrides(): void + { + CabinetTabletSetting::current()->clearOverrides(); + $this->overrideOpenToday = ''; + $this->overrideCloseToday = ''; + + session()->flash('success', 'Sonderöffnungszeiten wurden zurückgesetzt!'); + } + + public function render(): \Illuminate\View\View + { + return view('livewire.admin.cms.cabinet-info-tablet'); + } +} diff --git a/dev/flux-cms/Cms/DisplayList.php b/dev/flux-cms/Cms/DisplayList.php new file mode 100644 index 0000000..1e908b9 --- /dev/null +++ b/dev/flux-cms/Cms/DisplayList.php @@ -0,0 +1,153 @@ + */ + public $selectedVersionIds = []; + + public $displayIsActive = true; + + public $addVersionSelect = null; + + public function openModal(?int $id = null): void + { + if ($id) { + $display = Display::with('versions')->findOrFail($id); + $this->displayId = $display->id; + $this->displayName = $display->name; + $this->displayLocation = $display->location ?? ''; + $this->selectedVersionIds = $display->versions->pluck('id')->toArray(); + $this->displayIsActive = $display->is_active; + } else { + $this->resetForm(); + } + + $this->showModal = true; + } + + public function addVersion(?int $versionId = null): void + { + $id = $versionId ?? $this->addVersionSelect; + + if ($id && ! in_array((int) $id, $this->selectedVersionIds)) { + $this->selectedVersionIds[] = (int) $id; + } + + $this->addVersionSelect = null; + } + + public function removeVersion(int $index): void + { + array_splice($this->selectedVersionIds, $index, 1); + } + + public function moveVersion(int $index, string $direction): void + { + $newIndex = $direction === 'up' ? $index - 1 : $index + 1; + + if ($newIndex < 0 || $newIndex >= count($this->selectedVersionIds)) { + return; + } + + $temp = $this->selectedVersionIds[$index]; + $this->selectedVersionIds[$index] = $this->selectedVersionIds[$newIndex]; + $this->selectedVersionIds[$newIndex] = $temp; + } + + public function save(): void + { + $this->validate([ + 'displayName' => 'required|string|max:255', + 'displayLocation' => 'nullable|string|max:255', + 'selectedVersionIds' => 'array', + 'selectedVersionIds.*' => 'exists:display_versions,id', + ], [ + 'displayName.required' => 'Bitte geben Sie einen Namen ein.', + ]); + + $data = [ + 'name' => $this->displayName, + 'location' => $this->displayLocation ?: null, + 'is_active' => $this->displayIsActive, + ]; + + if ($this->displayId) { + $display = Display::findOrFail($this->displayId); + $display->update($data); + session()->flash('success', 'Display erfolgreich aktualisiert!'); + } else { + $display = Display::create($data); + session()->flash('success', 'Display erfolgreich erstellt!'); + } + + // Sync versions with sort_order + $syncData = []; + foreach ($this->selectedVersionIds as $sortOrder => $versionId) { + $syncData[$versionId] = ['sort_order' => $sortOrder]; + } + $display->versions()->sync($syncData); + + $this->closeModal(); + } + + public function deleteDisplay(int $id): void + { + $display = Display::findOrFail($id); + $name = $display->name; + $display->delete(); + + session()->flash('success', 'Display "'.$name.'" wurde gelöscht!'); + } + + public function toggleActive(int $id): void + { + $display = Display::findOrFail($id); + $display->update(['is_active' => ! $display->is_active]); + } + + public function closeModal(): void + { + $this->showModal = false; + $this->resetForm(); + } + + public function resetForm(): void + { + $this->displayId = null; + $this->displayName = ''; + $this->displayLocation = ''; + $this->selectedVersionIds = []; + $this->displayIsActive = true; + $this->addVersionSelect = null; + } + + public function render() + { + $displays = Display::with('versions') + ->orderBy('name') + ->get(); + + $versions = DisplayVersion::active() + ->orderBy('name') + ->get(); + + return view('livewire.admin.cms.display-list', [ + 'displays' => $displays, + 'versions' => $versions, + ]); + } +} diff --git a/dev/flux-cms/Cms/DisplayVersionEditor.php b/dev/flux-cms/Cms/DisplayVersionEditor.php new file mode 100644 index 0000000..7349d9b --- /dev/null +++ b/dev/flux-cms/Cms/DisplayVersionEditor.php @@ -0,0 +1,437 @@ + */ + public array $slideBullets = []; + + public string $slideDisclaimer = ''; + + public string $slideQrUrl = ''; + + public string $slideQrTitle = ''; + + public string $slideContact = ''; + + public bool $slideShowBrandText = false; + + public string $slideBrandTagline = ''; + + public bool $slideIsActive = true; + + // Settings Modal + public bool $showSettingsModal = false; + + public array $settings = []; + + /** @var array */ + public array $availableVideos = []; + + public function mount(DisplayVersion $displayVersion): void + { + $this->version = $displayVersion; + $this->versionName = $displayVersion->name; + $this->settings = $displayVersion->settings ?? []; + + if ($this->version->type === DisplayVersionType::VideoDisplay) { + $this->loadAvailableVideos(); + } + } + + public function loadAvailableVideos(): void + { + $assetsPath = public_path('_cabinet/assets'); + + if (File::exists($assetsPath)) { + $this->availableVideos = collect(File::files($assetsPath)) + ->map(fn ($file) => $file->getFilename()) + ->filter(fn ($filename) => in_array(pathinfo($filename, PATHINFO_EXTENSION), ['mp4', 'webm', 'mov'])) + ->values() + ->toArray(); + } + } + + public function toggleTheme(): void + { + $settings = $this->version->settings ?? []; + $settings['theme'] = ($settings['theme'] ?? 'dark') === 'dark' ? 'light' : 'dark'; + $this->version->update(['settings' => $settings]); + $this->settings = $settings; + } + + public function saveName(): void + { + $this->validate([ + 'versionName' => 'required|string|max:255', + ]); + + $this->version->update(['name' => $this->versionName]); + session()->flash('success', 'Name aktualisiert!'); + } + + // ======================================== + // SETTINGS + // ======================================== + + public function openSettingsModal(): void + { + $this->settings = $this->version->settings ?? []; + $this->showSettingsModal = true; + } + + public function saveSettings(): void + { + $this->version->update(['settings' => $this->settings]); + $this->showSettingsModal = false; + session()->flash('success', 'Einstellungen gespeichert!'); + } + + // ======================================== + // ITEM CRUD + // ======================================== + + public function openItemModal(?int $id = null, string $type = ''): void + { + $this->resetItemForm(); + + if ($id) { + $item = DisplayVersionItem::findOrFail($id); + $this->itemId = $item->id; + $this->itemType = $item->item_type; + $this->loadItemContent($item); + } else { + $this->itemType = $type ?: $this->defaultItemType(); + } + + $this->showItemModal = true; + } + + public function saveItem(): void + { + $content = $this->buildItemContent(); + $isActive = $this->getActiveFlag(); + + if ($this->itemId) { + $item = DisplayVersionItem::findOrFail($this->itemId); + $item->update([ + 'content' => $content, + 'is_active' => $isActive, + ]); + session()->flash('success', 'Inhalt aktualisiert!'); + } else { + $maxSort = DisplayVersionItem::where('display_version_id', $this->version->id) + ->where('item_type', $this->itemType) + ->max('sort_order') ?? -1; + + DisplayVersionItem::create([ + 'display_version_id' => $this->version->id, + 'item_type' => $this->itemType, + 'content' => $content, + 'sort_order' => $maxSort + 1, + 'is_active' => $isActive, + ]); + session()->flash('success', 'Inhalt hinzugefügt!'); + } + + $this->closeItemModal(); + } + + public function deleteItem(int $id): void + { + DisplayVersionItem::findOrFail($id)->delete(); + session()->flash('success', 'Inhalt gelöscht!'); + } + + public function toggleItemStatus(int $id): void + { + $item = DisplayVersionItem::findOrFail($id); + $item->update(['is_active' => ! $item->is_active]); + } + + public function moveItem(int $id, string $direction): void + { + $item = DisplayVersionItem::findOrFail($id); + $currentOrder = $item->sort_order; + + $swapItem = DisplayVersionItem::where('display_version_id', $this->version->id) + ->where('item_type', $item->item_type) + ->where('sort_order', $direction === 'up' ? $currentOrder - 1 : $currentOrder + 1) + ->first(); + + if ($swapItem) { + $item->update(['sort_order' => $swapItem->sort_order]); + $swapItem->update(['sort_order' => $currentOrder]); + } + } + + public function addBullet(): void + { + $this->slideBullets[] = ''; + } + + public function removeBullet(int $index): void + { + unset($this->slideBullets[$index]); + $this->slideBullets = array_values($this->slideBullets); + } + + public function closeItemModal(): void + { + $this->showItemModal = false; + $this->resetItemForm(); + } + + // ======================================== + // HELPERS + // ======================================== + + private function loadItemContent(DisplayVersionItem $item): void + { + $content = $item->content; + + match ($item->item_type) { + 'video' => $this->loadVideoContent($content), + 'footer' => $this->loadFooterContent($content), + 'media' => $this->loadMediaContent($content), + 'slide' => $this->loadSlideContent($content), + default => null, + }; + } + + private function loadVideoContent(array $content): void + { + $this->videoFilename = $content['filename'] ?? ''; + $this->videoTitle = $content['title'] ?? ''; + $this->videoPosition = $content['position'] ?? 25; + $this->videoIsActive = true; + } + + private function loadFooterContent(array $content): void + { + $this->footerHeadline = $content['headline'] ?? ''; + $this->footerSubline = $content['subline'] ?? ''; + $this->footerUrl = $content['url'] ?? ''; + $this->footerIsActive = true; + } + + private function loadMediaContent(array $content): void + { + $this->mediaType = $content['media_type'] ?? 'image'; + $this->mediaCategory = $content['category'] ?? 'immobilien'; + $this->mediaUrl = $content['media_url'] ?? ''; + $this->mediaHeadline = $content['headline'] ?? ''; + $this->mediaSubline = $content['subline'] ?? ''; + $this->mediaDuration = $content['duration_seconds'] ?? 10; + $this->mediaIsActive = true; + } + + private function loadSlideContent(array $content): void + { + $this->slideType = $content['type'] ?? 'product-hero'; + $this->slideDuration = $content['duration'] ?? 8000; + $this->slideImageUrl = $content['image_url'] ?? ''; + $this->slideBadge = $content['badge_text'] ?? ''; + $this->slideEyebrow = $content['eyebrow'] ?? ''; + $this->slideTitle = $content['title'] ?? ''; + $this->slideSubline = $content['subline'] ?? ''; + $this->slidePrice = $content['price'] ?? ''; + $this->slideOriginalPrice = $content['original_price'] ?? ''; + $this->slideTagText = $content['tag_text'] ?? ''; + $this->slideBullets = $content['bullets'] ?? []; + $this->slideDisclaimer = $content['disclaimer'] ?? ''; + $this->slideQrUrl = $content['qr_url'] ?? ''; + $this->slideQrTitle = $content['qr_title'] ?? ''; + $this->slideContact = $content['contact'] ?? ''; + $this->slideShowBrandText = $content['show_brand_text'] ?? false; + $this->slideBrandTagline = $content['brand_tagline'] ?? ''; + $this->slideIsActive = true; + } + + /** + * @return array + */ + private function buildItemContent(): array + { + return match ($this->itemType) { + 'video' => [ + 'filename' => $this->videoFilename, + 'title' => $this->videoTitle, + 'position' => $this->videoPosition, + ], + 'footer' => [ + 'headline' => $this->footerHeadline, + 'subline' => $this->footerSubline, + 'url' => $this->footerUrl ?: null, + ], + 'media' => [ + 'media_type' => $this->mediaType, + 'category' => $this->mediaCategory, + 'media_url' => $this->mediaUrl, + 'headline' => $this->mediaHeadline, + 'subline' => $this->mediaSubline, + 'duration_seconds' => $this->mediaDuration, + ], + 'slide' => [ + 'type' => $this->slideType, + 'duration' => $this->slideDuration, + 'image_url' => $this->slideImageUrl, + 'badge_text' => $this->slideBadge, + 'eyebrow' => $this->slideEyebrow, + 'title' => $this->slideTitle, + 'subline' => $this->slideSubline, + 'price' => $this->slidePrice, + 'original_price' => $this->slideOriginalPrice, + 'tag_text' => $this->slideTagText, + 'bullets' => $this->slideBullets, + 'disclaimer' => $this->slideDisclaimer, + 'qr_url' => $this->slideQrUrl, + 'qr_title' => $this->slideQrTitle, + 'contact' => $this->slideContact, + 'show_brand_text' => $this->slideShowBrandText, + 'brand_tagline' => $this->slideBrandTagline, + ], + default => [], + }; + } + + private function getActiveFlag(): bool + { + return match ($this->itemType) { + 'video' => $this->videoIsActive, + 'footer' => $this->footerIsActive, + 'media' => $this->mediaIsActive, + 'slide' => $this->slideIsActive, + default => true, + }; + } + + private function defaultItemType(): string + { + return match ($this->version->type) { + DisplayVersionType::VideoDisplay => 'video', + DisplayVersionType::B2in => 'media', + DisplayVersionType::Offers => 'slide', + }; + } + + private function resetItemForm(): void + { + $this->itemId = null; + $this->itemType = ''; + $this->videoFilename = ''; + $this->videoTitle = ''; + $this->videoPosition = 25; + $this->videoIsActive = true; + $this->footerHeadline = ''; + $this->footerSubline = ''; + $this->footerUrl = ''; + $this->footerIsActive = true; + $this->mediaType = 'image'; + $this->mediaCategory = 'immobilien'; + $this->mediaUrl = ''; + $this->mediaHeadline = ''; + $this->mediaSubline = ''; + $this->mediaDuration = 10; + $this->mediaIsActive = true; + $this->slideType = 'product-hero'; + $this->slideDuration = 8000; + $this->slideImageUrl = ''; + $this->slideBadge = ''; + $this->slideEyebrow = ''; + $this->slideTitle = ''; + $this->slideSubline = ''; + $this->slidePrice = ''; + $this->slideOriginalPrice = ''; + $this->slideTagText = ''; + $this->slideBullets = []; + $this->slideDisclaimer = ''; + $this->slideQrUrl = ''; + $this->slideQrTitle = ''; + $this->slideContact = ''; + $this->slideShowBrandText = false; + $this->slideBrandTagline = ''; + $this->slideIsActive = true; + } + + public function render() + { + $items = $this->version->items()->get()->groupBy('item_type'); + + return view('livewire.admin.cms.display-version-editor', [ + 'items' => $items, + ]); + } +} diff --git a/dev/flux-cms/Cms/DisplayVersionList.php b/dev/flux-cms/Cms/DisplayVersionList.php new file mode 100644 index 0000000..5eb1c97 --- /dev/null +++ b/dev/flux-cms/Cms/DisplayVersionList.php @@ -0,0 +1,102 @@ +newName = ''; + $this->newType = ''; + $this->showCreateModal = true; + } + + public function createVersion(): void + { + $this->validate([ + 'newName' => 'required|string|max:255', + 'newType' => 'required|string|in:video-display,b2in,offers', + ], [ + 'newName.required' => 'Bitte geben Sie einen Namen ein.', + 'newType.required' => 'Bitte wählen Sie einen Typ aus.', + ]); + + $version = DisplayVersion::create([ + 'name' => $this->newName, + 'type' => $this->newType, + 'settings' => $this->defaultSettingsForType($this->newType), + 'is_active' => true, + ]); + + $this->showCreateModal = false; + $this->newName = ''; + $this->newType = ''; + + session()->flash('success', 'Version "'.$version->name.'" wurde erstellt!'); + + $this->redirect( + route('admin.cms.display-version-edit', $version), + navigate: true + ); + } + + public function deleteVersion(int $id): void + { + $version = DisplayVersion::findOrFail($id); + $name = $version->name; + $version->delete(); + + session()->flash('success', 'Version "'.$name.'" wurde gelöscht!'); + } + + public function toggleActive(int $id): void + { + $version = DisplayVersion::findOrFail($id); + $version->update(['is_active' => ! $version->is_active]); + } + + /** + * @return array + */ + private function defaultSettingsForType(string $type): array + { + return match ($type) { + 'b2in' => [ + 'theme' => 'dark', + 'footer_name' => '', + 'footer_url' => '', + 'transition' => ['type' => 'crossfade', 'duration_ms' => 800], + 'default_image_duration' => 10, + 'rotation_weights' => ['immobilien' => 70, 'moebel' => 30], + 'display_active' => true, + ], + 'offers' => [ + 'loop' => true, + 'transition' => ['type' => 'fade', 'duration' => 600], + ], + default => [], + }; + } + + public function render() + { + $versions = DisplayVersion::withCount(['items', 'displays']) + ->orderBy('name') + ->get(); + + return view('livewire.admin.cms.display-version-list', [ + 'versions' => $versions, + 'types' => DisplayVersionType::cases(), + ]); + } +} diff --git a/dev/flux-cms/Cms/MediaLibraryUploader.php b/dev/flux-cms/Cms/MediaLibraryUploader.php new file mode 100644 index 0000000..8b495b6 --- /dev/null +++ b/dev/flux-cms/Cms/MediaLibraryUploader.php @@ -0,0 +1,45 @@ + */ + public array $uploads = []; + + public function updatedUploads(): void + { + $this->validate([ + 'uploads' => 'nullable|array|max:20', + 'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240', + ]); + + $service = app(MediaConversionService::class); + + foreach ($this->uploads as $file) { + $media = $service->storeUpload($file); + $this->dispatch('media-library-uploaded', mediaId: $media->id); + } + + $this->uploads = []; + } + + public function removeUpload(int $index): void + { + if (isset($this->uploads[$index])) { + unset($this->uploads[$index]); + $this->uploads = array_values($this->uploads); + } + } + + public function render() + { + return view('livewire.admin.cms.media-library-uploader'); + } +} diff --git a/dev/flux-cms/Cms/MediaPicker.php b/dev/flux-cms/Cms/MediaPicker.php new file mode 100644 index 0000000..14eb146 --- /dev/null +++ b/dev/flux-cms/Cms/MediaPicker.php @@ -0,0 +1,139 @@ + */ + public array $quickUploads = []; + + public function mount(?int $value = null): void + { + $this->value = $value; + } + + public function openPicker(): void + { + $this->showModal = true; + } + + public function selectMedia(int $id): void + { + $media = CmsMedia::find($id); + if (! $media) { + return; + } + + if ($media->isImage() && $this->profile) { + $service = app(MediaConversionService::class); + if (! $media->hasConversion($this->profile)) { + $service->convert($media, $this->profile); + $media->refresh(); + } + } + + $this->value = $media->id; + $this->showModal = false; + + $this->dispatch('media-selected', field: $this->field, mediaId: $media->id, url: $media->getConversionUrl($this->profile)); + } + + public function clearSelection(): void + { + $this->value = null; + $this->dispatch('media-selected', field: $this->field, mediaId: null, url: null); + } + + public function updatedQuickUploads(): void + { + $this->validate([ + 'quickUploads' => 'nullable|array|max:5', + 'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240', + ]); + + $service = app(MediaConversionService::class); + $lastMedia = null; + + foreach ($this->quickUploads as $file) { + $lastMedia = $service->storeUpload($file); + + if ($lastMedia->isImage() && $this->profile) { + $service->convert($lastMedia, $this->profile); + $lastMedia->refresh(); + } + } + + $this->quickUploads = []; + + if ($lastMedia) { + $this->value = $lastMedia->id; + $this->showModal = false; + + $this->dispatch('media-selected', field: $this->field, mediaId: $lastMedia->id, url: $lastMedia->getConversionUrl($this->profile)); + } + } + + public function removeQuickUpload(int $index): void + { + if (isset($this->quickUploads[$index])) { + unset($this->quickUploads[$index]); + $this->quickUploads = array_values($this->quickUploads); + } + } + + public function render(): View + { + return view('livewire.admin.cms.media-picker', [ + 'selectedMedia' => $this->resolveSelectedMedia(), + 'mediaItems' => $this->resolveMediaItems(), + ]); + } + + private function resolveSelectedMedia(): ?CmsMedia + { + if (! $this->value) { + return null; + } + + return CmsMedia::find($this->value); + } + + /** + * @return LengthAwarePaginator + */ + private function resolveMediaItems(): LengthAwarePaginator + { + return CmsMedia::query() + ->when($this->type === 'image', fn ($q) => $q->images()) + ->when($this->type === 'pdf', fn ($q) => $q->pdfs()) + ->when($this->type === 'document', fn ($q) => $q->documents()) + ->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%")) + ->orderByDesc('created_at') + ->paginate(18); + } +} diff --git a/dev/flux-cms/PLAN.md b/dev/flux-cms/PLAN.md new file mode 100644 index 0000000..518b3a6 --- /dev/null +++ b/dev/flux-cms/PLAN.md @@ -0,0 +1,524 @@ +# Flux CMS Integration – Entwicklungsplan + +> **Ziel:** Integration des flux-cms Packages für die b2in-Webseite. +> **Scope:** Nur b2in – weitere Subseiten (b2a, stileigentum, style2own) folgen später. +> **Stand:** 2026-03-18 + +--- + +## Ausgangslage + +### Was existiert +- **Package:** `packages/flux-cms/` (core, components, starter-components) liegt im Projekt +- **Composer:** `packages/*/*` Repository-Pfad ist in `composer.json` konfiguriert +- **Frontend:** Vollständige b2in-Webseite mit ~15 Seiten +- **Content-Quelle:** `config/content.php` – statische PHP-Arrays pro Theme +- **Content-Zugriff:** Livewire-Sections laden via `config("content.themes.{$theme}.{$section}")` +- **Immobilien/Projekte:** Komplett in `config/content.php` definiert (Slug, Titel, Preise, Galerie, Quick Facts, Investment Case, Location etc.), Route `immobilien/{slug}` liest direkt aus Config +- **Admin-Portal:** `portal.b2in.test` mit eigenem CMS (Cabinets/Displays), User-/Partner-Management +- **Domain-System:** Multi-Domain-Setup in `config/domains.php` + +### Was fehlt +- flux-cms/core ist **nicht** als Composer-Dependency installiert +- Keine `app/helpers.php` mit `cms()`/`tcms()`/`cms_media_url()` +- Keine `config/flux-cms.php` publiziert +- Keine `flux_cms_*` Datenbanktabellen +- Keine Admin-Views/Routes für das CMS +- Keine Medienbibliothek +- Kein Immobilien-Model (Projekte leben in Config) + +### Scope-Einschränkung Phase 1 + +**Nur diese Tabellen werden initial benötigt:** +- `flux_cms_contents` – Alle Seiteninhalte (Key-Value mit Übersetzungen) +- `flux_cms_media` – Medienbibliothek (Bilder, PDFs) + +**Nicht benötigt (kommt später bei Bedarf):** +- ~~`flux_cms_news_items`~~ – Nachrichteneinträge +- ~~`flux_cms_industries`~~ – Branchen +- ~~`flux_cms_faqs`~~ – FAQ-Einträge +- ~~`flux_cms_downloads`~~ – Downloads +- ~~`flux_cms_linkedin_posts`~~ – LinkedIn-Posts +- ~~`flux_cms_search_index`~~ – Suchindex + +**Zusätzlich benötigt:** +- Neues **Immobilien/Projekte-Model** mit eigenem CRUD im CMS-Backend (ersetzt die statischen Projekte aus `config/content.php`) + +--- + +## Phasen-Übersicht + +| Phase | Beschreibung | Abhängig von | +|-------|-------------|--------------| +| **1** | Package-Installation & Infrastruktur | – | +| **2** | Immobilien-Model & CRUD | Phase 1 | +| **3** | Content-Migration (config → DB) | Phase 1 | +| **4** | CMS Admin-Backend | Phase 1 | +| **5** | Frontend-Umstellung (config → cms) | Phase 2, 3 | +| **6** | Medienbibliothek & Bilder | Phase 1, 4 | +| **7** | Tests | Phase 2–6 | +| **8** | Feinschliff & Dokumentation | Phase 5–7 | + +--- + +## Phase 1 – Package-Installation & Infrastruktur + +### 1.1 Composer-Dependency installieren +```bash +composer require flux-cms/core:@dev +composer require intervention/image +``` + +**Prüfen:** +- [ ] `FluxCmsServiceProvider` wird automatisch geladen +- [ ] Keine Konflikte mit bestehenden Dependencies + +### 1.2 Konfiguration publizieren +```bash +php artisan vendor:publish --tag=flux-cms-config +``` + +**Anpassen in `config/flux-cms.php`:** +- `default_locale` → `'de'` +- `locales` → `['de' => 'Deutsch', 'en' => 'English']` +- `media.profiles` → an b2in-Bildgrößen anpassen +- `routes.enabled` → `false` (eigene Admin-Routes im Portal) + +### 1.3 Migrations ausführen + +**Nur die benötigten Migrations:** +- `create_flux_cms_contents_table` +- `create_flux_cms_media_table` + +Die restlichen Migrations (news, industries, faqs, downloads, linkedin, search_index) werden **nicht ausgeführt** – sie liegen im Package und können bei Bedarf später migriert werden. + +**Optionen:** +- A) Alle Migrations laufen lassen (Tabellen existieren, werden aber nicht genutzt) – einfacher +- B) Nur selektiv migrieren – sauberer, erfordert ggf. Anpassung am ServiceProvider + +→ **Empfehlung: Option A** – leere Tabellen stören nicht und vereinfachen spätere Erweiterung. + +```bash +php artisan migrate +``` + +### 1.4 Helper-Funktionen einrichten + +**Erstellen:** `app/helpers.php` mit `cms()`, `tcms()`, `cms_media_url()`, `media_url()` + +**Registrieren in `composer.json`:** +```json +"autoload": { + "files": ["app/helpers.php"] +} +``` +```bash +composer dump-autoload +``` + +### 1.5 Storage-Link +```bash +php artisan storage:link +``` + +**Ergebnis Phase 1:** Package installiert, DB-Tabellen vorhanden, Helper verfügbar. + +--- + +## Phase 2 – Immobilien/Projekte-Model & CRUD + +### 2.1 Datenstruktur analysieren + +Aktuelle Projekt-Daten aus `config/content.php` (am Beispiel Azizi Creek Views 4): + +| Feld | Typ | Beispiel | +|------|-----|---------| +| `slug` | string | `azizi-creek-views-4` | +| `title` | string | `Azizi Developments: Creek Views 4` | +| `location` | string | `Al Jaddaf, Dubai` | +| `status` | string | `NEW LAUNCH` | +| `launch_date` | string | `03.03.2026` | +| `price_from` | integer (AED) | `1125000` | +| `image` | string (Pfad) | `expose/a1/image-4.jpeg` | +| `highlights` | array\ | `['Prime Waterfront Views', ...]` | +| `quick_facts` | array\<{icon, label, value}\> | Typen, Größe, Einheiten, Entwickler | +| `investment_case` | object | `{title, text, views[]}` | +| `gallery` | array\ | Bildpfade | +| `location_info` | object | `{title, map_url, points[]}` | +| `contact` | object | `{title, subtitle, options[]}` | + +### 2.2 Migration erstellen + +Neue Migration `create_cms_projects_table`: + +```php +Schema::create('cms_projects', function (Blueprint $table) { + $table->id(); + $table->string('slug')->unique(); + $table->json('title'); // translatable + $table->json('location'); // translatable + $table->string('status')->nullable(); + $table->date('launch_date')->nullable(); + $table->unsignedInteger('price_from_aed')->nullable(); + $table->string('currency')->default('AED'); + $table->string('image')->nullable(); // CmsMedia Referenz + $table->json('highlights')->nullable(); // translatable array + $table->json('quick_facts')->nullable(); // [{icon, label, value}] + $table->json('investment_case')->nullable(); // {title, text, views[]} + $table->json('gallery')->nullable(); // [filename, ...] + $table->json('location_info')->nullable(); // {title, map_url, points[]} + $table->json('contact')->nullable(); // {title, subtitle, options[]} + $table->boolean('is_published')->default(false); + $table->unsignedInteger('order')->default(0); + $table->timestamps(); +}); +``` + +### 2.3 Model erstellen + +`App\Models\CmsProject` mit: +- `HasTranslations` (Spatie) für `title`, `location`, `highlights` +- Scopes: `published()`, `ordered()` +- `toFrontendArray()` → kompatibles Array für bestehende Blade-Views +- `getFormattedPrice()` → nutzt `PriceHelper::formatAed()` +- Accessors für `gallery_urls`, `image_url` etc. + +### 2.4 Factory & Seeder + +- **Factory:** `CmsProjectFactory` für Tests +- **Seeder:** `CmsProjectSeeder` – importiert die bestehenden Projekte aus `config/content.php` in die DB + +### 2.5 Admin CRUD (Volt-Komponente) + +Admin-View `admin.cms.projects-index`: +- Liste aller Projekte (Titel, Status, Preis, Published, Order) +- Erstellen/Bearbeiten Modal oder Inline +- Felder: alle aus 2.2 +- Galerie-Management via MediaPicker +- Bild-Upload via MediaLibraryUploader +- Sortierung per Drag & Drop oder Order-Feld +- Publish/Unpublish Toggle + +### 2.6 Route für Immobilien-Show anpassen + +```php +// Vorher: +Route::get('/immobilien/{slug}', function (string $slug) { + $project = config("content.themes.{$theme}.immobilien_projects.projects.{$slug}"); + ... +}); + +// Nachher: +Route::get('/immobilien/{slug}', function (string $slug) { + $project = CmsProject::where('slug', $slug)->published()->firstOrFail(); + return view('web.immobilien-show', ['project' => $project->toFrontendArray()]); +}); +``` + +**Ergebnis Phase 2:** Immobilien-Projekte in DB, editierbar im CMS, Frontend nutzt Model. + +--- + +## Phase 3 – Content-Migration (config → DB) + +### 3.1 Content-Struktur analysieren + +Die `config/content.php` enthält das b2in-Theme mit folgenden Sektionen: + +| Page | Section | Typ | +|------|---------|-----| +| global | `announcement_bar` | Text + Links | +| global | `header` | Navigation | +| global | `footer` | Text + Links | +| home | `hero` | Text + Bild + Stats | +| home | `founder_bar` | Text + Bild | +| home | `synergie_section` | Text + Bild | +| home | `vision_section` | Text + Bild | +| home | `ecosystem_core` | Text + Items | +| home | `cta_section` | Text + Link | +| home | `brand_worlds` | Text + Items | +| about | `about_hero`, `our_story`, `our_values`, `leadership_team` | Diverse | +| immobilien | `immobilien_hero_v2`, `immobilien_warum_dubai`, `immobilien_kaufprozess`, `immobilien_bruecke`, `immobilien_mindset`, `immobilien_moebel_vorteil` | Diverse | +| faq | FAQ-Kategorien | Q&A | +| netzwerk | Hero, Stats, Sections | Diverse | +| contact | Form-Info | Text | +| impressum/privacy/terms/cookie-policy | Langtext | HTML | + +### 3.2 CmsContentSeeder erstellen + +**Strategie:** Alle b2in-Inhalte aus `config/content.php` in `flux_cms_contents` überführen. + +- **Key-Schema:** `{page}.{section}.{field}` (z.B. `home.hero.title`, `about.our_story.title`) +- **Typen:** `text`, `html`, `image`, `json` (für Arrays wie `pillars`, `stats`, `navigation`) +- **Gruppen:** `home`, `about`, `immobilien`, `netzwerk`, `faq`, `contact`, `impressum`, `privacy`, `terms`, `cookie_policy`, `global` (Header, Footer) + +**Datei:** `database/seeders/CmsContentSeeder.php` – liest `config/content.php` und schreibt in DB. + +### 3.3 Seeders ausführen & verifizieren +```bash +php artisan db:seed --class=CmsContentSeeder +``` + +**Ergebnis Phase 3:** Alle b2in-Inhalte in der DB, abrufbar über `cms('home.hero.title')`. + +--- + +## Phase 4 – CMS Admin-Backend + +### 4.1 CMS als eigener Menüpunkt + +Das CMS wird im Admin-Portal (`portal.b2in.test`) als **eigener Top-Level-Menüpunkt "CMS"** in der Sidebar integriert. + +**Sidebar-Erweiterung** (`resources/views/components/layouts/app/sidebar.blade.php`): +``` +CMS +├── Dashboard (Übersicht: Anzahl Contents, Medien, Projekte) +├── Inhalte (Content-Editor für Text/HTML/Image/JSON Keys) +├── Projekte (Immobilien-CRUD – aus Phase 2) +├── Medienbibliothek (Upload, Grid, Conversions) +``` + +### 4.2 CMS-Layout + +**Entscheidung:** Die CMS-Views nutzen das bestehende Admin-Portal-Layout (`admin-master.blade.php` / `app.blade.php`), damit Navigation und User-Menü konsistent bleiben. + +→ Das Reference-Layout `layout-cms.blade.php` wird **nicht** als separates Layout genutzt, sondern als Vorlage für die Content-Struktur innerhalb des bestehenden Layouts. + +### 4.3 Admin-Views erstellen + +Aus den Package-Referenz-Views nur die benötigten kopieren und anpassen: + +| View | Quelle | Ziel | +|------|--------|------| +| Dashboard | `admin-reference/cms/dashboard.blade.php` | `livewire/admin/cms/dashboard.blade.php` | +| Content-Editor | `admin-reference/cms/content-index.blade.php` | `livewire/admin/cms/content-index.blade.php` | +| Medienbibliothek | `admin-reference/cms/media-index.blade.php` | `livewire/admin/cms/media-index.blade.php` | +| MediaPicker | `admin-reference/cms/media-picker.blade.php` | `livewire/admin/cms/media-picker.blade.php` | +| MediaUploader | `admin-reference/cms/media-library-uploader.blade.php` | `livewire/admin/cms/media-library-uploader.blade.php` | +| **Projekte** | **Neu erstellen** | `livewire/admin/cms/projects-index.blade.php` | + +**Nicht benötigt (vorerst):** news-index, industries-index, faqs-index, linkedin-index, downloads-index, team-index, search-index + +### 4.4 Livewire-Komponenten einrichten + +```bash +mkdir -p app/Livewire/Admin/Cms/ +``` + +Aus Package kopieren + Namespace anpassen: +- `MediaLibraryUploader.php` → `App\Livewire\Admin\Cms` +- `MediaPicker.php` → `App\Livewire\Admin\Cms` +- `MediaUploader.php` → `App\Livewire\Admin\Cms` + +### 4.5 Admin-Routes registrieren + +In `routes/admin.php` innerhalb der bestehenden `auth`-Middleware-Gruppe: + +```php +// Flux CMS Routes +Volt::route('admin/cms', 'admin.cms.dashboard')->name('cms.dashboard'); +Volt::route('admin/cms/content', 'admin.cms.content-index')->name('cms.content.index'); +Volt::route('admin/cms/media', 'admin.cms.media-index')->name('cms.media.index'); +Volt::route('admin/cms/projects', 'admin.cms.projects-index')->name('cms.projects.index'); +``` + +**Ergebnis Phase 4:** Funktionierendes Admin-Backend unter `portal.b2in.test/admin/cms` mit Dashboard, Content-Editor, Medienbibliothek und Projekte-CRUD. + +--- + +## Phase 5 – Frontend-Umstellung (config → cms) + +### 5.1 Strategie: Dualer Betrieb mit Fallback + +Die Umstellung erfolgt inkrementell. Jede Section einzeln umstellen, mit Fallback auf `config()`: + +```php +// Vorher (Hero.php): +$this->content = config("content.themes.{$theme}.hero", []); + +// Nachher: +$this->content = $this->loadFromCms('home.hero'); + +// Fallback-Methode in einem Trait oder Base-Class: +protected function loadFromCms(string $group): array +{ + $cmsContent = app(CmsContentService::class)->getGroup($group); + if (empty($cmsContent)) { + $theme = config('app.theme', 'b2in'); + $section = Str::afterLast($group, '.'); + return config("content.themes.{$theme}.{$section}", []); + } + return $cmsContent; +} +``` + +### 5.2 Sections schrittweise umstellen + +**Reihenfolge (nach Priorität / Sichtbarkeit):** + +1. **Home-Page Sections:** + - [ ] Hero → `home.hero` + - [ ] FounderBar → `home.founder_bar` + - [ ] ContentSection → `home.{section}` (dynamisch) + - [ ] VisionSection → `home.vision_section` + - [ ] EcosystemCore → `home.ecosystem_core` + - [ ] CTASection → `home.cta_section` + +2. **Globale Elemente:** + - [ ] Header (Navigation) → `global.header` + - [ ] Footer → `global.footer` + - [ ] AnnouncementBar → `global.announcement_bar` + +3. **Immobilien-Seite (statischer Content):** + - [ ] HeroV2 → `immobilien.hero_v2` + - [ ] WarumDubai → `immobilien.warum_dubai` + - [ ] Kaufprozess → `immobilien.kaufprozess` + - [ ] Brücke → `immobilien.bruecke` + - [ ] Mindset → `immobilien.mindset` + - [ ] MöbelVorteil → `immobilien.moebel_vorteil` + - [ ] Projekte-Liste → **CmsProject::published()->ordered()** (aus Phase 2) + +4. **Unterseiten:** + - [ ] About + - [ ] FAQ + - [ ] Contact + - [ ] Impressum / Privacy / Terms / Cookie-Policy + - [ ] Netzwerk + - [ ] Service / Portfolio + +### 5.3 Immobilien-Route umstellen + +```php +// routes/web.php – von config auf Model: +Route::get('/immobilien/{slug}', function (string $slug) { + $project = \App\Models\CmsProject::where('slug', $slug) + ->published() + ->firstOrFail(); + + return view('web.immobilien-show', ['project' => $project->toFrontendArray()]); +})->name('immobilien.show'); +``` + +Die `immobilien.blade.php` Projekte-Liste ebenfalls umstellen: +```php +// Vorher: $projects = config("content.themes.{$theme}.immobilien_projects", []); +// Nachher: $projects = CmsProject::published()->ordered()->get(); +``` + +### 5.4 Bilder umstellen + +```diff +- asset('img/assets/' . $heroV2['image']) ++ cms_media_url('immobilien.hero_v2.image', 'hero') +``` + +**Ergebnis Phase 5:** b2in-Frontend liest Inhalte aus DB + CmsProject-Model, editierbar über Admin. + +--- + +## Phase 6 – Medienbibliothek & Bilder + +### 6.1 Bestehende Bilder importieren + +Alle b2in-Bilder (aus `public/img/assets/`) in die CMS-Medienbibliothek importieren: +- `b2in/` – allgemeine b2in-Bilder (Hero, Founder, Sections) +- `expose/` – Immobilien-Projektbilder + +### 6.2 CmsMediaSeeder + +Seeder erstellt `CmsMedia`-Einträge für alle bestehenden Bilder und verknüpft sie mit den CmsContent-Keys vom Typ `image`. + +### 6.3 Bildprofile definieren + +In `config/flux-cms.php`: +- `hero` → 1920×800 (Hero-Banner) +- `card` → 768×512 (Kacheln, Sections) +- `thumbnail` → 400×300 (Listen, Übersichten) +- `avatar` → 400×400 (Team-Fotos, Founder) +- `gallery` → 1200×900 (Projekt-Galerie) +- `og_image` → 1200×630 (Social Sharing) + +### 6.4 Conversions generieren + +```bash +php artisan flux-cms:clear-cache +``` + +**Ergebnis Phase 6:** Alle Bilder in Medienbibliothek, Conversions generiert, URLs aufgelöst. + +--- + +## Phase 7 – Tests + +### 7.1 Referenz-Tests kopieren (selektiv) +```bash +mkdir -p tests/Feature/Cms +cp packages/flux-cms/core/tests-reference/Feature/Cms/CmsMediaTest.php tests/Feature/Cms/ +cp packages/flux-cms/core/tests-reference/Feature/Cms/CmsContentServiceTest.php tests/Feature/Cms/ +``` + +### 7.2 Projektspezifische Tests + +- **CmsProjectTest** – CRUD, published/unpublished, toFrontendArray(), Validierung +- **CmsContentSeederTest** – Prüft ob alle Keys aus config in DB existieren +- **CmsAdminAccessTest** – Prüft Zugriffskontrolle auf CMS-Admin-Routen +- **ImmobilienRouteTest** – Prüft `/immobilien/{slug}` mit DB-Daten +- **FrontendFallbackTest** – Prüft dualen Betrieb (CMS → config Fallback) +- **MediaIntegrationTest** – Upload, Conversion, URL-Auflösung + +### 7.3 Tests ausführen +```bash +php artisan test --compact --filter=Cms +php artisan test --compact --filter=Immobilien +``` + +**Ergebnis Phase 7:** Alle CMS-Funktionalitäten sind getestet. + +--- + +## Phase 8 – Feinschliff & Dokumentation + +### 8.1 Cache & Performance +- CMS-Content-Cache aktivieren +- Eager Loading optimieren +- Prüfen: Keine N+1-Queries auf Frontseiten + +### 8.2 config/content.php aufräumen +- Nach vollständiger Umstellung: b2in-Theme-Daten als `@deprecated` markieren +- Noch nicht entfernen (Fallback für andere Themes!) +- Immobilien-Projekte aus Config entfernen (leben jetzt in DB) + +### 8.3 Sidebar-Berechtigungen +- CMS-Zugang über Spatie-Permission absichern (`permission:manage-cms`) +- CMS-Menüpunkt nur für berechtigte User anzeigen + +### 8.4 Dokumentation +- Diesen Plan aktualisieren mit Status +- Notizen für Integration weiterer Subseiten (b2a, stileigentum, style2own) + +--- + +## Offene Fragen / Entscheidungen + +| # | Frage | Entscheidung | +|---|-------|-------------| +| 1 | Alle flux-cms Migrations laufen lassen oder nur contents + media? | Empfehlung: Alle (leere Tabellen stören nicht) | +| 2 | CmsProject: Eigene Migration oder flux-cms erweitern? | Eigene Migration im Projekt | +| 3 | FAQ-Daten: Vorerst in config belassen oder direkt in `flux_cms_faqs`? | Vorerst config | +| 4 | Magazin: Eigenes System belassen oder später auf CMS? | Vorerst belassen | +| 5 | Andere Themes (b2a etc.) weiterhin via `config()`? | Ja | + +--- + +## Fortschritt + +| Phase | Status | Notizen | +|-------|--------|---------| +| 1 – Installation & Infrastruktur | ✅ Fertig | 2026-03-18: Package installiert, Config angepasst, Migrations gelaufen, Helpers eingerichtet, 434 Tests grün | +| 2 – Immobilien-Model & CRUD | ✅ Fertig | 2026-03-18: Migration, Model, Factory, Seeder, 9 Tests grün. CRUD Admin-View folgt in Phase 4. | +| 3 – Content-Migration | ✅ Fertig | 2026-03-18: 61 Sections in 8 Gruppen migriert, Section-als-JSON-Ansatz, 8 Tests grün | +| 4 – CMS Admin-Backend | ✅ Fertig | 2026-03-18: Dashboard, Content-Editor, Projekte-CRUD, Medienbibliothek, Sidebar-Menü, MediaPicker/Uploader, 16 Tests grün | +| 5 – Frontend-Umstellung | ⬜ Offen | | +| 6 – Medienbibliothek | ⬜ Offen | | +| 7 – Tests | ⬜ Offen | |j +| 8 – Feinschliff | ⬜ Offen | | diff --git a/dev/flux-cms/helpers.php b/dev/flux-cms/helpers.php new file mode 100644 index 0000000..2e3835a --- /dev/null +++ b/dev/flux-cms/helpers.php @@ -0,0 +1,106 @@ + + */ + function legal_page(string $key): array + { + $fromCms = app(CmsContentService::class)->get('legal.'.$key); + + if (is_array($fromCms) && isset($fromCms['content'])) { + return $fromCms; + } + + return trans('b2in_legal.'.$key); + } +} + +if (! function_exists('cms')) { + /** + * Resolve a CMS content value by dot-notation key. + * + * First segment is the group, rest is the key: "home.hero.title" + * Falls back to __() if no DB entry exists. + * + * @param array $replace + */ + function cms(string $key, array $replace = [], ?string $locale = null): mixed + { + return app(CmsContentService::class)->get($key, $replace, $locale); + } +} + +if (! function_exists('tcms')) { + /** + * Typed CMS – always returns a string. + * + * @param array $replace + */ + function tcms(string $key, array $replace = [], ?string $locale = null): string + { + $text = cms($key, $replace, $locale); + + return is_string($text) ? $text : (string) $text; + } +} + +if (! function_exists('cms_media_url')) { + /** + * Resolve a CMS content key (type=image) to a full media URL. + * Falls back to asset('assets/images/...') if not found in media library. + */ + function cms_media_url(string $key, string $profile = ''): string + { + $filename = cms($key); + + if (! $filename || ! is_string($filename) || $filename === $key) { + $fallback = str_replace('.', '/', $key); + + return asset('assets/images/'.basename($fallback)); + } + + return media_url($filename, $profile); + } +} + +if (! function_exists('media_url')) { + /** + * Resolve a CmsMedia filename to its full storage URL. + * Uses in-memory cache to avoid repeated DB queries. + */ + function media_url(?string $filename, string $profile = ''): string + { + if (! $filename || $filename === '') { + return ''; + } + + static $resolved = []; + $cacheKey = $filename.'|'.$profile; + + if (isset($resolved[$cacheKey])) { + return $resolved[$cacheKey]; + } + + $media = CmsMedia::where('filename', $filename)->first(); + + if (! $media) { + $resolved[$cacheKey] = asset('assets/images/'.$filename); + + return $resolved[$cacheKey]; + } + + if ($profile && $media->hasConversion($profile)) { + $resolved[$cacheKey] = $media->getConversionUrl($profile); + } else { + $resolved[$cacheKey] = $media->getUrl(); + } + + return $resolved[$cacheKey]; + } +} diff --git a/dev/flux-cms/tasks.md b/dev/flux-cms/tasks.md new file mode 100644 index 0000000..6acc6b2 --- /dev/null +++ b/dev/flux-cms/tasks.md @@ -0,0 +1,15 @@ +### Aufgabe + +Integration des selbst entwickelten Packages flux-cms +packages/flux-cms + +Das Package wurde entwickelt, um in einem stehenden laravel framework mit livewire / (Volt) / Fluxi Eingesetzt zu werden. + +Der derzeitige Stand ist aus einem anderen Projekt, wo es spezifische Aufgaben schon sehr gut erledigt und genau das tut was es soll. + +In diesem Fall geht es darum im ersten Punkt die aktuelle b2in Webseite in das System zu integrieren. +Wichtig die weiteren Subseite Müssen später folgen. Hier geht es jetzt primär erst mal um den aktuellen Stand, der auch online ist. + +Nutze diesen Ordner für den Prozess der Integration und dokumentiere ihn hier, so dass die Arbeit jederzeit wieder aufgenommen werden kann. + +Erstelle einen Entwicklungsplan, der dann Stück Stück abgearbeitet werden kann, um eine saubere Integration zu gewährleisten diff --git a/docker-compose.yml b/docker-compose.yml index 677e1c8..99b2968 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,6 @@ services: extra_hosts: - 'host.docker.internal:host-gateway' ports: - #- '${APP_PORT:-80}:80' - '${VITE_PORT:-5174}:5174' - '${VITE_WEB_PORT:-5175}:5175' environment: @@ -20,24 +19,22 @@ services: XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}' XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}' IGNITION_LOCAL_SITES_PATH: '${PWD}' + + # --- Anbindung an das Mutterschiff --- DB_CONNECTION: mysql - DB_HOST: mysql + DB_HOST: global-mysql DB_PORT: 3306 DB_DATABASE: b2in - DB_USERNAME: sail + DB_USERNAME: root DB_PASSWORD: password - MAIL_HOST: mailpit + MAIL_HOST: global-mailpit MAIL_PORT: 1025 - REDIS_HOST: redis + REDIS_HOST: global-redis volumes: - '.:/var/www/html' networks: - sail - proxy - depends_on: - - mysql - - redis - - mailpit labels: - "traefik.enable=true" # Hauptdomain @@ -82,69 +79,9 @@ services: - "traefik.http.services.assets-main-service-b2in.loadbalancer.server.scheme=http" - "traefik.http.services.assets-web-service-b2in.loadbalancer.server.scheme=http" - "traefik.docker.network=proxy" - mysql: - image: 'mysql/mysql-server:8.0' - ports: - - '${FORWARD_DB_PORT:-33067}:3306' - environment: - MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}' - MYSQL_ROOT_HOST: '%' - MYSQL_DATABASE: '${DB_DATABASE}' - MYSQL_USER: '${DB_USERNAME}' - MYSQL_PASSWORD: '${DB_PASSWORD}' - MYSQL_ALLOW_EMPTY_PASSWORD: 1 - MYSQL_EXTRA_OPTIONS: '${MYSQL_EXTRA_OPTIONS}' - volumes: - - 'sail-mysql:/var/lib/mysql' - - './vendor/laravel/sail/database/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh' - networks: - - sail - healthcheck: - test: - - CMD - - mysqladmin - - ping - - '-p${DB_PASSWORD}' - retries: 3 - timeout: 5s - redis: - image: 'redis:alpine' - ports: - - '${FORWARD_REDIS_PORT:-6381}:6379' - volumes: - - 'sail-redis:/data' - networks: - - sail - healthcheck: - test: - - CMD - - redis-cli - - ping - retries: 3 - timeout: 5s - mailpit: - image: 'axllent/mailpit:latest' - ports: - - '${FORWARD_MAILPIT_PORT:-1026}:1025' - - '${FORWARD_MAILPIT_DASHBOARD_PORT:-8026}:8025' - networks: - - sail - - proxy - labels: - - "traefik.enable=true" - - "traefik.http.routers.mivita-mail.rule=Host(`mivita-mail.test`)" - - "traefik.http.routers.mivita-mail.entrypoints=websecure" - - "traefik.http.routers.mivita-mail.tls=true" - - "traefik.http.services.mivita-mail.loadbalancer.server.port=8025" - - "traefik.docker.network=proxy" networks: sail: driver: bridge proxy: external: true -volumes: - sail-mysql: - driver: local - sail-redis: - driver: local diff --git a/packages/acme/CookieConsent/composer.json b/packages/acme/CookieConsent/composer.json new file mode 100644 index 0000000..c51db79 --- /dev/null +++ b/packages/acme/CookieConsent/composer.json @@ -0,0 +1,39 @@ +{ + "name": "acme/cookie-consent", + "description": "Ein DSGVO-konformer Cookie Consent Manager für Laravel mit Alpine.js und Google Analytics Support", + "type": "library", + "license": "MIT", + "keywords": [ + "laravel", + "cookie", + "consent", + "gdpr", + "dsgvo", + "google-analytics", + "alpine.js" + ], + "autoload": { + "psr-4": { + "Acme\\CookieConsent\\": "src/" + } + }, + "authors": [ + { + "name": "Acme", + "email": "info@acme.de" + } + ], + "require": { + "php": "^8.1", + "illuminate/support": "^10.0|^11.0|^12.0" + }, + "extra": { + "laravel": { + "providers": [ + "Acme\\CookieConsent\\CookieConsentServiceProvider" + ] + } + }, + "minimum-stability": "stable", + "prefer-stable": true +} \ No newline at end of file diff --git a/packages/acme/CookieConsent/config/cookie-consent.php b/packages/acme/CookieConsent/config/cookie-consent.php new file mode 100644 index 0000000..41ac643 --- /dev/null +++ b/packages/acme/CookieConsent/config/cookie-consent.php @@ -0,0 +1,127 @@ + env('COOKIE_CONSENT_ENABLED', true), + + /* + |-------------------------------------------------------------------------- + | Google Analytics ID + |-------------------------------------------------------------------------- + | + | Hier trägst du deine Tracking ID ein (z.B. G-XXXXXXXXXX). + | Wenn der Wert leer ist, wird der Analytics-Toggle nicht angezeigt. + | + */ + 'analytics_id' => env('GOOGLE_ANALYTICS_ID', ''), + + /* + |-------------------------------------------------------------------------- + | Google Tag Manager ID + |-------------------------------------------------------------------------- + | + | Optional: Wenn du den Google Tag Manager statt direktem Analytics nutzt, + | trage hier deine GTM-ID ein (z.B. GTM-XXXXXXXX). + | + | Wenn sowohl GTM als auch Analytics-ID gesetzt sind, wird GTM bevorzugt. + | Der Tag Manager lädt dann alle weiteren Tags (inkl. GA4) selbst. + | + */ + 'gtm_id' => env('GOOGLE_TAG_MANAGER_ID', ''), + + /* + |-------------------------------------------------------------------------- + | Cookie Lebensdauer + |-------------------------------------------------------------------------- + | + | Wie viele Tage soll die Entscheidung des Nutzers gespeichert bleiben? + | Standard: 365 Tage (1 Jahr). DSGVO empfiehlt max. 12 Monate. + | + */ + 'cookie_lifetime' => env('COOKIE_CONSENT_LIFETIME', 365), + + /* + |-------------------------------------------------------------------------- + | Cookie Name + |-------------------------------------------------------------------------- + | + | Der Name des Cookies, in dem die Einwilligung gespeichert wird. + | + */ + 'cookie_name' => env('COOKIE_CONSENT_NAME', 'cookie_consent'), + + /* + |-------------------------------------------------------------------------- + | Links + |-------------------------------------------------------------------------- + | + | URLs für Datenschutz und Impressum. + | + */ + 'links' => [ + 'privacy' => '/datenschutz', + 'imprint' => '/impressum', + ], + + /* + |-------------------------------------------------------------------------- + | Position des Floating Buttons + |-------------------------------------------------------------------------- + | + | Wo soll der Cookie-Settings-Button angezeigt werden? + | Optionen: 'bottom-left', 'bottom-right' + | + */ + 'button_position' => 'bottom-left', + + /* + |-------------------------------------------------------------------------- + | IP-Anonymisierung + |-------------------------------------------------------------------------- + | + | Google Analytics IP-Anonymisierung aktivieren (empfohlen für DSGVO). + | + */ + 'anonymize_ip' => true, + + /* + |-------------------------------------------------------------------------- + | Farben (Theme) + |-------------------------------------------------------------------------- + | + | Passe die Farben an dein Projekt-Design an. + | Du kannst Tailwind-Klassen oder CSS-Farbwerte verwenden. + | + | Für Tailwind-Projekte: Nutze deine definierten Farben wie 'primary', 'secondary' + | Für Standard-CSS: Nutze HEX-Werte wie '#009bdd' + | + */ + 'colors' => [ + // Primärfarbe für Akzente, aktive Toggles, Icons + 'primary' => env('COOKIE_CONSENT_COLOR_PRIMARY', '#0088cc'), + + // Sekundärfarbe / Hover-Zustand der Primärfarbe + 'primary_hover' => env('COOKIE_CONSENT_COLOR_PRIMARY_HOVER', '#006699'), + + // Akzeptieren-Button (grün) + 'accept' => env('COOKIE_CONSENT_COLOR_ACCEPT', '#16a34a'), + 'accept_hover' => env('COOKIE_CONSENT_COLOR_ACCEPT_HOVER', '#15803d'), + + // Einstellungen speichern Button + 'save' => env('COOKIE_CONSENT_COLOR_SAVE', '#a5a5a5'), + 'save_hover' => env('COOKIE_CONSENT_COLOR_SAVE_HOVER', '#b3b3b3'), + + // Floating Button + 'button_bg' => env('COOKIE_CONSENT_COLOR_BUTTON_BG', '#0088cc'), + 'button_hover' => env('COOKIE_CONSENT_COLOR_BUTTON_HOVER', '#006699'), + ], + +]; diff --git a/packages/acme/CookieConsent/lang/de/cookie-consent.php b/packages/acme/CookieConsent/lang/de/cookie-consent.php new file mode 100644 index 0000000..771e258 --- /dev/null +++ b/packages/acme/CookieConsent/lang/de/cookie-consent.php @@ -0,0 +1,138 @@ + [ + 'title' => 'Datenschutzeinstellungen', + 'description' => 'Wir nutzen Cookies, um unsere Website für Sie zu optimieren. Einige sind essenziell, andere helfen uns, die Nutzung zu analysieren.', + ], + + // Cookie-Kategorien + 'categories' => [ + 'essential' => [ + 'title' => 'Technisch notwendig', + 'description' => 'Für die Grundfunktion der Seite.', + ], + 'analytics' => [ + 'title' => 'Analyse & Statistik', + 'description' => 'Google Analytics / Tag Manager (IP-Anonymisiert).', + ], + ], + + // Buttons + 'buttons' => [ + 'accept_all' => 'Alle akzeptieren', + 'save_selection' => 'Auswahl speichern', + 'reject_all' => 'Nur essenzielle', + 'change_settings' => 'Cookie-Einstellungen ändern', + ], + + // Links + 'links' => [ + 'privacy' => 'Datenschutzerklärung', + 'imprint' => 'Impressum', + ], + + // Accessibility + 'aria' => [ + 'open_settings' => 'Cookie-Einstellungen öffnen', + 'essential_always_active' => 'Technisch notwendige Cookies sind immer aktiv', + 'toggle_analytics' => 'Analytics Cookies aktivieren oder deaktivieren', + ], + + // Status + 'status' => [ + 'active' => 'Aktiv', + 'inactive' => 'Deaktiviert', + 'no_consent' => 'Sie haben noch keine Cookie-Einstellungen vorgenommen.', + ], + + // Privacy Info Komponente + 'privacy_info' => [ + 'current_settings_title' => 'Ihre aktuellen Cookie-Einstellungen', + 'essential_cookies' => 'Technisch notwendige Cookies', + 'analytics_cookies' => 'Analyse & Statistik (Google Analytics / Tag Manager)', + 'analytics_marketing_cookies' => 'Analyse- und Marketing-Cookies', + ], + + // Google Analytics Informationen + 'google_analytics' => [ + 'title' => 'Google Analytics', + 'intro' => 'Diese Website nutzt Google Analytics, einen Webanalysedienst der Google Ireland Limited („Google"), Gordon House, Barrow Street, Dublin 4, Irland.', + + 'technical_details' => [ + 'title' => 'Technische Details', + 'tracking_id' => 'Tracking-ID', + 'ip_anonymization' => 'IP-Anonymisierung', + 'ip_anonymization_value' => 'Aktiviert (anonymize_ip)', + 'consent_mode' => 'Consent Mode', + 'consent_mode_value' => 'Google Consent Mode v2', + 'cookie_duration' => 'Cookie-Speicherdauer', + 'cookie_duration_value' => ':days Tage', + ], + + 'purpose' => [ + 'title' => 'Zweck der Verarbeitung', + 'text' => 'Google Analytics verwendet Cookies, die eine Analyse der Benutzung der Website ermöglichen. Die durch das Cookie erzeugten Informationen über Ihre Benutzung dieser Website werden in der Regel an einen Server von Google in den USA übertragen und dort gespeichert.', + ], + + 'ip_anonymization' => [ + 'title' => 'IP-Anonymisierung', + 'text' => 'Wir haben auf dieser Website die Funktion IP-Anonymisierung aktiviert. Dadurch wird Ihre IP-Adresse von Google innerhalb von Mitgliedstaaten der Europäischen Union oder in anderen Vertragsstaaten des Abkommens über den Europäischen Wirtschaftsraum vor der Übermittlung in die USA gekürzt.', + ], + + 'legal_basis' => [ + 'title' => 'Rechtsgrundlage', + 'text' => 'Die Verarbeitung erfolgt auf Grundlage Ihrer Einwilligung gemäß Art. 6 Abs. 1 lit. a DSGVO. Sie können Ihre Einwilligung jederzeit widerrufen, indem Sie die Cookie-Einstellungen ändern.', + ], + + 'cookies' => [ + 'title' => 'Von Google Analytics gesetzte Cookies', + 'table' => [ + 'name' => 'Cookie', + 'purpose' => 'Zweck', + 'duration' => 'Speicherdauer', + ], + 'list' => [ + '_ga' => [ + 'purpose' => 'Unterscheidung von Nutzern', + 'duration' => '2 Jahre', + ], + '_ga_*' => [ + 'purpose' => 'Speicherung des Sitzungsstatus', + 'duration' => '2 Jahre', + ], + '_gid' => [ + 'purpose' => 'Unterscheidung von Nutzern', + 'duration' => '24 Stunden', + ], + '_gat' => [ + 'purpose' => 'Drosselung der Anfragerate', + 'duration' => '1 Minute', + ], + ], + ], + + 'objection' => [ + 'title' => 'Widerspruchsmöglichkeit', + 'text' => 'Sie können die Erfassung der durch das Cookie erzeugten und auf Ihre Nutzung der Website bezogenen Daten (inkl. Ihrer IP-Adresse) an Google sowie die Verarbeitung dieser Daten durch Google verhindern, indem Sie:', + 'options' => [ + 'revoke' => 'Ihre Einwilligung in den Cookie-Einstellungen widerrufen', + 'addon' => 'Das Browser-Add-on zur Deaktivierung von Google Analytics installieren:', + ], + ], + + 'more_info' => [ + 'title' => 'Weitere Informationen', + 'text' => 'Weitere Informationen zum Umgang mit Nutzerdaten bei Google Analytics finden Sie in der Datenschutzerklärung von Google:', + ], + ], + +]; diff --git a/packages/acme/CookieConsent/lang/en/cookie-consent.php b/packages/acme/CookieConsent/lang/en/cookie-consent.php new file mode 100644 index 0000000..1f2ef02 --- /dev/null +++ b/packages/acme/CookieConsent/lang/en/cookie-consent.php @@ -0,0 +1,138 @@ + [ + 'title' => 'Privacy Settings', + 'description' => 'We use cookies to optimize our website for you. Some are essential, others help us analyze usage.', + ], + + // Cookie Categories + 'categories' => [ + 'essential' => [ + 'title' => 'Strictly Necessary', + 'description' => 'Required for basic site functionality.', + ], + 'analytics' => [ + 'title' => 'Analytics & Statistics', + 'description' => 'Google Analytics / Tag Manager (IP anonymized).', + ], + ], + + // Buttons + 'buttons' => [ + 'accept_all' => 'Accept All', + 'save_selection' => 'Save Selection', + 'reject_all' => 'Essential Only', + 'change_settings' => 'Change Cookie Settings', + ], + + // Links + 'links' => [ + 'privacy' => 'Privacy Policy', + 'imprint' => 'Legal Notice', + ], + + // Accessibility + 'aria' => [ + 'open_settings' => 'Open cookie settings', + 'essential_always_active' => 'Strictly necessary cookies are always active', + 'toggle_analytics' => 'Enable or disable analytics cookies', + ], + + // Status + 'status' => [ + 'active' => 'Active', + 'inactive' => 'Disabled', + 'no_consent' => 'You have not yet made any cookie settings.', + ], + + // Privacy Info Component + 'privacy_info' => [ + 'current_settings_title' => 'Your Current Cookie Settings', + 'essential_cookies' => 'Strictly Necessary Cookies', + 'analytics_cookies' => 'Analytics & Statistics (Google Analytics / Tag Manager)', + 'analytics_marketing_cookies' => 'Analytics & Marketing Cookies', + ], + + // Google Analytics Information + 'google_analytics' => [ + 'title' => 'Google Analytics', + 'intro' => 'This website uses Google Analytics, a web analytics service provided by Google Ireland Limited ("Google"), Gordon House, Barrow Street, Dublin 4, Ireland.', + + 'technical_details' => [ + 'title' => 'Technical Details', + 'tracking_id' => 'Tracking ID', + 'ip_anonymization' => 'IP Anonymization', + 'ip_anonymization_value' => 'Enabled (anonymize_ip)', + 'consent_mode' => 'Consent Mode', + 'consent_mode_value' => 'Google Consent Mode v2', + 'cookie_duration' => 'Cookie Duration', + 'cookie_duration_value' => ':days days', + ], + + 'purpose' => [ + 'title' => 'Purpose of Processing', + 'text' => 'Google Analytics uses cookies that enable an analysis of your use of the website. The information generated by the cookie about your use of this website is usually transmitted to a Google server in the USA and stored there.', + ], + + 'ip_anonymization' => [ + 'title' => 'IP Anonymization', + 'text' => 'We have activated the IP anonymization function on this website. This means that your IP address is truncated by Google within member states of the European Union or in other contracting states of the Agreement on the European Economic Area before being transmitted to the USA.', + ], + + 'legal_basis' => [ + 'title' => 'Legal Basis', + 'text' => 'Processing is based on your consent pursuant to Art. 6 (1) lit. a GDPR. You can revoke your consent at any time by changing your cookie settings.', + ], + + 'cookies' => [ + 'title' => 'Cookies Set by Google Analytics', + 'table' => [ + 'name' => 'Cookie', + 'purpose' => 'Purpose', + 'duration' => 'Duration', + ], + 'list' => [ + '_ga' => [ + 'purpose' => 'Distinguishing users', + 'duration' => '2 years', + ], + '_ga_*' => [ + 'purpose' => 'Storing session state', + 'duration' => '2 years', + ], + '_gid' => [ + 'purpose' => 'Distinguishing users', + 'duration' => '24 hours', + ], + '_gat' => [ + 'purpose' => 'Throttling request rate', + 'duration' => '1 minute', + ], + ], + ], + + 'objection' => [ + 'title' => 'Right to Object', + 'text' => 'You can prevent the collection of data generated by the cookie and related to your use of the website (including your IP address) by Google and the processing of this data by Google by:', + 'options' => [ + 'revoke' => 'Revoking your consent in the cookie settings', + 'addon' => 'Installing the browser add-on to disable Google Analytics:', + ], + ], + + 'more_info' => [ + 'title' => 'More Information', + 'text' => 'For more information on how Google Analytics handles user data, please see Google\'s privacy policy:', + ], + ], + +]; diff --git a/packages/acme/CookieConsent/readme.md b/packages/acme/CookieConsent/readme.md new file mode 100644 index 0000000..8a0e839 --- /dev/null +++ b/packages/acme/CookieConsent/readme.md @@ -0,0 +1,365 @@ +# Laravel GDPR/DSGVO Cookie Consent Manager + +Ein leichtgewichtiges, DSGVO-konformes Cookie Consent Management System für Laravel. Es nutzt **Alpine.js** für die Frontend-Logik und ist speziell für die einfache Integration von Google Analytics 4 und Google Tag Manager (mit Google Consent Mode v2) entwickelt. + +## Features + +- 🇪🇺 **DSGVO Konform:** Skripte werden erst nach expliziter Zustimmung geladen (Prior Consent) +- 🔒 **Google Consent Mode v2:** Vollständige Integration mit Default-Einstellungen +- 📊 **Google Analytics 4:** Direktes GA4-Tracking mit IP-Anonymisierung +- 🏷️ **Google Tag Manager:** Alternativ GTM-Integration für komplexere Tag-Setups +- 🌍 **Mehrsprachig:** Deutsch und Englisch integriert, weitere Sprachen leicht hinzufügbar +- 🚀 **Performance:** Basiert auf Alpine.js, keine Server-Requests +- 🎨 **Flexibel:** Anpassbar über Blade-Komponenten und Tailwind CSS +- ⚙️ **Konfigurierbar:** Alle Optionen über Config steuerbar +- 📱 **Responsive:** Mobile-optimiertes Modal mit Animationen +- ♿ **Barrierefrei:** ARIA-Labels, Focus-Trap, Keyboard-Navigation +- 🍪 **Echte Cookies:** Speicherung als HTTP-Cookie mit konfigurierbarer Laufzeit + +## Voraussetzungen + +- PHP 8.1+ +- Laravel 10+ oder 11+ +- Alpine.js (via Livewire oder manuell eingebunden) +- Tailwind CSS (für das Standard-Styling) + +## Installation + +### 1. Package installieren + +```bash +composer require acme/cookie-consent +``` + +Falls das Package lokal entwickelt wird, füge es in der `composer.json` hinzu: + +```json +{ + "repositories": [ + { + "type": "path", + "url": "dev/acme/CookieConsent" + } + ], + "require": { + "acme/cookie-consent": "@dev" + + } +} + +composer update acme/cookie-consent + +composer update acme/cookie-consent && php artisan package:discover && php artisan config:clear && php artisan view:clear && php artisan cache:clear +``` + +### 2. Environment Variables + +Füge deine Tracking-ID in die `.env` Datei ein. Du kannst entweder Google Analytics direkt **oder** den Google Tag Manager nutzen: + +**Option A: Google Analytics direkt** +```dotenv +GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX +``` + +**Option B: Google Tag Manager (empfohlen für komplexere Setups)** +```dotenv +GOOGLE_TAG_MANAGER_ID=GTM-XXXXXXXX +``` + +> **Hinweis:** Wenn beide IDs gesetzt sind, wird der Google Tag Manager bevorzugt. Der GTM lädt dann alle weiteren Tags (inkl. GA4) selbstständig über die GTM-Konfiguration. + +Optionale Einstellungen: + +```dotenv +COOKIE_CONSENT_ENABLED=true +COOKIE_CONSENT_LIFETIME=365 +COOKIE_CONSENT_NAME=cookie_consent +``` + +### 3. Config veröffentlichen (Optional) + +```bash +php artisan vendor:publish --tag=cookie-consent-config +``` + +Dies erstellt `config/cookie-consent.php` mit allen Einstellungsmöglichkeiten. + +## Nutzung + +### Basis-Einbindung (Google Analytics) + +Füge die Komponente in dein Hauptlayout ein (idealerweise vor ``): + +```blade + + {{-- Dein Content --}} + + + + @livewireScripts + +``` + +### Mit Google Tag Manager + +Bei Nutzung des Google Tag Managers musst du zusätzlich die `gtm-noscript` Komponente **direkt nach dem öffnenden `` Tag** einbinden: + +```blade + + {{-- GTM noscript (nur erforderlich bei GTM-Nutzung) --}} + + + {{-- Dein Content --}} + + {{-- Cookie Consent Manager (vor ) --}} + + + @livewireScripts + +``` + +> **Wichtig:** Der `gtm-noscript` iframe wird von Google empfohlen und ermöglicht grundlegendes Tracking auch bei deaktiviertem JavaScript. Er wird nur geladen, wenn der Nutzer dem Tracking zugestimmt hat. + +### Parameter überschreiben + +Du kannst alle Parameter direkt bei der Einbindung überschreiben: + +```blade +{{-- Mit Google Analytics --}} + + +{{-- Mit Google Tag Manager --}} + + +{{-- GTM noscript kann auch überschrieben werden --}} + +``` + +## Mehrsprachigkeit + +Das Package unterstützt automatische Spracherkennung über `app()->getLocale()`. + +### Verfügbare Sprachen + +- 🇩🇪 **Deutsch** (de) - Standardsprache +- 🇬🇧 **Englisch** (en) + +### Sprachdateien veröffentlichen + +Um die Übersetzungen anzupassen oder weitere Sprachen hinzuzufügen: + +```bash +php artisan vendor:publish --tag=cookie-consent-lang +``` + +Die Dateien werden nach `lang/vendor/cookie-consent/` in deinem Laravel-Projekt kopiert. + +### Eigene Sprache hinzufügen + +1. Kopiere `lang/vendor/cookie-consent/de/cookie-consent.php` +2. Erstelle z.B. `lang/vendor/cookie-consent/fr/cookie-consent.php` +3. Übersetze alle Texte + +### Sprache setzen + +Die Sprache wird automatisch aus Laravel übernommen: + +```php +// In Middleware, Controller oder ServiceProvider +app()->setLocale('en'); // Wechselt zu Englisch + +// Oder basierend auf Benutzereinstellung +app()->setLocale(auth()->user()->language ?? 'de'); +``` + +## Cookie-Einstellungen manuell öffnen + +Laut DSGVO muss der Nutzer seine Entscheidung jederzeit ändern können. Das Package stellt dafür einen Event-Listener bereit: + +```html + + Cookie-Einstellungen bearbeiten + +``` + +Oder mit Vanilla JavaScript: + +```html + + Cookie-Einstellungen + +``` + +## Datenschutzerklärung Komponente + +Das Package enthält eine spezielle Komponente für deine Datenschutzerklärung, die: +- Die aktuellen Cookie-Einstellungen des Nutzers anzeigt +- Einen Button zum Ändern der Einstellungen bietet +- Ausführliche Informationen zu Google Analytics bereitstellt + +### Einbindung + +```blade +{{-- In deiner Datenschutz-Seite --}} + +``` + +### Parameter + +```blade + +``` + +### Nur bestimmte Teile anzeigen + +```blade +{{-- Nur die Cookie-Übersicht mit Button --}} + + +{{-- Nur die GA-Infos (ohne Einstellungs-Box) --}} + +``` + +Die Komponente aktualisiert sich automatisch, wenn der Nutzer seine Cookie-Einstellungen ändert (via `cookie-consent-updated` Event). + +## Konfiguration + +Die vollständige Konfigurationsdatei (`config/cookie-consent.php`): + +```php + env('COOKIE_CONSENT_ENABLED', true), + + // Google Analytics 4 Tracking ID + 'analytics_id' => env('GOOGLE_ANALYTICS_ID', ''), + + // Google Tag Manager ID (hat Priorität über analytics_id) + 'gtm_id' => env('GOOGLE_TAG_MANAGER_ID', ''), + + // Cookie Lebensdauer in Tagen (DSGVO: max. 12 Monate empfohlen) + 'cookie_lifetime' => env('COOKIE_CONSENT_LIFETIME', 365), + + // Name des Consent-Cookies + 'cookie_name' => env('COOKIE_CONSENT_NAME', 'cookie_consent'), + + // Links für Datenschutz und Impressum + 'links' => [ + 'privacy' => '/datenschutz', + 'imprint' => '/impressum', + ], + + // Position des Floating-Buttons: 'bottom-left' oder 'bottom-right' + 'button_position' => 'bottom-left', + + // IP-Anonymisierung für Google Analytics (bei GTM in GTM konfigurieren) + 'anonymize_ip' => true, +]; +``` + +## Google Consent Mode v2 + +Das Package implementiert den Google Consent Mode v2 vollständig: + +1. **Default-Einstellungen** werden VOR dem Laden von GA/GTM gesetzt (alles `denied`) +2. Bei Zustimmung wird ein **Consent Update** gesendet +3. GA4/GTM-Script wird erst NACH Zustimmung dynamisch geladen + +Diese Implementierung entspricht den aktuellen Google-Anforderungen (März 2024). + +## Google Tag Manager + +Für komplexere Tracking-Setups empfehlen wir den Google Tag Manager statt direktem GA4: + +### Vorteile von GTM + +- **Zentrale Verwaltung:** Alle Tags (GA4, Ads, Meta, etc.) über eine Oberfläche +- **Keine Code-Änderungen:** Neue Tags können ohne Deployment hinzugefügt werden +- **Debugging:** Eingebauter Vorschau-Modus zum Testen +- **Trigger & Variablen:** Komplexe Regeln ohne Programmierung + +### Einrichtung + +1. Setze `GOOGLE_TAG_MANAGER_ID` in der `.env` +2. Füge `` direkt nach `` ein +3. Konfiguriere GA4 und andere Tags im GTM-Interface + +### Consent Mode im GTM + +Der Consent Mode wird automatisch übermittelt. Im GTM solltest du: + +1. **Consent-Einstellungen aktivieren:** Admin > Container-Einstellungen > Consent-Übersicht aktivieren +2. **Tags konfigurieren:** Bei jedem Tag die "Einwilligungsprüfungen" entsprechend setzen +3. **GA4-Tag:** "analytics_storage" auf "Erforderlich" setzen + +## Design Anpassen + +Views veröffentlichen: + +```bash +php artisan vendor:publish --tag=cookie-consent-views +``` + +Die Dateien liegen dann unter `resources/views/vendor/cookie-consent/`. + +## Server-seitige Prüfung + +Da der Consent als echtes Cookie gespeichert wird, kannst du ihn auch serverseitig prüfen: + +```php +// In einem Controller oder Middleware +$consent = json_decode(request()->cookie('cookie_consent'), true); + +if ($consent && $consent['analytics'] === true) { + // Analytics-Tracking erlaubt +} +``` + +## Alle Assets veröffentlichen + +```bash +# Config +php artisan vendor:publish --tag=cookie-consent-config + +# Views +php artisan vendor:publish --tag=cookie-consent-views + +# Sprachdateien +php artisan vendor:publish --tag=cookie-consent-lang + +# Alles auf einmal +php artisan vendor:publish --provider="Acme\CookieConsent\CookieConsentServiceProvider" +``` + +## Rechtlicher Hinweis + +Dieses Package bietet eine technische Grundlage für DSGVO-konformes Tracking ("Privacy by Design"). **Es ersetzt keine anwaltliche Beratung.** Der Betreiber der Website ist dafür verantwortlich, dass: + +- Die Datenschutzerklärung vollständig und korrekt ist +- Alle eingesetzten Tracking-Dienste dort aufgeführt sind +- Die Cookie-Einwilligung nachweisbar dokumentiert wird + +## Lizenz + +MIT License diff --git a/packages/acme/CookieConsent/resources/views/components/gtm-noscript.blade.php b/packages/acme/CookieConsent/resources/views/components/gtm-noscript.blade.php new file mode 100644 index 0000000..828dcfc --- /dev/null +++ b/packages/acme/CookieConsent/resources/views/components/gtm-noscript.blade.php @@ -0,0 +1,108 @@ +@props([ + 'gtmId' => config('cookie-consent.gtm_id'), + 'enabled' => config('cookie-consent.enabled', true), + 'cookieName' => config('cookie-consent.cookie_name', 'cookie_consent'), +]) + +{{-- + Google Tag Manager (noscript) Komponente + + Diese Komponente muss direkt nach dem öffnenden Tag eingefügt werden: + + + + ... + + + Der noscript-Teil wird nur angezeigt, wenn: + 1. GTM aktiviert ist (gtm_id gesetzt) + 2. Der Cookie-Consent bereits erteilt wurde (analytics: true) + + Hinweis: Bei JavaScript-deaktivierten Browsern funktioniert GTM ohnehin nicht vollständig, + aber der noscript-iframe ermöglicht grundlegendes Tracking. +--}} + +@if ($enabled && $gtmId) + {{-- + Alpine.js Wrapper für dynamische Anzeige basierend auf Cookie-Consent + Der noscript-Teil wird erst nach Zustimmung in den DOM eingefügt + --}} +
+ {{-- GTM noscript iframe - wird nur bei Consent angezeigt --}} + +
+ + {{-- + Fallback für Server-Side Rendering: + Wenn der Cookie bereits existiert und Analytics erlaubt, + können wir den noscript-Teil direkt rendern + --}} + @php + $cookieValue = request()->cookie($cookieName); + $serverSideConsent = false; + if ($cookieValue) { + try { + $decoded = json_decode($cookieValue, true); + $serverSideConsent = isset($decoded['analytics']) && $decoded['analytics'] === true; + } catch (\Exception $e) { + $serverSideConsent = false; + } + } + @endphp + + @if ($serverSideConsent) + + + + @endif +@endif diff --git a/packages/acme/CookieConsent/resources/views/components/manager.blade.php b/packages/acme/CookieConsent/resources/views/components/manager.blade.php new file mode 100644 index 0000000..3197ba3 --- /dev/null +++ b/packages/acme/CookieConsent/resources/views/components/manager.blade.php @@ -0,0 +1,503 @@ +@props([ + 'analyticsId' => config('cookie-consent.analytics_id'), + 'gtmId' => config('cookie-consent.gtm_id'), + 'enabled' => config('cookie-consent.enabled', true), + 'cookieLifetime' => config('cookie-consent.cookie_lifetime', 365), + 'cookieName' => config('cookie-consent.cookie_name', 'cookie_consent'), + 'links' => config('cookie-consent.links', []), + 'buttonPosition' => config('cookie-consent.button_position', 'bottom-left'), + 'anonymizeIp' => config('cookie-consent.anonymize_ip', true), + 'colors' => config('cookie-consent.colors', []), +]) + +@php + $links = array_merge( + [ + 'privacy' => '/datenschutz', + 'imprint' => '/impressum', + ], + $links ?? [], + ); + + $colors = array_merge( + [ + 'primary' => '#009bdd', + 'primary_hover' => '#0071a8', + 'accept' => '#16a34a', + 'accept_hover' => '#15803d', + 'save' => '#1f2937', + 'save_hover' => '#111827', + 'button_bg' => '#1f2937', + 'button_hover' => '#374151', + ], + $colors ?? [], + ); + + $positionClasses = $buttonPosition === 'bottom-right' ? 'right-4' : 'left-4'; + + // Bestimme ob GTM oder Analytics verwendet wird (GTM hat Priorität) + $useGtm = !empty($gtmId); + $useAnalytics = !empty($analyticsId) && !$useGtm; + $hasTracking = $useGtm || $useAnalytics; +@endphp + +@if ($enabled) + {{-- CSS-Variablen für Farben --}} + + + {{-- Google Consent Mode v2: Default-Einstellungen VOR dem Laden von GA --}} + + + + + {{-- Cookie Consent Manager Script --}} + +@endif diff --git a/packages/acme/CookieConsent/resources/views/components/privacy-info.blade.php b/packages/acme/CookieConsent/resources/views/components/privacy-info.blade.php new file mode 100644 index 0000000..fa22cc6 --- /dev/null +++ b/packages/acme/CookieConsent/resources/views/components/privacy-info.blade.php @@ -0,0 +1,279 @@ +@props([ + 'analyticsId' => config('cookie-consent.analytics_id'), + 'cookieName' => config('cookie-consent.cookie_name', 'cookie_consent'), + 'cookieLifetime' => config('cookie-consent.cookie_lifetime', 365), + 'showAnalyticsInfo' => true, + 'showCurrentSettings' => true, + 'showChangeButton' => true, + 'colors' => config('cookie-consent.colors', []), +]) + +@php + $colors = array_merge( + [ + 'primary' => '#009bdd', + 'primary_hover' => '#0071a8', + ], + $colors ?? [], + ); +@endphp + +
merge(['class' => 'cookie-privacy-info']) }}> + {{-- Aktuelle Cookie-Einstellungen --}} + @if ($showCurrentSettings) +
+

+ + + + + {{ __('cookie-consent::cookie-consent.privacy_info.current_settings_title') }} +

+ +
+ {{-- Technisch notwendig --}} +
+ + {{ __('cookie-consent::cookie-consent.privacy_info.essential_cookies') }} + + + + + + {{ __('cookie-consent::cookie-consent.status.active') }} + +
+ + {{-- Analyse- und Marketing-Cookies (immer anzeigen) --}} +
+ + {{ __('cookie-consent::cookie-consent.privacy_info.analytics_marketing_cookies') }} + + + + + + {{ __('cookie-consent::cookie-consent.status.active') }} + + + + + + {{ __('cookie-consent::cookie-consent.status.inactive') }} + +
+
+ + {{-- Keine Einstellung vorhanden --}} +
+

+ + + + {{ __('cookie-consent::cookie-consent.status.no_consent') }} +

+
+ + {{-- Button zum Ändern --}} + @if ($showChangeButton) +
+ +
+ @endif +
+ @endif + + {{-- Google Analytics Informationen --}} + @if ($showAnalyticsInfo && $analyticsId) +
+

+ {{ __('cookie-consent::cookie-consent.google_analytics.title') }} +

+ +

+ {{ __('cookie-consent::cookie-consent.google_analytics.intro') }} +

+ +
+

+ {{ __('cookie-consent::cookie-consent.google_analytics.technical_details.title') }} +

+
    +
  • + {{ __('cookie-consent::cookie-consent.google_analytics.technical_details.tracking_id') }}: + {{ $analyticsId }} +
  • +
  • + {{ __('cookie-consent::cookie-consent.google_analytics.technical_details.ip_anonymization') }}: + {{ __('cookie-consent::cookie-consent.google_analytics.technical_details.ip_anonymization_value') }} +
  • +
  • + {{ __('cookie-consent::cookie-consent.google_analytics.technical_details.consent_mode') }}: + {{ __('cookie-consent::cookie-consent.google_analytics.technical_details.consent_mode_value') }} +
  • +
  • + {{ __('cookie-consent::cookie-consent.google_analytics.technical_details.cookie_duration') }}: + {{ __('cookie-consent::cookie-consent.google_analytics.technical_details.cookie_duration_value', ['days' => $cookieLifetime]) }} +
  • +
+
+ +

+ {{ __('cookie-consent::cookie-consent.google_analytics.purpose.title') }} +

+

+ {{ __('cookie-consent::cookie-consent.google_analytics.purpose.text') }} +

+ +

+ {{ __('cookie-consent::cookie-consent.google_analytics.ip_anonymization.title') }} +

+

+ {{ __('cookie-consent::cookie-consent.google_analytics.ip_anonymization.text') }} +

+ +

+ {{ __('cookie-consent::cookie-consent.google_analytics.legal_basis.title') }} +

+

+ {{ __('cookie-consent::cookie-consent.google_analytics.legal_basis.text') }} +

+ +

+ {{ __('cookie-consent::cookie-consent.google_analytics.cookies.title') }} +

+
+ + + + + + + + + + @foreach (__('cookie-consent::cookie-consent.google_analytics.cookies.list') as $cookieName => $cookie) + + + + + + @endforeach + +
+ {{ __('cookie-consent::cookie-consent.google_analytics.cookies.table.name') }} + + {{ __('cookie-consent::cookie-consent.google_analytics.cookies.table.purpose') }} + + {{ __('cookie-consent::cookie-consent.google_analytics.cookies.table.duration') }} +
{{ $cookieName }}{{ $cookie['purpose'] }}{{ $cookie['duration'] }}
+
+ +

+ {{ __('cookie-consent::cookie-consent.google_analytics.objection.title') }} +

+

+ {{ __('cookie-consent::cookie-consent.google_analytics.objection.text') }} +

+
    +
  • {{ __('cookie-consent::cookie-consent.google_analytics.objection.options.revoke') }}
  • +
  • + {{ __('cookie-consent::cookie-consent.google_analytics.objection.options.addon') }} + + tools.google.com/dlpage/gaoptout + +
  • +
+ +

+ {{ __('cookie-consent::cookie-consent.google_analytics.more_info.title') }} +

+

+ {{ __('cookie-consent::cookie-consent.google_analytics.more_info.text') }} + + policies.google.com/privacy + +

+
+ @endif +
+ + diff --git a/packages/acme/CookieConsent/src/CookieConsentServiceProvider.php b/packages/acme/CookieConsent/src/CookieConsentServiceProvider.php new file mode 100644 index 0000000..9aecadc --- /dev/null +++ b/packages/acme/CookieConsent/src/CookieConsentServiceProvider.php @@ -0,0 +1,46 @@ +mergeConfigFrom( + __DIR__.'/../config/cookie-consent.php', + 'cookie-consent' + ); + } + + public function boot(): void + { + // Views laden + $this->loadViewsFrom(__DIR__.'/../resources/views', 'cookie-consent'); + + // Anonyme Blade-Komponenten registrieren (ermöglicht ) + Blade::anonymousComponentPath(__DIR__.'/../resources/views/components', 'cookie-consent'); + + // Übersetzungen laden + $this->loadTranslationsFrom(__DIR__.'/../lang', 'cookie-consent'); + + if ($this->app->runningInConsole()) { + // Config veröffentlichen + $this->publishes([ + __DIR__.'/../config/cookie-consent.php' => config_path('cookie-consent.php'), + ], 'cookie-consent-config'); + + // Views veröffentlichen + $this->publishes([ + __DIR__.'/../resources/views' => resource_path('views/vendor/cookie-consent'), + ], 'cookie-consent-views'); + + // Übersetzungen veröffentlichen + $this->publishes([ + __DIR__.'/../lang' => lang_path('vendor/cookie-consent'), + ], 'cookie-consent-lang'); + } + } +} diff --git a/packages/acme/contact-form/README.md b/packages/acme/contact-form/README.md new file mode 100644 index 0000000..1d56192 --- /dev/null +++ b/packages/acme/contact-form/README.md @@ -0,0 +1,303 @@ +# Acme Contact Form + +Ein wiederverwendbares, spam-geschütztes Kontaktformular-Package für Laravel mit Livewire. Bietet Honeypot, zeitbasierte Prüfung, Inhaltsanalyse, Rate Limiting und mehr – ohne externe Dienste, DSGVO-konform. + +## Features + +- **Spam-Schutz**: Honeypot, Zeitprüfung, Inhaltsanalyse, Wegwerf-E-Mails, Rate Limiting, IP-Blacklist, Bot-User-Agent-Erkennung +- **Konfigurierbare Felder**: Presets (simple, full) oder eigene Felddefinitionen +- **Livewire**: Reaktives Formular ohne Page-Reload +- **Flexibel**: Einfache Formulare bis komplexe Multi-Feld-Formulare +- **DSGVO-konform**: Keine externen Services, alles in-house + +## Voraussetzungen + +- PHP 8.2+ +- Laravel 10+ / 11+ / 12+ +- Livewire 3+ + +## Installation + +### 1. Package installieren + +```bash +composer require acme/contact-form +``` + +Für lokale Entwicklung (Path-Repository): + +```json +{ + "repositories": [ + { + "type": "path", + "url": "package/acme/contact-form" + } + ], + "require": { + "acme/contact-form": "@dev" + } +} +``` + +```bash +composer update acme/contact-form +``` + +### 2. Konfiguration + +```bash +php artisan vendor:publish --tag=contact-form-config +``` + +In `.env`: + +```env +CONTACT_FORM_RECIPIENT=kontakt@ihre-domain.de +CONTACT_FORM_MAX_ATTEMPTS=3 +CONTACT_FORM_DECAY_MINUTES=15 +CONTACT_FORM_MIN_FILL_TIME=3 +``` + +### 3. Übersetzungen (optional) + +```bash +php artisan vendor:publish --tag=contact-form-lang +``` + +## Verwendung + +### Einfaches Formular (Preset: simple) + +Name, E-Mail, Nachricht, Datenschutz-Checkbox: + +```blade + +``` + +Oder mit explizitem Preset: + +```blade + +``` + +### Vollständiges Formular (Preset: full) + +Vorname, Nachname, E-Mail, Telefon, Unternehmen, Nachricht, Datenschutz: + +```blade + +``` + +### Eigenes Formular mit benutzerdefinierten Feldern + +Sie können beliebige Felder definieren. Jedes Feld benötigt: + +- `type`: `text`, `email`, `tel`, `textarea`, `select`, `checkbox`, `honeypot` +- `rules`: Laravel-Validierungsregeln (Array) +- `label`: Anzeigename (optional) +- `placeholder`: Platzhalter (optional) +- `required`: true/false (optional, wird aus rules abgeleitet) +- `name`: Formularfeld-Name (optional, Standard: Array-Key) +- `options`: Für `select` – Array [value => label] (optional) + +**Beispiel: Minimales Formular (nur E-Mail + Nachricht)** + +```blade + +``` + +**Beispiel: Formular mit Select-Feld** + +```blade + +``` + +**Wichtig**: Jedes Formular sollte ein Honeypot-Feld enthalten. Das Feld wird für Nutzer unsichtbar dargestellt; Bots füllen es oft aus und werden so erkannt. + +### Eigene Presets in der Config + +In `config/contact-form.php` können Sie eigene Presets definieren: + +```php +'presets' => [ + 'mein-formular' => [ + 'name' => [ + 'type' => 'text', + 'rules' => ['required', 'string', 'max:120'], + 'label' => 'Name', + ], + 'email' => [ + 'type' => 'email', + 'rules' => ['required', 'email:rfc,dns'], + 'label' => 'E-Mail', + ], + 'message' => [ + 'type' => 'textarea', + 'rules' => ['required', 'string', 'max:2000'], + 'label' => 'Nachricht', + ], + 'privacy' => [ + 'type' => 'checkbox', + 'rules' => ['accepted'], + 'label' => 'Datenschutz', + ], + 'honeypot' => [ + 'type' => 'honeypot', + 'name' => 'website', + 'rules' => ['nullable', 'string', 'max:0'], + ], + ], +], +``` + +Verwendung: + +```blade + +``` + +## Spam-Schutz im Detail + +| Maßnahme | Beschreibung | +|----------|--------------| +| **Honeypot** | Verstecktes Feld – wenn ausgefüllt → Spam | +| **Zeitprüfung** | Formular < 3 Sek. ausgefüllt → Spam | +| **Inhaltsanalyse** | Spam-Keywords, URLs, XSS-Muster → Spam | +| **Wegwerf-E-Mails** | tempmail.com, mailinator.com etc. → Spam | +| **Rate Limiting** | Max. 3 Anfragen/IP in 15 Min. (konfigurierbar) | +| **IP-Blacklist** | Blockierte IPs in Config | +| **Bot-User-Agent** | curl, wget, python-requests etc. → Spam | + +Bei Spam wird **immer** eine Erfolgsmeldung angezeigt (Täuschung von Bots), aber **keine E-Mail** versendet. Alle Anfragen werden geloggt. + +## Service und SpamDetector direkt nutzen + +Falls Sie einen eigenen Controller oder eine eigene Livewire-Komponente verwenden möchten: + +```php +use Acme\ContactForm\ContactFormService; +use Acme\ContactForm\SpamDetector; + +// Spam prüfen +$spamDetector = SpamDetector::fromConfig(); +$isSpam = $spamDetector->detect($validatedData, $formLoadedAt); + +// Anfrage verarbeiten +$service = app(ContactFormService::class); +$service->handle([ + 'name' => $request->input('name'), + 'email' => $request->input('email'), + 'message' => $request->input('message'), + 'ip' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'is_spam' => $isSpam, +], 'Betreff der E-Mail', 'Log-Kontext'); +``` + +## Konfiguration + +| Option | Beschreibung | Standard | +|--------|--------------|----------| +| `recipient` | E-Mail-Empfänger | aus .env | +| `rate_limit.max_attempts` | Max. Anfragen pro IP | 3 | +| `rate_limit.decay_minutes` | Zeitfenster in Minuten | 15 | +| `blacklisted_ips` | IP-Adressen blockieren | [] | +| `honeypot_fields` | Namen der Honeypot-Felder | company_check, website, url | +| `spam.min_fill_time_seconds` | Min. Zeit zum Ausfüllen | 3 | +| `spam.suspicious_patterns` | Regex für Spam-Erkennung | siehe Config | +| `spam.disposable_email_domains` | Wegwerf-Domains | siehe Config | + +## Styling anpassen + +Die Standard-Views nutzen generische Klassen (`border-gray-300`, `focus:ring-primary`). Passen Sie sie an Ihr Design an: + +```bash +php artisan vendor:publish --tag=contact-form-views +``` + +Die Views liegen dann unter `resources/views/vendor/contact-form/`. + +## Event nach Absenden + +Nach erfolgreichem Absenden wird das Event `contact-form-submitted` dispatched. Sie können darauf reagieren: + +```blade +
+ + + +
+``` + +## Lizenz + +MIT License diff --git a/packages/acme/contact-form/composer.json b/packages/acme/contact-form/composer.json new file mode 100644 index 0000000..2996206 --- /dev/null +++ b/packages/acme/contact-form/composer.json @@ -0,0 +1,38 @@ +{ + "name": "acme/contact-form", + "description": "Ein wiederverwendbares, spam-geschütztes Kontaktformular-Package für Laravel mit Livewire", + "type": "library", + "license": "MIT", + "keywords": [ + "laravel", + "livewire", + "contact-form", + "spam-protection", + "honeypot" + ], + "autoload": { + "psr-4": { + "Acme\\ContactForm\\": "src/" + } + }, + "authors": [ + { + "name": "Acme", + "email": "info@acme.de" + } + ], + "require": { + "php": "^8.2", + "illuminate/support": "^10.0|^11.0|^12.0", + "livewire/livewire": "^3.0|^4.0" + }, + "extra": { + "laravel": { + "providers": [ + "Acme\\ContactForm\\ContactFormServiceProvider" + ] + } + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/packages/acme/contact-form/config/contact-form.php b/packages/acme/contact-form/config/contact-form.php new file mode 100644 index 0000000..8215083 --- /dev/null +++ b/packages/acme/contact-form/config/contact-form.php @@ -0,0 +1,126 @@ + env('CONTACT_FORM_RECIPIENT', 'contact@example.com'), + + /* + |-------------------------------------------------------------------------- + | Rate Limiting + |-------------------------------------------------------------------------- + | + | Maximum number of submissions per IP within the given time window. + | + */ + 'rate_limit' => [ + 'max_attempts' => env('CONTACT_FORM_MAX_ATTEMPTS', 3), + 'decay_minutes' => env('CONTACT_FORM_DECAY_MINUTES', 15), + ], + + /* + |-------------------------------------------------------------------------- + | IP Blacklist + |-------------------------------------------------------------------------- + | + | IP addresses that will always be blocked as spam. + | + */ + 'blacklisted_ips' => [], + + /* + |-------------------------------------------------------------------------- + | Spam Detection + |-------------------------------------------------------------------------- + | + | Configuration for the built-in spam detection. + | + */ + 'honeypot_fields' => ['company_check', 'website', 'url', 'website_url'], + + 'spam' => [ + 'min_fill_time_seconds' => env('CONTACT_FORM_MIN_FILL_TIME', 3), + 'suspicious_patterns' => [ + '/\b(viagra|cialis|casino|poker|lottery|bitcoin|crypto)\b/i', + '/\b[A-Z]{10,}\b/', + '/ 2, + 'max_repeated_chars' => 10, + 'disposable_email_domains' => [ + 'tempmail.com', + 'guerrillamail.com', + '10minutemail.com', + 'mailinator.com', + 'throwaway.email', + 'yopmail.com', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Form Presets + |-------------------------------------------------------------------------- + | + | Predefined field configurations for different form types. + | See README for customization and defining your own forms. + | + */ + 'presets' => [ + 'simple' => [ + 'name' => ['type' => 'text', 'rules' => ['required', 'string', 'max:120'], 'label' => 'Name', 'required' => true], + 'email' => ['type' => 'email', 'rules' => ['required', 'email:rfc,dns', 'max:150'], 'label' => 'Email', 'required' => true], + 'message' => ['type' => 'textarea', 'rules' => ['required', 'string', 'max:2000'], 'label' => 'Message', 'required' => true], + 'privacy' => ['type' => 'checkbox', 'rules' => ['accepted'], 'label' => 'Privacy Policy', 'required' => true], + 'honeypot' => ['type' => 'honeypot', 'name' => 'website', 'rules' => ['nullable', 'string', 'max:0']], + ], + 'full' => [ + 'first_name' => ['type' => 'text', 'rules' => ['required', 'string', 'max:100'], 'label' => 'First Name', 'required' => true], + 'last_name' => ['type' => 'text', 'rules' => ['required', 'string', 'max:100'], 'label' => 'Last Name', 'required' => true], + 'email' => ['type' => 'email', 'rules' => ['required', 'email:rfc,dns', 'max:150'], 'label' => 'Email', 'required' => true], + 'phone' => ['type' => 'tel', 'rules' => ['nullable', 'string', 'max:80'], 'label' => 'Phone', 'required' => false], + 'company' => ['type' => 'text', 'rules' => ['required', 'string', 'max:150'], 'label' => 'Company', 'required' => true], + 'message' => ['type' => 'textarea', 'rules' => ['required', 'string', 'max:2000'], 'label' => 'Message', 'required' => true], + 'privacy' => ['type' => 'checkbox', 'rules' => ['accepted'], 'label' => 'Privacy Policy', 'required' => true], + 'honeypot' => ['type' => 'honeypot', 'name' => 'company_check', 'rules' => ['nullable', 'string', 'max:0']], + ], + 'business' => [ + 'first_name' => ['type' => 'text', 'rules' => ['required', 'string', 'max:100'], 'label' => 'First Name', 'required' => true], + 'last_name' => ['type' => 'text', 'rules' => ['required', 'string', 'max:100'], 'label' => 'Last Name', 'required' => true], + 'email' => ['type' => 'email', 'rules' => ['required', 'email:rfc,dns', 'max:150'], 'label' => 'Email', 'required' => true], + 'phone' => ['type' => 'tel', 'rules' => ['nullable', 'string', 'max:80'], 'label' => 'Phone', 'required' => false], + 'company' => ['type' => 'text', 'rules' => ['required', 'string', 'max:150'], 'label' => 'Company', 'required' => true], + 'project_type' => [ + 'type' => 'select', + 'rules' => ['nullable', 'string', 'max:150'], + 'label' => 'Project Type', + 'options' => [ + 'consulting' => 'Consulting', + 'implementation' => 'Implementation', + 'support' => 'Support', + ], + ], + 'message' => ['type' => 'textarea', 'rules' => ['required', 'string', 'max:2000'], 'label' => 'Message', 'required' => true], + 'timeline' => [ + 'type' => 'select', + 'rules' => ['nullable', 'string', 'max:150'], + 'label' => 'Timeline', + 'options' => [ + 'asap' => 'As soon as possible', + '3months' => 'Within 3 months', + '6months' => 'Within 6 months', + ], + ], + 'privacy' => ['type' => 'checkbox', 'rules' => ['accepted'], 'label' => 'Privacy Policy', 'required' => true], + 'honeypot' => ['type' => 'honeypot', 'name' => 'company_check', 'rules' => ['nullable', 'string', 'max:0']], + ], + ], + +]; diff --git a/packages/acme/contact-form/lang/de/contact-form.php b/packages/acme/contact-form/lang/de/contact-form.php new file mode 100644 index 0000000..f8921ec --- /dev/null +++ b/packages/acme/contact-form/lang/de/contact-form.php @@ -0,0 +1,9 @@ + 'Neue Kontaktanfrage', + 'success_message' => 'Vielen Dank! Ihre Nachricht wurde erfolgreich gesendet.', + 'submit' => 'Absenden', + 'sending' => 'Wird gesendet...', + 'select_placeholder' => 'Bitte wählen...', +]; diff --git a/packages/acme/contact-form/lang/en/contact-form.php b/packages/acme/contact-form/lang/en/contact-form.php new file mode 100644 index 0000000..1f521dd --- /dev/null +++ b/packages/acme/contact-form/lang/en/contact-form.php @@ -0,0 +1,9 @@ + 'New Contact Request', + 'success_message' => 'Thank you! Your message has been sent successfully.', + 'submit' => 'Submit', + 'sending' => 'Sending...', + 'select_placeholder' => 'Please select...', +]; diff --git a/packages/acme/contact-form/resources/views/form.blade.php b/packages/acme/contact-form/resources/views/form.blade.php new file mode 100644 index 0000000..faa805e --- /dev/null +++ b/packages/acme/contact-form/resources/views/form.blade.php @@ -0,0 +1,92 @@ +
+
+ @foreach ($fields as $key => $field) + @php + $name = $field['name'] ?? $key; + $label = $field['label'] ?? ucfirst(str_replace('_', ' ', $name)); + $required = $field['required'] ?? (in_array('required', $field['rules'] ?? [])); + $placeholder = $field['placeholder'] ?? ''; + @endphp + + @if ($field['type'] === 'honeypot') + + @elseif ($field['type'] === 'textarea') +
+ + + @error("formData.{$name}") +

{{ $message }}

+ @enderror +
+ @elseif ($field['type'] === 'checkbox') +
+
+ + +
+ @error("formData.{$name}") +

{{ $message }}

+ @enderror +
+ @elseif ($field['type'] === 'select') +
+ + + @error("formData.{$name}") +

{{ $message }}

+ @enderror +
+ @else +
+ + + @error("formData.{$name}") +

{{ $message }}

+ @enderror +
+ @endif + @endforeach + + @if ($success) +
+ {{ __('contact-form::contact-form.success_message') }} +
+ @endif + + +
+
diff --git a/packages/acme/contact-form/src/ContactFormService.php b/packages/acme/contact-form/src/ContactFormService.php new file mode 100644 index 0000000..aa67325 --- /dev/null +++ b/packages/acme/contact-form/src/ContactFormService.php @@ -0,0 +1,135 @@ +config = array_merge( + config('contact-form', []), + $config + ); + } + + /** + * Processes a contact form submission. + */ + public function handle(array $payload, string $subject = '', string $logContext = 'contact-form'): void + { + $isSpam = $payload['is_spam'] ?? false; + + if (! $isSpam) { + $isSpam = $this->performServerSideSpamCheck($payload); + $payload['is_spam'] = $isSpam; + } + + if ($isSpam) { + Log::info("SPAM detected - {$logContext}", $payload); + Log::info("Email NOT sent (spam detected - {$logContext})", [ + 'ip' => $payload['ip'] ?? 'unknown', + 'email' => $payload['email'] ?? 'unknown', + ]); + } else { + Log::info("{$logContext} received", $payload); + $this->notify($subject ?: __('contact-form::contact-form.default_subject'), $payload); + } + } + + protected function notify(string $subject, array $payload): void + { + $recipient = $this->config['recipient'] ?? null; + + if (empty($recipient)) { + return; + } + + $body = $this->formatMailBody($subject, $payload); + + try { + Mail::raw($body, function ($message) use ($recipient, $subject): void { + $message->to($recipient)->subject($subject); + }); + } catch (\Throwable $e) { + Log::warning('Contact form email could not be sent', [ + 'error' => $e->getMessage(), + ]); + } + } + + protected function formatMailBody(string $subject, array $payload): string + { + $lines = [$subject, str_repeat('-', 40)]; + + foreach ($payload as $key => $value) { + if ($key === 'is_spam') { + continue; + } + + $value = is_array($value) ? json_encode($value, JSON_UNESCAPED_UNICODE) : (string) $value; + $label = ucfirst(str_replace('_', ' ', $key)); + $lines[] = "{$label}: {$value}"; + } + + return implode(PHP_EOL, $lines); + } + + protected function performServerSideSpamCheck(array $payload): bool + { + $ip = $payload['ip'] ?? 'unknown'; + $config = $this->config; + + $maxAttempts = $config['rate_limit']['max_attempts'] ?? 3; + $decayMinutes = $config['rate_limit']['decay_minutes'] ?? 15; + + $cacheKey = 'contact_form_'.md5($ip); + $attempts = cache()->get($cacheKey, 0); + + if ($attempts >= $maxAttempts) { + Log::warning('Contact form rate limit exceeded', [ + 'ip' => $ip, + 'attempts' => $attempts, + 'max_attempts' => $maxAttempts, + ]); + + return true; + } + + cache()->put($cacheKey, $attempts + 1, now()->addMinutes($decayMinutes)); + + $blacklistedIPs = $config['blacklisted_ips'] ?? []; + if (in_array($ip, $blacklistedIPs)) { + Log::warning('Blacklisted IP detected', ['ip' => $ip]); + + return true; + } + + $userAgent = $payload['user_agent'] ?? ''; + $botPatterns = [ + '/bot/i', + '/crawler/i', + '/spider/i', + '/scraper/i', + '/curl/i', + '/wget/i', + '/python-requests/i', + ]; + + foreach ($botPatterns as $pattern) { + if (preg_match($pattern, $userAgent)) { + Log::warning('Bot user-agent detected', [ + 'ip' => $ip, + 'user_agent' => $userAgent, + ]); + + return true; + } + } + + return false; + } +} diff --git a/packages/acme/contact-form/src/ContactFormServiceProvider.php b/packages/acme/contact-form/src/ContactFormServiceProvider.php new file mode 100644 index 0000000..66e3c59 --- /dev/null +++ b/packages/acme/contact-form/src/ContactFormServiceProvider.php @@ -0,0 +1,44 @@ +mergeConfigFrom( + __DIR__.'/../config/contact-form.php', + 'contact-form' + ); + + $this->app->singleton(ContactFormService::class, function (): ContactFormService { + return new ContactFormService; + }); + } + + public function boot(): void + { + $this->loadViewsFrom(__DIR__.'/../resources/views', 'contact-form'); + + $this->loadTranslationsFrom(__DIR__.'/../lang', 'contact-form'); + + Livewire::component('contact-form', \Acme\ContactForm\Livewire\ContactForm::class); + + if ($this->app->runningInConsole()) { + $this->publishes([ + __DIR__.'/../config/contact-form.php' => config_path('contact-form.php'), + ], 'contact-form-config'); + + $this->publishes([ + __DIR__.'/../resources/views' => resource_path('views/vendor/contact-form'), + ], 'contact-form-views'); + + $this->publishes([ + __DIR__.'/../lang' => lang_path('vendor/contact-form'), + ], 'contact-form-lang'); + } + } +} diff --git a/packages/acme/contact-form/src/Livewire/ContactForm.php b/packages/acme/contact-form/src/Livewire/ContactForm.php new file mode 100644 index 0000000..c6baa63 --- /dev/null +++ b/packages/acme/contact-form/src/Livewire/ContactForm.php @@ -0,0 +1,109 @@ + */ + public array $formData = []; + + public ?int $formLoadedAt = null; + + public bool $success = false; + + public string $preset = 'simple'; + + public string $subject = ''; + + public string $logContext = 'contact-form'; + + /** + * @var array + */ + public array $customFields = []; + + public function mount( + string $preset = 'simple', + string $subject = '', + array $fields = [] + ): void { + $this->preset = $preset; + $this->subject = $subject; + $this->customFields = $fields; + $this->formLoadedAt = time(); + $this->initializeFormData(); + } + + protected function getFields(): array + { + if (! empty($this->customFields)) { + return $this->customFields; + } + + $presets = config('contact-form.presets', []); + + return $presets[$this->preset] ?? $presets['simple']; + } + + protected function initializeFormData(): void + { + foreach ($this->getFields() as $key => $field) { + $name = $field['name'] ?? $key; + $this->formData[$name] = match ($field['type']) { + 'checkbox' => false, + default => '', + }; + } + } + + protected function getRules(): array + { + $rules = []; + + foreach ($this->getFields() as $key => $field) { + $name = $field['name'] ?? $key; + $rules["formData.{$name}"] = $field['rules'] ?? ['nullable']; + } + + return $rules; + } + + public function submit(ContactFormService $service): void + { + $validated = $this->validate($this->getRules()); + + $flattened = $validated['formData'] ?? []; + $spamDetector = SpamDetector::fromConfig(); + $isSpam = $spamDetector->detect($flattened, $this->formLoadedAt); + + $payload = array_merge($flattened, [ + 'ip' => request()->ip(), + 'user_agent' => request()->userAgent(), + 'is_spam' => $isSpam, + ]); + + $service->handle($payload, $this->subject, $this->logContext); + + $this->success = true; + $this->dispatch('contact-form-submitted'); + $this->resetForm(); + } + + protected function resetForm(): void + { + $this->initializeFormData(); + $this->formLoadedAt = time(); + } + + public function render(): View + { + return view('contact-form::form', [ + 'fields' => $this->getFields(), + ]); + } +} diff --git a/packages/acme/contact-form/src/SpamDetector.php b/packages/acme/contact-form/src/SpamDetector.php new file mode 100644 index 0000000..e64caa3 --- /dev/null +++ b/packages/acme/contact-form/src/SpamDetector.php @@ -0,0 +1,165 @@ +checkHoneypot($validated, $request)) { + return true; + } + + if ($this->checkFillTime($formLoadedAt, $request)) { + return true; + } + + if ($this->checkContent($validated, $request)) { + return true; + } + + if ($this->checkDisposableEmail($validated, $request)) { + return true; + } + + return false; + } + + /** + * Checks honeypot fields — configured fields must be empty. + */ + protected function checkHoneypot(array $validated, Request $request): bool + { + $honeypotFields = $this->config['honeypot_fields'] ?? ['company_check', 'website', 'url', 'website_url']; + + foreach ($honeypotFields as $field) { + if (isset($validated[$field]) && ! empty($validated[$field])) { + $this->logSpam('Honeypot field filled', ['field' => $field], $request); + + return true; + } + } + + foreach ($validated as $key => $value) { + if (str_contains((string) $key, '_check') && ! empty($value)) { + $this->logSpam('Honeypot field filled', ['field' => $key], $request); + + return true; + } + } + + return false; + } + + /** + * Time-based check: form submitted too quickly. + */ + protected function checkFillTime(?int $formLoadedAt, Request $request): bool + { + if ($formLoadedAt === null) { + return false; + } + + $minSeconds = $this->config['spam']['min_fill_time_seconds'] ?? 3; + $timeSpent = time() - $formLoadedAt; + + if ($timeSpent < $minSeconds) { + $this->logSpam('Form submitted too quickly', ['time_spent' => $timeSpent], $request); + + return true; + } + + return false; + } + + /** + * Content analysis: suspicious patterns, excessive links, repeated characters, XSS. + */ + protected function checkContent(array $validated, Request $request): bool + { + $patterns = $this->config['spam']['suspicious_patterns'] ?? []; + $maxLinks = $this->config['spam']['max_links_in_message'] ?? 2; + $maxRepeated = $this->config['spam']['max_repeated_chars'] ?? 10; + + foreach ($validated as $key => $value) { + if (! is_string($value) || str_contains($key, 'honeypot')) { + continue; + } + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $value)) { + $this->logSpam('Suspicious content detected', ['pattern' => $pattern, 'field' => $key], $request); + + return true; + } + } + + $linkCount = substr_count(strtolower($value), 'http://') + + substr_count(strtolower($value), 'https://') + + substr_count(strtolower($value), 'www.'); + + if ($linkCount > $maxLinks) { + $this->logSpam('Too many links', ['link_count' => $linkCount], $request); + + return true; + } + + if (preg_match('/(.)\1{'.($maxRepeated + 1).',}/', $value)) { + $this->logSpam('Too many repeated characters', [], $request); + + return true; + } + } + + return false; + } + + /** + * Checks for disposable/throwaway email domains. + */ + protected function checkDisposableEmail(array $validated, Request $request): bool + { + $email = $validated['email'] ?? $validated['e-mail'] ?? null; + if (empty($email) || ! is_string($email)) { + return false; + } + + $domains = $this->config['spam']['disposable_email_domains'] ?? []; + $emailDomain = substr((string) strrchr($email, '@'), 1); + + if (in_array(strtolower($emailDomain), $domains)) { + $this->logSpam('Disposable email address', ['domain' => $emailDomain], $request); + + return true; + } + + return false; + } + + protected function logSpam(string $reason, array $context, Request $request): void + { + Log::warning('Spam detected (contact-form): '.$reason, array_merge([ + 'ip' => $request->ip(), + ], $context)); + } + + /** + * Static factory for convenient instantiation. + */ + public static function fromConfig(?array $config = null): self + { + return new self($config ?? config('contact-form', [])); + } +} diff --git a/packages/flux-cms/ARCHITECTURE.md b/packages/flux-cms/ARCHITECTURE.md index c3eb444..07faee4 100644 --- a/packages/flux-cms/ARCHITECTURE.md +++ b/packages/flux-cms/ARCHITECTURE.md @@ -1,786 +1,496 @@ -# Flux CMS - Architecture Documentation +# Flux CMS — Technische Architektur -## Überblick - -Flux CMS ist ein modular aufgebautes Content Management System für Laravel 12 mit Livewire 3 und Flux UI. Das System folgt einem "Code-as-Schema" Ansatz, bei dem Component-Felder direkt in PHP-Klassen definiert werden statt in der Datenbank. - -## Architektur-Prinzipien - -### 1. **Modularität** -- Package-basierte Architektur mit separaten Modulen -- Klare Trennung von Core, Components und Starter Components -- Erweiterbar durch zusätzliche Packages - -### 2. **Code-as-Schema** -- Component-Felder werden in PHP-Klassen definiert -- Keine Datenbank-Schemas für Felder -- Typsicherheit und IDE-Unterstützung - -### 3. **Multi-Domain & Multi-Language** -- Unterstützung für mehrere Domains -- Vollständige Mehrsprachigkeit mit spatie/laravel-translatable -- Domain-spezifische Konfigurationen - -### 4. **Component-First** -- Seiten bestehen aus wiederverwendbaren Livewire Components -- Drag & Drop Interface für Component-Anordnung -- Flexibles Layout-System - -## Package-Struktur +## Datenfluss ``` -packages/flux-cms/ -├── core/ # Kern-Funktionalität -│ ├── src/ -│ │ ├── Models/ # Eloquent Models -│ │ ├── Services/ # Business Logic -│ │ ├── FieldTypes/ # Field Type System -│ │ ├── Commands/ # Artisan Commands -│ │ ├── Http/ # Controllers & Middleware -│ │ │ └── Controllers/ -│ │ │ ├── Admin/ # Backend Controllers -│ │ │ │ ├── PageController.php -│ │ │ │ ├── BlogController.php -│ │ │ │ └── ... -│ │ │ └── PageController.php # Frontend Controller -│ │ └── FluxCmsServiceProvider.php -│ ├── database/migrations/ # Database Migrations -│ ├── config/ # Konfigurationsdateien -│ └── tests/ # Tests -│ -├── components/ # Livewire Components -│ ├── src/ -│ │ └── Livewire/ -│ │ ├── Backend/ # Admin Interface Components -│ │ └── Frontend/ # Public Frontend Components -│ └── resources/ -│ └── views/ # Blade Templates -│ -└── starter-components/ # Vorgefertigte Components - └── src/Components/ # Ready-to-use Components +┌───────────────────────────────────────────────────────┐ +│ Blade Template │ +│ {{ cms('welcome.hero.heading') }} │ +│ {{ cms_media_url('welcome.hero.image', 'hero') }} │ +│ {{ media_url('file.webp', 'card') }} │ +└────────────────────┬──────────────────────────────────┘ + │ + ┌───────────▼───────────────┐ + │ Helper-Funktionen │ + │ cms(), cms_media_url(), │ + │ media_url(), tcms() │ + │ (app/helpers.php) │ + └───────────┬───────────────┘ + │ + ┌───────────▼───────────────┐ + │ CmsContentService │ Singleton via ServiceProvider + │ get($key, $replace) │ + └───────────┬───────────────┘ + │ + ┌─────────▼─────────┐ + │ Cache Layer │ Cache::remember() pro Gruppe + │ (config TTL) │ Auto-Invalidierung bei Admin-Save + └─────────┬─────────┘ + │ + ┌─────────▼─────────┐ ┌──────────────────┐ + │ CmsContent Model │ │ __($key) │ + │ (DB Lookup) ├─NO──► Fallback zu │ + │ Übersetzbar │ │ Laravel Lang │ + └───────────────────┘ └──────────────────┘ ``` -## Datenbank-Schema +## Medien-Datenfluss -### Core Tabellen - -#### `flux_cms_pages` -```sql -- id (Primary Key) -- domain_key (String) - Domain-Zuordnung -- title (JSON) - Mehrsprachige Titel -- slug (JSON) - Mehrsprachige URL-Slugs -- content (JSON) - Mehrsprachiger Inhalt -- template (String) - Template-Name -- meta_title (JSON) - SEO Titel -- meta_description (JSON) - SEO Beschreibung -- is_published (Boolean) -- published_at (Timestamp) -- settings (JSON) - Zusätzliche Einstellungen -- created_at, updated_at +``` +┌────────────────────────────────────────────────────────────┐ +│ Blade Template │ +│ {{ media_url('datei.webp', 'hero') }} │ +└─────────────────────┬──────────────────────────────────────┘ + │ + ┌───────────▼────────────────┐ + │ media_url() Helper │ + │ 1. In-Memory-Cache prüfen │ + │ 2. CmsMedia::where(...) │ + │ 3. Conversion vorhanden? │ + │ 4. Fallback: asset(...) │ + └───────────┬────────────────┘ + │ + ┌────────────▼────────────────┐ + │ CmsMedia Model │ + │ - getUrl() │ + │ - getConversionUrl($name) │ + │ - hasThumbnail() │ + │ - isPdf() / isImage() │ + └────────────┬────────────────┘ + │ + ┌────────────▼────────────────┐ + │ Storage (public Disk) │ + │ cms/media/originals/ │ + │ cms/media/conversions/ │ + │ cms/media/thumbnails/ │ + └─────────────────────────────┘ ``` -#### `flux_cms_page_components` -```sql -- id (Primary Key) -- page_id (Foreign Key) - Verknüpfung zur Seite -- component_type (String) - Livewire Component Klasse -- content (JSON) - Component-spezifische Daten -- settings (JSON) - Component-Einstellungen -- order (Integer) - Sortierreihenfolge -- is_active (Boolean) -- created_at, updated_at -``` - -#### `flux_cms_page_versions` -```sql -- id (Primary Key) -- page_id (Foreign Key) -- page_data (JSON) - Snapshot der Seite -- components_data (JSON) - Snapshot der Components -- version_name (String) -- change_description (Text) -- created_by_type, created_by_id (Polymorphic) -- created_at, updated_at -``` - -#### `flux_cms_navigations` -```sql -- id (Primary Key) -- domain_key (String) -- name (String) - Eindeutiger Name -- display_name (JSON) - Mehrsprachiger Anzeigename -- settings (JSON) -- is_active (Boolean) -- created_at, updated_at -``` - -#### `flux_cms_navigation_items` -```sql -- id (Primary Key) -- navigation_id (Foreign Key) -- parent_id (Foreign Key, nullable) - Hierarchie -- page_id (Foreign Key, nullable) - Verknüpfung zu Seite -- title (JSON) - Mehrsprachiger Titel -- url (String, nullable) - Externe URL -- target (String) - Link-Ziel -- order (Integer) -- is_active (Boolean) -- settings (JSON) -- created_at, updated_at -``` - -#### `flux_cms_blog_posts` -```sql -- id (Primary Key) -- domain_key (String) -- title (JSON) - Mehrsprachiger Titel -- slug (JSON) - Mehrsprachige Slugs -- excerpt (JSON) - Mehrsprachige Auszüge -- content (JSON) - Mehrsprachiger Inhalt -- meta_title (JSON) - SEO Titel -- meta_description (JSON) - SEO Beschreibung -- is_published (Boolean) -- is_featured (Boolean) -- published_at (Timestamp) -- author_id (Foreign Key) -- category (String) -- tags (JSON) -- settings (JSON) -- created_at, updated_at -``` - -#### `flux_cms_slugs` (Neu) -```sql -- id (Primary Key) -- model_type, model_id (Polymorphic) -- locale (String) -- slug (String) -- created_at, updated_at -``` - -#### `tags` & `taggables` (Neu, von `spatie/laravel-tags`) -```sql -// tags -- id -- name (JSON) -- slug (JSON) -- type (String, nullable) -- order_column (Integer, nullable) - -// taggables -- tag_id (Foreign Key) -- taggable_type, taggable_id (Polymorphic) -``` - -## Component-System - -### Field Types - -Das System bietet verschiedene Field Types für Component-Definitionen: - -#### 1. **TextField** -```php -TextField::make('title', 'Titel') - ->translatable() - ->required() - ->placeholder('Titel eingeben') - ->helpText('Der Haupttitel des Elements') -``` - -#### 2. **MediaField** -```php -MediaField::make('image', 'Bild') - ->images() - ->multiple(false) - ->required() -``` - -#### 3. **WysiwygField** -```php -WysiwygField::make('content', 'Inhalt') - ->translatable() - ->toolbar(['bold', 'italic', 'link']) -``` - -#### 4. **SelectField** -```php -SelectField::make('layout', 'Layout') - ->options([ - 'default' => 'Standard', - 'wide' => 'Breit', - 'centered' => 'Zentriert' - ]) - ->default('default') -``` - -#### 5. **BooleanField** -```php -BooleanField::make('show_overlay', 'Overlay anzeigen') - ->toggle() - ->default(true) - ->helpText('Dunkles Overlay über dem Hintergrundbild') -``` - -#### 6. **NumberField** -```php -NumberField::make('columns', 'Spalten') - ->min(1) - ->max(12) - ->default(3) - ->helpText('Anzahl der Spalten im Grid') -``` - -### Component-Definition +### media_url() im Detail ```php -translatable() - ->required() - ->placeholder('Ihre Hauptüberschrift'), - - TextField::make('subheadline', 'Unterüberschrift') - ->translatable() - ->placeholder('Optionale Unterüberschrift'), - - MediaField::make('background_image', 'Hintergrundbild') - ->images() - ->required(), - - BooleanField::make('dark_overlay', 'Dunkles Overlay') - ->toggle() - ->default(true), - ]; - } - - public static function getCmsInfo(): array - { - return [ - 'name' => 'Hero Section', - 'description' => 'Großer Bereich mit Hintergrundbild und Text', - 'category' => 'Header', - 'preview' => 'hero-preview.jpg' - ]; - } - - public function render() - { - return view('components.hero-section'); - } + // 1. Null/Leer → leerer String + // 2. In-Memory-Cache: $resolved[$cacheKey] + // 3. CmsMedia::where('filename', $filename)->first() + // 4. Wenn $profile und Conversion existiert → getConversionUrl($profile) + // 5. Ohne Profil → getUrl() (Original) + // 6. Fallback: asset('assets/images/' . $filename) } ``` -## Service-Layer - -### ComponentRegistry - -Der ComponentRegistry Service scannt und verwaltet alle verfügbaren CMS Components: +### cms_media_url() im Detail ```php -loaded) { - $this->loadComponents(); - } - return $this->components; - } - - protected function loadComponents(): void - { - // Auto-discovery von Components - // Caching für Performance - // Validierung der Component-Struktur - } - - public function isValidComponent(string $className): bool - { - // Prüft ob Klasse gültige CMS Component ist - } + // 1. cms($key) → Dateiname aus CmsContent + // 2. media_url($filename, $profile) } ``` -## Multi-Domain Unterstützung +## Key-Resolution -### Domain-Konfiguration +Der `cms()`-Aufruf `cms('welcome.hero.heading')` wird so aufgelöst: -```php -// config/domains.php -return [ - 'portal.b2in.test' => [ - 'type' => 'admin', - 'name' => 'Admin Portal', - 'theme' => 'admin', - ], - 'b2in.test' => [ - 'type' => 'web', - 'name' => 'B2in Main', - 'theme' => 'b2in', - 'colors' => [ - 'primary' => '#1f2937', - 'secondary' => '#3b82f6', - ], - ], - // weitere Domains... -]; +1. **Parsing**: `welcome` → `group`, `hero.heading` → `key` +2. **Cache-Lookup**: `flux_cms.content.welcome` (alle Einträge der Gruppe) +3. **Key-Match**: `firstWhere('key', 'hero.heading')` +4. **Übersetzung**: `getTranslation('value', app()->getLocale())` mit Fallback auf `config('app.fallback_locale')` +5. **Platzhalter**: `:highlight` → Ersetzung aus `$replace` Array +6. **Fallback**: Wenn nichts in DB → `__('welcome.hero.heading', $replace, $locale)` + +## Datenbankschema + +### flux_cms_contents + +```sql +CREATE TABLE flux_cms_contents ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `group` VARCHAR(255) NOT NULL, + `key` VARCHAR(255) NOT NULL, + type VARCHAR(255) DEFAULT 'text', -- text, html, image, json, link + value JSON NOT NULL, -- {"de": "...", "en": "..."} + `order` INT DEFAULT 0, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + UNIQUE INDEX (group, key) +); ``` -### Domain-spezifische Inhalte +### flux_cms_media -Alle Inhalte werden über `domain_key` gefiltert: +```sql +CREATE TABLE flux_cms_media ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + filename VARCHAR(255) NOT NULL, -- Original-Dateiname + path VARCHAR(512) NOT NULL, -- Storage-Pfad + type VARCHAR(50) DEFAULT 'image', -- image, pdf, document + mime_type VARCHAR(255) NULL, + file_size BIGINT UNSIGNED NULL, + original_width INT UNSIGNED NULL, + original_height INT UNSIGNED NULL, + disk VARCHAR(50) DEFAULT 'public', + collection VARCHAR(255) NULL, -- Optionale Sammlung + conversions JSON NULL, -- {"hero": "path/...", "card": "path/..."} + title JSON NULL, -- Übersetzbar + alt_text JSON NULL, -- Übersetzbar + is_published BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + INDEX (filename), + INDEX (type), + INDEX (collection) +); +``` + +### flux_cms_downloads + +```sql +CREATE TABLE flux_cms_downloads ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + title JSON NOT NULL, -- Übersetzbar + description JSON NULL, -- Übersetzbar + category VARCHAR(255) NOT NULL, -- case_study, capability, success_story + file_path JSON NOT NULL, -- Übersetzbar: {"de": "file-de.pdf", "en": "file-en.pdf"} + thumbnail VARCHAR(255) NULL, -- CmsMedia-Dateiname + icon VARCHAR(255) NULL, -- Heroicon-Name + sub_category VARCHAR(255) NULL, -- Detail-Kategorie + type_label JSON NULL, -- Übersetzbar + alt JSON NULL, -- Übersetzbar: Alt-Text + open_text JSON NULL, -- Übersetzbar: Button-Text + download_text JSON NULL, -- Übersetzbar: Button-Text + highlights JSON NULL, -- [{"value": "100%", "label": "..."}] + checkpoints JSON NULL, -- [{"value": "..."}] + is_published BOOLEAN DEFAULT TRUE, + `order` INT DEFAULT 0, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL +); +``` + +### flux_cms_news_items + +```sql +CREATE TABLE flux_cms_news_items ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + icon VARCHAR(255) NULL, + text JSON NULL, -- Ticker-Text (übersetzbar) + title JSON NULL, + excerpt JSON NULL, + content JSON NULL, -- Voller HTML-Body + image VARCHAR(255) NULL, -- CmsMedia-Dateiname + date DATE NULL, + author VARCHAR(255) NULL, + link VARCHAR(255) NULL, + pdf_path VARCHAR(255) NULL, -- CmsMedia-Dateiname (PDF) + pdf_open_text JSON NULL, + pdf_download_text JSON NULL, + is_published BOOLEAN DEFAULT TRUE, + `order` INT DEFAULT 0, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL +); +``` + +### flux_cms_industries + +```sql +CREATE TABLE flux_cms_industries ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name JSON NOT NULL, -- Übersetzbar + is_published BOOLEAN DEFAULT TRUE, + `order` INT DEFAULT 0, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL +); +``` + +### flux_cms_faqs + +```sql +CREATE TABLE flux_cms_faqs ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + category VARCHAR(255) NOT NULL, + question JSON NOT NULL, + answer JSON NOT NULL, + help JSON NULL, + is_published BOOLEAN DEFAULT TRUE, + `order` INT DEFAULT 0, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL +); +``` + +### flux_cms_linkedin_posts + +```sql +CREATE TABLE flux_cms_linkedin_posts ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + linkedin_id VARCHAR(255) NULL UNIQUE, + title JSON NULL, + excerpt JSON NULL, + content JSON NULL, + author VARCHAR(255) NULL, + date DATE NULL, + url VARCHAR(255) NULL, + image VARCHAR(255) NULL, -- CmsMedia-Dateiname + tags JSON NULL, + source VARCHAR(255) DEFAULT 'manual', + is_published BOOLEAN DEFAULT TRUE, + `order` INT DEFAULT 0, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL +); +``` + +### flux_cms_search_index + +```sql +CREATE TABLE flux_cms_search_index ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + item_id VARCHAR(255) NOT NULL UNIQUE, -- z.B. 'home', 'leistungen' + route VARCHAR(255) NOT NULL, -- Named Route + route_params JSON NULL, -- Route-Parameter + category JSON NULL, -- Übersetzbar: Kategorie + title_key VARCHAR(255) NULL, -- CMS-Key für Titel + title_fallback JSON NULL, -- Übersetzbar: Fallback-Titel + description_key VARCHAR(255) NULL, -- CMS-Key für Beschreibung + description_fallback_key VARCHAR(255) NULL, -- Sekundärer CMS-Key + description_fallback_text JSON NULL, -- Übersetzbar: Statischer Text + keywords JSON NULL, -- Übersetzbar: String-Array + is_published BOOLEAN DEFAULT TRUE, + `order` INT DEFAULT 0, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + INDEX (item_id) +); +``` + +## Admin UI — Komponenten-Architektur + +Alle Admin-Views sind **Livewire Volt Functional Components**: ```php -// Automatische Domain-Filterung -Page::forDomain(request()->getHost())->published()->get(); -BlogPost::forDomain($domainKey)->published()->get(); -Navigation::forDomain($domainKey)->active()->get(); + ...); +$save = function () { ... }; +?> + +
+ {{-- Blade/Flux UI Template --}} +
``` +### Ausnahmen: Class-based Livewire + +File-Uploads und modale Auswahl benötigen class-based Livewire-Komponenten: + +| Komponente | Grund | +|------------|-------| +| `MediaLibraryUploader` | `WithFileUploads` Trait für Multi-File Upload | +| `MediaPicker` | `WithFileUploads` Trait für Quick-Upload + Event-System | + +### MediaPicker — Event-Architektur + +``` +┌───────────────────────────────────────────┐ +│ Admin-View (Volt Functional) │ +│ state: $imageMediaId, $image │ +│ │ +│ on('media-selected', fn($field, ...)) │ +│ → setzt MediaId + Dateiname │ +│ │ +│ │ +└──────────────┬────────────────────────────┘ + │ dispatches 'media-selected' +┌──────────────▼────────────────────────────┐ +│ MediaPicker (Class-based) │ +│ - Modal mit Medienauswahl │ +│ - Filterung nach Typ │ +│ - Quick-Upload │ +│ - $this->dispatch('media-selected', ...) │ +└───────────────────────────────────────────┘ +``` + +### Medien-Speicherung in Modellen + +Alle Medien werden über ihren **CmsMedia-Dateiname** referenziert: + +``` +CmsContent (type=image) → value = "keyvisual.webp" +CmsNewsItem → image = "news-header.webp", pdf_path = "report.pdf" +CmsDownload → thumbnail = "case-study.webp", file_path = {"de": "cs-de.pdf", "en": "cs-en.pdf"} +CmsLinkedinPost → image = "linkedin-post.webp" +Team (JSON in CmsContent) → image = "person.jpg" +``` + +Die Auflösung zum tatsächlichen URL erfolgt immer über `media_url($filename, $profile)`. + +## toFrontendArray() Pattern + +Für die Frontend-Ausgabe bieten `CmsNewsItem` und `CmsDownload` eine `toFrontendArray()` Methode: + +```php +// CmsDownload::toFrontendArray() +public function toFrontendArray(): array +{ + return [ + 'title' => $this->getTranslation('title', app()->getLocale()), + 'description' => $this->getTranslation('description', app()->getLocale()), + 'image' => media_url($this->thumbnail ?? '', 'card'), + 'pdf_path' => media_url( + $this->getTranslation('file_path', app()->getLocale()) ?? '' + ), + 'highlights' => $this->highlights ?? [], + 'checkpoints' => $this->checkpoints ?? [], + 'icon' => $this->icon, + // ... weitere Felder + ]; +} +``` + +Frontend-Verwendung: + +```blade +@foreach (CmsDownload::published()->byCategory('case_study')->ordered()->get() as $download) + +@endforeach +``` + +## Bildoptimierung — MediaConversionService + +``` +┌─────────────────────────────────────────────────────┐ +│ MediaConversionService (Singleton) │ +│ │ +│ Abhängigkeiten: intervention/image v3 │ +│ │ +│ storeUploadedFile($file, $collection) │ +│ ├── Speichert Original in cms/media/originals/ │ +│ ├── Erstellt CmsMedia-Eintrag (mit Dimensionen) │ +│ ├── Generiert Thumbnail automatisch │ +│ └── Gibt CmsMedia zurück │ +│ │ +│ convert($media, $profileName) │ +│ ├── Liest Profil aus Config │ +│ ├── Resize (cover fit) via intervention/image │ +│ ├── Konvertiert Format (webp, jpg, png) │ +│ ├── Setzt Qualität │ +│ ├── Speichert in cms/media/conversions/ │ +│ └── Aktualisiert $media->conversions JSON │ +│ │ +│ generateThumbnail($media) │ +│ ├── 200x200 Cover, WebP, Q70 │ +│ ├── Speichert in cms/media/thumbnails/ │ +│ └── Setzt $media->conversions['thumb'] │ +│ │ +│ generateAllConversions($media) │ +│ └── Iteriert alle Profiles aus Config │ +└─────────────────────────────────────────────────────┘ +``` + +### Conversion-Profile + +Konfigurierbar in `config/flux-cms.php`: + +```php +'media' => [ + 'max_upload_size' => 20480, // KB + 'allowed_types' => ['jpg', 'jpeg', 'png', 'webp', 'gif', 'svg', 'pdf', 'doc', 'docx'], + 'storage_disk' => 'public', + 'profiles' => [ + 'hero' => ['width' => 1920, 'height' => 800, 'format' => 'webp', 'quality' => 85], + 'card' => ['width' => 768, 'height' => 512, 'format' => 'webp', 'quality' => 80], + 'thumbnail' => ['width' => 400, 'height' => 300, 'format' => 'webp', 'quality' => 75], + 'avatar' => ['width' => 400, 'height' => 400, 'format' => 'webp', 'quality' => 80], + 'news' => ['width' => 1200, 'height' => 630, 'format' => 'webp', 'quality' => 80], + 'thumb' => ['width' => 200, 'height' => 200, 'format' => 'webp', 'quality' => 70], + ], +], +``` + +## Content-Typ-Erkennung (Seeder) + +| Bedingung | Typ | +|-----------|-----| +| PHP-Array (assoziativ → flatten; indexed → direkt) | `json` | +| Enthält HTML-Tags (`

`, `
`, ``, etc.) | `html` | +| Dateiendung `.jpg`, `.png`, `.svg`, etc. | `image` | +| Dateiendung `.pdf`, `.doc`, etc. | `link` | +| Alles andere | `text` | + +## HTML-Bereinigung (Seeder) + +### font-semibold → `` + +```html + +Wichtig + + +Wichtig +``` + +### text-gradient-premium → :highlight Pattern + +```php +// Vorher: +'heading' => 'Wie unsere Ingenieurstrategen die drei Säulen sichern', + +// Nachher: +'heading' => ':highlight die drei Säulen sichern', +'heading_highlight' => 'Wie unsere Ingenieurstrategen', +``` + +## Caching-Strategie + +- **Content-Cache**: Pro Gruppe gecached (z.B. `flux_cms.content.welcome`) +- **media_url() Cache**: In-Memory-Cache pro Request (statische Variable) +- **TTL**: Konfigurierbar (`flux-cms.cache.ttl`, Standard: 3600s) +- **Invalidierung**: Automatisch bei Save im Admin via `CmsContentService::clearCache($group)` +- **Cache-Store**: Konfigurierbar (`flux-cms.cache.store`) +- **Seeder**: Ruft `clearCache()` nach dem Seeding auf + +## Sortierung + +- Inhalte werden in der Reihenfolge der `lang/`-Datei gespeichert (chronologisch) +- `order`-Feld wird beim Seeding sequentiell vergeben +- Industries, Downloads, News haben Up/Down-Buttons + numerisches Order-Feld +- Downloads können zusätzlich nach Kategorie gefiltert werden + ## Mehrsprachigkeit -### Spatie Translatable Integration +Alle Modelle nutzen `Spatie\Translatable\HasTranslations`: ```php -// Model Definition -class Page extends Model -{ - use HasTranslations; - - protected $translatable = [ - 'title', - 'slug', - 'content', - 'meta_title', - 'meta_description' - ]; - - protected $casts = [ - 'title' => 'array', - 'slug' => 'array', - // ... - ]; -} - -// Verwendung -$page->getTranslation('title', 'de'); -$page->setTranslation('title', 'de', 'Deutscher Titel'); +$content->getTranslation('value', 'de'); +$content->setTranslation('value', 'en', 'New value'); +$content->save(); ``` -### Component-Inhalte +Besonderheit bei `CmsDownload`: +- `file_path` ist ebenfalls übersetzbar (separates PDF pro Sprache) +- `toFrontendArray()` löst automatisch die aktuelle Locale auf -```php -// Mehrsprachige Component-Inhalte -$content = [ - 'title' => [ - 'de' => 'Deutscher Titel', - 'en' => 'English Title' - ], - 'description' => [ - 'de' => 'Deutsche Beschreibung', - 'en' => 'English Description' - ] -]; -``` +Im Admin kann zwischen Sprachen gewechselt werden via `switchLocale()`. -## Versionierung +## Infrastruktur-Hinweise -### Page Versions +### HTTPS / Proxy +- `trustProxies(at: '*')` in `bootstrap/app.php` für korrekte URL-Generierung +- `URL::forceScheme('https')` in `AppServiceProvider` -```php -class Page extends Model -{ - public function createVersion(string $changeDescription = null): PageVersion - { - return $this->versions()->create([ - 'page_data' => $this->toArray(), - 'components_data' => $this->allComponents()->get()->toArray(), - 'change_description' => $changeDescription, - 'version_name' => $this->generateVersionName(), - ]); - } - - public function restoreVersion(PageVersion $version): void - { - // Backup current version - $this->createVersion('Backup before restoration'); - - // Restore page data - $this->update($version->page_data); - - // Restore components - $this->allComponents()->delete(); - foreach ($version->components_data as $componentData) { - $this->allComponents()->create($componentData); - } - } -} -``` - -## Medien-Management - -### Spatie Media Library Integration - -```php -// Models implementieren HasMedia Interface -class BlogPost extends Model implements HasMedia -{ - use InteractsWithMedia; - - public function registerMediaCollections(): void - { - $this->addMediaCollection('featured_image') - ->singleFile() - ->acceptsMimeTypes(['image/jpeg', 'image/png']); - - $this->addMediaCollection('gallery') - ->acceptsMimeTypes(['image/jpeg', 'image/png']); - } - - public function registerMediaConversions(Media $media = null): void - { - $this->addMediaConversion('thumb') - ->width(300) - ->height(200); - - $this->addMediaConversion('hero') - ->width(1200) - ->height(600); - } -} -``` - -## SEO & Performance - -### SEO Features - -```php -class Page extends Model -{ - public function getSeoTitle(string $locale = null): string - { - return $this->getTranslation('meta_title', $locale) - ?? $this->getTranslation('title', $locale); - } - - public function getSeoDescription(string $locale = null): string - { - return $this->getTranslation('meta_description', $locale) - ?? str_limit(strip_tags($this->getTranslation('content', $locale)), 160); - } - - public function getCanonicalUrl(): string - { - return route('pages.show', ['slug' => $this->slug]); - } -} -``` - -### Caching Strategy - -```php -// ComponentRegistry Caching -Cache::remember('flux_cms_components', 3600, function () { - return $this->scanForComponents(); -}); - -// Page Caching -Cache::tags(['pages', 'page-'.$page->id]) - ->remember('page-'.$page->id, 3600, function () use ($page) { - return $page->with('components')->first(); - }); -``` - -## Security - -### Authorization Gates - -```php -// FluxCmsServiceProvider -Gate::define('manage-flux-cms', function ($user) { - return $user->hasRole('cms-manager'); -}); - -Gate::define('edit-page', function ($user, Page $page) { - return $user->can('manage-flux-cms') && - $page->domain_key === request()->getHost(); -}); -``` - -### Input Validation - -```php -// Component Content Validation -public function getValidationRules(): array -{ - $rules = []; - foreach ($this->getCmsFields() as $field) { - $rules = array_merge($rules, $field->getValidationRules()); - } - return $rules; -} -``` - -## Testing Strategy - -Das Projekt verfolgt eine umfassende Teststrategie, die Unit-, Feature- und Browser-Tests kombiniert, um eine hohe Codequalität und Stabilität zu gewährleisten. - -### Unit-Tests -- **Fokus:** Isolierte Klassen und Methoden. -- **Ziele:** Korrektheit von Model-Beziehungen, Scopes, Business-Logik in Services und die Funktionalität der Field Types sicherstellen. -- **Beispiele:** - - `PageTest`: Überprüft Scopes wie `published()` und Beziehungen wie `slugs()`. - - `BlogPostTest`: Testet die polymorphe `author()`-Beziehung und die Tag-Funktionalität. - -### Feature-Tests -- **Fokus:** Komplette Anwendungs-Features über HTTP-Anfragen. -- **Ziele:** Korrektheit von CRUD-Operationen, Routen, Controller-Logik, Validierung und Autorisierung testen. -- **Beispiele:** - - `PageControllerTest`: Simuliert das Erstellen, Bearbeiten und Löschen von Seiten, inklusive Tests für Validierungsfehler und Zugriffsrechte. - - `BlogControllerTest`: Stellt sicher, dass die Blog-Verwaltung wie erwartet funktioniert. - -### Browser-Tests (Laravel Dusk) -- **Fokus:** Echte Benutzerinteraktionen in einem Browser. -- **Ziele:** Klick-Pfade, UI-Komponenten, JavaScript-Interaktionen und komplette Workflows im Admin-Panel testen. -- **Beispiele:** - - `LoginTest`: Simuliert den Admin-Login. - - Zukünftige Tests: Erstellen einer Seite mit Komponenten via Drag & Drop, Verwalten von Medien, etc. - -## Deployment & Configuration - -### Package Installation - -```bash -# Core Package -composer require flux-cms/core - -# Components Package -composer require flux-cms/components - -# Starter Components -composer require flux-cms/starter-components - -# Installation -php artisan flux-cms:install -``` - -### Konfiguration - -```php -// config/flux-cms.php -return [ - 'domains' => [ - 'auto_discovery' => true, - 'cache_timeout' => 3600, - ], - 'components' => [ - 'scan_paths' => [ - 'app/Livewire/Components', - 'vendor/flux-cms/starter-components/src/Components', - ], - 'cache_enabled' => true, - ], - 'media' => [ - 'disk' => 'public', - 'conversions' => [ - 'thumb' => [300, 200], - 'hero' => [1200, 600], - ], - ], - 'seo' => [ - 'auto_sitemap' => true, - 'meta_defaults' => [ - 'title_suffix' => ' | ' . config('app.name'), - ], - ], -]; -``` - -## Erweiterbarkeit - -### Custom Field Types - -```php -translatable(), - ColorPickerField::make('bg_color', 'Hintergrundfarbe')->default('#ffffff'), - ]; - } - - // Implementation... -} -``` - -## Best Practices - -### 1. **Component Design** -- Kleine, wiederverwendbare Components -- Klare Field-Definitionen -- Responsive Design -- Accessibility beachten - -### 2. **Performance** -- Eager Loading von Relationships -- Caching strategisch einsetzen -- Lazy Loading für große Listen -- Image Optimierung - -### 3. **SEO** -- Strukturierte Daten (JSON-LD) -- Optimierte Meta Tags -- Saubere URL-Struktur -- Sitemap-Generation - -### 4. **Security** -- Input Validation -- Authorization Gates -- CSRF Protection -- XSS Prevention - -### 5. **Wartbarkeit** -- Klare Namenskonventionen -- Dokumentierte APIs -- Typisierte Interfaces -- Umfassende Tests - -## Roadmap - -### Status Update (Dezember 2024) -Das Flux CMS Package hat einen Vollständigkeitsgrad von **95%** erreicht und ist **produktionsreif**. Die Grundarchitektur ist vollständig implementiert mit umfassender Test-Suite, Admin-Interface und Component-System. - -### Priorität 1 (Kurzfristig - Q1 2025) -- [ ] **RESTful API-Endpoints:** Implementierung von API-Controllern für Headless CMS-Funktionalität - - `Api/PageController.php` - Seiten-Management via API - - `Api/BlogController.php` - Blog-Management via API - - `Api/MediaController.php` - Medien-Management via API - - `Api/ComponentController.php` - Component-Management via API -- [ ] **Erweiterte Starter Components:** Vervollständigung der Component-Bibliothek - - `TextSection.php` - Einfache Text-Bereiche - - `ImageGallery.php` - Bildergalerien - - `ContactForm.php` - Kontaktformulare - - `TestimonialSection.php` - Testimonials - - `PricingTable.php` - Preistabellen -- [ ] **Dashboard Widgets:** Erweiterbares Dashboard-System - - `RecentPagesWidget.php` - Zuletzt bearbeitete Seiten - - `AnalyticsWidget.php` - Besucherstatistiken - - `MediaUsageWidget.php` - Medienverwendung - - `ComponentStatsWidget.php` - Component-Statistiken - -### Priorität 2 (Mittelfristig - Q2 2025) -- [ ] **Content Scheduling:** Vorausplanung von Inhalten - - `ScheduledPublishing.php` - Automatisches Veröffentlichen - - `ContentCalendar.php` - Content-Kalender - - `Auto-Unpublish.php` - Automatisches Archivieren -- [ ] **Advanced Analytics:** Erweiterte Analyse-Features - - Integration mit Google Analytics - - Content-Performance-Tracking - - User-Engagement-Metriken -- [ ] **Performance Monitoring:** System-Überwachung - - Query-Performance-Monitoring - - Cache-Hit-Rate-Tracking - - Component-Load-Time-Analyse - -### Priorität 3 (Langfristig - Q3-Q4 2025) -- [ ] **A/B Testing Framework:** Content-Optimierung - - `VariantManagement.php` - Varianten-Verwaltung - - `AnalyticsIntegration.php` - Conversion-Tracking - - `ConversionTracking.php` - Ziel-Tracking -- [ ] **AI-powered Content Suggestions:** Intelligente Inhaltsvorschläge - - Automatische SEO-Optimierung - - Content-Gap-Analyse - - Intelligente Tag-Vorschläge -- [ ] **GraphQL API:** Moderne API-Technologie - - Vollständige GraphQL-Implementierung - - Real-time Subscriptions - - Schema-First Development - -### Bereits implementiert ✅ -- [x] **Umfassende Test-Suite:** Feature- und Unit-Tests für alle Komponenten und Services -- [x] **Browser-Tests:** Laravel Dusk-Tests für kritische Admin-Workflows -- [x] **Tagging-UI für Blog Posts:** Bearbeiten von Tags pro Beitrag implementiert -- [x] **Slug-System:** Polymorphe Slugs für mehrsprachige URLs -- [x] **MakeComponentCommand:** Artisan Command für Component-Erstellung -- [x] **Admin-Controller:** Vollständige CRUD-Operationen für alle Entitäten -- [x] **Livewire Components:** Backend und Frontend Components -- [x] **Multi-Domain Support:** Vollständige Domain-Verwaltung -- [x] **Versionierung:** Page-Version-Management -- [x] **Media Management:** Spatie Media Library Integration - -### Technische Verbesserungen -- [ ] **Statische Code-Analyse:** Integration von PHPStan Level 8 -- [ ] **CI/CD Pipeline:** Automatisierte Tests und Deployment -- [ ] **Documentation:** API-Dokumentation mit OpenAPI/Swagger -- [ ] **Performance Optimization:** Advanced Caching-Strategien -- [ ] **Security Hardening:** Penetration Testing und Security Audit - -### Community & Ecosystem -- [ ] **Plugin-System:** Erweiterbares Plugin-Framework -- [ ] **Theme-System:** Vollständiges Theme-Management -- [ ] **Marketplace:** Component- und Theme-Marketplace -- [ ] **Developer Tools:** CLI-Tools für Entwickler -- [ ] **Migration Tools:** Import/Export-Funktionalität +### File-Uploads +- `flux:file-upload` Komponente (Flux UI) für Livewire-Uploads +- Maximale Upload-Größe konfigurierbar in `config/flux-cms.php` +- Erlaubte MIME-Types in Validation-Rules der Upload-Komponenten diff --git a/packages/flux-cms/INSTALLATION.md b/packages/flux-cms/INSTALLATION.md index f2934ba..1d16ed5 100644 --- a/packages/flux-cms/INSTALLATION.md +++ b/packages/flux-cms/INSTALLATION.md @@ -1,597 +1,14 @@ -# Flux CMS Installation Guide +# Installation -This guide will walk you through installing and setting up Flux CMS in your Laravel application. +> **Hinweis:** Diese Datei beschreibt das Legacy-Installationsverfahren (Page/Component-basiertes CMS). +> Für die aktuelle Content-basierte Implementierung siehe **[SETUP.md](./SETUP.md)**. -## System Requirements +Die aktuelle Version von Flux CMS arbeitet mit einem Key-Value Content Store und spezialisierten Models +(News, Industries, FAQs, Downloads, LinkedIn Posts, Team, Suchindex). Die vollständige Dokumentation +findest du in: -- **PHP**: 8.2 or higher -- **Laravel**: 11.0 or higher -- **Livewire**: 3.0 or higher -- **Database**: MySQL 8.0+ or PostgreSQL 13+ -- **Node.js**: 18+ (for asset compilation) - -## Required Laravel Packages - -Flux CMS requires these packages to be installed: - -- `spatie/laravel-translatable`: For multilingual content -- `spatie/laravel-medialibrary`: For media management -- `spatie/laravel-tags`: For tagging blog posts -- `livewire/livewire`: For reactive components -- `livewire/flux`: For UI components (recommended) - -## Step-by-Step Installation - -### 1. Install Required Dependencies - -```bash -# Install required packages -composer require spatie/laravel-translatable spatie/laravel-medialibrary spatie/laravel-tags - -# Install Livewire if not already installed -composer require livewire/livewire - -# Install Flux UI (recommended for admin interface) -composer require livewire/flux -``` - -### 2. Install Flux CMS Packages - -```bash -# Install core package -composer require flux-cms/core - -# Install components package (recommended) -composer require flux-cms/components - -# Install starter components (optional) -composer require flux-cms/starter-components -``` - -### 3. Run Installation Command - -```bash -# This will publish config, run migrations, and set up permissions -php artisan flux-cms:install - -# Or with options -php artisan flux-cms:install --no-migrate --no-publish -``` - -The installation command will: -- ✅ Check system requirements -- 📦 Publish configuration files -- 🗃️ Run database migrations -- 🔗 Create storage link -- 📝 Create sample content (optional) -- 🔐 Set up permissions (optional) - -### 4. Configure Your Application - -#### Environment Variables - -Add these to your `.env` file: - -```env -# Flux CMS Configuration -FLUX_CMS_DEFAULT_LOCALE=de -FLUX_CMS_CACHE_ENABLED=true -FLUX_CMS_ROUTES_ENABLED=true - -# Media Configuration -FLUX_CMS_MEDIA_DISK=public -FLUX_CMS_MAX_FILE_SIZE=10240 - -# Multi-domain (if using) -FLUX_CMS_MULTI_DOMAIN=true -FLUX_CMS_AUTO_DETECT_DOMAIN=true -``` - -#### Update App Configuration - -```php -// config/app.php -'locale' => env('APP_LOCALE', 'de'), -'fallback_locale' => 'de', - -// Add available locales -'available_locales' => ['de', 'en'], -``` - -### 5. Set Up Routes - -#### Admin Routes - -Add to your `routes/web.php` or create `routes/admin.php`: - -```php -// Admin routes (protected) -Route::middleware(['web', 'auth'])->prefix('admin')->name('admin.')->group(function () { - Route::prefix('cms')->name('cms.')->group(function () { - Route::get('/', [Admin\DashboardController::class, 'index'])->name('index'); - Route::resource('/pages', Admin\PageController::class)->except(['show']); - Route::get('/blog', [Admin\BlogController::class, 'index'])->name('blog.index'); - Route::get('/media', [Admin\MediaController::class, 'index'])->name('media.index'); - Route::get('/navigation', [Admin\NavigationController::class, 'index'])->name('navigation.index'); - }); -}); -``` - -#### Frontend Routes - -Add to your `routes/web.php` (MUST be at the end): - -```php -// SEO routes -Route::get('/sitemap.xml', [PageController::class, 'sitemap'])->name('sitemap'); -Route::get('/robots.txt', [PageController::class, 'robots'])->name('robots'); - -// Blog routes (if using blog) -Route::prefix('blog')->name('blog.')->group(function () { - Route::get('/', [PageController::class, 'blogIndex'])->name('index'); - Route::get('/{slug}', [PageController::class, 'blogPost'])->name('show'); -}); - -// CMS pages - MUST BE LAST! -Route::get('/{slug?}', [PageController::class, 'show']) - ->where('slug', '.*') - ->name('cms.page'); -``` - -### 6. Create Controllers - -#### Page Controller - -```php -getCurrentDomainKey($request); - $locale = app()->getLocale(); - - // Find page - $page = Page::forDomain($domainKey) - ->bySlug($slug, $locale) - ->published() - ->firstOrFail(); - - // Load components - $components = $page->components()->get(); - - return view('pages.show', compact('page', 'components')); - } - - public function sitemap(Request $request) - { - $domainKey = $this->getCurrentDomainKey($request); - $pages = Page::forDomain($domainKey)->published()->get(); - - return response()->view('sitemap', compact('pages')) - ->header('Content-Type', 'application/xml'); - } - - protected function getCurrentDomainKey(Request $request): string - { - $host = $request->getHost(); - $domains = config('domains.domains', []); - - foreach ($domains as $key => $config) { - if (isset($config['url']) && parse_url($config['url'], PHP_URL_HOST) === $host) { - return $key; - } - } - - return config('flux-cms.domains.default_domain', 'default'); - } -} -``` - -#### Admin Controller - -```php -middleware('can:flux-cms.view'); - } - - public function index() - { - $stats = [ - 'pages' => Page::count(), - 'published_pages' => Page::published()->count(), - 'draft_pages' => Page::where('is_published', false)->count(), - ]; - - return view('admin.cms.index', compact('stats')); - } - - public function pages() - { - $pages = Page::with(['components']) - ->orderBy('updated_at', 'desc') - ->paginate(20); - - return view('admin.cms.pages', compact('pages')); - } - - public function editPage(Page $page) - { - $this->authorize('flux-cms.edit'); - - return view('admin.cms.edit-page', compact('page')); - } -} -``` - -### 7. Create Views - -#### Frontend Page Template - -```blade -{{-- resources/views/pages/show.blade.php --}} -@extends('layouts.app') - -@section('title', $page->getSeoTitle()) -@section('description', $page->getSeoDescription()) - -@push('meta') - - - - @if($page->getTranslation('og_image')) - - @endif -@endpush - -@section('content') -

- @foreach($components as $component) - @if($component->canRender()) -
- @livewire($component->component_class, [ - 'content' => $component->getTranslations('content') - ], key('component-' . $component->id)) -
- @endif - @endforeach -
-@endsection -``` - -#### Admin Views - -```blade -{{-- resources/views/admin/cms/edit-page.blade.php --}} -@extends('layouts.admin') - -@section('title', 'Edit Page: ' . $page->getTranslation('title')) - -@section('content') -
- @livewire('flux-cms::page-editor', ['page' => $page]) -
-@endsection - -@push('scripts') - -@endpush -``` - -### 8. Set Up Permissions - -If you're using Spatie Laravel Permission: - -```php -// database/seeders/CmsPermissionSeeder.php - $permission]); - } - - // Create CMS role - $cmsRole = Role::firstOrCreate(['name' => 'flux-cms']); - $cmsRole->syncPermissions($permissions); - - // Assign to users - // User::find(1)->assignRole('flux-cms'); - } -} -``` - -Run the seeder: - -```bash -php artisan db:seed --class=CmsPermissionSeeder -``` - -### 9. Configure Multi-Domain (Optional) - -If you're using multi-domain setup, ensure your `config/domains.php` is configured: - -```php -// config/domains.php -return [ - 'domains' => [ - 'main' => [ - 'name' => 'Main Site', - 'url' => env('APP_URL', 'https://example.com'), - 'theme' => 'default', - ], - 'blog' => [ - 'name' => 'Blog Site', - 'url' => 'https://blog.example.com', - 'theme' => 'blog', - ], - ], -]; -``` - -Update Flux CMS config: - -```php -// config/flux-cms.php -'domains' => [ - 'enabled' => true, - 'config_source' => 'domains', // Use domains.php config - 'auto_detect' => true, -], -``` - -### 10. Create Your First Component - -Generate a new component: - -```bash -php artisan make:livewire Components/WelcomeHero -``` - -Update the component: - -```php -content = $content; - } - - public static function getCmsName(): string - { - return 'Welcome Hero'; - } - - public static function getCmsCategory(): string - { - return 'Content'; - } - - public static function getCmsFields(): array - { - return [ - TextField::make('headline', 'Headline') - ->translatable() - ->required(), - - WysiwygField::make('description', 'Description') - ->translatable(), - ]; - } - - public function render() - { - return view('livewire.components.welcome-hero'); - } -} -``` - -Create the view: - -```blade -{{-- resources/views/livewire/components/welcome-hero.blade.php --}} -
-
- @if($headline = $this->content['headline'][app()->getLocale()] ?? '') -

{{ $headline }}

- @endif - - @if($description = $this->content['description'][app()->getLocale()] ?? '') -
- {!! $description !!} -
- @endif -
-
-``` - -### 11. Create Your First Page - -```php -// database/seeders/CmsContentSeeder.php - 'main', - 'title' => [ - 'de' => 'Startseite', - 'en' => 'Homepage' - ], - 'slug' => [ - 'de' => '/', - 'en' => '/' - ], - 'meta_description' => [ - 'de' => 'Willkommen auf unserer Website', - 'en' => 'Welcome to our website' - ], - 'is_published' => true, - ]); - - // Add welcome hero component - $homepage->allComponents()->create([ - 'component_class' => \App\Livewire\Components\WelcomeHero::class, - 'order' => 1, - 'content' => [ - 'headline' => [ - 'de' => 'Willkommen bei Flux CMS', - 'en' => 'Welcome to Flux CMS' - ], - 'description' => [ - 'de' => 'Das moderne Content Management System für Laravel.', - 'en' => 'The modern Content Management System for Laravel.' - ] - ], - ]); - } -} -``` - -Run the seeder: - -```bash -php artisan db:seed --class=CmsContentSeeder -``` - -## Verification - -After installation, verify everything works: - -1. **Visit admin interface**: `/admin/cms` -2. **Edit a page**: Click on your homepage -3. **Add components**: Try adding components to your page -4. **Check frontend**: Visit your homepage -5. **Test translations**: Switch languages if configured - -## Troubleshooting - -### Common Issues - -#### 1. Component Registry Empty - -```bash -# Clear cache and refresh -php artisan flux-cms:clear-cache -php artisan cache:clear -``` - -#### 2. Permission Denied - -```bash -# Check if user has CMS role -php artisan tinker -> User::find(1)->assignRole('flux-cms'); -``` - -#### 3. Media Files Not Loading - -```bash -# Ensure storage link exists -php artisan storage:link - -# Check disk configuration -php artisan tinker -> Storage::disk('public')->exists('test.txt'); -``` - -#### 4. Routes Not Working - -- Ensure CMS routes are at the **end** of `routes/web.php` -- Check middleware and permissions -- Verify domain configuration if using multi-domain - -### Debug Mode - -Enable debug mode in config: - -```php -// config/flux-cms.php -'development' => [ - 'debug_mode' => true, - 'show_component_info' => true, - 'log_queries' => true, -], -``` - -### Getting Help - -- 📖 **Documentation**: Check the full documentation -- 💬 **Community**: Join GitHub Discussions -- 🐛 **Issues**: Report bugs on GitHub -- 📧 **Support**: Contact support team - -## Next Steps - -1. **Create custom components** for your specific needs -2. **Set up navigation** using the navigation manager -3. **Configure domains** if using multi-domain -4. **Customize styling** to match your brand -5. **Set up deployment** with proper caching - -You're now ready to start building amazing content with Flux CMS! 🚀 +- **[README.md](./README.md)** — Überblick, Features, alle Modelle, Helper-Funktionen +- **[SETUP.md](./SETUP.md)** — Schritt-für-Schritt Installationsanleitung (Erstinstallation) +- **[MIGRATION.md](./MIGRATION.md)** — Migration in ein neues Projekt (Checkliste) +- **[ARCHITECTURE.md](./ARCHITECTURE.md)** — Technische Details, Datenbankschema, Datenfluss +- **[README-FILE-UPLOAD.md](./README-FILE-UPLOAD.md)** — Medienbibliothek und Bildoptimierung diff --git a/packages/flux-cms/MIGRATION.md b/packages/flux-cms/MIGRATION.md new file mode 100644 index 0000000..20e3b10 --- /dev/null +++ b/packages/flux-cms/MIGRATION.md @@ -0,0 +1,264 @@ +# Flux CMS — Migration in ein neues Projekt + +Diese Checkliste führt dich durch alle Schritte, um Flux CMS aus einem bestehenden Projekt in ein neues Laravel-Projekt zu migrieren. + +> **Voraussetzungen:** Das neue Projekt benötigt Laravel 11+, Livewire 3, Volt, Flux UI (Free oder Pro) und Tailwind CSS v4. + +--- + +## Schritt 1 — Package-Verzeichnis kopieren + +```bash +# Das gesamte Package-Verzeichnis ins neue Projekt kopieren +cp -r altes-projekt/package/ neues-projekt/package/ +``` + +--- + +## Schritt 2 — composer.json anpassen + +```json +{ + "repositories": [ + { "type": "path", "url": "package/flux-cms/core" } + ], + "require": { + "flux-cms/core": "@dev", + "intervention/image": "^3.0", + "blade-ui-kit/blade-heroicons": "^2.0" + }, + "autoload": { + "files": ["app/helpers.php"] + } +} +``` + +```bash +composer update +``` + +--- + +## Schritt 3 — Konfiguration und Migrations + +```bash +# Config publizieren +php artisan vendor:publish --tag=flux-cms-config + +# Migrations ausführen (erstellt alle flux_cms_* Tabellen) +php artisan migrate +``` + +**Hinweis:** Falls das Quell-Projekt zusätzliche Migrations enthält (z.B. `add_detail_columns_to_flux_cms_downloads_table`), diese ebenfalls kopieren: + +```bash +cp altes-projekt/database/migrations/*flux_cms*.php neues-projekt/database/migrations/ +php artisan migrate +``` + +--- + +## Schritt 4 — Helper-Funktionen einrichten + +Datei `app/helpers.php` erstellen (vollständiger Inhalt in [SETUP.md](SETUP.md#21-datei-erstellen)). + +```bash +composer dump-autoload +``` + +--- + +## Schritt 5 — Livewire-Komponenten kopieren + +```bash +mkdir -p app/Livewire/Admin/Cms + +# Aus Package-Referenz kopieren +cp package/flux-cms/core/src/Helpers/MediaLibraryUploader.php app/Livewire/Admin/Cms/ +cp package/flux-cms/core/src/Helpers/MediaPicker.php app/Livewire/Admin/Cms/ +cp package/flux-cms/core/src/Helpers/MediaUploader.php app/Livewire/Admin/Cms/ +``` + +Namespace in allen drei Dateien anpassen: +```php +namespace App\Livewire\Admin\Cms; +``` + +--- + +## Schritt 6 — Admin-Views einrichten + +```bash +mkdir -p resources/views/livewire/admin/cms +mkdir -p resources/views/components/layouts + +# Alle Admin-Views kopieren +cp package/flux-cms/core/resources/views/admin-reference/cms/*.blade.php \ + resources/views/livewire/admin/cms/ + +# Admin-Layout kopieren +cp package/flux-cms/core/resources/views/admin-reference/layout-cms.blade.php \ + resources/views/components/layouts/cms.blade.php +``` + +**Anpassen:** +- `resources/views/components/layouts/cms.blade.php`: Branding/Logo anpassen +- `route('dashboard')` im Layout ggf. anpassen (Redirect nach Login) + +--- + +## Schritt 7 — Routes registrieren + +In `routes/web.php`: + +```php +use Livewire\Volt\Volt; + +Route::middleware(['auth'])->group(function () { + Volt::route('admin/cms', 'admin.cms.dashboard')->name('cms.dashboard'); + Volt::route('admin/cms/content', 'admin.cms.content-index')->name('cms.content.index'); + Volt::route('admin/cms/media', 'admin.cms.media-index')->name('cms.media.index'); + Volt::route('admin/cms/news', 'admin.cms.news-index')->name('cms.news.index'); + Volt::route('admin/cms/industries', 'admin.cms.industries-index')->name('cms.industries.index'); + Volt::route('admin/cms/faqs', 'admin.cms.faqs-index')->name('cms.faqs.index'); + Volt::route('admin/cms/linkedin', 'admin.cms.linkedin-index')->name('cms.linkedin.index'); + Volt::route('admin/cms/downloads', 'admin.cms.downloads-index')->name('cms.downloads.index'); + Volt::route('admin/cms/team', 'admin.cms.team-index')->name('cms.team.index'); + Volt::route('admin/cms/search-index', 'admin.cms.search-index')->name('cms.search-index'); +}); +``` + +--- + +## Schritt 8 — Storage-Link erstellen + +```bash +php artisan storage:link +``` + +--- + +## Schritt 9 — Seeders kopieren (optional) + +Falls Inhalte aus dem alten Projekt übernommen werden sollen: + +```bash +# Referenz-Seeders als Ausgangspunkt +cp package/flux-cms/core/database/seeders-reference/*.php database/seeders/ +``` + +Die Seeders müssen für das neue Projekt angepasst werden (andere Inhalte, andere Bilder). +Ausführungsreihenfolge in `DatabaseSeeder`: + +```php +$this->call([ + CmsContentSeeder::class, + CmsMediaSeeder::class, + CmsNewsItemSeeder::class, + CmsIndustrySeeder::class, + CmsFaqSeeder::class, + CmsLinkedinPostSeeder::class, + CmsDownloadSeeder::class, + CmsSearchIndexSeeder::class, +]); +``` + +--- + +## Schritt 10 — Medien migrieren (optional) + +Falls Medien aus dem alten Projekt übernommen werden sollen: + +```bash +# Storage-Verzeichnis kopieren +cp -r altes-projekt/storage/app/public/cms/ neues-projekt/storage/app/public/cms/ +``` + +Dann den `CmsMediaSeeder` ausführen, um die DB-Einträge wiederherzustellen: + +```bash +php artisan db:seed --class=CmsMediaSeeder +``` + +--- + +## Schritt 11 — Vite Build + +```bash +npm install +npm run build +# oder für Entwicklung: +npm run dev +``` + +--- + +## Schritt 12 — Tests einrichten (optional) + +```bash +# Referenz-Tests kopieren +cp -r package/flux-cms/core/tests-reference/Feature/Cms tests/Feature/Cms + +# Tests ausführen +php artisan test --filter=Cms +``` + +--- + +## Checkliste + +- [ ] Package-Verzeichnis kopiert (`package/flux-cms/`) +- [ ] `composer.json` angepasst (Repository, Require, Autoload) +- [ ] `composer update` ausgeführt +- [ ] Config publiziert (`vendor:publish --tag=flux-cms-config`) +- [ ] Migrations ausgeführt (inkl. projektspezifische) +- [ ] `app/helpers.php` erstellt +- [ ] `composer dump-autoload` ausgeführt +- [ ] Livewire-Komponenten kopiert + Namespace angepasst +- [ ] Admin-Views kopiert +- [ ] Layout angepasst (Branding, Route-Namen) +- [ ] Routes registriert +- [ ] `storage:link` ausgeführt +- [ ] Seeders angepasst und ausgeführt (optional) +- [ ] Medien migriert (optional) +- [ ] Vite Build ausgeführt +- [ ] Admin-Login testen: `/admin/cms` + +--- + +## Häufige Probleme + +### "View not found" für `livewire.admin.cms.*` +→ Prüfen ob Volt-Mount-Path `resources/views/livewire/` in `VoltServiceProvider` registriert ist. + +### Bilder werden nicht angezeigt +→ `php artisan storage:link` ausführen. Prüfen ob `APP_URL` in `.env` korrekt gesetzt ist. + +### "Route [cms.dashboard] not defined" +→ Sicherstellen dass alle CMS-Routes in `routes/web.php` registriert sind. + +### Bilder hinter HTTPS-Proxy haben falsche URLs +→ In `bootstrap/app.php`: +```php +->withMiddleware(function (Middleware $middleware) { + $middleware->trustProxies(at: '*'); +}) +``` +Und in `AppServiceProvider::boot()`: +```php +if (request()->header('X-Forwarded-Proto') === 'https') { + URL::forceScheme('https'); +} +``` + +### Flux UI `` fehlt +→ Das Layout `resources/views/components/layouts/cms.blade.php` muss `` enthalten (bereits im Referenz-Layout vorhanden). + +--- + +## Weiterführende Dokumentation + +- [README.md](README.md) — Überblick, alle Features, Helper-Funktionen +- [SETUP.md](SETUP.md) — Detaillierte Schritt-für-Schritt-Anleitung +- [ARCHITECTURE.md](ARCHITECTURE.md) — Datenbankschema, Datenfluss, Komponenten-Architektur +- [README-FILE-UPLOAD.md](README-FILE-UPLOAD.md) — Medienbibliothek im Detail diff --git a/packages/flux-cms/README-FILE-UPLOAD.md b/packages/flux-cms/README-FILE-UPLOAD.md new file mode 100644 index 0000000..4d0cc87 --- /dev/null +++ b/packages/flux-cms/README-FILE-UPLOAD.md @@ -0,0 +1,458 @@ +# File Upload mit Livewire Volt + Flux UI + +Vollständige Referenz für den Bild-Upload, wie er in `resources/views/livewire/products/form-teaser.blade.php` implementiert ist. + +--- + +## Überblick + +Der Upload nutzt: +- **Livewire `WithFileUploads`** – verwaltet temporäre Uploads via signierter URL +- **Flux UI `flux:file-upload`** – UI-Komponente (Dropzone + Vorschau) +- **Laravel `Storage::disk('public')`** – Permanente Speicherung +- **Polymorphe `media`-Tabelle** – Zuordnung von Dateien zu beliebigen Models +- **Alpine.js** – Drag-&-Drop-Sortierung der vorhandenen Bilder + +--- + +## 1. PHP / Livewire Volt – Komponentenlogik + +### Trait einbinden + +```php +use Livewire\WithFileUploads; + +new class extends Component { + use WithFileUploads; + + /** @var array<\Livewire\Features\SupportFileUploads\TemporaryUploadedFile> */ + public array $mainImages = []; +``` + +`WithFileUploads` muss zwingend eingebunden sein. Ohne ihn reagiert `wire:model` nicht auf Datei-Inputs. + +### Validierung + +```php +'mainImages' => 'nullable|array|min:0|max:10', +'mainImages.*' => 'mimes:jpeg,jpg,png|max:10240', +``` + +- `mainImages` ist ein **Array** (wegen `multiple`-Upload) +- `mainImages.*` validiert jede einzelne Datei +- `max:10240` = 10 MB in Kilobyte +- Livewire hat intern ein Default-Limit von 12 MB – das greift, bevor die eigene Validierung läuft (Achtung: Wert muss ≤ 12288 KB sein oder `livewire.temporary_file_upload.rules` anpassen) + +### Einzelnes Bild entfernen (Vorschauliste) + +```php +public function removePhoto(int $index): void +{ + if (isset($this->mainImages[$index])) { + unset($this->mainImages[$index]); + $this->mainImages = array_values($this->mainImages); + } +} +``` + +Nach `unset()` unbedingt `array_values()` aufrufen, damit die Array-Indizes wieder bei 0 beginnen – sonst bricht `@foreach` mit `$index` im Template. + +### Vorhandenes Bild aus der DB löschen + +```php +public function removeExistingMedia(int $mediaId): void +{ + $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(); + } +} +``` + +Immer erst die **Datei** vom Disk löschen, dann den **DB-Eintrag**. Anschließend `$this->existingMedia` synchronisieren, damit Livewire den State neu rendert. + +### Reihenfolge aktualisieren (Drag & Drop) + +```php +public function updateMediaOrder(array $orderedIds): void +{ + foreach ($orderedIds as $position => $mediaId) { + $this->product->media() + ->where('id', $mediaId) + ->update(['order_column' => $position + 1]); + } + + // Lokalen State synchronisieren + $this->existingMedia = collect($orderedIds) + ->map(fn ($id, $index) => collect($this->existingMedia)->firstWhere('id', $id) + ? array_merge(collect($this->existingMedia)->firstWhere('id', $id), ['order_column' => $index + 1]) + : null + ) + ->filter() + ->values() + ->toArray(); +} +``` + +### Bilder permanent speichern (Neu-Anlage) + +```php +$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++, + ]); +} +``` + +`$image->store(...)` verschiebt die temporäre Livewire-Datei in den endgültigen Pfad auf dem `public`-Disk. + +### Bilder permanent speichern (Bearbeiten – neue Bilder hinzufügen) + +```php +$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++, + ]); +} +``` + +`order_column` an bestehenden Max-Wert anhängen, nicht von 1 neu beginnen. + +### Nach dem Speichern zurücksetzen + +```php +// Neue Bilder leeren +$this->mainImages = []; + +// Vorhandene Bilder aus DB neu laden (mit sortBy) +$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(); +``` + +--- + +## 2. Blade / Flux UI – Template + +### Upload-Dropzone + +```blade + + + +``` + +- `wire:model="mainImages"` – bindet an das Array-Property +- `multiple` – erlaubt Mehrfachauswahl +- `accept` – schränkt den Browser-Dateidialog ein (kein serverseitiger Schutz!) +- `with-progress` – zeigt Upload-Fortschrittsbalken + +### Vorschauliste der neu hinzugefügten Bilder + +```blade +@if (isset($mainImages) && count($mainImages) > 0) +
+ @foreach ($mainImages as $index => $image) + + + + + + @endforeach +
+@endif +``` + +**Wichtig bei `temporaryUrl()`:** +Die Methode gibt nur dann eine URL zurück, wenn die Datei auch vorschaubar ist (`isPreviewable()` prüft die MIME-Type-Whitelist in `config/livewire.php`). Immer beide Bedingungen prüfen, sonst Fehler. + +### Fehleranzeige + +```blade + +``` + +Zeigt Validierungsfehler für das gesamte Array an (z. B. „Maximal 10 Bilder erlaubt."). +Für Fehler auf einzelnen Dateien würde `name="mainImages.0"` etc. verwendet. + +### Drag-&-Drop-Sortierung vorhandener Bilder + +```blade +
+ + @foreach ($existingMedia as $mediaIndex => $media) +
+ + @if ($mediaIndex === 0) +
+ Standard +
+ @endif + + {{ $media['alt_text'] ?? '' }} + + +
+ @endforeach +
+``` + +**Wichtig:** `wire:key="existing-media-{{ $media['id'] }}"` ist zwingend, damit Livewire beim Re-Render die DOM-Elemente korrekt zuordnet. + +--- + +## 3. Datenbank – Media-Tabelle + +```php +// Migration: database/migrations/xxxx_create_media_table.php + +Schema::create('media', function (Blueprint $table) { + $table->id(); + $table->string('model_type'); // z. B. "App\Models\Product" + $table->unsignedBigInteger('model_id'); + $table->string('file_path'); // relativer Pfad auf dem public-Disk + $table->string('type')->default('image'); // 'image', 'video', 'pdf', '3d_model' + $table->string('alt_text')->nullable(); + $table->integer('order_column')->default(0); + $table->timestamps(); + + $table->index(['model_type', 'model_id']); +}); +``` + +### Media-Model (`app/Models/Media.php`) + +```php +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 */ + public function model(): MorphTo + { + return $this->morphTo(); + } +} +``` + +### Beziehung im Parent-Model (`app/Models/Product.php`) + +```php +use Illuminate\Database\Eloquent\Relations\MorphMany; + +public function media(): MorphMany +{ + return $this->morphMany(Media::class, 'model'); +} +``` + +--- + +## 4. Filesystem-Konfiguration (`config/filesystems.php`) + +```php +'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => env('APP_URL') . '/storage', + 'visibility' => 'public', + 'throw' => false, +], +``` + +### Symlink anlegen + +```bash +php artisan storage:link +``` + +Erstellt `public/storage` → `storage/app/public`. **Ohne diesen Symlink sind hochgeladene Bilder nicht über den Browser erreichbar.** + +--- + +## 5. Kritische System-Anpassungen + +### 5a. `bootstrap/app.php` – Reverse-Proxy / HTTPS + +```php +->withMiddleware(function (Middleware $middleware) { + // Traefik/nginx Proxy: X-Forwarded-Proto-Header vertrauen + // ZWINGEND für korrekte signierte Upload-URLs hinter einem HTTPS-Proxy + $middleware->trustProxies(at: '*'); +}) +``` + +**Warum?** +Livewire generiert für temporäre Uploads **signierte URLs**. Wenn die App hinter einem Reverse-Proxy (Traefik, nginx, Load Balancer) läuft und der Proxy HTTPS terminiert, glaubt Laravel intern, es sei HTTP. Die signierte URL wird dann mit `http://` generiert, der Browser sendet aber `https://` – die Signatur stimmt nicht, Upload schlägt fehl mit `403`. + +### 5b. `app/Providers/AppServiceProvider.php` – Schema erzwingen + +```php +public function boot(): void +{ + // X-Forwarded-Proto auswerten und Schema erzwingen + // Nötig für Livewire Upload-URLs hinter Traefik + $scheme = request()->header('X-Forwarded-Proto') + ?? request()->server('HTTP_X_FORWARDED_PROTO') + ?? (request()->secure() ? 'https' : 'http'); + + if ($scheme === 'https') { + URL::forceScheme('https'); + } +} +``` + +**Warum zusätzlich zum `trustProxies`?** +`trustProxies` reicht in manchen Proxy-Setups nicht aus, wenn der Header-Name variiert. `URL::forceScheme('https')` ist die sichere Ergänzung, die sicherstellt, dass alle generierten URLs das korrekte Schema haben. + +**Ohne diese beiden Maßnahmen** scheitert der Upload mit einer `403 Signature mismatch`-Fehlermeldung in der Browser-Console – besonders frustrierend, weil kein PHP-Fehler erscheint. + +--- + +## 6. Livewire-Konfiguration (`config/livewire.php`) + +```php +'temporary_file_upload' => [ + 'disk' => null, // null = default-Disk (meist 'local') + 'rules' => null, // null = ['required', 'file', 'max:12288'] (12 MB Default) + 'directory' => null, // null = 'livewire-tmp' + 'middleware' => null, // null = 'throttle:60,1' + 'preview_mimes' => [ // Diese MIME-Types erlauben temporaryUrl() + 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp', + 'pdf', 'mp4', 'mov', 'avi', 'wmv', 'mp3', ... + ], + 'max_upload_time' => 5, // Minuten bis Upload ungültig wird + 'cleanup' => true, // Tmp-Dateien > 24h automatisch löschen +], +``` + +**Wichtig:** Das interne Default-Limit ist **12 MB** (`max:12288`). Eigene Validierungsregeln wie `max:10240` müssen immer unter diesem Wert liegen. Soll das Limit erhöht werden, muss `rules` hier überschrieben werden. + +--- + +## 7. Checkliste für ein neues Projekt + +| Schritt | Was | Wo | +|---------|-----|----| +| ✅ | `use WithFileUploads` im Volt/Livewire-Component | Komponentenklasse | +| ✅ | `public array $images = []` Property anlegen | Komponentenklasse | +| ✅ | `'images.*' => 'mimes:jpeg,png\|max:10240'` Validierung | `save()`-Methode | +| ✅ | `$image->store('pfad', 'public')` beim Speichern | `save()`-Methode | +| ✅ | `$this->images = []` nach dem Speichern leeren | `save()`-Methode | +| ✅ | `php artisan storage:link` ausführen | Terminal / Deploy | +| ✅ | `$middleware->trustProxies(at: '*')` | `bootstrap/app.php` | +| ✅ | `URL::forceScheme('https')` bei HTTPS-Proxy | `AppServiceProvider.php` | +| ✅ | `wire:key` in Foreach-Schleifen | Blade-Template | +| ✅ | `array_values()` nach `unset()` auf dem Array | `removePhoto()` | +| ✅ | `isPreviewable()` vor `temporaryUrl()` prüfen | Blade-Template | + +--- + +## 8. Häufige Fallstricke + +### Upload schlägt fehl mit 403 (Signature mismatch) +→ Reverse-Proxy-Problem. Siehe Punkt 5a und 5b. + +### Vorschau-Thumbnail zeigt nichts an +→ `isPreviewable()` gibt `false` zurück, wenn der MIME-Type nicht in `preview_mimes` steht. In der Livewire-Config prüfen. + +### Nach `removePhoto()` stimmen die Indizes nicht +→ `array_values()` vergessen. Livewire sendet den Index als Parameter – ohne Reindizierung kommt es zu Off-by-One-Fehlern. + +### Upload-Limit-Fehler vor der Validierung +→ PHP `upload_max_filesize` und `post_max_size` in `php.ini` überprüfen. Auch Livewires internes `max:12288`-Limit beachten. + +### `temporaryUrl()` wirft eine Exception +→ Bei lokalen Disks ohne `serve: true` in `filesystems.php` funktioniert `temporaryUrl()` nicht. Entweder `serve: true` setzen oder S3 verwenden. Im Template immer mit `isPreviewable()` absichern. + +### Bilder nach Deploy nicht sichtbar +→ `php artisan storage:link` auf dem Produktionssystem ausführen. Im Docker-Container nach jedem `down/up` prüfen, ob der Symlink noch existiert. diff --git a/packages/flux-cms/README.md b/packages/flux-cms/README.md index ca181c1..59a1f73 100644 --- a/packages/flux-cms/README.md +++ b/packages/flux-cms/README.md @@ -1,563 +1,576 @@ -# Flux CMS - Laravel Package Suite +# Flux CMS — Content Management für Laravel + Livewire + Flux UI -🚀 **Modern, component-first CMS for Laravel with multi-domain support** +## Überblick -[![Latest Version](https://img.shields.io/packagist/v/flux-cms/core.svg?style=flat-square)](https://packagist.org/packages/flux-cms/core) -[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) -[![Total Downloads](https://img.shields.io/packagist/dt/flux-cms/core.svg?style=flat-square)](https://packagist.org/packages/flux-cms/core) +Flux CMS ist ein modulares, mehrsprachiges Content-Management-System für Laravel-Anwendungen. Es nutzt Livewire Volt (Functional API) und Flux UI Pro als Admin-Oberfläche und speichert alle Inhalte übersetzbar in der Datenbank (`spatie/laravel-translatable`). -## Overview +**Kernkonzept:** Bestehende Inhalte aus Laravel `lang/`-Dateien werden in die Datenbank migriert. Ein `cms()` Helper mit Fallback auf `__()` sorgt für nahtlose Migration — Seiten funktionieren sofort, auch wenn noch nicht alle Inhalte in der DB sind. -Flux CMS is a powerful, modern Content Management System built specifically for Laravel applications. It features a revolutionary "Code-as-Schema" approach where content structure is defined directly in PHP components, offering unprecedented flexibility and developer experience. +### Tech-Stack -### 🎯 Key Features - -- **🧩 Component-First Architecture** - Build pages from reusable Livewire components -- **📝 Code-as-Schema** - Define fields in PHP, not in databases -- **🌍 Multi-Domain Support** - Manage multiple websites from one installation -- **🗣️ Full Multilingual** - Everything is translatable with fallbacks -- **📦 Versioning** - Automatic content versioning and rollback -- **🎨 Media Management** - Integrated media library with automatic conversions -- **⚡ Performance** - Optimized queries and smart caching -- **🔒 Secure** - Built-in security best practices - -## Package Architecture - -Flux CMS is split into modular packages for maximum flexibility: - -### Core Packages - -| Package | Description | Installation | -|---------|-------------|--------------| -| **flux-cms/core** | Core models, services, and field types | `composer require flux-cms/core` | -| **flux-cms/components** | Livewire backend and frontend components | `composer require flux-cms/components` | -| **flux-cms/starter-components** | Ready-to-use starter components | `composer require flux-cms/starter-components` | - -## Quick Start - -### 1. Installation - -```bash -# Install core package -composer require flux-cms/core - -# Install components (optional but recommended) -composer require flux-cms/components flux-cms/starter-components - -# Install and setup -php artisan flux-cms:install -``` - -### 2. Basic Configuration - -```php -// config/flux-cms.php -return [ - 'locales' => [ - 'de' => 'Deutsch', - 'en' => 'English', - ], - 'component_paths' => [ - 'App\\Livewire\\Components', - 'FluxCms\\StarterComponents\\Components', - ], - 'domains' => [ - 'enabled' => true, - 'config_source' => 'domains', // Use existing config/domains.php - ], -]; -``` - -### 3. Create Your First Component - -```php -content = $content; - } - - public static function getCmsName(): string - { - return 'Feature Section'; - } - - public static function getCmsDescription(): string - { - return 'Showcase features with icons and descriptions'; - } - - public static function getCmsCategory(): string - { - return 'Content'; - } - - public static function getCmsFields(): array - { - return [ - TextField::make('headline', 'Headline') - ->translatable() - ->required() - ->maxLength(100), - - WysiwygField::make('description', 'Description') - ->translatable() - ->toolbar(['bold', 'italic', 'link']), - - MediaField::make('icon', 'Icon') - ->images() - ->helpText('SVG or PNG icon'), - ]; - } - - public function render() - { - return view('components.feature-section'); - } - - // Helper methods - protected function getHeadline(?string $locale = null): string - { - $locale = $locale ?? app()->getLocale(); - return $this->content['headline'][$locale] ?? ''; - } -} -``` - -### 4. Create the Blade Template - -```blade -{{-- resources/views/components/feature-section.blade.php --}} -
-
- @if($this->getHeadline()) -

- {{ $this->getHeadline() }} -

- @endif - - @if($this->getDescription()) -
- {!! $this->getDescription() !!} -
- @endif -
-
-``` - -## Advanced Usage - -### Multi-Domain Setup - -```php -// Automatic domain detection from existing config/domains.php -$page = Page::forDomain('b2in')->bySlug('/about', 'en')->first(); - -// Create domain-specific content -$page = Page::create([ - 'domain_key' => 'b2in', - 'title' => ['de' => 'Über uns', 'en' => 'About us'], - 'is_published' => true, -]); - -$page->slugs()->create(['locale' => 'de', 'slug' => '/ueber-uns']); -$page->slugs()->create(['locale' => 'en', 'slug' => '/about']); -``` - -### Component Registry - -```php -// Get all available components -$registry = app(ComponentRegistry::class); -$components = $registry->getAvailableComponents(); - -// Search components -$results = $registry->searchComponents('hero'); - -// Get by category -$layoutComponents = $registry->getComponentsByCategory()['Layout']; - -// Validate component content -$errors = $registry->validateComponentContent(HeroSection::class, $content); -``` - -### Custom Field Types - -```php -required) { - $rules[] = 'required'; - } - - return $rules; - } - - public function toArray(): array - { - return [ - 'type' => $this->getType(), - 'key' => $this->key, - 'label' => $this->label, - 'translatable' => $this->translatable, - 'required' => $this->required, - 'default' => $this->default, - ]; - } -} -``` - -### Versioning - -```php -// Create version before major changes -$page->createVersion('Redesign homepage layout', auth()->id()); - -// Restore previous version -$version = $page->versions()->first(); -$version->restore(); - -// Compare versions -$differences = $version->getDifferences(); -``` - -## Field Types - -Flux CMS includes powerful field types out of the box: - -### Text Fields -```php -TextField::make('title', 'Title') - ->translatable() - ->required() - ->maxLength(100) - ->placeholder('Enter title...'); - -TextField::make('email', 'Email') - ->email() - ->required(); - -TextField::make('website', 'Website') - ->url(); -``` - -### Content Fields -```php -WysiwygField::make('content', 'Content') - ->translatable() - ->toolbar(['bold', 'italic', 'link', 'bulletList']) - ->allowImages(true) - ->minHeight(300); -``` - -### Media Fields -```php -MediaField::make('image', 'Image') - ->images() - ->required(); - -MediaField::make('gallery', 'Gallery') - ->images() - ->multiple(true, 10); - -MediaField::make('document', 'Document') - ->documents() - ->maxFileSize(5120); // 5MB -``` - -### Selection Fields -```php -SelectField::make('layout', 'Layout') - ->options([ - 'left' => 'Image Left', - 'right' => 'Image Right', - 'center' => 'Centered' - ]) - ->default('left') - ->searchable(); -``` - -### Other Fields -```php -NumberField::make('count', 'Count') - ->min(1) - ->max(100) - ->default(5); - -BooleanField::make('featured', 'Featured') - ->default(false) - ->labels('Yes', 'No'); -``` - -## Frontend Integration - -### Page Controller - -```php -getCurrentDomainKey($request); - $locale = app()->getLocale(); - - $page = Page::forDomain($domainKey) - ->bySlugWithFallback($slug) - ->published() - ->with(['components']) - ->firstOrFail(); - - $components = $page->components()->get(); - - return view('pages.show', compact('page', 'components')); - } -} -``` - -### Page Template - -```blade -{{-- resources/views/pages/show.blade.php --}} -@extends('layouts.app') - -@section('title', $page->getSeoTitle()) -@section('description', $page->getSeoDescription()) - -@section('content') - @foreach($components as $component) - @if($component->canRender()) - @livewire($component->component_class, [ - 'content' => $component->getTranslations('content') - ], key('component-' . $component->id)) - @endif - @endforeach -@endsection -``` - -## Backend Integration - -### Admin Routes - -```php -// routes/admin.php -Route::middleware(['web', 'auth'])->prefix('admin/cms')->name('admin.cms.')->group(function () { - Route::get('/', [Admin\DashboardController::class, 'index'])->name('index'); - Route::resource('pages', Admin\PageController::class)->except(['show']); - // ... other admin routes -}); -``` - -### Admin Controller - -```php -orderBy('updated_at', 'desc') - ->paginate(20); - - return view('admin.cms.pages.index', compact('pages')); - } - - public function edit(Page $page) - { - return view('admin.cms.pages.edit', compact('page')); - } -} -``` - -### Edit Page Template - -```blade -{{-- resources/views/admin/cms/edit.blade.php --}} -@extends('layouts.admin') - -@section('content') - @livewire('flux-cms::page-editor', ['page' => $page]) -@endsection -``` - -## Configuration - -### Available Locales - -```php -// config/flux-cms.php -'locales' => [ - 'de' => 'Deutsch', - 'en' => 'English', - 'fr' => 'Français', - 'es' => 'Español', -], -``` - -### Component Paths - -```php -'component_paths' => [ - 'App\\Livewire\\Components', - 'App\\CmsComponents', - 'FluxCms\\StarterComponents\\Components', -], -``` - -### Media Configuration - -```php -'media' => [ - 'disk' => 'public', - 'max_file_size' => 10240, // 10MB - 'conversions' => [ - 'thumb' => ['width' => 300, 'height' => 300], - 'medium' => ['width' => 800, 'height' => 600], - 'large' => ['width' => 1200, 'height' => 900], - ], -], -``` - -## Commands - -```bash -# Install Flux CMS -php artisan flux-cms:install - -# Create a new component (in app/Livewire/Web/Components) -php artisan flux-cms:make-component MyNewComponent - -# Clear component registry cache -php artisan flux-cms:clear-cache - -# Publish package assets -php artisan vendor:publish --tag=flux-cms -``` - -## Testing - -```bash -# Run tests from the root of your project -./vendor/bin/pest -``` - -## Security - -- **XSS Protection**: All user content is sanitized -- **CSRF Protection**: All forms include CSRF tokens -- **File Upload Security**: MIME type validation and file scanning -- **Permission System**: Integration with Spatie Laravel Permission - -## Performance - -- **Component Registry Caching**: Components are cached for fast lookup -- **Eager Loading**: Optimized database queries -- **Asset Optimization**: Automatic image conversions -- **Query Caching**: Smart caching for frequently accessed data - -## Upgrade Guide - -### From Dev Version to Package - -1. Install packages: -```bash -composer require flux-cms/core flux-cms/components flux-cms/starter-components -``` - -2. Migrate existing components: -```bash -# Copy your existing components to app/Livewire/Components/ -# Update namespaces and field imports -``` - -3. Update configuration: -```bash -php artisan vendor:publish --tag=flux-cms-config -``` - -4. Run migrations: -```bash -php artisan migrate -``` - -## Contributing - -We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details. - -### Development Setup - -```bash -# Clone the repository -git clone https://github.com/flux-cms/flux-cms.git - -# Install dependencies -composer install - -# Run tests -composer test - -# Code style -composer format -``` - -## License - -Flux CMS is open-sourced software licensed under the [MIT license](LICENSE.md). - -## Support - -- 📖 **Documentation**: [https://flux-cms.com/docs](https://flux-cms.com/docs) -- 💬 **Discussions**: [GitHub Discussions](https://github.com/flux-cms/flux-cms/discussions) -- 🐛 **Issues**: [GitHub Issues](https://github.com/flux-cms/flux-cms/issues) -- 💌 **Email**: support@flux-cms.com - -## Acknowledgments - -- Built with [Laravel](https://laravel.com) -- Powered by [Livewire](https://laravel-livewire.com) -- Uses [Spatie packages](https://spatie.be/open-source) -- UI with [Flux UI](https://fluxui.dev) +- Laravel 12, Livewire 3, Volt (Functional API) +- Flux UI Pro v2 (Komponenten-Bibliothek) +- Tailwind CSS v4 +- `spatie/laravel-translatable` für Mehrsprachigkeit +- `intervention/image` v3 für Bildverarbeitung +- Pest v4 für Tests --- -Made with ❤️ by the Flux CMS team +## Architektur + +``` +┌──────────────────────────────────────────────────────┐ +│ Frontend (Blade/Livewire Views) │ +│ {{ cms('welcome.hero.heading') }} │ +│ {{ cms_media_url('welcome.hero.image') }} │ +│ {{ media_url('keyvisual.webp', 'hero') }} │ +└──────────────┬───────────────────────────────────────┘ + │ +┌──────────────▼───────────────────────────────────────┐ +│ Helper-Funktionen │ +│ cms() → CmsContentService (DB + Cache) │ +│ cms_media_url() → CmsContent → CmsMedia → URL │ +│ media_url() → CmsMedia → URL (mit Cache) │ +└──────────────┬───────────────────────────────────────┘ + │ +┌──────────────▼───────────────────────────────────────┐ +│ Datenbank (flux_cms_* Tabellen) │ +│ CmsContent, CmsMedia, CmsNewsItem, CmsIndustry, │ +│ CmsFaq, CmsLinkedinPost, CmsDownload │ +└──────────────┬───────────────────────────────────────┘ + │ +┌──────────────▼───────────────────────────────────────┐ +│ Media Library (Storage / public disk) │ +│ cms/media/originals/ → Original-Uploads │ +│ cms/media/conversions/ → Generierte Bildgrößen │ +│ cms/media/thumbnails/ → Auto-generierte Thumbnails │ +└──────────────┬───────────────────────────────────────┘ + │ +┌──────────────▼───────────────────────────────────────┐ +│ Admin UI (/admin/cms/*) │ +│ Livewire Volt + Flux UI Pro │ +│ Content-Editor, Medienbibliothek, Downloads, │ +│ Team, News, LinkedIn, FAQs, Industries │ +└──────────────────────────────────────────────────────┘ +``` + +--- + +## Datenbankmodelle + +### CmsContent (Kern) +Flexibler Key-Value-Store für alle Seiteninhalte. + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `group` | string | Seiten-/Dateiname (z.B. `welcome`, `about`, `footer`) | +| `key` | string | Dot-Notation-Pfad (z.B. `hero.heading`, `cta.description`) | +| `type` | enum | `text`, `html`, `image`, `json`, `link` | +| `value` | JSON | Übersetzbar: `{"de": "...", "en": "..."}` | +| `order` | int | Sortierung (chronologisch nach Dateistruktur) | + +### CmsMedia (Medienbibliothek) +Zentrale Verwaltung aller Bilder und PDFs. + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `filename` | string | Original-Dateiname (z.B. `keyvisual.webp`) | +| `path` | string | Storage-Pfad (z.B. `cms/media/originals/abc123.webp`) | +| `type` | string | `image`, `pdf`, `document` | +| `mime_type` | string | MIME-Typ | +| `file_size` | int | Dateigröße in Bytes | +| `original_width` | int | Breite bei Bildern | +| `original_height` | int | Höhe bei Bildern | +| `disk` | string | Storage-Disk (default: `public`) | +| `collection` | string | Optionale Sammlung/Ordner | +| `conversions` | JSON | Generierte Bildgrößen-Pfade | +| `title` | JSON | Übersetzbar | +| `alt_text` | JSON | Übersetzbar | +| `is_published` | bool | Veröffentlichungsstatus | + +### CmsDownload (Case Studies, Capabilities, Success Stories) +Vollständiges Download-Management mit sprachspezifischen PDFs. + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `title` | JSON | Übersetzbar | +| `description` | JSON | Übersetzbar | +| `category` | string | `case_study`, `capability`, `success_story` | +| `icon` | string | Heroicon-Name | +| `sub_category` | string | Detail-Kategorie (z.B. "R&D Product Support") | +| `type_label` | JSON | Übersetzbar (z.B. "Case Study" / "Capability") | +| `alt` | JSON | Übersetzbar: Alt-Text für Vorschaubild | +| `file_path` | JSON | Übersetzbar: PDF-Dateiname pro Sprache (DE/EN) | +| `thumbnail` | string | Vorschaubild-Dateiname (CmsMedia) | +| `open_text` | JSON | Übersetzbar: Button-Text "PDF öffnen" | +| `download_text` | JSON | Übersetzbar: Button-Text "PDF downloaden" | +| `highlights` | JSON | Kennzahlen `[{"value": "100%", "label": "..."}]` | +| `checkpoints` | JSON | Checkpunkte `[{"value": "..."}]` | + +### Weitere Modelle + +| Modell | Tabelle | Zweck | +|--------|---------|-------| +| `CmsNewsItem` | `flux_cms_news_items` | News-Band mit Icon, PDF, Datum, Bild (via CmsMedia) | +| `CmsIndustry` | `flux_cms_industries` | Industries-Band mit Sortierung | +| `CmsFaq` | `flux_cms_faqs` | FAQ-Einträge mit Kategorie und Hilfe-Text | +| `CmsLinkedinPost` | `flux_cms_linkedin_posts` | LinkedIn-Posts mit Bild (via CmsMedia) | +| `CmsSearchIndex` | `flux_cms_search_index` | Seitensuche: Keywords, Titel, Beschreibungen pro Route | + +Alle Modelle nutzen `Spatie\Translatable\HasTranslations` für DE/EN (erweiterbar). + +### CmsSearchIndex (Seitensuche) +Zentrales Index-Modell für die Frontend-Suche. Jeder Eintrag repräsentiert eine Seite/Route. + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `item_id` | string | Eindeutige ID (z.B. `home`, `leistungen`) | +| `route` | string | Laravel Named Route (z.B. `home`, `leistungen`) | +| `route_params` | JSON | Route-Parameter als Array | +| `category` | JSON | Übersetzbar: Kategorie (z.B. `Startseite`, `Leistungen`) | +| `title_key` | string | CMS-Key für Titel (löst via `cms()` auf) | +| `title_fallback` | JSON | Übersetzbar: Statischer Fallback-Titel | +| `description_key` | string | CMS-Key für Beschreibung | +| `description_fallback_key` | string | Sekundärer CMS-Key als Fallback | +| `description_fallback_text` | JSON | Übersetzbar: Statischer Fallback-Text | +| `keywords` | JSON | Übersetzbar: Array aus Keywords oder CMS-Keys (Dot-Notation) | +| `is_published` | bool | Veröffentlichungsstatus | + +**Keywords:** Können direkte Texte oder CMS-Keys (mit Punkt) sein. CMS-Keys werden automatisch aufgelöst. + +```php +// toFrontendArray() löst alle Keys auf +$entries = CmsSearchIndex::published()->ordered()->get() + ->map(fn ($entry) => $entry->toFrontendArray()) + ->toArray(); +``` + +--- + +## Medienbibliothek + +### Konzept + +Alle Bilder und PDFs werden zentral in einer Medienbibliothek verwaltet. Anstatt Dateien direkt an Inhalte hochzuladen, werden sie aus der Bibliothek ausgewählt. Vorteile: + +- Zentrale Verwaltung aller Assets +- Automatische Bildoptimierung (Resize, Kompression, Formatkonvertierung) +- Wiederverwendung von Medien über verschiedene Inhalte hinweg +- Sprachunabhängige Bilder, sprachspezifische PDFs + +### Bildprofile (Conversions) + +Definiert in `config/flux-cms.php`: + +```php +'media' => [ + 'profiles' => [ + 'hero' => ['width' => 1920, 'height' => 800, 'format' => 'webp', 'quality' => 85], + 'card' => ['width' => 768, 'height' => 512, 'format' => 'webp', 'quality' => 80], + 'thumbnail' => ['width' => 400, 'height' => 300, 'format' => 'webp', 'quality' => 75], + 'avatar' => ['width' => 400, 'height' => 400, 'format' => 'webp', 'quality' => 80], + 'news' => ['width' => 1200, 'height' => 630, 'format' => 'webp', 'quality' => 80], + 'thumb' => ['width' => 200, 'height' => 200, 'format' => 'webp', 'quality' => 70], + ], +], +``` + +Conversions werden automatisch beim Upload (Thumbnail) oder on-demand im Admin generiert. + +### MediaConversionService + +```php +$service = app(MediaConversionService::class); + +// Einzelne Conversion +$service->convert($media, 'hero'); + +// Alle Conversions +$service->generateAllConversions($media); + +// Thumbnail automatisch +$service->generateThumbnail($media); +``` + +### Komponenten + +| Komponente | Typ | Beschreibung | +|------------|-----|-------------| +| `MediaLibraryUploader` | Class-based Livewire | Multi-File Drag&Drop Upload via `flux:file-upload` | +| `MediaPicker` | Class-based Livewire | Wiederverwendbares Modal zur Medienauswahl | + +**MediaPicker-Verwendung:** + +```blade + +``` + +Feuert `media-selected` Event mit `field`, `mediaId`, `url`: + +```php +on(['media-selected' => function (string $field, ?int $mediaId, ?string $url) { + if ($field === 'news_image') { + $this->imageMediaId = $mediaId; + $media = $mediaId ? CmsMedia::find($mediaId) : null; + $this->image = $media ? $media->filename : ''; + } +}]); +``` + +--- + +## Helper-Funktionen + +### `cms()` — Content abrufen + +```php +$value = cms('welcome.hero.heading'); +$value = cms('key', ['highlight' => '...']); +$value = cms('key', locale: 'en'); +``` + +**Fallback-Kette:** DB (mit Cache) → `__()` (Laravel-Translation) + +### `cms_media_url()` — Bild über CmsContent-Key + +```php +// Holt Dateiname aus CmsContent, löst über CmsMedia auf +$url = cms_media_url('welcome.hero.image'); +$url = cms_media_url('welcome.hero.image', 'hero'); // mit Conversion-Profil +``` + +### `media_url()` — Bild über Dateiname + +```php +// Löst CmsMedia-Dateiname direkt auf (mit In-Memory-Cache) +$url = media_url('keyvisual.webp'); +$url = media_url('keyvisual.webp', 'hero'); // mit Conversion-Profil +``` + +**Fallback:** `asset('assets/images/' . $filename)` wenn nicht in CmsMedia. + +### `tcms()` — Content mit Tooltip-Verarbeitung + +```php +$text = tcms('welcome.hero.heading'); +``` + +--- + +## Dateien-Übersicht + +### Package-Kern (`package/flux-cms/core/`) + +``` +src/ +├── Models/ +│ ├── CmsContent.php # Key-Value Content-Store +│ ├── CmsMedia.php # Medienbibliothek-Einträge +│ ├── CmsNewsItem.php # News (mit toFrontendArray()) +│ ├── CmsDownload.php # Downloads (mit toFrontendArray()) +│ ├── CmsIndustry.php # Industries +│ ├── CmsFaq.php # FAQs +│ └── CmsLinkedinPost.php # LinkedIn-Posts +├── Services/ +│ ├── CmsContentService.php # Content-Abruf mit Caching + Fallback +│ └── MediaConversionService.php # Bildverarbeitung (intervention/image) +├── Helpers/ +│ ├── cms_helpers.php # cms() und tcms() Helper +│ ├── MediaLibraryUploader.php # Multi-File Upload Komponente +│ └── MediaPicker.php # Medienauswahl-Modal +└── FluxCmsServiceProvider.php # Package-Registrierung + +database/ +├── migrations/ +│ ├── ..._create_flux_cms_contents_table.php +│ ├── ..._create_flux_cms_downloads_table.php +│ ├── ..._create_flux_cms_linkedin_posts_table.php +│ ├── ..._create_flux_cms_faqs_table.php +│ ├── ..._create_flux_cms_news_items_table.php +│ ├── ..._create_flux_cms_industries_table.php +│ └── ..._create_flux_cms_media_table.php +└── seeders-reference/ + +config/ +└── flux-cms.php # Locales, Cache, Media-Profiles, Auth +``` + +### Projekt-Integration (Host-App) + +``` +app/ +├── helpers.php # cms(), tcms(), cms_media_url(), media_url() +└── Livewire/Admin/Cms/ + ├── MediaLibraryUploader.php # Multi-File Upload + └── MediaPicker.php # Medienauswahl-Modal + +resources/views/ +├── livewire/admin/cms/ # Volt-Admin-Komponenten +│ ├── content-index.blade.php # Inhalte-Editor (text/html/image/json) +│ ├── news-index.blade.php # News-Verwaltung +│ ├── downloads-index.blade.php # Downloads (Case Studies/Capabilities/Stories) +│ ├── team-index.blade.php # Team-Verwaltung +│ ├── linkedin-index.blade.php # LinkedIn-Posts +│ ├── industries-index.blade.php # Industries +│ ├── faqs-index.blade.php # FAQ-Verwaltung +│ ├── media-index.blade.php # Medienbibliothek +│ ├── media-library-uploader.blade.php +│ ├── media-picker.blade.php +│ └── dashboard-index.blade.php # CMS-Dashboard +└── components/layouts/ + └── cms.blade.php # CMS-Admin-Layout mit Sidebar + +database/ +├── migrations/ +│ ├── ..._add_detail_columns_to_flux_cms_downloads_table.php +│ └── ..._change_file_path_to_json_on_flux_cms_downloads_table.php +└── seeders/ + ├── CmsContentSeeder.php # Lang → DB Migration + ├── CmsMediaSeeder.php # Medien-Einträge + CmsContent image-Typen + ├── CmsDownloadSeeder.php # Case Studies, Capabilities, Success Stories + ├── CmsNewsItemSeeder.php # News-Band (mit Media-Filename-Mapping) + ├── CmsLinkedinPostSeeder.php # LinkedIn-Posts (mit Media-Filenames) + ├── CmsIndustrySeeder.php + └── CmsFaqSeeder.php + +routes/web.php # CMS-Routes unter /admin/cms/* +``` + +--- + +## Admin-Bereiche + +### Inhalte (`/admin/cms/content`) +- Alle `CmsContent`-Einträge nach Gruppen +- **Text/HTML**: Inline-Editor (Flux Editor) mit konfigurierbaren Toolbars +- **Image**: MediaPicker mit Vorschau +- **JSON**: Modal-Editor mit strukturierter Bearbeitung (Icons, Editor, Inputs) +- Sprachumschaltung DE/EN + +### Medienbibliothek (`/admin/cms/media`) +- Grid- und Listenansicht (Toggle) +- Filter nach Typ (Bilder/PDFs/Dokumente), Sammlung, Dateiname +- Multi-File Drag&Drop Upload +- Detail-Sidebar: Metadaten, Alt-Text, Titel, Sammlung +- Bildgrößen-Management: Einzelne oder alle Conversions generieren +- **PDF-Vorschau**: Eingebettete PDF-Darstellung via iframe +- **Typ-Icons**: Farbige Icons (blau=Bild, rot=PDF) neben Dateinamen + +### Downloads (`/admin/cms/downloads`) +- **Drei Kategorien**: Case Studies, Capabilities, Success Stories +- Kategoriefilter-Buttons +- Vollständige Bearbeitung: Titel, Unterkategorie, Typ-Label, Alt-Text, Icon, Beschreibung +- **Vorschaubild**: MediaPicker mit Bildvorschau +- **Sprachspezifische PDFs**: Separater PDF-Upload pro Sprache (DE/EN) mit Vorschau +- **Highlights-Editor** (Case Studies/Success Stories): Dynamische Kennzahl-Paare +- **Checkpoints-Editor** (Capabilities): Dynamische Checkpunkte +- Sortierung mit Pfeiltasten +- `toFrontendArray()` für nahtlose Frontend-Integration + +### News-Band (`/admin/cms/news`) +- CRUD mit Icon (Heroicon-Select + Vorschau), Datum, Autor +- **Bild**: MediaPicker mit Vorschau in Liste und Formular +- **PDF**: MediaPicker mit eingebetteter PDF-Vorschau +- Rich-Text-Editor für Kurztext und Inhalt +- Sprachumschaltung + +### Team (`/admin/cms/team`) +- CRUD für Teammitglieder (JSON in CmsContent) +- **Profilbild**: MediaPicker mit Vorschau (rund) +- Name, Position, Kürzel, LinkedIn-URL, Kurzvorstellung +- Profil-Text mit Rich-Text-Editor + +### LinkedIn-Posts (`/admin/cms/linkedin`) +- CRUD mit **Bild über MediaPicker** (nicht mehr Text-Input) +- Titel, Autor, Datum, URL, Tags +- Rich-Text-Editor für Kurztext und Inhalt +- Bildvorschau in Liste und Formular + +### Industries (`/admin/cms/industries`) +- CRUD mit Sortierung (Pfeiltasten + numerisches Order-Feld) + +### FAQs (`/admin/cms/faqs`) +- CRUD nach Kategorien +- Rich-Text-Editor für Frage und Antwort + +### Suchindex (`/admin/cms/search-index`) +- Zwei-Spalten-Layout: Liste links, Editor rechts +- Jeder Eintrag = eine Seite/Route (z.B. `home`, `leistungen`) +- **Keywords** können direkte Texte oder CMS-Keys (Dot-Notation) sein +- **Reindexieren**-Button löst `search:extract-keywords` Artisan-Befehl aus +- Vorschau zeigt aufgelöste Titel/Beschreibung/Keywords pro Sprache +- Sprachumschaltung DE/EN + +--- + +## Content-Typen & Editor-Konfiguration + +### Text-Felder +Standard-Editor mit `bold italic` Toolbar. Im Admin automatisch erkannt. + +### HTML-Felder (Datenschutz, Impressum) +Voller Editor mit `heading | bold italic underline strike | bullet ordered blockquote | link`. + +### Image-Felder +MediaPicker mit Vorschau. Speichert CmsMedia-Dateiname als Wert. + +### JSON-Felder +Öffnen ein Modal mit strukturierter Bearbeitung: +- Erkennung ob String-Array oder Objekt-Array +- Icon-Felder → Searchable Heroicon-Select (nur Vorschau des gewählten Icons) +- Text-Felder (description, content, quote) → Rich-Text-Editor +- Verschachtelte JSON → Textarea +- Standard-Felder → Input + +### :highlight Pattern +Für gradient-gestylte Textteile in Headings: + +```php +// Template +{!! cms('welcome.solutions.heading', [ + 'highlight' => '' . cms('welcome.solutions.heading_highlight') . '', +]) !!} +``` + +--- + +## Medien-Integration im Frontend + +### Bilder über CmsContent-Keys (dynamisch austauschbar) + +```blade + + {{-- mit Conversion --}} +``` + +### Bilder über Dateiname (direkt) + +```blade + + {{-- z.B. Team-Profil --}} +``` + +### Downloads (toFrontendArray) + +```blade +@foreach (CmsDownload::published()->byCategory('case_study')->ordered()->get() as $dl) + +@endforeach +``` + +### News-Band (toFrontendArray) + +```blade +@php + $newsItems = CmsNewsItem::published()->ordered()->get() + ->map(fn ($item) => $item->toFrontendArray()) + ->toArray(); +@endphp +``` + +Die `toFrontendArray()` Methoden lösen `media_url()` automatisch auf — Bilder und PDFs sind sofort verlinkt. + +--- + +## Seeders + +### Ausführungsreihenfolge + +```php +$this->call([ + CmsContentSeeder::class, // Hauptinhalte aus lang/ Dateien + CmsMediaSeeder::class, // Medien-Einträge + Image-Content + CmsNewsItemSeeder::class, // News-Band + CmsIndustrySeeder::class, // Industries + CmsFaqSeeder::class, // FAQs + CmsLinkedinPostSeeder::class, // LinkedIn-Posts + CmsDownloadSeeder::class, // Case Studies, Capabilities, Stories +]); +``` + +### Medien-Seeder + +Der `CmsMediaSeeder` erfüllt zwei Aufgaben: +1. Erstellt `CmsMedia`-Einträge für alle hochgeladenen Dateien (Bilder + PDFs) +2. Erstellt `CmsContent`-Einträge vom Typ `image` (z.B. `welcome.hero.image` → `keyvisual-small.webp`) + +### Download-Seeder + +Enthält inline alle 12 Einträge (4 Case Studies, 5 Capabilities, 3 Success Stories) mit: +- Bilingualen Texten (DE/EN) +- Sprachspezifischen PDF-Dateinamen (DE/EN) +- Media-Dateinamen (statt alter Pfade) +- Highlights / Checkpoints + +### Dateiname-Mapping + +Die Seeder für News und LinkedIn mappen alte Pfade auf Media-Dateinamen: + +```php +// Alt: '/assets/images/capability-global-player.jpg' +// Neu: 'capability-global-player.webp' (CmsMedia-Dateiname) +``` + +--- + +## Installation + +→ Siehe [SETUP.md](SETUP.md) für eine vollständige Schritt-für-Schritt-Anleitung. Für die Migration in ein bestehendes Projekt: [MIGRATION.md](MIGRATION.md). + +### Kurzfassung + +1. Package einbinden (`composer.json` Repository + require) +2. `composer update flux-cms/core` +3. `php artisan vendor:publish --tag=flux-cms-config` +4. `php artisan migrate` +5. Helper-Funktionen in `app/helpers.php` + `composer.json` autoload +6. Admin-Views aus `admin-reference/` kopieren +7. Seeders kopieren & ausführen +8. Routes registrieren +9. `intervention/image` installieren: `composer require intervention/image` + +--- + +## Features-Übersicht + +| Feature | Status | Beschreibung | +|---------|--------|-------------| +| Mehrsprachige Inhalte | ✅ | DE/EN, erweiterbar | +| Content-Editor (Text/HTML/Image) | ✅ | Flux Editor mit konfigurierbaren Toolbars + MediaPicker | +| JSON-Modal-Editor | ✅ | Strukturierte Bearbeitung von Arrays | +| Heroicon-Select | ✅ | Searchable mit Vorschau (performant) | +| **Medienbibliothek** | ✅ | Zentrale Bild/PDF-Verwaltung mit Grid+Listenansicht | +| **Bildoptimierung** | ✅ | Automatische Conversions (Resize/Format/Qualität) | +| **MediaPicker** | ✅ | Wiederverwendbares Modal für Medienauswahl | +| **PDF-Vorschau** | ✅ | Eingebettete PDF-Darstellung im Admin | +| **Downloads (erweitert)** | ✅ | Case Studies, Capabilities, Success Stories mit Highlights/Checkpoints | +| **Sprachspezifische PDFs** | ✅ | Separate DE/EN PDFs pro Download | +| News-Verwaltung | ✅ | CRUD mit MediaPicker für Bild + PDF | +| Team-Verwaltung | ✅ | CRUD mit MediaPicker für Profilbilder | +| LinkedIn-Posts | ✅ | CRUD mit MediaPicker für Bilder | +| Industries-Verwaltung | ✅ | CRUD mit Sortierung | +| FAQ-Verwaltung | ✅ | CRUD nach Kategorien | +| Toast-Benachrichtigungen | ✅ | Flux Toast bei allen Aktionen | +| Fallback auf lang/ | ✅ | cms() → __() wenn nicht in DB | +| Cache | ✅ | Pro Gruppe, auto-invalidiert | +| Seeder mit cleanHtml() | ✅ | Automatische HTML-Bereinigung | +| **Media-Seeder** | ✅ | Wiederherstellung aller Medien nach DB-Reset | +| **Suchindex** | ✅ | Keywords, Titel, Beschreibungen pro Route mit CMS-Key-Auflösung | + +--- + +## Offene Punkte / Roadmap + +- [ ] Drag & Drop Sortierung (Alternative zu Pfeilen) +- [ ] Versionierung von Content-Änderungen +- [ ] Rollen-basierter Zugriff auf CMS-Bereiche +- [ ] Import/Export von Inhalten +- [ ] Automatischer LinkedIn-API-Import +- [ ] Bildbearbeitung (Crop/Rotate) im Admin diff --git a/packages/flux-cms/SETUP.md b/packages/flux-cms/SETUP.md new file mode 100644 index 0000000..cc0b833 --- /dev/null +++ b/packages/flux-cms/SETUP.md @@ -0,0 +1,493 @@ +# Flux CMS — Schritt-für-Schritt Setup-Anleitung + +Diese Anleitung beschreibt die Integration von Flux CMS in ein neues Laravel-Projekt. + +> **Migrierst du ein bestehendes Projekt?** Dann nutze stattdessen die kompakte **[MIGRATION.md](MIGRATION.md)** Checkliste. + +## Voraussetzungen + +- Laravel 11+ oder 12 +- Livewire 4 mit Volt +- Flux UI (Free oder Pro) +- `spatie/laravel-translatable` (wird vom Package mitgebracht) +- `intervention/image` v3 (für Bildoptimierung) +- Tailwind CSS v4 +- Heroicons (via `blade-ui-kit/blade-heroicons`) +- Mehrsprachige `lang/`-Dateien (DE/EN oder andere) + +--- + +## 1. Package installieren + +### 1.1 Repository registrieren + +```json +// composer.json +{ + "repositories": [ + { "type": "path", "url": "package/flux-cms/core" } + ] +} +``` + +### 1.2 Dependencies hinzufügen + +```bash +composer require flux-cms/core:@dev +composer require intervention/image +``` + +### 1.3 Konfiguration publizieren + +```bash +php artisan vendor:publish --tag=flux-cms-config +``` + +### 1.4 Migrations ausführen + +```bash +php artisan migrate +``` + +Dies erstellt folgende Tabellen: +- `flux_cms_contents` — Alle Seiteninhalte (Key-Value mit Übersetzungen) +- `flux_cms_news_items` — Nachrichteneinträge +- `flux_cms_industries` — Branchen-Band +- `flux_cms_faqs` — FAQ-Einträge +- `flux_cms_downloads` — Downloads (PDFs, etc.) +- `flux_cms_linkedin_posts` — LinkedIn-Posts +- `flux_cms_media` — Medienbibliothek (Bilder, PDFs) + +**Hinweis:** Je nach Projekt-Erweiterung können zusätzliche Migrations nötig sein (z.B. für erweiterte Downloads mit Highlights/Checkpoints). + +--- + +## 2. Helper-Funktionen einrichten + +### 2.1 Datei erstellen + +Erstelle `app/helpers.php`: + +```php +get($key, $replace, $locale); + } +} + +if (! function_exists('tcms')) { + function tcms(string $key, array $replace = [], ?string $locale = null): string + { + $text = cms($key, $replace, $locale); + return is_string($text) ? $text : (string) $text; + } +} + +if (! function_exists('cms_media_url')) { + /** + * Resolve a CmsContent key (type=image) to a full media URL. + * Falls back to asset('assets/images/...') if not found in media library. + */ + function cms_media_url(string $key, string $profile = ''): string + { + $filename = cms($key); + if (! $filename || ! is_string($filename) || $filename === $key) { + $fallback = str_replace('.', '/', $key); + return asset('assets/images/' . basename($fallback)); + } + return media_url($filename, $profile); + } +} + +if (! function_exists('media_url')) { + /** + * Resolve a CmsMedia filename to its full storage URL. + * Uses in-memory cache to avoid repeated DB queries. + */ + function media_url(?string $filename, string $profile = ''): string + { + if (! $filename || $filename === '') { + return ''; + } + + static $resolved = []; + $cacheKey = $filename . '|' . $profile; + + if (isset($resolved[$cacheKey])) { + return $resolved[$cacheKey]; + } + + $media = CmsMedia::where('filename', $filename)->first(); + if (! $media) { + $resolved[$cacheKey] = asset('assets/images/' . $filename); + return $resolved[$cacheKey]; + } + + if ($profile && $media->hasConversion($profile)) { + $resolved[$cacheKey] = $media->getConversionUrl($profile); + } else { + $resolved[$cacheKey] = $media->getUrl(); + } + + return $resolved[$cacheKey]; + } +} +``` + +### 2.2 Autoload registrieren + +```json +// composer.json → autoload +{ + "autoload": { + "files": ["app/helpers.php"] + } +} +``` + +```bash +composer dump-autoload +``` + +--- + +## 3. Admin-Oberfläche einrichten + +### 3.1 Layout erstellen + +Kopiere `core/resources/views/admin-reference/layout-cms.blade.php` nach `resources/views/components/layouts/cms.blade.php`. + +Passe die Sidebar-Navigation an: +- Branding/Logo +- Navigationspunkte: Dashboard, Inhalte, Medienbibliothek, News, Industries, Downloads, Team, FAQs, LinkedIn +- Benutzer-Menü + +**Wichtig:** Das Layout muss `` enthalten für die Benachrichtigungen. + +### 3.2 Admin Views kopieren + +```bash +mkdir -p resources/views/livewire/admin/cms/ + +cp package/flux-cms/core/resources/views/admin-reference/cms/*.blade.php \ + resources/views/livewire/admin/cms/ +``` + +| View | Datei | Beschreibung | +|------|-------|-------------| +| Dashboard | `dashboard-index.blade.php` | Übersicht mit Statistiken | +| Inhalte | `content-index.blade.php` | Haupteditor (Text/HTML/Image/JSON) | +| Medienbibliothek | `media-index.blade.php` | Zentrale Medienverwaltung (Grid+Liste) | +| News | `news-index.blade.php` | CRUD mit MediaPicker für Bild + PDF | +| Industries | `industries-index.blade.php` | CRUD mit Sortierung | +| FAQs | `faqs-index.blade.php` | CRUD nach Kategorien | +| LinkedIn | `linkedin-index.blade.php` | CRUD mit MediaPicker | +| Downloads | `downloads-index.blade.php` | CRUD für Case Studies/Capabilities/Stories | +| Team | `team-index.blade.php` | CRUD mit MediaPicker für Profilbilder | +| Suchindex | `search-index.blade.php` | Seitensuche: Keywords, Kategorien, Vorschau | + +### 3.3 Livewire-Komponenten einrichten + +Funktionale Volt-Komponenten können kein `WithFileUploads` verwenden. Daher gibt es class-based Livewire-Komponenten: + +```bash +# Multi-File Upload +cp package/flux-cms/core/src/Helpers/MediaLibraryUploader.php \ + app/Livewire/Admin/Cms/MediaLibraryUploader.php + +# Medienauswahl-Modal +cp package/flux-cms/core/src/Helpers/MediaPicker.php \ + app/Livewire/Admin/Cms/MediaPicker.php +``` + +Namespace in beiden Dateien anpassen: +```php +namespace App\Livewire\Admin\Cms; +``` + +### 3.4 Blade-Views für Livewire-Komponenten + +```bash +# MediaLibraryUploader View +cp package/flux-cms/core/resources/views/admin-reference/cms/media-library-uploader.blade.php \ + resources/views/livewire/admin/cms/media-library-uploader.blade.php + +# MediaPicker View +cp package/flux-cms/core/resources/views/admin-reference/cms/media-picker.blade.php \ + resources/views/livewire/admin/cms/media-picker.blade.php +``` + +### 3.5 Routes registrieren + +```php +// routes/web.php +use Livewire\Volt\Volt; + +Route::middleware(['auth'])->group(function () { + Volt::route('admin/cms', 'admin.cms.dashboard-index')->name('cms.dashboard'); + Volt::route('admin/cms/content', 'admin.cms.content-index')->name('cms.content.index'); + Volt::route('admin/cms/media', 'admin.cms.media-index')->name('cms.media.index'); + Volt::route('admin/cms/news', 'admin.cms.news-index')->name('cms.news.index'); + Volt::route('admin/cms/industries', 'admin.cms.industries-index')->name('cms.industries.index'); + Volt::route('admin/cms/faqs', 'admin.cms.faqs-index')->name('cms.faqs.index'); + Volt::route('admin/cms/linkedin', 'admin.cms.linkedin-index')->name('cms.linkedin.index'); + Volt::route('admin/cms/downloads', 'admin.cms.downloads-index')->name('cms.downloads.index'); + Volt::route('admin/cms/team', 'admin.cms.team-index')->name('cms.team.index'); + Volt::route('admin/cms/search-index', 'admin.cms.search-index')->name('cms.search-index'); +}); +``` + +--- + +## 4. Medienbibliothek einrichten + +### 4.1 Storage-Link + +```bash +php artisan storage:link +``` + +### 4.2 Storage-Verzeichnisse + +Die Verzeichnisse werden automatisch erstellt beim ersten Upload: +- `storage/app/public/cms/media/originals/` — Original-Uploads +- `storage/app/public/cms/media/conversions/` — Generierte Bildgrößen +- `storage/app/public/cms/media/thumbnails/` — Auto-Thumbnails + +### 4.3 Bildprofile konfigurieren + +In `config/flux-cms.php` die Conversion-Profile an dein Projekt anpassen: + +```php +'media' => [ + 'max_upload_size' => 20480, // KB + 'allowed_types' => ['jpg', 'jpeg', 'png', 'webp', 'gif', 'svg', 'pdf', 'doc', 'docx'], + 'storage_disk' => 'public', + 'profiles' => [ + 'hero' => ['width' => 1920, 'height' => 800, 'format' => 'webp', 'quality' => 85], + 'card' => ['width' => 768, 'height' => 512, 'format' => 'webp', 'quality' => 80], + 'thumbnail' => ['width' => 400, 'height' => 300, 'format' => 'webp', 'quality' => 75], + 'avatar' => ['width' => 400, 'height' => 400, 'format' => 'webp', 'quality' => 80], + 'news' => ['width' => 1200, 'height' => 630, 'format' => 'webp', 'quality' => 80], + 'thumb' => ['width' => 200, 'height' => 200, 'format' => 'webp', 'quality' => 70], + ], +], +``` + +### 4.4 HTTPS / Proxy + +Falls hinter einem Reverse Proxy, in `bootstrap/app.php`: + +```php +->withMiddleware(function (Middleware $middleware) { + $middleware->trustProxies(at: '*'); +}) +``` + +Und in `AppServiceProvider::boot()`: + +```php +if (request()->header('X-Forwarded-Proto') === 'https' || app()->environment('production')) { + URL::forceScheme('https'); +} +``` + +--- + +## 5. Inhalte importieren (Seeder) + +### 5.1 Seeders kopieren & anpassen + +```bash +cp package/flux-cms/core/database/seeders-reference/*.php database/seeders/ +``` + +### 5.2 CmsContentSeeder anpassen + +```php +protected array $skipFiles = [ + 'faqs', 'sections', 'validation', 'auth', 'passwords', 'pagination', +]; + +protected array $skipKeys = [ + 'components' => ['news_band', 'industries_band'], +]; +``` + +### 5.3 CmsMediaSeeder erstellen + +Erstelle einen Seeder, der alle hochgeladenen Medien und die `CmsContent`-Einträge vom Typ `image` wiederherstellt. Dieser wird nach dem `CmsContentSeeder` ausgeführt, um Image-Keys zu erzeugen (z.B. `welcome.hero.image` → Dateiname). + +### 5.4 CmsDownloadSeeder erstellen (optional) + +Falls du Downloads (Case Studies, Capabilities, Success Stories) verwendest, erstelle einen Seeder mit den vollständigen Inline-Daten. + +### 5.5 DatabaseSeeder registrieren + +```php +public function run(): void +{ + $this->call([ + CmsContentSeeder::class, + CmsMediaSeeder::class, + CmsNewsItemSeeder::class, + CmsIndustrySeeder::class, + CmsFaqSeeder::class, + CmsLinkedinPostSeeder::class, + CmsDownloadSeeder::class, + ]); +} +``` + +### 5.6 Seeding ausführen + +```bash +php artisan db:seed +``` + +--- + +## 6. Frontend umstellen + +### 6.1 `__()` durch `cms()` ersetzen + +```diff +- {{ __('welcome.hero.heading') }} ++ {{ cms('welcome.hero.heading') }} +``` + +### 6.2 Bilder über Medienbibliothek laden + +```diff +- ++ +``` + +Oder direkt über Dateiname: +```blade + +``` + +### 6.3 Downloads über CmsDownload + +```blade +@foreach (CmsDownload::published()->byCategory('case_study')->ordered()->get() as $dl) + +@endforeach +``` + +### 6.4 News über CmsNewsItem + +```blade +@php + $items = CmsNewsItem::published()->ordered()->get() + ->map(fn($i) => $i->toFrontendArray())->toArray(); +@endphp +``` + +### 6.5 :highlight Pattern + +```blade +{!! cms('welcome.solutions.heading', [ + 'highlight' => '' + . cms('welcome.solutions.heading_highlight') . '', +]) !!} +``` + +--- + +## 7. Tests einrichten + +### 7.1 Test-Dateien kopieren + +```bash +cp -r package/flux-cms/core/tests-reference/Feature/Cms tests/Feature/Cms +``` + +### 7.2 Tests ausführen + +```bash +php artisan test --filter=Cms +``` + +--- + +## 8. Anpassungen für dein Projekt + +### Sprachen hinzufügen + +`config/flux-cms.php`: +```php +'locales' => [ + 'de' => 'Deutsch', + 'en' => 'English', + 'fr' => 'Français', +], +``` + +### Neue Content-Typen + +1. Migration + Model mit `HasTranslations` +2. Admin-View als Volt-Komponente +3. Route registrieren, Sidebar-Link hinzufügen +4. `toFrontendArray()` Methode für Frontend-Integration +5. Seeder erstellen + +### Editor-Toolbar + +- **Standard** (`bold italic`): Normale Texte +- **Voll** (`heading | bold italic underline strike | bullet ordered blockquote | link`): Impressum, Datenschutz, News-Body + +### Icon-Auswahl (Performance) + +Die Icon-Auswahl nutzt Blade Heroicons. Nur der Name wird im Dropdown angezeigt, das SVG nur als Vorschau neben dem Select-Feld — damit werden nicht 2800+ SVGs gerendert. + +--- + +## Verzeichnisstruktur nach Installation + +``` +dein-projekt/ +├── app/ +│ ├── helpers.php # cms(), tcms(), cms_media_url(), media_url() +│ └── Livewire/Admin/Cms/ +│ ├── MediaLibraryUploader.php # Multi-File Upload +│ └── MediaPicker.php # Medienauswahl-Modal +├── config/ +│ └── flux-cms.php # CMS + Media Konfiguration +├── database/ +│ ├── migrations/ +│ │ └── *_create_flux_cms_*.php # Automatisch vom Package +│ └── seeders/ +│ ├── CmsContentSeeder.php # Lang → DB +│ ├── CmsMediaSeeder.php # Medien + Image-Content +│ ├── CmsDownloadSeeder.php # Case Studies etc. +│ ├── CmsNewsItemSeeder.php +│ ├── CmsIndustrySeeder.php +│ ├── CmsFaqSeeder.php +│ └── CmsLinkedinPostSeeder.php +├── package/flux-cms/core/ # Das Package +├── resources/views/ +│ ├── components/layouts/ +│ │ └── cms.blade.php # Admin-Layout +│ └── livewire/admin/cms/ +│ ├── content-index.blade.php # Content-Editor +│ ├── media-index.blade.php # Medienbibliothek +│ ├── media-library-uploader.blade.php +│ ├── media-picker.blade.php +│ ├── news-index.blade.php +│ ├── downloads-index.blade.php +│ ├── team-index.blade.php +│ ├── linkedin-index.blade.php +│ ├── industries-index.blade.php +│ ├── faqs-index.blade.php +│ ├── search-index.blade.php # Suchindex-Verwaltung +│ └── dashboard.blade.php # CMS-Dashboard +└── routes/web.php # CMS-Routes +``` diff --git a/packages/flux-cms/components/src/FluxCmsComponentsServiceProvider.php b/packages/flux-cms/components/src/FluxCmsComponentsServiceProvider.php index 2274d4b..8e49d51 100644 --- a/packages/flux-cms/components/src/FluxCmsComponentsServiceProvider.php +++ b/packages/flux-cms/components/src/FluxCmsComponentsServiceProvider.php @@ -2,10 +2,10 @@ namespace FluxCms\Components; -use Illuminate\Support\ServiceProvider; -use Livewire\Livewire; use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; +use Livewire\Livewire; use ReflectionClass; class FluxCmsComponentsServiceProvider extends ServiceProvider @@ -33,7 +33,7 @@ class FluxCmsComponentsServiceProvider extends ServiceProvider */ protected function bootViews(): void { - $this->loadViewsFrom(__DIR__ . '/../resources/views', 'flux-cms-components'); + $this->loadViewsFrom(__DIR__.'/../resources/views', 'flux-cms-components'); } /** @@ -44,12 +44,12 @@ class FluxCmsComponentsServiceProvider extends ServiceProvider if ($this->app->runningInConsole()) { // Publish views $this->publishes([ - __DIR__ . '/../resources/views' => resource_path('views/vendor/flux-cms-components'), + __DIR__.'/../resources/views' => resource_path('views/vendor/flux-cms-components'), ], 'flux-cms-components-views'); // Publish assets $this->publishes([ - __DIR__ . '/../resources/assets' => public_path('vendor/flux-cms-components'), + __DIR__.'/../resources/assets' => public_path('vendor/flux-cms-components'), ], 'flux-cms-components-assets'); } } @@ -59,22 +59,22 @@ class FluxCmsComponentsServiceProvider extends ServiceProvider */ protected function bootLivewireComponents(): void { - $this->registerLivewireComponentsFrom(__DIR__ . '/Livewire/Backend', 'FluxCms\\Components\\Livewire\\Backend', 'flux-cms::'); - $this->registerLivewireComponentsFrom(__DIR__ . '/Livewire/Frontend', 'FluxCms\\Components\\Livewire\\Frontend', 'flux-cms::'); + $this->registerLivewireComponentsFrom(__DIR__.'/Livewire/Backend', 'FluxCms\\Components\\Livewire\\Backend', 'flux-cms::'); + $this->registerLivewireComponentsFrom(__DIR__.'/Livewire/Frontend', 'FluxCms\\Components\\Livewire\\Frontend', 'flux-cms::'); } protected function registerLivewireComponentsFrom(string $path, string $namespace, string $aliasPrefix = ''): void { - $filesystem = new Filesystem(); - if (!$filesystem->isDirectory($path)) { + $filesystem = new Filesystem; + if (! $filesystem->isDirectory($path)) { return; } foreach ($filesystem->allFiles($path) as $file) { - $class = $namespace . '\\' . str_replace(['/', '.php'], ['\\', ''], $file->getRelativePathname()); + $class = $namespace.'\\'.str_replace(['/', '.php'], ['\\', ''], $file->getRelativePathname()); - if (class_exists($class) && is_subclass_of($class, \Livewire\Component::class) && !(new ReflectionClass($class))->isAbstract()) { - $alias = $aliasPrefix . Str::kebab(class_basename($class)); + if (class_exists($class) && is_subclass_of($class, \Livewire\Component::class) && ! (new ReflectionClass($class))->isAbstract()) { + $alias = $aliasPrefix.Str::kebab(class_basename($class)); Livewire::component($alias, $class); } } diff --git a/packages/flux-cms/components/src/Livewire/Backend/BlogEditor.php b/packages/flux-cms/components/src/Livewire/Backend/BlogEditor.php index ad666a8..2b386cd 100644 --- a/packages/flux-cms/components/src/Livewire/Backend/BlogEditor.php +++ b/packages/flux-cms/components/src/Livewire/Backend/BlogEditor.php @@ -2,13 +2,13 @@ namespace FluxCms\Components\Livewire\Backend; -use Livewire\Component; use FluxCms\Core\Models\BlogPost; -use Spatie\Tags\Tag; +use Livewire\Component; class BlogEditor extends Component { public BlogPost $post; + public string $tags = ''; public function mount(BlogPost $post) diff --git a/packages/flux-cms/components/src/Livewire/Backend/BlogManager.php b/packages/flux-cms/components/src/Livewire/Backend/BlogManager.php index aff13ec..fbd3af8 100644 --- a/packages/flux-cms/components/src/Livewire/Backend/BlogManager.php +++ b/packages/flux-cms/components/src/Livewire/Backend/BlogManager.php @@ -2,21 +2,26 @@ namespace FluxCms\Components\Livewire\Backend; +use FluxCms\Core\Models\BlogPost; use Livewire\Component; use Livewire\WithPagination; -use FluxCms\Core\Models\BlogPost; -use Illuminate\Support\Collection; class BlogManager extends Component { use WithPagination; public string $domainKey; + public array $availableLanguages = []; + public string $currentLocale = 'de'; + public string $search = ''; + public string $filterStatus = 'all'; + public bool $showCreateModal = false; + public ?BlogPost $editingPost = null; // Form data @@ -48,12 +53,12 @@ class BlogManager extends Component $query = BlogPost::forDomain($this->domainKey); // Search filter - if (!empty($this->search)) { + if (! empty($this->search)) { $query->where(function ($q) { - $q->where('title->de', 'like', '%' . $this->search . '%') - ->orWhere('title->en', 'like', '%' . $this->search . '%') - ->orWhere('content->de', 'like', '%' . $this->search . '%') - ->orWhere('content->en', 'like', '%' . $this->search . '%'); + $q->where('title->de', 'like', '%'.$this->search.'%') + ->orWhere('title->en', 'like', '%'.$this->search.'%') + ->orWhere('content->de', 'like', '%'.$this->search.'%') + ->orWhere('content->en', 'like', '%'.$this->search.'%'); }); } @@ -175,7 +180,7 @@ class BlogManager extends Component session()->flash('success', $message); } catch (\Exception $e) { - session()->flash('error', 'Error saving blog post: ' . $e->getMessage()); + session()->flash('error', 'Error saving blog post: '.$e->getMessage()); } } @@ -191,7 +196,7 @@ class BlogManager extends Component session()->flash('success', 'Blog post deleted successfully.'); } } catch (\Exception $e) { - session()->flash('error', 'Error deleting blog post: ' . $e->getMessage()); + session()->flash('error', 'Error deleting blog post: '.$e->getMessage()); } } @@ -213,7 +218,7 @@ class BlogManager extends Component session()->flash('success', $message); } } catch (\Exception $e) { - session()->flash('error', 'Error updating publish status: ' . $e->getMessage()); + session()->flash('error', 'Error updating publish status: '.$e->getMessage()); } } @@ -225,12 +230,12 @@ class BlogManager extends Component try { $post = BlogPost::find($postId); if ($post) { - $post->update(['is_featured' => !$post->is_featured]); + $post->update(['is_featured' => ! $post->is_featured]); $message = $post->is_featured ? 'Post marked as featured.' : 'Post removed from featured.'; session()->flash('success', $message); } } catch (\Exception $e) { - session()->flash('error', 'Error updating featured status: ' . $e->getMessage()); + session()->flash('error', 'Error updating featured status: '.$e->getMessage()); } } @@ -249,14 +254,14 @@ class BlogManager extends Component // Update title to indicate it's a copy $titles = $duplicate->getTranslations('title'); foreach ($titles as $locale => $title) { - $titles[$locale] = $title . ' (Copy)'; + $titles[$locale] = $title.' (Copy)'; } $duplicate->title = $titles; // Update slugs to avoid conflicts $slugs = $duplicate->getTranslations('slug'); foreach ($slugs as $locale => $slug) { - $slugs[$locale] = $slug . '-copy-' . time(); + $slugs[$locale] = $slug.'-copy-'.time(); } $duplicate->slug = $slugs; @@ -264,7 +269,7 @@ class BlogManager extends Component session()->flash('success', 'Blog post duplicated successfully.'); } } catch (\Exception $e) { - session()->flash('error', 'Error duplicating blog post: ' . $e->getMessage()); + session()->flash('error', 'Error duplicating blog post: '.$e->getMessage()); } } @@ -276,7 +281,7 @@ class BlogManager extends Component $title = $this->postData['title'][$locale] ?? ''; if ($title) { $slug = \Illuminate\Support\Str::slug($title); - $this->postData['slug'][$locale] = '/' . $slug; + $this->postData['slug'][$locale] = '/'.$slug; } } @@ -315,4 +320,4 @@ class BlogManager extends Component 'featured' => BlogPost::forDomain($this->domainKey)->featured()->count(), ]; } -} \ No newline at end of file +} diff --git a/packages/flux-cms/components/src/Livewire/Backend/ComponentEditor.php b/packages/flux-cms/components/src/Livewire/Backend/ComponentEditor.php index 6972765..008dff9 100644 --- a/packages/flux-cms/components/src/Livewire/Backend/ComponentEditor.php +++ b/packages/flux-cms/components/src/Livewire/Backend/ComponentEditor.php @@ -2,17 +2,22 @@ namespace FluxCms\Components\Livewire\Backend; -use Livewire\Component; use FluxCms\Core\Models\PageComponent; use FluxCms\Core\Services\ComponentRegistry; +use Livewire\Component; class ComponentEditor extends Component { public PageComponent $component; + public array $content = []; + public array $availableLanguages = []; + public string $currentLocale = 'de'; + public bool $expanded = false; + public array $validationErrors = []; protected ComponentRegistry $componentRegistry; @@ -56,7 +61,7 @@ class ComponentEditor extends Component */ public function toggleExpanded() { - $this->expanded = !$this->expanded; + $this->expanded = ! $this->expanded; } /** @@ -85,21 +90,22 @@ class ComponentEditor extends Component { $this->validateContent(); - if (!empty($this->validationErrors)) { + if (! empty($this->validationErrors)) { session()->flash('error', 'Please correct validation errors.'); + return; } try { $this->component->update([ - 'content' => $this->content + 'content' => $this->content, ]); session()->flash('success', 'Component saved successfully.'); $this->dispatch('component-saved', componentId: $this->component->id); } catch (\Exception $e) { - session()->flash('error', 'Error saving component: ' . $e->getMessage()); + session()->flash('error', 'Error saving component: '.$e->getMessage()); } } @@ -111,7 +117,7 @@ class ComponentEditor extends Component if (empty($this->validationErrors)) { try { $this->component->update([ - 'content' => $this->content + 'content' => $this->content, ]); } catch (\Exception $e) { // Silent fail for auto-save @@ -189,6 +195,7 @@ class ComponentEditor extends Component public function hasFieldError(string $fieldKey, ?string $locale = null): bool { $errorKey = $locale ? "{$fieldKey}.{$locale}" : $fieldKey; + return isset($this->validationErrors[$errorKey]); } @@ -198,6 +205,7 @@ class ComponentEditor extends Component public function getFieldErrors(string $fieldKey, ?string $locale = null): array { $errorKey = $locale ? "{$fieldKey}.{$locale}" : $fieldKey; + return $this->validationErrors[$errorKey] ?? []; } @@ -225,4 +233,4 @@ class ComponentEditor extends Component $this->content = $this->component->getTranslations('content'); $this->validationErrors = []; } -} \ No newline at end of file +} diff --git a/packages/flux-cms/components/src/Livewire/Backend/MediaManager.php b/packages/flux-cms/components/src/Livewire/Backend/MediaManager.php index 842ae46..e97b485 100644 --- a/packages/flux-cms/components/src/Livewire/Backend/MediaManager.php +++ b/packages/flux-cms/components/src/Livewire/Backend/MediaManager.php @@ -2,24 +2,32 @@ namespace FluxCms\Components\Livewire\Backend; +use Illuminate\Http\UploadedFile; use Livewire\Component; use Livewire\WithFileUploads; use Livewire\WithPagination; use Spatie\MediaLibrary\MediaCollections\Models\Media; -use Illuminate\Http\UploadedFile; class MediaManager extends Component { use WithFileUploads, WithPagination; public bool $showModal = false; + public ?string $targetComponentId = null; + public ?string $targetFieldKey = null; + public ?string $targetLocale = null; + public array $uploadingFiles = []; + public string $searchTerm = ''; + public string $filterType = 'all'; + public array $selectedMedia = []; + public bool $multiSelect = false; protected $paginationTheme = 'simple-bootstrap'; @@ -71,7 +79,7 @@ class MediaManager extends Component public function uploadFiles() { $this->validate([ - 'uploadingFiles.*' => 'file|max:' . config('flux-cms.media.max_file_size', 10240), + 'uploadingFiles.*' => 'file|max:'.config('flux-cms.media.max_file_size', 10240), ]); try { @@ -83,7 +91,7 @@ class MediaManager extends Component session()->flash('success', 'Files uploaded successfully.'); } catch (\Exception $e) { - session()->flash('error', 'Error uploading files: ' . $e->getMessage()); + session()->flash('error', 'Error uploading files: '.$e->getMessage()); } } @@ -94,8 +102,10 @@ class MediaManager extends Component { // Create a temporary model for media library // In real implementation, you'd use a dedicated media model - $mediaModel = new class extends \Illuminate\Database\Eloquent\Model implements \Spatie\MediaLibrary\HasMedia { + $mediaModel = new class extends \Illuminate\Database\Eloquent\Model implements \Spatie\MediaLibrary\HasMedia + { use \Spatie\MediaLibrary\InteractsWithMedia; + protected $table = 'flux_cms_media'; // Would exist in real implementation }; @@ -114,7 +124,7 @@ class MediaManager extends Component { if ($this->multiSelect) { if (in_array($mediaId, $this->selectedMedia)) { - $this->selectedMedia = array_filter($this->selectedMedia, fn($id) => $id !== $mediaId); + $this->selectedMedia = array_filter($this->selectedMedia, fn ($id) => $id !== $mediaId); } else { $this->selectedMedia[] = $mediaId; } @@ -165,7 +175,7 @@ class MediaManager extends Component session()->flash('success', 'Media deleted successfully.'); } } catch (\Exception $e) { - session()->flash('error', 'Error deleting media: ' . $e->getMessage()); + session()->flash('error', 'Error deleting media: '.$e->getMessage()); } } @@ -177,13 +187,13 @@ class MediaManager extends Component $query = Media::query()->orderBy('created_at', 'desc'); // Search filter - if (!empty($this->searchTerm)) { - $query->where('name', 'like', '%' . $this->searchTerm . '%'); + if (! empty($this->searchTerm)) { + $query->where('name', 'like', '%'.$this->searchTerm.'%'); } // Type filter if ($this->filterType !== 'all') { - $query->where('mime_type', 'like', $this->filterType . '%'); + $query->where('mime_type', 'like', $this->filterType.'%'); } return $query->paginate(20); @@ -271,6 +281,6 @@ class MediaManager extends Component $bytes /= (1 << (10 * $pow)); - return round($bytes, 2) . ' ' . $units[$pow]; + return round($bytes, 2).' '.$units[$pow]; } -} \ No newline at end of file +} diff --git a/packages/flux-cms/components/src/Livewire/Backend/NavigationManager.php b/packages/flux-cms/components/src/Livewire/Backend/NavigationManager.php index 67e832b..c1e1127 100644 --- a/packages/flux-cms/components/src/Livewire/Backend/NavigationManager.php +++ b/packages/flux-cms/components/src/Livewire/Backend/NavigationManager.php @@ -2,26 +2,35 @@ namespace FluxCms\Components\Livewire\Backend; -use Livewire\Component; use FluxCms\Core\Models\Navigation; use FluxCms\Core\Models\NavigationItem; use FluxCms\Core\Models\Page; use Illuminate\Support\Collection; +use Livewire\Component; class NavigationManager extends Component { public string $domainKey; + public Collection $navigations; + public ?Navigation $selectedNavigation = null; + public Collection $navigationItems; + public array $availableLanguages = []; + public string $currentLocale = 'de'; + public bool $showCreateModal = false; + public bool $showItemModal = false; + public ?NavigationItem $editingItem = null; // Form data public array $navigationData = []; + public array $itemData = []; public function mount(string $domainKey) @@ -58,8 +67,9 @@ class NavigationManager extends Component */ public function loadNavigationItems() { - if (!$this->selectedNavigation) { + if (! $this->selectedNavigation) { $this->navigationItems = collect(); + return; } @@ -124,7 +134,7 @@ class NavigationManager extends Component session()->flash('success', 'Navigation created successfully.'); } catch (\Exception $e) { - session()->flash('error', 'Error creating navigation: ' . $e->getMessage()); + session()->flash('error', 'Error creating navigation: '.$e->getMessage()); } } @@ -172,6 +182,7 @@ class NavigationManager extends Component // Validate that either page or external URL is provided if (empty($this->itemData['page_id']) && empty($this->itemData['external_url'])) { $this->addError('itemData.page_id', 'Either select a page or provide an external URL.'); + return; } @@ -205,7 +216,7 @@ class NavigationManager extends Component session()->flash('success', $message); } catch (\Exception $e) { - session()->flash('error', 'Error saving navigation item: ' . $e->getMessage()); + session()->flash('error', 'Error saving navigation item: '.$e->getMessage()); } } @@ -222,7 +233,7 @@ class NavigationManager extends Component session()->flash('success', 'Navigation item deleted successfully.'); } } catch (\Exception $e) { - session()->flash('error', 'Error deleting navigation item: ' . $e->getMessage()); + session()->flash('error', 'Error deleting navigation item: '.$e->getMessage()); } } @@ -234,11 +245,11 @@ class NavigationManager extends Component try { $item = NavigationItem::find($itemId); if ($item) { - $item->update(['is_active' => !$item->is_active]); + $item->update(['is_active' => ! $item->is_active]); $this->loadNavigationItems(); } } catch (\Exception $e) { - session()->flash('error', 'Error toggling navigation item: ' . $e->getMessage()); + session()->flash('error', 'Error toggling navigation item: '.$e->getMessage()); } } @@ -256,7 +267,7 @@ class NavigationManager extends Component session()->flash('success', 'Navigation order updated successfully.'); } catch (\Exception $e) { - session()->flash('error', 'Error updating order: ' . $e->getMessage()); + session()->flash('error', 'Error updating order: '.$e->getMessage()); } } @@ -279,7 +290,7 @@ class NavigationManager extends Component session()->flash('success', 'Navigation deleted successfully.'); } } catch (\Exception $e) { - session()->flash('error', 'Error deleting navigation: ' . $e->getMessage()); + session()->flash('error', 'Error deleting navigation: '.$e->getMessage()); } } @@ -288,7 +299,7 @@ class NavigationManager extends Component */ public function getAvailableParentsProperty(): Collection { - if (!$this->selectedNavigation) { + if (! $this->selectedNavigation) { return collect(); } @@ -327,4 +338,4 @@ class NavigationManager extends Component $this->navigationData = []; $this->itemData = []; } -} \ No newline at end of file +} diff --git a/packages/flux-cms/components/src/Livewire/Backend/PageEditor.php b/packages/flux-cms/components/src/Livewire/Backend/PageEditor.php index 860eec9..daed900 100644 --- a/packages/flux-cms/components/src/Livewire/Backend/PageEditor.php +++ b/packages/flux-cms/components/src/Livewire/Backend/PageEditor.php @@ -2,22 +2,29 @@ namespace FluxCms\Components\Livewire\Backend; -use Livewire\Component; -use Livewire\Attributes\On; use FluxCms\Core\Models\Page; use FluxCms\Core\Models\PageComponent; use FluxCms\Core\Services\ComponentRegistry; use Illuminate\Support\Collection; +use Livewire\Attributes\On; +use Livewire\Component; class PageEditor extends Component { public Page $page; + public Collection $components; + public array $availableLanguages = []; + public string $currentLocale = 'de'; + public bool $showComponentModal = false; + public array $availableComponents = []; + public string $selectedCategory = 'all'; + public bool $isLoading = false; protected ComponentRegistry $componentRegistry; @@ -39,7 +46,7 @@ class PageEditor extends Component public function render() { return view('flux-cms-components::livewire.backend.page-editor') - ->layout('flux-cms-components::layouts.admin'); + ->layout('flux-cms-components::layouts.admin'); } /** @@ -99,8 +106,9 @@ class PageEditor extends Component */ public function addComponent(string $componentClass) { - if (!$this->componentRegistry->isValidComponent($componentClass)) { + if (! $this->componentRegistry->isValidComponent($componentClass)) { $this->addError('component', 'Invalid component selected.'); + return; } @@ -121,7 +129,7 @@ class PageEditor extends Component session()->flash('success', 'Component added successfully.'); } catch (\Exception $e) { - $this->addError('component', 'Error adding component: ' . $e->getMessage()); + $this->addError('component', 'Error adding component: '.$e->getMessage()); } } @@ -133,8 +141,9 @@ class PageEditor extends Component try { $component = $this->components->firstWhere('id', $componentId); - if (!$component) { + if (! $component) { $this->addError('component', 'Component not found.'); + return; } @@ -145,7 +154,7 @@ class PageEditor extends Component session()->flash('success', 'Component deleted successfully.'); } catch (\Exception $e) { - $this->addError('component', 'Error deleting component: ' . $e->getMessage()); + $this->addError('component', 'Error deleting component: '.$e->getMessage()); } } @@ -157,8 +166,9 @@ class PageEditor extends Component try { $component = $this->components->firstWhere('id', $componentId); - if (!$component) { + if (! $component) { $this->addError('component', 'Component not found.'); + return; } @@ -169,7 +179,7 @@ class PageEditor extends Component session()->flash('success', 'Component duplicated successfully.'); } catch (\Exception $e) { - $this->addError('component', 'Error duplicating component: ' . $e->getMessage()); + $this->addError('component', 'Error duplicating component: '.$e->getMessage()); } } @@ -181,15 +191,15 @@ class PageEditor extends Component try { $component = $this->components->firstWhere('id', $componentId); - if (!$component) { + if (! $component) { return; } - $component->update(['is_active' => !$component->is_active]); + $component->update(['is_active' => ! $component->is_active]); $this->loadComponents(); } catch (\Exception $e) { - $this->addError('component', 'Error toggling component: ' . $e->getMessage()); + $this->addError('component', 'Error toggling component: '.$e->getMessage()); } } @@ -208,7 +218,7 @@ class PageEditor extends Component session()->flash('success', 'Component order updated.'); } catch (\Exception $e) { - $this->addError('order', 'Error updating order: ' . $e->getMessage()); + $this->addError('order', 'Error updating order: '.$e->getMessage()); } } @@ -237,7 +247,7 @@ class PageEditor extends Component } foreach ($config['fields'] as $field) { - if (!$field instanceof \FluxCms\Core\FieldTypes\BaseField) { + if (! $field instanceof \FluxCms\Core\FieldTypes\BaseField) { continue; } @@ -271,7 +281,7 @@ class PageEditor extends Component session()->flash('success', 'Page data saved successfully.'); } catch (\Exception $e) { - $this->addError('page', 'Error saving page: ' . $e->getMessage()); + $this->addError('page', 'Error saving page: '.$e->getMessage()); } } @@ -292,21 +302,21 @@ class PageEditor extends Component session()->flash('success', $message); } catch (\Exception $e) { - $this->addError('publish', 'Error updating publish status: ' . $e->getMessage()); + $this->addError('publish', 'Error updating publish status: '.$e->getMessage()); } } /** * Create version */ - public function createVersion(string $description = null) + public function createVersion(?string $description = null) { try { $this->page->createVersion($description, auth()->id()); session()->flash('success', 'Version created successfully.'); } catch (\Exception $e) { - $this->addError('version', 'Error creating version: ' . $e->getMessage()); + $this->addError('version', 'Error creating version: '.$e->getMessage()); } } @@ -320,10 +330,11 @@ class PageEditor extends Component if (empty($slug)) { $this->addError('preview', 'No slug available for current language.'); + return; } - $url = $this->page->getUrl($locale) . '?preview=1'; + $url = $this->page->getUrl($locale).'?preview=1'; $this->dispatch('open-preview', url: $url); } @@ -351,6 +362,7 @@ class PageEditor extends Component foreach ($this->availableComponents as $category => $categoryComponents) { $components = array_merge($components, $categoryComponents); } + return $components; } @@ -362,7 +374,7 @@ class PageEditor extends Component */ public function getPageStatusProperty(): string { - if (!$this->page->is_published) { + if (! $this->page->is_published) { return 'draft'; } @@ -372,4 +384,4 @@ class PageEditor extends Component return 'published'; } -} \ No newline at end of file +} diff --git a/packages/flux-cms/components/src/Livewire/Frontend/BlogList.php b/packages/flux-cms/components/src/Livewire/Frontend/BlogList.php index feb0918..c9dabcd 100644 --- a/packages/flux-cms/components/src/Livewire/Frontend/BlogList.php +++ b/packages/flux-cms/components/src/Livewire/Frontend/BlogList.php @@ -2,26 +2,34 @@ namespace FluxCms\Components\Livewire\Frontend; -use Livewire\Component; -use Livewire\WithPagination; use FluxCms\Core\Models\BlogPost; use Illuminate\Support\Collection; +use Livewire\Component; +use Livewire\WithPagination; class BlogList extends Component { use WithPagination; public string $domainKey; + public int $perPage = 12; + public bool $showFeatured = true; + public bool $showPagination = true; + public string $orderBy = 'published_at'; + public string $orderDirection = 'desc'; + public array $classes = []; // Filtering public string $search = ''; + public array $tags = []; + public ?string $category = null; protected $paginationTheme = 'simple-bootstrap'; @@ -63,12 +71,12 @@ class BlogList extends Component $query = BlogPost::forDomain($this->domainKey)->published(); // Search filter - if (!empty($this->search)) { + if (! empty($this->search)) { $locale = app()->getLocale(); $query->where(function ($q) use ($locale) { - $q->where("title->{$locale}", 'like', '%' . $this->search . '%') - ->orWhere("excerpt->{$locale}", 'like', '%' . $this->search . '%') - ->orWhere("content->{$locale}", 'like', '%' . $this->search . '%'); + $q->where("title->{$locale}", 'like', '%'.$this->search.'%') + ->orWhere("excerpt->{$locale}", 'like', '%'.$this->search.'%') + ->orWhere("content->{$locale}", 'like', '%'.$this->search.'%'); }); } @@ -119,6 +127,7 @@ class BlogList extends Component public function getPostTitle(BlogPost $post): string { $locale = app()->getLocale(); + return $post->getTranslation('title', $locale); } @@ -185,6 +194,7 @@ class BlogList extends Component { $defaultClasses = ['flux-cms-blog-list', 'blog-list']; $allClasses = array_merge($defaultClasses, $this->classes); + return implode(' ', $allClasses); } @@ -228,6 +238,7 @@ class BlogList extends Component public function getSearchPlaceholder(): string { $locale = app()->getLocale(); + return $locale === 'de' ? 'Blog durchsuchen...' : 'Search blog...'; } @@ -238,10 +249,10 @@ class BlogList extends Component { $locale = app()->getLocale(); - if (!empty($this->search)) { + if (! empty($this->search)) { return $locale === 'de' - ? 'Keine Artikel für "' . $this->search . '" gefunden.' - : 'No posts found for "' . $this->search . '".'; + ? 'Keine Artikel für "'.$this->search.'" gefunden.' + : 'No posts found for "'.$this->search.'".'; } return $locale === 'de' @@ -262,4 +273,4 @@ class BlogList extends Component return $minutes === 1 ? '1 min read' : "{$minutes} min read"; } -} \ No newline at end of file +} diff --git a/packages/flux-cms/components/src/Livewire/Frontend/BlogPost.php b/packages/flux-cms/components/src/Livewire/Frontend/BlogPost.php index 241084c..76984b0 100644 --- a/packages/flux-cms/components/src/Livewire/Frontend/BlogPost.php +++ b/packages/flux-cms/components/src/Livewire/Frontend/BlogPost.php @@ -2,18 +2,24 @@ namespace FluxCms\Components\Livewire\Frontend; -use Livewire\Component; use FluxCms\Core\Models\BlogPost as BlogPostModel; use Illuminate\Support\Collection; +use Livewire\Component; class BlogPost extends Component { public BlogPostModel $post; + public string $domainKey; + public bool $showRelated = true; + public bool $showAuthor = true; + public bool $showMeta = true; + public bool $showSocial = true; + public array $classes = []; public function mount( @@ -65,7 +71,7 @@ class BlogPost extends Component $locale = app()->getLocale(); return [ - 'title' => $this->getTitle() . ' - Blog', + 'title' => $this->getTitle().' - Blog', 'description' => $this->getExcerpt(160), 'keywords' => $this->post->getTranslation('meta_keywords', $locale), 'og_title' => $this->getTitle(), @@ -85,6 +91,7 @@ class BlogPost extends Component public function getTitle(): string { $locale = app()->getLocale(); + return $this->post->getTranslation('title', $locale); } @@ -94,6 +101,7 @@ class BlogPost extends Component public function getContent(): string { $locale = app()->getLocale(); + return $this->post->getTranslation('content', $locale); } @@ -228,6 +236,7 @@ class BlogPost extends Component public function getRelatedPostTitle(BlogPostModel $relatedPost): string { $locale = app()->getLocale(); + return $relatedPost->getTranslation('title', $locale); } @@ -311,4 +320,4 @@ class BlogPost extends Component 'next' => $nextPost, ]; } -} \ No newline at end of file +} diff --git a/packages/flux-cms/components/src/Livewire/Frontend/NavigationRenderer.php b/packages/flux-cms/components/src/Livewire/Frontend/NavigationRenderer.php index 917ddda..a4cfacf 100644 --- a/packages/flux-cms/components/src/Livewire/Frontend/NavigationRenderer.php +++ b/packages/flux-cms/components/src/Livewire/Frontend/NavigationRenderer.php @@ -2,18 +2,24 @@ namespace FluxCms\Components\Livewire\Frontend; -use Livewire\Component; use FluxCms\Core\Models\Navigation; use Illuminate\Support\Collection; +use Livewire\Component; class NavigationRenderer extends Component { public string $domainKey; + public string $navigationName; + public ?Navigation $navigation = null; + public Collection $navigationItems; + public string $currentUrl = ''; + public array $classes = []; + public bool $showInactive = false; public function mount( @@ -49,7 +55,7 @@ class NavigationRenderer extends Component $this->navigationItems = $this->navigation->getHierarchicalItems(); // Filter inactive items if needed - if (!$this->showInactive) { + if (! $this->showInactive) { $this->navigationItems = $this->navigationItems->where('is_active', true); } } else { @@ -79,6 +85,7 @@ class NavigationRenderer extends Component public function getItemLabel($item): string { $locale = app()->getLocale(); + return $item->getTranslation('label', $locale); } @@ -95,9 +102,10 @@ class NavigationRenderer extends Component */ public function getChildren($item): Collection { - if (!$this->showInactive) { + if (! $this->showInactive) { return $item->children->where('is_active', true); } + return $item->children; } @@ -114,8 +122,9 @@ class NavigationRenderer extends Component */ public function getNavigationClasses(): string { - $defaultClasses = ['flux-cms-navigation', 'navigation-' . $this->navigationName]; + $defaultClasses = ['flux-cms-navigation', 'navigation-'.$this->navigationName]; $allClasses = array_merge($defaultClasses, $this->classes); + return implode(' ', $allClasses); } @@ -138,7 +147,7 @@ class NavigationRenderer extends Component $classes[] = 'nav-item--has-children'; } - if (!$item->is_active) { + if (! $item->is_active) { $classes[] = 'nav-item--inactive'; } @@ -177,7 +186,7 @@ class NavigationRenderer extends Component $attributeStrings = []; foreach ($attributes as $key => $value) { - $attributeStrings[] = $key . '="' . htmlspecialchars($value) . '"'; + $attributeStrings[] = $key.'="'.htmlspecialchars($value).'"'; } return implode(' ', $attributeStrings); @@ -239,11 +248,12 @@ class NavigationRenderer extends Component */ public function getNavigationDisplayName(): string { - if (!$this->navigation) { + if (! $this->navigation) { return ''; } $locale = app()->getLocale(); + return $this->navigation->getTranslation('display_name', $locale); } -} \ No newline at end of file +} diff --git a/packages/flux-cms/components/src/Livewire/Frontend/PageRenderer.php b/packages/flux-cms/components/src/Livewire/Frontend/PageRenderer.php index f112b62..bae4a03 100644 --- a/packages/flux-cms/components/src/Livewire/Frontend/PageRenderer.php +++ b/packages/flux-cms/components/src/Livewire/Frontend/PageRenderer.php @@ -2,15 +2,18 @@ namespace FluxCms\Components\Livewire\Frontend; -use Livewire\Component; use FluxCms\Core\Models\Page; use Illuminate\Support\Collection; +use Livewire\Component; class PageRenderer extends Component { public Page $page; + public Collection $components; + public bool $isPreview = false; + public array $seoData = []; public function mount(Page $page, bool $isPreview = false) @@ -24,10 +27,10 @@ class PageRenderer extends Component public function render() { return view('flux-cms-components::livewire.frontend.page-renderer') - ->layout('flux-cms-components::layouts.frontend', [ - 'seoData' => $this->seoData, - 'page' => $this->page, - ]); + ->layout('flux-cms-components::layouts.frontend', [ + 'seoData' => $this->seoData, + 'page' => $this->page, + ]); } /** @@ -84,6 +87,7 @@ class PageRenderer extends Component public function getComponentContent(PageComponent $component): array { $locale = app()->getLocale(); + return $component->getTranslatedContent($locale); } @@ -93,23 +97,24 @@ class PageRenderer extends Component public function renderComponent(PageComponent $component): string { try { - if (!$this->canRenderComponent($component)) { + if (! $this->canRenderComponent($component)) { return ''; } $content = $this->getComponentContent($component); // Check if component class exists - if (!class_exists($component->component_class)) { + if (! class_exists($component->component_class)) { if ($this->isPreview) { return $this->renderComponentError($component, 'Component class not found'); } + return ''; } // Render component $componentHtml = \Livewire\Livewire::mount($component->component_class, [ - 'content' => $component->getTranslations('content') + 'content' => $component->getTranslations('content'), ])->html(); // Wrap component if enabled @@ -123,7 +128,7 @@ class PageRenderer extends Component \Log::error('Error rendering component', [ 'component_id' => $component->id, 'component_class' => $component->component_class, - 'error' => $e->getMessage() + 'error' => $e->getMessage(), ]); if ($this->isPreview) { @@ -141,10 +146,10 @@ class PageRenderer extends Component { $classes = [ 'flux-cms-component', - 'flux-cms-component--' . class_basename($component->component_class), + 'flux-cms-component--'.class_basename($component->component_class), ]; - if (!$component->is_active) { + if (! $component->is_active) { $classes[] = 'flux-cms-component--inactive'; } @@ -157,7 +162,7 @@ class PageRenderer extends Component } $attributeString = collect($attributes) - ->map(fn($value, $key) => "{$key}=\"{$value}\"") + ->map(fn ($value, $key) => "{$key}=\"{$value}\"") ->implode(' '); $classString = implode(' ', $classes); @@ -196,9 +201,9 @@ class PageRenderer extends Component public function getRelatedPages(): Collection { return Page::forDomain($this->page->domain_key) - ->published() - ->where('id', '!=', $this->page->id) - ->limit(3) - ->get(); + ->published() + ->where('id', '!=', $this->page->id) + ->limit(3) + ->get(); } -} \ No newline at end of file +} diff --git a/packages/flux-cms/components/tests/Feature/Backend/BlogEditorTest.php b/packages/flux-cms/components/tests/Feature/Backend/BlogEditorTest.php index 736bc6c..ddf8d37 100644 --- a/packages/flux-cms/components/tests/Feature/Backend/BlogEditorTest.php +++ b/packages/flux-cms/components/tests/Feature/Backend/BlogEditorTest.php @@ -2,10 +2,10 @@ namespace FluxCms\Components\Tests\Feature\Backend; -use Livewire\Livewire; -use FluxCms\Core\Models\BlogPost; use FluxCms\Components\Livewire\Backend\BlogEditor; +use FluxCms\Core\Models\BlogPost; use Illuminate\Foundation\Testing\RefreshDatabase; +use Livewire\Livewire; use Orchestra\Testbench\TestCase; class BlogEditorTest extends TestCase diff --git a/packages/flux-cms/core/composer.json b/packages/flux-cms/core/composer.json index 1b2e91e..893c412 100644 --- a/packages/flux-cms/core/composer.json +++ b/packages/flux-cms/core/composer.json @@ -19,9 +19,7 @@ "require": { "php": "^8.2", "laravel/framework": "^11.0|^12.0", - "spatie/laravel-translatable": "^6.0", - "spatie/laravel-medialibrary": "^11.0", - "spatie/laravel-tags": "^4.0" + "spatie/laravel-translatable": "^6.0" }, "require-dev": { "orchestra/testbench": "^9.0", @@ -53,4 +51,4 @@ "pestphp/pest-plugin": true } } -} +} \ No newline at end of file diff --git a/packages/flux-cms/core/config/flux-cms.php b/packages/flux-cms/core/config/flux-cms.php index 605f8a0..7871d5c 100644 --- a/packages/flux-cms/core/config/flux-cms.php +++ b/packages/flux-cms/core/config/flux-cms.php @@ -143,27 +143,61 @@ return [ 'media' => [ 'disk' => env('FLUX_CMS_MEDIA_DISK', 'public'), 'max_file_size' => env('FLUX_CMS_MAX_FILE_SIZE', 10240), // 10MB in KB + 'originals_path' => 'cms/media/originals', + 'conversions_path' => 'cms/media/conversions', 'allowed_extensions' => [ 'images' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'], 'documents' => ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'csv'], - 'videos' => ['mp4', 'webm', 'ogg', 'avi', 'mov'], - 'audio' => ['mp3', 'wav', 'ogg', 'flac'], ], - 'conversions' => [ + 'profiles' => [ 'thumb' => [ 'width' => 300, 'height' => 300, - 'fit' => 'crop', + 'format' => 'webp', + 'quality' => 80, + 'fit' => 'cover', ], - 'medium' => [ + 'hero' => [ + 'width' => 1920, + 'height' => 800, + 'format' => 'webp', + 'quality' => 85, + 'fit' => 'cover', + ], + 'service' => [ 'width' => 800, 'height' => 600, - 'fit' => 'contain', + 'format' => 'webp', + 'quality' => 85, + 'fit' => 'cover', ], - 'large' => [ + 'avatar' => [ + 'width' => 400, + 'height' => 400, + 'format' => 'webp', + 'quality' => 85, + 'fit' => 'cover', + ], + 'news' => [ 'width' => 1200, - 'height' => 900, - 'fit' => 'contain', + 'height' => 400, + 'format' => 'webp', + 'quality' => 85, + 'fit' => 'cover', + ], + 'thumbnail' => [ + 'width' => 600, + 'height' => 400, + 'format' => 'webp', + 'quality' => 80, + 'fit' => 'cover', + ], + 'og_image' => [ + 'width' => 1200, + 'height' => 630, + 'format' => 'jpg', + 'quality' => 90, + 'fit' => 'cover', ], ], ], @@ -184,12 +218,22 @@ return [ 'basic' => ['bold', 'italic'], 'standard' => ['bold', 'italic', 'link', 'bulletList', 'orderedList'], 'full' => [ - 'bold', 'italic', 'underline', 'strike', - 'heading1', 'heading2', 'heading3', - 'bulletList', 'orderedList', - 'link', 'image', 'table', - 'code', 'codeBlock', - 'quote', 'rule' + 'bold', + 'italic', + 'underline', + 'strike', + 'heading1', + 'heading2', + 'heading3', + 'bulletList', + 'orderedList', + 'link', + 'image', + 'table', + 'code', + 'codeBlock', + 'quote', + 'rule', ], ], ], @@ -302,4 +346,4 @@ return [ 'auto_detect' => env('FLUX_CMS_AUTO_DETECT_DOMAIN', true), ], -]; \ No newline at end of file +]; diff --git a/packages/flux-cms/core/database/migrations/2025_01_01_000001_create_flux_cms_contents_table.php b/packages/flux-cms/core/database/migrations/2025_01_01_000001_create_flux_cms_contents_table.php new file mode 100644 index 0000000..affd6c7 --- /dev/null +++ b/packages/flux-cms/core/database/migrations/2025_01_01_000001_create_flux_cms_contents_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('group')->index(); + $table->string('key'); + $table->string('type')->default('text'); + $table->json('value')->nullable(); + $table->integer('order')->default(0); + $table->timestamps(); + + $table->unique(['group', 'key']); + }); + } + + public function down(): void + { + Schema::dropIfExists('flux_cms_contents'); + } +}; diff --git a/packages/flux-cms/core/database/migrations/2025_01_01_000002_create_flux_cms_downloads_table.php b/packages/flux-cms/core/database/migrations/2025_01_01_000002_create_flux_cms_downloads_table.php new file mode 100644 index 0000000..1ef3df3 --- /dev/null +++ b/packages/flux-cms/core/database/migrations/2025_01_01_000002_create_flux_cms_downloads_table.php @@ -0,0 +1,30 @@ +id(); + $table->json('title'); + $table->json('description')->nullable(); + $table->string('category'); + $table->string('file_path')->nullable(); + $table->string('thumbnail')->nullable(); + $table->boolean('is_published')->default(true); + $table->integer('order')->default(0); + $table->timestamps(); + + $table->index('category'); + }); + } + + public function down(): void + { + Schema::dropIfExists('flux_cms_downloads'); + } +}; diff --git a/packages/flux-cms/core/database/migrations/2025_01_01_000003_create_flux_cms_linkedin_posts_table.php b/packages/flux-cms/core/database/migrations/2025_01_01_000003_create_flux_cms_linkedin_posts_table.php new file mode 100644 index 0000000..a98f415 --- /dev/null +++ b/packages/flux-cms/core/database/migrations/2025_01_01_000003_create_flux_cms_linkedin_posts_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('linkedin_id')->nullable()->unique(); + $table->json('title'); + $table->json('excerpt')->nullable(); + $table->json('content')->nullable(); + $table->string('author')->nullable(); + $table->date('date')->nullable(); + $table->string('url')->nullable(); + $table->string('image')->nullable(); + $table->json('tags')->nullable(); + $table->string('source')->default('manual'); + $table->boolean('is_published')->default(true); + $table->integer('order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('flux_cms_linkedin_posts'); + } +}; diff --git a/packages/flux-cms/core/database/migrations/2025_01_01_000004_create_flux_cms_faqs_table.php b/packages/flux-cms/core/database/migrations/2025_01_01_000004_create_flux_cms_faqs_table.php new file mode 100644 index 0000000..d6a74e4 --- /dev/null +++ b/packages/flux-cms/core/database/migrations/2025_01_01_000004_create_flux_cms_faqs_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('category')->index(); + $table->json('question'); + $table->json('answer'); + $table->json('help')->nullable(); + $table->boolean('is_published')->default(true); + $table->integer('order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('flux_cms_faqs'); + } +}; diff --git a/packages/flux-cms/core/database/migrations/2025_01_01_000005_create_flux_cms_news_items_table.php b/packages/flux-cms/core/database/migrations/2025_01_01_000005_create_flux_cms_news_items_table.php new file mode 100644 index 0000000..c0ad431 --- /dev/null +++ b/packages/flux-cms/core/database/migrations/2025_01_01_000005_create_flux_cms_news_items_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('icon')->nullable(); + $table->json('text'); + $table->json('title'); + $table->json('excerpt')->nullable(); + $table->json('content')->nullable(); + $table->string('image')->nullable(); + $table->date('date')->nullable(); + $table->string('author')->nullable(); + $table->string('link')->nullable(); + $table->string('pdf_path')->nullable(); + $table->json('pdf_open_text')->nullable(); + $table->json('pdf_download_text')->nullable(); + $table->boolean('is_published')->default(true); + $table->integer('order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('flux_cms_news_items'); + } +}; diff --git a/packages/flux-cms/core/database/migrations/2025_01_01_000006_create_flux_cms_industries_table.php b/packages/flux-cms/core/database/migrations/2025_01_01_000006_create_flux_cms_industries_table.php new file mode 100644 index 0000000..dc5c5cc --- /dev/null +++ b/packages/flux-cms/core/database/migrations/2025_01_01_000006_create_flux_cms_industries_table.php @@ -0,0 +1,24 @@ +id(); + $table->json('name'); + $table->boolean('is_published')->default(true); + $table->integer('order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('flux_cms_industries'); + } +}; diff --git a/packages/flux-cms/core/database/migrations/2025_01_01_000007_create_flux_cms_media_table.php b/packages/flux-cms/core/database/migrations/2025_01_01_000007_create_flux_cms_media_table.php new file mode 100644 index 0000000..85abd77 --- /dev/null +++ b/packages/flux-cms/core/database/migrations/2025_01_01_000007_create_flux_cms_media_table.php @@ -0,0 +1,38 @@ +id(); + $table->string('filename'); + $table->string('disk')->default('public'); + $table->string('path'); + $table->string('type')->default('image'); + $table->string('mime_type')->nullable(); + $table->unsignedBigInteger('file_size')->default(0); + $table->unsignedInteger('original_width')->nullable(); + $table->unsignedInteger('original_height')->nullable(); + $table->json('alt_text')->nullable(); + $table->json('title')->nullable(); + $table->string('collection')->nullable()->index(); + $table->json('conversions')->nullable(); + $table->boolean('is_published')->default(true); + $table->unsignedInteger('order')->default(0); + $table->timestamps(); + + $table->index(['type', 'collection']); + $table->index('is_published'); + }); + } + + public function down(): void + { + Schema::dropIfExists('flux_cms_media'); + } +}; diff --git a/packages/flux-cms/core/database/migrations/2025_01_01_000008_create_flux_cms_search_index_table.php b/packages/flux-cms/core/database/migrations/2025_01_01_000008_create_flux_cms_search_index_table.php new file mode 100644 index 0000000..eb2b58c --- /dev/null +++ b/packages/flux-cms/core/database/migrations/2025_01_01_000008_create_flux_cms_search_index_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('item_id')->unique(); + $table->string('route'); + $table->json('route_params')->nullable(); + $table->json('category'); + $table->string('title_key')->nullable(); + $table->json('title_fallback')->nullable(); + $table->string('description_key')->nullable(); + $table->string('description_fallback_key')->nullable(); + $table->json('description_fallback_text')->nullable(); + $table->json('keywords'); + $table->boolean('is_published')->default(true); + $table->integer('order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('flux_cms_search_index'); + } +}; diff --git a/packages/flux-cms/core/database/migrations/2024_01_01_000001_create_flux_cms_pages_table.php b/packages/flux-cms/core/database/migrations/legacy/2024_01_01_000001_create_flux_cms_pages_table.php similarity index 100% rename from packages/flux-cms/core/database/migrations/2024_01_01_000001_create_flux_cms_pages_table.php rename to packages/flux-cms/core/database/migrations/legacy/2024_01_01_000001_create_flux_cms_pages_table.php diff --git a/packages/flux-cms/core/database/migrations/2024_01_01_000002_create_flux_cms_page_components_table.php b/packages/flux-cms/core/database/migrations/legacy/2024_01_01_000002_create_flux_cms_page_components_table.php similarity index 99% rename from packages/flux-cms/core/database/migrations/2024_01_01_000002_create_flux_cms_page_components_table.php rename to packages/flux-cms/core/database/migrations/legacy/2024_01_01_000002_create_flux_cms_page_components_table.php index bebe2c0..8db4fcd 100644 --- a/packages/flux-cms/core/database/migrations/2024_01_01_000002_create_flux_cms_page_components_table.php +++ b/packages/flux-cms/core/database/migrations/legacy/2024_01_01_000002_create_flux_cms_page_components_table.php @@ -29,4 +29,4 @@ return new class extends Migration { Schema::dropIfExists('flux_cms_page_components'); } -}; \ No newline at end of file +}; diff --git a/packages/flux-cms/core/database/migrations/2024_01_01_000003_create_flux_cms_page_versions_table.php b/packages/flux-cms/core/database/migrations/legacy/2024_01_01_000003_create_flux_cms_page_versions_table.php similarity index 99% rename from packages/flux-cms/core/database/migrations/2024_01_01_000003_create_flux_cms_page_versions_table.php rename to packages/flux-cms/core/database/migrations/legacy/2024_01_01_000003_create_flux_cms_page_versions_table.php index 91e725d..24f80db 100644 --- a/packages/flux-cms/core/database/migrations/2024_01_01_000003_create_flux_cms_page_versions_table.php +++ b/packages/flux-cms/core/database/migrations/legacy/2024_01_01_000003_create_flux_cms_page_versions_table.php @@ -30,4 +30,4 @@ return new class extends Migration { Schema::dropIfExists('flux_cms_page_versions'); } -}; \ No newline at end of file +}; diff --git a/packages/flux-cms/core/database/migrations/2024_01_01_000004_create_flux_cms_navigations_table.php b/packages/flux-cms/core/database/migrations/legacy/2024_01_01_000004_create_flux_cms_navigations_table.php similarity index 99% rename from packages/flux-cms/core/database/migrations/2024_01_01_000004_create_flux_cms_navigations_table.php rename to packages/flux-cms/core/database/migrations/legacy/2024_01_01_000004_create_flux_cms_navigations_table.php index f4d33ed..ed4eb19 100644 --- a/packages/flux-cms/core/database/migrations/2024_01_01_000004_create_flux_cms_navigations_table.php +++ b/packages/flux-cms/core/database/migrations/legacy/2024_01_01_000004_create_flux_cms_navigations_table.php @@ -27,4 +27,4 @@ return new class extends Migration { Schema::dropIfExists('flux_cms_navigations'); } -}; \ No newline at end of file +}; diff --git a/packages/flux-cms/core/database/migrations/2024_01_01_000005_create_flux_cms_navigation_items_table.php b/packages/flux-cms/core/database/migrations/legacy/2024_01_01_000005_create_flux_cms_navigation_items_table.php similarity index 99% rename from packages/flux-cms/core/database/migrations/2024_01_01_000005_create_flux_cms_navigation_items_table.php rename to packages/flux-cms/core/database/migrations/legacy/2024_01_01_000005_create_flux_cms_navigation_items_table.php index 0499a79..9097a19 100644 --- a/packages/flux-cms/core/database/migrations/2024_01_01_000005_create_flux_cms_navigation_items_table.php +++ b/packages/flux-cms/core/database/migrations/legacy/2024_01_01_000005_create_flux_cms_navigation_items_table.php @@ -32,4 +32,4 @@ return new class extends Migration { Schema::dropIfExists('flux_cms_navigation_items'); } -}; \ No newline at end of file +}; diff --git a/packages/flux-cms/core/database/migrations/2024_01_01_000006_create_flux_cms_blog_posts_table.php b/packages/flux-cms/core/database/migrations/legacy/2024_01_01_000006_create_flux_cms_blog_posts_table.php similarity index 100% rename from packages/flux-cms/core/database/migrations/2024_01_01_000006_create_flux_cms_blog_posts_table.php rename to packages/flux-cms/core/database/migrations/legacy/2024_01_01_000006_create_flux_cms_blog_posts_table.php diff --git a/packages/flux-cms/core/database/migrations/2024_01_01_000007_create_tag_tables.php b/packages/flux-cms/core/database/migrations/legacy/2024_01_01_000007_create_tag_tables.php similarity index 100% rename from packages/flux-cms/core/database/migrations/2024_01_01_000007_create_tag_tables.php rename to packages/flux-cms/core/database/migrations/legacy/2024_01_01_000007_create_tag_tables.php diff --git a/packages/flux-cms/core/database/migrations/2024_01_01_000008_create_flux_cms_slugs_table.php b/packages/flux-cms/core/database/migrations/legacy/2024_01_01_000008_create_flux_cms_slugs_table.php similarity index 100% rename from packages/flux-cms/core/database/migrations/2024_01_01_000008_create_flux_cms_slugs_table.php rename to packages/flux-cms/core/database/migrations/legacy/2024_01_01_000008_create_flux_cms_slugs_table.php diff --git a/packages/flux-cms/core/database/seeders-reference/CmsContentSeeder.php b/packages/flux-cms/core/database/seeders-reference/CmsContentSeeder.php new file mode 100644 index 0000000..b178d3c --- /dev/null +++ b/packages/flux-cms/core/database/seeders-reference/CmsContentSeeder.php @@ -0,0 +1,186 @@ + + */ + protected array $skipFiles = [ + 'faqs', + 'sections', + 'search_index', + 'validation', + 'auth', + 'passwords', + 'pagination', + ]; + + /** + * Keys to skip within specific groups (handled by dedicated models). + * + * @var array> + */ + protected array $skipKeys = [ + 'components' => ['news_band', 'industries_band'], + ]; + + public function run(): void + { + $locales = ['de', 'en']; + + $deFiles = glob(lang_path('de/*.php')); + if (! $deFiles) { + return; + } + + foreach ($deFiles as $filePath) { + $group = pathinfo($filePath, PATHINFO_FILENAME); + + if (in_array($group, $this->skipFiles)) { + continue; + } + + $translations = []; + foreach ($locales as $locale) { + $localePath = lang_path("{$locale}/{$group}.php"); + if (file_exists($localePath)) { + $translations[$locale] = require $localePath; + } + } + + if (empty($translations)) { + continue; + } + + $deData = $translations['de'] ?? []; + $enData = $translations['en'] ?? []; + + $flatDe = $this->flatten($deData); + $flatEn = $this->flatten($enData); + + $allKeys = array_keys($flatDe); + foreach (array_keys($flatEn) as $enKey) { + if (! in_array($enKey, $allKeys)) { + $allKeys[] = $enKey; + } + } + + $order = 0; + foreach ($allKeys as $key) { + if ($this->shouldSkipKey($group, $key)) { + continue; + } + + $deValue = $flatDe[$key] ?? null; + $enValue = $flatEn[$key] ?? null; + + $type = $this->detectType($deValue ?? $enValue); + + $translatedValue = []; + if ($deValue !== null) { + $translatedValue['de'] = is_string($deValue) ? $this->cleanHtml($deValue) : $deValue; + } + if ($enValue !== null) { + $translatedValue['en'] = is_string($enValue) ? $this->cleanHtml($enValue) : $enValue; + } + + CmsContent::updateOrCreate( + ['group' => $group, 'key' => $key], + [ + 'type' => $type, + 'value' => $translatedValue, + 'order' => $order++, + ] + ); + } + } + } + + /** + * Flatten a nested array into dot-notation keys. + * Arrays of objects (indexed arrays) are stored as JSON type. + * + * @return array + */ + protected function flatten(array $array, string $prefix = ''): array + { + $result = []; + + foreach ($array as $key => $value) { + $fullKey = $prefix ? "{$prefix}.{$key}" : (string) $key; + + if (is_array($value) && ! Arr::isAssoc($value)) { + $result[$fullKey] = $value; + } elseif (is_array($value)) { + $result = array_merge($result, $this->flatten($value, $fullKey)); + } else { + $result[$fullKey] = $value; + } + } + + return $result; + } + + protected function detectType(mixed $value): string + { + if (is_array($value)) { + return 'json'; + } + + if (! is_string($value)) { + return 'text'; + } + + if (preg_match('/<[a-z][\s\S]*>/i', $value)) { + return 'html'; + } + + if (preg_match('/\.(jpg|jpeg|png|gif|webp|svg)(\?.*)?$/i', $value)) { + return 'image'; + } + + if (preg_match('/\.(pdf|doc|docx)$/i', $value)) { + return 'link'; + } + + return 'text'; + } + + /** + * Clean HTML by converting font-weight spans to tags. + * Preserves text-gradient-premium spans and email-protection spans. + */ + protected function cleanHtml(string $value): string + { + $value = preg_replace( + '/(\s*)(.*?)<\/span>/si', + '$1$2', + $value + ); + + return $value; + } + + protected function shouldSkipKey(string $group, string $key): bool + { + if (! isset($this->skipKeys[$group])) { + return false; + } + + foreach ($this->skipKeys[$group] as $skipPrefix) { + if ($key === $skipPrefix || str_starts_with($key, "{$skipPrefix}.")) { + return true; + } + } + + return false; + } +} diff --git a/packages/flux-cms/core/database/seeders-reference/CmsDownloadSeeder.php b/packages/flux-cms/core/database/seeders-reference/CmsDownloadSeeder.php new file mode 100644 index 0000000..f2a1b9e --- /dev/null +++ b/packages/flux-cms/core/database/seeders-reference/CmsDownloadSeeder.php @@ -0,0 +1,218 @@ +getItems(); + + foreach ($items as $index => $item) { + CmsDownload::updateOrCreate( + [ + 'category' => $item['category'], + 'order' => $index, + ], + [ + 'title' => $item['title'], + 'description' => $item['description'], + 'icon' => $item['icon'], + 'sub_category' => $item['sub_category'], + 'type_label' => $item['type_label'], + 'alt' => $item['alt'], + 'thumbnail' => $item['thumbnail'], + 'file_path' => $item['file_path'], + 'open_text' => $item['open_text'], + 'download_text' => $item['download_text'], + 'highlights' => $item['highlights'] ?? null, + 'checkpoints' => $item['checkpoints'] ?? null, + 'is_published' => true, + ] + ); + } + } + + /** + * @return array> + */ + private function getItems(): array + { + return [ + // === Case Studies === + [ + 'category' => 'case_study', + 'title' => ['de' => 'Hair-Care R&D Product Support', 'en' => 'Hair-Care R&D Product Support'], + 'description' => ['de' => 'Ein globaler FMCG-Kunde im Bereich Hair Care musste eine komplexe R&D-Roadmap umsetzen und wir die Koordination von rund 5 parallelen Entwicklungsinitiativen koordinierten.', 'en' => 'A global FMCG client in the hair care segment needed to implement a complex R&D roadmap. We coordinated approximately 5 parallel development initiatives.'], + 'icon' => 'document-chart-bar', + 'sub_category' => 'R&D Product Support', + 'type_label' => ['de' => 'Case Study', 'en' => 'Case Study'], + 'alt' => ['de' => 'Case Study Hair-Care R&D Product Support', 'en' => 'Case Study Hair-Care R&D Product Support'], + 'thumbnail' => 'case-study-7011.webp', + 'file_path' => ['de' => 'inno-projekt-Case_Study_7011_HairCareRD_ProductSupport_de.pdf', 'en' => 'inno-projekt-Case_Study_7011_HairCareRD_ProductSupport_en.pdf'], + 'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'], + 'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'], + 'highlights' => [['value' => '100%', 'label' => 'Dokumentations-
konformität'], ['value' => '5', 'label' => 'parallele
Entwicklungsinitiativen']], + ], + [ + 'category' => 'case_study', + 'title' => ['de' => 'Lab Support Data Integration', 'en' => 'Lab Support Data Integration'], + 'description' => ['de' => 'Eie wir für einen globalen FMCG-Player die Verpackungsvalidierung standardisierten und durch direkte Systemintegration die "Time-to-Result" signifikant beschleunigten.', 'en' => 'How we standardized packaging validation for a global FMCG player and significantly accelerated "Time-to-Result" through direct system integration.'], + 'icon' => 'document-chart-bar', + 'sub_category' => 'Lab Support Data Integration', + 'type_label' => ['de' => 'Case Study', 'en' => 'Case Study'], + 'alt' => ['de' => 'Case Study Lab Support Data Integration', 'en' => 'Case Study Lab Support Data Integration'], + 'thumbnail' => 'case-study-7012.webp', + 'file_path' => ['de' => 'inno-projekt-Case_Study_7012_LabSupport_DataIntegration_de.pdf', 'en' => 'inno-projekt-Case_Study_7012_LabSupport_DataIntegration_en.pdf'], + 'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'], + 'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'], + 'highlights' => [['value' => '20%', 'label' => 'Zeitgewinn'], ['value' => '100%', 'label' => 'Systemintegration
 ']], + ], + [ + 'category' => 'case_study', + 'title' => ['de' => 'Fragrance Pump Experience', 'en' => 'Fragrance Pump Experience'], + 'description' => ['de' => 'Wie wir für einen internationalen Premium-Konzern das subjektive Sprühgefühl von Parfüm in belastbare Ingenieursdaten übersetzten und das perfekte Markenerlebnis definierten.', 'en' => 'How we translated the subjective spray feel of perfume into robust engineering data for an international premium corporation and defined the perfect brand experience.'], + 'icon' => 'document-chart-bar', + 'sub_category' => 'Fragrance Pump Experience', + 'type_label' => ['de' => 'Case Study', 'en' => 'Case Study'], + 'alt' => ['de' => 'Case Study Fragrance Pump Experience', 'en' => 'Case Study Fragrance Pump Experience'], + 'thumbnail' => 'case-study-7013.webp', + 'file_path' => ['de' => 'inno-projekt-Case_Study_7013_FragrancePump_Experience_de.pdf', 'en' => 'inno-projekt-Case_Study_7013_FragrancePump_Experience_en.pdf'], + 'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'], + 'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'], + 'highlights' => [['value' => '∞', 'label' => 'Objektivierung-
von Emotionen'], ['value' => '∞', 'label' => 'Globale
Differenzierung']], + ], + [ + 'category' => 'case_study', + 'title' => ['de' => 'Master Data Excellence & Prozess-Outsourcing', 'en' => 'Master Data Excellence & Process Outsourcing'], + 'description' => ['de' => 'Ein globaler FMCG-Konzern stand vor der Herausforderung, seine internen F&E-Teams von hochadministrativem Aufwand zu befreien', 'en' => 'A global FMCG corporation faced the challenge of relieving its internal R&D teams from highly administrative workload.'], + 'icon' => 'document-chart-bar', + 'sub_category' => 'Master Data Excellence', + 'type_label' => ['de' => 'Case Study', 'en' => 'Case Study'], + 'alt' => ['de' => 'Case Study Master Data Excellence & Prozess-Outsourcing', 'en' => 'Case Study Master Data Excellence & Process Outsourcing'], + 'thumbnail' => 'case-study-7010.webp', + 'file_path' => ['de' => 'inno-projekt-Case_Study_7010_MasterDataExcellence_de.pdf', 'en' => 'inno-projekt-Case_Study_7010_MasterDataExcellence_en.pdf'], + 'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'], + 'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'], + 'highlights' => [['value' => '~60%', 'label' => 'Zeitgewinn'], ['value' => '20-25%', 'label' => 'Kosteneffizienz
 ']], + ], + // === Capabilities === + [ + 'category' => 'capability', + 'title' => ['de' => 'Global Player', 'en' => 'Global Player'], + 'description' => ['de' => 'Beherrschen Sie globale Komplexität. Skalieren Sie Innovationen sicher über Märkte und Werke hinweg.', 'en' => 'Master global complexity. Scale innovations safely across markets and plants.'], + 'icon' => 'document-text', + 'sub_category' => 'Globale Player & Internationale Projekte', + 'type_label' => ['de' => 'Capability', 'en' => 'Capability'], + 'alt' => ['de' => 'Capability Profile Global Player', 'en' => 'Capability Profile Global Player'], + 'thumbnail' => 'global-player.webp', + 'file_path' => ['de' => 'inno-projekt-Capability_9101_GlobalPlayer_de.pdf', 'en' => 'inno-projekt-Capability_9101_GlobalPlayer_en.pdf'], + 'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'], + 'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'], + 'checkpoints' => [['value' => 'Etablieren Sie globale Exzellenz'], ['value' => 'Sichern Sie Ihre "License to Operate"'], ['value' => 'Synchronisieren Sie Zentrale und Werke']], + ], + [ + 'category' => 'capability', + 'title' => ['de' => 'Nationale Champions', 'en' => 'National Champions'], + 'description' => ['de' => 'Realisieren Sie große Ideen mit pragmatischer Schlagkraft. Nutzen Sie bewährte Methoden maßgeschneidert für Ihre Strukturen.', 'en' => 'Turn big ideas into reality with pragmatic impact. Use proven methods tailored to your structures.'], + 'icon' => 'document-text', + 'sub_category' => 'Nationale Champions & Regionale Akteure', + 'type_label' => ['de' => 'Capability', 'en' => 'Capability'], + 'alt' => ['de' => 'Capability Profile Nationale Champions', 'en' => 'Capability Profile National Champions'], + 'thumbnail' => 'nationale-champions.webp', + 'file_path' => ['de' => 'inno-projekt-Capability_9102_NationaleChampions_de.pdf', 'en' => 'inno-projekt-Capability_9102_NationalChampions_en.pdf'], + 'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'], + 'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'], + 'checkpoints' => [['value' => 'Erweitern Sie Ihre Handlungsfähigkeit'], ['value' => 'Profitieren Sie von Best-Practices'], ['value' => 'Steigern Sie Ihre Marge']], + ], + [ + 'category' => 'capability', + 'title' => ['de' => 'Leistungsübersicht', 'en' => 'Service Overview'], + 'description' => ['de' => 'Bündeln Sie Ihre Anforderungen. Wir bieten Ihnen ein integriertes Spektrum aus Packaging, Engineering, Projektmanagement und spezialisiertem Consulting.', 'en' => 'We offer you an integrated spectrum of Packaging, Engineering, Project Management and specialized consulting.'], + 'icon' => 'document-text', + 'sub_category' => 'Ihr Leistungsportfolio für technische Exzellenz.', + 'type_label' => ['de' => 'Capability', 'en' => 'Capability'], + 'alt' => ['de' => 'Capability Leistungsübersicht', 'en' => 'Capability Service Overview for Technical Excellence'], + 'thumbnail' => 'keyvisual.webp', + 'file_path' => ['de' => 'inno-projekt-Capability_9202_Leistungsuebersicht_de.pdf', 'en' => 'inno-projekt-Capability_9202_Service_Overview_en.pdf'], + 'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'], + 'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'], + 'checkpoints' => [['value' => 'Ihre Buchungsmodelle'], ['value' => 'Verfügbare Experten-Rollen'], ['value' => 'Warum inno-projekt?']], + ], + [ + 'category' => 'capability', + 'title' => ['de' => 'Master Data Management', 'en' => 'Master Data Management'], + 'description' => ['de' => 'Erfahren Sie mehr über unsere Expertise im Bereich Master Data Management und Systemintegration für FMCG-Unternehmen.', 'en' => 'Learn more about our expertise in master data management and system integration for FMCG companies.'], + 'icon' => 'document-text', + 'sub_category' => 'Verwandeln Sie Datenchaos in Prozessgeschwindigkeit.', + 'type_label' => ['de' => 'Capability', 'en' => 'Capability'], + 'alt' => ['de' => 'Capability Profile Master Data Management', 'en' => 'Capability Profile Master Data Management'], + 'thumbnail' => 'leistung-2.webp', + 'file_path' => ['de' => 'inno-projekt-Capability_9012_MasterData_de.pdf', 'en' => 'inno-projekt-Capability_9012_MasterData_en.pdf'], + 'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'], + 'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'], + 'checkpoints' => [['value' => 'Entlasten Sie Ihre Experten'], ['value' => 'Beschleunigen Sie Ihren Markteintritt'], ['value' => 'Garantieren Sie Compliance']], + ], + [ + 'category' => 'capability', + 'title' => ['de' => 'Integrated Consumer Research', 'en' => 'Integrated Consumer Research'], + 'description' => ['de' => 'Erfahren Sie mehr über unsere Expertise im Bereich Integrated Consumer Research für FMCG-Unternehmen.', 'en' => 'Learn more about our expertise in integrated consumer research for FMCG companies.'], + 'icon' => 'document-text', + 'sub_category' => 'Verwandeln Sie subjektives Erleben in messbare technische Daten.', + 'type_label' => ['de' => 'Capability', 'en' => 'Capability'], + 'alt' => ['de' => 'Capability Integrated Consumer Research', 'en' => 'Capability Integrated Consumer Research'], + 'thumbnail' => 'leistung-2.webp', + 'file_path' => ['de' => 'inno-projekt-Capability_9011_Integrated_Consumer_Research_de.pdf', 'en' => 'inno-projekt-Capability_9011_Integrated_Consumer_Research_en.pdf'], + 'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'], + 'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'], + 'checkpoints' => [['value' => 'Minimieren Sie Fehlentwicklungen'], ['value' => 'Machen Sie Markenwerte messbar'], ['value' => 'Verstehen Sie Ihre "Emotional Map"']], + ], + // === Success Stories === + [ + 'category' => 'success_story', + 'title' => ['de' => 'Lab Support Data Integration', 'en' => 'Lab Support Data Integration'], + 'description' => ['de' => 'Eie wir für einen globalen FMCG-Player die Verpackungsvalidierung standardisierten und durch direkte Systemintegration die "Time-to-Result" signifikant beschleunigten.', 'en' => 'How we standardized packaging validation for a global FMCG player and significantly accelerated "Time-to-Result" through direct system integration.'], + 'icon' => 'document-chart-bar', + 'sub_category' => 'Lab Support Data Integration', + 'type_label' => ['de' => 'Case Study', 'en' => 'Case Study'], + 'alt' => ['de' => 'Case Study Lab Support Data Integration', 'en' => 'Case Study Lab Support Data Integration'], + 'thumbnail' => 'case-study-7012.webp', + 'file_path' => ['de' => 'inno-projekt-Case_Study_7012_LabSupport_DataIntegration_de.pdf', 'en' => 'inno-projekt-Case_Study_7012_LabSupport_DataIntegration_en.pdf'], + 'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'], + 'download_text' => ['de' => 'PDF herunterladen', 'en' => 'Download PDF'], + 'highlights' => [['value' => '20%', 'label' => 'Zeitgewinn'], ['value' => '100%', 'label' => 'Systemintegration
 ']], + ], + [ + 'category' => 'success_story', + 'title' => ['de' => 'Fragrance Pump Experience', 'en' => 'Fragrance Pump Experience'], + 'description' => ['de' => 'Wie wir für einen internationalen Premium-Konzern das subjektive Sprühgefühl von Parfüm in belastbare Ingenieursdaten übersetzten und das perfekte Markenerlebnis definierten.', 'en' => 'How we translated the subjective spray feel of perfume into robust engineering data for an international premium corporation and defined the perfect brand experience.'], + 'icon' => 'document-chart-bar', + 'sub_category' => 'Fragrance Pump Experience', + 'type_label' => ['de' => 'Case Study', 'en' => 'Case Study'], + 'alt' => ['de' => 'Case Study Fragrance Pump Experience', 'en' => 'Case Study Fragrance Pump Experience'], + 'thumbnail' => 'case-study-7013.webp', + 'file_path' => ['de' => 'inno-projekt-Case_Study_7013_FragrancePump_Experience_de.pdf', 'en' => 'inno-projekt-Case_Study_7013_FragrancePump_Experience_en.pdf'], + 'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'], + 'download_text' => ['de' => 'PDF herunterladen', 'en' => 'Download PDF'], + 'highlights' => [['value' => '∞', 'label' => 'Objektivierung-
von Emotionen'], ['value' => '∞', 'label' => 'Globale
Differenzierung']], + ], + [ + 'category' => 'success_story', + 'title' => ['de' => 'Master Data Excellence & Prozess-Outsourcing', 'en' => 'Master Data Excellence & Process Outsourcing'], + 'description' => ['de' => 'Ein globaler FMCG-Konzern stand vor der Herausforderung, seine internen F&E-Teams von hochadministrativem Aufwand zu befreien', 'en' => 'A global FMCG corporation faced the challenge of relieving its internal R&D teams from highly administrative workload.'], + 'icon' => 'document-chart-bar', + 'sub_category' => 'Master Data Excellence', + 'type_label' => ['de' => 'Case Study', 'en' => 'Case Study'], + 'alt' => ['de' => 'Case Study Master Data Excellence & Prozess-Outsourcing', 'en' => 'Case Study Master Data Excellence & Process Outsourcing'], + 'thumbnail' => 'case-study-7010.webp', + 'file_path' => ['de' => 'inno-projekt-Case_Study_7010_MasterDataExcellence_de.pdf', 'en' => 'inno-projekt-Case_Study_7010_MasterDataExcellence_en.pdf'], + 'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'], + 'download_text' => ['de' => 'PDF herunterladen', 'en' => 'Download PDF'], + 'highlights' => [['value' => '~60%', 'label' => 'Zeitgewinn'], ['value' => '20-25%', 'label' => 'Kosteneffizienz
 ']], + ], + ]; + } +} diff --git a/packages/flux-cms/core/database/seeders-reference/CmsFaqSeeder.php b/packages/flux-cms/core/database/seeders-reference/CmsFaqSeeder.php new file mode 100644 index 0000000..6a88a2b --- /dev/null +++ b/packages/flux-cms/core/database/seeders-reference/CmsFaqSeeder.php @@ -0,0 +1,60 @@ + $category, + 'question' => array_filter([ + 'de' => $de['question'] ?? null, + 'en' => $en['question'] ?? null, + ]), + 'answer' => array_filter([ + 'de' => $de['answer'] ?? null, + 'en' => $en['answer'] ?? null, + ]), + 'help' => array_filter([ + 'de' => $de['help'] ?? null, + 'en' => $en['help'] ?? null, + ]) ?: null, + 'is_published' => true, + 'order' => $i, + ]); + } + } + } +} diff --git a/packages/flux-cms/core/database/seeders-reference/CmsIndustrySeeder.php b/packages/flux-cms/core/database/seeders-reference/CmsIndustrySeeder.php new file mode 100644 index 0000000..fcc633d --- /dev/null +++ b/packages/flux-cms/core/database/seeders-reference/CmsIndustrySeeder.php @@ -0,0 +1,41 @@ + $i], + [ + 'name' => array_filter([ + 'de' => $deIndustries[$i] ?? null, + 'en' => $enIndustries[$i] ?? null, + ]), + 'is_published' => true, + ] + ); + } + } +} diff --git a/packages/flux-cms/core/database/seeders-reference/CmsLinkedinPostSeeder.php b/packages/flux-cms/core/database/seeders-reference/CmsLinkedinPostSeeder.php new file mode 100644 index 0000000..1a72da5 --- /dev/null +++ b/packages/flux-cms/core/database/seeders-reference/CmsLinkedinPostSeeder.php @@ -0,0 +1,75 @@ +getFallbackPosts(); + + foreach ($posts as $index => $post) { + CmsLinkedinPost::updateOrCreate( + ['linkedin_id' => $post['id'] ?? null], + [ + 'title' => ['de' => $post['title'], 'en' => $post['title']], + 'excerpt' => ['de' => $post['excerpt'], 'en' => $post['excerpt']], + 'content' => ['de' => $post['content'], 'en' => $post['content']], + 'author' => $post['author'] ?? 'inno-projekt', + 'date' => $post['date'] ?? null, + 'url' => $post['url'] ?? null, + 'image' => $post['image'] ?? null, + 'tags' => $post['tags'] ?? [], + 'source' => 'manual', + 'is_published' => true, + 'order' => $index, + ] + ); + } + } + + /** + * @return array> + */ + protected function getFallbackPosts(): array + { + return [ + [ + 'id' => '1', + 'title' => 'How to relieve your project management team and accelerate projects.', + 'excerpt' => 'Project managers in the FMCG sector often find themselves caught between two stools...', + 'content' => 'How to relieve your project management team and accelerate projects.

Project managers in the FMCG sector often find themselves caught between two stools: they are expected to meet the strategic expectations of management while at the same time solving operational problems on the front line.', + 'date' => '2026-01-13', + 'author' => 'inno-projekt', + 'tags' => ['Update', 'LinkedIn'], + 'url' => 'https://www.linkedin.com/posts/inno-projekt-gmbh_how-to-relieve-your-project-management-team-activity-7416741386902790144-dfJf', + 'image' => 'post-1.jpeg', + ], + [ + 'id' => '2', + 'title' => '2026 will be a clearly defined stress test for many packaging concepts.', + 'excerpt' => 'From August 12, 2026, the new EU Packaging Regulation (PPWR) will be largely binding...', + 'content' => '2026 will be a clearly defined stress test for many packaging concepts, primarily due to one thing:

From August 12, 2026, the new EU Packaging Regulation (PPWR) will be largely binding.', + 'date' => '2026-01-09', + 'author' => 'inno-projekt', + 'tags' => ['Update', 'LinkedIn'], + 'url' => 'https://www.linkedin.com/posts/inno-projekt-gmbh_2026-will-be-a-clearly-defined-stress-test-activity-7414929446262018049-2JjY', + 'image' => 'post-2.jpeg', + ], + [ + 'id' => '3', + 'title' => 'For many, the new working year is beginning these days.', + 'excerpt' => 'For us, it is time to take on responsibility again...', + 'content' => 'For many, the new working year is beginning these days.

For us, it is time to take on responsibility again.', + 'date' => '2026-01-02', + 'author' => 'inno-projekt', + 'tags' => ['Update', 'LinkedIn'], + 'url' => 'https://www.linkedin.com/posts/inno-projekt-gmbh_for-many-the-new-working-year-is-beginning-activity-7414204668291059712-MfKU', + 'image' => 'post-3.jpeg', + ], + ]; + } +} diff --git a/packages/flux-cms/core/database/seeders-reference/CmsMediaSeeder.php b/packages/flux-cms/core/database/seeders-reference/CmsMediaSeeder.php new file mode 100644 index 0000000..ee5f96e --- /dev/null +++ b/packages/flux-cms/core/database/seeders-reference/CmsMediaSeeder.php @@ -0,0 +1,155 @@ + 'success-1.webp', 'path' => 'cms/media/originals/Q1qptIeXjLHi2l05i9ovwVka7uVKmyHnYBh3xQN6.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 30960, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'success-2.webp', 'path' => 'cms/media/originals/btwUGqS3gj45hjnPelRu9uXfyUvACKsU81C7efZk.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 31870, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'success-3.webp', 'path' => 'cms/media/originals/s13wFweUaQytN4tDlLXjuEr4VEIuXDFPElVS43hg.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 46794, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'team.webp', 'path' => 'cms/media/originals/cUs47da887T1ZfkrHXTGbTE45LEAy6S8421E4YvD.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 33650, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'save-pillar-1.webp', 'path' => 'cms/media/originals/G5eDDfenyKtbRiP1w1QT79EANtdArogAOmYc402W.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 42774, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'save-pillar-2.webp', 'path' => 'cms/media/originals/hlhfRpV5GYKZCA9KSgJG8dY3ItULKn8o1SySxRUu.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 52858, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'save-pillar-3.webp', 'path' => 'cms/media/originals/X0FHC9ieaxBzYqR5Af9ZagqPUPZTA26zz0bU2CKo.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 48472, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'story-logo.webp', 'path' => 'cms/media/originals/Q8VL2F3lBmLQ30tWq3KZf1eeP3XUXgzAKHSaJQ8z.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 9796, 'original_width' => 768, 'original_height' => 370], + ['filename' => 'story-taob.webp', 'path' => 'cms/media/originals/tlMQPvaP4t4nn3dM2C1njMLghPui8CjTLYXswwBP.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 6770, 'original_width' => 768, 'original_height' => 300], + ['filename' => 'mittelstand.webp', 'path' => 'cms/media/originals/me3XNKIxWVw5pNhEZldm8GEN6CUvN4wjtm7ElGfx.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 51880, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'mittelstand1.webp', 'path' => 'cms/media/originals/V5MFyj8JWMo6WaBqfJJmqCA9drykvC65349RnU9H.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 51880, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'nationale-champions.webp', 'path' => 'cms/media/originals/LTSgeytA3mncxZxdXD5SlULD6EBcTiZCwuaDJ89w.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 66366, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'projekte1.webp', 'path' => 'cms/media/originals/0MPvcE7cpGeDe64JCWaF3heCQetvU0cOpGYZnvXf.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 51146, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'leistung-1.webp', 'path' => 'cms/media/originals/oVi2TrTyITKKGD22wjfhcjopgDzMofFqO2XX0Mh1.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 42336, 'original_width' => 800, 'original_height' => 600], + ['filename' => 'leistung-2.webp', 'path' => 'cms/media/originals/tVbL57cQkrKSQIkG1WWk9jOt4z9RiZUobLit5vpt.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 87448, 'original_width' => 800, 'original_height' => 600], + ['filename' => 'leistung-3.webp', 'path' => 'cms/media/originals/e7mwnJSowDbCjxQimayoUP0tZAOkzvT7y1LQjqYp.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 49824, 'original_width' => 800, 'original_height' => 600], + ['filename' => 'leistung-4.webp', 'path' => 'cms/media/originals/JHCKZVE1c9dor97PfD6yQhjkmd7PIJqwkilVBXAV.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 51996, 'original_width' => 800, 'original_height' => 600], + ['filename' => 'leistung-5.webp', 'path' => 'cms/media/originals/FCElmkfJ0Qacp7vQRMeR1J5s9oxjRNdcFEVyoOJE.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 48686, 'original_width' => 800, 'original_height' => 600], + ['filename' => 'leistungen-4.webp', 'path' => 'cms/media/originals/pbbDLBoYBI0I8Quzy0L9rHkmMzc3O78xmG9ZccRS.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 51996, 'original_width' => 800, 'original_height' => 600], + ['filename' => 'leistungen.webp', 'path' => 'cms/media/originals/ggwLmiykMRovYcMExdvHRRmUrkJrxMCpF5ZfOXJT.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 51018, 'original_width' => 800, 'original_height' => 600], + ['filename' => 'karriere1.webp', 'path' => 'cms/media/originals/KvaAufDevlUTVWQhQI5dsE6mVxORB063I8wmd1L1.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 44516, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'keyvisual-small.webp', 'path' => 'cms/media/originals/MHnntV48r17drkJIKZna9NG5Zqye62ypAFSA90L6.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 35164, 'original_width' => 960, 'original_height' => 400], + ['filename' => 'keyvisual.webp', 'path' => 'cms/media/originals/ma1SzM8v4l4pQeVGhrJ80bYxq2bBnuRC7C1hwNSV.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 9762, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'kontakt.webp', 'path' => 'cms/media/originals/3r2ugopYW4Rbiups57eNdzrjpqPvQYc19oVEoUap.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 33692, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'grosskonzerne.webp', 'path' => 'cms/media/originals/NBSE2qKQ1uZChAgVNAYespoLKVBzYYtAE96AoK1o.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 47772, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'integration-process.webp', 'path' => 'cms/media/originals/nqOmw0aYAvY0QP1OCfMvrl9B3rpIsDIRPuyNvoxz.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 54274, 'original_width' => 800, 'original_height' => 600], + ['filename' => 'karriere.webp', 'path' => 'cms/media/originals/V9Nkxj9ViC90a6kO6Qcg5UYWZpA3YkNBUEAyi2d0.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 45306, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'dirigent.webp', 'path' => 'cms/media/originals/ZdIjdXC5QMhAqhqluKLlOEB4Qi9LjqWz5BCOhTPw.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 40648, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'global-player.webp', 'path' => 'cms/media/originals/TJj3114VYbME1b5Mz3KZ8OWwEvByXMNdHkAggzud.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 40444, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'case-study-7010.webp', 'path' => 'cms/media/originals/ZbRnWxUI5K15CyistdZ0wxRojDoqeYHAeomVehx3.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 70382, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'case-study-7011.webp', 'path' => 'cms/media/originals/9b0tmCAz0msWZaCl1EPFKnu0fzs1HOwHEiiQPixo.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 53120, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'case-study-7012.webp', 'path' => 'cms/media/originals/DPVmai1rcxWxYwUPL9J0ON3AYvAe9tGOTeyEanLC.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 55586, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'case-study-7013.webp', 'path' => 'cms/media/originals/mLtyPzKJRbDeciSBdKRwxgy9fHQQr7sySdrgHD7L.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 52752, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'capability-global-player.webp', 'path' => 'cms/media/originals/Io8eU0kzezXhAC3nUD0c0udqqEYzU6UG2wDJUzOr.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 28912, 'original_width' => 768, 'original_height' => 300], + ['filename' => 'capability-national-champions.webp', 'path' => 'cms/media/originals/5WFTRenjgq8ZMS1w5zhPOAlK7yESJozAzchixY6g.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 46992, 'original_width' => 768, 'original_height' => 300], + ['filename' => 'capability-overview_of_services.webp', 'path' => 'cms/media/originals/Vjylm0dN37CEEH53zi3ZxHWTx1TOR7KYekrnhb3z.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 34220, 'original_width' => 768, 'original_height' => 300], + ['filename' => 'case-studies.webp', 'path' => 'cms/media/originals/wf3oMRP9pRGk32vvI0p4e25F7RT9pEiX3wIs8cO8.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 44688, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'art.webp', 'path' => 'cms/media/originals/U9owB5ZZNSM8mIjexOpZbW7ypKZoRHFuC7igXOuT.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 53226, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'bridge-builder-2.webp', 'path' => 'cms/media/originals/w1cNSvzLriT6mqqwOEMhoUVI3O3UMiT34IZqVmMC.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 37212, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'bridge-builder.webp', 'path' => 'cms/media/originals/n4ZWkwGSsa73oCDFjIvVgs4kG3iEOc8BEYEXl7fW.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 33050, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'capabilities.webp', 'path' => 'cms/media/originals/5VTPNpgX3vwpzJVipg7RczfbVyU5lOXPpCTrRu66.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 41244, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'about-team.webp', 'path' => 'cms/media/originals/iBJanSjRP72c5smgMu3bKVaVIbMrXhah3nOd5B1I.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 39250, 'original_width' => 800, 'original_height' => 600], + ['filename' => 'about.webp', 'path' => 'cms/media/originals/cHKkrpxYyjeaqCUMgBuO8bVdJZQPQwLYxqKOqR4O.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 31870, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'architekt.webp', 'path' => 'cms/media/originals/AaTTxGRDVSDMzfPSkANrT2gI4PjFTltLykPnk5vQ.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 27666, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'art-of-balance.webp', 'path' => 'cms/media/originals/2x1uvwBHhHhBdJwOnqXDEig4ngb1KK1UBwjhUbdV.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 10636, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'about-1.webp', 'path' => 'cms/media/originals/pcd3MP3TQ189hurZEXhuzrCEE2uAziXRjvyMGl5r.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 32548, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'art-of-balance.jpg', 'path' => 'cms/media/originals/0cbt8cH8mXsZ7i5juu45WYZDx1QLWKAlfdrXj158.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 30205, 'original_width' => 768, 'original_height' => 512], + ['filename' => 'daniel-el-titi.jpg', 'path' => 'cms/media/originals/FKKBL4HtByeEB0VxT9vqDGdbbesWWYup23HnuCMb.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 23299, 'original_width' => 400, 'original_height' => 400], + ['filename' => 'dogan-nergiz.jpg', 'path' => 'cms/media/originals/HHfjQ0sEFK8bD04e3v8KX6FMKHp5skAY97wAb1Gb.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 27201, 'original_width' => 400, 'original_height' => 400], + ['filename' => 'jana-doepfner.jpg', 'path' => 'cms/media/originals/m7FqrMZonLZXyYIG2L03D7QSzA7y1JRjm9OCnfkX.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 29203, 'original_width' => 400, 'original_height' => 400], + ['filename' => 'jessica-rath.jpg', 'path' => 'cms/media/originals/sOtVnXLtCIeBWFRvcldtHsSXb5yKLQhcwumSqunA.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 28226, 'original_width' => 400, 'original_height' => 400], + ['filename' => 'marcus-thiemann.jpg', 'path' => 'cms/media/originals/79HmDT478Zrv2hPOKsQhRWr288QEhFVgLKY17Rou.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 33777, 'original_width' => 400, 'original_height' => 400], + ['filename' => 'markus-kirsch.jpg', 'path' => 'cms/media/originals/TgyErbPn8rOL1Lzsjo42MXoTjFZqdP7t4xmaFAnB.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 33592, 'original_width' => 400, 'original_height' => 400], + ['filename' => 'martina-zeidler.jpg', 'path' => 'cms/media/originals/xVkW0fKZk7Fblbd3hdj00ntC5k6clNS7zHgsyY4S.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 45997, 'original_width' => 400, 'original_height' => 400], + ['filename' => 'peter-bernhards.jpg', 'path' => 'cms/media/originals/8MMBC2jzZzfNjJrKvSbuRTOQDdNgFxquhzecVZlK.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 25726, 'original_width' => 400, 'original_height' => 400], + ['filename' => 'sina-roehrs.jpg', 'path' => 'cms/media/originals/ljf17PgoAMAUse4TQ1FFE6IT6twBU93r2SpC4Ngm.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 44266, 'original_width' => 400, 'original_height' => 400], + ['filename' => 'post-1.jpeg', 'path' => 'cms/media/originals/5J1FlboQGfQ43gcnTPIBuntrXMkLMCtpIDRoLkro.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 76671, 'original_width' => 800, 'original_height' => 417], + ['filename' => 'post-2.jpeg', 'path' => 'cms/media/originals/bwlf6A9hTehxMIUqSlUA8274SCaN9PtpKXKhkMYL.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 68640, 'original_width' => 800, 'original_height' => 417], + ['filename' => 'post-3.jpeg', 'path' => 'cms/media/originals/1tWXALEJxq0UlNpA7ha936ubSxGXonwXTQHTLpKJ.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 67433, 'original_width' => 800, 'original_height' => 417], + ['filename' => 'inno-projekt-Capability_9011_Integrated_Consumer_Research_de.pdf', 'path' => 'cms/media/originals/BAY8u1AiIJx9uXdNqISgLGaXdLV5QiRhV3bQ2EC8.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 285318, 'original_width' => null, 'original_height' => null], + ['filename' => 'inno-projekt-Capability_9011_Integrated_Consumer_Research_en.pdf', 'path' => 'cms/media/originals/iSkUqzR600Ujd0lypZFd0eKLrCqGEtN2oFnhiqOZ.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 297608, 'original_width' => null, 'original_height' => null], + ['filename' => 'inno-projekt-Capability_9012_MasterData_de.pdf', 'path' => 'cms/media/originals/rhh7d9qWvOnLalBFC7g1ANYJXUBjjB1ZNfFY0yns.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 454633, 'original_width' => null, 'original_height' => null], + ['filename' => 'inno-projekt-Capability_9012_MasterData_en.pdf', 'path' => 'cms/media/originals/t9PpREeNXZU8xWVzZLXeqmdunq9JiZGUYNzmY2BX.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 451953, 'original_width' => null, 'original_height' => null], + ['filename' => 'inno-projekt-Capability_9101_GlobalPlayer_de.pdf', 'path' => 'cms/media/originals/fpFymdm8FwXT0v3XjJY6wks9qGlIBHeWWW8zcypF.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 358498, 'original_width' => null, 'original_height' => null], + ['filename' => 'inno-projekt-Capability_9101_GlobalPlayer_en.pdf', 'path' => 'cms/media/originals/ZKwcEebti4yjCNMOfZVimblesZgfncoN8db8lSaO.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 369039, 'original_width' => null, 'original_height' => null], + ['filename' => 'inno-projekt-Capability_9102_NationalChampions_en.pdf', 'path' => 'cms/media/originals/flcDISWYm41Cc8mTPs1ERKsmEY2pK1gnLLATOynv.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 466950, 'original_width' => null, 'original_height' => null], + ['filename' => 'inno-projekt-Capability_9102_NationaleChampions_de.pdf', 'path' => 'cms/media/originals/UiJ4HznbKtc5QNjZoHfxD8e8fpp2zXP6Ehh17Q5g.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 470648, 'original_width' => null, 'original_height' => null], + ['filename' => 'inno-projekt-Capability_9202_Leistungsuebersicht_de.pdf', 'path' => 'cms/media/originals/K1mwmLVYmQGhaxLrgeyBPPOQwBUTjKh1gCR1hbn5.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 213462, 'original_width' => null, 'original_height' => null], + ['filename' => 'inno-projekt-Capability_9202_Service_Overview_en.pdf', 'path' => 'cms/media/originals/ABM3X7BRMMpViLKBc9PbpPyi1lFFQBhviIRa53Fp.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 206054, 'original_width' => null, 'original_height' => null], + ['filename' => 'inno-projekt-Case_Study_7010_MasterDataExcellence_de.pdf', 'path' => 'cms/media/originals/ebvunH8d8qhEMamnpmA3Myvck6nOnOXzCdsC4XCr.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 437216, 'original_width' => null, 'original_height' => null], + ['filename' => 'inno-projekt-Case_Study_7010_MasterDataExcellence_en.pdf', 'path' => 'cms/media/originals/whR8GmCPX6qCbMoA8T99G7LEkm2oL2Z3YEG8vYg5.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 415218, 'original_width' => null, 'original_height' => null], + ['filename' => 'inno-projekt-Case_Study_7011_HairCareRD_ProductSupport_de.pdf', 'path' => 'cms/media/originals/zf1xUGsjiCG7SI4Ci2QiXkojwcwjmsAmqObXCX0W.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 361192, 'original_width' => null, 'original_height' => null], + ['filename' => 'inno-projekt-Case_Study_7011_HairCareRD_ProductSupport_en.pdf', 'path' => 'cms/media/originals/6cr2yF2CekUyEcTz0TzNNcd2fsEEvThutJjHHmpy.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 376542, 'original_width' => null, 'original_height' => null], + ['filename' => 'inno-projekt-Case_Study_7012_LabSupport_DataIntegration_de.pdf', 'path' => 'cms/media/originals/bRu1mHHsUEcv0gLSkoZQPZcUf9Ixr2Y2tbML8K5o.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 402710, 'original_width' => null, 'original_height' => null], + ['filename' => 'inno-projekt-Case_Study_7012_LabSupport_DataIntegration_en.pdf', 'path' => 'cms/media/originals/cB8h1DybKR5gjG4BNfkw4xQAa4K7DSi16sck8XCG.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 380668, 'original_width' => null, 'original_height' => null], + ['filename' => 'inno-projekt-Case_Study_7013_FragrancePump_Experience_de.pdf', 'path' => 'cms/media/originals/5yNbrbMGGm6jIC72SNPv6JHHTKWRy0Rf22MyPL0m.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 386255, 'original_width' => null, 'original_height' => null], + ['filename' => 'inno-projekt-Case_Study_7013_FragrancePump_Experience_en.pdf', 'path' => 'cms/media/originals/wVjrJVHWhY1SwYvYuDhLeFjL00UMPUQ4UeyaISvr.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 397411, 'original_width' => null, 'original_height' => null], + ]; + + $service = app(MediaConversionService::class); + + foreach ($mediaItems as $item) { + $media = CmsMedia::updateOrCreate( + ['filename' => $item['filename']], + [ + 'path' => $item['path'], + 'type' => $item['type'], + 'mime_type' => $item['mime_type'], + 'file_size' => $item['file_size'], + 'original_width' => $item['original_width'], + 'original_height' => $item['original_height'], + 'disk' => 'public', + 'is_published' => true, + ] + ); + + if ( + $item['type'] === 'image' + && Storage::disk('public')->exists($item['path']) + ) { + $service->generateThumbnail($media); + } + } + + $imageEntries = [ + ['group' => 'welcome', 'key' => 'hero.image', 'value' => 'keyvisual-small.webp'], + ['group' => 'art-of-balance', 'key' => 'hero.image', 'value' => 'keyvisual-small.webp'], + ['group' => 'art-of-balance', 'key' => 'integration_image', 'value' => 'integration-process.webp'], + ['group' => 'kontakt', 'key' => 'hero.image', 'value' => 'kontakt.webp'], + ['group' => 'faq_page', 'key' => 'hero.image', 'value' => 'kontakt.webp'], + ['group' => 'case-studies', 'key' => 'hero.image', 'value' => 'case-studies.webp'], + ['group' => 'capabilities', 'key' => 'hero.image', 'value' => 'capabilities.webp'], + ['group' => 'nationale-champions', 'key' => 'hero.image', 'value' => 'nationale-champions.webp'], + ['group' => 'global-player', 'key' => 'hero.image', 'value' => 'global-player.webp'], + ['group' => 'leistungen', 'key' => 'hero.image', 'value' => 'leistungen.webp'], + ['group' => 'leistungen', 'key' => 'feature_image', 'value' => 'leistung-1.webp'], + ['group' => 'karriere', 'key' => 'hero.image', 'value' => 'karriere.webp'], + ['group' => 'about', 'key' => 'hero.image', 'value' => 'about-1.webp'], + ['group' => 'team', 'key' => 'hero.image', 'value' => 'team.webp'], + ['group' => 'about', 'key' => 'preview_image', 'value' => 'about-team.webp'], + ['group' => 'digitale-transformation', 'key' => 'hero.image', 'value' => 'leistung-5.webp'], + ['group' => 'master-data', 'key' => 'hero.image', 'value' => 'leistung-2.webp'], + ['group' => 'nachhaltige-verpackungen', 'key' => 'hero.image', 'value' => 'leistung-3.webp'], + ['group' => 'prozess-optimierung', 'key' => 'hero.image', 'value' => 'leistung-4.webp'], + ['group' => 'strategische-projektumsetzung', 'key' => 'hero.image', 'value' => 'leistung-1.webp'], + ]; + + foreach ($imageEntries as $entry) { + $content = CmsContent::updateOrCreate( + ['group' => $entry['group'], 'key' => $entry['key']], + ['type' => 'image'] + ); + $content->setTranslation('value', 'de', $entry['value']); + $content->setTranslation('value', 'en', $entry['value']); + $content->save(); + } + + app(\FluxCms\Core\Services\CmsContentService::class)->clearCache(); + } +} diff --git a/packages/flux-cms/core/database/seeders-reference/CmsNewsItemSeeder.php b/packages/flux-cms/core/database/seeders-reference/CmsNewsItemSeeder.php new file mode 100644 index 0000000..5e2c682 --- /dev/null +++ b/packages/flux-cms/core/database/seeders-reference/CmsNewsItemSeeder.php @@ -0,0 +1,96 @@ + + */ + private array $imageMapping = [ + '/assets/images/capability-overview_of_services.jpg' => 'capability-overview_of_services.webp', + '/assets/images/capability-global-player.jpg' => 'capability-global-player.webp', + '/assets/images/capability-national-champions.jpg' => 'capability-national-champions.webp', + '/assets/images/story-taob.jpg?v1' => 'story-taob.webp', + '/assets/images/story-taob.jpg' => 'story-taob.webp', + '/assets/images/story-logo.jpg' => 'story-logo.webp', + '/assets/images/leistung-4.jpg' => 'leistung-4.webp', + '/assets/images/leistung-2.jpg' => 'leistung-2.webp', + ]; + + /** + * @var array + */ + private array $pdfMapping = [ + 'pdfs/inno-projekt-Capability_9202_Leistungsuebersicht_de.pdf' => 'inno-projekt-Capability_9202_Leistungsuebersicht_de.pdf', + 'pdfs/inno-projekt-Capability_9101_GlobalPlayer_de.pdf' => 'inno-projekt-Capability_9101_GlobalPlayer_de.pdf', + 'pdfs/inno-projekt-Capability_9102_NationaleChampions_de.pdf' => 'inno-projekt-Capability_9102_NationaleChampions_de.pdf', + ]; + + public function run(): void + { + $locales = ['de', 'en']; + $itemsByLocale = []; + + foreach ($locales as $locale) { + $path = lang_path("{$locale}/components.php"); + if (file_exists($path)) { + $data = require $path; + $itemsByLocale[$locale] = $data['news_band']['items'] ?? []; + } + } + + $deItems = $itemsByLocale['de'] ?? []; + $enItems = $itemsByLocale['en'] ?? []; + + $maxCount = max(count($deItems), count($enItems)); + + for ($i = 0; $i < $maxCount; $i++) { + $de = $deItems[$i] ?? []; + $en = $enItems[$i] ?? []; + + $rawImage = $de['image'] ?? $en['image'] ?? null; + $rawPdf = $de['pdf_path'] ?? $en['pdf_path'] ?? null; + + CmsNewsItem::updateOrCreate( + ['order' => $i], + [ + 'icon' => $de['icon'] ?? $en['icon'] ?? null, + 'text' => array_filter(['de' => $de['text'] ?? null, 'en' => $en['text'] ?? null]), + 'title' => array_filter(['de' => $de['title'] ?? null, 'en' => $en['title'] ?? null]), + 'excerpt' => array_filter(['de' => $de['excerpt'] ?? null, 'en' => $en['excerpt'] ?? null]), + 'content' => array_filter(['de' => $de['content'] ?? null, 'en' => $en['content'] ?? null]), + 'image' => $this->resolveImage($rawImage), + 'date' => $de['date'] ?? $en['date'] ?? null, + 'author' => $de['author'] ?? $en['author'] ?? null, + 'link' => $de['link'] ?? $en['link'] ?? null, + 'pdf_path' => $this->resolvePdf($rawPdf), + 'pdf_open_text' => array_filter(['de' => $de['pdf_open_text'] ?? null, 'en' => $en['pdf_open_text'] ?? null]), + 'pdf_download_text' => array_filter(['de' => $de['pdf_download_text'] ?? null, 'en' => $en['pdf_download_text'] ?? null]), + 'is_published' => true, + ] + ); + } + } + + private function resolveImage(?string $path): ?string + { + if (! $path) { + return null; + } + + return $this->imageMapping[$path] ?? pathinfo($path, PATHINFO_FILENAME).'.webp'; + } + + private function resolvePdf(?string $path): ?string + { + if (! $path) { + return null; + } + + return $this->pdfMapping[$path] ?? basename($path); + } +} diff --git a/packages/flux-cms/core/database/seeders-reference/CmsSearchIndexSeeder.php b/packages/flux-cms/core/database/seeders-reference/CmsSearchIndexSeeder.php new file mode 100644 index 0000000..cb6f853 --- /dev/null +++ b/packages/flux-cms/core/database/seeders-reference/CmsSearchIndexSeeder.php @@ -0,0 +1,74 @@ +loadItems('de'); + $enItems = $this->loadItems('en'); + + $deById = collect($deItems)->keyBy('id'); + $enById = collect($enItems)->keyBy('id'); + + $allIds = $deById->keys()->merge($enById->keys())->unique(); + + $order = 0; + foreach ($allIds as $itemId) { + $de = $deById->get($itemId, []); + $en = $enById->get($itemId, []); + $source = ! empty($de) ? $de : $en; + + CmsSearchIndex::updateOrCreate( + ['item_id' => $itemId], + [ + 'route' => $source['route'] ?? '', + 'route_params' => $source['route_params'] ?? [], + 'category' => [ + 'de' => $de['category'] ?? ($en['category'] ?? ''), + 'en' => $en['category'] ?? ($de['category'] ?? ''), + ], + 'title_key' => $source['title_key'] ?? null, + 'title_fallback' => [ + 'de' => $de['title_fallback'] ?? null, + 'en' => $en['title_fallback'] ?? null, + ], + 'description_key' => $source['description_key'] ?? null, + 'description_fallback_key' => $source['description_fallback_key'] ?? null, + 'description_fallback_text' => [ + 'de' => $de['description_fallback_text'] ?? null, + 'en' => $en['description_fallback_text'] ?? null, + ], + 'keywords' => [ + 'de' => $de['keywords'] ?? [], + 'en' => $en['keywords'] ?? [], + ], + 'is_published' => true, + 'order' => $order++, + ] + ); + } + + $this->command->info('CmsSearchIndexSeeder: '.$allIds->count().' Eintraege erstellt/aktualisiert.'); + } + + /** + * @return array> + */ + protected function loadItems(string $locale): array + { + $path = lang_path("{$locale}/search_index.php"); + if (! File::exists($path)) { + return []; + } + + $config = require $path; + + return $config['items'] ?? []; + } +} diff --git a/packages/flux-cms/core/database/seeders/CmsContentSeeder.php b/packages/flux-cms/core/database/seeders/CmsContentSeeder.php index c402c1d..44fc16c 100644 --- a/packages/flux-cms/core/database/seeders/CmsContentSeeder.php +++ b/packages/flux-cms/core/database/seeders/CmsContentSeeder.php @@ -2,9 +2,9 @@ namespace FluxCms\Core\Database\Seeders; -use Illuminate\Database\Seeder; -use FluxCms\Core\Models\Page; use FluxCms\Core\Models\BlogPost; +use FluxCms\Core\Models\Page; +use Illuminate\Database\Seeder; class CmsContentSeeder extends Seeder { @@ -27,19 +27,19 @@ class CmsContentSeeder extends Seeder 'domain_key' => 'default', 'title' => [ 'de' => 'Willkommen auf unserer Website', - 'en' => 'Welcome to our Website' + 'en' => 'Welcome to our Website', ], 'slug' => [ 'de' => '/', - 'en' => '/' + 'en' => '/', ], 'meta_description' => [ 'de' => 'Willkommen auf unserer modernen Website, erstellt mit Flux CMS.', - 'en' => 'Welcome to our modern website, built with Flux CMS.' + 'en' => 'Welcome to our modern website, built with Flux CMS.', ], 'meta_keywords' => [ 'de' => 'Website, CMS, Flux CMS, Laravel', - 'en' => 'Website, CMS, Flux CMS, Laravel' + 'en' => 'Website, CMS, Flux CMS, Laravel', ], 'is_published' => true, 'published_at' => now(), @@ -50,15 +50,15 @@ class CmsContentSeeder extends Seeder 'domain_key' => 'default', 'title' => [ 'de' => 'Über uns', - 'en' => 'About us' + 'en' => 'About us', ], 'slug' => [ 'de' => '/ueber-uns', - 'en' => '/about' + 'en' => '/about', ], 'meta_description' => [ 'de' => 'Erfahren Sie mehr über unser Unternehmen und unsere Mission.', - 'en' => 'Learn more about our company and our mission.' + 'en' => 'Learn more about our company and our mission.', ], 'is_published' => true, 'published_at' => now(), @@ -69,15 +69,15 @@ class CmsContentSeeder extends Seeder 'domain_key' => 'default', 'title' => [ 'de' => 'Kontakt', - 'en' => 'Contact' + 'en' => 'Contact', ], 'slug' => [ 'de' => '/kontakt', - 'en' => '/contact' + 'en' => '/contact', ], 'meta_description' => [ 'de' => 'Kontaktieren Sie uns für weitere Informationen.', - 'en' => 'Contact us for more information.' + 'en' => 'Contact us for more information.', ], 'is_published' => true, 'published_at' => now(), @@ -95,19 +95,19 @@ class CmsContentSeeder extends Seeder [ 'title' => [ 'de' => 'Willkommen bei Flux CMS', - 'en' => 'Welcome to Flux CMS' + 'en' => 'Welcome to Flux CMS', ], 'slug' => [ 'de' => 'willkommen-bei-flux-cms', - 'en' => 'welcome-to-flux-cms' + 'en' => 'welcome-to-flux-cms', ], 'excerpt' => [ 'de' => 'Flux CMS ist ein modernes, komponentenbasiertes Content Management System für Laravel.', - 'en' => 'Flux CMS is a modern, component-based Content Management System for Laravel.' + 'en' => 'Flux CMS is a modern, component-based Content Management System for Laravel.', ], 'content' => [ 'de' => '

Flux CMS revolutioniert die Art, wie Sie Inhalte verwalten. Mit seiner einzigartigen "Code-as-Schema" Philosophie definieren Sie Inhaltsstrukturen direkt in PHP-Komponenten.

Dies bietet beispiellose Flexibilität und eine hervorragende Entwicklererfahrung.

', - 'en' => '

Flux CMS revolutionizes the way you manage content. With its unique "Code-as-Schema" philosophy, you define content structures directly in PHP components.

This offers unprecedented flexibility and an excellent developer experience.

' + 'en' => '

Flux CMS revolutionizes the way you manage content. With its unique "Code-as-Schema" philosophy, you define content structures directly in PHP components.

This offers unprecedented flexibility and an excellent developer experience.

', ], 'category' => 'News', 'tags' => ['CMS', 'Laravel', 'Flux'], @@ -118,19 +118,19 @@ class CmsContentSeeder extends Seeder [ 'title' => [ 'de' => 'Multi-Domain Support', - 'en' => 'Multi-Domain Support' + 'en' => 'Multi-Domain Support', ], 'slug' => [ 'de' => 'multi-domain-support', - 'en' => 'multi-domain-support' + 'en' => 'multi-domain-support', ], 'excerpt' => [ 'de' => 'Verwalten Sie mehrere Websites von einer Installation aus.', - 'en' => 'Manage multiple websites from one installation.' + 'en' => 'Manage multiple websites from one installation.', ], 'content' => [ 'de' => '

Mit Flux CMS können Sie mehrere Domains von einer einzigen Installation aus verwalten. Jede Domain kann ihre eigenen Inhalte, Designs und Einstellungen haben.

', - 'en' => '

With Flux CMS, you can manage multiple domains from a single installation. Each domain can have its own content, designs, and settings.

' + 'en' => '

With Flux CMS, you can manage multiple domains from a single installation. Each domain can have its own content, designs, and settings.

', ], 'category' => 'Features', 'tags' => ['Multi-Domain', 'Features'], @@ -141,19 +141,19 @@ class CmsContentSeeder extends Seeder [ 'title' => [ 'de' => 'Komponenten-First Architektur', - 'en' => 'Component-First Architecture' + 'en' => 'Component-First Architecture', ], 'slug' => [ 'de' => 'komponenten-first-architektur', - 'en' => 'component-first-architecture' + 'en' => 'component-first-architecture', ], 'excerpt' => [ 'de' => 'Bauen Sie Seiten aus wiederverwendbaren Livewire-Komponenten.', - 'en' => 'Build pages from reusable Livewire components.' + 'en' => 'Build pages from reusable Livewire components.', ], 'content' => [ 'de' => '

Die Komponenten-First Architektur von Flux CMS ermöglicht es Ihnen, komplexe Seiten aus kleinen, wiederverwendbaren Komponenten zu erstellen.

Jede Komponente kann ihre eigenen Felder und Validierungsregeln definieren.

', - 'en' => '

The component-first architecture of Flux CMS allows you to create complex pages from small, reusable components.

Each component can define its own fields and validation rules.

' + 'en' => '

The component-first architecture of Flux CMS allows you to create complex pages from small, reusable components.

Each component can define its own fields and validation rules.

', ], 'category' => 'Architecture', 'tags' => ['Components', 'Livewire', 'Architecture'], diff --git a/packages/flux-cms/core/resources/views/admin-reference/cms/content-index.blade.php b/packages/flux-cms/core/resources/views/admin-reference/cms/content-index.blade.php new file mode 100644 index 0000000..fcdf4f3 --- /dev/null +++ b/packages/flux-cms/core/resources/views/admin-reference/cms/content-index.blade.php @@ -0,0 +1,473 @@ + null, + 'search' => '', + 'editingId' => null, + 'editLocale' => 'de', + 'editValue' => '', + 'editMediaId' => null, + 'showJsonModal' => false, + 'jsonItems' => [], + 'jsonIsStringArray' => false, + 'jsonEditingKey' => '', +]); + +on(['media-selected' => function ($mediaId, $url, $field) { + if ($field !== 'content_image') { + return; + } + $media = CmsMedia::find($mediaId); + if ($media) { + $this->editValue = $media->filename; + $this->editMediaId = $mediaId; + } +}]); + +$groups = computed(fn() => CmsContent::query()->selectRaw('`group`, count(*) as count')->groupBy('group')->orderBy('group')->pluck('count', 'group')->toArray()); + +$contents = computed(fn() => $this->selectedGroup ? CmsContent::forGroup($this->selectedGroup)->when($this->search, fn($q) => $q->where('key', 'like', "%{$this->search}%"))->orderBy('order')->get() : collect()); + +$availableIcons = computed(fn () => HeroiconOutlineList::names()); + +$selectGroup = function (string $group) { + $this->selectedGroup = $group; + $this->editingId = null; +}; + +$startEdit = function (int $id) { + $content = CmsContent::find($id); + if (!$content) { + return; + } + + $this->editingId = $id; + + if ($content->type === 'json') { + $value = $content->getTranslation('value', $this->editLocale); + if (!is_array($value)) { + $value = []; + } + + $this->jsonEditingKey = $content->key; + + if (!empty($value) && !is_array($value[0] ?? null)) { + $this->jsonIsStringArray = true; + $this->jsonItems = array_map(fn($v) => ['_value' => (string) $v], $value); + } else { + $this->jsonIsStringArray = false; + $this->jsonItems = array_map(fn($item) => is_array($item) ? array_map(fn($v) => is_array($v) ? json_encode($v, JSON_UNESCAPED_UNICODE) : (string) $v, $item) : ['_value' => (string) $item], $value); + } + + $this->showJsonModal = true; + } else { + $this->editValue = $content->getTranslation('value', $this->editLocale) ?? ''; + if (is_array($this->editValue)) { + $this->editValue = json_encode($this->editValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + } + + if ($content->type === 'image') { + $this->editMediaId = CmsMedia::where('filename', $this->editValue)->first()?->id; + } else { + $this->editMediaId = null; + } + } +}; + +$switchLocale = function (string $locale) { + $this->editLocale = $locale; + if ($this->editingId) { + $content = CmsContent::find($this->editingId); + if (!$content) { + return; + } + + if ($content->type === 'json') { + $value = $content->getTranslation('value', $locale); + if (!is_array($value)) { + $value = []; + } + + if (!empty($value) && !is_array($value[0] ?? null)) { + $this->jsonIsStringArray = true; + $this->jsonItems = array_map(fn($v) => ['_value' => (string) $v], $value); + } else { + $this->jsonIsStringArray = false; + $this->jsonItems = array_map(fn($item) => is_array($item) ? array_map(fn($v) => is_array($v) ? json_encode($v, JSON_UNESCAPED_UNICODE) : (string) $v, $item) : ['_value' => (string) $item], $value); + } + } else { + $this->editValue = $content->getTranslation('value', $locale) ?? ''; + if (is_array($this->editValue)) { + $this->editValue = json_encode($this->editValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + } + } + } +}; + +$addJsonItem = function () { + if ($this->jsonIsStringArray) { + $this->jsonItems[] = ['_value' => '']; + } elseif (!empty($this->jsonItems)) { + $template = array_map(fn() => '', $this->jsonItems[0]); + $this->jsonItems[] = $template; + } +}; + +$removeJsonItem = function (int $index) { + unset($this->jsonItems[$index]); + $this->jsonItems = array_values($this->jsonItems); +}; + +$saveJsonModal = function () { + $content = CmsContent::find($this->editingId); + if (!$content) { + return; + } + + if ($this->jsonIsStringArray) { + $value = array_values(array_map(fn($item) => $item['_value'] ?? '', $this->jsonItems)); + } else { + $value = array_values( + array_map(function ($item) { + $cleaned = []; + foreach ($item as $k => $v) { + if (str_starts_with($v, '[') || str_starts_with($v, '{')) { + $decoded = json_decode($v, true); + $cleaned[$k] = json_last_error() === JSON_ERROR_NONE ? $decoded : $v; + } else { + $cleaned[$k] = $v; + } + } + return $cleaned; + }, $this->jsonItems), + ); + } + + $content->setTranslation('value', $this->editLocale, $value); + $content->save(); + + app(CmsContentService::class)->clearCache($this->selectedGroup); + + $this->showJsonModal = false; + $this->editingId = null; + $this->jsonItems = []; + + Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'JSON-Inhalt wurde erfolgreich aktualisiert.'); +}; + +$saveEdit = function () { + $content = CmsContent::find($this->editingId); + if (!$content) { + return; + } + + $content->setTranslation('value', $this->editLocale, $this->editValue); + $content->save(); + + app(CmsContentService::class)->clearCache($this->selectedGroup); + + $this->editingId = null; + $this->editValue = ''; + + Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Inhalt wurde erfolgreich aktualisiert.'); +}; + +$cancelEdit = fn() => ($this->editingId = null); + +$cancelJsonModal = function () { + $this->showJsonModal = false; + $this->editingId = null; + $this->jsonItems = []; +}; + +?> + +
+
+ Inhalte verwalten +
+ {{ array_sum($this->groups) }} Einträge +
+
+ +
+ {{-- Sidebar: Groups --}} +
+ + Seiten / Gruppen +
+ @foreach ($this->groups as $group => $count) + + @endforeach +
+
+
+ + {{-- Main: Content Editor --}} +
+ @if ($selectedGroup) + +
+ {{ $selectedGroup }} +
+ +
+ @foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label) + + {{ strtoupper($code) }} + + @endforeach +
+
+
+ +
+ @forelse ($this->contents as $content) +
+
+
+
+ {{ $content->key }} + + {{ $content->type }} +
+ + @if ($editingId === $content->id && $content->type === 'image') +
+
+ @if ($editValue) +
+ +
+ @endif +
+ +

{{ $editValue ?: 'Kein Bild ausgewählt' }}

+
+
+
+ + Speichern + + Abbrechen +
+
+ @elseif ($editingId === $content->id && $content->type !== 'json') +
+ @if (in_array($selectedGroup, ['datenschutz', 'impressum'])) + + @else + + @endif +
+ + Speichern + + Abbrechen +
+
+ @else + @php + $displayValue = $content->getTranslation('value', $editLocale); + $displayStr = is_array($displayValue) + ? json_encode($displayValue, JSON_UNESCAPED_UNICODE) + : (string) $displayValue; + @endphp + @if ($content->type === 'image') +
+ @if ($displayStr) +
+ +
+ @endif + {{ $displayStr ?: 'Kein Bild' }} +
+ @elseif ($content->type === 'json') + @php + $jsonVal = $content->getTranslation('value', $editLocale); + $itemCount = is_array($jsonVal) ? count($jsonVal) : 0; + $firstItem = + is_array($jsonVal) && !empty($jsonVal) ? $jsonVal[0] : null; + $isObjects = is_array($firstItem); + @endphp +
+ {{ $itemCount }} + Einträge + @if ($isObjects && is_array($firstItem)) + Felder: + {{ implode(', ', array_keys($firstItem)) }} + @endif +
+ @elseif ($content->type === 'html') +
+ {!! \Illuminate\Support\Str::limit($displayStr, 200) !!} +
+ @else +

+ {{ \Illuminate\Support\Str::limit(strip_tags($displayStr), 120) }} +

+ @endif + @endif +
+ + @if ($editingId !== $content->id) + + @endif +
+
+ @empty + Keine Einträge gefunden. + @endforelse +
+
+ @else + +
+ + Seite auswählen + Wähle links eine Seite/Gruppe aus, um deren Inhalte zu bearbeiten. +
+
+ @endif +
+
+ + {{-- JSON Editor Modal --}} + +
+ {{ $jsonEditingKey }} + + {{ $jsonIsStringArray ? 'Einfache Liste' : 'Strukturierte Einträge' }} + ({{ count($jsonItems) }} Einträge) — {{ strtoupper($editLocale) }} + +
+ +
+ @foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label) + {{ strtoupper($code) }} + @endforeach +
+ +
+ @foreach ($jsonItems as $idx => $item) +
+
+ Eintrag {{ $idx + 1 }} + +
+ + @if ($jsonIsStringArray) + + @else +
+ @foreach ($item as $field => $fieldValue) + @php + $isIcon = in_array($field, ['icon']); + $isRichText = in_array($field, [ + 'description', + 'text', + 'content', + 'help', + 'answer', + 'quote', + ]); + $isLongText = in_array($field, ['tagline']); + $isNestedJson = + is_string($fieldValue) && + (str_starts_with($fieldValue, '[') || str_starts_with($fieldValue, '{')); + @endphp + + @if ($isIcon) +
+
+
+ + — Kein Icon — + + @foreach ($this->availableIcons as $iconName) + + {{ $iconName }} + @endforeach + +
+ @if (!empty($fieldValue)) +
+ +
+ @endif +
+
+ @elseif ($isRichText) +
+ +
+ @elseif ($isNestedJson) +
+ +
+ @else + + @endif + @endforeach +
+ @endif +
+ @endforeach +
+ +
+ + Eintrag hinzufügen + +
+ Abbrechen + Speichern +
+
+
+
diff --git a/packages/flux-cms/core/resources/views/admin-reference/cms/dashboard.blade.php b/packages/flux-cms/core/resources/views/admin-reference/cms/dashboard.blade.php new file mode 100644 index 0000000..3c7c5a3 --- /dev/null +++ b/packages/flux-cms/core/resources/views/admin-reference/cms/dashboard.blade.php @@ -0,0 +1,104 @@ + [ + 'contents' => CmsContent::count(), + 'groups' => CmsContent::distinct()->pluck('group')->count(), + 'news' => CmsNewsItem::count(), + 'industries' => CmsIndustry::count(), + 'faqs' => CmsFaq::count(), + 'linkedin' => CmsLinkedinPost::count(), + 'downloads' => CmsDownload::count(), + ], +); + +?> + + diff --git a/packages/flux-cms/core/resources/views/admin-reference/cms/downloads-index.blade.php b/packages/flux-cms/core/resources/views/admin-reference/cms/downloads-index.blade.php new file mode 100644 index 0000000..f30a978 --- /dev/null +++ b/packages/flux-cms/core/resources/views/admin-reference/cms/downloads-index.blade.php @@ -0,0 +1,408 @@ + 'de', + 'showForm' => false, + 'editingId' => null, + 'filterCategory' => '', + 'title' => '', + 'description' => '', + 'category' => 'case_study', + 'icon' => 'document-text', + 'sub_category' => '', + 'type_label' => '', + 'alt' => '', + 'file_path' => '', + 'fileMediaId' => null, + 'thumbnail' => '', + 'thumbMediaId' => null, + 'open_text' => '', + 'download_text' => '', + 'highlights' => [], + 'checkpoints' => [], +]); + +$downloads = computed(function () { + $query = CmsDownload::ordered(); + if ($this->filterCategory) { + $query->byCategory($this->filterCategory); + } + + return $query->get(); +}); + +$availableIcons = computed(fn () => HeroiconOutlineList::names()); + +$create = function () { + $this->reset(['editingId', 'title', 'description', 'icon', 'sub_category', 'type_label', 'alt', 'file_path', 'fileMediaId', 'thumbnail', 'thumbMediaId', 'open_text', 'download_text', 'highlights', 'checkpoints']); + $this->category = 'case_study'; + $this->icon = 'document-text'; + $this->highlights = []; + $this->checkpoints = []; + $this->showForm = true; +}; + +$edit = function (int $id) { + $dl = CmsDownload::findOrFail($id); + $this->editingId = $id; + $this->showForm = true; + $l = $this->editLocale; + $this->title = $dl->getTranslation('title', $l) ?? ''; + $this->description = $dl->getTranslation('description', $l) ?? ''; + $this->category = $dl->category; + $this->icon = $dl->icon ?? 'document-text'; + $this->sub_category = $dl->sub_category ?? ''; + $this->type_label = $dl->getTranslation('type_label', $l) ?? ''; + $this->alt = $dl->getTranslation('alt', $l) ?? ''; + $this->file_path = $dl->getTranslation('file_path', $l) ?? ''; + $this->thumbnail = $dl->thumbnail ?? ''; + $this->open_text = $dl->getTranslation('open_text', $l) ?? ''; + $this->download_text = $dl->getTranslation('download_text', $l) ?? ''; + $this->highlights = is_array($dl->highlights) ? $dl->highlights : []; + $this->checkpoints = is_array($dl->checkpoints) ? $dl->checkpoints : []; + $this->thumbMediaId = $this->thumbnail ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->thumbnail)->first()?->id : null; + $this->fileMediaId = $this->file_path ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->file_path)->first()?->id : null; +}; + +$save = function () { + $existing = $this->editingId ? CmsDownload::find($this->editingId) : null; + $merge = function (string $field, ?string $value) use ($existing) { + $t = $existing ? $existing->getTranslations($field) : []; + $t[$this->editLocale] = $value ?? ''; + + return $t; + }; + + $data = [ + 'title' => $merge('title', $this->title), + 'description' => $merge('description', $this->description), + 'category' => $this->category, + 'icon' => $this->icon, + 'sub_category' => $this->sub_category, + 'type_label' => $merge('type_label', $this->type_label), + 'alt' => $merge('alt', $this->alt), + 'file_path' => $merge('file_path', $this->file_path), + 'thumbnail' => $this->thumbnail, + 'open_text' => $merge('open_text', $this->open_text), + 'download_text' => $merge('download_text', $this->download_text), + 'highlights' => array_values(array_filter($this->highlights, fn($h) => !empty($h['value']) || !empty($h['label']))), + 'checkpoints' => array_values(array_filter($this->checkpoints, fn($c) => !empty($c['value']))), + ]; + + if ($existing) { + $existing->update($data); + } else { + $data['order'] = CmsDownload::max('order') + 1; + $data['is_published'] = true; + CmsDownload::create($data); + } + + $this->showForm = false; + $this->editingId = null; + + Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Download wurde erfolgreich gespeichert.'); +}; + +$delete = function (int $id) { + CmsDownload::findOrFail($id)->delete(); + Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'Download wurde entfernt.'); +}; + +$togglePublished = function (int $id) { + $dl = CmsDownload::findOrFail($id); + $dl->update(['is_published' => !$dl->is_published]); + Flux::toast(heading: 'Status geändert', text: $dl->is_published ? 'Veröffentlicht' : 'Entwurf'); +}; + +$switchLocale = function (string $locale) { + $this->editLocale = $locale; + if ($this->editingId) { + $dl = CmsDownload::find($this->editingId); + if ($dl) { + $this->title = $dl->getTranslation('title', $locale) ?? ''; + $this->description = $dl->getTranslation('description', $locale) ?? ''; + $this->type_label = $dl->getTranslation('type_label', $locale) ?? ''; + $this->alt = $dl->getTranslation('alt', $locale) ?? ''; + $this->file_path = $dl->getTranslation('file_path', $locale) ?? ''; + $this->open_text = $dl->getTranslation('open_text', $locale) ?? ''; + $this->download_text = $dl->getTranslation('download_text', $locale) ?? ''; + $this->fileMediaId = $this->file_path ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->file_path)->first()?->id : null; + } + } +}; + +$cancel = function () { + $this->showForm = false; + $this->editingId = null; +}; + +$addHighlight = function () { + $this->highlights[] = ['value' => '', 'label' => '']; +}; + +$removeHighlight = function (int $index) { + unset($this->highlights[$index]); + $this->highlights = array_values($this->highlights); +}; + +$addCheckpoint = function () { + $this->checkpoints[] = ['value' => '']; +}; + +$removeCheckpoint = function (int $index) { + unset($this->checkpoints[$index]); + $this->checkpoints = array_values($this->checkpoints); +}; + +$moveUp = function (int $id) { + $items = CmsDownload::ordered()->get(); + $idx = $items->search(fn($i) => $i->id === $id); + if ($idx > 0) { + $prev = $items[$idx - 1]; + $curr = $items[$idx]; + [$prev->order, $curr->order] = [$curr->order, $prev->order]; + $prev->save(); + $curr->save(); + } +}; + +$moveDown = function (int $id) { + $items = CmsDownload::ordered()->get(); + $idx = $items->search(fn($i) => $i->id === $id); + if ($idx !== false && $idx < $items->count() - 1) { + $next = $items[$idx + 1]; + $curr = $items[$idx]; + [$next->order, $curr->order] = [$curr->order, $next->order]; + $next->save(); + $curr->save(); + } +}; + +on([ + 'media-selected' => function (string $field, ?int $mediaId, ?string $url) { + if ($field === 'dl_file') { + $this->fileMediaId = $mediaId; + $media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null; + $this->file_path = $media ? $media->filename : ''; + } elseif ($field === 'dl_thumb') { + $this->thumbMediaId = $mediaId; + $media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null; + $this->thumbnail = $media ? $media->filename : ''; + } + }, +]); + +?> + +
+
+ Downloads +
+ @foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label) + {{ strtoupper($code) }} + @endforeach + Neu +
+
+ + {{-- Category Filter --}} +
+ Alle + Case Studies + Capabilities + Success Stories +
+ + @if ($showForm) + + {{ $editingId ? 'Bearbeiten' : 'Neuer Download' }} + + +
+ + + Case Study + Capability + Success Story + + + + +
+
+
+ + @foreach ($this->availableIcons as $iconName) + {{ $iconName }} + + @endforeach + +
+ @if ($icon) +
+ +
+ @endif +
+
+
+ + {{-- Vorschaubild + PDF --}} +
+
+ +
+ @if ($thumbnail) +
+ +
+ @endif +
+ +

{{ $thumbnail ?: 'Kein Bild' }}

+
+
+
+
+ + @if ($file_path) +
+ +
+

{{ $file_path }}

+ @endif + +
+
+ + {{-- Button-Texte --}} +
+ + +
+ + {{-- Beschreibung --}} +
+ +
+ + {{-- Highlights (Case Studies / Success Stories) --}} + @if ($category === 'case_study' || $category === 'success_story') +
+ +
+ @foreach ($highlights as $hIdx => $highlight) +
+ + + +
+ @endforeach +
+ + Highlight hinzufügen +
+ @endif + + {{-- Checkpoints (Capabilities) --}} + @if ($category === 'capability') +
+ +
+ @foreach ($checkpoints as $cIdx => $checkpoint) +
+ + +
+ @endforeach +
+ Checkpoint hinzufügen +
+ @endif + +
+ Speichern + Abbrechen +
+
+ @endif + + +
+ @forelse ($this->downloads as $dl) +
+
+ @if ($dl->thumbnail) +
+ +
+ @elseif ($dl->icon) +
+ +
+ @endif +
+
+ {{ $dl->getTranslation('title', $editLocale) }} + + {{ $dl->category === 'case_study' ? 'Case Study' : ($dl->category === 'capability' ? 'Capability' : 'Success Story') }} + + @unless ($dl->is_published) + Entwurf + @endunless +
+

+ {{ $dl->sub_category }} + @if ($dl->getTranslation('file_path', $editLocale)) + · {{ $dl->getTranslation('file_path', $editLocale) }} + @endif +

+
+
+
+ + + + + +
+
+ @empty + Keine Downloads vorhanden. + @endforelse +
+
+
diff --git a/packages/flux-cms/core/resources/views/admin-reference/cms/faqs-index.blade.php b/packages/flux-cms/core/resources/views/admin-reference/cms/faqs-index.blade.php new file mode 100644 index 0000000..a11a983 --- /dev/null +++ b/packages/flux-cms/core/resources/views/admin-reference/cms/faqs-index.blade.php @@ -0,0 +1,202 @@ + 'de', + 'selectedCategory' => null, + 'showForm' => false, + 'editingId' => null, + 'question' => '', + 'answer' => '', + 'help' => '', + 'category' => '', +]); + +$categories = computed(fn() => CmsFaq::query()->selectRaw('category, count(*) as count')->groupBy('category')->orderBy('category')->pluck('count', 'category')->toArray()); + +$faqs = computed(fn() => $this->selectedCategory ? CmsFaq::byCategory($this->selectedCategory)->ordered()->get() : collect()); + +$selectCategory = function (string $cat) { + $this->selectedCategory = $cat; + $this->showForm = false; + $this->editingId = null; +}; + +$create = function () { + $this->resetForm(); + $this->category = $this->selectedCategory ?? ''; + $this->showForm = true; +}; + +$edit = function (int $id) { + $faq = CmsFaq::findOrFail($id); + $this->editingId = $id; + $this->showForm = true; + $l = $this->editLocale; + $this->question = $faq->getTranslation('question', $l) ?? ''; + $this->answer = $faq->getTranslation('answer', $l) ?? ''; + $this->help = $faq->getTranslation('help', $l) ?? ''; + $this->category = $faq->category; +}; + +$save = function () { + $existing = $this->editingId ? CmsFaq::find($this->editingId) : null; + + $data = [ + 'category' => $this->category, + 'question' => $this->mergeTranslation($existing, 'question', $this->question), + 'answer' => $this->mergeTranslation($existing, 'answer', $this->answer), + 'help' => $this->help ? $this->mergeTranslation($existing, 'help', $this->help) : null, + ]; + + if ($existing) { + $existing->update($data); + } else { + $data['order'] = CmsFaq::where('category', $this->category)->max('order') + 1; + $data['is_published'] = true; + CmsFaq::create($data); + } + + $this->showForm = false; + $this->editingId = null; + + Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'FAQ wurde erfolgreich gespeichert.'); +}; + +$delete = function (int $id) { + CmsFaq::findOrFail($id)->delete(); + Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'FAQ wurde entfernt.'); +}; + +$togglePublished = function (int $id) { + $faq = CmsFaq::findOrFail($id); + $faq->update(['is_published' => !$faq->is_published]); + Flux::toast(heading: 'Status geändert', text: $faq->is_published ? 'Veröffentlicht' : 'Entwurf'); +}; + +$switchLocale = function (string $locale) { + $this->editLocale = $locale; + if ($this->editingId) { + $faq = CmsFaq::find($this->editingId); + if ($faq) { + $this->question = $faq->getTranslation('question', $locale) ?? ''; + $this->answer = $faq->getTranslation('answer', $locale) ?? ''; + $this->help = $faq->getTranslation('help', $locale) ?? ''; + } + } +}; + +$cancel = function () { + $this->showForm = false; + $this->editingId = null; +}; + +$resetForm = function () { + $this->editingId = null; + $this->question = ''; + $this->answer = ''; + $this->help = ''; +}; + +$mergeTranslation = function (?CmsFaq $model, string $field, string $value): array { + $existing = $model ? $model->getTranslations($field) : []; + $existing[$this->editLocale] = $value; + return $existing; +}; + +?> + +
+
+ FAQs +
+ @foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label) + {{ strtoupper($code) }} + @endforeach + Neu +
+
+ +
+
+ + Kategorien +
+ @foreach ($this->categories as $cat => $count) + + @endforeach +
+
+
+ +
+ @if ($showForm) + + {{ $editingId ? 'FAQ bearbeiten' : 'Neue FAQ' }} + +
+ + + + +
+ Speichern + Abbrechen +
+
+
+ @endif + + @if ($selectedCategory) + + {{ $selectedCategory }} +
+ @forelse ($this->faqs as $faq) +
+
+

{{ $faq->getTranslation('question', $editLocale) }}

+

+ {{ \Illuminate\Support\Str::limit(strip_tags($faq->getTranslation('answer', $editLocale) ?? ''), 100) }} +

+
+
+ + + +
+
+ @empty + Keine FAQs in dieser Kategorie. + @endforelse +
+
+ @else + +
+ + Kategorie auswählen + Wähle links eine Kategorie, um FAQs zu bearbeiten. +
+
+ @endif +
+
+
diff --git a/packages/flux-cms/core/resources/views/admin-reference/cms/industries-index.blade.php b/packages/flux-cms/core/resources/views/admin-reference/cms/industries-index.blade.php new file mode 100644 index 0000000..6333c7d --- /dev/null +++ b/packages/flux-cms/core/resources/views/admin-reference/cms/industries-index.blade.php @@ -0,0 +1,176 @@ + 'de', + 'showForm' => false, + 'editingId' => null, + 'name' => '', + 'order' => 0, +]); + +$industries = computed(fn () => CmsIndustry::ordered()->get()); + +$create = function () { + $this->editingId = null; + $this->name = ''; + $this->order = CmsIndustry::max('order') + 1; + $this->showForm = true; +}; + +$edit = function (int $id) { + $item = CmsIndustry::findOrFail($id); + $this->editingId = $id; + $this->name = $item->getTranslation('name', $this->editLocale) ?? ''; + $this->order = $item->order; + $this->showForm = true; +}; + +$save = function () { + $existing = $this->editingId ? CmsIndustry::find($this->editingId) : null; + $translations = $existing ? $existing->getTranslations('name') : []; + $translations[$this->editLocale] = $this->name; + + if ($existing) { + $existing->update(['name' => $translations, 'order' => (int) $this->order]); + } else { + CmsIndustry::create([ + 'name' => $translations, + 'order' => (int) $this->order, + 'is_published' => true, + ]); + } + + $this->showForm = false; + $this->editingId = null; + $this->name = ''; + + Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Industrie wurde erfolgreich gespeichert.'); +}; + +$delete = function (int $id) { + CmsIndustry::findOrFail($id)->delete(); + Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'Industrie wurde entfernt.'); +}; + +$togglePublished = function (int $id) { + $item = CmsIndustry::findOrFail($id); + $item->update(['is_published' => !$item->is_published]); + Flux::toast(heading: 'Status geändert', text: $item->is_published ? 'Veröffentlicht' : 'Entwurf'); +}; + +$switchLocale = function (string $locale) { + $this->editLocale = $locale; + if ($this->editingId) { + $item = CmsIndustry::find($this->editingId); + $this->name = $item?->getTranslation('name', $locale) ?? ''; + } +}; + +$moveUp = function (int $id) { + $items = CmsIndustry::ordered()->get(); + $index = $items->search(fn($i) => $i->id === $id); + + if ($index === false || $index === 0) { + return; + } + + $prev = $items[$index - 1]; + $current = $items[$index]; + + $tmpOrder = $prev->order; + $prev->update(['order' => $current->order]); + $current->update(['order' => $tmpOrder]); + + Flux::toast(heading: 'Verschoben', text: 'Position geändert.'); +}; + +$moveDown = function (int $id) { + $items = CmsIndustry::ordered()->get(); + $index = $items->search(fn($i) => $i->id === $id); + + if ($index === false || $index >= $items->count() - 1) { + return; + } + + $next = $items[$index + 1]; + $current = $items[$index]; + + $tmpOrder = $next->order; + $next->update(['order' => $current->order]); + $current->update(['order' => $tmpOrder]); + + Flux::toast(heading: 'Verschoben', text: 'Position geändert.'); +}; + +$cancel = function () { + $this->showForm = false; + $this->editingId = null; +}; + +?> + +
+
+ Industries Band +
+ @foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label) + {{ strtoupper($code) }} + @endforeach + Neu +
+
+ + @if ($showForm) + + {{ $editingId ? 'Bearbeiten' : 'Neue Industry' }} +
+
+
+ +
+ +
+
+ Speichern + Abbrechen +
+
+
+ @endif + + +
+ @forelse ($this->industries as $item) +
+
+ {{ $item->order }} + {{ $item->getTranslation('name', $editLocale) }} + @unless ($item->is_published) + Entwurf + @endunless +
+
+ + + + + +
+
+ @empty + Keine Industries vorhanden. + @endforelse +
+
+
diff --git a/packages/flux-cms/core/resources/views/admin-reference/cms/linkedin-index.blade.php b/packages/flux-cms/core/resources/views/admin-reference/cms/linkedin-index.blade.php new file mode 100644 index 0000000..1de53e8 --- /dev/null +++ b/packages/flux-cms/core/resources/views/admin-reference/cms/linkedin-index.blade.php @@ -0,0 +1,226 @@ + 'de', + 'showForm' => false, + 'editingId' => null, + 'title' => '', + 'excerpt' => '', + 'content' => '', + 'author' => '', + 'date' => null, + 'url' => '', + 'image' => '', + 'imageMediaId' => null, + 'tags' => '', + 'source' => 'manual', +]); + +$posts = computed(fn() => CmsLinkedinPost::ordered()->get()); + +$create = function () { + $this->reset(['editingId', 'title', 'excerpt', 'content', 'author', 'date', 'url', 'image', 'imageMediaId', 'tags', 'source']); + $this->source = 'manual'; + $this->showForm = true; +}; + +$edit = function (int $id) { + $post = CmsLinkedinPost::findOrFail($id); + $this->editingId = $id; + $this->showForm = true; + $l = $this->editLocale; + $this->title = $post->getTranslation('title', $l) ?? ''; + $this->excerpt = $post->getTranslation('excerpt', $l) ?? ''; + $this->content = $post->getTranslation('content', $l) ?? ''; + $this->author = $post->author ?? ''; + $this->date = $post->date?->format('Y-m-d'); + $this->url = $post->url ?? ''; + $this->image = $post->image ?? ''; + $this->imageMediaId = $this->image ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->image)->first()?->id : null; + $this->tags = is_array($post->tags) ? implode(', ', $post->tags) : ''; + $this->source = $post->source; +}; + +$save = function () { + $existing = $this->editingId ? CmsLinkedinPost::find($this->editingId) : null; + + $mergeT = function (string $field, string $value) use ($existing) { + $t = $existing ? $existing->getTranslations($field) : []; + $t[$this->editLocale] = $value; + return $t; + }; + + $tagsArray = array_map('trim', explode(',', $this->tags)); + $tagsArray = array_filter($tagsArray); + + $data = [ + 'title' => $mergeT('title', $this->title), + 'excerpt' => $mergeT('excerpt', $this->excerpt), + 'content' => $mergeT('content', $this->content), + 'author' => $this->author, + 'date' => $this->date ?: null, + 'url' => $this->url, + 'image' => $this->image, + 'tags' => array_values($tagsArray), + 'source' => $this->source, + ]; + + if ($existing) { + $existing->update($data); + } else { + $data['order'] = CmsLinkedinPost::max('order') + 1; + $data['is_published'] = true; + CmsLinkedinPost::create($data); + } + + $this->showForm = false; + $this->editingId = null; + + Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'LinkedIn-Post wurde erfolgreich gespeichert.'); +}; + +$delete = function (int $id) { + CmsLinkedinPost::findOrFail($id)->delete(); + Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'LinkedIn-Post wurde entfernt.'); +}; + +$togglePublished = function (int $id) { + $post = CmsLinkedinPost::findOrFail($id); + $post->update(['is_published' => !$post->is_published]); + Flux::toast(heading: 'Status geändert', text: $post->is_published ? 'Veröffentlicht' : 'Entwurf'); +}; + +$switchLocale = function (string $locale) { + $this->editLocale = $locale; + if ($this->editingId) { + $post = CmsLinkedinPost::find($this->editingId); + if ($post) { + $this->title = $post->getTranslation('title', $locale) ?? ''; + $this->excerpt = $post->getTranslation('excerpt', $locale) ?? ''; + $this->content = $post->getTranslation('content', $locale) ?? ''; + } + } +}; + +$cancel = function () { + $this->showForm = false; + $this->editingId = null; +}; + +on(['media-selected' => function (string $field, ?int $mediaId, ?string $url) { + if ($field === 'linkedin_image') { + $this->imageMediaId = $mediaId; + $media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null; + $this->image = $media ? $media->filename : ''; + } +}]); + +?> + +
+
+ LinkedIn Beiträge +
+ @foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label) + {{ strtoupper($code) }} + @endforeach + Neu +
+
+ + @if ($showForm) + + {{ $editingId ? 'Bearbeiten' : 'Neuer LinkedIn Beitrag' }} + +
+ + + + +
+ +
+ @if ($image) +
+ +
+ @endif +
+ +

{{ $image ?: 'Kein Bild' }}

+
+
+
+ + + Manuell + API + +
+
+ +
+
+ +
+
+ Speichern + Abbrechen +
+
+ @endif + + +
+ @forelse ($this->posts as $post) +
+
+ @if ($post->image) +
+ +
+ @endif +
+
+ {{ $post->getTranslation('title', $editLocale) }} + + {{ $post->source }} + @unless ($post->is_published) + Entwurf + @endunless +
+

{{ $post->author }} · + {{ $post->date?->format('d.m.Y') }}

+
+
+ + + +
+
+ @empty + Keine LinkedIn Beiträge vorhanden. + @endforelse +
+
+
diff --git a/packages/flux-cms/core/resources/views/admin-reference/cms/media-index.blade.php b/packages/flux-cms/core/resources/views/admin-reference/cms/media-index.blade.php new file mode 100644 index 0000000..d9d306d --- /dev/null +++ b/packages/flux-cms/core/resources/views/admin-reference/cms/media-index.blade.php @@ -0,0 +1,475 @@ + '', + 'filterType' => 'all', + 'filterCollection' => '', + 'viewMode' => 'grid', + 'editingId' => null, + 'editLocale' => 'de', + 'altText' => '', + 'mediaTitle' => '', + 'collection' => '', + 'showDetail' => false, + 'selectedProfiles' => [], +]); + +$media = computed( + fn() => CmsMedia::query() + ->when( + $this->filterType !== 'all', + fn($q) => match ($this->filterType) { + 'image' => $q->images(), + 'pdf' => $q->pdfs(), + 'document' => $q->documents(), + default => $q, + }, + ) + ->when($this->filterCollection, fn($q) => $q->inCollection($this->filterCollection)) + ->when($this->search, fn($q) => $q->where('filename', 'like', "%{$this->search}%")) + ->orderByDesc('created_at') + ->paginate(48), +); + +$collections = computed(fn() => CmsMedia::query()->whereNotNull('collection')->where('collection', '!=', '')->distinct()->pluck('collection')->sort()->values()->toArray()); + +$profiles = computed(fn() => config('flux-cms.media.profiles', [])); + +$stats = computed( + fn() => [ + 'total' => CmsMedia::count(), + 'images' => CmsMedia::images()->count(), + 'pdfs' => CmsMedia::pdfs()->count(), + ], +); + +on([ + 'media-library-uploaded' => function ($mediaId) { + $media = CmsMedia::find($mediaId); + if ($media) { + Flux::toast(variant: 'success', heading: 'Hochgeladen', text: $media->filename . ' wurde erfolgreich hochgeladen.'); + } + }, +]); + +$startEdit = function (int $id) { + $media = CmsMedia::find($id); + if (!$media) { + return; + } + $this->editingId = $id; + $this->altText = $media->getTranslation('alt_text', $this->editLocale) ?? ''; + $this->mediaTitle = $media->getTranslation('title', $this->editLocale) ?? ''; + $this->collection = $media->collection ?? ''; + $this->showDetail = true; +}; + +$switchLocale = function (string $locale) { + $this->editLocale = $locale; + if ($this->editingId) { + $media = CmsMedia::find($this->editingId); + if ($media) { + $this->altText = $media->getTranslation('alt_text', $locale) ?? ''; + $this->mediaTitle = $media->getTranslation('title', $locale) ?? ''; + } + } +}; + +$saveEdit = function () { + $media = CmsMedia::find($this->editingId); + if (!$media) { + return; + } + + $media->setTranslation('alt_text', $this->editLocale, $this->altText); + $media->setTranslation('title', $this->editLocale, $this->mediaTitle); + $media->collection = $this->collection; + $media->save(); + + Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Medien-Details wurden aktualisiert.'); +}; + +$generateConversion = function (string $profile) { + $media = CmsMedia::find($this->editingId); + if (!$media || !$media->isImage()) { + return; + } + + $service = app(MediaConversionService::class); + $result = $service->convert($media, $profile); + + if ($result) { + $media->refresh(); + Flux::toast(variant: 'success', heading: 'Conversion erstellt', text: "Profil \"{$profile}\" wurde generiert."); + } else { + Flux::toast(variant: 'danger', heading: 'Fehler', text: "Conversion \"{$profile}\" konnte nicht erstellt werden."); + } +}; + +$generateAllConversions = function () { + $media = CmsMedia::find($this->editingId); + if (!$media || !$media->isImage()) { + return; + } + + $service = app(MediaConversionService::class); + $results = $service->generateAllConversions($media); + + $count = count(array_filter($results)); + $media->refresh(); + Flux::toast(variant: 'success', heading: 'Alle Conversions erstellt', text: "{$count} Profile wurden generiert."); +}; + +$deleteMedia = function (int $id) { + $media = CmsMedia::find($id); + if (!$media) { + return; + } + + $filename = $media->filename; + $service = app(MediaConversionService::class); + $service->deleteAll($media); + $media->delete(); + + $this->editingId = null; + $this->showDetail = false; + + Flux::toast(variant: 'success', heading: 'Gelöscht', text: "{$filename} wurde entfernt."); +}; + +$closeDetail = function () { + $this->showDetail = false; + $this->editingId = null; +}; + +?> + +
+
+ Medienbibliothek +
+ {{ $this->stats['images'] }} Bilder + {{ $this->stats['pdfs'] }} PDFs +
+
+ + {{-- Upload Area --}} + +
+
+ +
+
+
+ + {{-- Filters --}} +
+ + + + Alle Typen + Bilder + PDFs + Dokumente + + + @if (!empty($this->collections)) + + Alle Ordner + @foreach ($this->collections as $col) + {{ $col }} + @endforeach + + @endif + +
+ + +
+
+ +
+ {{-- Media Grid / List --}} +
+ @if ($viewMode === 'grid') +
+ @forelse ($this->media as $item) +
+
+ @if ($item->isImage()) + {{ $item->getTranslation('alt_text', $editLocale) ?? $item->filename }} + @elseif ($item->isPdf()) +
+ +
+
+ @else +
+ + {{ strtoupper(pathinfo($item->filename, PATHINFO_EXTENSION)) }} +
+ @endif +
+
+ @if ($item->isImage()) + + @elseif ($item->isPdf()) + + @else + + @endif +
+

+ {{ $item->filename }}

+

{{ $item->getHumanFileSize() }}

+
+
+ @if ($item->collection) +
+ + {{ $item->collection }} +
+ @endif +
+ @empty +
+ + Noch keine Medien hochgeladen. +
+ @endforelse +
+ @else + {{-- List View --}} +
+ + + + + + + + + + + + + + @forelse ($this->media as $item) + + + + + + + + + + @empty + + + + @endforelse + +
DateinameDatum
+
+ @if ($item->isImage()) + + @elseif ($item->isPdf()) +
+ +
+ @else +
+ +
+ @endif +
+
+ {{ $item->filename }} + + {{ $item->created_at->format('d.m.Y') }}
+ + Noch keine Medien hochgeladen. +
+
+ @endif + + @if ($this->media->hasPages()) +
+ {{ $this->media->links() }} +
+ @endif +
+ + {{-- Detail Sidebar --}} + @if ($showDetail && $editingId) + @php $editMedia = \FluxCms\Core\Models\CmsMedia::find($editingId); @endphp + @if ($editMedia) +
+ +
+ Details + +
+ + {{-- Large Preview --}} +
+ @if ($editMedia->isImage()) + {{ $editMedia->filename }} + @elseif ($editMedia->isPdf()) + + @endif +
+ + {{-- File Info --}} +
+

Datei: {{ $editMedia->filename }}

+

Typ: {{ $editMedia->mime_type }}

+

Größe: {{ $editMedia->getHumanFileSize() }}

+ @if ($editMedia->getDimensionsLabel()) +

Abmessungen: {{ $editMedia->getDimensionsLabel() }} px

+ @endif +

Hochgeladen: {{ $editMedia->created_at->format('d.m.Y H:i') }}

+

URL: + + {{ $editMedia->getUrl() }} + +

+
+ + {{-- Locale Switcher --}} +
+ @foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label) + + {{ strtoupper($code) }} + + @endforeach +
+ + {{-- Edit Fields --}} +
+ + + + + + Speichern + +
+ + {{-- Conversions --}} + @if ($editMedia->isImage() && $editMedia->mime_type !== 'image/svg+xml') +
+
+ Bildgrößen + + Alle generieren + +
+ +
+ @foreach ($this->profiles as $profileName => $profileConfig) + @php + $hasConversion = $editMedia->hasConversion($profileName); + $conversionUrl = $hasConversion + ? $editMedia->getConversionUrl($profileName) + : null; + @endphp +
+
+ {{ $profileName }} + + {{ $profileConfig['width'] }}×{{ $profileConfig['height'] }} + {{ strtoupper($profileConfig['format'] ?? 'webp') }} + +
+
+ @if ($hasConversion) + OK + @else + + @endif + +
+
+ @endforeach +
+
+ @endif + + {{-- Delete --}} +
+ + Datei löschen + +
+
+
+ @endif + @endif +
+
diff --git a/packages/flux-cms/core/resources/views/admin-reference/cms/media-library-uploader.blade.php b/packages/flux-cms/core/resources/views/admin-reference/cms/media-library-uploader.blade.php new file mode 100644 index 0000000..f571e05 --- /dev/null +++ b/packages/flux-cms/core/resources/views/admin-reference/cms/media-library-uploader.blade.php @@ -0,0 +1,30 @@ +
+ + + + + @if (isset($uploads) && count($uploads) > 0) +
+ @foreach ($uploads as $index => $upload) + + + + + + @endforeach +
+ @endif + + +
diff --git a/packages/flux-cms/core/resources/views/admin-reference/cms/media-picker.blade.php b/packages/flux-cms/core/resources/views/admin-reference/cms/media-picker.blade.php new file mode 100644 index 0000000..0c39a02 --- /dev/null +++ b/packages/flux-cms/core/resources/views/admin-reference/cms/media-picker.blade.php @@ -0,0 +1,128 @@ +
+ {{-- Current Selection Preview --}} +
+
+ @if ($this->selected) +
+ @if ($this->selected->isImage()) + {{ $this->selected->filename }} + @elseif ($this->selected->isPdf()) +
+ +
+ @endif +
+

+ {{ $this->selected->filename }} +

+

+ {{ $this->selected->getHumanFileSize() }} + @if ($this->selected->getDimensionsLabel()) + — {{ $this->selected->getDimensionsLabel() }} + @endif +

+ @if ($this->selected->isImage() && $this->selected->hasConversion($profile)) + @php + $pConfig = config("flux-cms.media.profiles.{$profile}", []); + @endphp + + {{ $profile }}: {{ $pConfig['width'] ?? '?' }}×{{ $pConfig['height'] ?? '?' }} + + @endif +
+ +
+ @else +
+ Kein Medium ausgewählt +
+ @endif +
+ + {{ $label }} + +
+ + {{-- Picker Modal --}} + + {{ $label }} + + {{-- Quick Upload + Search --}} +
+ + + + + + + @if (isset($quickUploads) && count($quickUploads) > 0) +
+ @foreach ($quickUploads as $index => $upload) + + + + + + @endforeach +
+ @endif + + +
+ + @php $profileConfig = config("flux-cms.media.profiles.{$profile}", []); @endphp + @if (!empty($profileConfig)) + + Profil {{ $profile }}: {{ $profileConfig['width'] }}×{{ $profileConfig['height'] }} px, + {{ strtoupper($profileConfig['format'] ?? 'webp') }}, + Qualität {{ $profileConfig['quality'] ?? 85 }}% + + @endif + + {{-- Media Grid --}} +
+ @forelse ($this->mediaItems as $item) +
+
+ @if ($item->isImage()) + {{ $item->filename }} + @elseif ($item->isPdf()) +
+ +
+ @endif +
+
+

{{ $item->filename }}

+
+
+ @empty +
+ Keine Medien gefunden. +
+ @endforelse +
+ + @if ($this->mediaItems->hasPages()) +
+ {{ $this->mediaItems->links() }} +
+ @endif +
+
diff --git a/packages/flux-cms/core/resources/views/admin-reference/cms/media-uploader.blade.php b/packages/flux-cms/core/resources/views/admin-reference/cms/media-uploader.blade.php new file mode 100644 index 0000000..f42b8e8 --- /dev/null +++ b/packages/flux-cms/core/resources/views/admin-reference/cms/media-uploader.blade.php @@ -0,0 +1,8 @@ +
+ + +
+ +
+
diff --git a/packages/flux-cms/core/resources/views/admin-reference/cms/news-index.blade.php b/packages/flux-cms/core/resources/views/admin-reference/cms/news-index.blade.php new file mode 100644 index 0000000..36808fb --- /dev/null +++ b/packages/flux-cms/core/resources/views/admin-reference/cms/news-index.blade.php @@ -0,0 +1,271 @@ + 'de', + 'showForm' => false, + 'editingId' => null, + 'icon' => '', + 'text' => '', + 'title' => '', + 'excerpt' => '', + 'content' => '', + 'image' => '', + 'imageMediaId' => null, + 'pdfMediaId' => null, + 'date' => null, + 'author' => '', + 'link' => '', + 'pdf_path' => '', + 'pdf_open_text' => '', + 'pdf_download_text' => '', +]); + +$items = computed(fn() => CmsNewsItem::ordered()->get()); + +$availableIcons = computed(fn () => HeroiconOutlineList::names()); + +$create = function () { + $this->reset(['editingId', 'icon', 'text', 'title', 'excerpt', 'content', 'image', 'imageMediaId', 'pdfMediaId', 'date', 'author', 'link', 'pdf_path', 'pdf_open_text', 'pdf_download_text']); + $this->showForm = true; +}; + +$edit = function (int $id) { + $item = CmsNewsItem::findOrFail($id); + $this->editingId = $id; + $this->showForm = true; + $l = $this->editLocale; + $this->icon = $item->icon ?? ''; + $this->text = $item->getTranslation('text', $l) ?? ''; + $this->title = $item->getTranslation('title', $l) ?? ''; + $this->excerpt = $item->getTranslation('excerpt', $l) ?? ''; + $this->content = $item->getTranslation('content', $l) ?? ''; + $this->image = $item->image ?? ''; + $this->date = $item->date?->format('Y-m-d'); + $this->author = $item->author ?? ''; + $this->link = $item->link ?? ''; + $this->pdf_path = $item->pdf_path ?? ''; + $this->pdf_open_text = $item->getTranslation('pdf_open_text', $l) ?? ''; + $this->pdf_download_text = $item->getTranslation('pdf_download_text', $l) ?? ''; + $this->imageMediaId = $this->image ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->image)->first()?->id : null; + $this->pdfMediaId = $this->pdf_path ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->pdf_path)->first()?->id : null; +}; + +$save = function () { + $existing = $this->editingId ? CmsNewsItem::find($this->editingId) : null; + $merge = function (string $field, string $value) use ($existing) { + $t = $existing ? $existing->getTranslations($field) : []; + $t[$this->editLocale] = $value; + return $t; + }; + + $data = [ + 'icon' => $this->icon, + 'text' => $merge('text', $this->text), + 'title' => $merge('title', $this->title), + 'excerpt' => $merge('excerpt', $this->excerpt), + 'content' => $merge('content', $this->content), + 'image' => $this->image, + 'date' => $this->date ?: null, + 'author' => $this->author, + 'link' => $this->link, + 'pdf_path' => $this->pdf_path, + 'pdf_open_text' => $merge('pdf_open_text', $this->pdf_open_text), + 'pdf_download_text' => $merge('pdf_download_text', $this->pdf_download_text), + ]; + + if ($existing) { + $existing->update($data); + } else { + $data['order'] = CmsNewsItem::max('order') + 1; + $data['is_published'] = true; + CmsNewsItem::create($data); + } + + $this->showForm = false; + $this->editingId = null; + + Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'News-Eintrag wurde erfolgreich gespeichert.'); +}; + +$delete = function (int $id) { + CmsNewsItem::findOrFail($id)->delete(); + Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'News-Eintrag wurde entfernt.'); +}; + +$togglePublished = function (int $id) { + $item = CmsNewsItem::findOrFail($id); + $item->update(['is_published' => !$item->is_published]); + Flux::toast(heading: 'Status geändert', text: $item->is_published ? 'Veröffentlicht' : 'Entwurf'); +}; + +$switchLocale = function (string $locale) { + $this->editLocale = $locale; + if ($this->editingId) { + $item = CmsNewsItem::find($this->editingId); + if ($item) { + $this->text = $item->getTranslation('text', $locale) ?? ''; + $this->title = $item->getTranslation('title', $locale) ?? ''; + $this->excerpt = $item->getTranslation('excerpt', $locale) ?? ''; + $this->content = $item->getTranslation('content', $locale) ?? ''; + $this->pdf_open_text = $item->getTranslation('pdf_open_text', $locale) ?? ''; + $this->pdf_download_text = $item->getTranslation('pdf_download_text', $locale) ?? ''; + } + } +}; + +$cancel = function () { + $this->showForm = false; + $this->editingId = null; +}; + +on([ + 'media-selected' => function (string $field, ?int $mediaId, ?string $url) { + if ($field === 'news_image') { + $this->imageMediaId = $mediaId; + $media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null; + $this->image = $media ? $media->filename : ''; + } elseif ($field === 'news_pdf') { + $this->pdfMediaId = $mediaId; + $media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null; + $this->pdf_path = $media ? $media->filename : ''; + } + }, +]); + +?> + +
+
+ News Band +
+ @foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label) + {{ strtoupper($code) }} + @endforeach + Neu +
+
+ + @if ($showForm) + + {{ $editingId ? 'Bearbeiten' : 'Neuer News-Eintrag' }} + +
+ + +
+
+
+ + @foreach ($this->availableIcons as $iconName) + {{ $iconName }} + + @endforeach + +
+ @if ($icon) +
+ +
+ @endif +
+
+ + + +
+ +
+ @if ($image) +
+ +
+ @endif +
+ +

{{ $image ?: 'Kein Bild' }}

+
+
+
+
+ + @if ($pdf_path) +
+ +
+

{{ $pdf_path }}

+ @endif + +
+ + +
+
+ +
+
+ +
+
+ Speichern + Abbrechen +
+
+ @endif + + +
+ @forelse ($this->items as $item) +
+
+ @if ($item->image) +
+ +
+ @endif +
+
+ @if ($item->icon) + + @endif + {{ $item->getTranslation('title', $editLocale) }} + @unless ($item->is_published) + Entwurf + @endunless +
+

+ {{ $item->getTranslation('text', $editLocale) }}

+
+
+
+ + + +
+
+ @empty + Keine News-Einträge vorhanden. + @endforelse +
+
+
diff --git a/packages/flux-cms/core/resources/views/admin-reference/cms/search-index.blade.php b/packages/flux-cms/core/resources/views/admin-reference/cms/search-index.blade.php new file mode 100644 index 0000000..69d7eae --- /dev/null +++ b/packages/flux-cms/core/resources/views/admin-reference/cms/search-index.blade.php @@ -0,0 +1,452 @@ + '', + 'editingId' => null, + 'editLocale' => 'de', + 'itemId' => '', + 'route' => '', + 'routeParams' => '', + 'category' => '', + 'titleKey' => '', + 'titleFallback' => '', + 'descriptionKey' => '', + 'descriptionFallbackKey' => '', + 'descriptionFallbackText' => '', + 'keywords' => [], + 'newKeyword' => '', + 'isPublished' => true, + 'reindexing' => false, +]); + +$items = computed( + fn () => CmsSearchIndex::query() + ->when($this->search, fn ($q) => $q->where('item_id', 'like', "%{$this->search}%") + ->orWhere('route', 'like', "%{$this->search}%") + ->orWhere('category', 'like', "%{$this->search}%")) + ->ordered() + ->get() +); + +$startEdit = function (int $id) { + $item = CmsSearchIndex::find($id); + if (! $item) { + return; + } + + $this->editingId = $id; + $this->itemId = $item->item_id; + $this->route = $item->route; + $this->routeParams = implode(', ', $item->route_params ?? []); + $this->category = $item->getTranslation('category', $this->editLocale, false) ?? ''; + $this->titleKey = $item->title_key ?? ''; + $this->titleFallback = $item->getTranslation('title_fallback', $this->editLocale, false) ?? ''; + $this->descriptionKey = $item->description_key ?? ''; + $this->descriptionFallbackKey = $item->description_fallback_key ?? ''; + $this->descriptionFallbackText = $item->getTranslation('description_fallback_text', $this->editLocale, false) ?? ''; + $this->keywords = $item->getTranslation('keywords', $this->editLocale, false) ?? []; + if (! is_array($this->keywords)) { + $this->keywords = []; + } + $this->isPublished = $item->is_published; + $this->newKeyword = ''; +}; + +$switchLocale = function (string $locale) { + if ($this->editingId) { + $item = CmsSearchIndex::find($this->editingId); + if ($item) { + $this->editLocale = $locale; + $this->category = $item->getTranslation('category', $locale, false) ?? ''; + $this->titleFallback = $item->getTranslation('title_fallback', $locale, false) ?? ''; + $this->descriptionFallbackText = $item->getTranslation('description_fallback_text', $locale, false) ?? ''; + $this->keywords = $item->getTranslation('keywords', $locale, false) ?? []; + if (! is_array($this->keywords)) { + $this->keywords = []; + } + } + } else { + $this->editLocale = $locale; + } +}; + +$save = function () { + $item = CmsSearchIndex::find($this->editingId); + if (! $item) { + return; + } + + $item->item_id = $this->itemId; + $item->route = $this->route; + $item->route_params = array_values(array_filter(array_map('trim', explode(',', $this->routeParams)))); + $item->setTranslation('category', $this->editLocale, $this->category); + + $item->title_key = $this->titleKey ?: null; + $item->setTranslation('title_fallback', $this->editLocale, $this->titleFallback ?: null); + + $item->description_key = $this->descriptionKey ?: null; + $item->description_fallback_key = $this->descriptionFallbackKey ?: null; + $item->setTranslation('description_fallback_text', $this->editLocale, $this->descriptionFallbackText ?: null); + + $cleanKeywords = array_values(array_filter($this->keywords, fn ($k) => is_string($k) && trim($k) !== '')); + $item->setTranslation('keywords', $this->editLocale, $cleanKeywords); + $item->is_published = $this->isPublished; + $item->save(); + + Flux::toast(variant: 'success', heading: 'Gespeichert', text: "Suchindex-Eintrag '{$item->item_id}' wurde gespeichert."); +}; + +$addKeyword = function () { + $keyword = trim($this->newKeyword); + if ($keyword !== '' && ! in_array($keyword, $this->keywords)) { + $this->keywords[] = $keyword; + } + $this->newKeyword = ''; +}; + +$removeKeyword = function (int $index) { + unset($this->keywords[$index]); + $this->keywords = array_values($this->keywords); +}; + +$create = function () { + $maxOrder = CmsSearchIndex::max('order') ?? -1; + $item = CmsSearchIndex::create([ + 'item_id' => 'new-item-' . time(), + 'route' => 'home', + 'route_params' => [], + 'category' => ['de' => 'Neu', 'en' => 'New'], + 'keywords' => ['de' => [], 'en' => []], + 'is_published' => false, + 'order' => $maxOrder + 1, + ]); + + $this->editingId = $item->id; + $this->itemId = $item->item_id; + $this->route = $item->route; + $this->routeParams = ''; + $this->category = 'Neu'; + $this->titleKey = ''; + $this->titleFallback = ''; + $this->descriptionKey = ''; + $this->descriptionFallbackKey = ''; + $this->descriptionFallbackText = ''; + $this->keywords = []; + $this->isPublished = false; + $this->newKeyword = ''; + + Flux::toast(variant: 'success', heading: 'Erstellt', text: 'Neuer Suchindex-Eintrag wurde erstellt.'); +}; + +$delete = function (int $id) { + $item = CmsSearchIndex::find($id); + if ($item) { + $name = $item->item_id; + $item->delete(); + if ($this->editingId === $id) { + $this->editingId = null; + } + Flux::toast(variant: 'success', heading: 'Geloescht', text: "Eintrag '{$name}' wurde entfernt."); + } +}; + +$togglePublished = function (int $id) { + $item = CmsSearchIndex::find($id); + if ($item) { + $item->is_published = ! $item->is_published; + $item->save(); + Flux::toast(variant: 'success', heading: 'Status geaendert', text: $item->is_published ? 'Aktiviert' : 'Deaktiviert'); + } +}; + +$moveUp = function (int $id) { + $item = CmsSearchIndex::find($id); + if (! $item) { + return; + } + $prev = CmsSearchIndex::where('order', '<', $item->order)->orderByDesc('order')->first(); + if ($prev) { + $tmpOrder = $item->order; + $item->order = $prev->order; + $prev->order = $tmpOrder; + $item->save(); + $prev->save(); + } +}; + +$moveDown = function (int $id) { + $item = CmsSearchIndex::find($id); + if (! $item) { + return; + } + $next = CmsSearchIndex::where('order', '>', $item->order)->orderBy('order')->first(); + if ($next) { + $tmpOrder = $item->order; + $item->order = $next->order; + $next->order = $tmpOrder; + $item->save(); + $next->save(); + } +}; + +$reindex = function () { + $this->reindexing = true; + + try { + $exitCode = \Illuminate\Support\Facades\Artisan::call('search:extract-keywords', [ + '--apply' => true, + '--locale' => ['de', 'en'], + ]); + + $deItems = []; + $enItems = []; + $dePath = lang_path('de/search_index.php'); + $enPath = lang_path('en/search_index.php'); + + if (file_exists($dePath)) { + $deConfig = require $dePath; + $deItems = collect($deConfig['items'] ?? [])->keyBy('id'); + } + if (file_exists($enPath)) { + $enConfig = require $enPath; + $enItems = collect($enConfig['items'] ?? [])->keyBy('id'); + } + + $updated = 0; + foreach (CmsSearchIndex::all() as $entry) { + $de = $deItems->get($entry->item_id); + $en = $enItems->get($entry->item_id); + + if ($de && ! empty($de['keywords'])) { + $existing = $entry->getTranslation('keywords', 'de', false) ?? []; + $merged = array_values(array_unique(array_merge( + is_array($existing) ? $existing : [], + $de['keywords'] + ))); + $entry->setTranslation('keywords', 'de', $merged); + } + + if ($en && ! empty($en['keywords'])) { + $existing = $entry->getTranslation('keywords', 'en', false) ?? []; + $merged = array_values(array_unique(array_merge( + is_array($existing) ? $existing : [], + $en['keywords'] + ))); + $entry->setTranslation('keywords', 'en', $merged); + } + + if ($entry->isDirty()) { + $entry->save(); + $updated++; + } + } + + Flux::toast(variant: 'success', heading: 'Reindexierung abgeschlossen', text: "{$updated} Eintraege aktualisiert."); + } catch (\Exception $e) { + Flux::toast(variant: 'danger', heading: 'Fehler', text: $e->getMessage()); + } + + $this->reindexing = false; +}; + +?> + +
+
+
+ Suchindex + Verwalte die Seiten-Suche: Keywords, Kategorien und Beschreibungen. +
+
+ + Reindexieren + Wird reindexiert... + + Neuer Eintrag +
+
+ +
+ +
+ +
+ {{-- Liste --}} +
+ @foreach ($this->items as $item) +
+
+

+ {{ $item->item_id }}

+

+ {{ $item->getTranslation('category', 'de', false) }} + · {{ $item->route }}

+
+
+ + +
+
+ @endforeach +
+ + {{-- Editor --}} +
+ @if ($editingId) + @php $currentItem = CmsSearchIndex::find($editingId); @endphp + @if ($currentItem) +
+
+ {{ $currentItem->item_id }} +
+
+ @foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label) + + {{ strtoupper($code) }} + + @endforeach +
+ + {{ $isPublished ? 'Aktiv' : 'Inaktiv' }} + + +
+
+ +
+ + +
+ +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {{-- Keywords --}} +
+ + +
+ @foreach ($keywords as $kIdx => $keyword) + + @if (str_contains($keyword, '.')) + + @endif + {{ $keyword }} + + + @endforeach +
+ +
+ + Hinzufuegen +
+

+ = CMS-Key (wird aufgeloest), + normale Keywords werden direkt verwendet. + Enter druecken oder Button klicken zum Hinzufuegen. +

+
+ + {{-- Vorschau --}} + @php + $preview = $currentItem->toFrontendArray($editLocale); + @endphp +
+

Vorschau + ({{ strtoupper($editLocale) }})

+

{{ $preview['category'] }}

+

+ {{ $preview['title'] ?: '(kein Titel)' }}

+

+ {{ $preview['description'] ?: '(keine Beschreibung)' }}

+ @if (! empty($preview['url'])) +

{{ $preview['url'] }}

+ @endif + @if (! empty($preview['keywords'])) +
+ @foreach (array_slice($preview['keywords'], 0, 10) as $kw) + {{ $kw }} + @endforeach + @if (count($preview['keywords']) > 10) + +{{ count($preview['keywords']) - 10 }} + weitere + @endif +
+ @endif +
+ +
+ Speichern +
+
+ @endif + @else +
+
+ +

Eintrag aus der Liste auswaehlen oder neuen erstellen. +

+
+
+ @endif +
+
+
diff --git a/packages/flux-cms/core/resources/views/admin-reference/cms/team-index.blade.php b/packages/flux-cms/core/resources/views/admin-reference/cms/team-index.blade.php new file mode 100644 index 0000000..24d390e --- /dev/null +++ b/packages/flux-cms/core/resources/views/admin-reference/cms/team-index.blade.php @@ -0,0 +1,243 @@ + 'de', + 'showForm' => false, + 'editingIndex' => null, + 'name' => '', + 'role' => '', + 'image' => '', + 'imageMediaId' => null, + 'quote' => '', + 'preview' => '', + 'short' => '', + 'linkedin' => '', +]); + +$getProfiles = function (): array { + $content = CmsContent::where('group', 'team')->where('key', 'members.profiles')->first(); + if (!$content) { + return []; + } + + $val = $content->getTranslation('value', $this->editLocale); + + return is_array($val) ? $val : []; +}; + +$profiles = computed(fn() => $this->getProfiles()); + +$create = function () { + $this->reset(['editingIndex', 'name', 'role', 'image', 'quote', 'preview', 'short', 'linkedin']); + $this->showForm = true; +}; + +$edit = function (int $index) { + $profiles = $this->getProfiles(); + if (!isset($profiles[$index])) { + return; + } + + $p = $profiles[$index]; + $this->editingIndex = $index; + $this->showForm = true; + $this->name = $p['name'] ?? ''; + $this->role = $p['role'] ?? ''; + $this->image = $p['image'] ?? ''; + $this->quote = $p['quote'] ?? ''; + $this->preview = $p['preview'] ?? ''; + $this->short = $p['short'] ?? ($p['kuerzel'] ?? ''); + $this->linkedin = $p['linkedin'] ?? ''; + $this->imageMediaId = $this->image ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->image)->first()?->id : null; +}; + +$save = function () { + $content = CmsContent::where('group', 'team')->where('key', 'members.profiles')->first(); + if (!$content) { + return; + } + + $profiles = $content->getTranslation('value', $this->editLocale); + if (!is_array($profiles)) { + $profiles = []; + } + + $entry = [ + 'name' => $this->name, + 'role' => $this->role, + 'image' => $this->image, + 'quote' => $this->quote, + 'preview' => $this->preview, + 'short' => $this->short, + 'linkedin' => $this->linkedin, + ]; + + if ($this->editingIndex !== null && isset($profiles[$this->editingIndex])) { + $profiles[$this->editingIndex] = $entry; + } else { + $profiles[] = $entry; + } + + $content->setTranslation('value', $this->editLocale, array_values($profiles)); + $content->save(); + + app(CmsContentService::class)->clearCache('team'); + + $this->showForm = false; + $this->editingIndex = null; + + Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Teammitglied wurde gespeichert.'); +}; + +$delete = function (int $index) { + $content = CmsContent::where('group', 'team')->where('key', 'members.profiles')->first(); + if (!$content) { + return; + } + + $profiles = $content->getTranslation('value', $this->editLocale); + if (!is_array($profiles) || !isset($profiles[$index])) { + return; + } + + unset($profiles[$index]); + + $content->setTranslation('value', $this->editLocale, array_values($profiles)); + $content->save(); + + app(CmsContentService::class)->clearCache('team'); + + Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'Teammitglied wurde entfernt.'); +}; + +$switchLocale = function (string $locale) { + $this->editLocale = $locale; + if ($this->editingIndex !== null) { + $profiles = $this->getProfiles(); + if (isset($profiles[$this->editingIndex])) { + $p = $profiles[$this->editingIndex]; + $this->name = $p['name'] ?? ''; + $this->role = $p['role'] ?? ''; + $this->image = $p['image'] ?? ''; + $this->quote = $p['quote'] ?? ''; + $this->preview = $p['preview'] ?? ''; + $this->short = $p['short'] ?? ($p['kuerzel'] ?? ''); + $this->linkedin = $p['linkedin'] ?? ''; + } + } +}; + +$cancel = function () { + $this->showForm = false; + $this->editingIndex = null; +}; + +on([ + 'media-selected' => function (string $field, ?int $mediaId, ?string $url) { + if ($field === 'team_image') { + $this->imageMediaId = $mediaId; + $media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null; + $this->image = $media ? $media->filename : ''; + } + }, +]); + +?> + +
+
+ Team-Verwaltung +
+ @foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label) + {{ strtoupper($code) }} + @endforeach + Neu +
+
+ + @if ($showForm) + + + {{ $editingIndex !== null ? 'Teammitglied bearbeiten' : 'Neues Teammitglied' }} + +
+ + + + +
+ +
+ @if ($image) +
+ +
+ @endif +
+ +

{{ $image ?: 'Kein Bild' }}

+
+
+
+
+ +
+
+
+ +
+
+ Speichern + Abbrechen +
+
+ @endif + + +
+ @forelse ($this->profiles as $index => $profile) +
+
+ @if (!empty($profile['image'])) +
+ {{ $profile['name'] ?? '' }} +
+ @else +
+ {{ $profile['short'] ?? mb_substr($profile['name'] ?? '?', 0, 2) }} +
+ @endif +
+
{{ $profile['name'] ?? '—' }}
+

+ {{ $profile['role'] ?? '' }} +

+
+
+
+ + +
+
+ @empty + Keine Teammitglieder vorhanden. + @endforelse +
+
+
diff --git a/packages/flux-cms/core/resources/views/admin-reference/layout-cms.blade.php b/packages/flux-cms/core/resources/views/admin-reference/layout-cms.blade.php new file mode 100644 index 0000000..b0d9edc --- /dev/null +++ b/packages/flux-cms/core/resources/views/admin-reference/layout-cms.blade.php @@ -0,0 +1,149 @@ + + + + + @include('partials.head') + + + + + + + + + + + + + + Dashboard + + + Inhalte + + + News Band + + + Industries + + + FAQs + + + LinkedIn + + + Downloads + + + Team + + + Medienbibliothek + + + Suchindex + + + + + + + + + Zurück zum Dashboard + + + + @auth + + @endauth + + + @auth + + + + + + + + + {{ __('Settings') }} + + + + + +
+ @csrf + + {{ __('Log Out') }} + +
+
+
+
+ @endauth + + + {{ $slot }} + + + @persist('toast') + + @endpersist + + @fluxScripts + + + diff --git a/packages/flux-cms/core/resources/views/admin-reference/media-uploader.blade.php b/packages/flux-cms/core/resources/views/admin-reference/media-uploader.blade.php new file mode 100644 index 0000000..f42b8e8 --- /dev/null +++ b/packages/flux-cms/core/resources/views/admin-reference/media-uploader.blade.php @@ -0,0 +1,8 @@ +
+ + +
+ +
+
diff --git a/packages/flux-cms/core/routes/admin.php b/packages/flux-cms/core/routes/admin.php index a235833..806cb4c 100644 --- a/packages/flux-cms/core/routes/admin.php +++ b/packages/flux-cms/core/routes/admin.php @@ -1,13 +1,13 @@ info('Clearing Flux CMS component cache...'); $registry->clearCache(); $this->info('Flux CMS component cache cleared successfully!'); + return self::SUCCESS; } } diff --git a/packages/flux-cms/core/src/Commands/InstallCommand.php b/packages/flux-cms/core/src/Commands/InstallCommand.php index 0e888e8..231b13c 100644 --- a/packages/flux-cms/core/src/Commands/InstallCommand.php +++ b/packages/flux-cms/core/src/Commands/InstallCommand.php @@ -19,17 +19,17 @@ class InstallCommand extends Command $this->info('🚀 Installing Flux CMS...'); // Check requirements - if (!$this->checkRequirements()) { + if (! $this->checkRequirements()) { return 1; } // Publish configuration - if (!$this->option('no-publish')) { + if (! $this->option('no-publish')) { $this->publishAssets(); } // Run migrations - if (!$this->option('no-migrate')) { + if (! $this->option('no-migrate')) { $this->runMigrations(); } @@ -70,7 +70,7 @@ class InstallCommand extends Command } } - if (!$allPassed) { + if (! $allPassed) { $this->error('❌ Some requirements are not met. Please install missing dependencies.'); $this->line('Run: composer require spatie/laravel-translatable spatie/laravel-medialibrary'); } @@ -85,11 +85,12 @@ class InstallCommand extends Command protected function checkLivewireVersion(): bool { - if (!class_exists(\Livewire\Livewire::class)) { + if (! class_exists(\Livewire\Livewire::class)) { return false; } $version = \Livewire\Livewire::VERSION ?? '2.0.0'; + return version_compare($version, '3.0.0', '>='); } @@ -129,7 +130,7 @@ class InstallCommand extends Command protected function createStorageLink(): void { - if (!File::exists(public_path('storage'))) { + if (! File::exists(public_path('storage'))) { $this->info('🔗 Creating storage link...'); $this->call('storage:link'); $this->line('✅ Storage link created'); diff --git a/packages/flux-cms/core/src/Commands/MakeComponentCommand.php b/packages/flux-cms/core/src/Commands/MakeComponentCommand.php index 5910824..1d56ba3 100644 --- a/packages/flux-cms/core/src/Commands/MakeComponentCommand.php +++ b/packages/flux-cms/core/src/Commands/MakeComponentCommand.php @@ -8,22 +8,25 @@ use Symfony\Component\Console\Input\InputArgument; class MakeComponentCommand extends GeneratorCommand { protected $name = 'flux-cms:make-component'; + protected $description = 'Create a new Flux CMS component'; + protected $type = 'Flux CMS Component'; protected function getStub() { - return __DIR__ . '/stubs/component.stub'; + return __DIR__.'/stubs/component.stub'; } protected function getDefaultNamespace($rootNamespace) { - return $rootNamespace . '\Livewire\Web\Components'; + return $rootNamespace.'\Livewire\Web\Components'; } protected function buildClass($name) { $stub = parent::buildClass($name); + return str_replace('{{ componentName }}', $this->argument('name'), $stub); } @@ -43,7 +46,7 @@ class MakeComponentCommand extends GeneratorCommand protected function createView() { $name = $this->argument('name'); - $viewPath = resource_path('views/livewire/web/components/' . strtolower($name) . '.blade.php'); + $viewPath = resource_path('views/livewire/web/components/'.strtolower($name).'.blade.php'); if (! is_dir(dirname($viewPath))) { mkdir(dirname($viewPath), 0777, true); @@ -51,10 +54,11 @@ class MakeComponentCommand extends GeneratorCommand if (file_exists($viewPath)) { $this->error('View already exists!'); + return; } - $stub = $this->files->get(__DIR__ . '/stubs/view.stub'); + $stub = $this->files->get(__DIR__.'/stubs/view.stub'); $this->files->put($viewPath, $stub); $this->info('View created successfully.'); } diff --git a/packages/flux-cms/core/src/Commands/PublishCommand.php b/packages/flux-cms/core/src/Commands/PublishCommand.php index fe66d1b..0553fbb 100644 --- a/packages/flux-cms/core/src/Commands/PublishCommand.php +++ b/packages/flux-cms/core/src/Commands/PublishCommand.php @@ -3,7 +3,6 @@ namespace FluxCms\Core\Commands; use Illuminate\Console\Command; -use Illuminate\Support\Facades\File; class PublishCommand extends Command { @@ -27,6 +26,7 @@ class PublishCommand extends Command } $this->info('✅ Flux CMS assets published successfully!'); + return 0; } diff --git a/packages/flux-cms/core/src/FieldTypes/BaseField.php b/packages/flux-cms/core/src/FieldTypes/BaseField.php index 5c7e5a7..ce5f906 100644 --- a/packages/flux-cms/core/src/FieldTypes/BaseField.php +++ b/packages/flux-cms/core/src/FieldTypes/BaseField.php @@ -5,13 +5,21 @@ namespace FluxCms\Core\FieldTypes; abstract class BaseField { protected string $key; + protected string $label; + protected bool $translatable = false; + protected bool $required = false; + protected mixed $default = null; + protected array $rules = []; + protected array $attributes = []; + protected ?string $helpText = null; + protected ?string $placeholder = null; public function __construct(string $key, string $label) @@ -31,48 +39,56 @@ abstract class BaseField public function translatable(bool $translatable = true): static { $this->translatable = $translatable; + return $this; } public function required(bool $required = true): static { $this->required = $required; + return $this; } public function default(mixed $default): static { $this->default = $default; + return $this; } public function rules(array|string $rules): static { $this->rules = is_array($rules) ? $rules : [$rules]; + return $this; } public function helpText(string $helpText): static { $this->helpText = $helpText; + return $this; } public function placeholder(string $placeholder): static { $this->placeholder = $placeholder; + return $this; } public function attributes(array $attributes): static { $this->attributes = array_merge($this->attributes, $attributes); + return $this; } public function attribute(string $key, mixed $value): static { $this->attributes[$key] = $value; + return $this; } @@ -139,13 +155,15 @@ abstract class BaseField * Abstract Methods */ abstract public function getType(): string; + abstract public function getValidationRules(): array; + abstract public function toArray(): array; /** * Value Handling */ - public function getValue(array $content, string $locale = null): mixed + public function getValue(array $content, ?string $locale = null): mixed { if ($this->translatable && $locale) { return $content[$this->key][$locale] ?? $this->default; @@ -154,7 +172,7 @@ abstract class BaseField return $content[$this->key] ?? $this->default; } - public function setValue(array &$content, mixed $value, string $locale = null): void + public function setValue(array &$content, mixed $value, ?string $locale = null): void { if ($this->translatable && $locale) { $content[$this->key][$locale] = $value; @@ -166,7 +184,7 @@ abstract class BaseField /** * Validation */ - public function validate(mixed $value, string $locale = null): array + public function validate(mixed $value, ?string $locale = null): array { $rules = $this->getValidationRules(); @@ -177,7 +195,7 @@ abstract class BaseField // Für übersetzbare Felder Locale zu Regeln hinzufügen $fieldKey = $this->key; if ($locale && $this->translatable) { - $fieldKey .= '.' . $locale; + $fieldKey .= '.'.$locale; } try { @@ -190,7 +208,7 @@ abstract class BaseField return $validator->fails() ? $validator->errors()->get($fieldKey) : []; } catch (\Exception $e) { - return ['Validation error: ' . $e->getMessage()]; + return ['Validation error: '.$e->getMessage()]; } } @@ -226,7 +244,7 @@ abstract class BaseField /** * Rendering */ - public function render(array $content = [], string $locale = null): string + public function render(array $content = [], ?string $locale = null): string { $value = $this->getValue($content, $locale); @@ -242,8 +260,8 @@ abstract class BaseField $viewName = "flux-cms::fields.{$this->getType()}"; - if (!view()->exists($viewName)) { - $viewName = "flux-cms::fields.fallback"; + if (! view()->exists($viewName)) { + $viewName = 'flux-cms::fields.fallback'; } return view($viewName, $viewData)->render(); @@ -252,7 +270,7 @@ abstract class BaseField /** * Wire Model für Livewire */ - public function getWireModel(string $locale = null): string + public function getWireModel(?string $locale = null): string { if ($this->translatable && $locale) { return "content.{$this->key}.{$locale}"; @@ -264,12 +282,12 @@ abstract class BaseField /** * Field ID für Labels */ - public function getFieldId(string $locale = null): string + public function getFieldId(?string $locale = null): string { - $id = 'field_' . $this->key; + $id = 'field_'.$this->key; if ($this->translatable && $locale) { - $id .= '_' . $locale; + $id .= '_'.$locale; } return $id; @@ -280,7 +298,7 @@ abstract class BaseField */ public function getCssClasses(bool $hasError = false): string { - $classes = ['flux-cms-field', 'flux-cms-field--' . $this->getType()]; + $classes = ['flux-cms-field', 'flux-cms-field--'.$this->getType()]; if ($this->required) { $classes[] = 'flux-cms-field--required'; @@ -324,4 +342,4 @@ abstract class BaseField { return $value; } -} \ No newline at end of file +} diff --git a/packages/flux-cms/core/src/FieldTypes/BooleanField.php b/packages/flux-cms/core/src/FieldTypes/BooleanField.php index 1212d5f..4ab2e82 100644 --- a/packages/flux-cms/core/src/FieldTypes/BooleanField.php +++ b/packages/flux-cms/core/src/FieldTypes/BooleanField.php @@ -5,37 +5,44 @@ namespace FluxCms\Core\FieldTypes; class BooleanField extends BaseField { protected string $trueLabel = 'Yes'; + protected string $falseLabel = 'No'; + protected string $displayType = 'checkbox'; // checkbox, toggle, radio public function labels(string $trueLabel, string $falseLabel): static { $this->trueLabel = $trueLabel; $this->falseLabel = $falseLabel; + return $this; } public function displayType(string $type): static { $this->displayType = $type; + return $this; } public function toggle(): static { $this->displayType = 'toggle'; + return $this; } public function radio(): static { $this->displayType = 'radio'; + return $this; } public function checkbox(): static { $this->displayType = 'checkbox'; + return $this; } @@ -79,15 +86,17 @@ class BooleanField extends BaseField // Convert various truthy values to boolean if (is_string($value)) { $value = strtolower($value); + return in_array($value, ['1', 'true', 'yes', 'on'], true); } return (bool) $value; } - public function getValue(array $content, string $locale = null): mixed + public function getValue(array $content, ?string $locale = null): mixed { $value = parent::getValue($content, $locale); + return $this->sanitizeValue($value); } @@ -112,4 +121,4 @@ class BooleanField extends BaseField 'attributes' => $this->attributes, ]; } -} \ No newline at end of file +} diff --git a/packages/flux-cms/core/src/FieldTypes/MediaField.php b/packages/flux-cms/core/src/FieldTypes/MediaField.php index e928657..62a7649 100644 --- a/packages/flux-cms/core/src/FieldTypes/MediaField.php +++ b/packages/flux-cms/core/src/FieldTypes/MediaField.php @@ -5,16 +5,23 @@ namespace FluxCms\Core\FieldTypes; class MediaField extends BaseField { protected array $acceptedMimeTypes = ['image/jpeg', 'image/png', 'image/webp']; + protected bool $multiple = false; + protected int $maxFiles = 1; + protected ?string $collection = null; + protected array $conversions = []; + protected int $maxFileSize = 10240; // 10MB in KB + protected bool $showPreview = true; public function acceptedMimeTypes(array $mimeTypes): static { $this->acceptedMimeTypes = $mimeTypes; + return $this; } @@ -22,30 +29,35 @@ class MediaField extends BaseField { $this->multiple = $multiple; $this->maxFiles = $maxFiles; + return $this; } public function collection(string $collection): static { $this->collection = $collection; + return $this; } public function conversions(array $conversions): static { $this->conversions = $conversions; + return $this; } public function maxFileSize(int $sizeInKb): static { $this->maxFileSize = $sizeInKb; + return $this; } public function showPreview(bool $show = true): static { $this->showPreview = $show; + return $this; } @@ -53,6 +65,7 @@ class MediaField extends BaseField { $this->acceptedMimeTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml']; $this->collection = 'images'; + return $this; } @@ -64,10 +77,11 @@ class MediaField extends BaseField 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/plain', - 'text/csv' + 'text/csv', ]; $this->collection = 'documents'; $this->showPreview = false; + return $this; } @@ -75,6 +89,7 @@ class MediaField extends BaseField { $this->acceptedMimeTypes = ['video/mp4', 'video/webm', 'video/ogg']; $this->collection = 'videos'; + return $this; } @@ -83,6 +98,7 @@ class MediaField extends BaseField $this->acceptedMimeTypes = ['audio/mpeg', 'audio/wav', 'audio/ogg']; $this->collection = 'audio'; $this->showPreview = false; + return $this; } @@ -157,38 +173,37 @@ class MediaField extends BaseField public function isImageType(): bool { - return !empty(array_filter($this->acceptedMimeTypes, fn($type) => str_starts_with($type, 'image/'))); + return ! empty(array_filter($this->acceptedMimeTypes, fn ($type) => str_starts_with($type, 'image/'))); } public function isVideoType(): bool { - return !empty(array_filter($this->acceptedMimeTypes, fn($type) => str_starts_with($type, 'video/'))); + return ! empty(array_filter($this->acceptedMimeTypes, fn ($type) => str_starts_with($type, 'video/'))); } public function isAudioType(): bool { - return !empty(array_filter($this->acceptedMimeTypes, fn($type) => str_starts_with($type, 'audio/'))); + return ! empty(array_filter($this->acceptedMimeTypes, fn ($type) => str_starts_with($type, 'audio/'))); } public function isDocumentType(): bool { - return !empty(array_filter($this->acceptedMimeTypes, fn($type) => - str_starts_with($type, 'application/') || str_starts_with($type, 'text/') + return ! empty(array_filter($this->acceptedMimeTypes, fn ($type) => str_starts_with($type, 'application/') || str_starts_with($type, 'text/') )); } - public function getValue(array $content, string $locale = null): mixed + public function getValue(array $content, ?string $locale = null): mixed { $value = parent::getValue($content, $locale); // Ensure array for multiple fields - if ($this->multiple && !is_array($value)) { + if ($this->multiple && ! is_array($value)) { return $value ? [$value] : []; } // Ensure integer for single fields - if (!$this->multiple && is_array($value)) { - return !empty($value) ? (int) $value[0] : null; + if (! $this->multiple && is_array($value)) { + return ! empty($value) ? (int) $value[0] : null; } return $value; @@ -230,4 +245,4 @@ class MediaField extends BaseField 'attributes' => $this->attributes, ]; } -} \ No newline at end of file +} diff --git a/packages/flux-cms/core/src/FieldTypes/NumberField.php b/packages/flux-cms/core/src/FieldTypes/NumberField.php index 1a65fb2..5792bf8 100644 --- a/packages/flux-cms/core/src/FieldTypes/NumberField.php +++ b/packages/flux-cms/core/src/FieldTypes/NumberField.php @@ -5,25 +5,31 @@ namespace FluxCms\Core\FieldTypes; class NumberField extends BaseField { protected ?float $min = null; + protected ?float $max = null; + protected float $step = 1; + protected bool $decimal = false; public function min(float $min): static { $this->min = $min; + return $this; } public function max(float $max): static { $this->max = $max; + return $this; } public function step(float $step): static { $this->step = $step; + return $this; } @@ -33,6 +39,7 @@ class NumberField extends BaseField if ($decimal && $this->step === 1) { $this->step = 0.01; } + return $this; } @@ -41,6 +48,7 @@ class NumberField extends BaseField $this->decimal(true); $this->step(0.01); $this->min(0); + return $this; } @@ -50,6 +58,7 @@ class NumberField extends BaseField $this->min(0); $this->max(100); $this->step(0.1); + return $this; } @@ -141,4 +150,4 @@ class NumberField extends BaseField 'attributes' => $this->attributes, ]; } -} \ No newline at end of file +} diff --git a/packages/flux-cms/core/src/FieldTypes/SelectField.php b/packages/flux-cms/core/src/FieldTypes/SelectField.php index bd64f6f..9de9650 100644 --- a/packages/flux-cms/core/src/FieldTypes/SelectField.php +++ b/packages/flux-cms/core/src/FieldTypes/SelectField.php @@ -5,31 +5,38 @@ namespace FluxCms\Core\FieldTypes; class SelectField extends BaseField { protected array $options = []; + protected bool $multiple = false; + protected bool $searchable = false; + protected ?string $emptyOption = null; public function options(array $options): static { $this->options = $options; + return $this; } public function multiple(bool $multiple = true): static { $this->multiple = $multiple; + return $this; } public function searchable(bool $searchable = true): static { $this->searchable = $searchable; + return $this; } public function emptyOption(string $text): static { $this->emptyOption = $text; + return $this; } @@ -72,34 +79,34 @@ class SelectField extends BaseField $rules[] = 'string'; } - if (!empty($this->options)) { + if (! empty($this->options)) { $validValues = array_keys($this->options); if ($this->multiple) { $rules[] = 'array'; // Each value must be in the valid options foreach ($validValues as $value) { - $rules[] = "array"; + $rules[] = 'array'; } } else { - $rules[] = "in:" . implode(',', $validValues); + $rules[] = 'in:'.implode(',', $validValues); } } return array_merge($rules, $this->rules); } - public function getValue(array $content, string $locale = null): mixed + public function getValue(array $content, ?string $locale = null): mixed { $value = parent::getValue($content, $locale); // Ensure array for multiple selects - if ($this->multiple && !is_array($value)) { + if ($this->multiple && ! is_array($value)) { return $value ? [$value] : []; } // Ensure string for single selects - if (!$this->multiple && is_array($value)) { - return !empty($value) ? (string) $value[0] : ''; + if (! $this->multiple && is_array($value)) { + return ! empty($value) ? (string) $value[0] : ''; } return $value; @@ -122,4 +129,4 @@ class SelectField extends BaseField 'attributes' => $this->attributes, ]; } -} \ No newline at end of file +} diff --git a/packages/flux-cms/core/src/FieldTypes/TextField.php b/packages/flux-cms/core/src/FieldTypes/TextField.php index 842a659..76cf61d 100644 --- a/packages/flux-cms/core/src/FieldTypes/TextField.php +++ b/packages/flux-cms/core/src/FieldTypes/TextField.php @@ -5,25 +5,31 @@ namespace FluxCms\Core\FieldTypes; class TextField extends BaseField { protected int $maxLength = 255; + protected int $minLength = 0; + protected ?string $pattern = null; + protected string $inputType = 'text'; public function maxLength(int $maxLength): static { $this->maxLength = $maxLength; + return $this; } public function minLength(int $minLength): static { $this->minLength = $minLength; + return $this; } public function pattern(string $pattern): static { $this->pattern = $pattern; + return $this; } @@ -31,6 +37,7 @@ class TextField extends BaseField { $this->inputType = 'email'; $this->rules(['email']); + return $this; } @@ -38,24 +45,28 @@ class TextField extends BaseField { $this->inputType = 'url'; $this->rules(['url']); + return $this; } public function password(): static { $this->inputType = 'password'; + return $this; } public function tel(): static { $this->inputType = 'tel'; + return $this; } public function search(): static { $this->inputType = 'search'; + return $this; } @@ -117,7 +128,7 @@ class TextField extends BaseField public function sanitizeValue(mixed $value): mixed { - if (!is_string($value)) { + if (! is_string($value)) { return $value; } @@ -128,8 +139,8 @@ class TextField extends BaseField $value = strtolower($value); } elseif ($this->inputType === 'url') { // Ensure URL has protocol - if ($value && !preg_match('/^https?:\/\//', $value)) { - $value = 'https://' . $value; + if ($value && ! preg_match('/^https?:\/\//', $value)) { + $value = 'https://'.$value; } } @@ -163,4 +174,4 @@ class TextField extends BaseField 'regex' => 'The :attribute field format is invalid.', ]); } -} \ No newline at end of file +} diff --git a/packages/flux-cms/core/src/FieldTypes/WysiwygField.php b/packages/flux-cms/core/src/FieldTypes/WysiwygField.php index d8813a0..90625b4 100644 --- a/packages/flux-cms/core/src/FieldTypes/WysiwygField.php +++ b/packages/flux-cms/core/src/FieldTypes/WysiwygField.php @@ -5,45 +5,56 @@ namespace FluxCms\Core\FieldTypes; class WysiwygField extends BaseField { protected array $toolbar = ['bold', 'italic', 'link', 'bulletList', 'orderedList']; + protected int $minHeight = 200; + protected bool $allowImages = true; + protected bool $allowTables = false; + protected bool $allowCode = true; + protected string $editor = 'tiptap'; // tiptap, tinymce, quill public function toolbar(array $toolbar): static { $this->toolbar = $toolbar; + return $this; } public function minHeight(int $minHeight): static { $this->minHeight = $minHeight; + return $this; } public function allowImages(bool $allowImages = true): static { $this->allowImages = $allowImages; + return $this; } public function allowTables(bool $allowTables = true): static { $this->allowTables = $allowTables; + return $this; } public function allowCode(bool $allowCode = true): static { $this->allowCode = $allowCode; + return $this; } public function editor(string $editor): static { $this->editor = $editor; + return $this; } @@ -53,6 +64,7 @@ class WysiwygField extends BaseField $this->allowImages = false; $this->allowTables = false; $this->allowCode = false; + return $this; } @@ -64,11 +76,12 @@ class WysiwygField extends BaseField 'bulletList', 'orderedList', 'link', 'image', 'table', 'code', 'codeBlock', - 'quote', 'rule' + 'quote', 'rule', ]; $this->allowImages = true; $this->allowTables = true; $this->allowCode = true; + return $this; } @@ -120,7 +133,7 @@ class WysiwygField extends BaseField public function sanitizeValue(mixed $value): mixed { - if (!is_string($value)) { + if (! is_string($value)) { return $value; } @@ -130,8 +143,8 @@ class WysiwygField extends BaseField // Remove dangerous tags $dangerousTags = ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input']; foreach ($dangerousTags as $tag) { - $value = preg_replace('/<' . $tag . '[^>]*>.*?<\/' . $tag . '>/is', '', $value); - $value = preg_replace('/<' . $tag . '[^>]*\/?>/is', '', $value); + $value = preg_replace('/<'.$tag.'[^>]*>.*?<\/'.$tag.'>/is', '', $value); + $value = preg_replace('/<'.$tag.'[^>]*\/?>/is', '', $value); } // Remove javascript: links @@ -145,13 +158,13 @@ class WysiwygField extends BaseField public function transformForDisplay(mixed $value): mixed { - if (!is_string($value)) { + if (! is_string($value)) { return $value; } // Convert relative URLs to absolute $value = preg_replace_callback('/src="(\/[^"]*)"/', function ($matches) { - return 'src="' . url($matches[1]) . '"'; + return 'src="'.url($matches[1]).'"'; }, $value); return $value; @@ -176,4 +189,4 @@ class WysiwygField extends BaseField 'attributes' => $this->attributes, ]; } -} \ No newline at end of file +} diff --git a/packages/flux-cms/core/src/FluxCmsServiceProvider.php b/packages/flux-cms/core/src/FluxCmsServiceProvider.php index 53b23cb..20901b8 100644 --- a/packages/flux-cms/core/src/FluxCmsServiceProvider.php +++ b/packages/flux-cms/core/src/FluxCmsServiceProvider.php @@ -2,152 +2,63 @@ namespace FluxCms\Core; -use Illuminate\Support\ServiceProvider; -use Illuminate\Support\Facades\Route; +use FluxCms\Core\Services\CmsContentService; +use FluxCms\Core\Services\MediaConversionService; use Illuminate\Support\Facades\Gate; -use FluxCms\Core\Services\ComponentRegistry; -use FluxCms\Core\Commands\InstallCommand; -use FluxCms\Core\Commands\PublishCommand; -use FluxCms\Core\Commands\ClearCacheCommand; -use FluxCms\Core\Commands\MakeComponentCommand; +use Illuminate\Support\ServiceProvider; class FluxCmsServiceProvider extends ServiceProvider { - /** - * Register services - */ public function register(): void { - // Merge config - $this->mergeConfigFrom(__DIR__ . '/../config/flux-cms.php', 'flux-cms'); + $this->mergeConfigFrom(__DIR__.'/../config/flux-cms.php', 'flux-cms'); - // Register services - $this->app->singleton(ComponentRegistry::class, function ($app) { - return new ComponentRegistry(); + $this->app->singleton(CmsContentService::class, function () { + return new CmsContentService; }); - // Register aliases - $this->app->alias(ComponentRegistry::class, 'flux-cms.registry'); + $this->app->alias(CmsContentService::class, 'flux-cms.content'); + + $this->app->singleton(MediaConversionService::class, function () { + return new MediaConversionService; + }); } - /** - * Bootstrap services - */ public function boot(): void { $this->bootPublishing(); $this->bootMigrations(); $this->bootViews(); - $this->bootCommands(); - $this->bootRoutes(); - $this->bootMiddleware(); $this->bootGates(); - $this->bootTranslations(); } - /** - * Boot publishing - */ protected function bootPublishing(): void { if ($this->app->runningInConsole()) { - // Publish config $this->publishes([ - __DIR__ . '/../config/flux-cms.php' => config_path('flux-cms.php'), + __DIR__.'/../config/flux-cms.php' => config_path('flux-cms.php'), ], 'flux-cms-config'); - // Publish migrations $this->publishes([ - __DIR__ . '/../database/migrations' => database_path('migrations'), + __DIR__.'/../database/migrations' => database_path('migrations'), ], 'flux-cms-migrations'); - // Publish views $this->publishes([ - __DIR__ . '/../resources/views' => resource_path('views/vendor/flux-cms'), + __DIR__.'/../resources/views' => resource_path('views/vendor/flux-cms'), ], 'flux-cms-views'); - - // Publish all - $this->publishes([ - __DIR__ . '/../config/flux-cms.php' => config_path('flux-cms.php'), - __DIR__ . '/../database/migrations' => database_path('migrations'), - __DIR__ . '/../resources/views' => resource_path('views/vendor/flux-cms'), - ], 'flux-cms'); } } - /** - * Boot migrations - */ protected function bootMigrations(): void { - $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); + $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); } - /** - * Boot views - */ protected function bootViews(): void { - $this->loadViewsFrom(__DIR__ . '/../resources/views', 'flux-cms'); + $this->loadViewsFrom(__DIR__.'/../resources/views', 'flux-cms'); } - /** - * Boot commands - */ - protected function bootCommands(): void - { - if ($this->app->runningInConsole()) { - $this->commands([ - InstallCommand::class, - PublishCommand::class, - ClearCacheCommand::class, - MakeComponentCommand::class, - ]); - } - } - - /** - * Boot routes - */ - protected function bootRoutes(): void - { - if (config('flux-cms.routes.enabled', true)) { - $this->loadRoutesFromDirectory(__DIR__ . '/../routes'); - } - } - - /** - * Boot middleware - */ - protected function bootMiddleware(): void - { - $router = $this->app['router']; - - // Register middleware aliases - $router->aliasMiddleware('flux-cms:cms-access', \FluxCms\Core\Http\Middleware\CmsAccess::class); - $router->aliasMiddleware('flux-cms:domain-detection', \FluxCms\Core\Http\Middleware\DomainDetection::class); - $router->aliasMiddleware('flux-cms:preview-mode', \FluxCms\Core\Http\Middleware\PreviewMode::class); - } - - /** - * Load routes from directory - */ - protected function loadRoutesFromDirectory(string $directory): void - { - if (!is_dir($directory)) { - return; - } - - $files = glob($directory . '/*.php'); - - foreach ($files as $file) { - $this->loadRoutesFrom($file); - } - } - - /** - * Boot authorization gates - */ protected function bootGates(): void { Gate::define('flux-cms.view', function ($user) { @@ -171,52 +82,22 @@ class FluxCmsServiceProvider extends ServiceProvider }); } - /** - * Check if user has CMS permission - */ protected function userHasCmsPermission($user, string $permission): bool { - // If no user, deny access - if (!$user) { + if (! $user) { return false; } - // Check for Spatie Permission package - if (method_exists($user, 'can')) { - return $user->can("flux-cms.{$permission}") || $user->hasRole('flux-cms') || $user->hasRole('admin'); + if (method_exists($user, 'hasRole')) { + return $user->can("flux-cms.{$permission}") + || $user->hasRole('flux-cms') + || $user->hasRole('admin'); } - // Fallback: Check if user has admin role property if (isset($user->is_admin)) { return $user->is_admin; } - // Default: Allow access for authenticated users (can be overridden in config) - return config('flux-cms.auth.default_access', false); - } - - /** - * Boot translations - */ - protected function bootTranslations(): void - { - $this->loadTranslationsFrom(__DIR__ . '/../resources/lang', 'flux-cms'); - - if ($this->app->runningInConsole()) { - $this->publishes([ - __DIR__ . '/../resources/lang' => resource_path('lang/vendor/flux-cms'), - ], 'flux-cms-translations'); - } - } - - /** - * Get the services provided by the provider - */ - public function provides(): array - { - return [ - ComponentRegistry::class, - 'flux-cms.registry', - ]; + return config('flux-cms.auth.default_access', true); } } diff --git a/packages/flux-cms/core/src/Helpers/MediaLibraryUploader.php b/packages/flux-cms/core/src/Helpers/MediaLibraryUploader.php new file mode 100644 index 0000000..8b495b6 --- /dev/null +++ b/packages/flux-cms/core/src/Helpers/MediaLibraryUploader.php @@ -0,0 +1,45 @@ + */ + public array $uploads = []; + + public function updatedUploads(): void + { + $this->validate([ + 'uploads' => 'nullable|array|max:20', + 'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240', + ]); + + $service = app(MediaConversionService::class); + + foreach ($this->uploads as $file) { + $media = $service->storeUpload($file); + $this->dispatch('media-library-uploaded', mediaId: $media->id); + } + + $this->uploads = []; + } + + public function removeUpload(int $index): void + { + if (isset($this->uploads[$index])) { + unset($this->uploads[$index]); + $this->uploads = array_values($this->uploads); + } + } + + public function render() + { + return view('livewire.admin.cms.media-library-uploader'); + } +} diff --git a/packages/flux-cms/core/src/Helpers/MediaPicker.php b/packages/flux-cms/core/src/Helpers/MediaPicker.php new file mode 100644 index 0000000..14eb146 --- /dev/null +++ b/packages/flux-cms/core/src/Helpers/MediaPicker.php @@ -0,0 +1,139 @@ + */ + public array $quickUploads = []; + + public function mount(?int $value = null): void + { + $this->value = $value; + } + + public function openPicker(): void + { + $this->showModal = true; + } + + public function selectMedia(int $id): void + { + $media = CmsMedia::find($id); + if (! $media) { + return; + } + + if ($media->isImage() && $this->profile) { + $service = app(MediaConversionService::class); + if (! $media->hasConversion($this->profile)) { + $service->convert($media, $this->profile); + $media->refresh(); + } + } + + $this->value = $media->id; + $this->showModal = false; + + $this->dispatch('media-selected', field: $this->field, mediaId: $media->id, url: $media->getConversionUrl($this->profile)); + } + + public function clearSelection(): void + { + $this->value = null; + $this->dispatch('media-selected', field: $this->field, mediaId: null, url: null); + } + + public function updatedQuickUploads(): void + { + $this->validate([ + 'quickUploads' => 'nullable|array|max:5', + 'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240', + ]); + + $service = app(MediaConversionService::class); + $lastMedia = null; + + foreach ($this->quickUploads as $file) { + $lastMedia = $service->storeUpload($file); + + if ($lastMedia->isImage() && $this->profile) { + $service->convert($lastMedia, $this->profile); + $lastMedia->refresh(); + } + } + + $this->quickUploads = []; + + if ($lastMedia) { + $this->value = $lastMedia->id; + $this->showModal = false; + + $this->dispatch('media-selected', field: $this->field, mediaId: $lastMedia->id, url: $lastMedia->getConversionUrl($this->profile)); + } + } + + public function removeQuickUpload(int $index): void + { + if (isset($this->quickUploads[$index])) { + unset($this->quickUploads[$index]); + $this->quickUploads = array_values($this->quickUploads); + } + } + + public function render(): View + { + return view('livewire.admin.cms.media-picker', [ + 'selectedMedia' => $this->resolveSelectedMedia(), + 'mediaItems' => $this->resolveMediaItems(), + ]); + } + + private function resolveSelectedMedia(): ?CmsMedia + { + if (! $this->value) { + return null; + } + + return CmsMedia::find($this->value); + } + + /** + * @return LengthAwarePaginator + */ + private function resolveMediaItems(): LengthAwarePaginator + { + return CmsMedia::query() + ->when($this->type === 'image', fn ($q) => $q->images()) + ->when($this->type === 'pdf', fn ($q) => $q->pdfs()) + ->when($this->type === 'document', fn ($q) => $q->documents()) + ->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%")) + ->orderByDesc('created_at') + ->paginate(18); + } +} diff --git a/packages/flux-cms/core/src/Helpers/MediaUploader.php b/packages/flux-cms/core/src/Helpers/MediaUploader.php new file mode 100644 index 0000000..c06737d --- /dev/null +++ b/packages/flux-cms/core/src/Helpers/MediaUploader.php @@ -0,0 +1,39 @@ +validate(); + + $path = $this->file->store($this->directory, $this->disk); + + $this->dispatch('media-uploaded', field: $this->field, path: '/storage/'.$path); + + $this->file = null; + } + + public function render() + { + return view('livewire.admin.cms.media-uploader'); + } +} diff --git a/packages/flux-cms/core/src/Helpers/cms_helpers.php b/packages/flux-cms/core/src/Helpers/cms_helpers.php new file mode 100644 index 0000000..62db38d --- /dev/null +++ b/packages/flux-cms/core/src/Helpers/cms_helpers.php @@ -0,0 +1,85 @@ + $replace + */ + function cms(string $key, array $replace = [], ?string $locale = null): mixed + { + return app(CmsContentService::class)->get($key, $replace, $locale); + } +} + +if (! function_exists('tcms')) { + /** + * CMS content with automatic tooltip replacement. + * + * @param array $replace + */ + function tcms(string $key, array $replace = [], ?string $locale = null): string + { + $text = cms($key, $replace, $locale); + + if (! is_string($text)) { + $text = (string) $text; + } + + return \App\Helpers\TooltipHelper::autoTooltip($text); + } +} + +if (! function_exists('t__')) { + /** + * Übersetzt einen Schlüssel und ersetzt automatisch alle bekannten Begriffe mit Tooltips + * + * @param string $key Der Übersetzungsschlüssel (z.B. 'digitale-transformation.hero.description') + * @param array $replace Optionale Ersetzungen (z.B. ['name' => 'Max']) + * @param string|null $locale Optionale Locale (Standard: aktuelle Locale) + * @return string Der übersetzte Text mit automatischen Tooltips + */ + function t__(string $key, array $replace = [], ?string $locale = null): string + { + // Holt den übersetzten Text + $text = __($key, $replace, $locale); + + // Wendet automatische Tooltip-Ersetzung an + return \App\Helpers\TooltipHelper::autoTooltip($text); + } +} + +if (! function_exists('trans_tooltip')) { + /** + * Alias für t__() - Übersetzt einen Schlüssel und fügt automatisch Tooltips hinzu + * + * @param string $key Der Übersetzungsschlüssel + * @param array $replace Optionale Ersetzungen + * @param string|null $locale Optionale Locale + * @return string Der übersetzte Text mit automatischen Tooltips + */ + function trans_tooltip(string $key, array $replace = [], ?string $locale = null): string + { + return t__($key, $replace, $locale); + } +} + +if (! function_exists('tooltip')) { + /** + * Alias für t__() - Übersetzt einen Schlüssel und fügt automatisch Tooltips hinzu + * + * @param string $key Der Übersetzungsschlüssel + * @param array $replace Optionale Ersetzungen + * @param string|null $locale Optionale Locale + * @return string Der übersetzte Text mit automatischen Tooltips + */ + function tooltip(string $text): string + { + return \App\Helpers\TooltipHelper::autoTooltip($text); + } +} diff --git a/packages/flux-cms/core/src/Helpers/helpers.php b/packages/flux-cms/core/src/Helpers/helpers.php new file mode 100644 index 0000000..3c07ffe --- /dev/null +++ b/packages/flux-cms/core/src/Helpers/helpers.php @@ -0,0 +1,144 @@ + $replace + */ + function cms(string $key, array $replace = [], ?string $locale = null): mixed + { + return app(CmsContentService::class)->get($key, $replace, $locale); + } +} + +if (! function_exists('media_url')) { + /** + * Resolve a media library filename to its storage URL. + * + * Looks up CmsMedia by filename and returns the URL. + * Falls back to asset('assets/images/...') if not found. + */ + function media_url(string $filename, ?string $profile = null): string + { + static $cache = []; + + $cacheKey = $filename.'|'.($profile ?? ''); + if (isset($cache[$cacheKey])) { + return $cache[$cacheKey]; + } + + $media = \FluxCms\Core\Models\CmsMedia::where('filename', $filename)->first(); + + if (! $media) { + return $cache[$cacheKey] = asset('assets/images/'.$filename); + } + + if ($profile && $media->hasConversion($profile)) { + return $cache[$cacheKey] = $media->getConversionUrl($profile); + } + + return $cache[$cacheKey] = $media->getUrl(); + } +} + +if (! function_exists('cms_media_url')) { + /** + * Resolve a CMS content key to a media library URL. + * + * The CMS entry stores a CmsMedia filename. + * Returns the original URL or a conversion URL if a profile is specified. + */ + function cms_media_url(string $key, ?string $profile = null): string + { + $filename = cms($key); + + if (! $filename || ! is_string($filename)) { + return ''; + } + + $media = \FluxCms\Core\Models\CmsMedia::where('filename', $filename)->first(); + + if (! $media) { + return asset('assets/images/'.$filename); + } + + if ($profile && $media->hasConversion($profile)) { + return $media->getConversionUrl($profile); + } + + return $media->getUrl(); + } +} + +if (! function_exists('tcms')) { + /** + * CMS content with automatic tooltip replacement. + * + * @param array $replace + */ + function tcms(string $key, array $replace = [], ?string $locale = null): string + { + $text = cms($key, $replace, $locale); + + if (! is_string($text)) { + $text = (string) $text; + } + + return \App\Helpers\TooltipHelper::autoTooltip($text); + } +} + +if (! function_exists('t__')) { + /** + * Übersetzt einen Schlüssel und ersetzt automatisch alle bekannten Begriffe mit Tooltips + * + * @param string $key Der Übersetzungsschlüssel (z.B. 'digitale-transformation.hero.description') + * @param array $replace Optionale Ersetzungen (z.B. ['name' => 'Max']) + * @param string|null $locale Optionale Locale (Standard: aktuelle Locale) + * @return string Der übersetzte Text mit automatischen Tooltips + */ + function t__(string $key, array $replace = [], ?string $locale = null): string + { + // Holt den übersetzten Text + $text = __($key, $replace, $locale); + + // Wendet automatische Tooltip-Ersetzung an + return \App\Helpers\TooltipHelper::autoTooltip($text); + } +} + +if (! function_exists('trans_tooltip')) { + /** + * Alias für t__() - Übersetzt einen Schlüssel und fügt automatisch Tooltips hinzu + * + * @param string $key Der Übersetzungsschlüssel + * @param array $replace Optionale Ersetzungen + * @param string|null $locale Optionale Locale + * @return string Der übersetzte Text mit automatischen Tooltips + */ + function trans_tooltip(string $key, array $replace = [], ?string $locale = null): string + { + return t__($key, $replace, $locale); + } +} + +if (! function_exists('tooltip')) { + /** + * Alias für t__() - Übersetzt einen Schlüssel und fügt automatisch Tooltips hinzu + * + * @param string $key Der Übersetzungsschlüssel + * @param array $replace Optionale Ersetzungen + * @param string|null $locale Optionale Locale + * @return string Der übersetzte Text mit automatischen Tooltips + */ + function tooltip(string $text): string + { + return \App\Helpers\TooltipHelper::autoTooltip($text); + } +} diff --git a/packages/flux-cms/core/src/Http/Controllers/Admin/BlogController.php b/packages/flux-cms/core/src/Http/Controllers/Admin/BlogController.php index 0738b41..38a4c52 100644 --- a/packages/flux-cms/core/src/Http/Controllers/Admin/BlogController.php +++ b/packages/flux-cms/core/src/Http/Controllers/Admin/BlogController.php @@ -2,9 +2,9 @@ namespace FluxCms\Core\Http\Controllers\Admin; +use FluxCms\Core\Models\BlogPost; use Illuminate\Http\Request; use Illuminate\Routing\Controller; -use FluxCms\Core\Models\BlogPost; class BlogController extends Controller { @@ -36,6 +36,7 @@ class BlogController extends Controller public function edit(BlogPost $blogPost) { $this->authorize('flux-cms.edit'); + return view('flux-cms::admin.blog.edit', ['post' => $blogPost]); } } diff --git a/packages/flux-cms/core/src/Http/Controllers/Admin/ComponentController.php b/packages/flux-cms/core/src/Http/Controllers/Admin/ComponentController.php index 1c73a27..1c9fcf9 100644 --- a/packages/flux-cms/core/src/Http/Controllers/Admin/ComponentController.php +++ b/packages/flux-cms/core/src/Http/Controllers/Admin/ComponentController.php @@ -2,8 +2,8 @@ namespace FluxCms\Core\Http\Controllers\Admin; -use Illuminate\Routing\Controller; use FluxCms\Core\Services\ComponentRegistry; +use Illuminate\Routing\Controller; class ComponentController extends Controller { diff --git a/packages/flux-cms/core/src/Http/Controllers/Admin/DashboardController.php b/packages/flux-cms/core/src/Http/Controllers/Admin/DashboardController.php index 47ab770..d7f41b2 100644 --- a/packages/flux-cms/core/src/Http/Controllers/Admin/DashboardController.php +++ b/packages/flux-cms/core/src/Http/Controllers/Admin/DashboardController.php @@ -2,9 +2,9 @@ namespace FluxCms\Core\Http\Controllers\Admin; -use Illuminate\Routing\Controller; -use FluxCms\Core\Models\Page; use FluxCms\Core\Models\BlogPost; +use FluxCms\Core\Models\Page; +use Illuminate\Routing\Controller; class DashboardController extends Controller { diff --git a/packages/flux-cms/core/src/Http/Controllers/Admin/PageController.php b/packages/flux-cms/core/src/Http/Controllers/Admin/PageController.php index 72f3c9c..c834397 100644 --- a/packages/flux-cms/core/src/Http/Controllers/Admin/PageController.php +++ b/packages/flux-cms/core/src/Http/Controllers/Admin/PageController.php @@ -2,9 +2,9 @@ namespace FluxCms\Core\Http\Controllers\Admin; +use FluxCms\Core\Models\Page; use Illuminate\Http\Request; use Illuminate\Routing\Controller; -use FluxCms\Core\Models\Page; class PageController extends Controller { @@ -42,6 +42,7 @@ class PageController extends Controller $this->authorize('flux-cms.edit'); $domains = $this->getAvailableDomains(); $locales = config('flux-cms.locales'); + return view('flux-cms::admin.pages.create', compact('domains', 'locales')); } @@ -75,6 +76,7 @@ class PageController extends Controller public function edit(Page $page) { $this->authorize('flux-cms.edit'); + return view('flux-cms::admin.pages.edit', compact('page')); } @@ -92,7 +94,7 @@ class PageController extends Controller $page->update([ 'title' => $validated['title'], 'is_published' => $request->boolean('is_published'), - 'published_at' => $request->boolean('is_published') && !$page->published_at ? now() : $page->published_at, + 'published_at' => $request->boolean('is_published') && ! $page->published_at ? now() : $page->published_at, ]); foreach ($validated['slugs'] as $locale => $slug) { @@ -110,12 +112,13 @@ class PageController extends Controller { $this->authorize('flux-cms.delete'); $page->delete(); + return redirect()->route('admin.cms.pages.index')->with('success', 'Page deleted successfully!'); } private function getAvailableDomains(): array { - if (!config('flux-cms.domains.enabled')) { + if (! config('flux-cms.domains.enabled')) { return ['default' => 'Default']; } diff --git a/packages/flux-cms/core/src/Http/Controllers/PageController.php b/packages/flux-cms/core/src/Http/Controllers/PageController.php index bc35845..1156e50 100644 --- a/packages/flux-cms/core/src/Http/Controllers/PageController.php +++ b/packages/flux-cms/core/src/Http/Controllers/PageController.php @@ -2,10 +2,10 @@ namespace FluxCms\Core\Http\Controllers; +use FluxCms\Core\Models\BlogPost; +use FluxCms\Core\Models\Page; use Illuminate\Http\Request; use Illuminate\Routing\Controller; -use FluxCms\Core\Models\Page; -use FluxCms\Core\Models\BlogPost; class PageController extends Controller { @@ -117,7 +117,7 @@ class PageController extends Controller */ protected function getCurrentDomainKey(Request $request): string { - if (!config('flux-cms.domains.enabled')) { + if (! config('flux-cms.domains.enabled')) { return config('flux-cms.domains.default_domain', 'default'); } diff --git a/packages/flux-cms/core/src/Http/Middleware/CmsAccess.php b/packages/flux-cms/core/src/Http/Middleware/CmsAccess.php index b971cfb..e6ec6b1 100644 --- a/packages/flux-cms/core/src/Http/Middleware/CmsAccess.php +++ b/packages/flux-cms/core/src/Http/Middleware/CmsAccess.php @@ -16,12 +16,12 @@ class CmsAccess { $user = Auth::user(); - if (!$user) { + if (! $user) { return redirect()->route('login'); } // Check if user has CMS permission - if (!$this->userHasCmsPermission($user, $permission)) { + if (! $this->userHasCmsPermission($user, $permission)) { abort(403, 'Access denied. You do not have permission to access the CMS.'); } diff --git a/packages/flux-cms/core/src/Http/Middleware/DomainDetection.php b/packages/flux-cms/core/src/Http/Middleware/DomainDetection.php index aa222ba..76c6f07 100644 --- a/packages/flux-cms/core/src/Http/Middleware/DomainDetection.php +++ b/packages/flux-cms/core/src/Http/Middleware/DomainDetection.php @@ -13,15 +13,15 @@ class DomainDetection */ public function handle(Request $request, Closure $next): Response { - if (!config('flux-cms.domains.enabled')) { + if (! config('flux-cms.domains.enabled')) { return $next($request); } $domainKey = $this->detectDomainKey($request); - + // Set domain key in request for later use $request->attributes->set('flux_cms_domain_key', $domainKey); - + // Set locale based on domain if configured $this->setLocaleFromDomain($request, $domainKey); @@ -54,7 +54,7 @@ class DomainDetection protected function setLocaleFromDomain(Request $request, string $domainKey): void { $domains = config('domains.domains', []); - + if (isset($domains[$domainKey]['locale'])) { $locale = $domains[$domainKey]['locale']; app()->setLocale($locale); diff --git a/packages/flux-cms/core/src/Http/Middleware/PreviewMode.php b/packages/flux-cms/core/src/Http/Middleware/PreviewMode.php index 2a2856e..726bd85 100644 --- a/packages/flux-cms/core/src/Http/Middleware/PreviewMode.php +++ b/packages/flux-cms/core/src/Http/Middleware/PreviewMode.php @@ -16,7 +16,7 @@ class PreviewMode // Check if preview mode is enabled via query parameter if ($request->has('preview') && $request->boolean('preview')) { // Verify user has preview permission - if (!$this->userCanPreview($request)) { + if (! $this->userCanPreview($request)) { abort(403, 'Preview access denied'); } @@ -34,14 +34,14 @@ class PreviewMode { $user = $request->user(); - if (!$user) { + if (! $user) { return false; } // Check for Spatie Permission package if (method_exists($user, 'can')) { - return $user->can('flux-cms.view') || - $user->hasRole('flux-cms') || + return $user->can('flux-cms.view') || + $user->hasRole('flux-cms') || $user->hasRole('admin'); } diff --git a/packages/flux-cms/core/src/Models/BlogPost.php b/packages/flux-cms/core/src/Models/BlogPost.php index cf0a248..f8aba2b 100644 --- a/packages/flux-cms/core/src/Models/BlogPost.php +++ b/packages/flux-cms/core/src/Models/BlogPost.php @@ -3,20 +3,19 @@ namespace FluxCms\Core\Models; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Spatie\Translatable\HasTranslations; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\MediaCollections\Models\Media; use Spatie\Tags\HasTags; +use Spatie\Translatable\HasTranslations; class BlogPost extends Model implements HasMedia { - use HasTranslations, InteractsWithMedia, HasTags; + use HasTags, HasTranslations, InteractsWithMedia; public function getTable() { - return config('flux-cms.database.table_prefix', 'flux_cms_') . 'blog_posts'; + return config('flux-cms.database.table_prefix', 'flux_cms_').'blog_posts'; } protected $fillable = [ @@ -39,7 +38,7 @@ class BlogPost extends Model implements HasMedia 'excerpt', 'content', 'meta_title', - 'meta_description' + 'meta_description', ]; protected $casts = [ @@ -101,15 +100,16 @@ class BlogPost extends Model implements HasMedia return $query->orderBy('published_at', 'desc')->limit($limit); } - public function scopeBySlug($query, string $slug, string $locale = null) + public function scopeBySlug($query, string $slug, ?string $locale = null) { $locale = $locale ?? app()->getLocale(); + return $query->whereHas('slugs', function ($q) use ($slug, $locale) { $q->where('slug', $slug)->where('locale', $locale); }); } - public function scopeBySlugWithFallback($query, string $slug, string $locale = null) + public function scopeBySlugWithFallback($query, string $slug, ?string $locale = null) { $locale = $locale ?? app()->getLocale(); $fallbackLocale = config('app.fallback_locale'); @@ -126,7 +126,7 @@ class BlogPost extends Model implements HasMedia /** * Get the URL for this blog post */ - public function getUrl(string $locale = null): string + public function getUrl(?string $locale = null): string { $locale = $locale ?? app()->getLocale(); $slug = $this->slugs()->where('locale', $locale)->first(); @@ -141,6 +141,7 @@ class BlogPost extends Model implements HasMedia { $content = strip_tags($this->getTranslation('content', app()->getLocale())); $wordCount = str_word_count($content); + return max(1, ceil($wordCount / 200)); // Assuming 200 words per minute } @@ -173,7 +174,7 @@ class BlogPost extends Model implements HasMedia /** * Get SEO title with fallback to title */ - public function getSeoTitle(string $locale = null): string + public function getSeoTitle(?string $locale = null): string { $locale = $locale ?? app()->getLocale(); @@ -184,7 +185,7 @@ class BlogPost extends Model implements HasMedia /** * Get SEO description with fallback to excerpt */ - public function getSeoDescription(string $locale = null): string + public function getSeoDescription(?string $locale = null): string { $locale = $locale ?? app()->getLocale(); @@ -205,7 +206,7 @@ class BlogPost extends Model implements HasMedia ->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp']); } - public function registerMediaConversions(Media $media = null): void + public function registerMediaConversions(?Media $media = null): void { $this->addMediaConversion('thumb') ->width(300) @@ -233,7 +234,7 @@ class BlogPost extends Model implements HasMedia { $media = $this->getFeaturedImage(); - if (!$media) { + if (! $media) { return null; } diff --git a/packages/flux-cms/core/src/Models/CmsContent.php b/packages/flux-cms/core/src/Models/CmsContent.php new file mode 100644 index 0000000..94a09ec --- /dev/null +++ b/packages/flux-cms/core/src/Models/CmsContent.php @@ -0,0 +1,50 @@ + */ + public array $translatable = ['value']; + + protected function casts(): array + { + return [ + 'value' => 'array', + 'order' => 'integer', + ]; + } + + public function scopeForGroup($query, string $group) + { + return $query->where('group', $group); + } + + public function scopeByKey($query, string $key) + { + return $query->where('key', $key); + } + + public function getValueForLocale(?string $locale = null): mixed + { + $locale = $locale ?? app()->getLocale(); + + return $this->getTranslation('value', $locale) + ?? $this->getTranslation('value', config('app.fallback_locale', 'de')); + } +} diff --git a/packages/flux-cms/core/src/Models/CmsDownload.php b/packages/flux-cms/core/src/Models/CmsDownload.php new file mode 100644 index 0000000..04dbe47 --- /dev/null +++ b/packages/flux-cms/core/src/Models/CmsDownload.php @@ -0,0 +1,97 @@ + */ + public array $translatable = [ + 'title', + 'description', + 'type_label', + 'alt', + 'file_path', + 'open_text', + 'download_text', + ]; + + protected function casts(): array + { + return [ + 'title' => 'array', + 'description' => 'array', + 'type_label' => 'array', + 'alt' => 'array', + 'file_path' => 'array', + 'open_text' => 'array', + 'download_text' => 'array', + 'highlights' => 'array', + 'checkpoints' => 'array', + 'is_published' => 'boolean', + 'order' => 'integer', + ]; + } + + public function scopePublished($query) + { + return $query->where('is_published', true); + } + + public function scopeByCategory($query, string $category) + { + return $query->where('category', $category); + } + + public function scopeOrdered($query) + { + return $query->orderBy('order'); + } + + /** + * @return array + */ + public function toFrontendArray(?string $locale = null): array + { + $locale = $locale ?? app()->getLocale(); + + return [ + 'alt' => $this->getTranslation('alt', $locale) ?: $this->getTranslation('title', $locale), + 'icon' => $this->icon ?? 'document-text', + 'image' => $this->thumbnail ? media_url($this->thumbnail) : '', + 'title' => $this->getTranslation('title', $locale), + 'category' => $this->sub_category ?? $this->category, + 'pdf_path' => $this->getTranslation('file_path', $locale) ? media_url($this->getTranslation('file_path', $locale)) : '', + 'open_text' => $this->getTranslation('open_text', $locale) ?: __('PDF öffnen'), + 'type_label' => $this->getTranslation('type_label', $locale) ?: ucfirst(str_replace('_', ' ', $this->category)), + 'highlights' => $this->highlights ?? [], + 'checkpoints' => $this->checkpoints ?? [], + 'description' => $this->getTranslation('description', $locale), + 'download_text' => $this->getTranslation('download_text', $locale) ?: __('PDF downloaden'), + ]; + } +} diff --git a/packages/flux-cms/core/src/Models/CmsFaq.php b/packages/flux-cms/core/src/Models/CmsFaq.php new file mode 100644 index 0000000..96a5202 --- /dev/null +++ b/packages/flux-cms/core/src/Models/CmsFaq.php @@ -0,0 +1,51 @@ + */ + public array $translatable = ['question', 'answer', 'help']; + + protected function casts(): array + { + return [ + 'question' => 'array', + 'answer' => 'array', + 'help' => 'array', + 'is_published' => 'boolean', + 'order' => 'integer', + ]; + } + + public function scopePublished($query) + { + return $query->where('is_published', true); + } + + public function scopeByCategory($query, string $category) + { + return $query->where('category', $category); + } + + public function scopeOrdered($query) + { + return $query->orderBy('order'); + } +} diff --git a/packages/flux-cms/core/src/Models/CmsIndustry.php b/packages/flux-cms/core/src/Models/CmsIndustry.php new file mode 100644 index 0000000..177eb77 --- /dev/null +++ b/packages/flux-cms/core/src/Models/CmsIndustry.php @@ -0,0 +1,41 @@ + */ + public array $translatable = ['name']; + + protected function casts(): array + { + return [ + 'name' => 'array', + 'is_published' => 'boolean', + 'order' => 'integer', + ]; + } + + public function scopePublished($query) + { + return $query->where('is_published', true); + } + + public function scopeOrdered($query) + { + return $query->orderBy('order'); + } +} diff --git a/packages/flux-cms/core/src/Models/CmsLinkedinPost.php b/packages/flux-cms/core/src/Models/CmsLinkedinPost.php new file mode 100644 index 0000000..cb61edd --- /dev/null +++ b/packages/flux-cms/core/src/Models/CmsLinkedinPost.php @@ -0,0 +1,64 @@ + */ + public array $translatable = ['title', 'excerpt', 'content']; + + protected function casts(): array + { + return [ + 'title' => 'array', + 'excerpt' => 'array', + 'content' => 'array', + 'tags' => 'array', + 'date' => 'date', + 'is_published' => 'boolean', + 'order' => 'integer', + ]; + } + + public function scopePublished($query) + { + return $query->where('is_published', true); + } + + public function scopeOrdered($query) + { + return $query->orderBy('order')->orderByDesc('date'); + } + + public function scopeManual($query) + { + return $query->where('source', 'manual'); + } + + public function scopeFromApi($query) + { + return $query->where('source', 'api'); + } +} diff --git a/packages/flux-cms/core/src/Models/CmsMedia.php b/packages/flux-cms/core/src/Models/CmsMedia.php new file mode 100644 index 0000000..cd4d6fd --- /dev/null +++ b/packages/flux-cms/core/src/Models/CmsMedia.php @@ -0,0 +1,149 @@ + */ + public array $translatable = ['alt_text', 'title']; + + protected function casts(): array + { + return [ + 'alt_text' => 'array', + 'title' => 'array', + 'conversions' => 'array', + 'file_size' => 'integer', + 'original_width' => 'integer', + 'original_height' => 'integer', + 'is_published' => 'boolean', + 'order' => 'integer', + ]; + } + + public function scopePublished($query) + { + return $query->where('is_published', true); + } + + public function scopeImages($query) + { + return $query->where('type', 'image'); + } + + public function scopePdfs($query) + { + return $query->where('type', 'pdf'); + } + + public function scopeDocuments($query) + { + return $query->whereIn('type', ['pdf', 'document']); + } + + public function scopeInCollection($query, string $collection) + { + return $query->where('collection', $collection); + } + + public function scopeOrdered($query) + { + return $query->orderByDesc('created_at'); + } + + public function getUrl(): string + { + return Storage::disk($this->disk)->url($this->path); + } + + /** + * Get the URL for a specific conversion profile. + * Falls back to original if conversion doesn't exist. + */ + public function getConversionUrl(string $profile): string + { + $conversions = $this->conversions ?? []; + + if (isset($conversions[$profile]) && Storage::disk($this->disk)->exists($conversions[$profile])) { + return Storage::disk($this->disk)->url($conversions[$profile]); + } + + return $this->getUrl(); + } + + /** + * Check if a specific conversion exists. + */ + public function hasConversion(string $profile): bool + { + $conversions = $this->conversions ?? []; + + return isset($conversions[$profile]) + && Storage::disk($this->disk)->exists($conversions[$profile]); + } + + /** + * @return array + */ + public function getExistingConversions(): array + { + return $this->conversions ?? []; + } + + public function isImage(): bool + { + return $this->type === 'image'; + } + + public function isPdf(): bool + { + return $this->type === 'pdf'; + } + + public function getHumanFileSize(): string + { + $bytes = $this->file_size; + if ($bytes >= 1048576) { + return round($bytes / 1048576, 1).' MB'; + } + if ($bytes >= 1024) { + return round($bytes / 1024, 0).' KB'; + } + + return $bytes.' B'; + } + + public function getDimensionsLabel(): string + { + if ($this->original_width && $this->original_height) { + return $this->original_width.' × '.$this->original_height; + } + + return ''; + } +} diff --git a/packages/flux-cms/core/src/Models/CmsNewsItem.php b/packages/flux-cms/core/src/Models/CmsNewsItem.php new file mode 100644 index 0000000..0c654ff --- /dev/null +++ b/packages/flux-cms/core/src/Models/CmsNewsItem.php @@ -0,0 +1,88 @@ + */ + public array $translatable = [ + 'text', + 'title', + 'excerpt', + 'content', + 'pdf_open_text', + 'pdf_download_text', + ]; + + protected function casts(): array + { + return [ + 'text' => 'array', + 'title' => 'array', + 'excerpt' => 'array', + 'content' => 'array', + 'pdf_open_text' => 'array', + 'pdf_download_text' => 'array', + 'date' => 'date', + 'is_published' => 'boolean', + 'order' => 'integer', + ]; + } + + public function scopePublished($query) + { + return $query->where('is_published', true); + } + + public function scopeOrdered($query) + { + return $query->orderBy('order'); + } + + /** + * @return array + */ + public function toFrontendArray(?string $locale = null): array + { + $locale = $locale ?? app()->getLocale(); + + return [ + 'icon' => $this->icon, + 'text' => $this->getTranslation('text', $locale), + 'title' => $this->getTranslation('title', $locale), + 'excerpt' => $this->getTranslation('excerpt', $locale), + 'content' => $this->getTranslation('content', $locale), + 'image' => $this->image ? media_url($this->image) : '', + 'date' => $this->date?->format('Y-m-d'), + 'author' => $this->author, + 'link' => $this->link, + 'pdf_path' => $this->pdf_path ? media_url($this->pdf_path) : '', + 'pdf_open_text' => $this->getTranslation('pdf_open_text', $locale), + 'pdf_download_text' => $this->getTranslation('pdf_download_text', $locale), + ]; + } +} diff --git a/packages/flux-cms/core/src/Models/CmsSearchIndex.php b/packages/flux-cms/core/src/Models/CmsSearchIndex.php new file mode 100644 index 0000000..304b37d --- /dev/null +++ b/packages/flux-cms/core/src/Models/CmsSearchIndex.php @@ -0,0 +1,172 @@ + */ + public array $translatable = [ + 'category', + 'title_fallback', + 'description_fallback_text', + 'keywords', + ]; + + protected function casts(): array + { + return [ + 'route_params' => 'array', + 'keywords' => 'array', + 'is_published' => 'boolean', + 'order' => 'integer', + ]; + } + + public function scopePublished($query) + { + return $query->where('is_published', true); + } + + public function scopeOrdered($query) + { + return $query->orderBy('order'); + } + + /** + * Build a frontend-ready array for the search index. + */ + public function toFrontendArray(?string $locale = null): array + { + $locale = $locale ?? app()->getLocale(); + + $url = ''; + try { + $url = route($this->route, $this->route_params ?? []); + } catch (\Exception $e) { + // Route not found + } + + $title = $this->resolveTitle($locale); + $description = $this->resolveDescription($locale); + $category = $this->getTranslation('category', $locale, false) ?? ''; + $keywords = $this->resolveKeywords($locale); + + return [ + 'id' => $this->item_id, + 'url' => $url, + 'category' => $category, + 'title' => $title, + 'description' => $description, + 'keywords' => $keywords, + ]; + } + + protected function resolveTitle(?string $locale = null): string + { + if ($this->title_key) { + $value = $this->resolveContentKey($this->title_key, $locale); + if ($value !== '') { + return $value; + } + } + + $fallback = $this->getTranslation('title_fallback', $locale, false); + + return is_string($fallback) ? $fallback : ''; + } + + protected function resolveDescription(?string $locale = null): string + { + if ($this->description_key) { + $value = $this->resolveContentKey($this->description_key, $locale); + if ($value !== '') { + return $value; + } + } + + if ($this->description_fallback_key) { + $value = $this->resolveContentKey($this->description_fallback_key, $locale); + if ($value !== '') { + return $value; + } + } + + $fallback = $this->getTranslation('description_fallback_text', $locale, false); + + return is_string($fallback) ? $fallback : ''; + } + + /** + * Resolve keywords: translation-key keywords get resolved to their text value, + * plain text keywords are kept as-is. + */ + protected function resolveKeywords(?string $locale = null): array + { + $raw = $this->getTranslation('keywords', $locale, false); + if (! is_array($raw)) { + return []; + } + + $resolved = []; + foreach ($raw as $keyword) { + if (! is_string($keyword) || $keyword === '') { + continue; + } + + if (str_contains($keyword, '.')) { + $value = $this->resolveContentKey($keyword, $locale); + if ($value !== '') { + $resolved[] = $value; + + continue; + } + } + + $resolved[] = $keyword; + } + + return array_values(array_unique($resolved)); + } + + /** + * Resolve a CMS/translation key to plain text. + */ + protected function resolveContentKey(string $key, ?string $locale = null): string + { + if (function_exists('cms')) { + $value = cms($key, [], $locale); + if (is_string($value) && $value !== $key) { + return trim(strip_tags($value)); + } + } + + $value = __($key, [], $locale ?? app()->getLocale()); + if (is_array($value) || $value === $key) { + return ''; + } + + return trim(strip_tags((string) $value)); + } +} diff --git a/packages/flux-cms/core/src/Models/Navigation.php b/packages/flux-cms/core/src/Models/Navigation.php index 7f5a695..7bfc534 100644 --- a/packages/flux-cms/core/src/Models/Navigation.php +++ b/packages/flux-cms/core/src/Models/Navigation.php @@ -12,7 +12,7 @@ class Navigation extends Model public function getTable() { - return config('flux-cms.database.table_prefix', 'flux_cms_') . 'navigations'; + return config('flux-cms.database.table_prefix', 'flux_cms_').'navigations'; } protected $fillable = [ @@ -24,7 +24,7 @@ class Navigation extends Model ]; protected $translatable = [ - 'display_name' + 'display_name', ]; protected $casts = [ @@ -36,15 +36,15 @@ class Navigation extends Model public function items(): HasMany { return $this->hasMany(NavigationItem::class, 'navigation_id') - ->where('is_active', true) - ->whereNull('parent_id') - ->orderBy('order'); + ->where('is_active', true) + ->whereNull('parent_id') + ->orderBy('order'); } public function allItems(): HasMany { return $this->hasMany(NavigationItem::class, 'navigation_id') - ->orderBy('order'); + ->orderBy('order'); } /** @@ -89,4 +89,4 @@ class Navigation extends Model data_set($settings, $key, $value); $this->update(['settings' => $settings]); } -} \ No newline at end of file +} diff --git a/packages/flux-cms/core/src/Models/NavigationItem.php b/packages/flux-cms/core/src/Models/NavigationItem.php index fb96486..28ec314 100644 --- a/packages/flux-cms/core/src/Models/NavigationItem.php +++ b/packages/flux-cms/core/src/Models/NavigationItem.php @@ -13,7 +13,7 @@ class NavigationItem extends Model public function getTable() { - return config('flux-cms.database.table_prefix', 'flux_cms_') . 'navigation_items'; + return config('flux-cms.database.table_prefix', 'flux_cms_').'navigation_items'; } protected $fillable = [ @@ -29,7 +29,7 @@ class NavigationItem extends Model ]; protected $translatable = [ - 'title' + 'title', ]; protected $casts = [ @@ -52,14 +52,14 @@ class NavigationItem extends Model public function children(): HasMany { return $this->hasMany(NavigationItem::class, 'parent_id') - ->where('is_active', true) - ->orderBy('order'); + ->where('is_active', true) + ->orderBy('order'); } public function allChildren(): HasMany { return $this->hasMany(NavigationItem::class, 'parent_id') - ->orderBy('order'); + ->orderBy('order'); } public function page(): BelongsTo @@ -112,6 +112,7 @@ class NavigationItem extends Model { $ids = []; $this->collectDescendantIds($ids); + return $ids; } @@ -137,4 +138,4 @@ class NavigationItem extends Model data_set($settings, $key, $value); $this->update(['settings' => $settings]); } -} \ No newline at end of file +} diff --git a/packages/flux-cms/core/src/Models/Page.php b/packages/flux-cms/core/src/Models/Page.php index d934224..af24f19 100644 --- a/packages/flux-cms/core/src/Models/Page.php +++ b/packages/flux-cms/core/src/Models/Page.php @@ -4,10 +4,10 @@ namespace FluxCms\Core\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; -use Spatie\Translatable\HasTranslations; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\MediaCollections\Models\Media; +use Spatie\Translatable\HasTranslations; class Page extends Model implements HasMedia { @@ -15,7 +15,7 @@ class Page extends Model implements HasMedia public function getTable() { - return config('flux-cms.database.table_prefix', 'flux_cms_') . 'pages'; + return config('flux-cms.database.table_prefix', 'flux_cms_').'pages'; } protected $fillable = [ @@ -34,7 +34,7 @@ class Page extends Model implements HasMedia 'title', 'meta_description', 'meta_keywords', - 'og_image' + 'og_image', ]; protected $casts = [ @@ -84,7 +84,7 @@ class Page extends Model implements HasMedia ->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml']); } - public function registerMediaConversions(Media $media = null): void + public function registerMediaConversions(?Media $media = null): void { $this->addMediaConversion('og_thumb') ->width(1200) @@ -114,15 +114,16 @@ class Page extends Model implements HasMedia }); } - public function scopeBySlug($query, string $slug, string $locale = null) + public function scopeBySlug($query, string $slug, ?string $locale = null) { $locale = $locale ?? app()->getLocale(); + return $query->whereHas('slugs', function ($q) use ($slug, $locale) { $q->where('slug', $slug)->where('locale', $locale); }); } - public function scopeBySlugWithFallback($query, string $slug, string $locale = null) + public function scopeBySlugWithFallback($query, string $slug, ?string $locale = null) { $locale = $locale ?? app()->getLocale(); $fallbackLocale = config('app.fallback_locale'); @@ -147,7 +148,7 @@ class Page extends Model implements HasMedia /** * Version Management */ - public function createVersion(string $changeDescription = null, ?Model $user = null): PageVersion + public function createVersion(?string $changeDescription = null, ?Model $user = null): PageVersion { $version = $this->versions()->make([ 'page_data' => $this->toArray(), @@ -161,13 +162,15 @@ class Page extends Model implements HasMedia } $version->save(); + return $version; } protected function generateVersionName(): string { $count = $this->versions()->count(); - return "Version " . ($count + 1) . " - " . now()->format('Y-m-d H:i'); + + return 'Version '.($count + 1).' - '.now()->format('Y-m-d H:i'); } /** @@ -189,7 +192,7 @@ class Page extends Model implements HasMedia /** * SEO Methods */ - public function getSeoTitle(string $locale = null): string + public function getSeoTitle(?string $locale = null): string { $locale = $locale ?? app()->getLocale(); $title = $this->getTranslation('title', $locale); @@ -198,9 +201,10 @@ class Page extends Model implements HasMedia return $title ? "{$title} - {$siteName}" : $siteName; } - public function getSeoDescription(string $locale = null): string + public function getSeoDescription(?string $locale = null): string { $locale = $locale ?? app()->getLocale(); + return $this->getTranslation('meta_description', $locale) ?? ''; } @@ -212,12 +216,12 @@ class Page extends Model implements HasMedia /** * URL Generation */ - public function getUrl(string $locale = null): string + public function getUrl(?string $locale = null): string { $locale = $locale ?? app()->getLocale(); $slug = $this->slugs()->where('locale', $locale)->first(); - if (!$slug || $slug->slug === '/') { + if (! $slug || $slug->slug === '/') { return url('/'); } diff --git a/packages/flux-cms/core/src/Models/PageComponent.php b/packages/flux-cms/core/src/Models/PageComponent.php index 53d4b3a..376b018 100644 --- a/packages/flux-cms/core/src/Models/PageComponent.php +++ b/packages/flux-cms/core/src/Models/PageComponent.php @@ -2,13 +2,13 @@ namespace FluxCms\Core\Models; +use FluxCms\Core\Services\ComponentRegistry; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Spatie\Translatable\HasTranslations; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\MediaCollections\Models\Media; -use FluxCms\Core\Services\ComponentRegistry; +use Spatie\Translatable\HasTranslations; class PageComponent extends Model implements HasMedia { @@ -16,7 +16,7 @@ class PageComponent extends Model implements HasMedia public function getTable() { - return config('flux-cms.database.table_prefix', 'flux_cms_') . 'page_components'; + return config('flux-cms.database.table_prefix', 'flux_cms_').'page_components'; } protected $fillable = [ @@ -29,7 +29,7 @@ class PageComponent extends Model implements HasMedia ]; protected $translatable = [ - 'content' + 'content', ]; protected $casts = [ @@ -49,36 +49,36 @@ class PageComponent extends Model implements HasMedia public function registerMediaCollections(): void { $this->addMediaCollection('component_images') - ->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml']); + ->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml']); $this->addMediaCollection('component_files') - ->acceptsMimeTypes([ - 'application/pdf', - 'application/msword', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'text/plain' - ]); + ->acceptsMimeTypes([ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'text/plain', + ]); $this->addMediaCollection('component_videos') - ->acceptsMimeTypes(['video/mp4', 'video/webm', 'video/ogg']); + ->acceptsMimeTypes(['video/mp4', 'video/webm', 'video/ogg']); } - public function registerMediaConversions(Media $media = null): void + public function registerMediaConversions(?Media $media = null): void { $this->addMediaConversion('thumb') - ->width(300) - ->height(300) - ->sharpen(10); + ->width(300) + ->height(300) + ->sharpen(10); $this->addMediaConversion('medium') - ->width(800) - ->height(600) - ->sharpen(10); + ->width(800) + ->height(600) + ->sharpen(10); $this->addMediaConversion('large') - ->width(1200) - ->height(900) - ->sharpen(10); + ->width(1200) + ->height(900) + ->sharpen(10); } /** @@ -86,16 +86,17 @@ class PageComponent extends Model implements HasMedia */ public function getComponentConfig(): array { - if (!class_exists($this->component_class)) { + if (! class_exists($this->component_class)) { return [ 'name' => 'Unknown Component', 'fields' => [], 'category' => 'Unknown', - 'error' => 'Component class not found: ' . $this->component_class + 'error' => 'Component class not found: '.$this->component_class, ]; } $registry = app(ComponentRegistry::class); + return $registry->getComponentConfig($this->component_class); } @@ -104,11 +105,12 @@ class PageComponent extends Model implements HasMedia */ public function validateContent(): array { - if (!class_exists($this->component_class)) { + if (! class_exists($this->component_class)) { return ['component_class' => 'Component class not found']; } $registry = app(ComponentRegistry::class); + return $registry->validateComponentContent($this->component_class, $this->content ?? []); } @@ -133,7 +135,7 @@ class PageComponent extends Model implements HasMedia /** * Content Management */ - public function getTranslatedContent(string $locale = null): array + public function getTranslatedContent(?string $locale = null): array { $locale = $locale ?? app()->getLocale(); $content = $this->getTranslations('content'); @@ -141,7 +143,7 @@ class PageComponent extends Model implements HasMedia return $content[$locale] ?? $content[config('app.fallback_locale')] ?? []; } - public function setTranslatedContent(array $content, string $locale = null): void + public function setTranslatedContent(array $content, ?string $locale = null): void { $locale = $locale ?? app()->getLocale(); $translations = $this->getTranslations('content'); @@ -150,13 +152,14 @@ class PageComponent extends Model implements HasMedia $this->save(); } - public function getContentValue(string $key, string $locale = null): mixed + public function getContentValue(string $key, ?string $locale = null): mixed { $content = $this->getTranslatedContent($locale); + return data_get($content, $key); } - public function setContentValue(string $key, mixed $value, string $locale = null): void + public function setContentValue(string $key, mixed $value, ?string $locale = null): void { $content = $this->getTranslatedContent($locale); data_set($content, $key, $value); @@ -174,19 +177,21 @@ class PageComponent extends Model implements HasMedia public function getComponentName(): string { $config = $this->getComponentConfig(); + return $config['name'] ?? class_basename($this->component_class); } public function getComponentCategory(): string { $config = $this->getComponentConfig(); + return $config['category'] ?? 'General'; } /** * Duplication */ - public function duplicate(int $newOrder = null): self + public function duplicate(?int $newOrder = null): self { $duplicate = $this->replicate(); $duplicate->order = $newOrder ?? ($this->page->allComponents()->max('order') + 1); @@ -214,4 +219,4 @@ class PageComponent extends Model implements HasMedia data_set($settings, $key, $value); $this->update(['settings' => $settings]); } -} \ No newline at end of file +} diff --git a/packages/flux-cms/core/src/Models/PageVersion.php b/packages/flux-cms/core/src/Models/PageVersion.php index 76976c4..06a8e10 100644 --- a/packages/flux-cms/core/src/Models/PageVersion.php +++ b/packages/flux-cms/core/src/Models/PageVersion.php @@ -9,7 +9,7 @@ class PageVersion extends Model { public function getTable() { - return config('flux-cms.database.table_prefix', 'flux_cms_') . 'page_versions'; + return config('flux-cms.database.table_prefix', 'flux_cms_').'page_versions'; } protected $fillable = [ @@ -80,18 +80,18 @@ class PageVersion extends Model $changes = []; foreach ($old as $key => $value) { - if (!isset($new[$key])) { + if (! isset($new[$key])) { $changes['removed'][$key] = $value; } elseif ($new[$key] !== $value) { $changes['changed'][$key] = [ 'old' => $value, - 'new' => $new[$key] + 'new' => $new[$key], ]; } } foreach ($new as $key => $value) { - if (!isset($old[$key])) { + if (! isset($old[$key])) { $changes['added'][$key] = $value; } } diff --git a/packages/flux-cms/core/src/Services/CmsContentService.php b/packages/flux-cms/core/src/Services/CmsContentService.php new file mode 100644 index 0000000..95a3773 --- /dev/null +++ b/packages/flux-cms/core/src/Services/CmsContentService.php @@ -0,0 +1,194 @@ + + */ + protected array $memoryCache = []; + + protected bool $preloaded = false; + + public function __construct() + { + $this->cacheTtl = config('flux-cms.cache.ttl', 3600); + $this->cachePrefix = config('flux-cms.cache.key_prefix', 'flux_cms'); + } + + /** + * Resolve a CMS content value by dot-notation key. + * + * Format: "group.nested.key" where the first segment is the group + * and the rest is the key within that group. + * + * @param array $replace + */ + public function get(string $key, array $replace = [], ?string $locale = null): mixed + { + $locale = $locale ?? app()->getLocale(); + $fallbackLocale = config('app.fallback_locale', 'de'); + + [$group, $contentKey] = $this->parseKey($key); + + if (! $group || ! $contentKey) { + return $this->fallbackToLang($key, $replace, $locale); + } + + $groupData = $this->loadGroup($group); + + $content = $groupData->firstWhere('key', $contentKey); + + if (! $content) { + return $this->fallbackToLang($key, $replace, $locale); + } + + $value = $content->getTranslation('value', $locale) + ?? $content->getTranslation('value', $fallbackLocale); + + if ($value === null) { + return $this->fallbackToLang($key, $replace, $locale); + } + + if (is_string($value) && ! empty($replace)) { + foreach ($replace as $placeholder => $replacement) { + $value = str_replace(":{$placeholder}", (string) $replacement, $value); + } + } + + return $value; + } + + /** + * Wie get(), aber ohne Fallback auf __() – liefert null, wenn kein CMS-Eintrag existiert. + */ + public function getIfExists(string $key, ?string $locale = null): mixed + { + $locale = $locale ?? app()->getLocale(); + $fallbackLocale = config('app.fallback_locale', 'de'); + + [$group, $contentKey] = $this->parseKey($key); + + if (! $group || ! $contentKey) { + return null; + } + + $groupData = $this->loadGroup($group); + + $content = $groupData->firstWhere('key', $contentKey); + + if (! $content) { + return null; + } + + $value = $content->getTranslation('value', $locale) + ?? $content->getTranslation('value', $fallbackLocale); + + return $value; + } + + /** + * Get all content for a group, keyed by content key. + * + * @return array + */ + public function getGroup(string $group, ?string $locale = null): array + { + $locale = $locale ?? app()->getLocale(); + + $groupData = $this->loadGroup($group); + + $result = []; + foreach ($groupData as $content) { + $value = $content->getTranslation('value', $locale) + ?? $content->getTranslation('value', config('app.fallback_locale', 'de')); + $result[$content->key] = $value; + } + + return $result; + } + + /** + * Load a group — triggers a single bulk preload on first access. + */ + protected function loadGroup(string $group): \Illuminate\Support\Collection + { + if (! $this->preloaded) { + $this->preloadAll(); + } + + return $this->memoryCache[$group] ?? collect(); + } + + /** + * Preload ALL CMS content in a single query + single cache entry, + * then group by content group in memory. + */ + protected function preloadAll(): void + { + $this->preloaded = true; + + $cacheKey = "{$this->cachePrefix}.content.__all__"; + + $allContent = Cache::remember($cacheKey, $this->cacheTtl, function () { + return CmsContent::query()->orderBy('group')->orderBy('order')->get(); + }); + + foreach ($allContent->groupBy('group') as $groupName => $items) { + $this->memoryCache[$groupName] = $items; + } + } + + /** + * Clear cache for a specific group or all groups. + */ + public function clearCache(?string $group = null): void + { + Cache::forget("{$this->cachePrefix}.content.__all__"); + $this->memoryCache = []; + $this->preloaded = false; + + if ($group) { + Cache::forget("{$this->cachePrefix}.content.{$group}"); + } else { + $groups = CmsContent::query()->distinct()->pluck('group'); + foreach ($groups as $g) { + Cache::forget("{$this->cachePrefix}.content.{$g}"); + } + } + } + + /** + * @return array{0: string|null, 1: string|null} + */ + protected function parseKey(string $key): array + { + $dotPos = strpos($key, '.'); + if ($dotPos === false) { + return [null, null]; + } + + $group = substr($key, 0, $dotPos); + $contentKey = substr($key, $dotPos + 1); + + return [$group, $contentKey]; + } + + /** + * @param array $replace + */ + protected function fallbackToLang(string $key, array $replace = [], ?string $locale = null): mixed + { + return __($key, $replace, $locale); + } +} diff --git a/packages/flux-cms/core/src/Services/ComponentRegistry.php b/packages/flux-cms/core/src/Services/ComponentRegistry.php index 614ee56..335249a 100644 --- a/packages/flux-cms/core/src/Services/ComponentRegistry.php +++ b/packages/flux-cms/core/src/Services/ComponentRegistry.php @@ -2,17 +2,20 @@ namespace FluxCms\Core\Services; +use FluxCms\Core\FieldTypes\BaseField; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\File; use ReflectionClass; use ReflectionException; -use FluxCms\Core\FieldTypes\BaseField; class ComponentRegistry { protected array $componentPaths = []; + protected string $cacheKey = 'flux_cms.component.registry'; + protected int $cacheTtl = 3600; // 1 Stunde + protected bool $cacheEnabled = true; public function __construct() @@ -31,7 +34,7 @@ class ComponentRegistry */ public function getAvailableComponents(): array { - if (!$this->cacheEnabled) { + if (! $this->cacheEnabled) { return $this->scanComponents(); } @@ -46,6 +49,7 @@ class ComponentRegistry public function getComponent(string $className): ?array { $components = $this->getAvailableComponents(); + return $components[$className] ?? null; } @@ -54,7 +58,7 @@ class ComponentRegistry */ public function isValidComponent(string $className): bool { - if (!class_exists($className)) { + if (! class_exists($className)) { return false; } @@ -62,18 +66,18 @@ class ComponentRegistry $reflection = new ReflectionClass($className); // Muss getCmsFields Methode haben - if (!$reflection->hasMethod('getCmsFields')) { + if (! $reflection->hasMethod('getCmsFields')) { return false; } // getCmsFields muss static sein $method = $reflection->getMethod('getCmsFields'); - if (!$method->isStatic() || !$method->isPublic()) { + if (! $method->isStatic() || ! $method->isPublic()) { return false; } // Muss von Livewire\Component erben - if (!$reflection->isSubclassOf(\Livewire\Component::class)) { + if (! $reflection->isSubclassOf(\Livewire\Component::class)) { return false; } @@ -88,13 +92,13 @@ class ComponentRegistry */ public function getComponentConfig(string $className): array { - if (!$this->isValidComponent($className)) { + if (! $this->isValidComponent($className)) { return [ 'class' => $className, 'name' => 'Invalid Component', 'fields' => [], 'category' => 'Error', - 'error' => 'Component is not valid or does not exist' + 'error' => 'Component is not valid or does not exist', ]; } @@ -116,13 +120,14 @@ class ComponentRegistry return $config; } catch (\Exception $e) { - \Log::error("Error getting component config for {$className}: " . $e->getMessage()); + \Log::error("Error getting component config for {$className}: ".$e->getMessage()); + return [ 'class' => $className, 'name' => 'Error Component', 'fields' => [], 'category' => 'Error', - 'error' => $e->getMessage() + 'error' => $e->getMessage(), ]; } } @@ -137,7 +142,7 @@ class ComponentRegistry foreach ($this->componentPaths as $namespace) { $path = $this->namespaceToPath($namespace); - if (!is_dir($path)) { + if (! is_dir($path)) { continue; } @@ -150,6 +155,7 @@ class ComponentRegistry if ($categoryCompare !== 0) { return $categoryCompare; } + return strcmp($a['name'], $b['name']); }); @@ -163,7 +169,7 @@ class ComponentRegistry { $components = []; - if (!is_dir($path)) { + if (! is_dir($path)) { return $components; } @@ -178,7 +184,7 @@ class ComponentRegistry if ($this->isValidComponent($className)) { $config = $this->getComponentConfig($className); - if (!isset($config['error'])) { + if (! isset($config['error'])) { $components[$className] = $config; } } @@ -196,6 +202,7 @@ class ComponentRegistry if (str_starts_with($namespace, 'App\\')) { $path = str_replace('App\\', 'app/', $namespace); $path = str_replace('\\', '/', $path); + return base_path($path); } @@ -204,12 +211,14 @@ class ComponentRegistry $path = str_replace('FluxCms\\', 'packages/flux-cms/', $namespace); $path = str_replace('\\', '/', $path); $path = strtolower($path); - return base_path($path . '/src'); + + return base_path($path.'/src'); } // Fallback $path = str_replace('\\', '/', $namespace); - return base_path('vendor/' . strtolower($path)); + + return base_path('vendor/'.strtolower($path)); } /** @@ -217,11 +226,11 @@ class ComponentRegistry */ protected function fileToClassName(\SplFileInfo $file, string $namespace, string $basePath): string { - $relativePath = str_replace($basePath . '/', '', $file->getPathname()); + $relativePath = str_replace($basePath.'/', '', $file->getPathname()); $relativePath = str_replace('.php', '', $relativePath); $relativePath = str_replace('/', '\\', $relativePath); - return $namespace . '\\' . $relativePath; + return $namespace.'\\'.$relativePath; } /** @@ -232,6 +241,7 @@ class ComponentRegistry if (method_exists($className, 'getCmsName')) { return $className::getCmsName(); } + return $this->classNameToReadable($className); } @@ -240,6 +250,7 @@ class ComponentRegistry if (method_exists($className, 'getCmsCategory')) { return $className::getCmsCategory(); } + return 'General'; } @@ -248,6 +259,7 @@ class ComponentRegistry if (method_exists($className, 'getCmsDescription')) { return $className::getCmsDescription(); } + return null; } @@ -256,6 +268,7 @@ class ComponentRegistry if (method_exists($className, 'getCmsPreview')) { return $className::getCmsPreview(); } + return null; } @@ -264,6 +277,7 @@ class ComponentRegistry if (method_exists($className, 'getCmsIcon')) { return $className::getCmsIcon(); } + return 'puzzle-piece'; } @@ -272,6 +286,7 @@ class ComponentRegistry if (method_exists($className, 'getCmsTags')) { return $className::getCmsTags(); } + return []; } @@ -280,6 +295,7 @@ class ComponentRegistry if (method_exists($className, 'getCmsVersion')) { return $className::getCmsVersion(); } + return '1.0.0'; } @@ -296,7 +312,7 @@ class ComponentRegistry } else { \Log::warning('Invalid field type in component fields', [ 'field' => $field, - 'type' => gettype($field) + 'type' => gettype($field), ]); } } @@ -310,6 +326,7 @@ class ComponentRegistry protected function classNameToReadable(string $className): string { $baseName = class_basename($className); + return preg_replace('/([a-z])([A-Z])/', '$1 $2', $baseName); } @@ -318,14 +335,14 @@ class ComponentRegistry */ public function validateComponentContent(string $className, array $content): array { - if (!$this->isValidComponent($className)) { - return ['component' => ['Invalid component class: ' . $className]]; + if (! $this->isValidComponent($className)) { + return ['component' => ['Invalid component class: '.$className]]; } // Custom Validation der Komponente if (method_exists($className, 'validateContent')) { $customErrors = $className::validateContent($content); - if (!empty($customErrors)) { + if (! empty($customErrors)) { return $customErrors; } } @@ -344,7 +361,7 @@ class ComponentRegistry $availableLocales = config('flux-cms.locales', ['de', 'en']); foreach ($fields as $field) { - if (!$field instanceof BaseField) { + if (! $field instanceof BaseField) { continue; } @@ -353,14 +370,14 @@ class ComponentRegistry foreach ($availableLocales as $locale) { $value = $content[$field->getKey()][$locale] ?? null; $fieldErrors = $field->validate($value, $locale); - if (!empty($fieldErrors)) { + if (! empty($fieldErrors)) { $errors["{$field->getKey()}.{$locale}"] = $fieldErrors; } } } else { $value = $content[$field->getKey()] ?? null; $fieldErrors = $field->validate($value); - if (!empty($fieldErrors)) { + if (! empty($fieldErrors)) { $errors[$field->getKey()] = $fieldErrors; } } @@ -380,6 +397,7 @@ class ComponentRegistry public function refreshCache(): array { $this->clearCache(); + return $this->getAvailableComponents(); } @@ -388,7 +406,7 @@ class ComponentRegistry */ public function addComponentPath(string $namespace): void { - if (!in_array($namespace, $this->componentPaths)) { + if (! in_array($namespace, $this->componentPaths)) { $this->componentPaths[] = $namespace; $this->clearCache(); } @@ -396,7 +414,7 @@ class ComponentRegistry public function removeComponentPath(string $namespace): void { - $this->componentPaths = array_filter($this->componentPaths, fn($path) => $path !== $namespace); + $this->componentPaths = array_filter($this->componentPaths, fn ($path) => $path !== $namespace); $this->clearCache(); } @@ -434,7 +452,7 @@ class ComponentRegistry strtolower($component['name']), strtolower($component['category']), strtolower($component['description'] ?? ''), - strtolower(implode(' ', $component['tags'] ?? [])) + strtolower(implode(' ', $component['tags'] ?? [])), ]; foreach ($searchFields as $field) { @@ -467,4 +485,4 @@ class ComponentRegistry return $stats; } -} \ No newline at end of file +} diff --git a/packages/flux-cms/core/src/Services/HeroiconOutlineList.php b/packages/flux-cms/core/src/Services/HeroiconOutlineList.php new file mode 100644 index 0000000..6793787 --- /dev/null +++ b/packages/flux-cms/core/src/Services/HeroiconOutlineList.php @@ -0,0 +1,70 @@ + + */ + public static function names(): array + { + if (! config('flux-cms.cache.enabled', true)) { + return self::scanFilesystem(); + } + + $prefix = config('flux-cms.cache.key_prefix', 'flux_cms'); + $ttl = (int) config('flux-cms.cache.ttl', 3600); + $key = "{$prefix}.heroicon_outline_names"; + $store = config('flux-cms.cache.store'); + + $callback = fn (): array => self::scanFilesystem(); + + if ($store) { + return Cache::store($store)->remember($key, $ttl, $callback); + } + + return Cache::remember($key, $ttl, $callback); + } + + public static function forgetCached(): void + { + $prefix = config('flux-cms.cache.key_prefix', 'flux_cms'); + $key = "{$prefix}.heroicon_outline_names"; + $store = config('flux-cms.cache.store'); + + if ($store) { + Cache::store($store)->forget($key); + + return; + } + + Cache::forget($key); + } + + /** + * @return array + */ + public static function scanFilesystem(): array + { + $path = base_path('vendor/blade-ui-kit/blade-heroicons/resources/svg'); + if (! File::isDirectory($path)) { + return []; + } + + return collect(File::files($path)) + ->map(fn (\SplFileInfo $file): string => $file->getFilenameWithoutExtension()) + ->filter(fn (string $name): bool => str_starts_with($name, 'o-')) + ->map(fn (string $name): string => substr($name, 2)) + ->sort() + ->values() + ->toArray(); + } +} diff --git a/packages/flux-cms/core/src/Services/MediaConversionService.php b/packages/flux-cms/core/src/Services/MediaConversionService.php new file mode 100644 index 0000000..b7ec69c --- /dev/null +++ b/packages/flux-cms/core/src/Services/MediaConversionService.php @@ -0,0 +1,224 @@ +manager = new ImageManager(new GdDriver); + $this->disk = config('flux-cms.media.disk', 'public'); + $this->conversionsPath = config('flux-cms.media.conversions_path', 'cms/media/conversions'); + } + + /** + * Generate a specific conversion for a media item. + * + * @return string|null The path to the conversion file, or null on failure. + */ + public function convert(CmsMedia $media, string $profile): ?string + { + if (! $media->isImage()) { + return null; + } + + $profileConfig = config("flux-cms.media.profiles.{$profile}"); + if (! $profileConfig) { + return null; + } + + $originalPath = Storage::disk($this->disk)->path($media->path); + if (! file_exists($originalPath)) { + return null; + } + + $extension = $this->isSvg($media) ? 'svg' : ($profileConfig['format'] ?? 'webp'); + + if ($this->isSvg($media)) { + return $media->path; + } + + $conversionFilename = pathinfo($media->filename, PATHINFO_FILENAME) + .'-'.$profile + .'.'.$extension; + + $conversionPath = $this->conversionsPath.'/'.$profile.'/'.$conversionFilename; + $absolutePath = Storage::disk($this->disk)->path($conversionPath); + + $dir = dirname($absolutePath); + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + + try { + $image = $this->manager->read($originalPath); + + $width = $profileConfig['width']; + $height = $profileConfig['height']; + $fit = $profileConfig['fit'] ?? 'cover'; + $quality = $profileConfig['quality'] ?? 85; + + if ($fit === 'cover') { + $image->cover($width, $height); + } else { + $image->scaleDown($width, $height); + } + + $encoded = match ($extension) { + 'webp' => $image->toWebp($quality), + 'png' => $image->toPng(), + 'gif' => $image->toGif(), + default => $image->toJpeg($quality), + }; + + $encoded->save($absolutePath); + + $conversions = $media->conversions ?? []; + $conversions[$profile] = $conversionPath; + $media->update(['conversions' => $conversions]); + + return $conversionPath; + } catch (\Throwable $e) { + report($e); + + return null; + } + } + + /** + * Generate the thumbnail conversion used in the admin grid. + */ + public function generateThumbnail(CmsMedia $media): ?string + { + return $this->convert($media, 'thumb'); + } + + /** + * Generate all configured profile conversions. + * + * @return array + */ + public function generateAllConversions(CmsMedia $media): array + { + $profiles = array_keys(config('flux-cms.media.profiles', [])); + $results = []; + + foreach ($profiles as $profile) { + $results[$profile] = $this->convert($media, $profile); + } + + return $results; + } + + /** + * Delete all conversion files for a media item. + */ + public function deleteConversions(CmsMedia $media): void + { + $conversions = $media->conversions ?? []; + + foreach ($conversions as $path) { + if (Storage::disk($this->disk)->exists($path)) { + Storage::disk($this->disk)->delete($path); + } + } + + $media->update(['conversions' => []]); + } + + /** + * Delete the original file and all conversions. + */ + public function deleteAll(CmsMedia $media): void + { + $this->deleteConversions($media); + + if (Storage::disk($this->disk)->exists($media->path)) { + Storage::disk($this->disk)->delete($media->path); + } + } + + /** + * Store an uploaded file and create the CmsMedia record. + */ + public function storeUpload( + \Illuminate\Http\UploadedFile $file, + ?string $collection = null, + bool $generateThumbnail = true, + ): CmsMedia { + $originalsPath = config('flux-cms.media.originals_path', 'cms/media/originals'); + $path = $file->store($originalsPath, $this->disk); + + $type = $this->detectType($file); + $width = null; + $height = null; + + if ($type === 'image' && ! $this->isSvgFile($file)) { + try { + $dimensions = getimagesize($file->getRealPath()); + if ($dimensions) { + $width = $dimensions[0]; + $height = $dimensions[1]; + } + } catch (\Throwable) { + } + } + + $media = CmsMedia::create([ + 'filename' => $file->getClientOriginalName(), + 'disk' => $this->disk, + 'path' => $path, + 'type' => $type, + 'mime_type' => $file->getMimeType(), + 'file_size' => $file->getSize(), + 'original_width' => $width, + 'original_height' => $height, + 'collection' => $collection, + 'conversions' => [], + ]); + + if ($generateThumbnail && $media->isImage() && ! $this->isSvg($media)) { + $this->generateThumbnail($media); + } + + return $media; + } + + protected function detectType(\Illuminate\Http\UploadedFile $file): string + { + $mime = $file->getMimeType(); + + if (str_starts_with($mime, 'image/')) { + return 'image'; + } + + if ($mime === 'application/pdf') { + return 'pdf'; + } + + return 'document'; + } + + protected function isSvg(CmsMedia $media): bool + { + return $media->mime_type === 'image/svg+xml' + || str_ends_with(strtolower($media->filename), '.svg'); + } + + protected function isSvgFile(\Illuminate\Http\UploadedFile $file): bool + { + return $file->getMimeType() === 'image/svg+xml' + || str_ends_with(strtolower($file->getClientOriginalName()), '.svg'); + } +} diff --git a/packages/flux-cms/core/tests-reference/Feature/Cms/CmsAdminTest.php b/packages/flux-cms/core/tests-reference/Feature/Cms/CmsAdminTest.php new file mode 100644 index 0000000..7a9b6ed --- /dev/null +++ b/packages/flux-cms/core/tests-reference/Feature/Cms/CmsAdminTest.php @@ -0,0 +1,64 @@ +get(route('cms.dashboard')) + ->assertRedirect(route('login')); +}); + +it('allows authenticated users to access cms dashboard', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('cms.dashboard')) + ->assertSuccessful(); +}); + +it('allows authenticated users to access content index', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('cms.content.index')) + ->assertSuccessful(); +}); + +it('allows authenticated users to access news index', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('cms.news.index')) + ->assertSuccessful(); +}); + +it('allows authenticated users to access faq index', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('cms.faqs.index')) + ->assertSuccessful(); +}); + +it('allows authenticated users to access industries index', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('cms.industries.index')) + ->assertSuccessful(); +}); + +it('allows authenticated users to access linkedin index', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('cms.linkedin.index')) + ->assertSuccessful(); +}); + +it('allows authenticated users to access downloads index', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('cms.downloads.index')) + ->assertSuccessful(); +}); diff --git a/packages/flux-cms/core/tests-reference/Feature/Cms/CmsContentServiceTest.php b/packages/flux-cms/core/tests-reference/Feature/Cms/CmsContentServiceTest.php new file mode 100644 index 0000000..1132c40 --- /dev/null +++ b/packages/flux-cms/core/tests-reference/Feature/Cms/CmsContentServiceTest.php @@ -0,0 +1,92 @@ + 'welcome', + 'key' => 'hero.heading', + 'type' => 'text', + 'value' => ['de' => 'Willkommen', 'en' => 'Welcome'], + 'order' => 0, + ]); + + CmsContent::create([ + 'group' => 'welcome', + 'key' => 'hero.description', + 'type' => 'html', + 'value' => ['de' => '

Beschreibung

', 'en' => '

Description

'], + 'order' => 1, + ]); +}); + +it('resolves content by dot-notation key', function () { + app()->setLocale('de'); + $service = app(CmsContentService::class); + + expect($service->get('welcome.hero.heading'))->toBe('Willkommen'); +}); + +it('resolves content in english locale', function () { + app()->setLocale('en'); + $service = app(CmsContentService::class); + + expect($service->get('welcome.hero.heading'))->toBe('Welcome'); +}); + +it('falls back to lang file when no db entry', function () { + $service = app(CmsContentService::class); + + $result = $service->get('nonexistent.key.here'); + + expect($result)->toBe('nonexistent.key.here'); +}); + +it('returns html content correctly', function () { + app()->setLocale('de'); + $service = app(CmsContentService::class); + + expect($service->get('welcome.hero.description'))->toBe('

Beschreibung

'); +}); + +it('replaces placeholders in content', function () { + CmsContent::create([ + 'group' => 'test', + 'key' => 'greeting', + 'type' => 'text', + 'value' => ['de' => 'Hallo :name!'], + 'order' => 0, + ]); + + app()->setLocale('de'); + $service = app(CmsContentService::class); + + expect($service->get('test.greeting', ['name' => 'Welt']))->toBe('Hallo Welt!'); +}); + +it('gets all content for a group', function () { + app()->setLocale('de'); + $service = app(CmsContentService::class); + + $group = $service->getGroup('welcome'); + + expect($group)->toHaveKey('hero.heading', 'Willkommen') + ->toHaveKey('hero.description', '

Beschreibung

'); +}); + +it('clears cache for a group', function () { + $service = app(CmsContentService::class); + + $service->get('welcome.hero.heading'); + + $service->clearCache('welcome'); + + expect(true)->toBeTrue(); +}); + +it('cms helper function works', function () { + app()->setLocale('en'); + + expect(cms('welcome.hero.heading'))->toBe('Welcome'); +}); diff --git a/packages/flux-cms/core/tests-reference/Feature/Cms/CmsMediaTest.php b/packages/flux-cms/core/tests-reference/Feature/Cms/CmsMediaTest.php new file mode 100644 index 0000000..3ec8a68 --- /dev/null +++ b/packages/flux-cms/core/tests-reference/Feature/Cms/CmsMediaTest.php @@ -0,0 +1,173 @@ + 'test-image.jpg', + 'disk' => 'public', + 'path' => 'cms/media/originals/test-image.jpg', + 'type' => 'image', + 'mime_type' => 'image/jpeg', + 'file_size' => 102400, + 'original_width' => 1920, + 'original_height' => 1080, + ]); + + expect($media)->toBeInstanceOf(CmsMedia::class) + ->and($media->filename)->toBe('test-image.jpg') + ->and($media->isImage())->toBeTrue() + ->and($media->isPdf())->toBeFalse() + ->and($media->getHumanFileSize())->toBe('100 KB') + ->and($media->getDimensionsLabel())->toBe('1920 × 1080'); +}); + +test('CmsMedia scopes filter correctly', function () { + CmsMedia::create(['filename' => 'photo.jpg', 'disk' => 'public', 'path' => 'a.jpg', 'type' => 'image', 'collection' => 'hero']); + CmsMedia::create(['filename' => 'doc.pdf', 'disk' => 'public', 'path' => 'b.pdf', 'type' => 'pdf', 'collection' => 'downloads']); + CmsMedia::create(['filename' => 'draft.jpg', 'disk' => 'public', 'path' => 'c.jpg', 'type' => 'image', 'is_published' => false]); + + expect(CmsMedia::images()->count())->toBe(2) + ->and(CmsMedia::pdfs()->count())->toBe(1) + ->and(CmsMedia::published()->count())->toBe(2) + ->and(CmsMedia::inCollection('hero')->count())->toBe(1); +}); + +test('CmsMedia stores translatable alt_text and title', function () { + $media = CmsMedia::create([ + 'filename' => 'hero.jpg', + 'disk' => 'public', + 'path' => 'cms/media/originals/hero.jpg', + 'type' => 'image', + ]); + + $media->setTranslation('alt_text', 'de', 'Heldenbild'); + $media->setTranslation('alt_text', 'en', 'Hero image'); + $media->setTranslation('title', 'de', 'Startseite Hero'); + $media->save(); + + $media->refresh(); + + expect($media->getTranslation('alt_text', 'de'))->toBe('Heldenbild') + ->and($media->getTranslation('alt_text', 'en'))->toBe('Hero image') + ->and($media->getTranslation('title', 'de'))->toBe('Startseite Hero'); +}); + +test('CmsMedia conversions tracking works', function () { + $media = CmsMedia::create([ + 'filename' => 'test.jpg', + 'disk' => 'public', + 'path' => 'cms/media/originals/test.jpg', + 'type' => 'image', + 'conversions' => [], + ]); + + expect($media->hasConversion('hero'))->toBeFalse() + ->and($media->getExistingConversions())->toBe([]); + + $media->update(['conversions' => ['hero' => 'cms/media/conversions/hero/test-hero.webp']]); + + Storage::disk('public')->put('cms/media/conversions/hero/test-hero.webp', 'fake'); + + $media->refresh(); + + expect($media->hasConversion('hero'))->toBeTrue() + ->and($media->getExistingConversions())->toHaveKey('hero'); +}); + +test('MediaConversionService storeUpload creates media record', function () { + $file = UploadedFile::fake()->image('photo.jpg', 800, 600); + + $service = app(MediaConversionService::class); + $media = $service->storeUpload($file, 'test'); + + expect($media)->toBeInstanceOf(CmsMedia::class) + ->and($media->filename)->toBe('photo.jpg') + ->and($media->type)->toBe('image') + ->and($media->collection)->toBe('test') + ->and($media->original_width)->toBe(800) + ->and($media->original_height)->toBe(600) + ->and(Storage::disk('public')->exists($media->path))->toBeTrue(); +}); + +test('MediaConversionService generates thumbnail on upload', function () { + $file = UploadedFile::fake()->image('big-photo.jpg', 1920, 1080); + + $service = app(MediaConversionService::class); + $media = $service->storeUpload($file); + + $media->refresh(); + + expect($media->hasConversion('thumb'))->toBeTrue(); +}); + +test('MediaConversionService can generate specific profile conversion', function () { + $file = UploadedFile::fake()->image('hero-bg.jpg', 2400, 1600); + + $service = app(MediaConversionService::class); + $media = $service->storeUpload($file, generateThumbnail: false); + + $result = $service->convert($media, 'hero'); + + expect($result)->not->toBeNull(); + + $media->refresh(); + expect($media->hasConversion('hero'))->toBeTrue(); +}); + +test('MediaConversionService deleteAll removes files', function () { + $file = UploadedFile::fake()->image('delete-me.jpg', 400, 400); + + $service = app(MediaConversionService::class); + $media = $service->storeUpload($file); + + $path = $media->path; + + expect(Storage::disk('public')->exists($path))->toBeTrue(); + + $service->deleteAll($media); + + expect(Storage::disk('public')->exists($path))->toBeFalse(); +}); + +test('MediaConversionService skips SVG files for conversions', function () { + $file = UploadedFile::fake()->create('logo.svg', 5, 'image/svg+xml'); + + $service = app(MediaConversionService::class); + $media = $service->storeUpload($file, generateThumbnail: false); + + expect($media->type)->toBe('image') + ->and($media->mime_type)->toBe('image/svg+xml'); +}); + +test('media profiles are configured', function () { + $profiles = config('flux-cms.media.profiles'); + + expect($profiles)->toHaveKey('hero') + ->and($profiles)->toHaveKey('thumb') + ->and($profiles)->toHaveKey('avatar') + ->and($profiles)->toHaveKey('news') + ->and($profiles)->toHaveKey('thumbnail') + ->and($profiles)->toHaveKey('service') + ->and($profiles)->toHaveKey('og_image') + ->and($profiles['hero']['width'])->toBe(1920) + ->and($profiles['hero']['height'])->toBe(800) + ->and($profiles['avatar']['width'])->toBe(400); +}); + +test('media library admin page loads', function () { + $user = \App\Models\User::factory()->create(); + + $this->actingAs($user) + ->get(route('cms.media.index')) + ->assertSuccessful(); +}); diff --git a/packages/flux-cms/core/tests-reference/Feature/Cms/CmsModelsTest.php b/packages/flux-cms/core/tests-reference/Feature/Cms/CmsModelsTest.php new file mode 100644 index 0000000..c04b475 --- /dev/null +++ b/packages/flux-cms/core/tests-reference/Feature/Cms/CmsModelsTest.php @@ -0,0 +1,125 @@ + 'general', + 'question' => ['de' => 'Was ist das?', 'en' => 'What is this?'], + 'answer' => ['de' => 'Eine Antwort', 'en' => 'An answer'], + 'is_published' => true, + 'order' => 0, + ]); + + expect($faq->getTranslation('question', 'de'))->toBe('Was ist das?') + ->and($faq->getTranslation('question', 'en'))->toBe('What is this?'); +}); + +it('scopes faqs by category', function () { + CmsFaq::create([ + 'category' => 'general', + 'question' => ['de' => 'Frage 1'], + 'answer' => ['de' => 'Antwort 1'], + 'order' => 0, + ]); + CmsFaq::create([ + 'category' => 'technical', + 'question' => ['de' => 'Frage 2'], + 'answer' => ['de' => 'Antwort 2'], + 'order' => 0, + ]); + + expect(CmsFaq::byCategory('general')->count())->toBe(1) + ->and(CmsFaq::byCategory('technical')->count())->toBe(1); +}); + +it('scopes published items', function () { + CmsFaq::create([ + 'category' => 'test', + 'question' => ['de' => 'Sichtbar'], + 'answer' => ['de' => 'Ja'], + 'is_published' => true, + 'order' => 0, + ]); + CmsFaq::create([ + 'category' => 'test', + 'question' => ['de' => 'Versteckt'], + 'answer' => ['de' => 'Nein'], + 'is_published' => false, + 'order' => 1, + ]); + + expect(CmsFaq::published()->count())->toBe(1); +}); + +it('creates a news item with all fields', function () { + $item = CmsNewsItem::create([ + 'icon' => 'document-check', + 'text' => ['de' => 'Capability: Test', 'en' => 'Capability: Test'], + 'title' => ['de' => 'Titel DE', 'en' => 'Title EN'], + 'excerpt' => ['de' => 'Kurztext', 'en' => 'Short text'], + 'content' => ['de' => '

Inhalt

', 'en' => '

Content

'], + 'image' => '/images/test.jpg', + 'date' => '2026-01-01', + 'author' => 'Test Author', + 'pdf_path' => '/pdfs/test.pdf', + 'pdf_open_text' => ['de' => 'Öffnen', 'en' => 'Open'], + 'is_published' => true, + 'order' => 0, + ]); + + expect($item->toFrontendArray('de')) + ->toHaveKey('icon', 'document-check') + ->toHaveKey('title', 'Titel DE') + ->toHaveKey('pdf_path', '/pdfs/test.pdf'); +}); + +it('creates and queries industries', function () { + CmsIndustry::create(['name' => ['de' => 'FMCG', 'en' => 'FMCG'], 'order' => 0, 'is_published' => true]); + CmsIndustry::create(['name' => ['de' => 'Beauty', 'en' => 'Beauty'], 'order' => 1, 'is_published' => true]); + CmsIndustry::create(['name' => ['de' => 'Entwurf', 'en' => 'Draft'], 'order' => 2, 'is_published' => false]); + + expect(CmsIndustry::published()->count())->toBe(2) + ->and(CmsIndustry::ordered()->first()->getTranslation('name', 'de'))->toBe('FMCG'); +}); + +it('creates a download with category', function () { + $dl = CmsDownload::create([ + 'title' => ['de' => 'Case Study', 'en' => 'Case Study'], + 'description' => ['de' => 'Beschreibung', 'en' => 'Description'], + 'category' => 'case_study', + 'file_path' => '/pdfs/case-study.pdf', + 'is_published' => true, + 'order' => 0, + ]); + + expect(CmsDownload::byCategory('case_study')->count())->toBe(1) + ->and($dl->getTranslation('title', 'de'))->toBe('Case Study'); +}); + +it('creates a linkedin post with source flag', function () { + CmsLinkedinPost::create([ + 'title' => ['de' => 'Post Titel'], + 'content' => ['de' => '

Inhalt

'], + 'author' => 'Test', + 'date' => '2026-01-01', + 'source' => 'manual', + 'is_published' => true, + 'order' => 0, + ]); + CmsLinkedinPost::create([ + 'title' => ['de' => 'API Post'], + 'content' => ['de' => '

API

'], + 'linkedin_id' => 'ext-123', + 'source' => 'api', + 'is_published' => true, + 'order' => 1, + ]); + + expect(CmsLinkedinPost::manual()->count())->toBe(1) + ->and(CmsLinkedinPost::fromApi()->count())->toBe(1); +}); diff --git a/packages/flux-cms/core/tests-reference/Feature/Cms/CmsSeederTest.php b/packages/flux-cms/core/tests-reference/Feature/Cms/CmsSeederTest.php new file mode 100644 index 0000000..a0ff8a9 --- /dev/null +++ b/packages/flux-cms/core/tests-reference/Feature/Cms/CmsSeederTest.php @@ -0,0 +1,74 @@ +seed(CmsContentSeeder::class); + + expect(CmsContent::count())->toBeGreaterThan(0); + + $welcomeContent = CmsContent::forGroup('welcome')->get(); + expect($welcomeContent->count())->toBeGreaterThan(0); + + $heroHeading = CmsContent::where('group', 'welcome') + ->where('key', 'hero.heading_main') + ->first(); + expect($heroHeading)->not->toBeNull() + ->and($heroHeading->getTranslation('value', 'de'))->not->toBeEmpty(); +}); + +it('seeds news items from lang files', function () { + $this->seed(CmsNewsItemSeeder::class); + + expect(CmsNewsItem::count())->toBeGreaterThan(0); + + $first = CmsNewsItem::ordered()->first(); + expect($first->getTranslation('title', 'de'))->not->toBeEmpty(); +}); + +it('seeds industries from lang files', function () { + $this->seed(CmsIndustrySeeder::class); + + expect(CmsIndustry::count())->toBeGreaterThan(0); + + $first = CmsIndustry::ordered()->first(); + expect($first->getTranslation('name', 'de'))->toBe('FMCG'); +}); + +it('seeds faqs from lang files', function () { + $this->seed(CmsFaqSeeder::class); + + expect(CmsFaq::count())->toBeGreaterThan(0); + + $categories = CmsFaq::distinct()->pluck('category'); + expect($categories)->toContain('general'); +}); + +it('seeds linkedin posts', function () { + $this->seed(CmsLinkedinPostSeeder::class); + + expect(CmsLinkedinPost::count())->toBe(3); + + $first = CmsLinkedinPost::ordered()->first(); + expect($first->source)->toBe('manual') + ->and($first->getTranslation('title', 'de'))->not->toBeEmpty(); +}); + +it('does not create duplicates when run twice', function () { + $this->seed(CmsIndustrySeeder::class); + $countAfterFirst = CmsIndustry::count(); + + $this->seed(CmsIndustrySeeder::class); + $countAfterSecond = CmsIndustry::count(); + + expect($countAfterSecond)->toBe($countAfterFirst); +}); diff --git a/packages/flux-cms/core/tests/DuskTestCase.php b/packages/flux-cms/core/tests/DuskTestCase.php index 140e3f4..49a3411 100644 --- a/packages/flux-cms/core/tests/DuskTestCase.php +++ b/packages/flux-cms/core/tests/DuskTestCase.php @@ -2,11 +2,11 @@ namespace FluxCms\Core\Tests; +use Facebook\WebDriver\Chrome\ChromeOptions; +use Facebook\WebDriver\Remote\DesiredCapabilities; +use Facebook\WebDriver\Remote\RemoteWebDriver; use Laravel\Dusk\TestCase as BaseTestCase; use Orchestra\Testbench\Concerns\CreatesApplication; -use Facebook\WebDriver\Chrome\ChromeOptions; -use Facebook\WebDriver\Remote\RemoteWebDriver; -use Facebook\WebDriver\Remote\DesiredCapabilities; abstract class DuskTestCase extends BaseTestCase { @@ -16,6 +16,7 @@ abstract class DuskTestCase extends BaseTestCase * Prepare for Dusk test execution. * * @beforeClass + * * @return void */ public static function prepare() diff --git a/packages/flux-cms/core/tests/Feature/PageManagementTest.php b/packages/flux-cms/core/tests/Feature/PageManagementTest.php index 440ba7f..b39139d 100644 --- a/packages/flux-cms/core/tests/Feature/PageManagementTest.php +++ b/packages/flux-cms/core/tests/Feature/PageManagementTest.php @@ -3,9 +3,8 @@ namespace FluxCms\Core\Tests\Feature; use FluxCms\Core\Models\Page; -use FluxCms\Core\Models\PageComponent; -use Orchestra\Testbench\TestCase; use Illuminate\Foundation\Testing\RefreshDatabase; +use Orchestra\Testbench\TestCase; class PageManagementTest extends TestCase { @@ -50,7 +49,7 @@ class PageManagementTest extends TestCase 'order' => 1, 'content' => [ 'title' => ['de' => 'Komponenten Titel'], - 'text' => ['de' => 'Komponenten Text'] + 'text' => ['de' => 'Komponenten Text'], ], 'is_active' => true, ]); @@ -194,4 +193,4 @@ class PageManagementTest extends TestCase 'en' => 'English', ]); } -} \ No newline at end of file +} diff --git a/packages/flux-cms/core/tests/Unit/Admin/BlogControllerTest.php b/packages/flux-cms/core/tests/Unit/Admin/BlogControllerTest.php index 767d37c..00b83ed 100644 --- a/packages/flux-cms/core/tests/Unit/Admin/BlogControllerTest.php +++ b/packages/flux-cms/core/tests/Unit/Admin/BlogControllerTest.php @@ -2,8 +2,8 @@ namespace FluxCms\Core\Tests\Unit\Admin; -use FluxCms\Core\Models\User; use FluxCms\Core\Models\BlogPost; +use FluxCms\Core\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Orchestra\Testbench\TestCase; diff --git a/packages/flux-cms/core/tests/Unit/Admin/PageControllerTest.php b/packages/flux-cms/core/tests/Unit/Admin/PageControllerTest.php index 909846a..ea58987 100644 --- a/packages/flux-cms/core/tests/Unit/Admin/PageControllerTest.php +++ b/packages/flux-cms/core/tests/Unit/Admin/PageControllerTest.php @@ -2,8 +2,8 @@ namespace FluxCms\Core\Tests\Unit\Admin; -use FluxCms\Core\Models\User; use FluxCms\Core\Models\Page; +use FluxCms\Core\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Orchestra\Testbench\TestCase; diff --git a/packages/flux-cms/core/tests/Unit/ComponentRegistryTest.php b/packages/flux-cms/core/tests/Unit/ComponentRegistryTest.php index d71fc9a..e78307b 100644 --- a/packages/flux-cms/core/tests/Unit/ComponentRegistryTest.php +++ b/packages/flux-cms/core/tests/Unit/ComponentRegistryTest.php @@ -2,12 +2,12 @@ namespace FluxCms\Core\Tests\Unit; -use FluxCms\Core\Services\ComponentRegistry; -use FluxCms\Core\FieldTypes\TextField; use FluxCms\Core\FieldTypes\MediaField; -use Orchestra\Testbench\TestCase; +use FluxCms\Core\FieldTypes\TextField; +use FluxCms\Core\Services\ComponentRegistry; use Livewire\Component; use Mockery; +use Orchestra\Testbench\TestCase; class ComponentRegistryTest extends TestCase { @@ -16,7 +16,7 @@ class ComponentRegistryTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->registry = new ComponentRegistry(); + $this->registry = new ComponentRegistry; } public function test_can_detect_valid_component() @@ -45,7 +45,7 @@ class ComponentRegistryTest extends TestCase // Valid content $validContent = [ 'title' => ['de' => 'Test Titel', 'en' => 'Test Title'], - 'image' => 123 + 'image' => 123, ]; $errors = $this->registry->validateComponentContent($componentClass, $validContent); @@ -53,7 +53,7 @@ class ComponentRegistryTest extends TestCase // Invalid content (missing required field) $invalidContent = [ - 'image' => 123 + 'image' => 123, ]; $errors = $this->registry->validateComponentContent($componentClass, $invalidContent); @@ -68,8 +68,8 @@ class ComponentRegistryTest extends TestCase 'name' => 'Test Component', 'category' => 'Testing', 'description' => 'A test component', - 'tags' => ['test', 'example'] - ] + 'tags' => ['test', 'example'], + ], ]; $registry = Mockery::mock(ComponentRegistry::class)->makePartial(); @@ -91,7 +91,7 @@ class ComponentRegistryTest extends TestCase AnotherTestComponent::class => [ 'name' => 'Another Component', 'category' => 'Layout', - ] + ], ]; $registry = Mockery::mock(ComponentRegistry::class)->makePartial(); @@ -162,4 +162,4 @@ class AnotherTestComponent extends Component { return '
Another Component
'; } -} \ No newline at end of file +} diff --git a/packages/flux-cms/core/tests/Unit/Models/BlogPostTest.php b/packages/flux-cms/core/tests/Unit/Models/BlogPostTest.php index 4910ea3..96da6d9 100644 --- a/packages/flux-cms/core/tests/Unit/Models/BlogPostTest.php +++ b/packages/flux-cms/core/tests/Unit/Models/BlogPostTest.php @@ -4,7 +4,6 @@ namespace FluxCms\Core\Tests\Unit\Models; use FluxCms\Core\Models\BlogPost; use FluxCms\Core\Models\User; -use Spatie\Tags\Tag; use Illuminate\Foundation\Testing\RefreshDatabase; use Orchestra\Testbench\TestCase; @@ -17,7 +16,7 @@ class BlogPostTest extends TestCase $user = User::factory()->create(); $post = BlogPost::factory()->create([ 'author_id' => $user->id, - 'author_type' => User::class + 'author_type' => User::class, ]); $this->assertInstanceOf(User::class, $post->author); } diff --git a/packages/flux-cms/core/tests/Unit/Models/PageTest.php b/packages/flux-cms/core/tests/Unit/Models/PageTest.php index 5f08f3c..cf2ee7b 100644 --- a/packages/flux-cms/core/tests/Unit/Models/PageTest.php +++ b/packages/flux-cms/core/tests/Unit/Models/PageTest.php @@ -16,7 +16,7 @@ class PageTest extends TestCase $page = Page::factory()->create(); Slug::factory()->create([ 'model_id' => $page->id, - 'model_type' => Page::class + 'model_type' => Page::class, ]); $this->assertInstanceOf(Slug::class, $page->slugs->first()); } diff --git a/packages/flux-cms/starter-components/src/Components/HeroSection.php b/packages/flux-cms/starter-components/src/Components/HeroSection.php index f07b6e0..e1e8b93 100644 --- a/packages/flux-cms/starter-components/src/Components/HeroSection.php +++ b/packages/flux-cms/starter-components/src/Components/HeroSection.php @@ -2,11 +2,11 @@ namespace FluxCms\StarterComponents\Components; -use Livewire\Component; +use FluxCms\Core\FieldTypes\BooleanField; +use FluxCms\Core\FieldTypes\MediaField; use FluxCms\Core\FieldTypes\TextField; use FluxCms\Core\FieldTypes\WysiwygField; -use FluxCms\Core\FieldTypes\MediaField; -use FluxCms\Core\FieldTypes\BooleanField; +use Livewire\Component; class HeroSection extends Component { @@ -109,8 +109,8 @@ class HeroSection extends Component $ctaLink = $content['cta_link'] ?? ''; foreach ($ctaText as $locale => $text) { - if (!empty($text) && empty($ctaLink)) { - $errors["cta_link"] = ['Button link is required when button text is provided']; + if (! empty($text) && empty($ctaLink)) { + $errors['cta_link'] = ['Button link is required when button text is provided']; break; } } @@ -127,24 +127,28 @@ class HeroSection extends Component protected function getHeadline(?string $locale = null): string { $locale = $locale ?? app()->getLocale(); + return $this->content['headline'][$locale] ?? ''; } protected function getSubheadline(?string $locale = null): string { $locale = $locale ?? app()->getLocale(); + return $this->content['subheadline'][$locale] ?? ''; } protected function getDescription(?string $locale = null): string { $locale = $locale ?? app()->getLocale(); + return $this->content['description'][$locale] ?? ''; } protected function getCtaText(?string $locale = null): string { $locale = $locale ?? app()->getLocale(); + return $this->content['cta_text'][$locale] ?? ''; } @@ -165,17 +169,17 @@ class HeroSection extends Component protected function hasCta(): bool { - return !empty($this->getCtaText()) && !empty($this->getCtaLink()); + return ! empty($this->getCtaText()) && ! empty($this->getCtaLink()); } protected function hasBackgroundImage(): bool { - return !empty($this->content['background_image']); + return ! empty($this->content['background_image']); } protected function getBackgroundImageUrl(): ?string { - if (!$this->hasBackgroundImage()) { + if (! $this->hasBackgroundImage()) { return null; } @@ -222,4 +226,4 @@ class HeroSection extends Component return implode(' ', $classes); } -} \ No newline at end of file +} diff --git a/packages/flux-cms/starter-components/src/FluxCmsStarterComponentsServiceProvider.php b/packages/flux-cms/starter-components/src/FluxCmsStarterComponentsServiceProvider.php index 652f8b7..76111f7 100644 --- a/packages/flux-cms/starter-components/src/FluxCmsStarterComponentsServiceProvider.php +++ b/packages/flux-cms/starter-components/src/FluxCmsStarterComponentsServiceProvider.php @@ -2,10 +2,10 @@ namespace FluxCms\StarterComponents; -use Illuminate\Support\ServiceProvider; -use Livewire\Livewire; use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; +use Livewire\Livewire; use ReflectionClass; class FluxCmsStarterComponentsServiceProvider extends ServiceProvider @@ -33,7 +33,7 @@ class FluxCmsStarterComponentsServiceProvider extends ServiceProvider */ protected function bootViews(): void { - $this->loadViewsFrom(__DIR__ . '/../resources/views', 'flux-cms-starter'); + $this->loadViewsFrom(__DIR__.'/../resources/views', 'flux-cms-starter'); } /** @@ -44,12 +44,12 @@ class FluxCmsStarterComponentsServiceProvider extends ServiceProvider if ($this->app->runningInConsole()) { // Publish views $this->publishes([ - __DIR__ . '/../resources/views' => resource_path('views/vendor/flux-cms-starter'), + __DIR__.'/../resources/views' => resource_path('views/vendor/flux-cms-starter'), ], 'flux-cms-starter-views'); // Publish components $this->publishes([ - __DIR__ . '/Components' => app_path('Livewire/Components'), + __DIR__.'/Components' => app_path('Livewire/Components'), ], 'flux-cms-starter-components'); } } @@ -59,21 +59,21 @@ class FluxCmsStarterComponentsServiceProvider extends ServiceProvider */ protected function bootLivewireComponents(): void { - $this->registerLivewireComponentsFrom(__DIR__ . '/Components', 'FluxCms\\StarterComponents\\Components', 'flux-cms-starter::'); + $this->registerLivewireComponentsFrom(__DIR__.'/Components', 'FluxCms\\StarterComponents\\Components', 'flux-cms-starter::'); } protected function registerLivewireComponentsFrom(string $path, string $namespace, string $aliasPrefix = ''): void { - $filesystem = new Filesystem(); - if (!$filesystem->isDirectory($path)) { + $filesystem = new Filesystem; + if (! $filesystem->isDirectory($path)) { return; } foreach ($filesystem->allFiles($path) as $file) { - $class = $namespace . '\\' . str_replace(['/', '.php'], ['\\', ''], $file->getRelativePathname()); + $class = $namespace.'\\'.str_replace(['/', '.php'], ['\\', ''], $file->getRelativePathname()); - if (class_exists($class) && is_subclass_of($class, \Livewire\Component::class) && !(new ReflectionClass($class))->isAbstract()) { - $alias = $aliasPrefix . Str::kebab(class_basename($class)); + if (class_exists($class) && is_subclass_of($class, \Livewire\Component::class) && ! (new ReflectionClass($class))->isAbstract()) { + $alias = $aliasPrefix.Str::kebab(class_basename($class)); Livewire::component($alias, $class); } } diff --git a/public/_cabinet/_docs/CABINET_PROJECT.md b/public/_cabinet/_docs/CABINET_PROJECT.md new file mode 100644 index 0000000..8797eea --- /dev/null +++ b/public/_cabinet/_docs/CABINET_PROJECT.md @@ -0,0 +1,274 @@ +# CABINET Digital Signage System - Projektdokumentation + +**Stand:** Februar 2026 | **Version:** 1.3 + +--- + +## 1. Projektübersicht + +Das CABINET Digital Signage System ist ein In-Store-Display-System für den CABINET Store in Bielefeld. Es besteht aus zwei eigenständigen Modulen, die auf hochkant montierten Displays (9:16 Seitenverhältnis, 1080x1920px) im Store laufen: + +| Modul | Datei | Live-URL | Zweck | +|-------|-------|----------|-------| +| **Video-Display** | `index.html` | `cabinet.b2in.eu` | Zeigt eine Endlos-Playlist von Videos mit rotierenden Footer-Inhalten (Headline, Subline, QR-Code) | +| **Angebots-Display** | `offers/player.html` | (noch nicht live) | Zeigt statische Produkt-Slides (Angebote, Preise, Details) als Slide-Rotation | +| **Info-Tablet** | (in Planung) | `cabinet.b2in.eu/info` | Zeigt Store-Status, Öffnungszeiten, Termine im Schaufenster | + +Alle Inhalte werden uber das B2in-Backend (Admin-Portal unter `portal.b2in.test`) gepflegt. + +--- + +## 2. Architektur + +``` +public/_cabinet/ +├── index.html # LIVE: Video-Display (Hauptseite) +├── index_1.html # Ältere Version +├── index_2.html # Ältere Version +├── index-dynamic.html # Dynamische Variante +├── index-static-backup.html # Statisches Fallback +├── index copy.html # Kopie/Backup +│ +├── offer.html # Prototyp: Angebots-Slides (all-in-one) +├── offers/ # NEU: Modulares Angebots-System +│ ├── player.html # Slide-Player (lädt config.json + iFrames) +│ ├── config.json # Slide-Konfiguration (Reihenfolge, Dauer, Inhalte) +│ ├── shared-styles.css # Gemeinsame CSS-Styles für alle Slides +│ ├── slide-0-intro.html # Intro-Slide (Store-Vorstellung) +│ ├── slide-1-goya-hero.html # Produkt-Slide: GOYA Sideboard (Hero) +│ ├── slide-2-goya-details.html# Produkt-Slide: GOYA Details/Konditionen +│ └── slide-3-tando.html # Produkt-Slide: TANDO Spiegel (Impuls) +│ +├── assets/ # Medien-Dateien +│ ├── *.mp4 # Videos (Saison-Spots) +│ ├── *.jpg # Produkt-/Hintergrundbilder +│ └── cabinet-intro.jpg # Intro-Bild +│ +├── go.php # QR-Code Redirect + Klick-Tracking +├── logger.php # Remote-Logging-Endpoint (CORS-fähig) +├── view-logs.php # Web-basierter Log-Viewer +├── setup-logging.sh # Logging-Setup-Skript +├── test-logging.html # Test-Seite für Logging +├── logs/ # Log-Dateien (nach Level + Datum) +├── clicks.log # QR-Code-Klick-Log +│ +├── logo-cabinet-300.png # CABINET Logo (300px) +├── logo-cabinet.png.webp # CABINET Logo (WebP) +├── .htaccess.example # Apache-Konfigurationsvorlage +│ +├── infotablet.md # Konzept: Info-Tablet für Schaufenster +├── offer.md # Notizen zum Angebots-System +├── QUICK_START.md # Bedienungsanleitung +├── KIOSK_MODE_SETUP.md # Anleitung Kiosk-Modus (Fully Browser) +├── LOGGING_README.md # Logging-Dokumentation +├── VIDEO_OPTIMIZATION_README.md# Video-Optimierungstipps +└── CABINET_PROJECT.md # Diese Datei +``` + +--- + +## 3. Modul A: Video-Display (`index.html`) + +### Funktionsweise +- Lädt die Konfiguration (Video-Playlist + Footer-Inhalte) per API vom Backend +- Spielt Videos nacheinander in einer Endlosschleife ab +- Footer rotiert unabhängig alle 30 Sekunden (Headline + Subline + QR-Code) + +### API-Anbindung +- **Endpoint:** `GET /api/display/config` (auf `b2in.eu`) +- **Response:** + ```json + { + "videoPlaylist": [ + { "src": "assets/fruehjahr_2025.mp4", "position": 25 } + ], + "footerContent": [ + { "headline": "Text", "subline": "Subtext", "url": "https://..." } + ] + } + ``` +- **Polling:** Alle 5 Minuten wird die Config neu geladen +- **Präventiver Reload:** Alle 6 Stunden kompletter Page-Reload + +### Robustheit +- **Video-Watchdog:** Prüft alle 5 Sek. ob Video läuft, Recovery bei Stuck +- **Start-Timeout:** 10 Sek. Timeout, dann Skip zum nächsten Video +- **Memory-Management:** Cleanup nach jedem Video, Monitoring alle 10 Min. +- **Error-Recovery:** Nach 3 aufeinanderfolgenden Fehlern → Page-Reload +- **Remote-Logging:** Alle Events werden an `logger.php` gesendet + +### Layout +``` +┌─────────────────┐ +│ │ +│ VIDEO-BEREICH │ flex-grow: 1 (nimmt restliche Höhe ein) +│ (object-fit: │ +│ cover) │ +│ │ +├──────────────────┤ +│ ▬▬▬ Progress ▬▬ │ 3px Progress-Bar +│ CTA-TEXT │ QR │ ~10% Höhe +│ Headline │Code │ Footer mit rotierendem Inhalt +│ Subline │ │ +└─────────────────┘ +``` + +--- + +## 4. Modul B: Angebots-Display (`offers/`) + +### Funktionsweise +- `player.html` ist der Haupt-Player +- Lädt `config.json` für Slide-Konfiguration +- Jeder Slide ist ein eigenständiges HTML-Dokument, eingebettet per iFrame +- Slides rotieren automatisch mit konfigurierbarer Dauer (8-12 Sek.) +- Fade-Transition (0.6s) zwischen Slides + +### Slide-Typen +| Typ | Beschreibung | Beispiel | +|-----|-------------|---------| +| `intro` | Store-Vorstellung, allgemeiner Willkommenstext | slide-0-intro.html | +| `product-hero` | Produkt mit Hero-Bild, Preis, UVP | slide-1-goya-hero.html | +| `product-details` | Produkt-Details mit Bullet-Liste | slide-2-goya-details.html | +| `product-impulse` | Impulskauf-Produkt ("Jetzt mitnehmen") | slide-3-tando.html | + +### Slide-Layout (shared-styles.css) +``` +┌─────────────────────────────┐ +│ HEADER │ Logo + Tagline +│ [CABINET Logo] [Tagline] │ +├─────────────────────────────┤ +│ │ +│ HERO-BILD │ flex: 1 (nimmt Hauptfläche ein) +│ (Produktfoto) │ +│ [Badge] │ +│ │ +├──────────────┬──────────────┤ +│ INFO-BOX │ QR-BOX │ Unterer Bereich +│ Eyebrow │ Titel │ +│ Titel │ QR-Code │ +│ Preis/UVP │ Kontakt │ +│ Bullets │ │ +└──────────────┴──────────────┘ +``` + +### Design-System +- **Schrift:** IBM Plex Sans (Google Fonts) +- **Akzentfarbe:** `#009FE3` (Cabinet Blau) +- **Safe-Area:** 64px Padding +- **Max. Auflösung:** 1080x1920px (9:16) +- **QR-Codes:** Werden live per `api.qrserver.com` generiert + +### Status +Die Angebots-Slides sind **fertig entwickelt aber noch nicht live**. Die Inhalte (Produkte, Preise, Bilder) sind statisch in den HTML-Dateien hinterlegt. Eine Backend-Anbindung (wie beim Video-Display) fehlt noch. + +--- + +## 5. Modul C: Info-Tablet (in Planung) + +Konzept dokumentiert in `infotablet.md`. Ein kleines Tablet im Schaufenster zeigt: +- Store-Status (Geöffnet/Hinweis/Geschlossen) +- Öffnungszeiten (Wochenansicht, heutiger Tag hervorgehoben) +- Nächster freier Beratungstermin +- Kontakt + QR-Code + +**API geplant:** `GET /api/cabinet-tablet/status` + +--- + +## 6. Backend-Anbindung + +### Datenbank-Tabellen + +**`display_videos`** - Video-Playlist +| Spalte | Typ | Beschreibung | +|--------|-----|-------------| +| id | bigint | Primary Key | +| filename | varchar | Dateiname (z.B. `fruehjahr_2025.mp4`) | +| title | varchar | Anzeige-Titel (optional) | +| position | int | Vertikale Position des Videos (0-100, für `object-position`) | +| sort_order | int | Reihenfolge in der Playlist | +| is_active | tinyint | Aktiv/Inaktiv | + +**`display_footer_contents`** - Footer-Inhalte (CTA-Texte + QR-Codes) +| Spalte | Typ | Beschreibung | +|--------|-----|-------------| +| id | bigint | Primary Key | +| headline | varchar | Überschrift (z.B. "JETZT TERMIN BUCHEN") | +| subline | varchar | Unterzeile (z.B. "Beratung vor Ort...") | +| url | varchar | Ziel-URL für QR-Code (optional) | +| short_code | varchar | 6-stelliger Code für Redirect (unique) | +| clicks | int | Klick-Zähler (via go.php) | +| sort_order | int | Reihenfolge | +| is_active | tinyint | Aktiv/Inaktiv | + +### Backend-Komponenten + +| Komponente | Pfad | Funktion | +|-----------|------|----------| +| API-Controller | `app/Http/Controllers/Api/DisplayConfigController.php` | Liefert JSON-Config für Video-Display | +| Video-Model | `app/Models/DisplayVideo.php` | Eloquent-Model mit `active()` Scope | +| Footer-Model | `app/Models/DisplayFooterContent.php` | Model mit Short-Code-Generierung + Short-URL | +| Admin-Seite | `app/Livewire/Admin/Cms/CabinetDisplay.php` | Livewire-Verwaltung für Videos + Footer | +| Admin-Route | `/admin/cms/cabinet` | Admin-Portal-Seite | +| API-Route | `GET /api/display/config` | Öffentlicher API-Endpoint | + +### QR-Code / Redirect-System +1. Backend generiert automatisch einen 6-stelligen `short_code` pro Footer-Inhalt +2. QR-Code im Display zeigt URL wie `cabinet.b2in.eu/go.php?z=abc123` +3. `go.php` schlägt den Code in der DB nach, erhöht den Klick-Zähler, und leitet weiter +4. Fallback: Alte Legacy-Codes (t, t1, p, i, f) für Rückwärtskompatibilität + +--- + +## 7. Logging & Monitoring + +### Remote-Logging (`logger.php`) +- Empfängt POST-Requests mit JSON-Logdaten vom Display +- CORS-aktiviert für Cross-Origin-Zugriff +- Speichert Logs in drei Formaten: + - Pro Level: `logs/info_2026-02-27.log`, `logs/error_2026-02-27.log` + - Alle zusammen: `logs/all_2026-02-27.log` + - JSON: `logs/json_2026-02-27.log` +- Automatische Log-Rotation: Dateien > 30 Tage werden gelöscht + +### Was wird geloggt +- Video-Lifecycle: Start, Ende, Error, Stuck, Timeout +- Heartbeat alle 5 Minuten ("Display läuft") +- Memory-Status alle 10 Minuten +- Online/Offline-Status +- JavaScript-Fehler (global error handler) +- Resource-Loading-Fehler (Bilder, Videos) + +### Log-Viewer (`view-logs.php`) +Web-basierte Oberfläche zum Lesen der Logs mit Auto-Refresh und Farbcodierung. + +--- + +## 8. Deployment & Betrieb + +### Live-System +- **Domain:** `cabinet.b2in.eu` (Subdomain von b2in.eu) +- **Assets-URL:** `https://b2in.eu/_cabinet/assets/` +- **API-URL:** `https://b2in.eu/api/display/config` + +### Test-System +- **URL:** `b2in.test/_cabinet/` +- **API:** `b2in.test/api/display/config` + +### Kiosk-Modus +Empfohlen: **Fully Kiosk Browser** (Android) - Details in `KIOSK_MODE_SETUP.md` +- Auto-Start bei Boot +- Vollbild ohne Statusbar +- Auto-Restart bei Crash +- Zeitsteuerung (22:00 Standby, 07:00 Aufwachen) + +--- + +## 9. Offene Punkte / TODO + +- [ ] **Offers-System Backend-Anbindung**: `offers/` Slides haben noch keine API-Anbindung - Inhalte sind statisch im HTML +- [ ] **Offers live schalten**: Player + Slides auf Live-System deployen +- [ ] **Info-Tablet umsetzen**: Konzept steht (infotablet.md), Implementierung offen +- [ ] **Offers Config aus DB**: `config.json` sollte dynamisch vom Backend generiert werden +- [ ] **Bilder-Upload**: Produktbilder für Offers werden noch manuell in `assets/` abgelegt diff --git a/public/_cabinet/KIOSK_MODE_SETUP.md b/public/_cabinet/_docs/KIOSK_MODE_SETUP.md similarity index 100% rename from public/_cabinet/KIOSK_MODE_SETUP.md rename to public/_cabinet/_docs/KIOSK_MODE_SETUP.md diff --git a/public/_cabinet/LOGGING_README.md b/public/_cabinet/_docs/LOGGING_README.md similarity index 100% rename from public/_cabinet/LOGGING_README.md rename to public/_cabinet/_docs/LOGGING_README.md diff --git a/public/_cabinet/QUICK_START.md b/public/_cabinet/_docs/QUICK_START.md similarity index 100% rename from public/_cabinet/QUICK_START.md rename to public/_cabinet/_docs/QUICK_START.md diff --git a/public/_cabinet/VIDEO_OPTIMIZATION_README.md b/public/_cabinet/_docs/VIDEO_OPTIMIZATION_README.md similarity index 100% rename from public/_cabinet/VIDEO_OPTIMIZATION_README.md rename to public/_cabinet/_docs/VIDEO_OPTIMIZATION_README.md diff --git a/public/_cabinet/_docs/b2in-display-implementation.md b/public/_cabinet/_docs/b2in-display-implementation.md new file mode 100644 index 0000000..4f97740 --- /dev/null +++ b/public/_cabinet/_docs/b2in-display-implementation.md @@ -0,0 +1,175 @@ +# B2in Display – Frontend-Implementation + +**Ziel:** Frontend-Webapp für das B2in Schaufenster-Display (9:16 Portrait, 43–55 Zoll). +**Pfad:** `public/_cabinet/b2in/index.html` +**Konzept-Basis:** `public/_cabinet/_docs/b2in-displays.md` + +--- + +## Ist-Zustand + +Es gibt bereits drei Display-Typen unter `public/_cabinet/`: + +| Typ | Pfad | Status | Beschreibung | +|-----|------|--------|-------------| +| **Video-Display** | `index.html` | Live, stabil | CABINET-Branding. Videos + Footer-Rotation. API: `/api/display/config` | +| **Offers** | `offers/index.html` | Entwickelt, nicht live | CABINET-Branding. iFrame-basierte Slide-Rotation mit `config.json` | +| **Info-Tablet** | `info/index.html` | Entwickelt | CABINET-Branding. Store-Status/Öffnungszeiten. API: `/api/cabinet-tablet/status` | +| **B2in Display** | `b2in/` | **Neu – wird jetzt gebaut** | B2in-Branding. Playlist mit Videos/Bildern + Text. API: `/api/b2in-display/playlist` | + +--- + +## Architektur-Übersicht + +``` +public/_cabinet/b2in/ +├── index.html ← Haupt-Webapp (Playlist-Engine + UI) +├── b2in-styles.css ← Display-spezifische Styles +└── (assets/) ← Medien werden per API/URL referenziert, nicht lokal +``` + +Shared CSS aus `public/_cabinet/shared/cabinet-base.css` wird **nicht** importiert – das B2in-Display hat ein eigenständiges Branding (dunkel, B2in statt CABINET). Es ist ein komplett eigenes Design-System. + +--- + +## Schritte + +### Schritt 1: HTML-Grundgerüst + CSS + +**Datei:** `public/_cabinet/b2in/index.html` + `b2in-styles.css` + +Layout gemäß Konzept (Abschnitt 2.1): + +``` +┌─────────────────────────┐ +│ HEADER │ ← B2in-Logo (links) + Claim (rechts) +│ B2in · Claim │ Fest, immer sichtbar +├─────────────────────────┤ +│ │ +│ ┌─────────────────┐ │ ← Gradient oben (dunkel → transparent) +│ │ VIDEO / BILD │ │ 16:9 Content-Bereich +│ │ (16:9 Media) │ │ object-fit: cover +│ └─────────────────┘ │ ← Gradient unten (transparent → dunkel) +│ │ +├─────────────────────────┤ +│ TEXTFELD │ ← Headline (max 40 Zeichen) +│ Headline + Subline │ Subline (max 80 Zeichen) +├─────────────────────────┤ Wechselt synchron mit Media +│ FOOTER │ ← "Marcel Scheibe" + "b2in.de" + QR-Code +│ Name · URL · QR │ Fest, immer sichtbar +└─────────────────────────┘ +``` + +**Design-Entscheidungen:** +- Dunkler Hintergrund (#0a0a0a) – B2in-Branding, nicht CABINET-weiß +- 9:16 Aspect Ratio Container (wie bestehende Displays) +- Gradient-Overlays oben/unten über dem Media-Bereich +- IBM Plex Sans Font (wie alle Cabinet-Displays) +- Akzentfarbe: B2in-Blau (wird aus Branding-Guide übernommen) +- Kein CABINET-Logo, kein Accent #009FE3 + +### Schritt 2: Playlist-Engine (JavaScript) + +**Klasse:** `B2inDisplayApp` + +Kernfunktionalität: +1. **API laden** → `GET /api/b2in-display/playlist` (wird später gebaut, erstmal Mock/Fallback) +2. **Playlist sortieren** → nach `sort_order`, nur `is_active === true` +3. **Gewichtung anwenden** → 70/30 Immobilien/Möbel-Verteilung berechnen +4. **Rotation starten** → Item für Item durchspielen + +Item-Wechsel-Logik: +- **Video:** Abspielen bis Ende → nächstes Item +- **Bild:** `duration_seconds` abwarten → nächstes Item +- **Am Ende der Playlist:** Von vorne beginnen + +### Schritt 3: Video-Handling + +Bewährtes Pattern aus dem bestehenden `index.html` übernehmen: +- `autoplay muted playsinline` für Browser-Autoplay-Policy +- Memory-Management: `src` leeren + `load()` nach jedem Video +- Preloading: Nächstes Item im Hintergrund vorladen +- Start-Timeout (10s) → bei Timeout überspringen +- Watchdog: Prüft alle 5s ob Video noch läuft +- Error-Handling: Bei Fehler → Item überspringen, nie schwarzer Screen + +### Schritt 4: Bild-Handling + +- `` Element mit `object-fit: cover` +- Duration aus Item oder globaler `default_image_duration` +- Ken-Burns-Effekt (optionaler langsamer Zoom per CSS) +- Preload via `new Image()` im Hintergrund + +### Schritt 5: Transitions + +Drei Typen (per CMS-Setting steuerbar): + +| Typ | Umsetzung | +|-----|-----------| +| `fade` | Opacity 1→0, dann 0→1 | +| `crossfade` | Neues Element über dem alten einblenden (empfohlen, Standard) | +| `slide` | CSS transform translateX | + +Text-Synchronisation: +1. Text fade-out (400ms) +2. Neuen Text setzen +3. Text fade-in (400ms) +4. Startet 200ms vor Media-Wechsel + +### Schritt 6: Polling + Stabilität + +Gleicher Ansatz wie Info-Tablet, aber mit 60s Intervall: + +- **Lightweight Check** alle 60s → `GET /api/b2in-display/check` +- **Full Fetch** nur bei Timestamp-Änderung +- Laufendes Video/Bild wird zu Ende gespielt, dann neue Playlist +- localStorage-Cache als Offline-Fallback +- Auto-Reload alle 6 Stunden +- Connection-Recovery: 3 Fehler → 5 Min Pause → Retry +- 30 Min offline → Page-Reload + +### Schritt 7: Standby-Modus + +Wenn `display_active === false`: +- Nur B2in-Logo auf dunklem Hintergrund +- Kein Content, kein Textfeld, kein Footer-Text +- Polling läuft weiter (wartet auf Aktivierung) + +### Schritt 8: Error-Overlay + +Bei kritischen Fehlern (keine Playlist, kein Media): +- Dezentes B2in-Logo auf dunklem Hintergrund +- Niemals Browser-Fehler oder weißer Screen +- Automatischer Retry im Hintergrund + +--- + +## API-Abhängigkeit + +Das Frontend wird **zunächst mit Mock-Daten** gebaut. Die API (`/api/b2in-display/playlist` + `/check`) wird als separater Schritt im Backend implementiert. Das Frontend erkennt automatisch, ob die API verfügbar ist, und fällt auf eingebettete Demo-Daten zurück. + +**Mock-Playlist für Entwicklung:** +```javascript +const MOCK_PLAYLIST = { + settings: { + display_active: true, + footer_name: "Marcel Scheibe", + footer_url: "b2in.de", + transition: { type: "crossfade", duration_ms: 800 }, + default_image_duration: 10 + }, + items: [ + // Demo-Items mit Platzhalter-Medien + ], + updated_at: new Date().toISOString() +}; +``` + +--- + +## Abgrenzung (was NICHT in diesem Schritt passiert) + +- Kein CMS-Backend (Model, Migration, Controller, Admin-UI) → separater Schritt +- Kein Media-Upload → Videos/Bilder werden per URL referenziert +- Keine Gewichtungs-Logik im Backend → wird im Frontend berechnet +- Keine Änderung am bestehenden Video-Display (`index.html`) → bleibt unberührt diff --git a/public/_cabinet/_docs/b2in-displays.md b/public/_cabinet/_docs/b2in-displays.md new file mode 100644 index 0000000..b818d86 --- /dev/null +++ b/public/_cabinet/_docs/b2in-displays.md @@ -0,0 +1,386 @@ +# B2in Schaufenster-Display – Entwicklungskonzept + +**Status:** Entwicklungsvorlage | Februar 2026 + +--- + +## 1. Projektübersicht + +### 1.1 Zweck + +Ein großformatiges Display im Schaufenster des CABINET Stores in Bielefeld zeigt B2in-Content im Hochformat (9:16). Ziel: Laufkundschaft in 3–5 Sekunden eine klare Botschaft vermitteln – B2in als Marke für internationale Immobilien und exklusive Einrichtungskonzepte. Das Display läuft parallel zum CABINET-Schaufenster und ist eigenständig gebrandet. + +### 1.2 Technischer Ansatz + +Die Anzeige wird als Webapp unter einer Subdomain ( Subdomain (`cabinet.b2in.eu/info <- live / [`https://b2in.test/_cabinet](https://b2in.test/_cabinet)/info ← testserver) bereitgestellt. Ein Smart TV mit android greift im Vollbildmodus über den Browser darauf zu. Inhalte (Videos, Bilder, Texte) werden über das B2in-Backend (CMS) gepflegt und rotieren automatisch. + +### 1.3 Branding + +Ausschließlich B2in-Branding. Kein CABINET-Logo, kein Azizi, keine Partnerlogos. Der Frame zeigt: B2in-Logo, Claim "Connecting Design & Property", Marcel Scheibe als Person, b2in.de und QR-Code. + +--- + +## 2. Display-Architektur + +### 2.1 Formatierung + +Das Display steht im **9:16 Hochformat (Portrait)**. Das Videomaterial liegt im **16:9 Querformat** vor. Daraus ergibt sich ein natürlicher Split: + +`┌─────────────────────────┐ +│ HEADER │ ← Fester Bereich: Logo + Claim +│ B2in · Claim │ +├─────────────────────────┤ +│ │ +│ │ ← Oberer Leerraum (Gradient zum Video) +│ ┌───────────────────┐ │ +│ │ │ │ +│ │ VIDEO / BILD │ │ ← 16:9 Content-Bereich (rotierend) +│ │ (16:9) │ │ +│ │ │ │ +│ └───────────────────┘ │ +│ │ ← Unterer Leerraum (Gradient zum Text) +│ │ +├─────────────────────────┤ +│ TEXTFELD │ ← Rotierender Text (Headline + Subline) +│ Headline + Subline │ +├─────────────────────────┤ +│ FOOTER │ ← Fester Bereich: Name + URL + QR +│ Marcel Scheibe · QR │ +└─────────────────────────┘` + +### 2.2 Bereiche im Detail + +| # | Bereich | Inhalt | Verhalten | +| --- | --- | --- | --- | +| 1 | **Header** | B2in-Logo (links) + Claim "Connecting Design & Property" (rechts). | Fest. Steht permanent, unabhängig vom Content. | +| 2 | **Content-Bereich** | 16:9 Video (MP4) oder Einzelbild (JPG/PNG). Nimmt ca. 50–60% der Screenhöhe ein. Oben und unten Gradient-Übergang zum dunklen Hintergrund. | Rotiert automatisch. Reihenfolge und Dauer per CMS steuerbar. | +| 3 | **Textfeld** | Headline (max. 40 Zeichen) + Subline (max. 80 Zeichen). Passt sich dem aktuellen Content an. | Wechselt synchron mit dem Video/Bild. Sanfte Fade-Transition. | +| 4 | **Footer** | "Marcel Scheibe" (Name) + "b2in.de" (URL) + QR-Code (rechts). | Fest. Steht permanent. | + +--- + +## 3. Content-Rotation (Playlist-System) + +### 3.1 Konzept + +Das Display zeigt eine Playlist von Content-Items, die automatisch durchrotieren. Jedes Item besteht aus einem Medienelement (Video oder Bild) plus passendem Text. + +### 3.2 Gewichtung + +Die Rotation folgt einer definierten Gewichtung: + +| Kategorie | Anteil | Beispiel-Content | +| --- | --- | --- | +| **Immobilien** | ~70% | Dubai-Projekte, internationale Lifestyle-Aufnahmen, Architektur | +| **Möbel / Einrichtung** | ~30% | Lokale Händler-Highlights, Einrichtungskonzepte, Interior-Design | + +Die Gewichtung wird über ein `category`-Feld pro Item gesteuert. Das Frontend berechnet die Rotation so, dass die Verteilung über einen Durchlauf eingehalten wird (z. B. bei 10 Items: 7× Immobilien, 3× Möbel). + +### 3.3 Content-Item Struktur + +Jedes Playlist-Item hat folgende Eigenschaften: + +| Feld | Typ | Beschreibung | +| --- | --- | --- | +| `id` | Auto | Eindeutige ID | +| `title` | Text (intern) | Interner Name zur Identifikation im CMS (wird nicht angezeigt) | +| `category` | Dropdown | `immobilien` | `moebel` – bestimmt die Gewichtung in der Rotation | +| `media_type` | Dropdown | `video` | `image` | +| `media_file` | Upload (MP4/JPG/PNG) | Mediendatei. Videos: MP4, H.264, max. 1080p. Bilder: JPG/PNG, min. 1920x1080. | +| `media_url` | URL (optional) | Alternative zu Upload: Externe Video-/Bild-URL. Wird bevorzugt wenn vorhanden. | +| `headline` | Text | Max. 40 Zeichen. Wird im Textfeld angezeigt. | +| `subline` | Text | Max. 80 Zeichen. Erklärender Text unter der Headline. | +| `duration_seconds` | Zahl | Anzeigedauer in Sekunden. Für Bilder: empfohlen 8–12 Sek. Für Videos: wird ignoriert, Dauer ergibt sich aus Videolänge. | +| `sort_order` | Zahl | Reihenfolge in der Playlist. Niedrigste Zahl zuerst. | +| `is_active` | Toggle | Aktiv/Inaktiv. Inaktive Items werden übersprungen. | + +### 3.4 Beispiel-Playlist + +| # | Titel (intern) | Kategorie | Typ | Headline | Subline | Dauer | +| --- | --- | --- | --- | --- | --- | --- | +| 1 | Dubai Skyline Video | immobilien | video | Internationale Immobilien — Ihr Einstieg. | Beratung, Begleitung und Vermittlung. Persönlich. Transparent. | (Video) | +| 2 | Dubai Villa Rendering | immobilien | image | Ihr Zuhause. Weltweit. | Von Dubai bis Europa – wir finden Ihre Immobilie. | 10s | +| 3 | Möbel Showroom | moebel | image | Exklusive Einrichtung — Lokal. Für Sie. | Kuratierte Möbelkonzepte von lokalen Fachhändlern. | 10s | +| 4 | Dubai Pool Lifestyle | immobilien | video | Dubai. Lissabon. Und morgen? | Internationale Immobilien als Kapitalanlage. | (Video) | +| 5 | Dubai Apartment Tour | immobilien | video | Neubau ab 3.000 €/m² | Wertsteigerung. Steuervorteile. Persönliche Begleitung. | (Video) | +| 6 | Interior Design Mood | moebel | image | Lokale Händler. Echte Stücke. | Ausstellungsstücke und Designmöbel aus Ihrer Region. | 10s | +| 7 | Dubai Cityscape Drone | immobilien | video | Ihr Immobilien-Dolmetscher. | Marcel Scheibe begleitet Sie durch den gesamten Kaufprozess. | (Video) | + +--- + +## 4. CMS-Felder (B2in-Backend) + +Im B2in-Backend wird ein eigener Bereich „B2in Display" angelegt mit zwei Ebenen: globale Einstellungen und die Playlist-Items. + +### 4.1 Globale Einstellungen + +| Feldname | Typ | Beschreibung | +| --- | --- | --- | +| `display_active` | Toggle | Master-Schalter. Wenn deaktiviert, zeigt das Display einen Standby-Screen (nur Logo). | +| `footer_name` | Text | Name im Footer. Standard: "Marcel Scheibe" | +| `footer_url` | Text | URL im Footer. Standard: "b2in.de" | +| `qr_code_image` | Upload (PNG/SVG) | QR-Code Asset für den Footer. | +| `rotation_weight_immobilien` | Zahl (%) | Soll-Anteil Immobilien-Content. Standard: 70. | +| `rotation_weight_moebel` | Zahl (%) | Soll-Anteil Möbel-Content. Standard: 30. | +| `default_image_duration` | Zahl (Sek.) | Standard-Anzeigedauer für Bilder, wenn beim Item nichts eingetragen. Standard: 10. | +| `transition_type` | Dropdown | `fade` | `slide` | `crossfade` – Übergangseffekt zwischen Items. Standard: crossfade. | +| `transition_duration_ms` | Zahl | Dauer der Transition in Millisekunden. Standard: 800. | + +### 4.2 Playlist-Items + +Wiederholbare Einträge (Repeater-Feld oder eigene Collection), Felder wie in Abschnitt 3.3 definiert. + +### 4.3 Zeichenlimit-Hinweise für Marcel + +Im CMS sollten bei den Textfeldern visuelle Hinweise erscheinen: + +- **Headline:** Zähler „12/40 Zeichen" – wird rot ab 35 +- **Subline:** Zähler „45/80 Zeichen" – wird rot ab 70 +- **Begründung:** Auf dem Display müssen die Texte in 3 Sekunden lesbar sein. Längere Texte werden nicht gelesen. + +--- + +## 5. API-Endpoint + +### 5.1 Endpoint-Struktur + +`GET /api/b2in-display/playlist` + +**Response (JSON):** + +json + +`{ + "settings": { + "display_active": true, + "footer_name": "Marcel Scheibe", + "footer_url": "b2in.de", + "qr_code_url": "/assets/qr-b2in.svg", + "rotation_weights": { + "immobilien": 70, + "moebel": 30 + }, + "default_image_duration": 10, + "transition": { + "type": "crossfade", + "duration_ms": 800 + } + }, + "items": [ + { + "id": 1, + "category": "immobilien", + "media_type": "video", + "media_url": "/media/display/dubai-skyline.mp4", + "headline": "Internationale Immobilien — Ihr Einstieg.", + "subline": "Beratung, Begleitung und Vermittlung. Persönlich. Transparent.", + "duration_seconds": null, + "sort_order": 1, + "is_active": true + }, + { + "id": 2, + "category": "moebel", + "media_type": "image", + "media_url": "/media/display/showroom-lokal.jpg", + "headline": "Exklusive Einrichtung — Lokal. Für Sie.", + "subline": "Kuratierte Möbelkonzepte von lokalen Fachhändlern.", + "duration_seconds": 10, + "sort_order": 3, + "is_active": true + } + ], + "updated_at": "2026-02-26T14:30:00Z" +}` + +### 5.2 Update-Mechanismus (Polling) + +Gleicher Ansatz wie beim CABINET Info-Tablet: + +**1. Lightweight Check (alle 60 Sekunden):** + +`GET /api/b2in-display/check` + +json + +`{ "updated_at": "2026-02-26T14:30:00Z" }` + +**2. Full Fetch (nur bei Änderung):** +Wenn Timestamp abweicht → komplette Playlist neu laden. Laufendes Video wird zu Ende gespielt, dann greift die neue Playlist. + +**Warum 60 statt 30 Sekunden?** Das Display zeigt eine Playlist, keine Echtzeit-Infos. Wenn Marcel ein neues Video hochlädt, ist eine Verzögerung von maximal 60 Sekunden völlig akzeptabel. + +--- + +## 6. Frontend-Logik + +### 6.1 Playlist-Engine + +Das Frontend implementiert eine einfache Playlist-Engine: + +`Ablauf: +1. Playlist laden (API-Call) +2. Nur aktive Items filtern (is_active === true) +3. Items nach sort_order sortieren +4. Gewichtung anwenden: + - Items nach Kategorie gruppieren + - Rotation so mischen, dass die %-Verteilung eingehalten wird + - Beispiel bei 70/30: I, I, M, I, I, I, M, I, I, M (bei 10 Items) +5. Erstes Item anzeigen +6. Bei Video: Warten bis Video endet → nächstes Item +7. Bei Bild: duration_seconds abwarten → nächstes Item +8. Am Ende der Playlist: Von vorne beginnen +9. Zwischen Items: Transition (crossfade/fade/slide)` + +### 6.2 Video-Handling + +| Aspekt | Spezifikation | +| --- | --- | +| **Format** | MP4, H.264 Codec, max. 1080p (1920x1080) | +| **Autoplay** | Videos starten automatisch, gemutet (Browser-Policy). Ton ist nicht relevant für Schaufenster-Display. | +| **Ladezeit** | Videos werden im Hintergrund vorgeladen (nächstes Item in der Playlist wird gepreloaded während das aktuelle läuft). | +| **Fehlerfälle** | Wenn ein Video nicht lädt (404, Netzwerkfehler): Item überspringen, nächstes anzeigen. Fehler loggen. | +| **Loop** | Kein Loop pro Video. Jedes Video spielt einmal, dann kommt das nächste Playlist-Item. | + +### 6.3 Bild-Handling + +| Aspekt | Spezifikation | +| --- | --- | +| **Format** | JPG oder PNG, min. 1920x1080 Pixel | +| **Skalierung** | `object-fit: cover` – Bild füllt den 16:9-Bereich, wird bei Bedarf beschnitten. | +| **Dauer** | Aus `duration_seconds` des Items oder `default_image_duration` aus den globalen Settings. | +| **Ken-Burns-Effekt (optional)** | Langsamer Zoom-In während der Anzeigedauer. Macht statische Bilder lebendiger. Per CSS-Animation, kein JavaScript nötig. | + +### 6.4 Text-Synchronisation + +Headline und Subline wechseln synchron mit dem Media-Content: + +1. Aktuelles Textfeld ausblenden (fade-out, 400ms) +2. Neuen Text setzen +3. Neues Textfeld einblenden (fade-in, 400ms) +4. Timing: Text-Transition startet 200ms vor dem Media-Wechsel, damit beides gleichzeitig erscheint. + +--- + +## 7. Technische Stabilität & Kiosk-Betrieb + +### 7.1 Hardware-Setup + +| Komponente | Empfehlung | +| --- | --- | +| **Display** | TV/Monitor im Hochformat (9:16), 43–55 Zoll. Full HD ausreichend (1080x1920 in Portrait). LED/LCD mit hoher Helligkeit (min. 350 nits, ideal 500+ nits für Schaufenster mit Sonneneinstrahlung). | +| **Media Player** | Dedizierter Android-Stick oder -Box (z. B. Amazon Fire TV Stick 4K, Xiaomi Mi Box, oder professionell: BrightSign). Alternativ: Smart TV mit Browser. | +| **Kiosk-Software** | Fully Kiosk Browser (wenn Android). Bei BrightSign: eigene Kiosk-Funktionalität. | +| **Halterung** | VESA-Wandhalterung, Portrait-Montage. Ggf. mit Blickwinkelschutz-Folie wenn das Display zu nah an der Scheibe steht. | +| **Stromversorgung** | Dauerstrom. Optional: Zeitschaltuhr oder Smart Plug für automatisches Ein/Aus (z. B. 07:00–22:00). | +| **WLAN** | Stabiles Store-WLAN. Bei Verbindungsproblemen: LAN-Adapter als Fallback. | + +### 7.2 Webapp-seitige Stabilität + +| Maßnahme | Beschreibung | +| --- | --- | +| **Auto-Reload** | Kompletter Page-Reload alle 6 Stunden. Räumt Speicher auf – besonders wichtig bei Video-Wiedergabe, die Memory Leaks verursachen kann. | +| **Video-Memory-Management** | Nach jedem Video: `src` des Video-Elements leeren und neu setzen, damit der Browser den Speicher freigibt. Kein Stapeln von Video-Elementen. | +| **Offline-Fallback** | Wenn API nicht erreichbar: Letzte Playlist aus localStorage abspielen. Kein schwarzer Bildschirm. | +| **Connection-Recovery** | Polling-Fehler 3x hintereinander → 5 Min. Pause → erneut versuchen. Nach 30 Min. ohne Verbindung → Page-Reload. | +| **Lokaler Cache** | Playlist-JSON wird in localStorage gespeichert. Mediendateien werden im Browser-Cache gehalten (Cache-Control Headers serverseitig setzen, z. B. `max-age=86400`). | +| **Standby-Modus** | Optionaler CMS-Toggle: Wenn `display_active = false`, zeigt die Webapp nur das B2in-Logo auf dunklem Hintergrund. Kein Content, kein Textfeld. | +| **Error-Overlay** | Bei kritischen Fehlern (keine Playlist, kein Media): Dezentes B2in-Logo auf dunklem Hintergrund. Niemals ein Browser-Fehlerbild oder weißer Screen. | +| **Automatischer Playlist-Neustart** | Wenn die Playlist durchgelaufen ist und das nächste Polling eine Änderung zeigt: Neue Playlist nahtlos übernehmen. | + +### 7.3 Performance-Hinweise + +- Videos sollten für Web optimiert sein: MP4, H.264, AAC Audio (auch wenn gemutet), `moov atom` am Anfang der Datei (für schnellen Start) +- Empfohlene Bitrate: 5–8 Mbit/s für 1080p +- Bilder sollten als WebP oder komprimiertes JPG vorliegen (max. 500 KB pro Bild) +- Preloading: Immer das nächste Item vorladen, während das aktuelle abgespielt wird + +--- + +## 8. Frontend-Spezifikation + +### 8.1 Technologie + +- HTML/CSS/JS (Vanilla). Kein Framework nötig. +- Responsive für gängige Display-Auflösungen im 9:16 Portrait (1080x1920, 720x1280) + +### 8.3 Transitions zwischen Items + +| Transition-Typ | Beschreibung | +| --- | --- | +| `fade` | Aktuelles Item blendet aus (opacity 1→0), neues blendet ein (opacity 0→1). | +| `crossfade` | Neues Item blendet über dem aktuellen ein. Sanfter, da kein schwarzer Zwischenzustand. **Empfohlen.** | +| `slide` | Aktuelles Item gleitet nach links raus, neues kommt von rechts rein. | + +Standard: **crossfade** mit 800ms. Per CMS änderbar. + +--- + +## 9. Leitplanken für Content-Pflege + +**Diese Regeln sollten im CMS als Hinweistext sichtbar sein:** + +### ✓ Ja, so machen wir es: + +- **Ein Item, eine Botschaft.** Jedes Playlist-Item hat eine klare Aussage: Immobilien ODER Möbel. +- **Texte kurz halten.** Headline max. 40 Zeichen, Subline max. 80 Zeichen. Was in 3 Sekunden nicht gelesen werden kann, wird nicht gelesen. +- **Nur B2in-Content.** Keine Partnerlogos im Video/Bild. Kein Azizi-Branding. Kein CABINET. +- **Hochwertige Medien.** Nur professionelles Video-/Bildmaterial. Keine Handy-Fotos, keine Screenshots. +- **Regelmäßig aktualisieren.** Playlist mindestens quartalsweise prüfen. Abgelaufene Projekte rausnehmen. + +### ✗ Das vermeiden wir: + +- **Zu viele Items.** Max. 8–10 Items in der Playlist. Mehr führt zu langer Rotation, Wiederholung wird selten. +- **Text im Video.** Wenn das Video bereits Text enthält, Headline und Subline reduzieren oder leer lassen – sonst doppelt sich die Information. +- **Preislisten oder Grundrisse.** Das Display weckt Neugier, es informiert nicht. Details gehören auf die Website. +- **Verschiedene Botschaften mischen.** Kein Item, das gleichzeitig Dubai, Möbel und CABINET bewirbt. + +--- + +## 10. Umsetzungs-Checkliste + +| # | Aufgabe | Verantwortlich | Status | +| --- | --- | --- | --- | +| 1 | CMS-Bereich "B2in Display" anlegen: Globale Settings + Playlist-Repeater (Abschnitt 4) | Backend-Entwicklung | Offen | +| 2 | API-Endpoint `/api/b2in-display/playlist` + `/check` implementieren (Abschnitt 5) | Backend-Entwicklung | Offen | +| 3 | Media-Upload-Funktion im CMS (MP4 + JPG/PNG) mit Größen-Validierung | Backend-Entwicklung | Offen | +| 4 | Frontend-Webapp mit Playlist-Engine bauen (Abschnitt 6) | Frontend-Entwicklung | Offen | +| 5 | Video-Preloading + Memory-Management implementieren (Abschnitt 6.2 + 7.2) | Frontend-Entwicklung | Offen | +| 6 | Polling-Mechanismus + Offline-Fallback + Auto-Reload (Abschnitt 5.2 + 7.2) | Frontend-Entwicklung | Offen | +| 7 | Subdomain einrichten + SSL + Deployment | DevOps | Offen | +| 8 | Display-Hardware beschaffen + montieren (Abschnitt 7.1) | Marcel / Hardware | Offen | +| 9 | Kiosk-Software einrichten (Fully Kiosk oder BrightSign) | Technik | Offen | +| 10 | QR-Code generieren (Ziel: b2in.de) + als Asset hinterlegen | Design | Offen | +| 11 | Initiales Video-/Bildmaterial aufbereiten (Format, Komprimierung, Qualität) | Content / Design | Offen | +| 12 | Beispiel-Playlist im CMS anlegen + End-to-End-Test | QA | Offen | +| 13 | Installation im Store: Position, Helligkeit, Blickwinkel, WLAN-Stabilität | Marcel + Technik | Offen | + +--- + +## Anhang A: Zusammenfassung der Auto-Logiken + +Folgende Dinge passieren automatisch, ohne dass Marcel etwas pflegen muss: + +- Playlist rotiert endlos durch alle aktiven Items +- Gewichtung (70/30) wird automatisch aus den Kategorien berechnet +- Videos starten automatisch (gemutet) und gehen nach Ende zum nächsten Item +- Bilder werden nach definierter Dauer gewechselt +- Polling prüft alle 60 Sekunden auf CMS-Änderungen +- Bei Playlist-Änderung: Laufendes Item wird zu Ende gespielt, dann greift neue Playlist +- Auto-Reload alle 6 Stunden für Speicher-Hygiene +- Offline: Letzte Playlist läuft weiter aus dem Cache +- Bei Video-Fehler: Item wird übersprungen, kein schwarzer Screen +- Standby-Modus bei `display_active = false`: Nur Logo auf dunklem Hintergrund + +## Anhang B: Unterschiede zum CABINET Info-Tablet + +| Aspekt | CABINET Info-Tablet | B2in Display | +| --- | --- | --- | +| **Zweck** | Store-Information | Marken-/Content-Anzeige | +| **Branding** | CABINET | B2in | +| **Content-Typ** | Text/Daten (statisch) | Video/Bild (Playlist) | +| **Interaktion** | Keine | Keine | +| **Update-Frequenz** | Bei Bedarf (1–2x/Tag) | Playlist rotiert dauerhaft | +| **Polling-Intervall** | 30 Sekunden | 60 Sekunden | +| **Hardware** | Android-Tablet 8–10" | TV/Monitor 43–55" + Media Player | +| **CMS-Komplexität** | 12 einfache Felder | Globale Settings + Playlist-Repeater | diff --git a/public/_cabinet/_docs/infotablet.md b/public/_cabinet/_docs/infotablet.md new file mode 100644 index 0000000..37f34aa --- /dev/null +++ b/public/_cabinet/_docs/infotablet.md @@ -0,0 +1,200 @@ +# CABINET Info-Tablet – Entwicklungskonzept + +**Status:** Entwicklungsvorlage | Februar 2026 + +--- + +## 1. Projektübersicht + +### 1.1 Zweck + +Ein kleines Tablet im Schaufenster neben dem Eingang des CABINET Stores in Bielefeld zeigt Passanten und Kunden auf einen Blick die wichtigsten Store-Informationen: aktueller Öffnungsstatus, Öffnungszeiten, Sonderhinweise und den nächsten freien Beratungstermin. + +### 1.2 Technischer Ansatz + +Die Anzeige wird als responsive Webapp unter einer Subdomain (`cabinet.b2in.eu/info <- live / [`https://b2in.test/_cabinet](https://b2in.test/_cabinet)/info ← testserver) bereitgestellt. Das Tablet greift im Vollbild-Kioskmodus über den Browser darauf zu. Die Inhalte werden über das bestehende B2in-Backend (CMS) gepflegt. +Ordner /info +### 1.3 Branding + +Ausschließlich CABINET-Branding. Kein B2in-Logo, kein Hinweis auf andere Marken. Das Tablet ist ein reines Store-Informationstool. + +--- + +## 2. Content-Struktur & Layout + +Das Display ist in fünf feste Bereiche unterteilt, von oben nach unten: + +| # | Bereich | Inhalt | Verhalten | +| --- | --- | --- | --- | +| 1 | **Header** | CABINET Logo (links) + aktuelles Datum mit Wochentag (rechts). | Datum aktualisiert sich automatisch um Mitternacht. | +| 2 | **Status-Banner** | Drei Zustände: **Geöffnet** (grün): „Wir sind geöffnet" + „Heute bis [Uhrzeit] für Sie da." · **Hinweis** (gelb): Frei definierbare Headline + Subtext. Z. B. „Heute erst ab 11:00 Uhr" · **Geschlossen** (rot): Frei definierbare Headline + Subtext. Z. B. „Betriebsurlaub bis 03.03." | Farbe, Icon und Text wechseln je nach Status. CMS-gesteuert. | +| 3 | **Öffnungszeiten** | Wochenansicht Mo–So. Heutiger Tag visuell hervorgehoben (fetter Text, leichter Hintergrund). Wenn Sonderöffnung aktiv: heutige Zeit wird in Orange angezeigt. | Heutiger Tag wird automatisch erkannt. Sonderöffnung überschreibt Standard-Zeit für heute. | +| 4 | **Termin-Karte** | Dunkle Karte mit Kalender-Icon. Zeigt: „Nächster freier Termin" + Datum/Uhrzeit + „Beratung – ca. 45 Min." | CMS-gesteuert. Optional: Kann später an Kalendersystem angebunden werden. | +| 5 | **Footer** | Telefonnummer (links) + E-Mail (links) + QR-Code (rechts). QR-Code führt zur Website oder Terminbuchung. | Statisch. QR-Code wird einmalig generiert und als Asset hinterlegt. | + +--- + +## 3. CMS-Felder (B2in-Backend) + +Folgende Felder werden im B2in-Backend als eigener Bereich „CABINET Info-Tablet" angelegt. Alle Felder sind einfache Eingabefelder ohne komplexe Logik. + +| Feldname | Typ | Validierung | Beschreibung | +| --- | --- | --- | --- | +| `store_status` | Dropdown | `open` | `notice` | `closed` | Bestimmt Farbe und Grundtext des Status-Banners. | +| `notice_headline` | Text | Max. 40 Zeichen | Headline im Status-Banner bei Status „notice" oder „closed". Wird bei „open" ignoriert. | +| `notice_subtext` | Text | Max. 80 Zeichen | Erklärender Subtext unter der Headline. Optional. | +| `override_open_today` | Zeit (HH:MM) | Optional, Format HH:MM | Sonder-Öffnungszeit für heute. Überschreibt die Standard-Öffnungszeit in der Wochenansicht. | +| `override_close_today` | Zeit (HH:MM) | Optional, Format HH:MM | Sonder-Schlusszeit für heute. | +| `next_appointment_date` | Datum | TT.MM.JJJJ | Datum des nächsten freien Beratungstermins. | +| `next_appointment_time` | Zeit (HH:MM) | Format HH:MM | Uhrzeit des nächsten freien Termins. | +| `hours_monday` – `hours_sunday` | Text | z. B. „10:00–18:00" oder „Geschlossen" | Standard-Öffnungszeiten pro Wochentag. 7 Felder. | +| `contact_phone` | Text | Freitext | Telefonnummer im Footer. | +| `contact_email` | Text | E-Mail-Format | E-Mail-Adresse im Footer. | + +**Wichtig:** Die Felder `override_open_today` und `override_close_today` sollten sich automatisch um Mitternacht zurücksetzen (auf leer), damit die Sonderöffnung nicht versehentlich am nächsten Tag weiterläuft. + +--- + +## 4. API-Endpoint + +Das Backend stellt einen einfachen JSON-Endpoint bereit, den die Webapp abfragt. + +### 4.1 Endpoint-Struktur + +`GET /api/cabinet-tablet/status` + +**Response (JSON):** + +json + +`{ + "store_status": "notice", + "notice_headline": "Heute erst ab 11:00 Uhr", + "notice_subtext": "Wegen eines Kundentermins öffnen wir heute später.", + "override_open_today": "11:00", + "override_close_today": null, + "next_appointment": { + "date": "2026-02-27", + "time": "14:00" + }, + "hours": { + "monday": "10:00 – 18:00", + "tuesday": "10:00 – 18:00", + "wednesday": "10:00 – 18:00", + "thursday": "10:00 – 18:00", + "friday": "10:00 – 18:00", + "saturday": "10:00 – 14:00", + "sunday": "Geschlossen" + }, + "contact": { + "phone": "0521 – 123 456 0", + "email": "info@cabinet-bielefeld.de" + }, + "updated_at": "2026-02-26T09:15:00Z" +}` + +### 4.2 Update-Mechanismus (Polling) + +Die Webapp fragt den Endpoint in zwei Stufen ab: + +**1. Lightweight Check (alle 30 Sekunden):** +Die Webapp ruft einen minimalen Endpoint ab, der nur den `updated_at` Timestamp zurückgibt. + +`GET /api/cabinet-tablet/check` + +json + +`{ "updated_at": "2026-02-26T09:15:00Z" }` + +**2. Full Fetch (nur bei Änderung):** +Wenn der Timestamp sich vom lokal gespeicherten unterscheidet, wird der komplette Status-Endpoint abgerufen und die Anzeige aktualisiert – kein Page-Reload, nur DOM-Update per JavaScript. + +**Warum Polling statt WebSockets?** Für ein einzelnes Tablet, das auf ein CMS reagiert, das vielleicht 1–2x am Tag geändert wird, ist Polling robuster und einfacher zu warten. WebSockets wären Overkill und eine zusätzliche Fehlerquelle. + +--- + +## 5. Technische Stabilität & Kiosk-Betrieb + +### 5.1 Kiosk-App (Android) + +**Empfehlung: Fully Kiosk Browser** (ca. 7 € einmalig pro Gerät, Industriestandard für Digital Signage). + +Funktionen, die wir nutzen: + +- Vollbild-/Kioskmodus: Kein Zugriff auf Android-UI, Statusbar ausgeblendet +- Auto-Restart bei Browser-Absturz: App startet sich automatisch neu und lädt die URL +- Zeitsteuerung: Tablet geht nachts (z. B. 22:00) in Standby, wacht morgens (z. B. 07:00) auf +- Remote-Management: Über Fully Cloud kann die URL und Einstellungen remote geändert werden +- Bildschirm-Timeout: Screen bleibt dauerhaft an während der definierten Betriebszeit +- Motion Detection (optional): Bildschirm wird heller wenn jemand davor steht + +### 5.2 Webapp-seitige Stabilität + +Zusätzlich zur Kiosk-App bauen wir folgende Sicherheiten direkt in die Webapp ein: + +| Maßnahme | Beschreibung | +| --- | --- | +| **Auto-Reload** | Kompletter Page-Reload alle 6 Stunden (z. B. um 03:00, 09:00, 15:00, 21:00). Räumt Speicher auf und verhindert Memory Leaks durch langlebige Browser-Sessions. | +| **Offline-Fallback** | Wenn die API nicht erreichbar ist, zeigt die Webapp den letzten bekannten Stand an + dezenten Hinweis „Stand: [Zeitpunkt]". Kein Fehlerbildschirm, der Passanten verwirrt. | +| **Connection-Recovery** | Wenn das Polling 3x hintereinander fehlschlägt, wartet die Webapp 5 Minuten, dann versucht sie es erneut. Nach 30 Minuten ohne Verbindung: automatischer Page-Reload. | +| **Lokaler Cache** | Die letzte API-Response wird im localStorage gespeichert. Bei Neustart (z. B. nach Browser-Crash) wird sofort der Cache angezeigt, während im Hintergrund frische Daten geladen werden. | +| **Keine Interaktion** | Die Webapp hat keine klickbaren Elemente (außer dem QR-Code, der ohnehin nur visuell ist). Kein Scrollen, kein Touch-Event. Verhindert versehentliche Bedienung. | +| **Automatische Datumsaktualisierung** | Um Mitternacht: Neuen Wochentag setzen, heutigen Tag in der Öffnungszeiten-Liste aktualisieren, Sonderöffnungszeiten zurücksetzen. | + +--- + +## 6. Frontend-Spezifikation + +### 6.1 Technologie + +- Einfache statische HTML/CSS/JS-Seite (kein Framework notwendig) +- Responsive für Tablet-Größen hochkannt (ca. 1.600 x 2.456** Pixel **, 256 PPI**) +- Keine externen Abhängigkeiten außer einer Google-Fonts-Einbindung (Fallback auf System-Fonts) +- JavaScript Vanilla – kein React, Vue o. Ä. nötig + +### 6.3 Animations & Transitions + +Wenn sich der Status ändert (z. B. von „open" zu „notice"), soll der Übergang sanft per CSS-Transition (300ms ease) erfolgen – kein harter Wechsel. Gleiches gilt für Änderungen an Öffnungszeiten oder Termin. + +--- + +## 7. Hardware-Empfehlung + +| Komponente | Empfehlung | +| --- | --- | +| **Tablet** | Android-Tablet, 8–10 Zoll, min. 2 GB RAM. Muss nicht high-end sein – es zeigt nur eine Webseite. HUAWEI MatePad T | +| **Kiosk-App** | Fully Kiosk Browser (ca. 7 € Lizenz). | +| **Halterung** | Wandhalterung oder Standfuß mit Diebstahlschutz. Hochkant montiert. | +| **Stromversorgung** | Dauerhaft am Strom (Ladekabel mit Kabelkanal). Akku-Ladung auf 80 % begrenzen (Fully Kiosk unterstützt das), um Akku-Verschleiß zu minimieren. | +| **WLAN** | Stabiles Store-WLAN. Empfehlung: Festes WLAN-Profil im Tablet hinterlegen, Auto-Reconnect aktivieren. | + +--- + +## 8. Umsetzungs-Checkliste + +| # | Aufgabe | Verantwortlich | Status | +| --- | --- | --- | --- | +| 1 | CMS-Felder im B2in-Backend anlegen (Abschnitt 3) | Backend-Entwicklung | Offen | +| 2 | API-Endpoint implementieren (Abschnitt 4) | Backend-Entwicklung | Offen | +| 3 | Frontend-Webapp bauen (Abschnitt 2 + 6) | Frontend-Entwicklung | Offen | +| 4 | Polling-Mechanismus + Stabilität implementieren (Abschnitt 4.2 + 5.2) | Frontend-Entwicklung | Offen | +| 5 | Subdomain einrichten + Deployment | DevOps | Offen | +| 6 | Tablet beschaffen + Fully Kiosk einrichten (Abschnitt 7) | Marcel / Hardware | Offen | +| 7 | QR-Code generieren + als Asset hinterlegen | Design | Offen | +| 8 | End-to-End-Test: CMS-Änderung → Tablet zeigt Update | QA | Offen | +| 9 | Installation im Store + Feintuning Helligkeit/Position | Marcel + Technik | Offen | + +--- + +## Anhang: Zusammenfassung der Auto-Logiken + +Folgende Dinge passieren automatisch, ohne dass Marcel etwas pflegen muss: + +- Datum und Wochentag im Header aktualisieren sich um Mitternacht +- Heutiger Tag in der Öffnungszeitenliste wird automatisch hervorgehoben +- Sonderöffnungszeiten (override-Felder) setzen sich um Mitternacht zurück +- Bei Status „open": Banner-Text generiert sich automatisch aus der heutigen Schlusszeit +- Polling läuft im Hintergrund (alle 30 Sek.), DOM-Updates ohne Flackern +- Auto-Reload alle 6 Stunden für Speicher-Hygiene +- Offline-Fallback: Letzter Stand wird angezeigt, kein leerer Bildschirm +- Fully Kiosk: Auto-Restart bei Crash, Standby-Zeiten, Bildschirm-Management diff --git a/public/_cabinet/index copy.html b/public/_cabinet/_old/index copy.html similarity index 100% rename from public/_cabinet/index copy.html rename to public/_cabinet/_old/index copy.html diff --git a/public/_cabinet/index-dynamic.html b/public/_cabinet/_old/index-dynamic.html similarity index 100% rename from public/_cabinet/index-dynamic.html rename to public/_cabinet/_old/index-dynamic.html diff --git a/public/_cabinet/index-static-backup.html b/public/_cabinet/_old/index-static-backup.html similarity index 100% rename from public/_cabinet/index-static-backup.html rename to public/_cabinet/_old/index-static-backup.html diff --git a/public/_cabinet/index_1.html b/public/_cabinet/_old/index_1.html similarity index 100% rename from public/_cabinet/index_1.html rename to public/_cabinet/_old/index_1.html diff --git a/public/_cabinet/index_2.html b/public/_cabinet/_old/index_2.html similarity index 100% rename from public/_cabinet/index_2.html rename to public/_cabinet/_old/index_2.html diff --git a/public/_cabinet/assets/334716_medium.mp4 b/public/_cabinet/assets/334716_medium.mp4 new file mode 100644 index 0000000..6f2a907 Binary files /dev/null and b/public/_cabinet/assets/334716_medium.mp4 differ diff --git a/public/_cabinet/assets/48504-454713939_medium.mp4 b/public/_cabinet/assets/48504-454713939_medium.mp4 new file mode 100644 index 0000000..d984892 Binary files /dev/null and b/public/_cabinet/assets/48504-454713939_medium.mp4 differ diff --git a/public/_cabinet/assets/b2in-logo-positive.svg b/public/_cabinet/assets/b2in-logo-positive.svg new file mode 100644 index 0000000..58aae48 --- /dev/null +++ b/public/_cabinet/assets/b2in-logo-positive.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/_cabinet/assets/cabinet_logo.png b/public/_cabinet/assets/cabinet_logo.png new file mode 100644 index 0000000..d60b10e Binary files /dev/null and b/public/_cabinet/assets/cabinet_logo.png differ diff --git a/public/_cabinet/assets/csm_cabinet_c7258a627c.avif b/public/_cabinet/assets/csm_cabinet_c7258a627c.avif new file mode 100644 index 0000000..a3942a8 Binary files /dev/null and b/public/_cabinet/assets/csm_cabinet_c7258a627c.avif differ diff --git a/public/_cabinet/b2in/b2in-styles.css b/public/_cabinet/b2in/b2in-styles.css new file mode 100644 index 0000000..438d469 --- /dev/null +++ b/public/_cabinet/b2in/b2in-styles.css @@ -0,0 +1,499 @@ +/** + * B2in Schaufenster-Display – Styles + * 9:16 Portrait, 43–55 Zoll, B2in-Branding + * + * Eigenständiges Design – NICHT cabinet-base.css importiert. + * B2in hat ein eigenes Farbschema und Branding. + * + * Themes: data-theme="dark" (default) | data-theme="light" + */ + +:root { + /* B2in Brand Colors */ + --b2in-blue: #20a0da; + --b2in-dark: #2b3f51; + --b2in-blue-glow: rgba(32, 160, 218, 0.15); + + /* Surface Colors (Dark – default) */ + --bg: #0a0a0a; + --bg-raised: #111111; + --bg-card: #161616; + --fg: #ffffff; + --fg-muted: rgba(255, 255, 255, 0.55); + --fg-subtle: rgba(255, 255, 255, 0.35); + --line: rgba(255, 255, 255, 0.08); + + /* Typography */ + --font-main: 'IBM Plex Sans', ui-sans-serif, system-ui, -apple-system, sans-serif; + + /* Spacing */ + --safe-area: 48px; + --header-height: 100px; + --footer-height: 120px; + --text-area-height: 160px; + + /* Transitions */ + --transition-fast: 300ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-medium: 600ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 800ms cubic-bezier(0.4, 0, 0.2, 1); +} + +/* ======================================== + LIGHT THEME + ======================================== */ + +[data-theme="light"] { + --bg: #f7f8fa; + --bg-raised: #ffffff; + --bg-card: #eef0f3; + --fg: #2b3f51; + --fg-muted: rgba(43, 63, 81, 0.6); + --fg-subtle: rgba(43, 63, 81, 0.4); + --line: rgba(43, 63, 81, 0.1); + --b2in-blue-glow: rgba(32, 160, 218, 0.1); +} + +/* ======================================== + RESET + ======================================== */ + +*, *::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: var(--bg); + color: var(--fg); + display: flex; + align-items: center; + justify-content: center; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +/* ======================================== + 9:16 DISPLAY FRAME + ======================================== */ + +.display-frame { + width: 100%; + height: 100%; + max-width: 1080px; + max-height: 1920px; + aspect-ratio: 9 / 16; + display: flex; + flex-direction: column; + background: var(--bg); + position: relative; + overflow: hidden; +} + +@media (min-aspect-ratio: 9/16) { + .display-frame { + width: auto; + height: 100vh; + } +} + +@media (max-aspect-ratio: 9/16) { + .display-frame { + width: 100vw; + height: auto; + } +} + +/* ======================================== + HEADER + ======================================== */ + +.display-header { + height: var(--header-height); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--safe-area); + position: relative; + z-index: 10; + flex-shrink: 0; +} + +.display-header::after { + content: ''; + position: absolute; + bottom: 0; + left: var(--safe-area); + right: var(--safe-area); + height: 1px; + background: var(--line); +} + +.header-logo img { + height: 48px; + width: auto; +} + + + +.header-claim { + font-size: 16px; + font-weight: 300; + color: var(--fg-muted); + letter-spacing: 0.04em; + text-align: right; +} + +/* ======================================== + MEDIA AREA (Video / Bild) + ======================================== */ + +.media-area { + flex: 1; + position: relative; + overflow: hidden; +} + +/* Gradient Overlays: dunkel → transparent → dunkel */ +.media-area::before, +.media-area::after { + content: ''; + position: absolute; + left: 0; + right: 0; + height: 25%; + z-index: 2; + pointer-events: none; +} + +.media-area::before { + top: 0; + background: linear-gradient(180deg, var(--bg) 0%, transparent 100%); +} + +.media-area::after { + bottom: 0; + background: linear-gradient(0deg, var(--bg) 0%, transparent 100%); +} + +/* Light theme: Softer gradients */ +[data-theme="light"] .media-area::before { + height: 20%; + background: linear-gradient(180deg, var(--bg) 0%, transparent 100%); +} + +[data-theme="light"] .media-area::after { + height: 20%; + background: linear-gradient(0deg, var(--bg) 0%, transparent 100%); +} + +/* Media Container für Crossfade (zwei Layer übereinander) */ +.media-layer { + position: absolute; + inset: 0; + z-index: 1; +} + +.media-layer video, +.media-layer img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +/* Transitions */ +.media-layer { + opacity: 0; + transition: opacity var(--transition-slow); +} + +.media-layer.active { + opacity: 1; +} + +/* Ken-Burns Effekt für Bilder */ +.media-layer.ken-burns img { + animation: kenBurns 12s ease-in-out forwards; + transform-origin: center center; +} + +@keyframes kenBurns { + from { + transform: scale(1); + } + to { + transform: scale(1.06); + } +} + +/* Slide transition */ +.media-layer.slide-out { + animation: slideOut var(--transition-slow) forwards; +} + +.media-layer.slide-in { + animation: slideIn var(--transition-slow) forwards; +} + +@keyframes slideOut { + from { transform: translateX(0); opacity: 1; } + to { transform: translateX(-100%); opacity: 0; } +} + +@keyframes slideIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +/* ======================================== + TEXT AREA (Headline + Subline) + ======================================== */ + +.text-area { + height: var(--text-area-height); + display: flex; + flex-direction: column; + justify-content: center; + padding: 0 var(--safe-area); + position: relative; + z-index: 10; + flex-shrink: 0; +} + +.text-headline { + font-size: 36px; + font-weight: 600; + letter-spacing: -0.01em; + line-height: 1.15; + color: var(--fg); + margin-bottom: 8px; + opacity: 1; + transition: opacity 400ms ease; +} + +.text-subline { + font-size: 22px; + font-weight: 300; + line-height: 1.35; + color: var(--fg-muted); + opacity: 1; + transition: opacity 400ms ease; +} + +.text-area.fade-out .text-headline, +.text-area.fade-out .text-subline { + opacity: 0; +} + +/* ======================================== + FOOTER + ======================================== */ + +.display-footer { + height: var(--footer-height); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--safe-area); + position: relative; + z-index: 10; + flex-shrink: 0; +} + +.display-footer::before { + content: ''; + position: absolute; + top: 0; + left: var(--safe-area); + right: var(--safe-area); + height: 1px; + background: var(--line); +} + +.footer-person { + display: flex; + flex-direction: column; + gap: 2px; +} + +.footer-name { + font-size: 16px; + font-weight: 400; + color: var(--fg); + letter-spacing: 0.01em; +} + +.footer-url { + font-size: 20px; + font-weight: 600; + color: var(--b2in-blue); + letter-spacing: 0.02em; +} + +.footer-qr { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +.footer-qr img { + width: 72px; + height: 72px; + border-radius: 6px; + background: #ffffff; + padding: 4px; +} + +[data-theme="light"] .footer-qr img { + border: 1px solid var(--line); +} + +.footer-qr-label { + font-size: 11px; + font-weight: 500; + color: var(--fg-subtle); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +/* ======================================== + PROGRESS INDICATOR + ======================================== */ + +.progress-track { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: rgba(255, 255, 255, 0.05); + z-index: 20; +} + +[data-theme="light"] .progress-track { + background: rgba(0, 0, 0, 0.06); +} + +.progress-fill { + height: 100%; + width: 0%; + background: var(--b2in-blue); + transition: none; +} + +.progress-fill.animate { + transition: width linear; +} + +/* ======================================== + STANDBY MODE + ======================================== */ + +.standby-overlay { + position: absolute; + inset: 0; + background: var(--bg); + display: flex; + align-items: center; + justify-content: center; + z-index: 50; + opacity: 0; + pointer-events: none; + transition: opacity var(--transition-medium); +} + +.standby-overlay.active { + opacity: 1; + pointer-events: auto; +} + +.standby-logo img { + height: 80px; + width: auto; + opacity: 1; +} + + + +/* ======================================== + ERROR OVERLAY + ======================================== */ + +.error-overlay { + position: absolute; + inset: 0; + background: var(--bg); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 24px; + z-index: 40; + opacity: 0; + pointer-events: none; + transition: opacity var(--transition-medium); +} + +.error-overlay.active { + opacity: 1; + pointer-events: auto; +} + +.error-overlay img { + height: 60px; + width: auto; + opacity: 0.3; +} + +.error-overlay span { + font-size: 14px; + color: var(--fg-subtle); + font-weight: 400; +} + +/* ======================================== + OFFLINE BADGE + ======================================== */ + +.offline-badge { + position: fixed; + bottom: 16px; + left: 50%; + transform: translateX(-50%) translateY(4px); + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + color: var(--fg-muted); + font-size: 12px; + font-weight: 500; + padding: 6px 18px; + border-radius: 100px; + border: 1px solid var(--line); + opacity: 0; + transition: opacity 400ms ease, transform 400ms ease; + pointer-events: none; + z-index: 100; +} + +.offline-badge.visible { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +[data-theme="light"] .offline-badge { + background: rgba(0, 0, 0, 0.06); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + color: var(--fg-muted); + border-color: var(--line); +} diff --git a/public/_cabinet/b2in/index.html b/public/_cabinet/b2in/index.html new file mode 100644 index 0000000..8f97ab6 --- /dev/null +++ b/public/_cabinet/b2in/index.html @@ -0,0 +1,819 @@ + + + + + + B2in – Display + + + + + + +
+ + +
+ +
Connecting Design & Property
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+ + +
+ + +
+
+
+ + +
+ +
+ + +
+ B2in + +
+
+ + +
Offline – Stand:
+ + + + diff --git a/public/_cabinet/default.json b/public/_cabinet/default.json new file mode 100644 index 0000000..ea72988 --- /dev/null +++ b/public/_cabinet/default.json @@ -0,0 +1,47 @@ +{ + "videoPlaylist": [ + { + "src": "assets\/fruehjahr_2024.mp4", + "position": 40 + }, + { + "src": "assets\/fruehjahr_2025.mp4", + "position": 10 + }, + { + "src": "assets\/herbst_2025.mp4", + "position": 25 + }, + { + "src": "assets\/herbst_2024.mp4", + "position": 25 + } + ], + "footerContent": [ + { + "headline": "Beratung & Termin", + "subline": "Jetzt Termin vereinbaren.", + "url": "https:\/\/b2in.eu\/_cabinet\/go.php?z=c59kjb" + }, + { + "headline": "Beratung vor Ort", + "subline": "Einfach reinkommen.", + "url": "https:\/\/b2in.eu\/_cabinet\/go.php?z=3bi07j" + }, + { + "headline": "Pinterest", + "subline": "Inspirationen entdecken.", + "url": "https:\/\/b2in.eu\/_cabinet\/go.php?z=1cl8so" + }, + { + "headline": "Instagram", + "subline": "T\u00e4gliche Einblicke & Design.", + "url": "https:\/\/b2in.eu\/_cabinet\/go.php?z=hz1tx2" + }, + { + "headline": "Facebook", + "subline": "News, Aktionen & Community.", + "url": "https:\/\/b2in.eu\/_cabinet\/go.php?z=almb7t" + } + ] +} diff --git a/public/_cabinet/display/index.html b/public/_cabinet/display/index.html new file mode 100644 index 0000000..2f756cb --- /dev/null +++ b/public/_cabinet/display/index.html @@ -0,0 +1,1423 @@ + + + + + + Cabinet – Display Player + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ + +
+
+
Display wird geladen...
+
+
+ + + + + + + diff --git a/public/_cabinet/index.html b/public/_cabinet/index.html index 14d3e18..89e2395 100644 --- a/public/_cabinet/index.html +++ b/public/_cabinet/index.html @@ -312,7 +312,7 @@
- QR Code + QR Code
@@ -329,8 +329,8 @@ // 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'; + // Lokale JSON-Datei statt API (Übergangsweise bis neues Display-Modul freigegeben) + const CONFIG_URL = 'default.json'; /* ============================================== KONFIGURATION LADEN @@ -338,9 +338,9 @@ async function loadConfiguration() { try { - window.displayLogger?.log('Lade Konfiguration...', { url: API_URL }); + window.displayLogger?.log('Lade Konfiguration...', { url: CONFIG_URL }); - const response = await fetch(API_URL); + const response = await fetch(CONFIG_URL); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } @@ -416,6 +416,7 @@ let lastVideoTime = 0; let videoStuckCount = 0; let consecutiveErrors = 0; + let isTransitioning = false; const MAX_CONSECUTIVE_ERRORS = 3; const VIDEO_START_TIMEOUT = 10000; // 10 Sekunden const VIDEO_WATCHDOG_INTERVAL = 5000; // Alle 5 Sekunden prüfen @@ -424,13 +425,12 @@ videoElement.setAttribute('preload', 'metadata'); // Nur Metadaten vorladen, nicht ganzes Video function cleanupVideo() { - // Wichtig: Stoppt Video und gibt Speicher frei + isTransitioning = true; try { videoElement.pause(); videoElement.removeAttribute('src'); - videoElement.load(); // Triggert Garbage Collection des alten Videos + videoElement.load(); - // Timeouts clearen if (videoStartTimeout) { clearTimeout(videoStartTimeout); videoStartTimeout = null; @@ -445,54 +445,68 @@ function playNextVideo() { if (videoPlaylist.length === 0) return; - // Watchdog zurücksetzen + // Verhindert gleichzeitige, sich überschneidende Übergänge + if (isTransitioning) { + window.displayLogger?.log('playNextVideo übersprungen - Übergang läuft noch'); + return; + } + 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 + // Index VOR dem asynchronen Cleanup weiterschalten + currentVideoIndex++; + if (currentVideoIndex >= videoPlaylist.length) { + currentVideoIndex = 0; + window.displayLogger?.log('Playlist-Loop abgeschlossen, starte von vorne'); + } + + // Altes Video stoppen und Speicher freigeben (setzt isTransitioning = true) cleanupVideo(); - // Kleiner Delay um Cleanup abzuschließen + // Delay damit Cleanup vollständig abgeschlossen ist setTimeout(() => { + isTransitioning = false; + try { - // Neues Video laden videoElement.src = videoSrc; - if(footerContentLength !== 0 && video.position !== undefined) { + 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 + consecutiveErrors = 0; - // Start-Timeout clearen if (videoStartTimeout) { clearTimeout(videoStartTimeout); videoStartTimeout = null; } }) .catch(e => { - console.log("Autoplay blocked/failed", e); + // AbortError = play() wurde absichtlich durch pause()/load() unterbrochen. + // Das ist kein echter Fehler, sondern erwartetes Verhalten beim Cleanup. + if (e.name === 'AbortError') { + window.displayLogger?.log(`Video play absichtlich unterbrochen: ${video.src}`); + return; + } + window.displayLogger?.error(`Video play failed: ${video.src}`, { error: e.message }); @@ -502,10 +516,8 @@ 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'); } }); @@ -516,15 +528,7 @@ }); 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'); - } + }, 200); // 200ms für zuverlässiges Cleanup } function skipToNextVideo(reason) { @@ -557,8 +561,8 @@ src: videoElement.src }); - // Wenn 2x hintereinander stuck → Recovery - if (videoStuckCount >= 2) { + // Wenn 2x hintereinander stuck → Recovery (nur wenn kein Übergang läuft) + if (videoStuckCount >= 2 && !isTransitioning) { window.displayLogger?.error('Video definitiv stuck - starte nächstes', { currentTime: currentTime, src: videoElement.src @@ -586,6 +590,9 @@ }); videoElement.addEventListener('error', (e) => { + // Fehler während eines laufenden Übergangs ignorieren (z.B. nach removeAttribute('src')) + if (isTransitioning) return; + const error = videoElement.error; const errorCode = error?.code; const errorMessage = { @@ -602,7 +609,6 @@ mediaError: errorMessage }); - // Bei Fehler → Nächstes Video consecutiveErrors++; skipToNextVideo(`error_${errorMessage}`); }); @@ -814,6 +820,31 @@ // Beim Laden der Seite initialisieren initialize(); + // Datei-Versions-Check: Erkennt ob index.html auf dem Server geändert wurde + let fileVersion = null; + + async function checkFileVersion() { + try { + const response = await fetch(window.location.href, { + method: 'HEAD', + cache: 'no-store', + }); + const version = response.headers.get('ETag') || response.headers.get('Last-Modified'); + + if (fileVersion === null) { + fileVersion = version; + } else if (version && version !== fileVersion) { + window.displayLogger?.log('Datei geändert – Seite wird neu geladen'); + location.reload(); + } + } catch (e) { + // Offline – ignorieren + } + } + + checkFileVersion(); // Initiale Version merken + setInterval(checkFileVersion, 2 * 60 * 1000); // Alle 2 Minuten prüfen + // Auto-Reload alle 5 Minuten, um neue Inhalte zu laden setInterval(async () => { console.log('Prüfe auf neue Konfiguration...'); diff --git a/public/_cabinet/info/index.html b/public/_cabinet/info/index.html new file mode 100644 index 0000000..0ca815d --- /dev/null +++ b/public/_cabinet/info/index.html @@ -0,0 +1,612 @@ + + + + + + CABINET – Store Info + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+
+
+
Laden...
+
+
+
+ + +
+
Öffnungszeiten
+
+
+ Montag + +
+
+ Dienstag + +
+
+ Mittwoch + +
+
+ Donnerstag + +
+
+ Freitag + +
+
+ Samstag + +
+
+ Sonntag + +
+
+
+ + +
+
📅
+
+
Nächster freier Termin
+
+
Beratung – ca. 45 Min.
+
+
+ + +
+
+
+ + + + +
+
+ + + + +
+
+ +
+
+ + +
Stand:
+ + + + diff --git a/public/_cabinet/info/info-styles.css b/public/_cabinet/info/info-styles.css new file mode 100644 index 0000000..2be10cd --- /dev/null +++ b/public/_cabinet/info/info-styles.css @@ -0,0 +1,444 @@ +/** + * CABINET Info-Tablet - Styles + * Modern, refined design for 8-10" Android tablet in portrait mode + */ + +@import '../shared/cabinet-base.css'; + +/* ======================================== + OVERRIDE: Tablet-sized display tokens + ======================================== */ + +:root { + --safe-area: 32px; + --radius: 10px; + --radius-sm: 6px; + + /* Status palette */ + --status-open: #16a34a; + --status-open-bg: linear-gradient(135deg, #f0fdf4, #ecfdf5); + --status-open-border: #d1fae5; + --status-closed: #ca8a04; + --status-closed-bg: linear-gradient(135deg, #fefce8, #fef9c3); + --status-closed-border: #fde047; + --status-notice: #ea580c; + --status-notice-bg: linear-gradient(135deg, #fff7ed, #ffedd5); + --status-notice-border: #fed7aa; + --status-warning: #dc2626; + --status-warning-bg: linear-gradient(135deg, #fef2f2, #fee2e2); + --status-warning-border: #fecaca; + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.04); + --shadow-inner: inset 0 1px 2px rgba(0, 0, 0, 0.03); + + /* Refined background */ + --surface: #fafafa; + --surface-raised: #ffffff; +} + +body { + background: var(--surface); + padding: 0; +} + +/* ======================================== + SCREEN (full viewport) + ======================================== */ + +.screen { + width: 100vw; + height: 100vh; + background: var(--surface); + display: flex; + flex-direction: column; + padding: var(--safe-area); + gap: 22px; + overflow: hidden; +} + +/* ======================================== + HEADER + ======================================== */ + +.header { + min-height: auto; + padding-bottom: 16px; + align-items: center; + border-bottom: none; +} + +.brand-logo { + height: 64px; +} + +.header-date { + text-align: right; + line-height: 1.25; +} + +.header-weekday { + font-size: var(--text-lg); + font-weight: 600; + color: var(--fg-strong); + letter-spacing: -0.01em; +} + +.header-datestring { + font-size: var(--text-sm); + color: var(--muted); + font-weight: 400; + margin-top: 1px; +} + +.header-updated { + font-size: var(--text-xs); + color: var(--muted-light); + font-weight: 400; + margin-top: 4px; +} + +/* ======================================== + STATUS BANNER + ======================================== */ + +.status-banner { + border-radius: var(--radius); + padding: 28px 24px 32px; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 12px; + transition: all 400ms cubic-bezier(0.4, 0, 0.2, 1); + border: 1px solid transparent; + border-top-width: 5px; + box-shadow: var(--shadow-lg); + position: relative; + overflow: hidden; +} + +.status-banner[data-status="open"] { + background: var(--status-open-bg); + border-color: var(--status-open-border); + border-top-color: var(--status-open); +} + +.status-banner[data-status="closed"] { + background: var(--status-closed-bg); + border-color: var(--status-closed-border); + border-top-color: var(--status-closed); +} + +.status-banner[data-status="notice"] { + background: var(--status-notice-bg); + border-color: var(--status-notice-border); + border-top-color: var(--status-notice); +} + +.status-banner[data-status="warning"] { + background: var(--status-warning-bg); + border-color: var(--status-warning-border); + border-top-color: var(--status-warning); +} + +.status-icon { + width: 64px; + height: 64px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + font-weight: 700; + flex-shrink: 0; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + margin-bottom: 2px; +} + +.status-banner[data-status="open"] .status-icon { + background: var(--status-open); + color: #fff; +} + +.status-banner[data-status="closed"] .status-icon { + background: var(--status-closed); + color: #fff; +} + +.status-banner[data-status="notice"] .status-icon { + background: var(--status-notice); + color: #fff; +} + +.status-banner[data-status="warning"] .status-icon { + background: var(--status-warning); + color: #fff; +} + +.status-text { + display: flex; + flex-direction: column; + gap: 6px; +} + +.status-headline { + font-size: var(--text-3xl); + font-weight: 700; + color: var(--fg-strong); + letter-spacing: -0.02em; + line-height: 1.15; +} + +.status-subtext { + font-size: var(--text-lg); + color: var(--muted); + font-weight: 400; + line-height: 1.35; +} + +/* ======================================== + OPENING HOURS + ======================================== */ + +.hours-section { + flex: 1; + display: flex; + flex-direction: column; + background: var(--surface-raised); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 20px 22px; + overflow: hidden; + box-shadow: var(--shadow-sm); +} + +.hours-title { + font-size: 13px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.1em; + font-weight: 600; + margin-bottom: 6px; + padding-bottom: 8px; + border-bottom: 1px solid var(--line); +} + +.hours-list { + display: flex; + flex-direction: column; + gap: 0; + flex: 1; +} + +.hours-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 9px 12px; + border-radius: var(--radius-sm); + transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.hours-day { + font-size: var(--text-base); + font-weight: 400; + color: var(--fg); +} + +.hours-time { + font-size: var(--text-base); + font-weight: 400; + color: var(--fg); + font-feature-settings: 'tnum' 1; + font-variant-numeric: tabular-nums; +} + +/* Today highlight */ +.hours-row.today { + background: rgba(0, 159, 227, 0.06); + box-shadow: inset 3px 0 0 var(--accent); +} + +.hours-row.today .hours-day { + font-weight: 600; + color: var(--accent); +} + +.hours-row.today .hours-time { + font-weight: 600; + color: var(--accent); +} + +/* Override styling */ +.hours-row.today.override .hours-time { + color: #ea580c; + font-weight: 700; +} + +/* ======================================== + APPOINTMENT CARD + ======================================== */ + +.appointment-card { + background: linear-gradient(135deg, #111111, #1a1a1a); + border-radius: var(--radius); + padding: 22px 24px; + color: #ffffff; + display: flex; + align-items: center; + gap: 18px; + transition: all 400ms cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: var(--shadow-lg); + position: relative; + overflow: hidden; +} + +/* Subtle accent glow */ +.appointment-card::after { + content: ''; + position: absolute; + top: -50%; + right: -20%; + width: 200px; + height: 200px; + background: radial-gradient(circle, rgba(0, 159, 227, 0.08), transparent 70%); + pointer-events: none; +} + +.appointment-card.hidden { + display: none; +} + +.appointment-icon { + width: 44px; + height: 44px; + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.06); + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + flex-shrink: 0; +} + +.appointment-text { + flex: 1; + position: relative; + z-index: 1; +} + +.appointment-label { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + text-transform: uppercase; + letter-spacing: 0.1em; + font-weight: 600; + margin-bottom: 5px; +} + +.appointment-date { + font-size: var(--text-xl); + font-weight: 600; + letter-spacing: -0.01em; + line-height: 1.2; + margin-bottom: 3px; +} + +.appointment-note { + font-size: var(--text-sm); + color: rgba(255, 255, 255, 0.4); + font-weight: 400; +} + +/* ======================================== + FOOTER + ======================================== */ + +.info-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding-top: 14px; + border-top: 1px solid var(--line); +} + +.contact-block { + display: flex; + flex-direction: column; + gap: 6px; +} + +.contact-item { + display: flex; + align-items: center; + gap: 10px; + font-size: var(--text-base); + color: var(--fg); + font-weight: 400; +} + +.contact-icon { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--muted); +} + +.footer-qr { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.footer-qr img { + width: 102px; + height: 102px; + border-radius: var(--radius-sm); + box-shadow: var(--shadow-sm); + border: 1px solid var(--line); +} + +.footer-qr-label { + font-size: 11px; + color: var(--muted-light); + text-align: center; + font-weight: 500; + letter-spacing: 0.04em; +} + +/* ======================================== + OFFLINE INDICATOR + ======================================== */ + +.offline-badge { + position: fixed; + bottom: 12px; + left: 50%; + transform: translateX(-50%) translateY(4px); + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + color: #fff; + font-size: 12px; + font-weight: 500; + padding: 6px 16px; + border-radius: 100px; + opacity: 0; + transition: opacity 400ms ease, transform 400ms ease; + pointer-events: none; + z-index: 100; +} + +.offline-badge.visible { + opacity: 1; + transform: translateX(-50%) translateY(0); +} diff --git a/public/_cabinet/offers/player.html b/public/_cabinet/offers/index.html similarity index 100% rename from public/_cabinet/offers/player.html rename to public/_cabinet/offers/index.html diff --git a/public/_cabinet/offers/shared-styles.css b/public/_cabinet/offers/shared-styles.css index 40d7420..a422a6e 100644 --- a/public/_cabinet/offers/shared-styles.css +++ b/public/_cabinet/offers/shared-styles.css @@ -1,70 +1,11 @@ /** - * CABINET Display - Shared Styles - * Format: 9:16 (1080×1920px) - * Safe-Area: 64px + * CABINET Display - Offer Slides Styles + * Format: 9:16 (1080x1920px) + * + * Imports shared CABINET base tokens and adds offer-specific layout. */ -: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; -} +@import '../shared/cabinet-base.css'; /* ======================================== SCREEN CONTAINER (9:16 Frame) @@ -110,47 +51,6 @@ body { 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 ======================================== */ @@ -228,15 +128,6 @@ body { 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; @@ -344,100 +235,6 @@ body { 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) ======================================== */ @@ -468,26 +265,3 @@ body { } } -/* ======================================== - UTILITY CLASSES - ======================================== */ - -.text-accent { - color: var(--accent); -} - -.text-muted { - color: var(--muted); -} - -.font-bold { - font-weight: 700; -} - -.font-semibold { - font-weight: 600; -} - -.mt-auto { - margin-top: auto; -} diff --git a/public/_cabinet/offers/slide-0-intro.html b/public/_cabinet/offers/slide-0-intro.html index ecaade0..6acc60b 100644 --- a/public/_cabinet/offers/slide-0-intro.html +++ b/public/_cabinet/offers/slide-0-intro.html @@ -34,7 +34,7 @@
- + Bielefeld
diff --git a/public/_cabinet/offers/slide-1-goya-hero.html b/public/_cabinet/offers/slide-1-goya-hero.html index 19cc816..0c5afbd 100644 --- a/public/_cabinet/offers/slide-1-goya-hero.html +++ b/public/_cabinet/offers/slide-1-goya-hero.html @@ -42,7 +42,7 @@
- +
diff --git a/public/_cabinet/offers/slide-2-goya-details.html b/public/_cabinet/offers/slide-2-goya-details.html index b0c96c0..1e1cef2 100644 --- a/public/_cabinet/offers/slide-2-goya-details.html +++ b/public/_cabinet/offers/slide-2-goya-details.html @@ -61,7 +61,7 @@
- +
diff --git a/public/_cabinet/offers/slide-3-tando.html b/public/_cabinet/offers/slide-3-tando.html index bc52434..e59e404 100644 --- a/public/_cabinet/offers/slide-3-tando.html +++ b/public/_cabinet/offers/slide-3-tando.html @@ -45,7 +45,7 @@
- +
diff --git a/public/_cabinet/shared/cabinet-base.css b/public/_cabinet/shared/cabinet-base.css new file mode 100644 index 0000000..096a7e5 --- /dev/null +++ b/public/_cabinet/shared/cabinet-base.css @@ -0,0 +1,240 @@ +/** + * CABINET Display - Base Design Tokens & Shared Components + * Shared across all CABINET display projects (offers, info-tablet, etc.) + * + * Import this file in project-specific stylesheets: + * @import '../shared/cabinet-base.css'; + */ + +: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: 38px; + --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 (default for 9:16 displays, override in project CSS) */ + --max-width: 1080px; + --max-height: 1920px; +} + +/* ======================================== + RESET + ======================================== */ + +*, *::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; +} + +/* ======================================== + 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; +} + +/* ======================================== + 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 { + 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; +} + +/* ======================================== + SHARED TEXT STYLES + ======================================== */ + +.eyebrow { + font-size: var(--text-sm); + color: var(--muted); + letter-spacing: 0.14em; + text-transform: uppercase; + margin-bottom: 14px; + font-weight: 500; +} + +.disclaimer { + font-size: var(--text-xs); + color: var(--muted-light); + margin-top: 12px; + font-weight: 400; + letter-spacing: 0.01em; +} + +/* ======================================== + UTILITY CLASSES + ======================================== */ + +.text-accent { + color: var(--accent); +} + +.text-muted { + color: var(--muted); +} + +.font-bold { + font-weight: 700; +} + +.font-semibold { + font-weight: 600; +} + +.mt-auto { + margin-top: auto; +} diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png deleted file mode 100644 index c2efef6..0000000 Binary files a/public/apple-touch-icon.png and /dev/null differ diff --git a/public/favicon.ico b/public/favicon.ico index 236fadb..c949cad 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..c71b16b Binary files /dev/null and b/public/favicon.png differ diff --git a/public/favicon.svg b/public/favicon.svg index e4e710e..6e4b578 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1,3 +1,25 @@ - - - + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/favicon/android-icon-144x144.png b/public/favicon/android-icon-144x144.png new file mode 100644 index 0000000..4d59c54 Binary files /dev/null and b/public/favicon/android-icon-144x144.png differ diff --git a/public/favicon/android-icon-192x192.png b/public/favicon/android-icon-192x192.png new file mode 100644 index 0000000..338c769 Binary files /dev/null and b/public/favicon/android-icon-192x192.png differ diff --git a/public/favicon/android-icon-36x36.png b/public/favicon/android-icon-36x36.png new file mode 100644 index 0000000..3117128 Binary files /dev/null and b/public/favicon/android-icon-36x36.png differ diff --git a/public/favicon/android-icon-48x48.png b/public/favicon/android-icon-48x48.png new file mode 100644 index 0000000..9fe5826 Binary files /dev/null and b/public/favicon/android-icon-48x48.png differ diff --git a/public/favicon/android-icon-72x72.png b/public/favicon/android-icon-72x72.png new file mode 100644 index 0000000..94f262e Binary files /dev/null and b/public/favicon/android-icon-72x72.png differ diff --git a/public/favicon/android-icon-96x96.png b/public/favicon/android-icon-96x96.png new file mode 100644 index 0000000..f3afd5d Binary files /dev/null and b/public/favicon/android-icon-96x96.png differ diff --git a/public/favicon/apple-icon-114x114.png b/public/favicon/apple-icon-114x114.png new file mode 100644 index 0000000..0f5c644 Binary files /dev/null and b/public/favicon/apple-icon-114x114.png differ diff --git a/public/favicon/apple-icon-120x120.png b/public/favicon/apple-icon-120x120.png new file mode 100644 index 0000000..e1e9b4d Binary files /dev/null and b/public/favicon/apple-icon-120x120.png differ diff --git a/public/favicon/apple-icon-144x144.png b/public/favicon/apple-icon-144x144.png new file mode 100644 index 0000000..f81b3bf Binary files /dev/null and b/public/favicon/apple-icon-144x144.png differ diff --git a/public/favicon/apple-icon-152x152.png b/public/favicon/apple-icon-152x152.png new file mode 100644 index 0000000..b3ce717 Binary files /dev/null and b/public/favicon/apple-icon-152x152.png differ diff --git a/public/favicon/apple-icon-180x180.png b/public/favicon/apple-icon-180x180.png new file mode 100644 index 0000000..acf55a8 Binary files /dev/null and b/public/favicon/apple-icon-180x180.png differ diff --git a/public/favicon/apple-icon-57x57.png b/public/favicon/apple-icon-57x57.png new file mode 100644 index 0000000..bd69e12 Binary files /dev/null and b/public/favicon/apple-icon-57x57.png differ diff --git a/public/favicon/apple-icon-60x60.png b/public/favicon/apple-icon-60x60.png new file mode 100644 index 0000000..8ab2dba Binary files /dev/null and b/public/favicon/apple-icon-60x60.png differ diff --git a/public/favicon/apple-icon-72x72.png b/public/favicon/apple-icon-72x72.png new file mode 100644 index 0000000..94f262e Binary files /dev/null and b/public/favicon/apple-icon-72x72.png differ diff --git a/public/favicon/apple-icon-76x76.png b/public/favicon/apple-icon-76x76.png new file mode 100644 index 0000000..f79c946 Binary files /dev/null and b/public/favicon/apple-icon-76x76.png differ diff --git a/public/favicon/apple-icon-precomposed.png b/public/favicon/apple-icon-precomposed.png new file mode 100644 index 0000000..3b0de1a Binary files /dev/null and b/public/favicon/apple-icon-precomposed.png differ diff --git a/public/favicon/apple-icon.png b/public/favicon/apple-icon.png new file mode 100644 index 0000000..3b0de1a Binary files /dev/null and b/public/favicon/apple-icon.png differ diff --git a/public/favicon/browserconfig.xml b/public/favicon/browserconfig.xml new file mode 100644 index 0000000..c554148 --- /dev/null +++ b/public/favicon/browserconfig.xml @@ -0,0 +1,2 @@ + +#ffffff \ No newline at end of file diff --git a/public/favicon/favicon-16x16.png b/public/favicon/favicon-16x16.png new file mode 100644 index 0000000..6f20373 Binary files /dev/null and b/public/favicon/favicon-16x16.png differ diff --git a/public/favicon/favicon-32x32.png b/public/favicon/favicon-32x32.png new file mode 100644 index 0000000..7bc484a Binary files /dev/null and b/public/favicon/favicon-32x32.png differ diff --git a/public/favicon/favicon-96x96.png b/public/favicon/favicon-96x96.png new file mode 100644 index 0000000..2140f24 Binary files /dev/null and b/public/favicon/favicon-96x96.png differ diff --git a/public/favicon/favicon.ico b/public/favicon/favicon.ico new file mode 100644 index 0000000..c949cad Binary files /dev/null and b/public/favicon/favicon.ico differ diff --git a/public/favicon/manifest.json b/public/favicon/manifest.json new file mode 100644 index 0000000..013d4a6 --- /dev/null +++ b/public/favicon/manifest.json @@ -0,0 +1,41 @@ +{ + "name": "App", + "icons": [ + { + "src": "\/android-icon-36x36.png", + "sizes": "36x36", + "type": "image\/png", + "density": "0.75" + }, + { + "src": "\/android-icon-48x48.png", + "sizes": "48x48", + "type": "image\/png", + "density": "1.0" + }, + { + "src": "\/android-icon-72x72.png", + "sizes": "72x72", + "type": "image\/png", + "density": "1.5" + }, + { + "src": "\/android-icon-96x96.png", + "sizes": "96x96", + "type": "image\/png", + "density": "2.0" + }, + { + "src": "\/android-icon-144x144.png", + "sizes": "144x144", + "type": "image\/png", + "density": "3.0" + }, + { + "src": "\/android-icon-192x192.png", + "sizes": "192x192", + "type": "image\/png", + "density": "4.0" + } + ] +} \ No newline at end of file diff --git a/public/favicon/ms-icon-144x144.png b/public/favicon/ms-icon-144x144.png new file mode 100644 index 0000000..f81b3bf Binary files /dev/null and b/public/favicon/ms-icon-144x144.png differ diff --git a/public/favicon/ms-icon-150x150.png b/public/favicon/ms-icon-150x150.png new file mode 100644 index 0000000..5720d21 Binary files /dev/null and b/public/favicon/ms-icon-150x150.png differ diff --git a/public/favicon/ms-icon-310x310.png b/public/favicon/ms-icon-310x310.png new file mode 100644 index 0000000..4ba0182 Binary files /dev/null and b/public/favicon/ms-icon-310x310.png differ diff --git a/public/favicon/ms-icon-70x70.png b/public/favicon/ms-icon-70x70.png new file mode 100644 index 0000000..a242a18 Binary files /dev/null and b/public/favicon/ms-icon-70x70.png differ diff --git a/public/fonts/eb-garamond/_eb-garamond.css b/public/fonts/eb-garamond/_eb-garamond.css new file mode 100644 index 0000000..6289d77 --- /dev/null +++ b/public/fonts/eb-garamond/_eb-garamond.css @@ -0,0 +1,90 @@ +/* eb-garamond-regular - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'EB Garamond'; + font-style: normal; + font-weight: 400; + src: url('../fonts/eb-garamond-v32-latin-regular.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/eb-garamond-v32-latin-regular.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* eb-garamond-italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'EB Garamond'; + font-style: italic; + font-weight: 400; + src: url('../fonts/eb-garamond-v32-latin-italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/eb-garamond-v32-latin-italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* eb-garamond-500 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'EB Garamond'; + font-style: normal; + font-weight: 500; + src: url('../fonts/eb-garamond-v32-latin-500.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/eb-garamond-v32-latin-500.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* eb-garamond-500italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'EB Garamond'; + font-style: italic; + font-weight: 500; + src: url('../fonts/eb-garamond-v32-latin-500italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/eb-garamond-v32-latin-500italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* eb-garamond-600 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'EB Garamond'; + font-style: normal; + font-weight: 600; + src: url('../fonts/eb-garamond-v32-latin-600.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/eb-garamond-v32-latin-600.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* eb-garamond-600italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'EB Garamond'; + font-style: italic; + font-weight: 600; + src: url('../fonts/eb-garamond-v32-latin-600italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/eb-garamond-v32-latin-600italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* eb-garamond-700 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'EB Garamond'; + font-style: normal; + font-weight: 700; + src: url('../fonts/eb-garamond-v32-latin-700.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/eb-garamond-v32-latin-700.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* eb-garamond-700italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'EB Garamond'; + font-style: italic; + font-weight: 700; + src: url('../fonts/eb-garamond-v32-latin-700italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/eb-garamond-v32-latin-700italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* eb-garamond-800 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'EB Garamond'; + font-style: normal; + font-weight: 800; + src: url('../fonts/eb-garamond-v32-latin-800.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/eb-garamond-v32-latin-800.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* eb-garamond-800italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'EB Garamond'; + font-style: italic; + font-weight: 800; + src: url('../fonts/eb-garamond-v32-latin-800italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/eb-garamond-v32-latin-800italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} \ No newline at end of file diff --git a/public/fonts/eb-garamond/eb-garamond-v32-latin-500.ttf b/public/fonts/eb-garamond/eb-garamond-v32-latin-500.ttf new file mode 100644 index 0000000..aa199d2 Binary files /dev/null and b/public/fonts/eb-garamond/eb-garamond-v32-latin-500.ttf differ diff --git a/public/fonts/eb-garamond/eb-garamond-v32-latin-500.woff2 b/public/fonts/eb-garamond/eb-garamond-v32-latin-500.woff2 new file mode 100644 index 0000000..70a5def Binary files /dev/null and b/public/fonts/eb-garamond/eb-garamond-v32-latin-500.woff2 differ diff --git a/public/fonts/eb-garamond/eb-garamond-v32-latin-500italic.ttf b/public/fonts/eb-garamond/eb-garamond-v32-latin-500italic.ttf new file mode 100644 index 0000000..f4c49c0 Binary files /dev/null and b/public/fonts/eb-garamond/eb-garamond-v32-latin-500italic.ttf differ diff --git a/public/fonts/eb-garamond/eb-garamond-v32-latin-500italic.woff2 b/public/fonts/eb-garamond/eb-garamond-v32-latin-500italic.woff2 new file mode 100644 index 0000000..425ccd5 Binary files /dev/null and b/public/fonts/eb-garamond/eb-garamond-v32-latin-500italic.woff2 differ diff --git a/public/fonts/eb-garamond/eb-garamond-v32-latin-600.ttf b/public/fonts/eb-garamond/eb-garamond-v32-latin-600.ttf new file mode 100644 index 0000000..8bc9956 Binary files /dev/null and b/public/fonts/eb-garamond/eb-garamond-v32-latin-600.ttf differ diff --git a/public/fonts/eb-garamond/eb-garamond-v32-latin-600.woff2 b/public/fonts/eb-garamond/eb-garamond-v32-latin-600.woff2 new file mode 100644 index 0000000..6e358f1 Binary files /dev/null and b/public/fonts/eb-garamond/eb-garamond-v32-latin-600.woff2 differ diff --git a/public/fonts/eb-garamond/eb-garamond-v32-latin-600italic.ttf b/public/fonts/eb-garamond/eb-garamond-v32-latin-600italic.ttf new file mode 100644 index 0000000..2fa09a2 Binary files /dev/null and b/public/fonts/eb-garamond/eb-garamond-v32-latin-600italic.ttf differ diff --git a/public/fonts/eb-garamond/eb-garamond-v32-latin-600italic.woff2 b/public/fonts/eb-garamond/eb-garamond-v32-latin-600italic.woff2 new file mode 100644 index 0000000..33cf7e8 Binary files /dev/null and b/public/fonts/eb-garamond/eb-garamond-v32-latin-600italic.woff2 differ diff --git a/public/fonts/eb-garamond/eb-garamond-v32-latin-700.ttf b/public/fonts/eb-garamond/eb-garamond-v32-latin-700.ttf new file mode 100644 index 0000000..f7aa26c Binary files /dev/null and b/public/fonts/eb-garamond/eb-garamond-v32-latin-700.ttf differ diff --git a/public/fonts/eb-garamond/eb-garamond-v32-latin-700.woff2 b/public/fonts/eb-garamond/eb-garamond-v32-latin-700.woff2 new file mode 100644 index 0000000..85d5209 Binary files /dev/null and b/public/fonts/eb-garamond/eb-garamond-v32-latin-700.woff2 differ diff --git a/public/fonts/eb-garamond/eb-garamond-v32-latin-700italic.ttf b/public/fonts/eb-garamond/eb-garamond-v32-latin-700italic.ttf new file mode 100644 index 0000000..5fe2c55 Binary files /dev/null and b/public/fonts/eb-garamond/eb-garamond-v32-latin-700italic.ttf differ diff --git a/public/fonts/eb-garamond/eb-garamond-v32-latin-700italic.woff2 b/public/fonts/eb-garamond/eb-garamond-v32-latin-700italic.woff2 new file mode 100644 index 0000000..57308eb Binary files /dev/null and b/public/fonts/eb-garamond/eb-garamond-v32-latin-700italic.woff2 differ diff --git a/public/fonts/eb-garamond/eb-garamond-v32-latin-800.ttf b/public/fonts/eb-garamond/eb-garamond-v32-latin-800.ttf new file mode 100644 index 0000000..c5c44ed Binary files /dev/null and b/public/fonts/eb-garamond/eb-garamond-v32-latin-800.ttf differ diff --git a/public/fonts/eb-garamond/eb-garamond-v32-latin-800.woff2 b/public/fonts/eb-garamond/eb-garamond-v32-latin-800.woff2 new file mode 100644 index 0000000..317368a Binary files /dev/null and b/public/fonts/eb-garamond/eb-garamond-v32-latin-800.woff2 differ diff --git a/public/fonts/eb-garamond/eb-garamond-v32-latin-800italic.ttf b/public/fonts/eb-garamond/eb-garamond-v32-latin-800italic.ttf new file mode 100644 index 0000000..d542be1 Binary files /dev/null and b/public/fonts/eb-garamond/eb-garamond-v32-latin-800italic.ttf differ diff --git a/public/fonts/eb-garamond/eb-garamond-v32-latin-800italic.woff2 b/public/fonts/eb-garamond/eb-garamond-v32-latin-800italic.woff2 new file mode 100644 index 0000000..45405c9 Binary files /dev/null and b/public/fonts/eb-garamond/eb-garamond-v32-latin-800italic.woff2 differ diff --git a/public/fonts/eb-garamond/eb-garamond-v32-latin-italic.ttf b/public/fonts/eb-garamond/eb-garamond-v32-latin-italic.ttf new file mode 100644 index 0000000..9cda96b Binary files /dev/null and b/public/fonts/eb-garamond/eb-garamond-v32-latin-italic.ttf differ diff --git a/public/fonts/eb-garamond/eb-garamond-v32-latin-italic.woff2 b/public/fonts/eb-garamond/eb-garamond-v32-latin-italic.woff2 new file mode 100644 index 0000000..8d8f8aa Binary files /dev/null and b/public/fonts/eb-garamond/eb-garamond-v32-latin-italic.woff2 differ diff --git a/public/fonts/eb-garamond/eb-garamond-v32-latin-regular.ttf b/public/fonts/eb-garamond/eb-garamond-v32-latin-regular.ttf new file mode 100644 index 0000000..ff4d431 Binary files /dev/null and b/public/fonts/eb-garamond/eb-garamond-v32-latin-regular.ttf differ diff --git a/public/fonts/eb-garamond/eb-garamond-v32-latin-regular.woff2 b/public/fonts/eb-garamond/eb-garamond-v32-latin-regular.woff2 new file mode 100644 index 0000000..ffed311 Binary files /dev/null and b/public/fonts/eb-garamond/eb-garamond-v32-latin-regular.woff2 differ diff --git a/public/fonts/ephesis/_ephesis.css b/public/fonts/ephesis/_ephesis.css new file mode 100644 index 0000000..c805e12 --- /dev/null +++ b/public/fonts/ephesis/_ephesis.css @@ -0,0 +1,9 @@ +/* ephesis-regular - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Ephesis'; + font-style: normal; + font-weight: 400; + src: url('../fonts/ephesis-v11-latin-regular.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/ephesis-v11-latin-regular.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} \ No newline at end of file diff --git a/public/fonts/ephesis/ephesis-v11-latin-regular.ttf b/public/fonts/ephesis/ephesis-v11-latin-regular.ttf new file mode 100644 index 0000000..022d731 Binary files /dev/null and b/public/fonts/ephesis/ephesis-v11-latin-regular.ttf differ diff --git a/public/fonts/ephesis/ephesis-v11-latin-regular.woff2 b/public/fonts/ephesis/ephesis-v11-latin-regular.woff2 new file mode 100644 index 0000000..30ebdd2 Binary files /dev/null and b/public/fonts/ephesis/ephesis-v11-latin-regular.woff2 differ diff --git a/public/fonts/ibm-plex-sans/_ibm-plex-sans.css b/public/fonts/ibm-plex-sans/_ibm-plex-sans.css new file mode 100644 index 0000000..46c7774 --- /dev/null +++ b/public/fonts/ibm-plex-sans/_ibm-plex-sans.css @@ -0,0 +1,126 @@ +/* ibm-plex-sans-100 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 100; + src: url('../fonts/ibm-plex-sans-v23-latin-100.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/ibm-plex-sans-v23-latin-100.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* ibm-plex-sans-100italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'IBM Plex Sans'; + font-style: italic; + font-weight: 100; + src: url('../fonts/ibm-plex-sans-v23-latin-100italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/ibm-plex-sans-v23-latin-100italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* ibm-plex-sans-200 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 200; + src: url('../fonts/ibm-plex-sans-v23-latin-200.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/ibm-plex-sans-v23-latin-200.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* ibm-plex-sans-200italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'IBM Plex Sans'; + font-style: italic; + font-weight: 200; + src: url('../fonts/ibm-plex-sans-v23-latin-200italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/ibm-plex-sans-v23-latin-200italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* ibm-plex-sans-300 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 300; + src: url('../fonts/ibm-plex-sans-v23-latin-300.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/ibm-plex-sans-v23-latin-300.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* ibm-plex-sans-300italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'IBM Plex Sans'; + font-style: italic; + font-weight: 300; + src: url('../fonts/ibm-plex-sans-v23-latin-300italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/ibm-plex-sans-v23-latin-300italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* ibm-plex-sans-regular - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 400; + src: url('../fonts/ibm-plex-sans-v23-latin-regular.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/ibm-plex-sans-v23-latin-regular.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* ibm-plex-sans-italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'IBM Plex Sans'; + font-style: italic; + font-weight: 400; + src: url('../fonts/ibm-plex-sans-v23-latin-italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/ibm-plex-sans-v23-latin-italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* ibm-plex-sans-500 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 500; + src: url('../fonts/ibm-plex-sans-v23-latin-500.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/ibm-plex-sans-v23-latin-500.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* ibm-plex-sans-500italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'IBM Plex Sans'; + font-style: italic; + font-weight: 500; + src: url('../fonts/ibm-plex-sans-v23-latin-500italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/ibm-plex-sans-v23-latin-500italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* ibm-plex-sans-600 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 600; + src: url('../fonts/ibm-plex-sans-v23-latin-600.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/ibm-plex-sans-v23-latin-600.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* ibm-plex-sans-600italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'IBM Plex Sans'; + font-style: italic; + font-weight: 600; + src: url('../fonts/ibm-plex-sans-v23-latin-600italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/ibm-plex-sans-v23-latin-600italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* ibm-plex-sans-700 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 700; + src: url('../fonts/ibm-plex-sans-v23-latin-700.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/ibm-plex-sans-v23-latin-700.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* ibm-plex-sans-700italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'IBM Plex Sans'; + font-style: italic; + font-weight: 700; + src: url('../fonts/ibm-plex-sans-v23-latin-700italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/ibm-plex-sans-v23-latin-700italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} \ No newline at end of file diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-100.ttf b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-100.ttf new file mode 100644 index 0000000..3c2ee5a Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-100.ttf differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-100.woff2 b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-100.woff2 new file mode 100644 index 0000000..a6d6228 Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-100.woff2 differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-100italic.ttf b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-100italic.ttf new file mode 100644 index 0000000..e07f3ee Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-100italic.ttf differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-100italic.woff2 b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-100italic.woff2 new file mode 100644 index 0000000..aab19cc Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-100italic.woff2 differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-200.ttf b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-200.ttf new file mode 100644 index 0000000..6e83ee3 Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-200.ttf differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-200.woff2 b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-200.woff2 new file mode 100644 index 0000000..e33c92c Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-200.woff2 differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-200italic.ttf b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-200italic.ttf new file mode 100644 index 0000000..fbff77c Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-200italic.ttf differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-200italic.woff2 b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-200italic.woff2 new file mode 100644 index 0000000..2f8c704 Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-200italic.woff2 differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-300.ttf b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-300.ttf new file mode 100644 index 0000000..94c2714 Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-300.ttf differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-300.woff2 b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-300.woff2 new file mode 100644 index 0000000..d0a42af Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-300.woff2 differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-300italic.ttf b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-300italic.ttf new file mode 100644 index 0000000..ad51a93 Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-300italic.ttf differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-300italic.woff2 b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-300italic.woff2 new file mode 100644 index 0000000..bf4bb42 Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-300italic.woff2 differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-500.ttf b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-500.ttf new file mode 100644 index 0000000..3e58d5a Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-500.ttf differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-500.woff2 b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-500.woff2 new file mode 100644 index 0000000..6d5527e Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-500.woff2 differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-500italic.ttf b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-500italic.ttf new file mode 100644 index 0000000..47238d4 Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-500italic.ttf differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-500italic.woff2 b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-500italic.woff2 new file mode 100644 index 0000000..3c57765 Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-500italic.woff2 differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-600.ttf b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-600.ttf new file mode 100644 index 0000000..b9ca976 Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-600.ttf differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-600.woff2 b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-600.woff2 new file mode 100644 index 0000000..08c0d5a Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-600.woff2 differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-600italic.ttf b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-600italic.ttf new file mode 100644 index 0000000..7d67934 Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-600italic.ttf differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-600italic.woff2 b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-600italic.woff2 new file mode 100644 index 0000000..46457fb Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-600italic.woff2 differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-700.ttf b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-700.ttf new file mode 100644 index 0000000..4115207 Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-700.ttf differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-700.woff2 b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-700.woff2 new file mode 100644 index 0000000..953f94f Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-700.woff2 differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-700italic.ttf b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-700italic.ttf new file mode 100644 index 0000000..98ae51f Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-700italic.ttf differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-700italic.woff2 b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-700italic.woff2 new file mode 100644 index 0000000..fcee6a0 Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-700italic.woff2 differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-italic.ttf b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-italic.ttf new file mode 100644 index 0000000..f64320a Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-italic.ttf differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-italic.woff2 b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-italic.woff2 new file mode 100644 index 0000000..cf026fe Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-italic.woff2 differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-regular.ttf b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-regular.ttf new file mode 100644 index 0000000..2fd659c Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-regular.ttf differ diff --git a/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-regular.woff2 b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-regular.woff2 new file mode 100644 index 0000000..f0ee65d Binary files /dev/null and b/public/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-regular.woff2 differ diff --git a/public/fonts/inter/_inter.css b/public/fonts/inter/_inter.css new file mode 100644 index 0000000..38f7db4 --- /dev/null +++ b/public/fonts/inter/_inter.css @@ -0,0 +1,162 @@ +/* inter-100 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Inter'; + font-style: normal; + font-weight: 100; + src: url('../fonts/inter-v20-latin-100.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/inter-v20-latin-100.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* inter-100italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Inter'; + font-style: italic; + font-weight: 100; + src: url('../fonts/inter-v20-latin-100italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/inter-v20-latin-100italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* inter-200 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Inter'; + font-style: normal; + font-weight: 200; + src: url('../fonts/inter-v20-latin-200.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/inter-v20-latin-200.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* inter-200italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Inter'; + font-style: italic; + font-weight: 200; + src: url('../fonts/inter-v20-latin-200italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/inter-v20-latin-200italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* inter-300 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Inter'; + font-style: normal; + font-weight: 300; + src: url('../fonts/inter-v20-latin-300.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/inter-v20-latin-300.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* inter-300italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Inter'; + font-style: italic; + font-weight: 300; + src: url('../fonts/inter-v20-latin-300italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/inter-v20-latin-300italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* inter-regular - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + src: url('../fonts/inter-v20-latin-regular.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/inter-v20-latin-regular.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* inter-italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Inter'; + font-style: italic; + font-weight: 400; + src: url('../fonts/inter-v20-latin-italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/inter-v20-latin-italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* inter-500 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + src: url('../fonts/inter-v20-latin-500.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/inter-v20-latin-500.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* inter-500italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Inter'; + font-style: italic; + font-weight: 500; + src: url('../fonts/inter-v20-latin-500italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/inter-v20-latin-500italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* inter-600 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + src: url('../fonts/inter-v20-latin-600.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/inter-v20-latin-600.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* inter-600italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Inter'; + font-style: italic; + font-weight: 600; + src: url('../fonts/inter-v20-latin-600italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/inter-v20-latin-600italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* inter-700 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + src: url('../fonts/inter-v20-latin-700.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/inter-v20-latin-700.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* inter-700italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Inter'; + font-style: italic; + font-weight: 700; + src: url('../fonts/inter-v20-latin-700italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/inter-v20-latin-700italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* inter-800 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Inter'; + font-style: normal; + font-weight: 800; + src: url('../fonts/inter-v20-latin-800.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/inter-v20-latin-800.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* inter-800italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Inter'; + font-style: italic; + font-weight: 800; + src: url('../fonts/inter-v20-latin-800italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/inter-v20-latin-800italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* inter-900 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Inter'; + font-style: normal; + font-weight: 900; + src: url('../fonts/inter-v20-latin-900.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/inter-v20-latin-900.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* inter-900italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Inter'; + font-style: italic; + font-weight: 900; + src: url('../fonts/inter-v20-latin-900italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/inter-v20-latin-900italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} \ No newline at end of file diff --git a/public/fonts/inter/inter-v20-latin-100.ttf b/public/fonts/inter/inter-v20-latin-100.ttf new file mode 100644 index 0000000..5b2247e Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-100.ttf differ diff --git a/public/fonts/inter/inter-v20-latin-100.woff2 b/public/fonts/inter/inter-v20-latin-100.woff2 new file mode 100644 index 0000000..fbcfb9e Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-100.woff2 differ diff --git a/public/fonts/inter/inter-v20-latin-100italic.ttf b/public/fonts/inter/inter-v20-latin-100italic.ttf new file mode 100644 index 0000000..b970183 Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-100italic.ttf differ diff --git a/public/fonts/inter/inter-v20-latin-100italic.woff2 b/public/fonts/inter/inter-v20-latin-100italic.woff2 new file mode 100644 index 0000000..8427f11 Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-100italic.woff2 differ diff --git a/public/fonts/inter/inter-v20-latin-200.ttf b/public/fonts/inter/inter-v20-latin-200.ttf new file mode 100644 index 0000000..d63bb3d Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-200.ttf differ diff --git a/public/fonts/inter/inter-v20-latin-200.woff2 b/public/fonts/inter/inter-v20-latin-200.woff2 new file mode 100644 index 0000000..37267df Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-200.woff2 differ diff --git a/public/fonts/inter/inter-v20-latin-200italic.ttf b/public/fonts/inter/inter-v20-latin-200italic.ttf new file mode 100644 index 0000000..90b877e Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-200italic.ttf differ diff --git a/public/fonts/inter/inter-v20-latin-200italic.woff2 b/public/fonts/inter/inter-v20-latin-200italic.woff2 new file mode 100644 index 0000000..cb15f8d Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-200italic.woff2 differ diff --git a/public/fonts/inter/inter-v20-latin-300.ttf b/public/fonts/inter/inter-v20-latin-300.ttf new file mode 100644 index 0000000..4765ac6 Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-300.ttf differ diff --git a/public/fonts/inter/inter-v20-latin-300.woff2 b/public/fonts/inter/inter-v20-latin-300.woff2 new file mode 100644 index 0000000..ece952c Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-300.woff2 differ diff --git a/public/fonts/inter/inter-v20-latin-300italic.ttf b/public/fonts/inter/inter-v20-latin-300italic.ttf new file mode 100644 index 0000000..cf525ee Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-300italic.ttf differ diff --git a/public/fonts/inter/inter-v20-latin-300italic.woff2 b/public/fonts/inter/inter-v20-latin-300italic.woff2 new file mode 100644 index 0000000..dd92d3b Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-300italic.woff2 differ diff --git a/public/fonts/inter/inter-v20-latin-500.ttf b/public/fonts/inter/inter-v20-latin-500.ttf new file mode 100644 index 0000000..529dd8e Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-500.ttf differ diff --git a/public/fonts/inter/inter-v20-latin-500.woff2 b/public/fonts/inter/inter-v20-latin-500.woff2 new file mode 100644 index 0000000..54f0a59 Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-500.woff2 differ diff --git a/public/fonts/inter/inter-v20-latin-500italic.ttf b/public/fonts/inter/inter-v20-latin-500italic.ttf new file mode 100644 index 0000000..20d5901 Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-500italic.ttf differ diff --git a/public/fonts/inter/inter-v20-latin-500italic.woff2 b/public/fonts/inter/inter-v20-latin-500italic.woff2 new file mode 100644 index 0000000..f4f25da Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-500italic.woff2 differ diff --git a/public/fonts/inter/inter-v20-latin-600.ttf b/public/fonts/inter/inter-v20-latin-600.ttf new file mode 100644 index 0000000..fbd58d6 Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-600.ttf differ diff --git a/public/fonts/inter/inter-v20-latin-600.woff2 b/public/fonts/inter/inter-v20-latin-600.woff2 new file mode 100644 index 0000000..d189794 Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-600.woff2 differ diff --git a/public/fonts/inter/inter-v20-latin-600italic.ttf b/public/fonts/inter/inter-v20-latin-600italic.ttf new file mode 100644 index 0000000..9ef7d25 Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-600italic.ttf differ diff --git a/public/fonts/inter/inter-v20-latin-600italic.woff2 b/public/fonts/inter/inter-v20-latin-600italic.woff2 new file mode 100644 index 0000000..e882c78 Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-600italic.woff2 differ diff --git a/public/fonts/inter/inter-v20-latin-700.ttf b/public/fonts/inter/inter-v20-latin-700.ttf new file mode 100644 index 0000000..d75db62 Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-700.ttf differ diff --git a/public/fonts/inter/inter-v20-latin-700.woff2 b/public/fonts/inter/inter-v20-latin-700.woff2 new file mode 100644 index 0000000..a68fb10 Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-700.woff2 differ diff --git a/public/fonts/inter/inter-v20-latin-700italic.ttf b/public/fonts/inter/inter-v20-latin-700italic.ttf new file mode 100644 index 0000000..53c751e Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-700italic.ttf differ diff --git a/public/fonts/inter/inter-v20-latin-700italic.woff2 b/public/fonts/inter/inter-v20-latin-700italic.woff2 new file mode 100644 index 0000000..48b6e5b Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-700italic.woff2 differ diff --git a/public/fonts/inter/inter-v20-latin-800.ttf b/public/fonts/inter/inter-v20-latin-800.ttf new file mode 100644 index 0000000..90e9e47 Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-800.ttf differ diff --git a/public/fonts/inter/inter-v20-latin-800.woff2 b/public/fonts/inter/inter-v20-latin-800.woff2 new file mode 100644 index 0000000..74a16d4 Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-800.woff2 differ diff --git a/public/fonts/inter/inter-v20-latin-800italic.ttf b/public/fonts/inter/inter-v20-latin-800italic.ttf new file mode 100644 index 0000000..77bbb71 Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-800italic.ttf differ diff --git a/public/fonts/inter/inter-v20-latin-800italic.woff2 b/public/fonts/inter/inter-v20-latin-800italic.woff2 new file mode 100644 index 0000000..e98fa7e Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-800italic.woff2 differ diff --git a/public/fonts/inter/inter-v20-latin-900.ttf b/public/fonts/inter/inter-v20-latin-900.ttf new file mode 100644 index 0000000..ac1cc93 Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-900.ttf differ diff --git a/public/fonts/inter/inter-v20-latin-900.woff2 b/public/fonts/inter/inter-v20-latin-900.woff2 new file mode 100644 index 0000000..4db8333 Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-900.woff2 differ diff --git a/public/fonts/inter/inter-v20-latin-900italic.ttf b/public/fonts/inter/inter-v20-latin-900italic.ttf new file mode 100644 index 0000000..53e267d Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-900italic.ttf differ diff --git a/public/fonts/inter/inter-v20-latin-900italic.woff2 b/public/fonts/inter/inter-v20-latin-900italic.woff2 new file mode 100644 index 0000000..291eafc Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-900italic.woff2 differ diff --git a/public/fonts/inter/inter-v20-latin-italic.ttf b/public/fonts/inter/inter-v20-latin-italic.ttf new file mode 100644 index 0000000..a8175b5 Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-italic.ttf differ diff --git a/public/fonts/inter/inter-v20-latin-italic.woff2 b/public/fonts/inter/inter-v20-latin-italic.woff2 new file mode 100644 index 0000000..9e98286 Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-italic.woff2 differ diff --git a/public/fonts/inter/inter-v20-latin-regular.ttf b/public/fonts/inter/inter-v20-latin-regular.ttf new file mode 100644 index 0000000..c6678a6 Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-regular.ttf differ diff --git a/public/fonts/inter/inter-v20-latin-regular.woff2 b/public/fonts/inter/inter-v20-latin-regular.woff2 new file mode 100644 index 0000000..f15b025 Binary files /dev/null and b/public/fonts/inter/inter-v20-latin-regular.woff2 differ diff --git a/public/fonts/merriweather/_merriweather.css b/public/fonts/merriweather/_merriweather.css new file mode 100644 index 0000000..44c62c1 --- /dev/null +++ b/public/fonts/merriweather/_merriweather.css @@ -0,0 +1,126 @@ +/* merriweather-300 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Merriweather'; + font-style: normal; + font-weight: 300; + src: url('../fonts/merriweather-v33-latin-300.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/merriweather-v33-latin-300.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* merriweather-300italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Merriweather'; + font-style: italic; + font-weight: 300; + src: url('../fonts/merriweather-v33-latin-300italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/merriweather-v33-latin-300italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* merriweather-regular - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Merriweather'; + font-style: normal; + font-weight: 400; + src: url('../fonts/merriweather-v33-latin-regular.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/merriweather-v33-latin-regular.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* merriweather-italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Merriweather'; + font-style: italic; + font-weight: 400; + src: url('../fonts/merriweather-v33-latin-italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/merriweather-v33-latin-italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* merriweather-500 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Merriweather'; + font-style: normal; + font-weight: 500; + src: url('../fonts/merriweather-v33-latin-500.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/merriweather-v33-latin-500.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* merriweather-500italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Merriweather'; + font-style: italic; + font-weight: 500; + src: url('../fonts/merriweather-v33-latin-500italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/merriweather-v33-latin-500italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* merriweather-600 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Merriweather'; + font-style: normal; + font-weight: 600; + src: url('../fonts/merriweather-v33-latin-600.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/merriweather-v33-latin-600.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* merriweather-600italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Merriweather'; + font-style: italic; + font-weight: 600; + src: url('../fonts/merriweather-v33-latin-600italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/merriweather-v33-latin-600italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* merriweather-700 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Merriweather'; + font-style: normal; + font-weight: 700; + src: url('../fonts/merriweather-v33-latin-700.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/merriweather-v33-latin-700.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* merriweather-700italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Merriweather'; + font-style: italic; + font-weight: 700; + src: url('../fonts/merriweather-v33-latin-700italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/merriweather-v33-latin-700italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* merriweather-800 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Merriweather'; + font-style: normal; + font-weight: 800; + src: url('../fonts/merriweather-v33-latin-800.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/merriweather-v33-latin-800.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* merriweather-800italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Merriweather'; + font-style: italic; + font-weight: 800; + src: url('../fonts/merriweather-v33-latin-800italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/merriweather-v33-latin-800italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* merriweather-900 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Merriweather'; + font-style: normal; + font-weight: 900; + src: url('../fonts/merriweather-v33-latin-900.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/merriweather-v33-latin-900.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} +/* merriweather-900italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Merriweather'; + font-style: italic; + font-weight: 900; + src: url('../fonts/merriweather-v33-latin-900italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url('../fonts/merriweather-v33-latin-900italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} \ No newline at end of file diff --git a/public/fonts/merriweather/merriweather-v33-latin-300.ttf b/public/fonts/merriweather/merriweather-v33-latin-300.ttf new file mode 100644 index 0000000..b76a7c0 Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-300.ttf differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-300.woff2 b/public/fonts/merriweather/merriweather-v33-latin-300.woff2 new file mode 100644 index 0000000..cafa2fd Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-300.woff2 differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-300italic.ttf b/public/fonts/merriweather/merriweather-v33-latin-300italic.ttf new file mode 100644 index 0000000..d28e317 Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-300italic.ttf differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-300italic.woff2 b/public/fonts/merriweather/merriweather-v33-latin-300italic.woff2 new file mode 100644 index 0000000..5d870b3 Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-300italic.woff2 differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-500.ttf b/public/fonts/merriweather/merriweather-v33-latin-500.ttf new file mode 100644 index 0000000..f63d4e4 Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-500.ttf differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-500.woff2 b/public/fonts/merriweather/merriweather-v33-latin-500.woff2 new file mode 100644 index 0000000..eff5a1c Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-500.woff2 differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-500italic.ttf b/public/fonts/merriweather/merriweather-v33-latin-500italic.ttf new file mode 100644 index 0000000..32a337d Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-500italic.ttf differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-500italic.woff2 b/public/fonts/merriweather/merriweather-v33-latin-500italic.woff2 new file mode 100644 index 0000000..277cfa8 Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-500italic.woff2 differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-600.ttf b/public/fonts/merriweather/merriweather-v33-latin-600.ttf new file mode 100644 index 0000000..80fcf21 Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-600.ttf differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-600.woff2 b/public/fonts/merriweather/merriweather-v33-latin-600.woff2 new file mode 100644 index 0000000..c366145 Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-600.woff2 differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-600italic.ttf b/public/fonts/merriweather/merriweather-v33-latin-600italic.ttf new file mode 100644 index 0000000..cef60d6 Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-600italic.ttf differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-600italic.woff2 b/public/fonts/merriweather/merriweather-v33-latin-600italic.woff2 new file mode 100644 index 0000000..9e91423 Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-600italic.woff2 differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-700.ttf b/public/fonts/merriweather/merriweather-v33-latin-700.ttf new file mode 100644 index 0000000..b3fe9e7 Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-700.ttf differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-700.woff2 b/public/fonts/merriweather/merriweather-v33-latin-700.woff2 new file mode 100644 index 0000000..1c1896b Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-700.woff2 differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-700italic.ttf b/public/fonts/merriweather/merriweather-v33-latin-700italic.ttf new file mode 100644 index 0000000..913dda7 Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-700italic.ttf differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-700italic.woff2 b/public/fonts/merriweather/merriweather-v33-latin-700italic.woff2 new file mode 100644 index 0000000..6fe153e Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-700italic.woff2 differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-800.ttf b/public/fonts/merriweather/merriweather-v33-latin-800.ttf new file mode 100644 index 0000000..5414fd1 Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-800.ttf differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-800.woff2 b/public/fonts/merriweather/merriweather-v33-latin-800.woff2 new file mode 100644 index 0000000..275090e Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-800.woff2 differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-800italic.ttf b/public/fonts/merriweather/merriweather-v33-latin-800italic.ttf new file mode 100644 index 0000000..83f9de8 Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-800italic.ttf differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-800italic.woff2 b/public/fonts/merriweather/merriweather-v33-latin-800italic.woff2 new file mode 100644 index 0000000..6729bf3 Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-800italic.woff2 differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-900.ttf b/public/fonts/merriweather/merriweather-v33-latin-900.ttf new file mode 100644 index 0000000..c156f15 Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-900.ttf differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-900.woff2 b/public/fonts/merriweather/merriweather-v33-latin-900.woff2 new file mode 100644 index 0000000..b3fdf21 Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-900.woff2 differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-900italic.ttf b/public/fonts/merriweather/merriweather-v33-latin-900italic.ttf new file mode 100644 index 0000000..006f2ec Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-900italic.ttf differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-900italic.woff2 b/public/fonts/merriweather/merriweather-v33-latin-900italic.woff2 new file mode 100644 index 0000000..f4e2d9d Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-900italic.woff2 differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-italic.ttf b/public/fonts/merriweather/merriweather-v33-latin-italic.ttf new file mode 100644 index 0000000..f40f01b Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-italic.ttf differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-italic.woff2 b/public/fonts/merriweather/merriweather-v33-latin-italic.woff2 new file mode 100644 index 0000000..fdb5252 Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-italic.woff2 differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-regular.ttf b/public/fonts/merriweather/merriweather-v33-latin-regular.ttf new file mode 100644 index 0000000..3f2f2d1 Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-regular.ttf differ diff --git a/public/fonts/merriweather/merriweather-v33-latin-regular.woff2 b/public/fonts/merriweather/merriweather-v33-latin-regular.woff2 new file mode 100644 index 0000000..824753f Binary files /dev/null and b/public/fonts/merriweather/merriweather-v33-latin-regular.woff2 differ diff --git a/public/img/assets/b2a/10_large.webp b/public/img/assets/b2a/10_large.webp new file mode 100644 index 0000000..910c077 Binary files /dev/null and b/public/img/assets/b2a/10_large.webp differ diff --git a/public/img/assets/b2a/4d5b989609fa0adda725d522c044f752.webp b/public/img/assets/b2a/4d5b989609fa0adda725d522c044f752.webp new file mode 100644 index 0000000..ce56da7 Binary files /dev/null and b/public/img/assets/b2a/4d5b989609fa0adda725d522c044f752.webp differ diff --git a/public/img/assets/b2a/accommodation-1.webp b/public/img/assets/b2a/accommodation-1.webp new file mode 100644 index 0000000..fda6763 Binary files /dev/null and b/public/img/assets/b2a/accommodation-1.webp differ diff --git a/public/img/assets/b2a/accommodation-2.webp b/public/img/assets/b2a/accommodation-2.webp new file mode 100644 index 0000000..e63735d Binary files /dev/null and b/public/img/assets/b2a/accommodation-2.webp differ diff --git a/public/img/assets/b2a/accommodation-3.webp b/public/img/assets/b2a/accommodation-3.webp new file mode 100644 index 0000000..99c9ee5 Binary files /dev/null and b/public/img/assets/b2a/accommodation-3.webp differ diff --git a/public/img/assets/b2a/content-left.webp b/public/img/assets/b2a/content-left.webp new file mode 100644 index 0000000..5f57918 Binary files /dev/null and b/public/img/assets/b2a/content-left.webp differ diff --git a/public/img/assets/b2a/content-right.webp b/public/img/assets/b2a/content-right.webp new file mode 100644 index 0000000..df16055 Binary files /dev/null and b/public/img/assets/b2a/content-right.webp differ diff --git a/public/img/assets/b2a/faxon-euroline-steel-windows-img~d3114bcb0b9c4eeb_14-3023-1-66b0fb7-1.webp b/public/img/assets/b2a/faxon-euroline-steel-windows-img~d3114bcb0b9c4eeb_14-3023-1-66b0fb7-1.webp new file mode 100644 index 0000000..4579d72 Binary files /dev/null and b/public/img/assets/b2a/faxon-euroline-steel-windows-img~d3114bcb0b9c4eeb_14-3023-1-66b0fb7-1.webp differ diff --git a/public/img/assets/b2a/hero-image.webp b/public/img/assets/b2a/hero-image.webp new file mode 100644 index 0000000..bc4d84a Binary files /dev/null and b/public/img/assets/b2a/hero-image.webp differ diff --git a/public/img/assets/b2a/hero-slider-1.webp b/public/img/assets/b2a/hero-slider-1.webp new file mode 100644 index 0000000..444cee1 Binary files /dev/null and b/public/img/assets/b2a/hero-slider-1.webp differ diff --git a/public/img/assets/b2a/hero-slider-2.webp b/public/img/assets/b2a/hero-slider-2.webp new file mode 100644 index 0000000..a18d840 Binary files /dev/null and b/public/img/assets/b2a/hero-slider-2.webp differ diff --git a/public/img/assets/b2a/modern-showroom-high-point-02_1024x-1.webp b/public/img/assets/b2a/modern-showroom-high-point-02_1024x-1.webp new file mode 100644 index 0000000..b65689c Binary files /dev/null and b/public/img/assets/b2a/modern-showroom-high-point-02_1024x-1.webp differ diff --git a/public/img/assets/b2a/portrait.webp b/public/img/assets/b2a/portrait.webp new file mode 100644 index 0000000..f33d17d Binary files /dev/null and b/public/img/assets/b2a/portrait.webp differ diff --git a/public/img/assets/b2a/product-1.webp b/public/img/assets/b2a/product-1.webp new file mode 100644 index 0000000..f5e3353 Binary files /dev/null and b/public/img/assets/b2a/product-1.webp differ diff --git a/public/img/assets/b2a/product-2.webp b/public/img/assets/b2a/product-2.webp new file mode 100644 index 0000000..56378b7 Binary files /dev/null and b/public/img/assets/b2a/product-2.webp differ diff --git a/public/img/assets/b2a/product-3.webp b/public/img/assets/b2a/product-3.webp new file mode 100644 index 0000000..19115af Binary files /dev/null and b/public/img/assets/b2a/product-3.webp differ diff --git a/public/img/assets/b2a/vision.webp b/public/img/assets/b2a/vision.webp new file mode 100644 index 0000000..cce2af1 Binary files /dev/null and b/public/img/assets/b2a/vision.webp differ diff --git a/public/img/assets/b2a/world-1.webp b/public/img/assets/b2a/world-1.webp new file mode 100644 index 0000000..6d1b93a Binary files /dev/null and b/public/img/assets/b2a/world-1.webp differ diff --git a/public/img/assets/b2a/world-11.webp b/public/img/assets/b2a/world-11.webp new file mode 100644 index 0000000..0158304 Binary files /dev/null and b/public/img/assets/b2a/world-11.webp differ diff --git a/public/img/assets/b2a/world-2.webp b/public/img/assets/b2a/world-2.webp new file mode 100644 index 0000000..79a67bc Binary files /dev/null and b/public/img/assets/b2a/world-2.webp differ diff --git a/public/img/assets/b2a/world-21.webp b/public/img/assets/b2a/world-21.webp new file mode 100644 index 0000000..0158304 Binary files /dev/null and b/public/img/assets/b2a/world-21.webp differ diff --git a/public/img/assets/b2a/world-3.webp b/public/img/assets/b2a/world-3.webp new file mode 100644 index 0000000..45768f5 Binary files /dev/null and b/public/img/assets/b2a/world-3.webp differ diff --git a/public/img/assets/b2a/world-31.webp b/public/img/assets/b2a/world-31.webp new file mode 100644 index 0000000..0158304 Binary files /dev/null and b/public/img/assets/b2a/world-31.webp differ diff --git a/public/img/assets/b2in/about-hero.webp b/public/img/assets/b2in/about-hero.webp new file mode 100644 index 0000000..4d6fdc5 Binary files /dev/null and b/public/img/assets/b2in/about-hero.webp differ diff --git a/public/img/assets/b2in/accommodation-1.webp b/public/img/assets/b2in/accommodation-1.webp new file mode 100644 index 0000000..228c0a3 Binary files /dev/null and b/public/img/assets/b2in/accommodation-1.webp differ diff --git a/public/img/assets/b2in/accommodation-2.webp b/public/img/assets/b2in/accommodation-2.webp new file mode 100644 index 0000000..f7d58bb Binary files /dev/null and b/public/img/assets/b2in/accommodation-2.webp differ diff --git a/public/img/assets/b2in/b2a Kopie.webp b/public/img/assets/b2in/b2a Kopie.webp new file mode 100644 index 0000000..ca26163 Binary files /dev/null and b/public/img/assets/b2in/b2a Kopie.webp differ diff --git a/public/img/assets/b2in/b2a.webp b/public/img/assets/b2in/b2a.webp new file mode 100644 index 0000000..f549169 Binary files /dev/null and b/public/img/assets/b2in/b2a.webp differ diff --git a/public/img/assets/b2in/best-of-two-world.webp b/public/img/assets/b2in/best-of-two-world.webp new file mode 100644 index 0000000..841c188 Binary files /dev/null and b/public/img/assets/b2in/best-of-two-world.webp differ diff --git a/public/img/assets/b2in/best-of-two-worlds.jpg b/public/img/assets/b2in/best-of-two-worlds.jpg index 8815b64..5586546 100644 Binary files a/public/img/assets/b2in/best-of-two-worlds.jpg and b/public/img/assets/b2in/best-of-two-worlds.jpg differ diff --git a/public/img/assets/b2in/best-of-two-worlds.webp b/public/img/assets/b2in/best-of-two-worlds.webp new file mode 100644 index 0000000..03edb76 Binary files /dev/null and b/public/img/assets/b2in/best-of-two-worlds.webp differ diff --git a/public/img/assets/b2in/cabinet_logo.png b/public/img/assets/b2in/cabinet_logo.png new file mode 100644 index 0000000..d60b10e Binary files /dev/null and b/public/img/assets/b2in/cabinet_logo.png differ diff --git a/public/img/assets/b2in/ecosystem-hero.webp b/public/img/assets/b2in/ecosystem-hero.webp new file mode 100644 index 0000000..dab5047 Binary files /dev/null and b/public/img/assets/b2in/ecosystem-hero.webp differ diff --git a/public/img/assets/b2in/ecosystem_hub.webp b/public/img/assets/b2in/ecosystem_hub.webp new file mode 100644 index 0000000..905a47b Binary files /dev/null and b/public/img/assets/b2in/ecosystem_hub.webp differ diff --git a/public/img/assets/b2in/ecosystem_result.webp b/public/img/assets/b2in/ecosystem_result.webp new file mode 100644 index 0000000..6d7c8e0 Binary files /dev/null and b/public/img/assets/b2in/ecosystem_result.webp differ diff --git a/public/img/assets/b2in/ecosystem_start.webp b/public/img/assets/b2in/ecosystem_start.webp new file mode 100644 index 0000000..892799b Binary files /dev/null and b/public/img/assets/b2in/ecosystem_start.webp differ diff --git a/public/img/assets/b2in/end-customer-section.webp b/public/img/assets/b2in/end-customer-section.webp new file mode 100644 index 0000000..f6fdc42 Binary files /dev/null and b/public/img/assets/b2in/end-customer-section.webp differ diff --git a/public/img/assets/b2in/hero-immobilien.jpg b/public/img/assets/b2in/hero-immobilien.jpg new file mode 100644 index 0000000..f2308c9 Binary files /dev/null and b/public/img/assets/b2in/hero-immobilien.jpg differ diff --git a/public/img/assets/b2in/hero-immobilien.webp b/public/img/assets/b2in/hero-immobilien.webp new file mode 100644 index 0000000..8b87321 Binary files /dev/null and b/public/img/assets/b2in/hero-immobilien.webp differ diff --git a/public/img/assets/b2in/hero-room Kopie 2.webp b/public/img/assets/b2in/hero-room Kopie 2.webp new file mode 100644 index 0000000..2b21bc1 Binary files /dev/null and b/public/img/assets/b2in/hero-room Kopie 2.webp differ diff --git a/public/img/assets/b2in/hero-room Kopie.webp b/public/img/assets/b2in/hero-room Kopie.webp new file mode 100644 index 0000000..e5854d4 Binary files /dev/null and b/public/img/assets/b2in/hero-room Kopie.webp differ diff --git a/public/img/assets/b2in/hero-room.webp b/public/img/assets/b2in/hero-room.webp new file mode 100644 index 0000000..7c65230 Binary files /dev/null and b/public/img/assets/b2in/hero-room.webp differ diff --git a/public/img/assets/b2in/integriertes-model.webp b/public/img/assets/b2in/integriertes-model.webp new file mode 100644 index 0000000..74d0fe9 Binary files /dev/null and b/public/img/assets/b2in/integriertes-model.webp differ diff --git a/public/img/assets/b2in/magazin-.webp b/public/img/assets/b2in/magazin-.webp new file mode 100644 index 0000000..21e72e7 Binary files /dev/null and b/public/img/assets/b2in/magazin-.webp differ diff --git a/public/img/assets/b2in/magazin-1.jpg b/public/img/assets/b2in/magazin-1.jpg index 554469b..211b859 100644 Binary files a/public/img/assets/b2in/magazin-1.jpg and b/public/img/assets/b2in/magazin-1.jpg differ diff --git a/public/img/assets/b2in/magazin-1.webp b/public/img/assets/b2in/magazin-1.webp new file mode 100644 index 0000000..f307fe8 Binary files /dev/null and b/public/img/assets/b2in/magazin-1.webp differ diff --git a/public/img/assets/b2in/magazin-2.jpg b/public/img/assets/b2in/magazin-2.jpg index 2b04479..78d6838 100644 Binary files a/public/img/assets/b2in/magazin-2.jpg and b/public/img/assets/b2in/magazin-2.jpg differ diff --git a/public/img/assets/b2in/magazin-2.webp b/public/img/assets/b2in/magazin-2.webp new file mode 100644 index 0000000..8cf847c Binary files /dev/null and b/public/img/assets/b2in/magazin-2.webp differ diff --git a/public/img/assets/b2in/magazin-3.jpg b/public/img/assets/b2in/magazin-3.jpg index ec59386..61bebda 100644 Binary files a/public/img/assets/b2in/magazin-3.jpg and b/public/img/assets/b2in/magazin-3.jpg differ diff --git a/public/img/assets/b2in/magazin-3.webp b/public/img/assets/b2in/magazin-3.webp new file mode 100644 index 0000000..1f73b50 Binary files /dev/null and b/public/img/assets/b2in/magazin-3.webp differ diff --git a/public/img/assets/b2in/magazin-4.jpg b/public/img/assets/b2in/magazin-4.jpg new file mode 100644 index 0000000..c52fed3 Binary files /dev/null and b/public/img/assets/b2in/magazin-4.jpg differ diff --git a/public/img/assets/b2in/magazin-4.webp b/public/img/assets/b2in/magazin-4.webp new file mode 100644 index 0000000..47b0ae6 Binary files /dev/null and b/public/img/assets/b2in/magazin-4.webp differ diff --git a/public/img/assets/b2in/magazin-5.jpg b/public/img/assets/b2in/magazin-5.jpg new file mode 100644 index 0000000..98c15c5 Binary files /dev/null and b/public/img/assets/b2in/magazin-5.jpg differ diff --git a/public/img/assets/b2in/magazin-5.webp b/public/img/assets/b2in/magazin-5.webp new file mode 100644 index 0000000..9d7d602 Binary files /dev/null and b/public/img/assets/b2in/magazin-5.webp differ diff --git a/public/img/assets/b2in/marcel-scheibe-about.jpg b/public/img/assets/b2in/marcel-scheibe-about.jpg new file mode 100644 index 0000000..43b6c6d Binary files /dev/null and b/public/img/assets/b2in/marcel-scheibe-about.jpg differ diff --git a/public/img/assets/b2in/marcel-scheibe-about.psd b/public/img/assets/b2in/marcel-scheibe-about.psd new file mode 100644 index 0000000..c375428 Binary files /dev/null and b/public/img/assets/b2in/marcel-scheibe-about.psd differ diff --git a/public/img/assets/b2in/marcel-scheibe-about.webp b/public/img/assets/b2in/marcel-scheibe-about.webp new file mode 100644 index 0000000..a87ede1 Binary files /dev/null and b/public/img/assets/b2in/marcel-scheibe-about.webp differ diff --git a/public/img/assets/b2in/marcel-scheibe.jpg b/public/img/assets/b2in/marcel-scheibe.jpg index 6ac1ba0..6ba9c97 100644 Binary files a/public/img/assets/b2in/marcel-scheibe.jpg and b/public/img/assets/b2in/marcel-scheibe.jpg differ diff --git a/public/img/assets/b2in/marcel-scheibe.webp b/public/img/assets/b2in/marcel-scheibe.webp new file mode 100644 index 0000000..2c91d72 Binary files /dev/null and b/public/img/assets/b2in/marcel-scheibe.webp differ diff --git a/public/img/assets/b2in/partner-benefits-broker.webp b/public/img/assets/b2in/partner-benefits-broker.webp new file mode 100644 index 0000000..bc0f335 Binary files /dev/null and b/public/img/assets/b2in/partner-benefits-broker.webp differ diff --git a/public/img/assets/b2in/partner-benefits-developer.jpg b/public/img/assets/b2in/partner-benefits-developer.jpg new file mode 100644 index 0000000..f57aaf9 Binary files /dev/null and b/public/img/assets/b2in/partner-benefits-developer.jpg differ diff --git a/public/img/assets/b2in/partner-benefits-developer.webp b/public/img/assets/b2in/partner-benefits-developer.webp new file mode 100644 index 0000000..8a7e338 Binary files /dev/null and b/public/img/assets/b2in/partner-benefits-developer.webp differ diff --git a/public/img/assets/b2in/partner-benefits-retailer.webp b/public/img/assets/b2in/partner-benefits-retailer.webp new file mode 100644 index 0000000..42eccf0 Binary files /dev/null and b/public/img/assets/b2in/partner-benefits-retailer.webp differ diff --git a/public/img/assets/b2in/partner-benefits-supplier.webp b/public/img/assets/b2in/partner-benefits-supplier.webp new file mode 100644 index 0000000..bb4787b Binary files /dev/null and b/public/img/assets/b2in/partner-benefits-supplier.webp differ diff --git a/public/img/assets/b2in/partner-hero Kopie 2.webp b/public/img/assets/b2in/partner-hero Kopie 2.webp new file mode 100644 index 0000000..7dc0da5 Binary files /dev/null and b/public/img/assets/b2in/partner-hero Kopie 2.webp differ diff --git a/public/img/assets/b2in/partner-hero Kopie.webp b/public/img/assets/b2in/partner-hero Kopie.webp new file mode 100644 index 0000000..5a6099a Binary files /dev/null and b/public/img/assets/b2in/partner-hero Kopie.webp differ diff --git a/public/img/assets/b2in/partner-hero.webp b/public/img/assets/b2in/partner-hero.webp new file mode 100644 index 0000000..7dc0da5 Binary files /dev/null and b/public/img/assets/b2in/partner-hero.webp differ diff --git a/public/img/assets/b2in/room-1.webp b/public/img/assets/b2in/room-1.webp new file mode 100644 index 0000000..22aed45 Binary files /dev/null and b/public/img/assets/b2in/room-1.webp differ diff --git a/public/img/assets/b2in/room-2.webp b/public/img/assets/b2in/room-2.webp new file mode 100644 index 0000000..9b4461d Binary files /dev/null and b/public/img/assets/b2in/room-2.webp differ diff --git a/public/img/assets/b2in/room-3.webp b/public/img/assets/b2in/room-3.webp new file mode 100644 index 0000000..589ccb8 Binary files /dev/null and b/public/img/assets/b2in/room-3.webp differ diff --git a/public/img/assets/b2in/sarah-mueller.webp b/public/img/assets/b2in/sarah-mueller.webp new file mode 100644 index 0000000..6204502 Binary files /dev/null and b/public/img/assets/b2in/sarah-mueller.webp differ diff --git a/public/img/assets/b2in/stileigentum.webp b/public/img/assets/b2in/stileigentum.webp new file mode 100644 index 0000000..508d19b Binary files /dev/null and b/public/img/assets/b2in/stileigentum.webp differ diff --git a/public/img/assets/b2in/style2own.webp b/public/img/assets/b2in/style2own.webp new file mode 100644 index 0000000..34855d4 Binary files /dev/null and b/public/img/assets/b2in/style2own.webp differ diff --git a/public/img/assets/b2in/testo-1.webp b/public/img/assets/b2in/testo-1.webp new file mode 100644 index 0000000..ff7a6e6 Binary files /dev/null and b/public/img/assets/b2in/testo-1.webp differ diff --git a/public/img/assets/b2in/testo-2.webp b/public/img/assets/b2in/testo-2.webp new file mode 100644 index 0000000..e108c20 Binary files /dev/null and b/public/img/assets/b2in/testo-2.webp differ diff --git a/public/img/assets/b2in/testo-3.webp b/public/img/assets/b2in/testo-3.webp new file mode 100644 index 0000000..5e9fda6 Binary files /dev/null and b/public/img/assets/b2in/testo-3.webp differ diff --git a/public/img/assets/b2in/thomas-weber.webp b/public/img/assets/b2in/thomas-weber.webp new file mode 100644 index 0000000..95e6920 Binary files /dev/null and b/public/img/assets/b2in/thomas-weber.webp differ diff --git a/public/img/assets/expose/a1/image-1.jpeg b/public/img/assets/expose/a1/image-1.jpeg new file mode 100644 index 0000000..244754e Binary files /dev/null and b/public/img/assets/expose/a1/image-1.jpeg differ diff --git a/public/img/assets/expose/a1/image-1.webp b/public/img/assets/expose/a1/image-1.webp new file mode 100644 index 0000000..db02c22 Binary files /dev/null and b/public/img/assets/expose/a1/image-1.webp differ diff --git a/public/img/assets/expose/a1/image-2.jpeg b/public/img/assets/expose/a1/image-2.jpeg new file mode 100644 index 0000000..c4ddb2c Binary files /dev/null and b/public/img/assets/expose/a1/image-2.jpeg differ diff --git a/public/img/assets/expose/a1/image-2.webp b/public/img/assets/expose/a1/image-2.webp new file mode 100644 index 0000000..a283305 Binary files /dev/null and b/public/img/assets/expose/a1/image-2.webp differ diff --git a/public/img/assets/expose/a1/image-3.jpeg b/public/img/assets/expose/a1/image-3.jpeg new file mode 100644 index 0000000..1917b12 Binary files /dev/null and b/public/img/assets/expose/a1/image-3.jpeg differ diff --git a/public/img/assets/expose/a1/image-3.webp b/public/img/assets/expose/a1/image-3.webp new file mode 100644 index 0000000..f43e07e Binary files /dev/null and b/public/img/assets/expose/a1/image-3.webp differ diff --git a/public/img/assets/expose/a1/image-4.jpeg b/public/img/assets/expose/a1/image-4.jpeg new file mode 100644 index 0000000..d82fae4 Binary files /dev/null and b/public/img/assets/expose/a1/image-4.jpeg differ diff --git a/public/img/assets/expose/a1/image-4.webp b/public/img/assets/expose/a1/image-4.webp new file mode 100644 index 0000000..df176d4 Binary files /dev/null and b/public/img/assets/expose/a1/image-4.webp differ diff --git a/public/img/assets/expose/a1/image-5.jpeg b/public/img/assets/expose/a1/image-5.jpeg new file mode 100644 index 0000000..51cf0d1 Binary files /dev/null and b/public/img/assets/expose/a1/image-5.jpeg differ diff --git a/public/img/assets/expose/a1/image-5.webp b/public/img/assets/expose/a1/image-5.webp new file mode 100644 index 0000000..28400c2 Binary files /dev/null and b/public/img/assets/expose/a1/image-5.webp differ diff --git a/public/img/assets/expose/a1/image-6.jpeg b/public/img/assets/expose/a1/image-6.jpeg new file mode 100644 index 0000000..bf9ba0b Binary files /dev/null and b/public/img/assets/expose/a1/image-6.jpeg differ diff --git a/public/img/assets/expose/a1/image-6.webp b/public/img/assets/expose/a1/image-6.webp new file mode 100644 index 0000000..124e6f7 Binary files /dev/null and b/public/img/assets/expose/a1/image-6.webp differ diff --git a/public/img/assets/expose/a1/image-7.jpeg b/public/img/assets/expose/a1/image-7.jpeg new file mode 100644 index 0000000..f4c700e Binary files /dev/null and b/public/img/assets/expose/a1/image-7.jpeg differ diff --git a/public/img/assets/expose/a1/image-7.webp b/public/img/assets/expose/a1/image-7.webp new file mode 100644 index 0000000..9e38e3d Binary files /dev/null and b/public/img/assets/expose/a1/image-7.webp differ diff --git a/public/img/assets/stileigentum/accommodation-1.webp b/public/img/assets/stileigentum/accommodation-1.webp new file mode 100644 index 0000000..553994c Binary files /dev/null and b/public/img/assets/stileigentum/accommodation-1.webp differ diff --git a/public/img/assets/stileigentum/accommodation-2.webp b/public/img/assets/stileigentum/accommodation-2.webp new file mode 100644 index 0000000..76a77c6 Binary files /dev/null and b/public/img/assets/stileigentum/accommodation-2.webp differ diff --git a/public/img/assets/stileigentum/accommodation-3.webp b/public/img/assets/stileigentum/accommodation-3.webp new file mode 100644 index 0000000..f3a7f11 Binary files /dev/null and b/public/img/assets/stileigentum/accommodation-3.webp differ diff --git a/public/img/assets/stileigentum/content-left.webp b/public/img/assets/stileigentum/content-left.webp new file mode 100644 index 0000000..c4971e9 Binary files /dev/null and b/public/img/assets/stileigentum/content-left.webp differ diff --git a/public/img/assets/stileigentum/content-right.webp b/public/img/assets/stileigentum/content-right.webp new file mode 100644 index 0000000..a1b89a7 Binary files /dev/null and b/public/img/assets/stileigentum/content-right.webp differ diff --git a/public/img/assets/stileigentum/hero-image.webp b/public/img/assets/stileigentum/hero-image.webp new file mode 100644 index 0000000..44e6aa4 Binary files /dev/null and b/public/img/assets/stileigentum/hero-image.webp differ diff --git a/public/img/assets/stileigentum/hero-slider-1.webp b/public/img/assets/stileigentum/hero-slider-1.webp new file mode 100644 index 0000000..a175ac0 Binary files /dev/null and b/public/img/assets/stileigentum/hero-slider-1.webp differ diff --git a/public/img/assets/stileigentum/hero-slider-2.webp b/public/img/assets/stileigentum/hero-slider-2.webp new file mode 100644 index 0000000..f327331 Binary files /dev/null and b/public/img/assets/stileigentum/hero-slider-2.webp differ diff --git a/public/img/assets/stileigentum/portrait.webp b/public/img/assets/stileigentum/portrait.webp new file mode 100644 index 0000000..7101896 Binary files /dev/null and b/public/img/assets/stileigentum/portrait.webp differ diff --git a/public/img/assets/stileigentum/tile-1.webp b/public/img/assets/stileigentum/tile-1.webp new file mode 100644 index 0000000..0158304 Binary files /dev/null and b/public/img/assets/stileigentum/tile-1.webp differ diff --git a/public/img/assets/stileigentum/tile-2.webp b/public/img/assets/stileigentum/tile-2.webp new file mode 100644 index 0000000..0158304 Binary files /dev/null and b/public/img/assets/stileigentum/tile-2.webp differ diff --git a/public/img/assets/stileigentum/tile-3.webp b/public/img/assets/stileigentum/tile-3.webp new file mode 100644 index 0000000..0158304 Binary files /dev/null and b/public/img/assets/stileigentum/tile-3.webp differ diff --git a/public/img/assets/stileigentum/tile-4.webp b/public/img/assets/stileigentum/tile-4.webp new file mode 100644 index 0000000..0158304 Binary files /dev/null and b/public/img/assets/stileigentum/tile-4.webp differ diff --git a/public/img/assets/stileigentum/tile-5.webp b/public/img/assets/stileigentum/tile-5.webp new file mode 100644 index 0000000..0158304 Binary files /dev/null and b/public/img/assets/stileigentum/tile-5.webp differ diff --git a/public/img/assets/stileigentum/vision.webp b/public/img/assets/stileigentum/vision.webp new file mode 100644 index 0000000..a47139d Binary files /dev/null and b/public/img/assets/stileigentum/vision.webp differ diff --git a/public/img/assets/stileigentum/world-1.webp b/public/img/assets/stileigentum/world-1.webp new file mode 100644 index 0000000..cb6cd0d Binary files /dev/null and b/public/img/assets/stileigentum/world-1.webp differ diff --git a/public/img/assets/stileigentum/world-11.webp b/public/img/assets/stileigentum/world-11.webp new file mode 100644 index 0000000..0158304 Binary files /dev/null and b/public/img/assets/stileigentum/world-11.webp differ diff --git a/public/img/assets/stileigentum/world-2.webp b/public/img/assets/stileigentum/world-2.webp new file mode 100644 index 0000000..f0e67e5 Binary files /dev/null and b/public/img/assets/stileigentum/world-2.webp differ diff --git a/public/img/assets/stileigentum/world-21.webp b/public/img/assets/stileigentum/world-21.webp new file mode 100644 index 0000000..0158304 Binary files /dev/null and b/public/img/assets/stileigentum/world-21.webp differ diff --git a/public/img/assets/stileigentum/world-3.webp b/public/img/assets/stileigentum/world-3.webp new file mode 100644 index 0000000..e75305c Binary files /dev/null and b/public/img/assets/stileigentum/world-3.webp differ diff --git a/public/img/assets/stileigentum/world-31.webp b/public/img/assets/stileigentum/world-31.webp new file mode 100644 index 0000000..0158304 Binary files /dev/null and b/public/img/assets/stileigentum/world-31.webp differ diff --git a/public/img/assets/style2own/Style2own1.webp b/public/img/assets/style2own/Style2own1.webp new file mode 100644 index 0000000..b3b284a Binary files /dev/null and b/public/img/assets/style2own/Style2own1.webp differ diff --git a/public/img/assets/style2own/Style2own2.webp b/public/img/assets/style2own/Style2own2.webp new file mode 100644 index 0000000..841f575 Binary files /dev/null and b/public/img/assets/style2own/Style2own2.webp differ diff --git a/public/img/assets/style2own/Style2own3.webp b/public/img/assets/style2own/Style2own3.webp new file mode 100644 index 0000000..144d514 Binary files /dev/null and b/public/img/assets/style2own/Style2own3.webp differ diff --git a/public/img/assets/style2own/Style2own4.webp b/public/img/assets/style2own/Style2own4.webp new file mode 100644 index 0000000..f242d6a Binary files /dev/null and b/public/img/assets/style2own/Style2own4.webp differ diff --git a/public/img/assets/style2own/Style2own5.webp b/public/img/assets/style2own/Style2own5.webp new file mode 100644 index 0000000..4307f70 Binary files /dev/null and b/public/img/assets/style2own/Style2own5.webp differ diff --git a/public/img/assets/style2own/accommodation-1.webp b/public/img/assets/style2own/accommodation-1.webp new file mode 100644 index 0000000..fda6763 Binary files /dev/null and b/public/img/assets/style2own/accommodation-1.webp differ diff --git a/public/img/assets/style2own/accommodation-2.webp b/public/img/assets/style2own/accommodation-2.webp new file mode 100644 index 0000000..e63735d Binary files /dev/null and b/public/img/assets/style2own/accommodation-2.webp differ diff --git a/public/img/assets/style2own/accommodation-3.webp b/public/img/assets/style2own/accommodation-3.webp new file mode 100644 index 0000000..99c9ee5 Binary files /dev/null and b/public/img/assets/style2own/accommodation-3.webp differ diff --git a/public/img/assets/style2own/content-left.webp b/public/img/assets/style2own/content-left.webp new file mode 100644 index 0000000..d988002 Binary files /dev/null and b/public/img/assets/style2own/content-left.webp differ diff --git a/public/img/assets/style2own/content-right.webp b/public/img/assets/style2own/content-right.webp new file mode 100644 index 0000000..e031df3 Binary files /dev/null and b/public/img/assets/style2own/content-right.webp differ diff --git a/public/img/assets/style2own/hero-image.webp b/public/img/assets/style2own/hero-image.webp new file mode 100644 index 0000000..1e40a3c Binary files /dev/null and b/public/img/assets/style2own/hero-image.webp differ diff --git a/public/img/assets/style2own/hero-slider-1.webp b/public/img/assets/style2own/hero-slider-1.webp new file mode 100644 index 0000000..fd64ba9 Binary files /dev/null and b/public/img/assets/style2own/hero-slider-1.webp differ diff --git a/public/img/assets/style2own/hero-slider-2.webp b/public/img/assets/style2own/hero-slider-2.webp new file mode 100644 index 0000000..2015673 Binary files /dev/null and b/public/img/assets/style2own/hero-slider-2.webp differ diff --git a/public/img/assets/style2own/portrait.webp b/public/img/assets/style2own/portrait.webp new file mode 100644 index 0000000..c35cf8e Binary files /dev/null and b/public/img/assets/style2own/portrait.webp differ diff --git a/public/img/assets/style2own/tile-1.webp b/public/img/assets/style2own/tile-1.webp new file mode 100644 index 0000000..0158304 Binary files /dev/null and b/public/img/assets/style2own/tile-1.webp differ diff --git a/public/img/assets/style2own/tile-2.webp b/public/img/assets/style2own/tile-2.webp new file mode 100644 index 0000000..0158304 Binary files /dev/null and b/public/img/assets/style2own/tile-2.webp differ diff --git a/public/img/assets/style2own/tile-3.webp b/public/img/assets/style2own/tile-3.webp new file mode 100644 index 0000000..0158304 Binary files /dev/null and b/public/img/assets/style2own/tile-3.webp differ diff --git a/public/img/assets/style2own/tile-4.webp b/public/img/assets/style2own/tile-4.webp new file mode 100644 index 0000000..0158304 Binary files /dev/null and b/public/img/assets/style2own/tile-4.webp differ diff --git a/public/img/assets/style2own/tile-5.webp b/public/img/assets/style2own/tile-5.webp new file mode 100644 index 0000000..0158304 Binary files /dev/null and b/public/img/assets/style2own/tile-5.webp differ diff --git a/public/img/assets/style2own/vision.webp b/public/img/assets/style2own/vision.webp new file mode 100644 index 0000000..292fdf4 Binary files /dev/null and b/public/img/assets/style2own/vision.webp differ diff --git a/public/img/assets/style2own/world-1.webp b/public/img/assets/style2own/world-1.webp new file mode 100644 index 0000000..66b4f9d Binary files /dev/null and b/public/img/assets/style2own/world-1.webp differ diff --git a/public/img/assets/style2own/world-11.webp b/public/img/assets/style2own/world-11.webp new file mode 100644 index 0000000..0158304 Binary files /dev/null and b/public/img/assets/style2own/world-11.webp differ diff --git a/public/img/assets/style2own/world-2.webp b/public/img/assets/style2own/world-2.webp new file mode 100644 index 0000000..1eacc80 Binary files /dev/null and b/public/img/assets/style2own/world-2.webp differ diff --git a/public/img/assets/style2own/world-21.webp b/public/img/assets/style2own/world-21.webp new file mode 100644 index 0000000..0158304 Binary files /dev/null and b/public/img/assets/style2own/world-21.webp differ diff --git a/public/img/assets/style2own/world-3.webp b/public/img/assets/style2own/world-3.webp new file mode 100644 index 0000000..cca8750 Binary files /dev/null and b/public/img/assets/style2own/world-3.webp differ diff --git a/public/img/assets/style2own/world-31.webp b/public/img/assets/style2own/world-31.webp new file mode 100644 index 0000000..0158304 Binary files /dev/null and b/public/img/assets/style2own/world-31.webp differ diff --git a/resources/css/app.css b/resources/css/app.css index 368fbc5..0a88d97 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -55,6 +55,11 @@ @apply !mb-0 !leading-tight; } +/* CMS: Flux-Editor Highlight (mark) entspricht optisch „text-secondary“ im Frontend */ +[data-flux-editor] [data-slot='content'] mark { + @apply bg-transparent text-zinc-500 dark:text-zinc-400; +} + input:focus[data-flux-control], textarea:focus[data-flux-control], select:focus[data-flux-control] { diff --git a/resources/css/portal.css b/resources/css/portal.css index 41982ce..dc9c97f 100644 --- a/resources/css/portal.css +++ b/resources/css/portal.css @@ -1,3 +1,4 @@ +@import "./web/fonts.css"; @import "tailwindcss"; @import "../../vendor/livewire/flux/dist/flux.css"; @@ -70,10 +71,16 @@ select[data-flux-control] { @apply text-zinc-900 dark:text-zinc-50; } -/* Placeholder sollte deutlich heller sein */ +/* Placeholder-Farbe (light mode) */ input[data-flux-control]::placeholder, textarea[data-flux-control]::placeholder { - @apply text-zinc-400 dark:text-zinc-700; + color: var(--color-zinc-400); +} + +/* Placeholder-Farbe (dark mode) – @apply dark: funktioniert nicht in ::placeholder */ +:where(.dark, .dark *) input[data-flux-control]::placeholder, +:where(.dark, .dark *) textarea[data-flux-control]::placeholder { + color: var(--color-zinc-500); } .shadow-elegant { diff --git a/resources/css/web/exampels.css b/resources/css/web/exampels.css deleted file mode 100644 index 192290b..0000000 --- a/resources/css/web/exampels.css +++ /dev/null @@ -1,1215 +0,0 @@ -@import 'tailwindcss'; - -@source '../views'; -@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; - -@custom-variant dark (&:where(.dark, .dark *)); - -/* inno-projekt Design System - The Art of Balance -Typography: Commissioner als einzige Hausschrift für Headlines und Body -*/ - -@theme { - /* Font Family */ - --font-sans: 'Commissioner', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - --font-heading: 'Commissioner', ui-sans-serif, system-ui, sans-serif; - --font-body: 'Commissioner', ui-sans-serif, system-ui, sans-serif; - - /* Brand Colors - Premium Textures */ - --color-background: #F8F9FA; /* Soft paper-like background */ - --color-foreground: #474844; /* 220 7% 28% */ - - --color-card: rgba(255, 255, 255); /* Semi-transparent for glassmorphism */ - --color-card-solid: #ffffff; /* Solid white when needed */ - --color-card-foreground: #474844; - - --color-popover: #ffffff; - --color-popover-foreground: #474844; - - /* Primary Brand Blue #0088cc - Modern Premium Palette */ - --color-primary: #0088cc; /* Modern primary */ - --color-primary-foreground: #ffffff; - --color-primary-light: #009bdd; /* Gradient start */ - --color-primary-dark: #0071a8; /* Gradient end */ - --color-primary-darker: #005580; /* Deeper variant */ - --color-primary-glow: #33a3dd; /* Hover/glow state */ - - /* Premium Gradient - From #009bdd to #0071a8 */ - --gradient-premium: linear-gradient(135deg, #009bdd 0%, #0071a8 100%); - --gradient-premium-hover: linear-gradient(135deg, #33b4e6 0%, #0088cc 100%); - --gradient-subtle: linear-gradient(135deg, rgba(0, 155, 221, 0.05) 0%, rgba(0, 113, 168, 0.02) 100%); - - /* Secondary Anthracite #474844 */ - --color-secondary: #f1f1f1; /* Light gray as secondary */ - --color-secondary-foreground: #474844; - - --color-muted: #f8f8f8; /* 220 7% 97% */ - --color-muted-foreground: #606060; /* 220 7% 50% */ - - --color-accent: #e6f4fc; /* 206 100% 96% */ - --color-accent-foreground: #006eb7; - - --color-destructive: #dc3545; /* 0 84.2% 60.2% */ - --color-destructive-foreground: #ffffff; - - --color-border: #e0e0e0; /* 220 7% 90% */ - --color-input: #f1f1f1; /* 220 7% 95% */ - --color-ring: #006eb7; - - /* Section Background Colors for Alternating Pattern */ - --color-section-bg-white: #ffffff; - --color-section-bg-gray: #F8F9FA; - - /* Radius */ - --radius: 0.5rem; -} - -@layer theme { - .dark { - --color-background: #141414; /* 220 7% 8% */ - --color-foreground: #fafafa; - --color-card: #1f1f1f; - --color-card-foreground: #fafafa; - --color-popover: #1f1f1f; - --color-popover-foreground: #fafafa; - --color-primary: #2196f3; - --color-primary-foreground: #141414; - --color-secondary: #262626; - --color-secondary-foreground: #fafafa; - --color-muted: #262626; - --color-muted-foreground: #a6a6a6; - --color-accent: #262626; - --color-accent-foreground: #fafafa; - --color-destructive: #b71c1c; - --color-destructive-foreground: #fafafa; - --color-border: #333333; - --color-input: #333333; - --color-ring: #2196f3; - } -} - -@layer base { - html { - scroll-behavior: smooth; - } - - body { - @apply bg-background text-foreground antialiased; - font-family: var(--font-body); - font-feature-settings: "kern" 1, "liga" 1; - /* Subtle paper texture pattern */ - background-image: - radial-gradient(circle at 1px 1px, rgba(0, 0, 0, 0.015) 1px, transparent 0); - background-size: 40px 40px; - background-attachment: fixed; - } - - /* Typography System - Premium & Elegant */ - h1, h2, h3, h4, h5, h6 { - font-family: var(--font-heading); - @apply tracking-tight; - } - - h1 { - @apply text-5xl lg:text-6xl font-semibold leading-none; - } - - h2 { - @apply text-4xl lg:text-5xl font-medium leading-tight; - letter-spacing: -0.02em; - } - - h3 { - @apply text-3xl lg:text-4xl font-medium leading-tight; - } - - h4 { - @apply text-2xl lg:text-3xl font-normal leading-snug; - } - - h5 { - @apply text-xl lg:text-2xl font-normal leading-snug; - } - - p { - @apply leading-relaxed; - } - - /* Premium heading variants */ - .heading-elegant { - font-weight: 500; - letter-spacing: -0.02em; - } - - .heading-light { - font-weight: 400; - letter-spacing: -0.01em; - } -} - -@layer utilities { - /* Section Background Colors */ - .section-bg-white { - background-color: var(--color-section-bg-white); - } - - .section-bg-gray { - background: linear-gradient(135deg, #EBEEF0 0%, #F8F9FA 100%); - } - - /* Premium Section Spacing - Generous Whitespace */ - .section-spacing { - @apply py-20 lg:py-28; - } - - .section-spacing-large { - @apply py-24 lg:py-32; - } - - .section-spacing-compact { - @apply py-16 lg:py-20; - } - - /* Hero Background Gradient - Subtle diagonal from cool gray to white */ - .hero-gradient { - background: linear-gradient(135deg, #e2e8ee 0%, #ffffff 100%); - } - - /* Premium Service Card - Prominenter Style für Kernleistungen */ - .service-card-premium { - position: relative; - background: rgba(255, 255, 255, 0.98); - backdrop-filter: blur(10px) saturate(180%); - -webkit-backdrop-filter: blur(10px) saturate(180%); - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 1rem; - padding: 2rem; - box-shadow: - 0 2px 8px -2px rgba(0, 0, 0, 0.08), - 0 4px 20px -4px rgba(0, 136, 204, 0.15); - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - } - - .service-card-premium:hover { - transform: translateY(-8px); - box-shadow: 0 20px 20px -15px rgba(0, 136, 204, 0.5); - } - - .service-card-premium .service-icon { - position: relative; - width: 4rem; - height: 4rem; - background: var(--gradient-premium); - border-radius: 1rem; - display: flex; - align-items: center; - justify-content: center; - box-shadow: - 0 0 20px rgba(0, 155, 221, 0.15), - 0 0 40px rgba(0, 155, 221, 0.1), - 0 4px 20px -4px rgba(0, 136, 204, 0.2); - transition: all 0.3s ease; - overflow: hidden; - } - - /* Icon Shine Animation */ - .service-card-premium .service-icon::before { - content: ''; - position: absolute; - top: -50%; - left: -50%; - width: 200%; - height: 200%; - background: linear-gradient( - 45deg, - transparent 30%, - rgba(255, 255, 255, 0.2) 50%, - transparent 70% - ); - animation: icon-shine 5s ease-in-out infinite; - z-index: 1; - } - - .service-card-premium .service-icon svg { - position: relative; - z-index: 2; - } - - /* Service Card mit Hintergrundbild */ - .service-card-with-bg { - position: relative; - background: rgba(255, 255, 255, 0.95); - backdrop-filter: blur(10px) saturate(180%); - -webkit-backdrop-filter: blur(10px) saturate(180%); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 1rem; - padding: 2rem; - box-shadow: - 0 2px 8px -2px rgba(0, 0, 0, 0.15), - 0 4px 20px -4px rgba(0, 136, 204, 0.2); - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - overflow: hidden; - min-height: 280px; - display: flex; - flex-direction: column; - justify-content: flex-end; - } - - .service-card-with-bg:hover { - transform: translateY(-4px); - box-shadow: - 0 8px 30px -4px rgba(0, 0, 0, 0.2), - 0 12px 40px -8px rgba(0, 136, 204, 0.35); - } - - .service-card-with-bg .service-icon-white { - position: relative; - width: 4rem; - height: 4rem; - background: var(--color-white); - border-radius: 1rem; - display: flex; - align-items: center; - justify-content: center; - box-shadow: - 0 0 20px rgba(0, 155, 221, 0.15), - 0 0 40px rgba(0, 155, 221, 0.1), - 0 4px 20px -4px rgba(0, 136, 204, 0.2); - transition: all 0.3s ease; - overflow: hidden; - color:#2196f3; - } - - .service-card-with-bg .service-icon-white svg { - position: relative; - z-index: 2; - } - - - .service-card-with-bg h3 { - font-size: 1.25rem; - font-weight: 600; - margin-bottom: 0.5rem; - line-height: 1.4; - } - - /* Success Story Cards */ - .success-story-card { - position: relative; - background: var(--color-card-solid); - border-radius: 1rem; - overflow: hidden; - box-shadow: - 0 2px 8px -2px rgba(0, 0, 0, 0.08), - 0 4px 20px -4px rgba(0, 136, 204, 0.15); - transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); - display: flex; - flex-direction: column; - } - - .success-story-card:hover { - transform: translateY(-8px); - box-shadow: - 0 8px 30px -4px rgba(0, 0, 0, 0.15), - 0 12px 40px -8px rgba(0, 136, 204, 0.3); - } - - .success-story-image { - position: relative; - width: 100%; - height: 240px; - overflow: hidden; - } - - .success-story-overlay { - position: absolute; - inset: 0; - background: linear-gradient( - to bottom, - transparent 0%, - rgba(0, 0, 0, 0.05) 50%, - rgba(0, 136, 204, 0.08) 100% - ); - z-index: 1; - } - - .success-story-content { - padding: 1.5rem; - flex: 1; - display: flex; - flex-direction: column; - } - - .success-story-card h3 { - font-size: 1.25rem; - line-height: 1.4; - } - - /* Success Story Card Hover Effects */ - .success-story-card:hover .success-story-overlay { - background: linear-gradient( - to bottom, - transparent 0%, - rgba(0, 0, 0, 0.02) 50%, - rgba(0, 136, 204, 0.12) 100% - ); - } - - /* Testimonials Section */ - .testimonial-card-featured { - position: relative; - background: var(--color-card-solid); - border-radius: 1rem; - padding: 2rem; - box-shadow: - 0 4px 20px -4px rgba(0, 0, 0, 0.1), - 0 8px 40px -8px rgba(0, 136, 204, 0.2); - transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); - } - - .testimonial-card-featured:hover { - transform: translateY(-4px); - box-shadow: - 0 8px 30px -4px rgba(0, 0, 0, 0.15), - 0 12px 50px -8px rgba(0, 136, 204, 0.3); - } - - .testimonial-card-compact { - position: relative; - background: var(--color-card-solid); - border-radius: 1.25rem; - padding: 2rem; - box-shadow: - 0 2px 12px -2px rgba(0, 0, 0, 0.08), - 0 4px 24px -4px rgba(0, 136, 204, 0.15); - transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); - height: 100%; - display: flex; - flex-direction: column; - } - - .testimonial-card-compact:hover { - transform: translateY(-4px); - box-shadow: - 0 6px 24px -4px rgba(0, 0, 0, 0.12), - 0 10px 40px -8px rgba(0, 136, 204, 0.25); - } - - .testimonial-quote-icon { - margin-bottom: 1.5rem; - } - - .testimonial-quote-icon-small { - margin-bottom: 1rem; - } - - .testimonial-text-featured { - font-size: 1.375rem; - line-height: 1.6; - color: var(--color-foreground); - font-weight: 500; - margin-bottom: 2rem; - font-style: italic; - } - - .testimonial-text-compact { - font-size: 1rem; - line-height: 1.6; - color: var(--color-foreground); - font-weight: 500; - margin-bottom: 1.5rem; - font-style: italic; - flex: 1; - } - - .testimonial-author { - display: flex; - align-items: center; - gap: 1rem; - padding-top: 1.5rem; - border-top: 1px solid rgba(0, 0, 0, 0.08); - } - - .testimonial-author-compact { - display: flex; - align-items: center; - gap: 0.75rem; - padding-top: 1rem; - border-top: 1px solid rgba(0, 0, 0, 0.08); - margin-top: auto; - } - - .testimonial-avatar { - flex-shrink: 0; - } - - /* Testimonial Slider Styles */ - .testimonial-card-slider { - position: relative; - background: var(--color-card-solid); - border-radius: 1.75rem; - padding: 2rem; - box-shadow: - 0 4px 24px -4px rgba(0, 0, 0, 0.12), - 0 8px 48px -8px rgba(0, 136, 204, 0.25); - transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); - } - - .testimonial-quote-icon-large { - margin-bottom: 2rem; - } - - .testimonial-text-slider { - font-size: 1.15rem; - line-height: 1.6; - color: var(--color-foreground); - font-weight: 500; - margin-bottom: 1.5rem; - font-style: italic; - } - - .testimonial-author-slider { - display: flex; - align-items: center; - gap: 1.25rem; - padding-top: 1rem; - border-top: 1px solid rgba(0, 0, 0, 0.08); - } - - /* Navigation Buttons */ - .testimonial-nav-btn { - width: 48px; - height: 48px; - display: flex; - align-items: center; - justify-content: center; - background: rgba(255, 255, 255, 0.2); - backdrop-filter: blur(10px); - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 50%; - color: white; - transition: all 0.3s ease; - cursor: pointer; - } - - .testimonial-nav-btn:hover { - background: rgba(255, 255, 255, 0.3); - transform: scale(1.05); - } - - /* Dots Indicator */ - .testimonial-dot { - width: 10px; - height: 10px; - border-radius: 50%; - background: rgba(255, 255, 255, 0.3); - border: 2px solid rgba(255, 255, 255, 0.5); - transition: all 0.3s ease; - cursor: pointer; - } - - .testimonial-dot.active { - width: 32px; - border-radius: 5px; - background: rgba(255, 255, 255, 0.9); - border-color: rgba(255, 255, 255, 1); - } - - .testimonial-dot:hover:not(.active) { - background: rgba(255, 255, 255, 0.5); - transform: scale(1.2); - } - - /* Premium Sticky Header */ - .sticky-header { - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - transform: translateY(0); - } - - /* Offset für fixed header - damit Content nicht darunter verschwindet */ - body main { - padding-top: 64px; /* 4rem = h-16 vom header */ - } - - .sticky-header.scrolled { - background: rgba(248, 249, 250, 0.98) !important; - backdrop-filter: blur(16px) saturate(180%); - box-shadow: - 0 2px 8px -2px rgba(0, 0, 0, 0.08), - 0 4px 16px -4px rgba(0, 136, 204, 0.12), - 0 8px 24px -8px rgba(0, 136, 204, 0.08); - border-bottom-color: rgba(0, 136, 204, 0.15); - } - - /* Slide-Down Animation beim ersten Scroll */ - .sticky-header.hide { - transform: translateY(-100%); - } - - /* Scroll Progress Indicator */ - .scroll-progress-bar { - position: fixed; - top: 0; - left: 0; - height: 3px; - background: linear-gradient(90deg, var(--color-primary-light) 0%, var(--color-primary) 50%, var(--color-primary-dark) 100%); - width: 0%; - z-index: 99999; - transition: width 0.1s ease-out, opacity 0.3s ease; - opacity: 0; - box-shadow: - 0 0 10px rgba(0, 155, 221, 0.5), - 0 0 20px rgba(0, 155, 221, 0.3); - } - - .scroll-progress-bar.visible { - opacity: 1; - } - - /* Smooth Progress Animation */ - @media (prefers-reduced-motion: no-preference) { - .scroll-progress-bar { - transition: width 0.15s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease; - } - } - - /* Für Nutzer mit Motion-Präferenz */ - @media (prefers-reduced-motion: reduce) { - .scroll-progress-bar { - transition: opacity 0.3s ease; - } - } - - - /* Sicherere Cross-Browser-Variante von Methode 2: */ - .gradient-border-radius-safe { - position: relative; - border: 1px solid transparent; - /* * Zwei Hintergründe: - * 1. Die Inhaltsfarbe (z.B. weiß) - * 2. Der Verlauf (3x breiter für Animation) - */ - background-image: - linear-gradient(white, white), /* Innerer Hintergrund */ - linear-gradient(90deg, #00b3ff50 0%, #00568150 25%, #00b3ff40 50%, #00568150 75%, #00b3ff50 100%); /* Äußerer Verlauf */ - - /* Den Ursprung für beide festlegen */ - background-origin: border-box; - - /* * Den Clip-Pfad für beide festlegen: - * 1. Der weiße Hintergrund füllt nur die padding-box (Inhalt). - * 2. Der Verlauf füllt die border-box (alles). - */ - background-clip: padding-box, border-box; - - /* Größeren Hintergrund für Animation */ - background-size: 100%, 300% 100%; - background-position: 0 0, 0% 0; - - /* Animation für Gradient-Bewegung */ - animation: border-gradient-slide 8s ease-in-out infinite alternate; - } - - /* Animierter Border Gradient - Sliding */ - @keyframes border-gradient-slide { - 0%, 100% { - background-position: 0 0, 0% 0; - } - 50% { - background-position: 0 0, 100% 0; - } - } - - /* Hover: Animation pausieren */ - .gradient-border-radius-safe:hover { - animation-play-state: paused; - } - - @keyframes icon-shine { - 0%, 100% { - transform: translateX(-150%) translateY(-150%) rotate(35deg); - } - 40%, 60% { - transform: translateX(150%) translateY(150%) rotate(35deg); - } - } - - /* Hero Image Shine Effect */ - .hero-image-shine { - position: relative; - overflow: hidden; - } - - .hero-image-shine::after { - content: ''; - position: absolute; - top: -50%; - left: -50%; - width: 200%; - height: 200%; - background: linear-gradient( - 45deg, - transparent 30%, - rgba(255, 255, 255, 0.3) 50%, - transparent 70% - ); - animation: hero-shine 10s ease-in-out infinite; - pointer-events: none; - z-index: 10; - } - - @keyframes hero-shine { - 0%, 100% { - transform: translateX(-150%) translateY(-150%) rotate(35deg); - } - 40%, 60% { - transform: translateX(150%) translateY(150%) rotate(35deg); - } - } - - /* Button Shine Effect */ - .btn-shine { - position: relative; - overflow: hidden; - } - - .btn-shine::before { - content: ''; - position: absolute; - top: -50%; - left: -50%; - width: 200%; - height: 200%; - background: linear-gradient( - 45deg, - transparent 30%, - rgba(255, 255, 255, 0.2) 50%, - transparent 70% - ); - animation: btn-shine 8s ease-in-out infinite; - pointer-events: none; - z-index: 1; - } - - /* Weißer Button Shine - mit leichtem Blau/Grau */ - .btn-shine.bg-white::before { - background: linear-gradient( - 45deg, - transparent 30%, - rgba(0, 136, 204, 0.2) 50%, - transparent 70% - ); - } - - .btn-shine > * { - position: relative; - z-index: 2; - } - - @keyframes btn-shine { - 0%, 100% { - transform: translateX(-150%) translateY(-150%) rotate(35deg); - } - 40%, 60% { - transform: translateX(150%) translateY(150%) rotate(35deg); - } - } - - /* Partner Card Glow Effect - subtiler pulsierender Glow */ - .card-glow { - position: relative; - animation: card-glow-pulse 4s ease-in-out infinite; - } - - @keyframes card-glow-pulse { - 0%, 100% { - box-shadow: - 0 -4px 20px -4px rgba(0, 136, 204, 0.10), - 0 -8px 0 0 rgba(0, 136, 204, 0); - } - 50% { - box-shadow: - 0 -4px 20px -4px rgba(0, 136, 204, 0.25), - 0 -15px 20px 0 rgba(0, 136, 204, 0.15); - } - } - - .card-glow:hover { - animation: none; - } - - .service-card-premium:hover .service-icon { - box-shadow: - 0 0 25px rgba(0, 155, 221, 0.25), - 0 0 50px rgba(0, 155, 221, 0.15), - 0 10px 30px -10px rgba(0, 136, 204, 0.3); - transform: scale(1.05); - } - - .service-card-premium h3 { - font-family: var(--font-heading); - font-weight: 600; - font-size: 1.125rem; - margin-bottom: 0.75rem; - transition: color 0.3s ease; - } - - .service-card-premium:hover h3 { - color: var(--color-primary); - } - - /* Premium Scroll Animations - Optimiert und subtil */ - .scroll-animate { - opacity: 0; - will-change: opacity, transform; - transition: opacity 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94), - transform 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94); - } - - .scroll-animate.is-visible { - opacity: 1; - } - - /* Fade In - Sanftes Einblenden */ - .fade-in { - opacity: 0; - will-change: opacity; - transition: opacity 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94); - } - - .fade-in.is-visible { - opacity: 1; - } - - /* Slide from Bottom - Subtiler */ - .slide-up { - opacity: 0; - transform: translateY(30px); - will-change: opacity, transform; - transition: opacity 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94), - transform 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94); - } - - .slide-up.is-visible { - opacity: 1; - transform: translateY(0); - } - - /* Slide from Left - Subtiler */ - .slide-right { - opacity: 0; - transform: translateX(-30px); - will-change: opacity, transform; - transition: opacity 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94), - transform 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94); - } - - .slide-right.is-visible { - opacity: 1; - transform: translateX(0); - } - - /* Slide from Right - Subtiler */ - .slide-left { - opacity: 0; - transform: translateX(30px); - will-change: opacity, transform; - transition: opacity 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94), - transform 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94); - } - - .slide-left.is-visible { - opacity: 1; - transform: translateX(0); - } - - /* Scale In - Sehr subtil */ - .scale-in { - opacity: 0; - transform: scale(0.97); - will-change: opacity, transform; - transition: opacity 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94), - transform 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94); - } - - .scale-in.is-visible { - opacity: 1; - transform: scale(1); - } - - /* Staggered delays - Für gestaffelte Animationen */ - .delay-100 { - transition-delay: 0.1s; - } - - .delay-200 { - transition-delay: 0.2s; - } - - .delay-300 { - transition-delay: 0.3s; - } - - .delay-400 { - transition-delay: 0.4s; - } - - .delay-500 { - transition-delay: 0.5s; - } - - .delay-600 { - transition-delay: 0.6s; - } - - /* Reduziere Motion für Nutzer mit Präferenz */ - @media (prefers-reduced-motion: reduce) { - .scroll-animate, - .fade-in, - .slide-up, - .slide-right, - .slide-left, - .scale-in { - transition: none !important; - opacity: 1 !important; - transform: none !important; - } - } - - /* Glassmorphism Effects - Frosted Glass */ - .glass-card { - background: rgba(255, 255, 255, 0.98); - backdrop-filter: blur(12px) saturate(180%); - -webkit-backdrop-filter: blur(12px) saturate(180%); - border: 1px solid rgba(0, 0, 0, 0.08); - } - - .glass-card-light { - background: rgba(255, 255, 255, 0.95); - backdrop-filter: blur(16px) saturate(180%); - -webkit-backdrop-filter: blur(16px) saturate(180%); - border: 1px solid rgba(0, 0, 0, 0.06); - } - - .glass-card-strong { - background: rgba(255, 255, 255, 0.98); - backdrop-filter: blur(10px) saturate(180%); - -webkit-backdrop-filter: blur(10px) saturate(180%); - border: 1px solid rgba(0, 0, 0, 0.08); - } - - /* Metallic Shimmer Effect (inspired by Möbius band) */ - .shimmer-effect { - position: relative; - overflow: hidden; - } - - .shimmer-effect::after { - content: ''; - position: absolute; - top: -50%; - right: -50%; - bottom: -50%; - left: -50%; - background: linear-gradient( - to bottom, - transparent, - rgba(255, 255, 255, 0.1) 50%, - transparent - ); - transform: rotate(45deg); - animation: shimmer 3s infinite; - } - - @keyframes shimmer { - 0%, 100% { - transform: translateX(-150%) translateY(-150%) rotate(35deg); - } - 40%, 60% { - transform: translateX(150%) translateY(150%) rotate(35deg); - } - } - - /* Hover shimmer for cards */ - .hover-shimmer { - position: relative; - overflow: hidden; - } - - .hover-shimmer::after { - content: ''; - position: absolute; - top: 0; - left: -100%; - width: 100%; - height: 100%; - background: linear-gradient( - 90deg, - transparent, - rgba(255, 255, 255, 0.3), - transparent - ); - transition: left 0.5s ease; - pointer-events: none; - } - - .hover-shimmer:hover::after { - left: 100%; - } - - /* Premium Gradient Backgrounds */ - .bg-gradient-premium { - background: var(--gradient-premium); - background-size: 100% 100%; - transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); - } - - .bg-gradient-premium:hover { - background: var(--gradient-premium-hover); - transform: translateY(-1px); - box-shadow: 0 10px 40px -10px rgba(0, 136, 204, 0.4); - } - .bg-gradient-premium-safe { - background: var(--gradient-premium); - background-size: 100% 100%; - transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); - } - - .bg-gradient-subtle { - background: var(--gradient-subtle); - } - - /* Text Gradient */ - .text-gradient { - background: var(--gradient-premium); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - } - - .text-gradient-premium { - background: var(--gradient-premium); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - font-weight: 600; - } - - /* Shadow Effects - Updated for new primary color */ - .shadow-elegant { - box-shadow: 0 10px 30px -10px rgba(0, 136, 204, 0.2); - } - - .shadow-elegant-white { - box-shadow: 0 10px 30px -10px rgba(255, 255, 255, 0.2); - } - - .shadow-white { - box-shadow: 0 10px 30px -10px rgba(255, 255, 255, 0.4); - } - - .shadow-card { - box-shadow: - 0 2px 8px -2px rgba(0, 0, 0, 0.08), - 0 4px 20px -4px rgba(0, 136, 204, 0.15); - } - - .shadow-premium { - box-shadow: 0 20px 60px -15px rgba(0, 136, 204, 0.3); - } - - /* Glow Effects - Inspired by Möbius band's luminous edge */ - .glow-soft { - box-shadow: - 0 0 10px rgba(0, 155, 221, 0.15), - 0 0 20px rgba(0, 155, 221, 0.1), - 0 4px 10px -4px rgba(0, 136, 204, 0.2); - } - - .glow-medium { - box-shadow: - 0 0 20px rgba(0, 155, 221, 0.25), - 0 0 40px rgba(0, 155, 221, 0.15), - 0 10px 20px -10px rgba(0, 136, 204, 0.3); - } - - .glow-strong { - box-shadow: - 0 0 30px rgba(0, 155, 221, 0.35), - 0 0 60px rgba(0, 155, 221, 0.2), - 0 0 90px rgba(0, 113, 168, 0.1), - 0 10px 10px -10px rgba(0, 136, 204, 0.4); - } - - /* Hover Glow Enhancement */ - .hover-glow { - transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); - } - - .hover-glow:hover { - box-shadow: - 0 0 30px rgba(0, 155, 221, 0.4), - 0 0 60px rgba(0, 155, 221, 0.25), - 0 0 90px rgba(0, 113, 168, 0.15), - 0 10px 50px -10px rgba(0, 136, 204, 0.5); - transform: translateY(-2px); - } - - .hover-glow-white { - transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); - } - - .hover-glow-white:hover { - box-shadow: - 0 0 15px rgba(255, 255, 255, 0.4), - 0 0 30px rgba(255, 255, 255, 0.25), - 0 0 45px rgba(255, 255, 255, 0.15), - 0 10px 25px -10px rgba(255, 255, 255, 0.5); - transform: translateY(-2px); - } - - .hover-glow-soft { - transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); - - } - .hover-glow-soft:hover { - box-shadow: - 0 0 10px rgba(0, 155, 221, 0.15), - 0 0 20px rgba(0, 155, 221, 0.1), - 0 4px 10px -4px rgba(0, 136, 204, 0.2); - transform: translateY(-2px); - - } - /* Pulsing Glow Animation for special elements */ - .glow-pulse { - animation: glowPulse 3s ease-in-out infinite; - } - - @keyframes glowPulse { - 0%, 100% { - box-shadow: - 0 0 20px rgba(0, 155, 221, 0.2), - 0 0 40px rgba(0, 155, 221, 0.1), - 0 10px 30px -10px rgba(0, 136, 204, 0.3); - } - 50% { - box-shadow: - 0 0 30px rgba(0, 155, 221, 0.35), - 0 0 60px rgba(0, 155, 221, 0.2), - 0 0 90px rgba(0, 113, 168, 0.1), - 0 10px 40px -10px rgba(0, 136, 204, 0.4); - } - } - - /* Transition */ - .transition-smooth { - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - } - - /* Card Hover Effects */ - .card-hover { - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - } - - .card-hover:hover { - transform: translateY(-4px); - box-shadow: 0 10px 30px -10px rgba(0, 136, 204, 0.2); - } - - /* Premium Button Styles with Glow */ - .btn-premium { - background: var(--gradient-premium); - color: white; - padding: 0.75rem 1.5rem; - border-radius: 0.5rem; - font-weight: 500; - transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); - box-shadow: - 0 0 20px rgba(0, 155, 221, 0.15), - 0 0 40px rgba(0, 155, 221, 0.1), - 0 4px 15px -3px rgba(0, 136, 204, 0.3); - } - - .btn-premium:hover { - background: var(--gradient-premium-hover); - transform: translateY(-2px); - box-shadow: - 0 0 30px rgba(0, 155, 221, 0.4), - 0 0 60px rgba(0, 155, 221, 0.25), - 0 10px 40px -10px rgba(0, 136, 204, 0.5); - } - - /* Glow on Focus for Accessibility */ - .btn-premium:focus, - .hover-glow:focus { - outline: none; - box-shadow: - 0 0 0 3px rgba(0, 155, 221, 0.3), - 0 0 30px rgba(0, 155, 221, 0.4), - 0 0 60px rgba(0, 155, 221, 0.25); - } - - /* Inner Glow - Simulates the luminous inner edge of Möbius band */ - .inner-glow { - box-shadow: - inset 0 0 30px rgba(0, 155, 221, 0.15), - inset 0 0 15px rgba(0, 155, 221, 0.1), - 0 0 25px rgba(0, 155, 221, 0.2); - } - - .inner-glow-strong { - box-shadow: - inset 0 0 40px rgba(0, 155, 221, 0.25), - inset 0 0 20px rgba(0, 155, 221, 0.15), - 0 0 30px rgba(0, 155, 221, 0.3); - } - - /* Line Clamp Utilities */ - .line-clamp-1 { - overflow: hidden; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 1; - } - - .line-clamp-2 { - overflow: hidden; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - } - - .line-clamp-3 { - overflow: hidden; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; - } - - /* Alpine.js Cloak */ - [x-cloak] { - display: none !important; - } - - /* Prose Styling for Modal Content */ - .prose h3 { - @apply text-xl font-semibold mt-6 mb-3 text-foreground; - } - - .prose h4 { - @apply text-lg font-semibold mt-4 mb-2 text-foreground; - } - - .prose ul { - @apply list-disc ml-5 mb-4; - } - - .prose li { - @apply mb-2; - line-height: 1.3em; - } - - .prose p { - @apply mb-4 leading-relaxed text-foreground; - } -} diff --git a/resources/css/web/fonts.css b/resources/css/web/fonts.css new file mode 100644 index 0000000..799f452 --- /dev/null +++ b/resources/css/web/fonts.css @@ -0,0 +1,134 @@ +/** + * Lokale Font-Einbindungen (DSGVO-konform) + * Nur die tatsächlich verwendeten Weights aus shared-styles und Themes. + * Font-Dateien: public/fonts/{font-name}/ + */ + +/* Inter - Primary für alle Domains (300, 400, 500, 600, 700) */ +@font-face { + font-display: swap; + font-family: 'Inter'; + font-style: normal; + font-weight: 300; + src: url('/fonts/inter/inter-v20-latin-300.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + src: url('/fonts/inter/inter-v20-latin-regular.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + src: url('/fonts/inter/inter-v20-latin-500.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + src: url('/fonts/inter/inter-v20-latin-600.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + src: url('/fonts/inter/inter-v20-latin-700.woff2') format('woff2'); +} + +/* IBM Plex Sans - Secondary (b2in, local4local) */ +@font-face { + font-display: swap; + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 400; + src: url('/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-regular.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 500; + src: url('/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-500.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 600; + src: url('/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-600.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 700; + src: url('/fonts/ibm-plex-sans/ibm-plex-sans-v23-latin-700.woff2') format('woff2'); +} + +/* Merriweather - Secondary (b2a) */ +@font-face { + font-display: swap; + font-family: 'Merriweather'; + font-style: normal; + font-weight: 300; + src: url('/fonts/merriweather/merriweather-v33-latin-300.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Merriweather'; + font-style: normal; + font-weight: 400; + src: url('/fonts/merriweather/merriweather-v33-latin-regular.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Merriweather'; + font-style: normal; + font-weight: 700; + src: url('/fonts/merriweather/merriweather-v33-latin-700.woff2') format('woff2'); +} + +/* Ephesis - Secondary (style2own) */ +@font-face { + font-display: swap; + font-family: 'Ephesis'; + font-style: normal; + font-weight: 400; + src: url('/fonts/ephesis/ephesis-v11-latin-regular.woff2') format('woff2'); +} + +/* EB Garamond - Secondary (stileigentum) */ +@font-face { + font-display: swap; + font-family: 'EB Garamond'; + font-style: normal; + font-weight: 400; + src: url('/fonts/eb-garamond/eb-garamond-v32-latin-regular.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'EB Garamond'; + font-style: normal; + font-weight: 500; + src: url('/fonts/eb-garamond/eb-garamond-v32-latin-500.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'EB Garamond'; + font-style: normal; + font-weight: 600; + src: url('/fonts/eb-garamond/eb-garamond-v32-latin-600.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'EB Garamond'; + font-style: normal; + font-weight: 700; + src: url('/fonts/eb-garamond/eb-garamond-v32-latin-700.woff2') format('woff2'); +} diff --git a/resources/css/web/shared-styles.css b/resources/css/web/shared-styles.css index 0218365..d6ad68d 100644 --- a/resources/css/web/shared-styles.css +++ b/resources/css/web/shared-styles.css @@ -1,4 +1,5 @@ /* Shared styles for all web themes - no @apply directives */ +@import "./fonts.css"; *, ::before, @@ -17,6 +18,22 @@ body { -moz-osx-font-smoothing: grayscale; } +/* Cursor pointer für alle klickbaren Elemente (global) */ +button, +a, +[role="button"], +input[type="submit"], +input[type="button"], +input[type="reset"], +summary { + cursor: pointer; +} + +/* Klasse für klickbare Custom-Elemente (z. B. div mit @click) */ +.clickable { + cursor: pointer; +} + h1, h2, h3, h4, h5, h6 { font-weight: 500; letter-spacing: -0.025em; @@ -718,9 +735,6 @@ h1, h2, h3, h4, h5, h6 { position: relative; } -.text-section-title { - line-height: 0.95em; -} .variante-glass-flow { & * { diff --git a/resources/css/web/theme-main.css b/resources/css/web/theme-main.css index 40ccf79..cd1a26c 100644 --- a/resources/css/web/theme-main.css +++ b/resources/css/web/theme-main.css @@ -5,7 +5,7 @@ @custom-variant dark (&:where(.dark, .dark *)); @theme { - --font-sans: "Instrument Sans", ui-sans-serif, system-ui, sans-serif, + --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; diff --git a/resources/lang/de/b2in.php b/resources/lang/de/b2in.php new file mode 100644 index 0000000..44329f1 --- /dev/null +++ b/resources/lang/de/b2in.php @@ -0,0 +1,1638 @@ + [ + 'b2in' => [ + 'announcement_bar' => [ + 'enabled' => true, + 'id' => 'azizi-launch-2026', + 'badge' => 'NEW LAUNCH', + 'text' => 'Azizi Creek Views 4 – Exklusives Off-Market-Projekt in Dubai '.\App\Helpers\PriceHelper::formatAed(1_125_000, 'ab'), + 'link_text' => 'Exposé ansehen', + 'link_url' => '/immobilien/azizi-creek-views-4', + ], + 'header' => [ + 'portal_login' => 'Partner-Login', + 'navigation' => [ + ['label' => 'Home', 'url' => '/'], + ['label' => 'Immobilien', 'url' => '/immobilien'], + ['label' => 'Netzwerk', 'url' => '/netzwerk'], + ['label' => 'Magazin', 'url' => '/magazin'], + ['label' => 'FAQ', 'url' => '/faq'], + ['label' => 'Über B2in', 'url' => '/about'], + ], + ], + 'hero' => [ + 'title' => 'B2in – Wo exklusive Immobilien-Investments auf smartes Interior treffen.', + 'subtitle' => 'Ihr Partner für renditestarke internationale Immobilien und globales Supply-Chain-Management im Interior-Bereich.', + 'image' => 'b2in/hero-room.jpg', + 'image_alt' => 'B2in – Internationale Immobilien und exklusive Einrichtungskonzepte', + 'cta1_text' => 'Zu den Immobilien-Projekten', + 'cta1_link' => '/immobilien', + 'cta2_text' => 'Unser Netzwerk', + 'cta2_link' => '/netzwerk', + 'stats' => [ + 'Internationale Immobilien', + 'Exklusive Einrichtung', + 'Persönliche Beratung', + ], + 'card_title' => 'B2in', + 'card_text' => 'Connecting Design and Property', + ], + 'founder_bar' => [ + 'image' => 'b2in/marcel-scheibe.jpg', + 'name' => 'Marcel Scheibe', + 'title' => 'Gründer & CEO, B2in', + 'statement' => 'B2in by Marcel Scheibe – Ihr persönlicher Partner für internationale Immobilien und exklusive Einrichtungskonzepte.', + ], + 'synergie_section' => [ + 'title' => 'Zwei Welten. Ein Netzwerk.', + 'paragraphs' => [ + 'Wir verbinden den Immobilienkauf mit der perfekten Einrichtung. Immobilien-Investoren profitieren von unserem exklusiven Möbel-Netzwerk – Projektentwickler von unserer deutschen Vertragssicherheit im Supply-Chain-Management.', + ], + 'image' => 'b2in/best-of-two-worlds.jpg', + 'image_alt' => 'Das B2in-Ökosystem – Immobilien und Einrichtung', + 'image_caption' => 'Das B2in-Ökosystem', + ], + 'ecosystem_core' => [ + 'title' => 'Ein Ökosystem, drei Säulen', + 'subtitle' => '', + 'pillars' => [ + [ + 'icon' => 'building-office-2', + 'title' => 'Immobilien & Investments', + 'description' => 'Exklusive Off-Market-Projekte & High-Yield Renditeobjekte.', + 'link' => '/immobilien', + ], + [ + 'icon' => 'squares-2x2', + 'title' => 'Local-for-Local Marktplatz', + 'description' => 'Das Netzwerk für den regionalen Möbelfachhandel und Makler.', + 'link' => '/netzwerk', + ], + [ + 'icon' => 'clipboard-document-check', + 'title' => 'Supply-Chain-Management', + 'description' => 'Deutsche Vertragssicherheit für internationale Immobilienentwickler.', + 'link' => '/netzwerk', + ], + ], + ], + 'vision_section' => [ + 'title' => 'Gebaut auf Expertise und Vertrauen', + 'paragraphs' => [ + 'B2in (Bridges2international) verbindet zwei Welten: internationale Immobilien und exklusive Einrichtungskonzepte. Als Ihr persönlicher Partner navigiere ich Sie durch beide Bereiche – mit Expertise, Netzwerk und dem Anspruch, dass jede Entscheidung auf Vertrauen basiert.', + 'Ob ein Investment in Dubai, eine Villa in Lissabon oder die maßgeschneiderte Einrichtung Ihres neuen Zuhauses durch lokale Fachexperten – bei B2in laufen alle Fäden zusammen.', + 'Regional verwurzelt, international vernetzt – das ist B2in.', + ], + 'image' => 'b2in/marcel-scheibe-about.jpg', + 'image_alt' => 'Marcel Scheibe, Gründer und CEO von B2in', + 'image_caption' => 'Marcel Scheibe, Gründer & CEO', + ], + 'brand_worlds' => [ + 'title' => 'Unsere Welten:
Design, Immobilien und internationaler Handel', + 'subtitle' => 'Von internationalen Immobilien-Investments über exklusive Einrichtungskonzepte bis zum transatlantischen Handel – B2in verbindet die Welten, die zusammengehören.', + 'worlds' => [ + [ + 'image' => 'b2in/stileigentum.jpg', + 'title' => 'Stileigentum', + 'description' => 'Das Premium-Segment: Exklusive und hochwertige Einrichtungskonzepte für anspruchsvolle Kunden, die Qualität und Tradition schätzen.', + 'link' => env('DOMAIN_STILEIGENTUM_URL', 'https://stileigentum.test'), + 'logo' => 'img/logos/stileigentum-logo-positiv.svg', + 'logo_width' => 'w-35', + 'external' => true, + ], + [ + 'image' => 'b2in/style2own.jpg', + 'title' => 'Style2own', + 'description' => 'Der Lifestyle-Kanal: Moderne Einrichtungskonzepte für Young Professionals und trend-orientierte Kunden – urban, flexibel, inspirierend.', + 'link' => env('DOMAIN_STYLE2OWN_URL', 'https://style2own.test'), + 'logo' => 'img/logos/style2own-logo-positiv.svg', + 'logo_width' => 'w-28', + 'external' => true, + ], + [ + 'image' => 'b2in/b2a.jpg', + 'title' => 'B2A', + 'description' => 'Unsere Logistik-Power für den transatlantischen Handel. Wir ermöglichen Herstellern den direkten Zugang zu internationalen Märkten.', + 'link' => env('DOMAIN_B2A_URL', 'https://b2a.test'), + 'logo' => 'img/logos/b2a-logo-positiv.svg', + 'logo_width' => 'w-18', + 'external' => true, + ], + ], + ], + 'integriertes_modell_b2in' => [ + 'title' => 'Das Beste aus zwei Welten:
Immobilien und Einrichtung', + 'paragraphs' => [ + 'B2in verbindet, was zusammengehört: Wer eine Immobilie erwirbt, braucht die passende Einrichtung. Wer exklusiv einrichten möchte, findet über unser Netzwerk die besten lokalen Fachexperten – ergänzt durch das Sortiment europäischer Hersteller.', + 'Das Ergebnis: Ein nahtloses Erlebnis für den Kunden und neue Ertragsquellen für unsere Partner – ob Makler, Händler oder Entwickler.', + ], + 'image' => 'b2in/best-of-two-worlds.jpg', + 'image_alt' => 'Das Ergebnis für den Kunden – Immobilie und Einrichtung aus einer Hand', + 'image_caption' => 'Immobilie und Einrichtung – aus einer Hand', + ], + 'cta_section' => [ + 'title' => 'Ihr nächster Schritt', + 'subtitle' => 'Ob Immobilien-Investment, Supply-Chain-Partnerschaft oder Einrichtungs-Netzwerk – sprechen Sie direkt mit uns.', + 'button_text' => 'Kontakt aufnehmen', + 'button_link' => '/contact', + ], + 'immobilien_hero' => [ + 'title' => 'Investieren Sie in die Zukunft – Dubai, Lissabon & mehr.', + 'subtitle' => 'Exklusive Off-Market-Projekte und High-Yield-Investments. Persönlich kuratiert und begleitet von Marcel Scheibe.', + 'features' => [ + [ + 'title' => 'Off-Market', + 'description' => 'Exklusive Projekte', + 'icon' => 'lock-closed', + ], + [ + 'title' => 'High-Yield', + 'description' => 'Renditestarke Investments', + 'icon' => 'arrow-trending-up', + ], + [ + 'title' => 'Persönlich', + 'description' => 'Begleitung durch Marcel Scheibe', + 'icon' => 'user', + ], + [ + 'title' => 'International', + 'description' => 'Dubai, Lissabon & mehr', + 'icon' => 'globe-alt', + ], + ], + 'image' => 'b2in/hero-immobilien.jpg', + 'image_alt' => 'Internationale Immobilien-Investments', + 'card_title' => 'B2in Immobilien', + 'card_text' => 'Exklusive internationale Investments', + 'hub' => [ + 'title' => 'B2in Immobilien', + 'subtitle' => 'Exklusive internationale Investments', + ], + 'stats' => [ + 'Off-Market Zugang', + 'Persönliche Begleitung', + 'Renditestarke Objekte', + ], + ], + 'immobilien_projects' => [ + 'title' => 'Aktuelle Launches & Projekte', + 'subtitle' => 'Entdecken Sie unsere aktuellen Immobilien-Projekte auf internationalen Märkten.', + 'projects' => [ + 'azizi-creek-views-4' => [ + 'slug' => 'azizi-creek-views-4', + 'title' => 'Azizi Developments: Creek Views 4', + 'location' => 'Al Jaddaf, Dubai', + 'status' => 'NEW LAUNCH', + 'launch_date' => '03.03.2026', + 'price_from' => \App\Helpers\PriceHelper::formatAed(1_125_000, 'ab'), + 'image' => 'expose/a1/image-4.jpeg', + 'highlights' => [ + 'Prime Waterfront Views', + '1BR: '.\App\Helpers\PriceHelper::formatAed(1_125_000, 'ab'), + 'Exklusives 3BR Penthouse (Single Inventory)', + 'High Rental Demand & Capital Appreciation', + ], + 'quick_facts' => [ + ['icon' => 'home-modern', 'label' => 'Typen', 'value' => '1BR & 3BR Penthouse'], + ['icon' => 'squares-2x2', 'label' => 'Größe', 'value' => '543 – 2.346 sqft'], + ['icon' => 'building-office-2', 'label' => 'Einheiten', 'value' => 'Nur 132 (Limited)'], + ['icon' => 'user', 'label' => 'Entwickler', 'value' => 'Azizi Developments'], + ], + 'investment_case' => [ + 'title' => 'Starkes Investment, hohe Nachfrage.', + 'text' => 'Creek Views 4 bietet eine strategische Top-Lage in Al Jaddaf. Die Kombination aus limitiertem Angebot (nur 132 Einheiten) und Premium-Ausstattung macht dieses Projekt zur idealen Wahl für Investoren, die auf Wertsteigerung (Capital Appreciation) und hohe Mietnachfrage abzielen.', + 'views' => [ + 'Road View', + 'Sitting & Play Area View', + 'Neighbour View', + ], + ], + 'gallery' => [ + 'expose/a1/image-4.jpeg', + 'expose/a1/image-3.jpeg', + 'expose/a1/image-2.jpeg', + 'expose/a1/image-5.jpeg', + 'expose/a1/image-6.jpeg', + 'expose/a1/image-7.jpeg', + ], + 'location_info' => [ + 'title' => 'Strategische Location: Al Jaddaf', + 'map_url' => 'https://maps.google.com/?q=Al+Jaddaf+Dubai', + 'points' => [ + 'Direkte Anbindung an Dubai Creek und Waterfront', + 'Wenige Minuten bis Downtown Dubai & Burj Khalifa', + 'Exzellente Infrastruktur und wachsender Stadtteil', + ], + ], + 'contact' => [ + 'title' => 'Sichern Sie sich eine der 132 Einheiten.', + 'subtitle' => 'Ihr Ansprechpartner: Marcel Scheibe', + 'options' => [ + '' => 'Ich interessiere mich für...', + '1br' => '1 Bedroom Apartment', + '3br_penthouse' => '3BR Penthouse (Single Unit)', + 'general' => 'Allgemeine Beratung', + ], + ], + ], + ], + 'cta_text' => 'Jetzt Exposé & Verfügbarkeit anfragen', + 'cta_link' => '/contact', + ], + 'immobilien_moebel_vorteil' => [ + 'title' => 'Ihr Investment, Ihr Vorteil', + 'text' => 'Als Käufer einer B2in-Immobilie erhalten Sie exklusiven Insider-Zugang zu unserem B2in-Möbelnetzwerk. Richten Sie Ihre Immobilie zu unschlagbaren Partner-Konditionen ein.', + 'button_text' => 'Mehr zum B2in-Netzwerk', + 'button_link' => '/netzwerk', + ], + 'immobilien_trust' => [ + 'title' => 'Persönliche Begleitung', + 'paragraphs' => [ + 'Hinter jedem B2in-Investment steht Marcel Scheibe als persönlicher Ansprechpartner. Keine anonyme Plattform – sondern ein Gesicht mit Expertise, Netzwerk und dem Anspruch, dass Ihr Investment in den besten Händen ist.', + ], + 'image' => 'b2in/marcel-scheibe-about.jpg', + 'image_alt' => 'Marcel Scheibe – Persönliche Investmentbegleitung', + 'image_caption' => 'Marcel Scheibe, Gründer & CEO', + 'list' => [ + [ + 'icon' => 'calendar', + 'title' => 'Investor Evenings – Exklusive Quartals-Events im kleinen Kreis', + ], + [ + 'icon' => 'phone', + 'title' => 'Direkter Draht – Persönliches Gespräch und Terminvereinbarung', + ], + [ + 'icon' => 'shield-check', + 'title' => 'Transparenz – Von der Marktanalyse bis zum Closing begleitet', + ], + ], + ], + // ============================================================ + // IMMOBILIEN SOFT LAUNCH (v2) - Neue Sektionen + // ============================================================ + 'immobilien_hero_v2' => [ + 'title' => 'Investieren in globale Dynamik. Mit deutscher Verlässlichkeit.', + 'subtitle' => 'Exklusive Off-Market-Projekte, attraktive Renditen und eine Begleitung, die weit über den Kaufvertrag hinausgeht. Entdecken Sie den Immobilienmarkt in Dubai.', + 'cta_text' => 'Aktuelle Projekte ansehen', + 'cta_link' => '#projekte', + 'image' => 'b2in/hero-immobilien.jpg', + 'image_alt' => 'Dubai Skyline – Premium Immobilien-Investments', + ], + 'immobilien_warum_dubai' => [ + 'title' => 'Warum sich ein Investment in Dubai lohnt', + 'intro' => 'Dubai ist nicht nur eine finanzielle Entscheidung, sondern der Zugang zu einem der wachstumsstärksten Märkte der Welt. Investoren schätzen die klaren rechtlichen Strukturen und Rahmenbedingungen, die weltweit nahezu einzigartig sind:', + 'facts' => [ + [ + 'icon' => 'banknotes', + 'title' => '0 % Steuern', + 'description' => 'Keine Einkommensteuer auf Mieteinnahmen, keine Kapitalertragsteuer beim Verkauf.', + ], + [ + 'icon' => 'arrow-trending-up', + 'title' => 'Starke Renditen', + 'description' => 'Attraktive Mietrenditen von historisch 6 % bis 9 % jährlich.', + ], + [ + 'icon' => 'shield-check', + 'title' => 'Hohe Sicherheit', + 'description' => 'Ein staatlich regulierter Markt mit dem sicheren Escrow-Treuhand-System.', + ], + [ + 'icon' => 'currency-dollar', + 'title' => 'Stabile Währung', + 'description' => 'Der Dirham (AED) ist fest an den US-Dollar gekoppelt.', + ], + [ + 'icon' => 'identification', + 'title' => 'Golden Visa', + 'description' => 'Sichern Sie sich Aufenthaltsgenehmigungen über attraktive Investor-Programme.', + ], + [ + 'icon' => 'globe-alt', + 'title' => 'Hohe Nachfrage', + 'description' => 'Internationale Zuwanderung und begrenztes Angebot treiben die Nachfrage nach Wohnraum stetig nach oben.', + ], + ], + ], + 'immobilien_image_break' => [ + 'image' => 'b2in/hero-immobilien.jpg', + 'image_alt' => 'Dubai Skyline – Immobilien-Investments', + 'quote' => 'Dubai hat sich zu einem der dynamischsten Immobilienmärkte der Welt entwickelt.', + 'author' => 'Marcel Scheibe', + ], + 'immobilien_kaufprozess' => [ + 'title' => 'Klar strukturiert: Der Kaufprozess in Dubai', + 'intro' => 'Der Markt in Dubai ist schneller, digitaler und projektbezogen organisiert. Jeder Schritt ist durch das staatliche Escrow-System (Treuhandkonten nach Baufortschritt) maximal abgesichert.', + 'steps' => [ + [ + 'number' => '1', + 'title' => 'Reservierung (Booking Fee)', + 'description' => 'Mit einer Gebühr von ca. 3–10 % wird Ihre Wunsch-Einheit offiziell aus dem Verkauf genommen und für Sie blockiert.', + ], + [ + 'number' => '2', + 'title' => 'Anzahlung & Vertrag (SPA)', + 'description' => 'Nach einer ersten Anzahlung (meist 10 %) wird der offizielle Kaufvertrag, das Sales & Purchase Agreement (SPA), erstellt.', + ], + [ + 'number' => '3', + 'title' => 'Staatliche Registrierung (DLD)', + 'description' => 'Durch die Zahlung der Registrierungsgebühr (4 %) an das Dubai Land Department wird Ihr Eigentum offiziell im staatlichen Register verankert.', + ], + [ + 'number' => '4', + 'title' => 'Finaler Kaufvertrag', + 'description' => 'Ihr Eigentumsrecht ist offiziell gesichert. Die weiteren Zahlungen erfolgen streng nach Baufortschritt auf sichere Treuhandkonten.', + ], + ], + ], + 'immobilien_bruecke' => [ + 'title' => '"Der Markt spricht für sich. Meine Aufgabe ist eine andere."', + 'paragraphs' => [ + 'Für viele deutsche Investoren wirkt der Immobilienkauf in Dubai zunächst ungewohnt. In Deutschland sind wir Notare, Grundbücher und hochbürokratische Prozesse gewohnt. Dubai ist dynamischer.', + 'Ich bin nicht hier, um Ihnen den Markt zu verkaufen – die Qualität der Projekte spricht für sich. Meine Aufgabe ist es, Ihre Brücke zu sein. Als Investor, der selbst in Dubai gekauft hat, kenne ich die Praxis. Ich bin regelmäßig vor Ort, stehe im permanenten Austausch mit Bauträgern und begleite Sie durch den kompletten Prozess. Ich übersetze die internationale Geschwindigkeit in deutsche Verlässlichkeit.', + ], + 'advantage_title' => 'Ihr B2in-Vorteil', + 'advantage_text' => 'Meine Begleitung endet nicht beim Kauf. Planen Sie die lukrative Kurzzeitvermietung (z.B. Airbnb)? Über unser B2in-Netzwerk und meine Wurzeln in der Möbelbranche realisieren wir für Sie komplette Einrichtungskonzepte – für maximale Rendite bei minimalem Aufwand.', + 'cta_text' => 'Persönliches Beratungsgespräch vereinbaren', + 'cta_link' => '/contact', + 'image' => 'b2in/marcel-scheibe-about.jpg', + 'image_alt' => 'Marcel Scheibe – Persönliche Investmentbegleitung', + 'image_caption' => 'Marcel Scheibe, Gründer & CEO', + ], + 'immobilien_mindset' => [ + 'title' => 'Sind Sie der richtige Investor für Dubai?', + 'text_positive' => 'Dubai richtet sich an Menschen, die international denken. Der Staat priorisiert Effizienz, wirtschaftliche Entwicklung und enorme Geschwindigkeit. Wenn Sie an die Verschiebung weltweiter Wirtschaftszentren glauben und ein dynamisches System schätzen, ist Dubai der strategisch perfekte Baustein für Ihr Portfolio. Dann sind Sie bei uns genau richtig.', + 'text_negative' => 'Wenn Sie jedoch ein System bevorzugen, das auf langsamen Entscheidungswegen und maximaler Bürokratie aufbaut, wird dieser Markt nicht zu Ihren Erwartungen passen.', + 'closing' => 'Der Schritt ist kleiner, als Sie denken. Lassen Sie uns gemeinsam herausfinden, ob ein Investment in Dubai in Ihre Strategie passt.', + 'cta_text' => 'Unverbindliches Gespräch vereinbaren', + 'cta_link' => '/contact', + ], + + // ============================================================ + // NETZWERK SOFT LAUNCH - Kombinierte Teaser-Seite + // ============================================================ + 'netzwerk_hero' => [ + 'title' => 'Das B2in Ökosystem – Immobilien trifft Interior.', + 'subtitle' => 'Wir bauen aktuell das intelligenteste Local-for-Local Einrichtungsnetzwerk. Als Immobilienkunde von B2in profitieren Sie in Zukunft exklusiv von unserem Closed-Shop.', + 'image' => 'b2in/ecosystem-hero.jpg', + 'image_alt' => 'B2in Ökosystem – Einrichtung und Immobilien', + ], + 'netzwerk_image_break' => [ + 'image' => 'b2in/ecosystem-hero.jpg', + 'image_alt' => 'B2in Ökosystem – Einrichtung und Immobilien', + 'quote' => 'Wir verbinden, was zusammengehört: Immobilien und Einrichtung.', + 'author' => 'Marcel Scheibe', + ], + 'netzwerk_teasers' => [ + 'title' => 'Was wir aufbauen', + 'cards' => [ + [ + 'icon' => 'squares-2x2', + 'title' => 'Einrichtungsnetzwerk', + 'description' => 'Persönliche Beratung durch lokale Fachexperten, kuratierte europäische Hersteller und exklusive Konditionen für B2in-Immobilienkunden. Einrichtung, die man anfassen kann – nicht nur online bestellen.', + 'status' => 'In Entwicklung', + ], + [ + 'icon' => 'building-storefront', + 'title' => 'Für Händler & Fachhändler', + 'description' => 'Sie sind lokaler Einrichtungsexperte und möchten Teil eines Premium-Netzwerks werden? Wir verbinden Sie mit Kunden, die persönliche Beratung und Qualität schätzen.', + 'status' => 'Vorab-Registrierung möglich', + ], + [ + 'icon' => 'globe-alt', + 'title' => 'Für Entwickler & Marken', + 'description' => 'Deutsche Vertragssicherheit für internationale Immobilienentwickler. Supply-Chain-Management mit Fokus auf Termintreue, Qualitätskontrolle und Durchsetzungskraft.', + 'status' => 'Vorab-Registrierung möglich', + ], + ], + ], + 'netzwerk_cta' => [ + 'title' => 'Interesse an einer Partnerschaft?', + 'text' => 'Ob als Fachhändler, Hersteller, Makler oder Entwickler – kontaktieren Sie uns für eine Vorab-Registrierung und erfahren Sie als Erste, wenn unser Netzwerk live geht.', + 'button_text' => 'Kontakt aufnehmen', + 'button_link' => '/contact', + ], + 'netzwerk_cabinet_partner' => [ + 'badge' => 'Premiumpartner', + 'title' => 'Unser Premiumpartner: CABINET Einbauschränke', + 'lead' => 'Maßgefertigte Exzellenz für höchste Ansprüche.', + 'paragraphs' => [ + 'Im B2in-Ökosystem setzen wir auf Qualität, die Bestand hat. Wir sind stolz darauf, CABINET als Premiumpartner in unserem Netzwerk zu präsentieren. Der CABINET Store in Bielefeld (unser physischer Ankerpunkt) steht für maßgefertigte Einbaulösungen, die Design, Funktionalität und handwerkliche Präzision perfekt vereinen.', + 'Ob begehbare Kleiderschränke, smarte Raumlösungen oder exklusive Innenausbauten – CABINET liefert den Premium-Standard, den unsere Immobilien-Investoren und Entwickler für ihre High-End-Projekte fordern. Diese Partnerschaft ist ein lebendiger Beweis für unsere Philosophie: Wir verbinden internationale Immobilien mit deutscher Einrichtungsexzellenz.', + ], + 'image' => 'b2in/cabinet_logo.png', + 'image_alt' => 'CABINET Einbauschränke – Premiumpartner von B2in', + ], + 'netzwerk_immobilien_hint' => [ + 'title' => 'Schon jetzt verfügbar: Immobilien', + 'description' => 'Unser Immobilienbereich ist bereits aktiv. Entdecken Sie exklusive Off-Market-Projekte in Dubai mit persönlicher Begleitung durch Marcel Scheibe.', + 'button_text' => 'Zu den Immobilien-Projekten', + 'button_link' => '/immobilien', + ], + + 'interior_hero' => [ + 'title' => 'Exklusive Einrichtung. Lokal gedacht, international vernetzt.', + 'subtitle' => 'Das B2in-Einrichtungsnetzwerk verbindet lokale Fachexpertise mit internationalen Herstellern – persönlich, greifbar und in Ihrer Nähe.', + 'features' => [ + [ + 'title' => 'Local-for-Local', + 'description' => 'Ihr Fachhändler vor Ort', + 'icon' => 'map-pin', + ], + [ + 'title' => 'Premium-Netzwerk', + 'description' => 'Kuratierte Hersteller & Marken', + 'icon' => 'star', + ], + [ + 'title' => 'Zwei Marken', + 'description' => 'stileigentum & style2own', + 'icon' => 'squares-2x2', + ], + [ + 'title' => 'Persönlich', + 'description' => 'Beratung statt Algorithmus', + 'icon' => 'user', + ], + ], + 'image' => 'b2in/ecosystem-hero.jpg', + 'image_alt' => 'B2in Einrichtungsnetzwerk – Local-for-Local', + 'card_title' => 'B2in Interior', + 'card_text' => 'Local-for-Local Einrichtungsnetzwerk', + 'hub' => [ + 'title' => 'B2in Interior', + 'subtitle' => 'Das Einrichtungsnetzwerk', + ], + 'stats' => [ + 'Lokale Fachexperten', + 'Europäische Hersteller', + 'Persönliche Beratung', + ], + ], + 'interior_concept' => [ + 'title' => 'Was bedeutet Local-for-Local?', + 'paragraphs' => [ + 'Unser Einrichtungsnetzwerk setzt auf das, was online nicht geht: echte Beratung, echtes Anfassen, echte Expertise. Statt anonymer Plattformen vermitteln wir den direkten Kontakt zu lokalen Fachhändlern – Menschen, die ihr Handwerk verstehen und Sie persönlich begleiten.', + 'B2in verbindet diese lokalen Experten mit einem kuratierten Netzwerk europäischer Hersteller. Das Ergebnis: Zugang zu exklusiven Marken und Sortimenten, die Sie so nicht im Internet finden – aber mit der Beratungsqualität, die nur Ihr Fachhändler vor Ort bieten kann.', + ], + 'image' => 'b2in/ecosystem_result.jpg', + 'image_alt' => 'Local-for-Local – Persönliche Beratung statt anonyme Plattform', + 'image_caption' => 'Persönliche Einrichtungsberatung vor Ort', + ], + 'interior_brands' => [ + 'title' => 'Zwei Marken, ein Netzwerk', + 'subtitle' => 'Je nach Stil und Anspruch finden Sie Ihren Zugang über eine unserer zwei Einrichtungsmarken.', + 'brands' => [ + [ + 'name' => 'stileigentum', + 'tagline' => 'Premium-Einrichtung', + 'description' => 'Exklusive und hochwertige Einrichtungskonzepte für anspruchsvolle Kunden, die Qualität und Tradition schätzen. Handverlesene Hersteller, zeitlose Materialien, individuelle Beratung.', + 'audience' => 'Für Kunden, die das Besondere suchen.', + 'logo' => 'img/logos/stileigentum-logo-positiv.svg', + 'logo_width' => 'w-35', + 'link' => 'https://stileigentum.test', + ], + [ + 'name' => 'style2own', + 'tagline' => 'Design-Lifestyle', + 'description' => 'Moderne Einrichtungskonzepte für Young Professionals und trend-orientierte Kunden – urban, flexibel, inspirierend. Aktuelle Trends, smarte Lösungen, faire Preise.', + 'audience' => 'Für alle, die Design lieben.', + 'logo' => 'img/logos/style2own-logo-positiv.svg', + 'logo_width' => 'w-28', + 'link' => 'https://style2own.test', + ], + ], + ], + 'interior_zielgruppen' => [ + 'title' => 'Für wen ist das Einrichtungsnetzwerk?', + 'groups' => [ + [ + 'icon' => 'home', + 'title' => 'Privatpersonen', + 'description' => 'Sie richten Ihr neues Zuhause ein und wünschen sich persönliche Beratung statt endlosem Online-Scrollen? Über unser Netzwerk finden Sie lokale Fachexperten, die Ihren Stil verstehen.', + ], + [ + 'icon' => 'building-office-2', + 'title' => 'Immobilien-Investoren', + 'description' => 'Sie haben über B2in ein Investment getätigt? Als Immobilienkäufer erhalten Sie exklusiven Zugang zu Partner-Konditionen für die komplette Einrichtung – aus einer Hand.', + ], + [ + 'icon' => 'clipboard-document-check', + 'title' => 'Entwickler & Makler', + 'description' => 'Sie brauchen Mustereinrichtungen, Home-Staging oder eine schlüsselfertige Ausstattung für Ihre Projekte? B2in koordiniert von der Planung bis zur Lieferung.', + ], + ], + ], + 'interior_process' => [ + 'title' => 'So einfach funktioniert es', + 'steps' => [ + [ + 'number' => '01', + 'title' => 'Beratung', + 'description' => 'Kontaktieren Sie uns oder besuchen Sie einen unserer lokalen Partner. Gemeinsam definieren wir Ihren Stil, Ihr Budget und Ihre Wünsche.', + ], + [ + 'number' => '02', + 'title' => 'Auswahl', + 'description' => 'Ihr Fachhändler zeigt Ihnen kuratierte Kollektionen europäischer Hersteller – zum Anfassen, nicht nur auf dem Bildschirm.', + ], + [ + 'number' => '03', + 'title' => 'Lieferung & Einrichtung', + 'description' => 'B2in koordiniert Logistik und Lieferung. Ihr lokaler Partner begleitet die Einrichtung bis zum letzten Detail.', + ], + ], + ], + 'interior_trust' => [ + 'title' => 'Persönlich statt anonym', + 'paragraphs' => [ + 'Hinter dem B2in-Einrichtungsnetzwerk steht Marcel Scheibe mit der Überzeugung, dass gute Einrichtung persönliche Beratung braucht. Kein Algorithmus ersetzt das Gespräch mit einem Experten, der Materialien kennt, Räume versteht und Ihren Stil trifft.', + ], + 'image' => 'b2in/marcel-scheibe-about.jpg', + 'image_alt' => 'Marcel Scheibe – Das Gesicht hinter dem Einrichtungsnetzwerk', + 'image_caption' => 'Marcel Scheibe, Gründer & CEO', + 'list' => [ + [ + 'icon' => 'map-pin', + 'title' => 'Lokale Experten – Fachhändler in Ihrer Region, die Sie persönlich beraten', + ], + [ + 'icon' => 'star', + 'title' => 'Kuratierte Hersteller – Nur geprüfte europäische Qualitätsmarken', + ], + [ + 'icon' => 'shield-check', + 'title' => 'Transparente Preise – Faire Konditionen ohne versteckte Aufschläge', + ], + ], + ], + 'faq' => [ + 'title' => 'Häufig gestellte Fragen', + 'subtitle' => 'Hier finden Sie Antworten auf die häufigsten Fragen zu B2in und unserem Ökosystem.', + 'questions' => [ + [ + 'question' => 'Was ist B2in und welche Services bieten Sie an?', + 'answer' => 'B2in ist die Brücke zwischen internationalen Premium-Immobilien und exklusiven Einrichtungskonzepten. Wir bieten Immobilieninvestoren Zugang zu Off-Market-Projekten (Fokus: Dubai) und begleiten sie durch den gesamten Kaufprozess. Gleichzeitig bieten wir Bauträgern ein knallhartes Supply-Chain-Management für die Beschaffung deutscher Qualitätsmöbel und bauen ein innovatives Einrichtungsnetzwerk für lokale Händler auf.', + ], + [ + 'question' => 'Was bedeutet Supply-Chain-Management bei B2in?', + 'answer' => 'Für internationale Immobilienentwickler fungieren wir als verlängerter Arm in Deutschland. Wir übernehmen die operative und strategische Steuerung der Möbel- und Innenausstattungsbeschaffung. Das bedeutet: Wir sichern Lieferverträge ab, überwachen Meilensteine direkt bei den Herstellern und eskalieren bei Abweichungen sofort auf Managementebene. Unser Ziel ist absolute Vertragssicherheit und termingerechte Lieferung ohne Reibungsverluste.', + ], + [ + 'question' => 'Wie kann ich Partner bei B2in werden?', + 'answer' => 'Unser Netzwerk richtet sich an Immobilienentwickler, Makler und den regionalen Möbelfachhandel. Wenn Sie als Entwickler deutsche Beschaffungssicherheit suchen oder als Händler Teil unseres künftigen "Local for Local"-Netzwerks werden möchten, um qualifizierte Leads von Immobilienkäufern zu erhalten, nutzen Sie einfach unser Kontaktformular. Wir prüfen jede Partnerschaft individuell auf Qualität und Passgenauigkeit.', + ], + [ + 'question' => 'Wie funktioniert das Immobilien-Investment über B2in?', + 'answer' => 'Wir vermitteln nicht nur, wir begleiten Sie. Marcel Scheibe ist Ihr persönlicher Berater, der den Markt (insbesondere Dubai) aus eigener Investorensicht kennt. Nach einer Bedarfsanalyse stellen wir Ihnen exklusive Projekte vor (z. B. von Azizi Developments). Der Kaufprozess selbst ist durch das staatliche Escrow-System in Dubai maximal abgesichert. Ihr besonderer Vorteil: Als B2in-Kunde erhalten Sie im Anschluss exklusiven Zugang zu unserem Netzwerk, um Ihr Investment schlüsselfertig (Turnkey) und renditeoptimiert einrichten zu lassen.', + ], + [ + 'question' => 'Was macht B2in zu einem vertrauenswürdigen Partner?', + 'answer' => 'Vertrauen entsteht durch Transparenz und eigene Markterfahrung. B2in-Gründer Marcel Scheibe investiert selbst in die Märkte, die wir anbieten, und ist regelmäßig vor Ort, um Baufortschritte zu prüfen. Wir verbinden die immense Dynamik internationaler Märkte (wie Dubai) mit deutscher Verlässlichkeit, striktem Vertragsmanagement und einem kuratierten Netzwerk von Premiumpartnern wie CABINET. Bei uns haben Sie immer einen persönlichen Ansprechpartner, der Ihre Interessen vertritt.', + ], + ], + 'sections' => [ + [ + 'title' => 'Fokus: Immobilien-Investoren (B2C)', + 'icon' => 'home-modern', + 'questions' => [ + [ + 'question' => 'Muss ich für den Immobilienkauf persönlich nach Dubai reisen?', + 'answer' => 'Nein, der gesamte Kaufprozess kann vollständig digital und rechtsverbindlich aus der Ferne abgewickelt werden. Das Dubai Land Department bietet hierfür hochsichere Prozesse. Wir empfehlen unseren Investoren zwar gerne, sich vor Ort ein Bild von der Dynamik der Stadt zu machen, für den rechtssicheren Erwerb ist Ihre physische Anwesenheit jedoch nicht zwingend erforderlich.', + ], + [ + 'question' => 'Was passiert nach dem Immobilienkauf? Helfen Sie bei der Einrichtung?', + 'answer' => 'Genau hier liegt der große Vorteil des B2in-Ökosystems. Unser Service endet nicht mit dem Kaufvertrag. Als B2in-Kunde erhalten Sie exklusiven Zugang zu unserem Einrichtungsnetzwerk. Wir unterstützen Sie dabei, Ihre Immobilie renditeoptimiert und schlüsselfertig („Turnkey") mit Qualitätsmöbeln auszustatten – die perfekte Grundlage für eine lukrative Kurzzeitvermietung.', + ], + [ + 'question' => 'Welche steuerlichen Vorteile bietet ein Investment in Dubai?', + 'answer' => 'Dubai bietet ein weltweit einzigartiges wirtschaftliches Umfeld. Es fallen weder Einkommensteuer auf Mieteinnahmen noch Kapitalertragsteuer beim Verkauf der Immobilie an. In Kombination mit den starken Mietrenditen macht dies den Standort für internationale Anleger strategisch so wertvoll.', + ], + ], + ], + [ + 'title' => 'Fokus: B2B-Partner (Makler & Händler)', + 'icon' => 'briefcase', + 'questions' => [ + [ + 'question' => 'Wie profitieren Immobilienmakler von einer Partnerschaft mit B2in?', + 'answer' => 'B2in bietet Maklern ein exklusives Werkzeug zur Kundenbindung: Sie können Ihren Immobilienkäufern den Zugang zu unserem geschlossenen Einrichtungsnetzwerk als exklusives „Closing-Geschenk" überreichen. Gleichzeitig profitieren Sie als Makler durch unser technisches Clearing-System von einer passiven Lifetime-Vergütung an den Möbelumsätzen Ihrer Kunden.', + ], + [ + 'question' => 'Was genau ist der „Local for Local" Marktplatz?', + 'answer' => 'Unser „Local for Local"-Prinzip ist der Gegenentwurf zu anonymen Online-Möbelgiganten. Wir machen die sofort verfügbaren Bestände und Ausstellungsstücke regionaler Fachhändler (Säule „Local Express") für einen geschlossenen Kundenkreis transparent. So stärken wir den lokalen Handel vor Ort und bieten Käufern gleichzeitig Insider-Konditionen für Premium-Einrichtung.', + ], + [ + 'question' => 'Ist das Einrichtungsnetzwerk für jeden öffentlich zugänglich?', + 'answer' => 'Nein. Um die exklusiven Insider-Konditionen und Rabatte unserer Hersteller und lokalen Händler zu schützen, agiert der B2in-Möbelmarktplatz als „Closed Shop". Der Zugang erfolgt ausschließlich über Einladungen – beispielsweise durch unsere Partner-Makler beim Erwerb einer Immobilie.', + ], + ], + ], + ], + ], + 'contact_form' => [ + 'hero' => [ + 'title' => 'Senden Sie uns eine
Nachricht.', + 'subtitle' => 'Wir freuen uns auf Ihre Nachricht und werden uns schnellstmöglich bei Ihnen melden.', + ], + 'form' => [ + 'labels' => [ + 'first_name' => 'Vorname *', + 'last_name' => 'Nachname *', + 'company' => 'Firma (optional)', + 'email' => 'E-Mail *', + 'phone' => 'Telefon (optional)', + 'subject' => 'Betreff *', + 'message' => 'Ihre Nachricht *', + ], + 'subjects' => [ + '' => 'Wählen Sie einen Betreff', + 'immobilien' => 'Internationale Immobilien', + 'supply_chain' => 'Supply-Chain-Management', + 'general' => 'Allgemeine Anfrage', + 'partnership' => 'Partnerschaft', + 'press' => 'Presse', + 'career' => 'Karriere', + ], + 'placeholders' => [ + 'message' => 'Ihre Nachricht...', + ], + 'button_text' => 'Senden', + 'button_loading' => 'Wird gesendet...', + 'success_message' => 'Vielen Dank für Ihre Nachricht! Wir werden uns schnellstmöglich bei Ihnen melden.', + ], + 'contact_info' => [ + [ + 'title' => 'Unser Büro-Standort', + 'info' => [ + 'Rathausstraße 11', + '33602 Bielefeld', + ], + 'icon' => 'map-pin', + ], + [ + 'title' => 'Unsere E-Mail-Adresse', + 'info' => [ + 'info@b2in.eu', + ], + 'icon' => 'mail', + ], + [ + 'title' => 'Unser Kontakt-Nummer', + 'info' => [ + '+49 (0) 5221 9255055', + ], + 'icon' => 'phone', + ], + ], + 'social_media' => [ + 'title' => 'Follow for
exclusives', + 'subtitle' => 'Bleiben Sie auf dem Laufenden mit exklusiven Angeboten und News.', + 'platforms' => [ + ['name' => 'Instagram', 'handle' => '@b2in_official', 'url' => 'https://instagram.com/b2in_official'], + ['name' => 'Youtube', 'handle' => 'B2IN Channel', 'url' => 'https://youtube.com/@b2in'], + ['name' => 'Pinterest', 'handle' => 'B2IN Inspiration', 'url' => 'https://pinterest.com/b2in'], + ['name' => 'Facebook', 'handle' => 'B2IN Deutschland', 'url' => 'https://facebook.com/b2in'], + ['name' => 'LinkedIn', 'handle' => 'B2IN Company', 'url' => 'https://linkedin.com/company/b2in'], + ], + ], + ], + 'about_hero' => [ + 'title' => 'Über B2in: Unsere Mission', + 'quote' => '"Meine Mission ist es, zwei Welten zu verbinden, die zusammengehören: internationale Immobilien und exklusive Einrichtung. B2in gibt dem lokalen Fachexperten die digitalen Werkzeuge und den Immobilienentwickler den operativen Partner vor Ort.

Wir bauen Brücken – zwischen europäischem Design, internationalen Märkten und dem Zuhause der Menschen."', + 'founder_name' => 'Marcel Scheibe', + 'founder_title' => 'Gründer & CEO, B2in', + 'image' => 'b2in/about-hero.jpg', + 'image_alt' => 'Marcel Scheibe, Gründer und CEO von B2in', + + 'card_title' => 'B2in', + 'card_text' => 'Connecting Design and Property', + + ], + 'broker_section' => [ + 'title' => 'Lifetime Vergütung für Makler', + 'subtitle' => 'Profitieren Sie von einem revolutionären Vergütungsmodell, das über den einmaligen Verkauf hinausgeht. Bauen Sie langfristige Kundenbeziehungen auf und generieren Sie kontinuierliche Erträge.', + 'card_title' => 'Lifetime-Vergütung', + 'compensation' => [ + 'initial_sale' => '3.5%', + 'follow_up' => '1.5%', + ], + 'compensation_text' => 'Kontinuierliche Erträge über die gesamte Kundenbeziehung', + 'benefits' => [ + [ + 'title' => 'Lifetime-Vergütungsmodell', + 'description' => 'Kontinuierliche Provisionen durch langfristige Kundenbeziehungen und wiederkehrende Geschäfte', + 'icon' => 'trending-up', + ], + [ + 'title' => 'Schnellere Vermarktung', + 'description' => 'Durchdachte Wohnkonzepte reduzieren die Verkaufszeit und erhöhen die Erfolgschancen', + 'icon' => 'clock', + ], + [ + 'title' => 'Qualifizierte Leads', + 'description' => 'Vorgefilterte, interessierte Kunden durch das B2in-Portal und Premium-Mitgliedschaften', + 'icon' => 'target', + ], + [ + 'title' => 'Premium-Positioning', + 'description' => 'Exklusive Vermarktung hochwertiger Wohnkonzepte für anspruchsvolle Zielgruppen', + 'icon' => 'award', + ], + ], + ], + 'commitment_section' => [ + 'title' => 'Das Vertrauen unserer Partner', + 'subtitle' => 'Echte Meinungen von echten Partnern. Ihr Erfolg ist unser größter Ansporn.', + 'testimonials' => [ + [ + 'image' => 'b2in/testo-1.jpg', + 'rating' => 5, + 'quote' => 'Die Zusammenarbeit mit B2in hat unsere Erwartungen übertroffen. Professionell, effizient und immer lösungsorientiert.', + 'author' => 'Max Mustermann', + 'author_title' => 'Möbelhersteller', + ], + [ + 'image' => 'b2in/testo-2.jpg', + 'rating' => 5, + 'quote' => 'Dank der B2in-Plattform konnten wir unsere Reichweite signifikant erhöhen und neue Märkte erschließen.', + 'author' => 'Erika Mustermann', + 'author_title' => 'lokaler Möbelhändler', + ], + [ + 'image' => 'b2in/testo-3.jpg', + 'rating' => 5, + 'quote' => 'Das B2in-Portal hat die Art, wie ich Immobilien vermarkte, revolutioniert. Das Staging wertet meine Objekte auf, und die Möbelprovision ist ein extrem attraktiver Zusatzverdienst.', + 'author' => 'John Doe', + 'author_title' => 'Immobilienmakler', + ], + ], + ], + 'dark_stats_section' => [ + 'stats' => [ + ['number' => '17+', 'text' => 'Years of Experience'], + ['number' => '2M', 'text' => 'Happy Guests'], + ], + 'title' => 'Economically Sound and Well-
Friendly Service for
Families and Their
Precious Belongings', + 'description' => 'We understand that every family is unique, which is why we offer personalized services tailored to meet your specific needs. From luxury amenities to budget-friendly options, we ensure every guest feels valued and comfortable.', + 'features' => [ + ['title' => 'Top Consultation', 'subtitle' => 'Expert guidance'], + ['title' => 'Finest Meal', 'subtitle' => 'Gourmet dining'], + ['title' => 'Easy Tax Reduction', 'subtitle' => 'Cost-effective'], + ], + 'image' => 'b2in/accommodation-2.jpg', + 'image_alt' => 'Luxury interior design', + ], + 'ecosystem_hero' => [ + 'title' => 'Wie unser Ökosystem Wachstum für alle Partner generiert', + 'subtitle' => 'Ein intelligentes Netzwerk, das Immobilienkäufer, lokale Fachexperten, internationale Hersteller, Makler und Entwickler nahtlos miteinander verbindet – für außergewöhnliche Immobilien- und Einrichtungserlebnisse.', + 'features' => [ + [ + 'title' => 'Immobilien', + 'description' => 'Internationale Investments', + 'icon' => 'globe-alt', + ], + [ + 'title' => 'Einrichtung', + 'description' => 'Local for Local', + 'icon' => 'cube-transparent', + ], + [ + 'title' => 'Supply Chain', + 'description' => 'Beschaffung & Kontrolle', + 'icon' => 'clipboard-document-check', + ], + [ + 'title' => 'Technologie', + 'description' => 'Digitales Herzstück', + 'icon' => 'cpu-chip', + ], + ], + 'image' => 'b2in/ecosystem-hero.jpg', + 'image_alt' => 'Ecosystem Hero Image', + 'card_title' => 'B2in Portal', + 'card_text' => 'Die Technologie dahinter, die das Ökosystem ermöglicht', + 'hub' => [ + 'title' => 'B2in Portal', + 'subtitle' => 'Die Technologie dahinter, die das Ökosystem ermöglicht', + ], + 'stats' => [ + 'Exklusive Auswahl', + 'Persönlicher Service', + 'Werte die bleiben', + ], + ], + 'ecosystem_stats' => [ + 'title' => 'Unser Ecosystem in Zahlen', + 'subtitle' => 'Zahlen, die die Stärke und das Vertrauen in unser vernetztes Geschäftsmodell widerspiegeln.', + 'stats' => [ + [ + 'number' => '1,7K+', + 'label' => 'Partner & Experten im Netzwerk', + 'description' => 'Wachsende Community von Entwicklern, Maklern, Händlern und Herstellern', + ], + [ + 'number' => '510+', + 'label' => 'Realisierte Projekte', + 'description' => 'Immobilien- und Einrichtungsprojekte durch unser Netzwerk', + ], + [ + 'number' => '98%', + 'label' => 'Partner-Zufriedenheit', + 'description' => 'Zufriedenheit über alle Ecosystem-Teilnehmer hinweg', + ], + [ + 'number' => '24/7', + 'label' => 'Partner-Support', + 'description' => 'Kontinuierliche Verfügbarkeit der digitalen Infrastruktur', + ], + ], + ], + 'ecosystem_start' => [ + 'title' => 'Alles beginnt mit dem Moment of Need:', + 'paragraphs' => [ + 'Unser Ökosystem startet beim Kunden – genau dann, wenn er ihn braucht: beim Immobilienkauf.', + 'Über den Makler erhält der Kunde Zugang zum B2in-Ökosystem. Unsere Marken style2own und stileigentum schaffen den passenden Rahmen – je nach Zielgruppe und Lebensstil.', + ], + 'image' => 'b2in/ecosystem_start.jpg', + 'image_alt' => 'Der Einstieg ins Ökosystem – über den Immobilienkauf', + 'image_caption' => 'Der Einstieg: Immobilienkauf als Trigger', + 'image_description' => 'Eine stilisierte Grafik, die zeigt, wie der Immobilienkauf den Einstieg ins B2in-Ökosystem auslöst.', + ], + 'ecosystem_hub' => [ + 'title' => 'Im Hub trifft Local for Local auf internationale Expertise', + 'paragraphs' => [ + 'Sobald ein Kunde seine Region wählt, spielt unsere Plattform ihre Stärke aus: Die "Local First"-Logik zeigt die Angebote der lokalen Fachexperten prominent an.', + 'Ergänzt wird das Sortiment durch europäische Hersteller. Und für Immobilienentwickler liefern wir die operative Beschaffung gleich mit – vom Vertrag bis zur Qualitätskontrolle.', + ], + 'image' => 'b2in/ecosystem_hub.jpg', + 'image_alt' => 'Local for Local – lokale Expertise trifft internationale Beschaffung', + 'image_caption' => 'Local for Local: Lokale Expertise, internationale Reichweite', + 'image_description' => 'Eine klare Infografik, die zeigt: [Lokales Angebot] + [Internationale Beschaffung] = Das B2in-Ökosystem.', + ], + 'ecosystem_result' => [ + 'title' => 'Ein Kreislauf, in dem jeder gewinnt', + 'paragraphs' => [ + 'In diesem Zusammenspiel entstehen klare Vorteile für jeden Teilnehmer:', + ], + 'list' => [ + [ + 'icon' => 'globe-alt', + 'title' => 'Der Immobilienentwickler erhält einen verlässlichen Partner für Beschaffung und Qualitätskontrolle in Deutschland – transparent und termingerecht.', + ], + [ + 'icon' => 'building-storefront', + 'title' => 'Der lokale Händler gewinnt qualifizierte Kunden, die er alleine nicht erreicht hätte, und stärkt seine Position vor Ort.', + ], + [ + 'icon' => 'home-modern', + 'title' => 'Der Makler bietet seinen Kunden einen einzigartigen Mehrwert nach dem Abschluss und profitiert von attraktiven Zusatzprovisionen.', + ], + ], + 'image' => 'b2in/ecosystem_result.jpg', + 'image_alt' => 'Ihr Erfolg und die Partnerschaft mit B2in', + 'image_caption' => 'Ihr Erfolg und die Partnerschaft mit B2in', + 'image_description' => 'Eine stilisierte Grafik, die zeigt, wie Ihr Erfolg und die Partnerschaft mit B2in zusammenhängen.', + ], + 'end_customer_section' => [ + 'tag' => 'Für Endkunden', + 'title' => 'Exklusive Erlebnisse für Sie', + 'subtitle' => 'Mit Ihrer persönlichen Login-Karte erhalten Sie Zugang zu einer einzigartigen Erlebniswelt, die speziell auf Ihre Wohnwünsche und Lebensstil abgestimmt ist. Entdecken Sie kuratierte Immobilien und Services, die sonst nicht verfügbar sind.', + 'benefits' => [ + [ + 'title' => 'Exklusive Login-Karte', + 'description' => 'Personalisierter Zugang zu ausgewählten Immobilienerlebnissen und Premium-Services', + 'icon' => 'credit-card', + ], + [ + 'title' => 'Personalisierte Erlebniswelt', + 'description' => 'Maßgeschneiderte Immobilienangebote basierend auf individuellen Präferenzen und Bedürfnissen', + 'icon' => 'star', + ], + [ + 'title' => 'Kuratierte Wohnkonzepte', + 'description' => 'Hochwertige, durchdachte Immobilienlösungen von verifizierten Partnern', + 'icon' => 'home', + ], + [ + 'title' => 'Qualitätsgarantie', + 'description' => 'Geprüfte Anbieter und standardisierte Qualitätsprozesse für maximale Sicherheit', + 'icon' => 'shield', + ], + ], + 'image' => 'b2in/end-customer-section.jpg', + 'image_alt' => 'End Customer Section Image', + 'card_title' => 'Login-Karte', + 'card_text' => 'Ihr Schlüssel zu exklusiven Immobilienerlebnissen', + 'card' => [ + 'title' => 'Login-Karte', + 'subtitle' => 'Ihr Schlüssel zu exklusiven Immobilienerlebnissen', + 'member_number_label' => 'Mitgliedsnummer', + 'member_number' => 'B2IN-2024-VIP', + ], + ], + 'final_commitment' => [ + 'title' => 'Were committed to
your comfort and
satisfaction for
unforgettable
experiences', + 'author' => 'Robert Wilson', + 'author_title' => 'General Manager', + ], + 'digital_core' => [ + 'title' => 'Die Technologie, die diesen Kreislauf ermöglicht', + 'subtitle' => 'Unsere zentrale Plattform ist mehr als nur Technologie. Sie ist das digitale Herzstück für transparente Prozesse, datengestützte Entscheidungen und nahtlose Zusammenarbeit im gesamten Ökosystem.', + 'features' => [ + [ + 'title' => 'Maximale Zuverlässigkeit', + 'description' => 'Unsere Plattform ist jederzeit und von überall erreichbar. Sie wächst mit Ihrem Erfolg und garantiert einen stabilen Betrieb, auf den Sie sich verlassen können.', + 'icon' => 'cloud', + 'icon_style' => 'solid', + ], + [ + 'title' => 'Kompromisslose Sicherheit', + 'description' => 'Ihre Daten und die Ihrer Kunden sind unser höchstes Gut. Wir schützen sie mit modernsten Sicherheitsarchitekturen und garantieren vollsten Datenschutz.', + 'icon' => 'shield-check', + ], + [ + 'title' => 'Zukunftsfähige Integration', + 'description' => 'Das B2in-Portal ist offen für die Zukunft. Es lässt sich nahtlos in andere Systeme integrieren und ist bereit für zukünftige Erweiterungen und Technologien.', + 'icon' => 'squares-2x2', + ], + [ + 'title' => 'Datenbasierte Einblicke', + 'description' => 'Verfolgen Sie als Makler die Aktivitäten Ihrer Kunden oder als Lieferant die Performance Ihrer Produkte – alles in Echtzeit. Treffen Sie bessere Entscheidungen auf Basis valider Daten.', + 'icon' => 'chart-bar', + 'icon_style' => 'solid', + ], + [ + 'title' => 'Intelligente Personalisierung', + 'description' => 'Künstliche Intelligenz unterstützt Sie und Ihre Kunden – von personalisierten Einrichtungsvorschlägen bis zur Automatisierung von Prozessen für maximale Effizienz.', + 'icon' => 'cpu-chip', + ], + [ + 'title' => 'Exzellente User Experience', + 'description' => 'Unsere Plattform ist für maximale Geschwindigkeit optimiert und bietet auf jedem Gerät – vom Desktop bis zum Smartphone – eine intuitive und flüssige Bedienung.', + 'icon' => 'users', + 'icon_style' => 'solid', + ], + ], + ], + 'magazin_detail' => [ + 'back_to_magazine' => 'Zurück zum Magazin', + 'share_article' => 'Artikel teilen', + 'cta_title' => 'Entdecken Sie mehr über Immobilien-Investments und das B2in-Ökosystem.', + 'cta_button' => 'Weitere Artikel entdecken', + ], + 'magazin_list' => [ + 'title' => 'B2in Magazin', + 'subtitle' => 'Insights, Marktanalysen und Praxiswissen rund um Immobilien-Investments in Dubai, Einrichtungskonzepte und das B2in-Ökosystem.', + 'read_more' => 'Weiterlesen', + 'load_more' => 'Weitere Artikel laden', + ], + 'our_story' => [ + 'title' => 'Unsere Geschichte', + 'timeline' => [ + [ + 'title' => 'Die Idee', + 'description' => '2024 erkannten wir eine entscheidende Lücke: Der lokale Fachhandel braucht digitale Sichtbarkeit und Immobilienkäufer suchen nach nahtlosen Einrichtungslösungen. Diese beiden Welten gehören zusammen.', + 'icon' => 'light-bulb', + ], + [ + 'title' => 'Das Fundament', + 'description' => 'Wir entwickeln die B2in-Plattform: ein Ökosystem, das lokale Einrichtungsexperten, europäische Hersteller und Immobilienprofis auf einer gemeinsamen Plattform verbindet – fair, transparent und technologiegestützt.', + 'icon' => 'rocket-launch', + ], + [ + 'title' => 'Die Erweiterung', + 'description' => '2025/2026 erweitert B2in sein Ökosystem: Internationale Immobilien und Supply-Chain-Management für Entwickler werden zur dominanten Säule. Der lokale Möbelmarktplatz bleibt als starker ergänzender Bereich bestehen.', + 'icon' => 'globe-alt', + ], + [ + 'title' => 'Die Vision', + 'description' => 'B2in wird zum zentralen Netzwerk für "Design & Property" – regional verwurzelt, international vernetzt. In jeder Region der verlässliche Partner für Immobilien-Investments und exklusive Einrichtungskonzepte.', + 'icon' => 'star', + ], + ], + 'summary' => 'Was als Vision begann, den lokalen Handel zu stärken, ist heute ein Netzwerk, das Immobilien und Einrichtung nahtlos verbindet. B2in schließt die Lücke zwischen internationalen Investments und lokaler Expertise – mit Marcel Scheibe als Gesicht und Ansprechpartner.', + ], + 'about_image_break' => [ + 'image' => 'b2in/best-of-two-worlds.jpg', + 'image_alt' => 'B2in – Immobilien und Einrichtung verbinden', + 'quote' => 'Regional verwurzelt, international vernetzt – das ist B2in.', + 'author' => 'Marcel Scheibe', + ], + 'our_values' => [ + 'title' => 'Unsere Werte', + 'subtitle' => 'Diese sechs Grundpfeiler leiten unser tägliches Handeln und definieren, wer wir als Unternehmen sind und wofür wir stehen.', + 'values' => [ + [ + 'title' => 'Innovation', + 'description' => 'Wir entwickeln digitale Lösungen, die Immobilienprofis und lokale Einrichtungsexperten gleichermaßen einen echten Wettbewerbsvorteil verschaffen.', + 'icon' => 'light-bulb', + ], + [ + 'title' => 'Konnektivität', + 'description' => 'Wir verbinden internationale Immobilienmärkte mit lokaler Expertise und europäische Manufakturen mit den Menschen, die ihre Produkte schätzen.', + 'icon' => 'globe-alt', + ], + [ + 'title' => 'Qualität', + 'description' => 'Kompromisslose Standards – bei der Auswahl unserer Partner, der Kuration der Einrichtung, der Überwachung von Lieferketten und der Technologie, die alles zusammenhält.', + 'icon' => 'check-badge', + ], + [ + 'title' => 'Vertrauen', + 'description' => 'Transparente Modelle und verlässliche Partnerschaften sind unser Fundament. Hinter B2in steht ein Gesicht – und das Versprechen, dass wir liefern, was wir zusagen.', + 'icon' => 'user-group', + ], + [ + 'title' => 'Nachhaltigkeit', + 'description' => 'Wir stärken den lokalen Handel, optimieren Transportwege und sorgen dafür, dass internationale Beschaffung verantwortungsvoll und effizient abläuft.', + 'icon' => 'arrow-path', + ], + [ + 'title' => 'Design-Exzellenz', + 'description' => 'Design ist der Kern unserer Wertschöpfung – von internationaler Architektur über kuratierte Einrichtung bis zur intuitiven Gestaltung unserer digitalen Plattform.', + 'icon' => 'cube-transparent', + ], + ], + ], + 'partner_benefits' => [ + 'title' => 'Warum Partner werden?', + 'subtitle' => 'Entdecken Sie die Vorteile einer Partnerschaft mit B2in und wie Sie von unserem innovativen Ecosystem profitieren können.', + 'broker' => [ + 'tag' => 'Für Makler', + 'title' => 'Revolutionäres Provisionsmodell', + 'benefits' => [ + [ + 'icon' => 'trending-up', + 'title' => 'Lifetime-Provisionsmodell', + 'description' => 'Profitieren Sie von kontinuierlichen Einnahmen durch unser innovatives Vergütungssystem', + ], + [ + 'icon' => 'target', + 'title' => 'Schnellere Vermarktung', + 'description' => 'Durchdachte Wohnkonzepte verkürzen Vermarktungszeiten und erhöhen Ihre Erfolgsquote', + ], + [ + 'icon' => 'award', + 'title' => 'Mehrwert für Ihre Kunden', + 'description' => 'Bieten Sie Ihren Kunden exklusive, kuratierte Immobilienerlebnisse', + ], + ], + 'highlight' => [ + 'value' => '3.5% - ∞', + 'text' => 'Erstprovision bis Lifetime-Vergütung', + ], + ], + 'supplier' => [ + 'tag' => 'Für Lieferanten', + 'title' => 'Globale Marktchancen', + 'benefits' => [ + [ + 'icon' => 'globe', + 'title' => 'Zugang zu internationalen Märkten', + 'description' => 'Erweitern Sie Ihre Reichweite über Grenzen hinweg mit unserem globalen Netzwerk', + ], + [ + 'icon' => 'handshake', + 'title' => 'Faire Konditionen', + 'description' => 'Transparente und partnerschaftliche Geschäftsbedingungen für nachhaltigen Erfolg', + ], + [ + 'icon' => 'settings', + 'title' => 'Einfache Produktverwaltung', + 'description' => 'Intuitive Plattform für die Verwaltung und Präsentation Ihrer Produkte', + ], + ], + 'highlight' => [ + 'image' => 'b2in/accommodation-1.jpg', + 'alt' => 'Partner success visualization', + 'value' => '500+', + 'text' => 'Erfolgreiche Partner', + ], + ], + ], + 'supply_chain_intro' => [ + 'title' => 'Supply-Chain-Management: Ihr verlängerter Arm in Deutschland.', + 'paragraphs' => [ + 'Für Immobilienentwickler, die Möbel oder Innenausstattung aus Deutschland beziehen möchten, fungieren wir als verlängerter Arm vor Ort – mit klarem Fokus auf Vertragssicherheit, Termintreue und Durchsetzungskraft.', + ], + 'list' => [ + [ + 'icon' => 'document-check', + 'title' => 'Vertragsmanagement – Ausarbeitung, Strukturierung und Absicherung von Lieferverträgen', + ], + [ + 'icon' => 'shield-check', + 'title' => 'Vertragssicherung & Durchsetzung – Meilenstein-Überwachung, Eskalation, Nachverfolgung', + ], + [ + 'icon' => 'magnifying-glass-circle', + 'title' => 'Tracking & Qualitätskontrolle – Laufende Überwachung, persönliche Kontrolle, termingerechte Lieferung', + ], + ], + 'image' => 'b2in/marcel-scheibe-about.jpg', + 'image_alt' => 'Marcel Scheibe – Supply-Chain-Management', + 'image_caption' => 'Marcel Scheibe, Gründer & CEO', + ], + 'partner_cta' => [ + 'title' => 'Wachsen Sie mit uns', + 'subtitle' => 'Werden Sie Teil des B2in-Netzwerks – ob als Immobilienentwickler, Einrichtungsexperte, Hersteller oder Makler. Wir verbinden die Welten, die zusammengehören.', + 'stats' => [ + [ + 'number' => '500+', + 'label' => 'Aktive Partner', + ], + [ + 'number' => '98%', + 'label' => 'Zufriedenheitsrate', + ], + [ + 'number' => '24/7', + 'label' => 'Partner-Support', + ], + ], + 'button_text' => 'Werden Sie B2in Partner', + 'button_link' => '/contact', + 'small_text' => 'Entdecken Sie die Vorteile einer strategischen Partnerschaft mit B2in', + ], + 'partner_hero' => [ + 'title' => 'Für Entwickler & Partner', + 'subtitle' => 'Ob Immobilienentwickler, lokaler Einrichtungsexperte, europäische Marke oder Makler – B2in ist das Netzwerk, das Ihr Geschäft mit den richtigen Partnern und Kunden verbindet.', + 'partner_types' => [ + [ + 'title' => 'Immobilienentwickler', + 'description' => 'Supply-Chain-Management aus Deutschland', + 'icon' => 'globe-alt', + ], + [ + 'title' => 'Hersteller & Marken', + 'description' => 'Kuratierter Marktzugang, intelligente Logistik', + 'icon' => 'building-office-2', + ], + [ + 'title' => 'Lokale Händler', + 'description' => 'Digitale Reichweite, exklusives Sortiment', + 'icon' => 'building-storefront', + ], + [ + 'title' => 'Makler & Bauträger', + 'description' => 'Mehrwert für Kunden, Zusatzertrag für Sie', + 'icon' => 'home-modern', + ], + ], + 'image' => 'b2in/partner-hero.jpg', + 'image_alt' => 'Partner Hero Image', + 'card_title' => 'Partner Network', + 'card_text' => 'Werden Sie Teil unseres Ecosystems', + 'hub' => [ + 'title' => 'Partner Network', + 'subtitle' => 'Werden Sie Teil unseres Ecosystems', + ], + 'connection_points' => [ + ['name' => 'Makler', 'subtext' => 'Lifetime-Modell'], + ['name' => 'Lieferanten', 'subtext' => 'Global Markets'], + ['name' => 'Erfolg', 'subtext' => 'Messbare Ziele'], + ['name' => 'Qualität', 'subtext' => 'Premium Standards'], + ], + ], + 'partner_card_section' => [ + 'title' => 'Welcher Partner-Typ sind Sie?', + 'subtitle' => 'Entdecken Sie die Vorteile einer Partnerschaft mit B2in – ob im Bereich Immobilien, Einrichtung oder Supply Chain.', + 'cards' => [ + [ + 'title' => 'Für Immobilienentwickler', + 'description' => 'Operative Steuerung Ihrer Beschaffung aus Deutschland – Vertragsmanagement, Qualitätskontrolle und termingerechte Lieferung.', + 'icon' => 'globe-alt', + 'button' => '#partner-benefits-developer', + 'button_text' => 'Ihre Vorteile als Entwickler', + ], + [ + 'title' => 'Für lokale Händler & Fachexperten', + 'description' => 'Stärken Sie Ihr Geschäft vor Ort. Erhalten Sie Zugang zu Online-Kunden und einem exklusiven, überregionalen Sortiment.', + 'icon' => 'building-storefront', + 'button' => '#partner-benefits-retailer', + 'button_text' => 'Ihre Vorteile als Händler', + ], + [ + 'title' => 'Für Hersteller & europäische Marken', + 'description' => 'Erschließen Sie neue, kuratierte Vertriebskanäle in regionalen Märkten und profitieren Sie von unserer intelligenten Logistik.', + 'icon' => 'building-office-2', + 'button' => '#partner-benefits-supplier', + 'button_text' => 'Ihre Vorteile als Hersteller', + ], + [ + 'title' => 'Für Immobilienmakler & Bauträger', + 'description' => 'Bieten Sie Ihren Kunden einen einzigartigen Mehrwert, beschleunigen Sie die Vermarktung und sichern Sie sich attraktive Zusatzprovisionen.', + 'icon' => 'home-modern', + 'button' => '#partner-benefits-broker', + 'button_text' => 'Ihre Vorteile als Makler', + ], + ], + ], + 'partner_benefits_developer' => [ + 'id' => 'partner-benefits-developer', + 'tag' => 'Ihre Vorteile als Immobilienentwickler', + 'tag_icon' => 'globe-alt', + 'tag_title' => 'Ihr verlängerter Arm in Deutschland.', + 'features' => [ + [ + 'title' => 'Vertragsmanagement', + 'description' => 'Unterstützung bei der Ausarbeitung und Strukturierung von Lieferverträgen. Definition klarer Leistungs- und Qualitätsparameter, Absicherung von Zahlungs- und Lieferbedingungen.', + 'icon' => 'document-check', + ], + [ + 'title' => 'Vertragssicherung & Durchsetzung', + 'description' => 'Aktive Überwachung der vereinbarten Meilensteine, Eskalation auf Managementebene bei Abweichungen und konsequente Nachverfolgung offener Punkte.', + 'icon' => 'shield-check', + ], + [ + 'title' => 'Tracking & Qualitätskontrolle', + 'description' => 'Laufende Produktions- und Lieferüberwachung, persönliche Kontrolle bei Bedarf und Sicherstellung termingerechter Auslieferung.', + 'icon' => 'magnifying-glass-circle', + ], + [ + 'title' => 'Netzwerk & Marktkenntnis', + 'description' => 'Direkte Anbindung an Hersteller und Entscheider in Deutschland. Wir sorgen dafür, dass Vereinbarungen nicht nur auf dem Papier bestehen, sondern tatsächlich umgesetzt werden.', + 'icon' => 'link', + ], + ], + 'highlight' => [ + 'value' => '100%', + 'text' => 'Transparenz, Verlässlichkeit und planbare Lieferung – ohne operative Reibungsverluste', + ], + 'image' => 'b2in/partner-benefits-developer.jpg', + 'image_alt' => 'Supply-Chain-Management für Immobilienentwickler', + ], + 'partner_benefits_retailer' => [ + 'id' => 'partner-benefits-retailer', + 'tag' => 'Ihre Vorteile als lokaler Händler', + 'tag_icon' => 'building-storefront', + 'tag_title' => 'Werden Sie zum digitalen Champion in Ihrer Region.', + 'features' => [ + [ + 'title' => 'Digitale Reichweite', + 'description' => 'Wir bringen Ihnen die Online-Kunden, die Sie alleine nicht erreichen. Profitieren Sie von unseren reichweitenstarken Endkunden-Marken.', + 'icon' => 'signal', + ], + [ + 'title' => 'Sortiments-Erweiterung', + 'description' => 'Ergänzen Sie Ihr Angebot mit exklusiven Herstellermarken aus unserem Portfolio – ganz ohne eigenes Lagerrisiko.', + 'icon' => 'squares-plus', + ], + [ + 'title' => 'Stärkung gegen Online-Riesen', + 'description' => 'Mit "Local First" stärken wir gezielt Ihre Position im Markt. Der Kunde sieht Ihr Angebot immer zuerst.', + 'icon' => 'shield-check', + ], + [ + 'title' => 'Faire Konditionen', + 'description' => 'Unser Provisionsmodell ist transparent und darauf ausgelegt, dass wir nur dann verdienen, wenn Sie es auch tun.', + 'icon' => 'scale', + ], + ], + 'highlight' => [ + 'value' => '+35%', + 'text' => 'Digitale Reichweite im ersten Jahr (Durchschnitt unserer Partner)', + ], + 'image' => 'b2in/partner-benefits-retailer.jpg', + 'image_alt' => 'Partner Benefits Retailer', + ], + 'partner_benefits_supplier' => [ + 'id' => 'partner-benefits-supplier', + 'tag' => 'Ihre Vorteile als Hersteller & Marke', + 'tag_icon' => 'building-office-2', + 'tag_title' => 'Erschließen Sie neue Märkte – intelligent und kuratiert.', + 'features' => [ + [ + 'title' => 'Kuratierter Vertriebskanal', + 'description' => 'Statt sich im Rauschen großer Marktplätze zu verlieren, wird Ihre Marke gezielt designaffinen Kunden in kaufkräftigen Regionen präsentiert.', + 'icon' => 'eye', + ], + [ + 'title' => 'Effiziente Logistik', + 'description' => 'Unsere Bündel-Logistik senkt Ihre Vertriebskosten. Wir managen den Sammeltransport und die komplette Abwicklung.', + 'icon' => 'truck', + ], + [ + 'title' => 'Direkter Marktzugang', + 'description' => 'Überspringen Sie den klassischen Großhandel und bauen Sie eine direkte Beziehung zu regionalen Märkten und Endkunden auf.', + 'icon' => 'map-pin', + ], + [ + 'title' => 'Skalierbares Wachstum', + 'description' => 'Starten Sie mit uns in einer Region und wachsen Sie schrittweise in weitere europäische Hubs hinein.', + 'icon' => 'chart-bar-square', + ], + ], + 'highlight' => [ + 'value' => '> 20', + 'text' => 'Kuratierte regionale Hubs als neue Vertriebskanäle in Europa', + ], + 'image' => 'b2in/partner-benefits-supplier.jpg', + 'image_alt' => 'Partner Benefits Supplier', + ], + 'partner_benefits_broker' => [ + 'id' => 'partner-benefits-broker', + 'tag' => 'Ihre Vorteile als Makler & Immobilienprofi', + 'tag_icon' => 'home-modern', + 'tag_title' => 'Mehrwert für Ihre Kunden – Mehr Ertrag für Sie.', + 'features' => [ + + [ + 'title' => 'Schnellere Vermarktung durch Home Staging', + 'description' => 'Nutzen Sie unsere kuratierten Möbel-Pakete (stileigentum&style2own), um Ihre Immobilien professionell zu inszenieren. Beschleunigen Sie den Verkaufsprozess und erzielen Sie höhere Preise.', + 'icon' => 'rocket-launch', + ], + [ + 'title' => 'Einzigartiger Service nach dem Abschluss', + 'description' => 'Bieten Sie Ihren Käufern oder Mietern nach Vertragsunterzeichnung über unsere exklusive Login-Karte direkten Zugang zu einem kompletten Einrichtungsservice.', + 'icon' => 'gift', + ], + [ + 'title' => 'Attraktives Provisionsmodell', + 'description' => 'Profitieren Sie doppelt: Neben Ihrer klassischen Courtage erhalten Sie eine faire, lebenslange Provision auf alle Möbelumsätze, die Ihre Kunden über die Plattform generieren.', + 'icon' => 'currency-euro', + ], + [ + 'title' => 'Einfaches Handling', + 'description' => 'Unser digitales Makler-Portal macht es Ihnen leicht: Kunden einladen, Aktivitäten verfolgen und Provisionen transparent einsehen.', + 'icon' => 'finger-print', + ], + ], + 'highlight' => [ + 'value' => '-25%', + 'text' => 'Kürzere Vermarktungszeit für mit B2in inszenierte Objekte', + ], + 'image' => 'b2in/partner-benefits-broker.jpg', + 'image_alt' => 'Partner Benefits Broker', + ], + 'partner_process' => [ + 'title' => 'So werden Sie Partner', + 'subtitle' => 'In nur drei einfachen Schritten werden Sie Teil des B2in Ecosystems und können von allen Vorteilen unserer Partnerschaft profitieren.', + 'steps' => [ + [ + 'step' => '1', + 'title' => 'Bewerben', + 'description' => 'Erzählen Sie uns Ihre Geschichte. Füllen Sie unser kurzes Kontaktformular aus und zeigen Sie uns, was Ihre Produkte oder Ihr Geschäft auszeichnet.', + 'icon' => 'envelope', + 'image' => 'b2in/room-1.jpg', + ], + [ + 'step' => '2', + 'title' => 'Prüfung', + 'description' => 'Wir prüfen jede Anfrage persönlich. Unser Ziel ist es, ein hochwertiges und komplementäres Netzwerk aufzubauen, von dem alle profitieren.', + 'icon' => 'check-circle', + 'image' => 'b2in/room-2.jpg', + ], + [ + 'step' => '3', + 'title' => 'Onboarding', + 'description' => 'Willkommen an Bord! Wir schulen Sie persönlich im Umgang mit unserem Partner-Portal und stellen sicher, dass Sie vom ersten Tag an erfolgreich sind.', + 'icon' => 'rocket-launch', + 'image' => 'b2in/room-3.jpg', + ], + ], + 'cta' => [ + 'button_text' => ' Zum Partner-Portal', + 'button_link' => '/contact', + ], + 'cta' => [ + 'title' => 'Bereit für den nächsten Schritt?', + 'subtitle' => 'Werden Sie noch heute Teil des B2in Ecosystems und profitieren Sie von innovativen Geschäftsmodellen und nachhaltigen Erfolgsstrategien.', + 'button_text' => 'Jetzt Partner werden', + 'button_link' => '/contact', + ], + ], + + 'supplier_section' => [ + 'tag' => 'Für Lieferanten', + 'title' => 'Kuratierte Plattform für Anbieter', + 'subtitle' => 'Werden Sie Teil eines exklusiven Netzwerks hochwertiger Anbieter. Präsentieren Sie Ihre Produkte und Services einer vorqualifizierten, kaufkräftigen Zielgruppe mit höchsten Qualitätsansprüchen.', + 'benefits' => [ + [ + 'title' => 'Kuratierter Vertriebskanal', + 'description' => 'Zugang zu einer exklusiven, vorqualifizierten Kundenbasis mit hohem Qualitätsanspruch', + 'icon' => 'store', + ], + [ + 'title' => 'Selbstverwaltung', + 'description' => 'Vollständige Kontrolle über Produktpräsentation, Preisgestaltung und Verfügbarkeit', + 'icon' => 'settings', + ], + [ + 'title' => 'Zentrale Qualitätssicherung', + 'description' => 'Standardisierte Prozesse und Qualitätskontrollen für maximales Kundenvertrauen', + 'icon' => 'check-circle', + ], + [ + 'title' => 'Analytics & Insights', + 'description' => 'Detaillierte Verkaufsanalysen und Markteinblicke für optimierte Geschäftsentscheidungen', + 'icon' => 'bar-chart', + ], + ], + 'dashboard' => [ + 'title' => 'Anbieter-Dashboard', + 'stats' => [ + [ + 'label' => 'Produktsichtbarkeit', + 'value' => '94%', + ], + [ + 'label' => 'Qualitätsbewertung', + 'value' => '98%', + ], + ], + ], + ], + 'leadership_team' => [ + 'title' => 'Das Führungsteam', + 'subtitle' => 'Unser erfahrenes Team bringt jahrzehntelange Expertise in den Bereichen Technologie, Operations und Geschäftsentwicklung mit.', + 'team_tag' => 'B2IN TEAM', + 'team' => [ + [ + 'name' => 'Marcel Scheibe', + 'position' => 'Gründer & CEO', + 'expertise' => 'Visionär für die digitale Zukunft des lokalen Handels und strategischer Brückenbauer zwischen den USA und Europa.', + 'image' => 'b2in/marcel-scheibe.jpg', + ], + [ + 'name' => 'Sarah Müller', + 'position' => 'Head of Operations', + 'expertise' => 'Expertin für die Optimierung unserer europaweiten Logistikprozesse und die operative Exzellenz unserer regionalen Hubs.', + 'image' => 'b2in/sarah-mueller.jpg', + ], + [ + 'name' => 'Thomas Weber', + 'position' => 'Head of Technology', + 'expertise' => 'Technologieführer mit Fokus auf eine intuitive, skalierbare Plattform-Architektur, die Händler und Hersteller nahtlos verbindet.', + 'image' => 'b2in/thomas-weber.jpg', + ], + ], + ], + // Weitere Komponenten für b2in + ], + ], + 'articles' => [ + 1 => [ + 'id' => 1, + 'title' => 'Sicherheit statt Bürokratie:
Warum das Dubai Escrow-System deutsche Investoren überrascht', + 'subtitle' => 'Wie das staatlich regulierte Treuhandsystem in Dubai Investorengelder konsequent schützt – und warum es internationalen Standards weit voraus ist.', + 'image' => 'b2in/magazin-1.jpg', + 'category' => 'Dubai Investment', + 'date' => 'März 10, 2026', + 'readTime' => '6 min read', + 'author' => [ + 'name' => 'Marcel Scheibe', + 'bio' => 'Gründer & CEO von B2in. Marcel ist selbst Immobilien-Investor in Dubai und begleitet deutsche Käufer persönlich durch den gesamten Kaufprozess.', + 'avatar' => 'b2in/marcel-scheibe.jpg', + ], + 'content' => [ + 'intro' => 'Deutsche Investoren schätzen beim Immobilienkauf vor allem eines: maximale Sicherheit. Der vertraute Prozess über Notar und Grundbuchamt vermittelt ein Gefühl von Kontrolle, ist jedoch oft mit langen Wartezeiten und enormer Bürokratie verbunden. Wer zum ersten Mal auf den Immobilienmarkt in Dubai blickt, ist oft überrascht von der unglaublichen Dynamik und Geschwindigkeit. Doch bedeutet dieses Tempo ein höheres Risiko? Im Gegenteil: Dubai hat mit dem Escrow-System einen der sichersten und transparentesten regulatorischen Rahmenwerke der Welt geschaffen, der Investorengelder konsequent schützt.', + 'sections' => [ + [ + 'title' => 'Das Escrow-System: Sicherheit durch staatlich regulierte Treuhandkonten', + 'content' => 'Der wichtigste Schutzmechanismus beim Kauf einer Off-Plan-Immobilie in Dubai ist das gesetzlich vorgeschriebene Escrow-System. Käuferzahlungen fließen nicht direkt an den Bauträger, sondern werden auf ein projektbezogenes Treuhandkonto bei einer zugelassenen Bank eingezahlt. Dieses Konto wird von der Real Estate Regulatory Agency (RERA), einer Regulierungsbehörde des Dubai Land Department (DLD), überwacht. Der Entwickler erhält Zugriff auf die Gelder nur entsprechend dem tatsächlichen Baufortschritt, der von zertifizierten Ingenieuren bestätigt werden muss.', + ], + [ + 'title' => 'Strenge Kontrolle durch das Dubai Land Department (DLD)', + 'content' => 'Das Dubai Land Department ist die zentrale staatliche Institution für Immobilien in Dubai. Über seine Regulierungsbehörde RERA überwacht es sämtliche Off-Plan-Projekte und sorgt dafür, dass Bauträger strenge gesetzliche Vorgaben einhalten. Jeder Kaufvertrag (SPA) wird offiziell registriert, und Bauprojekte müssen im sogenannten Oqood-System erfasst werden. Zahlungen der Käufer fließen auf projektbezogene Escrow-Konten und dürfen nur entsprechend dem bestätigten Baufortschritt freigegeben werden.', + ], + [ + 'title' => 'Transparenz und hohe Planbarkeit', + 'content' => 'Durch die enge Verknüpfung von Käuferzahlungen mit dem tatsächlichen Baufortschritt entsteht ein hohes Maß an Transparenz für Investoren. Bauträger dürfen Mittel aus dem Escrow-Konto nur entsprechend dem bestätigten Baufortschritt abrufen. Dadurch wird sichergestellt, dass Investorengelder projektgebunden eingesetzt werden und nicht für andere Bauvorhaben verwendet werden können. Für Käufer bedeutet dies: Die Dynamik des Immobilienmarktes in Dubai wird mit einem klar regulierten System kombiniert, das international als vergleichsweise investorenfreundlich gilt.', + ], + ], + ], + ], + 2 => [ + 'id' => 2, + 'title' => 'Spotlight Al Jaddaf:
Warum smarte Investoren jetzt auf diesen aufstrebenden Hotspot setzen', + 'subtitle' => 'Strategische Waterfront-Location, hohe Mietnachfrage und enormes Potenzial für Wertsteigerung – Al Jaddaf ist der Hidden Champion unter Dubais Vierteln.', + 'image' => 'b2in/magazin-2.jpg', + 'category' => 'Dubai Investment', + 'date' => 'März 5, 2026', + 'readTime' => '7 min read', + 'author' => [ + 'name' => 'Marcel Scheibe', + 'bio' => 'Gründer & CEO von B2in. Marcel ist selbst Immobilien-Investor in Dubai und begleitet deutsche Käufer persönlich durch den gesamten Kaufprozess.', + 'avatar' => 'b2in/marcel-scheibe.jpg', + ], + 'content' => [ + 'intro' => 'Die Wahl der richtigen Lage ist der entscheidende Faktor für eine hohe Rendite und langfristige Wertsteigerung. Während weltbekannte Areale wie Downtown Dubai oder die Palm Jumeirah hohe Einstiegspreise aufrufen, suchen smarte Investoren nach den "Hidden Champions" – Vierteln, die vor einer massiven Aufwertung stehen. Eines der spannendsten Entwicklungsgebiete der Stadt ist aktuell Al Jaddaf. Historisch als Werft-Viertel am Dubai Creek bekannt, transformiert sich Al Jaddaf rasend schnell zu einem modernen, strategisch extrem wichtigen Knotenpunkt für exklusives Wohnen und Lifestyle.', + 'sections' => [ + [ + 'title' => 'Die strategische Waterfront-Location', + 'content' => 'Al Jaddaf bietet eine seltene Kombination aus direkter Wasserlage (Waterfront) und zentraler Anbindung. Eingebettet zwischen dem historischen Dubai Creek und der modernen Erweiterung, bietet es unverbaubare Blicke auf die Skyline. Gleichzeitig sind Hotspots wie Downtown Dubai, der Burj Khalifa sowie der Dubai International Airport in nur wenigen Autominuten erreichbar. Diese Logistik macht den Standort unvergleichlich.', + ], + [ + 'title' => 'Hohe Mietnachfrage durch perfekte Infrastruktur', + 'content' => 'Das Viertel zieht zunehmend Young Professionals, Expats und Familien an, die zentral, aber dennoch ruhig und exklusiv leben möchten. Die Nähe zur Healthcare City, zu kulturellen Highlights wie dem Jameel Arts Centre und die Anbindung an die Metro sorgen für eine exzellente Infrastruktur. Dies garantiert Investoren eine kontinuierlich hohe Mietnachfrage und minimale Leerstandsquoten.', + ], + [ + 'title' => 'Enormes Potenzial für Capital Appreciation (Wertsteigerung)', + 'content' => 'Aktuell bietet Al Jaddaf noch Einstiegspreise, die ein exzellentes Preis-Leistungs-Verhältnis darstellen – insbesondere im Vergleich zu bereits etablierten Premium-Vierteln. Mit neuen High-End-Projekten (wie etwa den Azizi Creek Views) wird das Viertel massiv aufgewertet. Wer jetzt investiert, profitiert in den kommenden Jahren nicht nur von attraktiven Mietrenditen, sondern vor allem von einer signifikanten Wertsteigerung der Immobilie.', + ], + ], + ], + ], + 3 => [ + 'id' => 3, + 'title' => 'Turnkey-Investments:
Wie die richtige Möblierung Ihre Mietrendite in Dubai maximiert', + 'subtitle' => 'Warum schlüsselfertige Einrichtungskonzepte den Unterschied zwischen durchschnittlicher und Premium-Rendite ausmachen.', + 'image' => 'b2in/magazin-3.jpg', + 'category' => 'Rendite & Einrichtung', + 'date' => 'Februar 25, 2026', + 'readTime' => '6 min read', + 'author' => [ + 'name' => 'Marcel Scheibe', + 'bio' => 'Gründer & CEO von B2in. Mit Wurzeln in der Möbelbranche verbindet Marcel Immobilien-Expertise mit Einrichtungskompetenz.', + 'avatar' => 'b2in/marcel-scheibe.jpg', + ], + 'content' => [ + 'intro' => 'Der Kauf einer Premium-Immobilie in Dubai ist der erste Schritt zu einem erfolgreichen Investment. Doch erst die richtige Nutzung entscheidet über die tatsächliche Rendite. Besonders lukrativ ist die Kurzzeitvermietung (Short-Term-Rental, z. B. via Airbnb), die in Dubai florierende Umsätze generiert. Die Herausforderung für viele internationale Investoren: Wie richtet man eine Wohnung über Tausende Kilometer Entfernung so ein, dass sie aus der Masse heraussticht und Premium-Preise erzielt? Die Antwort lautet "Turnkey" (schlüsselfertig) – intelligente Einrichtungskonzepte, die Ästhetik, Langlebigkeit und Effizienz vereinen.', + 'sections' => [ + [ + 'title' => 'Premium-Look für höhere Übernachtungspreise', + 'content' => 'Der erste Eindruck auf Buchungsplattformen entscheidet. Ein durchschnittlich eingerichtetes Apartment erzielt durchschnittliche Preise. Maßgeschneiderte Design-Konzepte, die perfekt auf die Architektur der Immobilie und die demografische Zielgruppe (Business-Reisende oder Urlauber) abgestimmt sind, erlauben es Ihnen, sich im Premium-Segment zu positionieren und die Mieteinnahmen signifikant zu steigern.', + ], + [ + 'title' => 'Der "Turnkey"-Vorteil aus der Ferne', + 'content' => 'Niemand möchte sich aus Europa heraus um Lieferverzögerungen, fehlende Schrauben oder Handwerkertermine in Dubai kümmern. Ein intelligentes Turnkey-Konzept nimmt Ihnen diesen gesamten Prozess ab. Über das exklusive B2in-Netzwerk erhalten Käufer Zugang zu einem Service, der von der ersten Design-Skizze bis zum fertig bezogenen Bett alles abdeckt – komplett gesteuert durch deutsches Projektmanagement.', + ], + [ + 'title' => 'Langlebigkeit durch deutsche Qualitätsstandards', + 'content' => 'Bei einer hohen Auslastung in der Kurzzeitvermietung werden Möbel stark beansprucht. Billige Ausstattungen müssen oft schon nach kurzer Zeit ersetzt werden, was die Rendite schmälert. Der Fokus auf langlebige Materialien und erstklassige Verarbeitungsqualität – oft gesichert durch Zugänge zu deutschen und europäischen Premium-Herstellern – reduziert die Instandhaltungskosten auf ein Minimum und erhält den Wert Ihres Investments.', + ], + ], + ], + ], + 4 => [ + 'id' => 4, + 'title' => 'Supply-Chain-Management für Entwickler:
Warum Vertragssicherheit den Unterschied macht', + 'subtitle' => 'Wie professionelles Beschaffungsmanagement aus Deutschland Bauverzögerungen verhindert und Millionen spart.', + 'image' => 'b2in/magazin-4.jpg', + 'category' => 'B2B & Partner', + 'date' => 'Februar 15, 2026', + 'readTime' => '7 min read', + 'author' => [ + 'name' => 'Marcel Scheibe', + 'bio' => 'Gründer & CEO von B2in. Als Brücke zwischen europäischen Herstellern und internationalen Entwicklern sorgt Marcel für Vertragssicherheit und Termintreue.', + 'avatar' => 'b2in/marcel-scheibe.jpg', + ], + 'content' => [ + 'intro' => 'Internationale Immobilienentwickler kennen das Problem: Ein Luxusprojekt ist nahezu fertiggestellt, doch die Innenausstattung aus Europa verzögert sich. Mangelhafte Kommunikation, versteckte Klauseln oder logistische Engpässe führen zu Bauverzögerungen, die Millionen kosten können. In einer globalisierten Welt reicht es nicht aus, Möbel und Materialien nur zu bestellen – man muss ihre Ankunft garantieren. Genau hier setzt professionelles Supply-Chain-Management an. Als verlängerter Arm vor Ort in Deutschland sorgt B2in dafür, dass Verträge nicht nur auf dem Papier existieren, sondern in der Realität pünktlich erfüllt werden.', + 'sections' => [ + [ + 'title' => 'Eskalation auf Managementebene', + 'content' => 'Wenn Lieferungen ins Stocken geraten, verpuffen E-Mails oft im Kundenservice. Effektives Supply-Chain-Management erfordert Durchsetzungskraft und die richtigen Kontakte. Durch ein tief verankertes Netzwerk in der europäischen Einrichtungsbranche greifen wir bei Abweichungen sofort ein und eskalieren Probleme direkt auf Managementebene der Hersteller, um sofortige Lösungen zu erzwingen.', + ], + [ + 'title' => 'Aktives Vertragsmanagement statt Hoffnungsprinzip', + 'content' => 'Vertragssicherheit beginnt vor der Unterschrift. Es geht darum, klare Leistungs- und Qualitätsparameter sowie harte Meilensteine zu definieren. Ein proaktives Management überwacht diese Parameter laufend und sichert Zahlungs- sowie Lieferbedingungen so ab, dass der Entwickler zu jedem Zeitpunkt die volle Kontrolle über den Prozess behält, ohne selbst operative Reibungsverluste zu erleiden.', + ], + [ + 'title' => 'Lückenloses Tracking und Qualitätskontrolle', + 'content' => 'Vertrauen ist gut, Kontrolle vor Ort ist besser. Um Ausfälle zu vermeiden, werden Produktionsfortschritte direkt an der Quelle überwacht. Durch regelmäßige, persönliche Qualitätskontrollen bei den Herstellern stellen wir sicher, dass die Ware nicht nur pünktlich verladen wird, sondern exakt den geforderten Premium-Standards der Immobilien-Projektentwickler entspricht.', + ], + ], + ], + ], + 5 => [ + 'id' => 5, + 'title' => 'Local for Local:
Wie der regionale Möbelhandel die Zukunft des Wohnens prägt', + 'subtitle' => 'Warum Konsumenten sich wieder nach Haptik und persönlicher Beratung sehnen – und wie digitale Marktplätze den lokalen Fachhandel stärken.', + 'image' => 'b2in/magazin-5.jpg', + 'category' => 'Einrichtung & Netzwerk', + 'date' => 'Februar 5, 2026', + 'readTime' => '5 min read', + 'author' => [ + 'name' => 'Marcel Scheibe', + 'bio' => 'Gründer & CEO von B2in. Marcel baut mit dem Local-for-Local-Konzept die Brücke zwischen digitalem Komfort und regionaler Fachhandels-Stärke.', + 'avatar' => 'b2in/marcel-scheibe.jpg', + ], + 'content' => [ + 'intro' => 'In den letzten Jahren schien der Trend unaufhaltsam: Gigantische Online-Plattformen dominierten den Möbelmarkt. Doch der Markt wandelt sich. Konsumenten sehnen sich wieder nach Haptik, persönlicher Beratung und sofortiger Verfügbarkeit. Gleichzeitig schlummern in den regionalen Möbelhäusern echte Schätze ("Hidden Gems"), die online oft unsichtbar bleiben. Das Konzept "Local for Local" setzt genau hier an: Es ist ein digitaler Marktplatz-Ansatz, der nicht den anonymen Großhandel, sondern den lokalen Fachhandel stärkt – und damit eine Brücke zwischen digitaler Bequemlichkeit und regionaler Stärke baut.', + 'sections' => [ + [ + 'title' => 'Digitale Sichtbarkeit für lokale Bestände', + 'content' => 'Die Frage "Was ist heute in meiner Nähe verfügbar?" konnte der lokale Handel digital oft nicht beantworten. Moderne Marktplatz-Technologien ändern das. Sie geben regionalen Händlern die Werkzeuge an die Hand, ihre sofort verfügbare Ausstellungs- und Lagerware einem breiten, kaufkräftigen Publikum (wie etwa neuen Immobilienbesitzern) sichtbar zu machen – ganz ohne komplexe eigene IT-Infrastruktur.', + ], + [ + 'title' => 'Support your Locals – Ein Gewinn für alle', + 'content' => 'Das "David gegen Goliath"-Prinzip bringt die Kunden zurück in die Geschäfte. Käufer profitieren von exklusiven Preisen für Ausstellungsstücke und Markenware, die oft günstiger ist als im Großmarkt. Der Händler wiederum steigert seine Frequenz vor Ort, baut Liquidität durch schnellen Abverkauf auf und gewinnt Neukunden, die den Wert echter, physischer Beratung schätzen.', + ], + [ + 'title' => 'Smarte Vernetzung statt anonymer Plattform', + 'content' => 'Die Zukunft gehört nicht den geschlossenen Online-Shops, sondern vernetzten Ökosystemen. Wenn Immobilienmakler, Kunden und regionale Händler auf einer Plattform zusammenkommen, entsteht ein Kreislauf des Vertrauens. Der Immobilienkauf wird zum Auslöser für den Möbelkauf, und der lokale Fachhandel wird zum verlässlichen Partner für die perfekte Einrichtung – lokal gedacht, intelligent vernetzt.', + ], + ], + ], + ], + ], + +]; diff --git a/resources/lang/de/b2in_legal.php b/resources/lang/de/b2in_legal.php new file mode 100644 index 0000000..0c592c6 --- /dev/null +++ b/resources/lang/de/b2in_legal.php @@ -0,0 +1,395 @@ + [ + 'title' => 'Impressum', + 'meta_title' => 'Impressum', + 'subtitle' => 'Angaben gemäß § 5 TMG', + 'back_link' => '← Zurück', + 'content' => ' +
+

Anbieter

+

+ B2in GmbH
+ Feldstraße 59
+ 32120 Hiddenhausen +

+
+ +
+

Vertreten durch

+

Marcel Scheibe – Geschäftsführer

+
+ +
+

Kontakt

+

+ E-Mail: info@b2in.de
+ Telefon: +49 (0) 5221 9255055 +

+
+ +
+

Registereintrag

+

+ Eintragung im Handelsregister.
+ Registergericht: Amtsg. Bad Oeynhausen
+ Registernummer: HRB 13068 +

+
+ +
+

Umsatzsteuer-ID

+

+ Umsatzsteuer-Identifikationsnummer gemäß § 27a Umsatzsteuergesetz:
+ DE283859321 +

+
+ +
+

Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV

+

+ Marcel Scheibe
+ Feldstraße 59
+ 32120 Hiddenhausen +

+
+ +
+

EU-Streitschlichtung

+

+ Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen. +

+
+ +
+

Haftung für Inhalte

+

+ Als Diensteanbieter sind wir gemäß § 7 Abs. 1 TMG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen. +

+

+ Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen Gesetzen bleiben hiervon unberührt. Eine diesbezügliche Haftung ist jedoch erst ab dem Zeitpunkt der Kenntnis einer konkreten Rechtsverletzung möglich. Bei Bekanntwerden von entsprechenden Rechtsverletzungen werden wir diese Inhalte umgehend entfernen. +

+
+ +
+

Haftung für Links

+

+ Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich. +

+
+ +
+

Urheberrecht

+

+ Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers. Downloads und Kopien dieser Seite sind nur für den privaten, nicht kommerziellen Gebrauch gestattet. +

+

+ Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die Urheberrechte Dritter beachtet. Insbesondere werden Inhalte Dritter als solche gekennzeichnet. Sollten Sie trotzdem auf eine Urheberrechtsverletzung aufmerksam werden, bitten wir um einen entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Inhalte umgehend entfernen. +

+
+ +
+

Konzeption, Design und Realisation

+

+ adametz.media, Bielefeld +

+
+ ', + ], + + 'terms' => [ + 'title' => 'Nutzungsbedingungen', + 'meta_title' => 'Nutzungsbedingungen', + 'subtitle' => 'Stand: 13.03.2026', + 'back_link' => '← Zurück', + 'content' => ' +
+

1. Geltungsbereich

+

+ Diese Nutzungsbedingungen gelten für die Nutzung der öffentlich zugänglichen Website und der damit verbundenen Informationsdienste der B2in GmbH („B2in", „wir", „uns"). Mit dem Aufruf und der Nutzung unserer Website erklären Sie sich mit diesen Bedingungen einverstanden. +

+
+ +
+

2. Leistungsbeschreibung

+

+ B2in ist das Netzwerk, das exklusives Design und internationale Immobilien verbindet („Connecting Design and Property"). Über unsere Website bieten wir unverbindliche Informationen zu unseren Dienstleistungen, Immobilienprojekten (z. B. in Dubai oder Lissabon), Supply-Chain-Management und unserem Einrichtungsnetzwerk an. Die konkrete Leistungserbringung, Vermittlung oder Beschaffung erfolgt nicht über die Website, sondern ausschließlich auf Basis gesonderter, individueller Verträge. +

+
+ +
+

3. Unverbindlichkeit von Immobilien- und Investmentinformationen (WICHTIG!)

+

+ Die auf der Website dargestellten Immobilienprojekte, Exposés, Preisangaben, Grundrisse und Renditeprognosen (z. B. erwartete Mietrenditen) dienen ausschließlich der allgemeinen Information und Inspiration. +

+

+ Keine Anlageberatung: Die bereitgestellten Inhalte stellen keine Finanz-, Steuer- oder Anlageberatung dar und sind kein bindendes Vertragsangebot. +

+

+ Änderungsvorbehalt: Da wir mit internationalen Bauträgern und Projektentwicklern zusammenarbeiten, können sich Preise, Verfügbarkeiten (z. B. von limitierten Einheiten), Baufortschritte und Übergabetermine jederzeit ändern. B2in übernimmt keine Gewähr für die zukünftige Wertentwicklung oder das Eintreten der prognostizierten Renditen. +

+
+ +
+

4. Partner-Portal und geschlossene Netzwerke

+

+ Für bestimmte Zielgruppen (z. B. Makler, Immobilienentwickler, Händler) bietet B2in den Zugang zu einem geschlossenen Portal bzw. Partner-Netzwerk an. Der Zugang zu diesen passwortgeschützten Bereichen unterliegt einer vorherigen Registrierung und Freigabe durch B2in. Für die Nutzung dieser geschlossenen Bereiche gelten gesonderte Allgemeine Geschäftsbedingungen (AGB) bzw. Partnerverträge. +

+
+ +
+

5. Urheber- und Nutzungsrechte

+

+ Die auf dieser Website bereitgestellten Inhalte (insbesondere Texte, Exposé-Bilder, Grafiken, Logos und Videos) sind urheberrechtlich geschützt. Eine Vervielfältigung, Bearbeitung, Verbreitung oder jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedarf der vorherigen schriftlichen Zustimmung von B2in oder des jeweiligen Rechteinhabers (z. B. Bauträger). +

+
+ +
+

6. Haftungsausschluss

+

+ Die Inhalte unserer Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte (insbesondere Projektbeschreibungen von Drittanbietern) können wir jedoch keine Gewähr übernehmen. Als Diensteanbieter sind wir gemäß § 7 Abs. 1 TMG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Wir sind jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen. +

+
+ +
+

7. Externe Links

+

+ Unser Angebot enthält Links zu externen Websites Dritter (z. B. Google Maps oder Partner-Websites), auf deren Inhalte wir keinen Einfluss haben. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich. Eine permanente inhaltliche Kontrolle der verlinkten Seiten ist ohne konkrete Anhaltspunkte einer Rechtsverletzung nicht zumutbar. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Links umgehend entfernen. +

+
+ +
+

8. Änderungen der Nutzungsbedingungen

+

+ Wir behalten uns vor, diese Nutzungsbedingungen jederzeit und ohne Nennung von Gründen zu ändern. Die aktuelle Version ist stets auf dieser Seite abrufbar. Mit der fortgesetzten Nutzung der Website nach einer Änderung akzeptieren Sie die geänderten Bedingungen. +

+
+ +
+

9. Schlussbestimmungen

+

+ Es gilt das Recht der Bundesrepublik Deutschland unter Ausschluss des UN-Kaufrechts. Sofern es sich beim Nutzer um einen Kaufmann, eine juristische Person des öffentlichen Rechts oder um ein öffentlich-rechtliches Sondervermögen handelt, ist der Gerichtsstand für alle Streitigkeiten aus Nutzungsverhältnissen der Sitz der B2in GmbH. +

+
+ ', + ], + + 'privacy' => [ + 'title' => 'Datenschutzerklärung', + 'meta_title' => 'Datenschutzerklärung', + 'subtitle' => 'Stand: 13.03.2026', + 'back_link' => '← Zurück', + 'content' => ' +
+

1. Verantwortlicher

+

+ Verantwortlich für die Datenverarbeitung auf dieser Website im Sinne der Datenschutz-Grundverordnung (DSGVO) ist: +

+

+ B2in GmbH
+ Feldstraße 59
+ 32120 Hiddenhausen
+ Telefon: +49 (0) 5221 9255055
+ E-Mail: info@b2in.eu +

+
+ +
+

2. Datenerfassung auf unserer Website (Server-Log-Dateien)

+

+ Beim Aufruf unserer Website werden durch den auf Ihrem Endgerät zum Einsatz kommenden Browser automatisch Informationen an den Server unserer Website gesendet. Diese Informationen werden temporär in einem sogenannten Logfile gespeichert. Folgende Informationen werden dabei ohne Ihr Zutun erfasst und bis zur automatisierten Löschung gespeichert: +

+
    +
  • IP-Adresse des anfragenden Rechners
  • +
  • Datum und Uhrzeit des Zugriffs
  • +
  • Name und URL der abgerufenen Datei
  • +
  • Website, von der aus der Zugriff erfolgt (Referrer-URL)
  • +
  • Verwendeter Browser und ggf. das Betriebssystem Ihres Rechners sowie der Name Ihres Access-Providers
  • +
+

+ Zweck und Rechtsgrundlage: Die Verarbeitung erfolgt zur Gewährleistung eines reibungslosen Verbindungsaufbaus der Website, zur Auswertung der Systemsicherheit und -stabilität sowie zu weiteren administrativen Zwecken. Die Rechtsgrundlage hierfür ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse folgt aus den oben aufgelisteten Zwecken zur Datenerhebung. +

+
+ +
+

3. Kontaktformular und E-Mail-Kontakt

+

+ Wenn Sie uns per Kontaktformular oder E-Mail Anfragen zukommen lassen, werden Ihre Angaben aus dem Anfrageformular inklusive der von Ihnen dort angegebenen Kontaktdaten (wie Name, E-Mail-Adresse, Telefonnummer) zwecks Bearbeitung der Anfrage und für den Fall von Anschlussfragen bei uns gespeichert. Diese Daten geben wir nicht ohne Ihre Einwilligung weiter. +

+

+ Die Verarbeitung dieser Daten erfolgt auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO, sofern Ihre Anfrage mit der Erfüllung eines Vertrags zusammenhängt oder zur Durchführung vorvertraglicher Maßnahmen (z. B. Immobilien-Investment oder Supply-Chain-Management) erforderlich ist. In allen übrigen Fällen beruht die Verarbeitung auf unserem berechtigten Interesse an der effektiven Bearbeitung der an uns gerichteten Anfragen (Art. 6 Abs. 1 lit. f DSGVO) oder auf Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO), sofern diese abgefragt wurde. +

+

+ Die von Ihnen im Kontaktformular eingegebenen Daten verbleiben bei uns, bis Sie uns zur Löschung auffordern, Ihre Einwilligung zur Speicherung widerrufen oder der Zweck für die Datenspeicherung entfällt (z. B. nach abgeschlossener Bearbeitung Ihrer Anfrage). Zwingende gesetzliche Bestimmungen – insbesondere Aufbewahrungsfristen – bleiben unberührt. +

+
+ +
+

4. Cookies

+

+ Unsere Website verwendet Cookies. Das sind kleine Textdateien, die Ihr Browser automatisch erstellt und die auf Ihrem Endgerät gespeichert werden. +

+
    +
  • Technisch notwendige Cookies: Diese sind erforderlich, um den Betrieb der Website zu gewährleisten (z. B. das Speichern Ihrer Consent-Einstellungen). Die Rechtsgrundlage hierfür ist Art. 6 Abs. 1 lit. f DSGVO.
  • +
  • Analyse- und Marketing-Cookies: Diese Cookies (z. B. Google Analytics) werden nur gesetzt, wenn Sie uns zuvor Ihre ausdrückliche Einwilligung über unser Cookie-Consent-Banner erteilt haben (Art. 6 Abs. 1 lit. a DSGVO).
  • +
+

+ Sie können Ihre Einwilligung jederzeit widerrufen oder anpassen, indem Sie die Cookie-Einstellungen über den folgenden Link aufrufen: + Cookie-Einstellungen ändern +

+
+ +
+

5. Google Analytics

+

+ Soweit Sie Ihre Einwilligung erklärt haben, wird auf dieser Website Google Analytics eingesetzt, ein Webanalysedienst der Google Ireland Limited, Gordon House, Barrow Street, Dublin 4, Irland („Google"). + Google Analytics verwendet Cookies, die eine Analyse der Benutzung der Website durch Sie ermöglichen. Die dadurch erzeugten Informationen werden in der Regel an einen Server von Google in den USA übertragen und dort gespeichert. +

+

+ IP-Anonymisierung: Wir haben auf dieser Website die Funktion IP-Anonymisierung aktiviert. Dadurch wird Ihre IP-Adresse von Google innerhalb von Mitgliedstaaten der Europäischen Union oder in anderen Vertragsstaaten des Abkommens über den Europäischen Wirtschaftsraum vor der Übermittlung in die USA gekürzt. +

+

+ Zweck und Rechtsgrundlage: In unserem Auftrag wird Google diese Informationen benutzen, um Ihre Nutzung der Website auszuwerten, um Reports über die Websiteaktivitäten zusammenzustellen und um weitere mit der Websitenutzung und der Internetnutzung verbundene Dienstleistungen gegenüber dem Websitebetreiber zu erbringen. + Die Rechtsgrundlage für den Einsatz von Google Analytics ist Ihre freiwillig erteilte Einwilligung gemäß Art. 6 Abs. 1 lit. a DSGVO sowie § 25 Abs. 1 TTDSG/TDDDG. +

+

+ Datenübermittlung in die USA: Datenübertragungen in die USA stützen sich auf den Angemessenheitsbeschluss der EU-Kommission (EU-US Data Privacy Framework), unter dem Google LLC zertifiziert ist, sowie auf Standardvertragsklauseln. +

+

+ Widerruf / Widerspruch: Sie können Ihre Einwilligung jederzeit mit Wirkung für die Zukunft über unsere + Cookie-Einstellungen + widerrufen. Sie können die Erfassung durch Google Analytics zudem verhindern, indem Sie das unter dem folgenden Link verfügbare Browser-Plugin herunterladen und installieren: + https://tools.google.com/dlpage/gaoptout?hl=de. +

+
+ +
+

6. Externe Links (z. B. Google Maps)

+

+ Auf unserer Website finden Sie Links zu externen Diensten, wie beispielsweise zu Google Maps, um Ihnen Standorte (z. B. Immobilien-Projekte) zu visualisieren. Wir binden diese Dienste nicht direkt auf unserer Seite ein, sondern verlinken lediglich darauf. Wenn Sie auf einen solchen Link klicken, verlassen Sie unsere Website und werden auf die Server des jeweiligen Anbieters (z. B. Google) weitergeleitet. Erst dort werden Ihre Daten (wie z. B. Ihre IP-Adresse) durch den externen Anbieter erfasst. Es gilt die Datenschutzerklärung des jeweiligen Anbieters. +

+
+ +
+

7. SSL- bzw. TLS-Verschlüsselung

+

+ Diese Seite nutzt aus Sicherheitsgründen und zum Schutz der Übertragung vertraulicher Inhalte, wie zum Beispiel Anfragen, die Sie an uns als Seitenbetreiber senden, eine SSL- bzw. TLS-Verschlüsselung. Eine verschlüsselte Verbindung erkennen Sie daran, dass die Adresszeile des Browsers von „http://" auf „https://" wechselt und an dem Schloss-Symbol in Ihrer Browserzeile. Wenn die Verschlüsselung aktiviert ist, können die Daten, die Sie an uns übermitteln, nicht von Dritten mitgelesen werden. +

+
+ +
+

8. Ihre Rechte als betroffene Person

+

+ Sie haben das Recht: +

+
    +
  • gemäß Art. 15 DSGVO Auskunft über Ihre von uns verarbeiteten personenbezogenen Daten zu verlangen.
  • +
  • gemäß Art. 16 DSGVO unverzüglich die Berichtigung unrichtiger oder Vervollständigung Ihrer bei uns gespeicherten Daten zu verlangen.
  • +
  • gemäß Art. 17 DSGVO die Löschung Ihrer bei uns gespeicherten Daten zu verlangen, soweit nicht die Verarbeitung zur Ausübung des Rechts auf freie Meinungsäußerung, zur Erfüllung einer rechtlichen Verpflichtung oder zur Geltendmachung von Rechtsansprüchen erforderlich ist.
  • +
  • gemäß Art. 18 DSGVO die Einschränkung der Verarbeitung Ihrer Daten zu verlangen.
  • +
  • gemäß Art. 20 DSGVO Ihre Daten in einem strukturierten, gängigen und maschinenlesebaren Format zu erhalten (Datenübertragbarkeit).
  • +
  • gemäß Art. 7 Abs. 3 DSGVO Ihre einmal erteilte Einwilligung jederzeit gegenüber uns zu widerrufen.
  • +
  • gemäß Art. 21 DSGVO Widerspruch gegen die Verarbeitung einzulegen, sofern Ihre Daten auf Grundlage von berechtigten Interessen verarbeitet werden.
  • +
+

+ Möchten Sie von Ihren Rechten Gebrauch machen, genügt eine E-Mail an: info@b2in.eu. +

+

+ Sie haben zudem das Recht, sich bei einer Datenschutz-Aufsichtsbehörde zu beschweren (Art. 77 DSGVO). +

+
+ ', + ], + + 'cookie_policy' => [ + 'title' => 'Cookie-Richtlinie', + 'meta_title' => 'Cookie-Richtlinie', + 'subtitle' => 'Stand: 13.03.2026', + 'back_link' => '← Zurück', + 'content' => ' +
+

1. Verantwortlicher

+

+ Verantwortlich für die Cookie-Nutzung auf dieser Website ist: +

+

+ B2In GmbH
+ Feldstraße 59
+ 32120 Hiddenhausen
+ Telefon: +49 (0) 5221 9255055
+ E-Mail: info@b2in.eu +

+
+ +
+

2. Was sind Cookies?

+

+ Cookies sind kleine Textdateien, die Ihr Browser automatisch erstellt und die auf Ihrem Endgerät (Computer, Tablet, Smartphone) gespeichert werden. Sie dienen dazu, die Nutzung der Website zu erleichtern und bestimmte Funktionen zu ermöglichen. +

+
+ +
+

3. Welche Cookies verwenden wir?

+

+ Wir unterscheiden zwischen zwei Kategorien von Cookies: +

+
    +
  • Technisch notwendige Cookies: Diese sind erforderlich, um den Betrieb der Website zu gewährleisten (z. B. das Speichern Ihrer Consent-Einstellungen). Sie können nicht deaktiviert werden. Die Rechtsgrundlage hierfür ist Art. 6 Abs. 1 lit. f DSGVO.
  • +
  • Analyse- und Marketing-Cookies: Diese Cookies (z. B. Google Analytics) werden nur gesetzt, wenn Sie uns zuvor Ihre ausdrückliche Einwilligung über unser Cookie-Consent-Banner erteilt haben. Die Rechtsgrundlage ist Art. 6 Abs. 1 lit. a DSGVO sowie § 25 Abs. 1 TTDSG/TDDDG.
  • +
+

+ Sie können Ihre Einwilligung jederzeit widerrufen oder anpassen, indem Sie die Cookie-Einstellungen über den folgenden Link aufrufen: + Cookie-Einstellungen ändern +

+
+ +
+

4. Google Analytics

+

+ Soweit Sie Ihre Einwilligung erklärt haben, wird auf dieser Website Google Analytics eingesetzt, ein Webanalysedienst der Google Ireland Limited, Gordon House, Barrow Street, Dublin 4, Irland („Google"). + Google Analytics verwendet Cookies, die eine Analyse der Benutzung der Website durch Sie ermöglichen. Die dadurch erzeugten Informationen werden in der Regel an einen Server von Google in den USA übertragen und dort gespeichert. +

+

+ IP-Anonymisierung: Wir haben auf dieser Website die Funktion IP-Anonymisierung aktiviert. Dadurch wird Ihre IP-Adresse von Google innerhalb von Mitgliedstaaten der Europäischen Union oder in anderen Vertragsstaaten des Abkommens über den Europäischen Wirtschaftsraum vor der Übermittlung in die USA gekürzt. +

+

+ Datenübermittlung in die USA: Datenübertragungen in die USA stützen sich auf den Angemessenheitsbeschluss der EU-Kommission (EU-US Data Privacy Framework), unter dem Google LLC zertifiziert ist, sowie auf Standardvertragsklauseln. +

+

+ Widerruf: Sie können Ihre Einwilligung jederzeit mit Wirkung für die Zukunft über unsere + Cookie-Einstellungen + widerrufen. Zudem können Sie die Erfassung durch Google Analytics verhindern, indem Sie das Browser-Plugin unter + https://tools.google.com/dlpage/gaoptout?hl=de + herunterladen und installieren. +

+
+ +
+

5. Speicherdauer

+

+ Session-Cookies werden beim Schließen des Browsers gelöscht. Ihre Cookie-Einwilligung (Consent-Einstellungen) wird als persistentes Cookie für maximal 12 Monate gespeichert. Analyse-Cookies von Google Analytics haben typischerweise eine Speicherdauer von bis zu 2 Jahren. +

+
+ +
+

6. Ihre Wahl

+

+ Sie können Ihren Browser so einstellen, dass Sie über das Setzen von Cookies informiert werden und diese einzeln erlauben oder ablehnen. Die Deaktivierung technisch notwendiger Cookies kann die Funktionalität unserer Website einschränken. +

+
+ +
+

7. Weitere Informationen

+

+ Ausführliche Informationen zum Datenschutz, zu Ihren Rechten und zur Datenverarbeitung finden Sie in unserer + Datenschutzerklärung. +

+
+ ', + ], +]; diff --git a/resources/lang/de/ui.php b/resources/lang/de/ui.php new file mode 100644 index 0000000..dde5468 --- /dev/null +++ b/resources/lang/de/ui.php @@ -0,0 +1,84 @@ + 'Zurück', + 'learn_more' => 'Mehr erfahren', + 'read_more' => 'Weiterlesen', + 'close' => 'Schließen', + 'contact' => 'Kontakt', + 'required_fields' => '* Pflichtfelder', + 'view' => 'Ansehen', + + // Header + 'main_navigation' => 'Hauptnavigation', + 'mobile_navigation' => 'Mobile Navigation', + 'menu_close' => 'Menü schließen', + 'menu_open' => 'Menü öffnen', + + // Footer + 'founder_ceo' => 'Gründer & CEO', + 'legal_notice' => 'Impressum', + 'cookie_settings' => 'Cookie-Einstellungen', + + // Announcement Bar + 'announcement_close' => 'Schließen', + + // Hero Slider + 'show_slide' => 'Slide :number anzeigen', + 'previous_image' => 'Vorheriges Bild', + 'next_image' => 'Nächstes Bild', + + // Magazine + 'read_article' => 'Artikel :title lesen', + + // Contact Form + 'contact_form' => [ + 'success_title' => 'Vielen Dank!', + 'success_message' => 'Ihre Nachricht ist bei uns eingegangen. Wir melden uns schnellstmöglich bei Ihnen.', + 'first_name' => 'Vorname *', + 'last_name' => 'Nachname *', + 'company' => 'Firma (optional)', + 'email' => 'Email *', + 'phone' => 'Telefon (optional)', + 'subject' => 'Betreff *', + 'message' => 'Nachricht *', + 'message_placeholder' => 'Ihre Nachricht...', + 'privacy_prefix' => 'Ich habe die', + 'privacy_suffix' => 'gelesen und stimme der Verarbeitung meiner Daten zu. *', + 'send' => 'Senden', + 'sending' => 'Wird gesendet...', + ], + + // Immobilien Contact Form + 'immobilien_form' => [ + 'success_title' => 'Anfrage erfolgreich gesendet!', + 'success_message' => 'Vielen Dank für Ihr Interesse. Wir melden uns so schnell wie möglich bei Ihnen.', + 'interest' => 'Interesse', + 'please_select' => 'Bitte wählen...', + 'first_name' => 'Vorname *', + 'first_name_placeholder' => 'Ihr Vorname', + 'last_name' => 'Nachname *', + 'last_name_placeholder' => 'Ihr Nachname', + 'email' => 'E-Mail *', + 'email_placeholder' => 'ihre@email.com', + 'phone' => 'Telefon (optional)', + 'message' => 'Nachricht (optional)', + 'message_placeholder' => 'Ihre Nachricht...', + 'privacy_prefix' => 'Ich habe die', + 'privacy_suffix' => 'gelesen und stimme der Verarbeitung meiner Daten zu. *', + 'submit' => 'Exposé & Verfügbarkeit anfragen', + 'sending' => 'Wird gesendet...', + ], + + // Portfolio + 'portfolio' => [ + 'no_projects' => 'Keine Projekte gefunden', + 'try_other_filter' => 'Versuchen Sie einen anderen Filter', + 'amenities' => 'Ausstattung', + 'project_details' => 'Projektdetails', + 'location' => 'Standort', + 'price' => 'Preis', + 'size' => 'Größe', + ], +]; diff --git a/resources/lang/en/b2in.php b/resources/lang/en/b2in.php new file mode 100644 index 0000000..858c064 --- /dev/null +++ b/resources/lang/en/b2in.php @@ -0,0 +1,1633 @@ + [ + 'b2in' => [ + 'announcement_bar' => [ + 'enabled' => true, + 'id' => 'azizi-launch-2026', + 'badge' => 'NEW LAUNCH', + 'text' => 'Azizi Creek Views 4 – Exclusive Off-Market Project in Dubai '.\App\Helpers\PriceHelper::formatAed(1_125_000, 'from'), + 'link_text' => 'View Exposé', + 'link_url' => '/immobilien/azizi-creek-views-4', + ], + 'header' => [ + 'portal_login' => 'Partner Login', + 'navigation' => [ + ['label' => 'Home', 'url' => '/'], + ['label' => 'Real Estate', 'url' => '/immobilien'], + ['label' => 'Network', 'url' => '/netzwerk'], + ['label' => 'Magazine', 'url' => '/magazin'], + ['label' => 'FAQ', 'url' => '/faq'], + ['label' => 'About B2in', 'url' => '/about'], + ], + ], + 'hero' => [ + 'title' => 'B2in – Where Exclusive Real Estate Investments Meet Smart Interior Design.', + 'subtitle' => 'Your partner for high-yield international real estate and global supply chain management in the interior sector.', + 'image' => 'b2in/hero-room.jpg', + 'image_alt' => 'B2in – International Real Estate and Exclusive Interior Concepts', + 'cta1_text' => 'View Real Estate Projects', + 'cta1_link' => '/immobilien', + 'cta2_text' => 'Our Network', + 'cta2_link' => '/netzwerk', + 'stats' => [ + 'International Real Estate', + 'Exclusive Furnishing', + 'Personal Consulting', + ], + 'card_title' => 'B2in', + 'card_text' => 'Connecting Design and Property', + ], + 'founder_bar' => [ + 'image' => 'b2in/marcel-scheibe.jpg', + 'name' => 'Marcel Scheibe', + 'title' => 'Founder & CEO, B2in', + 'statement' => 'B2in by Marcel Scheibe – Your personal partner for international real estate and exclusive interior concepts.', + ], + 'synergie_section' => [ + 'title' => 'Two Worlds. One Network.', + 'paragraphs' => [ + 'We connect real estate purchases with the perfect furnishing. Real estate investors benefit from our exclusive furniture network – project developers from our German contractual reliability in supply chain management.', + ], + 'image' => 'b2in/best-of-two-worlds.jpg', + 'image_alt' => 'The B2in Ecosystem – Real Estate and Interior', + 'image_caption' => 'The B2in Ecosystem', + ], + 'ecosystem_core' => [ + 'title' => 'One Ecosystem, Three Pillars', + 'subtitle' => '', + 'pillars' => [ + [ + 'icon' => 'building-office-2', + 'title' => 'Real Estate & Investments', + 'description' => 'Exclusive off-market projects & high-yield investment properties.', + 'link' => '/immobilien', + ], + [ + 'icon' => 'squares-2x2', + 'title' => 'Local-for-Local Marketplace', + 'description' => 'The network for regional furniture retailers and brokers.', + 'link' => '/netzwerk', + ], + [ + 'icon' => 'clipboard-document-check', + 'title' => 'Supply Chain Management', + 'description' => 'German contractual reliability for international property developers.', + 'link' => '/netzwerk', + ], + ], + ], + 'vision_section' => [ + 'title' => 'Built on Expertise and Trust', + 'paragraphs' => [ + 'B2in (Bridges2international) connects two worlds: international real estate and exclusive interior concepts. As your personal partner, I guide you through both areas – with expertise, network, and the commitment that every decision is based on trust.', + 'Whether an investment in Dubai, a villa in Lisbon, or the bespoke furnishing of your new home by local specialists – at B2in, everything comes together.', + 'Locally rooted, internationally connected – that is B2in.', + ], + 'image' => 'b2in/marcel-scheibe-about.jpg', + 'image_alt' => 'Marcel Scheibe, Founder and CEO of B2in', + 'image_caption' => 'Marcel Scheibe, Founder & CEO', + ], + 'brand_worlds' => [ + 'title' => 'Our Worlds:
Design, Real Estate, and International Trade', + 'subtitle' => 'From international real estate investments to exclusive interior concepts to transatlantic trade – B2in connects the worlds that belong together.', + 'worlds' => [ + [ + 'image' => 'b2in/stileigentum.jpg', + 'title' => 'Stileigentum', + 'description' => 'The premium segment: Exclusive and high-quality interior concepts for discerning clients who value quality and tradition.', + 'link' => env('DOMAIN_STILEIGENTUM_URL', 'https://stileigentum.test'), + 'logo' => 'img/logos/stileigentum-logo-positiv.svg', + 'logo_width' => 'w-35', + 'external' => true, + ], + [ + 'image' => 'b2in/style2own.jpg', + 'title' => 'Style2own', + 'description' => 'The lifestyle channel: Modern interior concepts for young professionals and trend-oriented clients – urban, flexible, inspiring.', + 'link' => env('DOMAIN_STYLE2OWN_URL', 'https://style2own.test'), + 'logo' => 'img/logos/style2own-logo-positiv.svg', + 'logo_width' => 'w-28', + 'external' => true, + ], + [ + 'image' => 'b2in/b2a.jpg', + 'title' => 'B2A', + 'description' => 'Our logistics powerhouse for transatlantic trade. We enable manufacturers direct access to international markets.', + 'link' => env('DOMAIN_B2A_URL', 'https://b2a.test'), + 'logo' => 'img/logos/b2a-logo-positiv.svg', + 'logo_width' => 'w-18', + 'external' => true, + ], + ], + ], + 'integriertes_modell_b2in' => [ + 'title' => 'The Best of Both Worlds:
Real Estate and Interior', + 'paragraphs' => [ + 'B2in connects what belongs together: Those who acquire property need the right furnishing. Those who want exclusive interiors will find the best local specialists through our network – complemented by the range of European manufacturers.', + 'The result: A seamless experience for the customer and new revenue streams for our partners – whether brokers, retailers, or developers.', + ], + 'image' => 'b2in/best-of-two-worlds.jpg', + 'image_alt' => 'The result for the customer – Property and interior from one source', + 'image_caption' => 'Property and interior – from one source', + ], + 'cta_section' => [ + 'title' => 'Your Next Step', + 'subtitle' => 'Whether real estate investment, supply chain partnership, or interior network – speak directly with us.', + 'button_text' => 'Get in Touch', + 'button_link' => '/contact', + ], + 'immobilien_hero' => [ + 'title' => 'Invest in the Future – Dubai, Lisbon & More.', + 'subtitle' => 'Exclusive off-market projects and high-yield investments. Personally curated and guided by Marcel Scheibe.', + 'features' => [ + [ + 'title' => 'Off-Market', + 'description' => 'Exclusive Projects', + 'icon' => 'lock-closed', + ], + [ + 'title' => 'High-Yield', + 'description' => 'High-Return Investments', + 'icon' => 'arrow-trending-up', + ], + [ + 'title' => 'Personal', + 'description' => 'Guidance by Marcel Scheibe', + 'icon' => 'user', + ], + [ + 'title' => 'International', + 'description' => 'Dubai, Lisbon & More', + 'icon' => 'globe-alt', + ], + ], + 'image' => 'b2in/hero-immobilien.jpg', + 'image_alt' => 'International Real Estate Investments', + 'card_title' => 'B2in Real Estate', + 'card_text' => 'Exclusive international investments', + 'hub' => [ + 'title' => 'B2in Real Estate', + 'subtitle' => 'Exclusive International Investments', + ], + 'stats' => [ + 'Off-Market Access', + 'Personal Guidance', + 'High-Yield Properties', + ], + ], + 'immobilien_projects' => [ + 'title' => 'Current Launches & Projects', + 'subtitle' => 'Discover our current real estate projects in international markets.', + 'projects' => [ + 'azizi-creek-views-4' => [ + 'slug' => 'azizi-creek-views-4', + 'title' => 'Azizi Developments: Creek Views 4', + 'location' => 'Al Jaddaf, Dubai', + 'status' => 'NEW LAUNCH', + 'launch_date' => '03.03.2026', + 'price_from' => \App\Helpers\PriceHelper::formatAed(1_125_000, 'from'), + 'image' => 'expose/a1/image-4.jpeg', + 'highlights' => [ + 'Prime Waterfront Views', + '1BR: '.\App\Helpers\PriceHelper::formatAed(1_125_000, 'from'), + 'Exclusive 3BR Penthouse (Single Inventory)', + 'High Rental Demand & Capital Appreciation', + ], + 'quick_facts' => [ + ['icon' => 'home-modern', 'label' => 'Types', 'value' => '1BR & 3BR Penthouse'], + ['icon' => 'squares-2x2', 'label' => 'Size', 'value' => '543 – 2,346 sqft'], + ['icon' => 'building-office-2', 'label' => 'Units', 'value' => 'Only 132 (Limited)'], + ['icon' => 'user', 'label' => 'Developer', 'value' => 'Azizi Developments'], + ], + 'investment_case' => [ + 'title' => 'Strong Investment, High Demand.', + 'text' => 'Creek Views 4 offers a strategic top location in Al Jaddaf. The combination of limited supply (only 132 units) and premium finishes makes this project the ideal choice for investors targeting capital appreciation and high rental demand.', + 'views' => [ + 'Road View', + 'Sitting & Play Area View', + 'Neighbour View', + ], + ], + 'gallery' => [ + 'expose/a1/image-4.jpeg', + 'expose/a1/image-3.jpeg', + 'expose/a1/image-2.jpeg', + 'expose/a1/image-5.jpeg', + 'expose/a1/image-6.jpeg', + 'expose/a1/image-7.jpeg', + ], + 'location_info' => [ + 'title' => 'Strategic Location: Al Jaddaf', + 'map_url' => 'https://maps.google.com/?q=Al+Jaddaf+Dubai', + 'points' => [ + 'Direct connection to Dubai Creek and Waterfront', + 'Just minutes to Downtown Dubai & Burj Khalifa', + 'Excellent infrastructure and growing district', + ], + ], + 'contact' => [ + 'title' => 'Secure one of the 132 units.', + 'subtitle' => 'Your contact: Marcel Scheibe', + 'options' => [ + '' => 'I am interested in...', + '1br' => '1 Bedroom Apartment', + '3br_penthouse' => '3BR Penthouse (Single Unit)', + 'general' => 'General Consultation', + ], + ], + ], + ], + 'cta_text' => 'Request Exposé & Availability Now', + 'cta_link' => '/contact', + ], + 'immobilien_moebel_vorteil' => [ + 'title' => 'Your Investment, Your Advantage', + 'text' => 'As a buyer of a B2in property, you receive exclusive insider access to our B2in furniture network. Furnish your property at unbeatable partner rates.', + 'button_text' => 'Learn More About the B2in Network', + 'button_link' => '/netzwerk', + ], + 'immobilien_trust' => [ + 'title' => 'Personal Guidance', + 'paragraphs' => [ + 'Behind every B2in investment stands Marcel Scheibe as your personal contact. No anonymous platform – but a face with expertise, network, and the commitment that your investment is in the best hands.', + ], + 'image' => 'b2in/marcel-scheibe-about.jpg', + 'image_alt' => 'Marcel Scheibe – Personal Investment Guidance', + 'image_caption' => 'Marcel Scheibe, Founder & CEO', + 'list' => [ + [ + 'icon' => 'calendar', + 'title' => 'Investor Evenings – Exclusive quarterly events in an intimate setting', + ], + [ + 'icon' => 'phone', + 'title' => 'Direct Line – Personal conversation and appointment scheduling', + ], + [ + 'icon' => 'shield-check', + 'title' => 'Transparency – Guided from market analysis to closing', + ], + ], + ], + // ============================================================ + // REAL ESTATE SOFT LAUNCH (v2) - New Sections + // ============================================================ + 'immobilien_hero_v2' => [ + 'title' => 'Investing in Global Dynamics. With German Reliability.', + 'subtitle' => 'Exclusive off-market projects, attractive returns, and guidance that goes far beyond the purchase contract. Discover the real estate market in Dubai.', + 'cta_text' => 'View Current Projects', + 'cta_link' => '#projekte', + 'image' => 'b2in/hero-immobilien.jpg', + 'image_alt' => 'Dubai Skyline – Premium Real Estate Investments', + ], + 'immobilien_warum_dubai' => [ + 'title' => 'Why an Investment in Dubai Is Worth It', + 'intro' => 'Dubai is not just a financial decision, but access to one of the world\'s fastest-growing markets. Investors value the clear legal structures and frameworks that are virtually unique worldwide:', + 'facts' => [ + [ + 'icon' => 'banknotes', + 'title' => '0% Taxes', + 'description' => 'No income tax on rental income, no capital gains tax on sale.', + ], + [ + 'icon' => 'arrow-trending-up', + 'title' => 'Strong Returns', + 'description' => 'Attractive rental yields of historically 6% to 9% annually.', + ], + [ + 'icon' => 'shield-check', + 'title' => 'High Security', + 'description' => 'A government-regulated market with a secure escrow trust system.', + ], + [ + 'icon' => 'currency-dollar', + 'title' => 'Stable Currency', + 'description' => 'The Dirham (AED) is pegged to the US Dollar.', + ], + [ + 'icon' => 'identification', + 'title' => 'Golden Visa', + 'description' => 'Secure residence permits through attractive investor programs.', + ], + [ + 'icon' => 'globe-alt', + 'title' => 'High Demand', + 'description' => 'International immigration and limited supply continuously drive housing demand upward.', + ], + ], + ], + 'immobilien_image_break' => [ + 'image' => 'b2in/hero-immobilien.jpg', + 'image_alt' => 'Dubai Skyline – Real Estate Investments', + 'quote' => 'Dubai has evolved into one of the most dynamic real estate markets in the world.', + 'author' => 'Marcel Scheibe', + ], + 'immobilien_kaufprozess' => [ + 'title' => 'Clearly Structured: The Purchase Process in Dubai', + 'intro' => 'The market in Dubai is faster, more digital, and organized by project. Every step is maximally secured through the government escrow system (trust accounts based on construction progress).', + 'steps' => [ + [ + 'number' => '1', + 'title' => 'Reservation (Booking Fee)', + 'description' => 'With a fee of approx. 3–10%, your desired unit is officially taken off the market and reserved for you.', + ], + [ + 'number' => '2', + 'title' => 'Down Payment & Contract (SPA)', + 'description' => 'After an initial down payment (usually 10%), the official Sales & Purchase Agreement (SPA) is drawn up.', + ], + [ + 'number' => '3', + 'title' => 'Government Registration (DLD)', + 'description' => 'By paying the registration fee (4%) to the Dubai Land Department, your ownership is officially recorded in the government register.', + ], + [ + 'number' => '4', + 'title' => 'Final Purchase Contract', + 'description' => 'Your ownership rights are officially secured. Further payments are made strictly according to construction progress into secure trust accounts.', + ], + ], + ], + 'immobilien_bruecke' => [ + 'title' => '"The market speaks for itself. My role is a different one."', + 'paragraphs' => [ + 'For many German investors, buying real estate in Dubai initially feels unfamiliar. In Germany, we are accustomed to notaries, land registries, and highly bureaucratic processes. Dubai is more dynamic.', + 'I am not here to sell you the market – the quality of the projects speaks for itself. My role is to be your bridge. As an investor who has bought in Dubai myself, I know the practice. I am regularly on-site, in permanent contact with developers, and I guide you through the entire process. I translate international speed into German reliability.', + ], + 'advantage_title' => 'Your B2in Advantage', + 'advantage_text' => 'My guidance doesn\'t end with the purchase. Planning lucrative short-term rentals (e.g., Airbnb)? Through our B2in network and my roots in the furniture industry, we realize complete interior concepts for you – for maximum returns with minimum effort.', + 'cta_text' => 'Schedule a Personal Consultation', + 'cta_link' => '/contact', + 'image' => 'b2in/marcel-scheibe-about.jpg', + 'image_alt' => 'Marcel Scheibe – Personal Investment Guidance', + 'image_caption' => 'Marcel Scheibe, Founder & CEO', + ], + 'immobilien_mindset' => [ + 'title' => 'Are You the Right Investor for Dubai?', + 'text_positive' => 'Dubai is for people who think internationally. The government prioritizes efficiency, economic development, and tremendous speed. If you believe in the shift of global economic centers and value a dynamic system, Dubai is the strategically perfect building block for your portfolio. Then you are in the right place with us.', + 'text_negative' => 'However, if you prefer a system built on slow decision-making and maximum bureaucracy, this market will not match your expectations.', + 'closing' => 'The step is smaller than you think. Let\'s find out together whether an investment in Dubai fits your strategy.', + 'cta_text' => 'Schedule a Non-Binding Consultation', + 'cta_link' => '/contact', + ], + + // ============================================================ + // NETWORK SOFT LAUNCH - Combined Teaser Page + // ============================================================ + 'netzwerk_hero' => [ + 'title' => 'The B2in Ecosystem – Real Estate Meets Interior.', + 'subtitle' => 'We are currently building the smartest local-for-local furnishing network. As a B2in real estate client, you will benefit exclusively from our closed shop in the future.', + 'image' => 'b2in/ecosystem-hero.jpg', + 'image_alt' => 'B2in Ecosystem – Interior and Real Estate', + ], + 'netzwerk_image_break' => [ + 'image' => 'b2in/ecosystem-hero.jpg', + 'image_alt' => 'B2in Ecosystem – Interior and Real Estate', + 'quote' => 'We connect what belongs together: real estate and interior.', + 'author' => 'Marcel Scheibe', + ], + 'netzwerk_teasers' => [ + 'title' => 'What We Are Building', + 'cards' => [ + [ + 'icon' => 'squares-2x2', + 'title' => 'Interior Network', + 'description' => 'Personal consultation by local specialists, curated European manufacturers, and exclusive rates for B2in real estate clients. Furnishing you can touch – not just order online.', + 'status' => 'In Development', + ], + [ + 'icon' => 'building-storefront', + 'title' => 'For Retailers & Specialists', + 'description' => 'Are you a local interior specialist looking to join a premium network? We connect you with clients who value personal consultation and quality.', + 'status' => 'Pre-Registration Available', + ], + [ + 'icon' => 'globe-alt', + 'title' => 'For Developers & Brands', + 'description' => 'German contractual reliability for international property developers. Supply chain management focused on schedule adherence, quality control, and enforcement.', + 'status' => 'Pre-Registration Available', + ], + ], + ], + 'netzwerk_cta' => [ + 'title' => 'Interested in a Partnership?', + 'text' => 'Whether as a specialist retailer, manufacturer, broker, or developer – contact us for pre-registration and be among the first to know when our network goes live.', + 'button_text' => 'Get in Touch', + 'button_link' => '/contact', + ], + 'netzwerk_cabinet_partner' => [ + 'badge' => 'Premium partner', + 'title' => 'Our premium partner: CABINET Built-In Wardrobes', + 'lead' => 'Bespoke excellence for the highest standards.', + 'paragraphs' => [ + 'In the B2in ecosystem, we focus on lasting quality. We are proud to feature CABINET as a premium partner in our network. The CABINET store in Bielefeld (our physical anchor) stands for bespoke built-in solutions that unite design, functionality, and craftsmanship.', + 'Whether walk-in wardrobes, smart room concepts, or exclusive interiors – CABINET delivers the premium standard our real estate investors and developers expect for high-end projects. This partnership reflects our philosophy: connecting international real estate with German interior excellence.', + ], + 'image' => 'b2in/cabinet_logo.png', + 'image_alt' => 'CABINET built-in wardrobes – B2in premium partner', + ], + 'netzwerk_immobilien_hint' => [ + 'title' => 'Already available: Real estate', + 'description' => 'Our real estate division is already active. Discover exclusive off-market projects in Dubai with personal guidance from Marcel Scheibe.', + 'button_text' => 'View real estate projects', + 'button_link' => '/immobilien', + ], + + 'interior_hero' => [ + 'title' => 'Exclusive Furnishing. Locally Conceived, Internationally Connected.', + 'subtitle' => 'The B2in interior network connects local expertise with international manufacturers – personal, tangible, and in your area.', + 'features' => [ + [ + 'title' => 'Local-for-Local', + 'description' => 'Your specialist retailer nearby', + 'icon' => 'map-pin', + ], + [ + 'title' => 'Premium Network', + 'description' => 'Curated manufacturers & brands', + 'icon' => 'star', + ], + [ + 'title' => 'Two Brands', + 'description' => 'stileigentum & style2own', + 'icon' => 'squares-2x2', + ], + [ + 'title' => 'Personal', + 'description' => 'Consultation over algorithms', + 'icon' => 'user', + ], + ], + 'image' => 'b2in/ecosystem-hero.jpg', + 'image_alt' => 'B2in Interior Network – Local-for-Local', + 'card_title' => 'B2in Interior', + 'card_text' => 'Local-for-Local Interior Network', + 'hub' => [ + 'title' => 'B2in Interior', + 'subtitle' => 'The Interior Network', + ], + 'stats' => [ + 'Local Specialists', + 'European Manufacturers', + 'Personal Consultation', + ], + ], + 'interior_concept' => [ + 'title' => 'What Does Local-for-Local Mean?', + 'paragraphs' => [ + 'Our interior network relies on what online cannot deliver: real consultation, real touch, real expertise. Instead of anonymous platforms, we facilitate direct contact with local specialist retailers – people who understand their craft and guide you personally.', + 'B2in connects these local experts with a curated network of European manufacturers. The result: Access to exclusive brands and collections that you won\'t find online – but with the consultation quality that only your local specialist can offer.', + ], + 'image' => 'b2in/ecosystem_result.jpg', + 'image_alt' => 'Local-for-Local – Personal consultation instead of anonymous platforms', + 'image_caption' => 'Personal interior consultation on-site', + ], + 'interior_brands' => [ + 'title' => 'Two Brands, One Network', + 'subtitle' => 'Depending on your style and standards, you can access our network through one of our two interior brands.', + 'brands' => [ + [ + 'name' => 'stileigentum', + 'tagline' => 'Premium Interior', + 'description' => 'Exclusive and high-quality interior concepts for discerning clients who value quality and tradition. Hand-selected manufacturers, timeless materials, individual consultation.', + 'audience' => 'For clients seeking the extraordinary.', + 'logo' => 'img/logos/stileigentum-logo-positiv.svg', + 'logo_width' => 'w-35', + 'link' => 'https://stileigentum.test', + ], + [ + 'name' => 'style2own', + 'tagline' => 'Design Lifestyle', + 'description' => 'Modern interior concepts for young professionals and trend-oriented clients – urban, flexible, inspiring. Current trends, smart solutions, fair prices.', + 'audience' => 'For everyone who loves design.', + 'logo' => 'img/logos/style2own-logo-positiv.svg', + 'logo_width' => 'w-28', + 'link' => 'https://style2own.test', + ], + ], + ], + 'interior_zielgruppen' => [ + 'title' => 'Who Is the Interior Network For?', + 'groups' => [ + [ + 'icon' => 'home', + 'title' => 'Private Individuals', + 'description' => 'Furnishing your new home and looking for personal consultation instead of endless online scrolling? Through our network, you\'ll find local specialists who understand your style.', + ], + [ + 'icon' => 'building-office-2', + 'title' => 'Real Estate Investors', + 'description' => 'Have you made an investment through B2in? As a property buyer, you receive exclusive access to partner rates for complete furnishing – all from one source.', + ], + [ + 'icon' => 'clipboard-document-check', + 'title' => 'Developers & Brokers', + 'description' => 'Need model furnishings, home staging, or turnkey outfitting for your projects? B2in coordinates from planning to delivery.', + ], + ], + ], + 'interior_process' => [ + 'title' => 'It\'s That Simple', + 'steps' => [ + [ + 'number' => '01', + 'title' => 'Consultation', + 'description' => 'Contact us or visit one of our local partners. Together, we define your style, budget, and preferences.', + ], + [ + 'number' => '02', + 'title' => 'Selection', + 'description' => 'Your specialist retailer shows you curated collections from European manufacturers – to touch, not just on a screen.', + ], + [ + 'number' => '03', + 'title' => 'Delivery & Setup', + 'description' => 'B2in coordinates logistics and delivery. Your local partner accompanies the setup down to the last detail.', + ], + ], + ], + 'interior_trust' => [ + 'title' => 'Personal Instead of Anonymous', + 'paragraphs' => [ + 'Behind the B2in interior network stands Marcel Scheibe with the conviction that great furnishing requires personal consultation. No algorithm replaces the conversation with an expert who knows materials, understands spaces, and matches your style.', + ], + 'image' => 'b2in/marcel-scheibe-about.jpg', + 'image_alt' => 'Marcel Scheibe – The face behind the interior network', + 'image_caption' => 'Marcel Scheibe, Founder & CEO', + 'list' => [ + [ + 'icon' => 'map-pin', + 'title' => 'Local Experts – Specialist retailers in your region who advise you personally', + ], + [ + 'icon' => 'star', + 'title' => 'Curated Manufacturers – Only verified European quality brands', + ], + [ + 'icon' => 'shield-check', + 'title' => 'Transparent Pricing – Fair rates with no hidden markups', + ], + ], + ], + 'faq' => [ + 'title' => 'Frequently Asked Questions', + 'subtitle' => 'Here you will find answers to the most common questions about B2in and our ecosystem.', + 'questions' => [ + [ + 'question' => 'What is B2in and what services do you offer?', + 'answer' => 'B2in is the bridge between international premium real estate and exclusive interior concepts. We offer real estate investors access to off-market projects (focus: Dubai) and guide them through the entire purchase process. At the same time, we provide developers with rigorous supply chain management for sourcing German quality furniture and are building an innovative interior network for local retailers.', + ], + [ + 'question' => 'What does supply chain management mean at B2in?', + 'answer' => 'For international property developers, we act as an extended arm in Germany. We take over the operational and strategic management of furniture and interior procurement. This means: We secure supply contracts, monitor milestones directly with manufacturers, and escalate immediately to management level in case of deviations. Our goal is absolute contractual reliability and on-time delivery without friction losses.', + ], + [ + 'question' => 'How can I become a partner at B2in?', + 'answer' => 'Our network is aimed at property developers, brokers, and regional furniture retailers. If you are a developer seeking German procurement reliability or a retailer wanting to become part of our future "Local for Local" network to receive qualified leads from property buyers, simply use our contact form. We evaluate every partnership individually for quality and fit.', + ], + [ + 'question' => 'How does real estate investment through B2in work?', + 'answer' => 'We don\'t just broker, we guide you. Marcel Scheibe is your personal advisor who knows the market (especially Dubai) from his own investor perspective. After a needs analysis, we present you with exclusive projects (e.g., from Azizi Developments). The purchase process itself is maximally secured through Dubai\'s government escrow system. Your special advantage: As a B2in client, you subsequently receive exclusive access to our network to furnish your investment turnkey and optimized for returns.', + ], + [ + 'question' => 'What makes B2in a trustworthy partner?', + 'answer' => 'Trust comes from transparency and personal market experience. B2in founder Marcel Scheibe invests himself in the markets we offer and is regularly on-site to check construction progress. We combine the immense dynamics of international markets (like Dubai) with German reliability, strict contract management, and a curated network of premium partners like CABINET. With us, you always have a personal contact who represents your interests.', + ], + ], + 'sections' => [ + [ + 'title' => 'Focus: Real Estate Investors (B2C)', + 'icon' => 'home-modern', + 'questions' => [ + [ + 'question' => 'Do I need to travel to Dubai personally for the property purchase?', + 'answer' => 'No, the entire purchase process can be completed fully digitally and legally binding from abroad. The Dubai Land Department offers highly secure processes for this. While we do recommend our investors to visit and experience the city\'s dynamics firsthand, your physical presence is not strictly required for the legally secure acquisition.', + ], + [ + 'question' => 'What happens after the property purchase? Do you help with furnishing?', + 'answer' => 'This is exactly where the great advantage of the B2in ecosystem lies. Our service doesn\'t end with the purchase contract. As a B2in client, you receive exclusive access to our interior network. We support you in furnishing your property turnkey and optimized for returns with quality furniture – the perfect foundation for lucrative short-term rentals.', + ], + [ + 'question' => 'What tax advantages does an investment in Dubai offer?', + 'answer' => 'Dubai offers a globally unique economic environment. There is neither income tax on rental income nor capital gains tax on the sale of the property. Combined with the strong rental yields, this makes the location strategically valuable for international investors.', + ], + ], + ], + [ + 'title' => 'Focus: B2B Partners (Brokers & Retailers)', + 'icon' => 'briefcase', + 'questions' => [ + [ + 'question' => 'How do real estate brokers benefit from a partnership with B2in?', + 'answer' => 'B2in offers brokers an exclusive tool for client retention: You can present your property buyers with access to our closed interior network as an exclusive "closing gift." At the same time, as a broker, you benefit from our technical clearing system with a passive lifetime compensation on your clients\' furniture purchases.', + ], + [ + 'question' => 'What exactly is the "Local for Local" marketplace?', + 'answer' => 'Our "Local for Local" principle is the counter-concept to anonymous online furniture giants. We make the immediately available stock and showroom pieces of regional specialist retailers (the "Local Express" pillar) transparent to a closed customer base. This strengthens local trade on-site while offering buyers insider rates for premium furnishing.', + ], + [ + 'question' => 'Is the interior network publicly accessible to everyone?', + 'answer' => 'No. To protect the exclusive insider rates and discounts from our manufacturers and local retailers, the B2in furniture marketplace operates as a "closed shop." Access is exclusively through invitations – for example, through our partner brokers upon property acquisition.', + ], + ], + ], + ], + ], + 'contact_form' => [ + 'hero' => [ + 'title' => 'Send Us a
Message.', + 'subtitle' => 'We look forward to your message and will get back to you as soon as possible.', + ], + 'form' => [ + 'labels' => [ + 'first_name' => 'First Name *', + 'last_name' => 'Last Name *', + 'company' => 'Company (optional)', + 'email' => 'Email *', + 'phone' => 'Phone (optional)', + 'subject' => 'Subject *', + 'message' => 'Your Message *', + ], + 'subjects' => [ + '' => 'Select a subject', + 'immobilien' => 'International Real Estate', + 'supply_chain' => 'Supply Chain Management', + 'general' => 'General Inquiry', + 'partnership' => 'Partnership', + 'press' => 'Press', + 'career' => 'Career', + ], + 'placeholders' => [ + 'message' => 'Your message...', + ], + 'button_text' => 'Send', + 'button_loading' => 'Sending...', + 'success_message' => 'Thank you for your message! We will get back to you as soon as possible.', + ], + 'contact_info' => [ + [ + 'title' => 'Our Office Location', + 'info' => [ + 'Rathausstraße 11', + '33602 Bielefeld', + ], + 'icon' => 'map-pin', + ], + [ + 'title' => 'Our Email Address', + 'info' => [ + 'info@b2in.eu', + ], + 'icon' => 'mail', + ], + [ + 'title' => 'Our Phone Number', + 'info' => [ + '+49 (0) 5221 9255055', + ], + 'icon' => 'phone', + ], + ], + 'social_media' => [ + 'title' => 'Follow for
exclusives', + 'subtitle' => 'Stay up to date with exclusive offers and news.', + 'platforms' => [ + ['name' => 'Instagram', 'handle' => '@b2in_official', 'url' => 'https://instagram.com/b2in_official'], + ['name' => 'Youtube', 'handle' => 'B2IN Channel', 'url' => 'https://youtube.com/@b2in'], + ['name' => 'Pinterest', 'handle' => 'B2IN Inspiration', 'url' => 'https://pinterest.com/b2in'], + ['name' => 'Facebook', 'handle' => 'B2IN Deutschland', 'url' => 'https://facebook.com/b2in'], + ['name' => 'LinkedIn', 'handle' => 'B2IN Company', 'url' => 'https://linkedin.com/company/b2in'], + ], + ], + ], + 'about_hero' => [ + 'title' => 'About B2in: Our Mission', + 'quote' => '"My mission is to connect two worlds that belong together: international real estate and exclusive interior design. B2in gives the local specialist the digital tools and the property developer the operational partner on the ground.

We build bridges – between European design, international markets, and people\'s homes."', + 'founder_name' => 'Marcel Scheibe', + 'founder_title' => 'Founder & CEO, B2in', + 'image' => 'b2in/about-hero.jpg', + 'image_alt' => 'Marcel Scheibe, Founder and CEO of B2in', + + 'card_title' => 'B2in', + 'card_text' => 'Connecting Design and Property', + + ], + 'broker_section' => [ + 'title' => 'Lifetime Compensation for Brokers', + 'subtitle' => 'Benefit from a revolutionary compensation model that goes beyond the one-time sale. Build long-term client relationships and generate continuous returns.', + 'card_title' => 'Lifetime Compensation', + 'compensation' => [ + 'initial_sale' => '3.5%', + 'follow_up' => '1.5%', + ], + 'compensation_text' => 'Continuous returns throughout the entire client relationship', + 'benefits' => [ + [ + 'title' => 'Lifetime Compensation Model', + 'description' => 'Continuous commissions through long-term client relationships and recurring business', + 'icon' => 'trending-up', + ], + [ + 'title' => 'Faster Marketing', + 'description' => 'Well-designed living concepts reduce sales time and increase success rates', + 'icon' => 'clock', + ], + [ + 'title' => 'Qualified Leads', + 'description' => 'Pre-filtered, interested clients through the B2in portal and premium memberships', + 'icon' => 'target', + ], + [ + 'title' => 'Premium Positioning', + 'description' => 'Exclusive marketing of high-quality living concepts for discerning target groups', + 'icon' => 'award', + ], + ], + ], + 'commitment_section' => [ + 'title' => 'The Trust of Our Partners', + 'subtitle' => 'Real opinions from real partners. Your success is our greatest motivation.', + 'testimonials' => [ + [ + 'image' => 'b2in/testo-1.jpg', + 'rating' => 5, + 'quote' => 'Working with B2in has exceeded our expectations. Professional, efficient, and always solution-oriented.', + 'author' => 'Max Mustermann', + 'author_title' => 'Furniture Manufacturer', + ], + [ + 'image' => 'b2in/testo-2.jpg', + 'rating' => 5, + 'quote' => 'Thanks to the B2in platform, we were able to significantly increase our reach and tap into new markets.', + 'author' => 'Erika Mustermann', + 'author_title' => 'Local Furniture Retailer', + ], + [ + 'image' => 'b2in/testo-3.jpg', + 'rating' => 5, + 'quote' => 'The B2in portal has revolutionized the way I market real estate. The staging enhances my properties, and the furniture commission is an extremely attractive additional income.', + 'author' => 'John Doe', + 'author_title' => 'Real Estate Broker', + ], + ], + ], + 'dark_stats_section' => [ + 'stats' => [ + ['number' => '17+', 'text' => 'Years of Experience'], + ['number' => '2M', 'text' => 'Happy Guests'], + ], + 'title' => 'Economically Sound and Well-
Friendly Service for
Families and Their
Precious Belongings', + 'description' => 'We understand that every family is unique, which is why we offer personalized services tailored to meet your specific needs. From luxury amenities to budget-friendly options, we ensure every guest feels valued and comfortable.', + 'features' => [ + ['title' => 'Top Consultation', 'subtitle' => 'Expert guidance'], + ['title' => 'Finest Meal', 'subtitle' => 'Gourmet dining'], + ['title' => 'Easy Tax Reduction', 'subtitle' => 'Cost-effective'], + ], + 'image' => 'b2in/accommodation-2.jpg', + 'image_alt' => 'Luxury interior design', + ], + 'ecosystem_hero' => [ + 'title' => 'How Our Ecosystem Generates Growth for All Partners', + 'subtitle' => 'An intelligent network that seamlessly connects property buyers, local specialists, international manufacturers, brokers, and developers – for extraordinary real estate and interior experiences.', + 'features' => [ + [ + 'title' => 'Real Estate', + 'description' => 'International Investments', + 'icon' => 'globe-alt', + ], + [ + 'title' => 'Interior', + 'description' => 'Local for Local', + 'icon' => 'cube-transparent', + ], + [ + 'title' => 'Supply Chain', + 'description' => 'Procurement & Control', + 'icon' => 'clipboard-document-check', + ], + [ + 'title' => 'Technology', + 'description' => 'Digital Core', + 'icon' => 'cpu-chip', + ], + ], + 'image' => 'b2in/ecosystem-hero.jpg', + 'image_alt' => 'Ecosystem Hero Image', + 'card_title' => 'B2in Portal', + 'card_text' => 'The technology behind it that enables the ecosystem', + 'hub' => [ + 'title' => 'B2in Portal', + 'subtitle' => 'The technology behind it that enables the ecosystem', + ], + 'stats' => [ + 'Exclusive Selection', + 'Personal Service', + 'Lasting Values', + ], + ], + 'ecosystem_stats' => [ + 'title' => 'Our Ecosystem in Numbers', + 'subtitle' => 'Numbers that reflect the strength and trust in our connected business model.', + 'stats' => [ + [ + 'number' => '1.7K+', + 'label' => 'Partners & Experts in the Network', + 'description' => 'Growing community of developers, brokers, retailers, and manufacturers', + ], + [ + 'number' => '510+', + 'label' => 'Completed Projects', + 'description' => 'Real estate and interior projects through our network', + ], + [ + 'number' => '98%', + 'label' => 'Partner Satisfaction', + 'description' => 'Satisfaction across all ecosystem participants', + ], + [ + 'number' => '24/7', + 'label' => 'Partner Support', + 'description' => 'Continuous availability of the digital infrastructure', + ], + ], + ], + 'ecosystem_start' => [ + 'title' => 'Everything Begins with the Moment of Need:', + 'paragraphs' => [ + 'Our ecosystem starts with the customer – exactly when they need it: at the property purchase.', + 'Through the broker, the customer gains access to the B2in ecosystem. Our brands style2own and stileigentum create the right framework – tailored to the target group and lifestyle.', + ], + 'image' => 'b2in/ecosystem_start.jpg', + 'image_alt' => 'The entry into the ecosystem – through the property purchase', + 'image_caption' => 'The Entry: Property Purchase as Trigger', + 'image_description' => 'A stylized graphic showing how the property purchase triggers entry into the B2in ecosystem.', + ], + 'ecosystem_hub' => [ + 'title' => 'In the Hub, Local for Local Meets International Expertise', + 'paragraphs' => [ + 'Once a customer selects their region, our platform plays to its strengths: The "Local First" logic prominently displays the offerings of local specialists.', + 'The range is complemented by European manufacturers. And for property developers, we deliver the operational procurement as well – from contract to quality control.', + ], + 'image' => 'b2in/ecosystem_hub.jpg', + 'image_alt' => 'Local for Local – Local expertise meets international procurement', + 'image_caption' => 'Local for Local: Local Expertise, International Reach', + 'image_description' => 'A clear infographic showing: [Local Offering] + [International Procurement] = The B2in Ecosystem.', + ], + 'ecosystem_result' => [ + 'title' => 'A Cycle Where Everyone Wins', + 'paragraphs' => [ + 'In this interplay, clear advantages emerge for every participant:', + ], + 'list' => [ + [ + 'icon' => 'globe-alt', + 'title' => 'The property developer receives a reliable partner for procurement and quality control in Germany – transparent and on schedule.', + ], + [ + 'icon' => 'building-storefront', + 'title' => 'The local retailer gains qualified customers they couldn\'t have reached on their own, strengthening their local position.', + ], + [ + 'icon' => 'home-modern', + 'title' => 'The broker offers their clients a unique added value after closing and benefits from attractive additional commissions.', + ], + ], + 'image' => 'b2in/ecosystem_result.jpg', + 'image_alt' => 'Your success and the partnership with B2in', + 'image_caption' => 'Your Success and the Partnership with B2in', + 'image_description' => 'A stylized graphic showing how your success and the partnership with B2in are interconnected.', + ], + 'end_customer_section' => [ + 'tag' => 'For End Customers', + 'title' => 'Exclusive Experiences for You', + 'subtitle' => 'With your personal login card, you gain access to a unique world of experiences, specially tailored to your living preferences and lifestyle. Discover curated properties and services that are otherwise unavailable.', + 'benefits' => [ + [ + 'title' => 'Exclusive Login Card', + 'description' => 'Personalized access to selected real estate experiences and premium services', + 'icon' => 'credit-card', + ], + [ + 'title' => 'Personalized Experience World', + 'description' => 'Tailored property offerings based on individual preferences and needs', + 'icon' => 'star', + ], + [ + 'title' => 'Curated Living Concepts', + 'description' => 'High-quality, well-designed property solutions from verified partners', + 'icon' => 'home', + ], + [ + 'title' => 'Quality Guarantee', + 'description' => 'Verified providers and standardized quality processes for maximum security', + 'icon' => 'shield', + ], + ], + 'image' => 'b2in/end-customer-section.jpg', + 'image_alt' => 'End Customer Section Image', + 'card_title' => 'Login Card', + 'card_text' => 'Your key to exclusive real estate experiences', + 'card' => [ + 'title' => 'Login Card', + 'subtitle' => 'Your key to exclusive real estate experiences', + 'member_number_label' => 'Member Number', + 'member_number' => 'B2IN-2024-VIP', + ], + ], + 'final_commitment' => [ + 'title' => 'We\'re committed to
your comfort and
satisfaction for
unforgettable
experiences', + 'author' => 'Robert Wilson', + 'author_title' => 'General Manager', + ], + 'digital_core' => [ + 'title' => 'The Technology That Enables This Cycle', + 'subtitle' => 'Our central platform is more than just technology. It is the digital core for transparent processes, data-driven decisions, and seamless collaboration across the entire ecosystem.', + 'features' => [ + [ + 'title' => 'Maximum Reliability', + 'description' => 'Our platform is accessible anytime, anywhere. It grows with your success and guarantees stable operations you can rely on.', + 'icon' => 'cloud', + 'icon_style' => 'solid', + ], + [ + 'title' => 'Uncompromising Security', + 'description' => 'Your data and your clients\' data are our highest priority. We protect them with state-of-the-art security architectures and guarantee complete data privacy.', + 'icon' => 'shield-check', + ], + [ + 'title' => 'Future-Proof Integration', + 'description' => 'The B2in portal is open for the future. It integrates seamlessly with other systems and is ready for future expansions and technologies.', + 'icon' => 'squares-2x2', + ], + [ + 'title' => 'Data-Driven Insights', + 'description' => 'Track your clients\' activities as a broker or your products\' performance as a supplier – all in real-time. Make better decisions based on valid data.', + 'icon' => 'chart-bar', + 'icon_style' => 'solid', + ], + [ + 'title' => 'Intelligent Personalization', + 'description' => 'Artificial intelligence supports you and your clients – from personalized interior suggestions to process automation for maximum efficiency.', + 'icon' => 'cpu-chip', + ], + [ + 'title' => 'Excellent User Experience', + 'description' => 'Our platform is optimized for maximum speed and offers intuitive, fluid navigation on every device – from desktop to smartphone.', + 'icon' => 'users', + 'icon_style' => 'solid', + ], + ], + ], + 'magazin_detail' => [ + 'back_to_magazine' => 'Back to Magazine', + 'share_article' => 'Share Article', + 'cta_title' => 'Discover more about real estate investments and the B2in ecosystem.', + 'cta_button' => 'Discover More Articles', + ], + 'magazin_list' => [ + 'title' => 'B2in Magazine', + 'subtitle' => 'Insights, market analyses, and practical knowledge about real estate investments in Dubai, interior concepts, and the B2in ecosystem.', + 'read_more' => 'Read More', + 'load_more' => 'Load More Articles', + ], + 'our_story' => [ + 'title' => 'Our Story', + 'timeline' => [ + [ + 'title' => 'The Idea', + 'description' => 'In 2024, we identified a crucial gap: Local retailers need digital visibility, and property buyers are looking for seamless furnishing solutions. These two worlds belong together.', + 'icon' => 'light-bulb', + ], + [ + 'title' => 'The Foundation', + 'description' => 'We are developing the B2in platform: an ecosystem that connects local interior specialists, European manufacturers, and real estate professionals on a shared platform – fair, transparent, and technology-driven.', + 'icon' => 'rocket-launch', + ], + [ + 'title' => 'The Expansion', + 'description' => '2025/2026 sees B2in expanding its ecosystem: International real estate and supply chain management for developers become the dominant pillar. The local furniture marketplace remains as a strong complementary area.', + 'icon' => 'globe-alt', + ], + [ + 'title' => 'The Vision', + 'description' => 'B2in becomes the central network for "Design & Property" – locally rooted, internationally connected. In every region, the reliable partner for real estate investments and exclusive interior concepts.', + 'icon' => 'star', + ], + ], + 'summary' => 'What began as a vision to strengthen local trade is today a network that seamlessly connects real estate and interior. B2in bridges the gap between international investments and local expertise – with Marcel Scheibe as the face and point of contact.', + ], + 'about_image_break' => [ + 'image' => 'b2in/best-of-two-worlds.jpg', + 'image_alt' => 'B2in – Connecting Real Estate and Interior', + 'quote' => 'Locally rooted, internationally connected – that is B2in.', + 'author' => 'Marcel Scheibe', + ], + 'our_values' => [ + 'title' => 'Our Values', + 'subtitle' => 'These six pillars guide our daily actions and define who we are as a company and what we stand for.', + 'values' => [ + [ + 'title' => 'Innovation', + 'description' => 'We develop digital solutions that give real estate professionals and local interior specialists a genuine competitive advantage.', + 'icon' => 'light-bulb', + ], + [ + 'title' => 'Connectivity', + 'description' => 'We connect international real estate markets with local expertise and European manufacturers with the people who value their products.', + 'icon' => 'globe-alt', + ], + [ + 'title' => 'Quality', + 'description' => 'Uncompromising standards – in the selection of our partners, the curation of interiors, the monitoring of supply chains, and the technology that holds it all together.', + 'icon' => 'check-badge', + ], + [ + 'title' => 'Trust', + 'description' => 'Transparent models and reliable partnerships are our foundation. Behind B2in stands a face – and the promise that we deliver what we commit to.', + 'icon' => 'user-group', + ], + [ + 'title' => 'Sustainability', + 'description' => 'We strengthen local trade, optimize transport routes, and ensure that international procurement runs responsibly and efficiently.', + 'icon' => 'arrow-path', + ], + [ + 'title' => 'Design Excellence', + 'description' => 'Design is the core of our value creation – from international architecture to curated interiors to the intuitive design of our digital platform.', + 'icon' => 'cube-transparent', + ], + ], + ], + 'partner_benefits' => [ + 'title' => 'Why Become a Partner?', + 'subtitle' => 'Discover the advantages of a partnership with B2in and how you can benefit from our innovative ecosystem.', + 'broker' => [ + 'tag' => 'For Brokers', + 'title' => 'Revolutionary Commission Model', + 'benefits' => [ + [ + 'icon' => 'trending-up', + 'title' => 'Lifetime Commission Model', + 'description' => 'Benefit from continuous income through our innovative compensation system', + ], + [ + 'icon' => 'target', + 'title' => 'Faster Marketing', + 'description' => 'Well-designed living concepts shorten marketing times and increase your success rate', + ], + [ + 'icon' => 'award', + 'title' => 'Added Value for Your Clients', + 'description' => 'Offer your clients exclusive, curated real estate experiences', + ], + ], + 'highlight' => [ + 'value' => '3.5% - ∞', + 'text' => 'Initial commission to lifetime compensation', + ], + ], + 'supplier' => [ + 'tag' => 'For Suppliers', + 'title' => 'Global Market Opportunities', + 'benefits' => [ + [ + 'icon' => 'globe', + 'title' => 'Access to International Markets', + 'description' => 'Expand your reach across borders with our global network', + ], + [ + 'icon' => 'handshake', + 'title' => 'Fair Terms', + 'description' => 'Transparent and partnership-based business conditions for sustainable success', + ], + [ + 'icon' => 'settings', + 'title' => 'Easy Product Management', + 'description' => 'Intuitive platform for managing and presenting your products', + ], + ], + 'highlight' => [ + 'image' => 'b2in/accommodation-1.jpg', + 'alt' => 'Partner success visualization', + 'value' => '500+', + 'text' => 'Successful Partners', + ], + ], + ], + 'supply_chain_intro' => [ + 'title' => 'Supply Chain Management: Your Extended Arm in Germany.', + 'paragraphs' => [ + 'For property developers looking to source furniture or interior fittings from Germany, we act as your extended arm on the ground – with a clear focus on contractual reliability, schedule adherence, and enforcement.', + ], + 'list' => [ + [ + 'icon' => 'document-check', + 'title' => 'Contract Management – Drafting, structuring, and securing supply contracts', + ], + [ + 'icon' => 'shield-check', + 'title' => 'Contract Enforcement – Milestone monitoring, escalation, follow-up', + ], + [ + 'icon' => 'magnifying-glass-circle', + 'title' => 'Tracking & Quality Control – Ongoing monitoring, personal inspection, on-time delivery', + ], + ], + 'image' => 'b2in/marcel-scheibe-about.jpg', + 'image_alt' => 'Marcel Scheibe – Supply Chain Management', + 'image_caption' => 'Marcel Scheibe, Founder & CEO', + ], + 'partner_cta' => [ + 'title' => 'Grow With Us', + 'subtitle' => 'Become part of the B2in network – whether as a property developer, interior specialist, manufacturer, or broker. We connect the worlds that belong together.', + 'stats' => [ + [ + 'number' => '500+', + 'label' => 'Active Partners', + ], + [ + 'number' => '98%', + 'label' => 'Satisfaction Rate', + ], + [ + 'number' => '24/7', + 'label' => 'Partner Support', + ], + ], + 'button_text' => 'Become a B2in Partner', + 'button_link' => '/contact', + 'small_text' => 'Discover the benefits of a strategic partnership with B2in', + ], + 'partner_hero' => [ + 'title' => 'For Developers & Partners', + 'subtitle' => 'Whether property developer, local interior specialist, European brand, or broker – B2in is the network that connects your business with the right partners and clients.', + 'partner_types' => [ + [ + 'title' => 'Property Developers', + 'description' => 'Supply chain management from Germany', + 'icon' => 'globe-alt', + ], + [ + 'title' => 'Manufacturers & Brands', + 'description' => 'Curated market access, intelligent logistics', + 'icon' => 'building-office-2', + ], + [ + 'title' => 'Local Retailers', + 'description' => 'Digital reach, exclusive product range', + 'icon' => 'building-storefront', + ], + [ + 'title' => 'Brokers & Developers', + 'description' => 'Added value for clients, additional income for you', + 'icon' => 'home-modern', + ], + ], + 'image' => 'b2in/partner-hero.jpg', + 'image_alt' => 'Partner Hero Image', + 'card_title' => 'Partner Network', + 'card_text' => 'Become Part of Our Ecosystem', + 'hub' => [ + 'title' => 'Partner Network', + 'subtitle' => 'Become Part of Our Ecosystem', + ], + 'connection_points' => [ + ['name' => 'Brokers', 'subtext' => 'Lifetime Model'], + ['name' => 'Suppliers', 'subtext' => 'Global Markets'], + ['name' => 'Success', 'subtext' => 'Measurable Goals'], + ['name' => 'Quality', 'subtext' => 'Premium Standards'], + ], + ], + 'partner_card_section' => [ + 'title' => 'What Partner Type Are You?', + 'subtitle' => 'Discover the benefits of a partnership with B2in – whether in real estate, interior, or supply chain.', + 'cards' => [ + [ + 'title' => 'For Property Developers', + 'description' => 'Operational management of your procurement from Germany – contract management, quality control, and on-time delivery.', + 'icon' => 'globe-alt', + 'button' => '#partner-benefits-developer', + 'button_text' => 'Your Benefits as a Developer', + ], + [ + 'title' => 'For Local Retailers & Specialists', + 'description' => 'Strengthen your local business. Gain access to online customers and an exclusive, supra-regional product range.', + 'icon' => 'building-storefront', + 'button' => '#partner-benefits-retailer', + 'button_text' => 'Your Benefits as a Retailer', + ], + [ + 'title' => 'For Manufacturers & European Brands', + 'description' => 'Open up new, curated distribution channels in regional markets and benefit from our intelligent logistics.', + 'icon' => 'building-office-2', + 'button' => '#partner-benefits-supplier', + 'button_text' => 'Your Benefits as a Manufacturer', + ], + [ + 'title' => 'For Real Estate Brokers & Developers', + 'description' => 'Offer your clients unique added value, accelerate marketing, and secure attractive additional commissions.', + 'icon' => 'home-modern', + 'button' => '#partner-benefits-broker', + 'button_text' => 'Your Benefits as a Broker', + ], + ], + ], + 'partner_benefits_developer' => [ + 'id' => 'partner-benefits-developer', + 'tag' => 'Your Benefits as a Property Developer', + 'tag_icon' => 'globe-alt', + 'tag_title' => 'Your Extended Arm in Germany.', + 'features' => [ + [ + 'title' => 'Contract Management', + 'description' => 'Support in drafting and structuring supply contracts. Definition of clear performance and quality parameters, securing payment and delivery terms.', + 'icon' => 'document-check', + ], + [ + 'title' => 'Contract Enforcement', + 'description' => 'Active monitoring of agreed milestones, escalation to management level in case of deviations, and consistent follow-up of open items.', + 'icon' => 'shield-check', + ], + [ + 'title' => 'Tracking & Quality Control', + 'description' => 'Ongoing production and delivery monitoring, personal inspection when needed, and ensuring on-time delivery.', + 'icon' => 'magnifying-glass-circle', + ], + [ + 'title' => 'Network & Market Knowledge', + 'description' => 'Direct connection to manufacturers and decision-makers in Germany. We ensure that agreements don\'t just exist on paper but are actually implemented.', + 'icon' => 'link', + ], + ], + 'highlight' => [ + 'value' => '100%', + 'text' => 'Transparency, reliability, and predictable delivery – without operational friction', + ], + 'image' => 'b2in/partner-benefits-developer.jpg', + 'image_alt' => 'Supply Chain Management for Property Developers', + ], + 'partner_benefits_retailer' => [ + 'id' => 'partner-benefits-retailer', + 'tag' => 'Your Benefits as a Local Retailer', + 'tag_icon' => 'building-storefront', + 'tag_title' => 'Become the Digital Champion in Your Region.', + 'features' => [ + [ + 'title' => 'Digital Reach', + 'description' => 'We bring you the online customers you can\'t reach on your own. Benefit from our high-reach consumer brands.', + 'icon' => 'signal', + ], + [ + 'title' => 'Product Range Extension', + 'description' => 'Complement your offering with exclusive manufacturer brands from our portfolio – without any inventory risk.', + 'icon' => 'squares-plus', + ], + [ + 'title' => 'Protection Against Online Giants', + 'description' => 'With "Local First," we specifically strengthen your market position. The customer always sees your offering first.', + 'icon' => 'shield-check', + ], + [ + 'title' => 'Fair Terms', + 'description' => 'Our commission model is transparent and designed so that we only earn when you do too.', + 'icon' => 'scale', + ], + ], + 'highlight' => [ + 'value' => '+35%', + 'text' => 'Digital reach in the first year (average of our partners)', + ], + 'image' => 'b2in/partner-benefits-retailer.jpg', + 'image_alt' => 'Partner Benefits Retailer', + ], + 'partner_benefits_supplier' => [ + 'id' => 'partner-benefits-supplier', + 'tag' => 'Your Benefits as a Manufacturer & Brand', + 'tag_icon' => 'building-office-2', + 'tag_title' => 'Open Up New Markets – Intelligently and Curated.', + 'features' => [ + [ + 'title' => 'Curated Distribution Channel', + 'description' => 'Instead of getting lost in the noise of large marketplaces, your brand is presented to design-savvy customers in affluent regions.', + 'icon' => 'eye', + ], + [ + 'title' => 'Efficient Logistics', + 'description' => 'Our bundled logistics reduce your distribution costs. We manage consolidated shipping and complete processing.', + 'icon' => 'truck', + ], + [ + 'title' => 'Direct Market Access', + 'description' => 'Skip the traditional wholesale and build a direct relationship with regional markets and end customers.', + 'icon' => 'map-pin', + ], + [ + 'title' => 'Scalable Growth', + 'description' => 'Start with us in one region and gradually expand into further European hubs.', + 'icon' => 'chart-bar-square', + ], + ], + 'highlight' => [ + 'value' => '> 20', + 'text' => 'Curated regional hubs as new distribution channels in Europe', + ], + 'image' => 'b2in/partner-benefits-supplier.jpg', + 'image_alt' => 'Partner Benefits Supplier', + ], + 'partner_benefits_broker' => [ + 'id' => 'partner-benefits-broker', + 'tag' => 'Your Benefits as a Broker & Real Estate Professional', + 'tag_icon' => 'home-modern', + 'tag_title' => 'Added Value for Your Clients – More Revenue for You.', + 'features' => [ + + [ + 'title' => 'Faster Marketing Through Home Staging', + 'description' => 'Use our curated furniture packages (stileigentum & style2own) to professionally stage your properties. Accelerate the sales process and achieve higher prices.', + 'icon' => 'rocket-launch', + ], + [ + 'title' => 'Unique After-Closing Service', + 'description' => 'Offer your buyers or tenants direct access to a complete furnishing service through our exclusive login card after contract signing.', + 'icon' => 'gift', + ], + [ + 'title' => 'Attractive Commission Model', + 'description' => 'Benefit twice: In addition to your classic brokerage fee, you receive a fair, lifetime commission on all furniture sales your clients generate through the platform.', + 'icon' => 'currency-euro', + ], + [ + 'title' => 'Easy Handling', + 'description' => 'Our digital broker portal makes it easy: invite clients, track activities, and view commissions transparently.', + 'icon' => 'finger-print', + ], + ], + 'highlight' => [ + 'value' => '-25%', + 'text' => 'Shorter marketing time for properties staged with B2in', + ], + 'image' => 'b2in/partner-benefits-broker.jpg', + 'image_alt' => 'Partner Benefits Broker', + ], + 'partner_process' => [ + 'title' => 'How to Become a Partner', + 'subtitle' => 'In just three simple steps, you become part of the B2in ecosystem and can benefit from all the advantages of our partnership.', + 'steps' => [ + [ + 'step' => '1', + 'title' => 'Apply', + 'description' => 'Tell us your story. Fill out our short contact form and show us what sets your products or business apart.', + 'icon' => 'envelope', + 'image' => 'b2in/room-1.jpg', + ], + [ + 'step' => '2', + 'title' => 'Review', + 'description' => 'We review every application personally. Our goal is to build a high-quality, complementary network that benefits everyone.', + 'icon' => 'check-circle', + 'image' => 'b2in/room-2.jpg', + ], + [ + 'step' => '3', + 'title' => 'Onboarding', + 'description' => 'Welcome aboard! We personally train you on using our partner portal and ensure you are successful from day one.', + 'icon' => 'rocket-launch', + 'image' => 'b2in/room-3.jpg', + ], + ], + 'cta' => [ + 'title' => 'Ready for the Next Step?', + 'subtitle' => 'Become part of the B2in ecosystem today and benefit from innovative business models and sustainable success strategies.', + 'button_text' => 'Become a Partner Now', + 'button_link' => '/contact', + ], + ], + + 'supplier_section' => [ + 'tag' => 'For Suppliers', + 'title' => 'Curated Platform for Providers', + 'subtitle' => 'Become part of an exclusive network of high-quality providers. Present your products and services to a pre-qualified, affluent audience with the highest quality standards.', + 'benefits' => [ + [ + 'title' => 'Curated Distribution Channel', + 'description' => 'Access to an exclusive, pre-qualified customer base with high quality standards', + 'icon' => 'store', + ], + [ + 'title' => 'Self-Management', + 'description' => 'Full control over product presentation, pricing, and availability', + 'icon' => 'settings', + ], + [ + 'title' => 'Central Quality Assurance', + 'description' => 'Standardized processes and quality controls for maximum customer trust', + 'icon' => 'check-circle', + ], + [ + 'title' => 'Analytics & Insights', + 'description' => 'Detailed sales analytics and market insights for optimized business decisions', + 'icon' => 'bar-chart', + ], + ], + 'dashboard' => [ + 'title' => 'Provider Dashboard', + 'stats' => [ + [ + 'label' => 'Product Visibility', + 'value' => '94%', + ], + [ + 'label' => 'Quality Rating', + 'value' => '98%', + ], + ], + ], + ], + 'leadership_team' => [ + 'title' => 'The Leadership Team', + 'subtitle' => 'Our experienced team brings decades of expertise in technology, operations, and business development.', + 'team_tag' => 'B2IN TEAM', + 'team' => [ + [ + 'name' => 'Marcel Scheibe', + 'position' => 'Founder & CEO', + 'expertise' => 'Visionary for the digital future of local trade and strategic bridge-builder between the USA and Europe.', + 'image' => 'b2in/marcel-scheibe.jpg', + ], + [ + 'name' => 'Sarah Müller', + 'position' => 'Head of Operations', + 'expertise' => 'Expert in optimizing our Europe-wide logistics processes and operational excellence of our regional hubs.', + 'image' => 'b2in/sarah-mueller.jpg', + ], + [ + 'name' => 'Thomas Weber', + 'position' => 'Head of Technology', + 'expertise' => 'Technology leader focused on an intuitive, scalable platform architecture that seamlessly connects retailers and manufacturers.', + 'image' => 'b2in/thomas-weber.jpg', + ], + ], + ], + // Additional components for b2in + ], + ], + 'articles' => [ + 1 => [ + 'id' => 1, + 'title' => 'Security Over Bureaucracy:
Why Dubai\'s Escrow System Surprises German Investors', + 'subtitle' => 'How the government-regulated trust system in Dubai consistently protects investor funds – and why it is far ahead of international standards.', + 'image' => 'b2in/magazin-1.jpg', + 'category' => 'Dubai Investment', + 'date' => 'March 10, 2026', + 'readTime' => '6 min read', + 'author' => [ + 'name' => 'Marcel Scheibe', + 'bio' => 'Founder & CEO of B2in. Marcel is a real estate investor in Dubai himself and personally guides German buyers through the entire purchase process.', + 'avatar' => 'b2in/marcel-scheibe.jpg', + ], + 'content' => [ + 'intro' => 'German investors value one thing above all when buying real estate: maximum security. The familiar process through notaries and land registries provides a sense of control but is often accompanied by long waiting times and enormous bureaucracy. Those looking at Dubai\'s real estate market for the first time are often surprised by its incredible dynamism and speed. But does this pace mean higher risk? On the contrary: with the Escrow system, Dubai has created one of the world\'s safest and most transparent regulatory frameworks that consistently protects investor funds.', + 'sections' => [ + [ + 'title' => 'The Escrow System: Security Through Government-Regulated Trust Accounts', + 'content' => 'The most important protection mechanism when purchasing an off-plan property in Dubai is the legally mandated Escrow system. Buyer payments do not flow directly to the developer but are deposited into a project-specific trust account at an authorized bank. This account is monitored by the Real Estate Regulatory Agency (RERA), a regulatory body of the Dubai Land Department (DLD). The developer only gains access to funds according to actual construction progress, which must be confirmed by certified engineers.', + ], + [ + 'title' => 'Strict Oversight by the Dubai Land Department (DLD)', + 'content' => 'The Dubai Land Department is the central government institution for real estate in Dubai. Through its regulatory body RERA, it oversees all off-plan projects and ensures that developers comply with strict legal requirements. Every purchase contract (SPA) is officially registered, and construction projects must be recorded in the so-called Oqood system. Buyer payments flow into project-specific Escrow accounts and may only be released according to confirmed construction progress.', + ], + [ + 'title' => 'Transparency and High Predictability', + 'content' => 'The close link between buyer payments and actual construction progress creates a high degree of transparency for investors. Developers may only draw funds from the Escrow account in accordance with confirmed construction progress. This ensures that investor funds are used project-specifically and cannot be diverted to other construction projects. For buyers, this means: the dynamism of Dubai\'s real estate market is combined with a clearly regulated system that is internationally regarded as comparatively investor-friendly.', + ], + ], + ], + ], + 2 => [ + 'id' => 2, + 'title' => 'Spotlight Al Jaddaf:
Why Smart Investors Are Betting on This Emerging Hotspot Now', + 'subtitle' => 'Strategic waterfront location, high rental demand, and enormous potential for capital appreciation – Al Jaddaf is the hidden champion among Dubai\'s districts.', + 'image' => 'b2in/magazin-2.jpg', + 'category' => 'Dubai Investment', + 'date' => 'March 5, 2026', + 'readTime' => '7 min read', + 'author' => [ + 'name' => 'Marcel Scheibe', + 'bio' => 'Founder & CEO of B2in. Marcel is a real estate investor in Dubai himself and personally guides German buyers through the entire purchase process.', + 'avatar' => 'b2in/marcel-scheibe.jpg', + ], + 'content' => [ + 'intro' => 'Choosing the right location is the decisive factor for high returns and long-term capital appreciation. While world-famous areas like Downtown Dubai or Palm Jumeirah command high entry prices, smart investors look for the "hidden champions" – districts on the verge of massive value appreciation. One of the city\'s most exciting development areas is currently Al Jaddaf. Historically known as the shipyard district on Dubai Creek, Al Jaddaf is rapidly transforming into a modern, strategically crucial hub for exclusive living and lifestyle.', + 'sections' => [ + [ + 'title' => 'The Strategic Waterfront Location', + 'content' => 'Al Jaddaf offers a rare combination of direct waterfront access and central connectivity. Nestled between the historic Dubai Creek and the modern extension, it provides unobstructed views of the skyline. At the same time, hotspots like Downtown Dubai, the Burj Khalifa, and Dubai International Airport are reachable in just a few minutes by car. This logistics advantage makes the location incomparable.', + ], + [ + 'title' => 'High Rental Demand Through Perfect Infrastructure', + 'content' => 'The district increasingly attracts young professionals, expats, and families who want to live centrally yet quietly and exclusively. The proximity to Healthcare City, cultural highlights like the Jameel Arts Centre, and metro connectivity provide excellent infrastructure. This guarantees investors continuously high rental demand and minimal vacancy rates.', + ], + [ + 'title' => 'Enormous Potential for Capital Appreciation', + 'content' => 'Currently, Al Jaddaf still offers entry prices that represent excellent value for money – especially compared to already established premium districts. With new high-end projects (such as Azizi Creek Views), the district is being massively upgraded. Those who invest now will benefit in the coming years not only from attractive rental yields but above all from significant property value appreciation.', + ], + ], + ], + ], + 3 => [ + 'id' => 3, + 'title' => 'Turnkey Investments:
How the Right Furnishing Maximizes Your Rental Yield in Dubai', + 'subtitle' => 'Why turnkey interior concepts make the difference between average and premium returns.', + 'image' => 'b2in/magazin-3.jpg', + 'category' => 'Returns & Interior', + 'date' => 'February 25, 2026', + 'readTime' => '6 min read', + 'author' => [ + 'name' => 'Marcel Scheibe', + 'bio' => 'Founder & CEO of B2in. With roots in the furniture industry, Marcel combines real estate expertise with interior design competence.', + 'avatar' => 'b2in/marcel-scheibe.jpg', + ], + 'content' => [ + 'intro' => 'Purchasing a premium property in Dubai is the first step toward a successful investment. But it is the right utilization that determines the actual return. Particularly lucrative is short-term rental (e.g., via Airbnb), which generates thriving revenues in Dubai. The challenge for many international investors: How do you furnish an apartment from thousands of kilometers away so that it stands out from the crowd and achieves premium prices? The answer is "turnkey" – intelligent interior concepts that combine aesthetics, durability, and efficiency.', + 'sections' => [ + [ + 'title' => 'Premium Look for Higher Nightly Rates', + 'content' => 'The first impression on booking platforms is decisive. An averagely furnished apartment achieves average prices. Bespoke design concepts perfectly tailored to the property\'s architecture and the demographic target group (business travelers or vacationers) allow you to position yourself in the premium segment and significantly increase rental income.', + ], + [ + 'title' => 'The "Turnkey" Advantage from Afar', + 'content' => 'Nobody wants to deal with delivery delays, missing screws, or contractor appointments in Dubai from Europe. An intelligent turnkey concept takes this entire process off your hands. Through the exclusive B2in network, buyers gain access to a service that covers everything from the first design sketch to the fully furnished bed – completely managed through German project management.', + ], + [ + 'title' => 'Durability Through German Quality Standards', + 'content' => 'With high occupancy in short-term rentals, furniture is heavily used. Cheap furnishings often need to be replaced after a short time, which reduces returns. The focus on durable materials and first-class workmanship – often secured through access to German and European premium manufacturers – reduces maintenance costs to a minimum and preserves the value of your investment.', + ], + ], + ], + ], + 4 => [ + 'id' => 4, + 'title' => 'Supply Chain Management for Developers:
Why Contractual Reliability Makes the Difference', + 'subtitle' => 'How professional procurement management from Germany prevents construction delays and saves millions.', + 'image' => 'b2in/magazin-4.jpg', + 'category' => 'B2B & Partners', + 'date' => 'February 15, 2026', + 'readTime' => '7 min read', + 'author' => [ + 'name' => 'Marcel Scheibe', + 'bio' => 'Founder & CEO of B2in. As a bridge between European manufacturers and international developers, Marcel ensures contractual reliability and schedule adherence.', + 'avatar' => 'b2in/marcel-scheibe.jpg', + ], + 'content' => [ + 'intro' => 'International property developers know the problem: a luxury project is nearly complete, but the interior fittings from Europe are delayed. Poor communication, hidden clauses, or logistical bottlenecks lead to construction delays that can cost millions. In a globalized world, it is not enough to simply order furniture and materials – you must guarantee their arrival. This is exactly where professional supply chain management comes in. As an extended arm on the ground in Germany, B2in ensures that contracts don\'t just exist on paper but are fulfilled on time in reality.', + 'sections' => [ + [ + 'title' => 'Escalation to Management Level', + 'content' => 'When deliveries stall, emails often fizzle out in customer service. Effective supply chain management requires enforcement power and the right contacts. Through a deeply embedded network in the European interior industry, we intervene immediately when deviations occur and escalate problems directly to the manufacturers\' management level to force immediate solutions.', + ], + [ + 'title' => 'Active Contract Management Instead of Hope', + 'content' => 'Contractual reliability begins before the signature. It is about defining clear performance and quality parameters as well as hard milestones. Proactive management continuously monitors these parameters and secures payment and delivery terms so that the developer retains full control over the process at all times without suffering operational friction losses.', + ], + [ + 'title' => 'Seamless Tracking and Quality Control', + 'content' => 'Trust is good, on-site control is better. To prevent failures, production progress is monitored directly at the source. Through regular, personal quality inspections at the manufacturers, we ensure that goods are not only loaded on time but exactly meet the premium standards demanded by the property project developers.', + ], + ], + ], + ], + 5 => [ + 'id' => 5, + 'title' => 'Local for Local:
How Regional Furniture Retail Is Shaping the Future of Living', + 'subtitle' => 'Why consumers are longing again for tactile experiences and personal consultation – and how digital marketplaces strengthen local specialist retail.', + 'image' => 'b2in/magazin-5.jpg', + 'category' => 'Interior & Network', + 'date' => 'February 5, 2026', + 'readTime' => '5 min read', + 'author' => [ + 'name' => 'Marcel Scheibe', + 'bio' => 'Founder & CEO of B2in. Marcel builds the bridge between digital convenience and regional specialist retail strength with the Local-for-Local concept.', + 'avatar' => 'b2in/marcel-scheibe.jpg', + ], + 'content' => [ + 'intro' => 'In recent years, the trend seemed unstoppable: gigantic online platforms dominated the furniture market. But the market is changing. Consumers are longing again for tactile experiences, personal consultation, and immediate availability. At the same time, true treasures ("hidden gems") lie dormant in regional furniture stores, often invisible online. The "Local for Local" concept addresses exactly this: it is a digital marketplace approach that strengthens not anonymous wholesale but local specialist retail – building a bridge between digital convenience and regional strength.', + 'sections' => [ + [ + 'title' => 'Digital Visibility for Local Inventory', + 'content' => 'The question "What is available near me today?" was one that local retailers often could not answer digitally. Modern marketplace technologies change that. They give regional retailers the tools to make their immediately available showroom and warehouse stock visible to a broad, affluent audience (such as new property owners) – all without complex in-house IT infrastructure.', + ], + [ + 'title' => 'Support Your Locals – A Win for Everyone', + 'content' => 'The "David vs. Goliath" principle brings customers back to the stores. Buyers benefit from exclusive prices for showroom pieces and branded goods that are often cheaper than at large retailers. The retailer, in turn, increases foot traffic, builds liquidity through quick sales, and gains new customers who value genuine, in-person consultation.', + ], + [ + 'title' => 'Smart Networking Instead of Anonymous Platforms', + 'content' => 'The future belongs not to closed online shops but to connected ecosystems. When real estate brokers, customers, and regional retailers come together on one platform, a cycle of trust emerges. The property purchase becomes the trigger for the furniture purchase, and the local specialist retailer becomes the reliable partner for the perfect furnishing – locally conceived, intelligently connected.', + ], + ], + ], + ], + ], +]; diff --git a/resources/lang/en/b2in_legal.php b/resources/lang/en/b2in_legal.php new file mode 100644 index 0000000..8343db9 --- /dev/null +++ b/resources/lang/en/b2in_legal.php @@ -0,0 +1,394 @@ + [ + 'title' => 'Legal Notice', + 'meta_title' => 'Legal Notice', + 'subtitle' => 'Information pursuant to § 5 TMG (German Telemedia Act)', + 'back_link' => '← Back', + 'content' => ' +
+

Provider

+

+ B2in GmbH
+ Feldstraße 59
+ 32120 Hiddenhausen, Germany +

+
+ +
+

Represented by

+

Marcel Scheibe – Managing Director

+
+ +
+

Contact

+

+ Email: info@b2in.de
+ Phone: +49 (0) 5221 9255055 +

+
+ +
+

Commercial Register

+

+ Registered in the Commercial Register.
+ Register Court: Amtsg. Bad Oeynhausen
+ Register Number: HRB 13068 +

+
+ +
+

VAT ID

+

+ VAT Identification Number pursuant to § 27a of the German VAT Act:
+ DE283859321 +

+
+ +
+

Responsible for Content pursuant to § 55 Para. 2 RStV

+

+ Marcel Scheibe
+ Feldstraße 59
+ 32120 Hiddenhausen, Germany +

+
+ +
+

EU Dispute Resolution

+

+ We are neither willing nor obliged to participate in dispute resolution proceedings before a consumer arbitration board. +

+
+ +
+

Liability for Content

+

+ As a service provider, we are responsible for our own content on these pages in accordance with general legislation pursuant to § 7 Para. 1 TMG. However, pursuant to §§ 8 to 10 TMG, we are not obligated as a service provider to monitor transmitted or stored third-party information or to investigate circumstances that indicate illegal activity. +

+

+ Obligations to remove or block the use of information under general law remain unaffected. However, liability in this regard is only possible from the point in time at which a concrete infringement of the law becomes known. If we become aware of any such infringements, we will remove this content immediately. +

+
+ +
+

Liability for Links

+

+ Our website contains links to external third-party websites over whose content we have no influence. Therefore, we cannot assume any liability for this third-party content. The respective provider or operator of the linked pages is always responsible for the content of the linked pages. +

+
+ +
+

Copyright

+

+ The content and works created by the site operators on these pages are subject to German copyright law. Duplication, processing, distribution, and any kind of exploitation beyond the limits of copyright require the written consent of the respective author or creator. Downloads and copies of this page are only permitted for private, non-commercial use. +

+

+ Insofar as the content on this page was not created by the operator, the copyrights of third parties are respected. In particular, third-party content is identified as such. Should you nevertheless become aware of a copyright infringement, please inform us accordingly. If we become aware of any infringements, we will remove such content immediately. +

+
+ +
+

Concept, Design and Implementation

+

+ adametz.media, Bielefeld +

+
+ ', + ], + + 'terms' => [ + 'title' => 'Terms of Use', + 'meta_title' => 'Terms of Use', + 'subtitle' => 'Last updated: 13 March 2026', + 'back_link' => '← Back', + 'content' => ' +
+

1. Scope

+

+ These Terms of Use apply to the use of the publicly accessible website and associated information services of B2in GmbH ("B2in", "we", "us"). By accessing and using our website, you agree to these terms. +

+
+ +
+

2. Description of Services

+

+ B2in is the network that connects exclusive design and international real estate ("Connecting Design and Property"). Through our website, we provide non-binding information about our services, real estate projects (e.g., in Dubai or Lisbon), supply chain management, and our interior network. The actual provision of services, brokerage, or procurement does not take place through the website, but exclusively on the basis of separate, individual contracts. +

+
+ +
+

3. Non-Binding Nature of Real Estate and Investment Information (IMPORTANT!)

+

+ The real estate projects, exposés, pricing information, floor plans, and return forecasts (e.g., expected rental yields) presented on the website are intended solely for general information and inspiration. +

+

+ No Investment Advice: The content provided does not constitute financial, tax, or investment advice and is not a binding contractual offer. +

+

+ Subject to Change: As we work with international developers and project builders, prices, availability (e.g., of limited units), construction progress, and handover dates may change at any time. B2in assumes no warranty for future value development or the achievement of projected returns. +

+
+ +
+

4. Partner Portal and Closed Networks

+

+ For certain target groups (e.g., brokers, property developers, retailers), B2in offers access to a closed portal or partner network. Access to these password-protected areas requires prior registration and approval by B2in. Separate General Terms and Conditions (GTC) or partner contracts apply to the use of these closed areas. +

+
+ +
+

5. Copyright and Usage Rights

+

+ The content provided on this website (in particular texts, exposé images, graphics, logos, and videos) is protected by copyright. Any reproduction, processing, distribution, or any kind of exploitation beyond the limits of copyright requires the prior written consent of B2in or the respective rights holder (e.g., developer). +

+
+ +
+

6. Disclaimer

+

+ The contents of our pages have been created with the utmost care. However, we cannot guarantee the accuracy, completeness, and timeliness of the content (particularly project descriptions from third-party providers). As a service provider, we are responsible for our own content on these pages in accordance with general legislation pursuant to § 7 Para. 1 TMG. However, we are not obligated to monitor transmitted or stored third-party information. +

+
+ +
+

7. External Links

+

+ Our website contains links to external third-party websites (e.g., Google Maps or partner websites) over whose content we have no influence. The respective provider or operator of the linked pages is always responsible for the content of the linked pages. A permanent content review of the linked pages is not reasonable without concrete evidence of a legal violation. If we become aware of any legal violations, we will remove such links immediately. +

+
+ +
+

8. Changes to the Terms of Use

+

+ We reserve the right to change these Terms of Use at any time and without stating reasons. The current version is always available on this page. By continuing to use the website after a change, you accept the modified terms. +

+
+ +
+

9. Final Provisions

+

+ The law of the Federal Republic of Germany shall apply, excluding the UN Convention on Contracts for the International Sale of Goods. If the user is a merchant, a legal entity under public law, or a special fund under public law, the place of jurisdiction for all disputes arising from the usage relationship shall be the registered office of B2in GmbH. +

+
+ ', + ], + + 'privacy' => [ + 'title' => 'Privacy Policy', + 'meta_title' => 'Privacy Policy', + 'subtitle' => 'Last updated: 13 March 2026', + 'back_link' => '← Back', + 'content' => ' +
+

1. Data Controller

+

+ The data controller responsible for data processing on this website within the meaning of the General Data Protection Regulation (GDPR) is: +

+

+ B2in GmbH
+ Feldstraße 59
+ 32120 Hiddenhausen, Germany
+ Phone: +49 (0) 5221 9255055
+ Email: info@b2in.eu +

+
+ +
+

2. Data Collection on Our Website (Server Log Files)

+

+ When you access our website, your browser automatically sends information to our website\'s server. This information is temporarily stored in a so-called log file. The following information is collected without your intervention and stored until automated deletion: +

+
    +
  • IP address of the requesting device
  • +
  • Date and time of access
  • +
  • Name and URL of the retrieved file
  • +
  • Website from which access is made (referrer URL)
  • +
  • Browser used and, if applicable, the operating system of your device and the name of your access provider
  • +
+

+ Purpose and Legal Basis: Processing is carried out to ensure a smooth connection to the website, to evaluate system security and stability, and for other administrative purposes. The legal basis for this is Art. 6 Para. 1 lit. f GDPR. Our legitimate interest follows from the purposes listed above for data collection. +

+
+ +
+

3. Contact Form and Email Contact

+

+ If you send us inquiries via the contact form or email, your details from the inquiry form, including the contact data you provided (such as name, email address, phone number), will be stored by us for the purpose of processing the inquiry and in case of follow-up questions. We do not share this data without your consent. +

+

+ The processing of this data is based on Art. 6 Para. 1 lit. b GDPR, insofar as your inquiry is related to the performance of a contract or is necessary for the implementation of pre-contractual measures (e.g., real estate investment or supply chain management). In all other cases, the processing is based on our legitimate interest in the effective processing of inquiries addressed to us (Art. 6 Para. 1 lit. f GDPR) or on your consent (Art. 6 Para. 1 lit. a GDPR), if this was requested. +

+

+ The data you enter in the contact form will remain with us until you request deletion, revoke your consent to storage, or the purpose for data storage no longer applies (e.g., after your inquiry has been fully processed). Mandatory statutory provisions – in particular retention periods – remain unaffected. +

+
+ +
+

4. Cookies

+

+ Our website uses cookies. These are small text files that your browser automatically creates and stores on your device. +

+
    +
  • Technically necessary cookies: These are required to ensure the operation of the website (e.g., saving your consent settings). The legal basis for this is Art. 6 Para. 1 lit. f GDPR.
  • +
  • Analytics and marketing cookies: These cookies (e.g., Google Analytics) are only set if you have previously given us your explicit consent via our cookie consent banner (Art. 6 Para. 1 lit. a GDPR).
  • +
+

+ You can revoke or adjust your consent at any time by accessing the cookie settings via the following link: + Change Cookie Settings +

+
+ +
+

5. Google Analytics

+

+ Insofar as you have given your consent, Google Analytics is used on this website, a web analytics service provided by Google Ireland Limited, Gordon House, Barrow Street, Dublin 4, Ireland ("Google"). + Google Analytics uses cookies that enable an analysis of your use of the website. The information generated is usually transmitted to a Google server in the USA and stored there. +

+

+ IP Anonymization: We have activated the IP anonymization function on this website. This means that your IP address is truncated by Google within member states of the European Union or in other contracting states of the Agreement on the European Economic Area before being transmitted to the USA. +

+

+ Purpose and Legal Basis: On our behalf, Google will use this information to evaluate your use of the website, to compile reports on website activity, and to provide other services related to website use and internet usage to the website operator. + The legal basis for the use of Google Analytics is your voluntarily given consent pursuant to Art. 6 Para. 1 lit. a GDPR and § 25 Para. 1 TTDSG/TDDDG. +

+

+ Data Transfer to the USA: Data transfers to the USA are based on the adequacy decision of the EU Commission (EU-US Data Privacy Framework), under which Google LLC is certified, as well as on Standard Contractual Clauses. +

+

+ Revocation / Objection: You can revoke your consent at any time with future effect via our + Cookie Settings. + You can also prevent data collection by Google Analytics by downloading and installing the browser plugin available at the following link: + https://tools.google.com/dlpage/gaoptout. +

+
+ +
+

6. External Links (e.g., Google Maps)

+

+ On our website, you will find links to external services, such as Google Maps, to visualize locations (e.g., real estate projects) for you. We do not directly embed these services on our site but merely link to them. When you click on such a link, you leave our website and are redirected to the servers of the respective provider (e.g., Google). Only there will your data (such as your IP address) be collected by the external provider. The privacy policy of the respective provider applies. +

+
+ +
+

7. SSL/TLS Encryption

+

+ This site uses SSL or TLS encryption for security reasons and to protect the transmission of confidential content, such as inquiries you send to us as the site operator. You can recognize an encrypted connection by the browser\'s address bar changing from "http://" to "https://" and by the lock icon in your browser bar. When encryption is activated, the data you transmit to us cannot be read by third parties. +

+
+ +
+

8. Your Rights as a Data Subject

+

+ You have the right: +

+
    +
  • pursuant to Art. 15 GDPR, to request information about your personal data processed by us.
  • +
  • pursuant to Art. 16 GDPR, to immediately request the correction of inaccurate or completion of your data stored by us.
  • +
  • pursuant to Art. 17 GDPR, to request the deletion of your data stored by us, unless processing is necessary for exercising the right of freedom of expression, for compliance with a legal obligation, or for the establishment of legal claims.
  • +
  • pursuant to Art. 18 GDPR, to request the restriction of processing of your data.
  • +
  • pursuant to Art. 20 GDPR, to receive your data in a structured, commonly used, and machine-readable format (data portability).
  • +
  • pursuant to Art. 7 Para. 3 GDPR, to revoke your consent at any time.
  • +
  • pursuant to Art. 21 GDPR, to object to the processing, insofar as your data is processed on the basis of legitimate interests.
  • +
+

+ To exercise your rights, simply send an email to: info@b2in.eu. +

+

+ You also have the right to lodge a complaint with a data protection supervisory authority (Art. 77 GDPR). +

+
+ ', + ], + + 'cookie_policy' => [ + 'title' => 'Cookie Policy', + 'meta_title' => 'Cookie Policy', + 'subtitle' => 'Last updated: 13 March 2026', + 'back_link' => '← Back', + 'content' => ' +
+

1. Data Controller

+

+ Responsible for cookie usage on this website is: +

+

+ B2in GmbH
+ Feldstraße 59
+ 32120 Hiddenhausen, Germany
+ Phone: +49 (0) 5221 9255055
+ Email: info@b2in.eu +

+
+ +
+

2. What Are Cookies?

+

+ Cookies are small text files that your browser automatically creates and stores on your device (computer, tablet, smartphone). They are used to facilitate the use of the website and to enable certain functions. +

+
+ +
+

3. What Cookies Do We Use?

+

+ We distinguish between two categories of cookies: +

+
    +
  • Technically necessary cookies: These are required to ensure the operation of the website (e.g., saving your consent settings). They cannot be deactivated. The legal basis for this is Art. 6 Para. 1 lit. f GDPR.
  • +
  • Analytics and marketing cookies: These cookies (e.g., Google Analytics) are only set if you have previously given us your explicit consent via our cookie consent banner. The legal basis is Art. 6 Para. 1 lit. a GDPR and § 25 Para. 1 TTDSG/TDDDG.
  • +
+

+ You can revoke or adjust your consent at any time by accessing the cookie settings via the following link: + Change Cookie Settings +

+
+ +
+

4. Google Analytics

+

+ Insofar as you have given your consent, Google Analytics is used on this website, a web analytics service provided by Google Ireland Limited, Gordon House, Barrow Street, Dublin 4, Ireland ("Google"). + Google Analytics uses cookies that enable an analysis of your use of the website. The information generated is usually transmitted to a Google server in the USA and stored there. +

+

+ IP Anonymization: We have activated the IP anonymization function on this website. This means that your IP address is truncated by Google within member states of the European Union or in other contracting states of the Agreement on the European Economic Area before being transmitted to the USA. +

+

+ Data Transfer to the USA: Data transfers to the USA are based on the adequacy decision of the EU Commission (EU-US Data Privacy Framework), under which Google LLC is certified, as well as on Standard Contractual Clauses. +

+

+ Revocation: You can revoke your consent at any time with future effect via our + Cookie Settings. + You can also prevent data collection by Google Analytics by downloading and installing the browser plugin at + https://tools.google.com/dlpage/gaoptout. +

+
+ +
+

5. Storage Duration

+

+ Session cookies are deleted when the browser is closed. Your cookie consent settings are stored as a persistent cookie for a maximum of 12 months. Analytics cookies from Google Analytics typically have a storage duration of up to 2 years. +

+
+ +
+

6. Your Choice

+

+ You can configure your browser to inform you about the setting of cookies and to allow or reject them individually. Deactivating technically necessary cookies may limit the functionality of our website. +

+
+ +
+

7. Further Information

+

+ For detailed information about data protection, your rights, and data processing, please refer to our + Privacy Policy. +

+
+ ', + ], +]; diff --git a/resources/lang/en/ui.php b/resources/lang/en/ui.php new file mode 100644 index 0000000..772c48d --- /dev/null +++ b/resources/lang/en/ui.php @@ -0,0 +1,84 @@ + 'Back', + 'learn_more' => 'Learn more', + 'read_more' => 'Read more', + 'close' => 'Close', + 'contact' => 'Contact', + 'required_fields' => '* Required fields', + 'view' => 'View', + + // Header + 'main_navigation' => 'Main navigation', + 'mobile_navigation' => 'Mobile navigation', + 'menu_close' => 'Close menu', + 'menu_open' => 'Open menu', + + // Footer + 'founder_ceo' => 'Founder & CEO', + 'legal_notice' => 'Legal Notice', + 'cookie_settings' => 'Cookie Settings', + + // Announcement Bar + 'announcement_close' => 'Close', + + // Hero Slider + 'show_slide' => 'Show slide :number', + 'previous_image' => 'Previous image', + 'next_image' => 'Next image', + + // Magazine + 'read_article' => 'Read article :title', + + // Contact Form + 'contact_form' => [ + 'success_title' => 'Thank you!', + 'success_message' => 'Your message has been received. We will get back to you as soon as possible.', + 'first_name' => 'First Name *', + 'last_name' => 'Last Name *', + 'company' => 'Company (optional)', + 'email' => 'Email *', + 'phone' => 'Phone (optional)', + 'subject' => 'Subject *', + 'message' => 'Message *', + 'message_placeholder' => 'Your message...', + 'privacy_prefix' => 'I have read the', + 'privacy_suffix' => 'and agree to the processing of my data. *', + 'send' => 'Send', + 'sending' => 'Sending...', + ], + + // Immobilien Contact Form + 'immobilien_form' => [ + 'success_title' => 'Inquiry sent successfully!', + 'success_message' => 'Thank you for your interest. We will get back to you as soon as possible.', + 'interest' => 'Interest', + 'please_select' => 'Please select...', + 'first_name' => 'First Name *', + 'first_name_placeholder' => 'Your first name', + 'last_name' => 'Last Name *', + 'last_name_placeholder' => 'Your last name', + 'email' => 'Email *', + 'email_placeholder' => 'your@email.com', + 'phone' => 'Phone (optional)', + 'message' => 'Message (optional)', + 'message_placeholder' => 'Your message...', + 'privacy_prefix' => 'I have read the', + 'privacy_suffix' => 'and agree to the processing of my data. *', + 'submit' => 'Request Exposé & Availability', + 'sending' => 'Sending...', + ], + + // Portfolio + 'portfolio' => [ + 'no_projects' => 'No projects found', + 'try_other_filter' => 'Try a different filter', + 'amenities' => 'Amenities', + 'project_details' => 'Project Details', + 'location' => 'Location', + 'price' => 'Price', + 'size' => 'Size', + ], +]; diff --git a/resources/views/components/layouts/app/sidebar.blade.php b/resources/views/components/layouts/app/sidebar.blade.php index b1d26a9..3d30c44 100644 --- a/resources/views/components/layouts/app/sidebar.blade.php +++ b/resources/views/components/layouts/app/sidebar.blade.php @@ -160,9 +160,47 @@ - {{ __('Cabinet') }} - + + {{ __('Übersicht') }} + + {{ __('Inhalte') }} + + {{ __('Projekte') }} + + {{ __('Magazin') }} + + {{ __('Medien') }} + + + + + + + {{ __('Übersicht') }} + + {{ __('Mediathek') }} + + {{ __('Versionen') }} + + {{ __('Displays') }} + + {{ __('Info-Tablet') }} + + @endhasrole @@ -342,7 +380,9 @@ + {{-- Flux vor Livewire: flux.js registriert Alpine.data('fluxModal') im alpine:init-Handler --}} @fluxScripts + @livewireScripts diff --git a/resources/views/components/web-picture.blade.php b/resources/views/components/web-picture.blade.php new file mode 100644 index 0000000..27d74d2 --- /dev/null +++ b/resources/views/components/web-picture.blade.php @@ -0,0 +1,18 @@ +@if ($hasWebp) + + + {{ $alt }}except(['src', 'alt', 'class', 'loading', 'width', 'height']) }} + /> + +@else +{{ $alt }}except(['src', 'alt', 'class', 'loading', 'width', 'height']) }} +/> +@endif diff --git a/resources/views/layouts/cabinet-quick.blade.php b/resources/views/layouts/cabinet-quick.blade.php new file mode 100644 index 0000000..3893a0e --- /dev/null +++ b/resources/views/layouts/cabinet-quick.blade.php @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + CABINET · Status + + @livewireStyles + + + + +
+ {{ $slot }} +
+ + @livewireScripts + + diff --git a/resources/views/livewire/admin/cms/articles-index.blade.php b/resources/views/livewire/admin/cms/articles-index.blade.php new file mode 100644 index 0000000..ab2e74c --- /dev/null +++ b/resources/views/livewire/admin/cms/articles-index.blade.php @@ -0,0 +1,367 @@ + '', + 'showForm' => false, + 'editingId' => null, + 'editLocale' => 'de', + 'slug' => '', + 'articleTitle' => '', + 'subtitle' => '', + 'image' => '', + 'category' => '', + 'date_label' => '', + 'read_time' => '', + 'authorName' => '', + 'authorBio' => '', + 'authorAvatar' => '', + 'intro' => '', + 'sections' => [], + 'is_published' => true, + 'order' => 0, +]); + +on(['media-selected' => function ($mediaId, $url, $field) { + $media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null; + if ($field === 'article_image' && $media) { + $this->image = $media->filename; + } + if ($field === 'author_avatar' && $media) { + $this->authorAvatar = $media->filename; + } +}]); + +$articles = computed( + fn () => CmsArticle::query() + ->when($this->search, fn ($q, $s) => $q->where('slug', 'like', "%{$s}%") + ->orWhereRaw("JSON_UNQUOTE(JSON_EXTRACT(title, '$.de')) LIKE ?", ["%{$s}%"])) + ->ordered() + ->get(), +); + +$openCreate = function () { + $this->editingId = null; + $this->reset(['slug', 'articleTitle', 'subtitle', 'image', 'category', 'date_label', 'read_time', 'authorName', 'authorBio', 'authorAvatar', 'intro', 'sections']); + $this->is_published = true; + $this->order = 0; + $this->sections = [['title' => '', 'content' => '']]; + $this->showForm = true; +}; + +$openEdit = function (int $id) { + $article = CmsArticle::find($id); + if (! $article) { + return; + } + + $this->editingId = $id; + $this->slug = $article->slug; + $this->image = $article->image ?? ''; + $this->category = $article->category ?? ''; + $this->date_label = $article->date_label ?? ''; + $this->read_time = $article->read_time ?? ''; + $this->is_published = $article->is_published; + $this->order = $article->order ?? 0; + + $author = $article->author ?? []; + $this->authorName = $author['name'] ?? ''; + $this->authorBio = $author['bio'] ?? ''; + $this->authorAvatar = $author['avatar'] ?? ''; + + $this->loadLocaleFields($article, $this->editLocale); + $this->showForm = true; +}; + +$loadLocaleFields = function (CmsArticle $article, string $locale) { + $this->articleTitle = $article->getTranslation('title', $locale) ?? ''; + $this->subtitle = $article->getTranslation('subtitle', $locale) ?? ''; + + $content = $article->getTranslation('content', $locale); + $this->intro = $content['intro'] ?? ''; + $this->sections = $content['sections'] ?? [['title' => '', 'content' => '']]; +}; + +$switchLocale = function (string $locale) { + $this->editLocale = $locale; + if ($this->editingId) { + $article = CmsArticle::find($this->editingId); + if ($article) { + $this->loadLocaleFields($article, $locale); + } + } +}; + +$addSection = function () { + $this->sections[] = ['title' => '', 'content' => '']; +}; + +$removeSection = function (int $index) { + unset($this->sections[$index]); + $this->sections = array_values($this->sections); +}; + +$save = function () { + $validated = validator([ + 'slug' => $this->slug, + 'articleTitle' => $this->articleTitle, + 'subtitle' => $this->subtitle, + 'image' => $this->image, + 'category' => $this->category, + 'date_label' => $this->date_label, + 'read_time' => $this->read_time, + 'authorName' => $this->authorName, + 'intro' => $this->intro, + 'is_published' => $this->is_published, + 'order' => $this->order, + ], [ + 'slug' => 'required|string|max:255', + 'articleTitle' => 'required|string|max:500', + 'subtitle' => 'nullable|string|max:1000', + 'image' => 'nullable|string|max:500', + 'category' => 'nullable|string|max:255', + 'date_label' => 'nullable|string|max:100', + 'read_time' => 'nullable|string|max:50', + 'authorName' => 'nullable|string|max:255', + 'intro' => 'nullable|string', + 'is_published' => 'boolean', + 'order' => 'integer|min:0', + ])->validate(); + + $article = $this->editingId + ? CmsArticle::findOrFail($this->editingId) + : CmsArticle::query()->make(); + + $article->slug = $validated['slug']; + $article->setTranslation('title', $this->editLocale, $validated['articleTitle']); + $article->setTranslation('subtitle', $this->editLocale, $validated['subtitle'] ?? ''); + + $contentData = [ + 'intro' => $validated['intro'] ?? '', + 'sections' => collect($this->sections) + ->filter(fn ($s) => ! empty($s['title']) || ! empty($s['content'])) + ->values() + ->toArray(), + ]; + $article->setTranslation('content', $this->editLocale, $contentData); + + $article->image = $validated['image'] ?? null; + $article->category = $validated['category'] ?? null; + $article->date_label = $validated['date_label'] ?? null; + $article->read_time = $validated['read_time'] ?? null; + $article->author = [ + 'name' => $this->authorName, + 'bio' => $this->authorBio, + 'avatar' => $this->authorAvatar, + ]; + $article->is_published = $validated['is_published']; + $article->order = $validated['order']; + $article->save(); + + $this->showForm = false; + $this->editingId = null; + + Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Artikel wurde erfolgreich gespeichert.'); +}; + +$togglePublished = function (int $id) { + $article = CmsArticle::findOrFail($id); + $article->update(['is_published' => ! $article->is_published]); + Flux::toast(heading: 'Status geändert', text: $article->is_published ? 'Veröffentlicht' : 'Entwurf'); +}; + +$deleteArticle = function (int $id) { + $article = CmsArticle::findOrFail($id); + $article->delete(); + Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'Artikel wurde entfernt.'); +}; + +$cancelForm = function () { + $this->showForm = false; + $this->editingId = null; +}; + +?> + +
+
+ Magazin Beiträge +
+ @foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label) + {{ strtoupper($code) }} + @endforeach + Neuer Artikel +
+
+ +
+ +
+ + @if ($showForm) + +
+ {{ $editingId ? 'Artikel bearbeiten' : 'Neuer Artikel' }} +
+ @foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label) + {{ strtoupper($code) }} + @endforeach +
+
+ +
+ + +
+ +
+ + + + +
+ + {{-- Bild --}} +
+ +
+ @if ($image) +
+ +
+ @endif +
+ +

{{ $image ?: 'Kein Bild' }}

+
+
+
+ + {{-- Autor --}} +
+ Autor +
+ + +
+ +
+ @if ($authorAvatar) +
+ +
+ @endif + +
+
+
+
+ + {{-- Inhalt --}} +
+ Inhalt ({{ strtoupper($editLocale) }}) + + +
+
+ + Abschnitt hinzufügen +
+
+ @foreach ($sections as $i => $section) +
+
+ Abschnitt {{ $i + 1 }} + @if (count($sections) > 1) + + @endif +
+ + +
+ @endforeach +
+
+
+ +
+ +
+ +
+ Speichern + Abbrechen +
+
+ @endif + + +
+ @forelse ($this->articles as $article) +
+
+ @if ($article->image) +
+ +
+ @else +
+ +
+ @endif +
+
+ + {{ strip_tags($article->getTranslation('title', $editLocale)) }} + + @if ($article->category) + {{ $article->category }} + @endif + @unless ($article->is_published) + Entwurf + @endunless +
+

+ {{ $article->author['name'] ?? '–' }} · {{ $article->date_label ?? '–' }} · {{ $article->read_time ?? '–' }} +

+
+
+
+ + + +
+
+ @empty +
+ + Keine Artikel + Erstelle den ersten Magazin-Beitrag mit dem Button oben. +
+ @endforelse +
+
+
diff --git a/resources/views/livewire/admin/c-m-s/cabinet-display.blade.php b/resources/views/livewire/admin/cms/cabinet-display.blade.php similarity index 100% rename from resources/views/livewire/admin/c-m-s/cabinet-display.blade.php rename to resources/views/livewire/admin/cms/cabinet-display.blade.php diff --git a/resources/views/livewire/admin/cms/cabinet-info-tablet.blade.php b/resources/views/livewire/admin/cms/cabinet-info-tablet.blade.php new file mode 100644 index 0000000..800f3ec --- /dev/null +++ b/resources/views/livewire/admin/cms/cabinet-info-tablet.blade.php @@ -0,0 +1,165 @@ +
+ + {{ __('Cabinet Info-Tablet') }} + {{ __('Einstellungen für das Schaufenster-Tablet') }} + + + {{-- Hilfe-Banner --}} + +
+ +
+

{{ __('Info-Tablet Steuerung') }}

+
+

Das Info-Tablet im Schaufenster zeigt Store-Status, Öffnungszeiten und den nächsten freien Termin.

+

Automatisch: Offen/Geschlossen wird automatisch aus den hinterlegten Öffnungszeiten berechnet.

+

API-Endpunkt: {{ url('/api/cabinet-tablet/status') }}

+

Website-Endpunkt: https://cabinet.b2in.eu/info

+

Quick-Status-Endpunkt: https://portal.b2in.eu/info/status?key={{ config('domains.cabinet_status_key') }}

+

Quick-Status-Hinweis: Der Quick-Status-Endpunkt ist ein kleines Tool, um den Store-Status schnell zu ändern. Auf dem Mobiltelefon den Link öffen und dem Home-Screen hinzufügen.

+
+
+
+
+ + {{-- Success-Meldungen --}} + @if (session()->has('success')) + + {{ session('success') }} + + @endif + +
+
+ + {{-- Store-Status --}} + + {{ __('Store-Status') }} + +
+ + + + + + + + @if($storeStatus !== 'auto') + @php + $noticeColors = match($storeStatus) { + 'notice' => 'bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800', + 'warning' => 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800', + default => 'bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800', + }; + @endphp +
+ + @error('noticeHeadline') {{ $message }} @enderror + + + @error('noticeSubtext') {{ $message }} @enderror +
+ @endif +
+
+ + {{-- Öffnungszeiten --}} + + {{ __('Öffnungszeiten') }} +

Felder leer lassen = Geschlossen. Der Status "Geöffnet/Geschlossen" wird automatisch aus diesen Zeiten berechnet.

+ +
+ @foreach([ + ['label' => 'Montag', 'open' => 'hoursMondayOpen', 'close' => 'hoursMondayClose'], + ['label' => 'Dienstag', 'open' => 'hoursTuesdayOpen', 'close' => 'hoursTuesdayClose'], + ['label' => 'Mittwoch', 'open' => 'hoursWednesdayOpen', 'close' => 'hoursWednesdayClose'], + ['label' => 'Donnerstag', 'open' => 'hoursThursdayOpen', 'close' => 'hoursThursdayClose'], + ['label' => 'Freitag', 'open' => 'hoursFridayOpen', 'close' => 'hoursFridayClose'], + ['label' => 'Samstag', 'open' => 'hoursSaturdayOpen', 'close' => 'hoursSaturdayClose'], + ['label' => 'Sonntag', 'open' => 'hoursSundayOpen', 'close' => 'hoursSundayClose'], + ] as $row) + @php + $isClosedDay = empty($this->{$row['open']}) && empty($this->{$row['close']}); + @endphp +
+ {{ $row['label'] }} +
+ +
+
+ +
+ + {{ $isClosedDay ? 'Geschlossen' : 'Geöffnet' }} + +
+ @endforeach +
+
+ + {{-- Sonderöffnung heute + +
+ {{ __('Sonderöffnung heute') }} + @if($overrideOpenToday || $overrideCloseToday) + + {{ __('Zurücksetzen') }} + + @endif +
+

Überschreibt die reguläre Öffnungszeit für heute. Wird um Mitternacht automatisch zurückgesetzt.

+ +
+ + @error('overrideOpenToday') {{ $message }} @enderror + + + @error('overrideCloseToday') {{ $message }} @enderror +
+
--}} + + {{-- Nächster Termin + + {{ __('Nächster freier Termin') }} + +
+ + @error('nextAppointmentDate') {{ $message }} @enderror + + + @error('nextAppointmentTime') {{ $message }} @enderror +
+
--}} + + {{-- Kontakt --}} + + {{ __('Kontakt') }} + +
+ + @error('contactPhone') {{ $message }} @enderror + + + @error('contactEmail') {{ $message }} @enderror +
+
+ + {{-- Speichern --}} +
+ + {{ __('Einstellungen speichern') }} + +
+
+
+
diff --git a/resources/views/livewire/admin/cms/content-index.blade.php b/resources/views/livewire/admin/cms/content-index.blade.php new file mode 100644 index 0000000..25bc797 --- /dev/null +++ b/resources/views/livewire/admin/cms/content-index.blade.php @@ -0,0 +1,782 @@ + is_array($v) ? json_encode($v, JSON_UNESCAPED_UNICODE) : (string) $v; + + if ($isList && ! is_array($value[0])) { + return [true, array_map(fn ($v) => ['_value' => $stringify($v)], $value)]; + } + + if ($isList) { + return [false, array_map(fn ($item) => is_array($item) + ? array_map($stringify, $item) + : ['_value' => (string) $item], $value)]; + } + + return [false, [array_map($stringify, $value)]]; + } +} + +if (! function_exists('_cmsFieldLooksLikeImage')) { + function _cmsFieldLooksLikeImage(string $fieldKey, mixed $fieldValue): bool + { + if (! is_string($fieldValue) || trim($fieldValue) === '') { + return false; + } + + $key = strtolower($fieldKey); + if (in_array($key, ['image', 'photo', 'avatar', 'picture', 'thumbnail', 'img', 'hero_image', 'background_image', 'cover_image'], true)) { + return true; + } + + if (preg_match('/_(image|photo|avatar|picture|thumb)$/i', $fieldKey)) { + return true; + } + + return (bool) preg_match('/\.(jpe?g|png|gif|webp|svg)$/i', $fieldValue); + } +} + +layout('components.layouts.app'); +title('CMS Inhalte'); + +state([ + 'selectedGroup' => null, + 'search' => '', + 'editingId' => null, + 'editingField' => null, + 'editLocale' => 'de', + 'editValue' => '', + 'editMediaId' => null, + 'showJsonModal' => false, + 'jsonItems' => [], + 'jsonIsStringArray' => false, + 'jsonEditingKey' => '', + 'editingFieldType' => 'text', +]); + +on(['media-selected' => function (string $field, ?int $mediaId, ?string $url) { + if ($field === 'content_image') { + $media = $mediaId ? CmsMedia::find($mediaId) : null; + if ($media) { + $this->editValue = $media->filename; + $this->editMediaId = $mediaId; + } else { + $this->editValue = ''; + $this->editMediaId = null; + } + + return; + } + + if (str_starts_with($field, 'jsonimg:')) { + $parts = explode(':', $field, 3); + if (count($parts) === 3 && $mediaId) { + $media = CmsMedia::find($mediaId); + if ($media) { + $idx = (int) $parts[1]; + $fname = $parts[2]; + if (isset($this->jsonItems[$idx]) && is_array($this->jsonItems[$idx])) { + $this->jsonItems[$idx][$fname] = $media->filename; + } + } + } + + return; + } +}]); + +$groups = computed(fn () => CmsContent::query() + ->selectRaw('`group`, count(*) as count') + ->groupBy('group') + ->orderBy('group') + ->pluck('count', 'group') + ->toArray()); + +$flatContents = computed(function () { + if (! $this->selectedGroup) { + return collect(); + } + + $contents = CmsContent::forGroup($this->selectedGroup) + ->orderBy('order') + ->get(); + + $rows = []; + + foreach ($contents as $content) { + $value = $content->getTranslation('value', $this->editLocale); + + if ($content->type === 'json' && is_array($value) && ! array_is_list($value)) { + foreach ($value as $fieldKey => $fieldValue) { + if ($this->search && ! str_contains(strtolower($content->key . '.' . $fieldKey), strtolower($this->search))) { + continue; + } + + $fieldType = 'text'; + if ($this->selectedGroup === 'legal' && $fieldKey === 'content') { + $fieldType = 'legal_html'; + } elseif (is_array($fieldValue)) { + $fieldType = 'json'; + } elseif (is_string($fieldValue) && preg_match('/<[^>]+>/', $fieldValue)) { + $fieldType = 'html'; + } elseif (_cmsFieldLooksLikeImage($fieldKey, $fieldValue)) { + $fieldType = 'image'; + } + + $rows[] = (object) [ + 'content_id' => $content->id, + 'section_key' => $content->key, + 'field_key' => $fieldKey, + 'display_key' => $content->key . '.' . $fieldKey, + 'type' => $fieldType, + 'value' => $fieldValue, + 'is_subfield' => true, + ]; + } + } else { + if ($this->search && ! str_contains(strtolower($content->key), strtolower($this->search))) { + continue; + } + + $rows[] = (object) [ + 'content_id' => $content->id, + 'section_key' => $content->key, + 'field_key' => null, + 'display_key' => $content->key, + 'type' => $content->type ?? 'text', + 'value' => $value, + 'is_subfield' => false, + ]; + } + } + + return collect($rows); +}); + +$availableIcons = computed(fn () => HeroiconOutlineList::names()); + +$selectGroup = function (string $group) { + $this->showJsonModal = false; + $this->jsonItems = []; + $this->jsonIsStringArray = false; + $this->jsonEditingKey = ''; + $this->editingId = null; + $this->editingField = null; + $this->editValue = ''; + $this->editMediaId = null; + $this->editingFieldType = 'text'; + $this->selectedGroup = $group; +}; + +$startFieldEdit = function (int $contentId, ?string $fieldKey = null) { + $content = CmsContent::find($contentId); + if (! $content) { + return; + } + + $fullValue = $content->getTranslation('value', $this->editLocale); + + if ($fieldKey !== null && is_array($fullValue)) { + $fieldValue = $fullValue[$fieldKey] ?? ''; + + if (is_array($fieldValue)) { + $this->editingId = $contentId; + $this->editingField = $fieldKey; + $this->editingFieldType = 'text'; + $this->jsonEditingKey = $content->key . '.' . $fieldKey; + + $isList = array_is_list($fieldValue); + if ($isList) { + [$this->jsonIsStringArray, $this->jsonItems] = _cmsParseJsonItems($fieldValue); + } else { + $this->jsonIsStringArray = false; + $this->jsonItems = [array_map( + fn ($v) => is_array($v) ? json_encode($v, JSON_UNESCAPED_UNICODE) : (string) $v, + $fieldValue, + )]; + } + + $this->jsonItems = CmsFluxEditorHtmlTransformer::toEditorJsonItems($this->jsonItems, $this->jsonIsStringArray); + + $this->showJsonModal = true; + + return; + } + + $this->editingId = $contentId; + $this->editingField = $fieldKey; + $this->editValue = (string) $fieldValue; + + if (_cmsFieldLooksLikeImage($fieldKey, $fieldValue)) { + $this->editingFieldType = 'image'; + $this->editMediaId = CmsMedia::where('filename', $this->editValue)->first()?->id; + } elseif ($content->group === 'legal' && $fieldKey === 'content') { + $this->editingFieldType = 'legal_html'; + $this->editMediaId = null; + } else { + $this->editingFieldType = is_string($fieldValue) && preg_match('/<[^>]+>/', $fieldValue) ? 'html' : 'text'; + $this->editMediaId = null; + } + + if ($this->editingFieldType === 'html') { + $this->editValue = CmsFluxEditorHtmlTransformer::toEditor($this->editValue); + } + } else { + $this->editingId = $contentId; + $this->editingField = null; + + if ($content->type === 'json') { + $this->jsonEditingKey = $content->key; + if (is_array($fullValue)) { + [$this->jsonIsStringArray, $this->jsonItems] = _cmsParseJsonItems($fullValue); + $this->jsonItems = CmsFluxEditorHtmlTransformer::toEditorJsonItems($this->jsonItems, $this->jsonIsStringArray); + } else { + $this->jsonIsStringArray = false; + $this->jsonItems = []; + } + $this->showJsonModal = true; + + return; + } + + $this->editValue = is_array($fullValue) + ? json_encode($fullValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) + : ((string) ($fullValue ?? '')); + + if ($content->type === 'image') { + $this->editingFieldType = 'image'; + $this->editMediaId = CmsMedia::where('filename', $this->editValue)->first()?->id; + } elseif ($content->type === 'html') { + $this->editingFieldType = 'html'; + $this->editMediaId = null; + $this->editValue = CmsFluxEditorHtmlTransformer::toEditor($this->editValue); + } else { + $this->editingFieldType = 'text'; + $this->editMediaId = null; + } + } +}; + +$switchLocale = function (string $locale) { + $this->editLocale = $locale; + + if ($this->editingId && $this->showJsonModal) { + $content = CmsContent::find($this->editingId); + if (! $content) { + return; + } + + $fullValue = $content->getTranslation('value', $locale); + + if ($this->editingField !== null && is_array($fullValue)) { + $fieldValue = $fullValue[$this->editingField] ?? []; + if (is_array($fieldValue)) { + [$this->jsonIsStringArray, $this->jsonItems] = _cmsParseJsonItems($fieldValue); + $this->jsonItems = CmsFluxEditorHtmlTransformer::toEditorJsonItems($this->jsonItems, $this->jsonIsStringArray); + } + } elseif (is_array($fullValue)) { + [$this->jsonIsStringArray, $this->jsonItems] = _cmsParseJsonItems($fullValue); + $this->jsonItems = CmsFluxEditorHtmlTransformer::toEditorJsonItems($this->jsonItems, $this->jsonIsStringArray); + } + } elseif ($this->editingId) { + $content = CmsContent::find($this->editingId); + if (! $content) { + return; + } + + $fullValue = $content->getTranslation('value', $locale); + + if ($this->editingField !== null && is_array($fullValue)) { + $fieldValue = $fullValue[$this->editingField] ?? ''; + $this->editValue = is_array($fieldValue) + ? json_encode($fieldValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) + : (string) $fieldValue; + if (! is_array($fieldValue)) { + if (_cmsFieldLooksLikeImage((string) $this->editingField, $fieldValue)) { + $this->editingFieldType = 'image'; + $this->editMediaId = CmsMedia::where('filename', $this->editValue)->first()?->id; + } elseif ($content->group === 'legal' && $this->editingField === 'content') { + $this->editingFieldType = 'legal_html'; + $this->editMediaId = null; + } else { + $this->editingFieldType = is_string($fieldValue) && preg_match('/<[^>]+>/', $fieldValue) ? 'html' : 'text'; + $this->editMediaId = null; + } + if ($this->editingFieldType === 'html') { + $this->editValue = CmsFluxEditorHtmlTransformer::toEditor($this->editValue); + } + } + } else { + $this->editValue = is_array($fullValue) + ? json_encode($fullValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) + : (string) ($fullValue ?? ''); + if ($content->type === 'image') { + $this->editingFieldType = 'image'; + $this->editMediaId = CmsMedia::where('filename', $this->editValue)->first()?->id; + } elseif ($content->type === 'html') { + $this->editingFieldType = 'html'; + $this->editMediaId = null; + $this->editValue = CmsFluxEditorHtmlTransformer::toEditor($this->editValue); + } else { + $this->editingFieldType = 'text'; + $this->editMediaId = null; + } + } + } +}; + +$saveEdit = function () { + $content = CmsContent::find($this->editingId); + if (! $content) { + return; + } + + $valueToSave = $this->editValue; + if ($this->editingFieldType === 'html') { + $valueToSave = CmsFluxEditorHtmlTransformer::fromEditor($valueToSave); + } + + if ($this->editingField !== null) { + $fullValue = $content->getTranslation('value', $this->editLocale); + if (! is_array($fullValue)) { + $fullValue = []; + } + $fullValue[$this->editingField] = $valueToSave; + $content->setTranslation('value', $this->editLocale, $fullValue); + } else { + $content->setTranslation('value', $this->editLocale, $valueToSave); + } + + $content->save(); + app(CmsContentService::class)->clearCache($this->selectedGroup); + + $this->editingId = null; + $this->editingField = null; + $this->editValue = ''; + $this->editingFieldType = 'text'; + $this->editMediaId = null; + + Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Inhalt wurde erfolgreich aktualisiert.'); +}; + +$addJsonItem = function () { + if ($this->jsonIsStringArray) { + $this->jsonItems[] = ['_value' => '']; + } elseif (! empty($this->jsonItems)) { + $template = array_map(fn () => '', $this->jsonItems[0]); + $this->jsonItems[] = $template; + } +}; + +$removeJsonItem = function (int $index) { + unset($this->jsonItems[$index]); + $this->jsonItems = array_values($this->jsonItems); +}; + +$saveJsonModal = function () { + $content = CmsContent::find($this->editingId); + if (! $content) { + return; + } + + $itemsForSave = CmsFluxEditorHtmlTransformer::fromEditorJsonItems($this->jsonItems, $this->jsonIsStringArray); + + if ($this->jsonIsStringArray) { + $newValue = array_values(array_map(fn ($item) => $item['_value'] ?? '', $itemsForSave)); + } else { + $newValue = array_values( + array_map(function ($item) { + $cleaned = []; + foreach ($item as $k => $v) { + if (is_string($v) && (str_starts_with($v, '[') || str_starts_with($v, '{'))) { + $decoded = json_decode($v, true); + $cleaned[$k] = json_last_error() === JSON_ERROR_NONE ? $decoded : $v; + } else { + $cleaned[$k] = $v; + } + } + + return $cleaned; + }, $itemsForSave), + ); + } + + if ($this->editingField !== null) { + $fullValue = $content->getTranslation('value', $this->editLocale); + if (! is_array($fullValue)) { + $fullValue = []; + } + $fullValue[$this->editingField] = $newValue; + $content->setTranslation('value', $this->editLocale, $fullValue); + } else { + $content->setTranslation('value', $this->editLocale, $newValue); + } + + $content->save(); + app(CmsContentService::class)->clearCache($this->selectedGroup); + + $this->showJsonModal = false; + $this->editingId = null; + $this->editingField = null; + $this->jsonItems = []; + + Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Inhalt wurde erfolgreich aktualisiert.'); +}; + +$cancelEdit = function () { + $this->editingId = null; + $this->editingField = null; + $this->editValue = ''; + $this->editingFieldType = 'text'; + $this->editMediaId = null; +}; + +$cancelJsonModal = function () { + $this->showJsonModal = false; + $this->editingId = null; + $this->editingField = null; + $this->jsonItems = []; +}; + +?> + +@php + $cmsGroupLabels = [ + 'legal' => 'Rechtliches', + ]; +@endphp + +
+
+ Inhalte verwalten +
+ {{ array_sum($this->groups) }} Einträge +
+
+ +
+
+ + Seiten / Gruppen +
+ @foreach ($this->groups as $group => $count) + + @endforeach +
+
+
+ +
+ @if ($selectedGroup) + +
+ {{ $cmsGroupLabels[$selectedGroup] ?? $selectedGroup }} +
+ +
+ @foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label) + + {{ strtoupper($code) }} + + @endforeach +
+
+
+ + @php $lastSection = null; @endphp +
+ @forelse ($this->flatContents as $row) + @if ($row->section_key !== $lastSection) + @php $lastSection = $row->section_key; @endphp +
+ {{ $row->section_key }} +
+ @endif + + @php + $isEditing = $editingId === $row->content_id + && $editingField === $row->field_key + && ! $showJsonModal; + $editKey = $row->field_key !== null + ? $row->content_id . ",'" . $row->field_key . "'" + : $row->content_id . ',null'; + @endphp + +
+
+
+
+ {{ $row->field_key ?? $row->section_key }} + @php + $typeColors = ['html' => 'amber', 'legal_html' => 'blue', 'image' => 'green', 'json' => 'violet', 'link' => 'rose']; + $badgeColor = $typeColors[$row->type] ?? 'zinc'; + @endphp + {{ $row->type }} +
+ +
+ @if ($isEditing) +
+ @if ($editingFieldType === 'image') +
+ @if ($editValue) +
+ +
+ @endif +
+ + +
+
+ @elseif ($editingFieldType === 'html') + + @elseif ($editingFieldType === 'legal_html') + {{-- natives textarea, Inhalt nur per Livewire-JS (kein im HTML); verhindert DOM-Bruch bei Impressum-HTML --}} +
+ + +
+ @else + + @endif +
+ + Speichern + Speichern… + + Abbrechen +
+
+ @else + @if ($row->type === 'json' && is_array($row->value)) + @php + $isList = array_is_list($row->value); + $cnt = count($row->value); + @endphp +
+ {{ $cnt }} {{ $isList ? 'Einträge' : 'Felder' }} + @if ($isList && $cnt > 0 && is_array($row->value[0] ?? null)) + {{ implode(', ', array_keys($row->value[0])) }} + @elseif (! $isList) + {{ implode(', ', array_slice(array_keys($row->value), 0, 5)) }}{{ $cnt > 5 ? ', …' : '' }} + @endif +
+ @elseif ($row->type === 'image' && is_string($row->value) && $row->value !== '') +
+
+ +
+ {{ $row->value }} +
+ @elseif ($row->type === 'legal_html') +

+ {{ \Illuminate\Support\Str::limit(strip_tags((string) $row->value), 200) }} +

+ @elseif ($row->type === 'html') +
+ {!! \Illuminate\Support\Str::limit((string) $row->value, 200) !!} +
+ @else +

+ {{ \Illuminate\Support\Str::limit(strip_tags(is_array($row->value) ? json_encode($row->value, JSON_UNESCAPED_UNICODE) : (string) ($row->value ?? '')), 120) }} +

+ @endif + @endif +
+
+ + @if (! $isEditing) + + @endif +
+
+ @empty + Keine Einträge gefunden. + @endforelse +
+
+ @else + +
+ + Seite auswählen + Wähle links eine Seite/Gruppe aus, um deren Inhalte zu bearbeiten. +
+
+ @endif +
+
+ + {{-- JSON-Editor: kein flux:modal — vermeidet ReferenceError fluxModal bei jedem Livewire-Render (z. B. bei legal_html). --}} + @if ($showJsonModal) +
+ + +
+ @endif +
diff --git a/resources/views/livewire/admin/cms/dashboard.blade.php b/resources/views/livewire/admin/cms/dashboard.blade.php new file mode 100644 index 0000000..42f08d3 --- /dev/null +++ b/resources/views/livewire/admin/cms/dashboard.blade.php @@ -0,0 +1,156 @@ + [ + 'contents' => CmsContent::count(), + 'groups' => CmsContent::distinct()->pluck('group')->count(), + 'projects' => CmsProject::count(), + 'projects_published' => CmsProject::published()->count(), + 'articles' => CmsArticle::count(), + 'articles_published' => CmsArticle::published()->count(), + 'media' => CmsMedia::count(), +]); + +?> + +
+
+ CMS + Inhalte, Projekte und Medien verwalten. +
+ + + + + So funktioniert das CMS +
+ +
+ + + Aufbau & Navigation + +

+ Das CMS ist die zentrale Stelle, um alle Inhalte der öffentlichen Website zu pflegen. + Es gliedert sich in vier Module, die Sie über die Kacheln oben erreichen: +

+
    +
  • Inhalte – Texte, Abschnitte und strukturierte Daten der Website (z. B. Startseite, Netzwerk, rechtliche Texte). Inhalte sind in Gruppen organisiert, sodass zusammengehörige Felder gebündelt bearbeitet werden können.
  • +
  • Projekte – Immobilien-Referenzobjekte mit Bildern, Preisen und Beschreibungen. Jedes Projekt kann als Entwurf angelegt und später veröffentlicht werden.
  • +
  • Magazin – Redaktionelle Artikel (z. B. über Dubai-Immobilien, Supply-Chain oder Einrichtung). Artikel unterstützen Kategorien, Autoren und Lesezeiten.
  • +
  • Medien – Zentrale Bild- und Dateiverwaltung. Alle Uploads landen hier und können in jedem Modul wiederverwendet werden.
  • +
+
+ + + +
+ + Medienbibliothek & Bildfelder + +

+ Jede Datei, die auf der Website erscheint, wird in der Medienbibliothek verwaltet. + Sie haben zwei Wege, Dateien hochzuladen: +

+
    +
  1. Vorab – Im Modul „Medien" Dateien hochladen, mit Titel und Alt-Text versehen und organisieren.
  2. +
  3. Im Kontext – Beim Bearbeiten eines Inhalts, Projekts oder Artikels auf „Bild auswählen" klicken. Im Auswahldialog können Sie ein bestehendes Medium wählen oder per Drag-and-drop direkt hochladen. Dieser Schnell-Upload wird automatisch in der Medienbibliothek gespeichert.
  4. +
+

+ Egal welchen Weg Sie wählen: Es gibt immer einen zentralen Bibliothekseintrag. So behalten Sie den Überblick und können dasselbe Bild an mehreren Stellen einsetzen. +

+
+ + + +
+ + + Inhalte bearbeiten + +

+ Inhalte sind nach Gruppen geordnet – z. B. homepage, netzwerk, legal. + Innerhalb einer Gruppe sehen Sie die einzelnen Felder (Texte, Rich-Text, Bilder oder JSON-Strukturen). +

+
    +
  • Mehrsprachigkeit: Wo mehrere Sprachen vorgesehen sind, wählen Sie die gewünschte Sprache (DE / EN) im Editor. Jede Sprachversion wird einzeln gespeichert.
  • +
  • Sofort live: Gespeicherte Änderungen erscheinen auf der öffentlichen Website, sobald die jeweilige Seite den entsprechenden CMS-Key einbindet – ein zusätzliches „Veröffentlichen" ist nicht nötig.
  • +
+
+ + + +
+ + + Projekte & Magazin verwalten + +

+ Projekte und Magazin-Artikel sind eigenständige Einträge mit eigenem Lebenszyklus: +

+
    +
  • Entwurf / Veröffentlicht: Neue Einträge starten als Entwurf. Erst nach dem Veröffentlichen sind sie auf der Website sichtbar. Sie können Einträge jederzeit wieder auf „Entwurf" setzen, um sie temporär auszublenden.
  • +
  • Reihenfolge: Über das Sortierfeld steuern Sie, in welcher Reihenfolge Projekte und Artikel auf der Website erscheinen (z. B. Portfolio-Listen, Magazin-Übersicht).
  • +
  • Bilder: Projekt- und Artikelbilder werden über die Medienauswahl zugeordnet (siehe oben).
  • +
+
+ +
+
+
diff --git a/resources/views/livewire/admin/cms/display-dashboard.blade.php b/resources/views/livewire/admin/cms/display-dashboard.blade.php new file mode 100644 index 0000000..33c3e9a --- /dev/null +++ b/resources/views/livewire/admin/cms/display-dashboard.blade.php @@ -0,0 +1,294 @@ + [ + 'displays' => Display::count(), + 'displays_active' => Display::where('is_active', true)->count(), + 'versions' => DisplayVersion::count(), + 'versions_active' => DisplayVersion::active()->count(), + 'items' => DisplayVersionItem::count(), + 'items_active' => DisplayVersionItem::where('is_active', true)->count(), + 'type_video' => DisplayVersion::ofType(DisplayVersionType::VideoDisplay)->count(), + 'type_b2in' => DisplayVersion::ofType(DisplayVersionType::B2in)->count(), + 'type_offers' => DisplayVersion::ofType(DisplayVersionType::Offers)->count(), + 'media_total' => DisplayMedia::count(), + 'media_uploads' => DisplayMedia::uploads()->count(), + 'media_externals' => DisplayMedia::externals()->count(), +]); + +$tabletStatus = computed(function () { + try { + $settings = CabinetTabletSetting::current(); + + return $settings->computeStatus()['status']; + } catch (\Throwable) { + return null; + } +}); + +?> + +
+
+ Store Displays + Displays, Inhalts-Versionen und Info-Tablet im Cabinet Showroom verwalten. +
+ + + + {{-- Versions-Typen Übersicht --}} +
+ +
+ +
+ Video-Display + {{ $this->stats['type_video'] }} {{ $this->stats['type_video'] === 1 ? 'Version' : 'Versionen' }} +
+
+
+ +
+ +
+ B2in Display + {{ $this->stats['type_b2in'] }} {{ $this->stats['type_b2in'] === 1 ? 'Version' : 'Versionen' }} +
+
+
+ +
+ +
+ Angebote + {{ $this->stats['type_offers'] }} {{ $this->stats['type_offers'] === 1 ? 'Version' : 'Versionen' }} +
+
+
+
+ + {{-- Beschreibung --}} + + So funktioniert das Display-System +
+ +
+ + + Überblick + +

+ Das Display-System steuert alle Bildschirme im Cabinet Showroom Bielefeld. + Es besteht aus drei Bereichen, die Sie über die Kacheln oben erreichen: +

+
    +
  • Mediathek – Zentrale Verwaltung aller Bilder und Videos fuer die Displays. Dateien bis 50 MB direkt hochladen oder groessere Videos als externe URL (Google Drive, OneDrive) einbinden.
  • +
  • Versionen – Content-Pakete, die auf den Displays abgespielt werden. Jede Version hat einen bestimmten Typ und enthält passende Inhalte (Videos, Bilder oder Angebots-Slides).
  • +
  • Displays – Die physischen Bildschirme im Showroom. Jedem Display werden eine oder mehrere Versionen als Playlist zugewiesen.
  • +
  • Info-Tablet – Das Tablet an der Eingangstür des Showrooms. Hier verwalten Sie Öffnungszeiten, den aktuellen Store-Status und Hinweise für Besucher.
  • +
+
+ + + +
+ + + Mediathek + +

+ Die Display-Mediathek verwaltet alle Bilder und Videos, die auf den Displays im Showroom angezeigt werden. + Sie ist unabhängig von der Website-Mediathek (Flux CMS) und speziell auf die Anforderungen der Displays zugeschnitten. +

+
    +
  • Direkt-Upload: Bilder und Videos bis 50 MB direkt per Drag-and-drop oder Dateiauswahl hochladen. Die Dateien werden auf dem Server gespeichert und stehen sofort zur Verfügung.
  • +
  • Externe URLs: Für Videos über 50 MB (z. B. 4K-Showroom-Rundgänge) können Sie einen Freigabe-Link von Google Drive, OneDrive oder anderen Cloud-Diensten hinterlegen. Diese URL wird wie ein normales Medium in der Mediathek verwaltet und kann genauso in Versionen eingebunden werden.
  • +
  • Sammlungen: Ordnen Sie Medien in Sammlungen wie immobilien, moebel oder brand, um bei vielen Dateien den Überblick zu behalten.
  • +
  • Medienauswahl im Editor: Beim Bearbeiten einer Version erscheint ein „Aus Mediathek"-Button. Darüber öffnen Sie die Medienauswahl und können bestehende Medien wählen oder direkt neue hochladen.
  • +
+
+ + + +
+ + + Versionen & Versions-Typen + +

+ Eine Version ist ein Content-Paket mit einem bestimmten Typ. + Der Typ bestimmt, welche Art von Inhalten hinzugefügt werden können: +

+
    +
  • + Video-Display – + Für Video-Playlists mit optionalem Footer. Inhalte: Videos (Dateiname, Titel, Position/Ausschnitt) und Footer-Zeilen (Überschrift, Unterzeile, optionaler QR-Code-Link). +
  • +
  • + B2in Display – + Für Medien-Rotation (Bilder und Videos) im Marken-Design. Inhalte: Media-Items mit Kategorie (Immobilien / Möbel), Überschrift, Unterzeile und Anzeigedauer. Unterstützt Light-/Dark-Theme. +
  • +
  • + Angebote – + Für Produkt-Slides im Angebotsformat. Verschiedene Slide-Layouts: Intro, Produkt-Hero, Produkt-Details und Impuls-Slides mit Preisen, Badges und QR-Codes. +
  • +
+

+ Innerhalb einer Version können Sie beliebig viele Inhalte anlegen, die Reihenfolge per Hoch/Runter-Sortierung festlegen und einzelne Einträge aktivieren oder deaktivieren. +

+
+ + + +
+ + + Displays & Playlists + +

+ Ein Display repräsentiert einen physischen Bildschirm im Showroom. +

+
    +
  • Versions-Zuweisung: Jedem Display können Sie eine oder mehrere Versionen zuordnen. Die Versionen werden in der festgelegten Reihenfolge als Playlist abgespielt.
  • +
  • Aktiv/Inaktiv: Über den Aktiv-Status können Sie einzelne Displays vorübergehend deaktivieren, ohne die Konfiguration zu verlieren.
  • +
  • API-Anbindung: Jedes Display ruft seine Inhalte über eine JSON-API ab (/api/display/{id}/config). Änderungen werden beim nächsten Abruf automatisch übernommen.
  • +
+
+ + + +
+ + + Info-Tablet + +

+ Das Info-Tablet zeigt Besuchern am Showroom-Eingang den aktuellen Status und die Öffnungszeiten. +

+
    +
  • + Store-Status: + Vier Modi stehen zur Verfügung – Automatisch (berechnet den Status aus den Öffnungszeiten), Geschlossen (manuell), Hinweis (eigene Nachricht) und Warnung (dringende Nachricht). +
  • +
  • + Öffnungszeiten: + Für jeden Wochentag (Montag–Sonntag) können individuelle Öffnungs- und Schließzeiten gepflegt werden. Tage ohne Zeiten gelten als geschlossen. +
  • +
  • + Tages-Overrides: + Für Sonderfälle (z. B. früher schließen) können Sie die Zeiten für den heutigen Tag überschreiben. Diese Überschreibungen werden automatisch um Mitternacht zurückgesetzt. +
  • +
  • + Kontaktdaten & Termine: + Telefonnummer, E-Mail-Adresse und der nächste Termin werden auf dem Tablet angezeigt und können hier zentral gepflegt werden. +
  • +
+
+ + + +
+ + + Typischer Workflow + +
    +
  1. Version erstellen – Unter „Versionen" eine neue Version mit passendem Typ anlegen (z. B. „Frühling 2026 Video").
  2. +
  3. Inhalte hinzufügen – In der Version Videos, Medien oder Slides anlegen, Reihenfolge festlegen und aktivieren.
  4. +
  5. Display zuweisen – Unter „Displays" die Version einem physischen Bildschirm zuordnen.
  6. +
  7. Fertig – Das Display lädt die neuen Inhalte automatisch über die API.
  8. +
+
+ +
+
+
diff --git a/resources/views/livewire/admin/cms/display-list.blade.php b/resources/views/livewire/admin/cms/display-list.blade.php new file mode 100644 index 0000000..e6d1780 --- /dev/null +++ b/resources/views/livewire/admin/cms/display-list.blade.php @@ -0,0 +1,204 @@ +
+ + {{ __('Displays') }} + {{ __('Verwalten Sie Ihre physischen Displays und weisen Sie ihnen Versionen zu') }} + + + @if (session()->has('success')) + + {{ session('success') }} + + @endif + + +
+
+ {{ __('Physische Displays') }} + {{ __('Jedem Display können mehrere Versionen als Playlist zugewiesen werden') }} +
+ + {{ __('Display hinzufügen') }} + +
+ + @if($displays->isEmpty()) +
+ +

{{ __('Noch keine Displays vorhanden. Fügen Sie Ihr erstes Display hinzu!') }}

+
+ @else +
+ @foreach($displays as $display) +
+ +
+
+ + {{ $display->is_active ? __('Aktiv') : __('Inaktiv') }} + + {{ $display->name }} + @if($display->location) + {{ $display->location }} + @endif +
+ + @if($display->versions->isNotEmpty()) +
+ @foreach($display->versions as $idx => $version) + @if($idx > 0) + + @endif + + {{ $version->name }} + + @endforeach +
+ @else +
+ {{ __('Keine Versionen zugewiesen') }} +
+ @endif + + {{-- Direct display links --}} + +
+ +
+ + + + + + + + +
+
+ @endforeach +
+ @endif +
+ + {{-- Display Modal --}} + +
+
+
+ {{ $displayId ? __('Display bearbeiten') : __('Display hinzufügen') }} +
+ + + @error('displayName') {{ $message }} @enderror + + + + {{-- Version Playlist --}} +
+ {{ __('Versions-Playlist') }} + {{ __('Versionen werden in dieser Reihenfolge als Schleife abgespielt') }} + + @if(count($selectedVersionIds) > 0) +
+ @foreach($selectedVersionIds as $index => $versionId) + @php $ver = $versions->firstWhere('id', $versionId); @endphp + @if($ver) +
+ {{ $index + 1 }} + + {{ $ver->type->label() }} + + {{ $ver->name }} +
+ + + + + + +
+
+ @endif + @endforeach +
+ @else +
+ {{ __('Noch keine Versionen hinzugefügt') }} +
+ @endif + +
+
+ + @foreach($versions as $version) + + @endforeach + +
+ + +
+
+ + + +
+ + {{ __('Abbrechen') }} + + + {{ $displayId ? __('Aktualisieren') : __('Hinzufügen') }} + +
+
+
+
+
diff --git a/resources/views/livewire/admin/cms/display-media-library.blade.php b/resources/views/livewire/admin/cms/display-media-library.blade.php new file mode 100644 index 0000000..b4a0917 --- /dev/null +++ b/resources/views/livewire/admin/cms/display-media-library.blade.php @@ -0,0 +1,541 @@ + '', + 'filterType' => 'all', + 'filterSource' => 'all', + 'filterCollection' => '', + 'viewMode' => 'grid', + 'editingId' => null, + 'editTitle' => '', + 'editAltText' => '', + 'editCollection' => '', + 'showDetail' => false, + // Upload + 'uploads' => [], + // External URL form + 'showUrlModal' => false, + 'urlInput' => '', + 'urlType' => 'video', + 'urlTitle' => '', + 'urlCollection' => '', + 'urlValidated' => null, +]); + +$media = computed( + fn () => DisplayMedia::query() + ->when($this->filterType !== 'all', fn ($q) => match ($this->filterType) { + 'image' => $q->images(), + 'video' => $q->videos(), + default => $q, + }) + ->when($this->filterSource !== 'all', fn ($q) => match ($this->filterSource) { + 'upload' => $q->uploads(), + 'external' => $q->externals(), + default => $q, + }) + ->when($this->filterCollection, fn ($q) => $q->inCollection($this->filterCollection)) + ->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%") + ->orWhere('title', 'like', "%{$this->search}%")) + ->orderByDesc('created_at') + ->paginate(48), +); + +$collections = computed(fn () => DisplayMedia::query() + ->whereNotNull('collection') + ->where('collection', '!=', '') + ->distinct() + ->pluck('collection') + ->sort() + ->values() + ->toArray()); + +$stats = computed(fn () => [ + 'total' => DisplayMedia::count(), + 'images' => DisplayMedia::images()->count(), + 'videos' => DisplayMedia::videos()->count(), + 'uploads' => DisplayMedia::uploads()->count(), + 'externals' => DisplayMedia::externals()->count(), +]); + +// ======================================== +// FILE UPLOAD +// ======================================== + +$handleUploads = function () { + $this->validate([ + 'uploads' => 'nullable|array|max:10', + 'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,mp4,webm,mov|max:51200', + ]); + + $service = app(DisplayMediaService::class); + $count = 0; + + foreach ($this->uploads as $file) { + $service->storeUpload($file); + $count++; + } + + $this->uploads = []; + + if ($count > 0) { + Flux::toast(variant: 'success', heading: 'Hochgeladen', text: "{$count} Datei(en) erfolgreich hochgeladen."); + } +}; + +// ======================================== +// EXTERNAL URL +// ======================================== + +$openUrlModal = function () { + $this->urlInput = ''; + $this->urlType = 'video'; + $this->urlTitle = ''; + $this->urlCollection = ''; + $this->urlValidated = null; + $this->showUrlModal = true; +}; + +$validateUrl = function () { + $this->validate(['urlInput' => 'required|url|max:2048']); + $service = app(DisplayMediaService::class); + $this->urlValidated = $service->validateExternalUrl($this->urlInput); +}; + +$saveExternalUrl = function () { + $this->validate([ + 'urlInput' => 'required|url|max:2048', + 'urlType' => 'required|in:image,video', + 'urlTitle' => 'nullable|string|max:255', + ]); + + $service = app(DisplayMediaService::class); + $service->createFromUrl( + url: $this->urlInput, + type: $this->urlType, + title: $this->urlTitle ?: null, + collection: $this->urlCollection ?: null, + ); + + $this->showUrlModal = false; + Flux::toast(variant: 'success', heading: 'Externe URL angelegt', text: 'Das Medium wurde als externe Referenz gespeichert.'); +}; + +// ======================================== +// DETAIL / EDIT +// ======================================== + +$startEdit = function (int $id) { + $media = DisplayMedia::find($id); + if (! $media) { + return; + } + $this->editingId = $id; + $this->editTitle = $media->title ?? ''; + $this->editAltText = $media->alt_text ?? ''; + $this->editCollection = $media->collection ?? ''; + $this->showDetail = true; +}; + +$saveEdit = function () { + $media = DisplayMedia::find($this->editingId); + if (! $media) { + return; + } + + $media->update([ + 'title' => $this->editTitle ?: null, + 'alt_text' => $this->editAltText ?: null, + 'collection' => $this->editCollection ?: null, + ]); + + Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Medien-Details wurden aktualisiert.'); +}; + +$deleteMedia = function (int $id) { + $media = DisplayMedia::find($id); + if (! $media) { + return; + } + + $filename = $media->getDisplayName(); + $service = app(DisplayMediaService::class); + $service->delete($media); + + $this->editingId = null; + $this->showDetail = false; + + Flux::toast(variant: 'success', heading: 'Gelöscht', text: $filename . ' wurde entfernt.'); +}; + +$closeDetail = function () { + $this->showDetail = false; + $this->editingId = null; +}; + +?> + +
+
+
+ Display-Mediathek + Bilder, Videos und externe URLs für Store Displays verwalten. +
+
+ {{ $this->stats['images'] }} Bilder + {{ $this->stats['videos'] }} Videos + {{ $this->stats['externals'] }} Extern +
+
+ + {{-- Upload + External URL --}} + +
+
+ + + + @if (isset($uploads) && count($uploads) > 0) +
+ @foreach ($uploads as $index => $upload) + + @endforeach +
+ + {{ count($uploads) }} Datei(en) hochladen + + @endif + +
+
+ + Externe URL anlegen + +
+
+
+ + {{-- Filters --}} +
+ + + + Alle Typen + Bilder + Videos + + + + Alle Quellen + Uploads + Extern + + + @if (! empty($this->collections)) + + Alle Sammlungen + @foreach ($this->collections as $col) + {{ $col }} + @endforeach + + @endif + +
+ + +
+
+ + {{-- Media Grid / List + Detail --}} +
+
+ @if ($viewMode === 'grid') +
+ @forelse ($this->media as $item) +
+
+ @if ($item->isImage() && $item->isUpload()) + {{ $item->alt_text ?? $item->filename }} + @elseif ($item->isVideo()) +
+ + Video +
+ @elseif ($item->isExternal() && $item->isImage()) +
+ + Extern +
+ @else +
+ + Link +
+ @endif +
+
+ @if ($item->isVideo()) + + @elseif ($item->isExternal()) + + @else + + @endif +
+

{{ $item->getDisplayName() }}

+

{{ $item->getHumanFileSize() }}

+
+
+ @if ($item->collection) +
+ {{ $item->collection }} +
+ @endif +
+ @empty +
+ + Noch keine Medien vorhanden. Laden Sie Dateien hoch oder legen Sie externe URLs an. +
+ @endforelse +
+ @else + {{-- List View --}} +
+ + + + + + + + + + + + + + @forelse ($this->media as $item) + + + + + + + + + + @empty + + + + @endforelse + +
NameDatum
+
+ @if ($item->isImage() && $item->isUpload()) + + @elseif ($item->isVideo()) + + @else + + @endif +
+
+ {{ $item->getDisplayName() }} + {{ $item->created_at->format('d.m.Y') }}
+ + Noch keine Medien vorhanden. +
+
+ @endif + + @if ($this->media->hasPages()) +
+ {{ $this->media->links() }} +
+ @endif +
+ + {{-- Detail Sidebar --}} + @if ($showDetail && $editingId) + @php $editMedia = \App\Models\DisplayMedia::find($editingId); @endphp + @if ($editMedia) +
+ +
+ Details + +
+ + {{-- Preview --}} +
+ @if ($editMedia->isImage() && $editMedia->isUpload()) + {{ $editMedia->filename }} + @elseif ($editMedia->isVideo() && $editMedia->isUpload()) + + @elseif ($editMedia->isExternal()) +
+ + Externe Ressource +
+ @endif +
+ + {{-- Metadata --}} +
+

Datei: {{ $editMedia->filename }}

+

Typ: + + {{ $editMedia->isVideo() ? 'Video' : 'Bild' }} + +

+

Quelle: + + {{ $editMedia->isExternal() ? 'Extern' : 'Upload' }} + +

+ @if ($editMedia->isUpload()) +

Größe: {{ $editMedia->getHumanFileSize() }}

+ @if ($editMedia->mime_type) +

MIME: {{ $editMedia->mime_type }}

+ @endif + @if ($editMedia->metadata && isset($editMedia->metadata['width'])) +

Abmessungen: {{ $editMedia->metadata['width'] }}×{{ $editMedia->metadata['height'] }} px

+ @endif + @endif +

Angelegt: {{ $editMedia->created_at->format('d.m.Y H:i') }}

+

URL: + + {{ Str::limit($editMedia->getUrl(), 80) }} + +

+
+ + {{-- Edit Form --}} +
+ + + + + + Speichern + +
+ +
+ + Löschen + +
+
+
+ @endif + @endif +
+ + {{-- External URL Modal --}} + +
+
+
+ Externe URL anlegen + Binden Sie große Videos oder Medien von Google Drive, OneDrive oder anderen Quellen ein. +
+ +
+
+ + +
+ +
+ + URL prüfen + + @if ($urlValidated === true) + + Erreichbar + + @elseif ($urlValidated === false) + + Nicht erreichbar – trotzdem speichern möglich + + @endif +
+ + + + + + + + + +
+ +
+ + Abbrechen + + + Anlegen + +
+
+
+
+
diff --git a/resources/views/livewire/admin/cms/display-media-picker.blade.php b/resources/views/livewire/admin/cms/display-media-picker.blade.php new file mode 100644 index 0000000..94e27be --- /dev/null +++ b/resources/views/livewire/admin/cms/display-media-picker.blade.php @@ -0,0 +1,136 @@ +
+
+
+ @if ($selectedMedia) +
+ @if ($selectedMedia->isImage() && $selectedMedia->isUpload()) + {{ $selectedMedia->filename }} + @elseif ($selectedMedia->isVideo()) +
+ +
+ @elseif ($selectedMedia->isExternal()) +
+ +
+ @else +
+ +
+ @endif +
+

+ {{ $selectedMedia->getDisplayName() }} +

+
+ {{ $selectedMedia->getHumanFileSize() }} + @if ($selectedMedia->isExternal()) + Extern + @endif + + {{ $selectedMedia->isVideo() ? 'Video' : 'Bild' }} + +
+
+ +
+ @else +
+ Kein Medium ausgewählt +
+ @endif +
+ + {{ $label }} + +
+ + + {{ $label }} + +
+ + + + + + + @if (isset($quickUploads) && count($quickUploads) > 0) +
+ @foreach ($quickUploads as $index => $upload) + + + + + + @endforeach +
+ @endif + + +
+ +
+ @forelse ($mediaItems as $item) +
+
+ @if ($item->isImage() && $item->isUpload()) + {{ $item->filename }} + @elseif ($item->isVideo()) +
+ +
+ @elseif ($item->isExternal() && $item->isImage()) +
+ +
+ @else +
+ +
+ @endif + + {{-- Badges --}} +
+ @if ($item->isExternal()) + URL + @endif + @if ($item->isVideo()) + Video + @endif +
+
+
+

{{ $item->getDisplayName() }}

+

{{ $item->getHumanFileSize() }}

+
+
+ @empty +
+ Keine Medien gefunden. Laden Sie Dateien hoch oder legen Sie externe URLs in der Mediathek an. +
+ @endforelse +
+ + @if ($mediaItems->hasPages()) +
+ {{ $mediaItems->links() }} +
+ @endif +
+
diff --git a/resources/views/livewire/admin/cms/display-version-editor.blade.php b/resources/views/livewire/admin/cms/display-version-editor.blade.php new file mode 100644 index 0000000..28fb040 --- /dev/null +++ b/resources/views/livewire/admin/cms/display-version-editor.blade.php @@ -0,0 +1,255 @@ +
+ {{-- Header --}} +
+
+ + {{ __('Zurück') }} + +
+
+ {{ $version->name }} + + {{ $version->type->label() }} + +
+ {{ __('Version bearbeiten') }} +
+
+
+ @if($version->type->value === 'b2in') +
+ + + +
+ @endif + + {{ __('Einstellungen') }} + +
+
+ + @if (session()->has('success')) + + {{ session('success') }} + + @endif + + {{-- Name bearbeiten --}} + +
+
+ +
+ {{ __('Speichern') }} +
+
+ + {{-- Type-specific content sections --}} + @if($version->type->value === 'video-display') + @include('livewire.admin.cms.partials.version-editor-video', ['items' => $items]) + @elseif($version->type->value === 'b2in') + @include('livewire.admin.cms.partials.version-editor-b2in', ['items' => $items]) + @elseif($version->type->value === 'offers') + @include('livewire.admin.cms.partials.version-editor-offers', ['items' => $items]) + @endif + + {{-- Item Modal --}} + +
+
+
+ {{ $itemId ? __('Inhalt bearbeiten') : __('Inhalt hinzufügen') }} +
+ + {{-- Video fields --}} + @if($itemType === 'video') + + + + + + @endif + + {{-- Footer fields --}} + @if($itemType === 'footer') + + + + + @endif + + {{-- Media fields (B2in) --}} + @if($itemType === 'media') + + + + + + + + + + + + + @if($mediaType === 'image') + + @endif + + @endif + + {{-- Slide fields (Offers) --}} + @if($itemType === 'slide') + {{-- Basis --}} + + + + + + + + + + + + + + {{-- Intro-spezifisch --}} + @if($slideType === 'intro') + + + @if($slideShowBrandText) + + @endif + @endif + + {{-- Product-Hero --}} + @if($slideType === 'product-hero') + + + @endif + + {{-- Product-Details --}} + @if($slideType === 'product-details') +
+ {{ __('Aufzählungspunkte') }} +
+ @foreach($slideBullets as $i => $bullet) +
+ + +
+ @endforeach +
+ + {{ __('Punkt hinzufügen') }} + +
+ @endif + + {{-- Product-Impulse --}} + @if($slideType === 'product-impulse') + + + + @endif + + {{-- QR --}} +
+ {{ __('QR-Code & Kontakt') }} +
+ + + +
+
+ + + @endif + +
+ + {{ __('Abbrechen') }} + + + {{ $itemId ? __('Aktualisieren') : __('Hinzufügen') }} + +
+
+
+
+ + {{-- Settings Modal --}} + +
+
+
+ {{ __('Einstellungen') }} + {{ $version->type->label() }} +
+ + @if($version->type->value === 'b2in') + + + + + + + + + + + + + + + @elseif($version->type->value === 'offers') + + + + + + + @elseif($version->type->value === 'video-display') +

{{ __('Keine speziellen Einstellungen für diesen Typ.') }}

+ @endif + +
+ + {{ __('Abbrechen') }} + + + {{ __('Speichern') }} + +
+
+
+
+
diff --git a/resources/views/livewire/admin/cms/display-version-list.blade.php b/resources/views/livewire/admin/cms/display-version-list.blade.php new file mode 100644 index 0000000..6b112b7 --- /dev/null +++ b/resources/views/livewire/admin/cms/display-version-list.blade.php @@ -0,0 +1,116 @@ +
+ + {{ __('Display-Versionen') }} + {{ __('Erstellen und verwalten Sie Inhalts-Versionen für Ihre Displays') }} + + + @if (session()->has('success')) + + {{ session('success') }} + + @endif + + +
+
+ {{ __('Versionen') }} + {{ __('Jede Version enthält Inhalte eines bestimmten Typs') }} +
+ + {{ __('Version erstellen') }} + +
+ + @if($versions->isEmpty()) +
+ +

{{ __('Noch keine Versionen vorhanden. Erstellen Sie Ihre erste Version!') }}

+
+ @else +
+ @foreach($versions as $version) +
+ +
+
+ + {{ $version->is_active ? __('Aktiv') : __('Inaktiv') }} + + + {{ $version->name }} + + + {{ $version->type->label() }} + +
+
+ {{ $version->items_count }} {{ __('Inhalte') }} + {{ $version->displays_count }} {{ __('Displays') }} +
+
+ +
+ + + + + + + + +
+
+ @endforeach +
+ @endif +
+ + {{-- Create Modal --}} + +
+
+
+ {{ __('Neue Version erstellen') }} +
+ + + @error('newName') {{ $message }} @enderror + + + @foreach($types as $type) + + @endforeach + + @error('newType') {{ $message }} @enderror + +
+ + {{ __('Abbrechen') }} + + + {{ __('Erstellen & Bearbeiten') }} + +
+
+
+
+
diff --git a/resources/views/livewire/admin/cms/media-index.blade.php b/resources/views/livewire/admin/cms/media-index.blade.php new file mode 100644 index 0000000..d097a7d --- /dev/null +++ b/resources/views/livewire/admin/cms/media-index.blade.php @@ -0,0 +1,441 @@ + '', + 'filterType' => 'all', + 'filterCollection' => '', + 'viewMode' => 'grid', + 'editingId' => null, + 'editLocale' => 'de', + 'altText' => '', + 'mediaTitle' => '', + 'collection' => '', + 'showDetail' => false, + 'selectedProfiles' => [], +]); + +$media = computed( + fn () => CmsMedia::query() + ->when( + $this->filterType !== 'all', + fn ($q) => match ($this->filterType) { + 'image' => $q->images(), + 'pdf' => $q->pdfs(), + 'document' => $q->documents(), + default => $q, + }, + ) + ->when($this->filterCollection, fn ($q) => $q->inCollection($this->filterCollection)) + ->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%")) + ->orderByDesc('created_at') + ->paginate(48), +); + +$collections = computed(fn () => CmsMedia::query()->whereNotNull('collection')->where('collection', '!=', '')->distinct()->pluck('collection')->sort()->values()->toArray()); + +$profiles = computed(fn () => config('flux-cms.media.profiles', [])); + +$stats = computed( + fn () => [ + 'total' => CmsMedia::count(), + 'images' => CmsMedia::images()->count(), + 'pdfs' => CmsMedia::pdfs()->count(), + ], +); + +on([ + 'media-library-uploaded' => function ($mediaId) { + $media = CmsMedia::find($mediaId); + if ($media) { + Flux::toast(variant: 'success', heading: 'Hochgeladen', text: $media->filename . ' wurde erfolgreich hochgeladen.'); + } + }, +]); + +$startEdit = function (int $id) { + $media = CmsMedia::find($id); + if (! $media) { + return; + } + $this->editingId = $id; + $this->altText = $media->getTranslation('alt_text', $this->editLocale) ?? ''; + $this->mediaTitle = $media->getTranslation('title', $this->editLocale) ?? ''; + $this->collection = $media->collection ?? ''; + $this->showDetail = true; +}; + +$switchLocale = function (string $locale) { + $this->editLocale = $locale; + if ($this->editingId) { + $media = CmsMedia::find($this->editingId); + if ($media) { + $this->altText = $media->getTranslation('alt_text', $locale) ?? ''; + $this->mediaTitle = $media->getTranslation('title', $locale) ?? ''; + } + } +}; + +$saveEdit = function () { + $media = CmsMedia::find($this->editingId); + if (! $media) { + return; + } + + $media->setTranslation('alt_text', $this->editLocale, $this->altText); + $media->setTranslation('title', $this->editLocale, $this->mediaTitle); + $media->collection = $this->collection; + $media->save(); + + Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Medien-Details wurden aktualisiert.'); +}; + +$generateConversion = function (string $profile) { + $media = CmsMedia::find($this->editingId); + if (! $media || ! $media->isImage()) { + return; + } + + $service = app(MediaConversionService::class); + $result = $service->convert($media, $profile); + + if ($result) { + $media->refresh(); + Flux::toast(variant: 'success', heading: 'Conversion erstellt', text: "Profil \"{$profile}\" wurde generiert."); + } else { + Flux::toast(variant: 'danger', heading: 'Fehler', text: "Conversion \"{$profile}\" konnte nicht erstellt werden."); + } +}; + +$generateAllConversions = function () { + $media = CmsMedia::find($this->editingId); + if (! $media || ! $media->isImage()) { + return; + } + + $service = app(MediaConversionService::class); + $results = $service->generateAllConversions($media); + + $count = count(array_filter($results)); + $media->refresh(); + Flux::toast(variant: 'success', heading: 'Alle Conversions erstellt', text: "{$count} Profile wurden generiert."); +}; + +$deleteMedia = function (int $id) { + $media = CmsMedia::find($id); + if (! $media) { + return; + } + + $filename = $media->filename; + $service = app(MediaConversionService::class); + $service->deleteAll($media); + $media->delete(); + + $this->editingId = null; + $this->showDetail = false; + + Flux::toast(variant: 'success', heading: 'Gelöscht', text: "{$filename} wurde entfernt."); +}; + +$closeDetail = function () { + $this->showDetail = false; + $this->editingId = null; +}; + +?> + +
+
+ Medienbibliothek +
+ {{ $this->stats['images'] }} Bilder + {{ $this->stats['pdfs'] }} PDFs +
+
+ + +
+
+ +
+
+
+ +
+ + + + Alle Typen + Bilder + PDFs + Dokumente + + + @if (! empty($this->collections)) + + Alle Ordner + @foreach ($this->collections as $col) + {{ $col }} + @endforeach + + @endif + +
+ + +
+
+ +
+
+ @if ($viewMode === 'grid') +
+ @forelse ($this->media as $item) +
+
+ @if ($item->isImage()) + {{ $item->getTranslation('alt_text', $editLocale) ?? $item->filename }} + @elseif ($item->isPdf()) +
+ +
+
+ @else +
+ + {{ strtoupper(pathinfo($item->filename, PATHINFO_EXTENSION)) }} +
+ @endif +
+
+ @if ($item->isImage()) + + @elseif ($item->isPdf()) + + @else + + @endif +
+

{{ $item->filename }}

+

{{ $item->getHumanFileSize() }}

+
+
+ @if ($item->collection) +
+ {{ $item->collection }} +
+ @endif +
+ @empty +
+ + Noch keine Medien hochgeladen. +
+ @endforelse +
+ @else +
+ + + + + + + + + + + + + + @forelse ($this->media as $item) + + + + + + + + + + @empty + + + + @endforelse + +
DateinameDatum
+
+ @if ($item->isImage()) + + @elseif ($item->isPdf()) +
+ +
+ @else +
+ +
+ @endif +
+
+ {{ $item->filename }} + {{ $item->created_at->format('d.m.Y') }}
+ + Noch keine Medien hochgeladen. +
+
+ @endif + + @if ($this->media->hasPages()) +
+ {{ $this->media->links() }} +
+ @endif +
+ + @if ($showDetail && $editingId) + @php $editMedia = \FluxCms\Core\Models\CmsMedia::find($editingId); @endphp + @if ($editMedia) +
+ +
+ Details + +
+ +
+ @if ($editMedia->isImage()) + {{ $editMedia->filename }} + @elseif ($editMedia->isPdf()) + + @endif +
+ +
+

Datei: {{ $editMedia->filename }}

+

Typ: {{ $editMedia->mime_type }}

+

Größe: {{ $editMedia->getHumanFileSize() }}

+ @if ($editMedia->getDimensionsLabel()) +

Abmessungen: {{ $editMedia->getDimensionsLabel() }} px

+ @endif +

Hochgeladen: {{ $editMedia->created_at->format('d.m.Y H:i') }}

+

URL: + + {{ $editMedia->getUrl() }} + +

+
+ +
+ @foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label) + + {{ strtoupper($code) }} + + @endforeach +
+ +
+ + + + + + Speichern + +
+ + @if ($editMedia->isImage() && $editMedia->mime_type !== 'image/svg+xml') +
+
+ Bildgrößen + + Alle generieren + +
+ +
+ @foreach ($this->profiles as $profileName => $profileConfig) + @php + $hasConversion = $editMedia->hasConversion($profileName); + $conversionUrl = $hasConversion ? $editMedia->getConversionUrl($profileName) : null; + @endphp +
+
+ {{ $profileName }} + + {{ $profileConfig['width'] }}×{{ $profileConfig['height'] }} + {{ strtoupper($profileConfig['format'] ?? 'webp') }} + +
+
+ @if ($hasConversion) + OK + @else + + @endif + +
+
+ @endforeach +
+
+ @endif + +
+ + Datei löschen + +
+
+
+ @endif + @endif +
+
diff --git a/resources/views/livewire/admin/cms/media-library-uploader.blade.php b/resources/views/livewire/admin/cms/media-library-uploader.blade.php new file mode 100644 index 0000000..f571e05 --- /dev/null +++ b/resources/views/livewire/admin/cms/media-library-uploader.blade.php @@ -0,0 +1,30 @@ +
+ + + + + @if (isset($uploads) && count($uploads) > 0) +
+ @foreach ($uploads as $index => $upload) + + + + + + @endforeach +
+ @endif + + +
diff --git a/resources/views/livewire/admin/cms/media-picker.blade.php b/resources/views/livewire/admin/cms/media-picker.blade.php new file mode 100644 index 0000000..dc8c12e --- /dev/null +++ b/resources/views/livewire/admin/cms/media-picker.blade.php @@ -0,0 +1,124 @@ +
+
+
+ @if ($selectedMedia) +
+ @if ($selectedMedia->isImage()) + {{ $selectedMedia->filename }} + @elseif ($selectedMedia->isPdf()) +
+ +
+ @endif +
+

+ {{ $selectedMedia->filename }} +

+

+ {{ $selectedMedia->getHumanFileSize() }} + @if ($selectedMedia->getDimensionsLabel()) + — {{ $selectedMedia->getDimensionsLabel() }} + @endif +

+ @if ($selectedMedia->isImage() && $selectedMedia->hasConversion($profile)) + @php + $pConfig = config("flux-cms.media.profiles.{$profile}", []); + @endphp + + {{ $profile }}: {{ $pConfig['width'] ?? '?' }}×{{ $pConfig['height'] ?? '?' }} + + @endif +
+ +
+ @else +
+ Kein Medium ausgewählt +
+ @endif +
+ + {{ $label }} + +
+ + + {{ $label }} + +
+ + + + + + + @if (isset($quickUploads) && count($quickUploads) > 0) +
+ @foreach ($quickUploads as $index => $upload) + + + + + + @endforeach +
+ @endif + + +
+ + @php $profileConfig = config("flux-cms.media.profiles.{$profile}", []); @endphp + @if (!empty($profileConfig)) + + Profil {{ $profile }}: {{ $profileConfig['width'] }}×{{ $profileConfig['height'] }} px, + {{ strtoupper($profileConfig['format'] ?? 'webp') }}, + Qualität {{ $profileConfig['quality'] ?? 85 }}% + + @endif + +
+ @forelse ($mediaItems as $item) +
+
+ @if ($item->isImage()) + {{ $item->filename }} + @elseif ($item->isPdf()) +
+ +
+ @endif +
+
+

{{ $item->filename }}

+
+
+ @empty +
+ Keine Medien gefunden. +
+ @endforelse +
+ + @if ($mediaItems->hasPages()) +
+ {{ $mediaItems->links() }} +
+ @endif +
+
diff --git a/resources/views/livewire/admin/cms/partials/version-editor-b2in.blade.php b/resources/views/livewire/admin/cms/partials/version-editor-b2in.blade.php new file mode 100644 index 0000000..bc7fb03 --- /dev/null +++ b/resources/views/livewire/admin/cms/partials/version-editor-b2in.blade.php @@ -0,0 +1,67 @@ +{{-- B2in: Media-Playlist --}} +@php + $mediaItems = $items->get('media', collect()); +@endphp + + +
+
+ {{ __('Media-Playlist') }} + {{ __('Bilder und Videos werden rotierend angezeigt') }} +
+ + {{ __('Medium hinzufügen') }} + +
+ + @if($mediaItems->isEmpty()) +
+ +

{{ __('Noch keine Medien vorhanden.') }}

+
+ @else +
+ @foreach($mediaItems as $index => $item) +
+ +
+ @if($index > 0) + + @endif + @if($index < count($mediaItems) - 1) + + @endif +
+ +
+
+ + {{ $item->is_active ? __('Aktiv') : __('Inaktiv') }} + + + {{ ($item->content['media_type'] ?? 'image') === 'video' ? 'Video' : 'Bild' }} + + + {{ ucfirst($item->content['category'] ?? '–') }} + + {{ $item->content['headline'] ?? '–' }} +
+
+ {{ $item->content['subline'] ?? '' }} + @if(($item->content['media_type'] ?? 'image') === 'image') + {{ $item->content['duration_seconds'] ?? 10 }}s + @endif +
+
+ +
+ + + +
+
+ @endforeach +
+ @endif +
diff --git a/resources/views/livewire/admin/cms/partials/version-editor-offers.blade.php b/resources/views/livewire/admin/cms/partials/version-editor-offers.blade.php new file mode 100644 index 0000000..c3c08df --- /dev/null +++ b/resources/views/livewire/admin/cms/partials/version-editor-offers.blade.php @@ -0,0 +1,73 @@ +{{-- Offers: Slides --}} +@php + $slides = $items->get('slide', collect()); +@endphp + + +
+
+ {{ __('Slides') }} + {{ __('Angebots-Slides werden in der angegebenen Reihenfolge angezeigt') }} +
+ + {{ __('Slide hinzufügen') }} + +
+ + @if($slides->isEmpty()) +
+ +

{{ __('Noch keine Slides vorhanden.') }}

+
+ @else +
+ @foreach($slides as $index => $item) +
+ +
+ @if($index > 0) + + @endif + @if($index < count($slides) - 1) + + @endif +
+ +
+
+ + {{ $item->is_active ? __('Aktiv') : __('Inaktiv') }} + + + {{ match($item->content['type'] ?? '') { + 'intro' => 'Intro', + 'product-hero' => 'Produkt-Hero', + 'product-details' => 'Produkt-Details', + 'product-impulse' => 'Produkt-Impuls', + default => $item->content['type'] ?? '–', + } }} + + {{ $item->content['title'] ?? '–' }} +
+
+ @if(!empty($item->content['price'])) + {{ $item->content['price'] }} + @endif + {{ number_format(($item->content['duration'] ?? 8000) / 1000, 1) }}s + @if(!empty($item->content['badge_text'])) + {{ $item->content['badge_text'] }} + @endif +
+
+ +
+ + + +
+
+ @endforeach +
+ @endif +
diff --git a/resources/views/livewire/admin/cms/partials/version-editor-video.blade.php b/resources/views/livewire/admin/cms/partials/version-editor-video.blade.php new file mode 100644 index 0000000..a5771e3 --- /dev/null +++ b/resources/views/livewire/admin/cms/partials/version-editor-video.blade.php @@ -0,0 +1,119 @@ +{{-- Video-Display: Video-Playlist + Footer-Inhalte --}} +@php + $videos = $items->get('video', collect()); + $footers = $items->get('footer', collect()); +@endphp + +{{-- Video-Playlist --}} + +
+
+ {{ __('Video-Playlist') }} + {{ __('Videos werden in der angegebenen Reihenfolge abgespielt') }} +
+ + {{ __('Video hinzufügen') }} + +
+ + @if($videos->isEmpty()) +
+ +

{{ __('Noch keine Videos vorhanden.') }}

+
+ @else +
+ @foreach($videos as $index => $item) +
+ +
+ @if($index > 0) + + @endif + @if($index < count($videos) - 1) + + @endif +
+ +
+
+ + {{ $item->is_active ? __('Aktiv') : __('Inaktiv') }} + + {{ $item->content['title'] ?? $item->content['filename'] ?? '–' }} +
+
+ {{ $item->content['filename'] ?? '–' }} + Position: {{ $item->content['position'] ?? 25 }}% +
+
+ +
+ + + +
+
+ @endforeach +
+ @endif +
+ +{{-- Footer-Inhalte --}} + +
+
+ {{ __('Footer-Inhalte') }} + {{ __('Inhalte werden im Footer rotiert') }} +
+ + {{ __('Inhalt hinzufügen') }} + +
+ + @if($footers->isEmpty()) +
+ +

{{ __('Noch keine Footer-Inhalte vorhanden.') }}

+
+ @else +
+ @foreach($footers as $index => $item) +
+ +
+ @if($index > 0) + + @endif + @if($index < count($footers) - 1) + + @endif +
+ +
+
+ + {{ $item->is_active ? __('Aktiv') : __('Inaktiv') }} + + {{ $item->content['headline'] ?? '–' }} +
+
+ {{ $item->content['subline'] ?? '' }} + @if(!empty($item->content['url'])) + {{ Str::limit($item->content['url'], 40) }} + @endif +
+
+ +
+ + + +
+
+ @endforeach +
+ @endif +
diff --git a/resources/views/livewire/admin/cms/projects-index.blade.php b/resources/views/livewire/admin/cms/projects-index.blade.php new file mode 100644 index 0000000..c39c53d --- /dev/null +++ b/resources/views/livewire/admin/cms/projects-index.blade.php @@ -0,0 +1,720 @@ + '', + 'showForm' => false, + 'editingId' => null, + 'editLocale' => 'de', + 'activeTab' => 'basic', + + 'slug' => '', + 'projectTitle' => '', + 'location' => '', + 'status' => '', + 'launch_date' => '', + 'price_from_aed' => '', + 'currency' => 'AED', + 'image' => '', + 'is_published' => true, + 'order' => 0, + + 'highlights' => [''], + 'quick_facts' => [['icon' => 'home-modern', 'label' => '', 'value' => '']], + 'investTitle' => '', + 'investText' => '', + 'investViews' => [''], + 'galleryItems' => [''], + 'locTitle' => '', + 'locMapUrl' => '', + 'locPoints' => [''], + 'contactTitle' => '', + 'contactSubtitle' => '', + 'contactOptions' => [['key' => '', 'value' => '']], + + 'trustTitle' => '', + 'trustIntro' => '', + 'trustColumns' => [ + ['icon' => 'heroicon-o-lock-closed', 'title' => '', 'text' => ''], + ['icon' => 'heroicon-o-building-library', 'title' => '', 'text' => ''], + ['icon' => 'heroicon-o-chart-bar', 'title' => '', 'text' => ''], + ], + 'trustCtaUrl' => '', + 'trustCtaLabel' => '', + 'furnitureTitle' => '', + 'furnitureText' => '', + 'furnitureButtonText' => '', + 'furnitureButtonLink' => '', +]); + +on(['media-selected' => function ($mediaId, $url, $field) { + $media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null; + if (! $media) { + return; + } + if ($field === 'project_image') { + $this->image = $media->filename; + } + if (str_starts_with($field, 'gallery_')) { + $idx = (int) str_replace('gallery_', '', $field); + if (isset($this->galleryItems[$idx])) { + $this->galleryItems[$idx] = $media->filename; + } + } +}]); + +$projects = computed( + fn () => CmsProject::query() + ->when($this->search, fn ($q) => $q->where('slug', 'like', "%{$this->search}%")) + ->ordered() + ->get(), +); + +$resetForm = function () { + $this->editingId = null; + $this->activeTab = 'basic'; + $this->slug = ''; + $this->projectTitle = ''; + $this->location = ''; + $this->status = ''; + $this->launch_date = ''; + $this->price_from_aed = ''; + $this->currency = 'AED'; + $this->image = ''; + $this->is_published = true; + $this->order = 0; + $this->highlights = ['']; + $this->quick_facts = [['icon' => 'home-modern', 'label' => '', 'value' => '']]; + $this->investTitle = ''; + $this->investText = ''; + $this->investViews = ['']; + $this->galleryItems = ['']; + $this->locTitle = ''; + $this->locMapUrl = ''; + $this->locPoints = ['']; + $this->contactTitle = ''; + $this->contactSubtitle = ''; + $this->contactOptions = [['key' => '', 'value' => '']]; + $this->trustTitle = ''; + $this->trustIntro = ''; + $this->trustColumns = [ + ['icon' => 'heroicon-o-lock-closed', 'title' => '', 'text' => ''], + ['icon' => 'heroicon-o-building-library', 'title' => '', 'text' => ''], + ['icon' => 'heroicon-o-chart-bar', 'title' => '', 'text' => ''], + ]; + $this->trustCtaUrl = ''; + $this->trustCtaLabel = ''; + $this->furnitureTitle = ''; + $this->furnitureText = ''; + $this->furnitureButtonText = ''; + $this->furnitureButtonLink = ''; +}; + +$loadProjectIntoForm = function (CmsProject $project, string $locale) { + $this->slug = $project->slug; + $this->image = $project->image ?? ''; + $this->status = $project->status ?? ''; + $this->launch_date = $project->launch_date?->format('Y-m-d') ?? ''; + $this->price_from_aed = $project->price_from_aed ?? ''; + $this->currency = $project->currency ?? 'AED'; + $this->is_published = $project->is_published; + $this->order = $project->order ?? 0; + + $this->projectTitle = $project->getTranslation('title', $locale) ?? ''; + $this->location = $project->getTranslation('location', $locale) ?? ''; + + $hl = $project->getTranslation('highlights', $locale); + $this->highlights = is_array($hl) && count($hl) > 0 ? $hl : ['']; + + $this->quick_facts = is_array($project->quick_facts) && count($project->quick_facts) > 0 + ? $project->quick_facts + : [['icon' => 'home-modern', 'label' => '', 'value' => '']]; + + $ic = $project->getTranslation('investment_case', $locale); + $this->investTitle = $ic['title'] ?? ''; + $this->investText = $ic['text'] ?? ''; + $this->investViews = is_array($ic['views'] ?? null) && count($ic['views']) > 0 ? $ic['views'] : ['']; + + $this->galleryItems = is_array($project->gallery) && count($project->gallery) > 0 + ? $project->gallery + : ['']; + + $li = $project->getTranslation('location_info', $locale); + $this->locTitle = $li['title'] ?? ''; + $this->locMapUrl = $li['map_url'] ?? ''; + $this->locPoints = is_array($li['points'] ?? null) && count($li['points']) > 0 ? $li['points'] : ['']; + + $ct = $project->getTranslation('contact', $locale); + $this->contactTitle = $ct['title'] ?? ''; + $this->contactSubtitle = $ct['subtitle'] ?? ''; + $opts = $ct['options'] ?? []; + $this->contactOptions = count($opts) > 0 + ? collect($opts)->map(fn ($v, $k) => ['key' => $k, 'value' => $v])->values()->toArray() + : [['key' => '', 'value' => '']]; + + $it = $project->getTranslation('investor_trust', $locale) ?? []; + $this->trustTitle = $it['title'] ?? ''; + $this->trustIntro = $it['intro'] ?? ''; + $trustCols = $it['columns'] ?? []; + $this->trustColumns = is_array($trustCols) && count($trustCols) > 0 + ? $trustCols + : [ + ['icon' => 'heroicon-o-lock-closed', 'title' => '', 'text' => ''], + ['icon' => 'heroicon-o-building-library', 'title' => '', 'text' => ''], + ['icon' => 'heroicon-o-chart-bar', 'title' => '', 'text' => ''], + ]; + $this->trustCtaUrl = $it['cta_url'] ?? ''; + $this->trustCtaLabel = $it['cta_label'] ?? ''; + + $fb = $project->getTranslation('furniture_benefit', $locale) ?? []; + $this->furnitureTitle = $fb['title'] ?? ''; + $this->furnitureText = $fb['text'] ?? ''; + $this->furnitureButtonText = $fb['button_text'] ?? ''; + $this->furnitureButtonLink = $fb['button_link'] ?? ''; +}; + +$openCreate = function () { + $this->resetForm(); + $this->showForm = true; +}; + +$openEdit = function (int $id) { + $project = CmsProject::find($id); + if (! $project) { + return; + } + $this->editingId = $id; + $this->activeTab = 'basic'; + $this->loadProjectIntoForm($project, $this->editLocale); + $this->showForm = true; +}; + +$duplicateProject = function (int $id) { + $project = CmsProject::find($id); + if (! $project) { + return; + } + $this->editingId = null; + $this->activeTab = 'basic'; + $this->loadProjectIntoForm($project, $this->editLocale); + $this->slug = $project->slug . '-kopie-' . Str::random(4); + $this->showForm = true; + Flux::toast(heading: 'Dupliziert', text: 'Projekt wurde als Kopie geladen. Bitte Slug anpassen und speichern.'); +}; + +$switchLocale = function (string $locale) { + $this->editLocale = $locale; + if ($this->editingId) { + $project = CmsProject::find($this->editingId); + if ($project) { + $this->projectTitle = $project->getTranslation('title', $locale) ?? ''; + $this->location = $project->getTranslation('location', $locale) ?? ''; + + $hl = $project->getTranslation('highlights', $locale); + $this->highlights = is_array($hl) && count($hl) > 0 ? $hl : ['']; + + $ic = $project->getTranslation('investment_case', $locale); + $this->investTitle = $ic['title'] ?? ''; + $this->investText = $ic['text'] ?? ''; + $this->investViews = is_array($ic['views'] ?? null) && count($ic['views']) > 0 ? $ic['views'] : ['']; + + $li = $project->getTranslation('location_info', $locale); + $this->locTitle = $li['title'] ?? ''; + $this->locMapUrl = $li['map_url'] ?? ''; + $this->locPoints = is_array($li['points'] ?? null) && count($li['points']) > 0 ? $li['points'] : ['']; + + $ct = $project->getTranslation('contact', $locale); + $this->contactTitle = $ct['title'] ?? ''; + $this->contactSubtitle = $ct['subtitle'] ?? ''; + $opts = $ct['options'] ?? []; + $this->contactOptions = count($opts) > 0 + ? collect($opts)->map(fn ($v, $k) => ['key' => $k, 'value' => $v])->values()->toArray() + : [['key' => '', 'value' => '']]; + + $it = $project->getTranslation('investor_trust', $locale) ?? []; + $this->trustTitle = $it['title'] ?? ''; + $this->trustIntro = $it['intro'] ?? ''; + $trustCols = $it['columns'] ?? []; + $this->trustColumns = is_array($trustCols) && count($trustCols) > 0 + ? $trustCols + : [ + ['icon' => 'heroicon-o-lock-closed', 'title' => '', 'text' => ''], + ['icon' => 'heroicon-o-building-library', 'title' => '', 'text' => ''], + ['icon' => 'heroicon-o-chart-bar', 'title' => '', 'text' => ''], + ]; + $this->trustCtaUrl = $it['cta_url'] ?? ''; + $this->trustCtaLabel = $it['cta_label'] ?? ''; + + $fb = $project->getTranslation('furniture_benefit', $locale) ?? []; + $this->furnitureTitle = $fb['title'] ?? ''; + $this->furnitureText = $fb['text'] ?? ''; + $this->furnitureButtonText = $fb['button_text'] ?? ''; + $this->furnitureButtonLink = $fb['button_link'] ?? ''; + } + } +}; + +$addHighlight = fn () => $this->highlights[] = ''; +$removeHighlight = function (int $i) { unset($this->highlights[$i]); $this->highlights = array_values($this->highlights); }; + +$addQuickFact = fn () => $this->quick_facts[] = ['icon' => 'home-modern', 'label' => '', 'value' => '']; +$removeQuickFact = function (int $i) { unset($this->quick_facts[$i]); $this->quick_facts = array_values($this->quick_facts); }; + +$addInvestView = fn () => $this->investViews[] = ''; +$removeInvestView = function (int $i) { unset($this->investViews[$i]); $this->investViews = array_values($this->investViews); }; + +$addGalleryItem = fn () => $this->galleryItems[] = ''; +$removeGalleryItem = function (int $i) { unset($this->galleryItems[$i]); $this->galleryItems = array_values($this->galleryItems); }; + +$addLocPoint = fn () => $this->locPoints[] = ''; +$removeLocPoint = function (int $i) { unset($this->locPoints[$i]); $this->locPoints = array_values($this->locPoints); }; + +$addContactOption = fn () => $this->contactOptions[] = ['key' => '', 'value' => '']; +$removeContactOption = function (int $i) { unset($this->contactOptions[$i]); $this->contactOptions = array_values($this->contactOptions); }; + +$addTrustColumn = fn () => $this->trustColumns[] = ['icon' => 'heroicon-o-lock-closed', 'title' => '', 'text' => '']; +$removeTrustColumn = function (int $i) { unset($this->trustColumns[$i]); $this->trustColumns = array_values($this->trustColumns); }; + +$save = function () { + $validated = validator([ + 'slug' => $this->slug, + 'projectTitle' => $this->projectTitle, + 'location' => $this->location, + 'status' => $this->status, + 'launch_date' => $this->launch_date, + 'price_from_aed' => $this->price_from_aed, + 'currency' => $this->currency, + 'image' => $this->image, + 'is_published' => $this->is_published, + 'order' => $this->order, + ], [ + 'slug' => 'required|string|max:255', + 'projectTitle' => 'required|string|max:500', + 'location' => 'nullable|string|max:500', + 'status' => 'nullable|string|max:100', + 'launch_date' => 'nullable|date', + 'price_from_aed' => 'nullable|integer|min:0', + 'currency' => 'nullable|string|max:10', + 'image' => 'nullable|string|max:500', + 'is_published' => 'boolean', + 'order' => 'integer|min:0', + ])->validate(); + + $project = $this->editingId + ? CmsProject::findOrFail($this->editingId) + : CmsProject::query()->make(); + + $project->slug = $validated['slug']; + $project->setTranslation('title', $this->editLocale, $validated['projectTitle']); + $project->setTranslation('location', $this->editLocale, $validated['location'] ?? ''); + $project->status = $validated['status'] ?? null; + $project->launch_date = $validated['launch_date'] ?: null; + $project->price_from_aed = $validated['price_from_aed'] ?: null; + $project->currency = $validated['currency'] ?? 'AED'; + $project->image = $validated['image'] ?? null; + $project->is_published = $validated['is_published']; + $project->order = $validated['order']; + + $project->setTranslation('highlights', $this->editLocale, array_values(array_filter($this->highlights, fn ($h) => trim($h) !== ''))); + + $project->quick_facts = collect($this->quick_facts) + ->filter(fn ($f) => ! empty($f['label']) || ! empty($f['value'])) + ->values() + ->toArray(); + + $project->setTranslation('investment_case', $this->editLocale, [ + 'title' => $this->investTitle, + 'text' => $this->investText, + 'views' => array_values(array_filter($this->investViews, fn ($v) => trim($v) !== '')), + ]); + + $project->gallery = array_values(array_filter($this->galleryItems, fn ($g) => trim($g) !== '')); + + $project->setTranslation('location_info', $this->editLocale, [ + 'title' => $this->locTitle, + 'map_url' => $this->locMapUrl, + 'points' => array_values(array_filter($this->locPoints, fn ($p) => trim($p) !== '')), + ]); + + $opts = []; + foreach ($this->contactOptions as $opt) { + if ($opt['value'] !== '') { + $opts[$opt['key']] = $opt['value']; + } + } + $project->setTranslation('contact', $this->editLocale, [ + 'title' => $this->contactTitle, + 'subtitle' => $this->contactSubtitle, + 'options' => $opts, + ]); + + $trustCols = collect($this->trustColumns) + ->filter(fn ($c) => ! empty(trim($c['title'] ?? '')) || ! empty(trim($c['text'] ?? ''))) + ->values() + ->toArray(); + + $project->setTranslation('investor_trust', $this->editLocale, [ + 'title' => $this->trustTitle, + 'intro' => $this->trustIntro, + 'columns' => $trustCols, + 'cta_url' => $this->trustCtaUrl, + 'cta_label' => $this->trustCtaLabel, + ]); + + $project->setTranslation('furniture_benefit', $this->editLocale, [ + 'title' => $this->furnitureTitle, + 'text' => $this->furnitureText, + 'button_text' => $this->furnitureButtonText, + 'button_link' => $this->furnitureButtonLink, + ]); + + $project->save(); + + $this->showForm = false; + $this->editingId = null; + + Flux::toast(variant: 'success', heading: 'Gespeichert', text: $project->slug . ' wurde erfolgreich gespeichert.'); +}; + +$deleteProject = function (int $id) { + $project = CmsProject::find($id); + if (! $project) { + return; + } + $slug = $project->slug; + $project->delete(); + Flux::toast(variant: 'success', heading: 'Gelöscht', text: "{$slug} wurde entfernt."); +}; + +$cancelForm = function () { + $this->showForm = false; + $this->editingId = null; +}; + +$setActiveTab = function (string $tab): void { + $this->activeTab = $tab; +}; + +?> + +
+
+ Projekte (Immobilien) + Neues Projekt +
+ +
+ +
+ + @if ($showForm) + +
+ {{ $editingId ? 'Projekt bearbeiten' : 'Neues Projekt' }} +
+ @foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label) + {{ strtoupper($code) }} + @endforeach +
+
+ + {{-- Explizite Tab-Steuerung: zuverlässiger als nur wire:model auf ui-tabs (Livewire-Sync) --}} +
+ Grunddaten + Quick Facts + Investment Case + Galerie + Location + Kontakt + Trust & Möbel +
+ +
+ {{-- TAB: Grunddaten --}} + @if ($activeTab === 'basic') +
+ + + + + + + + + +
+ +
+ @if ($image) +
+ +
+ @endif +
+ +
+
+ +
+ +
+ Highlights ({{ strtoupper($editLocale) }}) +
+ @foreach ($highlights as $i => $hl) +
+ + @if (count($highlights) > 1) + + @endif +
+ @endforeach +
+ Highlight +
+ +
+ +
+
+ @endif + + {{-- TAB: Quick Facts --}} + @if ($activeTab === 'facts') +
+ @foreach ($quick_facts as $i => $fact) +
+ + Haus + Fläche + Gebäude + Person + Preis + Kalender + Standort + + + + @if (count($quick_facts) > 1) + + @endif +
+ @endforeach + Quick Fact +
+ @endif + + {{-- TAB: Investment Case --}} + @if ($activeTab === 'invest') +
+ + + +
+ +
+ @foreach ($investViews as $i => $view) +
+ + @if (count($investViews) > 1) + + @endif +
+ @endforeach +
+ View +
+
+ @endif + + {{-- TAB: Galerie --}} + @if ($activeTab === 'gallery') +
+ @foreach ($galleryItems as $i => $img) +
+ @if ($img) +
+ +
+ @endif +
+ + +
+ @if (count($galleryItems) > 1) + + @endif +
+ @endforeach + Bild hinzufügen +
+ @endif + + {{-- TAB: Location --}} + @if ($activeTab === 'location') +
+ + + +
+ +
+ @foreach ($locPoints as $i => $point) +
+ + @if (count($locPoints) > 1) + + @endif +
+ @endforeach +
+ Punkt +
+
+ @endif + + {{-- TAB: Kontakt --}} + @if ($activeTab === 'contact') +
+ + + +
+ +
+ @foreach ($contactOptions as $i => $opt) +
+ + + @if (count($contactOptions) > 1) + + @endif +
+ @endforeach +
+ Option +
+
+ @endif + + {{-- TAB: Trust-Block & Möbel-Vorteil (Detailseite) --}} + @if ($activeTab === 'trust_synergy') +
+
+ Trust-Block: Investorenschutz ({{ strtoupper($editLocale) }}) +
+ + + + + +
+ +
+ @foreach ($trustColumns as $ti => $tc) +
+
+ + Schloss (Escrow) + Gebäude / DLD + Diagramm + Schild + Sparkles + + @if (count($trustColumns) > 1) + + @endif +
+ + +
+ @endforeach +
+ Spalte hinzufügen +
+
+
+ + + +
+ Möbel-Vorteil / Synergie ({{ strtoupper($editLocale) }}) + + +
+ + +
+
+
+ @endif +
+ +
+ Speichern + Abbrechen +
+
+ @endif + + {{-- Projektliste --}} + +
+ @forelse ($this->projects as $project) +
+
+ @if ($project->image) +
+ +
+ @else +
+ +
+ @endif +
+
+ {{ $project->title }} + @if ($project->is_published) + Live + @else + Entwurf + @endif +
+
+ {{ $project->location }} + @if ($project->status) + · {{ $project->status }} + @endif + @if ($project->getFormattedPrice()) + · {{ $project->getFormattedPrice() }} + @endif +
+
+
+
+ + + +
+
+ @empty +
+ + Keine Projekte + Erstelle das erste Projekt mit dem Button oben. +
+ @endforelse +
+
+
diff --git a/resources/views/livewire/admin/documentation.blade.php b/resources/views/livewire/admin/documentation.blade.php index d6b6e52..79d19a4 100644 --- a/resources/views/livewire/admin/documentation.blade.php +++ b/resources/views/livewire/admin/documentation.blade.php @@ -1,80 +1,14 @@ false]); -$content = computed(function () { - $mdPath = base_path('dev/entwicklung.md'); - - if (!file_exists($mdPath)) { - return '

Dokumentation nicht gefunden.

'; - } - - $markdown = file_get_contents($mdPath); - - // Configure CommonMark with GitHub Flavored Markdown - $environment = new Environment([ - 'html_input' => 'allow', - 'allow_unsafe_links' => false, - ]); - - $environment->addExtension(new CommonMarkCoreExtension()); - $environment->addExtension(new GithubFlavoredMarkdownExtension()); - - $converter = new MarkdownConverter($environment); - - return $converter->convert($markdown)->getContent(); -}); - -// Extract Table of Contents from markdown -$tableOfContents = computed(function () { - $mdPath = base_path('dev/entwicklung.md'); - - if (!file_exists($mdPath)) { - return []; - } - - $markdown = file_get_contents($mdPath); - $toc = []; - - // Extract headings (## and ###) - preg_match_all('/^(#{2,3})\s+(.+)$/m', $markdown, $matches, PREG_SET_ORDER); - - foreach ($matches as $match) { - $level = strlen($match[1]); - $title = trim($match[2]); - $slug = Str::slug($title); - - $toc[] = [ - 'level' => $level, - 'title' => $title, - 'slug' => $slug, - ]; - } - - return $toc; -}); - -$fileInfo = computed(function () { - $mdPath = base_path('dev/entwicklung.md'); - - if (!file_exists($mdPath)) { - return null; - } - - return [ - 'size' => round(filesize($mdPath) / 1024, 1) . ' KB', - 'modified' => \Carbon\Carbon::parse(filemtime($mdPath))->format('d.m.Y H:i'), - 'lines' => count(file($mdPath)), - ]; -}); +$content = computed(fn () => ProjectDocumentationContent::html()); +$tableOfContents = computed(fn () => ProjectDocumentationContent::tableOfContents()); +$fileInfo = computed(fn () => ProjectDocumentationContent::fileInfo()); ?> diff --git a/resources/views/livewire/admin/testing/registration-tester.blade.php b/resources/views/livewire/admin/testing/registration-tester.blade.php index 59803c9..5827cfc 100644 --- a/resources/views/livewire/admin/testing/registration-tester.blade.php +++ b/resources/views/livewire/admin/testing/registration-tester.blade.php @@ -141,7 +141,7 @@ new class extends Component { href="{{ config('domains.domain_b2in_url') . route('registration.landing', ['role' => $roleOptions[$selectedRole]['slug']], false) }}" target="_blank" > - {{ __('B2In') }} + {{ __('B2in') }} @if($selectedRole === 'customer') + + + {{-- Brand header --}} +
+ +
+ Status +
+ + @if(! $authorized) + + {{-- Not authorized --}} +
+
🔒
+
Kein Zugriff
+
Ungültiger oder fehlender Key.
+
+ + @else + + {{-- Status selector --}} +
+
Status wählen
+
+ @foreach($statusOptions as $value => $opt) + + @endforeach +
+
+ + {{-- Text fields (only for non-auto statuses) --}} + @if($storeStatus !== 'auto') +
+
+ + + @error('noticeHeadline') {{ $message }} @enderror +
+ +
+ + + @error('noticeSubtext') {{ $message }} @enderror +
+
+ @endif + + {{-- Success feedback --}} + @if($saved) +
+
+ Status wurde gespeichert und ist sofort aktiv. +
+ @endif + + {{-- Save button --}} + + + @endif +
diff --git a/resources/views/livewire/partner/create-account.blade.php b/resources/views/livewire/partner/create-account.blade.php index e5305a0..dab2e4b 100644 --- a/resources/views/livewire/partner/create-account.blade.php +++ b/resources/views/livewire/partner/create-account.blade.php @@ -11,7 +11,7 @@ use Livewire\Attributes\Title; use Livewire\Volt\Component; use Spatie\Permission\Models\Role; -new #[Layout('web.layouts.web-master-slot'), Title('Willkommen bei B2In')] class extends Component { +new #[Layout('web.layouts.web-master-slot'), Title('Willkommen bei B2in')] class extends Component { public string $firstName = ''; public string $lastName = ''; public string $email = ''; diff --git a/resources/views/livewire/partner/invitation-accept.blade.php b/resources/views/livewire/partner/invitation-accept.blade.php index 59e9f3f..d3e9e20 100644 --- a/resources/views/livewire/partner/invitation-accept.blade.php +++ b/resources/views/livewire/partner/invitation-accept.blade.php @@ -11,7 +11,7 @@ use Livewire\Attributes\Title; use Livewire\Volt\Component; use Spatie\Permission\Models\Role; -new #[Layout('components.layouts.guest'), Title('Willkommen bei B2In')] class extends Component { +new #[Layout('components.layouts.guest'), Title('Willkommen bei B2in')] class extends Component { public PartnerInvitation $invitation; public string $firstName = ''; @@ -96,7 +96,7 @@ new #[Layout('components.layouts.guest'), Title('Willkommen bei B2In')] class ex \DB::commit(); // 6. Weiterleitung zum Setup-Wizard - session()->flash('message', __('Willkommen bei B2In! Vervollständigen Sie nun Ihr Profil.')); + session()->flash('message', __('Willkommen bei B2in! Vervollständigen Sie nun Ihr Profil.')); $this->redirect(route('partner.setup.wizard'), navigate: true); } catch (\Exception $e) { diff --git a/resources/views/livewire/partner/my-data.blade.php b/resources/views/livewire/partner/my-data.blade.php index ee221a6..c6fe1f8 100644 --- a/resources/views/livewire/partner/my-data.blade.php +++ b/resources/views/livewire/partner/my-data.blade.php @@ -1,49 +1,120 @@ + */ + 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], + ]; + + // Neue Fotos (temporäre Upload-Objekte) + /** @var array */ + public array $newTeamPhotos = []; + + /** @var array */ + public array $newShowroomPhotos = []; + + /** @var array */ + public array $newBrandImages = []; + + // Bestehende Fotos (aus DB, für Drag-&-Drop) + /** @var array */ + public array $existingTeamPhotos = []; + + /** @var array */ + public array $existingShowroomPhotos = []; + + /** @var array */ + public array $existingBrandImages = []; + public string $roleIcon = 'shield-check'; + public string $roleName = '-'; public function mount(): void { $user = Auth::user(); - if (!$user->partner_id) { + if (! $user->partner_id) { $this->redirect(route('dashboard'), navigate: true); + return; } @@ -53,10 +124,10 @@ new #[Layout('components.layouts.app'), Title('Meine Daten')] class extends Comp $this->roleName = $role->display_name ?? $role->name; } - $this->partner = Partner::with('users')->findOrFail($user->partner_id); + $this->partner = Partner::with(['users', 'media'])->findOrFail($user->partner_id); $this->partnerType = $this->partner->type?->value ?? ''; - // Vorausfüllen: Partner-Daten + // Stammdaten $this->companyName = $this->partner->company_name ?? ''; $this->displayName = $this->partner->display_name ?? ''; $this->salutation = $this->partner->salutation ?? ''; @@ -73,12 +144,12 @@ new #[Layout('components.layouts.app'), Title('Meine Daten')] class extends Comp $this->deliveryRadius = $this->partner->delivery_radius_km; $this->assemblyRadius = $this->partner->assembly_radius_km; - // Ersetze Platzhalter wie "roles.broker M10000004" durch Übersetzung + ID - if(strpos($this->companyName, 'roles.') !== false) { + // Platzhalter wie "roles.broker M10000004" ersetzen + if (str_contains($this->companyName, 'roles.')) { $parts = explode(' ', $this->companyName, 2); $translatedRole = isset($parts[0]) ? __($parts[0]) : $this->companyName; - $partnerId = isset($parts[1]) ? ' ' . $parts[1] : ''; - $this->companyName = $translatedRole . $partnerId; + $partnerId = isset($parts[1]) ? ' '.$parts[1] : ''; + $this->companyName = $translatedRole.$partnerId; } // Namen aus User übernehmen, falls Partner-Felder leer sind @@ -88,24 +159,90 @@ new #[Layout('components.layouts.app'), Title('Meine Daten')] class extends Comp $this->lastName = $nameParts[1] ?? ''; } - // Marke laden für Manufacturer - if (strtolower(str_replace('-', '', $this->partnerType)) === 'manufacturer') { + // Profil-Felder + $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); + } + + // Marke für Manufacturer + if ($this->isManufacturer()) { $brand = Brand::where('partner_id', $this->partner->id)->first(); if ($brand) { $this->brandName = $brand->name; $this->brandDescription = $brand->description ?? ''; } } + + $this->loadExistingMedia(); } + // ── Foto-Verwaltung ──────────────────────────────────────────────────────── + + public function removeQueuedPhoto(int $index, string $type): void + { + $property = $this->queuedPropertyForType($type); + if ($property && isset($this->$property[$index])) { + unset($this->$property[$index]); + $this->$property = array_values($this->$property); + } + } + + public function removeExistingPhoto(int $mediaId, string $type): void + { + $media = Media::where('id', $mediaId) + ->where('model_type', Partner::class) + ->where('model_id', $this->partner->id) + ->firstOrFail(); + + Storage::disk('public')->delete($media->file_path); + $media->delete(); + + $property = $this->existingPropertyForType($type); + if ($property) { + $this->$property = array_values( + array_filter($this->$property, fn ($m) => $m['id'] !== $mediaId) + ); + } + } + + /** + * Reihenfolge per Drag-&-Drop aktualisieren. + * + * @param array $orderedIds + */ + public function updatePhotoOrder(array $orderedIds, string $type): void + { + foreach ($orderedIds as $position => $mediaId) { + $this->partner->media() + ->where('id', $mediaId) + ->where('type', $type) + ->update(['order_column' => $position + 1]); + } + + $property = $this->existingPropertyForType($type); + if (! $property) { + return; + } + + $reordered = collect($orderedIds)->map(function ($id, $index) use ($property) { + $media = collect($this->$property)->firstWhere('id', $id); + + return $media ? array_merge($media, ['order_column' => $index + 1]) : null; + })->filter()->values()->toArray(); + + $this->$property = $reordered; + } + + // ── Speichern ────────────────────────────────────────────────────────────── + public function saveData(): void { - $normalizedType = strtolower(str_replace('-', '', $this->partnerType)); - $isCustomer = $normalizedType === 'customer'; - $isBroker = $normalizedType === 'broker' || $normalizedType === 'estateagent'; - $isRetailer = $normalizedType === 'retailer'; - $isManufacturer = $normalizedType === 'manufacturer'; - $rules = [ 'salutation' => 'required|in:Herr,Frau,Divers', 'firstName' => 'required|string|max:255', @@ -116,26 +253,35 @@ new #[Layout('components.layouts.app'), Title('Meine Daten')] class extends Comp 'city' => 'required|string|max:255', 'country' => 'required|string|max:255', 'phone' => 'nullable|string|max:50', + 'storyText' => 'nullable|string|max:2000', + 'foundedYear' => 'nullable|integer|min:1800|max:'.now()->year, + 'specialtiesInput' => 'nullable|string|max:500', ]; - if (!$isCustomer) { + if (! $this->isCustomer()) { $rules['companyName'] = 'required|string|max:255'; $rules['description'] = 'nullable|string|max:1000'; $rules['website'] = 'nullable|url|max:255'; } - if ($isBroker) { + if ($this->isBroker()) { $rules['displayName'] = 'required|string|max:255'; } - if ($isRetailer) { + if ($this->isRetailer()) { $rules['deliveryRadius'] = 'required|integer|min:1|max:500'; $rules['assemblyRadius'] = 'required|integer|min:1|max:500'; + $rules['newTeamPhotos'] = 'nullable|array|max:10'; + $rules['newTeamPhotos.*'] = 'image|mimes:jpeg,jpg,png|max:10240'; + $rules['newShowroomPhotos'] = 'nullable|array|max:20'; + $rules['newShowroomPhotos.*'] = 'image|mimes:jpeg,jpg,png|max:10240'; } - if ($isManufacturer) { + if ($this->isManufacturer()) { $rules['brandName'] = 'required|string|max:255'; $rules['brandDescription'] = 'nullable|string|max:1000'; + $rules['newBrandImages'] = 'nullable|array|max:10'; + $rules['newBrandImages.*'] = 'image|mimes:jpeg,jpg,png|max:10240'; } $this->validate($rules, [ @@ -155,9 +301,21 @@ new #[Layout('components.layouts.app'), Title('Meine Daten')] class extends Comp 'assemblyRadius.required' => __('Bitte geben Sie einen Montageradius ein.'), 'assemblyRadius.min' => __('Der Montageradius muss mindestens 1 km betragen.'), 'brandName.required' => __('Bitte geben Sie einen Markennamen ein.'), + '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.'), + 'newTeamPhotos.*.image' => __('Nur Bilder (JPG, PNG) erlaubt.'), + 'newTeamPhotos.*.max' => __('Bilder dürfen maximal 10 MB groß sein.'), + 'newShowroomPhotos.*.image' => __('Nur Bilder (JPG, PNG) erlaubt.'), + 'newShowroomPhotos.*.max' => __('Bilder dürfen maximal 10 MB groß sein.'), + 'newBrandImages.*.image' => __('Nur Bilder (JPG, PNG) erlaubt.'), + 'newBrandImages.*.max' => __('Bilder dürfen maximal 10 MB groß sein.'), ]); - // Update Partner + $specialties = array_values(array_filter( + array_map('trim', explode(',', $this->specialtiesInput)) + )); + $updateData = [ 'salutation' => $this->salutation, 'first_name' => $this->firstName, @@ -168,27 +326,30 @@ new #[Layout('components.layouts.app'), Title('Meine Daten')] class extends Comp 'city' => $this->city, 'country' => $this->country, 'phone' => $this->phone, + 'story_text' => $this->storyText ?: null, + 'founded_year' => $this->foundedYear ?: null, + 'specialties' => $specialties ?: null, ]; - if (!$isCustomer) { + if (! $this->isCustomer()) { $updateData['company_name'] = $this->companyName; $updateData['description'] = $this->description; $updateData['website'] = $this->website; } - if ($isBroker) { + if ($this->isBroker()) { $updateData['display_name'] = $this->displayName; } - if ($isRetailer) { + if ($this->isRetailer()) { $updateData['delivery_radius_km'] = $this->deliveryRadius; $updateData['assembly_radius_km'] = $this->assemblyRadius; + $updateData['opening_hours'] = $this->openingHours; } $this->partner->update($updateData); - // Für Manufacturer: Marke aktualisieren oder erstellen - if ($isManufacturer) { + if ($this->isManufacturer()) { Brand::updateOrCreate( ['partner_id' => $this->partner->id], [ @@ -200,16 +361,144 @@ new #[Layout('components.layouts.app'), Title('Meine Daten')] class extends Comp ); } + $this->saveUploadedPhotos($this->newTeamPhotos, 'team_photo'); + $this->saveUploadedPhotos($this->newShowroomPhotos, 'showroom'); + $this->saveUploadedPhotos($this->newBrandImages, 'brand_image'); + + $this->newTeamPhotos = []; + $this->newShowroomPhotos = []; + $this->newBrandImages = []; + + $this->partner->load('media'); + $this->loadExistingMedia(); + session()->flash('message', __('Ihre Daten wurden erfolgreich aktualisiert.')); } + + // ── Hilfsmethoden ────────────────────────────────────────────────────────── + + private function isCustomer(): bool + { + return strtolower(str_replace('-', '', $this->partnerType)) === 'customer'; + } + + private function isRetailer(): bool + { + return strtolower(str_replace('-', '', $this->partnerType)) === 'retailer'; + } + + private function isManufacturer(): bool + { + return strtolower(str_replace('-', '', $this->partnerType)) === 'manufacturer'; + } + + private function isBroker(): bool + { + $t = strtolower(str_replace('-', '', $this->partnerType)); + + return $t === 'broker' || $t === 'estateagent'; + } + + private function loadExistingMedia(): void + { + $this->existingTeamPhotos = $this->partner->media + ->where('type', 'team_photo') + ->sortBy('order_column') + ->values() + ->map(fn ($m) => ['id' => $m->id, 'file_path' => $m->file_path, 'alt_text' => $m->alt_text ?? '']) + ->toArray(); + + $this->existingShowroomPhotos = $this->partner->media + ->where('type', 'showroom') + ->sortBy('order_column') + ->values() + ->map(fn ($m) => ['id' => $m->id, 'file_path' => $m->file_path, 'alt_text' => $m->alt_text ?? '']) + ->toArray(); + + $this->existingBrandImages = $this->partner->media + ->where('type', 'brand_image') + ->sortBy('order_column') + ->values() + ->map(fn ($m) => ['id' => $m->id, 'file_path' => $m->file_path, 'alt_text' => $m->alt_text ?? '']) + ->toArray(); + } + + /** @param array $files */ + private function saveUploadedPhotos(array $files, string $type): void + { + if (empty($files)) { + return; + } + + $maxOrder = $this->partner->media() + ->where('type', $type) + ->max('order_column') ?? 0; + + $index = $maxOrder + 1; + foreach ($files as $file) { + $path = $file->store('partners/'.$this->partner->id.'/'.$type, 'public'); + $this->partner->media()->create([ + 'file_path' => $path, + 'type' => $type, + 'alt_text' => $this->partner->company_name, + 'order_column' => $index++, + ]); + } + } + + private function queuedPropertyForType(string $type): ?string + { + return match ($type) { + 'team_photo' => 'newTeamPhotos', + 'showroom' => 'newShowroomPhotos', + 'brand_image' => 'newBrandImages', + default => null, + }; + } + + private function existingPropertyForType(string $type): ?string + { + return match ($type) { + 'team_photo' => 'existingTeamPhotos', + 'showroom' => 'existingShowroomPhotos', + 'brand_image' => 'existingBrandImages', + default => null, + }; + } + + /** @return array */ + protected function dayLabels(): array + { + return [ + 'monday' => __('Montag'), + 'tuesday' => __('Dienstag'), + 'wednesday' => __('Mittwoch'), + 'thursday' => __('Donnerstag'), + 'friday' => __('Freitag'), + 'saturday' => __('Samstag'), + 'sunday' => __('Sonntag'), + ]; + } + + public function with(): array + { + return [ + 'dayLabels' => $this->dayLabels(), + 'isRetailer' => $this->isRetailer(), + 'isManufacturer' => $this->isManufacturer(), + 'isCustomer' => $this->isCustomer(), + 'isBroker' => $this->isBroker(), + ]; + } }; ?>
+ {{-- Header --}}
{{ __('Meine Daten') }} - {{ __('Verwalten Sie Ihre Firmendaten und Kontaktinformationen') }} + {{ __('Verwalten Sie Ihre Firmendaten und Ihr Profil') }}
@svg('heroicon-o-'.$roleIcon, 'w-6 h-6 text-accent-600 dark:text-accent-400') @@ -217,225 +506,600 @@ new #[Layout('components.layouts.app'), Title('Meine Daten')] class extends Comp
- {{-- Success Message --}} - {{-- Formular --}} - -
- @php - $isCustomer = strtolower(str_replace('-', '', $partnerType)) === 'customer'; - $isRetailer = strtolower(str_replace('-', '', $partnerType)) === 'retailer'; - $isManufacturer = strtolower(str_replace('-', '', $partnerType)) === 'manufacturer'; - $isBroker = strtolower(str_replace('-', '', $partnerType)) === 'broker' || strtolower(str_replace('-', '', $partnerType)) === 'estateagent'; - @endphp + - {{-- Firmenname (nur für Nicht-Kunden) --}} - @if (!$isCustomer) - - {{ __('Firmenname') }} * - - @error('companyName') - {{ $message }} - @enderror - + {{-- Tab-Navigation --}} + + {{ __('Stammdaten') }} + {{ $isRetailer ? __('Präsentation') : __('Über uns') }} + @if ($isRetailer) + {{ __('Öffnungszeiten') }} + @endif + @if ($isRetailer || $isManufacturer) + {{ __('Fotos') }} + @endif + + + {{-- ── TAB 1: STAMMDATEN ── --}} + @if ($activeTab === 'stammdaten') +
+ +
+ {{ __('Firmendaten') }} + {{ __('Ihre Kontakt- und Adressdaten') }} +
+ + + @if (!$isCustomer) + + {{ __('Firmenname') }} * + + @error('companyName') {{ $message }} @enderror + + + @if ($isBroker) + + {{ __('Anzeigename') }} * + {{ __('Der Name, der Ihren Kunden angezeigt wird') }} + + @error('displayName') {{ $message }} @enderror + + @endif + + + {{ __('Kurzbeschreibung') }} + + @error('description') {{ $message }} @enderror + + + + @endif + + {{-- Persönliche Daten --}} +
+ + {{ __('Anrede') }} * + + {{ __('Bitte wählen') }} + {{ __('Herr') }} + {{ __('Frau') }} + {{ __('Divers') }} + + @error('salutation') {{ $message }} @enderror + + + + {{ __('Vorname') }} * + + @error('firstName') {{ $message }} @enderror + + + + {{ __('Nachname') }} * + + @error('lastName') {{ $message }} @enderror + +
+ + + + {{-- Adresse --}} +
+ + {{ __('Straße') }} * + + @error('street') {{ $message }} @enderror + + + + {{ __('Hausnummer') }} * + + @error('houseNumber') {{ $message }} @enderror + +
+ +
+ + {{ __('Postleitzahl') }} * + + @error('zip') {{ $message }} @enderror + + + + {{ __('Ort') }} * + + @error('city') {{ $message }} @enderror + +
- @if ($isBroker) - {{ __('Anzeigename') }} * - {{ __('Der Name, der Ihren Kunden angezeigt wird') }} - - @error('displayName') - {{ $message }} - @enderror + {{ __('Land') }} * + + {{ __('Deutschland') }} + {{ __('Österreich') }} + {{ __('Schweiz') }} + + @error('country') {{ $message }} @enderror + +
+ + {{ __('Telefon') }} + + @error('phone') {{ $message }} @enderror + + + @if (!$isCustomer) + + {{ __('Website') }} + + @error('website') {{ $message }} @enderror + + @endif +
+ + {{-- Liefergebiete (nur Händler) --}} + @if ($isRetailer) + + +
+

{{ __('Liefergebiete') }}

+

{{ __('Definieren Sie, in welchem Umkreis Sie liefern und montieren können.') }}

+
+ +
+ + {{ __('Lieferradius (km)') }} * + {{ __('Ich liefere im Umkreis von ... km') }} + + @error('deliveryRadius') {{ $message }} @enderror + + + + {{ __('Montageradius (km)') }} * + {{ __('Ich montiere im Umkreis von ... km') }} + + @error('assemblyRadius') {{ $message }} @enderror + +
+ @endif +
+
+ @endif + + {{-- ── TAB 2: PRÄSENTATION / ÜBER UNS ── --}} + @if ($activeTab === 'praesentation') +
+ + {{-- Story & Profil --}} + +
+ {{ $isRetailer ? __('Präsentation & Story') : __('Über das Unternehmen') }} + + {{ $isRetailer + ? __('Erzählen Sie Ihre Geschichte – was macht Ihren Showroom besonders?') + : __('Teilen Sie Ihre Unternehmensgeschichte und Spezialisierungen.') }} + +
+ + + + {{ __('Über uns / Story') }} + {{ __('Sichtbar auf Ihrem öffentlichen Profil. Max. 2000 Zeichen.') }} + +
{{ strlen($storyText) }} / 2000
+ @error('storyText') {{ $message }} @enderror +
+ +
+ + {{ __('Gründungsjahr') }} + + @error('foundedYear') {{ $message }} @enderror + + + + {{ __('Spezialisierungen') }} + {{ __('Kommagetrennt, z.B.: Polstermöbel, Outdoor, Küchen') }} + + @error('specialtiesInput') {{ $message }} @enderror + +
+
+ + {{-- Marke (nur Hersteller) --}} + @if ($isManufacturer) + +
+ {{ __('Ihre Marke') }} + {{ __('Unter dieser Marke werden Ihre Produkte auf B2in gelistet.') }} +
+ + + + {{ __('Markenname') }} * + + @error('brandName') {{ $message }} @enderror + + + + {{ __('Marken-Beschreibung') }} + + @error('brandDescription') {{ $message }} @enderror + +
@endif - - {{ __('Kurzbeschreibung') }} ({{ __('optional') }}) - - @error('description') - {{ $message }} - @enderror - - - - @endif - - {{-- Persönliche Daten --}} -
- - {{ __('Anrede') }} * - - {{ __('Bitte wählen') }} - {{ __('Herr') }} - {{ __('Frau') }} - {{ __('Divers') }} - - @error('salutation') - {{ $message }} - @enderror - - - - {{ __('Vorname') }} * - - @error('firstName') - {{ $message }} - @enderror - - - - {{ __('Nachname') }} * - - @error('lastName') - {{ $message }} - @enderror -
+ @endif - + {{-- ── TAB 3: ÖFFNUNGSZEITEN (nur Händler) ── --}} + @if ($activeTab === 'oeffnungszeiten' && $isRetailer) +
+ +
+ {{ __('Öffnungszeiten') }} + {{ __('Wann sind Sie für Kunden erreichbar oder Ihr Showroom geöffnet?') }} +
+ - {{-- Adresse --}} -
- - {{ __('Straße') }} * - - @error('street') - {{ $message }} - @enderror - +
+ @foreach ($dayLabels as $dayKey => $dayLabel) +
+
+ {{ $dayLabel }} +
- - {{ __('Hausnummer') }} * - - @error('houseNumber') - {{ $message }} - @enderror - + + + @unless ($openingHours[$dayKey]['closed'] ?? false) +
+ + + +
+ @endunless +
+ @endforeach +
+
+ @endif -
- - {{ __('Postleitzahl') }} * - - @error('zip') - {{ $message }} - @enderror - + {{-- ── TAB 4: FOTOS ── --}} + @if ($activeTab === 'fotos' && ($isRetailer || $isManufacturer)) +
+ + @if ($isRetailer) + {{-- ── Team-Fotos ── --}} + +
+ {{ __('Team-Fotos') }} + {{ __('Nur JPG/PNG – max. 10 MB pro Bild') }} +
+ + + @if (count($existingTeamPhotos) > 0) +
+ {{ __('Vorhandene Fotos') }} + {{ __('Per Drag & Drop sortieren – das erste Foto wird als Hauptbild verwendet.') }} +
+ @foreach ($existingTeamPhotos as $photo) +
+ {{ $photo['alt_text'] }} + +
+ +
+
+ @endforeach +
+
+ + @endif + + + + + + @if (count($newTeamPhotos) > 0) +
+ @foreach ($newTeamPhotos as $index => $photo) + + + + + + @endforeach +
+ @endif + +
+ + {{-- ── Showroom-Galerie ── --}} + +
+ {{ __('Showroom-Galerie') }} + {{ __('Bilder Ihres Showrooms für das öffentliche Profil – nur JPG/PNG, max. 10 MB') }} +
+ + + @if (count($existingShowroomPhotos) > 0) +
+ {{ __('Vorhandene Bilder') }} + {{ __('Per Drag & Drop sortieren.') }} +
+ @foreach ($existingShowroomPhotos as $photo) +
+ {{ $photo['alt_text'] }} + +
+ +
+
+ @endforeach +
+
+ + @endif + + + + + + @if (count($newShowroomPhotos) > 0) +
+ @foreach ($newShowroomPhotos as $index => $photo) + + + + + + @endforeach +
+ @endif + +
+ @endif + + @if ($isManufacturer) + {{-- ── Marken-Bilder ── --}} + +
+ {{ __('Marken-Bilder') }} + {{ __('Bilder für Ihre Marken-Präsentation (Atmosphäre, Brand-Story etc.) – nur JPG/PNG, max. 10 MB') }} +
+ + + @if (count($existingBrandImages) > 0) +
+ {{ __('Vorhandene Bilder') }} + {{ __('Per Drag & Drop sortieren.') }} +
+ @foreach ($existingBrandImages as $photo) +
+ {{ $photo['alt_text'] }} + +
+ +
+
+ @endforeach +
+
+ + @endif + + + + + + @if (count($newBrandImages) > 0) +
+ @foreach ($newBrandImages as $index => $photo) + + + + + + @endforeach +
+ @endif + +
+ @endif - - {{ __('Ort') }} * - - @error('city') - {{ $message }} - @enderror -
+ @endif - - {{ __('Land') }} * - - {{ __('Deutschland') }} - {{ __('Österreich') }} - {{ __('Schweiz') }} - - @error('country') - {{ $message }} - @enderror - - - - {{ __('Telefon') }} - - @error('phone') - {{ $message }} - @enderror - - - @if (!$isCustomer) - - {{ __('Website') }} - - @error('website') - {{ $message }} - @enderror - - @endif - - {{-- Liefergebiete für Händler (Retailer) --}} - @if ($isRetailer) - - -
-

- {{ __('Liefergebiete') }} -

-

- {{ __('Definieren Sie, in welchem Umkreis Sie liefern und montieren können.') }} -

-
- -
- - {{ __('Lieferradius (km)') }} * - {{ __('Ich liefere im Umkreis von ... km') }} - - @error('deliveryRadius') - {{ $message }} - @enderror - - - - {{ __('Montageradius (km)') }} * - {{ __('Ich montiere im Umkreis von ... km') }} - - @error('assemblyRadius') - {{ $message }} - @enderror - -
- @endif - - {{-- Marke für Hersteller (Manufacturer) --}} - @if ($isManufacturer) - - -
-

- {{ __('Ihre Marke') }} -

-

- {{ __('Unter dieser Marke werden Ihre Produkte auf B2In gelistet.') }} -

-
- - - {{ __('Markenname') }} * - - @error('brandName') - {{ $message }} - @enderror - - - - {{ __('Marken-Beschreibung') }} ({{ __('optional') }}) - - @error('brandDescription') - {{ $message }} - @enderror - - @endif - - - + {{-- Speichern-Button --}} +
+ + {{ __('Änderungen speichern') }} + {{ __('Wird gespeichert...') }} + +
-
- - {{ __('Änderungen speichern') }} - -
- - +
diff --git a/resources/views/livewire/partner/profile.blade.php b/resources/views/livewire/partner/profile.blade.php index e1e178d..318328b 100644 --- a/resources/views/livewire/partner/profile.blade.php +++ b/resources/views/livewire/partner/profile.blade.php @@ -2,26 +2,32 @@ use App\Enums\ProductStatus; use App\Models\Partner; -use App\Models\Product; use Livewire\Volt\Component; -use function Livewire\Volt\{layout, title}; + +use function Livewire\Volt\layout; layout('components.layouts.app'); -new class extends Component { +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->partner = Partner::with([ + 'hub', + 'media', + '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; } @@ -31,6 +37,9 @@ new class extends Component { return [ 'partner' => $this->partner, 'products' => $this->partner->products, + 'teamPhotos' => $this->partner->media->where('type', 'team_photo')->sortBy('order_column')->values(), + 'showroomPhotos' => $this->partner->media->where('type', 'showroom')->sortBy('order_column')->values(), + 'brandImages' => $this->partner->media->where('type', 'brand_image')->sortBy('order_column')->values(), ]; } }; ?> @@ -95,7 +104,7 @@ new class extends Component {
- {{-- Linke Spalte: Story + Spezialisierungen --}} + {{-- Linke Spalte: Story + Showroom + Marken-Bilder + Produkte --}}
{{-- Story / Über uns --}} @if($partner->story_text) @@ -107,6 +116,42 @@ new class extends Component { @endif + {{-- Showroom-Galerie (Händler) --}} + @if($showroomPhotos->isNotEmpty()) + + {{ __('Unser Showroom') }} +
+ @foreach($showroomPhotos as $photo) +
+ {{ __('Showroom') }} +
+ @endforeach +
+
+ @endif + + {{-- Marken-Bilder (Hersteller) --}} + @if($brandImages->isNotEmpty()) + + {{ __('Unsere Marke') }} +
+ @foreach($brandImages as $photo) +
+ {{ __('Markenbild') }} +
+ @endforeach +
+
+ @endif + {{-- Produkte --}} @if($products->isNotEmpty()) @@ -197,6 +242,24 @@ new class extends Component { @endif + {{-- Team-Fotos --}} + @if($teamPhotos->isNotEmpty()) + + {{ __('Unser Team') }} +
+ @foreach($teamPhotos as $photo) +
+ {{ __('Team') }} +
+ @endforeach +
+
+ @endif + {{-- Adresse --}} @if($partner->street || $partner->city) diff --git a/resources/views/livewire/partner/setup-wizard.blade.php b/resources/views/livewire/partner/setup-wizard.blade.php index 25157a5..7a93a6f 100644 --- a/resources/views/livewire/partner/setup-wizard.blade.php +++ b/resources/views/livewire/partner/setup-wizard.blade.php @@ -557,7 +557,7 @@ new #[Layout('components.layouts.guest'), Title('Setup-Wizard')] class extends C {{ __('Ihre Marke') }}

- {{ __('Unter dieser Marke werden Ihre Produkte auf B2In gelistet. Sie können später weitere Marken hinzufügen.') }} + {{ __('Unter dieser Marke werden Ihre Produkte auf B2in gelistet. Sie können später weitere Marken hinzufügen.') }}

@@ -650,7 +650,7 @@ new #[Layout('components.layouts.guest'), Title('Setup-Wizard')] class extends C ™️ {{ __('Ihre Marke') }} - {{ __('Unter dieser Marke werden Ihre Produkte auf B2In gelistet. (Sie können später weitere Marken hinzufügen)') }} + {{ __('Unter dieser Marke werden Ihre Produkte auf B2in gelistet. (Sie können später weitere Marken hinzufügen)') }}
diff --git a/resources/views/livewire/partner/setup-wizard_bak.blade.php b/resources/views/livewire/partner/setup-wizard_bak.blade.php index f280049..7a56dff 100644 --- a/resources/views/livewire/partner/setup-wizard_bak.blade.php +++ b/resources/views/livewire/partner/setup-wizard_bak.blade.php @@ -566,7 +566,7 @@ new #[Layout('components.layouts.guest'), Title('Setup-Wizard')] class extends C ™️ {{ __('Ihre Marke') }} - {{ __('Unter dieser Marke werden Ihre Produkte auf B2In gelistet. (Sie können später weitere Marken hinzufügen)') }} + {{ __('Unter dieser Marke werden Ihre Produkte auf B2in gelistet. (Sie können später weitere Marken hinzufügen)') }}
diff --git a/resources/views/livewire/products/form-standard.blade.php b/resources/views/livewire/products/form-standard.blade.php index 2eb96df..7d4da35 100644 --- a/resources/views/livewire/products/form-standard.blade.php +++ b/resources/views/livewire/products/form-standard.blade.php @@ -6,33 +6,48 @@ use App\Enums\ProductType; use App\Models\Brand; use App\Models\Category; use App\Models\Hub; +use App\Models\Partner; use App\Models\Product; use Flux\Flux; use Illuminate\Support\Facades\Storage; use Livewire\Volt\Component; use Livewire\WithFileUploads; -use function Livewire\Volt\{layout, title}; + +use function Livewire\Volt\layout; +use function Livewire\Volt\title; layout('components.layouts.app'); title('Standard-Produkt'); -new class extends Component { +new class extends Component +{ use WithFileUploads; public ?Product $product = null; + public bool $isEditing = false; + public string $activeTab = 'basis'; + public array $existingMedia = []; // --- Tab 1: Basis --- public string $name = ''; + public string $descriptionShort = ''; + public string $descriptionLong = ''; + public string $brandName = ''; + public ?int $categoryId = null; + public string $priceType = 'fixed'; + public string $priceDisplayText = ''; + public string $status = 'active'; + public string $partnerProductNumber = ''; // --- Tab 2: Bilder --- @@ -40,64 +55,111 @@ new class extends Component { // --- Tab 3: Maße & Material --- public ?int $widthCm = null; + public ?int $heightCm = null; + public ?int $depthCm = null; + public ?int $weightG = null; + public string $assemblyStatus = ''; + public string $careInstructions = ''; + public string $countryOfOrigin = ''; + public string $mainMaterial = ''; + public string $surfaceMaterial = ''; + public string $coverMaterial = ''; + public string $colorFinish = ''; + public array $certificates = []; + public ?int $assemblyTimeMin = null; + public ?int $loadCapacityKg = null; // --- Tab 4: Verpackung & Versand --- public ?int $packageCount = null; + public ?int $packageWeightG = null; + public ?int $packageWidthCm = null; + public ?int $packageHeightCm = null; + public ?int $packageDepthCm = null; + public string $packagingType = ''; + public ?int $packagingRecyclablePercent = null; + public bool $isPalletizable = false; + public string $hsCode = ''; + public string $deliveryType = ''; // --- Tab 5: Kommerziell --- public string $sku = ''; + public string $hanMpn = ''; + public string $eanGtin = ''; + public ?float $sellingPrice = null; + public ?float $purchasePrice = null; + public ?float $msrp = null; + public string $availabilityStatus = 'in_stock'; + public string $deliveryTimeText = ''; + public string $currency = 'EUR'; + public ?int $productionTimeDays = null; // --- Tab 6: Services & Garantie --- public bool $assemblyService = false; + public ?int $serviceRadiusKm = null; + public ?int $warrantyMonths = null; // --- Tab 7: Nachhaltigkeit & EUDR --- public ?float $co2FootprintKg = null; + public ?int $recyclingPercentage = null; + public bool $isRegionalProduction = false; + public array $woodOrigins = []; // --- Tab 8: Zuordnung & Verwaltung --- public ?int $hubId = null; + + // Nur für Admins: ausgewählter Partner für das neue Produkt + public ?int $selectedPartnerId = null; + public string $metaTitle = ''; + public string $metaDescription = ''; + public bool $visibleIsAvailable = false; + public ?string $visibleFrom = null; + public ?string $visibleUntil = null; + public ?int $storageVolumeLiters = null; + public ?int $assemblyEffortScore = null; + public ?int $designScore = null; public function mount(?Product $product = null): void @@ -126,6 +188,28 @@ new class extends Component { } } + public function updatedSelectedPartnerId(): void + { + if ($this->selectedPartnerId) { + $partner = Partner::find($this->selectedPartnerId); + if ($partner) { + $nextNumber = $partner->products()->count() + 1; + $this->partnerProductNumber = sprintf('P%03d-%04d', $partner->id, $nextNumber); + } + } + } + + private function resolvePartner(): ?Partner + { + $user = auth()->user(); + + if ($user->hasAnyRole(['Admin', 'Super-Admin'])) { + return $this->selectedPartnerId ? Partner::find($this->selectedPartnerId) : null; + } + + return $user->partner; + } + private function prefillFromProduct(Product $product): void { // Basis @@ -200,7 +284,7 @@ new class extends Component { // Wood origins $this->woodOrigins = $product->woodOrigins ->map( - fn($wo) => [ + fn ($wo) => [ 'wood_species' => $wo->wood_species, 'origin_country' => $wo->origin_country, 'origin_region' => $wo->origin_region ?? '', @@ -228,7 +312,7 @@ new class extends Component { ->sortBy('order_column') ->values() ->map( - fn($m) => [ + fn ($m) => [ 'id' => $m->id, 'file_path' => $m->file_path, 'alt_text' => $m->alt_text, @@ -261,7 +345,7 @@ new class extends Component { public function removeExistingMedia(int $mediaId): void { - if (!$this->isEditing) { + if (! $this->isEditing) { return; } @@ -269,7 +353,7 @@ new class extends Component { if ($media) { Storage::disk('public')->delete($media->file_path); $media->delete(); - $this->existingMedia = collect($this->existingMedia)->reject(fn($m) => $m['id'] === $mediaId)->values()->toArray(); + $this->existingMedia = collect($this->existingMedia)->reject(fn ($m) => $m['id'] === $mediaId)->values()->toArray(); } } @@ -352,7 +436,7 @@ new class extends Component { private function validationRules(): array { $allowedPriceTypes = collect(ProductType::SmartOrder->allowedPriceTypes()) - ->map(fn(PriceType $pt) => $pt->value) + ->map(fn (PriceType $pt) => $pt->value) ->implode(','); // SKU unique validation ignores own variant when editing @@ -364,7 +448,7 @@ new class extends Component { } } - return [ + $rules = [ // Basis 'name' => 'required|string|max:255', 'partnerProductNumber' => 'nullable|string|max:100', @@ -440,6 +524,12 @@ new class extends Component { 'assemblyEffortScore' => 'nullable|integer|min:1|max:5', 'designScore' => 'nullable|integer|min:1|max:5', ]; + + if (! $this->isEditing && auth()->user()->hasAnyRole(['Admin', 'Super-Admin'])) { + $rules['selectedPartnerId'] = 'required|exists:partners,id'; + } + + return $rules; } /** @@ -465,6 +555,8 @@ new class extends Component { 'assemblyEffortScore.max' => __('Der Aufbauaufwand muss zwischen 1 und 5 liegen.'), 'recyclingPercentage.min' => __('Der Recyclinganteil muss zwischen 0 und 100 liegen.'), 'recyclingPercentage.max' => __('Der Recyclinganteil muss zwischen 0 und 100 liegen.'), + 'selectedPartnerId.required' => __('Bitte wählen Sie einen Partner/Händler für dieses Produkt aus.'), + 'selectedPartnerId.exists' => __('Der gewählte Partner existiert nicht.'), ]; } @@ -498,7 +590,7 @@ new class extends Component { ->sortBy('order_column') ->values() ->map( - fn($m) => [ + fn ($m) => [ 'id' => $m->id, 'file_path' => $m->file_path, 'alt_text' => $m->alt_text, @@ -546,8 +638,7 @@ new class extends Component { private function saveNew(): void { - $user = auth()->user(); - $partner = $user->partner; + $partner = $this->resolvePartner(); // Marke suchen oder neu anlegen $brandId = $this->resolveOrCreateBrand($partner); @@ -566,7 +657,7 @@ new class extends Component { 'name' => $this->name, 'slug' => str($this->name) ->slug() - ->append('-' . uniqid()), + ->append('-'.uniqid()), 'product_type' => ProductType::SmartOrder, 'status' => $this->status === 'active' ? ProductStatus::Pending : ProductStatus::Draft, 'price_type' => PriceType::from($this->priceType), @@ -583,7 +674,7 @@ new class extends Component { 'surface_material' => $this->surfaceMaterial ?: null, 'cover_material' => $this->coverMaterial ?: null, 'color_finish' => $this->colorFinish ?: null, - 'certificates' => !empty($this->certificates) ? $this->certificates : null, + 'certificates' => ! empty($this->certificates) ? $this->certificates : null, 'assembly_time_min' => $this->assemblyTimeMin, 'load_capacity_kg' => $this->loadCapacityKg, 'delivery_type' => $this->deliveryType ?: null, @@ -643,7 +734,7 @@ new class extends Component { // Bilder speichern $index = 1; foreach ($this->mainImages as $image) { - $path = $image->store('products/' . $product->id, 'public'); + $path = $image->store('products/'.$product->id, 'public'); $product->media()->create([ 'file_path' => $path, 'type' => 'image', @@ -701,7 +792,7 @@ new class extends Component { 'surface_material' => $this->surfaceMaterial ?: null, 'cover_material' => $this->coverMaterial ?: null, 'color_finish' => $this->colorFinish ?: null, - 'certificates' => !empty($this->certificates) ? $this->certificates : null, + 'certificates' => ! empty($this->certificates) ? $this->certificates : null, 'assembly_time_min' => $this->assemblyTimeMin, 'load_capacity_kg' => $this->loadCapacityKg, 'delivery_type' => $this->deliveryType ?: null, @@ -775,7 +866,7 @@ new class extends Component { $maxOrder = $this->product->media()->max('order_column') ?? 0; $index = $maxOrder + 1; foreach ($this->mainImages as $image) { - $path = $image->store('products/' . $this->product->id, 'public'); + $path = $image->store('products/'.$this->product->id, 'public'); $this->product->media()->create([ 'file_path' => $path, 'type' => 'image', @@ -798,19 +889,19 @@ new class extends Component { private function resolveOrCreateBrand($partner): ?int { - if (!$this->brandName) { + if (! $this->brandName) { return null; } - $brand = Brand::where('name', $this->brandName)->where(fn($q) => $q->whereNull('partner_id')->orWhere('partner_id', $partner->id))->first(); + $brand = Brand::where('name', $this->brandName)->where(fn ($q) => $q->whereNull('partner_id')->orWhere('partner_id', $partner->id))->first(); - if (!$brand) { + if (! $brand) { $brand = Brand::create([ 'partner_id' => $partner->id, 'name' => $this->brandName, 'slug' => str($this->brandName) ->slug() - ->append('-' . uniqid()), + ->append('-'.uniqid()), 'is_active' => true, ]); } @@ -821,7 +912,7 @@ new class extends Component { private function saveWoodOrigins(Product $product): void { foreach ($this->woodOrigins as $origin) { - if (!empty($origin['wood_species']) && !empty($origin['origin_country'])) { + if (! empty($origin['wood_species']) && ! empty($origin['origin_country'])) { $product->woodOrigins()->create([ 'wood_species' => $origin['wood_species'], 'origin_country' => $origin['origin_country'], @@ -837,13 +928,18 @@ new class extends Component { public function with(): array { + $effectivePartnerId = auth()->user()->partner_id ?? $this->selectedPartnerId; + $data = [ 'categories' => Category::orderBy('name')->get(['id', 'name']), - 'brands' => Brand::where('is_active', true)->where(fn($q) => $q->whereNull('partner_id')->orWhere('partner_id', auth()->user()->partner_id))->orderBy('name')->pluck('name'), + 'brands' => Brand::where('is_active', true)->where(fn ($q) => $q->whereNull('partner_id')->orWhere('partner_id', $effectivePartnerId))->orderBy('name')->pluck('name'), 'hubs' => Hub::where('is_active', true) ->orderBy('name') ->get(['id', 'name']), 'allowedPriceTypes' => ProductType::SmartOrder->allowedPriceTypes(), + 'adminPartners' => auth()->user()->hasAnyRole(['Admin', 'Super-Admin']) && ! $this->isEditing + ? Partner::where('is_active', true)->orderBy('company_name')->get(['id', 'company_name', 'display_name']) + : collect(), 'countries' => collect([ 'DE' => 'Deutschland', 'AT' => 'Österreich', @@ -909,6 +1005,28 @@ new class extends Component { @endif + {{-- Admin: Partner-Auswahl (nur bei Neuanlage) --}} + @if (!$isEditing && auth()->user()->hasAnyRole(['Admin', 'Super-Admin'])) + +
+ {{ __('Partner auswählen') }} + {{ __('Als Admin legen Sie das Produkt im Namen eines Partners an.') }} +
+ + + {{ __('Partner / Händler') }} {{ __('Pflichtfeld') }} + + @foreach ($adminPartners as $p) + + {{ $p->display_name ?? $p->company_name }} + + @endforeach + + + +
+ @endif +
{{-- Tab Navigation --}} diff --git a/resources/views/livewire/products/form-teaser.blade.php b/resources/views/livewire/products/form-teaser.blade.php index 4810ee3..c13967b 100644 --- a/resources/views/livewire/products/form-teaser.blade.php +++ b/resources/views/livewire/products/form-teaser.blade.php @@ -4,36 +4,50 @@ use App\Enums\PriceType; use App\Enums\ProductStatus; use App\Enums\ProductType; use App\Models\Category; +use App\Models\Partner; 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}; + +use function Livewire\Volt\layout; +use function Livewire\Volt\title; layout('components.layouts.app'); title('Teaser-Produkt'); -new class extends Component { +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 = []; + // Nur für Admins: ausgewählter Partner für das neue Produkt + public ?int $selectedPartnerId = null; + public function mount(?Product $product = null): void { if ($product && $product->exists) { @@ -59,7 +73,7 @@ new class extends Component { ->sortBy('order_column') ->values() ->map( - fn($m) => [ + fn ($m) => [ 'id' => $m->id, 'file_path' => $m->file_path, 'alt_text' => $m->alt_text, @@ -85,9 +99,31 @@ new class extends Component { } } + public function updatedSelectedPartnerId(): void + { + if ($this->selectedPartnerId) { + $partner = Partner::find($this->selectedPartnerId); + if ($partner) { + $nextNumber = $partner->products()->count() + 1; + $this->partnerProductNumber = sprintf('P%03d-%04d', $partner->id, $nextNumber); + } + } + } + + private function resolvePartner(): ?Partner + { + $user = auth()->user(); + + if ($user->hasAnyRole(['Admin', 'Super-Admin'])) { + return $this->selectedPartnerId ? Partner::find($this->selectedPartnerId) : null; + } + + return $user->partner; + } + public function removeExistingMedia(int $mediaId): void { - if (!$this->isEditing) { + if (! $this->isEditing) { return; } @@ -95,7 +131,7 @@ new class extends Component { if ($media) { Storage::disk('public')->delete($media->file_path); $media->delete(); - $this->existingMedia = collect($this->existingMedia)->reject(fn($m) => $m['id'] === $mediaId)->values()->toArray(); + $this->existingMedia = collect($this->existingMedia)->reject(fn ($m) => $m['id'] === $mediaId)->values()->toArray(); } } @@ -114,7 +150,7 @@ new class extends Component { */ public function updateMediaOrder(array $orderedIds): void { - if (!$this->isEditing) { + if (! $this->isEditing) { return; } @@ -188,33 +224,42 @@ new class extends Component { } $allowedPriceTypes = collect(ProductType::LocalStock->allowedPriceTypes()) - ->map(fn(PriceType $pt) => $pt->value) + ->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.'), - ], - ); + $isAdminWithoutPartner = ! $this->isEditing && auth()->user()->hasAnyRole(['Admin', 'Super-Admin']); + + $rules = [ + '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', + ]; + + $messages = [ + '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 ($isAdminWithoutPartner) { + $rules['selectedPartnerId'] = 'required|exists:partners,id'; + $messages['selectedPartnerId.required'] = __('Bitte wählen Sie einen Partner/Händler für dieses Produkt aus.'); + $messages['selectedPartnerId.exists'] = __('Der gewählte Partner existiert nicht.'); + } + + $this->validate($rules, $messages); if ($this->isEditing) { $this->saveExisting(); @@ -229,7 +274,7 @@ new class extends Component { ->media->sortBy('order_column') ->values() ->map( - fn($m) => [ + fn ($m) => [ 'id' => $m->id, 'file_path' => $m->file_path, 'alt_text' => $m->alt_text, @@ -249,8 +294,7 @@ new class extends Component { private function saveNew(): void { - $user = auth()->user(); - $partner = $user->partner; + $partner = $this->resolvePartner(); // Status: 'active' im UI → Pending (zur Freigabe), 'draft' → Draft $newStatus = $this->status === 'active' ? ProductStatus::Pending : ProductStatus::Draft; @@ -268,7 +312,7 @@ new class extends Component { 'name' => $this->name, 'slug' => str($this->name) ->slug() - ->append('-' . uniqid()), + ->append('-'.uniqid()), 'product_type' => ProductType::LocalStock, 'status' => $newStatus, 'price_type' => PriceType::from($this->priceType), @@ -284,7 +328,7 @@ new class extends Component { // Bilder speichern $index = 1; foreach ($this->mainImages as $image) { - $path = $image->store('products/' . $product->id, 'public'); + $path = $image->store('products/'.$product->id, 'public'); $product->media()->create([ 'file_path' => $path, 'type' => 'image', @@ -322,7 +366,7 @@ new class extends Component { $maxOrder = $this->product->media()->max('order_column') ?? 0; $index = $maxOrder + 1; foreach ($this->mainImages as $image) { - $path = $image->store('products/' . $this->product->id, 'public'); + $path = $image->store('products/'.$this->product->id, 'public'); $this->product->media()->create([ 'file_path' => $path, 'type' => 'image', @@ -345,6 +389,9 @@ new class extends Component { 'categories' => Category::orderBy('name')->get(['id', 'name']), 'allowedPriceTypes' => ProductType::LocalStock->allowedPriceTypes(), 'isEditing' => $this->isEditing, + 'adminPartners' => auth()->user()->hasAnyRole(['Admin', 'Super-Admin']) && ! $this->isEditing + ? Partner::where('is_active', true)->orderBy('company_name')->get(['id', 'company_name', 'display_name']) + : collect(), ]; if ($this->isEditing) { @@ -384,6 +431,28 @@ new class extends Component { @endif + {{-- Admin: Partner-Auswahl (nur bei Neuanlage) --}} + @if (!$isEditing && auth()->user()->hasAnyRole(['Admin', 'Super-Admin'])) + +
+ {{ __('Partner auswählen') }} + {{ __('Als Admin legen Sie das Produkt im Namen eines Partners an.') }} +
+ + + {{ __('Partner / Händler') }} {{ __('Pflichtfeld') }} + + @foreach ($adminPartners as $p) + + {{ $p->display_name ?? $p->company_name }} + + @endforeach + + + +
+ @endif + {{-- Bild-Upload --}} diff --git a/resources/views/livewire/web/components/sections/about-hero.blade.php b/resources/views/livewire/web/components/sections/about-hero.blade.php index bd14241..05cecd8 100644 --- a/resources/views/livewire/web/components/sections/about-hero.blade.php +++ b/resources/views/livewire/web/components/sections/about-hero.blade.php @@ -21,7 +21,7 @@
- {{ $content['image_alt'] }}
diff --git a/resources/views/livewire/web/components/sections/benefits-section.blade.php b/resources/views/livewire/web/components/sections/benefits-section.blade.php index c3d8208..d35425d 100644 --- a/resources/views/livewire/web/components/sections/benefits-section.blade.php +++ b/resources/views/livewire/web/components/sections/benefits-section.blade.php @@ -20,7 +20,7 @@
@if(isset($content['image'])) - {{ $content['image_alt'] ?? 'Benefits Image' }}
@@ -122,7 +122,7 @@
@if(isset($content['image'])) - {{ $content['image_alt'] ?? 'Benefits Image' }}
diff --git a/resources/views/livewire/web/components/sections/brand-worlds.blade.php b/resources/views/livewire/web/components/sections/brand-worlds.blade.php index 4fcf181..10c6901 100644 --- a/resources/views/livewire/web/components/sections/brand-worlds.blade.php +++ b/resources/views/livewire/web/components/sections/brand-worlds.blade.php @@ -13,14 +13,17 @@ @foreach ($worlds as $world)
- {{ $world['title'] }}
{{-- Brand Cards --}} -
+
@foreach ($content['cards'] as $index => $card)
@if(isset($card['image']))
- {{ $card['title'] }}
@endif @@ -54,7 +54,7 @@ @if(isset($card['link']))
- Mehr erfahren + {{ __('ui.learn_more') }} diff --git a/resources/views/livewire/web/components/sections/commitment-section.blade.php b/resources/views/livewire/web/components/sections/commitment-section.blade.php index 8d69392..577f2aa 100644 --- a/resources/views/livewire/web/components/sections/commitment-section.blade.php +++ b/resources/views/livewire/web/components/sections/commitment-section.blade.php @@ -13,7 +13,7 @@ @foreach($content['testimonials'] as $index => $testimonial)
- {{ $testimonial['author'] }} + {{ $testimonial['author'] }}

{{ $testimonial['author'] }}

{{ $testimonial['author_title'] }}

diff --git a/resources/views/livewire/web/components/sections/content-section.blade.php b/resources/views/livewire/web/components/sections/content-section.blade.php index 4c5441f..6f6fa0d 100644 --- a/resources/views/livewire/web/components/sections/content-section.blade.php +++ b/resources/views/livewire/web/components/sections/content-section.blade.php @@ -7,7 +7,7 @@ {{-- Image --}}
- {{ $content['image_alt'] }}
@@ -58,7 +58,7 @@ {{-- Image --}}
- {{ $content['image_alt'] }}
diff --git a/resources/views/livewire/web/components/sections/dark-stats-section.blade.php b/resources/views/livewire/web/components/sections/dark-stats-section.blade.php index 01f101f..9b808ff 100644 --- a/resources/views/livewire/web/components/sections/dark-stats-section.blade.php +++ b/resources/views/livewire/web/components/sections/dark-stats-section.blade.php @@ -38,7 +38,7 @@
- {{ $content['image_alt'] }}
diff --git a/resources/views/livewire/web/components/sections/ecosystem-core.blade.php b/resources/views/livewire/web/components/sections/ecosystem-core.blade.php index 8d7654d..d5569bd 100644 --- a/resources/views/livewire/web/components/sections/ecosystem-core.blade.php +++ b/resources/views/livewire/web/components/sections/ecosystem-core.blade.php @@ -1,5 +1,6 @@ {{-- Hero Icons Helper Function --}} @php + if (!function_exists('renderHeroIcon')) { function renderHeroIcon($iconName, $style = 'outline', $color = 'text-secondary') { $iconPath = public_path("heroicons/optimized/24/{$style}/{$iconName}.svg"); @@ -19,13 +20,14 @@ '; } + } @endphp
{{-- Section Title --}}
-

{{ $content['title'] }}

+

{!! $content['title'] !!}

@if (isset($content['subtitle']))

{{ $content['subtitle'] }} @@ -36,7 +38,11 @@ {{-- Pillars Grid --}}

@foreach ($content['pillars'] as $index => $pillar) + @if (isset($pillar['link'])) + + @else + @endif @endforeach
diff --git a/resources/views/livewire/web/components/sections/ecosystem-hero.blade.php b/resources/views/livewire/web/components/sections/ecosystem-hero.blade.php index 15a5297..faff668 100644 --- a/resources/views/livewire/web/components/sections/ecosystem-hero.blade.php +++ b/resources/views/livewire/web/components/sections/ecosystem-hero.blade.php @@ -51,7 +51,7 @@
- {{ $content['image_alt'] }}
diff --git a/resources/views/livewire/web/components/sections/end-customer-section.blade.php b/resources/views/livewire/web/components/sections/end-customer-section.blade.php index 30a540f..3b11f31 100644 --- a/resources/views/livewire/web/components/sections/end-customer-section.blade.php +++ b/resources/views/livewire/web/components/sections/end-customer-section.blade.php @@ -55,7 +55,7 @@
- {{ $content['image_alt'] }}
diff --git a/resources/views/livewire/web/components/sections/f-a-q.blade.php b/resources/views/livewire/web/components/sections/f-a-q.blade.php index f50c3fb..665b18a 100644 --- a/resources/views/livewire/web/components/sections/f-a-q.blade.php +++ b/resources/views/livewire/web/components/sections/f-a-q.blade.php @@ -1,11 +1,12 @@ +@php($faqCounter = 0)
{{-- Section Title --}}
-

+

{!! $content['title'] !!} -

+ @if(!empty($content['subtitle']))

{!! $content['subtitle'] !!} @@ -14,10 +15,12 @@

{{-- FAQ Container --}} -
+
+ {{-- Allgemeine Fragen (ungrouped) --}} @if(!empty($content['questions']))
- @foreach($content['questions'] as $index => $faq) + @foreach($content['questions'] as $faq) + @php($index = $faqCounter++)
- @else - {{-- Fallback wenn keine FAQ-Daten vorhanden --}} + @endif + + {{-- Gruppierte Sektionen --}} + @if(!empty($content['sections'])) + @foreach($content['sections'] as $section) +
+ {{-- Sektions-Header --}} +
+
+ @svg('heroicon-o-' . ($section['icon'] ?? 'question-mark-circle'), 'w-5 h-5') +
+

{{ $section['title'] }}

+
+
+ +
+ @foreach($section['questions'] as $faq) + @php($index = $faqCounter++) +
+
+ +
+
+
+

+ {{ $faq['answer'] }} +

+
+
+
+ @endforeach +
+
+ @endforeach + @endif + + @if(empty($content['questions']) && empty($content['sections']))

Keine FAQ-Inhalte verfügbar.

diff --git a/resources/views/livewire/web/components/sections/founder-bar.blade.php b/resources/views/livewire/web/components/sections/founder-bar.blade.php new file mode 100644 index 0000000..123ff86 --- /dev/null +++ b/resources/views/livewire/web/components/sections/founder-bar.blade.php @@ -0,0 +1,24 @@ +
+
+
+ @if(isset($content['image'])) +
+ +
+ @endif + +
+

+ {!! $content['statement'] ?? '' !!} +

+

+ {{ $content['name'] ?? '' }} · {{ $content['title'] ?? '' }} +

+
+
+
+
diff --git a/resources/views/livewire/web/components/sections/hero-image.blade.php b/resources/views/livewire/web/components/sections/hero-image.blade.php index 7ec222d..51f87ec 100644 --- a/resources/views/livewire/web/components/sections/hero-image.blade.php +++ b/resources/views/livewire/web/components/sections/hero-image.blade.php @@ -25,7 +25,7 @@ {{-- Full Width Hero Image --}}
- {{ $content['hero_image_alt'] ?? $content['tiles'][0]['alt'] ?? '' }}
diff --git a/resources/views/livewire/web/components/sections/hero-slider.blade.php b/resources/views/livewire/web/components/sections/hero-slider.blade.php index 81b418f..2065641 100644 --- a/resources/views/livewire/web/components/sections/hero-slider.blade.php +++ b/resources/views/livewire/web/components/sections/hero-slider.blade.php @@ -10,7 +10,7 @@ :class="currentSlide === {{ $index }} ? 'opacity-100' : 'opacity-0'" > {{ $slide['image_alt'] }} @@ -38,7 +38,7 @@ @click="setSlide({{ $index }})" class="w-3 h-3 rounded-full border-2 border-white/50 transition-all duration-300" :class="currentSlide === {{ $index }} ? 'bg-white border-white' : 'bg-transparent hover:border-white/80'" - aria-label="Slide {{ $index + 1 }} anzeigen" + aria-label="{{ __('ui.show_slide', ['number' => $index + 1]) }}" > @endforeach
@@ -50,7 +50,7 @@ + + @endif +
diff --git a/resources/views/livewire/web/components/sections/leadership-team.blade.php b/resources/views/livewire/web/components/sections/leadership-team.blade.php index 838cbf8..958f884 100644 --- a/resources/views/livewire/web/components/sections/leadership-team.blade.php +++ b/resources/views/livewire/web/components/sections/leadership-team.blade.php @@ -15,7 +15,7 @@
-
+
@foreach($content['timeline'] as $index => $card)
diff --git a/resources/views/livewire/web/components/sections/our-values.blade.php b/resources/views/livewire/web/components/sections/our-values.blade.php index 5d6ccc8..d0d059d 100644 --- a/resources/views/livewire/web/components/sections/our-values.blade.php +++ b/resources/views/livewire/web/components/sections/our-values.blade.php @@ -1,5 +1,6 @@ {{-- Hero Icons Helper Function --}} @php +if (!function_exists('renderHeroIcon')) { function renderHeroIcon($iconName, $style = 'outline') { $iconPath = public_path("heroicons/optimized/24/{$style}/{$iconName}.svg"); $fallbackPath = public_path("heroicons/optimized/24/outline/sparkles.svg"); @@ -18,8 +19,9 @@ function renderHeroIcon($iconName, $style = 'outline') { '; } +} @endphp -
+

{!! $content['title'] !!}

diff --git a/resources/views/livewire/web/components/sections/partner-benefits.blade.php b/resources/views/livewire/web/components/sections/partner-benefits.blade.php index 40b167c..ae60219 100644 --- a/resources/views/livewire/web/components/sections/partner-benefits.blade.php +++ b/resources/views/livewire/web/components/sections/partner-benefits.blade.php @@ -138,7 +138,7 @@
- {{ $content['supplier']['highlight']['alt'] }}
@@ -175,7 +175,7 @@
- {{ $selectedProject['title'] }} @@ -200,7 +200,7 @@
-

Ausstattung

+

{{ __('ui.portfolio.amenities') }}

@foreach($selectedProject['features'] as $feature) @@ -213,7 +213,7 @@
-

Projektdetails

+

{{ __('ui.portfolio.project_details') }}

@if(isset($selectedProject['location']) && $selectedProject['location'] != '') @@ -223,7 +223,7 @@
- Standort + {{ __('ui.portfolio.location') }}
{{ $selectedProject['location'] }}
@@ -235,7 +235,7 @@
- Preis + {{ __('ui.portfolio.price') }}
{{ $selectedProject['price'] }}
@@ -247,7 +247,7 @@
- Größe + {{ __('ui.portfolio.size') }}
{{ $selectedProject['size'] }}
@@ -256,7 +256,7 @@
diff --git a/resources/views/livewire/web/components/sections/vision-section.blade.php b/resources/views/livewire/web/components/sections/vision-section.blade.php index bd6a5ad..28b934a 100644 --- a/resources/views/livewire/web/components/sections/vision-section.blade.php +++ b/resources/views/livewire/web/components/sections/vision-section.blade.php @@ -6,7 +6,7 @@ {{-- Image --}}
- {{ $content['image_alt'] }}

- Über B2In + Über B2in

"Unsere Vision ist es, Unternehmen durch innovative Konnektivitätslösungen zu verbinden - und nachhaltiges Wachstum in der digitalen Welt zu ermöglichen. Bei B2In schaffen wir + und nachhaltiges Wachstum in der digitalen Welt zu ermöglichen. Bei B2in schaffen wir nicht nur Verbindungen – wir bauen Brücken in die Zukunft."
@@ -16,7 +16,7 @@

Marcel Scheibe

-

Gründer & CEO, B2In

+

Gründer & CEO, B2in

@@ -25,9 +25,8 @@
Marcel Scheibe, Gründer und CEO von B2In + alt="Marcel Scheibe, Gründer und CEO von B2in" + class="w-full h-96 lg:h-[500px] object-cover" />
2019
@@ -36,4 +35,4 @@
-
\ No newline at end of file +
diff --git a/resources/views/livewire/web/components/sections_bak/ecosystem-hero.blade_bak.php b/resources/views/livewire/web/components/sections_bak/ecosystem-hero.blade_bak.php index d61e8f5..25c4862 100644 --- a/resources/views/livewire/web/components/sections_bak/ecosystem-hero.blade_bak.php +++ b/resources/views/livewire/web/components/sections_bak/ecosystem-hero.blade_bak.php @@ -3,7 +3,7 @@

- B2In Ecosystem + B2in Ecosystem

@@ -14,39 +14,39 @@

@foreach ($this->features as $feature) -
-
- @if ($feature['icon'] === 'users') - - - - @elseif($feature['icon'] === 'building-2') - - - - @elseif($feature['icon'] === 'network') - - - - @else - - - - @endif -
-
-

{{ $feature['title'] }}

-

{{ $feature['description'] }}

-
+
+
+ @if ($feature['icon'] === 'users') + + + + @elseif($feature['icon'] === 'building-2') + + + + @elseif($feature['icon'] === 'network') + + + + @else + + + + @endif
+
+

{{ $feature['title'] }}

+

{{ $feature['description'] }}

+
+
@endforeach
@@ -64,7 +64,7 @@ d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.367 2.684 3 3 0 00-5.367-2.684z" />
-

B2In Portal

+

B2in Portal

Zentrale Plattform

diff --git a/resources/views/livewire/web/components/sections_bak/magazin-list.blade_bak.php b/resources/views/livewire/web/components/sections_bak/magazin-list.blade_bak.php index 2340ae8..1f4d104 100644 --- a/resources/views/livewire/web/components/sections_bak/magazin-list.blade_bak.php +++ b/resources/views/livewire/web/components/sections_bak/magazin-list.blade_bak.php @@ -2,7 +2,7 @@

- B2In Magazin + B2in Magazin

Entdecken Sie die neuesten Trends, Insights und Geschichten aus der Welt @@ -12,57 +12,56 @@

diff --git a/resources/views/livewire/web/components/sections_bak/our-story.blade_bak.php b/resources/views/livewire/web/components/sections_bak/our-story.blade_bak.php index f41326c..4625822 100644 --- a/resources/views/livewire/web/components/sections_bak/our-story.blade_bak.php +++ b/resources/views/livewire/web/components/sections_bak/our-story.blade_bak.php @@ -6,21 +6,21 @@
@foreach($this->timeline as $item) -
-
-
-
-

{{ $item['title'] }}

-

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

+
+
+
+

{{ $item['title'] }}

+

+ {{ $item['description'] }} +

+
@endforeach

Was als Vision begann, traditionelle Geschäftsprozesse zu revolutionieren, ist heute eine - bewährte Plattform für digitale Innovation. B2In schließt die Lücke zwischen + bewährte Plattform für digitale Innovation. B2in schließt die Lücke zwischen traditionellen Unternehmen und modernen, digitalen Lösungen durch maßgeschneiderte Konnektivitätsservices, die Effizienz steigern und nachhaltiges Wachstum fördern.

diff --git a/resources/views/livewire/web/components/sections_bak/partner-benefits.blade_bak.php b/resources/views/livewire/web/components/sections_bak/partner-benefits.blade_bak.php index 5161b76..74b63cb 100644 --- a/resources/views/livewire/web/components/sections_bak/partner-benefits.blade_bak.php +++ b/resources/views/livewire/web/components/sections_bak/partner-benefits.blade_bak.php @@ -5,7 +5,7 @@ Warum Partner werden?

- Entdecken Sie die Vorteile einer Partnerschaft mit B2In und + Entdecken Sie die Vorteile einer Partnerschaft mit B2in und wie Sie von unserem innovativen Ecosystem profitieren können.

@@ -29,41 +29,41 @@
@foreach ($this->brokerBenefits as $index => $benefit) -
-
-
- @if ($benefit['icon'] === 'trending-up') - - - - @elseif($benefit['icon'] === 'target') - - - - @else - - - - - @endif -
-
-

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

-

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

-
+
+
+
+ @if ($benefit['icon'] === 'trending-up') + + + + @elseif($benefit['icon'] === 'target') + + + + @else + + + + + @endif +
+
+

+ {{ $benefit['title'] }} +

+

+ {{ $benefit['description'] }} +

+
@endforeach
@@ -96,45 +96,45 @@
@foreach ($this->supplierBenefits as $index => $benefit) -
-
-
- @if ($benefit['icon'] === 'globe') - - - - - @elseif($benefit['icon'] === 'handshake') - - - - - @else - - - - - - @endif -
-
-

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

-

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

-
+
+
+
+ @if ($benefit['icon'] === 'globe') + + + + + @elseif($benefit['icon'] === 'handshake') + + + + + @else + + + + + + @endif +
+
+

+ {{ $benefit['title'] }} +

+

+ {{ $benefit['description'] }} +

+
@endforeach
diff --git a/resources/views/livewire/web/components/sections_bak/partner-cta.blade_bak.php b/resources/views/livewire/web/components/sections_bak/partner-cta.blade_bak.php index cd7eae4..00b0998 100644 --- a/resources/views/livewire/web/components/sections_bak/partner-cta.blade_bak.php +++ b/resources/views/livewire/web/components/sections_bak/partner-cta.blade_bak.php @@ -9,28 +9,28 @@

- Werden Sie Teil des B2In-Partnernetzwerks und erschließen Sie neue + Werden Sie Teil des B2in-Partnernetzwerks und erschließen Sie neue Geschäftsmöglichkeiten durch innovative Konnektivitätslösungen.

@foreach($this->stats as $stat) -
-
{{ $stat['number'] }}
-

{{ $stat['label'] }}

-
+
+
{{ $stat['number'] }}
+

{{ $stat['label'] }}

+
@endforeach
- Werden Sie B2In Partner + Werden Sie B2in Partner

- Entdecken Sie die Vorteile einer strategischen Partnerschaft mit B2In + Entdecken Sie die Vorteile einer strategischen Partnerschaft mit B2in

-
\ No newline at end of file +
diff --git a/resources/views/livewire/web/components/sections_bak/partner-hero.blade_bak.php b/resources/views/livewire/web/components/sections_bak/partner-hero.blade_bak.php index c46d6dc..9eab1e8 100644 --- a/resources/views/livewire/web/components/sections_bak/partner-hero.blade_bak.php +++ b/resources/views/livewire/web/components/sections_bak/partner-hero.blade_bak.php @@ -4,50 +4,50 @@

Wachsen Sie mit uns.
- Werden Sie B2In Partner. + Werden Sie B2in Partner.

- Werden Sie Teil des B2In Ecosystems und profitieren Sie von innovativen + Werden Sie Teil des B2in Ecosystems und profitieren Sie von innovativen Geschäftsmodellen, die nachhaltiges Wachstum und langfristigen Erfolg ermöglichen. Gemeinsam gestalten wir die Zukunft der Immobilienbranche.

@foreach ($this->partnerTypes as $partner) -
-
- @if ($partner['icon'] === 'trending-up') - - - - @elseif($partner['icon'] === 'globe') - - - - @elseif($partner['icon'] === 'handshake') - - - - @else - - - - @endif -
-
-

{{ $partner['title'] }}

-

{{ $partner['description'] }}

-
+
+
+ @if ($partner['icon'] === 'trending-up') + + + + @elseif($partner['icon'] === 'globe') + + + + @elseif($partner['icon'] === 'handshake') + + + + @else + + + + @endif
+
+

{{ $partner['title'] }}

+

{{ $partner['description'] }}

+
+
@endforeach
diff --git a/resources/views/livewire/web/components/sections_bak/partner-process.blade_bak.php b/resources/views/livewire/web/components/sections_bak/partner-process.blade_bak.php index 1cdbf79..61c8e50 100644 --- a/resources/views/livewire/web/components/sections_bak/partner-process.blade_bak.php +++ b/resources/views/livewire/web/components/sections_bak/partner-process.blade_bak.php @@ -5,70 +5,70 @@ So werden Sie Partner

- In nur drei einfachen Schritten werden Sie Teil des B2In Ecosystems + In nur drei einfachen Schritten werden Sie Teil des B2in Ecosystems und können von allen Vorteilen unserer Partnerschaft profitieren.

@foreach ($this->steps as $index => $step) -
-
- {{ $step['title'] }} -
-
- - {{-- Step Number Badge --}} -
- {{ $step['step'] }} -
+
+
+ {{ $step['title'] }} +
-
-
-
- @if ($step['icon'] === 'file-text') - - - - - @elseif($step['icon'] === 'search') - - - - @else - - - - @endif -
-

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

-
- -

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

- - @if ($index === count($steps) - 1) - - - - @endif + {{-- Step Number Badge --}} +
+ {{ $step['step'] }}
+ +
+
+
+ @if ($step['icon'] === 'file-text') + + + + + @elseif($step['icon'] === 'search') + + + + @else + + + + @endif +
+

+ {{ $step['title'] }} +

+
+ +

+ {{ $step['description'] }} +

+ + @if ($index === count($steps) - 1) + + + + @endif +
+
@endforeach
@@ -79,7 +79,7 @@ Bereit für den nächsten Schritt?

- Werden Sie noch heute Teil des B2In Ecosystems und profitieren Sie + Werden Sie noch heute Teil des B2in Ecosystems und profitieren Sie von innovativen Geschäftsmodellen und nachhaltigen Erfolgsstrategien.

diff --git a/resources/views/livewire/web/components/ui/announcement-bar.blade.php b/resources/views/livewire/web/components/ui/announcement-bar.blade.php new file mode 100644 index 0000000..6b71967 --- /dev/null +++ b/resources/views/livewire/web/components/ui/announcement-bar.blade.php @@ -0,0 +1,42 @@ +@if (!empty($content) && ($content['enabled'] ?? false)) + +@endif diff --git a/resources/views/livewire/web/components/ui/contact-form.blade.php b/resources/views/livewire/web/components/ui/contact-form.blade.php index 3aa0619..66aa434 100644 --- a/resources/views/livewire/web/components/ui/contact-form.blade.php +++ b/resources/views/livewire/web/components/ui/contact-form.blade.php @@ -4,7 +4,7 @@
-
+

{!! $content['hero']['title'] ?? 'Send us a
message.' !!}

@@ -14,18 +14,27 @@
-
- @if (session()->has('message')) -
- {{ session('message') }} +
+ @if ($success) +
+
+ + + +
+
+

{{ __('ui.contact_form.success_title') }}

+

+ {{ $content['form']['success_message'] ?? __('ui.contact_form.success_message') }} +

+
- @endif - + @else
@error('message') {{ $message }} @enderror
+ {{-- Honeypot --}} + + +
+ + @error('privacy') {{ $message }} @enderror +
+ +

{{ __('ui.required_fields') }}

+ @endif
@@ -172,7 +217,7 @@ @endif

{{ $info['title'] }}

-
+
@foreach($info['info'] as $line)

{{ $line }}

@endforeach @@ -184,7 +229,7 @@ -
+ {{--
@@ -212,4 +257,5 @@
+ --}}
diff --git a/resources/views/livewire/web/components/ui/footer.blade.php b/resources/views/livewire/web/components/ui/footer.blade.php index 764be1a..ec3c04f 100644 --- a/resources/views/livewire/web/components/ui/footer.blade.php +++ b/resources/views/livewire/web/components/ui/footer.blade.php @@ -5,14 +5,16 @@
{{ $domainName ?? 'B2In' }} Logo + alt="{{ $domainName ?? 'B2in' }} Logo" class="h-14 w-auto" + width="120" height="56" + loading="lazy" />

Connecting Design and Property -

+

Marcel Scheibe – {{ __('ui.founder_ceo') }}


@@ -21,21 +23,19 @@ {{-- Links --}}
@@ -43,25 +43,18 @@ {{-- Bottom Bar --}}
-
- © {{ date('Y') }} B2In. All rights reserved. +
+ © {{ date('Y') }} B2in. All rights reserved. + + {{ __('ui.cookie_settings') }} +
- - - - - - - - - - - - + + + diff --git a/resources/views/livewire/web/components/ui/header.blade.php b/resources/views/livewire/web/components/ui/header.blade.php index 82534eb..afaeb35 100644 --- a/resources/views/livewire/web/components/ui/header.blade.php +++ b/resources/views/livewire/web/components/ui/header.blade.php @@ -6,7 +6,7 @@ alt="{{ $domainName ?? 'B2IN' }} Logo" class="h-10 w-auto" /> -
- - {{ $content['portal_login'] ?? 'Portal Login' }} - + {{-- Language Switcher --}} + + + + @if (!$loop->last) + | + @endif + @endforeach +
+ + + {{ __('ui.contact') }}
diff --git a/resources/views/partials/head.blade.php b/resources/views/partials/head.blade.php index 2855ac3..c70fa75 100644 --- a/resources/views/partials/head.blade.php +++ b/resources/views/partials/head.blade.php @@ -4,17 +4,26 @@ {{ $title ?? config('app.name') }} - - - + + + + + + + + + + + + + + + + + + - - - - - - @vite(['resources/css/portal.css', 'resources/js/app.js'], 'build/portal') +@vite(['resources/css/portal.css', 'resources/js/app.js'], 'build/portal') @livewireStyles @fluxAppearance diff --git a/resources/views/web/about.blade.php b/resources/views/web/about.blade.php index 99edd7a..e877314 100644 --- a/resources/views/web/about.blade.php +++ b/resources/views/web/about.blade.php @@ -1,6 +1,7 @@ @extends('web.layouts.web-master') @section('title', 'Über B2IN - Unser Team & Geschichte') +@section('meta_description', 'Lernen Sie B2in kennen – gegründet von Marcel Scheibe. Unsere Vision: Design & Immobilien intelligent verbinden.') @section('content')
@@ -8,8 +9,9 @@
+ - +
diff --git a/resources/views/web/about_bak.blade.php b/resources/views/web/about_bak.blade.php new file mode 100644 index 0000000..8cb77f8 --- /dev/null +++ b/resources/views/web/about_bak.blade.php @@ -0,0 +1,32 @@ +@extends('web.layouts.web-master') + +@section('title', 'Über B2IN - Unser Team & Geschichte') + +@section('content') +
+ + +
+ + + + + + +
+ + +
+@endsection + +@push('styles') + +@endpush + +@push('scripts') + {{-- Alpine.js wird zentral im Layout geladen --}} +@endpush diff --git a/resources/views/web/b2in.blade.php b/resources/views/web/b2in.blade.php index 0b40a65..5d9f35b 100644 --- a/resources/views/web/b2in.blade.php +++ b/resources/views/web/b2in.blade.php @@ -9,11 +9,10 @@
- + + - - +
diff --git a/resources/views/web/contact.blade.php b/resources/views/web/contact.blade.php index bbd315f..dc65b99 100644 --- a/resources/views/web/contact.blade.php +++ b/resources/views/web/contact.blade.php @@ -1,6 +1,7 @@ @extends('web.layouts.web-master') -@section('title', 'Kontakt - B2In') +@section('title', 'Kontakt - B2in') +@section('meta_description', 'Nehmen Sie Kontakt mit B2in auf – persönliche Beratung zu Immobilien in Dubai & Einrichtungslösungen.') @section('content')
diff --git a/resources/views/web/cookie-policy.blade.php b/resources/views/web/cookie-policy.blade.php new file mode 100644 index 0000000..6a8563c --- /dev/null +++ b/resources/views/web/cookie-policy.blade.php @@ -0,0 +1,50 @@ +@extends('web.layouts.web-master') + +@php + $legal = legal_page('cookie_policy'); +@endphp + +@section('title', $legal['meta_title'] . ' - ' . ($domainName ?? config('app.name'))) + +@section('content') +
+ + +
+
+
+
+

{{ $legal['title'] }}

+

+ {{ $legal['subtitle'] }} +

+ + + + {{-- Cookie-Einstellungen & aktuelle Status-Anzeige --}} + + + +
+
+
+
+ + +
+@endsection + +@push('styles') + +@endpush diff --git a/resources/views/web/dev/immobilien-v1.blade.php b/resources/views/web/dev/immobilien-v1.blade.php new file mode 100644 index 0000000..0f3a80f --- /dev/null +++ b/resources/views/web/dev/immobilien-v1.blade.php @@ -0,0 +1,134 @@ +@extends('web.layouts.web-master') + +@section('title', 'Internationale Immobilien - B2in') + +@section('content') +
+ + +
+ + + + {{-- Aktuelle Projekte --}} + @php + $projects = cms_theme_section('immobilien_projects'); + $moebelVorteil = cms_theme_section('immobilien_moebel_vorteil'); + @endphp + + @if (!empty($projects)) +
+
+
+

{!! $projects['title'] ?? '' !!}

+ @if (isset($projects['subtitle'])) +

+ {{ $projects['subtitle'] }} +

+ @endif +
+ +
+ @foreach ($projects['projects'] ?? [] as $project) +
+ @if (isset($project['image'])) +
+ {{ $project['title'] }} + @if (isset($project['status'])) +
+ + {{ $project['status'] }} + @if (isset($project['launch_date'])) + ({{ $project['launch_date'] }}) + @endif + +
+ @endif +
+ @endif + +
+
+

{{ $project['title'] }}

+ @if (isset($project['location'])) +

{{ $project['location'] }}

+ @endif +
+ + @if (isset($project['price_from'])) +

{{ $project['price_from'] }}

+ @endif + + @if (isset($project['highlights'])) +
    + @foreach ($project['highlights'] as $highlight) +
  • + + + + {{ $highlight }} +
  • + @endforeach +
+ @endif + + + {{ isset($project['slug']) ? 'Exposé ansehen' : ($projects['cta_text'] ?? 'Anfragen') }} + + + + +
+
+ @endforeach +
+
+
+ @endif + + {{-- Möbel-Vorteil Banner --}} + @if (!empty($moebelVorteil)) +
+
+
+

{!! $moebelVorteil['title'] ?? '' !!}

+

+ {{ $moebelVorteil['text'] ?? '' }} +

+ @if (isset($moebelVorteil['button_text'])) + + @endif +
+
+
+ @endif + + {{-- Trust & Kontakt --}} + + + +
+ + +
+@endsection + +@push('styles') + +@endpush + +@push('scripts') + {{-- Alpine.js wird zentral im Layout geladen --}} +@endpush diff --git a/resources/views/web/dev/interior-v1.blade.php b/resources/views/web/dev/interior-v1.blade.php new file mode 100644 index 0000000..b751d8d --- /dev/null +++ b/resources/views/web/dev/interior-v1.blade.php @@ -0,0 +1,160 @@ +@extends('web.layouts.web-master') + +@section('title', 'Einrichtungsnetzwerk - B2in Interior') + +@section('content') +
+ + +
+ + + + {{-- Local-for-Local Konzept --}} + + + {{-- Zwei Marken --}} + @php + $brands = cms_theme_section('interior_brands'); + $zielgruppen = cms_theme_section('interior_zielgruppen'); + $process = cms_theme_section('interior_process'); + @endphp + + @if (!empty($brands)) +
+
+
+

{!! $brands['title'] ?? '' !!}

+ @if (isset($brands['subtitle'])) +

+ {{ $brands['subtitle'] }} +

+ @endif +
+ +
+ @foreach ($brands['brands'] ?? [] as $brand) +
+
+ @if (isset($brand['logo'])) +
+ {{ $brand['name'] }} +
+ @endif +

+ {{ $brand['tagline'] ?? '' }} +

+

+ {{ $brand['description'] ?? '' }} +

+ @if (isset($brand['audience'])) +

+ {{ $brand['audience'] }} +

+ @endif + @if (isset($brand['link'])) + + @endif +
+
+ @endforeach +
+
+
+ @endif + + {{-- Für wen? Zielgruppen --}} + @if (!empty($zielgruppen)) +
+
+
+

{!! $zielgruppen['title'] ?? '' !!}

+
+ +
+ @foreach ($zielgruppen['groups'] ?? [] as $group) +
+
+ + @switch($group['icon'] ?? '') + @case('home') + + @break + @case('building-office-2') + + @break + @case('clipboard-document-check') + + @break + @endswitch + +
+

{{ $group['title'] ?? '' }}

+

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

+
+ @endforeach +
+
+
+ @endif + + {{-- So funktioniert es - Prozess --}} + @if (!empty($process)) +
+
+
+

{!! $process['title'] ?? '' !!}

+
+ +
+
+ @foreach ($process['steps'] ?? [] as $step) +
+
+ {{ $step['number'] ?? '' }} +
+
+

{{ $step['title'] ?? '' }}

+

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

+
+
+ @endforeach +
+
+
+
+ @endif + + {{-- Trust / Marcel Scheibe --}} + + + {{-- CTA --}} + +
+ + +
+@endsection + +@push('styles') + +@endpush + +@push('scripts') + {{-- Alpine.js wird zentral im Layout geladen --}} +@endpush diff --git a/resources/views/web/dev/partner-v1.blade.php b/resources/views/web/dev/partner-v1.blade.php new file mode 100644 index 0000000..8b4e6ea --- /dev/null +++ b/resources/views/web/dev/partner-v1.blade.php @@ -0,0 +1,38 @@ +@extends('web.layouts.web-master') + +@section('title', 'Für Entwickler & Partner - B2in') + +@section('content') +
+ + +
+ + + + + + + + + + + + +
+ + +
+@endsection + +@push('styles') + +@endpush + +@push('scripts') + {{-- Alpine.js wird zentral im Layout geladen --}} +@endpush diff --git a/resources/views/web/dev/sitemap.blade.php b/resources/views/web/dev/sitemap.blade.php new file mode 100644 index 0000000..6860f28 --- /dev/null +++ b/resources/views/web/dev/sitemap.blade.php @@ -0,0 +1,120 @@ +@extends('web.layouts.web-master') + +@section('title', 'Dev Sitemap - B2in') + +@section('content') +
+ + +
+
+
+ +
+

Dev Sitemap

+

Übersicht aller Seiten – inklusive Archiv-Versionen und Entwicklungsseiten.

+
+ + {{-- Live-Seiten --}} +
+

+ + + + Live-Seiten +

+
+ @php + $livePages = [ + ['url' => '/', 'label' => 'Home', 'description' => 'Startseite B2in'], + ['url' => '/immobilien', 'label' => 'Immobilien', 'description' => 'Dubai Investments – Hauptseite'], + ['url' => '/immobilien/azizi-creek-views-4', 'label' => 'Azizi Creek Views 4', 'description' => 'Projekt-Exposé'], + ['url' => '/netzwerk', 'label' => 'Netzwerk', 'description' => 'B2in Ökosystem – Teaser (Soft Launch)'], + ['url' => '/magazin', 'label' => 'Magazin', 'description' => 'Artikel & Insights'], + ['url' => '/about', 'label' => 'Über B2in', 'description' => 'Über uns & Team'], + ['url' => '/contact', 'label' => 'Kontakt', 'description' => 'Kontaktformular'], + ['url' => '/faq', 'label' => 'FAQ', 'description' => 'Häufige Fragen'], + ]; + @endphp + @foreach ($livePages as $page) + +
+ {{ $page['label'] }} + {{ $page['description'] }} +
+ {{ $page['url'] }} +
+ @endforeach +
+
+ + {{-- Archiv-Seiten --}} +
+

+ + + + Archiv (Pre-Soft-Launch Versionen) +

+
+ @php + $archivePages = [ + ['url' => '/dev/immobilien-v1', 'label' => 'Immobilien (v1)', 'description' => 'Originalversion vor Soft Launch'], + ['url' => '/dev/interior-v1', 'label' => 'Interior / Einrichtungsnetzwerk (v1)', 'description' => 'Originalversion vor Soft Launch'], + ['url' => '/dev/partner-v1', 'label' => 'Für Entwickler & Partner (v1)', 'description' => 'Originalversion vor Soft Launch'], + ]; + @endphp + @foreach ($archivePages as $page) + +
+ {{ $page['label'] }} + {{ $page['description'] }} +
+ {{ $page['url'] }} +
+ @endforeach +
+
+ + {{-- Dev-Tools --}} +
+

+ + + + Dev-Tools +

+
+ @php + $devPages = [ + ['url' => '/theme-demo', 'label' => 'Theme Demo', 'description' => 'Farben, Logos, Buttons aller Themes'], + ['url' => '/dev/sitemap', 'label' => 'Dev Sitemap', 'description' => 'Diese Seite'], + ]; + @endphp + @foreach ($devPages as $page) + +
+ {{ $page['label'] }} + {{ $page['description'] }} +
+ {{ $page['url'] }} +
+ @endforeach +
+
+ +
+
+
+ + +
+@endsection + +@push('styles') + +@endpush diff --git a/resources/views/web/ecosystem.blade.php b/resources/views/web/ecosystem.blade.php index c6a7a48..4f6a407 100644 --- a/resources/views/web/ecosystem.blade.php +++ b/resources/views/web/ecosystem.blade.php @@ -1,6 +1,6 @@ @extends('web.layouts.web-master') -@section('title', 'B2In Ecosystem - Intelligentes Netzwerk') +@section('title', 'B2in Ecosystem - Intelligentes Netzwerk') @section('content')
diff --git a/resources/views/web/faq.blade.php b/resources/views/web/faq.blade.php index f8b618f..7908a46 100644 --- a/resources/views/web/faq.blade.php +++ b/resources/views/web/faq.blade.php @@ -1,6 +1,7 @@ @extends('web.layouts.web-master') -@section('title', 'Stileigentum - Premium Immobilien & Zeitlose Eleganz') +@section('title', 'FAQ - B2in') +@section('meta_description', 'Häufig gestellte Fragen zu B2in – Immobilien in Dubai, Einrichtungsservice und mehr.') @section('content')
@@ -21,7 +22,3 @@ } @endpush - -@push('scripts') -{{-- Alpine.js wird zentral im Layout geladen --}} -@endpush diff --git a/resources/views/web/home.blade.php b/resources/views/web/home.blade.php index 6ab1811..a447d8d 100644 --- a/resources/views/web/home.blade.php +++ b/resources/views/web/home.blade.php @@ -1,6 +1,7 @@ @extends('web.layouts.web-master') @section('title', 'B2IN - Connecting Design and Property') +@section('meta_description', 'B2in verbindet exklusive Immobilien in Dubai mit europäischem Einrichtungsdesign. Ihr Partner für Investment & Interior.') @section('content')
@@ -8,12 +9,10 @@
- - - - - + + + +
diff --git a/resources/views/web/immobilien-show.blade.php b/resources/views/web/immobilien-show.blade.php new file mode 100644 index 0000000..c376b71 --- /dev/null +++ b/resources/views/web/immobilien-show.blade.php @@ -0,0 +1,363 @@ +@extends('web.layouts.web-master') + +@section('title', ($project['title'] ?? 'Exposé') . ' - B2in Immobilien') +@section('meta_description', ($project['investment_case']['text'] ?? 'Exklusives Off-Market-Immobilienprojekt in Dubai – jetzt Exposé ansehen.')) + +@section('content') +
+ + +
+ + {{-- 1. Hero --}} +
+ +
+
+
+ @if (isset($project['status'])) + + {{ $project['status'] }} + @if (isset($project['launch_date'])) + ({{ $project['launch_date'] }}) + @endif + + @endif +

{{ $project['title'] ?? '' }}

+ @if (isset($project['location'])) +

{{ $project['location'] }}

+ @endif + @if (isset($project['price_from'])) +

{{ $project['price_from'] }}

+ @endif +
+
+
+ + {{-- 2. Quick Facts --}} + {{-- Editierbar über CMS je Projekt --}} + + @if (!empty($project['quick_facts'])) +
+
+
+ @foreach ($project['quick_facts'] as $fact) +
+
+ @if (isset($fact['icon'])) + + @switch($fact['icon']) + @case('home-modern') + + @break + @case('squares-2x2') + + @break + @case('building-office-2') + + @break + @case('user') + + @break + @endswitch + + @endif +
+

{{ $fact['label'] ?? '' }}

+

{{ $fact['value'] ?? '' }}

+
+ @endforeach +
+
+
+ @endif + + {{-- 3. Investment Case --}} + + @if (!empty($project['investment_case'])) +
+
+
+

{{ $project['investment_case']['title'] ?? '' }}

+

+ {{ $project['investment_case']['text'] ?? '' }} +

+ + @if (!empty($project['investment_case']['views'])) +
+

Verfügbare Views:

+
+ @foreach ($project['investment_case']['views'] as $view) + + {{ $view }} + + @endforeach +
+
+ @endif + + @if (!empty($project['highlights'])) +
    + @foreach ($project['highlights'] as $highlight) +
  • + + + + {{ $highlight }} +
  • + @endforeach +
+ @endif +
+
+
+ @endif + + {{-- 4. Bildergalerie --}} + + @if (!empty($project['gallery'])) +
+
+
+

Galerie

+ +
+ @foreach ($project['gallery'] as $index => $image) +
+ +
+
+ @endforeach +
+
+
+ + {{-- Lightbox (via x-teleport ins body, umgeht alle Eltern-Constraints) --}} + +
+ @endif + + {{-- 5. Trust-Block: Investorenschutz (pro Projekt im CMS, siehe investor_trust) --}} + @php + $trust = $project['investor_trust'] ?? []; + $trustColumns = $trust['columns'] ?? []; + @endphp + @if (! empty($trust['title']) || count($trustColumns) > 0) +
+
+
+ @if (! empty($trust['title']) || ! empty($trust['intro'])) +
+ @if (! empty($trust['title'])) +

{{ $trust['title'] }}

+ @endif + @if (! empty($trust['intro'])) +

+ {{ $trust['intro'] }} +

+ @endif +
+ @endif + + @if (count($trustColumns) > 0) +
+ @foreach ($trustColumns as $col) +
+
+ @if (! empty($col['icon'])) + @svg($col['icon'], 'w-7 h-7') + @endif +
+

{{ $col['title'] ?? '' }}

+

+ {{ $col['text'] ?? '' }} +

+
+ @endforeach +
+ @endif + + @if (! empty($trust['cta_url']) && ! empty($trust['cta_label'])) + + @endif +
+
+
+ @endif + + {{-- 6. Location & Map --}} + @if (!empty($project['location_info'])) +
+
+
+

{{ $project['location_info']['title'] ?? '' }}

+ + @if (!empty($project['location_info']['points'])) +
    + @foreach ($project['location_info']['points'] as $point) +
  • + + + + + {{ $point }} +
  • + @endforeach +
+ @endif + + @if (isset($project['location_info']['map_url'])) + + @endif +
+
+
+ @endif + + {{-- Möbel-Vorteil / Synergie (pro Projekt: furniture_benefit; Fallback: Theme-Config) --}} + @php + $moebelVorteil = $project['furniture_benefit'] ?? []; + if (empty($moebelVorteil) || (empty($moebelVorteil['title'] ?? null) && empty($moebelVorteil['text'] ?? null))) { + $moebelVorteil = cms_theme_section('immobilien_moebel_vorteil'); + } + @endphp + @if (! empty($moebelVorteil)) +
+
+
+

{!! $moebelVorteil['title'] ?? '' !!}

+

+ {{ $moebelVorteil['text'] ?? '' }} +

+ @if (! empty($moebelVorteil['button_text'] ?? null)) + + @endif +
+
+
+ @endif + + {{-- 7. Lead-Generierung / Kontakt --}} + @if (!empty($project['contact'])) +
+
+
+

{{ $project['contact']['title'] ?? '' }}

+ @if (isset($project['contact']['subtitle'])) +

{{ $project['contact']['subtitle'] }}

+ @endif + +
+ +
+
+
+
+ @endif + +
+ + +
+@endsection + +@push('styles') + +@endpush + diff --git a/resources/views/web/immobilien.blade.php b/resources/views/web/immobilien.blade.php new file mode 100644 index 0000000..f3d10e3 --- /dev/null +++ b/resources/views/web/immobilien.blade.php @@ -0,0 +1,314 @@ +@extends('web.layouts.web-master') + +@section('title', 'Immobilien Dubai - B2in') +@section('meta_description', 'Exklusive Off-Market-Immobilien in Dubai. Persönliche Beratung, steuerfreie Renditen & Turnkey-Einrichtung aus einer Hand.') + +@section('content') +
+ + +
+ @php + $heroV2 = cms_theme_section('immobilien_hero_v2'); + $warumDubai = cms_theme_section('immobilien_warum_dubai'); + $kaufprozess = cms_theme_section('immobilien_kaufprozess'); + $bruecke = cms_theme_section('immobilien_bruecke'); + $mindset = cms_theme_section('immobilien_mindset'); + $projects = cms_theme_section('immobilien_projects'); + $moebelVorteil = cms_theme_section('immobilien_moebel_vorteil'); + @endphp + + {{-- Sektion 1: Hero --}} + @if (!empty($heroV2)) +
+ +
+
+
+

+ {!! $heroV2['title'] !!} +

+

+ {{ $heroV2['subtitle'] ?? '' }} +

+ @if (isset($heroV2['cta_text'])) + + @endif +
+
+
+ @endif + + + + {{-- Sektion 2: Warum Dubai? --}} + @if (!empty($warumDubai)) +
+
+
+

{!! $warumDubai['title'] !!}

+ @if (isset($warumDubai['intro'])) +

+ {{ $warumDubai['intro'] }} +

+ @endif +
+ +
+ @foreach ($warumDubai['facts'] ?? [] as $index => $fact) +
+
+ @svg('heroicon-o-' . ($fact['icon'] ?? 'check'), 'w-7 h-7') +
+

{{ $fact['title'] ?? '' }}

+

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

+
+ @endforeach +
+
+
+ @endif + + + + {{-- Sektion 3: Kaufprozess --}} + @if (!empty($kaufprozess)) +
+
+
+

{!! $kaufprozess['title'] !!}

+ @if (isset($kaufprozess['intro'])) +

+ {{ $kaufprozess['intro'] }} +

+ @endif +
+ +
+
+ @foreach ($kaufprozess['steps'] ?? [] as $index => $step) +
+
+
+ {{ $step['number'] ?? '' }} +
+
+

{{ $step['title'] ?? '' }}

+

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

+
+
+
+ @endforeach +
+
+
+
+ @endif + + {{-- Sektion 4: Die Brücke – Marcels Pitch --}} + @if (!empty($bruecke)) +
+
+
+
+
+ + @if (isset($bruecke['image_caption'])) +
+ {{ $bruecke['image_caption'] }} +
+ @endif +
+
+ +
+
+

{!! $bruecke['title'] !!}

+
+ @foreach ($bruecke['paragraphs'] ?? [] as $paragraph) +

{!! $paragraph !!}

+ @endforeach +
+ + @if (isset($bruecke['advantage_title'])) +
+

{{ $bruecke['advantage_title'] }}

+

{{ $bruecke['advantage_text'] ?? '' }}

+
+ @endif + + @if (isset($bruecke['cta_text'])) + + @endif +
+
+
+
+
+ @endif + + {{-- Aktuelle Projekte --}} + @if (!empty($projects)) +
+
+
+

{!! $projects['title'] ?? '' !!}

+ @if (isset($projects['subtitle'])) +

+ {{ $projects['subtitle'] }} +

+ @endif +
+ + @php + $projectCount = count($projects['projects'] ?? []); + @endphp +
+ @foreach ($projects['projects'] ?? [] as $project) +
+ @if (isset($project['image'])) +
+ + @if (isset($project['status'])) +
+ + {{ $project['status'] }} + @if (isset($project['launch_date'])) + ({{ $project['launch_date'] }}) + @endif + +
+ @endif +
+ @endif + +
+
+

{{ $project['title'] }}

+ @if (isset($project['location'])) +

{{ $project['location'] }}

+ @endif +
+ + @if (isset($project['price_from'])) +

{{ $project['price_from'] }}

+ @endif + + @if (isset($project['highlights'])) +
    + @foreach ($project['highlights'] as $highlight) +
  • + + + + {{ $highlight }} +
  • + @endforeach +
+ @endif + + + {{ isset($project['slug']) ? 'Exposé ansehen' : ($projects['cta_text'] ?? 'Anfragen') }} + + + + +
+
+ @endforeach +
+
+
+ @endif + + {{-- Sektion 5: Mindset-Check --}} + @if (!empty($mindset)) +
+
+
+
+

{!! $mindset['title'] !!}

+ +
+ @if (isset($mindset['text_positive'])) +

{!! $mindset['text_positive'] !!}

+ @endif + @if (isset($mindset['text_negative'])) +

{{ $mindset['text_negative'] }}

+ @endif + @if (isset($mindset['closing'])) +

{!! $mindset['closing'] !!}

+ @endif +
+ + @if (isset($mindset['cta_text'])) + + @endif +
+
+
+
+ @endif + + {{-- Möbel-Vorteil Banner (Teaser) --}} + @if (!empty($moebelVorteil)) +
+
+
+

{!! $moebelVorteil['title'] ?? '' !!}

+

+ {{ $moebelVorteil['text'] ?? '' }} +

+ @if (isset($moebelVorteil['button_text'])) + + @endif +
+
+
+ @endif + + +
+ + +
+@endsection + +@push('styles') + +@endpush diff --git a/resources/views/web/impressum.blade.php b/resources/views/web/impressum.blade.php new file mode 100644 index 0000000..3be229f --- /dev/null +++ b/resources/views/web/impressum.blade.php @@ -0,0 +1,45 @@ +@extends('web.layouts.web-master') + +@php + $legal = legal_page('impressum'); +@endphp + +@section('title', $legal['meta_title'] . ' - ' . ($domainName ?? config('app.name'))) + +@section('content') +
+ + +
+
+
+
+

{{ $legal['title'] }}

+

+ {{ $legal['subtitle'] }} +

+ + + + +
+
+
+
+ + +
+@endsection + +@push('styles') + +@endpush diff --git a/resources/views/web/interior.blade.php b/resources/views/web/interior.blade.php new file mode 100644 index 0000000..b751d8d --- /dev/null +++ b/resources/views/web/interior.blade.php @@ -0,0 +1,160 @@ +@extends('web.layouts.web-master') + +@section('title', 'Einrichtungsnetzwerk - B2in Interior') + +@section('content') +
+ + +
+ + + + {{-- Local-for-Local Konzept --}} + + + {{-- Zwei Marken --}} + @php + $brands = cms_theme_section('interior_brands'); + $zielgruppen = cms_theme_section('interior_zielgruppen'); + $process = cms_theme_section('interior_process'); + @endphp + + @if (!empty($brands)) +
+
+
+

{!! $brands['title'] ?? '' !!}

+ @if (isset($brands['subtitle'])) +

+ {{ $brands['subtitle'] }} +

+ @endif +
+ +
+ @foreach ($brands['brands'] ?? [] as $brand) +
+
+ @if (isset($brand['logo'])) +
+ {{ $brand['name'] }} +
+ @endif +

+ {{ $brand['tagline'] ?? '' }} +

+

+ {{ $brand['description'] ?? '' }} +

+ @if (isset($brand['audience'])) +

+ {{ $brand['audience'] }} +

+ @endif + @if (isset($brand['link'])) + + @endif +
+
+ @endforeach +
+
+
+ @endif + + {{-- Für wen? Zielgruppen --}} + @if (!empty($zielgruppen)) +
+
+
+

{!! $zielgruppen['title'] ?? '' !!}

+
+ +
+ @foreach ($zielgruppen['groups'] ?? [] as $group) +
+
+ + @switch($group['icon'] ?? '') + @case('home') + + @break + @case('building-office-2') + + @break + @case('clipboard-document-check') + + @break + @endswitch + +
+

{{ $group['title'] ?? '' }}

+

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

+
+ @endforeach +
+
+
+ @endif + + {{-- So funktioniert es - Prozess --}} + @if (!empty($process)) +
+
+
+

{!! $process['title'] ?? '' !!}

+
+ +
+
+ @foreach ($process['steps'] ?? [] as $step) +
+
+ {{ $step['number'] ?? '' }} +
+
+

{{ $step['title'] ?? '' }}

+

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

+
+
+ @endforeach +
+
+
+
+ @endif + + {{-- Trust / Marcel Scheibe --}} + + + {{-- CTA --}} + +
+ + +
+@endsection + +@push('styles') + +@endpush + +@push('scripts') + {{-- Alpine.js wird zentral im Layout geladen --}} +@endpush diff --git a/resources/views/web/layouts/web-master-slot.blade.php b/resources/views/web/layouts/web-master-slot.blade.php index fbfb03e..56cd3b5 100644 --- a/resources/views/web/layouts/web-master-slot.blade.php +++ b/resources/views/web/layouts/web-master-slot.blade.php @@ -7,15 +7,27 @@ {{ $title ?? ($domainName ?? config('app.name', 'Laravel')) }} - - - - - + + + + + + + + + + + + + + + + + + + @php - $primaryFont = \App\Helpers\ThemeHelper::getPrimaryFont(); - $secondaryFont = \App\Helpers\ThemeHelper::getSecondaryFont(); $theme = config('app.theme', 'b2in'); @endphp @@ -59,21 +71,6 @@ @stack('styles') - - @if ($primaryFont === 'Inter' && $secondaryFont === 'IBM Plex Sans') - - @elseif($primaryFont === 'Inter' && $secondaryFont === 'Merriweather') - - @elseif($primaryFont === 'Inter' && $secondaryFont === 'Ephesis') - - @elseif($primaryFont === 'Inter' && $secondaryFont === 'EB Garamond') - - @else - - @endif diff --git a/resources/views/web/layouts/web-master.blade.php b/resources/views/web/layouts/web-master.blade.php index a25158e..22f36cc 100644 --- a/resources/views/web/layouts/web-master.blade.php +++ b/resources/views/web/layouts/web-master.blade.php @@ -8,15 +8,42 @@ @yield('title', $domainName ?? config('app.name', 'Laravel')) - - + + @hasSection('meta_image') + + @else + + @endif + + + + + + - - + @stack('meta') + + + + + + + + + + + + + + + + + + + + @php - $primaryFont = \App\Helpers\ThemeHelper::getPrimaryFont(); - $secondaryFont = \App\Helpers\ThemeHelper::getSecondaryFont(); $theme = config('app.theme', 'b2in'); @endphp @@ -35,63 +62,59 @@ let topbarHeight = topbar.offsetHeight; let isHeaderSticky = false; + let ticking = false; function updateHeaderPosition() { const scrollTop = window.pageYOffset || document.documentElement.scrollTop; if (scrollTop >= topbarHeight && !isHeaderSticky) { - // TopBar ist nicht mehr sichtbar - Header wird sticky header.classList.remove('header-normal'); header.classList.add('header-sticky'); isHeaderSticky = true; } else if (scrollTop < topbarHeight && isHeaderSticky) { - // TopBar ist wieder sichtbar - Header wird normal header.classList.remove('header-sticky'); header.classList.add('header-normal'); isHeaderSticky = false; } + ticking = false; } - // Initial check updateHeaderPosition(); - // Listen for scroll events - window.addEventListener('scroll', updateHeaderPosition); + window.addEventListener('scroll', function() { + if (!ticking) { + requestAnimationFrame(updateHeaderPosition); + ticking = true; + } + }, { passive: true }); - // Listen for resize events (in case topbar height changes) window.addEventListener('resize', function() { topbarHeight = topbar.offsetHeight; updateHeaderPosition(); - }); + }, { passive: true }); }); @stack('styles') - - - @if ($primaryFont === 'Inter' && $secondaryFont === 'IBM Plex Sans') - - @elseif($primaryFont === 'Inter' && $secondaryFont === 'Merriweather') - - @elseif($primaryFont === 'Inter' && $secondaryFont === 'Ephesis') - - @elseif($primaryFont === 'Inter' && $secondaryFont === 'EB Garamond') - - @else - - @endif - - + {{-- GTM noscript (nur bei GTM-Nutzung) --}} + + + + + + {{-- TopBar (Backup: Sprachwechsel & Social Icons – folgt später) --}} + {{-- --}} + @yield('content') + {{-- Cookie Consent Manager (vor Livewire-Scripts) --}} + + @stack('scripts') diff --git a/resources/views/web/magazin-detail.blade.php b/resources/views/web/magazin-detail.blade.php index 2308f4d..a61f731 100644 --- a/resources/views/web/magazin-detail.blade.php +++ b/resources/views/web/magazin-detail.blade.php @@ -1,6 +1,6 @@ @extends('web.layouts.web-master') -@section('title', 'Magazin Artikel - B2In') +@section('title', 'Magazin Artikel - B2in') @section('content')
diff --git a/resources/views/web/magazin.blade.php b/resources/views/web/magazin.blade.php index a971701..f9571b3 100644 --- a/resources/views/web/magazin.blade.php +++ b/resources/views/web/magazin.blade.php @@ -1,6 +1,7 @@ @extends('web.layouts.web-master') -@section('title', 'B2In Magazin - Insights & Trends') +@section('title', 'B2in Magazin - Insights & Trends') +@section('meta_description', 'Insights rund um Immobilien-Investments in Dubai, Einrichtungstrends & Supply-Chain-Strategien im B2in Magazin.') @section('content')
@@ -19,11 +20,5 @@ [x-cloak] { display: none !important; } - - @endpush - -@push('scripts') - {{-- Alpine.js wird zentral im Layout geladen --}} -@endpush diff --git a/resources/views/web/netzwerk.blade.php b/resources/views/web/netzwerk.blade.php new file mode 100644 index 0000000..1dec308 --- /dev/null +++ b/resources/views/web/netzwerk.blade.php @@ -0,0 +1,183 @@ +@extends('web.layouts.web-master') + +@section('title', 'Netzwerk & Ökosystem - B2in') +@section('meta_description', 'Das B2in-Netzwerk: Europäisches Einrichtungsnetzwerk, Fachhändler-Partnerschaften & Marken-Kooperationen – in Entwicklung.') + +@section('content') +
+ + +
+ @php + $hero = cms_theme_section('netzwerk_hero'); + $teasers = cms_theme_section('netzwerk_teasers'); + $cta = cms_theme_section('netzwerk_cta'); + $cabinet = cms_theme_section('netzwerk_cabinet_partner'); + $immobilienHint = cms_theme_section('netzwerk_immobilien_hint'); + @endphp + + {{-- Hero --}} + @if (!empty($hero)) +
+
+
+
+
+

+ {!! $hero['title'] !!} +

+

+ {{ $hero['subtitle'] ?? '' }} +

+
+
+ +
+
+ +
+
+
+
+
+
+ @endif + + + + {{-- Teaser-Kacheln --}} + @if (!empty($teasers)) +
+
+
+

{!! $teasers['title'] !!}

+
+ +
+ @foreach ($teasers['cards'] ?? [] as $index => $card) +
+
+ @svg('heroicon-o-' . ($card['icon'] ?? 'squares-2x2'), 'w-8 h-8') +
+

{{ $card['title'] ?? '' }}

+

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

+ @if (isset($card['status'])) + + + {{ $card['status'] }} + + @endif +
+ @endforeach +
+
+
+ @endif + + + {{-- CABINET Premiumpartner --}} + @if (! empty($cabinet)) +
+
+
+
+
+ @if (! empty($cabinet['badge'] ?? null)) +
+ + {{ $cabinet['badge'] }} +
+ @endif + @if (! empty($cabinet['title'] ?? null)) +

+ {!! $cabinet['title'] !!} +

+ @endif + @if (! empty($cabinet['lead'] ?? null)) +

{{ $cabinet['lead'] }}

+ @endif + @foreach ($cabinet['paragraphs'] ?? [] as $paragraph) +

{{ $paragraph }}

+ @endforeach +
+
+
+ {{ $cabinet['image_alt'] ?? 'CABINET' }} +
+
+
+
+
+
+ @endif + + {{-- Immobilien-Vorteil Hinweis --}} + @if (! empty($immobilienHint)) +
+
+
+
+ @svg('heroicon-o-building-office-2', 'w-7 h-7') +
+ @if (! empty($immobilienHint['title'] ?? null)) +

{!! $immobilienHint['title'] !!}

+ @endif + @if (! empty($immobilienHint['description'] ?? null)) +

+ {{ $immobilienHint['description'] }} +

+ @endif + @if (! empty($immobilienHint['button_text'] ?? null)) + + @endif +
+
+
+ @endif + + {{-- CTA --}} + @if (!empty($cta)) +
+
+
+

{!! $cta['title'] !!}

+

+ {{ $cta['text'] ?? '' }} +

+ @if (isset($cta['button_text'])) + + @endif +
+
+
+ @endif +
+ + +
+@endsection + +@push('styles') + +@endpush diff --git a/resources/views/web/partner.blade.php b/resources/views/web/partner.blade.php index 8338363..8b4e6ea 100644 --- a/resources/views/web/partner.blade.php +++ b/resources/views/web/partner.blade.php @@ -1,6 +1,6 @@ @extends('web.layouts.web-master') -@section('title', 'Partner werden - B2In Ecosystem') +@section('title', 'Für Entwickler & Partner - B2in') @section('content')
@@ -8,10 +8,13 @@
- - - - + + + + + + + diff --git a/resources/views/web/portfolio.blade.php b/resources/views/web/portfolio.blade.php index 31312e4..2a5257b 100644 --- a/resources/views/web/portfolio.blade.php +++ b/resources/views/web/portfolio.blade.php @@ -1,6 +1,6 @@ @extends('web.layouts.web-master') -@section('title', 'B2In Magazin - Insights & Trends') +@section('title', 'B2in Magazin - Insights & Trends') @section('content')
diff --git a/resources/views/web/privacy.blade.php b/resources/views/web/privacy.blade.php new file mode 100644 index 0000000..59290a9 --- /dev/null +++ b/resources/views/web/privacy.blade.php @@ -0,0 +1,50 @@ +@extends('web.layouts.web-master') + +@php + $legal = legal_page('privacy'); +@endphp + +@section('title', $legal['meta_title'] . ' - ' . ($domainName ?? config('app.name'))) + +@section('content') +
+ + +
+
+
+
+

{{ $legal['title'] }}

+

+ {{ $legal['subtitle'] }} +

+ + + + {{-- Cookie-Einstellungen & Google-Analytics-Infos (CookieConsent-Paket) --}} + + + +
+
+
+
+ + +
+@endsection + +@push('styles') + +@endpush diff --git a/resources/views/web/terms.blade.php b/resources/views/web/terms.blade.php new file mode 100644 index 0000000..977959e --- /dev/null +++ b/resources/views/web/terms.blade.php @@ -0,0 +1,45 @@ +@extends('web.layouts.web-master') + +@php + $legal = legal_page('terms'); +@endphp + +@section('title', $legal['meta_title'] . ' - ' . ($domainName ?? config('app.name'))) + +@section('content') +
+ + +
+
+
+
+

{{ $legal['title'] }}

+

+ {{ $legal['subtitle'] }} +

+ + + + +
+
+
+
+ + +
+@endsection + +@push('styles') + +@endpush diff --git a/routes/admin.php b/routes/admin.php index b62bf34..44dcd9d 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -60,7 +60,15 @@ Route::middleware(['auth', 'partner.setup'])->group(function () { Volt::route('testing/registration', 'admin.testing.registration-tester')->name('testing.landing'); // CMS Routes - Route::get('admin/cms/cabinet', \App\Livewire\Admin\CMS\CabinetDisplay::class)->name('admin.cms.cabinet'); + Route::get('admin/cms/cabinet', \App\Livewire\Admin\Cms\CabinetDisplay::class)->name('admin.cms.cabinet'); + Route::get('admin/cms/cabinet-tablet', \App\Livewire\Admin\Cms\CabinetInfoTablet::class)->name('admin.cms.cabinet-tablet'); + + // Display CMS + Volt::route('admin/cms/display-dashboard', 'admin.cms.display-dashboard')->name('admin.cms.display-dashboard'); + Volt::route('admin/cms/display-media', 'admin.cms.display-media-library')->name('admin.cms.display-media'); + Route::get('admin/cms/display-versions', \App\Livewire\Admin\Cms\DisplayVersionList::class)->name('admin.cms.display-versions'); + Route::get('admin/cms/display-versions/{displayVersion}/edit', \App\Livewire\Admin\Cms\DisplayVersionEditor::class)->name('admin.cms.display-version-edit'); + Route::get('admin/cms/displays', \App\Livewire\Admin\Cms\DisplayList::class)->name('admin.cms.displays'); // Product Routes Volt::route('products/index', 'products.index')->name('products.index'); @@ -77,6 +85,13 @@ Route::middleware(['auth', 'partner.setup'])->group(function () { Volt::route('admin/hubs/create', 'admin.hubs.manage')->name('admin.hubs.create'); Volt::route('admin/hubs/{hubId}/edit', 'admin.hubs.manage')->name('admin.hubs.edit'); + // Flux CMS + Volt::route('admin/flux-cms', 'admin.cms.dashboard')->name('cms.dashboard'); + Volt::route('admin/flux-cms/content', 'admin.cms.content-index')->name('cms.content.index'); + Volt::route('admin/flux-cms/projects', 'admin.cms.projects-index')->name('cms.projects.index'); + Volt::route('admin/flux-cms/media', 'admin.cms.media-index')->name('cms.media.index'); + Volt::route('admin/flux-cms/articles', 'admin.cms.articles-index')->name('cms.articles.index'); + // Documentation Volt::route('admin/documentation', 'admin.documentation')->name('admin.documentation'); Route::get('admin/documentation/download', function () { diff --git a/routes/api.php b/routes/api.php index 3d52491..fe0a3ed 100644 --- a/routes/api.php +++ b/routes/api.php @@ -59,3 +59,7 @@ Route::post('/login', function (Request $request) { // Display-Konfiguration für Cabinet Display Route::get('/display/config', [\App\Http\Controllers\Api\DisplayConfigController::class, 'index']); + +// Cabinet Info-Tablet +Route::get('/cabinet-tablet/status', [\App\Http\Controllers\Api\CabinetTabletController::class, 'status']); +Route::get('/cabinet-tablet/check', [\App\Http\Controllers\Api\CabinetTabletController::class, 'check']); diff --git a/routes/console.php b/routes/console.php index 3c9adf1..db81cf6 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,10 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::command('cabinet:reset-overrides')->dailyAt('00:00'); diff --git a/routes/domains.php b/routes/domains.php index af77a99..99af91d 100644 --- a/routes/domains.php +++ b/routes/domains.php @@ -27,18 +27,6 @@ $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'; @@ -52,6 +40,14 @@ Route::domain($domainPortal)->group(function () { // Display-API-Route (öffentlich zugänglich) Route::get('/api/display/config', [\App\Http\Controllers\Api\DisplayConfigController::class, 'index']); + // Cabinet Info-Tablet API + Route::get('/api/cabinet-tablet/status', [\App\Http\Controllers\Api\CabinetTabletController::class, 'status']); + Route::get('/api/cabinet-tablet/check', [\App\Http\Controllers\Api\CabinetTabletController::class, 'check']); + + // Display Version API (per physical display) + Route::get('/api/display/{display}/config', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'config']); + Route::get('/api/display/{display}/check', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'check']); + // FluxUI Asset-Routen explizit für Portal-Domain registrieren // (Notwendig weil Route-Cache die globalen Flux-Routen nicht für alle Domains enthält) Route::get('/flux/flux.js', [\Flux\AssetManager::class, 'fluxJs']); @@ -119,3 +115,11 @@ Route::domain('landing2.test')->group(function () { // Fallback: Display-API für alle anderen Domains (z.B. localhost) Route::get('/api/display/config', [\App\Http\Controllers\Api\DisplayConfigController::class, 'index']); + +// Fallback: Cabinet Info-Tablet API +Route::get('/api/cabinet-tablet/status', [\App\Http\Controllers\Api\CabinetTabletController::class, 'status']); +Route::get('/api/cabinet-tablet/check', [\App\Http\Controllers\Api\CabinetTabletController::class, 'check']); + +// Fallback: Display Version API +Route::get('/api/display/{display}/config', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'config']); +Route::get('/api/display/{display}/check', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'check']); diff --git a/routes/web.php b/routes/web.php index f1c8fe1..9b4aace 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,10 @@ exists('web.' . $theme)) { - return view('web.' . $theme); + if (view()->exists('web.'.$theme)) { + return view('web.'.$theme); } // Fallback to the default home view @@ -32,10 +35,35 @@ Route::get('/about', function () { return view('web.about'); })->name('about'); Route::get('/ecosystem', function () { - return view('web.ecosystem'); + return redirect('/partner', 301); })->name('ecosystem'); +Route::get('/immobilien', function () { + return view('web.immobilien'); +})->name('immobilien'); +Route::get('/netzwerk', function () { + return view('web.netzwerk'); +})->name('netzwerk'); +Route::get('/interior', function () { + return redirect('/netzwerk', 301); +})->name('interior'); + +Route::get('/immobilien/{slug}', function (string $slug) { + $cmsProject = CmsProject::query()->published()->where('slug', $slug)->first(); + if ($cmsProject) { + $project = $cmsProject->toFrontendArray(); + + return view('web.immobilien-show', compact('project')); + } + + $theme = config('app.theme', 'b2in'); + $immobilienProjects = cms_theme_section('immobilien_projects', $theme); + $project = is_array($immobilienProjects) ? data_get($immobilienProjects, "projects.{$slug}") : null; + abort_unless($project, 404); + + return view('web.immobilien-show', compact('project')); +})->name('immobilien.show'); Route::get('/partner', function () { - return view('web.partner'); + return redirect('/netzwerk', 301); })->name('partner'); Route::get('/magazin', function () { return view('web.magazin'); @@ -59,36 +87,54 @@ Route::get('/faq', function () { return view('web.faq'); })->name('faq'); +// Rechtliche Seiten +Route::get('/impressum', function () { + return view('web.impressum'); +})->name('impressum'); +Route::get('/privacy', function () { + return view('web.privacy'); +})->name('privacy'); +Route::get('/terms', function () { + return view('web.terms'); +})->name('terms'); +Route::get('/cookie-policy', function () { + return view('web.cookie-policy'); +})->name('cookie-policy'); + // Theme Demo Route Route::get('/theme-demo', function () { return view('web.theme-demo'); })->name('theme-demo'); +// Dev: Sitemap & Archiv-Versionen +Route::prefix('dev')->group(function () { + Route::get('/sitemap', fn () => view('web.dev.sitemap'))->name('dev.sitemap'); + Route::get('/immobilien-v1', fn () => view('web.dev.immobilien-v1'))->name('dev.immobilien-v1'); + Route::get('/interior-v1', fn () => view('web.dev.interior-v1'))->name('dev.interior-v1'); + Route::get('/partner-v1', fn () => view('web.dev.partner-v1'))->name('dev.partner-v1'); +}); // Pfad-basierte Theme-Routen für lokale Entwicklung wurden entfernt // Die Themensauswahl wird nun über den ThemeServiceProvider gesteuert (Domain oder ?theme=... GET-Parameter) - - - Route::get('/partner/invitation/expired/{token}', function (string $token) { $invitation = \App\Models\PartnerInvitation::with('role')->where('token', $token)->firstOrFail(); + return view('partner.invitation-expired', compact('invitation')); })->name('partner.invitation.expired'); Route::get('/partner/invitation/used/{token}', function (string $token) { $invitation = \App\Models\PartnerInvitation::with('role')->where('token', $token)->firstOrFail(); + return view('partner.invitation-used', compact('invitation')); })->name('partner.invitation.used'); Volt::route('/partner/invitation/{token}', 'partner.invitation-accept') ->name('partner.invitation.accept'); - Volt::route('/partner/create-account', 'partner.create-account') ->name('partner.create.account'); - // Öffentliche Registrierung per QR-/Code (Landing, code-check) Volt::route('/reg/{role}', 'reg.landing') ->name('registration.landing'); @@ -96,12 +142,14 @@ Volt::route('/reg/{role}', 'reg.landing') Volt::route('/registration/thank-you', 'reg.thank-you') ->name('registration.thank-you'); - // Partner Setup Wizard & Daten Route::middleware('auth')->group(function () { Volt::route('/partner/setup', 'partner.setup-wizard') ->name('partner.setup.wizard'); }); +// Cabinet Quick-Status – key-geschützte Mini-App für schnellen Statuswechsel +Route::get('/info/status', QuickStatus::class)->name('cabinet.quick-status'); + // Authentifizierungs-Routen werden in domains.php eingebunden -//require __DIR__ . '/auth.php'; +// require __DIR__ . '/auth.php'; diff --git a/tests/Feature/AboutPageTest.php b/tests/Feature/AboutPageTest.php new file mode 100644 index 0000000..7e32e81 --- /dev/null +++ b/tests/Feature/AboutPageTest.php @@ -0,0 +1,15 @@ +get('/about') + ->assertSuccessful() + ->assertSee('Über B2in'); +}); + +it('about page shows image break section', function () { + $this->get('/about') + ->assertSuccessful() + ->assertSee('best-of-two-worlds.jpg'); +}); diff --git a/tests/Feature/Admin/Cms/MediaPickerTest.php b/tests/Feature/Admin/Cms/MediaPickerTest.php new file mode 100644 index 0000000..9f21b37 --- /dev/null +++ b/tests/Feature/Admin/Cms/MediaPickerTest.php @@ -0,0 +1,31 @@ + null]) + ->assertSuccessful() + ->assertSee('Kein Medium ausgewählt'); +}); + +test('media picker zeigt ausgewähltes medium ohne livewire property fehler', function () { + $media = CmsMedia::query()->create([ + 'filename' => 'test-image.jpg', + 'path' => 'media/test-image.jpg', + 'type' => 'image', + 'mime_type' => 'image/jpeg', + 'disk' => 'public', + ]); + + Livewire::test(MediaPicker::class, ['value' => $media->id]) + ->assertSuccessful() + ->assertSee('test-image.jpg') + ->assertDontSee('Kein Medium ausgewählt'); +}); diff --git a/tests/Feature/AdminDocumentationPageTest.php b/tests/Feature/AdminDocumentationPageTest.php new file mode 100644 index 0000000..fbab82d --- /dev/null +++ b/tests/Feature/AdminDocumentationPageTest.php @@ -0,0 +1,19 @@ +forceRootUrl('https://'.$portalDomain); +}); + +test('authenticated user can render admin documentation volt page', function () { + $user = User::factory()->create(); + + Volt::actingAs($user) + ->test('admin.documentation') + ->assertSuccessful(); +}); diff --git a/tests/Feature/AnnouncementBarTest.php b/tests/Feature/AnnouncementBarTest.php new file mode 100644 index 0000000..888bff4 --- /dev/null +++ b/tests/Feature/AnnouncementBarTest.php @@ -0,0 +1,22 @@ +get('/') + ->assertSuccessful() + ->assertSee('Azizi Creek Views 4') + ->assertSee('NEW LAUNCH'); +}); + +it('announcement bar is visible on immobilien page', function () { + $this->get('/immobilien') + ->assertSuccessful() + ->assertSee('Off-Market-Projekt'); +}); + +it('announcement bar links to project expose', function () { + $this->get('/') + ->assertSuccessful() + ->assertSee('/immobilien/azizi-creek-views-4'); +}); diff --git a/tests/Feature/CabinetInfoTabletTest.php b/tests/Feature/CabinetInfoTabletTest.php new file mode 100644 index 0000000..9a19c9c --- /dev/null +++ b/tests/Feature/CabinetInfoTabletTest.php @@ -0,0 +1,154 @@ +forceRootUrl('https://'.$portalDomain); +}); + +test('cabinet info tablet page requires authentication', function () { + $response = $this->get(route('admin.cms.cabinet-tablet')); + + $response->assertRedirect('/login'); +}); + +test('cabinet info tablet page renders for authenticated users', function () { + $user = User::factory()->create(); + + $this->actingAs($user); + + $response = $this->get(route('admin.cms.cabinet-tablet')); + + $response->assertSuccessful(); + $response->assertSeeLivewire(CabinetInfoTablet::class); +}); + +test('component mounts with existing settings', function () { + CabinetTabletSetting::factory()->create([ + 'store_status' => 'notice', + 'notice_headline' => 'Sonderverkauf', + 'contact_phone' => '0521 12345', + 'hours_monday_open' => '10:00', + 'hours_monday_close' => '18:00', + ]); + + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(CabinetInfoTablet::class) + ->assertSet('storeStatus', 'notice') + ->assertSet('noticeHeadline', 'Sonderverkauf') + ->assertSet('contactPhone', '0521 12345'); +}); + +test('save persists settings to database', function () { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(CabinetInfoTablet::class) + ->set('storeStatus', 'notice') + ->set('noticeHeadline', 'Winterpause') + ->set('noticeSubtext', 'Bis 02.01. geschlossen') + ->set('contactPhone', '0521 99999') + ->set('contactEmail', 'test@example.com') + ->call('save') + ->assertHasNoErrors(); + + $settings = CabinetTabletSetting::current(); + expect($settings->store_status)->toBe('notice'); + expect($settings->notice_headline)->toBe('Winterpause'); + expect($settings->contact_phone)->toBe('0521 99999'); +}); + +test('save validates store status', function () { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(CabinetInfoTablet::class) + ->set('storeStatus', 'open') // 'open' is not a valid mode anymore + ->call('save') + ->assertHasErrors(['storeStatus']); + + Livewire::actingAs($user) + ->test(CabinetInfoTablet::class) + ->set('storeStatus', 'warning') + ->call('save') + ->assertHasNoErrors(['storeStatus']); +}); + +test('save validates override time format', function () { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(CabinetInfoTablet::class) + ->set('overrideOpenToday', 'abc') + ->call('save') + ->assertHasErrors(['overrideOpenToday']); +}); + +test('save validates email format', function () { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(CabinetInfoTablet::class) + ->set('contactEmail', 'not-an-email') + ->call('save') + ->assertHasErrors(['contactEmail']); +}); + +test('save validates headline max length', function () { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(CabinetInfoTablet::class) + ->set('noticeHeadline', str_repeat('A', 41)) + ->call('save') + ->assertHasErrors(['noticeHeadline']); +}); + +test('save allows empty opening hours for closed days', function () { + CabinetTabletSetting::factory()->create([ + 'hours_monday_open' => '10:00', + 'hours_monday_close' => '18:00', + ]); + + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(CabinetInfoTablet::class) + ->set('hoursMondayOpen', '') + ->set('hoursMondayClose', '') + ->call('save') + ->assertHasNoErrors(); + + $settings = CabinetTabletSetting::current(); + expect($settings->hours_monday_open)->toBeNull(); + expect($settings->hours_monday_close)->toBeNull(); +}); + +test('clearOverrides resets override fields', function () { + CabinetTabletSetting::factory()->create([ + 'override_open_today' => '09:00', + 'override_close_today' => '20:00', + ]); + + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(CabinetInfoTablet::class) + ->assertSet('overrideOpenToday', '09:00') + ->assertSet('overrideCloseToday', '20:00') + ->call('clearOverrides') + ->assertSet('overrideOpenToday', '') + ->assertSet('overrideCloseToday', ''); + + $settings = CabinetTabletSetting::current(); + expect($settings->override_open_today)->toBeNull(); + expect($settings->override_close_today)->toBeNull(); +}); diff --git a/tests/Feature/CabinetQuickStatusTest.php b/tests/Feature/CabinetQuickStatusTest.php new file mode 100644 index 0000000..acd7799 --- /dev/null +++ b/tests/Feature/CabinetQuickStatusTest.php @@ -0,0 +1,113 @@ + VALID_KEY]); +}); + +test('page is accessible without login', function () { + $this->get('/info/status?k='.VALID_KEY) + ->assertSuccessful() + ->assertSeeLivewire(QuickStatus::class); +}); + +test('page shows forbidden when key is missing', function () { + Livewire::test(QuickStatus::class, ['k' => '']) + ->assertSet('authorized', false) + ->assertSee('Kein Zugriff'); +}); + +test('page shows forbidden when key is wrong', function () { + Livewire::test(QuickStatus::class, ['k' => 'wrong-key']) + ->assertSet('authorized', false) + ->assertSee('Kein Zugriff'); +}); + +test('page loads current status when key is correct', function () { + CabinetTabletSetting::factory()->create([ + 'store_status' => 'notice', + 'notice_headline' => 'Testhinweis', + ]); + + Livewire::test(QuickStatus::class, ['k' => VALID_KEY]) + ->assertSet('authorized', true) + ->assertSet('storeStatus', 'notice') + ->assertSet('noticeHeadline', 'Testhinweis'); +}); + +test('selectStatus changes storeStatus', function () { + Livewire::test(QuickStatus::class, ['k' => VALID_KEY]) + ->call('selectStatus', 'closed') + ->assertSet('storeStatus', 'closed'); +}); + +test('selectStatus ignores unknown values', function () { + Livewire::test(QuickStatus::class, ['k' => VALID_KEY]) + ->call('selectStatus', 'invalid') + ->assertSet('storeStatus', 'auto'); +}); + +test('save persists status and headline to database', function () { + Livewire::test(QuickStatus::class, ['k' => VALID_KEY]) + ->set('storeStatus', 'notice') + ->set('noticeHeadline', 'Sonderöffnung') + ->set('noticeSubtext', 'Heute nur bis 14 Uhr') + ->call('save') + ->assertHasNoErrors() + ->assertSet('saved', true); + + $settings = CabinetTabletSetting::current(); + expect($settings->store_status)->toBe('notice'); + expect($settings->notice_headline)->toBe('Sonderöffnung'); + expect($settings->notice_subtext)->toBe('Heute nur bis 14 Uhr'); +}); + +test('save clears headline when switching to auto', function () { + CabinetTabletSetting::factory()->create([ + 'store_status' => 'notice', + 'notice_headline' => 'Alter Text', + ]); + + Livewire::test(QuickStatus::class, ['k' => VALID_KEY]) + ->set('storeStatus', 'auto') + ->call('save') + ->assertHasNoErrors(); + + expect(CabinetTabletSetting::current()->store_status)->toBe('auto'); + expect(CabinetTabletSetting::current()->notice_headline)->toBeNull(); +}); + +test('save validates headline max length', function () { + Livewire::test(QuickStatus::class, ['k' => VALID_KEY]) + ->set('storeStatus', 'notice') + ->set('noticeHeadline', str_repeat('A', 41)) + ->call('save') + ->assertHasErrors(['noticeHeadline']); +}); + +test('save validates subtext max length', function () { + Livewire::test(QuickStatus::class, ['k' => VALID_KEY]) + ->set('storeStatus', 'notice') + ->set('noticeSubtext', str_repeat('A', 81)) + ->call('save') + ->assertHasErrors(['noticeSubtext']); +}); + +test('unauthorized user cannot save', function () { + $component = Livewire::test(QuickStatus::class, ['k' => 'wrong']) + ->set('storeStatus', 'closed') + ->call('save'); + + expect(CabinetTabletSetting::count())->toBe(0); +}); + +test('all four status options are available', function () { + $component = Livewire::test(QuickStatus::class, ['k' => VALID_KEY]); + + expect($component->get('statusOptions'))->toHaveKeys(['auto', 'closed', 'notice', 'warning']); +}); diff --git a/tests/Feature/CabinetTabletApiTest.php b/tests/Feature/CabinetTabletApiTest.php new file mode 100644 index 0000000..5d447eb --- /dev/null +++ b/tests/Feature/CabinetTabletApiTest.php @@ -0,0 +1,100 @@ +create([ + 'store_status' => 'auto', + 'hours_thursday_open' => '10:00', + 'hours_thursday_close' => '18:00', + 'contact_phone' => '0521 98620100', + 'contact_email' => 'info@cabinet-bielefeld.de', + 'next_appointment_date' => '2026-03-01', + 'next_appointment_time' => '14:00', + ]); + + // Thursday 12:00 – within opening hours + Carbon::setTestNow(Carbon::create(2026, 3, 5, 12, 0, 0, 'Europe/Berlin')); + + $response = $this->getJson('/api/cabinet-tablet/status'); + + $response->assertSuccessful() + ->assertJsonStructure([ + 'store_status', + 'today_close', + 'next_open', + 'notice_headline', + 'notice_subtext', + 'override_open_today', + 'override_close_today', + 'next_appointment' => ['date', 'time'], + 'hours' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'], + 'contact' => ['phone', 'email'], + 'updated_at', + ]) + ->assertJsonPath('store_status', 'open') + ->assertJsonPath('today_close', '18:00') + ->assertJsonPath('hours.monday', '10:00 – 18:00') + ->assertJsonPath('hours.sunday', 'Geschlossen') + ->assertJsonPath('contact.phone', '0521 98620100') + ->assertJsonPath('next_appointment.date', '2026-03-01') + ->assertJsonPath('next_appointment.time', '14:00'); + + Carbon::setTestNow(); +}); + +test('status endpoint returns closed with next_open outside opening hours', function () { + CabinetTabletSetting::factory()->create([ + 'store_status' => 'auto', + 'hours_thursday_open' => '10:00', + 'hours_thursday_close' => '18:00', + 'hours_friday_open' => '10:00', + 'hours_friday_close' => '18:00', + ]); + + // Thursday 20:00 – after closing + Carbon::setTestNow(Carbon::create(2026, 3, 5, 20, 0, 0, 'Europe/Berlin')); + + $response = $this->getJson('/api/cabinet-tablet/status'); + + $response->assertSuccessful() + ->assertJsonPath('store_status', 'closed') + ->assertJsonPath('next_open.label', 'Morgen') + ->assertJsonPath('next_open.time', '10:00'); + + Carbon::setTestNow(); +}); + +test('status endpoint returns notice when notice mode is set', function () { + CabinetTabletSetting::factory()->create([ + 'store_status' => 'notice', + 'notice_headline' => 'Urlaub', + 'notice_subtext' => 'Wir sind vom 10.–20. März im Urlaub.', + ]); + + $response = $this->getJson('/api/cabinet-tablet/status'); + + $response->assertSuccessful() + ->assertJsonPath('store_status', 'notice') + ->assertJsonPath('notice_headline', 'Urlaub'); +}); + +test('check endpoint returns updated_at and store_status but not full data', function () { + CabinetTabletSetting::factory()->create(['store_status' => 'auto']); + + $response = $this->getJson('/api/cabinet-tablet/check'); + + $response->assertSuccessful() + ->assertJsonStructure(['updated_at', 'store_status']) + ->assertJsonMissingPath('hours'); +}); + +test('status endpoint creates settings row if none exists', function () { + expect(CabinetTabletSetting::count())->toBe(0); + + $response = $this->getJson('/api/cabinet-tablet/status'); + + $response->assertSuccessful(); + expect(CabinetTabletSetting::count())->toBe(1); +}); diff --git a/tests/Feature/Cms/CmsAdminTest.php b/tests/Feature/Cms/CmsAdminTest.php new file mode 100644 index 0000000..8e555db --- /dev/null +++ b/tests/Feature/Cms/CmsAdminTest.php @@ -0,0 +1,410 @@ +forceRootUrl('https://'.$portalDomain); +}); + +// ======================================== +// Authentication +// ======================================== + +test('cms dashboard requires authentication', function () { + $this->get(route('cms.dashboard'))->assertRedirect('/login'); +}); + +test('cms content index requires authentication', function () { + $this->get(route('cms.content.index'))->assertRedirect('/login'); +}); + +test('cms projects index requires authentication', function () { + $this->get(route('cms.projects.index'))->assertRedirect('/login'); +}); + +test('cms media index requires authentication', function () { + $this->get(route('cms.media.index'))->assertRedirect('/login'); +}); + +// ======================================== +// Dashboard +// ======================================== + +test('cms dashboard renders for authenticated users', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('cms.dashboard')) + ->assertSuccessful() + ->assertSee('So funktioniert das CMS') + ->assertSee('Medienbibliothek'); +}); + +test('cms dashboard shows correct stats', function () { + $user = User::factory()->create(); + CmsProject::factory()->published()->count(2)->create(); + CmsProject::factory()->unpublished()->create(); + + Volt::actingAs($user) + ->test('admin.cms.dashboard') + ->assertSee('3') + ->assertSee('2 veröffentlicht'); +}); + +// ======================================== +// Content Index +// ======================================== + +test('cms content index renders for authenticated users', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('cms.content.index')) + ->assertSuccessful(); +}); + +test('cms content index shows groups', function () { + $user = User::factory()->create(); + + CmsContent::create([ + 'group' => 'home', + 'key' => 'hero', + 'type' => 'json', + 'value' => ['de' => ['title' => 'Test']], + ]); + + CmsContent::create([ + 'group' => 'home', + 'key' => 'about', + 'type' => 'text', + 'value' => ['de' => 'About text'], + ]); + + Volt::actingAs($user) + ->test('admin.cms.content-index') + ->assertSee('home') + ->assertSee('2'); +}); + +test('cms content index can select group and show flattened fields', function () { + $user = User::factory()->create(); + + CmsContent::create([ + 'group' => 'home', + 'key' => 'hero', + 'type' => 'json', + 'value' => ['de' => ['title' => 'Hero Title', 'subtitle' => 'Hero Sub']], + ]); + + Volt::actingAs($user) + ->test('admin.cms.content-index') + ->call('selectGroup', 'home') + ->assertSee('hero') + ->assertSee('title') + ->assertSee('subtitle') + ->assertSee('Hero Title') + ->assertSee('Hero Sub'); +}); + +test('cms content index can edit a flattened field', function () { + $user = User::factory()->create(); + + $content = CmsContent::create([ + 'group' => 'home', + 'key' => 'hero', + 'type' => 'json', + 'value' => ['de' => ['title' => 'Old Title', 'subtitle' => 'Sub']], + ]); + + Volt::actingAs($user) + ->test('admin.cms.content-index') + ->call('selectGroup', 'home') + ->call('startFieldEdit', $content->id, 'title') + ->assertSet('editingId', $content->id) + ->assertSet('editingField', 'title') + ->assertSet('editValue', 'Old Title') + ->set('editValue', 'New Title') + ->call('saveEdit') + ->assertSet('editingId', null); + + $content->refresh(); + $value = $content->getTranslation('value', 'de'); + expect($value['title'])->toBe('New Title') + ->and($value['subtitle'])->toBe('Sub'); +}); + +test('cms content index opens json modal for array subfields', function () { + $user = User::factory()->create(); + + $content = CmsContent::create([ + 'group' => 'home', + 'key' => 'hero', + 'type' => 'json', + 'value' => ['de' => ['title' => 'Title', 'stats' => [['label' => 'Projects', 'value' => '50+']]]], + ]); + + Volt::actingAs($user) + ->test('admin.cms.content-index') + ->call('selectGroup', 'home') + ->call('startFieldEdit', $content->id, 'stats') + ->assertSet('showJsonModal', true) + ->assertSet('jsonEditingKey', 'hero.stats'); +}); + +// ======================================== +// Projects Index +// ======================================== + +test('cms projects index renders for authenticated users', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('cms.projects.index')) + ->assertSuccessful(); +}); + +test('cms projects index lists projects', function () { + $user = User::factory()->create(); + CmsProject::factory()->published()->create(['slug' => 'test-project', 'title' => ['de' => 'Test Projekt']]); + + Volt::actingAs($user) + ->test('admin.cms.projects-index') + ->assertSee('Test Projekt'); +}); + +test('cms projects can open create form', function () { + $user = User::factory()->create(); + + Volt::actingAs($user) + ->test('admin.cms.projects-index') + ->call('openCreate') + ->assertSet('showForm', true) + ->assertSet('editingId', null); +}); + +test('cms projects can save a new project', function () { + $user = User::factory()->create(); + + Volt::actingAs($user) + ->test('admin.cms.projects-index') + ->call('openCreate') + ->set('slug', 'new-project') + ->set('projectTitle', 'New Project') + ->set('is_published', true) + ->set('order', 1) + ->call('save') + ->assertSet('showForm', false) + ->assertHasNoErrors(); + + expect(CmsProject::where('slug', 'new-project')->exists())->toBeTrue(); +}); + +test('cms projects can save with all detail fields', function () { + $user = User::factory()->create(); + + Volt::actingAs($user) + ->test('admin.cms.projects-index') + ->call('openCreate') + ->set('slug', 'full-project') + ->set('projectTitle', 'Full Project') + ->set('location', 'Dubai') + ->set('status', 'NEW LAUNCH') + ->set('price_from_aed', 1125000) + ->set('highlights', ['Highlight A', 'Highlight B']) + ->set('quick_facts', [['icon' => 'home-modern', 'label' => 'Typen', 'value' => '1BR']]) + ->set('investTitle', 'Starkes Investment') + ->set('investText', 'Beschreibung') + ->set('investViews', ['Road View']) + ->set('galleryItems', ['img1.jpg', 'img2.jpg']) + ->set('locTitle', 'Location: Al Jaddaf') + ->set('locMapUrl', 'https://maps.google.com') + ->set('locPoints', ['Punkt 1', 'Punkt 2']) + ->set('contactTitle', 'Kontakt Titel') + ->set('contactSubtitle', 'Ihr Ansprechpartner') + ->set('contactOptions', [['key' => '', 'value' => 'Bitte wählen'], ['key' => '1br', 'value' => '1BR Apt']]) + ->call('save') + ->assertSet('showForm', false); + + $project = CmsProject::where('slug', 'full-project')->first(); + expect($project)->not->toBeNull() + ->and($project->getTranslation('highlights', 'de'))->toHaveCount(2) + ->and($project->quick_facts)->toHaveCount(1) + ->and($project->gallery)->toHaveCount(2) + ->and($project->getTranslation('investment_case', 'de')['title'])->toBe('Starkes Investment') + ->and($project->getTranslation('location_info', 'de')['points'])->toHaveCount(2) + ->and($project->getTranslation('contact', 'de')['options'])->toHaveKey('1br'); +}); + +test('cms projects can edit existing project', function () { + $user = User::factory()->create(); + $project = CmsProject::factory()->published()->create(['slug' => 'edit-me', 'title' => ['de' => 'Edit Me']]); + + Volt::actingAs($user) + ->test('admin.cms.projects-index') + ->call('openEdit', $project->id) + ->assertSet('showForm', true) + ->assertSet('editingId', $project->id) + ->assertSet('slug', 'edit-me'); +}); + +test('cms projects can duplicate a project', function () { + $user = User::factory()->create(); + $project = CmsProject::factory()->published()->create([ + 'slug' => 'original-project', + 'title' => ['de' => 'Original Projekt'], + ]); + + Volt::actingAs($user) + ->test('admin.cms.projects-index') + ->call('duplicateProject', $project->id) + ->assertSet('showForm', true) + ->assertSet('editingId', null) + ->assertSet('projectTitle', 'Original Projekt'); + + expect($this->slug ?? '')->not->toBe('original-project'); +}); + +test('cms projects can delete a project', function () { + $user = User::factory()->create(); + $project = CmsProject::factory()->create(['slug' => 'delete-me']); + + Volt::actingAs($user) + ->test('admin.cms.projects-index') + ->call('deleteProject', $project->id); + + expect(CmsProject::where('slug', 'delete-me')->exists())->toBeFalse(); +}); + +// ======================================== +// Media Index +// ======================================== + +test('cms media index renders for authenticated users', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('cms.media.index')) + ->assertSuccessful(); +}); + +// ======================================== +// Articles (Magazin) +// ======================================== + +test('cms articles index requires authentication', function () { + $this->get(route('cms.articles.index'))->assertRedirect('/login'); +}); + +test('cms articles index renders for authenticated users', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('cms.articles.index')) + ->assertSuccessful() + ->assertSee('Magazin Beiträge'); +}); + +test('cms articles index shows existing articles', function () { + $user = User::factory()->create(); + CmsArticle::factory()->create([ + 'slug' => 'test-magazin-artikel', + 'title' => ['de' => 'Ein Test Artikel'], + 'category' => 'Dubai Investment', + 'author' => ['name' => 'Marcel Scheibe', 'bio' => 'CEO', 'avatar' => 'a.jpg'], + ]); + + $this->actingAs($user) + ->get(route('cms.articles.index')) + ->assertSee('Ein Test Artikel') + ->assertSee('Dubai Investment') + ->assertSee('Marcel Scheibe'); +}); + +test('cms articles index can create new article', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + Volt::test('admin.cms.articles-index') + ->call('openCreate') + ->assertSet('showForm', true) + ->assertSet('editingId', null); +}); + +test('cms articles index can save a new article', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + Volt::test('admin.cms.articles-index') + ->call('openCreate') + ->set('slug', 'neuer-test-artikel') + ->set('articleTitle', 'Neuer Testartikel') + ->set('subtitle', 'Ein Untertitel') + ->set('category', 'Test Kategorie') + ->set('date_label', 'März 23, 2026') + ->set('read_time', '5 min read') + ->set('authorName', 'Test Autor') + ->set('intro', 'Einleitungstext hier.') + ->set('sections', [['title' => 'Abschnitt 1', 'content' => 'Inhalt 1']]) + ->call('save') + ->assertSet('showForm', false); + + $article = CmsArticle::where('slug', 'neuer-test-artikel')->first(); + + expect($article)->not->toBeNull() + ->and($article->category)->toBe('Test Kategorie') + ->and($article->getTranslation('title', 'de'))->toBe('Neuer Testartikel'); + + $content = $article->getTranslation('content', 'de'); + expect($content['intro'])->toBe('Einleitungstext hier.') + ->and($content['sections'])->toHaveCount(1); +}); + +test('cms articles index can edit existing article', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $article = CmsArticle::factory()->create([ + 'slug' => 'edit-test', + 'title' => ['de' => 'Original Titel'], + ]); + + Volt::test('admin.cms.articles-index') + ->call('openEdit', $article->id) + ->assertSet('editingId', $article->id) + ->assertSet('slug', 'edit-test') + ->assertSet('showForm', true); +}); + +test('cms articles index can delete article', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $article = CmsArticle::factory()->create(); + + Volt::test('admin.cms.articles-index') + ->call('deleteArticle', $article->id); + + expect(CmsArticle::find($article->id))->toBeNull(); +}); + +test('cms articles index can toggle published status', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $article = CmsArticle::factory()->published()->create(); + + Volt::test('admin.cms.articles-index') + ->call('togglePublished', $article->id); + + expect($article->refresh()->is_published)->toBeFalse(); +}); diff --git a/tests/Feature/Cms/CmsArticleTest.php b/tests/Feature/Cms/CmsArticleTest.php new file mode 100644 index 0000000..cdd6bfc --- /dev/null +++ b/tests/Feature/Cms/CmsArticleTest.php @@ -0,0 +1,112 @@ +create(); + + expect($article)->toBeInstanceOf(CmsArticle::class) + ->and($article->slug)->not->toBeEmpty() + ->and($article->title)->not->toBeEmpty(); +}); + +test('published scope returns only published articles', function () { + CmsArticle::factory()->published()->count(2)->create(); + CmsArticle::factory()->unpublished()->create(); + + expect(CmsArticle::published()->count())->toBe(2); +}); + +test('ordered scope sorts by order then created_at desc', function () { + $first = CmsArticle::factory()->create(['order' => 1]); + $second = CmsArticle::factory()->create(['order' => 2]); + $third = CmsArticle::factory()->create(['order' => 3]); + + $sorted = CmsArticle::ordered()->pluck('id')->toArray(); + + expect($sorted)->toBe([$first->id, $second->id, $third->id]); +}); + +test('toFrontendArray returns compatible structure', function () { + $article = CmsArticle::factory()->create([ + 'slug' => 'test-article', + 'title' => 'Test Article', + 'subtitle' => 'Test Subtitle', + 'image' => 'b2in/magazin-1.jpg', + 'category' => 'Dubai Investment', + 'date_label' => 'März 10, 2026', + 'read_time' => '6 min read', + 'author' => ['name' => 'Marcel', 'bio' => 'CEO', 'avatar' => 'avatar.jpg'], + 'content' => ['de' => ['intro' => 'Intro text', 'sections' => [['title' => 'S1', 'content' => 'C1']]]], + ]); + + $array = $article->toFrontendArray(); + + expect($array) + ->toHaveKeys(['id', 'slug', 'title', 'subtitle', 'image', 'category', 'date', 'readTime', 'author', 'content']) + ->and($array['slug'])->toBe('test-article') + ->and($array['date'])->toBe('März 10, 2026') + ->and($array['readTime'])->toBe('6 min read') + ->and($array['author']['name'])->toBe('Marcel') + ->and($array['content']['sections'])->toHaveCount(1); +}); + +test('slug is unique', function () { + CmsArticle::factory()->create(['slug' => 'unique-slug']); + + expect(fn () => CmsArticle::factory()->create(['slug' => 'unique-slug'])) + ->toThrow(\Illuminate\Database\QueryException::class); +}); + +test('author cast returns array', function () { + $article = CmsArticle::factory()->create([ + 'author' => ['name' => 'Test Author', 'bio' => 'Bio text', 'avatar' => 'a.jpg'], + ]); + + $article->refresh(); + + expect($article->author) + ->toBeArray() + ->toHaveKey('name', 'Test Author') + ->toHaveKey('bio', 'Bio text'); +}); + +test('translatable fields work correctly', function () { + $article = CmsArticle::factory()->create([ + 'title' => ['de' => 'Deutscher Titel', 'en' => 'English Title'], + 'subtitle' => ['de' => 'Deutsch', 'en' => 'English'], + ]); + + expect($article->getTranslation('title', 'de'))->toBe('Deutscher Titel') + ->and($article->getTranslation('title', 'en'))->toBe('English Title'); +}); + +test('seeder creates articles from config data', function () { + $this->seed(\Database\Seeders\CmsArticleSeeder::class); + + expect(CmsArticle::count())->toBe(5); + + $article = CmsArticle::where('slug', 'escrow-system-dubai-investoren')->first(); + + expect($article)->not->toBeNull() + ->and($article->category)->toBe('Dubai Investment') + ->and($article->read_time)->toBe('6 min read') + ->and($article->author['name'])->toBe('Marcel Scheibe') + ->and($article->is_published)->toBeTrue(); + + $content = $article->getTranslation('content', 'de'); + expect($content)->toHaveKey('intro') + ->and($content['sections'])->toHaveCount(3); +}); + +test('seeder is idempotent', function () { + $this->seed(\Database\Seeders\CmsArticleSeeder::class); + $this->seed(\Database\Seeders\CmsArticleSeeder::class); + + expect(CmsArticle::count())->toBe(5); +}); diff --git a/tests/Feature/Cms/CmsContentSeederTest.php b/tests/Feature/Cms/CmsContentSeederTest.php new file mode 100644 index 0000000..97ea834 --- /dev/null +++ b/tests/Feature/Cms/CmsContentSeederTest.php @@ -0,0 +1,135 @@ +seed(\Database\Seeders\CmsContentSeeder::class); + + expect(CmsContent::count())->toBeGreaterThan(0); + + $expectedGroups = ['global', 'home', 'immobilien', 'netzwerk', 'faq', 'contact', 'about', 'magazin']; + + foreach ($expectedGroups as $group) { + expect(CmsContent::where('group', $group)->exists()) + ->toBeTrue("Expected group '{$group}' to exist in CmsContent"); + } +}); + +test('seeder creates all home sections', function () { + $this->seed(\Database\Seeders\CmsContentSeeder::class); + + $homeKeys = CmsContent::where('group', 'home')->pluck('key')->toArray(); + + expect($homeKeys) + ->toContain('hero') + ->toContain('founder_bar') + ->toContain('ecosystem_core') + ->toContain('vision_section'); +}); + +test('seeder stores hero content as translatable json', function () { + $this->seed(\Database\Seeders\CmsContentSeeder::class); + + $hero = CmsContent::where('group', 'home')->where('key', 'hero')->first(); + + expect($hero)->not->toBeNull() + ->and($hero->type)->toBe('json'); + + $valueDe = $hero->getTranslation('value', 'de'); + $valueEn = $hero->getTranslation('value', 'en'); + + expect($valueDe)->toBeArray() + ->toHaveKeys(['title', 'subtitle', 'image']); + + expect($valueEn)->toBeArray() + ->toHaveKeys(['title', 'subtitle', 'image']); +}); + +test('cms helper returns correct content after seeding', function () { + $this->seed(\Database\Seeders\CmsContentSeeder::class); + + $hero = cms('home.hero'); + + expect($hero)->toBeArray() + ->and($hero['title'])->toContain('B2in'); +}); + +test('cms content service getGroup returns all section keys', function () { + $this->seed(\Database\Seeders\CmsContentSeeder::class); + + $service = app(CmsContentService::class); + $homeGroup = $service->getGroup('home'); + + expect($homeGroup)->toBeArray() + ->toHaveKey('hero') + ->toHaveKey('founder_bar') + ->toHaveKey('ecosystem_core'); + + expect($homeGroup['hero'])->toBeArray() + ->toHaveKey('title'); +}); + +test('seeder is idempotent', function () { + $this->seed(\Database\Seeders\CmsContentSeeder::class); + $countFirst = CmsContent::count(); + + $this->seed(\Database\Seeders\CmsContentSeeder::class); + $countSecond = CmsContent::count(); + + expect($countSecond)->toBe($countFirst); +}); + +test('immobilien sections are seeded correctly', function () { + $this->seed(\Database\Seeders\CmsContentSeeder::class); + + $immoKeys = CmsContent::where('group', 'immobilien')->pluck('key')->toArray(); + + expect($immoKeys) + ->toContain('hero_v2') + ->toContain('warum_dubai') + ->toContain('kaufprozess') + ->toContain('bruecke') + ->toContain('mindset') + ->toContain('moebel_vorteil'); +}); + +test('faq content contains questions array', function () { + $this->seed(\Database\Seeders\CmsContentSeeder::class); + + $faq = cms('faq.faq'); + + expect($faq)->toBeArray() + ->toHaveKey('questions') + ->and($faq['questions'])->toBeArray() + ->not->toBeEmpty(); +}); + +test('cms_theme_section returns CMS content after seeding', function () { + $this->seed(\Database\Seeders\CmsContentSeeder::class); + + $hero = cms_theme_section('hero'); + + expect($hero)->toBeArray() + ->and($hero['title'])->toContain('B2in'); +}); + +test('portfolio is stored under immobilien group for CMS', function () { + $this->seed(\Database\Seeders\CmsContentSeeder::class); + + $portfolio = CmsContent::where('group', 'immobilien')->where('key', 'portfolio')->first(); + + expect($portfolio)->not->toBeNull() + ->and($portfolio->type)->toBe('json'); + + $valueDe = $portfolio->getTranslation('value', 'de'); + + expect($valueDe)->toBeArray() + ->toHaveKey('projects') + ->and($valueDe['projects'])->toBeArray(); +}); diff --git a/tests/Feature/Cms/CmsProjectTest.php b/tests/Feature/Cms/CmsProjectTest.php new file mode 100644 index 0000000..5e7f275 --- /dev/null +++ b/tests/Feature/Cms/CmsProjectTest.php @@ -0,0 +1,115 @@ +create(); + + expect($project)->toBeInstanceOf(CmsProject::class) + ->and($project->slug)->not->toBeEmpty() + ->and($project->title)->not->toBeEmpty(); +}); + +test('published scope returns only published projects', function () { + CmsProject::factory()->published()->count(2)->create(); + CmsProject::factory()->unpublished()->create(); + + expect(CmsProject::published()->count())->toBe(2); +}); + +test('ordered scope sorts by order then launch_date desc', function () { + $first = CmsProject::factory()->create([ + 'order' => 0, + 'launch_date' => '2026-06-01', + ]); + $second = CmsProject::factory()->create([ + 'order' => 0, + 'launch_date' => '2026-01-01', + ]); + $third = CmsProject::factory()->create([ + 'order' => 1, + 'launch_date' => '2026-12-01', + ]); + + $sorted = CmsProject::ordered()->pluck('id')->toArray(); + + expect($sorted)->toBe([$first->id, $second->id, $third->id]); +}); + +test('getFormattedPrice returns formatted AED with EUR and USD', function () { + $project = CmsProject::factory()->create(['price_from_aed' => 1_125_000]); + + $formatted = $project->getFormattedPrice(); + + expect($formatted) + ->toContain('AED') + ->toContain('EUR') + ->toContain('USD') + ->toContain('1.125.000'); +}); + +test('getFormattedPrice returns empty string when no price', function () { + $project = CmsProject::factory()->create(['price_from_aed' => null]); + + expect($project->getFormattedPrice())->toBe(''); +}); + +test('toFrontendArray returns compatible structure', function () { + $project = CmsProject::factory()->create([ + 'slug' => 'test-project', + 'title' => 'Test Project', + 'location' => 'Dubai', + 'status' => 'NEW LAUNCH', + 'launch_date' => '2026-03-03', + 'price_from_aed' => 1_000_000, + 'highlights' => ['Highlight 1', 'Highlight 2'], + 'quick_facts' => [['icon' => 'home', 'label' => 'Type', 'value' => '1BR']], + 'gallery' => ['img1.jpg', 'img2.jpg'], + ]); + + $array = $project->toFrontendArray(); + + expect($array) + ->toHaveKeys(['slug', 'title', 'location', 'status', 'launch_date', 'price_from', 'image', 'highlights', 'quick_facts', 'investment_case', 'gallery', 'location_info', 'contact', 'investor_trust', 'furniture_benefit']) + ->and($array['slug'])->toBe('test-project') + ->and($array['launch_date'])->toBe('03.03.2026') + ->and($array['highlights'])->toHaveCount(2) + ->and($array['gallery'])->toHaveCount(2) + ->and($array['price_from'])->toContain('AED'); +}); + +test('slug is unique', function () { + CmsProject::factory()->create(['slug' => 'unique-slug']); + + expect(fn () => CmsProject::factory()->create(['slug' => 'unique-slug'])) + ->toThrow(\Illuminate\Database\QueryException::class); +}); + +test('seeder creates project from config data', function () { + $this->seed(\Database\Seeders\CmsProjectSeeder::class); + + $project = CmsProject::where('slug', 'azizi-creek-views-4')->first(); + + expect($project)->not->toBeNull() + ->and($project->title)->toBe('Azizi Developments: Creek Views 4') + ->and($project->price_from_aed)->toBe(1_125_000) + ->and($project->is_published)->toBeTrue() + ->and($project->gallery)->toHaveCount(6) + ->and($project->quick_facts)->toHaveCount(4) + ->and($project->investor_trust)->toBeArray() + ->and($project->investor_trust['columns'] ?? [])->toHaveCount(3) + ->and($project->furniture_benefit)->toBeArray() + ->and($project->furniture_benefit['button_link'] ?? '')->toBe('/netzwerk'); +}); + +test('seeder is idempotent', function () { + $this->seed(\Database\Seeders\CmsProjectSeeder::class); + $this->seed(\Database\Seeders\CmsProjectSeeder::class); + + expect(CmsProject::count())->toBe(1); +}); diff --git a/tests/Feature/Cms/HeroiconOutlineListTest.php b/tests/Feature/Cms/HeroiconOutlineListTest.php new file mode 100644 index 0000000..8266e8a --- /dev/null +++ b/tests/Feature/Cms/HeroiconOutlineListTest.php @@ -0,0 +1,30 @@ +toBeArray(); + + foreach ($names as $name) { + expect($name)->toBeString()->not->toBeEmpty(); + } + + $sorted = $names; + sort($sorted); + expect($names)->toEqual($sorted); +}); + +test('heroicon outline forgetCached allows list to be rebuilt', function () { + HeroiconOutlineList::forgetCached(); + + $names = HeroiconOutlineList::names(); + + expect($names)->toBeArray(); +}); diff --git a/tests/Feature/Cms/NetzwerkPageCmsSeederTest.php b/tests/Feature/Cms/NetzwerkPageCmsSeederTest.php new file mode 100644 index 0000000..dc5db16 --- /dev/null +++ b/tests/Feature/Cms/NetzwerkPageCmsSeederTest.php @@ -0,0 +1,45 @@ +toBeArray() + ->toHaveKey('badge') + ->and($cabinet['paragraphs'])->toBeArray(); + + expect($hint)->toBeArray() + ->toHaveKey('title') + ->toHaveKey('button_text'); +}); + +test('netzwerk page cms seeder creates rows only once', function () { + $this->seed(\Database\Seeders\NetzwerkPageCmsSeeder::class); + + expect(CmsContent::where('group', 'netzwerk')->where('key', 'cabinet_partner')->exists())->toBeTrue() + ->and(CmsContent::where('group', 'netzwerk')->where('key', 'immobilien_hint')->exists())->toBeTrue(); + + $count = CmsContent::count(); + + $this->seed(\Database\Seeders\NetzwerkPageCmsSeeder::class); + + expect(CmsContent::count())->toBe($count); +}); + +test('cms_theme_section returns cabinet content from database after netzwerk page seed', function () { + $this->seed(\Database\Seeders\NetzwerkPageCmsSeeder::class); + + app()->setLocale('de'); + + $cabinet = cms_theme_section('netzwerk_cabinet_partner'); + + expect($cabinet)->toBeArray() + ->and($cabinet['badge'])->toBe('Premiumpartner'); +}); diff --git a/tests/Feature/CmsLegalSeederTest.php b/tests/Feature/CmsLegalSeederTest.php new file mode 100644 index 0000000..564cf6c --- /dev/null +++ b/tests/Feature/CmsLegalSeederTest.php @@ -0,0 +1,35 @@ +seed(\Database\Seeders\CmsLegalSeeder::class); + + expect(CmsContent::query()->where('group', 'legal')->count())->toBe(4); + + foreach (['impressum', 'privacy', 'terms', 'cookie_policy'] as $key) { + $row = CmsContent::query()->where('group', 'legal')->where('key', $key)->first(); + expect($row)->not->toBeNull() + ->and($row->type)->toBe('json'); + + $de = $row->getTranslation('value', 'de'); + expect($de)->toBeArray() + ->toHaveKeys(['title', 'meta_title', 'subtitle', 'back_link', 'content']) + ->and($de['title'])->not->toBeEmpty(); + } +}); + +test('legal_page liefert cms daten wenn vorhanden', function () { + $this->seed(\Database\Seeders\CmsLegalSeeder::class); + + $legal = legal_page('impressum'); + + expect($legal)->toBeArray() + ->toHaveKey('content') + ->and($legal['title'])->not->toBeEmpty(); +}); diff --git a/tests/Feature/ContactFormTest.php b/tests/Feature/ContactFormTest.php new file mode 100644 index 0000000..5dc537b --- /dev/null +++ b/tests/Feature/ContactFormTest.php @@ -0,0 +1,145 @@ +get('/contact') + ->assertSuccessful() + ->assertSeeLivewire(ContactForm::class); +}); + +it('contact form renders with privacy checkbox', function () { + Livewire::test(ContactForm::class) + ->assertSee('Datenschutzerklärung'); +}); + +it('contact form validates required fields', function () { + Livewire::test(ContactForm::class) + ->call('submit') + ->assertHasErrors(['firstName', 'lastName', 'email', 'subject', 'message', 'privacy']); +}); + +it('contact form requires privacy acceptance', function () { + Livewire::test(ContactForm::class) + ->set('firstName', 'Max') + ->set('lastName', 'Mustermann') + ->set('email', 'max@example.com') + ->set('subject', 'Allgemein') + ->set('message', 'Testnachricht') + ->set('privacy', false) + ->call('submit') + ->assertHasErrors(['privacy']); +}); + +it('contact form sends email on valid submission', function () { + Mail::fake(); + + $service = $this->mock(ContactFormService::class); + $service->shouldReceive('handle')->once(); + + Livewire::test(ContactForm::class) + ->set('firstName', 'Max') + ->set('lastName', 'Mustermann') + ->set('email', 'max@example.com') + ->set('subject', 'Allgemein') + ->set('message', 'Testnachricht für das Kontaktformular') + ->set('privacy', true) + ->call('submit') + ->assertHasNoErrors() + ->assertSet('success', true); +}); + +it('contact form resets after successful submission', function () { + Mail::fake(); + + $service = $this->mock(ContactFormService::class); + $service->shouldReceive('handle')->once(); + + Livewire::test(ContactForm::class) + ->set('firstName', 'Max') + ->set('lastName', 'Mustermann') + ->set('email', 'max@example.com') + ->set('subject', 'Allgemein') + ->set('message', 'Testnachricht') + ->set('privacy', true) + ->call('submit') + ->assertSet('firstName', '') + ->assertSet('lastName', '') + ->assertSet('email', '') + ->assertSet('message', '') + ->assertSet('privacy', false); +}); + +it('contact form blocks honeypot spam', function () { + Livewire::test(ContactForm::class) + ->set('firstName', 'Max') + ->set('lastName', 'Mustermann') + ->set('email', 'max@example.com') + ->set('subject', 'Allgemein') + ->set('message', 'Testnachricht') + ->set('privacy', true) + ->set('website', 'https://spam.com') + ->call('submit') + ->assertHasErrors(['website']); +}); + +it('immobilien contact form renders with privacy checkbox', function () { + Livewire::test(ImmobilienContactForm::class, [ + 'projectSlug' => 'test-projekt', + 'projectTitle' => 'Testprojekt Dubai', + 'interestOptions' => ['kauf' => 'Kauf', 'miete' => 'Miete'], + ]) + ->assertSee('Datenschutzerklärung') + ->assertSee('Exposé'); +}); + +it('immobilien contact form validates required fields', function () { + Livewire::test(ImmobilienContactForm::class) + ->call('submit') + ->assertHasErrors(['firstName', 'lastName', 'email', 'privacy']); +}); + +it('immobilien contact form requires privacy acceptance', function () { + Livewire::test(ImmobilienContactForm::class) + ->set('firstName', 'Max') + ->set('lastName', 'Mustermann') + ->set('email', 'max@example.com') + ->set('privacy', false) + ->call('submit') + ->assertHasErrors(['privacy']); +}); + +it('immobilien contact form sends inquiry on valid submission', function () { + Mail::fake(); + + $service = $this->mock(ContactFormService::class); + $service->shouldReceive('handle')->once(); + + Livewire::test(ImmobilienContactForm::class, [ + 'projectSlug' => 'azizi-creek-views-4', + 'projectTitle' => 'Creek Views 4', + 'interestOptions' => ['kauf' => 'Kauf'], + ]) + ->set('firstName', 'Max') + ->set('lastName', 'Mustermann') + ->set('email', 'max@example.com') + ->set('phone', '+49 123 456789') + ->set('interest', 'kauf') + ->set('message', 'Ich interessiere mich für diese Immobilie.') + ->set('privacy', true) + ->call('submit') + ->assertHasNoErrors() + ->assertSet('success', true); +}); + +it('immobilien show page renders livewire contact form component', function () { + $this->get('/immobilien/azizi-creek-views-4') + ->assertSuccessful() + ->assertSeeLivewire(ImmobilienContactForm::class); +}); diff --git a/tests/Feature/DisplayListTest.php b/tests/Feature/DisplayListTest.php new file mode 100644 index 0000000..62cc924 --- /dev/null +++ b/tests/Feature/DisplayListTest.php @@ -0,0 +1,148 @@ +forceRootUrl('https://'.$portalDomain); +}); + +test('display list requires authentication', function () { + $response = $this->get(route('admin.cms.displays')); + + $response->assertRedirect('/login'); +}); + +test('display list renders for authenticated users', function () { + $user = User::factory()->create(); + + $this->actingAs($user); + + $response = $this->get(route('admin.cms.displays')); + + $response->assertSuccessful(); + $response->assertSeeLivewire(DisplayList::class); +}); + +test('can create a display', function () { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(DisplayList::class) + ->call('openModal') + ->set('displayName', 'Display 1 - Eingang') + ->set('displayLocation', 'Schaufenster links') + ->call('save'); + + expect(Display::where('name', 'Display 1 - Eingang')->exists())->toBeTrue(); +}); + +test('can assign versions to a display', function () { + $user = User::factory()->create(); + $version1 = DisplayVersion::factory()->create(); + $version2 = DisplayVersion::factory()->create(); + $display = Display::factory()->create(); + + Livewire::actingAs($user) + ->test(DisplayList::class) + ->call('openModal', $display->id) + ->call('addVersion', $version1->id) + ->call('addVersion', $version2->id) + ->call('save'); + + $display->refresh(); + expect($display->versions)->toHaveCount(2); + expect($display->versions->first()->id)->toBe($version1->id); + expect($display->versions->last()->id)->toBe($version2->id); +}); + +test('can reorder versions in playlist', function () { + $user = User::factory()->create(); + $version1 = DisplayVersion::factory()->create(); + $version2 = DisplayVersion::factory()->create(); + $display = Display::factory()->create(); + + Livewire::actingAs($user) + ->test(DisplayList::class) + ->call('openModal', $display->id) + ->call('addVersion', $version1->id) + ->call('addVersion', $version2->id) + ->call('moveVersion', 1, 'up') + ->call('save'); + + $display->refresh(); + expect($display->versions->first()->id)->toBe($version2->id); + expect($display->versions->last()->id)->toBe($version1->id); +}); + +test('can remove version from playlist', function () { + $user = User::factory()->create(); + $version1 = DisplayVersion::factory()->create(); + $version2 = DisplayVersion::factory()->create(); + $display = Display::factory()->create(); + $display->versions()->attach([ + $version1->id => ['sort_order' => 0], + $version2->id => ['sort_order' => 1], + ]); + + Livewire::actingAs($user) + ->test(DisplayList::class) + ->call('openModal', $display->id) + ->call('removeVersion', 0) + ->call('save'); + + $display->refresh(); + expect($display->versions)->toHaveCount(1); + expect($display->versions->first()->id)->toBe($version2->id); +}); + +test('can delete a display', function () { + $user = User::factory()->create(); + $display = Display::factory()->create(); + + Livewire::actingAs($user) + ->test(DisplayList::class) + ->call('deleteDisplay', $display->id); + + expect(Display::find($display->id))->toBeNull(); +}); + +test('can toggle display active status', function () { + $user = User::factory()->create(); + $display = Display::factory()->create(['is_active' => true]); + + Livewire::actingAs($user) + ->test(DisplayList::class) + ->call('toggleActive', $display->id); + + expect($display->fresh()->is_active)->toBeFalse(); +}); + +test('validates required fields when creating display', function () { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(DisplayList::class) + ->call('openModal') + ->set('displayName', '') + ->call('save') + ->assertHasErrors(['displayName']); +}); + +test('does not add duplicate version to playlist', function () { + $user = User::factory()->create(); + $version = DisplayVersion::factory()->create(); + + $component = Livewire::actingAs($user) + ->test(DisplayList::class) + ->call('openModal') + ->call('addVersion', $version->id) + ->call('addVersion', $version->id); + + expect($component->get('selectedVersionIds'))->toHaveCount(1); +}); diff --git a/tests/Feature/DisplayMediaTest.php b/tests/Feature/DisplayMediaTest.php new file mode 100644 index 0000000..2022b4e --- /dev/null +++ b/tests/Feature/DisplayMediaTest.php @@ -0,0 +1,202 @@ +create(); + + expect($media->isUpload())->toBeTrue() + ->and($media->isExternal())->toBeFalse() + ->and($media->isImage())->toBeTrue() + ->and($media->isVideo())->toBeFalse() + ->and($media->source_type)->toBe('upload'); +}); + +it('creates an external video media', function () { + $media = DisplayMedia::factory()->externalVideo()->create(); + + expect($media->isExternal())->toBeTrue() + ->and($media->isUpload())->toBeFalse() + ->and($media->isVideo())->toBeTrue() + ->and($media->external_url)->toStartWith('https://'); +}); + +it('returns correct URL for uploaded media', function () { + $media = DisplayMedia::factory()->create(['path' => 'display-media/2026/03/test.webp']); + + expect($media->getUrl())->toContain('display-media/2026/03/test.webp'); +}); + +it('returns external URL for external media', function () { + $url = 'https://drive.google.com/file/d/abc123/view'; + $media = DisplayMedia::factory()->external()->create(['external_url' => $url]); + + expect($media->getUrl())->toBe($url); +}); + +it('returns human readable file size', function () { + $media = DisplayMedia::factory()->create(['file_size' => 1536000]); + expect($media->getHumanFileSize())->toBe('1.5 MB'); + + $external = DisplayMedia::factory()->external()->create(); + expect($external->getHumanFileSize())->toBe('Extern'); +}); + +it('returns display name from title or filename', function () { + $withTitle = DisplayMedia::factory()->create(['title' => 'Mein Video', 'filename' => 'file.mp4']); + expect($withTitle->getDisplayName())->toBe('Mein Video'); + + $withoutTitle = DisplayMedia::factory()->create(['title' => null, 'filename' => 'file.mp4']); + expect($withoutTitle->getDisplayName())->toBe('file.mp4'); +}); + +// ======================================== +// SCOPE TESTS +// ======================================== + +it('filters by image scope', function () { + DisplayMedia::factory()->create(['type' => 'image']); + DisplayMedia::factory()->video()->create(); + + expect(DisplayMedia::images()->count())->toBe(1); +}); + +it('filters by video scope', function () { + DisplayMedia::factory()->create(['type' => 'image']); + DisplayMedia::factory()->video()->create(); + + expect(DisplayMedia::videos()->count())->toBe(1); +}); + +it('filters by upload scope', function () { + DisplayMedia::factory()->create(); + DisplayMedia::factory()->external()->create(); + + expect(DisplayMedia::uploads()->count())->toBe(1); +}); + +it('filters by external scope', function () { + DisplayMedia::factory()->create(); + DisplayMedia::factory()->external()->create(); + + expect(DisplayMedia::externals()->count())->toBe(1); +}); + +it('filters by collection scope', function () { + DisplayMedia::factory()->create(['collection' => 'immobilien']); + DisplayMedia::factory()->create(['collection' => 'moebel']); + + expect(DisplayMedia::inCollection('immobilien')->count())->toBe(1); +}); + +it('filters by active scope', function () { + DisplayMedia::factory()->create(['is_active' => true]); + DisplayMedia::factory()->create(['is_active' => false]); + + expect(DisplayMedia::active()->count())->toBe(1); +}); + +// ======================================== +// SERVICE TESTS +// ======================================== + +it('stores an uploaded file', function () { + $service = app(DisplayMediaService::class); + $file = UploadedFile::fake()->image('test-photo.jpg', 800, 600); + + $media = $service->storeUpload($file, 'immobilien'); + + expect($media)->toBeInstanceOf(DisplayMedia::class) + ->and($media->filename)->toBe('test-photo.jpg') + ->and($media->source_type)->toBe('upload') + ->and($media->type)->toBe('image') + ->and($media->collection)->toBe('immobilien') + ->and($media->path)->not->toBeNull(); + + Storage::disk('public')->assertExists($media->path); +}); + +it('stores a video upload', function () { + $service = app(DisplayMediaService::class); + $file = UploadedFile::fake()->create('showroom.mp4', 10000, 'video/mp4'); + + $media = $service->storeUpload($file); + + expect($media->type)->toBe('video') + ->and($media->mime_type)->toBe('video/mp4'); +}); + +it('creates media from external URL', function () { + $service = app(DisplayMediaService::class); + + $media = $service->createFromUrl( + url: 'https://drive.google.com/file/d/abc123/view', + type: 'video', + title: 'Showroom Tour 4K', + collection: 'brand', + ); + + expect($media->source_type)->toBe('external') + ->and($media->external_url)->toBe('https://drive.google.com/file/d/abc123/view') + ->and($media->title)->toBe('Showroom Tour 4K') + ->and($media->type)->toBe('video') + ->and($media->collection)->toBe('brand') + ->and($media->file_size)->toBe(0); +}); + +it('deletes uploaded media and its file', function () { + $service = app(DisplayMediaService::class); + $file = UploadedFile::fake()->image('delete-me.jpg'); + $media = $service->storeUpload($file); + $path = $media->path; + + Storage::disk('public')->assertExists($path); + + $service->delete($media); + + Storage::disk('public')->assertMissing($path); + expect(DisplayMedia::find($media->id))->toBeNull(); +}); + +it('deletes external media record', function () { + $service = app(DisplayMediaService::class); + $media = $service->createFromUrl('https://example.com/video.mp4', 'video'); + + $service->delete($media); + + expect(DisplayMedia::find($media->id))->toBeNull(); +}); + +// ======================================== +// ROUTE TESTS +// ======================================== + +it('requires authentication for display media library', function () { + $this->get(route('admin.cms.display-media')) + ->assertRedirect(); +}); + +it('loads display media library for authenticated admin', function () { + Role::findOrCreate('Super-Admin', 'web'); + $user = User::factory()->create(); + $user->assignRole('Super-Admin'); + + $this->actingAs($user) + ->get(route('admin.cms.display-media')) + ->assertSuccessful(); +}); diff --git a/tests/Feature/DisplayVersionApiTest.php b/tests/Feature/DisplayVersionApiTest.php new file mode 100644 index 0000000..b017205 --- /dev/null +++ b/tests/Feature/DisplayVersionApiTest.php @@ -0,0 +1,168 @@ +forceRootUrl('https://'.$portalDomain); +}); + +test('returns playlist with video-display config', function () { + $version = DisplayVersion::factory()->create(['type' => 'video-display']); + DisplayVersionItem::factory()->create([ + 'display_version_id' => $version->id, + 'item_type' => 'video', + 'content' => ['filename' => 'test.mp4', 'title' => 'Test', 'position' => 30], + ]); + DisplayVersionItem::factory()->create([ + 'display_version_id' => $version->id, + 'item_type' => 'footer', + 'content' => ['headline' => 'Hello', 'subline' => 'World', 'url' => null], + ]); + $display = Display::factory()->create(); + $display->versions()->attach($version->id, ['sort_order' => 0]); + + $response = $this->getJson("/api/display/{$display->id}/config"); + + $response->assertSuccessful(); + $response->assertJsonStructure(['playlist', 'updated_at']); + $response->assertJsonPath('playlist.0.type', 'video-display'); + $response->assertJsonPath('playlist.0.videoPlaylist.0.src', 'assets/test.mp4'); + $response->assertJsonPath('playlist.0.footerContent.0.headline', 'Hello'); +}); + +test('returns playlist with b2in config', function () { + $version = DisplayVersion::factory()->create([ + 'type' => 'b2in', + 'settings' => ['theme' => 'dark', 'footer_name' => 'Marcel'], + ]); + DisplayVersionItem::factory()->create([ + 'display_version_id' => $version->id, + 'item_type' => 'media', + 'content' => [ + 'category' => 'immobilien', + 'media_type' => 'image', + 'media_url' => '../assets/test.jpg', + 'headline' => 'Headline', + 'subline' => 'Subline', + 'duration_seconds' => 10, + ], + ]); + $display = Display::factory()->create(); + $display->versions()->attach($version->id, ['sort_order' => 0]); + + $response = $this->getJson("/api/display/{$display->id}/config"); + + $response->assertSuccessful(); + $response->assertJsonPath('playlist.0.type', 'b2in'); + $response->assertJsonPath('playlist.0.settings.theme', 'dark'); + $response->assertJsonPath('playlist.0.items.0.category', 'immobilien'); +}); + +test('returns playlist with offers config', function () { + $version = DisplayVersion::factory()->create([ + 'type' => 'offers', + 'settings' => ['loop' => true], + ]); + DisplayVersionItem::factory()->create([ + 'display_version_id' => $version->id, + 'item_type' => 'slide', + 'content' => [ + 'type' => 'product-hero', + 'duration' => 10000, + 'image_url' => '../assets/goya1.jpg', + 'badge_text' => 'Einzelstück', + 'eyebrow' => 'Hersteller: Sudbrock', + 'title' => 'GOYA Sideboard', + 'subline' => '', + 'price' => '489 €', + 'original_price' => 'statt 4.744 €', + 'tag_text' => '', + 'bullets' => [], + 'disclaimer' => '', + 'qr_url' => 'https://cabinet-bielefeld.de', + 'qr_title' => 'Reservieren', + 'contact' => "0521 98620100\nTel. oder WhatsApp", + 'show_brand_text' => false, + 'brand_tagline' => '', + ], + ]); + $display = Display::factory()->create(); + $display->versions()->attach($version->id, ['sort_order' => 0]); + + $response = $this->getJson("/api/display/{$display->id}/config"); + + $response->assertSuccessful(); + $response->assertJsonPath('playlist.0.type', 'offers'); + $response->assertJsonPath('playlist.0.slides.0.type', 'product-hero'); + $response->assertJsonPath('playlist.0.slides.0.title', 'GOYA Sideboard'); + $response->assertJsonPath('playlist.0.slides.0.price', '489 €'); + $response->assertJsonPath('playlist.0.slides.0.qr_url', 'https://cabinet-bielefeld.de'); +}); + +test('returns playlist with multiple versions in order', function () { + $videoVersion = DisplayVersion::factory()->create(['type' => 'video-display', 'name' => 'Videos']); + DisplayVersionItem::factory()->create([ + 'display_version_id' => $videoVersion->id, + 'item_type' => 'video', + 'content' => ['filename' => 'test.mp4', 'title' => 'Test', 'position' => 25], + ]); + + $b2inVersion = DisplayVersion::factory()->create(['type' => 'b2in', 'name' => 'B2in']); + DisplayVersionItem::factory()->create([ + 'display_version_id' => $b2inVersion->id, + 'item_type' => 'media', + 'content' => ['category' => 'immobilien', 'media_type' => 'image', 'media_url' => 'test.jpg', 'headline' => 'H', 'subline' => 'S', 'duration_seconds' => 5], + ]); + + $display = Display::factory()->create(); + $display->versions()->attach([ + $videoVersion->id => ['sort_order' => 0], + $b2inVersion->id => ['sort_order' => 1], + ]); + + $response = $this->getJson("/api/display/{$display->id}/config"); + + $response->assertSuccessful(); + $response->assertJsonCount(2, 'playlist'); + $response->assertJsonPath('playlist.0.type', 'video-display'); + $response->assertJsonPath('playlist.1.type', 'b2in'); +}); + +test('returns 404 for inactive display', function () { + $version = DisplayVersion::factory()->create(); + $display = Display::factory()->create(['is_active' => false]); + $display->versions()->attach($version->id, ['sort_order' => 0]); + + $response = $this->getJson("/api/display/{$display->id}/config"); + + $response->assertNotFound(); +}); + +test('returns 404 for display without versions', function () { + $display = Display::factory()->create(); + + $response = $this->getJson("/api/display/{$display->id}/config"); + + $response->assertNotFound(); +}); + +test('check endpoint returns only updated_at', function () { + $version = DisplayVersion::factory()->create(); + $display = Display::factory()->create(); + $display->versions()->attach($version->id, ['sort_order' => 0]); + + $response = $this->getJson("/api/display/{$display->id}/check"); + + $response->assertSuccessful(); + $response->assertJsonStructure(['updated_at']); +}); + +test('existing display config api still works', function () { + $response = $this->getJson('/api/display/config'); + + $response->assertSuccessful(); + $response->assertJsonStructure(['videoPlaylist', 'footerContent']); +}); diff --git a/tests/Feature/DisplayVersionTest.php b/tests/Feature/DisplayVersionTest.php new file mode 100644 index 0000000..56e91ed --- /dev/null +++ b/tests/Feature/DisplayVersionTest.php @@ -0,0 +1,315 @@ +forceRootUrl('https://'.$portalDomain); +}); + +// ======================================== +// DisplayVersionList +// ======================================== + +test('display version list requires authentication', function () { + $response = $this->get(route('admin.cms.display-versions')); + + $response->assertRedirect('/login'); +}); + +test('display version list renders for authenticated users', function () { + $user = User::factory()->create(); + + $this->actingAs($user); + + $response = $this->get(route('admin.cms.display-versions')); + + $response->assertSuccessful(); + $response->assertSeeLivewire(DisplayVersionList::class); +}); + +test('can create a display version', function () { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(DisplayVersionList::class) + ->set('newName', 'Test Version') + ->set('newType', 'b2in') + ->call('createVersion'); + + expect(DisplayVersion::where('name', 'Test Version')->exists())->toBeTrue(); + + $version = DisplayVersion::where('name', 'Test Version')->first(); + expect($version->type)->toBe(DisplayVersionType::B2in); + expect($version->settings)->toBeArray(); + expect($version->settings)->toHaveKey('theme'); +}); + +test('create version validates required fields', function () { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(DisplayVersionList::class) + ->set('newName', '') + ->set('newType', '') + ->call('createVersion') + ->assertHasErrors(['newName', 'newType']); +}); + +test('can delete a display version', function () { + $user = User::factory()->create(); + $version = DisplayVersion::factory()->create(); + + Livewire::actingAs($user) + ->test(DisplayVersionList::class) + ->call('deleteVersion', $version->id); + + expect(DisplayVersion::find($version->id))->toBeNull(); +}); + +test('can toggle display version active status', function () { + $user = User::factory()->create(); + $version = DisplayVersion::factory()->create(['is_active' => true]); + + Livewire::actingAs($user) + ->test(DisplayVersionList::class) + ->call('toggleActive', $version->id); + + expect($version->fresh()->is_active)->toBeFalse(); +}); + +// ======================================== +// DisplayVersionEditor +// ======================================== + +test('display version editor renders with correct version data', function () { + $user = User::factory()->create(); + $version = DisplayVersion::factory()->create(['name' => 'My Version']); + + $this->actingAs($user); + + $response = $this->get(route('admin.cms.display-version-edit', $version)); + + $response->assertSuccessful(); + $response->assertSeeLivewire(DisplayVersionEditor::class); +}); + +test('can add a video item to video-display version', function () { + $user = User::factory()->create(); + $version = DisplayVersion::factory()->create(['type' => 'video-display']); + + Livewire::actingAs($user) + ->test(DisplayVersionEditor::class, ['displayVersion' => $version]) + ->call('openItemModal', null, 'video') + ->set('videoFilename', 'test.mp4') + ->set('videoTitle', 'Test Video') + ->set('videoPosition', 50) + ->call('saveItem'); + + $item = DisplayVersionItem::where('display_version_id', $version->id)->first(); + expect($item)->not->toBeNull(); + expect($item->item_type)->toBe('video'); + expect($item->content['filename'])->toBe('test.mp4'); + expect($item->content['position'])->toBe(50); +}); + +test('can add a media item to b2in version', function () { + $user = User::factory()->create(); + $version = DisplayVersion::factory()->create(['type' => 'b2in']); + + Livewire::actingAs($user) + ->test(DisplayVersionEditor::class, ['displayVersion' => $version]) + ->call('openItemModal', null, 'media') + ->set('mediaType', 'video') + ->set('mediaCategory', 'immobilien') + ->set('mediaUrl', '../assets/test.mp4') + ->set('mediaHeadline', 'Test Headline') + ->set('mediaSubline', 'Test Subline') + ->call('saveItem'); + + $item = DisplayVersionItem::where('display_version_id', $version->id)->first(); + expect($item)->not->toBeNull(); + expect($item->item_type)->toBe('media'); + expect($item->content['media_type'])->toBe('video'); + expect($item->content['headline'])->toBe('Test Headline'); +}); + +test('can edit an existing item', function () { + $user = User::factory()->create(); + $version = DisplayVersion::factory()->create(['type' => 'video-display']); + $item = DisplayVersionItem::factory()->create([ + 'display_version_id' => $version->id, + 'item_type' => 'video', + 'content' => ['filename' => 'old.mp4', 'title' => 'Old', 'position' => 25], + ]); + + Livewire::actingAs($user) + ->test(DisplayVersionEditor::class, ['displayVersion' => $version]) + ->call('openItemModal', $item->id) + ->set('videoFilename', 'new.mp4') + ->set('videoTitle', 'New Title') + ->call('saveItem'); + + $item->refresh(); + expect($item->content['filename'])->toBe('new.mp4'); + expect($item->content['title'])->toBe('New Title'); +}); + +test('can delete an item', function () { + $user = User::factory()->create(); + $version = DisplayVersion::factory()->create(); + $item = DisplayVersionItem::factory()->create(['display_version_id' => $version->id]); + + Livewire::actingAs($user) + ->test(DisplayVersionEditor::class, ['displayVersion' => $version]) + ->call('deleteItem', $item->id); + + expect(DisplayVersionItem::find($item->id))->toBeNull(); +}); + +test('can toggle item active status', function () { + $user = User::factory()->create(); + $version = DisplayVersion::factory()->create(); + $item = DisplayVersionItem::factory()->create([ + 'display_version_id' => $version->id, + 'is_active' => true, + ]); + + Livewire::actingAs($user) + ->test(DisplayVersionEditor::class, ['displayVersion' => $version]) + ->call('toggleItemStatus', $item->id); + + expect($item->fresh()->is_active)->toBeFalse(); +}); + +test('can reorder items', function () { + $user = User::factory()->create(); + $version = DisplayVersion::factory()->create(); + $item1 = DisplayVersionItem::factory()->create([ + 'display_version_id' => $version->id, + 'item_type' => 'video', + 'sort_order' => 0, + ]); + $item2 = DisplayVersionItem::factory()->create([ + 'display_version_id' => $version->id, + 'item_type' => 'video', + 'sort_order' => 1, + ]); + + Livewire::actingAs($user) + ->test(DisplayVersionEditor::class, ['displayVersion' => $version]) + ->call('moveItem', $item2->id, 'up'); + + expect($item1->fresh()->sort_order)->toBe(1); + expect($item2->fresh()->sort_order)->toBe(0); +}); + +test('can add a slide item to offers version', function () { + $user = User::factory()->create(); + $version = DisplayVersion::factory()->create(['type' => 'offers']); + + Livewire::actingAs($user) + ->test(DisplayVersionEditor::class, ['displayVersion' => $version]) + ->call('openItemModal', null, 'slide') + ->set('slideType', 'product-hero') + ->set('slideDuration', 10000) + ->set('slideImageUrl', '../assets/goya1.jpg') + ->set('slideBadge', 'Einzelstück') + ->set('slideEyebrow', 'Hersteller: Sudbrock') + ->set('slideTitle', 'GOYA Sideboard') + ->set('slidePrice', '489 €') + ->set('slideOriginalPrice', 'statt 4.744 €') + ->set('slideQrUrl', 'https://cabinet-bielefeld.de') + ->set('slideQrTitle', 'Reservieren') + ->set('slideContact', '0521 98620100') + ->call('saveItem'); + + $item = DisplayVersionItem::where('display_version_id', $version->id)->first(); + expect($item)->not->toBeNull(); + expect($item->item_type)->toBe('slide'); + expect($item->content['type'])->toBe('product-hero'); + expect($item->content['title'])->toBe('GOYA Sideboard'); + expect($item->content['price'])->toBe('489 €'); + expect($item->content['image_url'])->toBe('../assets/goya1.jpg'); + expect($item->content['qr_url'])->toBe('https://cabinet-bielefeld.de'); +}); + +test('can add a slide with bullets to offers version', function () { + $user = User::factory()->create(); + $version = DisplayVersion::factory()->create(['type' => 'offers']); + + Livewire::actingAs($user) + ->test(DisplayVersionEditor::class, ['displayVersion' => $version]) + ->call('openItemModal', null, 'slide') + ->set('slideType', 'product-details') + ->set('slideTitle', 'GOYA Sideboard') + ->set('slideBullets', ['Einzelstück', 'Abholung möglich', 'Lieferung optional']) + ->call('saveItem'); + + $item = DisplayVersionItem::where('display_version_id', $version->id)->first(); + expect($item->content['bullets'])->toHaveCount(3); + expect($item->content['bullets'][0])->toBe('Einzelstück'); +}); + +test('can edit a slide item with new fields', function () { + $user = User::factory()->create(); + $version = DisplayVersion::factory()->create(['type' => 'offers']); + $item = DisplayVersionItem::factory()->create([ + 'display_version_id' => $version->id, + 'item_type' => 'slide', + 'content' => [ + 'type' => 'product-hero', + 'duration' => 10000, + 'image_url' => '../assets/old.jpg', + 'badge_text' => 'Old Badge', + 'eyebrow' => 'Old', + 'title' => 'Old Title', + 'subline' => '', + 'price' => '100 €', + 'original_price' => '', + 'tag_text' => '', + 'bullets' => [], + 'disclaimer' => '', + 'qr_url' => 'https://example.com', + 'qr_title' => 'QR', + 'contact' => '', + 'show_brand_text' => false, + 'brand_tagline' => '', + ], + ]); + + Livewire::actingAs($user) + ->test(DisplayVersionEditor::class, ['displayVersion' => $version]) + ->call('openItemModal', $item->id) + ->set('slideTitle', 'New Title') + ->set('slidePrice', '299 €') + ->call('saveItem'); + + $item->refresh(); + expect($item->content['title'])->toBe('New Title'); + expect($item->content['price'])->toBe('299 €'); + expect($item->content['image_url'])->toBe('../assets/old.jpg'); +}); + +test('can save version settings', function () { + $user = User::factory()->create(); + $version = DisplayVersion::factory()->create([ + 'type' => 'b2in', + 'settings' => ['theme' => 'dark'], + ]); + + Livewire::actingAs($user) + ->test(DisplayVersionEditor::class, ['displayVersion' => $version]) + ->call('openSettingsModal') + ->set('settings.theme', 'light') + ->call('saveSettings'); + + expect($version->fresh()->settings['theme'])->toBe('light'); +}); diff --git a/tests/Feature/ImmobilienShowTest.php b/tests/Feature/ImmobilienShowTest.php new file mode 100644 index 0000000..22b014d --- /dev/null +++ b/tests/Feature/ImmobilienShowTest.php @@ -0,0 +1,65 @@ +get('/immobilien') + ->assertSuccessful() + ->assertSee('Creek Views 4'); +}); + +it('immobilien page shows new hero section', function () { + $this->get('/immobilien') + ->assertSuccessful() + ->assertSee('globale') + ->assertSee('Dynamik'); +}); + +it('immobilien page shows warum dubai section', function () { + $this->get('/immobilien') + ->assertSuccessful() + ->assertSee('Investment in Dubai') + ->assertSee('0 % Steuern') + ->assertSee('Golden Visa'); +}); + +it('immobilien page shows kaufprozess section', function () { + $this->get('/immobilien') + ->assertSuccessful() + ->assertSee('Kaufprozess') + ->assertSee('Reservierung') + ->assertSee('Finaler Kaufvertrag'); +}); + +it('immobilien page shows bruecke section', function () { + $this->get('/immobilien') + ->assertSuccessful() + ->assertSee('Meine Aufgabe') + ->assertSee('Ihr B2in-Vorteil'); +}); + +it('immobilien page shows mindset check section', function () { + $this->get('/immobilien') + ->assertSuccessful() + ->assertSee('Investor') + ->assertSee('Der Schritt ist kleiner'); +}); + +it('immobilien show page loads for valid slug', function () { + $this->get('/immobilien/azizi-creek-views-4') + ->assertSuccessful() + ->assertSee('Creek Views 4') + ->assertSee('Al Jaddaf, Dubai') + ->assertSee('Galerie') + ->assertSee('Starkes Investment'); +}); + +it('immobilien show page returns 404 for invalid slug', function () { + $this->get('/immobilien/nonexistent-project') + ->assertNotFound(); +}); + +it('ecosystem redirects to netzwerk via partner', function () { + $this->get('/ecosystem') + ->assertRedirect('/partner'); +}); diff --git a/tests/Feature/InteriorPageTest.php b/tests/Feature/InteriorPageTest.php new file mode 100644 index 0000000..af42e31 --- /dev/null +++ b/tests/Feature/InteriorPageTest.php @@ -0,0 +1,32 @@ +get('/interior') + ->assertRedirect('/netzwerk'); +}); + +it('partner redirects to netzwerk', function () { + $this->get('/partner') + ->assertRedirect('/netzwerk'); +}); + +it('netzwerk page loads successfully', function () { + $this->get('/netzwerk') + ->assertSuccessful() + ->assertSee('B2in'); +}); + +it('netzwerk page shows teaser cards', function () { + $this->get('/netzwerk') + ->assertSuccessful() + ->assertSee('Einrichtungsnetzwerk') + ->assertSee('In Entwicklung'); +}); + +it('netzwerk page shows immobilien cross-link', function () { + $this->get('/netzwerk') + ->assertSuccessful() + ->assertSee('Immobilien-Projekten'); +}); diff --git a/tests/Feature/LiveSiteReviewTest.php b/tests/Feature/LiveSiteReviewTest.php new file mode 100644 index 0000000..3a92618 --- /dev/null +++ b/tests/Feature/LiveSiteReviewTest.php @@ -0,0 +1,74 @@ + $this->get('/')->assertSuccessful()); +it('immobilien page loads', fn () => $this->get('/immobilien')->assertSuccessful()); +it('immobilien expose loads', fn () => $this->get('/immobilien/azizi-creek-views-4')->assertSuccessful()); +it('netzwerk page loads', fn () => $this->get('/netzwerk')->assertSuccessful()); +it('magazin list loads', fn () => $this->get('/magazin')->assertSuccessful()); +it('magazin article 1 loads', fn () => $this->get('/magazin/1')->assertSuccessful()); +it('magazin article 2 loads', fn () => $this->get('/magazin/2')->assertSuccessful()); +it('magazin article 3 loads', fn () => $this->get('/magazin/3')->assertSuccessful()); +it('magazin article 4 loads', fn () => $this->get('/magazin/4')->assertSuccessful()); +it('magazin article 5 loads', fn () => $this->get('/magazin/5')->assertSuccessful()); +it('about page loads', fn () => $this->get('/about')->assertSuccessful()); +it('contact page loads', fn () => $this->get('/contact')->assertSuccessful()); +it('faq page loads', fn () => $this->get('/faq')->assertSuccessful()); + +// ======================================== +// Redirects +// ======================================== + +it('interior redirects to netzwerk', fn () => $this->get('/interior')->assertRedirect('/netzwerk')); +it('partner redirects to netzwerk', fn () => $this->get('/partner')->assertRedirect('/netzwerk')); +it('ecosystem redirects to partner', fn () => $this->get('/ecosystem')->assertRedirect('/partner')); + +// ======================================== +// Dev/Archiv-Seiten +// ======================================== + +it('dev sitemap loads', fn () => $this->get('/dev/sitemap')->assertSuccessful()); +it('dev immobilien v1 loads', fn () => $this->get('/dev/immobilien-v1')->assertSuccessful()); +it('dev interior v1 loads', fn () => $this->get('/dev/interior-v1')->assertSuccessful()); +it('dev partner v1 loads', fn () => $this->get('/dev/partner-v1')->assertSuccessful()); + +// ======================================== +// 404 für ungültige Routen +// ======================================== + +it('invalid immobilien slug returns 404', fn () => $this->get('/immobilien/nonexistent')->assertNotFound()); + +// ======================================== +// Announcement Bar auf allen Seiten +// ======================================== + +it('announcement bar visible on homepage', fn () => $this->get('/')->assertSee('Azizi Creek Views 4')); +it('announcement bar visible on about', fn () => $this->get('/about')->assertSee('Azizi Creek Views 4')); + +// ======================================== +// Kritische Inhalte vorhanden +// ======================================== + +it('homepage has navigation', fn () => $this->get('/')->assertSee('Immobilien')->assertSee('Netzwerk')->assertSee('Magazin')); +it('immobilien has all five sections', function () { + $this->get('/immobilien') + ->assertSee('Dynamik') + ->assertSee('Investment in Dubai') + ->assertSee('Kaufprozess') + ->assertSee('Meine Aufgabe') + ->assertSee('Investor'); +}); +it('netzwerk has teaser cards', fn () => $this->get('/netzwerk')->assertSee('Einrichtungsnetzwerk')->assertSee('In Entwicklung')); +it('magazin has all five articles', function () { + $this->get('/magazin') + ->assertSee('Escrow-System') + ->assertSee('Al Jaddaf') + ->assertSee('Turnkey') + ->assertSee('Supply-Chain') + ->assertSee('Local for Local'); +}); diff --git a/tests/Feature/MagazinPageTest.php b/tests/Feature/MagazinPageTest.php new file mode 100644 index 0000000..c925bed --- /dev/null +++ b/tests/Feature/MagazinPageTest.php @@ -0,0 +1,33 @@ +get('/magazin') + ->assertSuccessful() + ->assertSee('Magazin'); +}); + +it('magazin list shows all five articles', function () { + $this->get('/magazin') + ->assertSuccessful() + ->assertSee('Escrow-System') + ->assertSee('Al Jaddaf') + ->assertSee('Turnkey-Investments') + ->assertSee('Supply-Chain-Management') + ->assertSee('Local for Local'); +}); + +it('magazin detail page loads for article 1', function () { + $this->get('/magazin/1') + ->assertSuccessful() + ->assertSee('Escrow-System') + ->assertSee('Dubai Land Department'); +}); + +it('magazin detail page loads for article 5', function () { + $this->get('/magazin/5') + ->assertSuccessful() + ->assertSee('Local for Local') + ->assertSee('Support your Locals'); +}); diff --git a/tests/Feature/Models/CabinetTabletSettingTest.php b/tests/Feature/Models/CabinetTabletSettingTest.php new file mode 100644 index 0000000..9ce6ac5 --- /dev/null +++ b/tests/Feature/Models/CabinetTabletSettingTest.php @@ -0,0 +1,208 @@ +toBe(0); + + $settings = CabinetTabletSetting::current(); + + expect($settings)->toBeInstanceOf(CabinetTabletSetting::class); + expect($settings->id)->toBe(1); + expect(CabinetTabletSetting::count())->toBe(1); +}); + +test('current() returns the same row on subsequent calls', function () { + $first = CabinetTabletSetting::current(); + $second = CabinetTabletSetting::current(); + + expect($first->id)->toBe($second->id); + expect(CabinetTabletSetting::count())->toBe(1); +}); + +test('getHoursArray returns all seven days as display strings', function () { + $settings = CabinetTabletSetting::factory()->create(); + + $hours = $settings->getHoursArray(); + + expect($hours)->toHaveKeys(['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']); + expect($hours['monday'])->toBe('10:00 – 18:00'); + expect($hours['sunday'])->toBe('Geschlossen'); +}); + +test('getHoursArray shows Geschlossen for days with null times', function () { + $settings = CabinetTabletSetting::factory()->create([ + 'hours_wednesday_open' => null, + 'hours_wednesday_close' => null, + ]); + + expect($settings->getHoursArray()['wednesday'])->toBe('Geschlossen'); +}); + +test('clearOverrides nulls both override fields', function () { + $settings = CabinetTabletSetting::factory()->create([ + 'override_open_today' => '09:00', + 'override_close_today' => '20:00', + ]); + + $settings->clearOverrides(); + $settings->refresh(); + + expect($settings->override_open_today)->toBeNull(); + expect($settings->override_close_today)->toBeNull(); +}); + +test('next_appointment_date is cast to date', function () { + $settings = CabinetTabletSetting::factory()->create([ + 'next_appointment_date' => '2026-03-15', + ]); + + expect($settings->next_appointment_date)->toBeInstanceOf(Carbon::class); + expect($settings->next_appointment_date->format('Y-m-d'))->toBe('2026-03-15'); +}); + +test('computeStatus returns open when within opening hours', function () { + $settings = CabinetTabletSetting::factory()->create([ + 'store_status' => 'auto', + 'hours_thursday_open' => '10:00', + 'hours_thursday_close' => '18:00', + ]); + + // Thursday 12:00 Berlin time + Carbon::setTestNow(Carbon::create(2026, 3, 5, 12, 0, 0, 'Europe/Berlin')); + + $result = $settings->computeStatus(); + + expect($result['status'])->toBe('open'); + expect($result['today_close'])->toBe('18:00'); + expect($result['next_open'])->toBeNull(); + + Carbon::setTestNow(); +}); + +test('computeStatus returns closed before opening time', function () { + $settings = CabinetTabletSetting::factory()->create([ + 'store_status' => 'auto', + 'hours_thursday_open' => '10:00', + 'hours_thursday_close' => '18:00', + ]); + + // Thursday 08:00 Berlin time + Carbon::setTestNow(Carbon::create(2026, 3, 5, 8, 0, 0, 'Europe/Berlin')); + + $result = $settings->computeStatus(); + + expect($result['status'])->toBe('closed'); + expect($result['next_open']['label'])->toBe('Heute'); + expect($result['next_open']['time'])->toBe('10:00'); + + Carbon::setTestNow(); +}); + +test('computeStatus returns closed after closing time with next day', function () { + $settings = CabinetTabletSetting::factory()->create([ + 'store_status' => 'auto', + 'hours_thursday_open' => '10:00', + 'hours_thursday_close' => '18:00', + 'hours_friday_open' => '10:00', + 'hours_friday_close' => '18:00', + ]); + + // Thursday 20:00 Berlin time + Carbon::setTestNow(Carbon::create(2026, 3, 5, 20, 0, 0, 'Europe/Berlin')); + + $result = $settings->computeStatus(); + + expect($result['status'])->toBe('closed'); + expect($result['next_open']['label'])->toBe('Morgen'); + expect($result['next_open']['time'])->toBe('10:00'); + + Carbon::setTestNow(); +}); + +test('computeStatus returns notice when store_status is notice', function () { + $settings = CabinetTabletSetting::factory()->create([ + 'store_status' => 'notice', + 'notice_headline' => 'Wir haben Urlaub', + ]); + + $result = $settings->computeStatus(); + + expect($result['status'])->toBe('notice'); + expect($result['next_open'])->toBeNull(); +}); + +test('computeStatus returns warning when store_status is warning', function () { + $settings = CabinetTabletSetting::factory()->create([ + 'store_status' => 'warning', + 'notice_headline' => 'Notfall', + ]); + + $result = $settings->computeStatus(); + + expect($result['status'])->toBe('warning'); + expect($result['next_open'])->toBeNull(); +}); + +test('computeStatus returns closed when store_status is manually closed', function () { + $settings = CabinetTabletSetting::factory()->create([ + 'store_status' => 'closed', + 'hours_friday_open' => '10:00', + 'hours_friday_close' => '18:00', + ]); + + // Thursday 12:00 (normally would be open, but manually closed) + Carbon::setTestNow(Carbon::create(2026, 3, 5, 12, 0, 0, 'Europe/Berlin')); + + $result = $settings->computeStatus(); + + expect($result['status'])->toBe('closed'); + expect($result['next_open']['label'])->toBe('Morgen'); + + Carbon::setTestNow(); +}); + +test('computeStatus uses override times when set', function () { + $settings = CabinetTabletSetting::factory()->create([ + 'store_status' => 'auto', + 'hours_thursday_open' => '10:00', + 'hours_thursday_close' => '18:00', + 'override_open_today' => '12:00', + 'override_close_today' => '16:00', + ]); + + // Thursday 11:00 – after regular open, but before override open + Carbon::setTestNow(Carbon::create(2026, 3, 5, 11, 0, 0, 'Europe/Berlin')); + + $result = $settings->computeStatus(); + + expect($result['status'])->toBe('closed'); + expect($result['next_open']['time'])->toBe('12:00'); + + Carbon::setTestNow(); +}); + +test('computeStatus skips closed days when finding next open time', function () { + $settings = CabinetTabletSetting::factory()->create([ + 'store_status' => 'auto', + 'hours_thursday_open' => '10:00', + 'hours_thursday_close' => '18:00', + 'hours_friday_open' => null, + 'hours_friday_close' => null, + 'hours_saturday_open' => '10:00', + 'hours_saturday_close' => '14:00', + 'hours_sunday_open' => null, + 'hours_sunday_close' => null, + ]); + + // Thursday 20:00 – after closing, Friday is closed + Carbon::setTestNow(Carbon::create(2026, 3, 5, 20, 0, 0, 'Europe/Berlin')); + + $result = $settings->computeStatus(); + + expect($result['next_open']['label'])->toBe('Samstag'); + expect($result['next_open']['time'])->toBe('10:00'); + + Carbon::setTestNow(); +}); diff --git a/tests/Feature/PartnerPolicyTest.php b/tests/Feature/PartnerPolicyTest.php index c48257d..47bbd2a 100644 --- a/tests/Feature/PartnerPolicyTest.php +++ b/tests/Feature/PartnerPolicyTest.php @@ -13,8 +13,8 @@ beforeEach(function () { Role::create(['name' => 'Admin']); Role::create(['name' => 'Retailer']); - Permission::create(['name' => 'curate products']); - Role::findByName('Admin')->givePermissionTo('curate products'); + $permission = Permission::firstOrCreate(['name' => 'curate products']); + Role::findByName('Admin')->givePermissionTo($permission); }); test('admin can view any partner', function () { diff --git a/tests/Feature/PartnerSelfServiceProfileTest.php b/tests/Feature/PartnerSelfServiceProfileTest.php new file mode 100644 index 0000000..aae877e --- /dev/null +++ b/tests/Feature/PartnerSelfServiceProfileTest.php @@ -0,0 +1,316 @@ +forgetCachedPermissions(); + Role::firstOrCreate(['name' => 'Retailer']); + Role::firstOrCreate(['name' => 'Manufacturer']); + Storage::fake('public'); +}); + +// ── Zugriff ────────────────────────────────────────────────────────────────── + +test('retailer can access my-data page', function () { + $partner = Partner::factory()->setupCompleted()->create(); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Retailer'); + + $this->actingAs($user) + ->get(route('partner.my-data')) + ->assertSuccessful(); +}); + +test('manufacturer can access my-data page', function () { + $partner = Partner::factory()->setupCompleted()->create(['type' => 'manufacturer']); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Manufacturer'); + + $this->actingAs($user) + ->get(route('partner.my-data')) + ->assertSuccessful(); +}); + +test('user without partner is redirected from my-data', function () { + Role::firstOrCreate(['name' => 'Admin']); + $user = User::factory()->create(['partner_id' => null]); + $user->assignRole('Admin'); + + $this->actingAs($user) + ->get(route('partner.my-data')) + ->assertRedirect(route('dashboard')); +}); + +// ── Händler: Story + Profil-Felder ─────────────────────────────────────────── + +test('retailer can save story text and founded year', function () { + $partner = Partner::factory()->setupCompleted()->create(['type' => 'retailer']); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Retailer'); + + Volt::actingAs($user)->test('partner.my-data') + ->set('companyName', 'Möbelhaus Mustermann') + ->set('salutation', 'Herr') + ->set('firstName', 'Max') + ->set('lastName', 'Mustermann') + ->set('street', 'Musterstraße') + ->set('houseNumber', '1') + ->set('zip', '12345') + ->set('city', 'Musterstadt') + ->set('country', 'Deutschland') + ->set('deliveryRadius', 50) + ->set('assemblyRadius', 30) + ->set('storyText', 'Seit 1992 sind wir Ihr Einrichtungshaus im Herzen der Stadt.') + ->set('foundedYear', 1992) + ->set('specialtiesInput', 'Polstermöbel, Outdoor, Küchen') + ->call('saveData') + ->assertHasNoErrors(); + + $partner->refresh(); + expect($partner->story_text)->toBe('Seit 1992 sind wir Ihr Einrichtungshaus im Herzen der Stadt.'); + expect($partner->founded_year)->toBe(1992); + expect($partner->specialties)->toContain('Polstermöbel'); + expect($partner->specialties)->toContain('Outdoor'); + expect($partner->specialties)->toContain('Küchen'); +}); + +test('retailer can save opening hours', function () { + $partner = Partner::factory()->setupCompleted()->create(['type' => 'retailer']); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Retailer'); + + Volt::actingAs($user)->test('partner.my-data') + ->set('companyName', $partner->company_name) + ->set('salutation', 'Herr') + ->set('firstName', 'Max') + ->set('lastName', 'Mustermann') + ->set('street', 'Str.') + ->set('houseNumber', '1') + ->set('zip', '12345') + ->set('city', 'Stadt') + ->set('country', 'Deutschland') + ->set('deliveryRadius', 50) + ->set('assemblyRadius', 30) + ->set('openingHours.monday.open', '08:00') + ->set('openingHours.monday.close', '20:00') + ->set('openingHours.sunday.closed', true) + ->call('saveData') + ->assertHasNoErrors(); + + $partner->refresh(); + expect($partner->opening_hours['monday']['open'])->toBe('08:00'); + expect($partner->opening_hours['monday']['close'])->toBe('20:00'); + expect($partner->opening_hours['sunday']['closed'])->toBeTrue(); +}); + +// ── Händler: Validierung ────────────────────────────────────────────────────── + +test('retailer story text cannot exceed 2000 characters', function () { + $partner = Partner::factory()->setupCompleted()->create(['type' => 'retailer']); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Retailer'); + + Volt::actingAs($user)->test('partner.my-data') + ->set('storyText', str_repeat('a', 2001)) + ->call('saveData') + ->assertHasErrors(['storyText' => 'max']); +}); + +test('retailer founded year must be a valid year', function () { + $partner = Partner::factory()->setupCompleted()->create(['type' => 'retailer']); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Retailer'); + + Volt::actingAs($user)->test('partner.my-data') + ->set('foundedYear', 1700) + ->call('saveData') + ->assertHasErrors(['foundedYear']); +}); + +test('retailer founded year cannot be in the future', function () { + $partner = Partner::factory()->setupCompleted()->create(['type' => 'retailer']); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Retailer'); + + Volt::actingAs($user)->test('partner.my-data') + ->set('foundedYear', now()->year + 1) + ->call('saveData') + ->assertHasErrors(['foundedYear']); +}); + +// ── Händler: Foto-Upload ───────────────────────────────────────────────────── + +test('retailer can upload team photos', function () { + $partner = Partner::factory()->setupCompleted()->create(['type' => 'retailer']); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Retailer'); + + $photo = UploadedFile::fake()->image('team.jpg', 400, 400); + + Volt::actingAs($user)->test('partner.my-data') + ->set('companyName', $partner->company_name) + ->set('salutation', 'Herr') + ->set('firstName', 'Max') + ->set('lastName', 'Muster') + ->set('street', 'Str.') + ->set('houseNumber', '1') + ->set('zip', '12345') + ->set('city', 'Stadt') + ->set('country', 'Deutschland') + ->set('deliveryRadius', 50) + ->set('assemblyRadius', 30) + ->set('newTeamPhotos', [$photo]) + ->call('saveData') + ->assertHasNoErrors(); + + expect( + Media::where('model_type', Partner::class) + ->where('model_id', $partner->id) + ->where('type', 'team_photo') + ->exists() + )->toBeTrue(); +}); + +test('retailer can upload showroom photos', function () { + $partner = Partner::factory()->setupCompleted()->create(['type' => 'retailer']); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Retailer'); + + $photo = UploadedFile::fake()->image('showroom.jpg', 800, 600); + + Volt::actingAs($user)->test('partner.my-data') + ->set('companyName', $partner->company_name) + ->set('salutation', 'Herr') + ->set('firstName', 'Max') + ->set('lastName', 'Muster') + ->set('street', 'Str.') + ->set('houseNumber', '1') + ->set('zip', '12345') + ->set('city', 'Stadt') + ->set('country', 'Deutschland') + ->set('deliveryRadius', 50) + ->set('assemblyRadius', 30) + ->set('newShowroomPhotos', [$photo]) + ->call('saveData') + ->assertHasNoErrors(); + + expect( + Media::where('model_type', Partner::class) + ->where('model_id', $partner->id) + ->where('type', 'showroom') + ->exists() + )->toBeTrue(); +}); + +test('retailer can delete a team photo', function () { + $partner = Partner::factory()->setupCompleted()->create(['type' => 'retailer']); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Retailer'); + + Storage::disk('public')->put('partners/'.$partner->id.'/team_photo/test.jpg', 'fake'); + $media = $partner->media()->create([ + 'file_path' => 'partners/'.$partner->id.'/team_photo/test.jpg', + 'type' => 'team_photo', + 'alt_text' => 'Team', + 'order_column' => 1, + ]); + + Volt::actingAs($user)->test('partner.my-data') + ->call('removeExistingPhoto', $media->id, 'team_photo') + ->assertHasNoErrors(); + + expect(Media::find($media->id))->toBeNull(); +}); + +// ── Hersteller: Story + Marke ───────────────────────────────────────────────── + +test('manufacturer can save story text and specialties', function () { + $partner = Partner::factory()->setupCompleted()->create(['type' => 'manufacturer']); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Manufacturer'); + + Volt::actingAs($user)->test('partner.my-data') + ->set('companyName', 'Muster Möbelwerke GmbH') + ->set('salutation', 'Herr') + ->set('firstName', 'Hans') + ->set('lastName', 'Muster') + ->set('street', 'Industriestr.') + ->set('houseNumber', '5') + ->set('zip', '33602') + ->set('city', 'Bielefeld') + ->set('country', 'Deutschland') + ->set('brandName', 'Muster Collection') + ->set('storyText', 'Seit 1975 produzieren wir hochwertige Möbel.') + ->set('foundedYear', 1975) + ->set('specialtiesInput', 'Polstermöbel, Massivholz') + ->call('saveData') + ->assertHasNoErrors(); + + $partner->refresh(); + expect($partner->story_text)->toBe('Seit 1975 produzieren wir hochwertige Möbel.'); + expect($partner->founded_year)->toBe(1975); + expect($partner->specialties)->toContain('Polstermöbel'); +}); + +test('manufacturer can upload brand images', function () { + $partner = Partner::factory()->setupCompleted()->create(['type' => 'manufacturer']); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Manufacturer'); + + $image = UploadedFile::fake()->image('brand.jpg', 800, 600); + + Volt::actingAs($user)->test('partner.my-data') + ->set('companyName', $partner->company_name) + ->set('salutation', 'Herr') + ->set('firstName', 'Hans') + ->set('lastName', 'Muster') + ->set('street', 'Str.') + ->set('houseNumber', '1') + ->set('zip', '12345') + ->set('city', 'Stadt') + ->set('country', 'Deutschland') + ->set('brandName', 'Muster Marke') + ->set('newBrandImages', [$image]) + ->call('saveData') + ->assertHasNoErrors(); + + expect( + Media::where('model_type', Partner::class) + ->where('model_id', $partner->id) + ->where('type', 'brand_image') + ->exists() + )->toBeTrue(); +}); + +// ── Öffentliches Profil ─────────────────────────────────────────────────────── + +test('public profile shows story text when set', function () { + $partner = Partner::factory()->setupCompleted()->create([ + 'story_text' => 'Unsere Geschichte seit 1985.', + ]); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Retailer'); + + Volt::actingAs($user)->test('partner.profile', ['partnerId' => $partner->id]) + ->assertSee('Unsere Geschichte seit 1985.'); +}); + +test('public profile shows specialties when set', function () { + $partner = Partner::factory()->setupCompleted()->create([ + 'specialties' => ['Sofas', 'Esstische'], + ]); + $user = User::factory()->create(['partner_id' => $partner->id]); + $user->assignRole('Retailer'); + + Volt::actingAs($user)->test('partner.profile', ['partnerId' => $partner->id]) + ->assertSee('Sofas') + ->assertSee('Esstische'); +}); diff --git a/tests/Feature/ProductCurationTest.php b/tests/Feature/ProductCurationTest.php index 2a19134..c559282 100644 --- a/tests/Feature/ProductCurationTest.php +++ b/tests/Feature/ProductCurationTest.php @@ -16,8 +16,8 @@ beforeEach(function () { app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions(); Role::create(['name' => 'Admin']); Role::create(['name' => 'Retailer']); - Permission::create(['name' => 'curate products']); - Role::findByName('Admin')->givePermissionTo('curate products'); + $permission = Permission::firstOrCreate(['name' => 'curate products']); + Role::findByName('Admin')->givePermissionTo($permission); }); function makeAdminUser(): User diff --git a/tests/Feature/ProductPolicyTest.php b/tests/Feature/ProductPolicyTest.php index 14de9ce..7646ce4 100644 --- a/tests/Feature/ProductPolicyTest.php +++ b/tests/Feature/ProductPolicyTest.php @@ -16,8 +16,8 @@ beforeEach(function () { Role::create(['name' => 'Retailer']); Role::create(['name' => 'Manufacturer']); Role::create(['name' => 'Customer']); - Permission::create(['name' => 'curate products']); - Role::findByName('Admin')->givePermissionTo('curate products'); + $permission = Permission::firstOrCreate(['name' => 'curate products']); + Role::findByName('Admin')->givePermissionTo($permission); }); test('admin can view any product', function () { diff --git a/tests/Feature/ResetCabinetTabletOverridesTest.php b/tests/Feature/ResetCabinetTabletOverridesTest.php new file mode 100644 index 0000000..d8a46e0 --- /dev/null +++ b/tests/Feature/ResetCabinetTabletOverridesTest.php @@ -0,0 +1,27 @@ +create([ + 'override_open_today' => '09:00', + 'override_close_today' => '20:00', + ]); + + $this->artisan('cabinet:reset-overrides') + ->assertSuccessful(); + + $settings = CabinetTabletSetting::current(); + expect($settings->override_open_today)->toBeNull(); + expect($settings->override_close_today)->toBeNull(); +}); + +test('cabinet:reset-overrides works when no overrides are set', function () { + CabinetTabletSetting::factory()->create([ + 'override_open_today' => null, + 'override_close_today' => null, + ]); + + $this->artisan('cabinet:reset-overrides') + ->assertSuccessful(); +}); diff --git a/tests/Feature/SoftLaunchDevPagesTest.php b/tests/Feature/SoftLaunchDevPagesTest.php new file mode 100644 index 0000000..5240104 --- /dev/null +++ b/tests/Feature/SoftLaunchDevPagesTest.php @@ -0,0 +1,26 @@ +get('/dev/sitemap') + ->assertSuccessful() + ->assertSee('Dev Sitemap') + ->assertSee('Live-Seiten') + ->assertSee('Archiv'); +}); + +it('dev immobilien v1 archive page loads successfully', function () { + $this->get('/dev/immobilien-v1') + ->assertSuccessful(); +}); + +it('dev interior v1 archive page loads successfully', function () { + $this->get('/dev/interior-v1') + ->assertSuccessful(); +}); + +it('dev partner v1 archive page loads successfully', function () { + $this->get('/dev/partner-v1') + ->assertSuccessful(); +}); diff --git a/tests/Feature/ThemeImageUrlTest.php b/tests/Feature/ThemeImageUrlTest.php new file mode 100644 index 0000000..9dfca04 --- /dev/null +++ b/tests/Feature/ThemeImageUrlTest.php @@ -0,0 +1,25 @@ +toBe('') + ->and(theme_image_url(''))->toBe('') + ->and(theme_image_url(' '))->toBe(''); +}); + +test('theme_image_url returns legacy path for slash-separated theme paths', function () { + $url = theme_image_url('b2in/example.jpg'); + + expect($url)->toContain('img/assets/b2in/example.jpg'); +}); + +test('theme_image_url passes through absolute http urls', function () { + $url = theme_image_url('https://example.org/x.webp'); + + expect($url)->toBe('https://example.org/x.webp'); +}); diff --git a/tests/Unit/CmsFluxEditorHtmlTransformerTest.php b/tests/Unit/CmsFluxEditorHtmlTransformerTest.php new file mode 100644 index 0000000..2a88ca7 --- /dev/null +++ b/tests/Unit/CmsFluxEditorHtmlTransformerTest.php @@ -0,0 +1,55 @@ +Hallo Akzent!

'; + $out = CmsFluxEditorHtmlTransformer::toEditor($html); + + expect($out)->toContain(''); + expect($out)->toContain('Akzent'); + expect($out)->not->toContain('text-secondary'); +}); + +test('fromEditor wandelt mark in span text-secondary um', function () { + $html = '

Hallo Akzent!

'; + $out = CmsFluxEditorHtmlTransformer::fromEditor($html); + + expect($out)->toContain('class="text-secondary"'); + expect($out)->toContain('Akzent'); + expect($out)->not->toContain(''); +}); + +test('Roundtrip erhält verschachtelte Formatierung', function () { + $original = '

Fett und normal

'; + $editor = CmsFluxEditorHtmlTransformer::toEditor($original); + $saved = CmsFluxEditorHtmlTransformer::fromEditor($editor); + + expect($saved)->toContain('text-secondary'); + expect($saved)->toContain('Fett'); +}); + +test('JSON-Rich-Text-Felder werden für den Editor umgewandelt', function () { + $items = [ + [ + 'title' => 'x', + 'description' => '

Hinweis

', + ], + ]; + $out = CmsFluxEditorHtmlTransformer::toEditorJsonItems($items, false); + + expect($out[0]['description'])->toContain(''); +}); + +test('JSON-Rich-Text-Felder werden beim Speichern zurückgewandelt', function () { + $items = [ + [ + 'description' => '

Hinweis

', + ], + ]; + $out = CmsFluxEditorHtmlTransformer::fromEditorJsonItems($items, false); + + expect($out[0]['description'])->toContain('text-secondary'); +}); diff --git a/vite.config.js b/vite.config.js index c9869eb..16bdc94 100644 --- a/vite.config.js +++ b/vite.config.js @@ -30,6 +30,11 @@ const allowedDomains = [ ]; export default defineConfig({ + build: { + rollupOptions: { + external: [/^\/fonts\//], + }, + }, plugins: [ laravel({ input: [ diff --git a/vite.portal.config.js b/vite.portal.config.js index bcb6f18..64a7ef3 100644 --- a/vite.portal.config.js +++ b/vite.portal.config.js @@ -40,6 +40,7 @@ export default defineConfig({ assetsDir: "", manifest: "manifest.json", rollupOptions: { + external: [/^\/fonts\//], output: { manualChunks: undefined, }, diff --git a/vite.web.config.js b/vite.web.config.js index 112d4c9..bd204f9 100644 --- a/vite.web.config.js +++ b/vite.web.config.js @@ -48,6 +48,7 @@ export default defineConfig({ assetsDir: "", manifest: "manifest.json", rollupOptions: { + external: [/^\/fonts\//], output: { manualChunks: undefined, },