From 3ef1b84600053dccc45752916190773c63f8692b Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Wed, 16 Sep 2026 11:53:09 +0000 Subject: [PATCH] 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) --- .../Auth/MagicLinkConsumeController.php | 13 ++ .../Web/NewsroomClaimController.php | 19 +- app/Services/Auth/ContactAccessService.php | 14 +- app/Services/Company/NewsroomClaimService.php | 4 +- app/Services/PressRelease/VisibilityStats.php | 196 ++++++++++++++++++ config/press_releases.php | 6 + dev/2026-09-01/roadmap-2026.md | 6 +- dev/2026-09-01/umsetzung-protokoll.md | 60 ++++++ .../emails/press-release/published.blade.php | 4 +- .../customer/press-kits/show.blade.php | 74 +++++-- .../customer/press-releases/show.blade.php | 149 ++++++++++--- resources/views/web/newsroom-claim.blade.php | 1 + tests/Feature/VisibilityStatsTest.php | 158 ++++++++++++++ tests/Feature/Web/NewsroomClaimTest.php | 31 ++- 14 files changed, 676 insertions(+), 59 deletions(-) create mode 100644 app/Services/PressRelease/VisibilityStats.php create mode 100644 tests/Feature/VisibilityStatsTest.php diff --git a/app/Http/Controllers/Auth/MagicLinkConsumeController.php b/app/Http/Controllers/Auth/MagicLinkConsumeController.php index e9e53d55..698f4160 100644 --- a/app/Http/Controllers/Auth/MagicLinkConsumeController.php +++ b/app/Http/Controllers/Auth/MagicLinkConsumeController.php @@ -6,6 +6,7 @@ use App\Enums\ConversionEventType; use App\Http\Controllers\Controller; use App\Listeners\RecordAuthActivity; use App\Models\MagicLink; +use App\Models\PressRelease; use App\Services\Conversion\ConversionRecorder; use App\Services\Customer\CustomerCompanyContext; use Illuminate\Http\RedirectResponse; @@ -106,6 +107,18 @@ class MagicLinkConsumeController extends Controller if ($landingCompanyId > 0 && $user->canAccessCustomer() && ! $user->canAccessAdmin() && $this->companies->findFor($user, $landingCompanyId) !== null) { $home = route('me.press-kits.show', ['id' => $landingCompanyId], absolute: false); + + // Aus der Veröffentlichungsmail (Roadmap 1c.2): direkt auf die + // Meldung mit ihren Aufrufen, wenn der Account sie sehen darf. + $landingPressReleaseId = (int) ($magicLink->payload['press_release_id'] ?? 0); + + if ($landingPressReleaseId > 0) { + $pressRelease = PressRelease::query()->withoutGlobalScopes()->whereKey($landingPressReleaseId)->where('company_id', $landingCompanyId)->first(); + + if ($pressRelease !== null && $user->can('view', $pressRelease)) { + $home = route('me.press-releases.show', ['id' => $landingPressReleaseId], absolute: false); + } + } } return redirect($home); diff --git a/app/Http/Controllers/Web/NewsroomClaimController.php b/app/Http/Controllers/Web/NewsroomClaimController.php index b38a8b72..adae4105 100644 --- a/app/Http/Controllers/Web/NewsroomClaimController.php +++ b/app/Http/Controllers/Web/NewsroomClaimController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Web; use App\Enums\ConversionSource; use App\Http\Controllers\Controller; use App\Models\Company; +use App\Models\PressRelease; use App\Scopes\PortalScope; use App\Services\Company\MergedNewsroomLookup; use App\Services\Company\NewsroomClaimService; @@ -70,11 +71,27 @@ class NewsroomClaimController extends Controller // Enumeration-sicher: dieselbe Meldung, ob ein Konto deaktiviert ist // oder nicht — die Adresse ist ohnehin nur maskiert sichtbar. - $this->claims->request($company, $request->ip(), $this->sourceOf($request)); + $this->claims->request($company, $request->ip(), $this->sourceOf($request), $this->landingPressReleaseId($request, $company)); return back()->with('newsroom-claim-status', __('static.newsroom_claim.flash_sent')); } + /** + * Kam der Besucher aus der Veröffentlichungsmail einer Meldung (`pr` im + * Link), landet er nach dem Login auf genau dieser Meldung samt Aufrufen + * (Roadmap 1c.2) — sofern sie wirklich zu dieser Firma gehört. + */ + private function landingPressReleaseId(Request $request, Company $company): ?int + { + $id = (int) $request->input('pr', 0); + + if ($id <= 0) { + return null; + } + + return PressRelease::query()->withoutGlobalScopes()->whereKey($id)->where('company_id', $company->getKey())->exists() ? $id : null; + } + private function resolveMissing(WebPortalContext $context, string $edition, string $slug): Response|RedirectResponse { $survivor = $this->mergedNewsrooms->survivorFor($context->portalValues, $slug); diff --git a/app/Services/Auth/ContactAccessService.php b/app/Services/Auth/ContactAccessService.php index 820d5dd3..93d90a4f 100644 --- a/app/Services/Auth/ContactAccessService.php +++ b/app/Services/Auth/ContactAccessService.php @@ -42,7 +42,7 @@ class ContactAccessService * - Mehrfach hinterlegt → ein Konto, ALLE Firmen/Kontakte zugeordnet. * - Deaktiviertes Bestandskonto / unbekannte E-Mail → keine Aktion. */ - public function requestMagicAccess(string $email, ?string $ip = null, ?ConversionSource $source = null, ?int $landingCompanyId = null): bool + public function requestMagicAccess(string $email, ?string $ip = null, ?ConversionSource $source = null, ?int $landingCompanyId = null, ?int $landingPressReleaseId = null): bool { $email = mb_strtolower(trim($email)); $contacts = $this->contactsFor($email); @@ -61,7 +61,7 @@ class ContactAccessService } $this->linkMemberships($user, $this->companyIdsFrom($contacts, $companies), $contacts); - $this->sendLoginLink($user, $ip, $source, $landingCompanyId); + $this->sendLoginLink($user, $ip, $source, $landingCompanyId, $landingPressReleaseId); return true; } @@ -200,13 +200,19 @@ class ContactAccessService /** * Versendet einen regulären Login-Magic-Link an den Account. */ - private function sendLoginLink(User $user, ?string $ip, ?ConversionSource $source = null, ?int $landingCompanyId = null): void + private function sendLoginLink(User $user, ?string $ip, ?ConversionSource $source = null, ?int $landingCompanyId = null, ?int $landingPressReleaseId = null): void { + // Landung nach dem Login (Roadmap 1b.2/1c.2): die Firma, um die es + // ging — und, wenn der Weg über eine Veröffentlichungsmail kam, die + // konkrete Meldung mit ihren Aufrufen. $generated = $this->magicLinks->createLoginLink( $user, $ip, source: $source, - payload: $landingCompanyId !== null ? ['company_id' => $landingCompanyId] : [], + payload: array_filter([ + 'company_id' => $landingCompanyId, + 'press_release_id' => $landingPressReleaseId, + ]), ); Mail::to($user->email)->queue((new MagicLoginLink( diff --git a/app/Services/Company/NewsroomClaimService.php b/app/Services/Company/NewsroomClaimService.php index 76693ecd..c1b9e2dc 100644 --- a/app/Services/Company/NewsroomClaimService.php +++ b/app/Services/Company/NewsroomClaimService.php @@ -107,12 +107,12 @@ class NewsroomClaimService * Verschickt die Anmeldelinks. Gibt zurück, an wie viele Adressen * tatsächlich etwas ging — deaktivierte Konten bekommen nichts. */ - public function request(Company $company, ?string $ip, ConversionSource $source = ConversionSource::ProfileClaim): int + public function request(Company $company, ?string $ip, ConversionSource $source = ConversionSource::ProfileClaim, ?int $landingPressReleaseId = null): int { $sent = 0; foreach ($this->recipients($company) as $email) { - if ($this->contactAccess->requestMagicAccess($email, $ip, $source, $company->getKey())) { + if ($this->contactAccess->requestMagicAccess($email, $ip, $source, $company->getKey(), $landingPressReleaseId)) { $sent++; } } diff --git a/app/Services/PressRelease/VisibilityStats.php b/app/Services/PressRelease/VisibilityStats.php new file mode 100644 index 00000000..2c81b6b3 --- /dev/null +++ b/app/Services/PressRelease/VisibilityStats.php @@ -0,0 +1,196 @@ +withoutGlobalScopes()->where('company_id', $company->id); + + $row = $base->clone() + ->selectRaw('count(*) as releases') + ->selectRaw('sum(case when status = ? then 1 else 0 end) as published', [PressReleaseStatus::Published->value]) + ->selectRaw('coalesce(sum(hits), 0) as views_total') + ->selectRaw('max(published_at) as last_published_at') + ->toBase() + ->first(); + + $recent = PressReleaseEvent::query() + ->where('type', PressReleaseEventType::View->value) + ->where('created_at', '>=', now()->subDays($days)) + ->whereIn('press_release_id', $base->clone()->select('id')) + ->count(); + + return [ + 'releases' => (int) ($row->releases ?? 0), + 'published' => (int) ($row->published ?? 0), + 'views_total' => (int) ($row->views_total ?? 0), + 'views_recent' => $recent, + 'last_published_at' => ($row->last_published_at ?? null) ? Carbon::parse($row->last_published_at) : null, + ]; + } + + /** + * Aufrufe der letzten N Tage je Meldung, für eine Liste. + * + * @param list $pressReleaseIds + * @return array + */ + public function recentViewsFor(array $pressReleaseIds, int $days = 30): array + { + if ($pressReleaseIds === []) { + return []; + } + + return PressReleaseEvent::query() + ->where('type', PressReleaseEventType::View->value) + ->where('created_at', '>=', now()->subDays($days)) + ->whereIn('press_release_id', $pressReleaseIds) + ->selectRaw('press_release_id, count(*) as views') + ->groupBy('press_release_id') + ->pluck('views', 'press_release_id') + ->map(fn ($views): int => (int) $views) + ->all(); + } + + /** + * Tagesreihe der Aufrufe, lückenlos über die letzten N Tage (heute + * eingeschlossen). Tage vor Beginn der Ereignisaufzeichnung oder vor der + * Veröffentlichung sind `null`, nicht 0 — es gab dort keine Messung. + * + * @return list + */ + public function dailyViews(PressRelease $pressRelease, int $days = 30): array + { + $days = max(1, min($days, (int) config('press_releases.events.retention_days', 395))); + $start = now()->startOfDay()->subDays($days - 1); + + $counts = $pressRelease->events() + ->where('type', PressReleaseEventType::View->value) + ->where('created_at', '>=', $start) + ->selectRaw('date(created_at) as day, count(*) as views') + ->groupBy('day') + ->pluck('views', 'day'); + + $measuredFrom = $this->measurementStart($pressRelease); + $series = []; + + for ($i = 0; $i < $days; $i++) { + $day = $start->copy()->addDays($i); + $series[] = [ + 'date' => $day, + 'views' => $measuredFrom !== null && $day->lt($measuredFrom->copy()->startOfDay()) + ? null + : (int) ($counts[$day->toDateString()] ?? 0), + ]; + } + + return $series; + } + + /** + * Einzelwerte einer Meldung für das Panel „Sichtbarkeit". + * + * @return array{hits: int, first_view_at: Carbon|null, last_view_at: Carbon|null, shares: array, shares_total: int, in_sitemap_since: Carbon|null, search_url: string|null} + */ + public function releaseSummary(PressRelease $pressRelease): array + { + $views = $pressRelease->events() + ->where('type', PressReleaseEventType::View->value) + ->selectRaw('min(created_at) as first_view, max(created_at) as last_view') + ->toBase() + ->first(); + + $shares = $pressRelease->events() + ->where('type', PressReleaseEventType::Share->value) + ->selectRaw('channel, count(*) as clicks') + ->groupBy('channel') + ->pluck('clicks', 'channel') + ->mapWithKeys(fn ($clicks, $channel): array => [ + PressReleaseShareChannel::tryFrom((string) $channel)?->label() ?? (string) $channel => (int) $clicks, + ]) + ->sortDesc(); + + $links = $pressRelease->isVisibleInArchive() ? $pressRelease->publicPortalLinks() : []; + $firstUrl = $links[0]['url'] ?? null; + + return [ + 'hits' => (int) $pressRelease->hits, + 'first_view_at' => $views?->first_view ? Carbon::parse($views->first_view) : null, + 'last_view_at' => $views?->last_view ? Carbon::parse($views->last_view) : null, + 'shares' => $shares->all(), + 'shares_total' => (int) $shares->sum(), + // Die Sitemap listet jede im Archiv sichtbare Meldung — ab dem + // Moment der Veröffentlichung. Das ist eine Aussage über uns, nicht + // über Google. + 'in_sitemap_since' => $links !== [] && $pressRelease->published_at !== null ? Carbon::instance($pressRelease->published_at) : null, + 'search_url' => $firstUrl !== null ? 'https://www.google.com/search?q='.rawurlencode('site:'.preg_replace('#^https?://#', '', $firstUrl)) : null, + ]; + } + + /** + * Ab wann es für diese Meldung überhaupt Tageswerte geben kann: der + * spätere von Veröffentlichung und Beginn der Ereignisaufzeichnung. + */ + private function measurementStart(PressRelease $pressRelease): ?Carbon + { + $candidates = collect([ + $pressRelease->published_at, + $this->eventsSince(), + ])->filter(); + + return $candidates->isEmpty() ? null : $candidates->max(); + } + + private function eventsSince(): ?Carbon + { + $since = config('press_releases.events.recorded_since'); + + return $since ? Carbon::parse((string) $since) : null; + } + + /** + * Umschaltbare Zeiträume der Verlaufsgrafik. + * + * @return Collection + */ + public static function ranges(): Collection + { + return collect([30, 90]); + } +} diff --git a/config/press_releases.php b/config/press_releases.php index 6cbd5e98..c87cef45 100644 --- a/config/press_releases.php +++ b/config/press_releases.php @@ -23,6 +23,12 @@ return [ 'events' => [ 'retention_days' => (int) env('PRESS_RELEASE_EVENTS_RETENTION_DAYS', 395), + + // Seit wann Aufrufe als Ereignis mit Zeitstempel geschrieben werden + // (Umbau vom Zähler auf press_release_events). Davor gibt es nur die + // Summe `hits` — die Verlaufsgrafik der Absenderansicht (Roadmap + // 1c.1) zeigt frühere Tage als „keine Messung", nicht als 0. + 'recorded_since' => env('PRESS_RELEASE_EVENTS_RECORDED_SINCE', '2026-07-17'), ], ]; diff --git a/dev/2026-09-01/roadmap-2026.md b/dev/2026-09-01/roadmap-2026.md index fd8590c5..8bcef313 100644 --- a/dev/2026-09-01/roadmap-2026.md +++ b/dev/2026-09-01/roadmap-2026.md @@ -136,9 +136,9 @@ Für selbst angelegte Firmendaten trägt der Nutzer die Verantwortung. Für die | Nr. | Arbeitspaket | Aufwand | Abhängigkeit | |---|---|---|---| -| 1c.1 | Absenderansicht je Meldung: Aufrufe im Verlauf, Indexierungsstatus, Einordnung gegen Portaldurchschnitt | M | 0.1 | -| 1c.2 | Zugang zur Ansicht über Magic Link, auch ohne Konto | S | 1b.2 | -| 1c.3 | Newsroom-Übersicht mit allen Meldungen der Firma und Summenwerten | M | 1a.3, 1c.1 | +| ~~1c.1~~ | Absenderansicht je Meldung: Aufrufe im Verlauf, Indexierungsstatus, Einordnung gegen Portaldurchschnitt — **gebaut 16.09.2026**: Panel „Sichtbarkeit" auf der Meldungsseite, Tagesbalken 30/90 Tage (Ereignisse seit 17.07.2026, davor „keine Messung"), Rohzahlen nach E2; Indexierung in **Variante B** („in der Sitemap seit", Link auf die Google-Suche) — Search-Console-Abgleich (Variante A) bewusst ans Ende geschoben | M | 0.1 | +| ~~1c.2~~ | Zugang zur Ansicht über Magic Link, auch ohne Konto — **16.09.2026**: der Claim-Weg aus 1b.2 trägt jetzt die Meldung (`pr`) bis auf das Token, der Login landet auf ihr; Mail-Hinweis um „Aufrufe einsehen" erweitert, bleibt ein Element (E4) | S | 1b.2 | +| ~~1c.3~~ | Newsroom-Übersicht mit allen Meldungen der Firma und Summenwerten — **16.09.2026**: Firmenseite zeigt alle Meldungen der Firma (vorher nur eigene — Beansprucher sahen eine leere Liste), Aufrufe je Meldung und 30 Tage, Statistik-Panel echt | M | 1a.3, 1c.1 | ### 1d Infoseite und Prüfwerkzeug diff --git a/dev/2026-09-01/umsetzung-protokoll.md b/dev/2026-09-01/umsetzung-protokoll.md index 33d37da4..9978646c 100644 --- a/dev/2026-09-01/umsetzung-protokoll.md +++ b/dev/2026-09-01/umsetzung-protokoll.md @@ -1280,3 +1280,63 @@ einen Job anstößt. Deploy: `migrate --force`, dann einmal `php artisan mail:check-domains --months=6 --limit=1500` (≈ 2 Minuten, nur DNS-Lesen). Danach ist der Bericht gefüllt. + +--- + +# Teil 5 — Block 1c: Sichtbarkeit belegen (16.09.2026) + +## Befunde vor dem Bau + +- `press_release_events` trägt seit 17.07.2026 je Aufruf einen Zeitstempel, + Index `(press_release_id, type, created_at)`, Tagesreihe je Meldung 4 ms, + Aufbewahrung 13 Monate (Teil 2). Davor nur die Summe `hits`. Beginn steht + in `config/press_releases.php` (`events.recorded_since`). +- E2 bleibt: Rohzahlen, keine Einordnung. +- **Leerlauf nach dem Claim:** Die Firmenseite im Kundenbereich filterte + Meldungen nach `user_id`. Ein Beansprucher sah „Keine Pressemitteilungen + für diese Firma", obwohl connektar 68 eingereicht hatte — die Policy + erlaubte das Ansehen längst. Das Statistik-Panel war ein Platzhalter. +- Keine Suchmaschinen-Anbindung im Code. Entscheidung mit dem Nutzer: + **Indexierung Variante B** (ohne Fremddienst), Search Console (A) später + als Abgleich, „ganz nach hinten". + +## 1c.3 — Newsroom-Übersicht + +`VisibilityStats::companySummary()` (Meldungen, veröffentlicht, Aufrufe +gesamt aus `hits`, Aufrufe 30 Tage aus Ereignissen, zuletzt veröffentlicht) +und `recentViewsFor()` für die Liste. Firmenseite: alle Meldungen der +Firma nach `coalesce(published_at, created_at)`, Spalten Aufrufe und 30 +Tage, Fremdeinreichungen als „eingereicht über Partner" markiert; +Statistik-Panel „Sichtbarkeit" echt. + +## 1c.1 — Absenderansicht je Meldung + +Panel „Sichtbarkeit" auf `me.press-releases.show`, nur für Meldungen, die +je online waren: Aufrufe gesamt (seit Veröffentlichung), Aufrufe im +Zeitraum (30/90 Tage umschaltbar, `?verlauf=`), Teilungen je Kanal, +Tagesbalken als reines SVG ohne Bibliothek. Tage vor Beginn der Zählung +oder vor der Veröffentlichung sind **`null`, nicht 0** — blass gezeichnet +mit Fußnote, damit die Summe nicht der Grafik widerspricht. +Auffindbarkeit: „In der Sitemap für Suchmaschinen seit " +(Aussage über uns) plus Schaltfläche „Bei Google prüfen" (`site:`-Suche). +Die alten Kacheln Aufrufe/Geteilt im Status-Panel sind darin aufgegangen. + +## 1c.2 — Zugang über Magic Link + +War durch 1b.2 fast fertig (Konto, Firmenzuordnung, Policy). Ergänzt: Der +Mail-Link trug schon `pr=`; die Claim-Seite reicht ihn als verstecktes +Feld durch, der Controller prüft die Zugehörigkeit zur Firma, das Token +bekommt `press_release_id`, der Login landet auf der Meldung +(`MagicLinkConsumeController`), sonst wie bisher auf der Firma. Wortlaut +in der Veröffentlichungsmail: „Diesen Newsroom verwalten und Aufrufe +einsehen" — weiterhin ein Element (E4). + +Tests: `VisibilityStatsTest` (6), `NewsroomClaimTest` (+1, 11). Gesamt +1680 grün, phpstan für die neuen Dateien sauber. + +**Nebenbefund:** phpstan meldet acht Punkte in `SettleUnpaidLegacyInvoices` +und `ManualInvoicePdfRenderer` aus dem parallel entwickelten Projekt +`dev/lagacy-rechnungen/` (Commit 36691a5) — nullsafe-Zugriffe auf nicht +nullbare Werte, eine immer-falsche Negation. Nicht angefasst. + +Deploy: kein `migrate`, nur Code; `view:cache` neu. Kein Backfill nötig. diff --git a/resources/views/emails/press-release/published.blade.php b/resources/views/emails/press-release/published.blade.php index 4005cfd5..f28955b5 100644 --- a/resources/views/emails/press-release/published.blade.php +++ b/resources/views/emails/press-release/published.blade.php @@ -40,8 +40,8 @@ @if ($manageNewsroomUrl)

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:
- Diesen Newsroom verwalten + Profil, Logo, Pressekontakte und die Aufrufe jeder Meldung im Verlauf. Den Zugang erhält die hinterlegte Firmen-E-Mail:
+ Diesen Newsroom verwalten und Aufrufe einsehen

@endif diff --git a/resources/views/livewire/customer/press-kits/show.blade.php b/resources/views/livewire/customer/press-kits/show.blade.php index 7f1b8192..a165c8b9 100644 --- a/resources/views/livewire/customer/press-kits/show.blade.php +++ b/resources/views/livewire/customer/press-kits/show.blade.php @@ -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 {{ __('Titel') }} {{ __('Status') }} {{ __('Datum') }} + {{ __('Aufrufe') }} + {{ __('30 Tage') }} {{ __('Aktionen') }} @forelse ($pressReleases as $pressRelease) - {{ $pressRelease->title }} + {{ $pressRelease->title }} + @if ($pressRelease->user_id !== auth()->id()) + {{ __('eingereicht über Partner') }} + @endif @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') ?? '–') }} + + {{ number_format((int) $pressRelease->hits, 0, ',', '.') }} + + + {{ number_format($recentViews[$pressRelease->id] ?? 0, 0, ',', '.') }} + @@ -731,7 +750,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component @empty - +
@@ -778,34 +797,51 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
- {{-- ============== STATISTIK ============== --}} + {{-- ============== STATISTIK (Roadmap 1c.3) ============== --}}
- {{ __('Statistik') }} - {{ __('Später') }} + {{ __('Sichtbarkeit') }} + {{ __('alle Meldungen dieser Firma') }}
-

- {{ __('Erste Kennzahlen zur Firma; detaillierte Auswertungen folgen später.') }} -

- {{ __('Pressemitteilungen') }} + {{ __('Aufrufe gesamt') }}
-
- {{ $company->press_releases_count }} +
+ {{ number_format($summary['views_total'], 0, ',', '.') }}
- {{ __('Pressekontakte') }} + {{ __('Aufrufe 30 Tage') }}
-
- {{ $company->contacts_count }} +
+ {{ number_format($summary['views_recent'], 0, ',', '.') }} +
+
+
+
+ {{ __('Veröffentlicht') }} +
+
+ {{ number_format($summary['published'], 0, ',', '.') }} + / {{ number_format($summary['releases'], 0, ',', '.') }} +
+
+
+
+ {{ __('Zuletzt veröffentlicht') }} +
+
+ {{ $summary['last_published_at']?->format('d.m.Y') ?? '–' }}
+

+ {{ __('Aufrufe sind Seitenaufrufe der Meldungen auf dem Portal. Der Verlauf je Meldung steht auf ihrer Detailseite.') }} +

diff --git a/resources/views/livewire/customer/press-releases/show.blade.php b/resources/views/livewire/customer/press-releases/show.blade.php index c6b08a45..69436e04 100644 --- a/resources/views/livewire/customer/press-releases/show.blade.php +++ b/resources/views/livewire/customer/press-releases/show.blade.php @@ -1,8 +1,6 @@ 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') ?? '–' }} -
-
{{ __('Aufrufe') }}
-
- {{ number_format($pr->hits, 0, ',', '.') }} + @if ($visibility === null) +
+
{{ __('Aufrufe') }}
+
{{ __('noch nicht veröffentlicht') }}
-
-
-
{{ __('Geteilt') }}
-
- {{ number_format($shareTotal, 0, ',', '.') }} -
- @if ($shareTotal > 0) -
{{ $shareBreakdown }}
- @endif -
+ @endif @if ($pr->scheduled_at)
{{ __('Geplante Veröffentlichung') }}
@@ -1483,6 +1475,109 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
+ {{-- ============== 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 +
+
+ {{ __('Sichtbarkeit') }} +
+ @foreach ($viewRanges as $days) + + @endforeach +
+
+
+
+
+
{{ __('Aufrufe gesamt') }}
+
{{ number_format($visibility['hits'], 0, ',', '.') }}
+
{{ __('seit Veröffentlichung') }}
+
+
+
{{ __('Aufrufe :days Tage', ['days' => $viewRange]) }}
+
{{ number_format($rangeSum, 0, ',', '.') }}
+
+ @if ($visibility['last_view_at']) + {{ __('zuletzt :date', ['date' => $visibility['last_view_at']->format('d.m.Y')]) }} + @else + {{ __('noch kein gemessener Aufruf') }} + @endif +
+
+
+
{{ __('Geteilt') }}
+
{{ number_format($visibility['shares_total'], 0, ',', '.') }}
+ @if ($visibility['shares'] !== []) +
{{ collect($visibility['shares'])->map(fn ($n, $label) => $label.' '.$n)->implode(' · ') }}
+ @endif +
+
+ +
+
+ {{ __('Aufrufe je Tag') }} + {{ __('Spitze :n', ['n' => number_format($peak, 0, ',', '.')]) }} +
+ + @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 + $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])> + {{ $day['date']->format('d.m.Y') }}: {{ $day['views'] === null ? __('keine Messung') : trans_choice(':n Aufruf|:n Aufrufe', $day['views'], ['n' => $day['views']]) }} + + @endforeach + +
+ {{ $dailyViews[0]['date']->format('d.m.') }} + {{ __('heute') }} +
+ @if (collect($dailyViews)->contains(fn ($day) => $day['views'] === null)) +

+ {{ __('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')]) }} +

+ @endif +
+ +
+
+
{{ __('Auffindbarkeit') }}
+
+ @if ($visibility['in_sitemap_since']) + {{ __('In der Sitemap für Suchmaschinen seit :date.', ['date' => $visibility['in_sitemap_since']->format('d.m.Y')]) }} + {{ __('Ob und wann Google die Seite aufnimmt, entscheidet Google — der Link zeigt den aktuellen Stand.') }} + @else + {{ __('Derzeit nicht öffentlich — nicht in der Sitemap.') }} + @endif +
+
+ @if ($visibility['search_url']) + + {{ __('Bei Google prüfen') }} + + @endif +
+
+
+ @endif + {{-- Content-Score wie im Editor (gemeinsame Komponente, 13.07.). --}} @if (! is_null($pr->content_score)) diff --git a/resources/views/web/newsroom-claim.blade.php b/resources/views/web/newsroom-claim.blade.php index 95b5c20a..e9783deb 100644 --- a/resources/views/web/newsroom-claim.blade.php +++ b/resources/views/web/newsroom-claim.blade.php @@ -55,6 +55,7 @@ @csrf {{-- Herkunft aus dem Mail-Link (?src=) bis auf das Token durchreichen. --}} + {{-- Honeypot --}}