recht: Meldeverfahren v2 Phasen 5+7 — DSGVO-Strukturformular, 10-€-Checkout mit KI-Vorprüfung, Triage & Panel-Modal

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 b4b2e013d6
commit 9afbedddeb
40 changed files with 2507 additions and 169 deletions

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. --}}