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:
40
src/app/api/admin/group-bookings/route.ts
Normal file
40
src/app/api/admin/group-bookings/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
43
src/app/api/admin/open-day/bookings/route.ts
Normal file
43
src/app/api/admin/open-day/bookings/route.ts
Normal 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 });
|
||||
}
|
||||
54
src/app/api/admin/open-day/classes/route.ts
Normal file
54
src/app/api/admin/open-day/classes/route.ts
Normal 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 });
|
||||
}
|
||||
54
src/app/api/admin/open-day/route.ts
Normal file
54
src/app/api/admin/open-day/route.ts
Normal 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 });
|
||||
}
|
||||
6
src/app/api/admin/unread-counts/route.ts
Normal file
6
src/app/api/admin/unread-counts/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getUnreadBookingCounts } from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(getUnreadBookingCounts());
|
||||
}
|
||||
24
src/app/api/group-booking/route.ts
Normal file
24
src/app/api/group-booking/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
54
src/app/api/open-day-register/route.ts
Normal file
54
src/app/api/open-day-register/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user