374 lines
15 KiB
Text
374 lines
15 KiB
Text
---
|
|
import Button from '@components/base/Button.astro';
|
|
|
|
interface Props {
|
|
isAuthenticated: boolean;
|
|
}
|
|
|
|
const { isAuthenticated } = Astro.props;
|
|
const MAX_MESSAGE_LENGTH = 500;
|
|
---
|
|
|
|
<div class="contact-form bg-white p-8 sm:p-10 rounded-2xl shadow-xl border border-gray-100 w-full opacity-0 animate-fadeInUp">
|
|
{!isAuthenticated ? (
|
|
<div class="text-center py-6">
|
|
<div class="bg-gray-100 w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-3">
|
|
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
|
|
</div>
|
|
<p class="text-gray-600 text-sm mb-4">Für das Kontaktformular müssen Sie sich anmelden.</p>
|
|
<Button href="/auth/login?callbackUrl=/kontakt" variant="blue" size="md" fullWidth={true}>
|
|
Anmelden
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<form id="contactForm" class="space-y-6" novalidate>
|
|
|
|
<!-- 1. Имя -->
|
|
<div class="form-item opacity-0 animate-fadeInUp delay-100">
|
|
<label for="name" class="block text-sm font-medium text-gray-700 mb-2">
|
|
Ihr Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="name"
|
|
name="name"
|
|
placeholder="Max Mustermann"
|
|
autocomplete="name"
|
|
class="appearance-none w-full px-4 py-3 rounded-lg bg-gray-50 border border-gray-200 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:bg-white transition"
|
|
/>
|
|
<p class="error-message mt-1 text-xs text-red-600 hidden items-center gap-1">
|
|
<span class="icon">⚠️</span> <span class="error-text"></span>
|
|
</p>
|
|
</div>
|
|
|
|
<!-- 2. Email -->
|
|
<div class="form-item opacity-0 animate-fadeInUp delay-200">
|
|
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
|
|
E-Mail
|
|
</label>
|
|
<input
|
|
type="email"
|
|
id="email"
|
|
name="email"
|
|
placeholder="ihre@email.de"
|
|
autocomplete="email"
|
|
class="appearance-none w-full px-4 py-3 rounded-lg bg-gray-50 border border-gray-200 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:bg-white transition"
|
|
/>
|
|
<p class="error-message mt-1 text-xs text-red-600 hidden items-center gap-1">
|
|
<span class="icon">⚠️</span> <span class="error-text"></span>
|
|
</p>
|
|
</div>
|
|
|
|
<!--
|
|
3. HONEYPOT (Ловушка для ботов)
|
|
Мы используем имя 'website' или 'confirm_email', так как боты любят их заполнять.
|
|
Скрываем через CSS, чтобы пользователь не видел.
|
|
-->
|
|
<div class="absolute opacity-0 -z-50 w-0 h-0 overflow-hidden" aria-hidden="true">
|
|
<label for="website_honeypot">Bitte lassen Sie dieses Feld leer</label>
|
|
<input
|
|
type="text"
|
|
id="website_honeypot"
|
|
name="website_honeypot"
|
|
tabindex="-1"
|
|
autocomplete="off"
|
|
/>
|
|
</div>
|
|
|
|
<!-- 4. Сообщение -->
|
|
<div class="form-item opacity-0 animate-fadeInUp delay-300">
|
|
<label for="message" class="block text-sm font-medium text-gray-700 mb-2">
|
|
Nachricht
|
|
</label>
|
|
<textarea
|
|
id="message"
|
|
name="message"
|
|
rows="5"
|
|
maxlength={MAX_MESSAGE_LENGTH}
|
|
placeholder="Wie können wir Ihnen helfen?"
|
|
class="appearance-none w-full px-4 py-3 rounded-lg bg-gray-50 border border-gray-200 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:bg-white transition resize-none"
|
|
></textarea>
|
|
|
|
<div class="mt-1 flex justify-between items-center">
|
|
<p class="error-message text-xs text-red-600 hidden items-center gap-1">
|
|
<span class="icon">⚠️</span> <span class="error-text"></span>
|
|
</p>
|
|
<p class="character-count text-xs text-gray-500 ml-auto">
|
|
<span id="charCount">0</span> / {MAX_MESSAGE_LENGTH}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Кнопка -->
|
|
<div class="form-item opacity-0 animate-fadeInUp delay-400">
|
|
<Button
|
|
type="submit"
|
|
variant="primary"
|
|
fullWidth
|
|
class="submit-btn"
|
|
>
|
|
<span class="btn-text">Nachricht senden</span>
|
|
<!-- Спиннер загрузки -->
|
|
<span class="loading-spinner hidden ml-2">
|
|
<svg class="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
</span>
|
|
</Button>
|
|
</div>
|
|
|
|
<!-- Блок статуса -->
|
|
<div id="formStatus" class="hidden p-4 rounded-lg text-center text-sm font-medium transition-all duration-300"></div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
|
|
<style>
|
|
/* Анимации определены локально */
|
|
@keyframes fadeInUp {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(20px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
.animate-fadeInUp {
|
|
animation: fadeInUp 0.5s ease-out forwards;
|
|
}
|
|
|
|
.delay-100 { animation-delay: 0.1s; }
|
|
.delay-200 { animation-delay: 0.2s; }
|
|
.delay-300 { animation-delay: 0.3s; }
|
|
.delay-400 { animation-delay: 0.4s; }
|
|
|
|
.error-message.show {
|
|
display: flex;
|
|
}
|
|
|
|
/* Подсветка ошибок */
|
|
:global(input.error), :global(textarea.error) {
|
|
border-color: #ef4444 !important;
|
|
background-color: #fef2f2 !important;
|
|
}
|
|
|
|
.status-message-success {
|
|
background-color: #dcfce7;
|
|
color: #166534;
|
|
border: 1px solid #bbf7d0;
|
|
}
|
|
|
|
.status-message-error {
|
|
background-color: #fee2e2;
|
|
color: #dc2626;
|
|
border: 1px solid #fecaca;
|
|
}
|
|
|
|
/* Состояния кнопки */
|
|
:global(.submit-btn.loading) {
|
|
opacity: 0.7;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.character-count.warning {
|
|
color: #f59e0b; /* Amber */
|
|
}
|
|
.character-count.limit {
|
|
color: #ef4444; /* Red */
|
|
}
|
|
</style>
|
|
|
|
<script define:vars={{ MAX_MESSAGE_LENGTH, isAuthenticated }}>
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Если пользователь не аутентифицирован, не инициализируем форму
|
|
if (!isAuthenticated) return;
|
|
|
|
const form = document.getElementById('contactForm');
|
|
if (!form) return;
|
|
|
|
const inputs = {
|
|
name: document.getElementById('name'),
|
|
email: document.getElementById('email'),
|
|
message: document.getElementById('message'),
|
|
honeypot: document.getElementById('website_honeypot')
|
|
};
|
|
const charCountEl = document.getElementById('charCount');
|
|
const submitBtn = form.querySelector('.submit-btn');
|
|
const btnText = submitBtn?.querySelector('.btn-text');
|
|
const spinner = submitBtn?.querySelector('.loading-spinner');
|
|
const statusDiv = document.getElementById('formStatus');
|
|
|
|
// --- 1. ОГРАНИЧЕНИЕ ВВОДА СИМВОЛОВ (Input Restriction) ---
|
|
|
|
// Для имени: запрещаем цифры и спецсимволы, кроме дефиса и пробела
|
|
inputs.name?.addEventListener('keydown', (e) => {
|
|
// Разрешаем управляющие клавиши (Backspace, Tab, стрелки, Ctrl+C/V)
|
|
if (['Backspace', 'Tab', 'ArrowLeft', 'ArrowRight', 'Delete', 'Enter'].includes(e.key) || e.ctrlKey || e.metaKey) {
|
|
return;
|
|
}
|
|
|
|
// Regex: Разрешаем буквы (латиница + немецкие), пробелы, дефис
|
|
const allowedPattern = /^[a-zA-ZäöüÄÖÜß\s-]$/;
|
|
|
|
if (!allowedPattern.test(e.key)) {
|
|
e.preventDefault(); // Блокируем ввод
|
|
// Опционально: можно мигнуть полем красным, чтобы показать запрет
|
|
inputs.name.classList.add('error');
|
|
setTimeout(() => inputs.name.classList.remove('error'), 200);
|
|
}
|
|
});
|
|
|
|
// Для сообщения: счетчик символов
|
|
inputs.message?.addEventListener('input', function() {
|
|
const count = this.value.length;
|
|
charCountEl.textContent = count;
|
|
const parent = charCountEl.parentElement;
|
|
|
|
parent.classList.remove('warning', 'limit');
|
|
if (count >= MAX_MESSAGE_LENGTH) {
|
|
parent.classList.add('limit');
|
|
} else if (count >= MAX_MESSAGE_LENGTH * 0.9) {
|
|
parent.classList.add('warning');
|
|
}
|
|
});
|
|
|
|
// --- 2. ВАЛИДАЦИЯ ---
|
|
|
|
const validateForm = () => {
|
|
let isValid = true;
|
|
clearErrors();
|
|
|
|
// Имя
|
|
const nameVal = inputs.name.value.trim();
|
|
if (!nameVal) {
|
|
showError('name', 'Bitte geben Sie Ihren Namen ein.');
|
|
isValid = false;
|
|
} else if (nameVal.length < 2) {
|
|
showError('name', 'Der Name ist zu kurz.');
|
|
isValid = false;
|
|
}
|
|
|
|
// Email
|
|
const emailVal = inputs.email.value.trim();
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
if (!emailVal) {
|
|
showError('email', 'Bitte geben Sie Ihre E-Mail ein.');
|
|
isValid = false;
|
|
} else if (!emailRegex.test(emailVal)) {
|
|
showError('email', 'Ungültiges E-Mail-Format.');
|
|
isValid = false;
|
|
}
|
|
|
|
// Сообщение
|
|
const msgVal = inputs.message.value.trim();
|
|
if (!msgVal) {
|
|
showError('message', 'Bitte schreiben Sie eine Nachricht.');
|
|
isValid = false;
|
|
} else if (msgVal.length < 10) {
|
|
showError('message', 'Ihre Nachricht ist zu kurz (min. 10 Zeichen).');
|
|
isValid = false;
|
|
}
|
|
|
|
// XSS Check (базовая защита)
|
|
if (/<script|onload|onclick/i.test(msgVal) || /<script|onload|onclick/i.test(nameVal)) {
|
|
showStatus('Sicherheitswarnung: Ungültige Zeichen erkannt.', 'error');
|
|
isValid = false;
|
|
}
|
|
|
|
return isValid;
|
|
};
|
|
|
|
const showError = (fieldId, msg) => {
|
|
const field = inputs[fieldId];
|
|
const errorP = field.parentElement.querySelector('.error-message');
|
|
const errorSpan = errorP.querySelector('.error-text');
|
|
|
|
field.classList.add('error');
|
|
errorSpan.textContent = msg;
|
|
errorP.classList.add('show');
|
|
};
|
|
|
|
const clearErrors = () => {
|
|
document.querySelectorAll('.error-message').forEach(el => el.classList.remove('show'));
|
|
document.querySelectorAll('.error').forEach(el => el.classList.remove('error'));
|
|
};
|
|
|
|
// --- 3. ОТПРАВКА ---
|
|
|
|
form.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
// A. Honeypot Check (Ловушка)
|
|
if (inputs.honeypot.value !== '') {
|
|
console.warn('Bot detected via honeypot.');
|
|
// Имитируем успех, чтобы бот не пытался снова
|
|
showStatus('Nachricht gesendet!', 'success');
|
|
form.reset();
|
|
return;
|
|
}
|
|
|
|
// B. Валидация
|
|
if (!validateForm()) return;
|
|
|
|
// C. UI Loading
|
|
setLoading(true);
|
|
|
|
try {
|
|
const formData = new FormData(form);
|
|
const data = Object.fromEntries(formData.entries());
|
|
// Удаляем honeypot из отправляемых данных
|
|
delete data.website_honeypot;
|
|
|
|
const response = await fetch('/api/contact', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok) {
|
|
showStatus('Vielen Dank! Ihre Nachricht wurde gesendet.', 'success');
|
|
form.reset();
|
|
charCountEl.textContent = '0';
|
|
} else {
|
|
showStatus(result.error || 'Fehler beim Senden.', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
showStatus('Verbindungsfehler. Bitte versuchen Sie es später.', 'error');
|
|
} finally {
|
|
setLoading(false);
|
|
// Скрываем сообщение через 5 сек
|
|
setTimeout(() => {
|
|
statusDiv.classList.add('hidden');
|
|
}, 5000);
|
|
}
|
|
});
|
|
|
|
function setLoading(isLoading) {
|
|
if (isLoading) {
|
|
submitBtn.classList.add('loading');
|
|
submitBtn.disabled = true;
|
|
btnText.classList.add('hidden');
|
|
spinner.classList.remove('hidden');
|
|
statusDiv.classList.add('hidden');
|
|
} else {
|
|
submitBtn.classList.remove('loading');
|
|
submitBtn.disabled = false;
|
|
btnText.classList.remove('hidden');
|
|
spinner.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
function showStatus(msg, type) {
|
|
statusDiv.textContent = msg;
|
|
statusDiv.className = `p-4 rounded-lg text-center font-medium status-message-${type}`;
|
|
statusDiv.classList.remove('hidden');
|
|
}
|
|
});
|
|
</script>
|