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 36691a5b13
commit 09ec866aac
14 changed files with 676 additions and 59 deletions

View file

@ -40,8 +40,8 @@
@if ($manageNewsroomUrl)
<p style="font-size: 12.5px; color: #6b7280;">
Hinweis: Der Newsroom von {{ $pressRelease->company->name }} lässt sich kostenlos pflegen —
Profil, Logo und Pressekontakte. Den Zugang erhält die hinterlegte Firmen-E-Mail:<br>
<a href="{{ $manageNewsroomUrl }}">Diesen Newsroom verwalten</a>
Profil, Logo, Pressekontakte und die Aufrufe jeder Meldung im Verlauf. Den Zugang erhält die hinterlegte Firmen-E-Mail:<br>
<a href="{{ $manageNewsroomUrl }}">Diesen Newsroom verwalten und Aufrufe einsehen</a>
</p>
@endif

View file

@ -7,6 +7,7 @@ use App\Services\Company\CompanyProfileCheck;
use App\Services\Conversion\ConversionRecorder;
use App\Services\Customer\CustomerCompanyContext;
use App\Services\Image\ImageService;
use App\Services\PressRelease\VisibilityStats;
use App\Support\WebCaches;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
@ -246,6 +247,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
{
$user = auth()->user();
$context = app(CustomerCompanyContext::class);
$stats = app(VisibilityStats::class);
$company = $context
->accessibleCompanyQuery($user)
->withCount(['contacts', 'pressReleases'])
@ -264,12 +266,18 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
->orderBy('first_name')
->limit(10)
->get(['id', 'company_id', 'first_name', 'last_name', 'responsibility', 'email', 'phone']),
'pressReleases' => PressRelease::withoutGlobalScopes()
->where('user_id', $user->id)
// Alle Meldungen der Firma, nicht nur die eigenen (Roadmap 1c.3):
// Wer den Newsroom beansprucht hat, soll sehen, was connektar
// für ihn eingeliefert hat — die Policy erlaubt das Firmen-
// mitgliedern ohnehin. Sortiert nach Veröffentlichung, Entwürfe
// nach Anlage.
'pressReleases' => $pressReleases = PressRelease::withoutGlobalScopes()
->where('company_id', $company->id)
->latest()
->limit(10)
->get(['id', 'title', 'status', 'created_at', 'published_at']),
->orderByRaw('coalesce(published_at, created_at) desc')
->limit(15)
->get(['id', 'title', 'status', 'created_at', 'published_at', 'hits', 'user_id']),
'recentViews' => $stats->recentViewsFor($pressReleases->modelKeys(), 30),
'summary' => $stats->companySummary($company, 30),
];
}
@ -697,13 +705,18 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
<flux:table.column>{{ __('Titel') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Datum') }}</flux:table.column>
<flux:table.column align="end">{{ __('Aufrufe') }}</flux:table.column>
<flux:table.column align="end">{{ __('30 Tage') }}</flux:table.column>
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
</flux:table.columns>
@forelse ($pressReleases as $pressRelease)
<flux:table.row wire:key="company-pr-{{ $pressRelease->id }}">
<flux:table.cell>
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $pressRelease->title }}</span>
<a href="{{ route('me.press-releases.show', $pressRelease->id) }}" wire:navigate class="text-[13px] font-semibold text-[color:var(--color-ink)] hover:underline">{{ $pressRelease->title }}</a>
@if ($pressRelease->user_id !== auth()->id())
<span class="block text-[11px] text-[color:var(--color-ink-4)]">{{ __('eingereicht über Partner') }}</span>
@endif
</flux:table.cell>
<flux:table.cell>
@php($statusValue = $pressRelease->status->value)
@ -722,6 +735,12 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
{{ $pressRelease->published_at?->format('d.m.Y') ?? ($pressRelease->created_at?->format('d.m.Y') ?? '–') }}
</span>
</flux:table.cell>
<flux:table.cell align="end">
<span class="text-[12.5px] tabular-nums font-semibold text-[color:var(--color-ink)]">{{ number_format((int) $pressRelease->hits, 0, ',', '.') }}</span>
</flux:table.cell>
<flux:table.cell align="end">
<span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ number_format($recentViews[$pressRelease->id] ?? 0, 0, ',', '.') }}</span>
</flux:table.cell>
<flux:table.cell>
<flux:button size="sm" variant="filled" icon="eye"
href="{{ route('me.press-releases.show', $pressRelease->id) }}" wire:navigate>
@ -731,7 +750,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="4">
<flux:table.cell colspan="6">
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
@ -778,34 +797,51 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
</div>
</article>
{{-- ============== STATISTIK ============== --}}
{{-- ============== STATISTIK (Roadmap 1c.3) ============== --}}
<article class="panel" id="statistik">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Statistik') }}</span>
<span class="badge warn">{{ __('Später') }}</span>
<span class="section-eyebrow">{{ __('Sichtbarkeit') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('alle Meldungen dieser Firma') }}</span>
</div>
<div class="p-5 space-y-4">
<p class="text-[12.5px] text-[color:var(--color-ink-2)] m-0">
{{ __('Erste Kennzahlen zur Firma; detaillierte Auswertungen folgen später.') }}
</p>
<div class="grid grid-cols-2 gap-3">
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[10.5px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
{{ __('Pressemitteilungen') }}
{{ __('Aufrufe gesamt') }}
</div>
<div class="text-[18px] font-bold text-[color:var(--color-ink)] mt-1">
{{ $company->press_releases_count }}
<div class="text-[18px] font-bold tabular-nums text-[color:var(--color-ink)] mt-1">
{{ number_format($summary['views_total'], 0, ',', '.') }}
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[10.5px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
{{ __('Pressekontakte') }}
{{ __('Aufrufe 30 Tage') }}
</div>
<div class="text-[18px] font-bold text-[color:var(--color-ink)] mt-1">
{{ $company->contacts_count }}
<div class="text-[18px] font-bold tabular-nums text-[color:var(--color-ink)] mt-1">
{{ number_format($summary['views_recent'], 0, ',', '.') }}
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[10.5px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
{{ __('Veröffentlicht') }}
</div>
<div class="text-[18px] font-bold tabular-nums text-[color:var(--color-ink)] mt-1">
{{ number_format($summary['published'], 0, ',', '.') }}
<span class="text-[11.5px] font-medium text-[color:var(--color-ink-3)]">/ {{ number_format($summary['releases'], 0, ',', '.') }}</span>
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[10.5px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
{{ __('Zuletzt veröffentlicht') }}
</div>
<div class="text-[14px] font-bold text-[color:var(--color-ink)] mt-1.5">
{{ $summary['last_published_at']?->format('d.m.Y') ?? '–' }}
</div>
</div>
</div>
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0 leading-relaxed">
{{ __('Aufrufe sind Seitenaufrufe der Meldungen auf dem Portal. Der Verlauf je Meldung steht auf ihrer Detailseite.') }}
</p>
</div>
</article>
</div>

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" />

View file

@ -55,6 +55,7 @@
@csrf
{{-- Herkunft aus dem Mail-Link (?src=) bis auf das Token durchreichen. --}}
<input type="hidden" name="src" value="{{ request()->query('src') }}" />
<input type="hidden" name="pr" value="{{ (int) request()->query('pr') ?: '' }}" />
{{-- Honeypot --}}
<div class="hidden" aria-hidden="true">