Compare commits

..

3 Commits

Author SHA1 Message Date
diana.dolgolyova bbe485d8fc feat: add author credit to footer 2026-04-05 19:28:27 +03:00
diana.dolgolyova 16ac56f62e 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
2026-04-03 17:06:55 +03:00
diana.dolgolyova fa26092ea4 fix: mobile horizontal overflow and hero responsiveness
- Remove scrollbar-gutter: stable, add overflow-x: hidden on html
- Cap section-glow pseudo-element width to viewport on mobile
- Scale down hero logo, text, spacing, and button for small screens (iPhone SE)
2026-04-03 00:14:42 +03:00
14 changed files with 431 additions and 147 deletions
+1 -1
View File
@@ -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",
+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} />
+19 -5
View File
@@ -173,11 +173,25 @@ export function GenericBookingsList<T extends BaseBooking>({
</div>
)}
</button>
{isOpen && (
<div className="px-4 pb-3 pt-1 space-y-2">
{group.items.map((item) => renderItem(item, group.isArchived))}
</div>
)}
{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">
{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>
);
}
+35 -17
View File
@@ -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>
);
+58 -8
View File
@@ -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) {
await adminFetch(`/api/admin/open-day/classes?id=${id}`, { method: "DELETE" });
onClassesChange();
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;
await updateClass(id, { cancelled: !cls.cancelled });
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>
);
}
+2 -2
View File
@@ -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
View File
@@ -19,7 +19,7 @@
html {
scroll-behavior: smooth;
scrollbar-gutter: stable;
overflow-x: hidden;
}
body {
+1 -1
View File
@@ -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;
+1
View File
@@ -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>
+7 -7
View File
@@ -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 */}
+28 -30
View File
@@ -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,35 +351,33 @@ 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">
<ScheduleFilters
typeDots={typeDots}
types={types}
availableStatuses={availableStatuses}
levels={levels}
filterTypes={filterTypes}
toggleFilterType={toggleFilterType}
filterTrainerSet={filterTrainerSet}
toggleFilterTrainer={toggleFilterTrainer}
filterStatusSet={filterStatusSet}
toggleFilterStatus={toggleFilterStatus}
filterLevel={filterLevel}
setFilterLevel={setFilterLevel}
filterTime={filterTime}
setFilterTime={setFilterTime}
availableDays={availableDays}
filterDaySet={filterDaySet}
toggleDay={toggleDay}
hasActiveFilter={hasActiveFilter}
clearFilters={clearFilters}
trainerNames={trainerNames}
scheduleConfig={scheduleConfig}
/>
{/* Mobile filter — inline with hall tabs */}
<div className="sm:hidden">
<ScheduleFilters
typeDots={typeDots}
types={types}
availableStatuses={availableStatuses}
levels={levels}
filterTypes={filterTypes}
toggleFilterType={toggleFilterType}
filterTrainerSet={filterTrainerSet}
toggleFilterTrainer={toggleFilterTrainer}
filterStatusSet={filterStatusSet}
toggleFilterStatus={toggleFilterStatus}
filterLevel={filterLevel}
setFilterLevel={setFilterLevel}
filterTime={filterTime}
setFilterTime={setFilterTime}
availableDays={availableDays}
filterDaySet={filterDaySet}
toggleDay={toggleDay}
hasActiveFilter={hasActiveFilter}
clearFilters={clearFilters}
trainerNames={trainerNames}
scheduleConfig={scheduleConfig}
/>
</div>
</div>
</Reveal>
@@ -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>
+43 -3
View File
@@ -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}
+9
View File
@@ -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>