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>
|
||||
66
frontend/src/components/contacts/ContactHeader.astro
Normal file
66
frontend/src/components/contacts/ContactHeader.astro
Normal 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>
|
||||
94
frontend/src/components/contacts/ContactInfo.astro
Normal file
94
frontend/src/components/contacts/ContactInfo.astro
Normal 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>
|
||||
60
frontend/src/components/contacts/ContactMap.astro
Normal file
60
frontend/src/components/contacts/ContactMap.astro
Normal 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>
|
||||
62
frontend/src/components/contacts/Contacts.astro
Normal file
62
frontend/src/components/contacts/Contacts.astro
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue