Удалить локальную CRM и Prisma, обновить Docker Compose и автодеплой Gitea
All checks were successful
Auto Deploy / deploy (push) Successful in 47s

This commit is contained in:
deonisii
2026-04-19 00:03:26 +03:00
parent e37b503187
commit 71ec905860
25 changed files with 92 additions and 2254 deletions

View File

@@ -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
docker image prune -f

View File

@@ -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"]
CMD ["node", "server.js"]

View File

@@ -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 (
<main className="min-h-screen bg-neutral-950 text-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10">
<div className="flex items-center justify-between gap-4 mb-8">
<h1 className="text-3xl font-bold">Заявки</h1>
<form action="/api/admin/logout" method="POST">
<button className="rounded-2xl border border-white/10 px-4 py-2 hover:bg-white/5">
Выйти
</button>
</form>
</div>
<form className="grid md:grid-cols-[1fr_220px_auto] gap-4 mb-6">
<input
type="text"
name="q"
defaultValue={q}
placeholder="Поиск: компания, телефон, email, сообщение"
className="rounded-2xl border border-white/10 bg-neutral-900 px-4 py-3 outline-none"
/>
<select
name="status"
defaultValue={status}
className="rounded-2xl border border-white/10 bg-neutral-900 px-4 py-3 outline-none"
>
<option value="">Все статусы</option>
<option value="NEW">Новая</option>
<option value="IN_PROGRESS">В работе</option>
<option value="CALL_SCHEDULED">Назначен звонок</option>
<option value="WON">Успешно</option>
<option value="LOST">Закрыта</option>
</select>
<button className="rounded-2xl bg-emerald-600 px-5 py-3 font-semibold hover:bg-emerald-500">
Найти
</button>
</form>
<div className="overflow-x-auto rounded-2xl border border-white/10 bg-neutral-900">
<table className="w-full text-sm">
<thead className="border-b border-white/10 text-neutral-400">
<tr>
<th className="text-left px-4 py-3">Дата</th>
<th className="text-left px-4 py-3">Заявка</th>
<th className="text-left px-4 py-3">Компания</th>
<th className="text-left px-4 py-3">Телефон</th>
<th className="text-left px-4 py-3">Email</th>
<th className="text-left px-4 py-3">Сообщение</th>
<th className="text-left px-4 py-3">Статус</th>
<th className="text-left px-4 py-3">Источник</th>
</tr>
</thead>
<tbody>
{leads.map((lead) => (
<tr key={lead.id} className="border-b border-white/5 align-top">
<td className="px-4 py-3 whitespace-nowrap">
{new Date(lead.createdAt).toLocaleString("ru-RU")}
</td>
<td className="px-4 py-3 whitespace-nowrap">
{formatLeadNumber(lead.id, lead.createdAt)}
</td>
<td className="px-4 py-3">{lead.company}</td>
<td className="px-4 py-3">{lead.phone}</td>
<td className="px-4 py-3">{lead.email || "—"}</td>
<td className="px-4 py-3 text-neutral-300">{lead.message || "—"}</td>
<td className="px-4 py-3">
<LeadStatusSelect leadId={lead.id} value={lead.status} />
</td>
<td className="px-4 py-3">{lead.source}</td>
</tr>
))}
{leads.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-neutral-400">
Пока заявок нет
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</main>
);
}

View File

@@ -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<HTMLFormElement>) {
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 (
<main className="min-h-screen bg-neutral-950 text-white flex items-center justify-center px-4">
<div className="w-full max-w-md rounded-3xl border border-white/10 bg-neutral-900 p-8">
<h1 className="text-3xl font-bold mb-2">Вход в CRM</h1>
<p className="text-neutral-400 mb-6">WorkParking CRM</p>
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => 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
/>
<input
type="password"
placeholder="Пароль"
value={password}
onChange={(e) => 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
/>
<button
type="submit"
disabled={isLoading}
className="w-full rounded-2xl bg-emerald-600 px-4 py-3 font-semibold hover:bg-emerald-500 disabled:opacity-60"
>
{isLoading ? "Входим..." : "Войти"}
</button>
{errorText && <p className="text-sm text-red-400">{errorText}</p>}
</form>
</div>
</main>
);
}

View File

@@ -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 });
}
}

View File

@@ -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;
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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,
});
}

View File

@@ -1,5 +1,3 @@
import LeadForm from "@/components/lead-form";
export default function ContactsPage() {
return (
<main className="bg-neutral-950">
@@ -87,14 +85,49 @@ export default function ContactsPage() {
</div>
</section>
<section className="pb-16 sm:pb-20">
<section id="contact-request" className="pb-16 sm:pb-20">
<div className="max-w-5xl mx-auto px-4 sm:px-6">
<div className="rounded-[32px] border border-white/10 bg-neutral-900 p-6 sm:p-10">
<LeadForm
id="lead-form"
title="Оставить заявку"
description="Заполните короткую форму. Мы свяжемся с вами, уточним задачу и предложим подходящий сценарий для объекта."
/>
<div className="max-w-3xl">
<h2 className="text-3xl font-bold tracking-[-0.04em] sm:text-4xl">
Обсудим ваш объект
</h2>
<p className="mt-4 text-base leading-relaxed text-neutral-400 sm:text-lg">
Мы убрали локальную CRM, базу заявок и админку из сайта. Дальше
сюда можно спокойно подключить внешнюю форму из EspoCRM или
любой другой системы, а пока используйте прямые контакты.
</p>
</div>
<div className="mt-8 grid gap-4 md:grid-cols-2">
<a
href="tel:+79999698149"
className="rounded-3xl border border-emerald-500/30 bg-emerald-500/10 px-5 py-5 transition-colors hover:bg-emerald-500/15"
>
<div className="text-sm uppercase tracking-[0.18em] text-emerald-300">
Телефон
</div>
<div className="mt-3 text-2xl font-semibold">
+7 (999) 969-81-49
</div>
<p className="mt-2 text-neutral-400">
Для обсуждения объекта, внедрения и тарифов
</p>
</a>
<a
href="mailto:sale@parkflow.ru"
className="rounded-3xl border border-white/10 bg-black/25 px-5 py-5 transition-colors hover:border-white/20 hover:bg-white/[0.03]"
>
<div className="text-sm uppercase tracking-[0.18em] text-neutral-400">
Email
</div>
<div className="mt-3 text-2xl font-semibold">sale@parkflow.ru</div>
<p className="mt-2 text-neutral-400">
Можно сразу прислать адрес объекта и краткое описание задачи
</p>
</a>
</div>
</div>
</div>
</section>

View File

@@ -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() {
<div className="mt-7 flex flex-col gap-3 sm:mt-8 sm:flex-row sm:gap-4">
<Link
href="/contacts#lead-form"
href="/contacts#contact-request"
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-emerald-600 px-5 py-3.5 text-sm font-semibold text-white transition-colors hover:bg-emerald-500 sm:px-6 sm:py-4 sm:text-base"
>
Получить консультацию
@@ -411,11 +410,35 @@ export default function Home() {
</div>
<div className="rounded-[28px] border border-white/10 bg-black/25 p-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] sm:p-6">
<LeadForm
title="Оставить заявку"
description="Ответим, уточним сценарий подключения и предложим вариант под ваш объект."
compact
/>
<h3 className="text-3xl font-bold tracking-[-0.05em]">
Свяжитесь с нами
</h3>
<p className="mt-4 text-base leading-relaxed text-neutral-400 sm:text-lg">
Дальше вы сможете подключить сюда форму из EspoCRM или другой
внешней CRM. Пока сайт остаётся чистым фронтом без своей базы
заявок и админки.
</p>
<div className="mt-8 grid gap-3">
<a
href="tel:+79999698149"
className="inline-flex items-center justify-center rounded-2xl bg-emerald-600 px-5 py-4 text-base font-semibold text-white transition-colors hover:bg-emerald-500"
>
Позвонить: +7 (999) 969-81-49
</a>
<a
href="mailto:sale@parkflow.ru"
className="inline-flex items-center justify-center rounded-2xl border border-white/15 bg-white/[0.02] px-5 py-4 text-base font-semibold transition-colors hover:border-white/30 hover:bg-white/5"
>
Написать: sale@parkflow.ru
</a>
<Link
href="/contacts#contact-request"
className="inline-flex items-center justify-center rounded-2xl border border-white/15 bg-white/[0.02] px-5 py-4 text-base font-semibold transition-colors hover:border-white/30 hover:bg-white/5"
>
Открыть страницу контактов
</Link>
</div>
</div>
</div>
</div>

View File

@@ -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<FieldErrors>({});
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<HTMLFormElement>) {
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 (
<div id={id}>
{(title || description) && (
<div className={compact ? "mb-6" : "mb-8"}>
{title && (
<h3 className="text-2xl font-semibold tracking-[-0.03em] sm:text-3xl">
{title}
</h3>
)}
{description && (
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-neutral-400 sm:text-base">
{description}
</p>
)}
</div>
)}
<form onSubmit={handleSubmit} className="grid gap-4 sm:gap-5" noValidate>
<div>
<label htmlFor="lead-company" className={labelClassName}>
Объект или компания
</label>
<input
id="lead-company"
type="text"
placeholder="Например: ЖК Сокол, ТСЖ Север, УК Домсервис"
value={company}
onChange={(e) => {
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 ? (
<p className={errorClassName}>{errors.company}</p>
) : (
<p className={helperClassName}>Укажите ЖК, ТСЖ, УК или адрес объекта</p>
)}
</div>
<div>
<label htmlFor="lead-phone" className={labelClassName}>
Телефон
</label>
<input
id="lead-phone"
type="tel"
placeholder="+7 (999) 123-45-67"
value={phone}
onChange={(e) => {
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 ? (
<p className={errorClassName}>{errors.phone}</p>
) : (
<p className={helperClassName}>Только мобильные и городские номера РФ</p>
)}
</div>
<div>
<label htmlFor="lead-email" className={labelClassName}>
Email
</label>
<input
id="lead-email"
type="email"
placeholder="name@company.ru"
value={email}
onChange={(e) => {
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 ? (
<p className={errorClassName}>{errors.email}</p>
) : (
<p className={helperClassName}>Отправим ответ и детали по этому адресу</p>
)}
</div>
<div>
<label htmlFor="lead-message" className={labelClassName}>
Комментарий
</label>
<textarea
id="lead-message"
placeholder="Коротко опишите объект, текущий шлагбаум и что хотите улучшить"
value={message}
onChange={(e) => setMessage(e.target.value)}
className="min-h-32 w-full rounded-2xl border border-white/10 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"
/>
</div>
<button
type="submit"
disabled={isSubmitting}
className="inline-flex items-center justify-center rounded-2xl bg-emerald-600 px-6 py-4 text-base font-semibold transition-colors hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60"
>
{isSubmitting ? "Отправка..." : "Оставить заявку"}
</button>
{resultMessage && (
<p className={`text-sm ${submitError ? "text-rose-300" : "text-emerald-300"}`}>
{resultMessage}
</p>
)}
</form>
</div>
);
}

View File

@@ -1,83 +0,0 @@
"use client";
import { useState } from "react";
const statuses = [
{ value: "NEW", label: "Новая" },
{ value: "IN_PROGRESS", label: "В работе" },
{ value: "CALL_SCHEDULED", label: "Назначен звонок" },
{ value: "WON", label: "Успешно" },
{ value: "LOST", label: "Закрыта" },
] as const;
function getStatusDot(value: string) {
switch (value) {
case "NEW":
return "bg-sky-400";
case "IN_PROGRESS":
return "bg-amber-400";
case "CALL_SCHEDULED":
return "bg-violet-400";
case "WON":
return "bg-emerald-400";
case "LOST":
return "bg-rose-400";
default:
return "bg-neutral-400";
}
}
export default function LeadStatusSelect({
leadId,
value,
}: {
leadId: string;
value: string;
}) {
const [status, setStatus] = useState(value);
const [isSaving, setIsSaving] = useState(false);
async function updateStatus(nextStatus: string) {
const prev = status;
setStatus(nextStatus);
setIsSaving(true);
try {
const response = await fetch(`/api/leads/${leadId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ status: nextStatus }),
});
if (!response.ok) {
setStatus(prev);
alert("Не удалось обновить статус");
}
} catch {
setStatus(prev);
alert("Ошибка сети");
} finally {
setIsSaving(false);
}
}
return (
<div className="flex items-center gap-2">
<span className={`inline-block h-2.5 w-2.5 rounded-full ${getStatusDot(status)}`} />
<select
value={status}
disabled={isSaving}
onChange={(e) => updateStatus(e.target.value)}
className="rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-sm outline-none"
>
{statuses.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</div>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { Menu, X, Send, MessageCircle, FileText } from "lucide-react";
import { Menu, X, Send, MessageCircle, Phone } from "lucide-react";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { createPortal } from "react-dom";
@@ -74,12 +74,12 @@ export default function MobileMenu({
<div className="flex-1 overflow-y-auto px-4 py-5">
<div className="mx-auto w-full max-w-none">
<Link
href="/contacts#lead-form"
href="/contacts#contact-request"
onClick={() => setMobileOpen(false)}
className="mb-6 flex w-full items-center justify-center gap-2 rounded-2xl bg-emerald-600 px-4 py-4 text-sm font-semibold text-white transition-colors hover:bg-emerald-500"
>
<FileText className="w-5 h-5" />
Оставить заявку
<Phone className="w-5 h-5" />
Связаться с нами
</Link>
<nav className="flex w-full flex-col rounded-3xl border border-white/10 bg-white/[0.03] px-4 py-1">

View File

@@ -1,37 +1,12 @@
services:
db:
image: postgres:16-alpine
container_name: workparking-db
restart: unless-stopped
environment:
POSTGRES_DB: workparking
POSTGRES_USER: workparking
POSTGRES_PASSWORD: change_me_strong_password
ports:
- "127.0.0.1:5432:5432"
volumes:
- workparking_pgdata:/var/lib/postgresql/data
workparking:
build:
context: .
args:
DATABASE_URL: postgresql://workparking:change_me_strong_password@db:5432/workparking?schema=public
container_name: workparking
restart: unless-stopped
environment:
NODE_ENV: production
PORT: 3000
HOSTNAME: 0.0.0.0
DATABASE_URL: postgresql://workparking:change_me_strong_password@db:5432/workparking?schema=public
ADMIN_EMAIL: admin@workparking.ru
ADMIN_PASSWORD: "vvEzQxqXzgjS-9oT"
ADMIN_SESSION_SECRET: "d9yyLuMk7xdNqNv2vxEXQzGHSc_ZcAM49NFfUKZJrFysyZ3Yb2"
CRM_HOST: crm.workparking.ru
ports:
- "127.0.0.1:3011:3000"
depends_on:
- db
volumes:
workparking_pgdata:

View File

@@ -1,71 +0,0 @@
const SESSION_COOKIE = "wp_admin_session";
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
function getSecret() {
const secret = process.env.ADMIN_SESSION_SECRET;
if (!secret) {
throw new Error("ADMIN_SESSION_SECRET is not set");
}
return secret;
}
function toHex(buffer: ArrayBuffer) {
return Array.from(new Uint8Array(buffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
async function sign(value: string) {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(getSecret()),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const signature = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(value)
);
return toHex(signature);
}
export async function createSessionToken(email: string) {
const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS;
const payload = `${email}.${expiresAt}`;
const signature = await sign(payload);
return `${payload}.${signature}`;
}
export async function verifySessionToken(token?: string | null) {
if (!token) return false;
const parts = token.split(".");
if (parts.length < 3) return false;
const signature = parts.pop()!;
const expiresAt = Number(parts.pop());
const email = parts.join(".");
if (!email || !expiresAt || Number.isNaN(expiresAt)) return false;
if (expiresAt < Math.floor(Date.now() / 1000)) return false;
const payload = `${email}.${expiresAt}`;
const expectedSignature = await sign(payload);
return signature === expectedSignature;
}
export function getSessionCookieName() {
return SESSION_COOKIE;
}
export function getAdminCredentials() {
return {
email: process.env.ADMIN_EMAIL || "",
password: process.env.ADMIN_PASSWORD || "",
};
}

View File

@@ -1,21 +0,0 @@
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const globalForPrisma = globalThis as unknown as {
prisma?: PrismaClient;
};
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
});
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
adapter,
log: ["error", "warn"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}

1229
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,14 +9,9 @@
"lint": "eslint"
},
"dependencies": {
"@prisma/adapter-pg": "^7.7.0",
"@prisma/client": "^7.7.0",
"dotenv": "^17.4.2",
"framer-motion": "^12.38.0",
"lucide-react": "^1.8.0",
"next": "16.2.4",
"pg": "^8.20.0",
"prisma": "^7.7.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-icons": "^5.6.0"

View File

@@ -1,12 +0,0 @@
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"),
},
});

View File

@@ -1,16 +0,0 @@
-- CreateEnum
CREATE TYPE "LeadStatus" AS ENUM ('NEW', 'IN_PROGRESS', 'CALL_SCHEDULED', 'WON', 'LOST');
-- CreateTable
CREATE TABLE "Lead" (
"id" TEXT NOT NULL,
"company" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"message" TEXT,
"source" TEXT NOT NULL DEFAULT 'website',
"status" "LeadStatus" NOT NULL DEFAULT 'NEW',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Lead_pkey" PRIMARY KEY ("id")
);

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Lead" ADD COLUMN "email" TEXT;

View File

@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -1,27 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
enum LeadStatus {
NEW
IN_PROGRESS
CALL_SCHEDULED
WON
LOST
}
model Lead {
id String @id @default(cuid())
company String
phone String
email String?
message String?
source String @default("website")
status LeadStatus @default(NEW)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@@ -1,57 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionCookieName, verifySessionToken } from "@/lib/auth";
function normalizeHost(host: string) {
return host.split(":")[0].toLowerCase();
}
export async function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname;
const search = request.nextUrl.search;
const host = normalizeHost(request.headers.get("host") || "");
const crmHost = (process.env.CRM_HOST || "crm.workparking.ru").toLowerCase();
const isCrmHost = host === crmHost;
const isAdminPath = pathname.startsWith("/admin");
const isLoginPage = pathname === "/admin/login";
const token = request.cookies.get(getSessionCookieName())?.value;
const isAuthed = await verifySessionToken(token);
if (isCrmHost && pathname === "/") {
const url = request.nextUrl.clone();
url.pathname = isAuthed ? "/admin/leads" : "/admin/login";
url.search = "";
return NextResponse.redirect(url);
}
if (isAdminPath && !isCrmHost) {
const url = request.nextUrl.clone();
url.protocol = "https";
url.hostname = crmHost;
url.port = "";
return NextResponse.redirect(url);
}
if (isAdminPath) {
if (!isAuthed && !isLoginPage) {
const loginUrl = request.nextUrl.clone();
loginUrl.pathname = "/admin/login";
loginUrl.search = `?next=${encodeURIComponent(`${pathname}${search}`)}`;
return NextResponse.redirect(loginUrl);
}
if (isAuthed && isLoginPage) {
const url = request.nextUrl.clone();
url.pathname = "/admin/leads";
url.search = "";
return NextResponse.redirect(url);
}
}
return NextResponse.next();
}
export const config = {
matcher: ["/", "/admin/:path*"],
};