Compare commits

...

2 Commits

8 changed files with 140 additions and 44 deletions
+4 -4
View File
@@ -191,7 +191,7 @@ export function SelectField({
return ( return (
<div ref={containerRef} className="relative"> <div ref={containerRef} className="relative">
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label> {label && <label className="block text-sm text-neutral-400 mb-1.5">{label}</label>}
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -199,9 +199,9 @@ export function SelectField({
setSearch(""); setSearch("");
setTimeout(() => inputRef.current?.focus(), 0); setTimeout(() => inputRef.current?.focus(), 0);
}} }}
className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-left outline-none transition-colors ${ className={`w-full rounded-lg border bg-neutral-800 text-left outline-none transition-colors ${
open ? "border-gold" : "border-white/10" label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
} ${value ? "text-white" : "text-neutral-500"}`} } ${open ? "border-gold" : "border-white/10"} ${value ? "text-white" : "text-neutral-500"}`}
> >
{selectedLabel || placeholder || "Выберите..."} {selectedLabel || placeholder || "Выберите..."}
</button> </button>
+8
View File
@@ -525,6 +525,14 @@ export default function MasterClassesEditorPage() {
placeholder="Вы записаны! Мы свяжемся с вами" placeholder="Вы записаны! Мы свяжемся с вами"
/> />
<TextareaField
label="Текст для листа ожидания"
value={data.waitingListText || ""}
onChange={(v) => update({ ...data, waitingListText: v || undefined })}
placeholder="Все места заняты, но мы добавили вас в лист ожидания..."
rows={2}
/>
<ArrayEditor <ArrayEditor
label="Мастер-классы" label="Мастер-классы"
items={data.items} items={data.items}
+96 -36
View File
@@ -5,7 +5,7 @@ import {
Plus, X, Loader2, Calendar, Trash2, Ban, CheckCircle2, RotateCcw, Sparkles, Plus, X, Loader2, Calendar, Trash2, Ban, CheckCircle2, RotateCcw, Sparkles,
} from "lucide-react"; } from "lucide-react";
import { adminFetch } from "@/lib/csrf"; import { adminFetch } from "@/lib/csrf";
import { ParticipantLimits } from "../_components/FormField"; import { ParticipantLimits, SelectField } from "../_components/FormField";
// --- Types --- // --- Types ---
@@ -19,6 +19,8 @@ interface OpenDayEvent {
discountThreshold: number; discountThreshold: number;
minBookings: number; minBookings: number;
maxParticipants: number; maxParticipants: number;
successMessage?: string;
waitingListText?: string;
active: boolean; active: boolean;
} }
@@ -100,6 +102,29 @@ 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>
<textarea
value={event.successMessage || ""}
onChange={(e) => onChange({ successMessage: e.target.value || undefined })}
rows={2}
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 resize-none"
placeholder="Вы записаны!"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Текст для листа ожидания</label>
<textarea
value={event.waitingListText || ""}
onChange={(e) => onChange({ waitingListText: e.target.value || undefined })}
rows={2}
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 resize-none"
placeholder="Все места заняты, но мы добавили вас в лист ожидания..."
/>
</div>
</div>
<div> <div>
<label className="block text-sm text-neutral-400 mb-1.5">Цена за занятие (BYN)</label> <label className="block text-sm text-neutral-400 mb-1.5">Цена за занятие (BYN)</label>
<input <input
@@ -179,6 +204,57 @@ function EventSettings({
); );
} }
// --- New Class Form (create only on save) ---
function NewClassForm({
startTime,
trainers,
styles,
onSave,
onCancel,
}: {
startTime: string;
trainers: string[];
styles: string[];
onSave: (data: { trainer: string; style: string; endTime: string }) => void;
onCancel: () => void;
}) {
const [style, setStyle] = useState("");
const [trainer, setTrainer] = useState("");
const endTime = addHour(startTime);
const formRef = useRef<HTMLDivElement>(null);
useEffect(() => {
formRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}, []);
// Auto-save on click outside
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (formRef.current && !formRef.current.contains(e.target as Node)) {
if (style && trainer) onSave({ trainer, style, endTime });
else onCancel();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [style, trainer, endTime, onSave, onCancel]);
const canSave = style && trainer;
return (
<div ref={formRef} className="p-2 space-y-1.5 ring-1 ring-gold/30 rounded-lg">
<SelectField label="" value={style} onChange={setStyle} options={styles.map((s) => ({ value: s, label: s }))} placeholder="Стиль..." />
<SelectField label="" value={trainer} onChange={setTrainer} options={trainers.map((t) => ({ value: t, label: t }))} placeholder="Тренер..." />
<div className="flex gap-1 justify-end">
<button onClick={onCancel} className="text-[10px] text-neutral-500 hover:text-white px-1">Отмена</button>
<button onClick={() => canSave && onSave({ trainer, style, endTime })} disabled={!canSave}
className="text-[10px] text-gold hover:text-gold-light px-1 font-medium disabled:opacity-30 disabled:cursor-not-allowed">OK</button>
</div>
</div>
);
}
// --- Class Grid Cell --- // --- Class Grid Cell ---
function ClassCell({ function ClassCell({
@@ -186,7 +262,6 @@ function ClassCell({
minBookings, minBookings,
trainers, trainers,
styles, styles,
autoEdit,
onUpdate, onUpdate,
onDelete, onDelete,
onCancel, onCancel,
@@ -195,49 +270,28 @@ function ClassCell({
minBookings: number; minBookings: number;
trainers: string[]; trainers: string[];
styles: string[]; styles: string[];
autoEdit?: boolean;
onUpdate: (id: number, data: Partial<OpenDayClass>) => void; onUpdate: (id: number, data: Partial<OpenDayClass>) => void;
onDelete: (id: number) => void; onDelete: (id: number) => void;
onCancel: (id: number) => void; onCancel: (id: number) => void;
}) { }) {
const [editing, setEditing] = useState(!!autoEdit); const [editing, setEditing] = useState(false);
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 cellRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (autoEdit && cellRef.current) {
cellRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, [autoEdit]);
const [endTime, setEndTime] = useState(cls.endTime);
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 }); onUpdate(cls.id, { trainer: trainer.trim(), style: style.trim() });
setEditing(false); setEditing(false);
} }
} }
const selectCls = "w-full rounded-md border border-white/10 bg-neutral-800 px-2 py-1 text-xs text-white outline-none focus:border-gold [color-scheme:dark]";
if (editing) { if (editing) {
return ( return (
<div ref={cellRef} className="p-2 space-y-1.5 ring-1 ring-gold/30 rounded-lg"> <div className="p-2 space-y-1.5 rounded-lg">
<select value={style} onChange={(e) => setStyle(e.target.value)} className={selectCls}> <SelectField label="" value={style} onChange={setStyle} options={styles.map((s) => ({ value: s, label: s }))} placeholder="Стиль..." />
<option value="">Стиль...</option> <SelectField label="" value={trainer} onChange={setTrainer} options={trainers.map((t) => ({ value: t, label: t }))} placeholder="Тренер..." />
{styles.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<select value={trainer} onChange={(e) => setTrainer(e.target.value)} className={selectCls}>
<option value="">Тренер...</option>
{trainers.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<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"> <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">
Отмена Отмена
@@ -341,16 +395,15 @@ function ScheduleGrid({
return counts; return counts;
}, [classes, halls]); }, [classes, halls]);
const [newClassTime, setNewClassTime] = useState<string | null>(null); const [creatingTime, setCreatingTime] = useState<string | null>(null);
async function addClass(startTime: string) { async function confirmCreate(startTime: string, data: { trainer: string; style: string; endTime: string }) {
const endTime = addHour(startTime);
await adminFetch("/api/admin/open-day/classes", { await adminFetch("/api/admin/open-day/classes", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ eventId, hall: selectedHall, startTime, endTime, trainer: "—", style: "—" }), body: JSON.stringify({ eventId, hall: selectedHall, startTime, endTime: data.endTime, trainer: data.trainer, style: data.style }),
}); });
setNewClassTime(startTime); setCreatingTime(null);
onClassesChange(); onClassesChange();
} }
@@ -418,14 +471,21 @@ function ScheduleGrid({
minBookings={minBookings} minBookings={minBookings}
trainers={trainers} trainers={trainers}
styles={styles} styles={styles}
autoEdit={cls.startTime === newClassTime} onUpdate={updateClass}
onUpdate={(id, data) => { setNewClassTime(null); updateClass(id, data); }}
onDelete={deleteClass} onDelete={deleteClass}
onCancel={cancelClass} onCancel={cancelClass}
/> />
) : creatingTime === time ? (
<NewClassForm
startTime={time}
trainers={trainers}
styles={styles}
onSave={(data) => confirmCreate(time, data)}
onCancel={() => setCreatingTime(null)}
/>
) : ( ) : (
<button <button
onClick={() => addClass(time)} onClick={() => setCreatingTime(time)}
className="w-full rounded-lg border border-dashed border-white/5 p-2 text-neutral-600 hover:text-gold hover:border-gold/20 transition-colors" className="w-full rounded-lg border border-dashed border-white/5 p-2 text-neutral-600 hover:text-gold hover:border-gold/20 transition-colors"
> >
<Plus size={12} className="mx-auto" /> <Plus size={12} className="mx-auto" />
@@ -279,6 +279,7 @@ export function MasterClasses({ data, regCounts = {} }: MasterClassesProps) {
endpoint="/api/master-class-register" endpoint="/api/master-class-register"
extraBody={{ masterClassTitle: signupTitle }} extraBody={{ masterClassTitle: signupTitle }}
successMessage={data.successMessage} successMessage={data.successMessage}
waitingMessage={data.waitingListText}
/> />
</section> </section>
); );
+2
View File
@@ -132,6 +132,8 @@ export function OpenDay({ data }: OpenDayProps) {
subtitle={signup.label} subtitle={signup.label}
endpoint="/api/open-day-register" endpoint="/api/open-day-register"
extraBody={{ classId: signup.classId, eventId: event.id }} extraBody={{ classId: signup.classId, eventId: event.id }}
successMessage={event.successMessage}
waitingMessage={event.waitingListText}
/> />
)} )}
</section> </section>
+5 -4
View File
@@ -16,6 +16,8 @@ interface SignupModalProps {
extraBody?: Record<string, unknown>; extraBody?: Record<string, unknown>;
/** Custom success message */ /** Custom success message */
successMessage?: string; successMessage?: string;
/** Custom waiting list message */
waitingMessage?: string;
/** Callback with API response data on success */ /** Callback with API response data on success */
onSuccess?: (data: Record<string, unknown>) => void; onSuccess?: (data: Record<string, unknown>) => void;
} }
@@ -28,6 +30,7 @@ export function SignupModal({
endpoint, endpoint,
extraBody, extraBody,
successMessage, successMessage,
waitingMessage,
onSuccess, onSuccess,
}: SignupModalProps) { }: SignupModalProps) {
const [name, setName] = useState(""); const [name, setName] = useState("");
@@ -154,10 +157,8 @@ export function SignupModal({
<CheckCircle size={28} className="text-amber-500" /> <CheckCircle size={28} className="text-amber-500" />
</div> </div>
<h3 className="text-lg font-bold text-white">Вы в листе ожидания</h3> <h3 className="text-lg font-bold text-white">Вы в листе ожидания</h3>
<p className="mt-2 text-sm text-neutral-400 leading-relaxed"> <p className="mt-2 text-sm text-neutral-400 leading-relaxed whitespace-pre-line">
Все места заняты, но мы добавили вас в лист ожидания. {waitingMessage || "Все места заняты, но мы добавили вас в лист ожидания.\nЕсли кто-то откажется — мы предложим место вам."}
<br />
Если кто-то откажется мы предложим место вам.
</p> </p>
<a <a
href={BRAND.instagram} href={BRAND.instagram}
+23
View File
@@ -275,6 +275,19 @@ const migrations: Migration[] = [
} }
}, },
}, },
{
version: 16,
name: "add_messages_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 === "success_message")) {
db.exec("ALTER TABLE open_day_events ADD COLUMN success_message TEXT");
}
if (!cols.some((c) => c.name === "waiting_list_text")) {
db.exec("ALTER TABLE open_day_events ADD COLUMN waiting_list_text TEXT");
}
},
},
]; ];
function runMigrations(db: Database.Database) { function runMigrations(db: Database.Database) {
@@ -969,6 +982,8 @@ interface OpenDayEventRow {
discount_threshold: number; discount_threshold: number;
min_bookings: number; min_bookings: number;
max_participants: number; max_participants: number;
success_message: string | null;
waiting_list_text: string | null;
active: number; active: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -984,6 +999,8 @@ export interface OpenDayEvent {
discountThreshold: number; discountThreshold: number;
minBookings: number; minBookings: number;
maxParticipants: number; maxParticipants: number;
successMessage?: string;
waitingListText?: string;
active: boolean; active: boolean;
} }
@@ -1064,6 +1081,8 @@ function mapEventRow(r: OpenDayEventRow): OpenDayEvent {
discountThreshold: r.discount_threshold, discountThreshold: r.discount_threshold,
minBookings: r.min_bookings, minBookings: r.min_bookings,
maxParticipants: r.max_participants ?? 0, maxParticipants: r.max_participants ?? 0,
successMessage: r.success_message ?? undefined,
waitingListText: r.waiting_list_text ?? undefined,
active: !!r.active, active: !!r.active,
}; };
} }
@@ -1174,6 +1193,8 @@ export function updateOpenDayEvent(
discountThreshold: number; discountThreshold: number;
minBookings: number; minBookings: number;
maxParticipants: number; maxParticipants: number;
successMessage: string;
waitingListText: string;
active: boolean; active: boolean;
}> }>
): void { ): void {
@@ -1188,6 +1209,8 @@ export function updateOpenDayEvent(
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.maxParticipants !== undefined) { sets.push("max_participants = ?"); vals.push(data.maxParticipants); }
if (data.successMessage !== undefined) { sets.push("success_message = ?"); vals.push(data.successMessage || null); }
if (data.waitingListText !== undefined) { sets.push("waiting_list_text = ?"); vals.push(data.waitingListText || null); }
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')");
+1
View File
@@ -148,6 +148,7 @@ export interface SiteContent {
masterClasses: { masterClasses: {
title: string; title: string;
successMessage?: string; successMessage?: string;
waitingListText?: string;
items: MasterClassItem[]; items: MasterClassItem[];
}; };
schedule: { schedule: {