astro_advokat/frontend/src/components/base/ContactForm.astro

583 lines
21 KiB
Text
Raw Normal View History

2026-03-30 20:21:41 +05:00
---
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>