Display CMS Optimierungen 03-06-2026
This commit is contained in:
parent
6c6d683b9a
commit
bebfa63e87
17 changed files with 925 additions and 133 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,6 +5,7 @@
|
|||
/public/hot
|
||||
/public/storage
|
||||
/public/vendor
|
||||
/public/flux
|
||||
/storage/*.key
|
||||
/storage/app
|
||||
/storage/framework
|
||||
|
|
|
|||
84
app/Console/Commands/PublishFluxAssets.php
Normal file
84
app/Console/Commands/PublishFluxAssets.php
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use RuntimeException;
|
||||
|
||||
class PublishFluxAssets extends Command
|
||||
{
|
||||
protected $signature = 'flux:publish-assets {--force : Overwrite existing published Flux assets}';
|
||||
|
||||
protected $description = 'Publish Flux JavaScript assets to public/flux for static delivery (like livewire:publish --assets)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$sourceDir = $this->resolveSourceDirectory();
|
||||
$targetDir = public_path('flux');
|
||||
|
||||
if (! File::isDirectory($targetDir)) {
|
||||
File::makeDirectory($targetDir, 0755, true);
|
||||
}
|
||||
|
||||
$files = [
|
||||
'flux.js',
|
||||
'flux.min.js',
|
||||
'editor.js',
|
||||
'editor.min.js',
|
||||
'editor.css',
|
||||
];
|
||||
|
||||
$published = 0;
|
||||
|
||||
foreach ($files as $file) {
|
||||
$source = $sourceDir.'/'.$file;
|
||||
|
||||
if (! is_file($source)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$target = $targetDir.'/'.$file;
|
||||
|
||||
if (is_file($target) && ! $this->option('force') && hash_file('sha256', $source) === hash_file('sha256', $target)) {
|
||||
$this->line(" <comment>•</comment> {$file} (unverändert)");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
File::copy($source, $target);
|
||||
$this->line(" <info>✓</info> {$file}");
|
||||
$published++;
|
||||
}
|
||||
|
||||
if ($published === 0) {
|
||||
$this->info('Flux-Assets sind bereits aktuell.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("{$published} Flux-Asset(s) nach public/flux/ veröffentlicht.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function resolveSourceDirectory(): string
|
||||
{
|
||||
$proDist = base_path('vendor/livewire/flux-pro/dist');
|
||||
|
||||
if (is_dir($proDist)) {
|
||||
return $proDist;
|
||||
}
|
||||
|
||||
$freeDist = base_path('vendor/livewire/flux/dist');
|
||||
|
||||
if (is_dir($freeDist)) {
|
||||
return $freeDist;
|
||||
}
|
||||
|
||||
throw new RuntimeException('Flux vendor assets not found. Run composer install first.');
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ use App\Enums\DisplayVersionType;
|
|||
use App\Models\DisplayMedia;
|
||||
use App\Models\DisplayVersion;
|
||||
use App\Models\DisplayVersionItem;
|
||||
use App\Services\DisplayQrCodeService;
|
||||
use App\Support\DisplayModuleSettings;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
|
@ -174,6 +175,7 @@ class DisplayVersionEditor extends Component
|
|||
public function saveSettings(): void
|
||||
{
|
||||
$this->normalizeBrandPositions();
|
||||
$this->settings = $this->settingsWithQrImage($this->settings);
|
||||
$this->version->update(['settings' => $this->settings]);
|
||||
$this->showSettingsModal = false;
|
||||
$this->refreshModulePreview();
|
||||
|
|
@ -463,7 +465,7 @@ class DisplayVersionEditor extends Component
|
|||
*/
|
||||
private function buildItemContent(): array
|
||||
{
|
||||
return match ($this->itemType) {
|
||||
$content = match ($this->itemType) {
|
||||
'video' => [
|
||||
'filename' => $this->videoFilename,
|
||||
'title' => $this->videoTitle,
|
||||
|
|
@ -514,6 +516,95 @@ class DisplayVersionEditor extends Component
|
|||
],
|
||||
default => [],
|
||||
};
|
||||
|
||||
return $this->contentWithQrImage($content);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $content
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function contentWithQrImage(array $content): array
|
||||
{
|
||||
$qrUrl = match ($this->itemType) {
|
||||
'footer' => $content['url'] ?? null,
|
||||
'slide' => $content['qr_url'] ?? null,
|
||||
default => null,
|
||||
};
|
||||
|
||||
$qrCodeService = app(DisplayQrCodeService::class);
|
||||
$qrCode = $qrCodeService->generateForUrl(is_string($qrUrl) ? $qrUrl : null);
|
||||
|
||||
if ($qrCode) {
|
||||
$content['qr_image_path'] = $qrCode['path'];
|
||||
$content['qr_image_url'] = $qrCode['url'];
|
||||
} elseif ($this->itemId && is_string($qrUrl) && $qrUrl !== '') {
|
||||
$existingContent = DisplayVersionItem::find($this->itemId)?->content ?? [];
|
||||
$existingQrUrl = match ($this->itemType) {
|
||||
'footer' => $existingContent['url'] ?? null,
|
||||
'slide' => $existingContent['qr_url'] ?? null,
|
||||
default => null,
|
||||
};
|
||||
$preservedExistingQrImage = false;
|
||||
|
||||
if (is_string($existingQrUrl)
|
||||
&& $qrCodeService->normalizeUrl($existingQrUrl) === $qrCodeService->normalizeUrl($qrUrl)
|
||||
&& ! empty($existingContent['qr_image_path'])
|
||||
&& ! empty($existingContent['qr_image_url'])) {
|
||||
$content['qr_image_path'] = $existingContent['qr_image_path'];
|
||||
$content['qr_image_url'] = $existingContent['qr_image_url'];
|
||||
$preservedExistingQrImage = true;
|
||||
}
|
||||
|
||||
if (! $preservedExistingQrImage) {
|
||||
unset($content['qr_image_path'], $content['qr_image_url']);
|
||||
}
|
||||
} else {
|
||||
unset($content['qr_image_path'], $content['qr_image_url']);
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $settings
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function settingsWithQrImage(array $settings): array
|
||||
{
|
||||
if ($this->version->type !== DisplayVersionType::B2in) {
|
||||
return $settings;
|
||||
}
|
||||
|
||||
$qrUrl = $settings['qr_url'] ?? $settings['footer_url'] ?? null;
|
||||
$qrCodeService = app(DisplayQrCodeService::class);
|
||||
$qrCode = $qrCodeService->generateForUrl(is_string($qrUrl) ? $qrUrl : null);
|
||||
|
||||
if ($qrCode) {
|
||||
$settings['qr_image_path'] = $qrCode['path'];
|
||||
$settings['qr_image_url'] = $qrCode['url'];
|
||||
} elseif (is_string($qrUrl) && $qrUrl !== '') {
|
||||
$existingSettings = $this->settingsWithDefaults();
|
||||
$existingQrUrl = $existingSettings['qr_url'] ?? $existingSettings['footer_url'] ?? null;
|
||||
$preservedExistingQrImage = false;
|
||||
|
||||
if (is_string($existingQrUrl)
|
||||
&& $qrCodeService->normalizeUrl($existingQrUrl) === $qrCodeService->normalizeUrl($qrUrl)
|
||||
&& ! empty($existingSettings['qr_image_path'])
|
||||
&& ! empty($existingSettings['qr_image_url'])) {
|
||||
$settings['qr_image_path'] = $existingSettings['qr_image_path'];
|
||||
$settings['qr_image_url'] = $existingSettings['qr_image_url'];
|
||||
$preservedExistingQrImage = true;
|
||||
}
|
||||
|
||||
if (! $preservedExistingQrImage) {
|
||||
unset($settings['qr_image_path'], $settings['qr_image_url']);
|
||||
}
|
||||
} else {
|
||||
unset($settings['qr_image_path'], $settings['qr_image_url']);
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
private function getActiveFlag(): bool
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ class QuickStatus extends Component
|
|||
public function mount(?string $k = null): void
|
||||
{
|
||||
$validKey = config('domains.cabinet_status_key');
|
||||
$key = $k ?? request()->query('k');
|
||||
$key = $k ?? request()->query('k') ?? request()->query('key');
|
||||
if (! $validKey || $key !== $validKey) {
|
||||
$this->authorized = false;
|
||||
|
||||
|
|
|
|||
|
|
@ -90,6 +90,10 @@ class DisplayPlaylistConfigBuilder
|
|||
$data['url'] = $item->content['url'];
|
||||
}
|
||||
|
||||
if (! empty($item->content['qr_image_url'])) {
|
||||
$data['qr_image_url'] = $item->content['qr_image_url'];
|
||||
}
|
||||
|
||||
return $data;
|
||||
});
|
||||
|
||||
|
|
@ -170,6 +174,7 @@ class DisplayPlaylistConfigBuilder
|
|||
'disclaimer' => $content['disclaimer'] ?? '',
|
||||
'show_disclaimer' => $content['show_disclaimer'] ?? ($content['disclaimer'] ?? '') !== '',
|
||||
'qr_url' => $content['qr_url'] ?? '',
|
||||
'qr_image_url' => $content['qr_image_url'] ?? '',
|
||||
'qr_title' => $content['qr_title'] ?? '',
|
||||
'show_qr' => $content['show_qr'] ?? ($content['qr_url'] ?? '') !== '',
|
||||
'contact' => $content['contact'] ?? '',
|
||||
|
|
|
|||
71
app/Services/DisplayQrCodeService.php
Normal file
71
app/Services/DisplayQrCodeService.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DisplayQrCodeService
|
||||
{
|
||||
/**
|
||||
* @return array{path: string, url: string}|null
|
||||
*/
|
||||
public function generateForUrl(?string $url): ?array
|
||||
{
|
||||
$normalizedUrl = $this->normalizeUrl($url);
|
||||
|
||||
if ($normalizedUrl === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = 'display-qr-codes/'.sha1($normalizedUrl).'.png';
|
||||
$disk = Storage::disk('public');
|
||||
|
||||
if (! $disk->exists($path)) {
|
||||
try {
|
||||
$response = Http::timeout(10)
|
||||
->retry(2, 300)
|
||||
->get($this->remoteQrUrl($normalizedUrl));
|
||||
|
||||
if (! $response->successful() || $response->body() === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$disk->put($path, $response->body());
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => $path,
|
||||
'url' => asset('storage/'.$path),
|
||||
];
|
||||
}
|
||||
|
||||
public function normalizeUrl(?string $url): string
|
||||
{
|
||||
$url = trim((string) $url);
|
||||
|
||||
if ($url === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (str_starts_with($url, 'http://') || str_starts_with($url, 'https://')) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return 'https://'.$url;
|
||||
}
|
||||
|
||||
private function remoteQrUrl(string $url): string
|
||||
{
|
||||
return 'https://api.qrserver.com/v1/create-qr-code/?'.http_build_query([
|
||||
'size' => '300x300',
|
||||
'margin' => '8',
|
||||
'color' => '000000',
|
||||
'bgcolor' => 'ffffff',
|
||||
'data' => $url,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ class DisplayModuleSettings
|
|||
],
|
||||
DisplayVersionType::B2in->value => [
|
||||
'theme' => 'dark',
|
||||
'header_logo_url' => '../assets/b2in-logo-positive.svg',
|
||||
'header_logo_url' => '/_cabinet/assets/b2in-logo-positive.svg',
|
||||
'header_claim' => 'Connecting Design & Property',
|
||||
'logo_position' => 'top-left',
|
||||
'claim_position' => 'top-right',
|
||||
|
|
|
|||
|
|
@ -63,8 +63,14 @@
|
|||
"post-update-cmd": [
|
||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force",
|
||||
"@php artisan livewire:publish --assets --ansi",
|
||||
"@php artisan flux:publish-assets --ansi --force",
|
||||
"@php artisan boost:update --ansi || true"
|
||||
],
|
||||
"post-install-cmd": [
|
||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force",
|
||||
"@php artisan livewire:publish --assets --ansi",
|
||||
"@php artisan flux:publish-assets --ansi --force"
|
||||
],
|
||||
"post-root-package-install": [
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||
],
|
||||
|
|
|
|||
274
dev/displays-11-05-2026/02-deployment-live-29-05-2026.md
Normal file
274
dev/displays-11-05-2026/02-deployment-live-29-05-2026.md
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
# Display CMS – Deployment auf Live (29.05.2026)
|
||||
|
||||
Kurze Übersicht der Änderungen aus Commit `6c6d683` und was beim Live-Deploy zu beachten ist.
|
||||
|
||||
---
|
||||
|
||||
## Was wurde geändert?
|
||||
|
||||
### Mediathek
|
||||
- Videos zeigen ein **Vorschaubild** (Poster-Frame) statt nur eines Icons.
|
||||
- Poster werden beim Upload automatisch erzeugt (`ffmpeg`).
|
||||
- Neuer Artisan-Command zum Nachgenerieren bestehender Videos: `display-media:generate-video-thumbnails`
|
||||
- Neue Kategorie **„Sonstiges“** für B2in-Medien.
|
||||
|
||||
### B2in-Display-Modul
|
||||
- Media-Picker zeigt **alle Medien** (Bild + Video); der Typ wird aus der Datei abgeleitet.
|
||||
- **Marke** (ehem. Header): Logo und Claim frei in Ecken positionierbar; separate Schalter für Anzeige.
|
||||
- **Footer** global ein-/ausblendbar; unten positionierte Markenelemente nur ohne Footer.
|
||||
- Kleine **Thumbnail-Vorschau** vor allen Medien-URL-Feldern im Editor.
|
||||
|
||||
### Video-Playlist-Modul
|
||||
- Video-Vorschaubilder in der Übersicht.
|
||||
- Feld „Titel“ heißt jetzt **„Name“** (intern, nicht auf dem Display).
|
||||
- Vorschau im Modal zeigt bei neuen Einträgen kein falsches Video mehr.
|
||||
|
||||
### Angebote-Modul
|
||||
- **Kein Slide-Typ** mehr – einheitliches Detail-Layout.
|
||||
- Jeder Inhaltsblock (Logo, Badge, Texte, Preis, QR, Kontakt …) ist **pro Angebot** ein-/ausblendbar.
|
||||
- Logo und Brand-Text liegen **pro Slide**, nicht mehr in den Modul-Meta-Einstellungen.
|
||||
- Meta-Einstellungen: nur noch Endlosschleife, Transition, Dauer.
|
||||
- **Streichpreis**: Originalpreis rot durchgestrichen (optional).
|
||||
|
||||
### Display-Player (`public/_cabinet/display/index.html`)
|
||||
- B2in: Marke/Footer-Einstellungen werden ausgespielt.
|
||||
- Angebote: dynamisches Layout, Streichpreis, Show-Flags.
|
||||
- **Bugfix**: Endlosschleife bei leeren Modulen gestoppt (kein CPU-Loop mehr).
|
||||
|
||||
### Admin-Portal (allgemein)
|
||||
- **Script-Reihenfolge** korrigiert: `@livewireScripts` vor `@fluxScripts` (behebt `fluxModal`-Fehler).
|
||||
- Stale Datei `public/flux/flux.js` **entfernt** – Flux wird **nicht** statisch aus `public/` geladen (siehe Abschnitt „Flux UI auf Live“).
|
||||
- Livewire-Modal-Crash beim Aktualisieren von Inhalten behoben.
|
||||
|
||||
---
|
||||
|
||||
## Flux UI auf Live (wichtig – Switcher, Modals, Dropdowns)
|
||||
|
||||
### Diagnose (portal.b2in.eu)
|
||||
|
||||
```bash
|
||||
curl -I "https://portal.b2in.eu/flux/flux.min.js?id=test"
|
||||
# Aktuell: HTTP 404 ← Browser führt das Script NICHT aus → Switcher/Modals kaputt
|
||||
|
||||
curl -I "https://portal.b2in.eu/vendor/livewire/livewire.min.js?id=test"
|
||||
# HTTP 200 ← Livewire funktioniert, weil Assets statisch unter public/vendor/livewire/ liegen
|
||||
```
|
||||
|
||||
**Ursache:** Flux wird auf Live per Laravel-Route ausgeliefert, liefert dort aber **HTTP 404** (trotz JS-Inhalt). Livewire nutzt veröffentlichte Dateien in `public/vendor/livewire/` → **HTTP 200**.
|
||||
|
||||
**Symptome:** Modals öffnen nicht, Switcher reagieren nicht, Konsole zeigt `ReferenceError: Can't find variable: fluxModal`.
|
||||
|
||||
### Fix auf dem Live-Server
|
||||
|
||||
Flux-Assets wie Livewire statisch veröffentlichen:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
composer install --no-dev --optimize-autoloader
|
||||
# ↑ führt flux:publish-assets automatisch aus (composer.json post-install-cmd)
|
||||
|
||||
# Falls nötig manuell:
|
||||
php artisan flux:publish-assets --force
|
||||
|
||||
php artisan route:clear
|
||||
php artisan config:cache
|
||||
php artisan view:clear
|
||||
```
|
||||
|
||||
**auth.json** muss auf Live existieren (Flux Pro) – die Datei liegt nicht in Git.
|
||||
|
||||
Der Command kopiert aktuelle Dateien aus `vendor/livewire/flux-pro/dist/` nach `public/flux/`. Nginx liefert sie dann direkt mit **HTTP 200** aus (wie bei Livewire).
|
||||
|
||||
### Prüfen ob Flux korrekt läuft
|
||||
|
||||
```bash
|
||||
curl -I "https://portal.b2in.eu/flux/flux.min.js?id=test"
|
||||
# Erwartung: HTTP 200
|
||||
# Kein x-fastcgi-cache Header (dann kommt es aus PHP statt statisch)
|
||||
```
|
||||
|
||||
Im Admin-Portal → DevTools → Netzwerk:
|
||||
|
||||
1. `vendor/livewire/livewire.min.js` → **200**
|
||||
2. `flux/flux.min.js` → **200** (nicht 404!)
|
||||
3. `livewire.js` lädt **vor** `flux.min.js`
|
||||
4. Hard-Reload (`Cmd+Shift+R` / `Ctrl+Shift+R`)
|
||||
|
||||
### Wichtig
|
||||
|
||||
| ❌ Falsch | ✅ Richtig |
|
||||
|---|---|
|
||||
| Alte `public/flux/flux.js` aus altem Backup zurückkopieren | `php artisan flux:publish-assets --force` |
|
||||
| `public/flux/` löschen ohne neu zu publishen | Danach immer `flux:publish-assets` ausführen |
|
||||
| Nur `route:clear` erwarten, dass es reicht | Statische Assets publishen (wie bei Livewire) |
|
||||
|
||||
---
|
||||
|
||||
## Live-Deploy Checkliste
|
||||
|
||||
### 1. Code deployen
|
||||
|
||||
```bash
|
||||
git pull
|
||||
composer install --no-dev --optimize-autoloader
|
||||
```
|
||||
|
||||
`composer install` veröffentlicht dabei automatisch Livewire- und Flux-Assets nach `public/` (`post-install-cmd`). Falls der Deploy-Prozess Composer-Skripte mit `--no-scripts` deaktiviert, danach zwingend manuell ausführen:
|
||||
|
||||
```bash
|
||||
php artisan livewire:publish --assets
|
||||
php artisan flux:publish-assets --force
|
||||
```
|
||||
|
||||
**Keine neuen Migrationen** in diesem Release. Die Spalte `display_media.thumbnail_path` existiert bereits seit der Mediathek-Migration (`2026_03_26_*`).
|
||||
|
||||
### 2. FFmpeg installieren (Pflicht für Video-Poster)
|
||||
|
||||
Video-Poster werden serverseitig per `ffmpeg` erzeugt (`DisplayMediaService`, Artisan-Command). Ohne FFmpeg funktionieren Uploads weiterhin, aber **keine Video-Vorschaubilder** (weder in der Mediathek noch im Editor).
|
||||
|
||||
#### Dev Container (lokal)
|
||||
|
||||
Im Dev Container ist `ffmpeg` bereits vorinstalliert. Prüfen:
|
||||
|
||||
```bash
|
||||
ffmpeg -version
|
||||
# Erwartung: /usr/bin/ffmpeg
|
||||
```
|
||||
|
||||
#### Ubuntu / Debian (Live-Server)
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y ffmpeg
|
||||
ffmpeg -version
|
||||
```
|
||||
|
||||
#### CentOS / RHEL / AlmaLinux
|
||||
|
||||
```bash
|
||||
sudo dnf install -y ffmpeg
|
||||
# Alternativ auf älteren Systemen:
|
||||
sudo yum install -y ffmpeg
|
||||
```
|
||||
|
||||
Falls `ffmpeg` im Standard-Repo fehlt, ggf. **RPM Fusion** aktivieren.
|
||||
|
||||
#### macOS (lokal ohne Dev Container)
|
||||
|
||||
```bash
|
||||
brew install ffmpeg
|
||||
```
|
||||
|
||||
#### Wichtig: FFmpeg muss für PHP erreichbar sein
|
||||
|
||||
Nicht nur in der SSH-Session, sondern auch für den **Webserver-User** (PHP-FPM), da Poster sowohl beim Artisan-Command als auch beim Upload in der Mediathek erzeugt werden:
|
||||
|
||||
```bash
|
||||
# Prüfen (User ggf. anpassen: www-data, nginx, apache …)
|
||||
sudo -u www-data which ffmpeg
|
||||
sudo -u www-data ffmpeg -version
|
||||
```
|
||||
|
||||
Wenn `which ffmpeg` für den Webserver-User leer ist, `ffmpeg` systemweit installieren oder sicherstellen, dass `/usr/bin` im PATH von PHP-FPM liegt. Der Code ruft `ffmpeg` direkt auf – ein separater `.env`-Key ist nicht nötig.
|
||||
|
||||
### 3. Bestehende Videos: Poster nachgenerieren
|
||||
|
||||
Einmalig nach dem Deploy (oder bei Bedarf erneut mit `--force`):
|
||||
|
||||
```bash
|
||||
php artisan display-media:generate-video-thumbnails
|
||||
```
|
||||
|
||||
Thumbnails landen auf dem `public`-Disk neben der Videodatei (`…-poster.jpg`).
|
||||
|
||||
### 4. Laravel-Caches leeren
|
||||
|
||||
```bash
|
||||
php artisan route:clear # wichtig für Flux-Routen auf Portal-Domain
|
||||
php artisan config:cache
|
||||
php artisan view:clear
|
||||
```
|
||||
|
||||
> **Hinweis:** `route:cache` erst verwenden, wenn `/flux/flux.min.js` auf Live mit HTTP 200 antwortet. Siehe Abschnitt „Flux UI auf Live“.
|
||||
|
||||
### 5. Storage prüfen
|
||||
|
||||
```bash
|
||||
php artisan storage:link # nur falls noch nicht vorhanden
|
||||
```
|
||||
|
||||
Schreibrechte auf `storage/app/public/display-media/` müssen für den Webserver/PHP-User gesetzt sein (Poster-Erzeugung).
|
||||
|
||||
### 6. Umgebungsvariablen
|
||||
|
||||
In `.env` auf Live prüfen:
|
||||
|
||||
| Variable | Live-Beispiel | Hinweis |
|
||||
|---|---|---|
|
||||
| `DISPLAY_PLAYER_URL` | `https://cabinet.b2in.eu/display` | Player-URL für Admin-Vorschau und Links |
|
||||
| `DISPLAY_DOMAIN` | `b2in.eu` | Fallback in `config/display.php` |
|
||||
| `FILESYSTEM_DISK` | `public` oder S3 | Poster werden auf dem Media-Disk gespeichert |
|
||||
|
||||
Keine neuen Pflicht-ENV-Keys in diesem Release.
|
||||
|
||||
### 7. Frontend-Assets
|
||||
|
||||
Für dieses Release reichen **keine** separaten npm-Builds – Änderungen liegen in Blade-Templates, PHP und der statischen Player-HTML-Datei.
|
||||
|
||||
Falls euer Deploy-Prozess generell Assets baut:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run build:admin
|
||||
```
|
||||
|
||||
### 8. Display-Player in der Praxis (Cabinet-Geräte)
|
||||
|
||||
- Die Player-Datei `public/_cabinet/display/index.html` ist **statisch** und wird mit dem Deploy ausgeliefert.
|
||||
- Geräte laden die Config alle **60 Sekunden** neu; nach Deploy spätestens innerhalb einer Minute die neue Player-Logik.
|
||||
- Zur Sicherheit: einmal **Hard-Reload** am Display-Browser oder kurz warten (Player hat zusätzlich 6h-Reload und Datei-Change-Erkennung).
|
||||
- **Cache-Control** für Preview-Routen ist auf `no-cache` gesetzt – betrifft Admin-Vorschau, nicht zwingend CDN vor `/display`.
|
||||
|
||||
### 9. Nach dem Deploy testen
|
||||
|
||||
Kurz im Admin-Portal (`portal.b2in.eu`):
|
||||
|
||||
1. **Mediathek** – Video zeigt Poster? Neues Video hochladen → Poster erscheint?
|
||||
2. **B2in-Modul** – Footer aus, Logo-Ecke unten, Claim-Position, Vorschau im iframe.
|
||||
3. **Angebote** – Slide bearbeiten, Blöcke ein/aus, Streichpreis, Logo pro Slide.
|
||||
4. **Leeres Modul** – Editor öffnen ohne Inhalte → kein Browser-Crash, kein Endlos-Reload in der Vorschau.
|
||||
5. **Browser-Konsole** – keine `fluxModal`- oder Livewire-Morph-Fehler mehr.
|
||||
6. **Flux-Assets** – `/flux/flux.min.js` → 200; Switcher und Modals reagieren normal.
|
||||
|
||||
---
|
||||
|
||||
## Rückwärtskompatibilität
|
||||
|
||||
- **Bestehende Angebots-Slides** ohne `show_*`-Flags rendern weiter wie bisher (Fallback: Block sichtbar, wenn Inhalt vorhanden).
|
||||
- **B2in-Module** mit alten Einstellungen behalten ihre Defaults (Footer an, Logo oben links).
|
||||
- **Offers Meta-Einstellungen** (Logo, Brand-Text, Footer) in alten Modul-`settings` werden ignoriert – Branding liegt jetzt pro Slide. Bestehende Slides ohne Logo-Felder nutzen weiter die Player-Defaults.
|
||||
|
||||
---
|
||||
|
||||
## Bekannte Abhängigkeiten
|
||||
|
||||
| Abhängigkeit | Zweck | Ohne |
|
||||
|---|---|---|
|
||||
| `ffmpeg` | Video-Poster | Upload ok, kein Thumbnail |
|
||||
| `public`-Storage | Medien + Poster | Upload schlägt fehl |
|
||||
| Portal-Domain | Admin + API | Editor/Vorschau |
|
||||
| Display-Domain | Player-Ausspielung | Cabinet-Geräte |
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
Bei Problemen: auf den vorherigen Git-Stand zurück (`git revert` oder Deploy des letzten Releases).
|
||||
|
||||
- Keine DB-Migration rückgängig machen nötig.
|
||||
- Bereits erzeugte Poster-Dateien können liegen bleiben (harmlos).
|
||||
- `public/flux/` nicht manuell aus altem Backup wiederherstellen – stattdessen `php artisan flux:publish-assets --force`.
|
||||
|
||||
---
|
||||
|
||||
*Stand: 29.05.2026 · Commit `6c6d683`*
|
||||
|
|
@ -270,6 +270,9 @@
|
|||
transform-origin: top left;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.offer-slide--no-header { grid-template-rows: 1fr auto; }
|
||||
.offer-slide--no-bottom { grid-template-rows: auto 1fr; }
|
||||
.offer-slide--no-header.offer-slide--no-bottom { grid-template-rows: 1fr; }
|
||||
|
||||
/* Offer Header */
|
||||
.offer-header {
|
||||
|
|
@ -309,6 +312,13 @@
|
|||
display: grid; grid-template-columns: 1fr 300px; gap: 24px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.offer-bottom--info-only,
|
||||
.offer-bottom--qr-only {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.offer-bottom--compact {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* Info Box */
|
||||
.offer-info {
|
||||
|
|
@ -317,6 +327,13 @@
|
|||
border: 1px solid #e8e8e8; border-radius: 24px;
|
||||
padding: 28px; min-height: 340px;
|
||||
}
|
||||
.offer-info--compact {
|
||||
min-height: 0;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
.offer-info--title-only {
|
||||
padding: 22px 28px;
|
||||
}
|
||||
.offer-info-content { flex: 1; }
|
||||
.offer-eyebrow {
|
||||
font-size: 18px; color: #737373; letter-spacing: 0.14em;
|
||||
|
|
@ -546,6 +563,57 @@ function normalizeQrUrl(url) {
|
|||
return `https://${url}`;
|
||||
}
|
||||
|
||||
function qrCodeImageUrl(url, size = 300, margin = 8, extraParams = {}) {
|
||||
const normalizedUrl = normalizeQrUrl(url);
|
||||
if (!normalizedUrl) return '';
|
||||
|
||||
const params = new URLSearchParams({
|
||||
size: `${size}x${size}`,
|
||||
margin: String(margin),
|
||||
data: normalizedUrl,
|
||||
...extraParams,
|
||||
});
|
||||
|
||||
return `https://api.qrserver.com/v1/create-qr-code/?${params.toString()}`;
|
||||
}
|
||||
|
||||
function setQrImage(img, url, options = {}) {
|
||||
const baseSrc = options.imageUrl || qrCodeImageUrl(url, options.size || 300, options.margin || 8, options.params || {});
|
||||
if (!img || !baseSrc) return false;
|
||||
|
||||
const maxAttempts = options.maxAttempts || 3;
|
||||
let attempt = 0;
|
||||
|
||||
img.alt = options.alt || 'QR Code';
|
||||
img.loading = 'eager';
|
||||
img.decoding = 'async';
|
||||
img.referrerPolicy = 'no-referrer';
|
||||
img.style.visibility = 'hidden';
|
||||
|
||||
const load = () => {
|
||||
attempt++;
|
||||
const separator = baseSrc.includes('?') ? '&' : '?';
|
||||
img.src = `${baseSrc}${separator}cb=${Date.now()}-${attempt}`;
|
||||
};
|
||||
|
||||
img.onload = () => {
|
||||
img.style.visibility = 'visible';
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
if (attempt < maxAttempts) {
|
||||
setTimeout(load, attempt * 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
img.style.visibility = 'hidden';
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cabinet Display Player
|
||||
*
|
||||
|
|
@ -1157,8 +1225,7 @@ class VideoDisplayRenderer {
|
|||
headline.textContent = content.headline || '';
|
||||
subline.textContent = content.subline || '';
|
||||
|
||||
if (content.url) {
|
||||
qrImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&margin=10&data=${encodeURIComponent(content.url)}`;
|
||||
if ((content.url || content.qr_image_url) && setQrImage(qrImg, content.url, { imageUrl: content.qr_image_url, margin: 10, alt: 'QR' })) {
|
||||
qrContainer.style.display = 'flex';
|
||||
} else {
|
||||
qrContainer.style.display = 'none';
|
||||
|
|
@ -1218,7 +1285,7 @@ class B2inRenderer {
|
|||
layer.className = 'version-layer b2in-layer active' + (this.settings.show_footer === false ? ' no-footer' : '');
|
||||
layer.setAttribute('data-theme', this.theme);
|
||||
|
||||
const headerLogoUrl = this.resolveUrl(this.settings.header_logo_url || '../assets/b2in-logo-positive.svg');
|
||||
const headerLogoUrl = this.resolveUrl(this.settings.header_logo_url || '/_cabinet/assets/b2in-logo-positive.svg');
|
||||
const headerClaim = this.settings.header_claim || 'Connecting Design & Property';
|
||||
const footerUrl = this.settings.footer_url || 'B2in.eu';
|
||||
const footerName = this.settings.footer_name || '';
|
||||
|
|
@ -1227,6 +1294,7 @@ class B2inRenderer {
|
|||
? `<span class="b2in-footer-name">${escapeHtml(footerPrefix ? `${footerPrefix} ${footerName}` : footerName)}</span>`
|
||||
: '';
|
||||
const qrUrl = normalizeQrUrl(this.settings.qr_url || footerUrl || 'b2in.eu');
|
||||
const qrImageUrl = this.settings.qr_image_url || '';
|
||||
|
||||
// Footer visibility, brand element visibility + corner positioning
|
||||
const showFooter = this.settings.show_footer !== false;
|
||||
|
|
@ -1269,7 +1337,7 @@ class B2inRenderer {
|
|||
${footerNameHtml}
|
||||
</div>
|
||||
<div class="b2in-footer-qr">
|
||||
<img src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&margin=5&data=${encodeURIComponent(qrUrl)}" alt="QR">
|
||||
<img alt="QR">
|
||||
</div>
|
||||
</footer>`
|
||||
: '';
|
||||
|
|
@ -1300,6 +1368,11 @@ class B2inRenderer {
|
|||
this.headline = layer.querySelector('#b2in-headline');
|
||||
this.subline = layer.querySelector('#b2in-subline');
|
||||
this.progress = layer.querySelector('#b2in-progress');
|
||||
|
||||
const footerQrImg = layer.querySelector('.b2in-footer-qr img');
|
||||
if (footerQrImg) {
|
||||
setQrImage(footerQrImg, qrUrl, { imageUrl: qrImageUrl, size: 200, margin: 5, alt: 'QR' });
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
|
|
@ -1409,6 +1482,8 @@ class B2inRenderer {
|
|||
if (!url) return '';
|
||||
if (url.startsWith('http')) return url;
|
||||
if (url.startsWith('/')) return url;
|
||||
if (url.startsWith('../assets/')) return `/_cabinet/assets/${url.slice('../assets/'.length)}`;
|
||||
if (!url.includes('/') && url.endsWith('.svg')) return `/_cabinet/assets/${url}`;
|
||||
if (url.startsWith('../')) return url;
|
||||
return `../${url}`;
|
||||
}
|
||||
|
|
@ -1512,12 +1587,19 @@ class OffersRenderer {
|
|||
qr: (slide.show_qr ?? has(slide.qr_url)) && has(qrUrl),
|
||||
contact: (slide.show_contact ?? has(slide.contact)) && has(contactText),
|
||||
};
|
||||
const showInfo = has(slide.title) || show.eyebrow || show.subline || show.bullets || show.price || show.disclaimer;
|
||||
const showQrBox = show.qr || show.contact;
|
||||
const showBottom = showInfo || showQrBox;
|
||||
const infoBlockCount = [has(slide.title), show.eyebrow, show.subline, show.bullets, show.price, show.disclaimer]
|
||||
.filter(Boolean).length;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'offers-slide-container';
|
||||
|
||||
const article = document.createElement('article');
|
||||
article.className = 'offer-slide';
|
||||
if (!show.logo) article.classList.add('offer-slide--no-header');
|
||||
if (!showBottom) article.classList.add('offer-slide--no-bottom');
|
||||
|
||||
// --- HEADER (Logo & Marke) – per slide, toggleable ---
|
||||
if (show.logo) {
|
||||
|
|
@ -1549,9 +1631,6 @@ class OffersRenderer {
|
|||
}
|
||||
|
||||
article.appendChild(header);
|
||||
} else {
|
||||
// Without the header row, let the hero stay the flexible middle row.
|
||||
article.style.gridTemplateRows = '1fr auto';
|
||||
}
|
||||
|
||||
// --- HERO ---
|
||||
|
|
@ -1571,13 +1650,25 @@ class OffersRenderer {
|
|||
|
||||
article.appendChild(hero);
|
||||
|
||||
if (!showBottom) {
|
||||
wrapper.appendChild(article);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// --- BOTTOM: Info + QR ---
|
||||
const bottom = document.createElement('section');
|
||||
bottom.className = 'offer-bottom';
|
||||
if (showInfo && !showQrBox) bottom.classList.add('offer-bottom--info-only');
|
||||
if (!showInfo && showQrBox) bottom.classList.add('offer-bottom--qr-only');
|
||||
if (showInfo && infoBlockCount <= 3 && !show.price && !show.disclaimer) bottom.classList.add('offer-bottom--compact');
|
||||
|
||||
// Info
|
||||
if (showInfo) {
|
||||
const info = document.createElement('div');
|
||||
info.className = 'offer-info';
|
||||
if (infoBlockCount <= 3 && !show.price && !show.disclaimer) info.classList.add('offer-info--compact');
|
||||
if (infoBlockCount === 1 && has(slide.title)) info.classList.add('offer-info--title-only');
|
||||
|
||||
const infoContent = document.createElement('div');
|
||||
infoContent.className = 'offer-info-content';
|
||||
|
|
@ -1663,9 +1754,10 @@ class OffersRenderer {
|
|||
}
|
||||
|
||||
bottom.appendChild(info);
|
||||
}
|
||||
|
||||
// QR / Contact box – only when at least one of the two is enabled.
|
||||
if (show.qr || show.contact) {
|
||||
if (showQrBox) {
|
||||
const qrBox = document.createElement('aside');
|
||||
qrBox.className = 'offer-qr-box';
|
||||
|
||||
|
|
@ -1681,8 +1773,15 @@ class OffersRenderer {
|
|||
const qrWrapper = document.createElement('div');
|
||||
qrWrapper.className = 'offer-qr-wrapper';
|
||||
const qrImg = document.createElement('img');
|
||||
qrImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&color=000000&bgcolor=ffffff&margin=8&data=${encodeURIComponent(normalizeQrUrl(qrUrl))}`;
|
||||
qrImg.alt = 'QR Code';
|
||||
setQrImage(qrImg, qrUrl, {
|
||||
imageUrl: slide.qr_image_url || '',
|
||||
size: 300,
|
||||
margin: 8,
|
||||
params: {
|
||||
color: '000000',
|
||||
bgcolor: 'ffffff',
|
||||
},
|
||||
});
|
||||
qrWrapper.appendChild(qrImg);
|
||||
qrBox.appendChild(qrWrapper);
|
||||
}
|
||||
|
|
@ -1695,8 +1794,6 @@ class OffersRenderer {
|
|||
}
|
||||
|
||||
bottom.appendChild(qrBox);
|
||||
} else {
|
||||
bottom.style.gridTemplateColumns = '1fr';
|
||||
}
|
||||
|
||||
article.appendChild(bottom);
|
||||
|
|
@ -1757,6 +1854,8 @@ class OffersRenderer {
|
|||
if (!url) return '';
|
||||
if (url.startsWith('http')) return url;
|
||||
if (url.startsWith('/')) return url;
|
||||
if (url.startsWith('../assets/')) return `/_cabinet/assets/${url.slice('../assets/'.length)}`;
|
||||
if (!url.includes('/') && url.endsWith('.svg')) return `/_cabinet/assets/${url}`;
|
||||
if (url.startsWith('../')) return url;
|
||||
return `../${url}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,12 +11,23 @@
|
|||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-blue-900 dark:text-blue-100 mb-2">{{ __('Info-Tablet Steuerung') }}</h3>
|
||||
<div class="text-sm text-blue-800 dark:text-blue-200 space-y-1">
|
||||
<p>Das Info-Tablet im Schaufenster zeigt Store-Status, Öffnungszeiten und den nächsten freien Termin.</p>
|
||||
<p>• <strong>Automatisch:</strong> Offen/Geschlossen wird automatisch aus den hinterlegten Öffnungszeiten berechnet.</p>
|
||||
<p>• <strong>API-Endpunkt:</strong> <code class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded">{{ url('/api/cabinet-tablet/status') }}</code></p>
|
||||
<p>• <strong>Website-Endpunkt:</strong> <code class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded"><a target="_blank" href="https://cabinet.b2in.eu/info">https://cabinet.b2in.eu/info</a></code></p>
|
||||
<p>• <strong>Quick-Status-Endpunkt:</strong> <code class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded"><a target="_blank" href="https://portal.b2in.eu/info/status?key={{ config('domains.cabinet_status_key') }}">https://portal.b2in.eu/info/status?key={{ config('domains.cabinet_status_key') }}</a></code></p>
|
||||
<p>• <strong>Quick-Status-Hinweis:</strong> 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.</p>
|
||||
<p>Das Info-Tablet im Schaufenster zeigt Store-Status, Öffnungszeiten und den nächsten freien
|
||||
Termin.</p>
|
||||
<p>• <strong>Automatisch:</strong> Offen/Geschlossen wird automatisch aus den hinterlegten
|
||||
Öffnungszeiten berechnet.</p>
|
||||
<p>• <strong>API-Endpunkt:</strong> <code
|
||||
class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded">{{ url('/api/cabinet-tablet/status') }}</code>
|
||||
</p>
|
||||
<p>• <strong>Website-Endpunkt:</strong> <code
|
||||
class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded"><a target="_blank"
|
||||
href="https://cabinet.b2in.eu/info">https://cabinet.b2in.eu/info</a></code></p>
|
||||
<p>• <strong>Quick-Status-Endpunkt:</strong> <code
|
||||
class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded"><a target="_blank"
|
||||
href="https://portal.b2in.eu/info/status?key={{ config('domains.cabinet_status_key') }}">https://portal.b2in.eu/info/status?key={{ config('domains.cabinet_status_key') }}</a></code>
|
||||
</p>
|
||||
<p>• <strong>Quick-Status-Hinweis:</strong> 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.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -29,8 +40,7 @@
|
|||
</x-success-alert>
|
||||
@endif
|
||||
|
||||
<form wire:submit.prevent="save"
|
||||
x-data
|
||||
<form wire:submit.prevent="save" x-data
|
||||
x-on:submit.prevent="
|
||||
const hours = {};
|
||||
document.querySelectorAll('[data-hours-prop]').forEach(el => {
|
||||
|
|
@ -54,20 +64,28 @@
|
|||
<option value="closed">Manuell geschlossen (Gelb)</option>
|
||||
</flux:select>
|
||||
|
||||
@if($storeStatus !== 'auto')
|
||||
@if ($storeStatus !== 'auto')
|
||||
@php
|
||||
$noticeColors = match($storeStatus) {
|
||||
'notice' => 'bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800',
|
||||
$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',
|
||||
default
|
||||
=> 'bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800',
|
||||
};
|
||||
@endphp
|
||||
<div class="space-y-4 p-4 rounded-lg {{ $noticeColors }}">
|
||||
<flux:input wire:model="noticeHeadline" label="Headline" placeholder="z.B. Heute erst ab 11:00 Uhr" maxlength="40" />
|
||||
@error('noticeHeadline') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
<flux:input wire:model="noticeHeadline" label="Headline"
|
||||
placeholder="z.B. Heute erst ab 11:00 Uhr" maxlength="40" />
|
||||
@error('noticeHeadline')
|
||||
<span class="text-red-600 text-sm">{{ $message }}</span>
|
||||
@enderror
|
||||
|
||||
<flux:input wire:model="noticeSubtext" label="Subtext (optional)" placeholder="z.B. Wegen eines Kundentermins öffnen wir heute später." maxlength="80" />
|
||||
@error('noticeSubtext') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
<flux:input wire:model="noticeSubtext" label="Subtext (optional)"
|
||||
placeholder="z.B. Wegen eines Kundentermins öffnen wir heute später." maxlength="80" />
|
||||
@error('noticeSubtext')
|
||||
<span class="text-red-600 text-sm">{{ $message }}</span>
|
||||
@enderror
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -76,30 +94,28 @@
|
|||
{{-- Öffnungszeiten --}}
|
||||
<flux:card>
|
||||
<flux:heading size="lg" class="mb-1">{{ __('Öffnungszeiten') }}</flux:heading>
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400 mb-4">Felder leer lassen = Geschlossen. Der Status "Geöffnet/Geschlossen" wird automatisch aus diesen Zeiten berechnet.</p>
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400 mb-4">Felder leer lassen = Geschlossen. Der Status
|
||||
"Geöffnet/Geschlossen" wird automatisch aus diesen Zeiten berechnet.</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
@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)
|
||||
@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
|
||||
<div class="grid grid-cols-[120px_1fr_1fr_80px] items-center gap-3 py-2 border-b border-zinc-100 dark:border-zinc-800 last:border-0">
|
||||
<span class="text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ $row['label'] }}</span>
|
||||
<div
|
||||
class="grid grid-cols-[120px_1fr_1fr_80px] items-center gap-3 py-2 border-b border-zinc-100 dark:border-zinc-800 last:border-0">
|
||||
<span
|
||||
class="text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ $row['label'] }}</span>
|
||||
<div data-hours-prop="{{ $row['open'] }}">
|
||||
<flux:time-picker wire:model.live="{{ $row['open'] }}" label="Öffnung" time-format="24-hour" interval="15" clearable placeholder="–" />
|
||||
<flux:time-picker wire:model.live="{{ $row['open'] }}" label="Öffnung"
|
||||
time-format="24-hour" interval="15" clearable placeholder="–" />
|
||||
</div>
|
||||
<div data-hours-prop="{{ $row['close'] }}">
|
||||
<flux:time-picker wire:model.live="{{ $row['close'] }}" label="Schluss" time-format="24-hour" interval="15" clearable placeholder="–" />
|
||||
<flux:time-picker wire:model.live="{{ $row['close'] }}" label="Schluss"
|
||||
time-format="24-hour" interval="15" clearable placeholder="–" />
|
||||
</div>
|
||||
<span class="text-xs text-center {{ $isClosedDay ? 'text-red-500 dark:text-red-400' : 'text-emerald-600 dark:text-emerald-400' }}">
|
||||
<span
|
||||
class="text-xs text-center {{ $isClosedDay ? 'text-red-500 dark:text-red-400' : 'text-emerald-600 dark:text-emerald-400' }}">
|
||||
{{ $isClosedDay ? 'Geschlossen' : 'Geöffnet' }}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -111,7 +127,7 @@
|
|||
<flux:card>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<flux:heading size="lg">{{ __('Sonderöffnung heute') }}</flux:heading>
|
||||
@if($overrideOpenToday || $overrideCloseToday)
|
||||
@if ($overrideOpenToday || $overrideCloseToday)
|
||||
<flux:button wire:click="clearOverrides" size="sm" variant="ghost" icon="x-mark">
|
||||
{{ __('Zurücksetzen') }}
|
||||
</flux:button>
|
||||
|
|
@ -126,7 +142,7 @@
|
|||
<flux:time-picker wire:model="overrideCloseToday" label="Schluss" time-format="24-hour" interval="15" clearable placeholder="–" />
|
||||
@error('overrideCloseToday') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
</flux:card>--}}
|
||||
</flux:card> --}}
|
||||
|
||||
{{-- Nächster Termin
|
||||
<flux:card>
|
||||
|
|
@ -139,7 +155,7 @@
|
|||
<flux:time-picker wire:model="nextAppointmentTime" label="Uhrzeit" time-format="24-hour" interval="15" clearable placeholder="–" />
|
||||
@error('nextAppointmentTime') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
</flux:card>--}}
|
||||
</flux:card> --}}
|
||||
|
||||
{{-- Kontakt --}}
|
||||
<flux:card>
|
||||
|
|
@ -147,10 +163,15 @@
|
|||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<flux:input wire:model="contactPhone" label="Telefon" placeholder="z.B. 0521 98620100" />
|
||||
@error('contactPhone') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
@error('contactPhone')
|
||||
<span class="text-red-600 text-sm">{{ $message }}</span>
|
||||
@enderror
|
||||
|
||||
<flux:input wire:model="contactEmail" label="E-Mail" type="email" placeholder="z.B. info@cabinet-bielefeld.de" />
|
||||
@error('contactEmail') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
<flux:input wire:model="contactEmail" label="E-Mail" type="email"
|
||||
placeholder="z.B. info@cabinet-bielefeld.de" />
|
||||
@error('contactEmail')
|
||||
<span class="text-red-600 text-sm">{{ $message }}</span>
|
||||
@enderror
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
:key="'picker-b2in-header-logo-' . $context . '-' . $version->id" />
|
||||
<div class="flex items-end gap-3">
|
||||
<x-media-thumb :url="$settings['header_logo_url'] ?? ''" />
|
||||
<flux:input wire:model.live.debounce.500ms="settings.header_logo_url" label="Logo URL" placeholder="../assets/b2in-logo-positive.svg" class="flex-1" />
|
||||
<flux:input wire:model.live.debounce.500ms="settings.header_logo_url" label="Logo URL" placeholder="/_cabinet/assets/b2in-logo-positive.svg" class="flex-1" />
|
||||
</div>
|
||||
<flux:input wire:model="settings.header_claim" label="Claim" placeholder="Connecting Design & Property" />
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ test('page is accessible without login', function () {
|
|||
->assertSeeLivewire(QuickStatus::class);
|
||||
});
|
||||
|
||||
test('page accepts key query parameter used in admin link', function () {
|
||||
$this->get('/info/status?key='.VALID_KEY)
|
||||
->assertSuccessful()
|
||||
->assertSeeLivewire(QuickStatus::class);
|
||||
});
|
||||
|
||||
test('page shows forbidden when key is missing', function () {
|
||||
Livewire::test(QuickStatus::class, ['k' => ''])
|
||||
->assertSet('authorized', false)
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@ test('returns playlist with offers config', function () {
|
|||
'bullets' => [],
|
||||
'disclaimer' => '',
|
||||
'qr_url' => 'https://cabinet-bielefeld.de',
|
||||
'qr_image_url' => 'https://portal.b2in.test/storage/display-qr-codes/cabinet.png',
|
||||
'qr_title' => 'Reservieren',
|
||||
'contact' => "0521 98620100\nTel. oder WhatsApp",
|
||||
'show_brand_text' => false,
|
||||
|
|
@ -193,6 +194,7 @@ test('returns playlist with offers config', function () {
|
|||
$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');
|
||||
$response->assertJsonPath('playlist.0.slides.0.qr_image_url', 'https://portal.b2in.test/storage/display-qr-codes/cabinet.png');
|
||||
});
|
||||
|
||||
test('returns playlist with multiple versions in order', function () {
|
||||
|
|
@ -449,7 +451,25 @@ test('display player keeps previews in a strict 9 by 16 viewport', function () {
|
|||
->toContain('this.settings.footer_claim')
|
||||
->toContain('this.settings.footer_url')
|
||||
->toContain('this.settings.header_logo_url')
|
||||
->toContain('/_cabinet/assets/b2in-logo-positive.svg')
|
||||
->toContain("url.startsWith('../assets/')")
|
||||
->toContain("!url.includes('/') && url.endsWith('.svg')")
|
||||
->toContain('this.settings.qr_label')
|
||||
->toContain('function setQrImage')
|
||||
->toContain('function qrCodeImageUrl')
|
||||
->toContain('options.imageUrl')
|
||||
->toContain('content.qr_image_url')
|
||||
->toContain('this.settings.qr_image_url')
|
||||
->toContain('slide.qr_image_url')
|
||||
->toContain("img.loading = 'eager'")
|
||||
->toContain('maxAttempts')
|
||||
->toContain('cb=${Date.now()}-${attempt}')
|
||||
->toContain('offer-slide--no-header')
|
||||
->toContain('offer-slide--no-bottom')
|
||||
->toContain('offer-bottom--info-only')
|
||||
->toContain('offer-bottom--qr-only')
|
||||
->toContain('offer-info--compact')
|
||||
->toContain('const showBottom = showInfo || showQrBox')
|
||||
->toContain('this.settings.show_footer !== false')
|
||||
->toContain('this.settings.show_logo !== false')
|
||||
->toContain('this.settings.show_claim !== false')
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ use App\Models\DisplayPlaylistItem;
|
|||
use App\Models\DisplayVersion;
|
||||
use App\Models\DisplayVersionItem;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Livewire\Livewire;
|
||||
|
||||
|
|
@ -70,6 +72,7 @@ test('can create a display version', function () {
|
|||
expect($version->settings)->toBeArray();
|
||||
expect($version->settings)->toHaveKey('theme');
|
||||
expect($version->settings)->toHaveKey('header_logo_url');
|
||||
expect($version->settings['header_logo_url'])->toBe('/_cabinet/assets/b2in-logo-positive.svg');
|
||||
expect($version->settings)->toHaveKey('footer_url');
|
||||
});
|
||||
|
||||
|
|
@ -446,6 +449,56 @@ test('can add a slide item to offers version', function () {
|
|||
expect($item->content['qr_url'])->toBe('https://cabinet-bielefeld.de');
|
||||
});
|
||||
|
||||
test('slide editor generates and stores a qr image when a qr url is saved', function () {
|
||||
Storage::fake('public');
|
||||
Http::fake([
|
||||
'api.qrserver.com/*' => Http::response('fake-png', 200, ['Content-Type' => 'image/png']),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$version = DisplayVersion::factory()->create(['type' => 'offers']);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||
->call('openItemModal', null, 'slide')
|
||||
->set('slideTitle', 'QR Slide')
|
||||
->set('slideQrUrl', 'cabinet-bielefeld.de')
|
||||
->call('saveItem');
|
||||
|
||||
$item = DisplayVersionItem::where('display_version_id', $version->id)->first();
|
||||
|
||||
expect($item->content['qr_url'])->toBe('cabinet-bielefeld.de')
|
||||
->and($item->content['qr_image_path'])->toStartWith('display-qr-codes/')
|
||||
->and($item->content['qr_image_url'])->toContain('/storage/display-qr-codes/');
|
||||
|
||||
expect(Storage::disk('public')->exists($item->content['qr_image_path']))->toBeTrue();
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
test('b2in settings generate and store a qr image when a qr url is saved', function () {
|
||||
Storage::fake('public');
|
||||
Http::fake([
|
||||
'api.qrserver.com/*' => Http::response('fake-png', 200, ['Content-Type' => 'image/png']),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$version = DisplayVersion::factory()->create(['type' => 'b2in']);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
|
||||
->call('openSettingsModal')
|
||||
->set('settings.qr_url', 'b2in.eu')
|
||||
->call('saveSettings');
|
||||
|
||||
$settings = $version->fresh()->settings;
|
||||
|
||||
expect($settings['qr_image_path'])->toStartWith('display-qr-codes/')
|
||||
->and($settings['qr_image_url'])->toContain('/storage/display-qr-codes/');
|
||||
|
||||
expect(Storage::disk('public')->exists($settings['qr_image_path']))->toBeTrue();
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
test('can add a slide with bullets to offers version', function () {
|
||||
$user = User::factory()->create();
|
||||
$version = DisplayVersion::factory()->create(['type' => 'offers']);
|
||||
|
|
|
|||
31
tests/Feature/FluxAssetRouteTest.php
Normal file
31
tests/Feature/FluxAssetRouteTest.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
beforeEach(function () {
|
||||
URL::forceRootUrl('https://'.config('domains.domain_portal'));
|
||||
});
|
||||
|
||||
test('portal domain serves flux javascript from laravel route', function () {
|
||||
$this->get('https://'.config('domains.domain_portal').'/flux/flux.js?id=test')
|
||||
->assertSuccessful()
|
||||
->assertHeader('content-type', 'text/javascript; charset=utf-8');
|
||||
});
|
||||
|
||||
test('portal domain serves minified flux javascript route in production mode', function () {
|
||||
config(['app.debug' => false]);
|
||||
|
||||
$this->get('https://'.config('domains.domain_portal').'/flux/flux.min.js?id=test')
|
||||
->assertSuccessful()
|
||||
->assertHeader('content-type', 'text/javascript; charset=utf-8');
|
||||
});
|
||||
|
||||
test('flux pro vendor bundle contains alpine components used by the admin ui', function () {
|
||||
$path = base_path('vendor/livewire/flux-pro/dist/flux.js');
|
||||
|
||||
expect(is_file($path))->toBeTrue('Flux Pro must be installed via composer (auth.json on live).');
|
||||
|
||||
expect(file_get_contents($path))->toContain('fluxModal');
|
||||
});
|
||||
30
tests/Feature/PublishFluxAssetsTest.php
Normal file
30
tests/Feature/PublishFluxAssetsTest.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
test('flux publish assets command copies javascript into public flux directory', function () {
|
||||
$targetDir = public_path('flux');
|
||||
|
||||
if (File::isDirectory($targetDir)) {
|
||||
File::deleteDirectory($targetDir);
|
||||
}
|
||||
|
||||
$this->artisan('flux:publish-assets')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(is_file(public_path('flux/flux.js')))->toBeTrue();
|
||||
expect(is_file(public_path('flux/flux.min.js')))->toBeTrue();
|
||||
expect(filesize(public_path('flux/flux.min.js')))->toBeGreaterThan(1000);
|
||||
|
||||
File::deleteDirectory($targetDir);
|
||||
});
|
||||
|
||||
test('composer install and update publish flux assets', function () {
|
||||
$composer = json_decode((string) file_get_contents(base_path('composer.json')), true, flags: JSON_THROW_ON_ERROR);
|
||||
$expectedCommand = '@php artisan flux:publish-assets --ansi --force';
|
||||
|
||||
expect($composer['scripts']['post-install-cmd'] ?? [])->toContain($expectedCommand);
|
||||
expect($composer['scripts']['post-update-cmd'] ?? [])->toContain($expectedCommand);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue