presseportale/resources/views/components/web/contact-launcher.blade.php
Kevin Adametz 06d2889a4d feat: Spamschutz für Kontakt-Widget und Registrierung mit Cloudflare Turnstile
- 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>
2026-10-09 08:49:58 +00:00

214 lines
12 KiB
PHP

{{--
Schwebendes Kontakt-Widget (unten rechts, alle Seiten): runder Launcher-
Button, der ein kleines Panel mit Themenauswahl und Kontaktformular
öffnet — Vorbereitung für einen späteren Chatbot. Öffnet auch auf das
Window-Event `open-contact-modal` (bestehende „Kontakt"-Trigger in
Footer/Utility-Bar/Landing) und automatisch nach dem Absenden
(Erfolgs-Flash bzw. Validierungsfehler). Versand über den
host-agnostischen POST-Endpunkt des Kontaktformulars (kontakt.store),
der das Anliegen zusätzlich in der Admin-Queue „Kontakt" persistiert.
Styling: nutzt nur Token-Utilities, die in Web- UND Portal-Build
existieren (bg-bg, text-ink, border-bg-rule …); die Akzentfarbe kommt
per CSS-Variablen-Fallback (Hub-Blau → Brand-Farbe der Spokes).
--}}
@php
$edition = in_array(app()->getLocale(), ['de', 'en'], true) ? app()->getLocale() : 'de';
$launcherAccent = 'background-color: var(--color-hub, var(--color-brand, #1A2540));';
$prefillName = old('name', auth()->user()->name ?? '');
$prefillEmail = old('email', auth()->user()->email ?? '');
$launcherTopics = [
[
'value' => \App\Enums\ContactMessageTopic::Contact->value,
'label' => __('web.launcher.topic_contact'),
'description' => __('web.launcher.topic_contact_desc'),
],
[
'value' => \App\Enums\ContactMessageTopic::Advertising->value,
'label' => __('web.launcher.topic_advertising'),
'description' => __('web.launcher.topic_advertising_desc'),
],
[
'value' => \App\Enums\ContactMessageTopic::BugReport->value,
'label' => __('web.launcher.topic_bug'),
'description' => __('web.launcher.topic_bug_desc'),
],
[
'value' => \App\Enums\ContactMessageTopic::PressReleaseReport->value,
'label' => __('web.launcher.topic_press'),
'description' => __('web.launcher.topic_press_desc'),
],
[
'value' => \App\Enums\ContactMessageTopic::Feedback->value,
'label' => __('web.launcher.topic_feedback'),
'description' => __('web.launcher.topic_feedback_desc'),
],
];
$launcherTopicLabels = collect($launcherTopics)->pluck('label', 'value');
@endphp
<div
x-data="{
open: @js(session()->has('contact-status') || (old('topic') !== null && $errors->any())),
topic: @js(old('topic')),
topicLabels: @js($launcherTopicLabels),
}"
{{-- Optionales Event-Detail `topic` wählt das Anliegen direkt vor (z. B. Werbeplätze). --}}
@open-contact-modal.window="open = true; if ($event.detail?.topic && topicLabels[$event.detail.topic]) { topic = $event.detail.topic }"
@keydown.escape.window="open = false"
class="fixed bottom-5 right-5 z-50 flex flex-col items-end gap-3 print:hidden"
>
{{-- Panel --}}
<div x-show="open" x-transition.origin.bottom.right.duration.150ms style="display: none;"
class="w-[calc(100vw-40px)] max-w-[360px] bg-bg-elev border border-bg-rule rounded-[6px] shadow-2xl overflow-hidden"
role="dialog" aria-modal="false" aria-label="{{ __('web.launcher.contact') }}">
{{-- Kopf --}}
<div class="flex items-center justify-between gap-3 px-5 py-4 text-white" style="{{ $launcherAccent }}">
<div>
<div class="text-[10px] font-bold tracking-[0.18em] uppercase opacity-70">{{ __('web.launcher.contact') }}</div>
<div class="text-[15px] font-semibold leading-tight" x-text="topic ? topicLabels[topic] : @js(__('web.launcher.title'))"></div>
</div>
<button type="button" @click="open = false" aria-label="{{ __('web.launcher.close') }}"
class="w-8 h-8 flex items-center justify-center rounded-full text-white/80 hover:text-white hover:bg-white/10 transition-colors cursor-pointer">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
</svg>
</button>
</div>
@if (session('contact-status'))
{{-- Erfolgsmeldung nach Absenden --}}
<div class="p-5">
<p class="text-[13px] text-ink-2 leading-[1.6] m-0" role="status">
{{ session('contact-status') }}
</p>
<button type="button" @click="open = false"
class="mt-4 inline-flex items-center justify-center px-4 py-2 text-[12.5px] font-semibold text-white rounded-[4px] transition-opacity hover:opacity-90 cursor-pointer"
style="{{ $launcherAccent }}">
{{ __('web.launcher.close') }}
</button>
</div>
@else
{{-- Schritt 1: Themenauswahl --}}
<div x-show="!topic" class="p-3">
<p class="text-[12px] text-ink-3 leading-[1.5] m-0 px-2 pt-1 pb-2">
{{ __('web.launcher.intro') }}
</p>
@foreach ($launcherTopics as $launcherTopic)
<button type="button" @click="topic = '{{ $launcherTopic['value'] }}'"
class="w-full flex items-center justify-between gap-3 px-3 py-2.5 text-left rounded-[4px] hover:bg-bg transition-colors cursor-pointer">
<span>
<span class="block text-[13px] font-semibold text-ink">{{ $launcherTopic['label'] }}</span>
<span class="block text-[11.5px] text-ink-3">{{ $launcherTopic['description'] }}</span>
</span>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" class="text-ink-3 flex-shrink-0" aria-hidden="true">
<path d="M4 2l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
@endforeach
</div>
{{-- Schritt 2: Formular --}}
<form x-show="topic" method="POST" action="{{ route('kontakt.store', ['edition' => $edition]) }}"
class="p-5 space-y-3.5" novalidate style="display: none;">
@csrf
<input type="hidden" name="topic" :value="topic" />
<input type="hidden" name="page_url" value="{{ request()->fullUrl() }}" />
<button type="button" @click="topic = null"
class="inline-flex items-center gap-1.5 text-[11.5px] font-medium text-ink-3 hover:text-ink transition-colors cursor-pointer">
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M8 2L4 6l4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{{ __('web.launcher.back') }}
</button>
<div>
<label for="contact-launcher-name" class="block text-[11.5px] font-semibold text-ink mb-1">{{ __('web.launcher.name') }}</label>
<input id="contact-launcher-name" type="text" name="name" value="{{ $prefillName }}" required
autocomplete="name"
class="w-full border border-bg-rule rounded-[3px] px-3 py-2 text-[13px] bg-bg text-ink focus:outline-none" />
@error('name')
<p class="mt-1 text-[11.5px] text-red-600">{{ $message }}</p>
@enderror
</div>
<div>
<label for="contact-launcher-email" class="block text-[11.5px] font-semibold text-ink mb-1">{{ __('web.launcher.email') }}</label>
<input id="contact-launcher-email" type="email" name="email" value="{{ $prefillEmail }}" required
autocomplete="email"
class="w-full border border-bg-rule rounded-[3px] px-3 py-2 text-[13px] bg-bg text-ink focus:outline-none" />
@error('email')
<p class="mt-1 text-[11.5px] text-red-600">{{ $message }}</p>
@enderror
</div>
<div>
<label for="contact-launcher-message" class="block text-[11.5px] font-semibold text-ink mb-1">{{ __('web.launcher.message') }}</label>
<textarea id="contact-launcher-message" name="message" rows="4" required
class="w-full border border-bg-rule rounded-[3px] px-3 py-2 text-[13px] bg-bg text-ink focus:outline-none"
placeholder="{{ __('web.launcher.message_placeholder') }}">{{ old('message') }}</textarea>
@error('message')
<p class="mt-1 text-[11.5px] text-red-600">{{ $message }}</p>
@enderror
</div>
{{-- Rechtswidrige Inhalte gehören ins förmliche Meldeverfahren (DSA). --}}
<p x-show="topic === '{{ \App\Enums\ContactMessageTopic::PressReleaseReport->value }}'"
class="text-[11px] text-ink-3 leading-[1.5] m-0">
{{ __('web.launcher.legal_hint') }}
<a href="{{ route('melden', ['edition' => $edition]) }}" class="underline underline-offset-2 hover:text-ink">{{ __('web.launcher.legal_link') }}</a>
</p>
{{-- Spamschutz für Gäste und unbestätigte Konten (siehe ContactController). --}}
@unless (auth()->user()?->hasVerifiedEmail())
<x-turnstile action="contact" />
@error('turnstile')
<p class="text-[11.5px] text-red-600 m-0">{{ $message }}</p>
@enderror
<input type="hidden" name="{{ \App\Services\Spam\ContactSpamFilter::STARTED_FIELD }}" value="{{ \App\Services\Spam\ContactSpamFilter::startedToken() }}" />
@endunless
{{-- Bot-Schutz: versteckte Honeypot-Felder (siehe ContactController). --}}
<div class="hidden" aria-hidden="true">
<label for="contact-launcher-website">Website</label>
<input id="contact-launcher-website" type="text" name="website" tabindex="-1" autocomplete="off" value="{{ old('website') }}" />
<label for="contact-launcher-homepage">Homepage</label>
<input id="contact-launcher-homepage" type="text" name="homepage" tabindex="-1" autocomplete="off" value="{{ old('homepage') }}" />
</div>
<div class="flex items-center justify-between gap-3 pt-1">
<p class="text-[10.5px] text-ink-3 leading-[1.5] m-0">
{{ __('web.launcher.privacy_note') }}
(<a href="{{ route('datenschutz', ['edition' => $edition]) }}" class="underline hover:text-ink">{{ __('web.footer.privacy') }}</a>).
</p>
<button type="submit"
class="inline-flex items-center justify-center px-4 py-2 text-[12.5px] font-semibold text-white rounded-[4px] whitespace-nowrap transition-opacity hover:opacity-90 cursor-pointer"
style="{{ $launcherAccent }}">
{{ __('web.launcher.submit') }}
</button>
</div>
</form>
@endif
</div>
{{-- Runder Launcher-Button --}}
<button type="button" @click="open = !open"
class="rounded-full shadow-xl flex items-center justify-center text-white transition-transform hover:scale-105 cursor-pointer"
style="{{ $launcherAccent }} width: 52px; height: 52px;"
:aria-expanded="open.toString()"
aria-haspopup="dialog"
aria-label="{{ __('web.launcher.contact') }}">
{{-- Sprechblasen-Icon (geschlossen) / X (offen) --}}
<svg x-show="!open" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"
stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<svg x-show="open" width="18" height="18" viewBox="0 0 16 16" fill="none" aria-hidden="true" style="display: none;">
<path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
</button>
</div>