Compare commits
3 Commits
bf9b1876b5
...
bbe485d8fc
| Author | SHA1 | Date | |
|---|---|---|---|
| bbe485d8fc | |||
| 16ac56f62e | |||
| fa26092ea4 |
+1
-1
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev -H 0.0.0.0",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
|
||||
@@ -1,16 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { X, ChevronDown } from "lucide-react";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
|
||||
type Tab = "classes" | "events";
|
||||
type EventType = "master-class" | "open-day";
|
||||
|
||||
interface McOption { title: string; date: string }
|
||||
interface OdClass { id: number; style: string; time: string; hall: string }
|
||||
interface OdClass { id: number; style: string; start_time: string; hall: string; trainer: string }
|
||||
interface OdEvent { id: number; date: string; title?: string }
|
||||
interface ScheduleClass { type: string; trainer: string; time: string; day: string; hall: string }
|
||||
|
||||
function shortName(fullName: string) {
|
||||
const parts = fullName.trim().split(/\s+/);
|
||||
// Names stored as "Имя Фамилия" → show "Фамилия И."
|
||||
return parts.length > 1 ? `${parts[1]} ${parts[0][0]}.` : parts[0];
|
||||
}
|
||||
|
||||
const SHORT_DAYS: Record<string, string> = {
|
||||
"Понедельник": "Пн", "Вторник": "Вт", "Среда": "Ср",
|
||||
"Четверг": "Чт", "Пятница": "Пт", "Суббота": "Сб", "Воскресенье": "Вс",
|
||||
};
|
||||
|
||||
// --- Searchable dropdown ---
|
||||
|
||||
interface SearchSelectOption { value: string; label: string }
|
||||
|
||||
function SearchSelect({ options, value, onChange, placeholder }: {
|
||||
options: SearchSelectOption[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const selected = options.find((o) => o.value === value);
|
||||
|
||||
const filtered = search
|
||||
? options.filter((o) => o.label.toLowerCase().includes(search.toLowerCase()))
|
||||
: options;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handle(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handle);
|
||||
return () => document.removeEventListener("mousedown", handle);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div
|
||||
onClick={() => { setOpen(true); setTimeout(() => inputRef.current?.focus(), 0); }}
|
||||
className={`flex items-center gap-2 w-full rounded-lg border px-3 py-2 text-sm cursor-text transition-colors ${
|
||||
open ? "border-gold/40 bg-white/[0.06]" : "border-white/[0.08] bg-white/[0.04]"
|
||||
}`}
|
||||
>
|
||||
{open ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={selected ? selected.label : placeholder}
|
||||
className="flex-1 bg-transparent text-white placeholder-neutral-500 outline-none text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") { setOpen(false); setSearch(""); }
|
||||
if (e.key === "Backspace" && !search && value) { onChange(""); }
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className={`flex-1 truncate ${selected ? "text-white" : "text-neutral-500"}`}>
|
||||
{selected ? selected.label : placeholder}
|
||||
</span>
|
||||
)}
|
||||
{value && !open ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onChange(""); }}
|
||||
className="text-neutral-500 hover:text-white transition-colors shrink-0"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
) : (
|
||||
<ChevronDown size={14} className={`text-neutral-500 shrink-0 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-20 mt-1 w-full rounded-lg border border-white/[0.08] shadow-xl overflow-hidden" style={{ backgroundColor: "#141414" }}>
|
||||
<div className="max-h-48 overflow-y-auto styled-scrollbar">
|
||||
{filtered.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-neutral-500">Ничего не найдено</p>
|
||||
)}
|
||||
{filtered.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => { onChange(o.value); setOpen(false); setSearch(""); }}
|
||||
className={`w-full px-3 py-2 text-left text-sm transition-colors ${
|
||||
o.value === value ? "bg-gold/10 text-gold" : "text-white hover:bg-white/[0.05]"
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Modal ---
|
||||
|
||||
export function AddBookingModal({
|
||||
open,
|
||||
@@ -32,13 +144,36 @@ export function AddBookingModal({
|
||||
const [odClasses, setOdClasses] = useState<OdClass[]>([]);
|
||||
const [odEventId, setOdEventId] = useState<number | null>(null);
|
||||
const [odClassId, setOdClassId] = useState("");
|
||||
const [scheduleClasses, setScheduleClasses] = useState<ScheduleClass[]>([]);
|
||||
const [classInfo, setClassInfo] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(""); setPhone("+375 "); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId("");
|
||||
setName(""); setPhone("+375 "); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId(""); setClassInfo("");
|
||||
|
||||
// Fetch upcoming MCs (filter out expired)
|
||||
// Fetch schedule classes
|
||||
adminFetch("/api/admin/sections/schedule").then((r) => r.json()).then((data: { locations?: { name: string; days: { day: string; classes: { type: string; trainer: string; time: string }[] }[] }[] }) => {
|
||||
const classes: ScheduleClass[] = [];
|
||||
for (const loc of data.locations || []) {
|
||||
for (const day of loc.days) {
|
||||
for (const cls of day.classes) {
|
||||
classes.push({ type: cls.type, trainer: cls.trainer, time: cls.time, day: day.day, hall: loc.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Deduplicate by type+trainer+time+day+hall
|
||||
const seen = new Set<string>();
|
||||
const unique = classes.filter((c) => {
|
||||
const key = `${c.type}|${c.trainer}|${c.time}|${c.day}|${c.hall}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
setScheduleClasses(unique);
|
||||
}).catch(() => {});
|
||||
|
||||
// Fetch upcoming MCs
|
||||
adminFetch("/api/admin/sections/masterClasses").then((r) => r.json()).then((data: { items?: { title: string; slots: { date: string }[] }[] }) => {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const upcoming = (data.items || [])
|
||||
@@ -89,19 +224,39 @@ export function AddBookingModal({
|
||||
|
||||
const hasUpcomingMc = mcOptions.length > 0;
|
||||
const hasOpenDay = odEventId !== null && odClasses.length > 0;
|
||||
const hasEvents = hasUpcomingMc || hasOpenDay;
|
||||
|
||||
// Build options for each dropdown
|
||||
const classOptions: SearchSelectOption[] = scheduleClasses.map((c, i) => ({
|
||||
value: String(i),
|
||||
label: `${shortName(c.trainer)} — ${c.type} · ${SHORT_DAYS[c.day] || c.day} ${c.time} · ${c.hall}`,
|
||||
}));
|
||||
|
||||
const mcSelectOptions: SearchSelectOption[] = mcOptions.map((mc) => ({
|
||||
value: mc.title,
|
||||
label: mc.title,
|
||||
}));
|
||||
|
||||
const odSelectOptions: SearchSelectOption[] = odClasses.map((c) => ({
|
||||
value: String(c.id),
|
||||
label: `${shortName(c.trainer)} — ${c.start_time} · ${c.hall}`,
|
||||
}));
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!name.trim() || !phone.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
if (tab === "classes") {
|
||||
const selectedClass = classInfo ? scheduleClasses[Number(classInfo)] : null;
|
||||
const groupInfo = selectedClass
|
||||
? `${selectedClass.type}, ${shortName(selectedClass.trainer)}, ${SHORT_DAYS[selectedClass.day] || selectedClass.day} ${selectedClass.time}, ${selectedClass.hall}`
|
||||
: undefined;
|
||||
await adminFetch("/api/admin/group-bookings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
phone: phone.trim(),
|
||||
...(groupInfo && { groupInfo }),
|
||||
...(instagram.trim() && { instagram: instagram.trim() }),
|
||||
...(telegram.trim() && { telegram: telegram.trim() }),
|
||||
}),
|
||||
@@ -130,18 +285,6 @@ export function AddBookingModal({
|
||||
if (!open) return null;
|
||||
|
||||
const inputClass = "w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white outline-none focus:border-gold/40 placeholder-neutral-500";
|
||||
const tabBtn = (key: Tab, label: string, disabled?: boolean) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => !disabled && setTab(key)}
|
||||
disabled={disabled}
|
||||
className={`flex-1 rounded-lg py-2 text-xs font-medium transition-all ${
|
||||
tab === key ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
const canSubmit = name.trim() && phone.trim() && !saving
|
||||
&& (tab === "classes" || (tab === "events" && eventType === "master-class" && hasUpcomingMc)
|
||||
@@ -159,20 +302,21 @@ export function AddBookingModal({
|
||||
<p className="mt-1 text-xs text-neutral-400">Ручная запись (Instagram, звонок, лично)</p>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{/* Tab: Classes vs Events */}
|
||||
<div className="flex gap-2">
|
||||
{tabBtn("classes", "Занятие")}
|
||||
{tabBtn("events", "Мероприятие", !hasEvents)}
|
||||
</div>
|
||||
|
||||
{/* Events sub-selector */}
|
||||
{tab === "events" && (
|
||||
<div className="flex gap-2">
|
||||
{/* Type selector — single row */}
|
||||
<div className="flex rounded-lg border border-white/[0.08] bg-white/[0.03] p-0.5">
|
||||
<button
|
||||
onClick={() => setTab("classes")}
|
||||
className={`flex-1 rounded-md py-2 text-xs font-medium transition-all ${
|
||||
tab === "classes" ? "bg-gold/20 text-gold shadow-sm" : "text-neutral-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
Занятие
|
||||
</button>
|
||||
{hasUpcomingMc && (
|
||||
<button
|
||||
onClick={() => setEventType("master-class")}
|
||||
className={`flex-1 rounded-lg py-1.5 text-[11px] font-medium transition-all ${
|
||||
eventType === "master-class" ? "bg-purple-500/15 text-purple-400 border border-purple-500/30" : "bg-neutral-800/50 text-neutral-500 border border-white/5 hover:text-neutral-300"
|
||||
onClick={() => { setTab("events"); setEventType("master-class"); }}
|
||||
className={`flex-1 rounded-md py-2 text-xs font-medium transition-all ${
|
||||
tab === "events" && eventType === "master-class" ? "bg-purple-500/15 text-purple-400 shadow-sm" : "text-neutral-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
Мастер-класс
|
||||
@@ -180,39 +324,44 @@ export function AddBookingModal({
|
||||
)}
|
||||
{hasOpenDay && (
|
||||
<button
|
||||
onClick={() => setEventType("open-day")}
|
||||
className={`flex-1 rounded-lg py-1.5 text-[11px] font-medium transition-all ${
|
||||
eventType === "open-day" ? "bg-blue-500/15 text-blue-400 border border-blue-500/30" : "bg-neutral-800/50 text-neutral-500 border border-white/5 hover:text-neutral-300"
|
||||
onClick={() => { setTab("events"); setEventType("open-day"); }}
|
||||
className={`flex-1 rounded-md py-2 text-xs font-medium transition-all ${
|
||||
tab === "events" && eventType === "open-day" ? "bg-blue-500/15 text-blue-400 shadow-sm" : "text-neutral-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
Open Day
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Class selector (optional for Занятие) */}
|
||||
{tab === "classes" && classOptions.length > 0 && (
|
||||
<SearchSelect
|
||||
options={classOptions}
|
||||
value={classInfo}
|
||||
onChange={setClassInfo}
|
||||
placeholder="Выберите занятие (необязательно)"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* MC selector */}
|
||||
{tab === "events" && eventType === "master-class" && mcOptions.length > 0 && (
|
||||
<select value={mcTitle} onChange={(e) => setMcTitle(e.target.value)} className={inputClass + " [color-scheme:dark]"}>
|
||||
<option value="" className="bg-neutral-900">Выберите мастер-класс</option>
|
||||
{mcOptions.map((mc) => (
|
||||
<option key={mc.title} value={mc.title} className="bg-neutral-900">
|
||||
{mc.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{tab === "events" && eventType === "master-class" && mcSelectOptions.length > 0 && (
|
||||
<SearchSelect
|
||||
options={mcSelectOptions}
|
||||
value={mcTitle}
|
||||
onChange={setMcTitle}
|
||||
placeholder="Выберите мастер-класс"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Open Day class selector */}
|
||||
{tab === "events" && eventType === "open-day" && odClasses.length > 0 && (
|
||||
<select value={odClassId} onChange={(e) => setOdClassId(e.target.value)} className={inputClass + " [color-scheme:dark]"}>
|
||||
<option value="" className="bg-neutral-900">Выберите занятие</option>
|
||||
{odClasses.map((c) => (
|
||||
<option key={c.id} value={c.id} className="bg-neutral-900">
|
||||
{c.time} · {c.style} · {c.hall}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{tab === "events" && eventType === "open-day" && odSelectOptions.length > 0 && (
|
||||
<SearchSelect
|
||||
options={odSelectOptions}
|
||||
value={odClassId}
|
||||
onChange={setOdClassId}
|
||||
placeholder="Выберите занятие"
|
||||
/>
|
||||
)}
|
||||
|
||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Имя" className={inputClass} />
|
||||
|
||||
@@ -173,13 +173,27 @@ export function GenericBookingsList<T extends BaseBooking>({
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{isOpen && (
|
||||
{isOpen && (() => {
|
||||
const regular = group.items.filter((i) => !i.notes?.includes("Лист ожидания"));
|
||||
const waiting = group.items.filter((i) => i.notes?.includes("Лист ожидания"));
|
||||
return (
|
||||
<div className="px-4 pb-3 pt-1 space-y-2">
|
||||
{group.items.map((item) => renderItem(item, group.isArchived))}
|
||||
{regular.map((item) => renderItem(item, group.isArchived))}
|
||||
{waiting.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<div className="flex-1 h-px bg-amber-500/20" />
|
||||
<span className="text-[10px] text-amber-400 shrink-0">лист ожидания</span>
|
||||
<div className="flex-1 h-px bg-amber-500/20" />
|
||||
</div>
|
||||
{waiting.map((item) => renderItem(item, group.isArchived))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -597,10 +597,12 @@ function countByStatus(items: { status: string }[]): TabCounts {
|
||||
}
|
||||
|
||||
|
||||
function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
|
||||
function DashboardSummary({ refreshTrigger, onNavigate, onFilter, activeTab, activeFilter }: {
|
||||
refreshTrigger: number;
|
||||
onNavigate: (tab: Tab) => void;
|
||||
onFilter: (f: BookingFilter) => void;
|
||||
activeTab: Tab;
|
||||
activeFilter: BookingFilter;
|
||||
}) {
|
||||
const [counts, setCounts] = useState<DashboardCounts | null>(null);
|
||||
|
||||
@@ -716,6 +718,11 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
|
||||
<p className="text-lg font-bold text-neutral-600 mt-1">—</p>
|
||||
</div>
|
||||
);
|
||||
const isActiveCard = activeTab === c.tab;
|
||||
const hl = (status: BookingFilter) =>
|
||||
isActiveCard && activeFilter === status
|
||||
? "rounded-md bg-white/10 px-1.5 -mx-1.5 py-0.5 -my-0.5 ring-1 ring-white/20"
|
||||
: "";
|
||||
return (
|
||||
<button key={c.tab} onClick={() => { onNavigate(c.tab); onFilter("all"); }}
|
||||
className={`rounded-xl border ${c.color} bg-neutral-900 p-3 text-left transition-all hover:bg-neutral-800/80 hover:scale-[1.02]`}>
|
||||
@@ -723,8 +730,8 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
|
||||
<div className="flex items-baseline gap-2 mt-1 flex-wrap">
|
||||
{tc.new > 0 && (
|
||||
<>
|
||||
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
|
||||
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter("new"); }}>
|
||||
<span className={`inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all ${hl("new")}`}
|
||||
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter(activeFilter === "new" && isActiveCard ? "all" : "new"); }}>
|
||||
<span className="text-lg font-bold text-gold">{tc.new}</span>
|
||||
<span className="text-[10px] text-neutral-500">новых</span>
|
||||
</span>
|
||||
@@ -733,8 +740,8 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
|
||||
{tc.contacted > 0 && (
|
||||
<>
|
||||
{tc.new > 0 && <span className="text-neutral-700">·</span>}
|
||||
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
|
||||
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter("contacted"); }}>
|
||||
<span className={`inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all ${hl("contacted")}`}
|
||||
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter(activeFilter === "contacted" && isActiveCard ? "all" : "contacted"); }}>
|
||||
<span className="text-sm font-medium text-blue-400">{tc.contacted}</span>
|
||||
<span className="text-[10px] text-neutral-500">в работе</span>
|
||||
</span>
|
||||
@@ -743,8 +750,8 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
|
||||
{tc.confirmed > 0 && (
|
||||
<>
|
||||
{(tc.new > 0 || tc.contacted > 0) && <span className="text-neutral-700">·</span>}
|
||||
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
|
||||
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter("confirmed"); }}>
|
||||
<span className={`inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all ${hl("confirmed")}`}
|
||||
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter(activeFilter === "confirmed" && isActiveCard ? "all" : "confirmed"); }}>
|
||||
<span className="text-sm font-medium text-emerald-400">{tc.confirmed}</span>
|
||||
<span className="text-[10px] text-neutral-500">подтв.</span>
|
||||
</span>
|
||||
@@ -753,8 +760,8 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
|
||||
{tc.declined > 0 && (
|
||||
<>
|
||||
{(tc.new > 0 || tc.contacted > 0 || tc.confirmed > 0) && <span className="text-neutral-700">·</span>}
|
||||
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
|
||||
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter("declined"); }}>
|
||||
<span className={`inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all ${hl("declined")}`}
|
||||
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter(activeFilter === "declined" && isActiveCard ? "all" : "declined"); }}>
|
||||
<span className="text-sm font-medium text-red-400">{tc.declined}</span>
|
||||
<span className="text-[10px] text-neutral-500">отказ</span>
|
||||
</span>
|
||||
@@ -941,15 +948,26 @@ function BookingsPageInner() {
|
||||
) : (
|
||||
<>
|
||||
{/* Dashboard — what needs attention */}
|
||||
<DashboardSummary refreshTrigger={dashboardKey + refreshKey} onNavigate={setTab} onFilter={setStatusFilter} />
|
||||
<DashboardSummary refreshTrigger={dashboardKey + refreshKey} onNavigate={setTab} onFilter={setStatusFilter} activeTab={tab} activeFilter={statusFilter} />
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mt-5 flex border-b border-white/10">
|
||||
{/* Tabs — select on mobile, tabs on desktop */}
|
||||
<div className="mt-5 sm:hidden">
|
||||
<select
|
||||
value={tab}
|
||||
onChange={(e) => setTab(e.target.value as Tab)}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-900 px-4 py-2.5 text-sm font-medium text-white outline-none focus:border-gold/40 transition-colors [color-scheme:dark]"
|
||||
>
|
||||
{TABS.map((t) => (
|
||||
<option key={t.key} value={t.key}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mt-5 hidden sm:flex border-b border-white/10">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
|
||||
className={`shrink-0 px-4 py-2.5 text-sm font-medium transition-colors relative whitespace-nowrap ${
|
||||
tab === t.key ? "text-gold" : "text-neutral-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
@@ -964,9 +982,9 @@ function BookingsPageInner() {
|
||||
{/* Tab content */}
|
||||
<div className="mt-4">
|
||||
{tab === "reminders" && <RemindersTab key={refreshKey} />}
|
||||
{tab === "classes" && <GroupBookingsTab filter={statusFilter} onDataChange={refreshDashboard} />}
|
||||
{tab === "master-classes" && <McRegistrationsTab filter={statusFilter} onDataChange={refreshDashboard} />}
|
||||
{tab === "open-day" && <OpenDayBookingsTab filter={statusFilter} hallFilter={hallFilter} onDataChange={refreshDashboard} />}
|
||||
{tab === "classes" && <GroupBookingsTab key={refreshKey} filter={statusFilter} onDataChange={refreshDashboard} />}
|
||||
{tab === "master-classes" && <McRegistrationsTab key={refreshKey} filter={statusFilter} onDataChange={refreshDashboard} />}
|
||||
{tab === "open-day" && <OpenDayBookingsTab key={refreshKey} filter={statusFilter} hallFilter={hallFilter} onDataChange={refreshDashboard} />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -974,7 +992,7 @@ function BookingsPageInner() {
|
||||
<AddBookingModal
|
||||
open={addOpen}
|
||||
onClose={() => setAddOpen(false)}
|
||||
onAdded={() => { setRefreshKey((k) => k + 1); refreshDashboard(); }}
|
||||
onAdded={() => { setStatusFilter("all"); setRefreshKey((k) => k + 1); refreshDashboard(); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -333,8 +333,8 @@ function ClassCell({
|
||||
)}
|
||||
{cls.cancelled && <span className="text-[9px] text-neutral-500">отменено</span>}
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className="absolute top-1.5 right-1.5 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{/* Actions — always visible on mobile, hover on desktop */}
|
||||
<div className="absolute top-1.5 right-1.5 flex gap-1 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onCancel(cls.id); }}
|
||||
className={`rounded-md p-1 transition-colors ${cls.cancelled ? "text-neutral-500 hover:text-emerald-400 hover:bg-emerald-400/10" : "text-neutral-500 hover:text-yellow-400 hover:bg-yellow-400/10"}`}
|
||||
@@ -375,8 +375,22 @@ function ScheduleGrid({
|
||||
}) {
|
||||
const [selectedHall, setSelectedHall] = useState(halls[0] ?? "");
|
||||
const [editingClassId, setEditingClassId] = useState<number | null>(null);
|
||||
const [confirmAction, setConfirmAction] = useState<{ message: string; onConfirm: () => void } | null>(null);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const timeSlots = generateTimeSlots(10, 22);
|
||||
|
||||
// Close edit mode when clicking outside the grid
|
||||
useEffect(() => {
|
||||
if (editingClassId === null) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (gridRef.current && !gridRef.current.contains(e.target as Node)) {
|
||||
setEditingClassId(null);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [editingClassId]);
|
||||
|
||||
// Build lookup: time -> class for selected hall
|
||||
const hallClasses = useMemo(() => {
|
||||
const map: Record<string, OpenDayClass> = {};
|
||||
@@ -415,19 +429,31 @@ function ScheduleGrid({
|
||||
onClassesChange();
|
||||
}
|
||||
|
||||
async function deleteClass(id: number) {
|
||||
function deleteClass(id: number) {
|
||||
setConfirmAction({
|
||||
message: "Удалить занятие? Это действие нельзя отменить.",
|
||||
onConfirm: async () => {
|
||||
await adminFetch(`/api/admin/open-day/classes?id=${id}`, { method: "DELETE" });
|
||||
onClassesChange();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function cancelClass(id: number) {
|
||||
function cancelClass(id: number) {
|
||||
const cls = classes.find((c) => c.id === id);
|
||||
if (!cls) return;
|
||||
setConfirmAction({
|
||||
message: cls.cancelled
|
||||
? "Восстановить занятие?"
|
||||
: `Отменить занятие? (${cls.bookingCount} записей)`,
|
||||
onConfirm: async () => {
|
||||
await updateClass(id, { cancelled: !cls.cancelled });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-neutral-900 p-5 space-y-3">
|
||||
<div ref={gridRef} className="rounded-xl border border-white/10 bg-neutral-900 p-5 space-y-3">
|
||||
<h2 className="text-lg font-bold">Расписание</h2>
|
||||
|
||||
{halls.length === 0 ? (
|
||||
@@ -499,6 +525,30 @@ function ScheduleGrid({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Confirm dialog */}
|
||||
{confirmAction && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setConfirmAction(null)}>
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
<div className="relative w-full max-w-xs rounded-xl border border-white/[0.08] bg-[#141414] p-5 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<p className="text-sm text-white text-center">{confirmAction.message}</p>
|
||||
<div className="mt-4 flex gap-2 justify-center">
|
||||
<button
|
||||
onClick={() => setConfirmAction(null)}
|
||||
className="rounded-lg border border-white/10 px-4 py-2 text-xs font-medium text-neutral-400 hover:text-white hover:border-white/25 transition-colors"
|
||||
>
|
||||
Нет
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { confirmAction.onConfirm(); setConfirmAction(null); }}
|
||||
className="rounded-lg bg-gold/20 border border-gold/30 px-4 py-2 text-xs font-medium text-gold hover:bg-gold/30 transition-colors"
|
||||
>
|
||||
Да
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,11 +49,11 @@ export async function PUT(request: NextRequest) {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, phone, instagram, telegram } = body;
|
||||
const { name, phone, groupInfo, instagram, telegram } = body;
|
||||
if (!name?.trim() || !phone?.trim()) {
|
||||
return NextResponse.json({ error: "name and phone are required" }, { status: 400 });
|
||||
}
|
||||
const id = addGroupBooking(name.trim(), phone.trim(), undefined, instagram?.trim() || undefined, telegram?.trim() || undefined);
|
||||
const id = addGroupBooking(name.trim(), phone.trim(), groupInfo?.trim() || undefined, instagram?.trim() || undefined, telegram?.trim() || undefined);
|
||||
return NextResponse.json({ ok: true, id });
|
||||
} catch (err) {
|
||||
console.error("[admin/group-bookings] POST error:", err);
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-gutter: stable;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 600px;
|
||||
width: min(600px, 100%);
|
||||
height: 400px;
|
||||
background: radial-gradient(ellipse, rgba(201, 169, 110, 0.05), transparent 70%);
|
||||
pointer-events: none;
|
||||
|
||||
@@ -14,6 +14,7 @@ export function Footer() {
|
||||
<div className="flex items-center gap-1.5 text-sm text-neutral-500">
|
||||
<span>Made with</span>
|
||||
<Heart size={14} className="fill-gold text-gold" />
|
||||
<span>by Diana Dolgolyova</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -164,28 +164,28 @@ export function Hero({ data: hero }: HeroProps) {
|
||||
|
||||
{/* Content */}
|
||||
<div className="section-container relative z-10 text-center" style={{ textShadow: "0 1px 0 rgba(201,169,110,0.3), 0 2px 0 rgba(201,169,110,0.2), 0 4px 8px rgba(0,0,0,0.4), 0 8px 20px rgba(0,0,0,0.3)" }}>
|
||||
<div className="hero-logo relative mx-auto mb-12 flex items-center justify-center" style={{ width: 220, height: 181 }}>
|
||||
<div className="hero-logo relative mx-auto mb-6 sm:mb-12 flex items-center justify-center" style={{ width: 160, height: 132 }}>
|
||||
<div className="absolute -inset-10 rounded-full blur-[80px]" style={{ background: "radial-gradient(circle, rgba(201,169,110,0.25), transparent 70%)" }} />
|
||||
<div className="hero-logo-heartbeat relative">
|
||||
<HeroLogo
|
||||
size={220}
|
||||
className="drop-shadow-[0_0_10px_rgba(201,169,110,0.35)] drop-shadow-[0_0_40px_rgba(201,169,110,0.15)]"
|
||||
size={160}
|
||||
className="drop-shadow-[0_0_10px_rgba(201,169,110,0.35)] drop-shadow-[0_0_40px_rgba(201,169,110,0.15)] sm:scale-[1.375]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="hero-title font-display text-5xl font-bold tracking-tight sm:text-6xl lg:text-8xl">
|
||||
<h1 className="hero-title font-display text-4xl font-bold tracking-tight sm:text-6xl lg:text-8xl">
|
||||
<span className="gradient-text">{hero.headline}</span>
|
||||
</h1>
|
||||
|
||||
<p className="hero-subtitle mx-auto mt-8 max-w-xl text-xl text-gold/80 sm:text-2xl">
|
||||
<p className="hero-subtitle mx-auto mt-5 max-w-xl text-lg text-gold/80 sm:mt-8 sm:text-2xl">
|
||||
{hero.subheadline}
|
||||
</p>
|
||||
|
||||
<div className="hero-cta mt-14">
|
||||
<div className="hero-cta mt-8 sm:mt-14">
|
||||
<button
|
||||
onClick={openBooking}
|
||||
className="group relative rounded-full border border-gold/60 bg-gold/15 px-10 py-5 text-lg font-semibold text-gold backdrop-blur-md transition-all duration-300 hover:bg-gold/25 hover:border-gold hover:shadow-[0_0_40px_rgba(201,169,110,0.35)] cursor-pointer"
|
||||
className="group relative rounded-full border border-gold/60 bg-gold/15 px-8 py-4 text-base font-semibold text-gold backdrop-blur-md transition-all duration-300 hover:bg-gold/25 hover:border-gold hover:shadow-[0_0_40px_rgba(201,169,110,0.35)] cursor-pointer sm:px-10 sm:py-5 sm:text-lg"
|
||||
>
|
||||
<span className="relative z-10">{hero.ctaText}</span>
|
||||
{/* Pulse glow on hover */}
|
||||
|
||||
@@ -313,9 +313,9 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
|
||||
<SectionHeading centered>{schedule.title}</SectionHeading>
|
||||
</Reveal>
|
||||
|
||||
{/* Location tabs */}
|
||||
{/* Location tabs + mobile filter */}
|
||||
<Reveal>
|
||||
<div className="mt-8 flex justify-center gap-2 flex-wrap">
|
||||
<div className="mt-8 flex items-center justify-center gap-2 flex-wrap">
|
||||
{/* "All studios" tab — only when multiple locations */}
|
||||
{schedule.locations.length > 1 && (
|
||||
<button
|
||||
@@ -351,12 +351,9 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Mobile filter button — visible only on small screens */}
|
||||
<Reveal>
|
||||
<div className="mt-4 flex sm:hidden justify-center">
|
||||
{/* Mobile filter — inline with hall tabs */}
|
||||
<div className="sm:hidden">
|
||||
<ScheduleFilters
|
||||
typeDots={typeDots}
|
||||
types={types}
|
||||
@@ -381,6 +378,7 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
|
||||
scheduleConfig={scheduleConfig}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* View mode toggle + filter button */}
|
||||
|
||||
@@ -82,15 +82,16 @@ export function ScheduleFilters({
|
||||
{/* Filter button — same style as По дням / По группам buttons */}
|
||||
<button
|
||||
onClick={() => setModalOpen(true)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-xs font-medium transition-all duration-200 cursor-pointer ${
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-5 py-2.5 text-sm font-medium transition-all duration-200 cursor-pointer sm:rounded-lg sm:px-4 sm:py-2 sm:text-xs ${
|
||||
totalActive > 0
|
||||
? "bg-white text-neutral-900 shadow-sm dark:bg-white/10 dark:text-white"
|
||||
: "text-neutral-500 hover:text-neutral-700 dark:text-white/35 dark:hover:text-white/60"
|
||||
? "border border-gold/40 bg-gold/10 text-gold sm:border-0 sm:bg-white sm:text-neutral-900 sm:shadow-sm dark:sm:bg-white/10 dark:sm:text-white"
|
||||
: "border border-neutral-300 text-neutral-500 hover:text-neutral-700 dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/20 sm:border-0 sm:hover:text-neutral-700 dark:sm:text-white/35 dark:sm:hover:text-white/60"
|
||||
}`}
|
||||
>
|
||||
<SlidersHorizontal size={13} />
|
||||
<SlidersHorizontal size={16} className="sm:hidden" />
|
||||
<SlidersHorizontal size={13} className="hidden sm:block" />
|
||||
{totalActive > 0 && (
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-gold text-[9px] font-bold text-black -mr-0.5">
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-gold text-[10px] font-bold text-black sm:h-4 sm:w-4 sm:text-[9px] -mr-0.5">
|
||||
{totalActive}
|
||||
</span>
|
||||
)}
|
||||
@@ -196,14 +197,18 @@ export function ScheduleFilters({
|
||||
const desc = scheduleConfig?.levels?.find((l) => l.value === level)?.description;
|
||||
const active = filterLevel === level;
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={level}
|
||||
onClick={() => setFilterLevel(active ? null : level)}
|
||||
className={`flex items-center gap-2.5 w-full rounded-lg px-3 py-2 transition-all cursor-pointer ${
|
||||
active
|
||||
? "bg-gold/10"
|
||||
: "hover:bg-white/[0.03]"
|
||||
}`}
|
||||
onClick={() => setFilterLevel(active ? null : level)}
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setFilterLevel(active ? null : level); } }}
|
||||
>
|
||||
<span className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 transition-colors ${
|
||||
active ? "border-gold" : "border-white/20"
|
||||
@@ -214,7 +219,7 @@ export function ScheduleFilters({
|
||||
{level}
|
||||
</span>
|
||||
{desc && <InfoTip text={desc} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,8 @@ const {
|
||||
cardSpacing: CARD_SPACING,
|
||||
} = UI_CONFIG.team;
|
||||
|
||||
const MOBILE_SWIPE_THRESHOLD = 30;
|
||||
|
||||
function wrapIndex(i: number, total: number) {
|
||||
return ((i % total) + total) % total;
|
||||
}
|
||||
@@ -95,9 +97,47 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
|
||||
if (swipeHintVisible) setSwipeHintVisible(false);
|
||||
}, [swipeHintVisible]);
|
||||
|
||||
// Pointer handlers
|
||||
// Mobile: simple swipe (touch) — snap to next/prev, no drag visuals
|
||||
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const isMobileRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
isMobileRef.current = "ontouchstart" in window;
|
||||
}, []);
|
||||
|
||||
const onTouchStart = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
touchStartRef.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||
hideSwipeHint();
|
||||
},
|
||||
[hideSwipeHint],
|
||||
);
|
||||
|
||||
const onTouchEnd = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
if (!touchStartRef.current) return;
|
||||
const dx = e.changedTouches[0].clientX - touchStartRef.current.x;
|
||||
const dy = e.changedTouches[0].clientY - touchStartRef.current.y;
|
||||
touchStartRef.current = null;
|
||||
|
||||
// Only trigger if horizontal swipe is dominant
|
||||
if (Math.abs(dx) > MOBILE_SWIPE_THRESHOLD && Math.abs(dx) > Math.abs(dy) * 1.2) {
|
||||
wasDragRef.current = true;
|
||||
pausedUntilRef.current = Date.now() + PAUSE_MS;
|
||||
if (dx < 0) {
|
||||
onActiveChange(wrapIndex(activeIndex + 1, total));
|
||||
} else {
|
||||
onActiveChange(wrapIndex(activeIndex - 1, total));
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeIndex, total, onActiveChange],
|
||||
);
|
||||
|
||||
// Desktop: pointer drag with continuous offset
|
||||
const onPointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (isMobileRef.current) return;
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
isDraggingRef.current = true;
|
||||
wasDragRef.current = false;
|
||||
@@ -114,8 +154,6 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
|
||||
const dx = e.clientX - dragStartRef.current.x;
|
||||
if (Math.abs(dx) > 10) wasDragRef.current = true;
|
||||
|
||||
// Continuously snap the base index as user drags past card boundaries
|
||||
// This keeps cards wrapping around smoothly during drag
|
||||
const steps = Math.round(dx / CARD_SPACING);
|
||||
if (steps !== 0) {
|
||||
const newBase = wrapIndex(dragStartRef.current.startIndex - steps, total);
|
||||
@@ -210,6 +248,8 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
|
||||
tabIndex={0}
|
||||
className="relative mx-auto flex items-end justify-center cursor-grab select-none active:cursor-grabbing touch-pan-y focus:outline-none focus-visible:ring-2 focus-visible:ring-gold/50 focus-visible:rounded-2xl"
|
||||
style={{ height: UI_CONFIG.team.stageHeight }}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
|
||||
@@ -188,6 +188,15 @@ export function SignupModal({
|
||||
{successMessage || "Вы записаны!"}
|
||||
</h3>
|
||||
{subtitle && <p className="mt-1 text-sm text-neutral-400">{subtitle}</p>}
|
||||
<a
|
||||
href={BRAND.instagram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-1.5 text-sm text-pink-400 hover:text-pink-300"
|
||||
>
|
||||
<Instagram size={14} />
|
||||
{instagramHint || "По вопросам пишите в Instagram"}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user