feat: add master classes section with registration system

- New master classes section on landing page with upcoming events grid
- Admin CRUD for master classes (image, slots, trainer, style, cost, location)
- User signup modal (name + Instagram required, Telegram optional)
- Admin registration management: view, add, edit, delete with quick-contact links
- Customizable success message for signup confirmation
- Auto-filter past events, Russian date formatting, duration auto-calculation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 18:29:06 +03:00
parent 6981376171
commit 84b0bc4d60
14 changed files with 1573 additions and 3 deletions

View File

@@ -8,6 +8,7 @@ import {
Sparkles,
Users,
BookOpen,
Star,
Calendar,
DollarSign,
HelpCircle,
@@ -27,6 +28,7 @@ const NAV_ITEMS = [
{ href: "/admin/about", label: "О студии", icon: FileText },
{ href: "/admin/team", label: "Команда", icon: Users },
{ href: "/admin/classes", label: "Направления", icon: BookOpen },
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star },
{ href: "/admin/schedule", label: "Расписание", icon: Calendar },
{ href: "/admin/pricing", label: "Цены", icon: DollarSign },
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },

View File

@@ -0,0 +1,947 @@
"use client";
import { useState, useRef, useEffect, useMemo } from "react";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
import { Plus, X, Upload, Loader2, ImageIcon, AlertCircle, Check, ChevronDown, ChevronUp, Instagram, Send, Trash2, Pencil } from "lucide-react";
import type { MasterClassItem, MasterClassSlot } from "@/types/content";
interface MasterClassesData {
title: string;
successMessage?: string;
items: MasterClassItem[];
}
interface McRegistration {
id: number;
masterClassTitle: string;
name: string;
instagram: string;
telegram?: string;
createdAt: string;
}
// --- Autocomplete Multi-Select ---
function AutocompleteMulti({
label,
value,
onChange,
options,
placeholder,
}: {
label: string;
value: string;
onChange: (v: string) => void;
options: string[];
placeholder?: string;
}) {
const selected = useMemo(() => (value ? value.split(", ").filter(Boolean) : []), [value]);
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const filtered = useMemo(() => {
if (!query) return options.filter((o) => !selected.includes(o));
const q = query.toLowerCase();
return options.filter(
(o) => !selected.includes(o) && o.toLowerCase().includes(q)
);
}, [query, options, selected]);
useEffect(() => {
if (!open) return;
function handle(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setQuery("");
}
}
document.addEventListener("mousedown", handle);
return () => document.removeEventListener("mousedown", handle);
}, [open]);
function addItem(item: string) {
onChange([...selected, item].join(", "));
setQuery("");
inputRef.current?.focus();
}
function removeItem(item: string) {
onChange(selected.filter((s) => s !== item).join(", "));
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter") {
e.preventDefault();
if (filtered.length > 0) {
addItem(filtered[0]);
} else if (query.trim()) {
addItem(query.trim());
}
}
if (e.key === "Backspace" && !query && selected.length > 0) {
removeItem(selected[selected.length - 1]);
}
if (e.key === "Escape") {
setOpen(false);
setQuery("");
}
}
return (
<div ref={containerRef} className="relative">
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
{/* Selected chips + input */}
<div
onClick={() => {
setOpen(true);
inputRef.current?.focus();
}}
className={`flex flex-wrap items-center gap-1.5 rounded-lg border bg-neutral-800 px-3 py-2 min-h-[42px] cursor-text transition-colors ${
open ? "border-gold" : "border-white/10"
}`}
>
{selected.map((item) => (
<span
key={item}
className="inline-flex items-center gap-1 rounded-full bg-gold/15 border border-gold/30 px-2.5 py-0.5 text-xs font-medium text-gold"
>
{item}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeItem(item);
}}
className="text-gold/60 hover:text-gold transition-colors"
>
<X size={10} />
</button>
</span>
))}
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
placeholder={selected.length === 0 ? placeholder : ""}
className="flex-1 min-w-[80px] bg-transparent text-sm text-white placeholder-neutral-500 outline-none"
/>
</div>
{/* Dropdown */}
{open && filtered.length > 0 && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden max-h-48 overflow-y-auto">
{filtered.map((opt) => (
<button
key={opt}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => addItem(opt)}
className="w-full px-4 py-2 text-left text-sm text-white hover:bg-white/5 transition-colors"
>
{opt}
</button>
))}
</div>
)}
</div>
);
}
// --- Location Select ---
function LocationSelect({
value,
onChange,
locations,
}: {
value: string;
onChange: (v: string) => void;
locations: { name: string; address: string }[];
}) {
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Локация</label>
<div className="flex flex-wrap gap-1.5">
{locations.map((loc) => {
const active = value === loc.name;
return (
<button
key={loc.name}
type="button"
onClick={() => onChange(active ? "" : loc.name)}
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
active
? "bg-gold/20 text-gold border border-gold/40"
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:border-white/25 hover:text-white"
}`}
>
{active && <Check size={10} className="inline mr-1" />}
{loc.name}
<span className="text-neutral-500 ml-1 text-[10px]">
{loc.address.replace(/^г\.\s*\S+,\s*/, "")}
</span>
</button>
);
})}
</div>
</div>
);
}
// --- Date List ---
function calcDurationText(startTime: string, endTime: string): string {
if (!startTime || !endTime) return "";
const [sh, sm] = startTime.split(":").map(Number);
const [eh, em] = endTime.split(":").map(Number);
const mins = (eh * 60 + em) - (sh * 60 + sm);
if (mins <= 0) return "";
const h = Math.floor(mins / 60);
const m = mins % 60;
if (h > 0 && m > 0) return `${h} ч ${m} мин`;
if (h > 0) return h === 1 ? "1 час" : h < 5 ? `${h} часа` : `${h} часов`;
return `${m} мин`;
}
function SlotsField({
slots,
onChange,
}: {
slots: MasterClassSlot[];
onChange: (slots: MasterClassSlot[]) => void;
}) {
function addSlot() {
// Copy time from last slot for convenience
const last = slots[slots.length - 1];
onChange([...slots, {
date: "",
startTime: last?.startTime ?? "",
endTime: last?.endTime ?? "",
}]);
}
function updateSlot(index: number, patch: Partial<MasterClassSlot>) {
onChange(slots.map((s, i) => (i === index ? { ...s, ...patch } : s)));
}
function removeSlot(index: number) {
onChange(slots.filter((_, i) => i !== index));
}
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Даты и время</label>
<div className="space-y-2">
{slots.map((slot, i) => {
const dur = calcDurationText(slot.startTime, slot.endTime);
return (
<div key={i} className="flex items-center gap-2 flex-wrap">
<input
type="date"
value={slot.date}
onChange={(e) => updateSlot(i, { date: e.target.value })}
className={`w-[140px] rounded-lg border bg-neutral-800 px-3 py-2 text-sm text-white outline-none transition-colors [color-scheme:dark] ${
!slot.date ? "border-red-500/50" : "border-white/10 focus:border-gold"
}`}
/>
<input
type="time"
value={slot.startTime}
onChange={(e) => updateSlot(i, { startTime: e.target.value })}
className="w-[100px] rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
/>
<span className="text-neutral-500 text-xs"></span>
<input
type="time"
value={slot.endTime}
onChange={(e) => updateSlot(i, { endTime: e.target.value })}
className="w-[100px] rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
/>
{dur && (
<span className="text-[11px] text-neutral-500 bg-neutral-800/50 rounded-full px-2 py-0.5">
{dur}
</span>
)}
<button
type="button"
onClick={() => removeSlot(i)}
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
>
<X size={14} />
</button>
</div>
);
})}
<button
type="button"
onClick={addSlot}
className="flex items-center gap-2 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-3 py-1.5 text-xs text-neutral-500 hover:text-gold hover:border-gold/30 transition-colors"
>
<Plus size={12} />
Добавить дату
</button>
</div>
</div>
);
}
// --- Image Upload ---
function ImageUploadField({
value,
onChange,
}: {
value: string;
onChange: (path: string) => void;
}) {
const [uploading, setUploading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const formData = new FormData();
formData.append("file", file);
formData.append("folder", "master-classes");
try {
const res = await fetch("/api/admin/upload", {
method: "POST",
body: formData,
});
const result = await res.json();
if (result.path) onChange(result.path);
} catch {
/* upload failed */
} finally {
setUploading(false);
}
}
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">
Изображение
</label>
{value ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 rounded-lg bg-neutral-700/50 px-3 py-2 text-sm text-neutral-300">
<ImageIcon size={14} className="text-gold" />
<span className="max-w-[200px] truncate">
{value.split("/").pop()}
</span>
</div>
<button
type="button"
onClick={() => onChange("")}
className="rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
>
<X size={14} />
</button>
<label className="flex cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-sm text-neutral-400 hover:text-white hover:border-white/25 transition-colors">
{uploading ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Upload size={14} />
)}
Заменить
<input
type="file"
accept="image/*"
onChange={handleUpload}
className="hidden"
/>
</label>
</div>
) : (
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-3 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors">
{uploading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Upload size={16} />
)}
{uploading ? "Загрузка..." : "Загрузить изображение"}
<input
ref={inputRef}
type="file"
accept="image/*"
onChange={handleUpload}
className="hidden"
/>
</label>
)}
</div>
);
}
// --- Instagram Link Field ---
function InstagramLinkField({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const error = getInstagramError(value);
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">
Ссылка на Instagram
</label>
<div className="relative">
<input
type="url"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="https://instagram.com/p/... или /reel/..."
className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none transition-colors ${
error ? "border-red-500/50" : "border-white/10 focus:border-gold"
}`}
/>
{value && !error && (
<Check
size={14}
className="absolute right-3 top-1/2 -translate-y-1/2 text-emerald-400"
/>
)}
{error && (
<AlertCircle
size={14}
className="absolute right-3 top-1/2 -translate-y-1/2 text-red-400"
/>
)}
</div>
{error && (
<p className="mt-1 text-[11px] text-red-400">{error}</p>
)}
</div>
);
}
function getInstagramError(url: string): string | null {
if (!url) return null;
try {
const parsed = new URL(url);
const host = parsed.hostname.replace("www.", "");
if (host !== "instagram.com" && host !== "instagr.am") {
return "Ссылка должна вести на instagram.com";
}
const validPaths = ["/p/", "/reel/", "/tv/", "/stories/"];
if (!validPaths.some((p) => parsed.pathname.includes(p))) {
return "Ожидается ссылка на пост, рилс или сторис (/p/, /reel/, /tv/)";
}
return null;
} catch {
return "Некорректная ссылка";
}
}
// --- Validation badge ---
function ValidationHint({ fields }: { fields: Record<string, string> }) {
const missing = Object.entries(fields).filter(([, v]) => !(v ?? "").trim());
if (missing.length === 0) return null;
return (
<div className="flex items-start gap-1.5 rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-xs text-red-400">
<AlertCircle size={12} className="shrink-0 mt-0.5" />
<span>
Не заполнено: {missing.map(([k]) => k).join(", ")}
</span>
</div>
);
}
// --- Registration Row (inline edit) ---
function RegistrationRow({
reg,
onUpdate,
onDelete,
}: {
reg: McRegistration;
onUpdate: (updated: McRegistration) => void;
onDelete: () => void;
}) {
const [editing, setEditing] = useState(false);
const [name, setName] = useState(reg.name);
const [ig, setIg] = useState(reg.instagram.replace(/^@/, ""));
const [tg, setTg] = useState((reg.telegram || "").replace(/^@/, ""));
const [saving, setSaving] = useState(false);
async function save() {
if (!name.trim() || !ig.trim()) return;
setSaving(true);
const body = {
id: reg.id,
name: name.trim(),
instagram: `@${ig.trim()}`,
telegram: tg.trim() ? `@${tg.trim()}` : undefined,
};
const res = await fetch("/api/admin/mc-registrations", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) {
onUpdate({ ...reg, name: body.name, instagram: body.instagram, telegram: body.telegram });
setEditing(false);
}
setSaving(false);
}
function cancel() {
setName(reg.name);
setIg(reg.instagram.replace(/^@/, ""));
setTg((reg.telegram || "").replace(/^@/, ""));
setEditing(false);
}
if (editing) {
return (
<div className="rounded-lg bg-neutral-800/50 px-3 py-2 space-y-2">
<div className="flex gap-2">
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Имя"
className="flex-1 rounded-md border border-white/10 bg-neutral-800 px-2 py-1.5 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold"
/>
</div>
<div className="flex gap-2">
<div className="flex flex-1 items-center rounded-md border border-white/10 bg-neutral-800 text-sm">
<span className="flex items-center gap-1 pl-2 text-neutral-500 select-none">
<Instagram size={11} className="text-pink-400" />@
</span>
<input
value={ig}
onChange={(e) => setIg(e.target.value.replace(/^@/, ""))}
placeholder="instagram"
className="flex-1 bg-transparent px-1 py-1.5 text-white placeholder-neutral-500 outline-none"
/>
</div>
<div className="flex flex-1 items-center rounded-md border border-white/10 bg-neutral-800 text-sm">
<span className="flex items-center gap-1 pl-2 text-neutral-500 select-none">
<Send size={11} className="text-blue-400" />@
</span>
<input
value={tg}
onChange={(e) => setTg(e.target.value.replace(/^@/, ""))}
placeholder="telegram"
className="flex-1 bg-transparent px-1 py-1.5 text-white placeholder-neutral-500 outline-none"
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={cancel}
className="rounded-md px-3 py-1 text-xs text-neutral-400 hover:text-white transition-colors"
>
Отмена
</button>
<button
type="button"
onClick={save}
disabled={saving || !name.trim() || !ig.trim()}
className="rounded-md bg-gold/20 px-3 py-1 text-xs font-medium text-gold hover:bg-gold/30 transition-colors disabled:opacity-40"
>
{saving ? "..." : "Сохранить"}
</button>
</div>
</div>
);
}
return (
<div className="flex items-center gap-2 rounded-lg bg-neutral-800/50 px-3 py-2 text-sm">
<span className="font-medium text-white">{reg.name}</span>
<span className="text-neutral-500">·</span>
<a
href={`https://ig.me/m/${reg.instagram.replace(/^@/, "")}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 transition-colors"
title="Написать в Instagram"
>
<Instagram size={12} />
<span className="text-neutral-300">{reg.instagram}</span>
</a>
{reg.telegram && (
<>
<span className="text-neutral-600">·</span>
<a
href={`https://t.me/${reg.telegram.replace(/^@/, "")}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 transition-colors"
title="Написать в Telegram"
>
<Send size={12} />
<span className="text-neutral-300">{reg.telegram}</span>
</a>
</>
)}
<span className="text-neutral-600 text-xs ml-auto">
{new Date(reg.createdAt).toLocaleDateString("ru-RU")}
</span>
<button
type="button"
onClick={() => setEditing(true)}
className="rounded p-1 text-neutral-500 hover:text-gold transition-colors"
title="Редактировать"
>
<Pencil size={12} />
</button>
<button
type="button"
onClick={onDelete}
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
title="Удалить"
>
<Trash2 size={12} />
</button>
</div>
);
}
// --- Registrations List ---
function RegistrationsList({ title }: { title: string }) {
const [open, setOpen] = useState(false);
const [regs, setRegs] = useState<McRegistration[]>([]);
const [loading, setLoading] = useState(false);
const [count, setCount] = useState<number | null>(null);
const [adding, setAdding] = useState(false);
const [newName, setNewName] = useState("");
const [newIg, setNewIg] = useState("");
const [newTg, setNewTg] = useState("");
const [savingNew, setSavingNew] = useState(false);
useEffect(() => {
if (!title) return;
fetch(`/api/admin/mc-registrations?title=${encodeURIComponent(title)}`)
.then((r) => r.json())
.then((data: McRegistration[]) => {
setCount(data.length);
setRegs(data);
})
.catch(() => {});
}, [title]);
function toggle() {
if (!open && regs.length === 0 && count !== 0) {
setLoading(true);
fetch(`/api/admin/mc-registrations?title=${encodeURIComponent(title)}`)
.then((r) => r.json())
.then((data: McRegistration[]) => {
setRegs(data);
setCount(data.length);
})
.catch(() => {})
.finally(() => setLoading(false));
}
setOpen(!open);
}
async function handleAdd() {
if (!newName.trim() || !newIg.trim()) return;
setSavingNew(true);
const body = {
masterClassTitle: title,
name: newName.trim(),
instagram: `@${newIg.trim()}`,
telegram: newTg.trim() ? `@${newTg.trim()}` : undefined,
};
const res = await fetch("/api/admin/mc-registrations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) {
const { id } = await res.json();
setRegs((prev) => [{
id,
masterClassTitle: title,
name: body.name,
instagram: body.instagram,
telegram: body.telegram,
createdAt: new Date().toISOString(),
}, ...prev]);
setCount((prev) => (prev !== null ? prev + 1 : 1));
setNewName("");
setNewIg("");
setNewTg("");
setAdding(false);
}
setSavingNew(false);
}
async function handleDelete(id: number) {
await fetch(`/api/admin/mc-registrations?id=${id}`, { method: "DELETE" });
setRegs((prev) => prev.filter((r) => r.id !== id));
setCount((prev) => (prev !== null ? prev - 1 : null));
}
function handleUpdate(updated: McRegistration) {
setRegs((prev) => prev.map((r) => (r.id === updated.id ? updated : r)));
}
if (!title) return null;
return (
<div className="border-t border-white/5 pt-3">
<button
type="button"
onClick={toggle}
className="flex items-center gap-2 text-sm text-neutral-400 hover:text-white transition-colors"
>
{open ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
Записи{count !== null ? ` (${count})` : ""}
</button>
{open && (
<div className="mt-2 space-y-1.5">
{loading && (
<div className="flex items-center gap-2 text-xs text-neutral-500">
<Loader2 size={12} className="animate-spin" />
Загрузка...
</div>
)}
{!loading && regs.length === 0 && !adding && (
<p className="text-xs text-neutral-500">Пока никто не записался</p>
)}
{regs.map((reg) => (
<RegistrationRow
key={reg.id}
reg={reg}
onUpdate={handleUpdate}
onDelete={() => handleDelete(reg.id)}
/>
))}
{adding ? (
<div className="rounded-lg bg-neutral-800/50 px-3 py-2 space-y-2">
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Имя"
className="w-full rounded-md border border-white/10 bg-neutral-800 px-2 py-1.5 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold"
/>
<div className="flex gap-2">
<div className="flex flex-1 items-center rounded-md border border-white/10 bg-neutral-800 text-sm">
<span className="flex items-center gap-1 pl-2 text-neutral-500 select-none">
<Instagram size={11} className="text-pink-400" />@
</span>
<input
value={newIg}
onChange={(e) => setNewIg(e.target.value.replace(/^@/, ""))}
placeholder="instagram"
className="flex-1 bg-transparent px-1 py-1.5 text-white placeholder-neutral-500 outline-none"
/>
</div>
<div className="flex flex-1 items-center rounded-md border border-white/10 bg-neutral-800 text-sm">
<span className="flex items-center gap-1 pl-2 text-neutral-500 select-none">
<Send size={11} className="text-blue-400" />@
</span>
<input
value={newTg}
onChange={(e) => setNewTg(e.target.value.replace(/^@/, ""))}
placeholder="telegram"
className="flex-1 bg-transparent px-1 py-1.5 text-white placeholder-neutral-500 outline-none"
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={() => { setAdding(false); setNewName(""); setNewIg(""); setNewTg(""); }}
className="rounded-md px-3 py-1 text-xs text-neutral-400 hover:text-white transition-colors"
>
Отмена
</button>
<button
type="button"
onClick={handleAdd}
disabled={savingNew || !newName.trim() || !newIg.trim()}
className="rounded-md bg-gold/20 px-3 py-1 text-xs font-medium text-gold hover:bg-gold/30 transition-colors disabled:opacity-40"
>
{savingNew ? "..." : "Добавить"}
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setAdding(true)}
className="flex items-center gap-1.5 rounded-lg border border-dashed border-white/10 px-3 py-1.5 text-xs text-neutral-500 hover:text-gold hover:border-gold/30 transition-colors"
>
<Plus size={12} />
Добавить запись
</button>
)}
</div>
)}
</div>
);
}
// --- Main page ---
export default function MasterClassesEditorPage() {
const [trainers, setTrainers] = useState<string[]>([]);
const [styles, setStyles] = useState<string[]>([]);
const [locations, setLocations] = useState<{ name: string; address: string }[]>([]);
useEffect(() => {
// Fetch trainers from team
fetch("/api/admin/team")
.then((r) => r.json())
.then((members: { name: string }[]) => {
setTrainers(members.map((m) => m.name));
})
.catch(() => {});
// Fetch styles from classes section
fetch("/api/admin/sections/classes")
.then((r) => r.json())
.then((data: { items: { name: string }[] }) => {
setStyles(data.items.map((c) => c.name));
})
.catch(() => {});
// Fetch locations from schedule section
fetch("/api/admin/sections/schedule")
.then((r) => r.json())
.then((data: { locations: { name: string; address: string }[] }) => {
setLocations(data.locations);
})
.catch(() => {});
}, []);
return (
<SectionEditor<MasterClassesData>
sectionKey="masterClasses"
title="Мастер-классы"
>
{(data, update) => (
<>
<InputField
label="Заголовок секции"
value={data.title}
onChange={(v) => update({ ...data, title: v })}
/>
<InputField
label="Текст после записи (success popup)"
value={data.successMessage || ""}
onChange={(v) => update({ ...data, successMessage: v || undefined })}
placeholder="Вы записаны! Мы свяжемся с вами"
/>
<ArrayEditor
label="Мастер-классы"
items={data.items}
onChange={(items) => update({ ...data, items })}
renderItem={(item, _i, updateItem) => (
<div className="space-y-3">
<ValidationHint
fields={{
Название: item.title,
Тренер: item.trainer,
Стиль: item.style,
Стоимость: item.cost,
"Даты и время": (item.slots ?? []).length > 0 ? "ok" : "",
}}
/>
<InputField
label="Название"
value={item.title}
onChange={(v) => updateItem({ ...item, title: v })}
placeholder="Мастер-класс от Анны Тарыбы"
/>
<ImageUploadField
value={item.image}
onChange={(v) => updateItem({ ...item, image: v })}
/>
<div className="grid gap-3 sm:grid-cols-2">
<AutocompleteMulti
label="Тренер"
value={item.trainer}
onChange={(v) => updateItem({ ...item, trainer: v })}
options={trainers}
placeholder="Добавить тренера..."
/>
<AutocompleteMulti
label="Стиль"
value={item.style}
onChange={(v) => updateItem({ ...item, style: v })}
options={styles}
placeholder="Добавить стиль..."
/>
</div>
<InputField
label="Стоимость"
value={item.cost}
onChange={(v) => updateItem({ ...item, cost: v })}
placeholder="40 BYN"
/>
{locations.length > 0 && (
<LocationSelect
value={item.location || ""}
onChange={(v) =>
updateItem({ ...item, location: v || undefined })
}
locations={locations}
/>
)}
<SlotsField
slots={item.slots ?? []}
onChange={(slots) => updateItem({ ...item, slots })}
/>
<TextareaField
label="Описание"
value={item.description || ""}
onChange={(v) =>
updateItem({ ...item, description: v || undefined })
}
placeholder="Описание мастер-класса, трек, стиль..."
rows={3}
/>
<InstagramLinkField
value={item.instagramUrl || ""}
onChange={(v) =>
updateItem({ ...item, instagramUrl: v || undefined })
}
/>
<RegistrationsList title={item.title} />
</div>
)}
createItem={() => ({
title: "",
image: "",
slots: [],
trainer: "",
cost: "",
style: "",
})}
addLabel="Добавить мастер-класс"
/>
</>
)}
</SectionEditor>
);
}

View File

@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from "next/server";
import { getMcRegistrations, addMcRegistration, updateMcRegistration, deleteMcRegistration } from "@/lib/db";
export async function GET(request: NextRequest) {
const title = request.nextUrl.searchParams.get("title");
if (!title) {
return NextResponse.json({ error: "title parameter is required" }, { status: 400 });
}
const registrations = getMcRegistrations(title);
return NextResponse.json(registrations);
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { masterClassTitle, name, instagram, telegram } = body;
if (!masterClassTitle || !name || !instagram) {
return NextResponse.json({ error: "masterClassTitle, name, instagram are required" }, { status: 400 });
}
const id = addMcRegistration(masterClassTitle.trim(), name.trim(), instagram.trim(), telegram?.trim() || undefined);
return NextResponse.json({ ok: true, id });
} catch {
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
const { id, name, instagram, telegram } = body;
if (!id || !name || !instagram) {
return NextResponse.json({ error: "id, name, instagram are required" }, { status: 400 });
}
updateMcRegistration(id, name.trim(), instagram.trim(), telegram?.trim() || undefined);
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
const idStr = request.nextUrl.searchParams.get("id");
if (!idStr) {
return NextResponse.json({ error: "id parameter is required" }, { status: 400 });
}
const id = parseInt(idStr, 10);
if (isNaN(id)) {
return NextResponse.json({ error: "Invalid id" }, { status: 400 });
}
deleteMcRegistration(id);
return NextResponse.json({ ok: true });
}

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { getSection, setSection, SECTION_KEYS } from "@/lib/db";
import { siteContent } from "@/data/content";
import { revalidatePath } from "next/cache";
type Params = { params: Promise<{ key: string }> };
@@ -10,10 +11,17 @@ export async function GET(_request: NextRequest, { params }: Params) {
return NextResponse.json({ error: "Invalid section key" }, { status: 400 });
}
const data = getSection(key);
let data = getSection(key);
if (!data) {
// Auto-seed from fallback content if section doesn't exist yet
const fallback = (siteContent as unknown as Record<string, unknown>)[key];
if (fallback) {
setSection(key, fallback);
data = fallback;
} else {
return NextResponse.json({ error: "Section not found" }, { status: 404 });
}
}
return NextResponse.json(data);
}

View File

@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { addMcRegistration } from "@/lib/db";
export async function POST(request: Request) {
try {
const body = await request.json();
const { masterClassTitle, name, instagram, telegram } = body;
if (!masterClassTitle || typeof masterClassTitle !== "string") {
return NextResponse.json({ error: "masterClassTitle is required" }, { status: 400 });
}
if (!name || typeof name !== "string" || !name.trim()) {
return NextResponse.json({ error: "name is required" }, { status: 400 });
}
if (!instagram || typeof instagram !== "string" || !instagram.trim()) {
return NextResponse.json({ error: "Instagram аккаунт обязателен" }, { status: 400 });
}
const id = addMcRegistration(
masterClassTitle.trim(),
name.trim(),
instagram.trim(),
telegram && typeof telegram === "string" ? telegram.trim() : undefined
);
return NextResponse.json({ ok: true, id });
} catch {
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}

View File

@@ -2,6 +2,7 @@ import { Hero } from "@/components/sections/Hero";
import { Team } from "@/components/sections/Team";
import { About } from "@/components/sections/About";
import { Classes } from "@/components/sections/Classes";
import { MasterClasses } from "@/components/sections/MasterClasses";
import { Schedule } from "@/components/sections/Schedule";
import { Pricing } from "@/components/sections/Pricing";
import { FAQ } from "@/components/sections/FAQ";
@@ -29,6 +30,7 @@ export default function HomePage() {
/>
<Team data={content.team} schedule={content.schedule.locations} />
<Classes data={content.classes} />
<MasterClasses data={content.masterClasses} />
<Schedule data={content.schedule} classItems={content.classes.items} />
<Pricing data={content.pricing} />
<FAQ data={content.faq} />

View File

@@ -0,0 +1,224 @@
"use client";
import { useState, useMemo } from "react";
import Image from "next/image";
import { Calendar, Clock, User, MapPin, Instagram } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import { MasterClassSignupModal } from "@/components/ui/MasterClassSignupModal";
import type { SiteContent, MasterClassItem, MasterClassSlot } from "@/types";
interface MasterClassesProps {
data: SiteContent["masterClasses"];
}
const MONTHS_RU = [
"января", "февраля", "марта", "апреля", "мая", "июня",
"июля", "августа", "сентября", "октября", "ноября", "декабря",
];
const WEEKDAYS_RU = [
"воскресенье", "понедельник", "вторник", "среда",
"четверг", "пятница", "суббота",
];
function parseDate(iso: string) {
return new Date(iso + "T00:00:00");
}
function formatSlots(slots: MasterClassSlot[]): string {
if (slots.length === 0) return "";
const sorted = [...slots].sort(
(a, b) => parseDate(a.date).getTime() - parseDate(b.date).getTime()
);
const dates = sorted.map((s) => parseDate(s.date)).filter((d) => !isNaN(d.getTime()));
if (dates.length === 0) return "";
// Time part from first slot
const timePart = sorted[0].startTime
? `, ${sorted[0].startTime}${sorted[0].endTime}`
: "";
if (dates.length === 1) {
const d = dates[0];
return `${d.getDate()} ${MONTHS_RU[d.getMonth()]} (${WEEKDAYS_RU[d.getDay()]})${timePart}`;
}
const sameMonth = dates.every((d) => d.getMonth() === dates[0].getMonth());
const sameWeekday = dates.every((d) => d.getDay() === dates[0].getDay());
if (sameMonth) {
const days = dates.map((d) => d.getDate()).join(" и ");
const weekdayHint = sameWeekday ? ` (${WEEKDAYS_RU[dates[0].getDay()]})` : "";
return `${days} ${MONTHS_RU[dates[0].getMonth()]}${weekdayHint}${timePart}`;
}
const parts = dates.map((d) => `${d.getDate()} ${MONTHS_RU[d.getMonth()]}`);
return parts.join(", ") + timePart;
}
function calcDuration(slot: MasterClassSlot): string {
if (!slot.startTime || !slot.endTime) return "";
const [sh, sm] = slot.startTime.split(":").map(Number);
const [eh, em] = slot.endTime.split(":").map(Number);
const mins = (eh * 60 + em) - (sh * 60 + sm);
if (mins <= 0) return "";
const h = Math.floor(mins / 60);
const m = mins % 60;
if (h > 0 && m > 0) return `${h} ч ${m} мин`;
if (h > 0) return h === 1 ? "1 час" : h < 5 ? `${h} часа` : `${h} часов`;
return `${m} мин`;
}
function isUpcoming(item: MasterClassItem): boolean {
const today = new Date();
today.setHours(0, 0, 0, 0);
const lastDate = (item.slots ?? [])
.map((s) => parseDate(s.date))
.reduce((a, b) => (a > b ? a : b), new Date(0));
return lastDate >= today;
}
export function MasterClasses({ data }: MasterClassesProps) {
const [signupTitle, setSignupTitle] = useState<string | null>(null);
const upcoming = useMemo(() => {
return data.items
.filter(isUpcoming)
.sort((a, b) => {
const aFirst = parseDate(a.slots[0]?.date ?? "");
const bFirst = parseDate(b.slots[0]?.date ?? "");
return aFirst.getTime() - bFirst.getTime();
});
}, [data.items]);
return (
<section
id="master-classes"
className="section-glow relative section-padding bg-neutral-100 dark:bg-[#080808] overflow-hidden"
>
<div className="section-divider absolute top-0 left-0 right-0" />
<div className="section-container">
<Reveal>
<SectionHeading centered>{data.title}</SectionHeading>
</Reveal>
{upcoming.length === 0 ? (
<Reveal>
<div className="mt-10 py-12 text-center">
<p className="text-sm text-neutral-500 dark:text-white/40">
Следите за анонсами мастер-классов в нашем{" "}
<a
href="https://instagram.com/blackheartdancehouse/"
target="_blank"
rel="noopener noreferrer"
className="text-gold hover:text-gold-light underline underline-offset-2 transition-colors"
>
Instagram
</a>
</p>
</div>
</Reveal>
) : (
<Reveal>
<div className="mt-10 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{upcoming.map((item, i) => {
const duration = item.slots[0] ? calcDuration(item.slots[0]) : "";
const slotsDisplay = formatSlots(item.slots);
return (
<div
key={i}
className="group rounded-2xl border border-neutral-200 bg-white overflow-hidden transition-colors dark:border-white/[0.06] dark:bg-[#0a0a0a]"
>
{/* Image */}
{item.image && (
<div className="relative aspect-[16/9] w-full overflow-hidden">
<Image
src={item.image}
alt={item.title}
fill
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
<div className="absolute bottom-3 left-3">
<span className="inline-flex items-center gap-1.5 rounded-full border border-gold/40 bg-black/60 px-3 py-1 text-xs font-semibold text-gold backdrop-blur-sm">
<Calendar size={12} />
{slotsDisplay}
</span>
</div>
</div>
)}
{/* Content */}
<div className="p-5 space-y-3">
<h3 className="text-lg font-bold text-neutral-900 dark:text-white/90 leading-tight">
{item.title}
</h3>
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-sm text-neutral-600 dark:text-white/50">
<User size={14} className="shrink-0" />
<span>{item.trainer}</span>
</div>
<div className="flex items-center gap-2 text-sm text-neutral-600 dark:text-white/50">
<span className="inline-block h-2 w-2 rounded-full bg-gold shrink-0" />
<span>{item.style}</span>
</div>
{duration && (
<div className="flex items-center gap-2 text-sm text-neutral-600 dark:text-white/50">
<Clock size={14} className="shrink-0" />
<span>{duration}</span>
</div>
)}
{item.location && (
<div className="flex items-center gap-2 text-sm text-neutral-400 dark:text-white/35">
<MapPin size={14} className="shrink-0" />
<span>{item.location}</span>
</div>
)}
</div>
<div className="pt-1">
<span className="text-lg font-bold text-neutral-900 dark:text-white/90">
{item.cost}
</span>
</div>
<div className="flex gap-2 pt-1">
<button
onClick={() => setSignupTitle(item.title)}
className="flex-1 rounded-xl bg-gold py-2.5 text-sm font-semibold text-black transition-all hover:bg-gold-light hover:shadow-lg hover:shadow-gold/20 cursor-pointer"
>
Записаться
</button>
{item.instagramUrl && (
<button
onClick={() => window.open(item.instagramUrl, "_blank", "noopener,noreferrer")}
className="flex items-center justify-center gap-1.5 rounded-xl border border-neutral-200 px-4 py-2.5 text-sm text-neutral-500 transition-colors hover:border-gold/30 hover:text-gold dark:border-white/[0.08] dark:text-white/40 dark:hover:text-gold cursor-pointer"
>
<Instagram size={16} />
Подробнее
</button>
)}
</div>
</div>
</div>
);
})}
</div>
</Reveal>
)}
</div>
<MasterClassSignupModal
open={signupTitle !== null}
onClose={() => setSignupTitle(null)}
masterClassTitle={signupTitle ?? ""}
successMessage={data.successMessage}
/>
</section>
);
}

View File

@@ -0,0 +1,195 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { X, Instagram, Send, CheckCircle } from "lucide-react";
interface MasterClassSignupModalProps {
open: boolean;
onClose: () => void;
masterClassTitle: string;
successMessage?: string;
}
export function MasterClassSignupModal({
open,
onClose,
masterClassTitle,
successMessage,
}: MasterClassSignupModalProps) {
const [name, setName] = useState("");
const [instagram, setInstagram] = useState("");
const [telegram, setTelegram] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState("");
// Close on Escape
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
// Lock body scroll
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [open]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setSubmitting(true);
try {
const res = await fetch("/api/master-class-register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
masterClassTitle,
name: name.trim(),
instagram: `@${instagram.trim()}`,
telegram: telegram.trim() ? `@${telegram.trim()}` : undefined,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Ошибка регистрации");
}
setSubmitted(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка регистрации");
} finally {
setSubmitting(false);
}
},
[masterClassTitle, name, instagram, telegram]
);
const handleClose = useCallback(() => {
onClose();
setTimeout(() => {
setName("");
setInstagram("");
setTelegram("");
setSubmitted(false);
setError("");
}, 300);
}, [onClose]);
if (!open) return null;
return createPortal(
<div
className="modal-overlay fixed inset-0 z-50 flex items-center justify-center p-4"
onClick={handleClose}
>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div
className="modal-content relative w-full max-w-md rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-6 sm:p-8 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={handleClose}
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full text-neutral-500 transition-colors hover:bg-white/[0.06] hover:text-white cursor-pointer"
>
<X size={18} />
</button>
{submitted ? (
<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">Отлично!</h3>
<p className="mt-2 text-sm text-neutral-400">
{successMessage || "Вы записаны! Мы свяжемся с вами"}
</p>
<button
onClick={handleClose}
className="mt-6 rounded-full bg-gold px-6 py-2.5 text-sm font-semibold text-black transition-all hover:bg-gold-light cursor-pointer"
>
Закрыть
</button>
</div>
) : (
<>
<div className="mb-6">
<h3 className="text-xl font-bold text-white">Записаться</h3>
<p className="mt-1 text-sm text-neutral-400">{masterClassTitle}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ваше имя"
required
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
/>
</div>
<div className="flex items-center gap-0 rounded-xl border border-white/[0.08] bg-white/[0.04] transition-colors focus-within:border-gold/40 focus-within:bg-white/[0.06]">
<span className="flex items-center gap-1.5 pl-4 text-sm text-neutral-500 select-none">
<Instagram size={14} className="text-pink-400" />
@
</span>
<input
type="text"
value={instagram}
onChange={(e) => setInstagram(e.target.value.replace(/^@/, ""))}
placeholder="username"
required
className="flex-1 bg-transparent px-2 py-3 text-sm text-white placeholder-neutral-500 outline-none"
/>
</div>
<div className="flex items-center gap-0 rounded-xl border border-white/[0.08] bg-white/[0.04] transition-colors focus-within:border-gold/40 focus-within:bg-white/[0.06]">
<span className="flex items-center gap-1.5 pl-4 text-sm text-neutral-500 select-none">
<Send size={14} className="text-blue-400" />
@
</span>
<input
type="text"
value={telegram}
onChange={(e) => setTelegram(e.target.value.replace(/^@/, ""))}
placeholder="username (необязательно)"
className="flex-1 bg-transparent px-2 py-3 text-sm text-white placeholder-neutral-500 outline-none"
/>
</div>
{error && (
<p className="text-sm text-red-400">{error}</p>
)}
<button
type="submit"
disabled={submitting}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-gold py-3 text-sm font-semibold text-black transition-all hover:bg-gold-light hover:shadow-lg hover:shadow-gold/20 cursor-pointer disabled:opacity-50"
>
{submitting ? "Отправка..." : "Записаться"}
</button>
</form>
</>
)}
</div>
</div>,
document.body
);
}

View File

@@ -307,6 +307,10 @@ export const siteContent: SiteContent = {
"В случае болезни, подтверждённой больничным листом, возможно продление срока действия абонемента.",
],
},
masterClasses: {
title: "Мастер-классы",
items: [],
},
schedule: {
title: "Расписание",
locations: [

View File

@@ -41,6 +41,7 @@ const sectionData: Record<string, unknown> = {
hero: siteContent.hero,
about: siteContent.about,
classes: siteContent.classes,
masterClasses: siteContent.masterClasses,
faq: siteContent.faq,
pricing: siteContent.pricing,
schedule: siteContent.schedule,

View File

@@ -11,6 +11,7 @@ export const NAV_LINKS: NavLink[] = [
{ label: "О нас", href: "#about" },
{ label: "Команда", href: "#team" },
{ label: "Направления", href: "#classes" },
{ label: "Мастер-классы", href: "#master-classes" },
{ label: "Расписание", href: "#schedule" },
{ label: "Стоимость", href: "#pricing" },
{ label: "FAQ", href: "#faq" },

View File

@@ -26,6 +26,15 @@ function initTables(db: Database.Database) {
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS mc_registrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
master_class_title TEXT NOT NULL,
name TEXT NOT NULL,
instagram TEXT NOT NULL,
telegram TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS team_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
@@ -228,6 +237,7 @@ const SECTION_KEYS = [
"hero",
"about",
"classes",
"masterClasses",
"faq",
"pricing",
"schedule",
@@ -257,6 +267,7 @@ export function getSiteContent(): SiteContent | null {
hero: sections.hero,
about: sections.about,
classes: sections.classes,
masterClasses: sections.masterClasses ?? { title: "Мастер-классы", items: [] },
faq: sections.faq,
pricing: sections.pricing,
schedule: sections.schedule,
@@ -276,4 +287,74 @@ export function isDatabaseSeeded(): boolean {
return row.count > 0;
}
// --- MC Registrations ---
interface McRegistrationRow {
id: number;
master_class_title: string;
name: string;
instagram: string;
telegram: string | null;
created_at: string;
}
export interface McRegistration {
id: number;
masterClassTitle: string;
name: string;
instagram: string;
telegram?: string;
createdAt: string;
}
export function addMcRegistration(
masterClassTitle: string,
name: string,
instagram: string,
telegram?: string
): number {
const db = getDb();
const result = db
.prepare(
`INSERT INTO mc_registrations (master_class_title, name, instagram, telegram)
VALUES (?, ?, ?, ?)`
)
.run(masterClassTitle, name, instagram, telegram || null);
return result.lastInsertRowid as number;
}
export function getMcRegistrations(masterClassTitle: string): McRegistration[] {
const db = getDb();
const rows = db
.prepare(
"SELECT * FROM mc_registrations WHERE master_class_title = ? ORDER BY created_at DESC"
)
.all(masterClassTitle) as McRegistrationRow[];
return rows.map((r) => ({
id: r.id,
masterClassTitle: r.master_class_title,
name: r.name,
instagram: r.instagram,
telegram: r.telegram ?? undefined,
createdAt: r.created_at,
}));
}
export function updateMcRegistration(
id: number,
name: string,
instagram: string,
telegram?: string
): void {
const db = getDb();
db.prepare(
"UPDATE mc_registrations SET name = ?, instagram = ?, telegram = ? WHERE id = ?"
).run(name, instagram, telegram || null, id);
}
export function deleteMcRegistration(id: number): void {
const db = getDb();
db.prepare("DELETE FROM mc_registrations WHERE id = ?").run(id);
}
export { SECTION_KEYS };

View File

@@ -69,6 +69,24 @@ export interface ScheduleLocation {
days: ScheduleDay[];
}
export interface MasterClassSlot {
date: string; // ISO "2026-03-13"
startTime: string; // "19:00"
endTime: string; // "21:00"
}
export interface MasterClassItem {
title: string;
image: string;
slots: MasterClassSlot[];
trainer: string;
cost: string;
style: string;
location?: string;
description?: string;
instagramUrl?: string;
}
export interface ContactInfo {
title: string;
addresses: string[];
@@ -113,6 +131,11 @@ export interface SiteContent {
rentalItems: PricingItem[];
rules: string[];
};
masterClasses: {
title: string;
successMessage?: string;
items: MasterClassItem[];
};
schedule: {
title: string;
locations: ScheduleLocation[];

View File

@@ -1,2 +1,2 @@
export type { NavLink } from "./navigation";
export type { ClassItem, TeamMember, FAQItem, PricingItem, ContactInfo, SiteContent, ScheduleClass, ScheduleDay, ScheduleLocation } from "./content";
export type { ClassItem, TeamMember, FAQItem, PricingItem, MasterClassItem, MasterClassSlot, ContactInfo, SiteContent, ScheduleClass, ScheduleDay, ScheduleLocation } from "./content";