feat: mobile UX improvements across admin and public site

- Team carousel: simple swipe on mobile instead of drag
- Schedule: filter button inline with hall tabs, larger on mobile
- Schedule filters: fix nested button hydration error
- Admin bookings: select dropdown on mobile, filter highlight on dashboard cards
- Admin bookings: searchable dropdowns in add booking modal with class selector
- Admin bookings: waiting list divider inside groups
- Admin bookings: new bookings appear without page reload
- Admin open-day: action buttons visible on mobile, confirm dialog, click-outside to close edit
- API: pass groupInfo on group booking creation
- SignupModal: Instagram link on success popup
This commit is contained in:
2026-04-03 17:06:55 +03:00
parent fa26092ea4
commit 16ac56f62e
9 changed files with 420 additions and 137 deletions
+213 -64
View File
@@ -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,60 +302,66 @@ 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)}
{/* 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={() => { 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>
{/* Events sub-selector */}
{tab === "events" && (
<div className="flex gap-2">
{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"
}`}
>
Мастер-класс
</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>
{/* 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} />