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,
|
Sparkles,
|
||||||
Users,
|
Users,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
Star,
|
||||||
Calendar,
|
Calendar,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
@@ -27,6 +28,7 @@ const NAV_ITEMS = [
|
|||||||
{ href: "/admin/about", label: "О студии", icon: FileText },
|
{ href: "/admin/about", label: "О студии", icon: FileText },
|
||||||
{ href: "/admin/team", label: "Команда", icon: Users },
|
{ href: "/admin/team", label: "Команда", icon: Users },
|
||||||
{ href: "/admin/classes", label: "Направления", icon: BookOpen },
|
{ href: "/admin/classes", label: "Направления", icon: BookOpen },
|
||||||
|
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star },
|
||||||
{ href: "/admin/schedule", label: "Расписание", icon: Calendar },
|
{ href: "/admin/schedule", label: "Расписание", icon: Calendar },
|
||||||
{ href: "/admin/pricing", label: "Цены", icon: DollarSign },
|
{ href: "/admin/pricing", label: "Цены", icon: DollarSign },
|
||||||
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },
|
{ 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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { getSection, setSection, SECTION_KEYS } from "@/lib/db";
|
import { getSection, setSection, SECTION_KEYS } from "@/lib/db";
|
||||||
|
import { siteContent } from "@/data/content";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
type Params = { params: Promise<{ key: string }> };
|
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 });
|
return NextResponse.json({ error: "Invalid section key" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = getSection(key);
|
let data = getSection(key);
|
||||||
if (!data) {
|
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);
|
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 { Team } from "@/components/sections/Team";
|
||||||
import { About } from "@/components/sections/About";
|
import { About } from "@/components/sections/About";
|
||||||
import { Classes } from "@/components/sections/Classes";
|
import { Classes } from "@/components/sections/Classes";
|
||||||
|
import { MasterClasses } from "@/components/sections/MasterClasses";
|
||||||
import { Schedule } from "@/components/sections/Schedule";
|
import { Schedule } from "@/components/sections/Schedule";
|
||||||
import { Pricing } from "@/components/sections/Pricing";
|
import { Pricing } from "@/components/sections/Pricing";
|
||||||
import { FAQ } from "@/components/sections/FAQ";
|
import { FAQ } from "@/components/sections/FAQ";
|
||||||
@@ -29,6 +30,7 @@ export default function HomePage() {
|
|||||||
/>
|
/>
|
||||||
<Team data={content.team} schedule={content.schedule.locations} />
|
<Team data={content.team} schedule={content.schedule.locations} />
|
||||||
<Classes data={content.classes} />
|
<Classes data={content.classes} />
|
||||||
|
<MasterClasses data={content.masterClasses} />
|
||||||
<Schedule data={content.schedule} classItems={content.classes.items} />
|
<Schedule data={content.schedule} classItems={content.classes.items} />
|
||||||
<Pricing data={content.pricing} />
|
<Pricing data={content.pricing} />
|
||||||
<FAQ data={content.faq} />
|
<FAQ data={content.faq} />
|
||||||
|
|||||||
224
src/components/sections/MasterClasses.tsx
Normal file
224
src/components/sections/MasterClasses.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
195
src/components/ui/MasterClassSignupModal.tsx
Normal file
195
src/components/ui/MasterClassSignupModal.tsx
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -307,6 +307,10 @@ export const siteContent: SiteContent = {
|
|||||||
"В случае болезни, подтверждённой больничным листом, возможно продление срока действия абонемента.",
|
"В случае болезни, подтверждённой больничным листом, возможно продление срока действия абонемента.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
masterClasses: {
|
||||||
|
title: "Мастер-классы",
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
schedule: {
|
schedule: {
|
||||||
title: "Расписание",
|
title: "Расписание",
|
||||||
locations: [
|
locations: [
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const sectionData: Record<string, unknown> = {
|
|||||||
hero: siteContent.hero,
|
hero: siteContent.hero,
|
||||||
about: siteContent.about,
|
about: siteContent.about,
|
||||||
classes: siteContent.classes,
|
classes: siteContent.classes,
|
||||||
|
masterClasses: siteContent.masterClasses,
|
||||||
faq: siteContent.faq,
|
faq: siteContent.faq,
|
||||||
pricing: siteContent.pricing,
|
pricing: siteContent.pricing,
|
||||||
schedule: siteContent.schedule,
|
schedule: siteContent.schedule,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const NAV_LINKS: NavLink[] = [
|
|||||||
{ label: "О нас", href: "#about" },
|
{ label: "О нас", href: "#about" },
|
||||||
{ label: "Команда", href: "#team" },
|
{ label: "Команда", href: "#team" },
|
||||||
{ label: "Направления", href: "#classes" },
|
{ label: "Направления", href: "#classes" },
|
||||||
|
{ label: "Мастер-классы", href: "#master-classes" },
|
||||||
{ label: "Расписание", href: "#schedule" },
|
{ label: "Расписание", href: "#schedule" },
|
||||||
{ label: "Стоимость", href: "#pricing" },
|
{ label: "Стоимость", href: "#pricing" },
|
||||||
{ label: "FAQ", href: "#faq" },
|
{ label: "FAQ", href: "#faq" },
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ function initTables(db: Database.Database) {
|
|||||||
updated_at TEXT DEFAULT (datetime('now'))
|
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 (
|
CREATE TABLE IF NOT EXISTS team_members (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -228,6 +237,7 @@ const SECTION_KEYS = [
|
|||||||
"hero",
|
"hero",
|
||||||
"about",
|
"about",
|
||||||
"classes",
|
"classes",
|
||||||
|
"masterClasses",
|
||||||
"faq",
|
"faq",
|
||||||
"pricing",
|
"pricing",
|
||||||
"schedule",
|
"schedule",
|
||||||
@@ -257,6 +267,7 @@ export function getSiteContent(): SiteContent | null {
|
|||||||
hero: sections.hero,
|
hero: sections.hero,
|
||||||
about: sections.about,
|
about: sections.about,
|
||||||
classes: sections.classes,
|
classes: sections.classes,
|
||||||
|
masterClasses: sections.masterClasses ?? { title: "Мастер-классы", items: [] },
|
||||||
faq: sections.faq,
|
faq: sections.faq,
|
||||||
pricing: sections.pricing,
|
pricing: sections.pricing,
|
||||||
schedule: sections.schedule,
|
schedule: sections.schedule,
|
||||||
@@ -276,4 +287,74 @@ export function isDatabaseSeeded(): boolean {
|
|||||||
return row.count > 0;
|
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 };
|
export { SECTION_KEYS };
|
||||||
|
|||||||
@@ -69,6 +69,24 @@ export interface ScheduleLocation {
|
|||||||
days: ScheduleDay[];
|
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 {
|
export interface ContactInfo {
|
||||||
title: string;
|
title: string;
|
||||||
addresses: string[];
|
addresses: string[];
|
||||||
@@ -113,6 +131,11 @@ export interface SiteContent {
|
|||||||
rentalItems: PricingItem[];
|
rentalItems: PricingItem[];
|
||||||
rules: string[];
|
rules: string[];
|
||||||
};
|
};
|
||||||
|
masterClasses: {
|
||||||
|
title: string;
|
||||||
|
successMessage?: string;
|
||||||
|
items: MasterClassItem[];
|
||||||
|
};
|
||||||
schedule: {
|
schedule: {
|
||||||
title: string;
|
title: string;
|
||||||
locations: ScheduleLocation[];
|
locations: ScheduleLocation[];
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export type { NavLink } from "./navigation";
|
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";
|
||||||
|
|||||||
Reference in New Issue
Block a user