recht: Meldeverfahren v2 Phasen 1-4 — Re-Sync-Sperre, Anonymisierung, Magic-Link-Intake, Self-Service-Korrektur

Phase 1: sync_locked_at auf press_releases (Importer überspringt gesperrte
PMs auch bei --force), LegalRequest um Verifikation/Ausführung erweitert,
verschlüsselte Vorher-Snapshots mit Löschfrist (snapshots:purge, täglich).

Phase 2: AnonymizationService (Unicode-Wortgrenzen-Schwärzung, Pflicht-Diff,
transaktionale Ausführung mit Snapshot, Kontakt-Detach, Bild-Löschung,
Sitemap-Invalidierung, Verlauf ohne Klartext-Altwerte); Admin-Queue kann
DSGVO-Anfragen anonymisieren oder depublizieren, inkl. Snapshot-Restore.

Phase 3: Deflektions-Trichter „PM melden/ändern" (melden/pm) mit Kosten-
Tabelle vorab; Magic-Link geht nur an die hinterlegte Pressekontaktadresse
(neuer purpose press_release_report), atomare Verifikation, Anliegen-Auswahl
erzeugt verifizierte Queue-Anfragen; toter-Kanal-Fallback; Owner-Einstieg.

Phase 4: Panel-Abschnitt „Änderung & Korrektur" — Levenshtein-Gate (5 %,
Mini-Edit-Ausnahme ≤ 2 Zeichen) stuft klein/groß ein; kleine Korrektur
kostenlos sofort live (Snapshot, Sperre, Verlauf, Re-Score), größere als
Change-Anfrage (Interim bis 10-€-Checkout, Phase 7).

Entwicklungsplan mit Entscheidungen E1-E8 und offenen Punkten in
docs/Decision-Update; 41 Pest-Tests (tests/Feature/Legal) grün.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-22 15:56:34 +00:00
parent 58c0cc3f23
commit b4b2e013d6
42 changed files with 3868 additions and 1 deletions

View file

@ -0,0 +1,36 @@
<x-emails.layout title="Bestätigungslink für Ihre Pressemitteilung" preheader="Über diesen Link können Sie Änderungs-, Lösch- und Datenschutz-Anliegen zu Ihrer Pressemitteilung stellen.">
<p>Guten Tag,</p>
<p>
für die folgende Pressemitteilung wurde über unser Meldeverfahren ein
Änderungs- oder Lösch-Anliegen gestartet:
</p>
<dl class="meta">
<dt>Pressemitteilung</dt>
<dd>{{ $pressRelease->title }}</dd>
</dl>
<p>
Wenn Sie dieses Anliegen stellen möchten, bestätigen Sie bitte über den
folgenden Link, dass Sie Zugriff auf die hinterlegte Pressekontaktadresse
haben. Anschließend können Sie Ihr Anliegen auswählen:
</p>
<p>
<a href="{{ $verificationUrl }}">{{ $verificationUrl }}</a>
</p>
<p>
Der Link ist gültig bis <strong>{{ $expiresAt->format('d.m.Y H:i') }} Uhr</strong>
und kann nur einmal verwendet werden.
</p>
<div class="note">
<p>
Sie haben kein Anliegen gestartet? Dann können Sie diese E-Mail ignorieren —
ohne Klick auf den Link passiert nichts. Der Link wurde ausschließlich an die
in der Veröffentlichung hinterlegte Pressekontaktadresse gesendet.
</p>
</div>
</x-emails.layout>

View file

@ -1,7 +1,11 @@
<?php
use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType;
use App\Enums\PressReleaseStatus;
use App\Models\LegalRequest;
use App\Services\PressRelease\AnonymizationService;
use App\Services\PressRelease\PressReleaseService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
@ -16,10 +20,190 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')]
public string $notification = '';
/** Schwärzbegriffe inkl. Varianten, ein Begriff pro Zeile. */
public string $redactionTerms = '';
public string $redactionReplacement = '';
public bool $detachContacts = true;
/** @var list<int> */
public array $removeImageIds = [];
/** @var array{fields: array<string, array{hits: int, after: string}>, total_hits: int}|null */
public ?array $redactionPreview = null;
public function mount(int $id): void
{
$this->id = $id;
$this->adminNote = (string) ($this->request()->admin_note ?? '');
$this->redactionReplacement = AnonymizationService::DEFAULT_REPLACEMENT;
}
/**
* Pflicht-Vorschau (Konzept §Spur B): Ohne Diff keine Ausführung.
*/
public function previewAnonymization(AnonymizationService $service): void
{
$this->authorize('compliance:manage');
$pressRelease = $this->request()->pressRelease;
abort_unless($pressRelease !== null, 404);
if ($this->redactionTermList() === [] && ! $this->detachContacts && $this->removeImageIds === []) {
$this->addError('redactionTerms', __('Mindestens einen Schwärzbegriff eingeben oder Kontakte/Bilder auswählen.'));
return;
}
$this->redactionPreview = $service->preview(
$pressRelease,
$this->redactionTermList(),
$this->replacementValue(),
);
}
public function executeAnonymization(AnonymizationService $service): void
{
$this->authorize('compliance:manage');
$request = $this->request();
$pressRelease = $request->pressRelease;
abort_unless($pressRelease !== null, 404);
if ($this->redactionPreview === null) {
$this->addError('redactionTerms', __('Bitte zuerst die Vorschau prüfen.'));
return;
}
$service->execute(
$pressRelease,
$this->redactionTermList(),
$this->replacementValue(),
$this->detachContacts,
$this->removeImageIds,
legalRequest: $request,
);
$request->update([
'status' => LegalRequestStatus::Resolved->value,
'admin_note' => $this->adminNote !== '' ? $this->adminNote : null,
'resolved_by_user_id' => auth()->id(),
'resolved_at' => now(),
'executed_at' => now(),
]);
$this->redactionPreview = null;
$this->notification = __('Anonymisierung ausgeführt — PM geschwärzt, Re-Sync-Sperre gesetzt.');
}
/**
* Alternative zur Schwärzung (Matrix-Test §1/§2): Bei Vanity-/Personen-PMs
* bleibt nach Schwärzung nur ein Rumpf — dann ist Depublizierung das
* mildere Restmittel (im DSGVO-Fall kostenlos, umkehrbar).
*/
public function depublishRelease(PressReleaseService $service): void
{
$this->authorize('compliance:manage');
$request = $this->request();
$pressRelease = $request->pressRelease;
abort_unless($pressRelease !== null, 404);
if ($pressRelease->status !== PressReleaseStatus::Published) {
$this->addError('redactionTerms', __('Nur veröffentlichte PMs können depubliziert werden.'));
return;
}
$service->depublish($pressRelease);
$pressRelease->lockSync('Depublizierung im Meldeverfahren '.$request->reference());
$request->update([
'status' => LegalRequestStatus::Resolved->value,
'admin_note' => $this->adminNote !== '' ? $this->adminNote : null,
'resolved_by_user_id' => auth()->id(),
'resolved_at' => now(),
'executed_at' => now(),
]);
$this->notification = __('PM depubliziert (Tombstone, umkehrbar) — Re-Sync-Sperre gesetzt.');
}
/**
* Rückgängig machen: spielt den verschlüsselten Vorher-Snapshot einer
* ausgeführten Korrektur/Anonymisierung zurück (bis zur Löschfrist).
*/
public function restoreSnapshot(\App\Services\PressRelease\CorrectionService $correctionService): void
{
$this->authorize('compliance:manage');
$request = $this->request();
$snapshot = $request->redactionSnapshots()->latest('id')->first();
abort_unless($snapshot !== null, 404);
$correctionService->restoreSnapshot($snapshot, auth()->user());
$request->update([
'status' => LegalRequestStatus::InProgress->value,
'executed_at' => null,
]);
$this->notification = __('Vorher-Stand wiederhergestellt — Anfrage wieder in Bearbeitung.');
}
/**
* @return list<string>
*/
private function redactionTermList(): array
{
return collect(preg_split('/\r\n|\r|\n/', $this->redactionTerms) ?: [])
->map(fn (string $term): string => trim($term))
->filter()
->unique()
->values()
->all();
}
private function replacementValue(): string
{
return trim($this->redactionReplacement) !== ''
? trim($this->redactionReplacement)
: AnonymizationService::DEFAULT_REPLACEMENT;
}
public function canRedact(LegalRequest $request): bool
{
return $request->pressRelease !== null
&& in_array($request->type, [LegalRequestType::Dsgvo, LegalRequestType::PersonalRights], true);
}
/**
* HTML-Inhalt lesbar und kopierbar machen: Block-Enden zu Zeilenumbrüchen,
* Tags raus — der Admin kopiert Begriffe direkt aus dieser Ansicht.
*/
public function displayValue(?string $value): string
{
// Ohne "?"+">" im Pattern — der Volt-Compiler würde die Sequenz als
// PHP-Schließtag lesen und die Klasse zerschneiden.
$withBreaks = preg_replace('#<br[^>]*>|</(p|div|h[1-6]|li|blockquote)>#i', "\n", (string) $value) ?? '';
return trim(strip_tags($withBreaks));
}
/**
* @return array<string, string>
*/
public function redactableFieldLabels(): array
{
return [
'title' => __('Titel'),
'subtitle' => __('Untertitel'),
'text' => __('Text'),
'boilerplate_override' => __('Boilerplate'),
'keywords' => __('Keywords'),
];
}
public function markInProgress(): void
@ -70,7 +254,7 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')]
private function request(): LegalRequest
{
return LegalRequest::query()->with(['pressRelease', 'resolver'])->findOrFail($this->id);
return LegalRequest::query()->with(['pressRelease.images', 'resolver'])->findOrFail($this->id);
}
public function with(): array
@ -175,6 +359,107 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')]
</div>
</article>
{{-- ============== ANONYMISIERUNG (Spur B) ============== --}}
@if ($this->canRedact($request) && $request->status->isOpen())
<article class="panel xl:col-span-2">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Anonymisierung (Schwärzung)') }}</span>
</div>
<div class="p-5 space-y-4">
<p class="text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('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>
<flux:field>
<flux:textarea wire:model="redactionTerms" rows="4"
:label="__('Schwärzbegriffe (ein Begriff pro Zeile, Varianten einzeln)')"
:placeholder="'Matvey Teplitsky&#10;Herr Teplitsky&#10;Teplitskys'" />
<flux:error name="redactionTerms" />
</flux:field>
<div class="grid gap-3 sm:grid-cols-2">
<flux:field>
<flux:input wire:model="redactionReplacement"
:label="__('Ersetzungswert (Rolle/Platzhalter, nie leer)')"
:placeholder="\App\Services\PressRelease\AnonymizationService::DEFAULT_REPLACEMENT" />
</flux:field>
<div class="pt-6">
<flux:checkbox wire:model="detachContacts"
:label="__('Kontakt-Zuordnungen der PM lösen (Ansprechpartner raus)')" />
</div>
</div>
@if ($request->pressRelease->images->isNotEmpty())
<flux:field>
<flux:label>{{ __('Bilder entfernen (Klarname in Pressebild!)') }}</flux:label>
<div class="space-y-1">
@foreach ($request->pressRelease->images as $image)
<flux:checkbox wire:model="removeImageIds" value="{{ $image->id }}"
:label="($image->title ?: $image->path)" />
@endforeach
</div>
</flux:field>
@endif
<div class="flex flex-wrap gap-2 pt-2 border-t border-[color:var(--color-bg-rule)]">
<flux:button variant="filled" icon="eye" wire:click="previewAnonymization">
{{ $redactionPreview === null ? __('Vorschau erstellen (Pflicht)') : __('Vorschau aktualisieren') }}
</flux:button>
@if ($redactionPreview !== null)
<flux:button variant="primary" icon="shield-check" wire:click="executeAnonymization"
wire:confirm="{{ __('Schwärzung jetzt ausführen? Alt-Werte werden verschlüsselt gesichert, die Re-Sync-Sperre wird gesetzt.') }}">
{{ __('Anonymisieren ausführen') }}
</flux:button>
@endif
@if ($request->pressRelease->status === \App\Enums\PressReleaseStatus::Published)
<flux:button variant="danger" icon="archive-box-arrow-down" wire:click="depublishRelease"
wire:confirm="{{ __('PM stattdessen depublizieren (Tombstone, umkehrbar)? Richtig, wenn nach Schwärzung nur ein Rumpf bliebe.') }}">
{{ __('Stattdessen depublizieren') }}
</flux:button>
@endif
</div>
{{-- Kompletter PM-Inhalt: vor der Vorschau das Original (Begriffe
von hier kopieren), danach der Stand nach Schwärzung. --}}
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-[12.5px] font-semibold text-[color:var(--color-ink)]">
{{ $redactionPreview === null
? __('Aktueller Inhalt — Begriffe von hier in das Feld oben kopieren')
: __('Vorschau nach Schwärzung') }}
</p>
@if ($redactionPreview !== null)
<span class="badge dot {{ $redactionPreview['total_hits'] > 0 ? 'ok' : 'warn' }}">
{{ __(':count Ersetzung(en)', ['count' => $redactionPreview['total_hits']]) }}
</span>
@endif
</div>
@foreach ($this->redactableFieldLabels() as $field => $label)
@php
$fieldValue = $redactionPreview['fields'][$field]['after']
?? $request->pressRelease->getAttribute($field);
$fieldHits = $redactionPreview['fields'][$field]['hits'] ?? 0;
@endphp
@continue(blank($fieldValue))
<div class="text-[12.5px]">
<span class="font-mono text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)]">
{{ $label }}@if ($fieldHits > 0) · {{ $fieldHits }}× {{ __('ersetzt') }}@endif
</span>
<p class="mt-1 text-[color:var(--color-ink)] whitespace-pre-line">{{ $this->displayValue($fieldValue) }}</p>
</div>
@endforeach
@if ($redactionPreview !== null && $redactionPreview['total_hits'] === 0)
<p class="text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('Keine Texttreffer — es würden nur Kontakte/Bilder entfernt.') }}
</p>
@endif
</div>
</div>
</article>
@endif
{{-- ============== BEARBEITUNG ============== --}}
<article class="panel">
<div class="panel-head">
@ -195,6 +480,31 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')]
</p>
@endif
<dl class="text-[12px] text-[color:var(--color-ink-3)] space-y-1">
<div class="flex justify-between gap-2">
<dt>{{ __('Verifikation') }}</dt>
<dd class="text-[color:var(--color-ink)]">
{{ $request->verification_method?->label() ?? __('nicht verifiziert') }}
@if ($request->verified_at)
· {{ $request->verified_at->format('d.m.Y H:i') }}
@endif
</dd>
</div>
@if ($request->executed_at)
<div class="flex justify-between gap-2">
<dt>{{ __('Ausgeführt') }}</dt>
<dd class="text-[color:var(--color-ink)]">{{ $request->executed_at->format('d.m.Y H:i') }}</dd>
</div>
@endif
</dl>
@if ($request->executed_at && $request->redactionSnapshots()->exists())
<flux:button variant="filled" icon="arrow-uturn-left" wire:click="restoreSnapshot"
wire:confirm="{{ __('Vorher-Stand aus dem verschlüsselten Snapshot wiederherstellen? Die Re-Sync-Sperre bleibt bestehen.') }}">
{{ __('Rückgängig machen (Snapshot)') }}
</flux:button>
@endif
<div class="flex flex-col gap-2 pt-2 border-t border-[color:var(--color-bg-rule)]">
@if ($request->status->isOpen())
@if ($request->status !== \App\Enums\LegalRequestStatus::InProgress)

View file

@ -41,6 +41,15 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
public bool $depublicationConfirmed = false;
/** Spur A/D (Phase 4): Korrektur-Formular sichtbar? */
public bool $correctionOpen = false;
/** @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 */
public ?array $correctionPreview = null;
/**
* Kontext fürs „Nicht genügend Credits"-Modal (QA-Befund 07.07.):
* ['label' => Add-on-Name, 'required' => int, 'available' => int].
@ -294,6 +303,130 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
Flux::modal('insufficient-credits')->show();
}
/**
* Spur A/D (Meldeverfahren Phase 4): Korrektur-Formular öffnen und mit
* den aktuellen Werten vorbefüllen.
*/
public function startCorrection(): void
{
$pr = $this->getMyPR();
$this->authorize('view', $pr);
$this->correctionValues = [
'title' => (string) ($pr->title ?? ''),
'subtitle' => (string) ($pr->subtitle ?? ''),
'text' => (string) ($pr->text ?? ''),
'keywords' => (string) ($pr->keywords ?? ''),
];
$this->correctionOpen = true;
$this->correctionPreview = null;
}
/**
* Pflicht-Vorschau: Levenshtein-Gate stuft klein (kostenlos, sofort)
* vs. groß (Spur D, 10 €) ein — der Nutzer wählt das nie selbst.
*/
public function previewCorrection(\App\Services\PressRelease\CorrectionService $correctionService): void
{
$pr = $this->getMyPR();
$this->authorize('view', $pr);
$classification = $correctionService->classify($pr, $this->correctionValues);
if (! $classification['has_changes']) {
$this->addError('correction', __('Es wurden keine Änderungen erkannt.'));
$this->correctionPreview = null;
return;
}
$this->resetErrorBag('correction');
$this->correctionPreview = $classification;
}
public function applyCorrection(\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.'));
return;
}
try {
$legalRequest = $correctionService->apply(
$pr,
$this->correctionValues,
(string) auth()->user()->email,
\App\Enums\LegalRequestVerificationMethod::LoggedIn,
auth()->user(),
);
} catch (\App\Services\PressRelease\CorrectionTooLargeException) {
$this->addError('correction', __('Die Änderung überschreitet den Rahmen einer kleinen Korrektur — bitte als kostenpflichtige Änderung einreichen.'));
return;
} catch (\App\Services\PressRelease\CorrectionRateLimitException $e) {
$this->addError('correction', $e->getMessage());
return;
}
$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.
*/
public function submitPaidChangeRequest(): void
{
$pr = $this->getMyPR();
$this->authorize('view', $pr);
if ($this->correctionPreview === null) {
$this->addError('correction', __('Bitte zuerst die Vorschau prüfen.'));
return;
}
$alreadyOpen = \App\Models\LegalRequest::query()
->where('press_release_id', $pr->id)
->where('type', \App\Enums\LegalRequestType::Change->value)
->open()
->exists();
if ($alreadyOpen) {
$this->addError('correction', __('Für diese Pressemitteilung liegt bereits eine offene Änderungs-Anfrage vor.'));
return;
}
$legalRequest = \App\Models\LegalRequest::query()->create([
'press_release_id' => $pr->id,
'portal' => $pr->portal?->value,
'type' => \App\Enums\LegalRequestType::Change->value,
'status' => \App\Enums\LegalRequestStatus::Open->value,
'requester_email' => (string) auth()->user()->email,
'message' => __('Kostenpflichtige Änderung (10 €) über das Kunden-Panel eingereicht.'),
'payload' => [
'source' => 'self_service',
'new_values' => $this->correctionValues,
],
'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()]));
}
/**
* Unternehmer-Pflicht-Checkbox vor dem Depublikations-Checkout (T1neu):
* protokolliert wie im Buchungs-Modal — der Checkout-Controller verlangt
@ -557,6 +690,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<flux:button variant="filled" icon="link" wire:click="generateShareLink">
{{ __('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">
{{ __('Ändern/Melden') }}
</flux:button>
</form>
@endif
<flux:button variant="filled" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
@ -1142,6 +1285,88 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
</article>
@endif
{{-- ============== ÄNDERUNG (Meldeverfahren Phase 4, Spur A/D) ==============
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">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Änderung & Korrektur') }}</span>
</div>
<div class="p-5 space-y-4">
<div class="flex items-start gap-3">
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0 bg-[color:var(--color-hub-soft)] text-[color:var(--color-hub)]">
<flux:icon.pencil-square class="size-[18px]" />
</div>
<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')]) }}
</p>
</div>
</div>
@if (! $correctionOpen)
<flux:button size="sm" variant="filled" icon="pencil" wire:click="startCorrection">
{{ __('Korrektur/Änderung starten') }}
</flux:button>
@else
<div class="space-y-3">
<flux:field>
<flux:input wire:model="correctionValues.title" :label="__('Titel')" />
</flux:field>
<flux:field>
<flux:input wire:model="correctionValues.subtitle" :label="__('Untertitel')" />
</flux:field>
<flux:field>
<flux:textarea wire:model="correctionValues.text" rows="10" :label="__('Text')" />
</flux:field>
<flux:field>
<flux:input wire:model="correctionValues.keywords" :label="__('Keywords')" />
</flux:field>
<flux:error name="correction" />
<div class="flex flex-wrap gap-2">
<flux:button size="sm" variant="filled" icon="eye" wire:click="previewCorrection">
{{ $correctionPreview === null ? __('Vorschau prüfen (Pflicht)') : __('Vorschau aktualisieren') }}
</flux:button>
@if ($correctionPreview !== null && $correctionPreview['is_minor'])
<flux:button size="sm" variant="primary" icon="check" wire:click="applyCorrection"
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 €)') }}
</flux:button>
@endif
<flux:button size="sm" variant="ghost" wire:click="$set('correctionOpen', false)">
{{ __('Abbrechen') }}
</flux:button>
</div>
@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']]) }}
</p>
@foreach ($correctionPreview['fields'] as $field => $meta)
@continue(! $meta['changed'])
<p class="m-0 text-[color:var(--color-ink-2)]">
{{ $field }}: {{ __('Änderungsdistanz :percent %', ['percent' => $meta['distance_percent']]) }}
</p>
@endforeach
</div>
@endif
</div>
@endif
</div>
</article>
@endif
{{-- ============== DEPUBLIKATION (T12, Meldeverfahren Weg 3) ==============
Bewusst ganz unten (QA-Befund 07.07.): seltener Ausnahme-Service,
soll nicht zwischen Add-ons und Inhalt um Aufmerksamkeit konkurrieren. --}}

View file

@ -0,0 +1,73 @@
@extends('web.layouts.web-master')
@section('title', __('static.pm_report.concerns_title'))
@section('meta_description', __('static.pm_report.meta'))
@section('content')
<x-web.static-page
:eyebrow="__('static.legal.eyebrow')"
:title="__('static.pm_report.concerns_title')"
:breadcrumb="__('static.pm_report.breadcrumb')"
>
<div class="max-w-[640px]">
<div class="mb-6 px-4 py-3 border border-bg-rule bg-bg-elev text-[13px] text-ink-2">
<p class="font-semibold text-ink mb-1">{{ $pressRelease->title }}</p>
<p>{{ __('static.pm_report.verified_as', ['email' => $contactEmail]) }}</p>
</div>
@if (session('pm-report-status'))
<div class="mb-6 px-4 py-3 border border-bg-rule bg-bg-elev text-[13px] text-ink-2" role="status">
{{ session('pm-report-status') }}
</div>
@endif
<form method="POST" action="{{ route('melden.pm.anliegen.store') }}" class="space-y-5">
@csrf
<fieldset>
<legend class="block text-[12px] font-semibold text-ink mb-2">{{ __('static.pm_report.concern_legend') }}</legend>
<div class="space-y-2">
@foreach ([
'correction' => ['label' => __('static.pm_report.track_correction'), 'desc' => __('static.pm_report.track_correction_desc'), 'cost' => __('static.pm_report.cost_free')],
'dsgvo' => ['label' => __('static.pm_report.track_dsgvo'), 'desc' => __('static.pm_report.track_dsgvo_desc'), 'cost' => __('static.pm_report.cost_free')],
'depublication' => ['label' => __('static.pm_report.track_depublication'), 'desc' => __('static.pm_report.track_depublication_desc'), 'cost' => '35 €'],
'change' => ['label' => __('static.pm_report.track_change'), 'desc' => __('static.pm_report.track_change_desc'), 'cost' => '10 €'],
] 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) />
<span class="text-[13px] text-ink">
<span class="font-semibold">{{ $track['label'] }}</span>
<span class="text-ink-3">· {{ $track['cost'] }}</span>
<span class="block text-[12.5px] text-ink-2">{{ $track['desc'] }}</span>
</span>
</label>
@endforeach
</div>
@error('concern')
<p class="mt-1 text-[12px] text-red-600">{{ $message }}</p>
@enderror
</fieldset>
<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>
@error('message')
<p class="mt-1 text-[12px] text-red-600">{{ $message }}</p>
@enderror
</div>
<p class="text-[12.5px] text-ink-3 leading-relaxed">
{{ __('static.pm_report.concern_note') }}
</p>
<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') }}
</button>
</form>
</div>
</x-web.static-page>
@endsection

View file

@ -0,0 +1,87 @@
@extends('web.layouts.web-master')
@section('title', __('static.pm_report.title'))
@section('meta_description', __('static.pm_report.meta'))
@section('content')
<x-web.static-page
:eyebrow="__('static.legal.eyebrow')"
:title="__('static.pm_report.title')"
:breadcrumb="__('static.pm_report.breadcrumb')"
>
<div class="max-w-[640px]">
<p class="text-[13.5px] text-ink-2 leading-relaxed mb-6">
{{ __('static.pm_report.intro') }}
</p>
{{-- Transparenz vorab: kostenlos vs. kostenpflichtig (Anti-Zombie). --}}
<div class="border border-bg-rule mb-6">
<table class="w-full text-[12.5px]">
<tbody>
@foreach ([
['label' => __('static.pm_report.track_correction'), 'cost' => __('static.pm_report.cost_free')],
['label' => __('static.pm_report.track_dsgvo'), 'cost' => __('static.pm_report.cost_free')],
['label' => __('static.pm_report.track_depublication'), 'cost' => '35 €'],
['label' => __('static.pm_report.track_change'), 'cost' => '10 €'],
] as $track)
<tr class="border-b border-bg-rule last:border-b-0">
<td class="px-3 py-2 text-ink">{{ $track['label'] }}</td>
<td class="px-3 py-2 text-right font-semibold text-ink">{{ $track['cost'] }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@if (session('pm-report-status'))
<div class="mb-6 px-4 py-3 border border-bg-rule bg-bg-elev text-[13px] text-ink-2" role="status">
{{ session('pm-report-status') }}
</div>
@endif
@if (session('pm-report-error'))
<div class="mb-6 px-4 py-3 border border-red-300 bg-red-50 text-[13px] text-red-700" role="alert">
{{ session('pm-report-error') }}
</div>
@endif
@if (session('pm-report-manual'))
<div class="mb-6 px-4 py-3 border border-bg-rule bg-bg-elev text-[13px] text-ink-2" role="status">
{{ __('static.pm_report.manual_fallback') }}
<a href="{{ route('melden.form', ['way' => 'kontaktdaten']) }}" class="underline underline-offset-2 hover:text-ink">
{{ __('static.pm_report.manual_fallback_link') }}
</a>
</div>
@endif
<form method="POST" action="{{ route('melden.pm.store') }}" class="space-y-5">
@csrf
{{-- Honeypot --}}
<div class="hidden" aria-hidden="true">
<label for="website">Website</label>
<input id="website" type="text" name="website" tabindex="-1" autocomplete="off" />
</div>
<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
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')
<p class="mt-1 text-[12px] text-red-600">{{ $message }}</p>
@enderror
</div>
<p class="text-[12.5px] text-ink-3 leading-relaxed">
{{ __('static.pm_report.verification_note') }}
</p>
<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.submit') }}
</button>
</form>
</div>
</x-web.static-page>
@endsection

View file

@ -34,6 +34,17 @@
<p>{!! __('static.report.intro_direct') !!}</p>
<p>{!! __('static.report.intro_free') !!}</p>
{{-- Meldeverfahren v2 (Phase 3): Self-Service-Einstieg über die
hinterlegte Pressekontaktadresse. --}}
<div class="my-6 px-4 py-4 border border-bg-rule bg-bg-elev">
<p class="font-semibold text-ink mb-1">{{ __('static.pm_report.title') }}</p>
<p class="text-[13px] text-ink-2 mb-3">{{ __('static.pm_report.verification_note') }}</p>
<a href="{{ route('melden.pm') }}"
class="inline-flex items-center px-4 py-2 bg-brand text-white text-[13px] font-semibold hover:opacity-90 no-underline">
{{ __('static.pm_report.breadcrumb') }}
</a>
</div>
<h2>{{ __('static.report.way1_heading') }}</h2>
<p>{{ __('static.report.way1_intro') }}</p>
<ul>