feat: min/max participants — shared ParticipantLimits component

- New ParticipantLimits component in FormField.tsx (reusable)
- Used in both Open Day settings and MC editor — identical layout
- Open Day: event-level min/max (DB migration 15)
- MC: per-event min/max (JSON fields)
- Public: waiting list when full, spots counter, amber success modal
This commit is contained in:
2026-03-24 22:11:10 +03:00
parent 4acc88c1ab
commit d08905ee93
13 changed files with 484 additions and 50 deletions

View File

@@ -11,6 +11,8 @@ interface InputFieldProps {
type?: "text" | "url" | "tel";
}
const inputCls = "w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors";
export function InputField({
label,
value,
@@ -26,12 +28,39 @@ export function InputField({
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"
className={inputCls}
/>
</div>
);
}
export function ParticipantLimits({
min,
max,
onMinChange,
onMaxChange,
}: {
min: number;
max: number;
onMinChange: (v: number) => void;
onMaxChange: (v: number) => void;
}) {
return (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Мин. участников</label>
<input type="number" min={0} value={min} onChange={(e) => onMinChange(parseInt(e.target.value) || 0)} className={inputCls} />
<p className="text-[10px] text-neutral-600 mt-1">Если записей меньше занятие можно отменить</p>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Макс. участников</label>
<input type="number" min={0} value={max} onChange={(e) => onMaxChange(parseInt(e.target.value) || 0)} className={inputCls} />
<p className="text-[10px] text-neutral-600 mt-1">0 = без лимита. При заполнении лист ожидания</p>
</div>
</div>
);
}
interface TextareaFieldProps {
label: string;
value: string;

View File

@@ -2,7 +2,7 @@
import { useState, useRef, useEffect, useMemo } from "react";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField";
import { InputField, TextareaField, ParticipantLimits } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
import { Plus, X, Upload, Loader2, ImageIcon, AlertCircle, Check } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
@@ -609,6 +609,13 @@ export default function MasterClassesEditorPage() {
}
/>
<ParticipantLimits
min={item.minParticipants ?? 0}
max={item.maxParticipants ?? 0}
onMinChange={(v) => updateItem({ ...item, minParticipants: v })}
onMaxChange={(v) => updateItem({ ...item, maxParticipants: v })}
/>
</div>
)}
createItem={() => ({

View File

@@ -5,6 +5,7 @@ import {
Plus, X, Loader2, Calendar, Trash2, Ban, CheckCircle2, RotateCcw,
} from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import { ParticipantLimits } from "../_components/FormField";
// --- Types ---
@@ -17,6 +18,7 @@ interface OpenDayEvent {
discountPrice: number;
discountThreshold: number;
minBookings: number;
maxParticipants: number;
active: boolean;
}
@@ -128,18 +130,12 @@ function EventSettings({
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Мин. записей на занятие</label>
<input
type="number"
value={event.minBookings}
onChange={(e) => onChange({ minBookings: parseInt(e.target.value) || 1 })}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors"
/>
<p className="text-[10px] text-neutral-600 mt-1">Если записей меньше занятие можно отменить</p>
</div>
</div>
<ParticipantLimits
min={event.minBookings}
max={event.maxParticipants ?? 0}
onMinChange={(v) => onChange({ minBookings: v })}
onMaxChange={(v) => onChange({ maxParticipants: v })}
/>
<div className="flex items-center gap-3 pt-1">
<button
@@ -185,13 +181,12 @@ function ClassCell({
const [trainer, setTrainer] = useState(cls.trainer);
const [style, setStyle] = useState(cls.style);
const [endTime, setEndTime] = useState(cls.endTime);
const [maxPart, setMaxPart] = useState(cls.maxParticipants);
const atRisk = cls.bookingCount < minBookings && !cls.cancelled;
function save() {
if (trainer.trim() && style.trim()) {
onUpdate(cls.id, { trainer: trainer.trim(), style: style.trim(), endTime, maxParticipants: maxPart });
onUpdate(cls.id, { trainer: trainer.trim(), style: style.trim(), endTime });
setEditing(false);
}
}
@@ -209,15 +204,9 @@ function ClassCell({
<option value="">Тренер...</option>
{trainers.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<div className="flex gap-2">
<div className="flex-1">
<label className="text-[10px] text-neutral-500 mb-0.5 block">До</label>
<input type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} className={selectCls} />
</div>
<div className="flex-1">
<label className="text-[10px] text-neutral-500 mb-0.5 block">Макс. чел.</label>
<input type="number" min={0} value={maxPart} onChange={(e) => setMaxPart(parseInt(e.target.value) || 0)} placeholder="0 = без лимита" className={selectCls} />
</div>
<div>
<label className="text-[10px] text-neutral-500 mb-0.5 block">До</label>
<input type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} className={selectCls} />
</div>
<div className="flex gap-1 justify-end">
<button onClick={() => setEditing(false)} className="text-[10px] text-neutral-500 hover:text-white px-1">
@@ -255,7 +244,7 @@ function ClassCell({
? "text-red-400"
: "text-emerald-400"
}`}>
{cls.bookingCount}{cls.maxParticipants > 0 ? `/${cls.maxParticipants}` : ""} чел.
{cls.bookingCount} чел.
</span>
{atRisk && !cls.cancelled && (
<span className="text-[9px] text-red-400">мин. {minBookings}</span>
@@ -500,6 +489,7 @@ export default function OpenDayAdminPage() {
discountPrice: 20,
discountThreshold: 3,
minBookings: 4,
maxParticipants: 0,
active: true,
});
}

View File

@@ -1,7 +1,8 @@
import { NextResponse } from "next/server";
import { addMcRegistration } from "@/lib/db";
import { addMcRegistration, getMcRegistrations, getSection } from "@/lib/db";
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
import { sanitizeName, sanitizePhone, sanitizeHandle, sanitizeText } from "@/lib/validation";
import type { MasterClassItem } from "@/types/content";
export async function POST(request: Request) {
const ip = getClientIp(request);
@@ -31,6 +32,15 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
}
// Check if MC is full — if so, booking goes to waiting list
const mcSection = getSection("masterClasses") as { items?: MasterClassItem[] } | null;
const mcItem = mcSection?.items?.find((mc) => mc.title === cleanTitle);
let isWaiting = false;
if (mcItem?.maxParticipants && mcItem.maxParticipants > 0) {
const currentRegs = getMcRegistrations(cleanTitle);
isWaiting = currentRegs.length >= mcItem.maxParticipants;
}
const id = addMcRegistration(
cleanTitle,
cleanName,
@@ -39,7 +49,7 @@ export async function POST(request: Request) {
cleanPhone
);
return NextResponse.json({ ok: true, id });
return NextResponse.json({ ok: true, id, isWaiting });
} catch (err) {
console.error("[master-class-register] POST error:", err);
return NextResponse.json({ error: "Internal error" }, { status: 500 });

View File

@@ -3,6 +3,7 @@ import {
addOpenDayBooking,
getPersonOpenDayBookings,
getOpenDayEvent,
getOpenDayClassById,
} from "@/lib/db";
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
import { sanitizeName, sanitizePhone, sanitizeHandle } from "@/lib/validation";
@@ -34,6 +35,12 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
}
// Check if class is full (event-level max) — if so, booking goes to waiting list
const cls = getOpenDayClassById(classId);
const event = getOpenDayEvent(eventId);
const maxP = event?.maxParticipants ?? 0;
const isWaiting = maxP > 0 && cls ? cls.bookingCount >= maxP : false;
const id = addOpenDayBooking(classId, eventId, {
name: cleanName,
phone: cleanPhone,
@@ -43,12 +50,11 @@ export async function POST(request: NextRequest) {
// 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 });
return NextResponse.json({ ok: true, id, totalBookings, pricePerClass, isWaiting });
} catch (e) {
const msg = e instanceof Error ? e.message : "Internal error";
if (msg.includes("UNIQUE")) {

View File

@@ -15,10 +15,15 @@ import { Footer } from "@/components/layout/Footer";
import { getContent } from "@/lib/content";
import { OpenDay } from "@/components/sections/OpenDay";
import { getActiveOpenDay } from "@/lib/openDay";
import { getAllMcRegistrations } from "@/lib/db";
export default function HomePage() {
const content = getContent();
const openDayData = getActiveOpenDay();
// Count MC registrations per title for capacity check
const allMcRegs = getAllMcRegistrations();
const mcRegCounts: Record<string, number> = {};
for (const reg of allMcRegs) mcRegCounts[reg.masterClassTitle] = (mcRegCounts[reg.masterClassTitle] || 0) + 1;
return (
<>
@@ -36,7 +41,7 @@ export default function HomePage() {
/>
<Team data={content.team} schedule={content.schedule.locations} />
<Classes data={content.classes} />
<MasterClasses data={content.masterClasses} />
<MasterClasses data={content.masterClasses} regCounts={mcRegCounts} />
<Schedule data={content.schedule} classItems={content.classes.items} />
<Pricing data={content.pricing} />
<News data={content.news} />