first commit
This commit is contained in:
commit
0065c017e4
496 changed files with 54265 additions and 0 deletions
374
frontend/src/components/contacts/ContactForm.astro
Normal file
374
frontend/src/components/contacts/ContactForm.astro
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
---
|
||||
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue