diff --git a/src/app/admin/bookings/AddBookingModal.tsx b/src/app/admin/bookings/AddBookingModal.tsx index 4267c20..06a2c21 100644 --- a/src/app/admin/bookings/AddBookingModal.tsx +++ b/src/app/admin/bookings/AddBookingModal.tsx @@ -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 = { + "Понедельник": "Пн", "Вторник": "Вт", "Среда": "Ср", + "Четверг": "Чт", "Пятница": "Пт", "Суббота": "Сб", "Воскресенье": "Вс", +}; + +// --- 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(null); + const inputRef = useRef(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 ( +
+
{ 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 ? ( + 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(""); } + }} + /> + ) : ( + + {selected ? selected.label : placeholder} + + )} + {value && !open ? ( + + ) : ( + + )} +
+ + {open && ( +
+
+ {filtered.length === 0 && ( +

Ничего не найдено

+ )} + {filtered.map((o) => ( + + ))} +
+
+ )} +
+ ); +} + +// --- Modal --- export function AddBookingModal({ open, @@ -32,13 +144,36 @@ export function AddBookingModal({ const [odClasses, setOdClasses] = useState([]); const [odEventId, setOdEventId] = useState(null); const [odClassId, setOdClassId] = useState(""); + const [scheduleClasses, setScheduleClasses] = useState([]); + 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(); + 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) => ( - - ); const canSubmit = name.trim() && phone.trim() && !saving && (tab === "classes" || (tab === "events" && eventType === "master-class" && hasUpcomingMc) @@ -159,60 +302,66 @@ export function AddBookingModal({

Ручная запись (Instagram, звонок, лично)

- {/* Tab: Classes vs Events */} -
- {tabBtn("classes", "Занятие")} - {tabBtn("events", "Мероприятие", !hasEvents)} + {/* Type selector — single row */} +
+ + {hasUpcomingMc && ( + + )} + {hasOpenDay && ( + + )}
- {/* Events sub-selector */} - {tab === "events" && ( -
- {hasUpcomingMc && ( - - )} - {hasOpenDay && ( - - )} -
+ {/* Class selector (optional for Занятие) */} + {tab === "classes" && classOptions.length > 0 && ( + )} {/* MC selector */} - {tab === "events" && eventType === "master-class" && mcOptions.length > 0 && ( - + {tab === "events" && eventType === "master-class" && mcSelectOptions.length > 0 && ( + )} {/* Open Day class selector */} - {tab === "events" && eventType === "open-day" && odClasses.length > 0 && ( - + {tab === "events" && eventType === "open-day" && odSelectOptions.length > 0 && ( + )} setName(e.target.value)} placeholder="Имя" className={inputClass} /> diff --git a/src/app/admin/bookings/GenericBookingsList.tsx b/src/app/admin/bookings/GenericBookingsList.tsx index 892453e..1e276e0 100644 --- a/src/app/admin/bookings/GenericBookingsList.tsx +++ b/src/app/admin/bookings/GenericBookingsList.tsx @@ -173,11 +173,25 @@ export function GenericBookingsList({
)} - {isOpen && ( -
- {group.items.map((item) => renderItem(item, group.isArchived))} -
- )} + {isOpen && (() => { + const regular = group.items.filter((i) => !i.notes?.includes("Лист ожидания")); + const waiting = group.items.filter((i) => i.notes?.includes("Лист ожидания")); + return ( +
+ {regular.map((item) => renderItem(item, group.isArchived))} + {waiting.length > 0 && ( + <> +
+
+ лист ожидания +
+
+ {waiting.map((item) => renderItem(item, group.isArchived))} + + )} +
+ ); + })()}
); } diff --git a/src/app/admin/bookings/page.tsx b/src/app/admin/bookings/page.tsx index e0c4017..aaeaed1 100644 --- a/src/app/admin/bookings/page.tsx +++ b/src/app/admin/bookings/page.tsx @@ -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(null); @@ -716,6 +718,11 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {

); + 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 (
); diff --git a/src/app/admin/open-day/page.tsx b/src/app/admin/open-day/page.tsx index 47d65f0..4f3cd47 100644 --- a/src/app/admin/open-day/page.tsx +++ b/src/app/admin/open-day/page.tsx @@ -333,8 +333,8 @@ function ClassCell({ )} {cls.cancelled && отменено} - {/* Actions */} -
+ {/* Actions — always visible on mobile, hover on desktop */} +
+ +
+
+ + )} ); } diff --git a/src/app/api/admin/group-bookings/route.ts b/src/app/api/admin/group-bookings/route.ts index d6cd7e2..97d1abb 100644 --- a/src/app/api/admin/group-bookings/route.ts +++ b/src/app/api/admin/group-bookings/route.ts @@ -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); diff --git a/src/components/sections/Schedule.tsx b/src/components/sections/Schedule.tsx index 237e3a0..1ff49d0 100644 --- a/src/components/sections/Schedule.tsx +++ b/src/components/sections/Schedule.tsx @@ -313,9 +313,9 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe {schedule.title} - {/* Location tabs */} + {/* Location tabs + mobile filter */} -
+
{/* "All studios" tab — only when multiple locations */} {schedule.locations.length > 1 && ( ))} -
- - {/* Mobile filter button — visible only on small screens */} - -
- + {/* Mobile filter — inline with hall tabs */} +
+ +
diff --git a/src/components/sections/schedule/ScheduleFilters.tsx b/src/components/sections/schedule/ScheduleFilters.tsx index 1a4b260..6ea0363 100644 --- a/src/components/sections/schedule/ScheduleFilters.tsx +++ b/src/components/sections/schedule/ScheduleFilters.tsx @@ -82,15 +82,16 @@ export function ScheduleFilters({ {/* Filter button — same style as По дням / По группам buttons */} +
); })} diff --git a/src/components/sections/team/TeamCarousel.tsx b/src/components/sections/team/TeamCarousel.tsx index 620e6fc..f92ffac 100644 --- a/src/components/sections/team/TeamCarousel.tsx +++ b/src/components/sections/team/TeamCarousel.tsx @@ -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} diff --git a/src/components/ui/SignupModal.tsx b/src/components/ui/SignupModal.tsx index 0f17e16..da63944 100644 --- a/src/components/ui/SignupModal.tsx +++ b/src/components/ui/SignupModal.tsx @@ -188,6 +188,15 @@ export function SignupModal({ {successMessage || "Вы записаны!"} {subtitle &&

{subtitle}

} + + + {instagramHint || "По вопросам пишите в Instagram"} + )}