Compare commits
2 Commits
1047b71abe
...
259b31a722
| Author | SHA1 | Date | |
|---|---|---|---|
| 259b31a722 | |||
| 0c39bdba5e |
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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')");
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user