customer: Sichtbarkeit belegen - Aufrufe im Verlauf, Newsroom-Uebersicht (Roadmap 1c)

- VisibilityStats: Firmen-Kennzahlen ueber alle Meldungen, Aufrufe je
  Meldung (30 Tage), lueckenlose Tagesreihe (Tage vor der Zaehlung sind
  null, nicht 0), Sitemap-seit und Google-Suchlink (Indexierung Variante B).
- Firmenseite: alle Meldungen der Firma statt nur eigene (Beansprucher
  sahen eine leere Liste), Aufrufe-Spalten, echtes Statistik-Panel.
- Meldungsseite: Panel Sichtbarkeit mit SVG-Tagesbalken 30/90 Tage.
- Claim aus der Veroeffentlichungsmail landet auf der Meldung
  (payload press_release_id), Mail-Hinweis erweitert, ein Element (E4).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-16 11:53:09 +00:00
parent a00f9dc79a
commit 3ef1b84600
14 changed files with 676 additions and 59 deletions

View file

@ -1,8 +1,6 @@
<?php
use App\Enums\BoostPlacement;
use App\Enums\PressReleaseEventType;
use App\Enums\PressReleaseShareChannel;
use App\Enums\PressReleaseStatus;
use App\Enums\SinglePurchaseStatus;
use App\Enums\SinglePurchaseType;
@ -22,10 +20,12 @@ use App\Services\PressRelease\BoostService;
use App\Services\PressRelease\PressReleaseCoverImage;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use App\Services\PressRelease\VisibilityStats;
use Flux\Flux;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends Component
@ -37,6 +37,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
public ?string $shareUrl = null;
/** Zeitraum der Verlaufsgrafik „Sichtbarkeit" in Tagen (Roadmap 1c.1). */
#[Url(as: 'verlauf', except: 30)]
public int $viewRange = 30;
public ?string $shareExpiresAt = null;
public bool $depublicationConfirmed = false;
@ -784,15 +788,17 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
$boostService = app(BoostService::class);
$proofService = app(ProofPdfService::class);
// Share-Klicks der öffentlichen Detailseite, je Kanal aggregiert.
$shareCounts = $pr->events()
->where('type', PressReleaseEventType::Share)
->selectRaw('channel, count(*) as clicks')
->groupBy('channel')
->pluck('clicks', 'channel');
// Sichtbarkeit belegen (Roadmap 1c.1): Rohzahlen und Tagesverlauf,
// nur für Meldungen, die je online waren.
$stats = app(VisibilityStats::class);
$wasPublic = $pr->published_at !== null || (int) $pr->hits > 0;
$range = VisibilityStats::ranges()->contains($this->viewRange) ? $this->viewRange : 30;
return [
'pr' => $pr,
'visibility' => $wasPublic ? $stats->releaseSummary($pr) : null,
'dailyViews' => $wasPublic ? $stats->dailyViews($pr, $range) : [],
'viewRanges' => VisibilityStats::ranges(),
'publicLinks' => $pr->status === PressReleaseStatus::Published
? $pr->publicPortalLinks()
: [],
@ -816,11 +822,6 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
// (Decision-Update §5.1, Schalter billing.enforce_booking).
'hasActiveBooking' => $user->hasActiveBooking(),
'latestRejection' => $latestRejection,
'shareTotal' => (int) $shareCounts->sum(),
'shareBreakdown' => $shareCounts
->sortDesc()
->map(fn (int $clicks, string $channel) => PressReleaseShareChannel::from($channel)->label().' '.number_format($clicks, 0, ',', '.'))
->implode(' · '),
'contacts' => $pr->contacts,
'statusLogs' => $pr->statusLogs,
// T12: Depublikation ist config-gated (ohne Preis kein Angebot).
@ -1375,21 +1376,12 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
{{ $pr->published_at?->format('d.m.Y H:i') ?? '–' }}
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Aufrufe') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ number_format($pr->hits, 0, ',', '.') }}
@if ($visibility === null)
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Aufrufe') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">{{ __('noch nicht veröffentlicht') }}</div>
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Geteilt') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ number_format($shareTotal, 0, ',', '.') }}
</div>
@if ($shareTotal > 0)
<div class="text-[11px] text-[color:var(--color-ink-3)] mt-0.5">{{ $shareBreakdown }}</div>
@endif
</div>
@endif
@if ($pr->scheduled_at)
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Geplante Veröffentlichung') }}</div>
@ -1483,6 +1475,109 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
</div>
</article>
{{-- ============== SICHTBARKEIT (Roadmap 1c.1) ============== --}}
@if ($visibility !== null)
@php
$measured = array_values(array_filter($dailyViews, fn ($day) => $day['views'] !== null));
$peak = max(1, (int) collect($measured)->max('views'));
$rangeSum = (int) collect($measured)->sum('views');
$barCount = count($dailyViews);
$chartWidth = 600;
$chartHeight = 96;
$gap = $barCount > 45 ? 1 : 2;
$barWidth = max(1, ($chartWidth - ($barCount - 1) * $gap) / max(1, $barCount));
@endphp
<article class="panel" id="sichtbarkeit">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Sichtbarkeit') }}</span>
<div class="flex items-center gap-1">
@foreach ($viewRanges as $days)
<button type="button" wire:click="$set('viewRange', {{ $days }})"
@class([
'px-2 py-0.5 rounded-[4px] text-[11px] font-semibold transition-colors',
'bg-[color:var(--color-hub)] text-white' => $viewRange === $days,
'text-[color:var(--color-ink-3)] hover:bg-[color:var(--color-bg-elev)]' => $viewRange !== $days,
])>{{ __(':days Tage', ['days' => $days]) }}</button>
@endforeach
</div>
</div>
<div class="p-5 space-y-4">
<div class="grid gap-2 sm:grid-cols-3">
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Aufrufe gesamt') }}</div>
<div class="text-[18px] font-bold tabular-nums text-[color:var(--color-ink)] mt-1">{{ number_format($visibility['hits'], 0, ',', '.') }}</div>
<div class="text-[11px] text-[color:var(--color-ink-3)] mt-0.5">{{ __('seit Veröffentlichung') }}</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Aufrufe :days Tage', ['days' => $viewRange]) }}</div>
<div class="text-[18px] font-bold tabular-nums text-[color:var(--color-ink)] mt-1">{{ number_format($rangeSum, 0, ',', '.') }}</div>
<div class="text-[11px] text-[color:var(--color-ink-3)] mt-0.5">
@if ($visibility['last_view_at'])
{{ __('zuletzt :date', ['date' => $visibility['last_view_at']->format('d.m.Y')]) }}
@else
{{ __('noch kein gemessener Aufruf') }}
@endif
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Geteilt') }}</div>
<div class="text-[18px] font-bold tabular-nums text-[color:var(--color-ink)] mt-1">{{ number_format($visibility['shares_total'], 0, ',', '.') }}</div>
@if ($visibility['shares'] !== [])
<div class="text-[11px] text-[color:var(--color-ink-3)] mt-0.5">{{ collect($visibility['shares'])->map(fn ($n, $label) => $label.' '.$n)->implode(' · ') }}</div>
@endif
</div>
</div>
<div>
<div class="flex items-baseline justify-between mb-2">
<span class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Aufrufe je Tag') }}</span>
<span class="text-[11px] tabular-nums text-[color:var(--color-ink-4)]">{{ __('Spitze :n', ['n' => number_format($peak, 0, ',', '.')]) }}</span>
</div>
<svg viewBox="0 0 {{ $chartWidth }} {{ $chartHeight }}" class="w-full h-24 block" role="img" aria-label="{{ __('Aufrufe je Tag, letzte :days Tage', ['days' => $viewRange]) }}">
@foreach ($dailyViews as $i => $day)
@php
$x = $i * ($barWidth + $gap);
$h = $day['views'] === null ? 3 : max($day['views'] > 0 ? 2 : 1, round($day['views'] / $peak * ($chartHeight - 4)));
@endphp
<rect x="{{ $x }}" y="{{ $chartHeight - $h }}" width="{{ $barWidth }}" height="{{ $h }}" rx="1"
@class(['fill-[color:var(--color-hub)]' => $day['views'] !== null && $day['views'] > 0, 'fill-[color:var(--color-bg-rule)]' => $day['views'] === 0, 'fill-[color:var(--color-bg-rule)] opacity-40' => $day['views'] === null])>
<title>{{ $day['date']->format('d.m.Y') }}: {{ $day['views'] === null ? __('keine Messung') : trans_choice(':n Aufruf|:n Aufrufe', $day['views'], ['n' => $day['views']]) }}</title>
</rect>
@endforeach
</svg>
<div class="flex justify-between mt-1 text-[10.5px] tabular-nums text-[color:var(--color-ink-4)]">
<span>{{ $dailyViews[0]['date']->format('d.m.') }}</span>
<span>{{ __('heute') }}</span>
</div>
@if (collect($dailyViews)->contains(fn ($day) => $day['views'] === null))
<p class="mt-2 text-[11px] text-[color:var(--color-ink-4)] m-0">
{{ __('Blasse Tage liegen vor Beginn der Tageszählung (:date) — die Gesamtsumme enthält sie trotzdem.', ['date' => \Illuminate\Support\Carbon::parse((string) config('press_releases.events.recorded_since'))->format('d.m.Y')]) }}
</p>
@endif
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3 flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Auffindbarkeit') }}</div>
<div class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1">
@if ($visibility['in_sitemap_since'])
{{ __('In der Sitemap für Suchmaschinen seit :date.', ['date' => $visibility['in_sitemap_since']->format('d.m.Y')]) }}
<span class="text-[color:var(--color-ink-4)]">{{ __('Ob und wann Google die Seite aufnimmt, entscheidet Google — der Link zeigt den aktuellen Stand.') }}</span>
@else
{{ __('Derzeit nicht öffentlich — nicht in der Sitemap.') }}
@endif
</div>
</div>
@if ($visibility['search_url'])
<flux:button size="sm" variant="filled" icon="magnifying-glass" href="{{ $visibility['search_url'] }}" target="_blank" rel="noopener">
{{ __('Bei Google prüfen') }}
</flux:button>
@endif
</div>
</div>
</article>
@endif
{{-- Content-Score wie im Editor (gemeinsame Komponente, 13.07.). --}}
@if (! is_null($pr->content_score))
<x-content-score-panel :score="$pr->content_score" :tier="$pr->content_tier" />