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:
@@ -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 },
|
||||
|
||||
947
src/app/admin/master-classes/page.tsx
Normal file
947
src/app/admin/master-classes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
src/app/api/admin/mc-registrations/route.ts
Normal file
52
src/app/api/admin/mc-registrations/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
30
src/app/api/master-class-register/route.ts
Normal file
30
src/app/api/master-class-register/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user