feat: add booking management, Open Day, unified signup modal

- MC registrations: notification toggles (confirm/remind) with urgency
- Group bookings: save to DB from BookingModal, admin CRUD at /admin/bookings
- Open Day: full event system with schedule grid (halls × time), per-class
  booking, discount pricing (30 BYN / 20 BYN from 3+), auto-cancel threshold
- Unified SignupModal replaces 3 separate forms — consistent fields
  (name, phone, instagram, telegram), Instagram DM fallback on network error
- Centralized /admin/bookings page with 3 tabs (classes, MC, Open Day),
  collapsible sections, notification toggles, filter chips
- Unread booking badge on sidebar + dashboard widget with per-type breakdown
- Pricing: contact hint (Instagram/Telegram/phone) on price & rental tabs,
  admin toggle to show/hide
- DB migrations 5-7: group_bookings table, open_day tables, unified fields

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 12:58:04 +03:00
parent 7497ede2fd
commit b94ee69033
31 changed files with 3198 additions and 407 deletions

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { getGroupBookings, toggleGroupBookingNotification, deleteGroupBooking } from "@/lib/db";
export async function GET() {
const bookings = getGroupBookings();
return NextResponse.json(bookings);
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
if (body.action === "toggle-notify") {
const { id, field, value } = body;
if (!id || !field || typeof value !== "boolean") {
return NextResponse.json({ error: "id, field, value are required" }, { status: 400 });
}
if (field !== "notified_confirm" && field !== "notified_reminder") {
return NextResponse.json({ error: "Invalid field" }, { status: 400 });
}
toggleGroupBookingNotification(id, field, value);
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
} catch {
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
const idStr = request.nextUrl.searchParams.get("id");
if (!idStr) {
return NextResponse.json({ error: "id parameter is required" }, { status: 400 });
}
const id = parseInt(idStr, 10);
if (isNaN(id)) {
return NextResponse.json({ error: "Invalid id" }, { status: 400 });
}
deleteGroupBooking(id);
return NextResponse.json({ ok: true });
}

View File

@@ -1,13 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { getMcRegistrations, addMcRegistration, updateMcRegistration, deleteMcRegistration } from "@/lib/db";
import { getMcRegistrations, getAllMcRegistrations, addMcRegistration, updateMcRegistration, toggleMcNotification, deleteMcRegistration } from "@/lib/db";
export async function GET(request: NextRequest) {
const title = request.nextUrl.searchParams.get("title");
if (!title) {
return NextResponse.json({ error: "title parameter is required" }, { status: 400 });
if (title) {
return NextResponse.json(getMcRegistrations(title));
}
const registrations = getMcRegistrations(title);
return NextResponse.json(registrations);
// No title = return all registrations
return NextResponse.json(getAllMcRegistrations());
}
export async function POST(request: NextRequest) {
@@ -27,6 +27,21 @@ export async function POST(request: NextRequest) {
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
// Toggle notification status
if (body.action === "toggle-notify") {
const { id, field, value } = body;
if (!id || !field || typeof value !== "boolean") {
return NextResponse.json({ error: "id, field, value are required" }, { status: 400 });
}
if (field !== "notified_confirm" && field !== "notified_reminder") {
return NextResponse.json({ error: "Invalid field" }, { status: 400 });
}
toggleMcNotification(id, field, value);
return NextResponse.json({ ok: true });
}
// Regular update
const { id, name, instagram, telegram } = body;
if (!id || !name || !instagram) {
return NextResponse.json({ error: "id, name, instagram are required" }, { status: 400 });

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import {
getOpenDayBookings,
toggleOpenDayNotification,
deleteOpenDayBooking,
} from "@/lib/db";
export async function GET(request: NextRequest) {
const eventIdStr = request.nextUrl.searchParams.get("eventId");
if (!eventIdStr) return NextResponse.json({ error: "eventId is required" }, { status: 400 });
const eventId = parseInt(eventIdStr, 10);
if (isNaN(eventId)) return NextResponse.json({ error: "Invalid eventId" }, { status: 400 });
return NextResponse.json(getOpenDayBookings(eventId));
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
if (body.action === "toggle-notify") {
const { id, field, value } = body;
if (!id || !field || typeof value !== "boolean") {
return NextResponse.json({ error: "id, field, value required" }, { status: 400 });
}
if (field !== "notified_confirm" && field !== "notified_reminder") {
return NextResponse.json({ error: "Invalid field" }, { status: 400 });
}
toggleOpenDayNotification(id, field, value);
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
} catch {
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
const idStr = request.nextUrl.searchParams.get("id");
if (!idStr) return NextResponse.json({ error: "id is required" }, { status: 400 });
const id = parseInt(idStr, 10);
if (isNaN(id)) return NextResponse.json({ error: "Invalid id" }, { status: 400 });
deleteOpenDayBooking(id);
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import {
getOpenDayClasses,
addOpenDayClass,
updateOpenDayClass,
deleteOpenDayClass,
} from "@/lib/db";
export async function GET(request: NextRequest) {
const eventIdStr = request.nextUrl.searchParams.get("eventId");
if (!eventIdStr) return NextResponse.json({ error: "eventId is required" }, { status: 400 });
const eventId = parseInt(eventIdStr, 10);
if (isNaN(eventId)) return NextResponse.json({ error: "Invalid eventId" }, { status: 400 });
return NextResponse.json(getOpenDayClasses(eventId));
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { eventId, hall, startTime, endTime, trainer, style } = body;
if (!eventId || !hall || !startTime || !endTime || !trainer || !style) {
return NextResponse.json({ error: "All fields required" }, { status: 400 });
}
const id = addOpenDayClass(eventId, { hall, startTime, endTime, trainer, style });
return NextResponse.json({ ok: true, id });
} catch (e) {
const msg = e instanceof Error ? e.message : "Internal error";
if (msg.includes("UNIQUE")) {
return NextResponse.json({ error: "Этот слот уже занят" }, { status: 409 });
}
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
if (!body.id) return NextResponse.json({ error: "id is required" }, { status: 400 });
const { id, ...data } = body;
updateOpenDayClass(id, data);
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
const idStr = request.nextUrl.searchParams.get("id");
if (!idStr) return NextResponse.json({ error: "id is required" }, { status: 400 });
const id = parseInt(idStr, 10);
if (isNaN(id)) return NextResponse.json({ error: "Invalid id" }, { status: 400 });
deleteOpenDayClass(id);
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import {
getOpenDayEvents,
getOpenDayEvent,
createOpenDayEvent,
updateOpenDayEvent,
deleteOpenDayEvent,
} from "@/lib/db";
export async function GET(request: NextRequest) {
const idStr = request.nextUrl.searchParams.get("id");
if (idStr) {
const id = parseInt(idStr, 10);
if (isNaN(id)) return NextResponse.json({ error: "Invalid id" }, { status: 400 });
const event = getOpenDayEvent(id);
if (!event) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(event);
}
return NextResponse.json(getOpenDayEvents());
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
if (!body.date || typeof body.date !== "string") {
return NextResponse.json({ error: "date is required" }, { status: 400 });
}
const id = createOpenDayEvent(body);
return NextResponse.json({ ok: true, id });
} catch {
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
if (!body.id) return NextResponse.json({ error: "id is required" }, { status: 400 });
const { id, ...data } = body;
updateOpenDayEvent(id, data);
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
const idStr = request.nextUrl.searchParams.get("id");
if (!idStr) return NextResponse.json({ error: "id is required" }, { status: 400 });
const id = parseInt(idStr, 10);
if (isNaN(id)) return NextResponse.json({ error: "Invalid id" }, { status: 400 });
deleteOpenDayEvent(id);
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,6 @@
import { NextResponse } from "next/server";
import { getUnreadBookingCounts } from "@/lib/db";
export async function GET() {
return NextResponse.json(getUnreadBookingCounts());
}

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { addGroupBooking } from "@/lib/db";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { name, phone, groupInfo, instagram, telegram } = body;
if (!name || typeof name !== "string" || !phone || typeof phone !== "string") {
return NextResponse.json({ error: "name and phone are required" }, { status: 400 });
}
const cleanName = name.trim().slice(0, 100);
const cleanPhone = phone.trim().slice(0, 30);
const cleanGroup = typeof groupInfo === "string" ? groupInfo.trim().slice(0, 200) : undefined;
const cleanIg = typeof instagram === "string" ? instagram.trim().slice(0, 100) : undefined;
const cleanTg = typeof telegram === "string" ? telegram.trim().slice(0, 100) : undefined;
const id = addGroupBooking(cleanName, cleanPhone, cleanGroup, cleanIg, cleanTg);
return NextResponse.json({ ok: true, id });
} catch {
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}

View File

@@ -4,7 +4,7 @@ import { addMcRegistration } from "@/lib/db";
export async function POST(request: Request) {
try {
const body = await request.json();
const { masterClassTitle, name, instagram, telegram } = body;
const { masterClassTitle, name, phone, instagram, telegram } = body;
if (!masterClassTitle || typeof masterClassTitle !== "string" || masterClassTitle.length > 200) {
return NextResponse.json({ error: "masterClassTitle is required" }, { status: 400 });
@@ -12,18 +12,20 @@ export async function POST(request: Request) {
if (!name || typeof name !== "string" || !name.trim() || name.length > 100) {
return NextResponse.json({ error: "name is required (max 100 chars)" }, { status: 400 });
}
if (!instagram || typeof instagram !== "string" || !instagram.trim() || instagram.length > 100) {
return NextResponse.json({ error: "Instagram аккаунт обязателен" }, { status: 400 });
}
if (telegram && (typeof telegram !== "string" || telegram.length > 100)) {
return NextResponse.json({ error: "Telegram too long" }, { status: 400 });
if (!phone || typeof phone !== "string" || !phone.trim()) {
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
}
const cleanIg = instagram && typeof instagram === "string" ? instagram.trim().slice(0, 100) : "";
const cleanTg = telegram && typeof telegram === "string" ? telegram.trim().slice(0, 100) : undefined;
const cleanPhone = phone.trim().slice(0, 30);
const id = addMcRegistration(
masterClassTitle.trim().slice(0, 200),
name.trim().slice(0, 100),
instagram.trim().slice(0, 100),
telegram && typeof telegram === "string" ? telegram.trim().slice(0, 100) : undefined
cleanIg,
cleanTg,
cleanPhone
);
return NextResponse.json({ ok: true, id });

View File

@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import {
addOpenDayBooking,
isOpenDayClassBookedByPhone,
getPersonOpenDayBookings,
getOpenDayEvent,
} from "@/lib/db";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { classId, eventId, name, phone, instagram, telegram } = body;
if (!classId || !eventId || !name || !phone) {
return NextResponse.json({ error: "classId, eventId, name, phone are required" }, { status: 400 });
}
const cleanName = (name as string).trim().slice(0, 100);
const cleanPhone = (phone as string).replace(/\D/g, "").slice(0, 15);
const cleanIg = instagram ? (instagram as string).trim().slice(0, 100) : undefined;
const cleanTg = telegram ? (telegram as string).trim().slice(0, 100) : undefined;
if (!cleanPhone) {
return NextResponse.json({ error: "Invalid phone" }, { status: 400 });
}
// Check not already booked
if (isOpenDayClassBookedByPhone(classId, cleanPhone)) {
return NextResponse.json({ error: "Вы уже записаны на это занятие" }, { status: 409 });
}
const id = addOpenDayBooking(classId, eventId, {
name: cleanName,
phone: cleanPhone,
instagram: cleanIg,
telegram: cleanTg,
});
// Return total bookings for this person (for discount calculation)
const totalBookings = getPersonOpenDayBookings(eventId, cleanPhone);
const event = getOpenDayEvent(eventId);
const pricePerClass = event && totalBookings >= event.discountThreshold
? event.discountPrice
: event?.pricePerClass ?? 30;
return NextResponse.json({ ok: true, id, totalBookings, pricePerClass });
} catch (e) {
const msg = e instanceof Error ? e.message : "Internal error";
if (msg.includes("UNIQUE")) {
return NextResponse.json({ error: "Вы уже записаны на это занятие" }, { status: 409 });
}
return NextResponse.json({ error: msg }, { status: 500 });
}
}