astro_advokat/frontend/src/components/base/ContactForm.astro
2026-03-30 20:21:41 +05:00

582 lines
21 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
import SectionHeader from "@components/base/SectionHeader.astro";
import { CONTACT_CONSTANTS } from "@constants/constants.ts";
const contactIcons = {
phone:
"M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z",
location:
"M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z M15 11a3 3 0 11-6 0 3 3 0 016 0z",
email:
"M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z",
};
const PRACTICE_AREAS = [
{ value: "civil", label: "Гражданское право" },
{ value: "admin", label: "Административное право" },
{ value: "family", label: "Семейное право" },
{ value: "arbitration", label: "Арбитражные дела" },
{ value: "realestate", label: "Недвижимость" },
{ value: "svo", label: "СВО" },
] as const;
interface ContactInfo {
icon: keyof typeof contactIcons;
label: string;
type: "phone" | "address" | "email";
href?: string;
value: string;
}
const contactInfo: ContactInfo[] = [
{
icon: "phone",
label: "Телефон",
type: "phone",
href: CONTACT_CONSTANTS.phoneHref,
value: CONTACT_CONSTANTS.phone,
},
{
icon: "location",
label: "Адрес",
type: "address",
value: CONTACT_CONSTANTS.address,
},
{
icon: "email",
label: "Email",
type: "email",
href: `mailto:${CONTACT_CONSTANTS.email}`,
value: CONTACT_CONSTANTS.email,
},
];
---
<section class="relative py-24 bg-gray-50" id="contact">
<div class="w-full px-4 md:container md:mx-auto md:px-4 md:max-w-7xl">
<SectionHeader
label="Свяжитесь с нами"
title="Получите бесплатную консультацию"
description="Опишите вашу ситуацию, и мы свяжемся с вами в ближайшее время для первичного правового анализа"
/>
</div>
<div class="w-full px-4 md:container md:mx-auto md:px-4 md:max-w-7xl">
<div class="bg-white rounded-3xl shadow-2xl overflow-hidden">
<div class="flex flex-col lg:flex-row">
<!-- Левая колонка -->
<div
class="w-full lg:w-2/5 bg-gradient-to-br from-[var(--color-navy)] to-[#1a1f3d] p-10 lg:p-12 text-white relative overflow-hidden"
>
<div
class="absolute top-0 right-0 w-64 h-64 bg-[var(--color-gold)] opacity-10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2"
>
</div>
<div
class="absolute bottom-0 left-0 w-48 h-48 bg-[var(--color-blue-primary)] opacity-20 rounded-full blur-3xl translate-y-1/2 -translate-x-1/2"
>
</div>
<div class="relative z-10">
<span
class="block text-[var(--color-gold)] text-xs font-bold uppercase tracking-[0.2em] mb-4"
>Свяжитесь с нами</span
>
<p class="text-gray-400 leading-relaxed mb-12">
Опишите вашу ситуацию, и мы свяжемся с вами в ближайшее время для
первичного правового анализа.
</p>
<div class="space-y-8">
{
contactInfo.map((item) => (
<div class="flex items-start gap-4 group">
<div class="w-12 h-12 bg-white/10 rounded-xl flex items-center justify-center flex-shrink-0 group-hover:bg-[var(--color-gold)] transition-colors">
<svg
class="w-5 h-5 text-[var(--color-gold)] group-hover:text-white transition-colors"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d={contactIcons[item.icon]}
/>
</svg>
</div>
<div>
<span class="block text-xs text-gray-500 uppercase tracking-wider mb-1">
{item.label}
</span>
{item.href && item.type !== "phone" ? (
<a
href={item.href}
class="text-lg font-bold hover:text-[var(--color-gold)] transition-colors"
>
{item.value}
</a>
) : item.type === "phone" ? (
<button
data-consultation-modal
class="text-lg font-bold hover:text-[var(--color-gold)] transition-colors cursor-pointer text-left"
>
{item.value}
</button>
) : (
<span class="text-lg font-bold">{item.value}</span>
)}
</div>
</div>
))
}
</div>
</div>
</div>
<!-- Правая колонка (Форма) -->
<div class="w-full lg:w-3/5 p-10 lg:p-12 bg-white">
<form class="space-y-8" id="consultation-form" novalidate>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Имя -->
<div class="relative group">
<label
for="name"
class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2"
>
Ваше имя <span class="text-red-500">*</span>
</label>
<input
type="text"
id="name"
name="name"
required
minlength="2"
maxlength="50"
pattern="[А-Яа-яЁёA-Za-z\s\-]+"
placeholder="Иван Иванов"
class="w-full py-3 bg-gray-50 border border-gray-200 rounded-xl px-4 text-gray-900 font-medium focus:outline-none focus:border-[var(--color-gold)] focus:ring-2 focus:ring-[var(--color-gold)]/20 transition-all"
/>
<span class="error-message hidden text-red-500 text-xs mt-1">
Введите корректное имя (минимум 2 символа)
</span>
</div>
<!-- Телефон -->
<div class="relative group">
<label
for="phone"
class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2"
>
Телефон <span class="text-red-500">*</span>
</label>
<input
type="tel"
id="phone"
name="phone"
required
pattern="\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}"
placeholder="+7 (___) ___-__-__"
class="w-full py-3 bg-gray-50 border border-gray-200 rounded-xl px-4 text-gray-900 font-medium focus:outline-none focus:border-[var(--color-gold)] focus:ring-2 focus:ring-[var(--color-gold)]/20 transition-all"
/>
<span class="error-message hidden text-red-500 text-xs mt-1">
Введите полный номер телефона
</span>
</div>
</div>
<!-- Сфера вопроса -->
<div class="relative">
<label
for="practice"
class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2"
>
Сфера вопроса
</label>
<div class="relative">
<select
id="practice"
name="practice"
class="w-full py-3 bg-gray-50 border border-gray-200 rounded-xl px-4 text-gray-900 font-medium focus:outline-none focus:border-[var(--color-gold)] focus:ring-2 focus:ring-[var(--color-gold)]/20 appearance-none cursor-pointer transition-all"
>
{
PRACTICE_AREAS.map((area) => (
<option value={area.value}>{area.label}</option>
))
}
</select>
<div
class="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-gray-400"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"></path>
</svg>
</div>
</div>
</div>
<!-- Сообщение -->
<div class="relative">
<label
for="message"
class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2"
>
Ваше сообщение <span class="text-red-500">*</span>
</label>
<textarea
id="message"
name="message"
required
minlength="10"
maxlength="1000"
rows="4"
placeholder="Опишите ситуацию..."
class="w-full py-3 bg-gray-50 border border-gray-200 rounded-xl px-4 text-gray-900 font-medium focus:outline-none focus:border-[var(--color-gold)] focus:ring-2 focus:ring-[var(--color-gold)]/20 resize-none transition-all"
></textarea>
<div class="flex justify-between mt-1">
<span class="error-message hidden text-red-500 text-xs">
Опишите ситуацию подробнее (минимум 10 символов)
</span>
<span class="char-count text-xs text-gray-400 ml-auto">
0 / 1000
</span>
</div>
</div>
<button
type="submit"
id="submit-btn"
disabled
class="w-full py-4 bg-gradient-to-r from-[var(--color-gold)] to-[var(--color-gold-hover)] text-white font-bold rounded-xl shadow-lg shadow-[var(--color-gold)]/30 hover:shadow-xl hover:shadow-[var(--color-gold)]/40 hover:-translate-y-0.5 hover:cursor-pointer transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:transform-none"
>
<span class="btn-text">Отправить запрос</span>
<span class="btn-loading hidden">Отправка...</span>
</button>
<p class="text-center text-xs text-gray-400">
Нажимая кнопку, вы соглашаетесь с{" "}
<a
href="/policy"
class="text-[var(--color-gold)] hover:underline"
>
политикой конфиденциальности
</a>
</p>
</form>
</div>
</div>
</div>
</div>
</section>
<script>
// Типы для валидации
type ValidationRule = {
pattern?: RegExp;
minLength?: number;
maxLength?: number;
required?: boolean;
};
const validationRules: Record<string, ValidationRule> = {
name: {
required: true,
minLength: 2,
maxLength: 50,
pattern: /^[А-Яа-яЁёA-Za-z\s\-]+$/,
},
phone: {
required: true,
pattern: /^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$/,
},
message: {
required: true,
minLength: 10,
maxLength: 1000,
},
};
// Элементы формы
const form = document.getElementById("consultation-form") as HTMLFormElement;
const submitBtn = document.getElementById("submit-btn") as HTMLButtonElement;
const btnText = submitBtn.querySelector(".btn-text") as HTMLSpanElement;
const btnLoading = submitBtn.querySelector(".btn-loading") as HTMLSpanElement;
// Отслеживаем, было ли поле в фокусе (для показа ошибок только после взаимодействия)
const touchedFields = new Set<string>();
// Валидация поля
function validateField(
field: HTMLInputElement | HTMLTextAreaElement,
showError: boolean = false,
): boolean {
const name = field.name;
const rules = validationRules[name];
const errorEl = field.parentElement?.querySelector(
".error-message",
) as HTMLElement;
if (!rules) return true;
let isValid = true;
let errorMsg = "";
// Проверка обязательности
if (rules.required && !field.value.trim()) {
isValid = false;
errorMsg = "Обязательное поле";
}
// Проверка минимальной длины (только если поле не пустое)
else if (
rules.minLength &&
field.value.length > 0 &&
field.value.length < rules.minLength
) {
isValid = false;
errorMsg = `Минимум ${rules.minLength} символов`;
}
// Проверка максимальной длины
else if (rules.maxLength && field.value.length > rules.maxLength) {
isValid = false;
errorMsg = `Максимум ${rules.maxLength} символов`;
}
// Проверка паттерна (только если поле не пустое)
else if (
rules.pattern &&
field.value.length > 0 &&
!rules.pattern.test(field.value)
) {
isValid = false;
errorMsg = "Некорректный формат";
}
// Отображение ошибки только если поле было в фокусе или принудительный показ
if (errorEl && (showError || touchedFields.has(name))) {
if (!isValid && (field.value.length > 0 || showError)) {
errorEl.textContent = errorMsg;
errorEl.classList.remove("hidden");
field.classList.add(
"border-red-500",
"focus:border-red-500",
"focus:ring-red-500/20",
);
field.classList.remove(
"focus:border-[var(--color-gold)]",
"focus:ring-[var(--color-gold)]/20",
);
} else {
errorEl.classList.add("hidden");
field.classList.remove(
"border-red-500",
"focus:border-red-500",
"focus:ring-red-500/20",
);
field.classList.add(
"focus:border-[var(--color-gold)]",
"focus:ring-[var(--color-gold)]/20",
);
}
}
return isValid;
}
// Проверка всей формы (без показа ошибок, только для активации кнопки)
function checkFormValidity(): boolean {
const fields = form.querySelectorAll<
HTMLInputElement | HTMLTextAreaElement
>("input[required], textarea[required]");
let isValid = true;
fields.forEach((field) => {
if (!validateField(field, false)) isValid = false;
});
submitBtn.disabled = !isValid;
return isValid;
}
// Показать все ошибки (при попытке отправки)
function showAllErrors(): boolean {
const fields = form.querySelectorAll<
HTMLInputElement | HTMLTextAreaElement
>("input[required], textarea[required]");
let isValid = true;
fields.forEach((field) => {
touchedFields.add(field.name);
if (!validateField(field, true)) isValid = false;
});
return isValid;
}
// Маска телефона с ограничением ввода
const phoneInput = document.getElementById("phone") as HTMLInputElement;
phoneInput?.addEventListener("keypress", (e) => {
// Разрешаем только цифры и управляющие клавиши
if (
!/\d/.test(e.key) &&
!["Backspace", "Delete", "ArrowLeft", "ArrowRight", "Tab"].includes(e.key)
) {
e.preventDefault();
}
});
phoneInput?.addEventListener("input", (e) => {
const target = e.target as HTMLInputElement;
let value = target.value.replace(/\D/g, "");
// Ограничиваем длину
if (value.length > 11) value = value.slice(0, 11);
// Убираем 7 или 8 в начале
if (value.startsWith("7")) value = value.slice(1);
if (value.startsWith("8")) value = value.slice(1);
// Форматируем
let formatted = "+7";
if (value.length > 0) formatted += " (" + value.slice(0, 3);
if (value.length > 3) formatted += ") " + value.slice(3, 6);
if (value.length > 6) formatted += "-" + value.slice(6, 8);
if (value.length > 8) formatted += "-" + value.slice(8, 10);
target.value = formatted;
validateField(target);
checkFormValidity();
});
// Ограничение ввода для имени (только буквы, пробелы, дефис)
const nameInput = document.getElementById("name") as HTMLInputElement;
nameInput?.addEventListener("keypress", (e) => {
if (
!/[А-Яа-яЁёA-Za-z\s\-]/.test(e.key) &&
!["Backspace", "Delete", "ArrowLeft", "ArrowRight", "Tab"].includes(e.key)
) {
e.preventDefault();
}
});
nameInput?.addEventListener("input", () => {
validateField(nameInput);
checkFormValidity();
});
// Счетчик символов для сообщения
const messageInput = document.getElementById(
"message",
) as HTMLTextAreaElement;
const charCount = messageInput?.parentElement?.querySelector(
".char-count",
) as HTMLElement;
messageInput?.addEventListener("input", () => {
const length = messageInput.value.length;
if (charCount) {
charCount.textContent = `${length} / 1000`;
charCount.classList.toggle("text-red-500", length > 1000);
}
validateField(messageInput);
checkFormValidity();
});
// Отмечаем поле как "тронутое" при фокусе
form.querySelectorAll("input, textarea").forEach((field) => {
field.addEventListener("focus", () => {
touchedFields.add((field as HTMLInputElement).name);
});
});
// Валидация при потере фокуса (показываем ошибки только если поле было заполнено неверно)
form.querySelectorAll("input, textarea").forEach((field) => {
field.addEventListener("blur", () => {
const input = field as HTMLInputElement;
touchedFields.add(input.name);
// Показываем ошибку только если поле не пустое и невалидно, или если пытались отправить
if (input.value.length > 0) {
validateField(input, true);
}
});
});
// Отправка формы
form?.addEventListener("submit", async (e) => {
e.preventDefault();
// Показываем все ошибки при попытке отправки
if (!showAllErrors()) return;
// Блокировка кнопки
submitBtn.disabled = true;
btnText.classList.add("hidden");
btnLoading.classList.remove("hidden");
try {
const formData = new FormData(form);
const data = Object.fromEntries(formData);
// Отправка на API
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) {
throw new Error(result.error || "Ошибка при отправке");
}
// Успех
if (typeof window.showToast === "function") {
window.showToast(
"Заявка успешно отправлена! Мы свяжемся с вами в ближайшее время.",
"success",
5000,
);
}
form.reset();
touchedFields.clear();
checkFormValidity(); // Сброс состояния кнопки
// Сброс счётчика символов
if (charCount) {
charCount.textContent = "0 / 1000";
charCount.classList.remove("text-red-500");
}
} catch (error) {
console.error("[ContactForm] Ошибка:", error);
const errorMessage =
error instanceof Error
? error.message
: "Ошибка при отправке. Попробуйте позже.";
if (typeof window.showToast === "function") {
window.showToast(errorMessage, "error", 5000);
}
} finally {
submitBtn.disabled = false;
btnText.classList.remove("hidden");
btnLoading.classList.add("hidden");
}
});
// Начальная проверка (без показа ошибок)
checkFormValidity();
</script>