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:
@@ -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;
|
||||
|
||||
@@ -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={() => ({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { SiteContent, MasterClassItem, MasterClassSlot } from "@/types";
|
||||
|
||||
interface MasterClassesProps {
|
||||
data: SiteContent["masterClasses"];
|
||||
regCounts?: Record<string, number>;
|
||||
}
|
||||
|
||||
const MONTHS_RU = [
|
||||
@@ -88,13 +89,17 @@ function isUpcoming(item: MasterClassItem): boolean {
|
||||
|
||||
function MasterClassCard({
|
||||
item,
|
||||
currentRegs,
|
||||
onSignup,
|
||||
}: {
|
||||
item: MasterClassItem;
|
||||
currentRegs: number;
|
||||
onSignup: () => void;
|
||||
}) {
|
||||
const duration = item.slots[0] ? calcDuration(item.slots[0]) : "";
|
||||
const slotsDisplay = formatSlots(item.slots);
|
||||
const maxP = item.maxParticipants ?? 0;
|
||||
const isFull = maxP > 0 && currentRegs >= maxP;
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
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>
|
||||
{item.instagramUrl && (
|
||||
<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 upcoming = useMemo(() => {
|
||||
@@ -238,6 +263,7 @@ export function MasterClasses({ data }: MasterClassesProps) {
|
||||
<MasterClassCard
|
||||
key={item.title}
|
||||
item={item}
|
||||
currentRegs={regCounts[item.title] ?? 0}
|
||||
onSignup={() => setSignupTitle(item.title)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -93,6 +93,7 @@ export function OpenDay({ data }: OpenDayProps) {
|
||||
<ClassCard
|
||||
key={cls.id}
|
||||
cls={cls}
|
||||
maxParticipants={event.maxParticipants}
|
||||
onSignup={setSignup}
|
||||
/>
|
||||
))}
|
||||
@@ -137,9 +138,11 @@ export function OpenDay({ data }: OpenDayProps) {
|
||||
|
||||
function ClassCard({
|
||||
cls,
|
||||
maxParticipants = 0,
|
||||
onSignup,
|
||||
}: {
|
||||
cls: OpenDayClass;
|
||||
maxParticipants?: number;
|
||||
onSignup: (info: { classId: number; label: string }) => void;
|
||||
}) {
|
||||
const label = `${cls.style} · ${cls.trainer} · ${cls.startTime}–${cls.endTime}`;
|
||||
@@ -161,8 +164,10 @@ function ClassCard({
|
||||
);
|
||||
}
|
||||
|
||||
const isFull = maxParticipants > 0 && cls.bookingCount >= maxParticipants;
|
||||
|
||||
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-1 min-w-0">
|
||||
<span className="text-xs text-gold font-medium">{cls.startTime}–{cls.endTime}</span>
|
||||
@@ -171,12 +176,21 @@ function ClassCard({
|
||||
<Users size={10} />
|
||||
{cls.trainer}
|
||||
</p>
|
||||
{maxParticipants > 0 && (
|
||||
<p className={`text-[10px] mt-1 ${isFull ? "text-amber-400" : "text-neutral-500"}`}>
|
||||
{cls.bookingCount}/{maxParticipants} мест
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -148,19 +148,44 @@ export function SignupModal({
|
||||
|
||||
{success ? (
|
||||
<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">
|
||||
<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>
|
||||
{successData?.isWaiting ? (
|
||||
<>
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-amber-500/10">
|
||||
<CheckCircle size={28} className="text-amber-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white">Вы в листе ожидания</h3>
|
||||
<p className="mt-2 text-sm text-neutral-400 leading-relaxed">
|
||||
Все места заняты, но мы добавили вас в лист ожидания.
|
||||
<br />
|
||||
Если кто-то откажется — мы предложим место вам.
|
||||
</p>
|
||||
<a
|
||||
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
|
||||
onClick={handleClose}
|
||||
|
||||
@@ -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) {
|
||||
@@ -958,6 +968,7 @@ interface OpenDayEventRow {
|
||||
discount_price: number;
|
||||
discount_threshold: number;
|
||||
min_bookings: number;
|
||||
max_participants: number;
|
||||
active: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -972,6 +983,7 @@ export interface OpenDayEvent {
|
||||
discountPrice: number;
|
||||
discountThreshold: number;
|
||||
minBookings: number;
|
||||
maxParticipants: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
@@ -1051,6 +1063,7 @@ function mapEventRow(r: OpenDayEventRow): OpenDayEvent {
|
||||
discountPrice: r.discount_price,
|
||||
discountThreshold: r.discount_threshold,
|
||||
minBookings: r.min_bookings,
|
||||
maxParticipants: r.max_participants ?? 0,
|
||||
active: !!r.active,
|
||||
};
|
||||
}
|
||||
@@ -1160,6 +1173,7 @@ export function updateOpenDayEvent(
|
||||
discountPrice: number;
|
||||
discountThreshold: number;
|
||||
minBookings: number;
|
||||
maxParticipants: number;
|
||||
active: boolean;
|
||||
}>
|
||||
): void {
|
||||
@@ -1173,6 +1187,7 @@ export function updateOpenDayEvent(
|
||||
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.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 (sets.length === 0) return;
|
||||
sets.push("updated_at = datetime('now')");
|
||||
@@ -1204,6 +1219,17 @@ export function addOpenDayClass(
|
||||
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[] {
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
|
||||
@@ -87,6 +87,8 @@ export interface MasterClassItem {
|
||||
location?: string;
|
||||
description?: string;
|
||||
instagramUrl?: string;
|
||||
minParticipants?: number;
|
||||
maxParticipants?: number;
|
||||
}
|
||||
|
||||
export interface NewsItem {
|
||||
|
||||
Reference in New Issue
Block a user