From 090a893cc6e5e7eb88c7d02e77d8919a5c95bb26 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 7 Jul 2026 13:11:57 +0000 Subject: [PATCH] PM-Vorschau: echtes Portal-Design, Direkt-Button und Portal-Switch Der Vorschau-Link (/pm-vorschau/{token}) renderte eine schlichte Standalone-Seite, die dem spaeteren Erscheinungsbild nicht entsprach. Jetzt rendert er die echte Detailseite (web.release-detail) im Theme des Zielportals: - Controller wendet Domain-/Theme-Konfig des Zielportals an (Sprache der PM, Links zeigen auf die Portal-Domain), kein Hit-Zaehler - Gelbes Vorschau-Banner mit Status und Link-Gueltigkeit; noindex per Meta-Tag + X-Robots-Tag-Header, Canonical entfaellt bei noindex (neue robots-Section im Web-Layout) - Teilen-Buttons teilen den Vorschau-Link selbst (oeffentliche URL existiert vor Veroeffentlichung noch nicht) - PMs fuer beide Portale: ?portal=... waehlt die Ansicht, Umschalter im Banner; im Kundenpanel je Portal ein Vorschau-Button, sonst ein einzelner "Vorschau oeffnen"-Button (Link-Feld jetzt kopierbar) - Alte press-release-preview-View entfernt Co-authored-by: Cursor --- .../PressReleasePreviewController.php | 94 +++++++++++- app/Support/DomainAssetContext.php | 1 - lang/de/web.php | 5 + lang/en/web.php | 5 + .../customer/press-releases/show.blade.php | 22 ++- .../views/press-release-preview.blade.php | 136 ------------------ resources/views/web/release-detail.blade.php | 58 +++++++- tests/Feature/PressReleasePreviewTest.php | 134 +++++++++++++++++ 8 files changed, 306 insertions(+), 149 deletions(-) delete mode 100644 resources/views/press-release-preview.blade.php diff --git a/app/Http/Controllers/PressReleasePreviewController.php b/app/Http/Controllers/PressReleasePreviewController.php index 09d59f02..05ae82ab 100644 --- a/app/Http/Controllers/PressReleasePreviewController.php +++ b/app/Http/Controllers/PressReleasePreviewController.php @@ -2,14 +2,17 @@ namespace App\Http\Controllers; +use App\Enums\Portal; +use App\Http\Middleware\SetEdition; use App\Models\MagicLink; use App\Models\PressRelease; -use Illuminate\Contracts\View\View; +use App\Support\DomainAssetContext; +use Illuminate\Support\Facades\URL; use Symfony\Component\HttpFoundation\Response; class PressReleasePreviewController extends Controller { - public function __invoke(string $token): View|Response + public function __invoke(string $token): Response { $magicLink = MagicLink::query() ->where('token_hash', hash('sha256', $token)) @@ -28,7 +31,13 @@ class PressReleasePreviewController extends Controller $pressRelease = $pressReleaseId ? PressRelease::withoutGlobalScopes() - ->with(['company:id,name,slug', 'category.translations', 'images', 'user:id,name']) + ->with([ + 'company', + 'category.translations', + 'category.parent.translations', + 'images' => fn ($query) => $query->orderByDesc('is_preview')->orderBy('sort_order'), + 'contacts', + ]) ->find($pressReleaseId) : null; @@ -36,10 +45,81 @@ class PressReleasePreviewController extends Controller return $this->renderError(__('Die Pressemitteilung wurde nicht gefunden.'), 404); } - return view('press-release-preview', [ - 'pressRelease' => $pressRelease, - 'expiresAt' => $magicLink->expires_at, - ]); + $domainKey = $this->resolveTargetDomainKey($pressRelease); + + $this->applyTargetPortalPresentation($pressRelease, $domainKey); + + return response() + ->view('web.release-detail', [ + 'release' => $pressRelease, + 'companyReleases' => collect(), + 'relatedReleases' => collect(), + 'isPreview' => true, + 'previewExpiresAt' => $magicLink->expires_at, + 'previewPortalOptions' => $this->previewPortalOptions($pressRelease, $domainKey), + ]) + ->header('X-Robots-Tag', 'noindex, nofollow'); + } + + /** + * Zielportal der Vorschau. Bei PMs für beide Portale wählt der optionale + * Query-Parameter `portal` die Ansicht (Default: presseecho); bei + * Einzel-Portal-PMs ist das Portal fix. + */ + private function resolveTargetDomainKey(PressRelease $pressRelease): string + { + if ($pressRelease->portal === Portal::Both) { + $requested = request()->query('portal'); + + return in_array($requested, [Portal::Presseecho->value, Portal::Businessportal24->value], true) + ? $requested + : Portal::Presseecho->value; + } + + return $pressRelease->portal === Portal::Businessportal24 + ? Portal::Businessportal24->value + : Portal::Presseecho->value; + } + + /** + * Umschalt-Optionen für den Vorschau-Banner — nur bei PMs, die auf beiden + * Portalen erscheinen. + * + * @return list|null + */ + private function previewPortalOptions(PressRelease $pressRelease, string $activeDomainKey): ?array + { + if ($pressRelease->portal !== Portal::Both) { + return null; + } + + return collect([Portal::Presseecho->value, Portal::Businessportal24->value]) + ->map(fn (string $domainKey): array => [ + 'label' => $domainKey, + 'url' => request()->url().'?portal='.$domainKey, + 'active' => $domainKey === $activeDomainKey, + ]) + ->all(); + } + + /** + * Rendert die Vorschau im Look des Zielportals: Theme-/Asset-Konfiguration + * der Web-Domain anwenden, Links auf das Zielportal zeigen lassen und die + * Ausgabe-Sprache der PM übernehmen — damit die Vorschau 1:1 der späteren + * öffentlichen Detailseite entspricht. + */ + private function applyTargetPortalPresentation(PressRelease $pressRelease, string $domainKey): void + { + $domainConfig = DomainAssetContext::applyWebDomainConfig($domainKey); + + URL::forceRootUrl($domainConfig['url'] ?? config('app.url')); + + $language = in_array($pressRelease->language, SetEdition::EDITIONS, true) + ? $pressRelease->language + : SetEdition::DEFAULT_EDITION; + + app()->setLocale($language); + URL::defaults(['edition' => $language]); } private function renderError(string $message, int $status): Response diff --git a/app/Support/DomainAssetContext.php b/app/Support/DomainAssetContext.php index 5cf7248c..f410211b 100644 --- a/app/Support/DomainAssetContext.php +++ b/app/Support/DomainAssetContext.php @@ -92,7 +92,6 @@ class DomainAssetContext 'magic-links.*', 'me.*', 'settings.*', - 'press-releases.preview', )) { return true; } diff --git a/lang/de/web.php b/lang/de/web.php index 3bbb99cf..2913759f 100644 --- a/lang/de/web.php +++ b/lang/de/web.php @@ -302,6 +302,11 @@ return [ 'related' => 'Verwandte Meldungen', 'related_sub' => 'Reaktionen, ähnliche Themen, Branchenkontext', 'all_from_category' => 'Alle Meldungen aus :category', + 'preview_label' => 'Vorschau', + 'preview_text' => 'So erscheint die Pressemitteilung nach der Veröffentlichung. Diese Ansicht ist nicht öffentlich.', + 'preview_status' => 'Status: :status', + 'preview_valid_until' => 'Link gültig bis :date', + 'preview_portal_switch' => 'Ansicht', ], ]; diff --git a/lang/en/web.php b/lang/en/web.php index 2c37c643..11b8a540 100644 --- a/lang/en/web.php +++ b/lang/en/web.php @@ -297,6 +297,11 @@ return [ 'related' => 'Related releases', 'related_sub' => 'Reactions, similar topics, industry context', 'all_from_category' => 'All releases in :category', + 'preview_label' => 'Preview', + 'preview_text' => 'This is how the press release will appear once published. This view is not public.', + 'preview_status' => 'Status: :status', + 'preview_valid_until' => 'Link valid until :date', + 'preview_portal_switch' => 'View', ], ]; diff --git a/resources/views/livewire/customer/press-releases/show.blade.php b/resources/views/livewire/customer/press-releases/show.blade.php index 0ba1f82b..81d0b6f9 100644 --- a/resources/views/livewire/customer/press-releases/show.blade.php +++ b/resources/views/livewire/customer/press-releases/show.blade.php @@ -362,8 +362,26 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends {{ __('Öffentlicher Vorschau-Link erstellt') }} {{ __('gültig bis :date', ['date' => $shareExpiresAt]) }} -
- +
+ +
+ @if ($pr->portal === \App\Enums\Portal::Both) + {{-- PM erscheint auf beiden Portalen: Vorschau je Portal öffnen --}} + + {{ __('Vorschau presseecho') }} + + + {{ __('Vorschau businessportal24') }} + + @else + + {{ __('Vorschau öffnen') }} + + @endif +
@endif diff --git a/resources/views/press-release-preview.blade.php b/resources/views/press-release-preview.blade.php deleted file mode 100644 index 06edbd6f..00000000 --- a/resources/views/press-release-preview.blade.php +++ /dev/null @@ -1,136 +0,0 @@ - - - - - - - {{ $pressRelease->title }} – Vorschau - - - -
- {{ __('Private Vorschau – Link gültig bis :date', ['date' => $expiresAt?->format('d.m.Y H:i')]) }} -
- -
-

{{ $pressRelease->title }}

- -
- @if($pressRelease->company) - {{ $pressRelease->company->name }} - · - @endif - @if($pressRelease->category) - {{ $pressRelease->category->translations->firstWhere('locale', $pressRelease->language)?->name ?? '–' }} - · - @endif - {{ $pressRelease->created_at?->format('d.m.Y') }} -
- - @if($pressRelease->images->isNotEmpty()) -
- @foreach($pressRelease->images as $image) - @if($image->variantUrl('medium') ?? $image->url()) - {{ $image->title ?? $pressRelease->title }} - @endif - @endforeach -
- @endif - -
{!! $pressRelease->renderedText() !!}
- - @if($pressRelease->keywords) -

{{ __('Stichwörter') }}: {{ $pressRelease->keywords }}

- @endif - - -
- - diff --git a/resources/views/web/release-detail.blade.php b/resources/views/web/release-detail.blade.php index 6d25cf2c..0b35af5e 100644 --- a/resources/views/web/release-detail.blade.php +++ b/resources/views/web/release-detail.blade.php @@ -3,6 +3,12 @@ @php use Illuminate\Support\Str; + // Private Vorschau über Magic-Link (/pm-vorschau/{token}): identisches + // Layout wie die spätere öffentliche Seite, plus Hinweis-Banner und noindex. + $isPreview = ($isPreview ?? false) === true; + $previewExpiresAt = $previewExpiresAt ?? null; + $previewPortalOptions = $previewPortalOptions ?? null; + $company = $release->company; $categoryTranslation = $release->category?->translations?->firstWhere('locale', app()->getLocale()) @@ -17,7 +23,9 @@ $parentSlug = $parentTranslation?->slug; $parentHref = $parentSlug ? route('kategorie', ['slug' => $parentSlug]) : route('web.home'); - $publishedAt = $release->published_at; + // In der Vorschau ersatzweise der geplante Termin, damit die Meta-Zeile + // der späteren Veröffentlichung entspricht. + $publishedAt = $release->published_at ?? ($isPreview ? $release->scheduledAtLocal() : null); $embargoAt = $release->embargoAtLocal(); $bodyHtml = $release->renderedText(); @@ -55,7 +63,9 @@ $publisherVerified = (bool) ($company?->is_active && filled($company?->website)); $isRecommended = $release->content_score !== null && $release->content_score >= 80; - $shareUrl = route('release.detail', ['slug' => $release->slug]); + // In der Vorschau wird der Vorschau-Link selbst geteilt — die öffentliche + // URL existiert vor der Veröffentlichung noch nicht. + $shareUrl = $isPreview ? request()->fullUrl() : route('release.detail', ['slug' => $release->slug]); $brandLabel = config('app.theme') === 'presseecho' ? 'presseecho' : 'businessportal24'; @endphp @@ -65,7 +75,11 @@ @if ($imageUrl) @section('og_image', $imageUrl) @endif -@section('canonical', $shareUrl) +@if ($isPreview) + @section('robots', 'noindex,nofollow') +@else + @section('canonical', $shareUrl) +@endif @section('content') {{-- Lese-Fortschrittsbalken --}} @@ -78,6 +92,44 @@ + @if ($isPreview) + {{-- Hinweis-Banner: private Vorschau, nicht öffentlich erreichbar --}} +
+
+ + ● {{ __('web.detail.preview_label') }} + + {{ __('web.detail.preview_text') }} + + {{ __('web.detail.preview_status', ['status' => $release->status->label()]) }} + @if ($previewExpiresAt) + · {{ __('web.detail.preview_valid_until', ['date' => $previewExpiresAt->translatedFormat('j. F Y, H:i')]) }} + @endif + + + @if (! empty($previewPortalOptions)) + {{-- PM erscheint auf beiden Portalen: Ansicht umschalten --}} + + {{ __('web.detail.preview_portal_switch') }} + + @foreach ($previewPortalOptions as $option) + $option['active'], + 'hover:bg-amber-100 text-amber-900' => ! $option['active'], + ]) + @if ($option['active']) aria-current="true" @endif> + {{ $option['label'] }} + + @endforeach + + + @endif +
+
+ @endif +
{{-- Breadcrumbs --}} diff --git a/tests/Feature/PressReleasePreviewTest.php b/tests/Feature/PressReleasePreviewTest.php index ccd2e972..dce50fe7 100644 --- a/tests/Feature/PressReleasePreviewTest.php +++ b/tests/Feature/PressReleasePreviewTest.php @@ -1,11 +1,29 @@ seed(RolesAndPermissionsSeeder::class); + + $customer = User::factory()->create(['is_active' => true]); + $customer->assignRole('customer'); + + $pr = PressRelease::factory()->forPortal($portal)->create([ + 'user_id' => $customer->id, + 'status' => PressReleaseStatus::Draft->value, + ]); + + return [$customer, $pr]; +} + test('preview link returns press release for valid unexpired token', function () { /** @var TestCase $this */ $owner = User::factory()->create(); @@ -24,6 +42,97 @@ test('preview link returns press release for valid unexpired token', function () ->assertSee('Vertraulicher Inhalt'); }); +test('preview renders the public release detail layout with preview banner and noindex', function () { + /** @var TestCase $this */ + $owner = User::factory()->create(); + $pr = PressRelease::factory()->forPortal(Portal::Presseecho)->create([ + 'user_id' => $owner->id, + 'status' => PressReleaseStatus::Draft->value, + 'title' => 'Entwurf im Portal-Design', + ]); + + $share = app(MagicLinkGenerator::class)->createPressReleaseShareLink($pr, $owner); + + $this->get(route('press-releases.preview', ['token' => $share['plain_token']])) + ->assertOk() + ->assertViewIs('web.release-detail') + ->assertHeader('X-Robots-Tag', 'noindex, nofollow') + ->assertSee('', false) + ->assertDontSee('rel="canonical"', false) + ->assertSeeText('Vorschau') + ->assertSeeText('So erscheint die Pressemitteilung nach der Veröffentlichung.') + ->assertSeeText('Status: Entwurf') + ->assertSeeText('Entwurf im Portal-Design'); +}); + +test('preview applies the theme of the target portal', function () { + /** @var TestCase $this */ + $owner = User::factory()->create(); + $pr = PressRelease::factory()->forPortal(Portal::Businessportal24)->create([ + 'user_id' => $owner->id, + 'status' => PressReleaseStatus::Draft->value, + ]); + + $share = app(MagicLinkGenerator::class)->createPressReleaseShareLink($pr, $owner); + + $this->get(route('press-releases.preview', ['token' => $share['plain_token']])); + + expect(config('app.theme'))->toBe('businessportal24'); +}); + +test('preview of a single portal release shows no portal switch', function () { + /** @var TestCase $this */ + $owner = User::factory()->create(); + $pr = PressRelease::factory()->forPortal(Portal::Presseecho)->create(['user_id' => $owner->id]); + + $share = app(MagicLinkGenerator::class)->createPressReleaseShareLink($pr, $owner); + + $this->get(route('press-releases.preview', ['token' => $share['plain_token']])) + ->assertOk() + ->assertDontSee('?portal=businessportal24', false); +}); + +test('preview of a both portals release defaults to presseecho and shows the portal switch', function () { + /** @var TestCase $this */ + $owner = User::factory()->create(); + $pr = PressRelease::factory()->forPortal(Portal::Both)->create(['user_id' => $owner->id]); + + $share = app(MagicLinkGenerator::class)->createPressReleaseShareLink($pr, $owner); + + $this->get(route('press-releases.preview', ['token' => $share['plain_token']])) + ->assertOk() + ->assertSee('?portal=presseecho', false) + ->assertSee('?portal=businessportal24', false); + + expect(config('app.theme'))->toBe('presseecho'); +}); + +test('preview of a both portals release can be switched to businessportal24', function () { + /** @var TestCase $this */ + $owner = User::factory()->create(); + $pr = PressRelease::factory()->forPortal(Portal::Both)->create(['user_id' => $owner->id]); + + $share = app(MagicLinkGenerator::class)->createPressReleaseShareLink($pr, $owner); + + $this->get(route('press-releases.preview', ['token' => $share['plain_token'], 'portal' => 'businessportal24'])) + ->assertOk(); + + expect(config('app.theme'))->toBe('businessportal24'); +}); + +test('portal query parameter is ignored for single portal releases', function () { + /** @var TestCase $this */ + $owner = User::factory()->create(); + $pr = PressRelease::factory()->forPortal(Portal::Presseecho)->create(['user_id' => $owner->id]); + + $share = app(MagicLinkGenerator::class)->createPressReleaseShareLink($pr, $owner); + + $this->get(route('press-releases.preview', ['token' => $share['plain_token'], 'portal' => 'businessportal24'])) + ->assertOk(); + + expect(config('app.theme'))->toBe('presseecho'); +}); + test('preview link allows access to draft press release without authentication', function () { /** @var TestCase $this */ $owner = User::factory()->create(); @@ -72,3 +181,28 @@ test('preview link for soft deleted press release returns 404', function () { $this->get(route('press-releases.preview', ['token' => $share['plain_token']])) ->assertStatus(404); }); + +test('share panel offers a direct open button for single portal releases', function () { + /** @var TestCase $this */ + [$customer, $pr] = makePreviewCustomerWithPressRelease(Portal::Presseecho); + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) + ->call('generateShareLink') + ->assertSee('Vorschau öffnen') + ->assertDontSee('Vorschau presseecho') + ->assertDontSee('?portal=', false); +}); + +test('share panel offers one open button per portal for both portal releases', function () { + /** @var TestCase $this */ + [$customer, $pr] = makePreviewCustomerWithPressRelease(Portal::Both); + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) + ->call('generateShareLink') + ->assertSee('Vorschau presseecho') + ->assertSee('Vorschau businessportal24') + ->assertSee('?portal=presseecho', false) + ->assertSee('?portal=businessportal24', false); +});