first commit
This commit is contained in:
commit
4a589825c2
297 changed files with 33019 additions and 0 deletions
582
frontend/src/components/base/ContactForm.astro
Normal file
582
frontend/src/components/base/ContactForm.astro
Normal file
|
|
@ -0,0 +1,582 @@
|
|||
---
|
||||
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue