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,10 +11,17 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
||||
return NextResponse.json({ error: "Invalid section key" }, { status: 400 });
|
||||
}
|
||||
|
||||
const data = getSection(key);
|
||||
let data = getSection(key);
|
||||
if (!data) {
|
||||
// Auto-seed from fallback content if section doesn't exist yet
|
||||
const fallback = (siteContent as unknown as Record<string, unknown>)[key];
|
||||
if (fallback) {
|
||||
setSection(key, fallback);
|
||||
data = fallback;
|
||||
} else {
|
||||
return NextResponse.json({ error: "Section not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
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} />
|
||||
|
||||
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: {
|
||||
title: "Расписание",
|
||||
locations: [
|
||||
|
||||
@@ -41,6 +41,7 @@ const sectionData: Record<string, unknown> = {
|
||||
hero: siteContent.hero,
|
||||
about: siteContent.about,
|
||||
classes: siteContent.classes,
|
||||
masterClasses: siteContent.masterClasses,
|
||||
faq: siteContent.faq,
|
||||
pricing: siteContent.pricing,
|
||||
schedule: siteContent.schedule,
|
||||
|
||||
@@ -11,6 +11,7 @@ export const NAV_LINKS: NavLink[] = [
|
||||
{ label: "О нас", href: "#about" },
|
||||
{ label: "Команда", href: "#team" },
|
||||
{ label: "Направления", href: "#classes" },
|
||||
{ label: "Мастер-классы", href: "#master-classes" },
|
||||
{ label: "Расписание", href: "#schedule" },
|
||||
{ label: "Стоимость", href: "#pricing" },
|
||||
{ label: "FAQ", href: "#faq" },
|
||||
|
||||
@@ -26,6 +26,15 @@ function initTables(db: Database.Database) {
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mc_registrations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
master_class_title TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
instagram TEXT NOT NULL,
|
||||
telegram TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
@@ -228,6 +237,7 @@ const SECTION_KEYS = [
|
||||
"hero",
|
||||
"about",
|
||||
"classes",
|
||||
"masterClasses",
|
||||
"faq",
|
||||
"pricing",
|
||||
"schedule",
|
||||
@@ -257,6 +267,7 @@ export function getSiteContent(): SiteContent | null {
|
||||
hero: sections.hero,
|
||||
about: sections.about,
|
||||
classes: sections.classes,
|
||||
masterClasses: sections.masterClasses ?? { title: "Мастер-классы", items: [] },
|
||||
faq: sections.faq,
|
||||
pricing: sections.pricing,
|
||||
schedule: sections.schedule,
|
||||
@@ -276,4 +287,74 @@ export function isDatabaseSeeded(): boolean {
|
||||
return row.count > 0;
|
||||
}
|
||||
|
||||
// --- MC Registrations ---
|
||||
|
||||
interface McRegistrationRow {
|
||||
id: number;
|
||||
master_class_title: string;
|
||||
name: string;
|
||||
instagram: string;
|
||||
telegram: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface McRegistration {
|
||||
id: number;
|
||||
masterClassTitle: string;
|
||||
name: string;
|
||||
instagram: string;
|
||||
telegram?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function addMcRegistration(
|
||||
masterClassTitle: string,
|
||||
name: string,
|
||||
instagram: string,
|
||||
telegram?: string
|
||||
): number {
|
||||
const db = getDb();
|
||||
const result = db
|
||||
.prepare(
|
||||
`INSERT INTO mc_registrations (master_class_title, name, instagram, telegram)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
)
|
||||
.run(masterClassTitle, name, instagram, telegram || null);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
export function getMcRegistrations(masterClassTitle: string): McRegistration[] {
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.prepare(
|
||||
"SELECT * FROM mc_registrations WHERE master_class_title = ? ORDER BY created_at DESC"
|
||||
)
|
||||
.all(masterClassTitle) as McRegistrationRow[];
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
masterClassTitle: r.master_class_title,
|
||||
name: r.name,
|
||||
instagram: r.instagram,
|
||||
telegram: r.telegram ?? undefined,
|
||||
createdAt: r.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
export function updateMcRegistration(
|
||||
id: number,
|
||||
name: string,
|
||||
instagram: string,
|
||||
telegram?: string
|
||||
): void {
|
||||
const db = getDb();
|
||||
db.prepare(
|
||||
"UPDATE mc_registrations SET name = ?, instagram = ?, telegram = ? WHERE id = ?"
|
||||
).run(name, instagram, telegram || null, id);
|
||||
}
|
||||
|
||||
export function deleteMcRegistration(id: number): void {
|
||||
const db = getDb();
|
||||
db.prepare("DELETE FROM mc_registrations WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
export { SECTION_KEYS };
|
||||
|
||||
@@ -69,6 +69,24 @@ export interface ScheduleLocation {
|
||||
days: ScheduleDay[];
|
||||
}
|
||||
|
||||
export interface MasterClassSlot {
|
||||
date: string; // ISO "2026-03-13"
|
||||
startTime: string; // "19:00"
|
||||
endTime: string; // "21:00"
|
||||
}
|
||||
|
||||
export interface MasterClassItem {
|
||||
title: string;
|
||||
image: string;
|
||||
slots: MasterClassSlot[];
|
||||
trainer: string;
|
||||
cost: string;
|
||||
style: string;
|
||||
location?: string;
|
||||
description?: string;
|
||||
instagramUrl?: string;
|
||||
}
|
||||
|
||||
export interface ContactInfo {
|
||||
title: string;
|
||||
addresses: string[];
|
||||
@@ -113,6 +131,11 @@ export interface SiteContent {
|
||||
rentalItems: PricingItem[];
|
||||
rules: string[];
|
||||
};
|
||||
masterClasses: {
|
||||
title: string;
|
||||
successMessage?: string;
|
||||
items: MasterClassItem[];
|
||||
};
|
||||
schedule: {
|
||||
title: string;
|
||||
locations: ScheduleLocation[];
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export type { NavLink } from "./navigation";
|
||||
export type { ClassItem, TeamMember, FAQItem, PricingItem, ContactInfo, SiteContent, ScheduleClass, ScheduleDay, ScheduleLocation } from "./content";
|
||||
export type { ClassItem, TeamMember, FAQItem, PricingItem, MasterClassItem, MasterClassSlot, ContactInfo, SiteContent, ScheduleClass, ScheduleDay, ScheduleLocation } from "./content";
|
||||
|
||||
Reference in New Issue
Block a user