- Turnstile für Gäste und unbestätigte Konten (TurnstileVerifier, <x-turnstile>); ist Cloudflare nicht erreichbar, wird durchgelassen und im Verlauf, in der Kontakt-Queue und in der Betriebsüberwachung markiert - Inhaltsfilter des Kontaktformulars (fremde Links, Floskel mit Seitentitel, Zeichensalat, Sperrliste, Absenden unter 3 Sekunden); Treffer landen in der Spam-Quarantäne ohne Mail, Löschung nach 30 Tagen - Admin: Filter „Spam“, Sammelaktion, „Kein Spam“ holt die Mail nach - Registrierung lehnt Zufallsnamen ab; users:prune-bot-registrations (täglicher Lauf erst mit USERS_PRUNE_BOT_REGISTRATIONS=true) - contact-messages:quarantine-spam für den Altbestand - CSP erlaubt challenges.cloudflare.com, Log-Kanal spam - Datenschutzerklärung: Abschnitt 6a Cloudflare Turnstile (Vorschlag) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
248 lines
12 KiB
PHP
248 lines
12 KiB
PHP
<?php
|
||
|
||
use App\Enums\ContactMessageStatus;
|
||
use App\Enums\ContactMessageTopic;
|
||
use App\Models\ContactMessage;
|
||
use Livewire\Attributes\Layout;
|
||
use Livewire\Attributes\Title;
|
||
use Livewire\Attributes\Url;
|
||
use Livewire\Component;
|
||
use Livewire\WithPagination;
|
||
|
||
new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Component
|
||
{
|
||
use WithPagination;
|
||
|
||
#[Url(as: 'status', except: 'open')]
|
||
public string $statusFilter = 'open';
|
||
|
||
#[Url(as: 'topic', except: 'all')]
|
||
public string $topicFilter = 'all';
|
||
|
||
/** @var list<string> Auswahl für die Sammelaktion „Als Spam markieren“. */
|
||
public array $selected = [];
|
||
|
||
public string $notification = '';
|
||
|
||
public function updatedStatusFilter(): void
|
||
{
|
||
$this->selected = [];
|
||
$this->resetPage();
|
||
}
|
||
|
||
/**
|
||
* Sammelaktion: ausgewählte Anliegen in die Spam-Quarantäne legen
|
||
* (Spamschutz B3). Keine Mail, Löschung nach 30 Tagen.
|
||
*/
|
||
public function markSelectedAsSpam(): void
|
||
{
|
||
$this->authorize('compliance:manage');
|
||
|
||
$messages = ContactMessage::query()
|
||
->whereKey(array_map('intval', $this->selected))
|
||
->where('status', '!=', ContactMessageStatus::Spam->value)
|
||
->get();
|
||
|
||
foreach ($messages as $message) {
|
||
$message->markAsSpam(auth()->user());
|
||
}
|
||
|
||
$this->selected = [];
|
||
$this->notification = __(':count Anliegen als Spam markiert.', ['count' => $messages->count()]);
|
||
}
|
||
|
||
public function updatedTopicFilter(): void
|
||
{
|
||
$this->selected = [];
|
||
$this->resetPage();
|
||
}
|
||
|
||
public function with(): array
|
||
{
|
||
$messages = ContactMessage::query()
|
||
->when($this->statusFilter === 'open', fn ($query) => $query->open())
|
||
->when(! in_array($this->statusFilter, ['open', 'all'], true), fn ($query) => $query->where('status', $this->statusFilter))
|
||
->when($this->topicFilter !== 'all', fn ($query) => $query->where('topic', $this->topicFilter))
|
||
->latest()
|
||
->paginate(50);
|
||
|
||
return [
|
||
'messages' => $messages,
|
||
'topicOptions' => ContactMessageTopic::cases(),
|
||
'openCount' => ContactMessage::query()->open()->count(),
|
||
'spamCount' => ContactMessage::query()->where('status', ContactMessageStatus::Spam->value)->count(),
|
||
'totalCount' => ContactMessage::query()->where('status', '!=', ContactMessageStatus::Spam->value)->count(),
|
||
];
|
||
}
|
||
|
||
public function statusBadgeClass(ContactMessageStatus $status): string
|
||
{
|
||
return match ($status) {
|
||
ContactMessageStatus::Open => 'warn',
|
||
ContactMessageStatus::InProgress => 'hub',
|
||
ContactMessageStatus::Resolved => 'ok',
|
||
ContactMessageStatus::Rejected => 'err',
|
||
ContactMessageStatus::Spam => 'muted',
|
||
};
|
||
}
|
||
}; ?>
|
||
|
||
<div class="space-y-8">
|
||
{{-- ============== PAGE HEADER ============== --}}
|
||
<x-portal.page-header
|
||
:badge="__('Admin Backend')"
|
||
:eyebrow="__('Administration · Kontakt')"
|
||
:title="__('Kontakt')"
|
||
:description="__('Anliegen aus dem Kontakt-Widget und dem Kontaktformular: Kontaktaufnahmen, Fehlermeldungen, PM-Meldungen und Feedback – manuelle Bearbeitung.')"
|
||
/>
|
||
|
||
{{-- ============== KPI-Reihe ============== --}}
|
||
<section class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||
<x-portal.stat-card variant="primary" :label="__('Offen')" :value="number_format($openCount)">
|
||
<x-slot:meta>{{ __('zu bearbeiten') }}</x-slot:meta>
|
||
<x-slot:trend>{{ __('offen + in Bearbeitung') }}</x-slot:trend>
|
||
</x-portal.stat-card>
|
||
<x-portal.stat-card variant="muted" :label="__('Gesamt')" :value="number_format($totalCount)">
|
||
<x-slot:meta>{{ __('alle Anliegen') }}</x-slot:meta>
|
||
<x-slot:trend>{{ __('inkl. erledigt, ohne Spam') }}</x-slot:trend>
|
||
</x-portal.stat-card>
|
||
<x-portal.stat-card variant="muted" :label="__('Spam')" :value="number_format($spamCount)">
|
||
<x-slot:meta>{{ __('in Quarantäne') }}</x-slot:meta>
|
||
<x-slot:trend>{{ __('ohne Mail, nach :days Tagen gelöscht', ['days' => \App\Models\ContactMessage::SPAM_RETENTION_DAYS]) }}</x-slot:trend>
|
||
</x-portal.stat-card>
|
||
</section>
|
||
|
||
{{-- ============== FILTER ============== --}}
|
||
<article class="panel">
|
||
<div class="panel-head">
|
||
<span class="section-eyebrow">{{ __('Filter') }}</span>
|
||
</div>
|
||
<div class="p-5 flex flex-col gap-4 sm:flex-row">
|
||
<flux:select wire:model.live="statusFilter" class="w-full sm:w-56">
|
||
<option value="open">{{ __('Offen & in Bearbeitung') }}</option>
|
||
<option value="all">{{ __('Alle Status') }}</option>
|
||
<option value="resolved">{{ __('Erledigt') }}</option>
|
||
<option value="rejected">{{ __('Abgelehnt') }}</option>
|
||
<option value="spam">{{ __('Spam (Quarantäne)') }}</option>
|
||
</flux:select>
|
||
|
||
<flux:select wire:model.live="topicFilter" class="w-full sm:w-56">
|
||
<option value="all">{{ __('Alle Anliegen') }}</option>
|
||
@foreach ($topicOptions as $topicOption)
|
||
<option value="{{ $topicOption->value }}">{{ $topicOption->label() }}</option>
|
||
@endforeach
|
||
</flux:select>
|
||
</div>
|
||
</article>
|
||
|
||
{{-- ============== TABELLE ============== --}}
|
||
<article class="panel overflow-hidden">
|
||
<div class="panel-head">
|
||
<span class="section-eyebrow">{{ __('Anliegen') }}</span>
|
||
<div class="flex items-center gap-3">
|
||
@if (count($selected) > 0)
|
||
<flux:button size="sm" variant="danger" icon="no-symbol" wire:click="markSelectedAsSpam"
|
||
wire:confirm="{{ __('Ausgewählte Anliegen als Spam markieren? Es geht keine Mail raus, nach 30 Tagen werden sie gelöscht.') }}">
|
||
{{ __(':count als Spam markieren', ['count' => count($selected)]) }}
|
||
</flux:button>
|
||
@endif
|
||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||
{{ __(':count Einträge', ['count' => $messages->total()]) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
@if ($notification)
|
||
<div class="mx-5 mt-4 px-4 py-3 rounded-[5px] border text-[12.5px] flex items-center gap-2
|
||
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
|
||
<flux:icon.check-circle class="size-[16px] flex-shrink-0" />
|
||
{{ $notification }}
|
||
</div>
|
||
@endif
|
||
<flux:table>
|
||
<flux:table.columns>
|
||
<flux:table.column class="w-8"><span class="sr-only">{{ __('Auswahl') }}</span></flux:table.column>
|
||
<flux:table.column>{{ __('Anliegen') }}</flux:table.column>
|
||
<flux:table.column>{{ __('Status') }}</flux:table.column>
|
||
<flux:table.column>{{ __('Absender') }}</flux:table.column>
|
||
<flux:table.column>{{ __('Portal') }}</flux:table.column>
|
||
<flux:table.column>{{ __('Eingegangen') }}</flux:table.column>
|
||
<flux:table.column>{{ __('Aktion') }}</flux:table.column>
|
||
</flux:table.columns>
|
||
|
||
<flux:table.rows>
|
||
@forelse ($messages as $message)
|
||
<flux:table.row :key="$message->id">
|
||
<flux:table.cell>
|
||
@unless ($message->isSpam())
|
||
<input type="checkbox" wire:model.live="selected" value="{{ $message->id }}"
|
||
aria-label="{{ __('Anliegen :ref auswählen', ['ref' => $message->reference()]) }}"
|
||
class="size-4 cursor-pointer accent-[color:var(--color-hub)]" />
|
||
@endunless
|
||
</flux:table.cell>
|
||
<flux:table.cell>
|
||
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">
|
||
{{ $message->topic->label() }}
|
||
</span>
|
||
<span class="block text-[11px] font-mono text-[color:var(--color-ink-4)]">
|
||
{{ $message->reference() }}
|
||
</span>
|
||
</flux:table.cell>
|
||
<flux:table.cell>
|
||
<span class="badge dot {{ $this->statusBadgeClass($message->status) }}">
|
||
{{ $message->status->label() }}
|
||
</span>
|
||
@if ($message->isSpam() && $message->spamReasonLabel())
|
||
<span class="block mt-1 text-[11px] text-[color:var(--color-ink-3)]">{{ $message->spamReasonLabel() }}</span>
|
||
@elseif ($message->skippedBotCheck())
|
||
<span class="badge warn mt-1">{{ __('ohne Bot-Prüfung') }}</span>
|
||
@endif
|
||
</flux:table.cell>
|
||
<flux:table.cell>
|
||
<span class="text-[12.5px] text-[color:var(--color-ink)]">
|
||
{{ $message->name ?: __('anonym') }}
|
||
</span>
|
||
<span class="block text-[11.5px] text-[color:var(--color-ink-3)]">
|
||
{{ $message->email }}
|
||
</span>
|
||
</flux:table.cell>
|
||
<flux:table.cell>
|
||
<span class="text-[12.5px] text-[color:var(--color-ink-3)]">
|
||
{{ $message->portal ?: '—' }}
|
||
</span>
|
||
</flux:table.cell>
|
||
<flux:table.cell>
|
||
<span class="text-[12px] text-[color:var(--color-ink-3)]">
|
||
{{ $message->created_at?->format('d.m.Y H:i') ?? '-' }}
|
||
</span>
|
||
</flux:table.cell>
|
||
<flux:table.cell>
|
||
<flux:button size="sm" variant="filled" icon="eye"
|
||
href="{{ route('admin.contact-messages.show', $message->id) }}" wire:navigate>
|
||
{{ __('Öffnen') }}
|
||
</flux:button>
|
||
</flux:table.cell>
|
||
</flux:table.row>
|
||
@empty
|
||
<flux:table.row>
|
||
<flux:table.cell colspan="7">
|
||
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
|
||
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
|
||
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
|
||
<flux:icon.chat-bubble-left-right class="size-6" />
|
||
</div>
|
||
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
|
||
{{ __('Keine Anliegen in dieser Ansicht') }}
|
||
</div>
|
||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0 max-w-[420px]">
|
||
{{ __('Anliegen aus dem Kontakt-Widget (Kontaktaufnahme, Fehlermeldung, PM-Meldung, Feedback) laufen hier auf.') }}
|
||
</p>
|
||
</div>
|
||
</flux:table.cell>
|
||
</flux:table.row>
|
||
@endforelse
|
||
</flux:table.rows>
|
||
</flux:table>
|
||
|
||
<x-portal.table-pagination :paginator="$messages" />
|
||
</article>
|
||
</div>
|