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,9 +11,16 @@ 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) {
return NextResponse.json({ error: "Section not found" }, { status: 404 });
// 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} />