diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 90d9795..d9be9be 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -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 }, diff --git a/src/app/admin/master-classes/page.tsx b/src/app/admin/master-classes/page.tsx new file mode 100644 index 0000000..7cd369a --- /dev/null +++ b/src/app/admin/master-classes/page.tsx @@ -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(null); + const inputRef = useRef(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 ( +
+ + {/* Selected chips + input */} +
{ + 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) => ( + + {item} + + + ))} + { + 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" + /> +
+ + {/* Dropdown */} + {open && filtered.length > 0 && ( +
+ {filtered.map((opt) => ( + + ))} +
+ )} +
+ ); +} + +// --- Location Select --- +function LocationSelect({ + value, + onChange, + locations, +}: { + value: string; + onChange: (v: string) => void; + locations: { name: string; address: string }[]; +}) { + return ( +
+ +
+ {locations.map((loc) => { + const active = value === loc.name; + return ( + + ); + })} +
+
+ ); +} + +// --- 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) { + onChange(slots.map((s, i) => (i === index ? { ...s, ...patch } : s))); + } + + function removeSlot(index: number) { + onChange(slots.filter((_, i) => i !== index)); + } + + return ( +
+ +
+ {slots.map((slot, i) => { + const dur = calcDurationText(slot.startTime, slot.endTime); + return ( +
+ 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" + }`} + /> + 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]" + /> + + 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 && ( + + {dur} + + )} + +
+ ); + })} + +
+
+ ); +} + +// --- Image Upload --- +function ImageUploadField({ + value, + onChange, +}: { + value: string; + onChange: (path: string) => void; +}) { + const [uploading, setUploading] = useState(false); + const inputRef = useRef(null); + + async function handleUpload(e: React.ChangeEvent) { + 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 ( +
+ + {value ? ( +
+
+ + + {value.split("/").pop()} + +
+ + +
+ ) : ( + + )} +
+ ); +} + +// --- Instagram Link Field --- +function InstagramLinkField({ + value, + onChange, +}: { + value: string; + onChange: (v: string) => void; +}) { + const error = getInstagramError(value); + + return ( +
+ +
+ 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 && ( + + )} + {error && ( + + )} +
+ {error && ( +

{error}

+ )} +
+ ); +} + +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 }) { + const missing = Object.entries(fields).filter(([, v]) => !(v ?? "").trim()); + if (missing.length === 0) return null; + return ( +
+ + + Не заполнено: {missing.map(([k]) => k).join(", ")} + +
+ ); +} + +// --- 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 ( +
+
+ 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" + /> +
+
+
+ + @ + + setIg(e.target.value.replace(/^@/, ""))} + placeholder="instagram" + className="flex-1 bg-transparent px-1 py-1.5 text-white placeholder-neutral-500 outline-none" + /> +
+
+ + @ + + setTg(e.target.value.replace(/^@/, ""))} + placeholder="telegram" + className="flex-1 bg-transparent px-1 py-1.5 text-white placeholder-neutral-500 outline-none" + /> +
+
+
+ + +
+
+ ); + } + + return ( +
+ {reg.name} + · + + + {reg.instagram} + + {reg.telegram && ( + <> + · + + + {reg.telegram} + + + )} + + {new Date(reg.createdAt).toLocaleDateString("ru-RU")} + + + +
+ ); +} + +// --- Registrations List --- +function RegistrationsList({ title }: { title: string }) { + const [open, setOpen] = useState(false); + const [regs, setRegs] = useState([]); + const [loading, setLoading] = useState(false); + const [count, setCount] = useState(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 ( +
+ + + {open && ( +
+ {loading && ( +
+ + Загрузка... +
+ )} + + {!loading && regs.length === 0 && !adding && ( +

Пока никто не записался

+ )} + + {regs.map((reg) => ( + handleDelete(reg.id)} + /> + ))} + + {adding ? ( +
+ 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" + /> +
+
+ + @ + + setNewIg(e.target.value.replace(/^@/, ""))} + placeholder="instagram" + className="flex-1 bg-transparent px-1 py-1.5 text-white placeholder-neutral-500 outline-none" + /> +
+
+ + @ + + setNewTg(e.target.value.replace(/^@/, ""))} + placeholder="telegram" + className="flex-1 bg-transparent px-1 py-1.5 text-white placeholder-neutral-500 outline-none" + /> +
+
+
+ + +
+
+ ) : ( + + )} +
+ )} +
+ ); +} + +// --- Main page --- +export default function MasterClassesEditorPage() { + const [trainers, setTrainers] = useState([]); + const [styles, setStyles] = useState([]); + 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 ( + + sectionKey="masterClasses" + title="Мастер-классы" + > + {(data, update) => ( + <> + update({ ...data, title: v })} + /> + + update({ ...data, successMessage: v || undefined })} + placeholder="Вы записаны! Мы свяжемся с вами" + /> + + update({ ...data, items })} + renderItem={(item, _i, updateItem) => ( +
+ 0 ? "ok" : "", + }} + /> + + updateItem({ ...item, title: v })} + placeholder="Мастер-класс от Анны Тарыбы" + /> + + updateItem({ ...item, image: v })} + /> + +
+ updateItem({ ...item, trainer: v })} + options={trainers} + placeholder="Добавить тренера..." + /> + updateItem({ ...item, style: v })} + options={styles} + placeholder="Добавить стиль..." + /> +
+ + updateItem({ ...item, cost: v })} + placeholder="40 BYN" + /> + + {locations.length > 0 && ( + + updateItem({ ...item, location: v || undefined }) + } + locations={locations} + /> + )} + + updateItem({ ...item, slots })} + /> + + + updateItem({ ...item, description: v || undefined }) + } + placeholder="Описание мастер-класса, трек, стиль..." + rows={3} + /> + + + updateItem({ ...item, instagramUrl: v || undefined }) + } + /> + + +
+ )} + createItem={() => ({ + title: "", + image: "", + slots: [], + trainer: "", + cost: "", + style: "", + })} + addLabel="Добавить мастер-класс" + /> + + )} + + ); +} diff --git a/src/app/api/admin/mc-registrations/route.ts b/src/app/api/admin/mc-registrations/route.ts new file mode 100644 index 0000000..85f2de0 --- /dev/null +++ b/src/app/api/admin/mc-registrations/route.ts @@ -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 }); +} diff --git a/src/app/api/admin/sections/[key]/route.ts b/src/app/api/admin/sections/[key]/route.ts index cce12ea..33d5b3e 100644 --- a/src/app/api/admin/sections/[key]/route.ts +++ b/src/app/api/admin/sections/[key]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getSection, setSection, SECTION_KEYS } from "@/lib/db"; +import { siteContent } from "@/data/content"; import { revalidatePath } from "next/cache"; type Params = { params: Promise<{ key: string }> }; @@ -10,9 +11,16 @@ export async function GET(_request: NextRequest, { params }: Params) { return NextResponse.json({ error: "Invalid section key" }, { status: 400 }); } - const data = getSection(key); + let data = getSection(key); if (!data) { - return NextResponse.json({ error: "Section not found" }, { status: 404 }); + // Auto-seed from fallback content if section doesn't exist yet + const fallback = (siteContent as unknown as Record)[key]; + if (fallback) { + setSection(key, fallback); + data = fallback; + } else { + return NextResponse.json({ error: "Section not found" }, { status: 404 }); + } } return NextResponse.json(data); diff --git a/src/app/api/master-class-register/route.ts b/src/app/api/master-class-register/route.ts new file mode 100644 index 0000000..8452a2c --- /dev/null +++ b/src/app/api/master-class-register/route.ts @@ -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 }); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 8cd8ee6..c400bae 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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() { /> + diff --git a/src/components/sections/MasterClasses.tsx b/src/components/sections/MasterClasses.tsx new file mode 100644 index 0000000..808b60f --- /dev/null +++ b/src/components/sections/MasterClasses.tsx @@ -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(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 ( +
+
+ +
+ + {data.title} + + + {upcoming.length === 0 ? ( + +
+

+ Следите за анонсами мастер-классов в нашем{" "} + + Instagram + +

+
+
+ ) : ( + +
+ {upcoming.map((item, i) => { + const duration = item.slots[0] ? calcDuration(item.slots[0]) : ""; + const slotsDisplay = formatSlots(item.slots); + + return ( +
+ {/* Image */} + {item.image && ( +
+ {item.title} +
+
+ + + {slotsDisplay} + +
+
+ )} + + {/* Content */} +
+

+ {item.title} +

+ +
+
+ + {item.trainer} +
+
+ + {item.style} +
+ {duration && ( +
+ + {duration} +
+ )} + {item.location && ( +
+ + {item.location} +
+ )} +
+ +
+ + {item.cost} + +
+ +
+ + {item.instagramUrl && ( + + )} +
+
+
+ ); + })} +
+ + )} +
+ + setSignupTitle(null)} + masterClassTitle={signupTitle ?? ""} + successMessage={data.successMessage} + /> +
+ ); +} diff --git a/src/components/ui/MasterClassSignupModal.tsx b/src/components/ui/MasterClassSignupModal.tsx new file mode 100644 index 0000000..deb1f50 --- /dev/null +++ b/src/components/ui/MasterClassSignupModal.tsx @@ -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( +
+
+ +
e.stopPropagation()} + > + + + {submitted ? ( +
+
+ +
+

Отлично!

+

+ {successMessage || "Вы записаны! Мы свяжемся с вами"} +

+ +
+ ) : ( + <> +
+

Записаться

+

{masterClassTitle}

+
+ +
+
+ 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]" + /> +
+ +
+ + + @ + + 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" + /> +
+ +
+ + + @ + + 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" + /> +
+ + {error && ( +

{error}

+ )} + + +
+ + )} +
+
, + document.body + ); +} diff --git a/src/data/content.ts b/src/data/content.ts index 18f4c61..0aa3bbb 100644 --- a/src/data/content.ts +++ b/src/data/content.ts @@ -307,6 +307,10 @@ export const siteContent: SiteContent = { "В случае болезни, подтверждённой больничным листом, возможно продление срока действия абонемента.", ], }, + masterClasses: { + title: "Мастер-классы", + items: [], + }, schedule: { title: "Расписание", locations: [ diff --git a/src/data/seed.ts b/src/data/seed.ts index 40ca5d3..5255256 100644 --- a/src/data/seed.ts +++ b/src/data/seed.ts @@ -41,6 +41,7 @@ const sectionData: Record = { hero: siteContent.hero, about: siteContent.about, classes: siteContent.classes, + masterClasses: siteContent.masterClasses, faq: siteContent.faq, pricing: siteContent.pricing, schedule: siteContent.schedule, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index aac4bca..c7dbc33 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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" }, diff --git a/src/lib/db.ts b/src/lib/db.ts index 940d5ca..27216d2 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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 }; diff --git a/src/types/content.ts b/src/types/content.ts index ed7a411..da8cce0 100644 --- a/src/types/content.ts +++ b/src/types/content.ts @@ -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[]; diff --git a/src/types/index.ts b/src/types/index.ts index 0dc4e71..0944a08 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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";