diff --git a/app/admin/leads/page.tsx b/app/admin/leads/page.tsx index 3e4593d..7292d35 100644 --- a/app/admin/leads/page.tsx +++ b/app/admin/leads/page.tsx @@ -1,16 +1,80 @@ import { prisma } from "@/lib/prisma"; +import LeadStatusSelect from "@/components/lead-status-select"; export const dynamic = "force-dynamic"; -export default async function AdminLeadsPage() { +type SearchParams = Promise<{ + q?: string; + status?: string; +}>; + +export default async function AdminLeadsPage({ + searchParams, +}: { + searchParams: SearchParams; +}) { + const params = await searchParams; + const q = params.q?.trim() || ""; + const status = params.status?.trim() || ""; + const leads = await prisma.lead.findMany({ + where: { + AND: [ + q + ? { + OR: [ + { company: { contains: q, mode: "insensitive" } }, + { phone: { contains: q, mode: "insensitive" } }, + { email: { contains: q, mode: "insensitive" } }, + { message: { contains: q, mode: "insensitive" } }, + ], + } + : {}, + status ? { status: status as any } : {}, + ], + }, orderBy: { createdAt: "desc" }, }); return (
-
-

Заявки

+
+
+

Заявки

+ +
+ +
+
+ +
+ + + + + +
@@ -19,6 +83,7 @@ export default async function AdminLeadsPage() { + @@ -32,17 +97,18 @@ export default async function AdminLeadsPage() { - + + - ))} {leads.length === 0 && ( - diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx new file mode 100644 index 0000000..7b61006 --- /dev/null +++ b/app/admin/login/page.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useState } from "react"; + +export default function AdminLoginPage() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [errorText, setErrorText] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setErrorText(""); + setIsLoading(true); + + try { + const response = await fetch("/api/admin/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email, password }), + }); + + const data = await response.json(); + + if (!response.ok) { + setErrorText(data.error || "Ошибка входа"); + return; + } + + window.location.href = "/admin/leads"; + } catch { + setErrorText("Ошибка сети"); + } finally { + setIsLoading(false); + } + } + + return ( +
+
+

Вход в CRM

+

WorkParking CRM

+ +
+ setEmail(e.target.value)} + className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 outline-none focus:border-emerald-500" + required + /> + + setPassword(e.target.value)} + className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 outline-none focus:border-emerald-500" + required + /> + + + + {errorText &&

{errorText}

} + +
+
+ ); +} \ No newline at end of file diff --git a/app/api/admin/login/route.ts b/app/api/admin/login/route.ts new file mode 100644 index 0000000..05f5eda --- /dev/null +++ b/app/api/admin/login/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { createSessionToken, getAdminCredentials, getSessionCookieName } from "@/lib/auth"; + +type LoginPayload = { + email?: string; + password?: string; +}; + +export async function POST(request: Request) { + try { + const body = (await request.json()) as LoginPayload; + const email = body.email?.trim().toLowerCase() || ""; + const password = body.password?.trim() || ""; + + const admin = getAdminCredentials(); + + if (email !== admin.email.toLowerCase() || password !== admin.password) { + return NextResponse.json({ error: "Неверный email или пароль" }, { status: 401 }); + } + + const token = await createSessionToken(email); + const response = NextResponse.json({ success: true }); + + response.cookies.set({ + name: getSessionCookieName(), + value: token, + httpOnly: true, + secure: true, + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24 * 7, + }); + + return response; + } catch { + return NextResponse.json({ error: "Ошибка авторизации" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/admin/logout/route.ts b/app/api/admin/logout/route.ts new file mode 100644 index 0000000..103b985 --- /dev/null +++ b/app/api/admin/logout/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { getSessionCookieName } from "@/lib/auth"; + +export async function POST() { + const response = NextResponse.json({ success: true }); + + response.cookies.set({ + name: getSessionCookieName(), + value: "", + httpOnly: true, + secure: true, + sameSite: "lax", + path: "/", + maxAge: 0, + }); + + return response; +} \ No newline at end of file diff --git a/app/api/leads/[id]/route.ts b/app/api/leads/[id]/route.ts new file mode 100644 index 0000000..fd24f42 --- /dev/null +++ b/app/api/leads/[id]/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +const allowedStatuses = [ + "NEW", + "IN_PROGRESS", + "CALL_SCHEDULED", + "WON", + "LOST", +] as const; + +type LeadStatus = (typeof allowedStatuses)[number]; + +type PatchPayload = { + status?: LeadStatus; +}; + +export async function PATCH( + request: Request, + context: { params: Promise<{ id: string }> } +) { + try { + const { id } = await context.params; + const body = (await request.json()) as PatchPayload; + + if (!body.status || !allowedStatuses.includes(body.status)) { + return NextResponse.json({ error: "Некорректный статус" }, { status: 400 }); + } + + const lead = await prisma.lead.update({ + where: { id }, + data: { status: body.status }, + }); + + return NextResponse.json({ success: true, lead }); + } catch (error) { + console.error("PATCH /api/leads/[id] error:", error); + return NextResponse.json( + { error: "Не удалось обновить статус" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/leads/route.ts b/app/api/leads/route.ts index 06c5dc7..67086a3 100644 --- a/app/api/leads/route.ts +++ b/app/api/leads/route.ts @@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma"; type LeadPayload = { company?: string; phone?: string; + email?: string; message?: string; }; @@ -13,11 +14,12 @@ export async function POST(request: Request) { const company = body.company?.trim(); const phone = body.phone?.trim(); + const email = body.email?.trim().toLowerCase(); const message = body.message?.trim() || ""; - if (!company || !phone) { + if (!company || !phone || !email) { return NextResponse.json( - { error: "Компания и телефон обязательны" }, + { error: "Компания, телефон и email обязательны" }, { status: 400 } ); } @@ -26,6 +28,7 @@ export async function POST(request: Request) { data: { company, phone, + email, message, source: "website", }, @@ -39,20 +42,4 @@ export async function POST(request: Request) { { status: 500 } ); } -} - -export async function GET() { - try { - const leads = await prisma.lead.findMany({ - orderBy: { createdAt: "desc" }, - }); - - return NextResponse.json(leads); - } catch (error) { - console.error("GET /api/leads error:", error); - return NextResponse.json( - { error: "Не удалось получить заявки" }, - { status: 500 } - ); - } } \ No newline at end of file diff --git a/components/lead-form.tsx b/components/lead-form.tsx index c86a802..0c75ccc 100644 --- a/components/lead-form.tsx +++ b/components/lead-form.tsx @@ -2,18 +2,49 @@ import { useState } from "react"; +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; +} + +function isValidEmail(email: string) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + export default function LeadForm() { const [company, setCompany] = useState(""); const [phone, setPhone] = useState(""); + const [email, setEmail] = useState(""); const [message, setMessage] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [resultMessage, setResultMessage] = useState(""); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); - setIsSubmitting(true); setResultMessage(""); + const normalizedPhone = normalizePhone(phone); + if (!normalizedPhone) { + setResultMessage("Введите корректный российский телефон"); + return; + } + + if (!isValidEmail(email.trim())) { + setResultMessage("Введите корректный email"); + return; + } + + setIsSubmitting(true); + try { const response = await fetch("/api/leads", { method: "POST", @@ -22,7 +53,8 @@ export default function LeadForm() { }, body: JSON.stringify({ company, - phone, + phone: normalizedPhone, + email, message, }), }); @@ -37,10 +69,10 @@ export default function LeadForm() { setResultMessage("Заявка отправлена. Мы свяжемся с вами."); setCompany(""); setPhone(""); + setEmail(""); setMessage(""); - } catch (error) { - console.error(error); - setResultMessage("Ошибка сети. Попробуйте ещё раз."); + } catch { + setResultMessage("Не удалось сохранить заявку"); } finally { setIsSubmitting(false); } @@ -59,15 +91,24 @@ export default function LeadForm() { setPhone(e.target.value)} className="w-full rounded-2xl border border-white/10 bg-black/30 px-5 py-4 outline-none placeholder:text-neutral-500 focus:border-emerald-500" required /> + setEmail(e.target.value)} + className="w-full rounded-2xl border border-white/10 bg-black/30 px-5 py-4 outline-none placeholder:text-neutral-500 focus:border-emerald-500" + required + /> +
Дата Компания ТелефонEmail Сообщение Статус Источник {lead.company} {lead.phone} - {lead.message || "—"} + {lead.email || "—"}{lead.message || "—"} + {lead.status} {lead.source}
+ Пока заявок нет