10-04-2026
This commit is contained in:
parent
4d6b4930b2
commit
4bb89aad8c
836 changed files with 52961 additions and 5950 deletions
39
packages/acme/CookieConsent/composer.json
Normal file
39
packages/acme/CookieConsent/composer.json
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"name": "acme/cookie-consent",
|
||||
"description": "Ein DSGVO-konformer Cookie Consent Manager für Laravel mit Alpine.js und Google Analytics Support",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"cookie",
|
||||
"consent",
|
||||
"gdpr",
|
||||
"dsgvo",
|
||||
"google-analytics",
|
||||
"alpine.js"
|
||||
],
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Acme\\CookieConsent\\": "src/"
|
||||
}
|
||||
},
|
||||
"authors": [
|
||||
{
|
||||
"name": "Acme",
|
||||
"email": "info@acme.de"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.1",
|
||||
"illuminate/support": "^10.0|^11.0|^12.0"
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Acme\\CookieConsent\\CookieConsentServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
127
packages/acme/CookieConsent/config/cookie-consent.php
Normal file
127
packages/acme/CookieConsent/config/cookie-consent.php
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cookie Consent aktivieren
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Hilfreich, um den Manager in bestimmten Umgebungen komplett auszuschalten.
|
||||
|
|
||||
*/
|
||||
'enabled' => env('COOKIE_CONSENT_ENABLED', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Google Analytics ID
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Hier trägst du deine Tracking ID ein (z.B. G-XXXXXXXXXX).
|
||||
| Wenn der Wert leer ist, wird der Analytics-Toggle nicht angezeigt.
|
||||
|
|
||||
*/
|
||||
'analytics_id' => env('GOOGLE_ANALYTICS_ID', ''),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Google Tag Manager ID
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Optional: Wenn du den Google Tag Manager statt direktem Analytics nutzt,
|
||||
| trage hier deine GTM-ID ein (z.B. GTM-XXXXXXXX).
|
||||
|
|
||||
| Wenn sowohl GTM als auch Analytics-ID gesetzt sind, wird GTM bevorzugt.
|
||||
| Der Tag Manager lädt dann alle weiteren Tags (inkl. GA4) selbst.
|
||||
|
|
||||
*/
|
||||
'gtm_id' => env('GOOGLE_TAG_MANAGER_ID', ''),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cookie Lebensdauer
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Wie viele Tage soll die Entscheidung des Nutzers gespeichert bleiben?
|
||||
| Standard: 365 Tage (1 Jahr). DSGVO empfiehlt max. 12 Monate.
|
||||
|
|
||||
*/
|
||||
'cookie_lifetime' => env('COOKIE_CONSENT_LIFETIME', 365),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cookie Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Der Name des Cookies, in dem die Einwilligung gespeichert wird.
|
||||
|
|
||||
*/
|
||||
'cookie_name' => env('COOKIE_CONSENT_NAME', 'cookie_consent'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Links
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| URLs für Datenschutz und Impressum.
|
||||
|
|
||||
*/
|
||||
'links' => [
|
||||
'privacy' => '/datenschutz',
|
||||
'imprint' => '/impressum',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Position des Floating Buttons
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Wo soll der Cookie-Settings-Button angezeigt werden?
|
||||
| Optionen: 'bottom-left', 'bottom-right'
|
||||
|
|
||||
*/
|
||||
'button_position' => 'bottom-left',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| IP-Anonymisierung
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Google Analytics IP-Anonymisierung aktivieren (empfohlen für DSGVO).
|
||||
|
|
||||
*/
|
||||
'anonymize_ip' => true,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Farben (Theme)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Passe die Farben an dein Projekt-Design an.
|
||||
| Du kannst Tailwind-Klassen oder CSS-Farbwerte verwenden.
|
||||
|
|
||||
| Für Tailwind-Projekte: Nutze deine definierten Farben wie 'primary', 'secondary'
|
||||
| Für Standard-CSS: Nutze HEX-Werte wie '#009bdd'
|
||||
|
|
||||
*/
|
||||
'colors' => [
|
||||
// Primärfarbe für Akzente, aktive Toggles, Icons
|
||||
'primary' => env('COOKIE_CONSENT_COLOR_PRIMARY', '#0088cc'),
|
||||
|
||||
// Sekundärfarbe / Hover-Zustand der Primärfarbe
|
||||
'primary_hover' => env('COOKIE_CONSENT_COLOR_PRIMARY_HOVER', '#006699'),
|
||||
|
||||
// Akzeptieren-Button (grün)
|
||||
'accept' => env('COOKIE_CONSENT_COLOR_ACCEPT', '#16a34a'),
|
||||
'accept_hover' => env('COOKIE_CONSENT_COLOR_ACCEPT_HOVER', '#15803d'),
|
||||
|
||||
// Einstellungen speichern Button
|
||||
'save' => env('COOKIE_CONSENT_COLOR_SAVE', '#a5a5a5'),
|
||||
'save_hover' => env('COOKIE_CONSENT_COLOR_SAVE_HOVER', '#b3b3b3'),
|
||||
|
||||
// Floating Button
|
||||
'button_bg' => env('COOKIE_CONSENT_COLOR_BUTTON_BG', '#0088cc'),
|
||||
'button_hover' => env('COOKIE_CONSENT_COLOR_BUTTON_HOVER', '#006699'),
|
||||
],
|
||||
|
||||
];
|
||||
138
packages/acme/CookieConsent/lang/de/cookie-consent.php
Normal file
138
packages/acme/CookieConsent/lang/de/cookie-consent.php
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cookie Consent - Deutsche Übersetzungen
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
// Modal
|
||||
'modal' => [
|
||||
'title' => 'Datenschutzeinstellungen',
|
||||
'description' => 'Wir nutzen Cookies, um unsere Website für Sie zu optimieren. Einige sind essenziell, andere helfen uns, die Nutzung zu analysieren.',
|
||||
],
|
||||
|
||||
// Cookie-Kategorien
|
||||
'categories' => [
|
||||
'essential' => [
|
||||
'title' => 'Technisch notwendig',
|
||||
'description' => 'Für die Grundfunktion der Seite.',
|
||||
],
|
||||
'analytics' => [
|
||||
'title' => 'Analyse & Statistik',
|
||||
'description' => 'Google Analytics / Tag Manager (IP-Anonymisiert).',
|
||||
],
|
||||
],
|
||||
|
||||
// Buttons
|
||||
'buttons' => [
|
||||
'accept_all' => 'Alle akzeptieren',
|
||||
'save_selection' => 'Auswahl speichern',
|
||||
'reject_all' => 'Nur essenzielle',
|
||||
'change_settings' => 'Cookie-Einstellungen ändern',
|
||||
],
|
||||
|
||||
// Links
|
||||
'links' => [
|
||||
'privacy' => 'Datenschutzerklärung',
|
||||
'imprint' => 'Impressum',
|
||||
],
|
||||
|
||||
// Accessibility
|
||||
'aria' => [
|
||||
'open_settings' => 'Cookie-Einstellungen öffnen',
|
||||
'essential_always_active' => 'Technisch notwendige Cookies sind immer aktiv',
|
||||
'toggle_analytics' => 'Analytics Cookies aktivieren oder deaktivieren',
|
||||
],
|
||||
|
||||
// Status
|
||||
'status' => [
|
||||
'active' => 'Aktiv',
|
||||
'inactive' => 'Deaktiviert',
|
||||
'no_consent' => 'Sie haben noch keine Cookie-Einstellungen vorgenommen.',
|
||||
],
|
||||
|
||||
// Privacy Info Komponente
|
||||
'privacy_info' => [
|
||||
'current_settings_title' => 'Ihre aktuellen Cookie-Einstellungen',
|
||||
'essential_cookies' => 'Technisch notwendige Cookies',
|
||||
'analytics_cookies' => 'Analyse & Statistik (Google Analytics / Tag Manager)',
|
||||
'analytics_marketing_cookies' => 'Analyse- und Marketing-Cookies',
|
||||
],
|
||||
|
||||
// Google Analytics Informationen
|
||||
'google_analytics' => [
|
||||
'title' => 'Google Analytics',
|
||||
'intro' => 'Diese Website nutzt Google Analytics, einen Webanalysedienst der Google Ireland Limited („Google"), Gordon House, Barrow Street, Dublin 4, Irland.',
|
||||
|
||||
'technical_details' => [
|
||||
'title' => 'Technische Details',
|
||||
'tracking_id' => 'Tracking-ID',
|
||||
'ip_anonymization' => 'IP-Anonymisierung',
|
||||
'ip_anonymization_value' => 'Aktiviert (anonymize_ip)',
|
||||
'consent_mode' => 'Consent Mode',
|
||||
'consent_mode_value' => 'Google Consent Mode v2',
|
||||
'cookie_duration' => 'Cookie-Speicherdauer',
|
||||
'cookie_duration_value' => ':days Tage',
|
||||
],
|
||||
|
||||
'purpose' => [
|
||||
'title' => 'Zweck der Verarbeitung',
|
||||
'text' => 'Google Analytics verwendet Cookies, die eine Analyse der Benutzung der Website ermöglichen. Die durch das Cookie erzeugten Informationen über Ihre Benutzung dieser Website werden in der Regel an einen Server von Google in den USA übertragen und dort gespeichert.',
|
||||
],
|
||||
|
||||
'ip_anonymization' => [
|
||||
'title' => 'IP-Anonymisierung',
|
||||
'text' => 'Wir haben auf dieser Website die Funktion IP-Anonymisierung aktiviert. Dadurch wird Ihre IP-Adresse von Google innerhalb von Mitgliedstaaten der Europäischen Union oder in anderen Vertragsstaaten des Abkommens über den Europäischen Wirtschaftsraum vor der Übermittlung in die USA gekürzt.',
|
||||
],
|
||||
|
||||
'legal_basis' => [
|
||||
'title' => 'Rechtsgrundlage',
|
||||
'text' => 'Die Verarbeitung erfolgt auf Grundlage Ihrer Einwilligung gemäß Art. 6 Abs. 1 lit. a DSGVO. Sie können Ihre Einwilligung jederzeit widerrufen, indem Sie die Cookie-Einstellungen ändern.',
|
||||
],
|
||||
|
||||
'cookies' => [
|
||||
'title' => 'Von Google Analytics gesetzte Cookies',
|
||||
'table' => [
|
||||
'name' => 'Cookie',
|
||||
'purpose' => 'Zweck',
|
||||
'duration' => 'Speicherdauer',
|
||||
],
|
||||
'list' => [
|
||||
'_ga' => [
|
||||
'purpose' => 'Unterscheidung von Nutzern',
|
||||
'duration' => '2 Jahre',
|
||||
],
|
||||
'_ga_*' => [
|
||||
'purpose' => 'Speicherung des Sitzungsstatus',
|
||||
'duration' => '2 Jahre',
|
||||
],
|
||||
'_gid' => [
|
||||
'purpose' => 'Unterscheidung von Nutzern',
|
||||
'duration' => '24 Stunden',
|
||||
],
|
||||
'_gat' => [
|
||||
'purpose' => 'Drosselung der Anfragerate',
|
||||
'duration' => '1 Minute',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'objection' => [
|
||||
'title' => 'Widerspruchsmöglichkeit',
|
||||
'text' => 'Sie können die Erfassung der durch das Cookie erzeugten und auf Ihre Nutzung der Website bezogenen Daten (inkl. Ihrer IP-Adresse) an Google sowie die Verarbeitung dieser Daten durch Google verhindern, indem Sie:',
|
||||
'options' => [
|
||||
'revoke' => 'Ihre Einwilligung in den Cookie-Einstellungen widerrufen',
|
||||
'addon' => 'Das Browser-Add-on zur Deaktivierung von Google Analytics installieren:',
|
||||
],
|
||||
],
|
||||
|
||||
'more_info' => [
|
||||
'title' => 'Weitere Informationen',
|
||||
'text' => 'Weitere Informationen zum Umgang mit Nutzerdaten bei Google Analytics finden Sie in der Datenschutzerklärung von Google:',
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
138
packages/acme/CookieConsent/lang/en/cookie-consent.php
Normal file
138
packages/acme/CookieConsent/lang/en/cookie-consent.php
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cookie Consent - English Translations
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
// Modal
|
||||
'modal' => [
|
||||
'title' => 'Privacy Settings',
|
||||
'description' => 'We use cookies to optimize our website for you. Some are essential, others help us analyze usage.',
|
||||
],
|
||||
|
||||
// Cookie Categories
|
||||
'categories' => [
|
||||
'essential' => [
|
||||
'title' => 'Strictly Necessary',
|
||||
'description' => 'Required for basic site functionality.',
|
||||
],
|
||||
'analytics' => [
|
||||
'title' => 'Analytics & Statistics',
|
||||
'description' => 'Google Analytics / Tag Manager (IP anonymized).',
|
||||
],
|
||||
],
|
||||
|
||||
// Buttons
|
||||
'buttons' => [
|
||||
'accept_all' => 'Accept All',
|
||||
'save_selection' => 'Save Selection',
|
||||
'reject_all' => 'Essential Only',
|
||||
'change_settings' => 'Change Cookie Settings',
|
||||
],
|
||||
|
||||
// Links
|
||||
'links' => [
|
||||
'privacy' => 'Privacy Policy',
|
||||
'imprint' => 'Legal Notice',
|
||||
],
|
||||
|
||||
// Accessibility
|
||||
'aria' => [
|
||||
'open_settings' => 'Open cookie settings',
|
||||
'essential_always_active' => 'Strictly necessary cookies are always active',
|
||||
'toggle_analytics' => 'Enable or disable analytics cookies',
|
||||
],
|
||||
|
||||
// Status
|
||||
'status' => [
|
||||
'active' => 'Active',
|
||||
'inactive' => 'Disabled',
|
||||
'no_consent' => 'You have not yet made any cookie settings.',
|
||||
],
|
||||
|
||||
// Privacy Info Component
|
||||
'privacy_info' => [
|
||||
'current_settings_title' => 'Your Current Cookie Settings',
|
||||
'essential_cookies' => 'Strictly Necessary Cookies',
|
||||
'analytics_cookies' => 'Analytics & Statistics (Google Analytics / Tag Manager)',
|
||||
'analytics_marketing_cookies' => 'Analytics & Marketing Cookies',
|
||||
],
|
||||
|
||||
// Google Analytics Information
|
||||
'google_analytics' => [
|
||||
'title' => 'Google Analytics',
|
||||
'intro' => 'This website uses Google Analytics, a web analytics service provided by Google Ireland Limited ("Google"), Gordon House, Barrow Street, Dublin 4, Ireland.',
|
||||
|
||||
'technical_details' => [
|
||||
'title' => 'Technical Details',
|
||||
'tracking_id' => 'Tracking ID',
|
||||
'ip_anonymization' => 'IP Anonymization',
|
||||
'ip_anonymization_value' => 'Enabled (anonymize_ip)',
|
||||
'consent_mode' => 'Consent Mode',
|
||||
'consent_mode_value' => 'Google Consent Mode v2',
|
||||
'cookie_duration' => 'Cookie Duration',
|
||||
'cookie_duration_value' => ':days days',
|
||||
],
|
||||
|
||||
'purpose' => [
|
||||
'title' => 'Purpose of Processing',
|
||||
'text' => 'Google Analytics uses cookies that enable an analysis of your use of the website. The information generated by the cookie about your use of this website is usually transmitted to a Google server in the USA and stored there.',
|
||||
],
|
||||
|
||||
'ip_anonymization' => [
|
||||
'title' => 'IP Anonymization',
|
||||
'text' => 'We have activated the IP anonymization function on this website. This means that your IP address is truncated by Google within member states of the European Union or in other contracting states of the Agreement on the European Economic Area before being transmitted to the USA.',
|
||||
],
|
||||
|
||||
'legal_basis' => [
|
||||
'title' => 'Legal Basis',
|
||||
'text' => 'Processing is based on your consent pursuant to Art. 6 (1) lit. a GDPR. You can revoke your consent at any time by changing your cookie settings.',
|
||||
],
|
||||
|
||||
'cookies' => [
|
||||
'title' => 'Cookies Set by Google Analytics',
|
||||
'table' => [
|
||||
'name' => 'Cookie',
|
||||
'purpose' => 'Purpose',
|
||||
'duration' => 'Duration',
|
||||
],
|
||||
'list' => [
|
||||
'_ga' => [
|
||||
'purpose' => 'Distinguishing users',
|
||||
'duration' => '2 years',
|
||||
],
|
||||
'_ga_*' => [
|
||||
'purpose' => 'Storing session state',
|
||||
'duration' => '2 years',
|
||||
],
|
||||
'_gid' => [
|
||||
'purpose' => 'Distinguishing users',
|
||||
'duration' => '24 hours',
|
||||
],
|
||||
'_gat' => [
|
||||
'purpose' => 'Throttling request rate',
|
||||
'duration' => '1 minute',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'objection' => [
|
||||
'title' => 'Right to Object',
|
||||
'text' => 'You can prevent the collection of data generated by the cookie and related to your use of the website (including your IP address) by Google and the processing of this data by Google by:',
|
||||
'options' => [
|
||||
'revoke' => 'Revoking your consent in the cookie settings',
|
||||
'addon' => 'Installing the browser add-on to disable Google Analytics:',
|
||||
],
|
||||
],
|
||||
|
||||
'more_info' => [
|
||||
'title' => 'More Information',
|
||||
'text' => 'For more information on how Google Analytics handles user data, please see Google\'s privacy policy:',
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
365
packages/acme/CookieConsent/readme.md
Normal file
365
packages/acme/CookieConsent/readme.md
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
# Laravel GDPR/DSGVO Cookie Consent Manager
|
||||
|
||||
Ein leichtgewichtiges, DSGVO-konformes Cookie Consent Management System für Laravel. Es nutzt **Alpine.js** für die Frontend-Logik und ist speziell für die einfache Integration von Google Analytics 4 und Google Tag Manager (mit Google Consent Mode v2) entwickelt.
|
||||
|
||||
## Features
|
||||
|
||||
- 🇪🇺 **DSGVO Konform:** Skripte werden erst nach expliziter Zustimmung geladen (Prior Consent)
|
||||
- 🔒 **Google Consent Mode v2:** Vollständige Integration mit Default-Einstellungen
|
||||
- 📊 **Google Analytics 4:** Direktes GA4-Tracking mit IP-Anonymisierung
|
||||
- 🏷️ **Google Tag Manager:** Alternativ GTM-Integration für komplexere Tag-Setups
|
||||
- 🌍 **Mehrsprachig:** Deutsch und Englisch integriert, weitere Sprachen leicht hinzufügbar
|
||||
- 🚀 **Performance:** Basiert auf Alpine.js, keine Server-Requests
|
||||
- 🎨 **Flexibel:** Anpassbar über Blade-Komponenten und Tailwind CSS
|
||||
- ⚙️ **Konfigurierbar:** Alle Optionen über Config steuerbar
|
||||
- 📱 **Responsive:** Mobile-optimiertes Modal mit Animationen
|
||||
- ♿ **Barrierefrei:** ARIA-Labels, Focus-Trap, Keyboard-Navigation
|
||||
- 🍪 **Echte Cookies:** Speicherung als HTTP-Cookie mit konfigurierbarer Laufzeit
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- PHP 8.1+
|
||||
- Laravel 10+ oder 11+
|
||||
- Alpine.js (via Livewire oder manuell eingebunden)
|
||||
- Tailwind CSS (für das Standard-Styling)
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Package installieren
|
||||
|
||||
```bash
|
||||
composer require acme/cookie-consent
|
||||
```
|
||||
|
||||
Falls das Package lokal entwickelt wird, füge es in der `composer.json` hinzu:
|
||||
|
||||
```json
|
||||
{
|
||||
"repositories": [
|
||||
{
|
||||
"type": "path",
|
||||
"url": "dev/acme/CookieConsent"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"acme/cookie-consent": "@dev"
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
composer update acme/cookie-consent
|
||||
|
||||
composer update acme/cookie-consent && php artisan package:discover && php artisan config:clear && php artisan view:clear && php artisan cache:clear
|
||||
```
|
||||
|
||||
### 2. Environment Variables
|
||||
|
||||
Füge deine Tracking-ID in die `.env` Datei ein. Du kannst entweder Google Analytics direkt **oder** den Google Tag Manager nutzen:
|
||||
|
||||
**Option A: Google Analytics direkt**
|
||||
```dotenv
|
||||
GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX
|
||||
```
|
||||
|
||||
**Option B: Google Tag Manager (empfohlen für komplexere Setups)**
|
||||
```dotenv
|
||||
GOOGLE_TAG_MANAGER_ID=GTM-XXXXXXXX
|
||||
```
|
||||
|
||||
> **Hinweis:** Wenn beide IDs gesetzt sind, wird der Google Tag Manager bevorzugt. Der GTM lädt dann alle weiteren Tags (inkl. GA4) selbstständig über die GTM-Konfiguration.
|
||||
|
||||
Optionale Einstellungen:
|
||||
|
||||
```dotenv
|
||||
COOKIE_CONSENT_ENABLED=true
|
||||
COOKIE_CONSENT_LIFETIME=365
|
||||
COOKIE_CONSENT_NAME=cookie_consent
|
||||
```
|
||||
|
||||
### 3. Config veröffentlichen (Optional)
|
||||
|
||||
```bash
|
||||
php artisan vendor:publish --tag=cookie-consent-config
|
||||
```
|
||||
|
||||
Dies erstellt `config/cookie-consent.php` mit allen Einstellungsmöglichkeiten.
|
||||
|
||||
## Nutzung
|
||||
|
||||
### Basis-Einbindung (Google Analytics)
|
||||
|
||||
Füge die Komponente in dein Hauptlayout ein (idealerweise vor `</body>`):
|
||||
|
||||
```blade
|
||||
<body>
|
||||
{{-- Dein Content --}}
|
||||
|
||||
<x-cookie-consent::components.manager />
|
||||
|
||||
@livewireScripts
|
||||
</body>
|
||||
```
|
||||
|
||||
### Mit Google Tag Manager
|
||||
|
||||
Bei Nutzung des Google Tag Managers musst du zusätzlich die `gtm-noscript` Komponente **direkt nach dem öffnenden `<body>` Tag** einbinden:
|
||||
|
||||
```blade
|
||||
<body>
|
||||
{{-- GTM noscript (nur erforderlich bei GTM-Nutzung) --}}
|
||||
<x-cookie-consent::components.gtm-noscript />
|
||||
|
||||
{{-- Dein Content --}}
|
||||
|
||||
{{-- Cookie Consent Manager (vor </body>) --}}
|
||||
<x-cookie-consent::components.manager />
|
||||
|
||||
@livewireScripts
|
||||
</body>
|
||||
```
|
||||
|
||||
> **Wichtig:** Der `gtm-noscript` iframe wird von Google empfohlen und ermöglicht grundlegendes Tracking auch bei deaktiviertem JavaScript. Er wird nur geladen, wenn der Nutzer dem Tracking zugestimmt hat.
|
||||
|
||||
### Parameter überschreiben
|
||||
|
||||
Du kannst alle Parameter direkt bei der Einbindung überschreiben:
|
||||
|
||||
```blade
|
||||
{{-- Mit Google Analytics --}}
|
||||
<x-cookie-consent::components.manager
|
||||
analytics-id="G-SPECIAL-ID"
|
||||
cookie-lifetime="180"
|
||||
button-position="bottom-right"
|
||||
/>
|
||||
|
||||
{{-- Mit Google Tag Manager --}}
|
||||
<x-cookie-consent::components.manager
|
||||
gtm-id="GTM-SPECIAL-ID"
|
||||
cookie-lifetime="180"
|
||||
button-position="bottom-right"
|
||||
/>
|
||||
|
||||
{{-- GTM noscript kann auch überschrieben werden --}}
|
||||
<x-cookie-consent::components.gtm-noscript
|
||||
gtm-id="GTM-SPECIAL-ID"
|
||||
/>
|
||||
```
|
||||
|
||||
## Mehrsprachigkeit
|
||||
|
||||
Das Package unterstützt automatische Spracherkennung über `app()->getLocale()`.
|
||||
|
||||
### Verfügbare Sprachen
|
||||
|
||||
- 🇩🇪 **Deutsch** (de) - Standardsprache
|
||||
- 🇬🇧 **Englisch** (en)
|
||||
|
||||
### Sprachdateien veröffentlichen
|
||||
|
||||
Um die Übersetzungen anzupassen oder weitere Sprachen hinzuzufügen:
|
||||
|
||||
```bash
|
||||
php artisan vendor:publish --tag=cookie-consent-lang
|
||||
```
|
||||
|
||||
Die Dateien werden nach `lang/vendor/cookie-consent/` in deinem Laravel-Projekt kopiert.
|
||||
|
||||
### Eigene Sprache hinzufügen
|
||||
|
||||
1. Kopiere `lang/vendor/cookie-consent/de/cookie-consent.php`
|
||||
2. Erstelle z.B. `lang/vendor/cookie-consent/fr/cookie-consent.php`
|
||||
3. Übersetze alle Texte
|
||||
|
||||
### Sprache setzen
|
||||
|
||||
Die Sprache wird automatisch aus Laravel übernommen:
|
||||
|
||||
```php
|
||||
// In Middleware, Controller oder ServiceProvider
|
||||
app()->setLocale('en'); // Wechselt zu Englisch
|
||||
|
||||
// Oder basierend auf Benutzereinstellung
|
||||
app()->setLocale(auth()->user()->language ?? 'de');
|
||||
```
|
||||
|
||||
## Cookie-Einstellungen manuell öffnen
|
||||
|
||||
Laut DSGVO muss der Nutzer seine Entscheidung jederzeit ändern können. Das Package stellt dafür einen Event-Listener bereit:
|
||||
|
||||
```html
|
||||
<a href="#" @click.prevent="$dispatch('open-cookie-settings')">
|
||||
Cookie-Einstellungen bearbeiten
|
||||
</a>
|
||||
```
|
||||
|
||||
Oder mit Vanilla JavaScript:
|
||||
|
||||
```html
|
||||
<a href="#" onclick="window.dispatchEvent(new CustomEvent('open-cookie-settings')); return false;">
|
||||
Cookie-Einstellungen
|
||||
</a>
|
||||
```
|
||||
|
||||
## Datenschutzerklärung Komponente
|
||||
|
||||
Das Package enthält eine spezielle Komponente für deine Datenschutzerklärung, die:
|
||||
- Die aktuellen Cookie-Einstellungen des Nutzers anzeigt
|
||||
- Einen Button zum Ändern der Einstellungen bietet
|
||||
- Ausführliche Informationen zu Google Analytics bereitstellt
|
||||
|
||||
### Einbindung
|
||||
|
||||
```blade
|
||||
{{-- In deiner Datenschutz-Seite --}}
|
||||
<x-cookie-consent::components.privacy-info />
|
||||
```
|
||||
|
||||
### Parameter
|
||||
|
||||
```blade
|
||||
<x-cookie-consent::components.privacy-info
|
||||
:show-analytics-info="true" {{-- GA-Infos anzeigen --}}
|
||||
:show-current-settings="true" {{-- Aktuelle Einstellungen anzeigen --}}
|
||||
:show-change-button="true" {{-- Ändern-Button anzeigen --}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Nur bestimmte Teile anzeigen
|
||||
|
||||
```blade
|
||||
{{-- Nur die Cookie-Übersicht mit Button --}}
|
||||
<x-cookie-consent::components.privacy-info
|
||||
:show-analytics-info="false"
|
||||
/>
|
||||
|
||||
{{-- Nur die GA-Infos (ohne Einstellungs-Box) --}}
|
||||
<x-cookie-consent::components.privacy-info
|
||||
:show-current-settings="false"
|
||||
:show-change-button="false"
|
||||
/>
|
||||
```
|
||||
|
||||
Die Komponente aktualisiert sich automatisch, wenn der Nutzer seine Cookie-Einstellungen ändert (via `cookie-consent-updated` Event).
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Die vollständige Konfigurationsdatei (`config/cookie-consent.php`):
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
return [
|
||||
// Cookie Manager aktivieren/deaktivieren
|
||||
'enabled' => env('COOKIE_CONSENT_ENABLED', true),
|
||||
|
||||
// Google Analytics 4 Tracking ID
|
||||
'analytics_id' => env('GOOGLE_ANALYTICS_ID', ''),
|
||||
|
||||
// Google Tag Manager ID (hat Priorität über analytics_id)
|
||||
'gtm_id' => env('GOOGLE_TAG_MANAGER_ID', ''),
|
||||
|
||||
// Cookie Lebensdauer in Tagen (DSGVO: max. 12 Monate empfohlen)
|
||||
'cookie_lifetime' => env('COOKIE_CONSENT_LIFETIME', 365),
|
||||
|
||||
// Name des Consent-Cookies
|
||||
'cookie_name' => env('COOKIE_CONSENT_NAME', 'cookie_consent'),
|
||||
|
||||
// Links für Datenschutz und Impressum
|
||||
'links' => [
|
||||
'privacy' => '/datenschutz',
|
||||
'imprint' => '/impressum',
|
||||
],
|
||||
|
||||
// Position des Floating-Buttons: 'bottom-left' oder 'bottom-right'
|
||||
'button_position' => 'bottom-left',
|
||||
|
||||
// IP-Anonymisierung für Google Analytics (bei GTM in GTM konfigurieren)
|
||||
'anonymize_ip' => true,
|
||||
];
|
||||
```
|
||||
|
||||
## Google Consent Mode v2
|
||||
|
||||
Das Package implementiert den Google Consent Mode v2 vollständig:
|
||||
|
||||
1. **Default-Einstellungen** werden VOR dem Laden von GA/GTM gesetzt (alles `denied`)
|
||||
2. Bei Zustimmung wird ein **Consent Update** gesendet
|
||||
3. GA4/GTM-Script wird erst NACH Zustimmung dynamisch geladen
|
||||
|
||||
Diese Implementierung entspricht den aktuellen Google-Anforderungen (März 2024).
|
||||
|
||||
## Google Tag Manager
|
||||
|
||||
Für komplexere Tracking-Setups empfehlen wir den Google Tag Manager statt direktem GA4:
|
||||
|
||||
### Vorteile von GTM
|
||||
|
||||
- **Zentrale Verwaltung:** Alle Tags (GA4, Ads, Meta, etc.) über eine Oberfläche
|
||||
- **Keine Code-Änderungen:** Neue Tags können ohne Deployment hinzugefügt werden
|
||||
- **Debugging:** Eingebauter Vorschau-Modus zum Testen
|
||||
- **Trigger & Variablen:** Komplexe Regeln ohne Programmierung
|
||||
|
||||
### Einrichtung
|
||||
|
||||
1. Setze `GOOGLE_TAG_MANAGER_ID` in der `.env`
|
||||
2. Füge `<x-cookie-consent::components.gtm-noscript />` direkt nach `<body>` ein
|
||||
3. Konfiguriere GA4 und andere Tags im GTM-Interface
|
||||
|
||||
### Consent Mode im GTM
|
||||
|
||||
Der Consent Mode wird automatisch übermittelt. Im GTM solltest du:
|
||||
|
||||
1. **Consent-Einstellungen aktivieren:** Admin > Container-Einstellungen > Consent-Übersicht aktivieren
|
||||
2. **Tags konfigurieren:** Bei jedem Tag die "Einwilligungsprüfungen" entsprechend setzen
|
||||
3. **GA4-Tag:** "analytics_storage" auf "Erforderlich" setzen
|
||||
|
||||
## Design Anpassen
|
||||
|
||||
Views veröffentlichen:
|
||||
|
||||
```bash
|
||||
php artisan vendor:publish --tag=cookie-consent-views
|
||||
```
|
||||
|
||||
Die Dateien liegen dann unter `resources/views/vendor/cookie-consent/`.
|
||||
|
||||
## Server-seitige Prüfung
|
||||
|
||||
Da der Consent als echtes Cookie gespeichert wird, kannst du ihn auch serverseitig prüfen:
|
||||
|
||||
```php
|
||||
// In einem Controller oder Middleware
|
||||
$consent = json_decode(request()->cookie('cookie_consent'), true);
|
||||
|
||||
if ($consent && $consent['analytics'] === true) {
|
||||
// Analytics-Tracking erlaubt
|
||||
}
|
||||
```
|
||||
|
||||
## Alle Assets veröffentlichen
|
||||
|
||||
```bash
|
||||
# Config
|
||||
php artisan vendor:publish --tag=cookie-consent-config
|
||||
|
||||
# Views
|
||||
php artisan vendor:publish --tag=cookie-consent-views
|
||||
|
||||
# Sprachdateien
|
||||
php artisan vendor:publish --tag=cookie-consent-lang
|
||||
|
||||
# Alles auf einmal
|
||||
php artisan vendor:publish --provider="Acme\CookieConsent\CookieConsentServiceProvider"
|
||||
```
|
||||
|
||||
## Rechtlicher Hinweis
|
||||
|
||||
Dieses Package bietet eine technische Grundlage für DSGVO-konformes Tracking ("Privacy by Design"). **Es ersetzt keine anwaltliche Beratung.** Der Betreiber der Website ist dafür verantwortlich, dass:
|
||||
|
||||
- Die Datenschutzerklärung vollständig und korrekt ist
|
||||
- Alle eingesetzten Tracking-Dienste dort aufgeführt sind
|
||||
- Die Cookie-Einwilligung nachweisbar dokumentiert wird
|
||||
|
||||
## Lizenz
|
||||
|
||||
MIT License
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
@props([
|
||||
'gtmId' => config('cookie-consent.gtm_id'),
|
||||
'enabled' => config('cookie-consent.enabled', true),
|
||||
'cookieName' => config('cookie-consent.cookie_name', 'cookie_consent'),
|
||||
])
|
||||
|
||||
{{--
|
||||
Google Tag Manager (noscript) Komponente
|
||||
|
||||
Diese Komponente muss direkt nach dem öffnenden <body> Tag eingefügt werden:
|
||||
|
||||
<body>
|
||||
<x-cookie-consent::components.gtm-noscript />
|
||||
...
|
||||
</body>
|
||||
|
||||
Der noscript-Teil wird nur angezeigt, wenn:
|
||||
1. GTM aktiviert ist (gtm_id gesetzt)
|
||||
2. Der Cookie-Consent bereits erteilt wurde (analytics: true)
|
||||
|
||||
Hinweis: Bei JavaScript-deaktivierten Browsern funktioniert GTM ohnehin nicht vollständig,
|
||||
aber der noscript-iframe ermöglicht grundlegendes Tracking.
|
||||
--}}
|
||||
|
||||
@if ($enabled && $gtmId)
|
||||
{{--
|
||||
Alpine.js Wrapper für dynamische Anzeige basierend auf Cookie-Consent
|
||||
Der noscript-Teil wird erst nach Zustimmung in den DOM eingefügt
|
||||
--}}
|
||||
<div x-data="{
|
||||
gtmId: '{{ $gtmId }}',
|
||||
cookieName: '{{ $cookieName }}',
|
||||
hasConsent: false,
|
||||
|
||||
init() {
|
||||
this.checkConsent();
|
||||
|
||||
// Auf Consent-Updates hören
|
||||
window.addEventListener('cookie-consent-updated', (e) => {
|
||||
this.hasConsent = e.detail && e.detail.analytics === true;
|
||||
});
|
||||
|
||||
// Auf GTM-Load-Event hören
|
||||
window.addEventListener('gtm-consent-granted', () => {
|
||||
this.hasConsent = true;
|
||||
});
|
||||
},
|
||||
|
||||
checkConsent() {
|
||||
const cookie = this.getCookie(this.cookieName);
|
||||
if (cookie) {
|
||||
try {
|
||||
const settings = JSON.parse(cookie);
|
||||
this.hasConsent = settings.analytics === true;
|
||||
} catch (e) {
|
||||
this.hasConsent = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
getCookie(name) {
|
||||
const nameEQ = name + '=';
|
||||
const ca = document.cookie.split(';');
|
||||
for (let i = 0; i < ca.length; i++) {
|
||||
let c = ca[i].trim();
|
||||
if (c.indexOf(nameEQ) === 0) {
|
||||
return decodeURIComponent(c.substring(nameEQ.length));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}">
|
||||
{{-- GTM noscript iframe - wird nur bei Consent angezeigt --}}
|
||||
<template x-if="hasConsent">
|
||||
<noscript>
|
||||
<iframe x-bind:src="'https://www.googletagmanager.com/ns.html?id=' + gtmId" height="0" width="0"
|
||||
style="display:none;visibility:hidden" title="Google Tag Manager"></iframe>
|
||||
</noscript>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{{--
|
||||
Fallback für Server-Side Rendering:
|
||||
Wenn der Cookie bereits existiert und Analytics erlaubt,
|
||||
können wir den noscript-Teil direkt rendern
|
||||
--}}
|
||||
@php
|
||||
$cookieValue = request()->cookie($cookieName);
|
||||
$serverSideConsent = false;
|
||||
if ($cookieValue) {
|
||||
try {
|
||||
$decoded = json_decode($cookieValue, true);
|
||||
$serverSideConsent = isset($decoded['analytics']) && $decoded['analytics'] === true;
|
||||
} catch (\Exception $e) {
|
||||
$serverSideConsent = false;
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
|
||||
@if ($serverSideConsent)
|
||||
<!-- Google Tag Manager (noscript) -->
|
||||
<noscript>
|
||||
<iframe src="https://www.googletagmanager.com/ns.html?id={{ $gtmId }}" height="0" width="0"
|
||||
style="display:none;visibility:hidden" title="Google Tag Manager"></iframe>
|
||||
</noscript>
|
||||
<!-- End Google Tag Manager (noscript) -->
|
||||
@endif
|
||||
@endif
|
||||
|
|
@ -0,0 +1,503 @@
|
|||
@props([
|
||||
'analyticsId' => config('cookie-consent.analytics_id'),
|
||||
'gtmId' => config('cookie-consent.gtm_id'),
|
||||
'enabled' => config('cookie-consent.enabled', true),
|
||||
'cookieLifetime' => config('cookie-consent.cookie_lifetime', 365),
|
||||
'cookieName' => config('cookie-consent.cookie_name', 'cookie_consent'),
|
||||
'links' => config('cookie-consent.links', []),
|
||||
'buttonPosition' => config('cookie-consent.button_position', 'bottom-left'),
|
||||
'anonymizeIp' => config('cookie-consent.anonymize_ip', true),
|
||||
'colors' => config('cookie-consent.colors', []),
|
||||
])
|
||||
|
||||
@php
|
||||
$links = array_merge(
|
||||
[
|
||||
'privacy' => '/datenschutz',
|
||||
'imprint' => '/impressum',
|
||||
],
|
||||
$links ?? [],
|
||||
);
|
||||
|
||||
$colors = array_merge(
|
||||
[
|
||||
'primary' => '#009bdd',
|
||||
'primary_hover' => '#0071a8',
|
||||
'accept' => '#16a34a',
|
||||
'accept_hover' => '#15803d',
|
||||
'save' => '#1f2937',
|
||||
'save_hover' => '#111827',
|
||||
'button_bg' => '#1f2937',
|
||||
'button_hover' => '#374151',
|
||||
],
|
||||
$colors ?? [],
|
||||
);
|
||||
|
||||
$positionClasses = $buttonPosition === 'bottom-right' ? 'right-4' : 'left-4';
|
||||
|
||||
// Bestimme ob GTM oder Analytics verwendet wird (GTM hat Priorität)
|
||||
$useGtm = !empty($gtmId);
|
||||
$useAnalytics = !empty($analyticsId) && !$useGtm;
|
||||
$hasTracking = $useGtm || $useAnalytics;
|
||||
@endphp
|
||||
|
||||
@if ($enabled)
|
||||
{{-- CSS-Variablen für Farben --}}
|
||||
<style>
|
||||
.cookie-consent-wrapper {
|
||||
--cc-primary: {{ $colors['primary'] }};
|
||||
--cc-primary-hover: {{ $colors['primary_hover'] }};
|
||||
--cc-accept: {{ $colors['accept'] }};
|
||||
--cc-accept-hover: {{ $colors['accept_hover'] }};
|
||||
--cc-save: {{ $colors['save'] }};
|
||||
--cc-save-hover: {{ $colors['save_hover'] }};
|
||||
--cc-button-bg: {{ $colors['button_bg'] }};
|
||||
--cc-button-hover: {{ $colors['button_hover'] }};
|
||||
}
|
||||
|
||||
.cookie-consent-wrapper .cc-btn-accept {
|
||||
background-color: var(--cc-accept);
|
||||
}
|
||||
|
||||
.cookie-consent-wrapper .cc-btn-accept:hover {
|
||||
background-color: var(--cc-accept-hover);
|
||||
}
|
||||
|
||||
.cookie-consent-wrapper .cc-btn-save {
|
||||
background-color: var(--cc-save);
|
||||
}
|
||||
|
||||
.cookie-consent-wrapper .cc-btn-save:hover {
|
||||
background-color: var(--cc-save-hover);
|
||||
}
|
||||
|
||||
.cookie-consent-wrapper .cc-floating-btn {
|
||||
background-color: var(--cc-button-bg);
|
||||
}
|
||||
|
||||
.cookie-consent-wrapper .cc-floating-btn:hover {
|
||||
background-color: var(--cc-button-hover);
|
||||
}
|
||||
|
||||
.cookie-consent-wrapper .cc-icon-bg {
|
||||
background-color: color-mix(in srgb, var(--cc-primary) 15%, transparent);
|
||||
}
|
||||
|
||||
.cookie-consent-wrapper .cc-icon-color {
|
||||
color: var(--cc-primary);
|
||||
}
|
||||
|
||||
.cookie-consent-wrapper .cc-toggle-active {
|
||||
background-color: var(--cc-primary);
|
||||
}
|
||||
|
||||
.cookie-consent-wrapper .cc-toggle-inactive {
|
||||
background-color: #d1d5db;
|
||||
}
|
||||
|
||||
.cookie-consent-wrapper .cc-link:hover {
|
||||
color: var(--cc-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
{{-- Google Consent Mode v2: Default-Einstellungen VOR dem Laden von GA --}}
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
|
||||
// Consent Mode v2 Defaults - alles verweigert bis Nutzer entscheidet
|
||||
gtag('consent', 'default', {
|
||||
'analytics_storage': 'denied',
|
||||
'ad_storage': 'denied',
|
||||
'ad_user_data': 'denied',
|
||||
'ad_personalization': 'denied',
|
||||
'functionality_storage': 'granted',
|
||||
'personalization_storage': 'denied',
|
||||
'security_storage': 'granted',
|
||||
'wait_for_update': 500
|
||||
});
|
||||
</script>
|
||||
|
||||
<div x-data="cookieConsentManager({
|
||||
analyticsId: '{{ $analyticsId }}',
|
||||
gtmId: '{{ $gtmId }}',
|
||||
useGtm: {{ $useGtm ? 'true' : 'false' }},
|
||||
cookieLifetime: {{ $cookieLifetime }},
|
||||
cookieName: '{{ $cookieName }}',
|
||||
anonymizeIp: {{ $anonymizeIp ? 'true' : 'false' }}
|
||||
})" x-init="init()" @open-cookie-settings.window="openModal()"
|
||||
class="cookie-consent-wrapper">
|
||||
{{-- Floating Button --}}
|
||||
<button x-show="!isOpen && hasConsented" x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 scale-75" x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-75" @click="openModal()"
|
||||
class="cc-floating-btn fixed bottom-2 {{ $positionClasses }} z-40 text-white p-3 rounded-full shadow-lg hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 transition-all duration-300 cursor-pointer"
|
||||
style="--tw-ring-color: {{ $colors['primary'] }}"
|
||||
aria-label="{{ __('cookie-consent::cookie-consent.aria.open_settings') }}"
|
||||
title="{{ __('cookie-consent::cookie-consent.modal.title') }}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{{-- Modal Backdrop + Dialog --}}
|
||||
<div x-show="isOpen" x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-50 overflow-y-auto" style="display: none;" role="dialog" aria-modal="true"
|
||||
aria-labelledby="cookie-consent-title">
|
||||
{{-- Backdrop --}}
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" @click="closeIfAllowed()"></div>
|
||||
|
||||
{{-- Dialog Container --}}
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div x-show="isOpen" x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
class="relative bg-white rounded-xl shadow-2xl max-w-lg w-full overflow-hidden"
|
||||
@click.away="closeIfAllowed()" x-trap.noscroll="isOpen">
|
||||
{{-- Header --}}
|
||||
<div class="px-6 pt-6 pb-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="cc-icon-bg flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="cc-icon-color h-5 w-5" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 id="cookie-consent-title" class="text-xl font-bold text-gray-900">
|
||||
{{ __('cookie-consent::cookie-consent.modal.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
{{ __('cookie-consent::cookie-consent.modal.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Cookie Options --}}
|
||||
<div class="px-6 py-4 bg-gray-50 border-y border-gray-200 space-y-4">
|
||||
{{-- Essenzielle Cookies (immer aktiv) --}}
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<label class="font-medium text-gray-900">
|
||||
{{ __('cookie-consent::cookie-consent.categories.essential.title') }}
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-0.5">
|
||||
{{ __('cookie-consent::cookie-consent.categories.essential.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<button type="button" disabled
|
||||
class="cc-toggle-active relative inline-flex h-6 w-11 flex-shrink-0 cursor-not-allowed rounded-full opacity-60 cursor-pointer"
|
||||
role="switch" aria-checked="true"
|
||||
aria-label="{{ __('cookie-consent::cookie-consent.aria.essential_always_active') }}">
|
||||
<span
|
||||
class="translate-x-5 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out mt-0.5"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Analytics Cookies (optional, nur wenn Analytics ID oder GTM ID vorhanden) --}}
|
||||
@if ($hasTracking)
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<label for="analytics-toggle" class="font-medium text-gray-900 cursor-pointer">
|
||||
{{ __('cookie-consent::cookie-consent.categories.analytics.title') }}
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-0.5">
|
||||
{{ __('cookie-consent::cookie-consent.categories.analytics.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<button type="button" id="analytics-toggle"
|
||||
@click="tempSettings.analytics = !tempSettings.analytics"
|
||||
:class="tempSettings.analytics ? 'cc-toggle-active' : 'cc-toggle-inactive'"
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2 cursor-pointer"
|
||||
:style="tempSettings.analytics ? '--tw-ring-color: {{ $colors['primary'] }}' : ''"
|
||||
role="switch" :aria-checked="tempSettings.analytics.toString()"
|
||||
aria-label="{{ __('cookie-consent::cookie-consent.aria.toggle_analytics') }}">
|
||||
<span :class="tempSettings.analytics ? 'translate-x-5' : 'translate-x-0.5'"
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out mt-0.5"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Footer Links --}}
|
||||
<div class="px-6 py-3 bg-gray-50 border-b border-gray-200">
|
||||
<div class="flex items-center justify-center gap-4 text-xs text-gray-500">
|
||||
<a href="{{ $links['privacy'] }}" class="cc-link hover:underline transition-colors">
|
||||
{{ __('cookie-consent::cookie-consent.links.privacy') }}
|
||||
</a>
|
||||
<span class="text-gray-300">|</span>
|
||||
<a href="{{ $links['imprint'] }}" class="cc-link hover:underline transition-colors">
|
||||
{{ __('cookie-consent::cookie-consent.links.imprint') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Action Buttons --}}
|
||||
<div class="px-6 py-4 bg-white">
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<button @click="acceptAll()"
|
||||
class="cc-btn-accept flex-1 inline-flex justify-center items-center px-4 py-2.5 border border-transparent text-sm font-semibold rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-200 cursor-pointer"
|
||||
style="--tw-ring-color: {{ $colors['accept'] }}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{{ __('cookie-consent::cookie-consent.buttons.accept_all') }}
|
||||
</button>
|
||||
<button @click="saveSettings()"
|
||||
class="cc-btn-save flex-1 inline-flex justify-center items-center px-4 py-2.5 border border-transparent text-sm font-semibold rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-200 cursor-pointer"
|
||||
style="--tw-ring-color: {{ $colors['save'] }}">
|
||||
{{ __('cookie-consent::cookie-consent.buttons.save_selection') }}
|
||||
</button>
|
||||
<button @click="rejectAll()"
|
||||
class="flex-1 inline-flex justify-center items-center px-4 py-2.5 border border-gray-300 text-sm font-semibold rounded-lg text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-colors duration-200 cursor-pointer">
|
||||
{{ __('cookie-consent::cookie-consent.buttons.reject_all') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Cookie Consent Manager Script --}}
|
||||
<script>
|
||||
function cookieConsentManager(config) {
|
||||
return {
|
||||
isOpen: false,
|
||||
hasConsented: false,
|
||||
analyticsLoaded: false,
|
||||
gtmLoaded: false,
|
||||
config: config,
|
||||
|
||||
settings: {
|
||||
analytics: false,
|
||||
},
|
||||
tempSettings: {
|
||||
analytics: false,
|
||||
},
|
||||
|
||||
init() {
|
||||
const saved = this.getCookie(this.config.cookieName);
|
||||
|
||||
if (saved) {
|
||||
try {
|
||||
this.settings = JSON.parse(saved);
|
||||
this.tempSettings = {
|
||||
...this.settings
|
||||
};
|
||||
this.hasConsented = true;
|
||||
this.isOpen = false;
|
||||
|
||||
// Consent Mode aktualisieren und ggf. Analytics laden
|
||||
this.applyConsent();
|
||||
} catch (e) {
|
||||
console.error('Cookie Consent: Error parsing settings', e);
|
||||
this.isOpen = true;
|
||||
}
|
||||
} else {
|
||||
// Erstbesuch -> Modal öffnen
|
||||
this.isOpen = true;
|
||||
}
|
||||
|
||||
// Prüfen ob ein Cookie-Blocker-Plugin das Modal versteckt hat
|
||||
this.$nextTick(() => {
|
||||
this.checkVisibility();
|
||||
});
|
||||
},
|
||||
|
||||
checkVisibility() {
|
||||
if (!this.isOpen) return;
|
||||
|
||||
const wrapper = this.$root;
|
||||
if (!wrapper) return;
|
||||
|
||||
// Prüfen ob das Element oder ein Eltern-Element versteckt wurde
|
||||
const computedStyle = window.getComputedStyle(wrapper);
|
||||
const isHidden = computedStyle.display === 'none' ||
|
||||
computedStyle.visibility === 'hidden' ||
|
||||
wrapper.offsetParent === null;
|
||||
|
||||
if (isHidden) {
|
||||
console.log('Cookie Consent: Banner wurde durch externes Plugin versteckt, Scroll freigeben');
|
||||
this.isOpen = false;
|
||||
this.hasConsented = true; // Verhindert erneutes Öffnen
|
||||
}
|
||||
},
|
||||
|
||||
openModal() {
|
||||
this.tempSettings = {
|
||||
...this.settings
|
||||
};
|
||||
this.isOpen = true;
|
||||
},
|
||||
|
||||
closeIfAllowed() {
|
||||
// Modal nur schließen wenn bereits eine Entscheidung getroffen wurde
|
||||
if (this.hasConsented) {
|
||||
this.isOpen = false;
|
||||
}
|
||||
},
|
||||
|
||||
acceptAll() {
|
||||
this.settings.analytics = true;
|
||||
this.save();
|
||||
},
|
||||
|
||||
rejectAll() {
|
||||
this.settings.analytics = false;
|
||||
this.save();
|
||||
},
|
||||
|
||||
saveSettings() {
|
||||
this.settings = {
|
||||
...this.tempSettings
|
||||
};
|
||||
this.save();
|
||||
},
|
||||
|
||||
save() {
|
||||
this.setCookie(
|
||||
this.config.cookieName,
|
||||
JSON.stringify(this.settings),
|
||||
this.config.cookieLifetime
|
||||
);
|
||||
this.hasConsented = true;
|
||||
this.isOpen = false;
|
||||
this.applyConsent();
|
||||
|
||||
// Event für andere Komponenten (z.B. Privacy-Info) dispatchen
|
||||
window.dispatchEvent(new CustomEvent('cookie-consent-updated', {
|
||||
detail: this.settings
|
||||
}));
|
||||
},
|
||||
|
||||
applyConsent() {
|
||||
const analyticsStatus = this.settings.analytics ? 'granted' : 'denied';
|
||||
|
||||
// Google Consent Mode v2 Update
|
||||
if (typeof gtag === 'function') {
|
||||
gtag('consent', 'update', {
|
||||
'analytics_storage': analyticsStatus,
|
||||
'ad_storage': analyticsStatus,
|
||||
'ad_user_data': analyticsStatus,
|
||||
'ad_personalization': analyticsStatus
|
||||
});
|
||||
}
|
||||
|
||||
// Tracking laden wenn zugestimmt
|
||||
if (this.settings.analytics) {
|
||||
// GTM hat Priorität über direktes Analytics
|
||||
if (this.config.useGtm && this.config.gtmId && !this.gtmLoaded) {
|
||||
this.loadGoogleTagManager();
|
||||
} else if (!this.config.useGtm && this.config.analyticsId && !this.analyticsLoaded) {
|
||||
this.loadGoogleAnalytics();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
loadGoogleAnalytics() {
|
||||
if (this.analyticsLoaded || !this.config.analyticsId) return;
|
||||
|
||||
// gtag.js Script laden
|
||||
const script = document.createElement('script');
|
||||
script.async = true;
|
||||
script.src = `https://www.googletagmanager.com/gtag/js?id=${this.config.analyticsId}`;
|
||||
script.onload = () => {
|
||||
gtag('js', new Date());
|
||||
|
||||
const configOptions = {
|
||||
'send_page_view': true
|
||||
};
|
||||
|
||||
if (this.config.anonymizeIp) {
|
||||
configOptions['anonymize_ip'] = true;
|
||||
}
|
||||
|
||||
gtag('config', this.config.analyticsId, configOptions);
|
||||
this.analyticsLoaded = true;
|
||||
|
||||
console.log('Cookie Consent: Google Analytics loaded');
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
},
|
||||
|
||||
loadGoogleTagManager() {
|
||||
if (this.gtmLoaded || !this.config.gtmId) return;
|
||||
|
||||
// Google Tag Manager Script laden
|
||||
(function(w, d, s, l, i) {
|
||||
w[l] = w[l] || [];
|
||||
w[l].push({
|
||||
'gtm.start': new Date().getTime(),
|
||||
event: 'gtm.js'
|
||||
});
|
||||
var f = d.getElementsByTagName(s)[0],
|
||||
j = d.createElement(s),
|
||||
dl = l != 'dataLayer' ? '&l=' + l : '';
|
||||
j.async = true;
|
||||
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl;
|
||||
f.parentNode.insertBefore(j, f);
|
||||
})(window, document, 'script', 'dataLayer', this.config.gtmId);
|
||||
|
||||
this.gtmLoaded = true;
|
||||
|
||||
// Event für GTM noscript-Komponente dispatchen
|
||||
window.dispatchEvent(new CustomEvent('gtm-consent-granted', {
|
||||
detail: {
|
||||
gtmId: this.config.gtmId
|
||||
}
|
||||
}));
|
||||
|
||||
console.log('Cookie Consent: Google Tag Manager loaded');
|
||||
},
|
||||
|
||||
// Cookie Helper Functions
|
||||
setCookie(name, value, days) {
|
||||
const date = new Date();
|
||||
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
|
||||
const expires = "expires=" + date.toUTCString();
|
||||
const sameSite = "SameSite=Lax";
|
||||
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; ${expires}; path=/; ${sameSite}${secure}`;
|
||||
},
|
||||
|
||||
getCookie(name) {
|
||||
const nameEQ = name + "=";
|
||||
const ca = document.cookie.split(';');
|
||||
for (let i = 0; i < ca.length; i++) {
|
||||
let c = ca[i].trim();
|
||||
if (c.indexOf(nameEQ) === 0) {
|
||||
return decodeURIComponent(c.substring(nameEQ.length));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
deleteCookie(name) {
|
||||
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endif
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
@props([
|
||||
'analyticsId' => config('cookie-consent.analytics_id'),
|
||||
'cookieName' => config('cookie-consent.cookie_name', 'cookie_consent'),
|
||||
'cookieLifetime' => config('cookie-consent.cookie_lifetime', 365),
|
||||
'showAnalyticsInfo' => true,
|
||||
'showCurrentSettings' => true,
|
||||
'showChangeButton' => true,
|
||||
'colors' => config('cookie-consent.colors', []),
|
||||
])
|
||||
|
||||
@php
|
||||
$colors = array_merge(
|
||||
[
|
||||
'primary' => '#009bdd',
|
||||
'primary_hover' => '#0071a8',
|
||||
],
|
||||
$colors ?? [],
|
||||
);
|
||||
@endphp
|
||||
|
||||
<div x-data="cookiePrivacyInfo({ cookieName: '{{ $cookieName }}' })" x-init="init()"
|
||||
style="--cc-primary: {{ $colors['primary'] }}; --cc-primary-hover: {{ $colors['primary_hover'] }}"
|
||||
{{ $attributes->merge(['class' => 'cookie-privacy-info']) }}>
|
||||
{{-- Aktuelle Cookie-Einstellungen --}}
|
||||
@if ($showCurrentSettings)
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
|
||||
<h4 class="font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-600" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
{{ __('cookie-consent::cookie-consent.privacy_info.current_settings_title') }}
|
||||
</h4>
|
||||
|
||||
<div class="space-y-2">
|
||||
{{-- Technisch notwendig --}}
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<span class="text-gray-700">
|
||||
{{ __('cookie-consent::cookie-consent.privacy_info.essential_cookies') }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
{{ __('cookie-consent::cookie-consent.status.active') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{-- Analyse- und Marketing-Cookies (immer anzeigen) --}}
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0">
|
||||
<span class="text-gray-700">
|
||||
{{ __('cookie-consent::cookie-consent.privacy_info.analytics_marketing_cookies') }}
|
||||
</span>
|
||||
<span x-show="settings.analytics"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
{{ __('cookie-consent::cookie-consent.status.active') }}
|
||||
</span>
|
||||
<span x-show="!settings.analytics"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
{{ __('cookie-consent::cookie-consent.status.inactive') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Keine Einstellung vorhanden --}}
|
||||
<div x-show="!hasConsented" class="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||
<p class="text-sm text-yellow-800">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline mr-1" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
{{ __('cookie-consent::cookie-consent.status.no_consent') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- Button zum Ändern --}}
|
||||
@if ($showChangeButton)
|
||||
<div class="mt-4">
|
||||
<button @click="$dispatch('open-cookie-settings')" type="button"
|
||||
class="inline-flex items-center px-4 py-2 border shadow-sm text-sm font-medium rounded-md text-white focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-200"
|
||||
style="background-color: var(--cc-primary); border-color: var(--cc-primary); --tw-ring-color: var(--cc-primary)"
|
||||
onmouseover="this.style.backgroundColor='var(--cc-primary-hover)'"
|
||||
onmouseout="this.style.backgroundColor='var(--cc-primary)'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
{{ __('cookie-consent::cookie-consent.buttons.change_settings') }}
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Google Analytics Informationen --}}
|
||||
@if ($showAnalyticsInfo && $analyticsId)
|
||||
<div class="prose prose-gray max-w-none">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">
|
||||
{{ __('cookie-consent::cookie-consent.google_analytics.title') }}
|
||||
</h3>
|
||||
|
||||
<p class="text-gray-600 mb-4">
|
||||
{{ __('cookie-consent::cookie-consent.google_analytics.intro') }}
|
||||
</p>
|
||||
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
|
||||
<h4 class="font-medium text-blue-900 mb-2">
|
||||
{{ __('cookie-consent::cookie-consent.google_analytics.technical_details.title') }}
|
||||
</h4>
|
||||
<ul class="text-sm text-blue-800 space-y-1">
|
||||
<li>
|
||||
<strong>{{ __('cookie-consent::cookie-consent.google_analytics.technical_details.tracking_id') }}:</strong>
|
||||
<code class="bg-blue-100 px-1 rounded">{{ $analyticsId }}</code>
|
||||
</li>
|
||||
<li>
|
||||
<strong>{{ __('cookie-consent::cookie-consent.google_analytics.technical_details.ip_anonymization') }}:</strong>
|
||||
{{ __('cookie-consent::cookie-consent.google_analytics.technical_details.ip_anonymization_value') }}
|
||||
</li>
|
||||
<li>
|
||||
<strong>{{ __('cookie-consent::cookie-consent.google_analytics.technical_details.consent_mode') }}:</strong>
|
||||
{{ __('cookie-consent::cookie-consent.google_analytics.technical_details.consent_mode_value') }}
|
||||
</li>
|
||||
<li>
|
||||
<strong>{{ __('cookie-consent::cookie-consent.google_analytics.technical_details.cookie_duration') }}:</strong>
|
||||
{{ __('cookie-consent::cookie-consent.google_analytics.technical_details.cookie_duration_value', ['days' => $cookieLifetime]) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h4 class="font-medium text-gray-900 mt-4 mb-2">
|
||||
{{ __('cookie-consent::cookie-consent.google_analytics.purpose.title') }}
|
||||
</h4>
|
||||
<p class="text-gray-600 mb-4">
|
||||
{{ __('cookie-consent::cookie-consent.google_analytics.purpose.text') }}
|
||||
</p>
|
||||
|
||||
<h4 class="font-medium text-gray-900 mt-4 mb-2">
|
||||
{{ __('cookie-consent::cookie-consent.google_analytics.ip_anonymization.title') }}
|
||||
</h4>
|
||||
<p class="text-gray-600 mb-4">
|
||||
{{ __('cookie-consent::cookie-consent.google_analytics.ip_anonymization.text') }}
|
||||
</p>
|
||||
|
||||
<h4 class="font-medium text-gray-900 mt-4 mb-2">
|
||||
{{ __('cookie-consent::cookie-consent.google_analytics.legal_basis.title') }}
|
||||
</h4>
|
||||
<p class="text-gray-600 mb-4">
|
||||
{{ __('cookie-consent::cookie-consent.google_analytics.legal_basis.text') }}
|
||||
</p>
|
||||
|
||||
<h4 class="font-medium text-gray-900 mt-4 mb-2">
|
||||
{{ __('cookie-consent::cookie-consent.google_analytics.cookies.title') }}
|
||||
</h4>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 border border-gray-200 rounded-lg overflow-hidden">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{ __('cookie-consent::cookie-consent.google_analytics.cookies.table.name') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{ __('cookie-consent::cookie-consent.google_analytics.cookies.table.purpose') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{ __('cookie-consent::cookie-consent.google_analytics.cookies.table.duration') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@foreach (__('cookie-consent::cookie-consent.google_analytics.cookies.list') as $cookieName => $cookie)
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-sm font-mono text-gray-900">{{ $cookieName }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">{{ $cookie['purpose'] }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">{{ $cookie['duration'] }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h4 class="font-medium text-gray-900 mt-6 mb-2">
|
||||
{{ __('cookie-consent::cookie-consent.google_analytics.objection.title') }}
|
||||
</h4>
|
||||
<p class="text-gray-600 mb-4">
|
||||
{{ __('cookie-consent::cookie-consent.google_analytics.objection.text') }}
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-gray-600 mb-4 space-y-1">
|
||||
<li>{{ __('cookie-consent::cookie-consent.google_analytics.objection.options.revoke') }}</li>
|
||||
<li>
|
||||
{{ __('cookie-consent::cookie-consent.google_analytics.objection.options.addon') }}
|
||||
<a href="https://tools.google.com/dlpage/gaoptout" target="_blank" rel="noopener noreferrer"
|
||||
class="text-blue-600 hover:text-blue-800 underline">
|
||||
tools.google.com/dlpage/gaoptout
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h4 class="font-medium text-gray-900 mt-4 mb-2">
|
||||
{{ __('cookie-consent::cookie-consent.google_analytics.more_info.title') }}
|
||||
</h4>
|
||||
<p class="text-gray-600">
|
||||
{{ __('cookie-consent::cookie-consent.google_analytics.more_info.text') }}
|
||||
<a href="https://policies.google.com/privacy" target="_blank" rel="noopener noreferrer"
|
||||
class="text-blue-600 hover:text-blue-800 underline">
|
||||
policies.google.com/privacy
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function cookiePrivacyInfo(config) {
|
||||
return {
|
||||
hasConsented: false,
|
||||
settings: {
|
||||
analytics: false,
|
||||
},
|
||||
config: config,
|
||||
|
||||
init() {
|
||||
this.loadSettings();
|
||||
|
||||
// Auf Änderungen lauschen (wenn Modal geschlossen wird)
|
||||
window.addEventListener('cookie-consent-updated', () => {
|
||||
this.loadSettings();
|
||||
});
|
||||
},
|
||||
|
||||
loadSettings() {
|
||||
const saved = this.getCookie(this.config.cookieName);
|
||||
|
||||
if (saved) {
|
||||
try {
|
||||
this.settings = JSON.parse(saved);
|
||||
this.hasConsented = true;
|
||||
} catch (e) {
|
||||
console.error('Cookie Privacy Info: Error parsing settings', e);
|
||||
}
|
||||
} else {
|
||||
this.hasConsented = false;
|
||||
this.settings = {
|
||||
analytics: false
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
getCookie(name) {
|
||||
const nameEQ = name + "=";
|
||||
const ca = document.cookie.split(';');
|
||||
for (let i = 0; i < ca.length; i++) {
|
||||
let c = ca[i].trim();
|
||||
if (c.indexOf(nameEQ) === 0) {
|
||||
return decodeURIComponent(c.substring(nameEQ.length));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\CookieConsent;
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class CookieConsentServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->mergeConfigFrom(
|
||||
__DIR__.'/../config/cookie-consent.php',
|
||||
'cookie-consent'
|
||||
);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
// Views laden
|
||||
$this->loadViewsFrom(__DIR__.'/../resources/views', 'cookie-consent');
|
||||
|
||||
// Anonyme Blade-Komponenten registrieren (ermöglicht <x-cookie-consent::manager />)
|
||||
Blade::anonymousComponentPath(__DIR__.'/../resources/views/components', 'cookie-consent');
|
||||
|
||||
// Übersetzungen laden
|
||||
$this->loadTranslationsFrom(__DIR__.'/../lang', 'cookie-consent');
|
||||
|
||||
if ($this->app->runningInConsole()) {
|
||||
// Config veröffentlichen
|
||||
$this->publishes([
|
||||
__DIR__.'/../config/cookie-consent.php' => config_path('cookie-consent.php'),
|
||||
], 'cookie-consent-config');
|
||||
|
||||
// Views veröffentlichen
|
||||
$this->publishes([
|
||||
__DIR__.'/../resources/views' => resource_path('views/vendor/cookie-consent'),
|
||||
], 'cookie-consent-views');
|
||||
|
||||
// Übersetzungen veröffentlichen
|
||||
$this->publishes([
|
||||
__DIR__.'/../lang' => lang_path('vendor/cookie-consent'),
|
||||
], 'cookie-consent-lang');
|
||||
}
|
||||
}
|
||||
}
|
||||
303
packages/acme/contact-form/README.md
Normal file
303
packages/acme/contact-form/README.md
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
# Acme Contact Form
|
||||
|
||||
Ein wiederverwendbares, spam-geschütztes Kontaktformular-Package für Laravel mit Livewire. Bietet Honeypot, zeitbasierte Prüfung, Inhaltsanalyse, Rate Limiting und mehr – ohne externe Dienste, DSGVO-konform.
|
||||
|
||||
## Features
|
||||
|
||||
- **Spam-Schutz**: Honeypot, Zeitprüfung, Inhaltsanalyse, Wegwerf-E-Mails, Rate Limiting, IP-Blacklist, Bot-User-Agent-Erkennung
|
||||
- **Konfigurierbare Felder**: Presets (simple, full) oder eigene Felddefinitionen
|
||||
- **Livewire**: Reaktives Formular ohne Page-Reload
|
||||
- **Flexibel**: Einfache Formulare bis komplexe Multi-Feld-Formulare
|
||||
- **DSGVO-konform**: Keine externen Services, alles in-house
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- PHP 8.2+
|
||||
- Laravel 10+ / 11+ / 12+
|
||||
- Livewire 3+
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Package installieren
|
||||
|
||||
```bash
|
||||
composer require acme/contact-form
|
||||
```
|
||||
|
||||
Für lokale Entwicklung (Path-Repository):
|
||||
|
||||
```json
|
||||
{
|
||||
"repositories": [
|
||||
{
|
||||
"type": "path",
|
||||
"url": "package/acme/contact-form"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"acme/contact-form": "@dev"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
composer update acme/contact-form
|
||||
```
|
||||
|
||||
### 2. Konfiguration
|
||||
|
||||
```bash
|
||||
php artisan vendor:publish --tag=contact-form-config
|
||||
```
|
||||
|
||||
In `.env`:
|
||||
|
||||
```env
|
||||
CONTACT_FORM_RECIPIENT=kontakt@ihre-domain.de
|
||||
CONTACT_FORM_MAX_ATTEMPTS=3
|
||||
CONTACT_FORM_DECAY_MINUTES=15
|
||||
CONTACT_FORM_MIN_FILL_TIME=3
|
||||
```
|
||||
|
||||
### 3. Übersetzungen (optional)
|
||||
|
||||
```bash
|
||||
php artisan vendor:publish --tag=contact-form-lang
|
||||
```
|
||||
|
||||
## Verwendung
|
||||
|
||||
### Einfaches Formular (Preset: simple)
|
||||
|
||||
Name, E-Mail, Nachricht, Datenschutz-Checkbox:
|
||||
|
||||
```blade
|
||||
<livewire:contact-form />
|
||||
```
|
||||
|
||||
Oder mit explizitem Preset:
|
||||
|
||||
```blade
|
||||
<livewire:contact-form preset="simple" subject="Kontaktanfrage" />
|
||||
```
|
||||
|
||||
### Vollständiges Formular (Preset: full)
|
||||
|
||||
Vorname, Nachname, E-Mail, Telefon, Unternehmen, Nachricht, Datenschutz:
|
||||
|
||||
```blade
|
||||
<livewire:contact-form preset="full" subject="Neue Kontaktanfrage" />
|
||||
```
|
||||
|
||||
### Eigenes Formular mit benutzerdefinierten Feldern
|
||||
|
||||
Sie können beliebige Felder definieren. Jedes Feld benötigt:
|
||||
|
||||
- `type`: `text`, `email`, `tel`, `textarea`, `select`, `checkbox`, `honeypot`
|
||||
- `rules`: Laravel-Validierungsregeln (Array)
|
||||
- `label`: Anzeigename (optional)
|
||||
- `placeholder`: Platzhalter (optional)
|
||||
- `required`: true/false (optional, wird aus rules abgeleitet)
|
||||
- `name`: Formularfeld-Name (optional, Standard: Array-Key)
|
||||
- `options`: Für `select` – Array [value => label] (optional)
|
||||
|
||||
**Beispiel: Minimales Formular (nur E-Mail + Nachricht)**
|
||||
|
||||
```blade
|
||||
<livewire:contact-form
|
||||
:fields="[
|
||||
'email' => [
|
||||
'type' => 'email',
|
||||
'rules' => ['required', 'email:rfc,dns', 'max:150'],
|
||||
'label' => 'Ihre E-Mail',
|
||||
'placeholder' => 'name@beispiel.de',
|
||||
],
|
||||
'message' => [
|
||||
'type' => 'textarea',
|
||||
'rules' => ['required', 'string', 'max:2000'],
|
||||
'label' => 'Nachricht',
|
||||
],
|
||||
'privacy' => [
|
||||
'type' => 'checkbox',
|
||||
'rules' => ['accepted'],
|
||||
'label' => 'Ich stimme der Datenschutzerklärung zu.',
|
||||
],
|
||||
'honeypot' => [
|
||||
'type' => 'honeypot',
|
||||
'name' => 'website',
|
||||
'rules' => ['nullable', 'string', 'max:0'],
|
||||
],
|
||||
]"
|
||||
subject="Feedback"
|
||||
/>
|
||||
```
|
||||
|
||||
**Beispiel: Formular mit Select-Feld**
|
||||
|
||||
```blade
|
||||
<livewire:contact-form
|
||||
:fields="[
|
||||
'name' => [
|
||||
'type' => 'text',
|
||||
'rules' => ['required', 'string', 'max:120'],
|
||||
'label' => 'Name',
|
||||
],
|
||||
'email' => [
|
||||
'type' => 'email',
|
||||
'rules' => ['required', 'email:rfc,dns'],
|
||||
'label' => 'E-Mail',
|
||||
],
|
||||
'topic' => [
|
||||
'type' => 'select',
|
||||
'rules' => ['required', 'string', 'in:general,support,sales'],
|
||||
'label' => 'Betreff',
|
||||
'placeholder' => 'Bitte wählen...',
|
||||
'options' => [
|
||||
'general' => 'Allgemeine Anfrage',
|
||||
'support' => 'Technischer Support',
|
||||
'sales' => 'Verkauf',
|
||||
],
|
||||
],
|
||||
'message' => [
|
||||
'type' => 'textarea',
|
||||
'rules' => ['required', 'string', 'max:2000'],
|
||||
'label' => 'Nachricht',
|
||||
],
|
||||
'privacy' => [
|
||||
'type' => 'checkbox',
|
||||
'rules' => ['accepted'],
|
||||
'label' => 'Datenschutz akzeptiert',
|
||||
],
|
||||
'honeypot' => [
|
||||
'type' => 'honeypot',
|
||||
'name' => 'company_check',
|
||||
'rules' => ['nullable', 'string', 'max:0'],
|
||||
],
|
||||
]"
|
||||
subject="Anfrage über Kontaktformular"
|
||||
/>
|
||||
```
|
||||
|
||||
**Wichtig**: Jedes Formular sollte ein Honeypot-Feld enthalten. Das Feld wird für Nutzer unsichtbar dargestellt; Bots füllen es oft aus und werden so erkannt.
|
||||
|
||||
### Eigene Presets in der Config
|
||||
|
||||
In `config/contact-form.php` können Sie eigene Presets definieren:
|
||||
|
||||
```php
|
||||
'presets' => [
|
||||
'mein-formular' => [
|
||||
'name' => [
|
||||
'type' => 'text',
|
||||
'rules' => ['required', 'string', 'max:120'],
|
||||
'label' => 'Name',
|
||||
],
|
||||
'email' => [
|
||||
'type' => 'email',
|
||||
'rules' => ['required', 'email:rfc,dns'],
|
||||
'label' => 'E-Mail',
|
||||
],
|
||||
'message' => [
|
||||
'type' => 'textarea',
|
||||
'rules' => ['required', 'string', 'max:2000'],
|
||||
'label' => 'Nachricht',
|
||||
],
|
||||
'privacy' => [
|
||||
'type' => 'checkbox',
|
||||
'rules' => ['accepted'],
|
||||
'label' => 'Datenschutz',
|
||||
],
|
||||
'honeypot' => [
|
||||
'type' => 'honeypot',
|
||||
'name' => 'website',
|
||||
'rules' => ['nullable', 'string', 'max:0'],
|
||||
],
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
Verwendung:
|
||||
|
||||
```blade
|
||||
<livewire:contact-form preset="mein-formular" />
|
||||
```
|
||||
|
||||
## Spam-Schutz im Detail
|
||||
|
||||
| Maßnahme | Beschreibung |
|
||||
|----------|--------------|
|
||||
| **Honeypot** | Verstecktes Feld – wenn ausgefüllt → Spam |
|
||||
| **Zeitprüfung** | Formular < 3 Sek. ausgefüllt → Spam |
|
||||
| **Inhaltsanalyse** | Spam-Keywords, URLs, XSS-Muster → Spam |
|
||||
| **Wegwerf-E-Mails** | tempmail.com, mailinator.com etc. → Spam |
|
||||
| **Rate Limiting** | Max. 3 Anfragen/IP in 15 Min. (konfigurierbar) |
|
||||
| **IP-Blacklist** | Blockierte IPs in Config |
|
||||
| **Bot-User-Agent** | curl, wget, python-requests etc. → Spam |
|
||||
|
||||
Bei Spam wird **immer** eine Erfolgsmeldung angezeigt (Täuschung von Bots), aber **keine E-Mail** versendet. Alle Anfragen werden geloggt.
|
||||
|
||||
## Service und SpamDetector direkt nutzen
|
||||
|
||||
Falls Sie einen eigenen Controller oder eine eigene Livewire-Komponente verwenden möchten:
|
||||
|
||||
```php
|
||||
use Acme\ContactForm\ContactFormService;
|
||||
use Acme\ContactForm\SpamDetector;
|
||||
|
||||
// Spam prüfen
|
||||
$spamDetector = SpamDetector::fromConfig();
|
||||
$isSpam = $spamDetector->detect($validatedData, $formLoadedAt);
|
||||
|
||||
// Anfrage verarbeiten
|
||||
$service = app(ContactFormService::class);
|
||||
$service->handle([
|
||||
'name' => $request->input('name'),
|
||||
'email' => $request->input('email'),
|
||||
'message' => $request->input('message'),
|
||||
'ip' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'is_spam' => $isSpam,
|
||||
], 'Betreff der E-Mail', 'Log-Kontext');
|
||||
```
|
||||
|
||||
## Konfiguration
|
||||
|
||||
| Option | Beschreibung | Standard |
|
||||
|--------|--------------|----------|
|
||||
| `recipient` | E-Mail-Empfänger | aus .env |
|
||||
| `rate_limit.max_attempts` | Max. Anfragen pro IP | 3 |
|
||||
| `rate_limit.decay_minutes` | Zeitfenster in Minuten | 15 |
|
||||
| `blacklisted_ips` | IP-Adressen blockieren | [] |
|
||||
| `honeypot_fields` | Namen der Honeypot-Felder | company_check, website, url |
|
||||
| `spam.min_fill_time_seconds` | Min. Zeit zum Ausfüllen | 3 |
|
||||
| `spam.suspicious_patterns` | Regex für Spam-Erkennung | siehe Config |
|
||||
| `spam.disposable_email_domains` | Wegwerf-Domains | siehe Config |
|
||||
|
||||
## Styling anpassen
|
||||
|
||||
Die Standard-Views nutzen generische Klassen (`border-gray-300`, `focus:ring-primary`). Passen Sie sie an Ihr Design an:
|
||||
|
||||
```bash
|
||||
php artisan vendor:publish --tag=contact-form-views
|
||||
```
|
||||
|
||||
Die Views liegen dann unter `resources/views/vendor/contact-form/`.
|
||||
|
||||
## Event nach Absenden
|
||||
|
||||
Nach erfolgreichem Absenden wird das Event `contact-form-submitted` dispatched. Sie können darauf reagieren:
|
||||
|
||||
```blade
|
||||
<div x-data="{ submitted: false }"
|
||||
x-on:contact-form-submitted.window="submitted = true">
|
||||
<livewire:contact-form />
|
||||
|
||||
<template x-if="submitted">
|
||||
<p>Vielen Dank für Ihre Nachricht!</p>
|
||||
</template>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Lizenz
|
||||
|
||||
MIT License
|
||||
38
packages/acme/contact-form/composer.json
Normal file
38
packages/acme/contact-form/composer.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"name": "acme/contact-form",
|
||||
"description": "Ein wiederverwendbares, spam-geschütztes Kontaktformular-Package für Laravel mit Livewire",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"livewire",
|
||||
"contact-form",
|
||||
"spam-protection",
|
||||
"honeypot"
|
||||
],
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Acme\\ContactForm\\": "src/"
|
||||
}
|
||||
},
|
||||
"authors": [
|
||||
{
|
||||
"name": "Acme",
|
||||
"email": "info@acme.de"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"illuminate/support": "^10.0|^11.0|^12.0",
|
||||
"livewire/livewire": "^3.0|^4.0"
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Acme\\ContactForm\\ContactFormServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
126
packages/acme/contact-form/config/contact-form.php
Normal file
126
packages/acme/contact-form/config/contact-form.php
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Email Recipient
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The email address that contact form submissions will be sent to.
|
||||
|
|
||||
*/
|
||||
'recipient' => env('CONTACT_FORM_RECIPIENT', 'contact@example.com'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Rate Limiting
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Maximum number of submissions per IP within the given time window.
|
||||
|
|
||||
*/
|
||||
'rate_limit' => [
|
||||
'max_attempts' => env('CONTACT_FORM_MAX_ATTEMPTS', 3),
|
||||
'decay_minutes' => env('CONTACT_FORM_DECAY_MINUTES', 15),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| IP Blacklist
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| IP addresses that will always be blocked as spam.
|
||||
|
|
||||
*/
|
||||
'blacklisted_ips' => [],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Spam Detection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration for the built-in spam detection.
|
||||
|
|
||||
*/
|
||||
'honeypot_fields' => ['company_check', 'website', 'url', 'website_url'],
|
||||
|
||||
'spam' => [
|
||||
'min_fill_time_seconds' => env('CONTACT_FORM_MIN_FILL_TIME', 3),
|
||||
'suspicious_patterns' => [
|
||||
'/\b(viagra|cialis|casino|poker|lottery|bitcoin|crypto)\b/i',
|
||||
'/\b[A-Z]{10,}\b/',
|
||||
'/<script|<iframe|javascript:/i',
|
||||
],
|
||||
'max_links_in_message' => 2,
|
||||
'max_repeated_chars' => 10,
|
||||
'disposable_email_domains' => [
|
||||
'tempmail.com',
|
||||
'guerrillamail.com',
|
||||
'10minutemail.com',
|
||||
'mailinator.com',
|
||||
'throwaway.email',
|
||||
'yopmail.com',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Form Presets
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Predefined field configurations for different form types.
|
||||
| See README for customization and defining your own forms.
|
||||
|
|
||||
*/
|
||||
'presets' => [
|
||||
'simple' => [
|
||||
'name' => ['type' => 'text', 'rules' => ['required', 'string', 'max:120'], 'label' => 'Name', 'required' => true],
|
||||
'email' => ['type' => 'email', 'rules' => ['required', 'email:rfc,dns', 'max:150'], 'label' => 'Email', 'required' => true],
|
||||
'message' => ['type' => 'textarea', 'rules' => ['required', 'string', 'max:2000'], 'label' => 'Message', 'required' => true],
|
||||
'privacy' => ['type' => 'checkbox', 'rules' => ['accepted'], 'label' => 'Privacy Policy', 'required' => true],
|
||||
'honeypot' => ['type' => 'honeypot', 'name' => 'website', 'rules' => ['nullable', 'string', 'max:0']],
|
||||
],
|
||||
'full' => [
|
||||
'first_name' => ['type' => 'text', 'rules' => ['required', 'string', 'max:100'], 'label' => 'First Name', 'required' => true],
|
||||
'last_name' => ['type' => 'text', 'rules' => ['required', 'string', 'max:100'], 'label' => 'Last Name', 'required' => true],
|
||||
'email' => ['type' => 'email', 'rules' => ['required', 'email:rfc,dns', 'max:150'], 'label' => 'Email', 'required' => true],
|
||||
'phone' => ['type' => 'tel', 'rules' => ['nullable', 'string', 'max:80'], 'label' => 'Phone', 'required' => false],
|
||||
'company' => ['type' => 'text', 'rules' => ['required', 'string', 'max:150'], 'label' => 'Company', 'required' => true],
|
||||
'message' => ['type' => 'textarea', 'rules' => ['required', 'string', 'max:2000'], 'label' => 'Message', 'required' => true],
|
||||
'privacy' => ['type' => 'checkbox', 'rules' => ['accepted'], 'label' => 'Privacy Policy', 'required' => true],
|
||||
'honeypot' => ['type' => 'honeypot', 'name' => 'company_check', 'rules' => ['nullable', 'string', 'max:0']],
|
||||
],
|
||||
'business' => [
|
||||
'first_name' => ['type' => 'text', 'rules' => ['required', 'string', 'max:100'], 'label' => 'First Name', 'required' => true],
|
||||
'last_name' => ['type' => 'text', 'rules' => ['required', 'string', 'max:100'], 'label' => 'Last Name', 'required' => true],
|
||||
'email' => ['type' => 'email', 'rules' => ['required', 'email:rfc,dns', 'max:150'], 'label' => 'Email', 'required' => true],
|
||||
'phone' => ['type' => 'tel', 'rules' => ['nullable', 'string', 'max:80'], 'label' => 'Phone', 'required' => false],
|
||||
'company' => ['type' => 'text', 'rules' => ['required', 'string', 'max:150'], 'label' => 'Company', 'required' => true],
|
||||
'project_type' => [
|
||||
'type' => 'select',
|
||||
'rules' => ['nullable', 'string', 'max:150'],
|
||||
'label' => 'Project Type',
|
||||
'options' => [
|
||||
'consulting' => 'Consulting',
|
||||
'implementation' => 'Implementation',
|
||||
'support' => 'Support',
|
||||
],
|
||||
],
|
||||
'message' => ['type' => 'textarea', 'rules' => ['required', 'string', 'max:2000'], 'label' => 'Message', 'required' => true],
|
||||
'timeline' => [
|
||||
'type' => 'select',
|
||||
'rules' => ['nullable', 'string', 'max:150'],
|
||||
'label' => 'Timeline',
|
||||
'options' => [
|
||||
'asap' => 'As soon as possible',
|
||||
'3months' => 'Within 3 months',
|
||||
'6months' => 'Within 6 months',
|
||||
],
|
||||
],
|
||||
'privacy' => ['type' => 'checkbox', 'rules' => ['accepted'], 'label' => 'Privacy Policy', 'required' => true],
|
||||
'honeypot' => ['type' => 'honeypot', 'name' => 'company_check', 'rules' => ['nullable', 'string', 'max:0']],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
9
packages/acme/contact-form/lang/de/contact-form.php
Normal file
9
packages/acme/contact-form/lang/de/contact-form.php
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'default_subject' => 'Neue Kontaktanfrage',
|
||||
'success_message' => 'Vielen Dank! Ihre Nachricht wurde erfolgreich gesendet.',
|
||||
'submit' => 'Absenden',
|
||||
'sending' => 'Wird gesendet...',
|
||||
'select_placeholder' => 'Bitte wählen...',
|
||||
];
|
||||
9
packages/acme/contact-form/lang/en/contact-form.php
Normal file
9
packages/acme/contact-form/lang/en/contact-form.php
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'default_subject' => 'New Contact Request',
|
||||
'success_message' => 'Thank you! Your message has been sent successfully.',
|
||||
'submit' => 'Submit',
|
||||
'sending' => 'Sending...',
|
||||
'select_placeholder' => 'Please select...',
|
||||
];
|
||||
92
packages/acme/contact-form/resources/views/form.blade.php
Normal file
92
packages/acme/contact-form/resources/views/form.blade.php
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<div>
|
||||
<form wire:submit="submit" class="space-y-6">
|
||||
@foreach ($fields as $key => $field)
|
||||
@php
|
||||
$name = $field['name'] ?? $key;
|
||||
$label = $field['label'] ?? ucfirst(str_replace('_', ' ', $name));
|
||||
$required = $field['required'] ?? (in_array('required', $field['rules'] ?? []));
|
||||
$placeholder = $field['placeholder'] ?? '';
|
||||
@endphp
|
||||
|
||||
@if ($field['type'] === 'honeypot')
|
||||
<div class="opacity-0 absolute top-0 left-0 h-0 w-0 -z-10 overflow-hidden" aria-hidden="true">
|
||||
<label for="{{ $name }}">{{ $label }}</label>
|
||||
<input type="text" id="{{ $name }}" name="{{ $name }}"
|
||||
wire:model="formData.{{ $name }}"
|
||||
tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
@elseif ($field['type'] === 'textarea')
|
||||
<div>
|
||||
<label for="{{ $name }}" class="block text-sm font-medium mb-2">
|
||||
{{ $label }}@if ($required) <span aria-hidden="true">*</span>@endif
|
||||
</label>
|
||||
<textarea id="{{ $name }}" name="{{ $name }}" rows="5"
|
||||
wire:model="formData.{{ $name }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="{{ $placeholder }}"
|
||||
@if ($required) required @endif></textarea>
|
||||
@error("formData.{$name}")
|
||||
<p class="text-sm text-red-600 mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
@elseif ($field['type'] === 'checkbox')
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" id="{{ $name }}" name="{{ $name }}"
|
||||
wire:model="formData.{{ $name }}"
|
||||
class="rounded border-gray-300"
|
||||
@if ($required) required @endif>
|
||||
<label for="{{ $name }}" class="text-sm">{{ $label }}</label>
|
||||
</div>
|
||||
@error("formData.{$name}")
|
||||
<p class="text-sm text-red-600 mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
@elseif ($field['type'] === 'select')
|
||||
<div>
|
||||
<label for="{{ $name }}" class="block text-sm font-medium mb-2">
|
||||
{{ $label }}@if ($required) <span aria-hidden="true">*</span>@endif
|
||||
</label>
|
||||
<select id="{{ $name }}" name="{{ $name }}"
|
||||
wire:model="formData.{{ $name }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@if ($required) required @endif>
|
||||
<option value="">{{ $placeholder ?: __('contact-form::contact-form.select_placeholder') }}</option>
|
||||
@foreach ($field['options'] ?? [] as $value => $optionLabel)
|
||||
<option value="{{ $value }}">{{ $optionLabel }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error("formData.{$name}")
|
||||
<p class="text-sm text-red-600 mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
@else
|
||||
<div>
|
||||
<label for="{{ $name }}" class="block text-sm font-medium mb-2">
|
||||
{{ $label }}@if ($required) <span aria-hidden="true">*</span>@endif
|
||||
</label>
|
||||
<input type="{{ $field['type'] ?? 'text' }}" id="{{ $name }}" name="{{ $name }}"
|
||||
wire:model="formData.{{ $name }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="{{ $placeholder }}"
|
||||
@if ($required) required @endif>
|
||||
@error("formData.{$name}")
|
||||
<p class="text-sm text-red-600 mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@if ($success)
|
||||
<div class="p-4 rounded-lg border border-green-200 bg-green-50 text-green-800 text-sm">
|
||||
{{ __('contact-form::contact-form.success_message') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<button type="submit" wire:loading.attr="disabled"
|
||||
class="w-full inline-flex items-center justify-center px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:opacity-70 disabled:cursor-not-allowed">
|
||||
<span wire:loading.remove>{{ __('contact-form::contact-form.submit') }}</span>
|
||||
<span wire:loading>{{ __('contact-form::contact-form.sending') }}</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
135
packages/acme/contact-form/src/ContactFormService.php
Normal file
135
packages/acme/contact-form/src/ContactFormService.php
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\ContactForm;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class ContactFormService
|
||||
{
|
||||
public function __construct(
|
||||
protected array $config = []
|
||||
) {
|
||||
$this->config = array_merge(
|
||||
config('contact-form', []),
|
||||
$config
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a contact form submission.
|
||||
*/
|
||||
public function handle(array $payload, string $subject = '', string $logContext = 'contact-form'): void
|
||||
{
|
||||
$isSpam = $payload['is_spam'] ?? false;
|
||||
|
||||
if (! $isSpam) {
|
||||
$isSpam = $this->performServerSideSpamCheck($payload);
|
||||
$payload['is_spam'] = $isSpam;
|
||||
}
|
||||
|
||||
if ($isSpam) {
|
||||
Log::info("SPAM detected - {$logContext}", $payload);
|
||||
Log::info("Email NOT sent (spam detected - {$logContext})", [
|
||||
'ip' => $payload['ip'] ?? 'unknown',
|
||||
'email' => $payload['email'] ?? 'unknown',
|
||||
]);
|
||||
} else {
|
||||
Log::info("{$logContext} received", $payload);
|
||||
$this->notify($subject ?: __('contact-form::contact-form.default_subject'), $payload);
|
||||
}
|
||||
}
|
||||
|
||||
protected function notify(string $subject, array $payload): void
|
||||
{
|
||||
$recipient = $this->config['recipient'] ?? null;
|
||||
|
||||
if (empty($recipient)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$body = $this->formatMailBody($subject, $payload);
|
||||
|
||||
try {
|
||||
Mail::raw($body, function ($message) use ($recipient, $subject): void {
|
||||
$message->to($recipient)->subject($subject);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Contact form email could not be sent', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function formatMailBody(string $subject, array $payload): string
|
||||
{
|
||||
$lines = [$subject, str_repeat('-', 40)];
|
||||
|
||||
foreach ($payload as $key => $value) {
|
||||
if ($key === 'is_spam') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = is_array($value) ? json_encode($value, JSON_UNESCAPED_UNICODE) : (string) $value;
|
||||
$label = ucfirst(str_replace('_', ' ', $key));
|
||||
$lines[] = "{$label}: {$value}";
|
||||
}
|
||||
|
||||
return implode(PHP_EOL, $lines);
|
||||
}
|
||||
|
||||
protected function performServerSideSpamCheck(array $payload): bool
|
||||
{
|
||||
$ip = $payload['ip'] ?? 'unknown';
|
||||
$config = $this->config;
|
||||
|
||||
$maxAttempts = $config['rate_limit']['max_attempts'] ?? 3;
|
||||
$decayMinutes = $config['rate_limit']['decay_minutes'] ?? 15;
|
||||
|
||||
$cacheKey = 'contact_form_'.md5($ip);
|
||||
$attempts = cache()->get($cacheKey, 0);
|
||||
|
||||
if ($attempts >= $maxAttempts) {
|
||||
Log::warning('Contact form rate limit exceeded', [
|
||||
'ip' => $ip,
|
||||
'attempts' => $attempts,
|
||||
'max_attempts' => $maxAttempts,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
cache()->put($cacheKey, $attempts + 1, now()->addMinutes($decayMinutes));
|
||||
|
||||
$blacklistedIPs = $config['blacklisted_ips'] ?? [];
|
||||
if (in_array($ip, $blacklistedIPs)) {
|
||||
Log::warning('Blacklisted IP detected', ['ip' => $ip]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$userAgent = $payload['user_agent'] ?? '';
|
||||
$botPatterns = [
|
||||
'/bot/i',
|
||||
'/crawler/i',
|
||||
'/spider/i',
|
||||
'/scraper/i',
|
||||
'/curl/i',
|
||||
'/wget/i',
|
||||
'/python-requests/i',
|
||||
];
|
||||
|
||||
foreach ($botPatterns as $pattern) {
|
||||
if (preg_match($pattern, $userAgent)) {
|
||||
Log::warning('Bot user-agent detected', [
|
||||
'ip' => $ip,
|
||||
'user_agent' => $userAgent,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\ContactForm;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Livewire\Livewire;
|
||||
|
||||
class ContactFormServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->mergeConfigFrom(
|
||||
__DIR__.'/../config/contact-form.php',
|
||||
'contact-form'
|
||||
);
|
||||
|
||||
$this->app->singleton(ContactFormService::class, function (): ContactFormService {
|
||||
return new ContactFormService;
|
||||
});
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->loadViewsFrom(__DIR__.'/../resources/views', 'contact-form');
|
||||
|
||||
$this->loadTranslationsFrom(__DIR__.'/../lang', 'contact-form');
|
||||
|
||||
Livewire::component('contact-form', \Acme\ContactForm\Livewire\ContactForm::class);
|
||||
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->publishes([
|
||||
__DIR__.'/../config/contact-form.php' => config_path('contact-form.php'),
|
||||
], 'contact-form-config');
|
||||
|
||||
$this->publishes([
|
||||
__DIR__.'/../resources/views' => resource_path('views/vendor/contact-form'),
|
||||
], 'contact-form-views');
|
||||
|
||||
$this->publishes([
|
||||
__DIR__.'/../lang' => lang_path('vendor/contact-form'),
|
||||
], 'contact-form-lang');
|
||||
}
|
||||
}
|
||||
}
|
||||
109
packages/acme/contact-form/src/Livewire/ContactForm.php
Normal file
109
packages/acme/contact-form/src/Livewire/ContactForm.php
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\ContactForm\Livewire;
|
||||
|
||||
use Acme\ContactForm\ContactFormService;
|
||||
use Acme\ContactForm\SpamDetector;
|
||||
use Illuminate\View\View;
|
||||
use Livewire\Component;
|
||||
|
||||
class ContactForm extends Component
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $formData = [];
|
||||
|
||||
public ?int $formLoadedAt = null;
|
||||
|
||||
public bool $success = false;
|
||||
|
||||
public string $preset = 'simple';
|
||||
|
||||
public string $subject = '';
|
||||
|
||||
public string $logContext = 'contact-form';
|
||||
|
||||
/**
|
||||
* @var array<string, array{type: string, rules: array, label?: string, placeholder?: string, required?: bool, name?: string, options?: array}>
|
||||
*/
|
||||
public array $customFields = [];
|
||||
|
||||
public function mount(
|
||||
string $preset = 'simple',
|
||||
string $subject = '',
|
||||
array $fields = []
|
||||
): void {
|
||||
$this->preset = $preset;
|
||||
$this->subject = $subject;
|
||||
$this->customFields = $fields;
|
||||
$this->formLoadedAt = time();
|
||||
$this->initializeFormData();
|
||||
}
|
||||
|
||||
protected function getFields(): array
|
||||
{
|
||||
if (! empty($this->customFields)) {
|
||||
return $this->customFields;
|
||||
}
|
||||
|
||||
$presets = config('contact-form.presets', []);
|
||||
|
||||
return $presets[$this->preset] ?? $presets['simple'];
|
||||
}
|
||||
|
||||
protected function initializeFormData(): void
|
||||
{
|
||||
foreach ($this->getFields() as $key => $field) {
|
||||
$name = $field['name'] ?? $key;
|
||||
$this->formData[$name] = match ($field['type']) {
|
||||
'checkbox' => false,
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
protected function getRules(): array
|
||||
{
|
||||
$rules = [];
|
||||
|
||||
foreach ($this->getFields() as $key => $field) {
|
||||
$name = $field['name'] ?? $key;
|
||||
$rules["formData.{$name}"] = $field['rules'] ?? ['nullable'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public function submit(ContactFormService $service): void
|
||||
{
|
||||
$validated = $this->validate($this->getRules());
|
||||
|
||||
$flattened = $validated['formData'] ?? [];
|
||||
$spamDetector = SpamDetector::fromConfig();
|
||||
$isSpam = $spamDetector->detect($flattened, $this->formLoadedAt);
|
||||
|
||||
$payload = array_merge($flattened, [
|
||||
'ip' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
'is_spam' => $isSpam,
|
||||
]);
|
||||
|
||||
$service->handle($payload, $this->subject, $this->logContext);
|
||||
|
||||
$this->success = true;
|
||||
$this->dispatch('contact-form-submitted');
|
||||
$this->resetForm();
|
||||
}
|
||||
|
||||
protected function resetForm(): void
|
||||
{
|
||||
$this->initializeFormData();
|
||||
$this->formLoadedAt = time();
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('contact-form::form', [
|
||||
'fields' => $this->getFields(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
165
packages/acme/contact-form/src/SpamDetector.php
Normal file
165
packages/acme/contact-form/src/SpamDetector.php
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<?php
|
||||
|
||||
namespace Acme\ContactForm;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SpamDetector
|
||||
{
|
||||
public function __construct(
|
||||
protected array $config
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Checks the given data for spam indicators.
|
||||
*/
|
||||
public function detect(array $validated, ?int $formLoadedAt = null, ?Request $request = null): bool
|
||||
{
|
||||
$request = $request ?? request();
|
||||
|
||||
if ($this->checkHoneypot($validated, $request)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->checkFillTime($formLoadedAt, $request)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->checkContent($validated, $request)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->checkDisposableEmail($validated, $request)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks honeypot fields — configured fields must be empty.
|
||||
*/
|
||||
protected function checkHoneypot(array $validated, Request $request): bool
|
||||
{
|
||||
$honeypotFields = $this->config['honeypot_fields'] ?? ['company_check', 'website', 'url', 'website_url'];
|
||||
|
||||
foreach ($honeypotFields as $field) {
|
||||
if (isset($validated[$field]) && ! empty($validated[$field])) {
|
||||
$this->logSpam('Honeypot field filled', ['field' => $field], $request);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($validated as $key => $value) {
|
||||
if (str_contains((string) $key, '_check') && ! empty($value)) {
|
||||
$this->logSpam('Honeypot field filled', ['field' => $key], $request);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time-based check: form submitted too quickly.
|
||||
*/
|
||||
protected function checkFillTime(?int $formLoadedAt, Request $request): bool
|
||||
{
|
||||
if ($formLoadedAt === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$minSeconds = $this->config['spam']['min_fill_time_seconds'] ?? 3;
|
||||
$timeSpent = time() - $formLoadedAt;
|
||||
|
||||
if ($timeSpent < $minSeconds) {
|
||||
$this->logSpam('Form submitted too quickly', ['time_spent' => $timeSpent], $request);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content analysis: suspicious patterns, excessive links, repeated characters, XSS.
|
||||
*/
|
||||
protected function checkContent(array $validated, Request $request): bool
|
||||
{
|
||||
$patterns = $this->config['spam']['suspicious_patterns'] ?? [];
|
||||
$maxLinks = $this->config['spam']['max_links_in_message'] ?? 2;
|
||||
$maxRepeated = $this->config['spam']['max_repeated_chars'] ?? 10;
|
||||
|
||||
foreach ($validated as $key => $value) {
|
||||
if (! is_string($value) || str_contains($key, 'honeypot')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match($pattern, $value)) {
|
||||
$this->logSpam('Suspicious content detected', ['pattern' => $pattern, 'field' => $key], $request);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$linkCount = substr_count(strtolower($value), 'http://') +
|
||||
substr_count(strtolower($value), 'https://') +
|
||||
substr_count(strtolower($value), 'www.');
|
||||
|
||||
if ($linkCount > $maxLinks) {
|
||||
$this->logSpam('Too many links', ['link_count' => $linkCount], $request);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preg_match('/(.)\1{'.($maxRepeated + 1).',}/', $value)) {
|
||||
$this->logSpam('Too many repeated characters', [], $request);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for disposable/throwaway email domains.
|
||||
*/
|
||||
protected function checkDisposableEmail(array $validated, Request $request): bool
|
||||
{
|
||||
$email = $validated['email'] ?? $validated['e-mail'] ?? null;
|
||||
if (empty($email) || ! is_string($email)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$domains = $this->config['spam']['disposable_email_domains'] ?? [];
|
||||
$emailDomain = substr((string) strrchr($email, '@'), 1);
|
||||
|
||||
if (in_array(strtolower($emailDomain), $domains)) {
|
||||
$this->logSpam('Disposable email address', ['domain' => $emailDomain], $request);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function logSpam(string $reason, array $context, Request $request): void
|
||||
{
|
||||
Log::warning('Spam detected (contact-form): '.$reason, array_merge([
|
||||
'ip' => $request->ip(),
|
||||
], $context));
|
||||
}
|
||||
|
||||
/**
|
||||
* Static factory for convenient instantiation.
|
||||
*/
|
||||
public static function fromConfig(?array $config = null): self
|
||||
{
|
||||
return new self($config ?? config('contact-form', []));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue