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>

View file

@ -0,0 +1,66 @@
---
interface Props {
page: unknown;
}
const { page } = Astro.props;
const contactBlock = page?.layout?.find(
(block: unknown) => block && typeof block === 'object' && block.blockType === "contact"
);
const title =
(contactBlock?.blockType === "contact" && contactBlock.title) ??
"Kontaktieren Sie uns";
const subtitle =
(contactBlock?.blockType === "contact" && contactBlock.subtitle) ??
"Haben Sie Fragen oder möchten Sie eine Fahrt buchen? Wir sind hier, um zu helfen. Füllen Sie das Formular aus oder nutzen Sie die untenstehenden Kontaktinformationen.";
---
<div class="text-center opacity-0 animate-staggerFadeIn">
<div class="text-4xl md:text-5xl font-bold text-gray-900 mb-4 tracking-tight opacity-0 animate-fadeInUp delay-100">
{title}
</div>
<p class="text-lg text-gray-600 leading-relaxed max-w-2xl mx-auto opacity-0 animate-fadeInUp delay-300">
{subtitle}
</p>
</div>
<style is:global>
@keyframes staggerFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-staggerFadeIn {
animation: staggerFadeIn 0.6s ease-out forwards;
}
.animate-fadeInUp {
animation: fadeInUp 0.6s ease-out forwards;
}
.delay-100 {
animation-delay: 0.1s;
}
.delay-300 {
animation-delay: 0.3s;
}
</style>

View file

@ -0,0 +1,94 @@
---
interface Props {
siteSettings: unknown;
}
const { siteSettings } = Astro.props;
const contactDetails = [
{
icon: "📞",
title: 'Telefon',
value: siteSettings?.contact?.phoneNumber || siteSettings?.contact?.phone,
href: siteSettings?.contact?.phoneHref || `tel:${siteSettings?.contact?.phoneNumber || siteSettings?.contact?.phone || ''}`,
},
{
icon: "✉️",
title: 'E-Mail',
value: siteSettings?.contact?.emailAddress || siteSettings?.contact?.email,
href: siteSettings?.contact?.emailHref || `mailto:${siteSettings?.contact?.emailAddress || siteSettings?.contact?.email || ''}`,
},
{
icon: "📍",
title: 'Standort',
value: siteSettings?.contact?.address || 'Berlin, Deutschland',
href: siteSettings?.contact?.mapUrl || '#',
},
{
icon: "🕒",
title: 'Öffnungszeiten',
value: siteSettings?.contact?.workingHours || '24/7 erreichbar',
href: '#',
},
];
---
<div class="space-y-6 opacity-0 animate-staggerFadeIn">
{contactDetails.map((detail, index) => {
const Wrapper = detail.href === '#' ? 'div' : 'a';
return (
<Wrapper
href={detail.href === '#' ? undefined : detail.href}
class="contact-item flex items-center p-4 rounded-xl group opacity-0 animate-fadeInUp"
style={`animation-delay: ${0.2 + (index * 0.15)}s`}
>
<div class="flex-shrink-0 flex items-center justify-center h-14 w-14 rounded-full bg-amber-100 text-amber-600 mr-5 transition-colors duration-300 group-hover:bg-amber-500 group-hover:text-white text-xl">
{detail.icon}
</div>
<div>
<h2 class="text-lg font-semibold text-gray-900">
{detail.title}
</h2>
<p class="text-md text-gray-600 transition-colors duration-300 group-hover:text-amber-600">
{detail.value}
</p>
</div>
</Wrapper>
);
})}
</div>
<style is:global>
@keyframes staggerFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-staggerFadeIn {
animation: staggerFadeIn 0.6s ease-out forwards;
}
.animate-fadeInUp {
animation: fadeInUp 0.5s ease-out forwards;
}
.contact-item:hover {
transform: scale(1.02);
transition: transform 0.2s ease;
}
</style>

View file

@ -0,0 +1,60 @@
---
interface Props {
siteSettings: {
address: string;
[key: string]: any;
};
}
const { siteSettings } = Astro.props;
// Формируем URL для Google Maps, используя поле address из вашей коллекции
const encodedAddress = encodeURIComponent(siteSettings.address);
const mapUrl = `https://maps.google.com/maps?q=${encodedAddress}&t=&z=14&ie=UTF8&iwloc=&output=embed`;
---
<div class="contact-map-wrapper opacity-0 animate-fadeInUp">
<div class="relative w-full h-80 md:h-[450px] rounded-2xl overflow-hidden shadow-2xl border-4 border-white">
<!-- Интерактивная карта Google -->
<iframe
class="absolute inset-0 w-full h-full border-0"
title="Google Map Location"
src={mapUrl}
loading="lazy"
allowfullscreen
referrerpolicy="no-referrer-when-downgrade"
></iframe>
<!-- Невидимый слой поверх для плавности скролла (опционально) -->
<div class="pointer-events-none absolute inset-0 ring-1 ring-inset ring-black/10 rounded-2xl"></div>
</div>
<!-- Подпись под картой (опционально) -->
<p class="mt-4 text-center text-gray-500 font-light italic">
📍 {siteSettings.address}
</p>
</div>
<style>
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeInUp {
animation: fadeInUp 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@media (max-width: 768px) {
iframe {
pointer-events: auto;
}
}
</style>

View file

@ -0,0 +1,62 @@
---
import ContactForm from './ContactForm.astro';
import ContactHeader from './ContactHeader.astro';
import ContactInfo from './ContactInfo.astro';
import ContactMap from './ContactMap.astro';
interface SiteSettings {
address: string;
contact_email?: string;
contact_phone?: string;
[key: string]: any;
}
interface Props {
siteSettings: SiteSettings;
page: any;
}
const { siteSettings, page } = Astro.props;
const locals = Astro.locals as any;
const isAuthenticated = locals.user ? true : false;
---
<section class="relative bg-gray-50 py-12 sm:py-16 overflow-hidden">
<!-- Декоративные фоновые элементы -->
<div class="absolute top-0 left-0 -translate-x-1/2 -translate-y-1/2">
<div class="w-[40rem] h-[40rem] bg-amber-400/10 rounded-full blur-3xl" />
</div>
<div class="absolute bottom-0 right-0 translate-x-1/2 translate-y-1/2">
<div class="w-[40rem] h-[40rem] bg-amber-400/15 rounded-full blur-3xl" />
</div>
<div class="max-w-7xl mx-auto px-6 lg:px-8 relative z-10">
<ContactHeader page={page} />
<div class="grid grid-cols-1 lg:grid-cols-2 gap-x-16 gap-y-12 mt-16 opacity-0 animate-fadeInUp delay-200">
<div class="flex flex-col justify-center">
<ContactInfo siteSettings={siteSettings} />
</div>
<div>
<ContactForm isAuthenticated={isAuthenticated} />
</div>
</div>
<div class="mt-20 lg:mt-24 opacity-0 animate-fadeInUp delay-400">
<ContactMap siteSettings={siteSettings} />
</div>
</div>
</section>
<style>
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fadeInUp { animation: fadeInUp 0.7s ease-out forwards; }
.delay-200 { animation-delay: 0.2s; }
.delay-400 { animation-delay: 0.4s; }
</style>