recht: Meldeverfahren v2 Phasen 5+7 — DSGVO-Strukturformular, 10-€-Checkout mit KI-Vorprüfung, Triage & Panel-Modal
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled

Phase 7 (Spur D, E2 revidiert): KI-Vorprüfung VOR der Zahlung im Modal —
rot lehnt ohne Zahlung ab, gelb/grün führt zum Stripe-Checkout
(SinglePurchaseType::Change, 10 € netto), nach Zahlungseingang übernimmt
der Webhook die geprüfte Fassung sofort (applyPaid: Snapshot, Sperre,
Verlauf). Nur einmal je PM; Obergrenze legal.change_max_distance_percent
(50 %) verhindert die verdeckte Selbst-Depublizierung.

Phase 5 (Spur B): strukturiertes Schwärzungsformular mit Pflicht-Vorschau
im verifizierten Web-Bereich UND als Modal im Kunden-Panel; Admin-Editor
wird aus payload.redaction vorbefüllt. Umsetzungsmail LegalRequestExecuted
an Antragsteller, Bestätigungsmail PressReleaseChangeApplied (Spur A/D,
neuer abbestellbarer Typ press_release_corrected).

Außerdem:
- Re-Sync-Sperre + Sitemap-Invalidierung fest in depublish() verankert
  (Lücke im bezahlten Webhook-Pfad geschlossen)
- Öffentliche „Rechtliches"-Seite als zweistufige Triage: primär der
  Portal-Anmeldelink, Formular nur Fallback mit Identifizierungs-Hinweis;
  Store auf report/dsgvo/personal_rights gehärtet
- Panel-Button „Ändern/Melden" öffnet Anliegen-Modal (4 Spuren) statt
  des Web-Formulars
- Pressekontakt-Fallback für Legacy-Bestände (30k PMs ohne Verknüpfung):
  Detailseite + pressContactEmail() nutzen PM-Kontakt → Firmen-Kontakt →
  Firmendaten
- Korrektur-Gate entity-fest (Editor-Normalisierung zählt nicht als
  Änderung), toEditorHtml() in Admin-Edit + Korrektur-Panel,
  Wochenlimit-Hinweis in Text und Bremsen-Meldung

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-23 13:02:57 +00:00
parent 04b49c567d
commit e6eca9c85f
40 changed files with 2507 additions and 169 deletions

View file

@ -0,0 +1,38 @@
<x-emails.layout title="Umsetzungsbestätigung zu Ihrer Meldung" preheader="Ihr Anliegen wurde umgesetzt.">
<p>Guten Tag{{ $legalRequest->requesterName() ? ' '.$legalRequest->requesterName() : '' }},</p>
@if ($action === 'depublished')
<p>
Ihr Anliegen wurde umgesetzt: Die betroffene Pressemitteilung wurde aus der
öffentlichen Ansicht entfernt. Unter der bisherigen Adresse erscheint nur noch
der Hinweis, dass die Mitteilung entfernt wurde.
</p>
@else
<p>
Ihr Anliegen wurde umgesetzt: Die beantragten personenbezogenen Angaben wurden
in der betroffenen Pressemitteilung geschwärzt bzw. entfernt. Die Mitteilung
bleibt im Übrigen online.
</p>
@endif
<dl class="meta">
<dt>Vorgangsnummer</dt>
<dd>{{ $legalRequest->reference() }}</dd>
@if ($legalRequest->pressRelease)
<dt>Betroffene Pressemitteilung</dt>
<dd>{{ $legalRequest->pressRelease->title }}</dd>
@endif
@if ($legalRequest->executed_at)
<dt>Umgesetzt am</dt>
<dd>{{ $legalRequest->executed_at->format('d.m.Y H:i') }} Uhr</dd>
@endif
</dl>
<div class="note">
<p>
Bitte beachten Sie: Kopien in Suchmaschinen oder Drittarchiven können
zeitweise weiter verfügbar sein, bis deren Zwischenspeicher aktualisiert
sind. Bei Rückfragen geben Sie bitte die Vorgangsnummer an.
</p>
</div>
</x-emails.layout>

View file

@ -0,0 +1,35 @@
<x-emails.layout :title="$isPaidChange ? 'Ihre Änderung ist live' : 'Ihre Korrektur ist live'" preheader="Die beauftragte Anpassung wurde übernommen und ist öffentlich sichtbar.">
<p>Hallo {{ $user->name }},</p>
@if ($isPaidChange)
<p>Ihre kostenpflichtige Änderung wurde nach Zahlungseingang übernommen. Die folgende Pressemitteilung ist ab sofort in der geänderten Fassung öffentlich sichtbar:</p>
@else
<p>Ihre Korrektur wurde übernommen. Die folgende Pressemitteilung ist ab sofort in der korrigierten Fassung öffentlich sichtbar:</p>
@endif
<p class="title">{{ $pressRelease->title }}</p>
<x-emails.button :url="$showUrl">Pressemitteilung im Konto ansehen</x-emails.button>
<dl class="meta">
<dt>Vorgang</dt>
<dd>{{ $legalRequest->reference() }}</dd>
@if ($changedFieldLabels !== [])
<dt>Geänderte Felder</dt>
<dd>{{ implode(', ', $changedFieldLabels) }}</dd>
@endif
@if ($legalRequest->executed_at)
<dt>Übernommen am</dt>
<dd>{{ $legalRequest->executed_at->format('d.m.Y H:i') }} Uhr</dd>
@endif
</dl>
<div class="note">
@if ($isPaidChange)
<p>Die Rechnung zu diesem Auftrag finden Sie in Ihrem Konto unter Rechnungen.</p>
@endif
<p>Bei Fragen erreichen Sie uns über den <a href="{{ config('domains.domain_portal_url') }}/de/hilfe">Hilfe-Bereich auf pressekonto</a>.</p>
</div>
<x-emails.unsubscribe-note :type="$notificationType" :url="$unsubscribeUrl" />
</x-emails.layout>

View file

@ -3,9 +3,11 @@
use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType;
use App\Enums\PressReleaseStatus;
use App\Mail\LegalRequestExecuted;
use App\Models\LegalRequest;
use App\Services\PressRelease\AnonymizationService;
use App\Services\PressRelease\PressReleaseService;
use Illuminate\Support\Facades\Mail;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
@ -38,6 +40,16 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')]
$this->id = $id;
$this->adminNote = (string) ($this->request()->admin_note ?? '');
$this->redactionReplacement = AnonymizationService::DEFAULT_REPLACEMENT;
// Spur B (Phase 5): strukturierter Schwärzungsauftrag aus dem
// verifizierten Web-Formular befüllt den Editor vor — der Admin
// prüft, ergänzt und führt aus.
$redaction = (array) ($this->request()->payload['redaction'] ?? []);
if ($redaction !== []) {
$this->redactionTerms = implode("\n", (array) ($redaction['terms'] ?? []));
$this->detachContacts = (bool) ($redaction['detach_contacts'] ?? true);
}
}
/**
@ -94,6 +106,8 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')]
'executed_at' => now(),
]);
$this->sendExecutedMail($request, 'anonymized');
$this->redactionPreview = null;
$this->notification = __('Anonymisierung ausgeführt — PM geschwärzt, Re-Sync-Sperre gesetzt.');
}
@ -117,8 +131,7 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')]
return;
}
$service->depublish($pressRelease);
$pressRelease->lockSync('Depublizierung im Meldeverfahren '.$request->reference());
$service->depublish($pressRelease, 'Depublizierung im Meldeverfahren '.$request->reference());
$request->update([
'status' => LegalRequestStatus::Resolved->value,
@ -128,9 +141,26 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')]
'executed_at' => now(),
]);
$this->sendExecutedMail($request, 'depublished');
$this->notification = __('PM depubliziert (Tombstone, umkehrbar) — Re-Sync-Sperre gesetzt.');
}
/**
* Umsetzungs-Bestätigung an die meldende Person (Phase 5) — nur wenn
* eine Antragsteller-Adresse vorliegt (interne Vorgänge haben keine).
*
* @param 'anonymized'|'depublished' $action
*/
private function sendExecutedMail(LegalRequest $request, string $action): void
{
if (! $request->requester_email) {
return;
}
Mail::to($request->requester_email)->queue(new LegalRequestExecuted($request->fresh(), $action));
}
/**
* Rückgängig machen: spielt den verschlüsselten Vorher-Snapshot einer
* ausgeführten Korrektur/Anonymisierung zurück (bis zur Löschfrist).
@ -370,6 +400,15 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')]
{{ __('Schwärzt personenbezogene Daten über Wortgrenzen (inkl. Varianten wie „Herr Teplitsky", „Teplitskys"). Kostenlos, ohne KI-Gate; Alt-Werte werden verschlüsselt gesichert und die Re-Sync-Sperre gesetzt.') }}
</p>
@if ((array) ($request->payload['redaction'] ?? []) !== [])
<div class="rounded-[6px] border border-[color:var(--color-warn)]/40 bg-[color:var(--color-warn-soft)] px-3 py-2 text-[12.5px] text-[color:var(--color-ink)]">
{{ __('Strukturierter Schwärzungsauftrag aus dem Web-Formular — Begriffe und Optionen sind unten vorbefüllt (:hits Treffer laut Antragsteller-Vorschau).', ['hits' => (int) ($request->payload['redaction']['preview_total_hits'] ?? 0)]) }}
@if ($request->payload['redaction']['remove_images_requested'] ?? false)
<span class="font-semibold">{{ __('Antragsteller bittet zusätzlich um Entfernung von Bildern mit Personenbezug — unten auswählen.') }}</span>
@endif
</div>
@endif
<flux:field>
<flux:textarea wire:model="redactionTerms" rows="4"
:label="__('Schwärzbegriffe (ein Begriff pro Zeile, Varianten einzeln)')"

View file

@ -100,7 +100,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
$this->categoryId = $pr->category_id;
$this->title = $pr->title;
$this->subtitle = $pr->subtitle ?? '';
$this->text = $pr->text;
$this->text = app(PressReleaseHtmlSanitizer::class)->toEditorHtml($pr->text);
$this->keywords = $pr->keywords ?? '';
$this->backlinkUrl = $pr->backlink_url ?? '';
$this->boilerplateOverride = $pr->boilerplate_override ?? '';

View file

@ -47,9 +47,33 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
/** @var array<string, string> Bearbeitbare Feld-Werte der Korrektur. */
public array $correctionValues = [];
/** @var array{fields: array<string, array{distance_percent: float, changed: bool}>, max_distance_percent: float, has_changes: bool, is_minor: bool}|null */
/** @var array{fields: array<string, array{distance_percent: float, changed: bool}>, max_distance_percent: float, has_changes: bool, is_minor: bool, exceeds_paid_limit: bool}|null */
public ?array $correctionPreview = null;
/**
* Spur D (Phase 7): Ergebnis der einmaligen KI-Vorprüfung im
* Änderungs-Modal — rot stoppt vor der Zahlung.
*
* @var array{classification: string, reasons: list<string>}|null
*/
public ?array $paidChangePrecheck = null;
/** Spur D: Unternehmer-/AGB-Bestätigung im Änderungs-Modal. */
public bool $changeConfirmed = false;
/** Spur B (DSGVO-Modal): Schwärzbegriffe, ein Begriff pro Zeile. */
public string $dsgvoTerms = '';
public bool $dsgvoDetachContacts = false;
public bool $dsgvoRemoveImages = false;
/** Spur B: Begründung des Anliegens (Pflicht). */
public string $dsgvoMessage = '';
/** @var array{fields: array<string, array{hits: int, after: string}>, total_hits: int}|null */
public ?array $dsgvoPreview = null;
/**
* Kontext fürs „Nicht genügend Credits"-Modal (QA-Befund 07.07.):
* ['label' => Add-on-Name, 'required' => int, 'available' => int].
@ -315,7 +339,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
$this->correctionValues = [
'title' => (string) ($pr->title ?? ''),
'subtitle' => (string) ($pr->subtitle ?? ''),
'text' => (string) ($pr->text ?? ''),
// Legacy-Plaintext für den HTML-Editor in <p>/<br> wandeln —
// sonst kollabieren die Umbrüche und gingen beim Speichern verloren.
'text' => app(\App\Services\PressRelease\PressReleaseHtmlSanitizer::class)->toEditorHtml($pr->text),
'keywords' => (string) ($pr->keywords ?? ''),
];
$this->correctionOpen = true;
@ -373,17 +399,25 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
return;
}
$user = auth()->user();
if ($user->email && $user->wantsEmailNotification(\App\Enums\EmailNotificationType::PressReleaseCorrected)) {
\Illuminate\Support\Facades\Mail::to($user->email)
->queue(new \App\Mail\PressReleaseChangeApplied($user, $pr, $legalRequest));
}
$this->correctionOpen = false;
$this->correctionPreview = null;
Flux::toast(__('Korrektur gespeichert (:reference) — die Änderung ist sofort live.', ['reference' => $legalRequest->reference()]));
}
/**
* Interim bis zum 10-€-Checkout (Phase 7): größere Änderung als
* verifizierte Anfrage in die Admin-Queue geben — Zahlungsaufforderung
* folgt, keine Kosten ohne Bestätigung.
* Spur D Schritt 1 (Phase 7): Modal mit der Zwischenerklärung öffnen —
* die kostenpflichtige Änderung läuft über eine einmalige KI-Vorprüfung
* VOR der Zahlung. Je PM ist der Vorgang nur einmal möglich; ein
* abgebrochener Stripe-Checkout darf aber wieder aufgenommen werden.
*/
public function submitPaidChangeRequest(): void
public function openPaidChangeModal(): void
{
$pr = $this->getMyPR();
$this->authorize('view', $pr);
@ -394,14 +428,229 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
return;
}
$alreadyOpen = \App\Models\LegalRequest::query()
if ((int) config('billing.change_price_cents') <= 0) {
$this->addError('correction', __('Die kostenpflichtige Änderung ist derzeit nicht online buchbar. Bitte wenden Sie sich an :email.', ['email' => (string) config('legal.contact_email')]));
return;
}
if ($this->correctionPreview['exceeds_paid_limit'] ?? false) {
$this->addError('correction', __('Die Änderung ist zu umfangreich (über :limit % Distanz) und kann nicht als Änderung gebucht werden.', ['limit' => (int) config('legal.change_max_distance_percent')]));
return;
}
$existing = $this->latestChangeRequest($pr);
if ($existing !== null) {
$check = $existing->payload['ki_check']['classification'] ?? null;
if ($existing->status === \App\Enums\LegalRequestStatus::Open && in_array($check, ['green', 'yellow'], true)) {
// Abgebrochene Zahlung: direkt wieder zum Zahlungs-Schritt —
// es gilt die bereits geprüfte Fassung aus der Anfrage.
$this->paidChangePrecheck = [
'classification' => (string) $check,
'reasons' => (array) ($existing->payload['ki_check']['reasons'] ?? []),
];
Flux::modal('paid-change')->show();
return;
}
$this->addError('correction', __('Die kostenpflichtige Änderung ist je Pressemitteilung nur einmal möglich — für diese Mitteilung wurde sie bereits genutzt oder abgelehnt.'));
return;
}
$this->paidChangePrecheck = null;
$this->changeConfirmed = false;
Flux::modal('paid-change')->show();
}
/**
* Spur D Schritt 2: einmalige KI-Vorprüfung der geänderten Fassung.
* Rot → Change-Anfrage wird direkt abgelehnt, keine Zahlung. Gelb/Grün →
* die Anfrage bleibt offen und der Zahlungs-Schritt wird freigeschaltet.
* Der Checkout-Controller verlangt genau diese Anfrage als Nachweis.
*/
public function runPaidChangePrecheck(\App\Services\PressRelease\CorrectionService $correctionService): void
{
$pr = $this->getMyPR();
$this->authorize('view', $pr);
if ($this->correctionPreview === null) {
$this->addError('correction', __('Bitte zuerst die Vorschau prüfen.'));
Flux::modal('paid-change')->close();
return;
}
if ($this->latestChangeRequest($pr) !== null) {
$this->addError('correction', __('Die kostenpflichtige Änderung ist je Pressemitteilung nur einmal möglich — für diese Mitteilung wurde sie bereits genutzt oder abgelehnt.'));
Flux::modal('paid-change')->close();
return;
}
$result = $correctionService->precheckProposedContent($pr, $this->correctionValues);
$isRed = $result->classification === \App\Enums\PressReleaseClassification::Red;
\App\Models\LegalRequest::query()->create([
'press_release_id' => $pr->id,
'portal' => $pr->portal?->value,
'type' => \App\Enums\LegalRequestType::Change->value,
'status' => $isRed
? \App\Enums\LegalRequestStatus::Rejected->value
: \App\Enums\LegalRequestStatus::Open->value,
'requester_email' => (string) auth()->user()->email,
'message' => __('Kostenpflichtige Änderung (Spur D) über das Kunden-Panel eingereicht.'),
'payload' => [
'source' => 'self_service',
'new_values' => $this->correctionValues,
'fields' => $this->correctionPreview['fields'],
'ki_check' => [
'classification' => $result->classification->value,
'reasons' => $result->reasons,
'provider' => $result->provider,
'model' => $result->model,
'checked_at' => now()->toIso8601String(),
],
],
'requester_ip' => request()->ip(),
'verified_at' => now(),
'verification_method' => \App\Enums\LegalRequestVerificationMethod::LoggedIn->value,
'resolved_at' => $isRed ? now() : null,
]);
$this->paidChangePrecheck = [
'classification' => $result->classification->value,
'reasons' => $result->reasons,
];
}
/**
* Unternehmer-Pflicht-Checkbox im Änderungs-Modal (Spur D) —
* protokolliert wie bei der Depublikation; der Checkout-Controller
* verlangt die zeitnahe Bestätigung zusätzlich serverseitig.
*/
public function updatedChangeConfirmed(bool $value): void
{
if (! $value) {
return;
}
$agb = LegalDocument::current(LegalDocument::TYPE_AGB);
BookingConfirmation::query()->create([
'user_id' => auth()->id(),
'booking_type' => 'press_release_change',
'booking_ref' => (string) $this->id,
'legal_document_id' => $agb?->id,
'agb_version' => $agb?->version ?? (string) config('legal.agb_version'),
'ip' => request()->ip(),
'confirmed_at' => now(),
]);
}
/**
* Jüngste Änderungs-Anfrage (Spur D) dieser PM — Grundlage der
* „nur einmal möglich"-Regel.
*/
private function latestChangeRequest(PressRelease $pr): ?\App\Models\LegalRequest
{
return \App\Models\LegalRequest::query()
->where('press_release_id', $pr->id)
->where('type', \App\Enums\LegalRequestType::Change->value)
->latest('id')
->first();
}
/**
* Anliegen-Modal → Korrektur/Änderung: Formular öffnen und zum
* Panel-Abschnitt scrollen.
*/
public function goToCorrection(): void
{
$this->startCorrection();
Flux::modal('pm-concerns')->close();
$this->js("document.getElementById('korrektur-panel')?.scrollIntoView({behavior: 'smooth'})");
}
/**
* Anliegen-Modal → Depublizierung: zum Panel-Abschnitt scrollen.
*/
public function goToDepublication(): void
{
Flux::modal('pm-concerns')->close();
$this->js("document.getElementById('depublikation-panel')?.scrollIntoView({behavior: 'smooth'})");
}
public function openDsgvoModal(): void
{
Flux::modal('pm-concerns')->close();
Flux::modal('pm-dsgvo')->show();
}
/**
* Spur B (strukturierter Schwärzungsauftrag, wie im Web-Formular):
* Pflicht-Vorschau vor der Einreichung — verändert nichts an der PM.
*/
public function previewDsgvo(\App\Services\PressRelease\AnonymizationService $anonymizationService): void
{
$pr = $this->getMyPR();
$this->authorize('view', $pr);
$terms = $this->dsgvoTermList();
if ($terms === [] && ! $this->dsgvoDetachContacts && ! $this->dsgvoRemoveImages) {
$this->addError('dsgvo', __('Bitte mindestens einen Begriff angeben oder eine der Entfernungs-Optionen auswählen.'));
$this->dsgvoPreview = null;
return;
}
$preview = $anonymizationService->preview($pr, $terms);
if ($terms !== [] && $preview['total_hits'] === 0) {
$this->addError('dsgvo', __('Die angegebenen Begriffe kommen in der Mitteilung nicht vor. Bitte prüfen Sie die Schreibweise (ganzwörtliche Suche).'));
$this->dsgvoPreview = null;
return;
}
$this->resetErrorBag('dsgvo');
$this->dsgvoPreview = $preview;
}
/**
* Einreichung ≠ Ausführung: erzeugt die verifizierte DSGVO-Anfrage für
* die Admin-Queue (Spur B bleibt admin-gegated), die PM bleibt unberührt.
*/
public function submitDsgvo(): void
{
$pr = $this->getMyPR();
$this->authorize('view', $pr);
if ($this->dsgvoPreview === null) {
$this->addError('dsgvo', __('Bitte zuerst die Vorschau prüfen.'));
return;
}
if (mb_strlen(trim($this->dsgvoMessage)) < 10) {
$this->addError('dsgvo', __('Bitte begründen Sie Ihr Anliegen kurz (mindestens 10 Zeichen).'));
return;
}
$alreadyOpen = \App\Models\LegalRequest::query()
->where('press_release_id', $pr->id)
->where('type', \App\Enums\LegalRequestType::Dsgvo->value)
->open()
->exists();
if ($alreadyOpen) {
$this->addError('correction', __('Für diese Pressemitteilung liegt bereits eine offene Änderungs-Anfrage vor.'));
$this->addError('dsgvo', __('Für diese Pressemitteilung liegt bereits eine offene Datenschutz-Anfrage vor.'));
return;
}
@ -409,22 +658,48 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
$legalRequest = \App\Models\LegalRequest::query()->create([
'press_release_id' => $pr->id,
'portal' => $pr->portal?->value,
'type' => \App\Enums\LegalRequestType::Change->value,
'type' => \App\Enums\LegalRequestType::Dsgvo->value,
'status' => \App\Enums\LegalRequestStatus::Open->value,
'requester_email' => (string) auth()->user()->email,
'message' => __('Kostenpflichtige Änderung (10 €) über das Kunden-Panel eingereicht.'),
'message' => trim($this->dsgvoMessage),
'payload' => [
'source' => 'self_service',
'new_values' => $this->correctionValues,
'concern' => 'dsgvo',
'redaction' => [
'terms' => $this->dsgvoTermList(),
'detach_contacts' => $this->dsgvoDetachContacts,
'remove_images_requested' => $this->dsgvoRemoveImages,
'preview_total_hits' => (int) ($this->dsgvoPreview['total_hits'] ?? 0),
],
],
'requester_ip' => request()->ip(),
'verified_at' => now(),
'verification_method' => \App\Enums\LegalRequestVerificationMethod::LoggedIn->value,
]);
$this->correctionOpen = false;
$this->correctionPreview = null;
Flux::toast(__('Änderungs-Anfrage :reference eingereicht — Sie erhalten vor der Umsetzung eine Zahlungsaufforderung.', ['reference' => $legalRequest->reference()]));
\Illuminate\Support\Facades\Mail::to($legalRequest->requester_email)->queue(
(new \App\Mail\LegalRequestReceived($legalRequest))->afterCommit()
);
\Illuminate\Support\Facades\Mail::to((string) config('legal.contact_email'))->queue(
(new \App\Mail\LegalRequestSubmitted($legalRequest))->afterCommit()
);
$this->reset('dsgvoTerms', 'dsgvoDetachContacts', 'dsgvoRemoveImages', 'dsgvoMessage', 'dsgvoPreview');
Flux::modal('pm-dsgvo')->close();
Flux::toast(__('Datenschutz-Anfrage :reference eingereicht — unser Team prüft und setzt die Schwärzung um.', ['reference' => $legalRequest->reference()]));
}
/**
* @return list<string>
*/
private function dsgvoTermList(): array
{
return collect(preg_split('/\r\n|\r|\n/', $this->dsgvoTerms) ?: [])
->map(fn (string $term): string => trim($term))
->filter()
->unique()
->values()
->all();
}
/**
@ -691,14 +966,14 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
{{ __('Vorschau-Link') }}
</flux:button>
@if ($pr->status === \App\Enums\PressReleaseStatus::Published)
{{-- Meldeverfahren (Phase 3): Eigentümer überspringt die
Magic-Link-Verifikation (Zuordnung = Verifikation). --}}
<form method="POST" action="{{ route('me.press-releases.report', ['pressReleaseId' => $pr->id]) }}">
@csrf
<flux:button type="submit" variant="filled" icon="flag">
{{-- Meldeverfahren: Anliegen-Auswahl im Modal — Korrektur/Änderung
und Depublizierung springen zu den Panel-Abschnitten, DSGVO
öffnet das strukturierte Schwärzungsformular. --}}
<flux:modal.trigger name="pm-concerns">
<flux:button variant="filled" icon="flag">
{{ __('Ändern/Melden') }}
</flux:button>
</form>
</flux:modal.trigger>
@endif
<flux:button variant="filled" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
{{ __('Zurück') }}
@ -1289,7 +1564,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
Kleine Korrektur (Levenshtein-Gate) kostenlos + sofort live; größere
Änderung wird automatisch als Spur D (10 €) eingestuft. --}}
@if ($pr->status === PressReleaseStatus::Published)
<article class="panel">
<article class="panel" id="korrektur-panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Änderung & Korrektur') }}</span>
</div>
@ -1301,7 +1576,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<div class="min-w-0">
<h3 class="text-[14px] font-bold text-[color:var(--color-ink)] m-0">{{ __('Veröffentlichte Mitteilung korrigieren oder ändern') }}</h3>
<p class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1 mb-0">
{{ __('Kleine Korrekturen (Tippfehler, Kontaktdaten — ohne neuen Inhalt) sind kostenlos und sofort live. Größere Änderungen (neuer Text, max. Distanz über :percent %) kosten 10 € und durchlaufen erneut die Freigabe. Die Einstufung übernimmt das System anhand der Vorschau.', ['percent' => (int) config('legal.correction_max_distance_percent')]) }}
{{ __('Kleine Korrekturen (Tippfehler, Kontaktdaten — ohne neuen Inhalt) sind kostenlos und sofort live. Größere Änderungen (Distanz über :percent %) kosten :price € zzgl. USt., werden vor der Zahlung einmalig automatisch geprüft und sind je Mitteilung nur einmal möglich. Die Einstufung übernimmt das System anhand der Vorschau.', ['percent' => (int) config('legal.correction_max_distance_percent'), 'price' => number_format((int) config('billing.change_price_cents') / 100, 2, ',', '.')]) }}
{{ trans_choice('Pro Mitteilung ist höchstens eine kostenlose Korrektur pro Woche möglich.|Pro Mitteilung sind höchstens :count kostenlose Korrekturen pro Woche möglich.', (int) config('legal.corrections_per_week'), ['count' => (int) config('legal.corrections_per_week')]) }}
</p>
</div>
</div>
@ -1319,7 +1595,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<flux:input wire:model="correctionValues.subtitle" :label="__('Untertitel')" />
</flux:field>
<flux:field>
<flux:textarea wire:model="correctionValues.text" rows="10" :label="__('Text')" />
<flux:label>{{ __('Text') }}</flux:label>
<flux:editor
wire:model="correctionValues.text"
toolbar="heading | bold italic | bullet ordered blockquote | link | undo redo"
/>
</flux:field>
<flux:field>
<flux:input wire:model="correctionValues.keywords" :label="__('Keywords')" />
@ -1336,9 +1616,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
wire:confirm="{{ __('Korrektur jetzt speichern? Die Änderung ist sofort öffentlich sichtbar.') }}">
{{ __('Kostenlos speichern — sofort live') }}
</flux:button>
@elseif ($correctionPreview !== null)
<flux:button size="sm" variant="primary" icon="credit-card" wire:click="submitPaidChangeRequest">
{{ __('Als kostenpflichtige Änderung einreichen (10 €)') }}
@elseif ($correctionPreview !== null && ! ($correctionPreview['exceeds_paid_limit'] ?? false))
<flux:button size="sm" variant="primary" icon="credit-card" wire:click="openPaidChangeModal">
{{ __('Als kostenpflichtige Änderung einreichen (:price € zzgl. USt.)', ['price' => number_format((int) config('billing.change_price_cents') / 100, 2, ',', '.')]) }}
</flux:button>
@endif
<flux:button size="sm" variant="ghost" wire:click="$set('correctionOpen', false)">
@ -1349,9 +1629,13 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
@if ($correctionPreview !== null)
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] px-3 py-2.5 text-[12.5px] space-y-1">
<p class="font-semibold text-[color:var(--color-ink)] m-0">
{{ $correctionPreview['is_minor']
? __('Einstufung: Kleine Korrektur — kostenlos, wird sofort übernommen.')
: __('Einstufung: Größere Änderung (max. Distanz :percent %) — 10 € mit erneutem Freigabelauf.', ['percent' => $correctionPreview['max_distance_percent']]) }}
@if ($correctionPreview['is_minor'])
{{ __('Einstufung: Kleine Korrektur — kostenlos, wird sofort übernommen.') }}
@elseif ($correctionPreview['exceeds_paid_limit'] ?? false)
{{ __('Einstufung: Zu umfangreich (max. Distanz :percent %, Grenze :limit %) — eine so weitgehende Änderung ist nicht buchbar. Für einen neuen Text nutzen Sie bitte die Depublikation und eine Neuveröffentlichung.', ['percent' => $correctionPreview['max_distance_percent'], 'limit' => (int) config('legal.change_max_distance_percent')]) }}
@else
{{ __('Einstufung: Größere Änderung (max. Distanz :percent %) — kostenpflichtig (:price € zzgl. USt.) nach einmaliger automatischer Prüfung.', ['percent' => $correctionPreview['max_distance_percent'], 'price' => number_format((int) config('billing.change_price_cents') / 100, 2, ',', '.')]) }}
@endif
</p>
@foreach ($correctionPreview['fields'] as $field => $meta)
@continue(! $meta['changed'])
@ -1375,7 +1659,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
$depubNet = number_format($depublicationPriceCents / 100, 2, ',', '.');
$depubGross = number_format($depublicationPriceCents * (1 + (float) config('billing.vat_rate')) / 100, 2, ',', '.');
@endphp
<article class="panel">
<article class="panel" id="depublikation-panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Depublikation (Service)') }}</span>
</div>
@ -1424,6 +1708,196 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
</article>
@endif
{{-- ============== MODAL: ANLIEGEN-AUSWAHL (Ändern/Melden) ==============
Vier Wege: Korrektur & Änderung springen zum Panel-Abschnitt,
Depublizierung ebenso, DSGVO öffnet das Schwärzungsformular. --}}
<flux:modal name="pm-concerns" class="w-full max-w-md">
<div class="space-y-5">
<div>
<flux:text class="eyebrow muted">{{ __('Ändern/Melden') }}</flux:text>
<flux:heading size="lg">{{ __('Was möchten Sie tun?') }}</flux:heading>
</div>
<div class="space-y-2">
<button type="button" wire:click="goToCorrection"
class="w-full text-left rounded-[6px] border border-[color:var(--color-bg-rule)] px-4 py-3 hover:bg-[color:var(--color-bg-elev)] transition-colors">
<span class="block text-[13px] font-bold text-[color:var(--color-ink)]">{{ __('Kleine Korrektur') }} <span class="font-normal text-[color:var(--color-ink-3)]">· {{ __('kostenlos') }}</span></span>
<span class="block text-[12.5px] text-[color:var(--color-ink-2)] mt-0.5">{{ __('Tippfehler, Kontaktdaten — ohne neuen Inhalt, sofort live.') }}</span>
</button>
<button type="button" wire:click="goToCorrection"
class="w-full text-left rounded-[6px] border border-[color:var(--color-bg-rule)] px-4 py-3 hover:bg-[color:var(--color-bg-elev)] transition-colors">
<span class="block text-[13px] font-bold text-[color:var(--color-ink)]">{{ __('Inhaltliche Änderung') }} <span class="font-normal text-[color:var(--color-ink-3)]">· {{ number_format((int) config('billing.change_price_cents') / 100, 2, ',', '.') }} € {{ __('zzgl. USt.') }}</span></span>
<span class="block text-[12.5px] text-[color:var(--color-ink-2)] mt-0.5">{{ __('Neuer oder umgeschriebener Text — mit einmaliger automatischer Prüfung vor der Zahlung.') }}</span>
</button>
<button type="button" wire:click="openDsgvoModal"
class="w-full text-left rounded-[6px] border border-[color:var(--color-bg-rule)] px-4 py-3 hover:bg-[color:var(--color-bg-elev)] transition-colors">
<span class="block text-[13px] font-bold text-[color:var(--color-ink)]">{{ __('Entfernung persönlicher Daten (DSGVO)') }} <span class="font-normal text-[color:var(--color-ink-3)]">· {{ __('kostenlos') }}</span></span>
<span class="block text-[12.5px] text-[color:var(--color-ink-2)] mt-0.5">{{ __('Namen und Kontaktdaten natürlicher Personen schwärzen lassen — Prüfung durch unser Team.') }}</span>
</button>
<button type="button" wire:click="goToDepublication"
class="w-full text-left rounded-[6px] border border-[color:var(--color-bg-rule)] px-4 py-3 hover:bg-[color:var(--color-bg-elev)] transition-colors">
<span class="block text-[13px] font-bold text-[color:var(--color-ink)]">{{ __('Depublizierung') }} <span class="font-normal text-[color:var(--color-ink-3)]">· {{ number_format((int) config('billing.depublication_price_cents') / 100, 2, ',', '.') }} € {{ __('zzgl. USt.') }}</span></span>
<span class="block text-[12.5px] text-[color:var(--color-ink-2)] mt-0.5">{{ __('Mitteilung vollständig aus der öffentlichen Ansicht nehmen (Tombstone bleibt).') }}</span>
</button>
</div>
<flux:modal.close>
<flux:button variant="ghost" size="sm">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
</div>
</flux:modal>
{{-- ============== MODAL: DSGVO-SCHWÄRZUNG (Spur B, strukturiert) ==============
Wie das Web-Formular (Phase 5): Pflicht-Vorschau, Einreichung ≠
Ausführung — die Umsetzung bleibt beim Admin. --}}
<flux:modal name="pm-dsgvo" class="w-full max-w-lg">
<div class="space-y-5">
<div>
<flux:text class="eyebrow muted">{{ __('Datenschutz (DSGVO)') }}</flux:text>
<flux:heading size="lg">{{ __('Persönliche Daten schwärzen lassen') }}</flux:heading>
</div>
<p class="text-[12.5px] text-[color:var(--color-ink-2)] m-0">
{{ __('Geben Sie an, welche Angaben entfernt werden sollen. Die Umsetzung erfolgt nach Prüfung durch unser Team; die Mitteilung bleibt im Übrigen online.') }}
</p>
<flux:field>
<flux:textarea wire:model="dsgvoTerms" rows="4"
:label="__('Zu entfernende Angaben (ein Begriff pro Zeile, Varianten einzeln)')"
:placeholder="'Max Mustermann&#10;Mustermanns&#10;max@firma.example'" />
</flux:field>
<flux:checkbox wire:model="dsgvoDetachContacts"
:label="__('Hinterlegte Pressekontakt-Daten von dieser Mitteilung entfernen')" />
<flux:checkbox wire:model="dsgvoRemoveImages"
:label="__('Bilder mit Personenbezug entfernen (Auswahl trifft unser Team)')" />
<flux:field>
<flux:textarea wire:model="dsgvoMessage" rows="3"
:label="__('Begründung (Pflicht)')"
:placeholder="__('z. B. Ich bin die betroffene Person und wünsche die Entfernung meines Namens.')" />
</flux:field>
<flux:error name="dsgvo" />
@if ($dsgvoPreview !== null)
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] px-3 py-2.5 text-[12.5px] space-y-2 max-h-64 overflow-y-auto">
<p class="font-semibold text-[color:var(--color-ink)] m-0">
{{ trans_choice('Vorschau: Eine Stelle wird ersetzt.|Vorschau: :hits Stellen werden ersetzt.', (int) $dsgvoPreview['total_hits'], ['hits' => (int) $dsgvoPreview['total_hits']]) }}
</p>
@php
$dsgvoFieldLabels = ['title' => __('Titel'), 'subtitle' => __('Untertitel'), 'text' => __('Text'), 'boilerplate_override' => __('Boilerplate'), 'keywords' => __('Keywords')];
@endphp
@forelse ($dsgvoPreview['fields'] as $field => $meta)
<div>
<p class="m-0 text-[12px] font-semibold text-[color:var(--color-ink)]">{{ $dsgvoFieldLabels[$field] ?? $field }} · {{ $meta['hits'] }}×</p>
<p class="m-0 text-[color:var(--color-ink-2)] whitespace-pre-line">{{ \Illuminate\Support\Str::limit(trim(strip_tags((string) $meta['after'])), 400) }}</p>
</div>
@empty
<p class="m-0 text-[color:var(--color-ink-2)]">{{ __('Keine Text-Ersetzungen — Ihre Anfrage betrifft nur Kontaktdaten bzw. Bilder.') }}</p>
@endforelse
</div>
@endif
<div class="flex flex-wrap gap-2">
<flux:button size="sm" variant="filled" icon="eye" wire:click="previewDsgvo">
{{ $dsgvoPreview === null ? __('Vorschau prüfen (Pflicht)') : __('Vorschau aktualisieren') }}
</flux:button>
@if ($dsgvoPreview !== null)
<flux:button size="sm" variant="primary" icon="check" wire:click="submitDsgvo">
{{ __('Verbindlich einreichen') }}
</flux:button>
@endif
<flux:modal.close>
<flux:button size="sm" variant="ghost">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
</div>
</div>
</flux:modal>
{{-- ============== MODAL: KOSTENPFLICHTIGE ÄNDERUNG (Spur D, Phase 7) ==============
Zwischenschritt vor der Zahlung: einmalige KI-Vorprüfung der neuen
Fassung. Rot → Ablehnung ohne Zahlung; Gelb/Grün → Checkbox + Stripe. --}}
<flux:modal name="paid-change" class="w-full max-w-md">
@php
$paidChangeNet = number_format((int) config('billing.change_price_cents') / 100, 2, ',', '.');
$paidChangeGross = number_format((int) config('billing.change_price_cents') * (1 + (float) config('billing.vat_rate')) / 100, 2, ',', '.');
@endphp
<div class="space-y-5">
<div>
<flux:text class="eyebrow muted">{{ __('Kostenpflichtige Änderung') }}</flux:text>
<flux:heading size="lg">{{ __('Prüfung & Zahlung') }}</flux:heading>
</div>
@if ($paidChangePrecheck === null)
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[12.5px] leading-[1.6] text-[color:var(--color-ink-2)] space-y-2">
<p class="m-0">
{{ __('Ihre geänderte Fassung wird jetzt einmalig automatisch geprüft — wie beim Freigabelauf einer neuen Pressemitteilung.') }}
</p>
<ul class="m-0 pl-4 list-disc space-y-1">
<li>{{ __('Ergebnis Rot: Die Einreichung wird direkt abgelehnt, es entstehen keine Kosten.') }}</li>
<li>{{ __('Ergebnis Gelb oder Grün: Sie gelangen weiter zur Zahlung (:net € zzgl. USt., :gross € inkl. 19 % USt.). Nach Zahlungseingang geht die Änderung sofort live.', ['net' => $paidChangeNet, 'gross' => $paidChangeGross]) }}</li>
</ul>
<p class="m-0 font-semibold text-[color:var(--color-ink)]">
{{ __('Dieser Vorgang ist je Pressemitteilung nur einmal möglich.') }}
</p>
</div>
<div class="flex gap-2">
<flux:button variant="primary" icon="shield-check" wire:click="runPaidChangePrecheck">
<span wire:loading.remove wire:target="runPaidChangePrecheck">{{ __('Prüfung jetzt starten') }}</span>
<span wire:loading wire:target="runPaidChangePrecheck">{{ __('Prüfung läuft …') }}</span>
</flux:button>
<flux:modal.close>
<flux:button variant="ghost">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
</div>
@elseif ($paidChangePrecheck['classification'] === 'red')
<div class="rounded-[6px] border-l-[3px] border border-[color:var(--color-err)] bg-[color:var(--color-err-soft)] p-4 text-[12.5px] leading-[1.6] text-[color:var(--color-ink)]">
<p class="m-0 font-bold">{{ __('Prüfung nicht bestanden — die Einreichung wurde abgelehnt.') }}</p>
<p class="m-0 mt-1 text-[color:var(--color-ink-2)]">
{{ __('Es entstehen keine Kosten. Die geänderte Fassung verstößt gegen unsere Inhalts-Richtlinien und kann nicht veröffentlicht werden.') }}
</p>
@if ($paidChangePrecheck['reasons'] !== [])
<ul class="m-0 mt-2 pl-4 list-disc space-y-1 text-[color:var(--color-ink-2)]">
@foreach ($paidChangePrecheck['reasons'] as $reason)
<li>{{ $reason }}</li>
@endforeach
</ul>
@endif
</div>
<flux:modal.close>
<flux:button variant="ghost">{{ __('Schließen') }}</flux:button>
</flux:modal.close>
@else
<div class="rounded-[6px] border-l-[3px] border border-[color:var(--color-ok)] bg-[color:var(--color-ok-soft)] p-4 text-[12.5px] leading-[1.6] text-[color:var(--color-ink)]">
<p class="m-0 font-bold">{{ __('Prüfung bestanden.') }}</p>
<p class="m-0 mt-1 text-[color:var(--color-ink-2)]">
{{ __('Nach Zahlungseingang (:net € zzgl. USt., :gross € inkl. 19 % USt.) wird die geprüfte Fassung sofort übernommen und ist öffentlich sichtbar.', ['net' => $paidChangeNet, 'gross' => $paidChangeGross]) }}
</p>
</div>
<div class="space-y-2">
<flux:switch
align="left"
wire:model.live="changeConfirmed"
:label="__('Ich bestelle in Ausübung meiner gewerblichen oder selbständigen beruflichen Tätigkeit (§ 14 BGB) und akzeptiere die AGB.')"
/>
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0">
<a href="{{ route('agb', ['edition' => 'de']) }}" target="_blank" rel="noopener" class="underline underline-offset-2 hover:text-[color:var(--color-ink)]">
{{ __('AGB (Version :version)', ['version' => \App\Models\LegalDocument::currentAgbVersion()]) }}
</a>
</p>
</div>
@if ($changeConfirmed)
<flux:button variant="primary" icon="credit-card" href="{{ route('me.checkout.change', $pr->id) }}">
{{ __('Kostenpflichtig ändern') }}
</flux:button>
@else
<flux:button variant="primary" icon="credit-card" disabled>
{{ __('Kostenpflichtig ändern') }}
</flux:button>
@endif
@endif
</div>
</flux:modal>
{{-- ============== MODAL: ADD-ON-KAUF BESTÄTIGEN ==============
Vorgeschaltete Bestätigung (Entscheidung 10.07.): kein Add-on-Klick
bucht direkt Credits ab — erst „Kostenpflichtig buchen" kauft. --}}

View file

@ -5,16 +5,29 @@
@section('content')
{{--
Hinweistexte je Anfragetyp (R2.5, final 10.07.2026): Wortlaut folgt dem am
06.07. inhaltlich freigegebenen Meldeverfahren v0.1 (/melden bzw.
lang/de/static.php static.report.*) und dem tatsächlichen Flow
(Vorgangsnummer M-{id}, unverzügliche Eingangsbestätigung nach DSA Art. 16
Abs. 4 sofern E-Mail angegeben, kostenlose manuelle Prüfung). Die noch
ausstehende anwaltliche Freigabe des Rechtstexte-Pakets [EXTERN] umfasst
auch diese Texte.
Zweistufige Triage (Meldeverfahren v2, 23.07.2026): Stufe 1 wählt das
Anliegen, Stufe 2 zeigt den passenden Weg. Absender-Anliegen und
DSGVO/Persönlichkeitsrechte werden primär auf den passwortlosen
Anmeldelink ins Portal (pressekonto /anmeldelink) geleitet — dort werden
die PMs der Kontaktadresse im Konto zusammengeführt und alle
Selbstservice-Funktionen stehen bereit. Das Kontaktformular ist nur der
Fallback und weist auf die notwendige persönliche Identifizierung hin.
Meldungen rechtswidriger Inhalte gehen direkt ins Formular (DSA: jede
dritte Person darf melden).
Hinweistexte folgen dem am 06.07. freigegebenen Meldeverfahren v0.1;
die anwaltliche Freigabe des Rechtstexte-Pakets [EXTERN] umfasst auch
diese Texte.
--}}
<style>[x-cloak]{display:none!important}</style>
<section
x-data="{ type: @js(old('type', $type->value)), emailRequired() { return ['dsgvo', 'personal_rights'].includes(this.type); } }"
x-data="{
concern: @js(old('type', $initialConcern)),
showFallback: @js(in_array(old('type'), ['dsgvo', 'personal_rights'], true)),
pick(value) { this.concern = value; this.showFallback = false; },
needsFallbackToggle() { return ['dsgvo', 'personal_rights'].includes(this.concern); },
formVisible() { return this.concern === 'report' || (this.needsFallbackToggle() && this.showFallback); },
emailRequired() { return ['dsgvo', 'personal_rights'].includes(this.concern); }
}"
class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-10"
>
<a href="{{ route('release.detail', ['slug' => $release->slug]) }}"
@ -28,9 +41,9 @@
Betrifft: <span class="text-ink-2 font-medium">{{ $release->title }}</span>
</p>
<p class="text-[12.5px] text-ink-3 leading-relaxed mb-6">
Datenschutz (DSGVO), Persönlichkeitsrechte und Meldungen werden von unserem
Team manuell geprüft. Bitte wählen Sie die Art Ihrer Anfrage – dazu erscheint
ein Hinweis mit den genauen Regeln.
Bitte wählen Sie zuerst Ihr Anliegen — dazu erscheint der passende Weg.
Viele Anliegen können Sie nach einer kurzen Verifizierung direkt selbst
anstoßen; alle Prüfungen sind kostenlos.
</p>
@if (session('legal-status'))
@ -39,63 +52,141 @@
</div>
@endif
<form method="POST" action="{{ route('legal-request.store', ['slug' => $release->slug]) }}" class="space-y-5">
@csrf
<div>
<label for="type" class="block text-[12px] font-semibold text-ink mb-1.5">Art der Anfrage</label>
<select id="type" name="type" x-model="type"
class="w-full border border-bg-rule px-3 py-2.5 text-[13px] bg-white text-ink focus:outline-none focus:border-brand">
@foreach ($types as $case)
<option value="{{ $case->value }}" @selected(old('type', $type->value) === $case->value)>
{{ $case->publicLabel() }}
</option>
@endforeach
</select>
@error('type')
<p class="mt-1 text-[12px] text-red-600">{{ $message }}</p>
@enderror
{{-- ===== Stufe 1: Anliegen wählen ===== --}}
<fieldset class="mb-6">
<legend class="block text-[12px] font-semibold text-ink mb-2">Was möchten Sie tun?</legend>
<div class="space-y-2">
@foreach ([
'owner' => ['label' => 'Korrektur, Änderung oder Depublizierung', 'desc' => 'Sie sind Absender oder hinterlegter Pressekontakt und möchten Inhalte korrigieren, ändern oder die Mitteilung offline nehmen.', 'badge' => 'Selbstservice'],
'dsgvo' => ['label' => 'Entfernung persönlicher Daten (DSGVO)', 'desc' => 'Ihr Name oder Ihre Kontaktdaten erscheinen in dieser Mitteilung und sollen entfernt werden (Art. 17 DSGVO).', 'badge' => 'kostenlos'],
'personal_rights' => ['label' => 'Verletzung von Persönlichkeitsrechten', 'desc' => 'Unwahre Tatsachenbehauptungen, Bild- oder Namensrechte — die Mitteilung betrifft Sie persönlich.', 'badge' => 'kostenlos'],
'report' => ['label' => 'Rechtswidrige Inhalte melden', 'desc' => 'Urheberrechts- oder Markenverletzung, irreführende Inhalte, Spam — Meldung als dritte Person, auch ohne eigene Betroffenheit.', 'badge' => 'kostenlos'],
] as $value => $card)
<label class="flex items-start gap-3 border border-bg-rule px-3 py-2.5 cursor-pointer hover:bg-bg-elev"
:class="concern === '{{ $value }}' ? 'border-brand bg-bg-elev' : ''">
<input type="radio" name="concern_choice" value="{{ $value }}" class="mt-0.5"
@change="pick('{{ $value }}')"
:checked="concern === '{{ $value }}'" />
<span class="text-[13px] text-ink">
<span class="font-semibold">{{ $card['label'] }}</span>
<span class="text-ink-3">· {{ $card['badge'] }}</span>
<span class="block text-[12.5px] text-ink-2">{{ $card['desc'] }}</span>
</span>
</label>
@endforeach
</div>
</fieldset>
{{-- Hinweistext je Anfragetyp (Wortlaut: siehe Kommentar am Seitenanfang). --}}
<div x-cloak x-show="type === 'report'"
class="px-4 py-3 border border-bg-rule bg-bg-elev text-[12.5px] text-ink-2 leading-relaxed">
<strong class="text-ink">Meldung:</strong>
Melden Sie diese Pressemitteilung, wenn Sie sie für rechtswidrig halten
oder sie Ihre Rechte verletzt — z.&nbsp;B. unwahre Tatsachenbehauptungen,
Urheberrechts- oder Markenverletzungen, irreführende Inhalte oder Spam.
Bitte benennen Sie konkret, welche Inhalte betroffen sind und warum.
Jede Meldung wird von unserem Team manuell geprüft; die Meldung ist
kostenlos. Wenn Sie eine E-Mail-Adresse angeben, erhalten Sie unverzüglich
eine Eingangsbestätigung mit Vorgangsnummer und anschließend unsere
Entscheidung mit Begründung.
{{-- ===== Stufe 2a: Absender-Anliegen → passwortloser Portal-Login ===== --}}
<div x-cloak x-show="concern === 'owner'" class="space-y-4">
<div class="px-4 py-3 border border-bg-rule bg-bg-elev text-[12.5px] text-ink-2 leading-relaxed">
<strong class="text-ink">So funktioniert es:</strong>
Fordern Sie im Portal einen passwortlosen Anmeldelink an Ihre hinterlegte
Pressekontakt- oder Firmenadresse an. Nach dem Klick sind Sie angemeldet und
Ihre Pressemitteilungen werden in Ihrem Konto zusammengeführt — dort können
Sie kleine Korrekturen kostenlos und sofort umsetzen, größere Änderungen
(10&nbsp;€) und die Depublizierung (35&nbsp;€) direkt beauftragen.
</div>
<div x-cloak x-show="type === 'dsgvo'"
class="px-4 py-3 border border-bg-rule bg-bg-elev text-[12.5px] text-ink-2 leading-relaxed">
<a href="{{ $portalMagicLinkUrl }}"
class="inline-flex items-center px-5 py-2.5 bg-brand text-white text-[13px] font-semibold hover:opacity-90">
Anmeldelink im Portal anfordern
</a>
<p class="text-[12px] text-ink-3 leading-relaxed">
Keine erreichbare Pressekontaktadresse mehr? Dann nutzen Sie bitte das
Formular unter „Entfernung persönlicher Daten" bzw. die manuelle Prüfung
über das <a href="{{ route('melden') }}" class="underline hover:text-ink">Meldeverfahren</a> —
wir verifizieren Ihre Berechtigung anhand eines Nachweises.
</p>
</div>
{{-- ===== Stufe 2b: DSGVO ===== --}}
<div x-cloak x-show="concern === 'dsgvo'" class="space-y-4">
<div class="px-4 py-3 border border-bg-rule bg-bg-elev text-[12.5px] text-ink-2 leading-relaxed">
<strong class="text-ink">Datenschutz / DSGVO:</strong>
Als natürliche Person können Sie die Entfernung, Ersetzung oder
Anonymisierung Ihrer persönlichen Daten in dieser Pressemitteilung
verlangen (Art. 17 DSGVO) — zum Beispiel Name, Telefonnummer oder
E-Mail-Adresse. Bitte geben Sie an, welche Angaben Sie betreffen. Die
Prüfung und Bearbeitung ist für Sie kostenlos. Zur Prüfung Ihrer
Berechtigung benötigen wir Ihre E-Mail-Adresse; in der Regel senden wir
einen Bestätigungslink an die betroffene Adresse, andernfalls können wir
einen geeigneten Identitäts- oder Berechtigungsnachweis verlangen. Sie
erhalten unverzüglich eine Eingangsbestätigung mit Vorgangsnummer. Die
E-Mail-Adresse. Die Prüfung und Bearbeitung ist für Sie kostenlos. Die
Pressemitteilung selbst bleibt nach der Entfernung Ihrer Daten
grundsätzlich bestehen.
</div>
<div x-cloak x-show="type === 'personal_rights'"
class="px-4 py-3 border border-bg-rule bg-bg-elev text-[12.5px] text-ink-2 leading-relaxed">
<div class="px-4 py-3 border border-bg-rule text-[12.5px] text-ink-2 leading-relaxed">
<strong class="text-ink">Empfohlener Weg — passwortloser Anmeldelink ins Portal:</strong>
Sind Sie die in der Veröffentlichung hinterlegte Kontaktperson (oder haben
Zugriff auf deren Postfach), melden Sie sich per Anmeldelink im Portal an —
Ihre Mitteilungen werden dort in Ihrem Konto zusammengeführt und Sie geben
die zu entfernenden Angaben direkt mit Vorschau an. Der schnellste Weg.
</div>
<a href="{{ $portalMagicLinkUrl }}"
class="inline-flex items-center px-5 py-2.5 bg-brand text-white text-[13px] font-semibold hover:opacity-90">
Anmeldelink im Portal anfordern
</a>
<p>
<button type="button" @click="showFallback = true" x-show="!showFallback"
class="text-[12.5px] text-ink-2 underline underline-offset-2 hover:text-ink">
Ich bin nicht der hinterlegte Pressekontakt oder habe keinen Zugriff auf das Postfach
</button>
</p>
</div>
{{-- ===== Stufe 2c: Persönlichkeitsrechte ===== --}}
<div x-cloak x-show="concern === 'personal_rights'" class="space-y-4">
<div class="px-4 py-3 border border-bg-rule bg-bg-elev text-[12.5px] text-ink-2 leading-relaxed">
<strong class="text-ink">Persönlichkeitsrecht:</strong>
Wenn diese Mitteilung Ihre Persönlichkeitsrechte verletzt — z.&nbsp;B.
durch unwahre Tatsachenbehauptungen oder die Verletzung von Bild- oder
Namensrechten —, schildern Sie bitte konkret, welche Inhalte betroffen
sind und warum. Für die Eingangsbestätigung mit Vorgangsnummer und
Rückfragen benötigen wir Ihre E-Mail-Adresse. Wir prüfen Ihre Anfrage
sorgfältig und zeitnah und teilen Ihnen unsere Entscheidung mit
Begründung mit; bei berechtigten Beanstandungen wird der Inhalt
angepasst, gesperrt oder entfernt. Die Prüfung ist kostenlos.
Namensrechten —, prüfen wir Ihre Anfrage sorgfältig und zeitnah und teilen
Ihnen unsere Entscheidung mit Begründung mit; bei berechtigten
Beanstandungen wird der Inhalt angepasst, gesperrt oder entfernt. Die
Prüfung ist kostenlos.
</div>
<div class="px-4 py-3 border border-bg-rule text-[12.5px] text-ink-2 leading-relaxed">
<strong class="text-ink">Schnellster Weg — passwortloser Anmeldelink ins Portal:</strong>
Sind Sie zugleich die hinterlegte Kontaktperson dieser Veröffentlichung,
melden Sie sich per Anmeldelink im Portal an und stoßen die Anpassung
direkt in Ihrem Konto an.
</div>
<a href="{{ $portalMagicLinkUrl }}"
class="inline-flex items-center px-5 py-2.5 bg-brand text-white text-[13px] font-semibold hover:opacity-90">
Anmeldelink im Portal anfordern
</a>
<p>
<button type="button" @click="showFallback = true" x-show="!showFallback"
class="text-[12.5px] text-ink-2 underline underline-offset-2 hover:text-ink">
Ich bin nicht der hinterlegte Pressekontakt — Anfrage per Formular stellen
</button>
</p>
</div>
{{-- ===== Stufe 2d: Meldung → direkt zum Formular ===== --}}
<div x-cloak x-show="concern === 'report'"
class="mb-4 px-4 py-3 border border-bg-rule bg-bg-elev text-[12.5px] text-ink-2 leading-relaxed">
<strong class="text-ink">Meldung:</strong>
Melden Sie diese Pressemitteilung, wenn Sie sie für rechtswidrig halten
oder sie Ihre Rechte verletzt — z.&nbsp;B. unwahre Tatsachenbehauptungen,
Urheberrechts- oder Markenverletzungen, irreführende Inhalte oder Spam.
Bitte benennen Sie konkret, welche Inhalte betroffen sind und warum.
Jede Meldung wird von unserem Team manuell geprüft; die Meldung ist
kostenlos. Wenn Sie eine E-Mail-Adresse angeben, erhalten Sie unverzüglich
eine Eingangsbestätigung mit Vorgangsnummer und anschließend unsere
Entscheidung mit Begründung.
</div>
{{-- ===== Formular: direkt (Meldung) bzw. Fallback (DSGVO/Persönlichkeitsrecht) ===== --}}
<form x-cloak x-show="formVisible()" method="POST"
action="{{ route('legal-request.store', ['slug' => $release->slug]) }}" class="space-y-5 mt-2">
@csrf
<input type="hidden" name="type" :value="concern" />
{{-- Identifizierungs-Hinweis nur im Fallback-Weg (DSGVO/Persönlichkeitsrecht). --}}
<div x-cloak x-show="needsFallbackToggle()"
class="px-4 py-3 border-l-2 border border-bg-rule text-[12.5px] text-ink-2 leading-relaxed">
<strong class="text-ink">Wichtig — persönliche Identifizierung erforderlich:</strong>
Da personenbezogene Daten bzw. Persönlichkeitsrechte betroffen sind, müssen
wir Ihre Berechtigung prüfen. In der Regel senden wir einen Bestätigungslink
an die betroffene Adresse; andernfalls können wir einen geeigneten
Identitäts- oder Berechtigungsnachweis verlangen. Sie erhalten unverzüglich
eine Eingangsbestätigung mit Vorgangsnummer.
</div>
<div>
@ -128,8 +219,6 @@
verwirft der Controller die Anfrage still. Die Namen ('website',
'homepage') sind bewusst „attraktiv" für Auto-Filler. Zusätzlich greift
serverseitig ein Rate-Limit pro IP/PM.
Mögliche Erweiterung später: zeitbasierte Falle (signierter Render-
Zeitstempel, Ablehnung bei Absenden < 2s) oder Captcha bei Missbrauch.
--}}
<div class="hidden" aria-hidden="true">
<label for="website">Website</label>

View file

@ -4,6 +4,7 @@
@section('meta_description', __('static.pm_report.meta'))
@section('content')
<style>[x-cloak]{display:none!important}</style>
<x-web.static-page
:eyebrow="__('static.legal.eyebrow')"
:title="__('static.pm_report.concerns_title')"
@ -21,7 +22,73 @@
</div>
@endif
<form method="POST" action="{{ route('melden.pm.anliegen.store') }}" class="space-y-5">
@if (($dsgvoPreview ?? null) !== null)
{{-- Pflicht-Diff (Spur B): Vorschau der Schwärzung bestätigen, bevor
die Anfrage entsteht. Ausführung bleibt beim Admin. --}}
@php
$fieldLabels = [
'title' => __('static.pm_report.dsgvo_field_title'),
'subtitle' => __('static.pm_report.dsgvo_field_subtitle'),
'text' => __('static.pm_report.dsgvo_field_text'),
'boilerplate_override' => __('static.pm_report.dsgvo_field_boilerplate'),
'keywords' => __('static.pm_report.dsgvo_field_keywords'),
];
@endphp
<div class="mb-6 border border-bg-rule">
<div class="px-4 py-3 bg-bg-elev border-b border-bg-rule">
<p class="text-[13px] font-semibold text-ink">{{ __('static.pm_report.dsgvo_preview_title') }}</p>
<p class="text-[12.5px] text-ink-2 mt-0.5">
{{ trans_choice('static.pm_report.dsgvo_preview_hits', (int) $dsgvoPreview['total_hits'], ['hits' => (int) $dsgvoPreview['total_hits']]) }}
</p>
</div>
<div class="px-4 py-3 space-y-4 text-[13px]">
@forelse ($dsgvoPreview['fields'] as $field => $meta)
<div>
<p class="text-[12px] font-semibold text-ink mb-1">
{{ $fieldLabels[$field] ?? $field }}
<span class="font-normal text-ink-3">· {{ trans_choice('static.pm_report.dsgvo_preview_field_hits', (int) $meta['hits'], ['hits' => (int) $meta['hits']]) }}</span>
</p>
<p class="text-ink-2 leading-relaxed whitespace-pre-line border-l-2 border-bg-rule pl-3">{{ \Illuminate\Support\Str::limit(trim(strip_tags((string) $meta['after'])), 600) }}</p>
</div>
@empty
<p class="text-ink-2">{{ __('static.pm_report.dsgvo_preview_no_text_hits') }}</p>
@endforelse
@if ($dsgvoInput['detach_contacts'])
<p class="text-ink-2">✓ {{ __('static.pm_report.dsgvo_preview_contacts') }}</p>
@endif
@if ($dsgvoInput['remove_images'])
<p class="text-ink-2">✓ {{ __('static.pm_report.dsgvo_preview_images') }}</p>
@endif
</div>
<div class="px-4 py-3 border-t border-bg-rule bg-bg-elev">
<form method="POST" action="{{ route('melden.pm.anliegen.store') }}" class="flex flex-wrap items-center gap-3">
@csrf
<input type="hidden" name="concern" value="dsgvo" />
<input type="hidden" name="confirmed" value="1" />
<input type="hidden" name="redaction_terms" value="{{ $dsgvoInput['redaction_terms'] }}" />
<input type="hidden" name="detach_contacts" value="{{ $dsgvoInput['detach_contacts'] ? 1 : 0 }}" />
<input type="hidden" name="remove_images" value="{{ $dsgvoInput['remove_images'] ? 1 : 0 }}" />
<input type="hidden" name="message" value="{{ $dsgvoInput['message'] }}" />
<button type="submit"
class="inline-flex items-center px-5 py-2.5 bg-brand text-white text-[13px] font-semibold hover:opacity-90">
{{ __('static.pm_report.dsgvo_preview_confirm') }}
</button>
<span class="text-[12.5px] text-ink-3">{{ __('static.pm_report.dsgvo_preview_edit_hint') }}</span>
</form>
</div>
</div>
@endif
@php
$initialConcern = old('concern', isset($dsgvoInput) ? 'dsgvo' : '');
$prefilledTerms = old('redaction_terms', $dsgvoInput['redaction_terms'] ?? '');
$prefilledMessage = old('message', $dsgvoInput['message'] ?? '');
$prefilledDetach = old('detach_contacts', ($dsgvoInput['detach_contacts'] ?? false) ? '1' : null);
$prefilledRemoveImages = old('remove_images', ($dsgvoInput['remove_images'] ?? false) ? '1' : null);
@endphp
<form method="POST" action="{{ route('melden.pm.anliegen.store') }}" class="space-y-5"
x-data="{ concern: '{{ $initialConcern }}' }">
@csrf
<fieldset>
@ -35,7 +102,8 @@
] as $value => $track)
<label class="flex items-start gap-3 border border-bg-rule px-3 py-2.5 cursor-pointer hover:bg-bg-elev">
<input type="radio" name="concern" value="{{ $value }}" required class="mt-0.5"
@checked(old('concern') === $value) />
x-model="concern"
@checked($initialConcern === $value) />
<span class="text-[13px] text-ink">
<span class="font-semibold">{{ $track['label'] }}</span>
<span class="text-ink-3">· {{ $track['cost'] }}</span>
@ -49,11 +117,35 @@
@enderror
</fieldset>
{{-- Spur B (Phase 5): strukturierter Schwärzungsauftrag statt nur Freitext.
Wording bewusst „Schwärzung/Entfernung", nicht „Anonymisierung". --}}
<div x-cloak x-show="concern === 'dsgvo'" class="space-y-4 border border-bg-rule bg-bg-elev px-4 py-4">
<div>
<label for="redaction_terms" class="block text-[12px] font-semibold text-ink mb-1.5">{{ __('static.pm_report.dsgvo_terms_label') }}</label>
<textarea id="redaction_terms" name="redaction_terms" rows="4" maxlength="2000"
placeholder="{{ __('static.pm_report.dsgvo_terms_placeholder') }}"
class="w-full border border-bg-rule px-3 py-2.5 text-[13px] bg-white text-ink focus:outline-none focus:border-brand">{{ $prefilledTerms }}</textarea>
<p class="mt-1 text-[12px] text-ink-3">{{ __('static.pm_report.dsgvo_terms_hint') }}</p>
@error('redaction_terms')
<p class="mt-1 text-[12px] text-red-600">{{ $message }}</p>
@enderror
</div>
<label class="flex items-start gap-2.5 text-[13px] text-ink cursor-pointer">
<input type="checkbox" name="detach_contacts" value="1" class="mt-0.5" @checked($prefilledDetach) />
<span>{{ __('static.pm_report.dsgvo_detach_contacts') }}</span>
</label>
<label class="flex items-start gap-2.5 text-[13px] text-ink cursor-pointer">
<input type="checkbox" name="remove_images" value="1" class="mt-0.5" @checked($prefilledRemoveImages) />
<span>{{ __('static.pm_report.dsgvo_remove_images') }}</span>
</label>
<p class="text-[12px] text-ink-3 leading-relaxed">{{ __('static.pm_report.dsgvo_note') }}</p>
</div>
<div>
<label for="message" class="block text-[12px] font-semibold text-ink mb-1.5">{{ __('static.pm_report.label_message') }}</label>
<textarea id="message" name="message" rows="6" required minlength="10" maxlength="5000"
placeholder="{{ __('static.pm_report.placeholder_message') }}"
class="w-full border border-bg-rule px-3 py-2.5 text-[13px] bg-white text-ink focus:outline-none focus:border-brand">{{ old('message') }}</textarea>
class="w-full border border-bg-rule px-3 py-2.5 text-[13px] bg-white text-ink focus:outline-none focus:border-brand">{{ $prefilledMessage }}</textarea>
@error('message')
<p class="mt-1 text-[12px] text-red-600">{{ $message }}</p>
@enderror
@ -65,7 +157,8 @@
<button type="submit"
class="inline-flex items-center px-5 py-2.5 bg-brand text-white text-[13px] font-semibold hover:opacity-90">
{{ __('static.pm_report.concern_submit') }}
<span x-show="concern === 'dsgvo'" x-cloak>{{ __('static.pm_report.dsgvo_preview_button') }}</span>
<span x-show="concern !== 'dsgvo'">{{ __('static.pm_report.concern_submit') }}</span>
</button>
</form>
</div>

View file

@ -65,7 +65,7 @@
<div>
<label for="reported_url" class="block text-[12px] font-semibold text-ink mb-1.5">{{ __('static.report_form.label_url') }}</label>
<input id="reported_url" type="url" name="reported_url" value="{{ old('reported_url') }}" required
<input id="reported_url" type="url" name="reported_url" value="{{ old('reported_url', request('reported_url')) }}" required
placeholder="https://…"
class="w-full border border-bg-rule px-3 py-2.5 text-[13px] bg-white text-ink focus:outline-none focus:border-brand" />
@error('reported_url')

View file

@ -70,6 +70,10 @@
$contact = $release->contacts->first();
$contactName = $contact ? trim(implode(' ', array_filter([$contact->title, $contact->first_name, $contact->last_name]))) : null;
// Legacy-Bestände ohne Kontakt-Datensatz: der Block erscheint trotzdem,
// solange die Firma erreichbare Angaben hat (wie im Altsystem).
$contactEmail = $contact?->email ?: $company?->email;
$hasContactBlock = $contact || filled($contactEmail) || filled($company?->address) || filled($company?->website);
// Attributions-Zeile wie im Legacy-System: Urheber (Firma der PM) und
// Übermittler (Profilfeld agency_name des einliefernden Accounts, z. B.
@ -440,8 +444,8 @@
<x-web.ad-slot variant="inline" />
@endunless
{{-- Pressekontakt --}}
@if ($contact)
{{-- Pressekontakt (Fallback ohne Kontakt-Datensatz: Firmendaten) --}}
@if ($hasContactBlock)
<div class="mt-8 px-5 sm:px-7 py-6 bg-white border border-bg-rule-strong max-w-[680px]">
<div class="eyebrow mb-3 text-[10.5px]">{{ __('web.detail.press_contact') }}</div>
@ -456,9 +460,9 @@
</span>
@endif
</div>
@if ($contact->responsibility || $companyName)
@if ($contact?->responsibility || $companyName)
<div class="text-[12.5px] text-ink-3 mb-3.5 leading-[1.5]">
@if ($contact->responsibility){{ $contact->responsibility }}<br />@endif
@if ($contact?->responsibility){{ $contact->responsibility }}<br />@endif
{{ $companyName }}
</div>
@endif
@ -470,16 +474,16 @@
<span class="text-ink-2">{{ $company->address }}</span>
</span>
@endif
@if ($contact->phone)
@if ($contact?->phone)
<span class="flex items-center gap-2">
<svg width="14" height="14" viewBox="0 0 14 14" class="text-ink-3 flex-shrink-0" aria-hidden="true"><path d="M2 3l3-1 1.5 3-1.5 1c.5 2 2 3.5 4 4l1-1.5L13 10l-1 3c-5 0-10-5-10-10z" fill="currentColor" /></svg>
<a href="tel:{{ preg_replace('/[^0-9+]/', '', $contact->phone) }}" class="text-ink-2 hover:text-ink transition-colors">{{ $contact->phone }}</a>
</span>
@endif
@if ($contact->email)
@if ($contactEmail)
<span class="flex items-center gap-2">
<svg width="14" height="14" viewBox="0 0 14 14" class="text-ink-3 flex-shrink-0" aria-hidden="true"><path d="M1 3h12v8H1zM1 3l6 4 6-4" stroke="currentColor" stroke-width="1.2" fill="none" /></svg>
<a href="mailto:{{ $contact->email }}" class="text-ink underline underline-offset-2 hover:text-brand transition-colors">{{ $contact->email }}</a>
<a href="mailto:{{ $contactEmail }}" class="text-ink underline underline-offset-2 hover:text-brand transition-colors">{{ $contactEmail }}</a>
</span>
@endif
@if ($company?->website)