From 71ec905860d09f112a4babbaaaa00ec6771c5869 Mon Sep 17 00:00:00 2001 From: deonisii Date: Sun, 19 Apr 2026 00:03:26 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=B4=D0=B0=D0=BB=D0=B8=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD=D1=83=D1=8E=20CRM=20?= =?UTF-8?q?=D0=B8=20Prisma,=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20Docker=20Compose=20=D0=B8=20=D0=B0=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE=D0=B9=20Gitea?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/deploy.yml | 3 +- Dockerfile | 8 +- app/admin/leads/page.tsx | 141 -- app/admin/login/page.tsx | 78 -- app/api/admin/login/route.ts | 38 - app/api/admin/logout/route.ts | 18 - app/api/leads/[id]/route.ts | 43 - app/api/leads/route.ts | 45 - app/api/test/route.ts | 10 - app/contacts/page.tsx | 49 +- app/page.tsx | 37 +- components/lead-form.tsx | 317 ----- components/lead-status-select.tsx | 83 -- components/mobile-menu.tsx | 8 +- docker-compose.yml | 25 - lib/auth.ts | 71 - lib/prisma.ts | 21 - package-lock.json | 1229 +---------------- package.json | 5 - prisma.config.ts | 12 - .../20260417121735_init_leads/migration.sql | 16 - .../migration.sql | 2 - prisma/migrations/migration_lock.toml | 3 - prisma/schema.prisma | 27 - proxy.ts | 57 - 25 files changed, 92 insertions(+), 2254 deletions(-) delete mode 100644 app/admin/leads/page.tsx delete mode 100644 app/admin/login/page.tsx delete mode 100644 app/api/admin/login/route.ts delete mode 100644 app/api/admin/logout/route.ts delete mode 100644 app/api/leads/[id]/route.ts delete mode 100644 app/api/leads/route.ts delete mode 100644 app/api/test/route.ts delete mode 100644 components/lead-form.tsx delete mode 100644 components/lead-status-select.tsx delete mode 100644 lib/auth.ts delete mode 100644 lib/prisma.ts delete mode 100644 prisma.config.ts delete mode 100644 prisma/migrations/20260417121735_init_leads/migration.sql delete mode 100644 prisma/migrations/20260417182328_add_email_to_leads/migration.sql delete mode 100644 prisma/migrations/migration_lock.toml delete mode 100644 prisma/schema.prisma delete mode 100644 proxy.ts diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index c165c64..3f77a8b 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -15,5 +15,4 @@ jobs: cd /home/deonisii/workparking git pull origin main docker compose up -d --build - docker compose exec -T workparking npx prisma migrate deploy - docker image prune -f \ No newline at end of file + docker image prune -f diff --git a/Dockerfile b/Dockerfile index fabf50f..5fdff16 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,13 +8,9 @@ RUN npm ci FROM base AS builder WORKDIR /app -ARG DATABASE_URL -ENV DATABASE_URL=$DATABASE_URL - COPY --from=deps /app/node_modules ./node_modules COPY . . -RUN npx prisma generate RUN npm run build FROM base AS runner @@ -26,12 +22,10 @@ ENV HOSTNAME=0.0.0.0 COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/public ./public -COPY --from=builder /app/prisma ./prisma -COPY --from=builder /app/prisma.config.ts ./prisma.config.ts COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static EXPOSE 3000 -CMD ["node", "server.js"] \ No newline at end of file +CMD ["node", "server.js"] diff --git a/app/admin/leads/page.tsx b/app/admin/leads/page.tsx deleted file mode 100644 index d359887..0000000 --- a/app/admin/leads/page.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { prisma } from "@/lib/prisma"; -import LeadStatusSelect from "@/components/lead-status-select"; -import { LeadStatus } from "@prisma/client"; - -export const dynamic = "force-dynamic"; - -type SearchParams = Promise<{ - q?: string; - status?: string; -}>; - -const leadStatuses = Object.values(LeadStatus); - -function formatLeadNumber(id: string, createdAt: Date) { - const date = new Date(createdAt); - const y = date.getFullYear(); - const m = String(date.getMonth() + 1).padStart(2, "0"); - const d = String(date.getDate()).padStart(2, "0"); - - return `WP-${y}${m}${d}-${id.slice(-6).toUpperCase()}`; -} - -export default async function AdminLeadsPage({ - searchParams, -}: { - searchParams: SearchParams; -}) { - const params = await searchParams; - const q = params.q?.trim() || ""; - const statusParam = params.status?.trim() || ""; - const status = leadStatuses.includes(statusParam as LeadStatus) - ? (statusParam as LeadStatus) - : undefined; - - 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 } : {}, - ], - }, - orderBy: { createdAt: "desc" }, - }); - - return ( -
-
-
-

Заявки

- -
- -
-
- -
- - - - - -
- -
- - - - - - - - - - - - - - - {leads.map((lead) => ( - - - - - - - - - - - ))} - - {leads.length === 0 && ( - - - - )} - -
ДатаЗаявкаКомпанияТелефонEmailСообщениеСтатусИсточник
- {new Date(lead.createdAt).toLocaleString("ru-RU")} - - {formatLeadNumber(lead.id, lead.createdAt)} - {lead.company}{lead.phone}{lead.email || "—"}{lead.message || "—"} - - {lead.source}
- Пока заявок нет -
-
-
-
- ); -} diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx deleted file mode 100644 index 7b61006..0000000 --- a/app/admin/login/page.tsx +++ /dev/null @@ -1,78 +0,0 @@ -"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 deleted file mode 100644 index 05f5eda..0000000 --- a/app/api/admin/login/route.ts +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 103b985..0000000 --- a/app/api/admin/logout/route.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index fd24f42..0000000 --- a/app/api/leads/[id]/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 67086a3..0000000 --- a/app/api/leads/route.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { NextResponse } from "next/server"; -import { prisma } from "@/lib/prisma"; - -type LeadPayload = { - company?: string; - phone?: string; - email?: string; - message?: string; -}; - -export async function POST(request: Request) { - try { - const body = (await request.json()) as LeadPayload; - - const company = body.company?.trim(); - const phone = body.phone?.trim(); - const email = body.email?.trim().toLowerCase(); - const message = body.message?.trim() || ""; - - if (!company || !phone || !email) { - return NextResponse.json( - { error: "Компания, телефон и email обязательны" }, - { status: 400 } - ); - } - - const lead = await prisma.lead.create({ - data: { - company, - phone, - email, - message, - source: "website", - }, - }); - - return NextResponse.json({ success: true, leadId: lead.id }, { status: 201 }); - } catch (error) { - console.error("POST /api/leads error:", error); - return NextResponse.json( - { error: "Не удалось сохранить заявку" }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/app/api/test/route.ts b/app/api/test/route.ts deleted file mode 100644 index 1176f2c..0000000 --- a/app/api/test/route.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { prisma } from "@/lib/prisma"; - -export async function GET() { - const leads = await prisma.lead.findMany(); - - return Response.json({ - ok: true, - count: leads.length, - }); -} \ No newline at end of file diff --git a/app/contacts/page.tsx b/app/contacts/page.tsx index 2e60ad9..4f60949 100644 --- a/app/contacts/page.tsx +++ b/app/contacts/page.tsx @@ -1,5 +1,3 @@ -import LeadForm from "@/components/lead-form"; - export default function ContactsPage() { return (
@@ -87,14 +85,49 @@ export default function ContactsPage() { -
+
- +
+

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

+

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

+
+ +
diff --git a/app/page.tsx b/app/page.tsx index 93fddf4..9160861 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,5 @@ import Link from "next/link"; import Image from "next/image"; -import LeadForm from "@/components/lead-form"; import BarrierIcon from "@/components/barrier-icon"; import { ArrowRight, @@ -135,7 +134,7 @@ export default function Home() {
Получить консультацию @@ -411,11 +410,35 @@ 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 deleted file mode 100644 index 4fcee7e..0000000 --- a/components/lead-form.tsx +++ /dev/null @@ -1,317 +0,0 @@ -"use client"; - -import { useState } from "react"; - -type FieldErrors = { - company?: string; - phone?: string; - email?: string; -}; - -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); -} - -type LeadFormProps = { - id?: string; - title?: string; - description?: string; - compact?: boolean; -}; - -export default function LeadForm({ - id, - title, - description, - compact = false, -}: LeadFormProps) { - 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(""); - const [submitError, setSubmitError] = useState(false); - const [errors, setErrors] = useState({}); - - function validateFields() { - const nextErrors: FieldErrors = {}; - const normalizedPhone = normalizePhone(phone); - const trimmedCompany = company.trim(); - const trimmedEmail = email.trim(); - - if (trimmedCompany.length < 2) { - nextErrors.company = "Укажите название объекта или компании"; - } - - if (!normalizedPhone) { - nextErrors.phone = "Введите российский номер в формате +7"; - } - - if (!trimmedEmail) { - nextErrors.email = "Введите email"; - } else if (!isValidEmail(trimmedEmail)) { - nextErrors.email = "Проверьте корректность email"; - } - - return { - nextErrors, - normalizedPhone, - trimmedCompany, - trimmedEmail, - }; - } - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setResultMessage(""); - setSubmitError(false); - - const { nextErrors, normalizedPhone, trimmedCompany, trimmedEmail } = - 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({ - company: trimmedCompany, - phone: normalizedPhone, - email: trimmedEmail, - message: message.trim(), - }), - }); - - const data = await response.json(); - - if (!response.ok) { - setSubmitError(true); - setResultMessage(data.error || "Ошибка отправки"); - return; - } - - setSubmitError(false); - setResultMessage("Заявка отправлена. Мы свяжемся с вами."); - setCompany(""); - setPhone(""); - setEmail(""); - setMessage(""); - } 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} -

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

{errors.company}

- ) : ( -

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

- )} -
- -
- - { - 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" - required - /> - {errors.email ? ( -

{errors.email}

- ) : ( -

Отправим ответ и детали по этому адресу

- )} -
- -
- -