Compare commits
3 Commits
bf9b1876b5
...
bbe485d8fc
| Author | SHA1 | Date | |
|---|---|---|---|
| bbe485d8fc | |||
| 16ac56f62e | |||
| fa26092ea4 |
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev -H 0.0.0.0",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
|
|||||||
@@ -1,16 +1,128 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { X } from "lucide-react";
|
import { X, ChevronDown } from "lucide-react";
|
||||||
import { adminFetch } from "@/lib/csrf";
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
|
||||||
type Tab = "classes" | "events";
|
type Tab = "classes" | "events";
|
||||||
type EventType = "master-class" | "open-day";
|
type EventType = "master-class" | "open-day";
|
||||||
|
|
||||||
interface McOption { title: string; date: string }
|
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 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({
|
export function AddBookingModal({
|
||||||
open,
|
open,
|
||||||
@@ -32,13 +144,36 @@ export function AddBookingModal({
|
|||||||
const [odClasses, setOdClasses] = useState<OdClass[]>([]);
|
const [odClasses, setOdClasses] = useState<OdClass[]>([]);
|
||||||
const [odEventId, setOdEventId] = useState<number | null>(null);
|
const [odEventId, setOdEventId] = useState<number | null>(null);
|
||||||
const [odClassId, setOdClassId] = useState("");
|
const [odClassId, setOdClassId] = useState("");
|
||||||
|
const [scheduleClasses, setScheduleClasses] = useState<ScheduleClass[]>([]);
|
||||||
|
const [classInfo, setClassInfo] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
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 }[] }[] }) => {
|
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 today = new Date().toISOString().split("T")[0];
|
||||||
const upcoming = (data.items || [])
|
const upcoming = (data.items || [])
|
||||||
@@ -89,19 +224,39 @@ export function AddBookingModal({
|
|||||||
|
|
||||||
const hasUpcomingMc = mcOptions.length > 0;
|
const hasUpcomingMc = mcOptions.length > 0;
|
||||||
const hasOpenDay = odEventId !== null && odClasses.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() {
|
async function handleSubmit() {
|
||||||
if (!name.trim() || !phone.trim()) return;
|
if (!name.trim() || !phone.trim()) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (tab === "classes") {
|
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", {
|
await adminFetch("/api/admin/group-bookings", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
phone: phone.trim(),
|
phone: phone.trim(),
|
||||||
|
...(groupInfo && { groupInfo }),
|
||||||
...(instagram.trim() && { instagram: instagram.trim() }),
|
...(instagram.trim() && { instagram: instagram.trim() }),
|
||||||
...(telegram.trim() && { telegram: telegram.trim() }),
|
...(telegram.trim() && { telegram: telegram.trim() }),
|
||||||
}),
|
}),
|
||||||
@@ -130,18 +285,6 @@ export function AddBookingModal({
|
|||||||
if (!open) return null;
|
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 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
|
const canSubmit = name.trim() && phone.trim() && !saving
|
||||||
&& (tab === "classes" || (tab === "events" && eventType === "master-class" && hasUpcomingMc)
|
&& (tab === "classes" || (tab === "events" && eventType === "master-class" && hasUpcomingMc)
|
||||||
@@ -159,60 +302,66 @@ export function AddBookingModal({
|
|||||||
<p className="mt-1 text-xs text-neutral-400">Ручная запись (Instagram, звонок, лично)</p>
|
<p className="mt-1 text-xs text-neutral-400">Ручная запись (Instagram, звонок, лично)</p>
|
||||||
|
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
{/* Tab: Classes vs Events */}
|
{/* Type selector — single row */}
|
||||||
<div className="flex gap-2">
|
<div className="flex rounded-lg border border-white/[0.08] bg-white/[0.03] p-0.5">
|
||||||
{tabBtn("classes", "Занятие")}
|
<button
|
||||||
{tabBtn("events", "Мероприятие", !hasEvents)}
|
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={() => { 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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Мастер-класс
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{hasOpenDay && (
|
||||||
|
<button
|
||||||
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Events sub-selector */}
|
{/* Class selector (optional for Занятие) */}
|
||||||
{tab === "events" && (
|
{tab === "classes" && classOptions.length > 0 && (
|
||||||
<div className="flex gap-2">
|
<SearchSelect
|
||||||
{hasUpcomingMc && (
|
options={classOptions}
|
||||||
<button
|
value={classInfo}
|
||||||
onClick={() => setEventType("master-class")}
|
onChange={setClassInfo}
|
||||||
className={`flex-1 rounded-lg py-1.5 text-[11px] font-medium transition-all ${
|
placeholder="Выберите занятие (необязательно)"
|
||||||
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"
|
/>
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Мастер-класс
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{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"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Open Day
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* MC selector */}
|
{/* MC selector */}
|
||||||
{tab === "events" && eventType === "master-class" && mcOptions.length > 0 && (
|
{tab === "events" && eventType === "master-class" && mcSelectOptions.length > 0 && (
|
||||||
<select value={mcTitle} onChange={(e) => setMcTitle(e.target.value)} className={inputClass + " [color-scheme:dark]"}>
|
<SearchSelect
|
||||||
<option value="" className="bg-neutral-900">Выберите мастер-класс</option>
|
options={mcSelectOptions}
|
||||||
{mcOptions.map((mc) => (
|
value={mcTitle}
|
||||||
<option key={mc.title} value={mc.title} className="bg-neutral-900">
|
onChange={setMcTitle}
|
||||||
{mc.title}
|
placeholder="Выберите мастер-класс"
|
||||||
</option>
|
/>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Open Day class selector */}
|
{/* Open Day class selector */}
|
||||||
{tab === "events" && eventType === "open-day" && odClasses.length > 0 && (
|
{tab === "events" && eventType === "open-day" && odSelectOptions.length > 0 && (
|
||||||
<select value={odClassId} onChange={(e) => setOdClassId(e.target.value)} className={inputClass + " [color-scheme:dark]"}>
|
<SearchSelect
|
||||||
<option value="" className="bg-neutral-900">Выберите занятие</option>
|
options={odSelectOptions}
|
||||||
{odClasses.map((c) => (
|
value={odClassId}
|
||||||
<option key={c.id} value={c.id} className="bg-neutral-900">
|
onChange={setOdClassId}
|
||||||
{c.time} · {c.style} · {c.hall}
|
placeholder="Выберите занятие"
|
||||||
</option>
|
/>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Имя" className={inputClass} />
|
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Имя" className={inputClass} />
|
||||||
|
|||||||
@@ -173,11 +173,25 @@ export function GenericBookingsList<T extends BaseBooking>({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{isOpen && (
|
{isOpen && (() => {
|
||||||
<div className="px-4 pb-3 pt-1 space-y-2">
|
const regular = group.items.filter((i) => !i.notes?.includes("Лист ожидания"));
|
||||||
{group.items.map((item) => renderItem(item, group.isArchived))}
|
const waiting = group.items.filter((i) => i.notes?.includes("Лист ожидания"));
|
||||||
</div>
|
return (
|
||||||
)}
|
<div className="px-4 pb-3 pt-1 space-y-2">
|
||||||
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -597,10 +597,12 @@ function countByStatus(items: { status: string }[]): TabCounts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
|
function DashboardSummary({ refreshTrigger, onNavigate, onFilter, activeTab, activeFilter }: {
|
||||||
refreshTrigger: number;
|
refreshTrigger: number;
|
||||||
onNavigate: (tab: Tab) => void;
|
onNavigate: (tab: Tab) => void;
|
||||||
onFilter: (f: BookingFilter) => void;
|
onFilter: (f: BookingFilter) => void;
|
||||||
|
activeTab: Tab;
|
||||||
|
activeFilter: BookingFilter;
|
||||||
}) {
|
}) {
|
||||||
const [counts, setCounts] = useState<DashboardCounts | null>(null);
|
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>
|
<p className="text-lg font-bold text-neutral-600 mt-1">—</p>
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<button key={c.tab} onClick={() => { onNavigate(c.tab); onFilter("all"); }}
|
<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]`}>
|
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">
|
<div className="flex items-baseline gap-2 mt-1 flex-wrap">
|
||||||
{tc.new > 0 && (
|
{tc.new > 0 && (
|
||||||
<>
|
<>
|
||||||
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
|
<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("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-lg font-bold text-gold">{tc.new}</span>
|
||||||
<span className="text-[10px] text-neutral-500">новых</span>
|
<span className="text-[10px] text-neutral-500">новых</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -733,8 +740,8 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
|
|||||||
{tc.contacted > 0 && (
|
{tc.contacted > 0 && (
|
||||||
<>
|
<>
|
||||||
{tc.new > 0 && <span className="text-neutral-700">·</span>}
|
{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"
|
<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("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-sm font-medium text-blue-400">{tc.contacted}</span>
|
||||||
<span className="text-[10px] text-neutral-500">в работе</span>
|
<span className="text-[10px] text-neutral-500">в работе</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -743,8 +750,8 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
|
|||||||
{tc.confirmed > 0 && (
|
{tc.confirmed > 0 && (
|
||||||
<>
|
<>
|
||||||
{(tc.new > 0 || tc.contacted > 0) && <span className="text-neutral-700">·</span>}
|
{(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"
|
<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("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-sm font-medium text-emerald-400">{tc.confirmed}</span>
|
||||||
<span className="text-[10px] text-neutral-500">подтв.</span>
|
<span className="text-[10px] text-neutral-500">подтв.</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -753,8 +760,8 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
|
|||||||
{tc.declined > 0 && (
|
{tc.declined > 0 && (
|
||||||
<>
|
<>
|
||||||
{(tc.new > 0 || tc.contacted > 0 || tc.confirmed > 0) && <span className="text-neutral-700">·</span>}
|
{(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"
|
<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("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-sm font-medium text-red-400">{tc.declined}</span>
|
||||||
<span className="text-[10px] text-neutral-500">отказ</span>
|
<span className="text-[10px] text-neutral-500">отказ</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -941,15 +948,26 @@ function BookingsPageInner() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Dashboard — what needs attention */}
|
{/* 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 */}
|
{/* Tabs — select on mobile, tabs on desktop */}
|
||||||
<div className="mt-5 flex border-b border-white/10">
|
<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) => (
|
{TABS.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.key}
|
key={t.key}
|
||||||
onClick={() => setTab(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"
|
tab === t.key ? "text-gold" : "text-neutral-400 hover:text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -964,9 +982,9 @@ function BookingsPageInner() {
|
|||||||
{/* Tab content */}
|
{/* Tab content */}
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{tab === "reminders" && <RemindersTab key={refreshKey} />}
|
{tab === "reminders" && <RemindersTab key={refreshKey} />}
|
||||||
{tab === "classes" && <GroupBookingsTab filter={statusFilter} onDataChange={refreshDashboard} />}
|
{tab === "classes" && <GroupBookingsTab key={refreshKey} filter={statusFilter} onDataChange={refreshDashboard} />}
|
||||||
{tab === "master-classes" && <McRegistrationsTab filter={statusFilter} onDataChange={refreshDashboard} />}
|
{tab === "master-classes" && <McRegistrationsTab key={refreshKey} filter={statusFilter} onDataChange={refreshDashboard} />}
|
||||||
{tab === "open-day" && <OpenDayBookingsTab filter={statusFilter} hallFilter={hallFilter} onDataChange={refreshDashboard} />}
|
{tab === "open-day" && <OpenDayBookingsTab key={refreshKey} filter={statusFilter} hallFilter={hallFilter} onDataChange={refreshDashboard} />}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -974,7 +992,7 @@ function BookingsPageInner() {
|
|||||||
<AddBookingModal
|
<AddBookingModal
|
||||||
open={addOpen}
|
open={addOpen}
|
||||||
onClose={() => setAddOpen(false)}
|
onClose={() => setAddOpen(false)}
|
||||||
onAdded={() => { setRefreshKey((k) => k + 1); refreshDashboard(); }}
|
onAdded={() => { setStatusFilter("all"); setRefreshKey((k) => k + 1); refreshDashboard(); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -333,8 +333,8 @@ function ClassCell({
|
|||||||
)}
|
)}
|
||||||
{cls.cancelled && <span className="text-[9px] text-neutral-500">отменено</span>}
|
{cls.cancelled && <span className="text-[9px] text-neutral-500">отменено</span>}
|
||||||
</div>
|
</div>
|
||||||
{/* Actions */}
|
{/* Actions — always visible on mobile, hover on desktop */}
|
||||||
<div className="absolute top-1.5 right-1.5 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="absolute top-1.5 right-1.5 flex gap-1 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onCancel(cls.id); }}
|
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"}`}
|
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 [selectedHall, setSelectedHall] = useState(halls[0] ?? "");
|
||||||
const [editingClassId, setEditingClassId] = useState<number | null>(null);
|
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);
|
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
|
// Build lookup: time -> class for selected hall
|
||||||
const hallClasses = useMemo(() => {
|
const hallClasses = useMemo(() => {
|
||||||
const map: Record<string, OpenDayClass> = {};
|
const map: Record<string, OpenDayClass> = {};
|
||||||
@@ -415,19 +429,31 @@ function ScheduleGrid({
|
|||||||
onClassesChange();
|
onClassesChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteClass(id: number) {
|
function deleteClass(id: number) {
|
||||||
await adminFetch(`/api/admin/open-day/classes?id=${id}`, { method: "DELETE" });
|
setConfirmAction({
|
||||||
onClassesChange();
|
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);
|
const cls = classes.find((c) => c.id === id);
|
||||||
if (!cls) return;
|
if (!cls) return;
|
||||||
await updateClass(id, { cancelled: !cls.cancelled });
|
setConfirmAction({
|
||||||
|
message: cls.cancelled
|
||||||
|
? "Восстановить занятие?"
|
||||||
|
: `Отменить занятие? (${cls.bookingCount} записей)`,
|
||||||
|
onConfirm: async () => {
|
||||||
|
await updateClass(id, { cancelled: !cls.cancelled });
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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>
|
<h2 className="text-lg font-bold">Расписание</h2>
|
||||||
|
|
||||||
{halls.length === 0 ? (
|
{halls.length === 0 ? (
|
||||||
@@ -499,6 +525,30 @@ function ScheduleGrid({
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,11 +49,11 @@ export async function PUT(request: NextRequest) {
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, phone, instagram, telegram } = body;
|
const { name, phone, groupInfo, instagram, telegram } = body;
|
||||||
if (!name?.trim() || !phone?.trim()) {
|
if (!name?.trim() || !phone?.trim()) {
|
||||||
return NextResponse.json({ error: "name and phone are required" }, { status: 400 });
|
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 });
|
return NextResponse.json({ ok: true, id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[admin/group-bookings] POST error:", err);
|
console.error("[admin/group-bookings] POST error:", err);
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
scrollbar-gutter: stable;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
width: 600px;
|
width: min(600px, 100%);
|
||||||
height: 400px;
|
height: 400px;
|
||||||
background: radial-gradient(ellipse, rgba(201, 169, 110, 0.05), transparent 70%);
|
background: radial-gradient(ellipse, rgba(201, 169, 110, 0.05), transparent 70%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export function Footer() {
|
|||||||
<div className="flex items-center gap-1.5 text-sm text-neutral-500">
|
<div className="flex items-center gap-1.5 text-sm text-neutral-500">
|
||||||
<span>Made with</span>
|
<span>Made with</span>
|
||||||
<Heart size={14} className="fill-gold text-gold" />
|
<Heart size={14} className="fill-gold text-gold" />
|
||||||
|
<span>by Diana Dolgolyova</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -164,28 +164,28 @@ export function Hero({ data: hero }: HeroProps) {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* 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="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="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">
|
<div className="hero-logo-heartbeat relative">
|
||||||
<HeroLogo
|
<HeroLogo
|
||||||
size={220}
|
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)]"
|
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>
|
||||||
</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>
|
<span className="gradient-text">{hero.headline}</span>
|
||||||
</h1>
|
</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}
|
{hero.subheadline}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="hero-cta mt-14">
|
<div className="hero-cta mt-8 sm:mt-14">
|
||||||
<button
|
<button
|
||||||
onClick={openBooking}
|
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>
|
<span className="relative z-10">{hero.ctaText}</span>
|
||||||
{/* Pulse glow on hover */}
|
{/* Pulse glow on hover */}
|
||||||
|
|||||||
@@ -313,9 +313,9 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
|
|||||||
<SectionHeading centered>{schedule.title}</SectionHeading>
|
<SectionHeading centered>{schedule.title}</SectionHeading>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
{/* Location tabs */}
|
{/* Location tabs + mobile filter */}
|
||||||
<Reveal>
|
<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 */}
|
{/* "All studios" tab — only when multiple locations */}
|
||||||
{schedule.locations.length > 1 && (
|
{schedule.locations.length > 1 && (
|
||||||
<button
|
<button
|
||||||
@@ -351,35 +351,33 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
|
|
||||||
{/* Mobile filter button — visible only on small screens */}
|
{/* Mobile filter — inline with hall tabs */}
|
||||||
<Reveal>
|
<div className="sm:hidden">
|
||||||
<div className="mt-4 flex sm:hidden justify-center">
|
<ScheduleFilters
|
||||||
<ScheduleFilters
|
typeDots={typeDots}
|
||||||
typeDots={typeDots}
|
types={types}
|
||||||
types={types}
|
availableStatuses={availableStatuses}
|
||||||
availableStatuses={availableStatuses}
|
levels={levels}
|
||||||
levels={levels}
|
filterTypes={filterTypes}
|
||||||
filterTypes={filterTypes}
|
toggleFilterType={toggleFilterType}
|
||||||
toggleFilterType={toggleFilterType}
|
filterTrainerSet={filterTrainerSet}
|
||||||
filterTrainerSet={filterTrainerSet}
|
toggleFilterTrainer={toggleFilterTrainer}
|
||||||
toggleFilterTrainer={toggleFilterTrainer}
|
filterStatusSet={filterStatusSet}
|
||||||
filterStatusSet={filterStatusSet}
|
toggleFilterStatus={toggleFilterStatus}
|
||||||
toggleFilterStatus={toggleFilterStatus}
|
filterLevel={filterLevel}
|
||||||
filterLevel={filterLevel}
|
setFilterLevel={setFilterLevel}
|
||||||
setFilterLevel={setFilterLevel}
|
filterTime={filterTime}
|
||||||
filterTime={filterTime}
|
setFilterTime={setFilterTime}
|
||||||
setFilterTime={setFilterTime}
|
availableDays={availableDays}
|
||||||
availableDays={availableDays}
|
filterDaySet={filterDaySet}
|
||||||
filterDaySet={filterDaySet}
|
toggleDay={toggleDay}
|
||||||
toggleDay={toggleDay}
|
hasActiveFilter={hasActiveFilter}
|
||||||
hasActiveFilter={hasActiveFilter}
|
clearFilters={clearFilters}
|
||||||
clearFilters={clearFilters}
|
trainerNames={trainerNames}
|
||||||
trainerNames={trainerNames}
|
scheduleConfig={scheduleConfig}
|
||||||
scheduleConfig={scheduleConfig}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
|
|||||||
@@ -82,15 +82,16 @@ export function ScheduleFilters({
|
|||||||
{/* Filter button — same style as По дням / По группам buttons */}
|
{/* Filter button — same style as По дням / По группам buttons */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setModalOpen(true)}
|
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
|
totalActive > 0
|
||||||
? "bg-white text-neutral-900 shadow-sm dark:bg-white/10 dark:text-white"
|
? "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"
|
||||||
: "text-neutral-500 hover:text-neutral-700 dark:text-white/35 dark:hover:text-white/60"
|
: "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 && (
|
{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}
|
{totalActive}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -196,14 +197,18 @@ export function ScheduleFilters({
|
|||||||
const desc = scheduleConfig?.levels?.find((l) => l.value === level)?.description;
|
const desc = scheduleConfig?.levels?.find((l) => l.value === level)?.description;
|
||||||
const active = filterLevel === level;
|
const active = filterLevel === level;
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={level}
|
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 ${
|
className={`flex items-center gap-2.5 w-full rounded-lg px-3 py-2 transition-all cursor-pointer ${
|
||||||
active
|
active
|
||||||
? "bg-gold/10"
|
? "bg-gold/10"
|
||||||
: "hover:bg-white/[0.03]"
|
: "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 ${
|
<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"
|
active ? "border-gold" : "border-white/20"
|
||||||
@@ -214,7 +219,7 @@ export function ScheduleFilters({
|
|||||||
{level}
|
{level}
|
||||||
</span>
|
</span>
|
||||||
{desc && <InfoTip text={desc} />}
|
{desc && <InfoTip text={desc} />}
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const {
|
|||||||
cardSpacing: CARD_SPACING,
|
cardSpacing: CARD_SPACING,
|
||||||
} = UI_CONFIG.team;
|
} = UI_CONFIG.team;
|
||||||
|
|
||||||
|
const MOBILE_SWIPE_THRESHOLD = 30;
|
||||||
|
|
||||||
function wrapIndex(i: number, total: number) {
|
function wrapIndex(i: number, total: number) {
|
||||||
return ((i % total) + total) % total;
|
return ((i % total) + total) % total;
|
||||||
}
|
}
|
||||||
@@ -95,9 +97,47 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
|
|||||||
if (swipeHintVisible) setSwipeHintVisible(false);
|
if (swipeHintVisible) setSwipeHintVisible(false);
|
||||||
}, [swipeHintVisible]);
|
}, [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(
|
const onPointerDown = useCallback(
|
||||||
(e: React.PointerEvent) => {
|
(e: React.PointerEvent) => {
|
||||||
|
if (isMobileRef.current) return;
|
||||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
isDraggingRef.current = true;
|
isDraggingRef.current = true;
|
||||||
wasDragRef.current = false;
|
wasDragRef.current = false;
|
||||||
@@ -114,8 +154,6 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
|
|||||||
const dx = e.clientX - dragStartRef.current.x;
|
const dx = e.clientX - dragStartRef.current.x;
|
||||||
if (Math.abs(dx) > 10) wasDragRef.current = true;
|
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);
|
const steps = Math.round(dx / CARD_SPACING);
|
||||||
if (steps !== 0) {
|
if (steps !== 0) {
|
||||||
const newBase = wrapIndex(dragStartRef.current.startIndex - steps, total);
|
const newBase = wrapIndex(dragStartRef.current.startIndex - steps, total);
|
||||||
@@ -210,6 +248,8 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
|
|||||||
tabIndex={0}
|
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"
|
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 }}
|
style={{ height: UI_CONFIG.team.stageHeight }}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
|
|||||||
@@ -188,6 +188,15 @@ export function SignupModal({
|
|||||||
{successMessage || "Вы записаны!"}
|
{successMessage || "Вы записаны!"}
|
||||||
</h3>
|
</h3>
|
||||||
{subtitle && <p className="mt-1 text-sm text-neutral-400">{subtitle}</p>}
|
{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>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user