добавлена форма заявки тестовая, тестовая сттраница crm база данных
Some checks failed
Auto Deploy / deploy (push) Failing after 45s

This commit is contained in:
deonisii
2026-04-17 16:10:29 +03:00
parent b4325ec1fa
commit 64e8a4427d
15 changed files with 2051 additions and 45 deletions

2
.gitignore vendored
View File

@@ -39,3 +39,5 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
/app/generated/prisma

View File

@@ -9,6 +9,7 @@ FROM base AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
RUN npx prisma generate
RUN npm run build RUN npm run build
FROM base AS runner FROM base AS runner
@@ -19,6 +20,7 @@ ENV PORT=3000
ENV HOSTNAME=0.0.0.0 ENV HOSTNAME=0.0.0.0
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/.next/static ./.next/static

56
app/admin/leads/page.tsx Normal file
View File

@@ -0,0 +1,56 @@
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function AdminLeadsPage() {
const leads = await prisma.lead.findMany({
orderBy: { createdAt: "desc" },
});
return (
<main className="min-h-screen bg-neutral-950 text-white">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-10">
<h1 className="text-3xl font-bold mb-8">Заявки</h1>
<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">Статус</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">{lead.company}</td>
<td className="px-4 py-3">{lead.phone}</td>
<td className="px-4 py-3 text-neutral-300">
{lead.message || "—"}
</td>
<td className="px-4 py-3">{lead.status}</td>
<td className="px-4 py-3">{lead.source}</td>
</tr>
))}
{leads.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-neutral-400">
Пока заявок нет
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</main>
);
}

58
app/api/leads/route.ts Normal file
View File

@@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
type LeadPayload = {
company?: string;
phone?: 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 message = body.message?.trim() || "";
if (!company || !phone) {
return NextResponse.json(
{ error: "Компания и телефон обязательны" },
{ status: 400 }
);
}
const lead = await prisma.lead.create({
data: {
company,
phone,
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 }
);
}
}
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 }
);
}
}

10
app/api/test/route.ts Normal file
View File

@@ -0,0 +1,10 @@
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,6 @@
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import LeadForm from "@/components/lead-form";
import { import {
ArrowRight, ArrowRight,
Camera, Camera,
@@ -305,28 +306,8 @@ export default function Home() {
</p> </p>
</div> </div>
<form className="mt-8 grid gap-4 sm:gap-5"> <LeadForm />
<input
type="text"
placeholder="Название ЖК, ТСЖ или УК"
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"
/>
<input
type="tel"
placeholder="+7 (___) ___-__-__"
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"
/>
<textarea
placeholder="Опишите текущий шлагбаум и что хотите добавить: номерной доступ, приложение, история, аналитика"
className="min-h-32 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"
/>
<button
type="submit"
className="inline-flex items-center justify-center rounded-2xl bg-emerald-600 px-6 py-4 text-base font-semibold hover:bg-emerald-500 transition-colors"
>
Отправить заявку
</button>
</form>
</div> </div>
</div> </div>
</section> </section>

89
components/lead-form.tsx Normal file
View File

@@ -0,0 +1,89 @@
"use client";
import { useState } from "react";
export default function LeadForm() {
const [company, setCompany] = useState("");
const [phone, setPhone] = useState("");
const [message, setMessage] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [resultMessage, setResultMessage] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsSubmitting(true);
setResultMessage("");
try {
const response = await fetch("/api/leads", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
company,
phone,
message,
}),
});
const data = await response.json();
if (!response.ok) {
setResultMessage(data.error || "Ошибка отправки");
return;
}
setResultMessage("Заявка отправлена. Мы свяжемся с вами.");
setCompany("");
setPhone("");
setMessage("");
} catch (error) {
console.error(error);
setResultMessage("Ошибка сети. Попробуйте ещё раз.");
} finally {
setIsSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit} className="mt-8 grid gap-4 sm:gap-5">
<input
type="text"
placeholder="Название ЖК, ТСЖ или УК"
value={company}
onChange={(e) => setCompany(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
/>
<input
type="tel"
placeholder="+7 (___) ___-__-__"
value={phone}
onChange={(e) => 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
/>
<textarea
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-5 py-4 outline-none placeholder:text-neutral-500 focus:border-emerald-500"
/>
<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 hover:bg-emerald-500 transition-colors disabled:opacity-60"
>
{isSubmitting ? "Отправка..." : "Отправить заявку"}
</button>
{resultMessage && (
<p className="text-sm text-neutral-300">{resultMessage}</p>
)}
</form>
);
}

View File

@@ -1,4 +1,17 @@
services: 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: workparking:
build: . build: .
container_name: workparking container_name: workparking
@@ -7,5 +20,11 @@ services:
NODE_ENV: production NODE_ENV: production
PORT: 3000 PORT: 3000
HOSTNAME: 0.0.0.0 HOSTNAME: 0.0.0.0
DATABASE_URL: postgresql://workparking:change_me_strong_password@db:5432/workparking?schema=public
ports: ports:
- "127.0.0.1:3011:3000" - "127.0.0.1:3011:3000"
depends_on:
- db
volumes:
workparking_pgdata:

21
lib/prisma.ts Normal file
View File

@@ -0,0 +1,21 @@
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;
}

1750
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,9 +9,13 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.7.0",
"@prisma/client": "^7.7.0",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"next": "16.2.4", "next": "16.2.4",
"pg": "^8.20.0",
"prisma": "^7.7.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-icons": "^5.6.0" "react-icons": "^5.6.0"
@@ -24,6 +28,7 @@
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.4", "eslint-config-next": "16.2.4",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5" "typescript": "^5"
} }
} }

12
prisma.config.ts Normal file
View File

@@ -0,0 +1,12 @@
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

@@ -0,0 +1,16 @@
-- 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

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

26
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,26 @@
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
message String?
source String @default("website")
status LeadStatus @default(NEW)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}