- 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>
214 lines
12 KiB
PHP
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>
|