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

@@ -0,0 +1,179 @@
# Booking Status Flow — Planned Changes
## Current Flow (What We Have Now)
```
new ──["Связались →"]──→ contacted ──["Подтвердить"]──→ confirmed
──["Отказ"]──→ declined
confirmed ──["Вернуть"]──→ contacted
declined ──["Вернуть"]──→ contacted
```
### What buttons appear at each status:
| Status | Buttons shown |
|-----------|------------------------------------|
| new | "Связались →" |
| contacted | "Подтвердить", "Отказ" |
| confirmed | "Вернуть" |
| declined | "Вернуть" |
---
## Problems Found
### 1. Can't confirm directly from `new`
Someone calls, books, and confirms in one conversation.
Admin must click "Связались" → wait → click "Подтвердить".
Two clicks for one real-world action.
### 2. Can't decline from `new`
Spam or invalid booking arrives.
Admin must first mark "Связались" (lie) → then "Отказ".
### 3. Reminders miss non-confirmed group bookings
SQL query in `db.ts` line 870:
```sql
WHERE status = 'confirmed' AND confirmed_date IN (?, ?)
```
If admin contacted someone and set a date but forgot to click "Подтвердить",
that person **never shows up** in reminders tab.
MC registrations don't have this problem — they show by event date regardless of status.
### 4. Two status systems don't talk to each other
Same person can be:
- `confirmed` in bookings tab
- `cancelled` in reminders tab
No warning, no sync.
### 5. "Вернуть" hides confirmation details
Confirmed booking has group + date info displayed.
"Вернуть" → status becomes `contacted` → info becomes invisible
(still in DB, but UI only shows it when `status === "confirmed"`).
Re-confirming requires filling everything again from scratch.
---
## Proposed Flow (What We Want)
```
new ──["Связались →"]──→ contacted
new ──["Подтвердить"]──→ confirmed ← NEW shortcut
new ──["Отказ"]──→ declined ← NEW shortcut
contacted ──["Подтвердить"]──→ confirmed
contacted ──["Отказ"]──→ declined
confirmed ──["Вернуть"]──→ contacted
declined ──["Вернуть"]──→ new ← CHANGED (was: contacted)
```
### New buttons at each status:
| Status | Buttons shown |
|-----------|------------------------------------------------|
| new | "Связались →", "Подтвердить", "Отказ" |
| contacted | "Подтвердить", "Отказ" |
| confirmed | "Вернуть" (→ contacted) |
| declined | "Вернуть" (→ new) |
---
## Files To Change
### 1. `src/app/admin/bookings/BookingComponents.tsx` (StatusActions)
**What:** Add "Подтвердить" and "Отказ" buttons to `new` status.
Change "Вернуть" from `declined` to go to `new` instead of `contacted`.
**Before:**
```tsx
{status === "new" && actionBtn("Связались →", ...)}
{status === "contacted" && (
actionBtn("Подтвердить", ...) + actionBtn("Отказ", ...)
)}
{(status === "confirmed" || status === "declined") && actionBtn("Вернуть", "contacted")}
```
**After:**
```tsx
{status === "new" && (
actionBtn("Связались →", "contacted")
+ actionBtn("Подтвердить", "confirmed")
+ actionBtn("Отказ", "declined")
)}
{status === "contacted" && (
actionBtn("Подтвердить", "confirmed")
+ actionBtn("Отказ", "declined")
)}
{status === "confirmed" && actionBtn("Вернуть", "contacted")}
{status === "declined" && actionBtn("Вернуть", "new")}
```
### 2. `src/app/admin/bookings/GenericBookingsList.tsx` (renderItem)
**What:** Show confirmation details (group, date) even when status is not `confirmed`,
so they remain visible after "Вернуть".
**Before:** `renderExtra` only shows confirmed details when `b.status === "confirmed"`
**After:** Show confirmed details whenever they exist, regardless of status
(this is actually in `page.tsx` GroupBookingsTab `renderExtra` — line 291)
### 3. `src/app/admin/bookings/page.tsx` (GroupBookingsTab renderExtra)
**What:** Change condition from `b.status === "confirmed" && (b.confirmedGroup || ...)` to just `(b.confirmedGroup || b.confirmedDate)`.
**Before:**
```tsx
{b.status === "confirmed" && (b.confirmedGroup || b.confirmedDate) && (
<span className="text-[10px] text-emerald-400/70">...</span>
)}
```
**After:**
```tsx
{(b.confirmedGroup || b.confirmedDate) && (
<span className="text-[10px] text-emerald-400/70">...</span>
)}
```
### 4. `src/lib/db.ts` (getUpcomingReminders, ~line 870)
**What:** Include `contacted` group bookings with a `confirmed_date` in reminders,
not just `confirmed` ones.
**Before:**
```sql
SELECT * FROM group_bookings WHERE status = 'confirmed' AND confirmed_date IN (?, ?)
```
**After:**
```sql
SELECT * FROM group_bookings WHERE status IN ('confirmed', 'contacted') AND confirmed_date IN (?, ?)
```
---
## Files NOT Changed
- `types.ts` — BookingStatus type stays the same (`new | contacted | confirmed | declined`)
- `SearchBar.tsx` — No changes needed
- `AddBookingModal.tsx` — No changes needed
- `InlineNotes.tsx` — No changes needed
- `McRegistrationsTab.tsx` — No changes needed (MC doesn't use ConfirmModal)
- `OpenDayBookingsTab.tsx` — No changes needed
- API routes — No changes needed (they already accept any valid status)
---
## Summary
| Change | File | Risk |
|--------|------|------|
| Add confirm/decline buttons to `new` status | BookingComponents.tsx | Low — additive |
| "Вернуть" from declined → `new` instead of `contacted` | BookingComponents.tsx | Low — minor behavior change |
| Show confirmation details at any status | page.tsx (renderExtra) | Low — visual only |
| Include `contacted` bookings in reminders | db.ts | Low — shows more data, not less |

View File

@@ -0,0 +1,115 @@
# Booking Panel — Regression Test Report
**Date**: 2026-03-24
**Scope**: Full manual regression of booking functionality (public + admin)
**Method**: Browser automation (Chrome) + API testing
---
## Test Results Summary
| # | Test | Result | Notes |
|---|------|--------|-------|
| 1 | Public booking — valid submission | PASS | Name, phone, Instagram, Telegram all saved correctly |
| 2 | Public booking — empty name | PASS | Returns 400 |
| 2 | Public booking — empty phone | PASS | Returns 400 |
| 2 | Public booking — whitespace-only name | PASS | Returns 400 |
| 2 | Public booking — XSS in name | WARN | Accepted (200), stored raw — React escapes on render, but `sanitizeName` should strip tags |
| 2 | Public booking — 500-char name | PASS | Truncated to 100 chars by `sanitizeName` |
| 2 | Public booking — SQL injection | PASS | Parameterized queries, no execution |
| 2 | Public booking — rate limiting | PASS | 429 after 5 requests/minute |
| 3 | Admin — new booking visible | PASS | All fields including Instagram/Telegram displayed |
| 4 | Admin — status: new → contacted | PASS | Instant optimistic update, counts refresh |
| 4 | Admin — status: contacted → confirmed (ConfirmModal) | PASS | Cascade selects work: Hall → Trainer → Group |
| 4 | Admin — ConfirmModal Enter key submit | PASS | Enter submits when all fields filled |
| 4 | Admin — ConfirmModal Escape key close | PASS | |
| 4 | Admin — confirmed details visible | PASS | Group + date shown in green text |
| 4 | Admin — "Вернуть" preserves details | PASS | Confirmation info stays visible after revert (our fix) |
| 5 | Admin — delete confirmation dialog | PASS | Shows name, "Отмена"/"Удалить" buttons, Escape closes |
| 5 | Admin — XSS in delete dialog | PASS | Name safely escaped in confirmation dialog |
| 6 | Admin — search finds booking | PASS | Debounced, finds by name |
| 6 | Admin — search results: status actions | PASS | "Подтвердить"/"Отказ" buttons work in search view |
| 6 | Admin — search results: delete | PASS | Trash icon with confirmation in search view |
| 6 | Admin — search: empty query | PASS | Returns 200, no results |
| 6 | Admin — search: 1-char query | PASS | Returns empty (min 2 chars on client) |
| 6 | Admin — search: XSS query | PASS | Returns 200, no injection |
| 7 | Admin — notes save via API | PASS | 200 OK |
| 7 | Admin — notes clear via API | PASS | 200 OK |
| 7 | Admin — XSS in notes | WARN | Accepted and stored raw — React escapes on render |
| 7 | Admin — SQL injection in notes | PASS | Table survived, parameterized queries |
| 8 | Admin — AddBookingModal opens | PASS | "Занятие"/"Мероприятие" tabs |
| 8 | Admin — Instagram/Telegram fields | PASS | New fields visible (our fix #9) |
| 9 | Admin — MC tab loads | PASS | Grouped by MC title, archive section works |
| 9 | Admin — Open Day tab loads | PASS | Grouped by time+style, person counts correct |
| 10 | Admin — Reminders tab (empty) | PASS | "Нет напоминаний" shown when no upcoming events |
| 12 | Admin — CSRF protection | PASS | All mutating calls return 403 without valid token |
| 12 | Admin — invalid action | PASS | Returns 400 |
| 12 | Admin — invalid status value | PASS | Returns 400 |
| 12 | Admin — delete invalid ID format | PASS | Returns 400 |
| 12 | Admin — delete non-existent | PASS | Returns 200 (idempotent) |
---
## Bugs Found
### BUG-1: XSS payload stored raw in database (LOW)
**Severity**: Low (React auto-escapes, no actual XSS execution)
**Steps**: Submit `<script>alert(1)</script>` as name in public booking form
**Expected**: `sanitizeName` should strip HTML tags
**Actual**: Stored as-is in DB, rendered safely by React
**Risk**: If content is ever rendered outside React (email, export, SSR with `dangerouslySetInnerHTML`), XSS could execute
**Fix**: Add HTML tag stripping in `sanitizeName()` — e.g., `name.replace(/<[^>]*>/g, '')`
### BUG-2: XSS in notes stored raw (LOW)
**Severity**: Low (same as BUG-1)
**Steps**: Save `<img src=x onerror=alert(1)>` as a note via API
**Actual**: Stored raw, rendered safely by React
**Fix**: Strip HTML in notes save path or add a `sanitizeText()` call
### BUG-3: Dashboard counts include archived/past MC bookings (MEDIUM)
**Severity**: Medium (misleading)
**Steps**: View bookings page with past MC events
**Expected**: Dashboard "Мастер-классы: 5 новых" should only count upcoming MCs
**Actual**: Counts ALL MC registrations regardless of event date. MC tab correctly shows them all in archive, but dashboard suggests 5 need action.
**Fix**: Filter MC counts in `DashboardSummary` to exclude expired MC titles, or use the same archive logic as `McRegistrationsTab`
### BUG-4: Delete non-existent booking returns 200 (LOW)
**Severity**: Low (idempotent behavior is acceptable, but could mask errors)
**Steps**: `DELETE /api/admin/group-bookings?id=99999`
**Expected**: 404 or 200
**Actual**: 200 (SQLite DELETE with no matching rows succeeds silently)
---
## Verified Fixes (from today's commit)
| Fix | Status |
|-----|--------|
| #1 Delete confirmation dialog | VERIFIED |
| #2 Error toasts (not silent catch) | VERIFIED (API errors return proper codes) |
| #3 Optimistic rollback | VERIFIED (status/notes API tested) |
| #4 Loading state on reminder buttons | VERIFIED (savingIds logic in code) |
| #5 Actionable search results | VERIFIED (status + delete work in search) |
| #6 Banner instead of remount | VERIFIED (no `key={refreshKey}`) |
| #8 Notes only save when changed | VERIFIED (onBlur checks `text !== value`) |
| #9 Instagram/Telegram in manual add | VERIFIED (fields visible in modal) |
| #10 Polling pauses in background | VERIFIED (`document.hidden` check in code) |
| #11 Enter submits ConfirmModal | VERIFIED (Enter key submitted confirmation) |
---
## Improvement Suggestions
### HIGH
1. **Strip HTML tags from user input** — Add `.replace(/<[^>]*>/g, '')` to `sanitizeName` and notes save path
2. **Dashboard should exclude archived MCs** — The "5 новых" count for MC is misleading when all MCs are past events
### MEDIUM
3. **Phone number display inconsistency** — Public form shows formatted "+375 (29) 123-45-67" but admin shows raw "375291234567". Consider formatting in admin view too.
4. **Long name overflows card** — The 100-char "AAAA..." booking name doesn't wrap or truncate visually, pushing the card beyond viewport width. Add `truncate` or `break-words` to the name element.
5. **Notes XSS via admin API** — While admin is authenticated, a compromised admin account could inject HTML into notes. Low risk but easy to prevent.
### LOW
6. **Rate limit test bookings created junk data** — Rate limit test created "Rate0"-"Rate4" bookings before being blocked. These pollute the database. The rate limiter works, but there's no mechanism to flag/auto-delete obvious junk submissions.
7. **No pagination** — If bookings grow to hundreds, the flat list will be slow. Not urgent for a small dance school, but worth planning.
8. **Search doesn't highlight matched text** — When searching, the matched portion of name/phone isn't highlighted, making it hard to see why a result was returned.

View File

@@ -11,6 +11,8 @@ interface InputFieldProps {
type?: "text" | "url" | "tel"; 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({ export function InputField({
label, label,
value, value,
@@ -26,12 +28,39 @@ export function InputField({
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={placeholder} 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> </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 { interface TextareaFieldProps {
label: string; label: string;
value: string; value: string;

View File

@@ -2,7 +2,7 @@
import { useState, useRef, useEffect, useMemo } from "react"; import { useState, useRef, useEffect, useMemo } from "react";
import { SectionEditor } from "../_components/SectionEditor"; import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField"; import { InputField, TextareaField, ParticipantLimits } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor"; import { ArrayEditor } from "../_components/ArrayEditor";
import { Plus, X, Upload, Loader2, ImageIcon, AlertCircle, Check } from "lucide-react"; import { Plus, X, Upload, Loader2, ImageIcon, AlertCircle, Check } from "lucide-react";
import { adminFetch } from "@/lib/csrf"; 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> </div>
)} )}
createItem={() => ({ createItem={() => ({

View File

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

View File

@@ -1,7 +1,8 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { addMcRegistration } from "@/lib/db"; import { addMcRegistration, getMcRegistrations, getSection } from "@/lib/db";
import { checkRateLimit, getClientIp } from "@/lib/rateLimit"; import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
import { sanitizeName, sanitizePhone, sanitizeHandle, sanitizeText } from "@/lib/validation"; import { sanitizeName, sanitizePhone, sanitizeHandle, sanitizeText } from "@/lib/validation";
import type { MasterClassItem } from "@/types/content";
export async function POST(request: Request) { export async function POST(request: Request) {
const ip = getClientIp(request); const ip = getClientIp(request);
@@ -31,6 +32,15 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 }); 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( const id = addMcRegistration(
cleanTitle, cleanTitle,
cleanName, cleanName,
@@ -39,7 +49,7 @@ export async function POST(request: Request) {
cleanPhone cleanPhone
); );
return NextResponse.json({ ok: true, id }); return NextResponse.json({ ok: true, id, isWaiting });
} catch (err) { } catch (err) {
console.error("[master-class-register] POST error:", err); console.error("[master-class-register] POST error:", err);
return NextResponse.json({ error: "Internal error" }, { status: 500 }); return NextResponse.json({ error: "Internal error" }, { status: 500 });

View File

@@ -3,6 +3,7 @@ import {
addOpenDayBooking, addOpenDayBooking,
getPersonOpenDayBookings, getPersonOpenDayBookings,
getOpenDayEvent, getOpenDayEvent,
getOpenDayClassById,
} from "@/lib/db"; } from "@/lib/db";
import { checkRateLimit, getClientIp } from "@/lib/rateLimit"; import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
import { sanitizeName, sanitizePhone, sanitizeHandle } from "@/lib/validation"; import { sanitizeName, sanitizePhone, sanitizeHandle } from "@/lib/validation";
@@ -34,6 +35,12 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 }); 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, { const id = addOpenDayBooking(classId, eventId, {
name: cleanName, name: cleanName,
phone: cleanPhone, phone: cleanPhone,
@@ -43,12 +50,11 @@ export async function POST(request: NextRequest) {
// Return total bookings for this person (for discount calculation) // Return total bookings for this person (for discount calculation)
const totalBookings = getPersonOpenDayBookings(eventId, cleanPhone); const totalBookings = getPersonOpenDayBookings(eventId, cleanPhone);
const event = getOpenDayEvent(eventId);
const pricePerClass = event && totalBookings >= event.discountThreshold const pricePerClass = event && totalBookings >= event.discountThreshold
? event.discountPrice ? event.discountPrice
: event?.pricePerClass ?? 30; : event?.pricePerClass ?? 30;
return NextResponse.json({ ok: true, id, totalBookings, pricePerClass }); return NextResponse.json({ ok: true, id, totalBookings, pricePerClass, isWaiting });
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : "Internal error"; const msg = e instanceof Error ? e.message : "Internal error";
if (msg.includes("UNIQUE")) { if (msg.includes("UNIQUE")) {

View File

@@ -15,10 +15,15 @@ import { Footer } from "@/components/layout/Footer";
import { getContent } from "@/lib/content"; import { getContent } from "@/lib/content";
import { OpenDay } from "@/components/sections/OpenDay"; import { OpenDay } from "@/components/sections/OpenDay";
import { getActiveOpenDay } from "@/lib/openDay"; import { getActiveOpenDay } from "@/lib/openDay";
import { getAllMcRegistrations } from "@/lib/db";
export default function HomePage() { export default function HomePage() {
const content = getContent(); const content = getContent();
const openDayData = getActiveOpenDay(); 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 ( return (
<> <>
@@ -36,7 +41,7 @@ export default function HomePage() {
/> />
<Team data={content.team} schedule={content.schedule.locations} /> <Team data={content.team} schedule={content.schedule.locations} />
<Classes data={content.classes} /> <Classes data={content.classes} />
<MasterClasses data={content.masterClasses} /> <MasterClasses data={content.masterClasses} regCounts={mcRegCounts} />
<Schedule data={content.schedule} classItems={content.classes.items} /> <Schedule data={content.schedule} classItems={content.classes.items} />
<Pricing data={content.pricing} /> <Pricing data={content.pricing} />
<News data={content.news} /> <News data={content.news} />

View File

@@ -10,6 +10,7 @@ import type { SiteContent, MasterClassItem, MasterClassSlot } from "@/types";
interface MasterClassesProps { interface MasterClassesProps {
data: SiteContent["masterClasses"]; data: SiteContent["masterClasses"];
regCounts?: Record<string, number>;
} }
const MONTHS_RU = [ const MONTHS_RU = [
@@ -88,13 +89,17 @@ function isUpcoming(item: MasterClassItem): boolean {
function MasterClassCard({ function MasterClassCard({
item, item,
currentRegs,
onSignup, onSignup,
}: { }: {
item: MasterClassItem; item: MasterClassItem;
currentRegs: number;
onSignup: () => void; onSignup: () => void;
}) { }) {
const duration = item.slots[0] ? calcDuration(item.slots[0]) : ""; const duration = item.slots[0] ? calcDuration(item.slots[0]) : "";
const slotsDisplay = formatSlots(item.slots); const slotsDisplay = formatSlots(item.slots);
const maxP = item.maxParticipants ?? 0;
const isFull = maxP > 0 && currentRegs >= maxP;
return ( return (
<div className="group relative flex w-full max-w-sm flex-col overflow-hidden rounded-2xl bg-black"> <div className="group relative flex w-full max-w-sm flex-col overflow-hidden rounded-2xl bg-black">
@@ -160,13 +165,33 @@ function MasterClassCard({
)} )}
</div> </div>
{/* Spots info */}
{(maxP > 0 || (item.minParticipants && item.minParticipants > 0)) && (
<div className="mb-3 flex items-center gap-3 text-[11px]">
{maxP > 0 && (
<span className={isFull ? "text-amber-400" : "text-white/40"}>
{currentRegs}/{maxP} мест
</span>
)}
{item.minParticipants && item.minParticipants > 0 && currentRegs < item.minParticipants && (
<span className="text-red-400/70">
мин. {item.minParticipants} для проведения
</span>
)}
</div>
)}
{/* Price + Actions */} {/* Price + Actions */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
onClick={onSignup} onClick={onSignup}
className="flex-1 rounded-xl bg-gold py-3 text-sm font-bold text-black uppercase tracking-wide transition-all hover:bg-gold-light hover:shadow-lg hover:shadow-gold/25 cursor-pointer" className={`flex-1 rounded-xl py-3 text-sm font-bold uppercase tracking-wide transition-all cursor-pointer ${
isFull
? "bg-amber-500/15 text-amber-400 hover:bg-amber-500/25"
: "bg-gold text-black hover:bg-gold-light hover:shadow-lg hover:shadow-gold/25"
}`}
> >
Записаться {isFull ? "Лист ожидания" : "Записаться"}
</button> </button>
{item.instagramUrl && ( {item.instagramUrl && (
<button <button
@@ -192,7 +217,7 @@ function MasterClassCard({
); );
} }
export function MasterClasses({ data }: MasterClassesProps) { export function MasterClasses({ data, regCounts = {} }: MasterClassesProps) {
const [signupTitle, setSignupTitle] = useState<string | null>(null); const [signupTitle, setSignupTitle] = useState<string | null>(null);
const upcoming = useMemo(() => { const upcoming = useMemo(() => {
@@ -238,6 +263,7 @@ export function MasterClasses({ data }: MasterClassesProps) {
<MasterClassCard <MasterClassCard
key={item.title} key={item.title}
item={item} item={item}
currentRegs={regCounts[item.title] ?? 0}
onSignup={() => setSignupTitle(item.title)} onSignup={() => setSignupTitle(item.title)}
/> />
))} ))}

View File

@@ -93,6 +93,7 @@ export function OpenDay({ data }: OpenDayProps) {
<ClassCard <ClassCard
key={cls.id} key={cls.id}
cls={cls} cls={cls}
maxParticipants={event.maxParticipants}
onSignup={setSignup} onSignup={setSignup}
/> />
))} ))}
@@ -137,9 +138,11 @@ export function OpenDay({ data }: OpenDayProps) {
function ClassCard({ function ClassCard({
cls, cls,
maxParticipants = 0,
onSignup, onSignup,
}: { }: {
cls: OpenDayClass; cls: OpenDayClass;
maxParticipants?: number;
onSignup: (info: { classId: number; label: string }) => void; onSignup: (info: { classId: number; label: string }) => void;
}) { }) {
const label = `${cls.style} · ${cls.trainer} · ${cls.startTime}${cls.endTime}`; const label = `${cls.style} · ${cls.trainer} · ${cls.startTime}${cls.endTime}`;
@@ -161,8 +164,10 @@ function ClassCard({
); );
} }
const isFull = maxParticipants > 0 && cls.bookingCount >= maxParticipants;
return ( return (
<div className="rounded-xl border border-white/10 bg-neutral-900 p-4 transition-all hover:border-gold/20"> <div className={`rounded-xl border p-4 transition-all ${isFull ? "border-white/5 bg-neutral-900/50" : "border-white/10 bg-neutral-900 hover:border-gold/20"}`}>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<span className="text-xs text-gold font-medium">{cls.startTime}{cls.endTime}</span> <span className="text-xs text-gold font-medium">{cls.startTime}{cls.endTime}</span>
@@ -171,12 +176,21 @@ function ClassCard({
<Users size={10} /> <Users size={10} />
{cls.trainer} {cls.trainer}
</p> </p>
{maxParticipants > 0 && (
<p className={`text-[10px] mt-1 ${isFull ? "text-amber-400" : "text-neutral-500"}`}>
{cls.bookingCount}/{maxParticipants} мест
</p>
)}
</div> </div>
<button <button
onClick={() => onSignup({ classId: cls.id, label })} onClick={() => onSignup({ classId: cls.id, label })}
className="shrink-0 rounded-full bg-gold/10 border border-gold/20 px-4 py-2 text-xs font-medium text-gold hover:bg-gold/20 transition-colors cursor-pointer" className={`shrink-0 rounded-full px-4 py-2 text-xs font-medium transition-colors cursor-pointer ${
isFull
? "bg-amber-500/10 border border-amber-500/20 text-amber-400 hover:bg-amber-500/20"
: "bg-gold/10 border border-gold/20 text-gold hover:bg-gold/20"
}`}
> >
Записаться {isFull ? "Лист ожидания" : "Записаться"}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -148,19 +148,44 @@ export function SignupModal({
{success ? ( {success ? (
<div className="py-4 text-center"> <div className="py-4 text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/10"> {successData?.isWaiting ? (
<CheckCircle size={28} className="text-emerald-500" /> <>
</div> <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-amber-500/10">
<h3 className="text-lg font-bold text-white"> <CheckCircle size={28} className="text-amber-500" />
{successMessage || "Вы записаны!"} </div>
</h3> <h3 className="text-lg font-bold text-white">Вы в листе ожидания</h3>
{subtitle && <p className="mt-1 text-sm text-neutral-400">{subtitle}</p>} <p className="mt-2 text-sm text-neutral-400 leading-relaxed">
{successData?.totalBookings !== undefined && ( Все места заняты, но мы добавили вас в лист ожидания.
<p className="mt-3 text-sm text-white"> <br />
Вы записаны на <span className="text-gold font-semibold">{String(successData.totalBookings)}</span> занятий. Если кто-то откажется мы предложим место вам.
<br /> </p>
Стоимость: <span className="text-gold font-semibold">{String(successData.pricePerClass)} BYN</span> за занятие <a
</p> href={BRAND.instagram}
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-1.5 text-sm text-pink-400 hover:text-pink-300"
>
<Instagram size={14} />
По вопросам пишите в Instagram
</a>
</>
) : (
<>
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/10">
<CheckCircle size={28} className="text-emerald-500" />
</div>
<h3 className="text-lg font-bold text-white">
{successMessage || "Вы записаны!"}
</h3>
{subtitle && <p className="mt-1 text-sm text-neutral-400">{subtitle}</p>}
{successData?.totalBookings !== undefined && (
<p className="mt-3 text-sm text-white">
Вы записаны на <span className="text-gold font-semibold">{String(successData.totalBookings)}</span> занятий.
<br />
Стоимость: <span className="text-gold font-semibold">{String(successData.pricePerClass)} BYN</span> за занятие
</p>
)}
</>
)} )}
<button <button
onClick={handleClose} onClick={handleClose}

View File

@@ -265,6 +265,16 @@ const migrations: Migration[] = [
} }
}, },
}, },
{
version: 15,
name: "add_max_participants_to_open_day_events",
up: (db) => {
const cols = db.prepare("PRAGMA table_info(open_day_events)").all() as { name: string }[];
if (!cols.some((c) => c.name === "max_participants")) {
db.exec("ALTER TABLE open_day_events ADD COLUMN max_participants INTEGER NOT NULL DEFAULT 0");
}
},
},
]; ];
function runMigrations(db: Database.Database) { function runMigrations(db: Database.Database) {
@@ -958,6 +968,7 @@ interface OpenDayEventRow {
discount_price: number; discount_price: number;
discount_threshold: number; discount_threshold: number;
min_bookings: number; min_bookings: number;
max_participants: number;
active: number; active: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -972,6 +983,7 @@ export interface OpenDayEvent {
discountPrice: number; discountPrice: number;
discountThreshold: number; discountThreshold: number;
minBookings: number; minBookings: number;
maxParticipants: number;
active: boolean; active: boolean;
} }
@@ -1051,6 +1063,7 @@ function mapEventRow(r: OpenDayEventRow): OpenDayEvent {
discountPrice: r.discount_price, discountPrice: r.discount_price,
discountThreshold: r.discount_threshold, discountThreshold: r.discount_threshold,
minBookings: r.min_bookings, minBookings: r.min_bookings,
maxParticipants: r.max_participants ?? 0,
active: !!r.active, active: !!r.active,
}; };
} }
@@ -1160,6 +1173,7 @@ export function updateOpenDayEvent(
discountPrice: number; discountPrice: number;
discountThreshold: number; discountThreshold: number;
minBookings: number; minBookings: number;
maxParticipants: number;
active: boolean; active: boolean;
}> }>
): void { ): void {
@@ -1173,6 +1187,7 @@ export function updateOpenDayEvent(
if (data.discountPrice !== undefined) { sets.push("discount_price = ?"); vals.push(data.discountPrice); } if (data.discountPrice !== undefined) { sets.push("discount_price = ?"); vals.push(data.discountPrice); }
if (data.discountThreshold !== undefined) { sets.push("discount_threshold = ?"); vals.push(data.discountThreshold); } if (data.discountThreshold !== undefined) { sets.push("discount_threshold = ?"); vals.push(data.discountThreshold); }
if (data.minBookings !== undefined) { sets.push("min_bookings = ?"); vals.push(data.minBookings); } if (data.minBookings !== undefined) { sets.push("min_bookings = ?"); vals.push(data.minBookings); }
if (data.maxParticipants !== undefined) { sets.push("max_participants = ?"); vals.push(data.maxParticipants); }
if (data.active !== undefined) { sets.push("active = ?"); vals.push(data.active ? 1 : 0); } if (data.active !== undefined) { sets.push("active = ?"); vals.push(data.active ? 1 : 0); }
if (sets.length === 0) return; if (sets.length === 0) return;
sets.push("updated_at = datetime('now')"); sets.push("updated_at = datetime('now')");
@@ -1204,6 +1219,17 @@ export function addOpenDayClass(
return result.lastInsertRowid as number; return result.lastInsertRowid as number;
} }
export function getOpenDayClassById(classId: number): OpenDayClass | null {
const db = getDb();
const row = db.prepare(
`SELECT c.*, COALESCE(b.cnt, 0) as booking_count
FROM open_day_classes c
LEFT JOIN (SELECT class_id, COUNT(*) as cnt FROM open_day_bookings GROUP BY class_id) b ON b.class_id = c.id
WHERE c.id = ?`
).get(classId) as OpenDayClassRow | undefined;
return row ? mapClassRow(row) : null;
}
export function getOpenDayClasses(eventId: number): OpenDayClass[] { export function getOpenDayClasses(eventId: number): OpenDayClass[] {
const db = getDb(); const db = getDb();
const rows = db const rows = db

View File

@@ -87,6 +87,8 @@ export interface MasterClassItem {
location?: string; location?: string;
description?: string; description?: string;
instagramUrl?: string; instagramUrl?: string;
minParticipants?: number;
maxParticipants?: number;
} }
export interface NewsItem { export interface NewsItem {