diff --git a/README.md b/README.md index b4f06d1..a6d4746 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,16 @@ components/ ## Локальный запуск +Переменные окружения для отправки лидов в EspoCRM: + +```env +ESPOCRM_API_URL=https://crm.parkflow.ru/api/v1/Lead +ESPOCRM_API_KEY=your_api_key_here +ESPOCRM_LEAD_SOURCE=Web Site +``` + +Их можно положить в локальный `.env` или задать на сервере. + Установить зависимости: ```bash @@ -98,20 +108,18 @@ docker compose down - Prisma - PostgreSQL -- локальная форма заявок с записью в БД -- API-роуты для лидов - админка - локальная авторизация -То есть репозиторий теперь хранит только сайт. +При этом форма заявок снова доступна, но она работает не через локальную базу, а через внешний CRM API. ## Дальнейшие шаги Когда будете подключать внешнюю CRM, можно сделать один из вариантов: -1. встроить форму из `EspoCRM` на страницу контактов -2. вести кнопки и CTA на внешний CRM-URL -3. подключить webhook/API внешней CRM без своей локальной базы +1. использовать текущую серверную отправку лидов в `EspoCRM` +2. встроить форму из `EspoCRM` на страницу контактов +3. вести кнопки и CTA на внешний CRM-URL ## Репозиторий diff --git a/app/api/leads/route.ts b/app/api/leads/route.ts new file mode 100644 index 0000000..44854c6 --- /dev/null +++ b/app/api/leads/route.ts @@ -0,0 +1,94 @@ +import { NextResponse } from "next/server"; + +type LeadRequestBody = { + name?: string; + phone?: string; + email?: string | null; + message?: string | null; +}; + +function normalizePhone(input: string) { + const digits = input.replace(/\D/g, ""); + + if (digits.length === 11 && (digits.startsWith("7") || digits.startsWith("8"))) { + return `+7${digits.slice(1)}`; + } + + if (digits.length === 10) { + return `+7${digits}`; + } + + return null; +} + +export async function POST(request: Request) { + const apiUrl = process.env.ESPOCRM_API_URL; + const apiKey = process.env.ESPOCRM_API_KEY; + const leadSource = process.env.ESPOCRM_LEAD_SOURCE || "Web Site"; + + if (!apiUrl || !apiKey) { + console.error("EspoCRM env is not configured"); + return NextResponse.json( + { error: "Интеграция CRM временно не настроена" }, + { status: 500 }, + ); + } + + try { + const body = (await request.json()) as LeadRequestBody; + const name = body.name?.trim() || ""; + const email = body.email?.trim() || null; + const message = body.message?.trim() || null; + const phoneNumber = body.phone ? normalizePhone(body.phone) : null; + + if (name.length < 2 || !phoneNumber) { + return NextResponse.json( + { error: "Проверьте название объекта и номер телефона" }, + { status: 400 }, + ); + } + + const descriptionParts = [message || "Заявка с сайта"]; + + if (email) { + descriptionParts.push(`Email: ${email}`); + } + + const payload = { + name, + phoneNumber, + emailAddress: email, + description: descriptionParts.join("\n"), + source: leadSource, + }; + + const response = await fetch(apiUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify(payload), + cache: "no-store", + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("EspoCRM lead create failed:", response.status, errorText); + + return NextResponse.json( + { error: "Не удалось отправить заявку в CRM" }, + { status: 502 }, + ); + } + + return NextResponse.json({ success: true }, { status: 201 }); + } catch (error) { + console.error("POST /api/leads error:", error); + return NextResponse.json( + { error: "Не удалось обработать заявку" }, + { status: 500 }, + ); + } +} diff --git a/app/contacts/page.tsx b/app/contacts/page.tsx index 4f60949..b5b38b6 100644 --- a/app/contacts/page.tsx +++ b/app/contacts/page.tsx @@ -1,3 +1,5 @@ +import LeadForm from "@/components/lead-form"; + export default function ContactsPage() { return (
@@ -88,46 +90,11 @@ export default function ContactsPage() {
-
-

- Обсудим ваш объект -

-

- Мы убрали локальную CRM, базу заявок и админку из сайта. Дальше - сюда можно спокойно подключить внешнюю форму из EspoCRM или - любой другой системы, а пока используйте прямые контакты. -

-
- -
- -
- Телефон -
-
- +7 (999) 969-81-49 -
-

- Для обсуждения объекта, внедрения и тарифов -

-
- - -
- Email -
-
sale@parkflow.ru
-

- Можно сразу прислать адрес объекта и краткое описание задачи -

-
-
+
diff --git a/app/page.tsx b/app/page.tsx index 9160861..151c900 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import Image from "next/image"; import BarrierIcon from "@/components/barrier-icon"; +import LeadForm from "@/components/lead-form"; import { ArrowRight, Camera, @@ -134,7 +135,7 @@ export default function Home() {
Получить консультацию @@ -410,35 +411,11 @@ export default function Home() {
-

- Свяжитесь с нами -

-

- Дальше вы сможете подключить сюда форму из EspoCRM или другой - внешней CRM. Пока сайт остаётся чистым фронтом без своей базы - заявок и админки. -

- -
- - Позвонить: +7 (999) 969-81-49 - - - Написать: sale@parkflow.ru - - - Открыть страницу контактов - -
+
diff --git a/components/lead-form.tsx b/components/lead-form.tsx new file mode 100644 index 0000000..f21a69c --- /dev/null +++ b/components/lead-form.tsx @@ -0,0 +1,314 @@ +"use client"; + +import { useState } from "react"; + +type FieldErrors = { + name?: string; + phone?: string; + email?: string; +}; + +type LeadFormProps = { + id?: string; + title?: string; + description?: string; + compact?: boolean; +}; + +function getPhoneDigits(input: string) { + return input.replace(/\D/g, "").slice(0, 11); +} + +function formatRussianPhoneInput(input: string) { + const digits = getPhoneDigits(input); + + if (!digits) { + return ""; + } + + let normalized = digits; + + if (normalized[0] === "8") { + normalized = `7${normalized.slice(1)}`; + } + + if (normalized[0] !== "7") { + normalized = `7${normalized.slice(0, 10)}`; + } + + normalized = normalized.slice(0, 11); + + const country = "+7"; + const part1 = normalized.slice(1, 4); + const part2 = normalized.slice(4, 7); + const part3 = normalized.slice(7, 9); + const part4 = normalized.slice(9, 11); + + let result = country; + + if (part1) { + result += ` (${part1}`; + } + + if (part1.length === 3) { + result += ")"; + } + + if (part2) { + result += ` ${part2}`; + } + + if (part3) { + result += `-${part3}`; + } + + if (part4) { + result += `-${part4}`; + } + + return result; +} + +function normalizePhone(input: string) { + const digits = getPhoneDigits(input); + + if (digits.length === 11 && (digits.startsWith("7") || digits.startsWith("8"))) { + return `7${digits.slice(1)}`; + } + + if (digits.length === 10) { + return `7${digits}`; + } + + return null; +} + +function isValidEmail(email: string) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +export default function LeadForm({ + id, + title, + description, + compact = false, +}: LeadFormProps) { + const [name, setName] = useState(""); + const [phone, setPhone] = useState(""); + const [email, setEmail] = useState(""); + const [message, setMessage] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [resultMessage, setResultMessage] = useState(""); + const [submitError, setSubmitError] = useState(false); + const [errors, setErrors] = useState({}); + + function validateFields() { + const nextErrors: FieldErrors = {}; + const trimmedName = name.trim(); + const trimmedEmail = email.trim(); + const normalizedPhone = normalizePhone(phone); + + if (trimmedName.length < 2) { + nextErrors.name = "Укажите название объекта или компании"; + } + + if (!normalizedPhone) { + nextErrors.phone = "Введите российский номер в формате +7"; + } + + if (trimmedEmail && !isValidEmail(trimmedEmail)) { + nextErrors.email = "Проверьте корректность email"; + } + + return { + nextErrors, + trimmedName, + trimmedEmail, + normalizedPhone, + }; + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setResultMessage(""); + setSubmitError(false); + + const { nextErrors, trimmedName, trimmedEmail, normalizedPhone } = + validateFields(); + + if (Object.keys(nextErrors).length > 0 || !normalizedPhone) { + setErrors(nextErrors); + return; + } + + setErrors({}); + setIsSubmitting(true); + + try { + const response = await fetch("/api/leads", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: trimmedName, + phone: normalizedPhone, + email: trimmedEmail || null, + message: message.trim() || null, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + setSubmitError(true); + setResultMessage(data.error || "Не удалось отправить заявку"); + return; + } + + setName(""); + setPhone(""); + setEmail(""); + setMessage(""); + setResultMessage("Заявка отправлена. Мы свяжемся с вами."); + setSubmitError(false); + } catch { + setSubmitError(true); + setResultMessage("Не удалось отправить заявку"); + } finally { + setIsSubmitting(false); + } + } + + const fieldClassName = + "w-full rounded-2xl border bg-black/30 px-4 py-3.5 text-white outline-none transition-colors placeholder:text-neutral-500 focus:border-emerald-500 sm:px-5 sm:py-4"; + const labelClassName = "mb-2 block text-sm font-medium text-neutral-200"; + const helperClassName = "mt-2 text-xs text-neutral-500"; + const errorClassName = "mt-2 text-xs text-rose-300"; + + return ( +
+ {(title || description) && ( +
+ {title && ( +

+ {title} +

+ )} + {description && ( +

+ {description} +

+ )} +
+ )} + +
+
+ + { + setName(e.target.value); + if (errors.name) { + setErrors((current) => ({ ...current, name: undefined })); + } + }} + className={`${fieldClassName} ${errors.name ? "border-rose-400/70" : "border-white/10"}`} + autoComplete="organization" + required + /> + {errors.name ? ( +

{errors.name}

+ ) : ( +

Укажите ЖК, ТСЖ, УК или адрес объекта

+ )} +
+ +
+ + { + setPhone(formatRussianPhoneInput(e.target.value)); + if (errors.phone) { + setErrors((current) => ({ ...current, phone: undefined })); + } + }} + className={`${fieldClassName} ${errors.phone ? "border-rose-400/70" : "border-white/10"}`} + inputMode="numeric" + autoComplete="tel" + required + /> + {errors.phone ? ( +

{errors.phone}

+ ) : ( +

Только российские номера

+ )} +
+ +
+ + { + setEmail(e.target.value); + if (errors.email) { + setErrors((current) => ({ ...current, email: undefined })); + } + }} + className={`${fieldClassName} ${errors.email ? "border-rose-400/70" : "border-white/10"}`} + autoComplete="email" + /> + {errors.email ? ( +

{errors.email}

+ ) : ( +

Необязательно, но удобно для ответа

+ )} +
+ +
+ +