first commit

This commit is contained in:
Web-serfer 2026-03-29 17:24:16 +05:00
commit 0065c017e4
496 changed files with 54265 additions and 0 deletions

View 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>