From eb949f1a376ea6a45c002bdcfe6c9c779a3c65c7 Mon Sep 17 00:00:00 2001 From: "diana.dolgolyova" Date: Wed, 25 Mar 2026 12:53:45 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20booking=20UX=20improvements=20=E2=80=94?= =?UTF-8?q?=20waiting=20list,=20card=20focus,=20sort=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auto-note "Лист ожидания" for registrations when class is full - Waiting list triggers on confirmed count (not total registrations) - Card highlight + scroll after status change - Hover effect on booking cards - Freshly changed cards appear first in their status group - Polling no longer remounts tabs (fixes page jump on approve) - Fix MasterClassesData missing waitingListText type - Add Turbopack troubleshooting docs to CLAUDE.md --- CLAUDE.md | 9 +++++++ src/app/admin/bookings/BookingComponents.tsx | 14 +++++----- .../admin/bookings/GenericBookingsList.tsx | 27 ++++++++++++++++--- src/app/admin/bookings/page.tsx | 8 +++--- src/app/admin/bookings/types.ts | 5 +++- src/app/admin/master-classes/page.tsx | 1 + src/app/api/master-class-register/route.ts | 6 +++-- src/app/api/open-day-register/route.ts | 8 +++--- src/lib/db.ts | 25 +++++++++++------ 9 files changed, 75 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 71fdfd1..dd45512 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,6 +158,15 @@ src/ - Migrations run automatically on server start via `runMigrations()` and are tracked in the `_migrations` table - Use `CREATE TABLE IF NOT EXISTS` and column-existence checks (`PRAGMA table_info`) for safety +## Turbopack / Dev Server Troubleshooting +If the dev server hangs on "Compiling..." or shows a white page: +1. Kill all node processes: `taskkill /F /IM node.exe` +2. Remove stale lock: `rm -f .next/dev/lock` +3. Clear cache: `rm -rf .next node_modules/.cache` +4. Restart: `npm run dev` +- This often happens after shutting down the PC without stopping the server first +- Always stop the dev server (Ctrl+C) before shutting down + ## Git - Remote: Gitea at `git.dolgolyov-family.by` - User: diana.dolgolyova diff --git a/src/app/admin/bookings/BookingComponents.tsx b/src/app/admin/bookings/BookingComponents.tsx index af30f2e..80bb687 100644 --- a/src/app/admin/bookings/BookingComponents.tsx +++ b/src/app/admin/bookings/BookingComponents.tsx @@ -159,15 +159,15 @@ export function StatusActions({ status, onStatus }: { status: BookingStatus; onS ); } -export function BookingCard({ status, children }: { status: BookingStatus; children: React.ReactNode }) { +export function BookingCard({ status, highlight, children }: { status: BookingStatus; highlight?: boolean; children: React.ReactNode }) { return (
{children}
diff --git a/src/app/admin/bookings/GenericBookingsList.tsx b/src/app/admin/bookings/GenericBookingsList.tsx index 40e2f4f..892453e 100644 --- a/src/app/admin/bookings/GenericBookingsList.tsx +++ b/src/app/admin/bookings/GenericBookingsList.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useMemo } from "react"; +import { useState, useMemo, useRef, useEffect, useCallback } from "react"; import { ChevronDown, ChevronRight, Archive } from "lucide-react"; import { adminFetch } from "@/lib/csrf"; import { type BookingStatus, type BookingFilter, type BaseBooking, type BookingGroup, sortByStatus } from "./types"; @@ -32,8 +32,20 @@ export function GenericBookingsList({ }: GenericBookingsListProps) { const [showArchived, setShowArchived] = useState(false); const [expanded, setExpanded] = useState>({}); + const [highlightId, setHighlightId] = useState(null); + const highlightRef = useRef(null); const { showError } = useToast(); + // Scroll to highlighted card and clear highlight after animation + useEffect(() => { + if (highlightId === null) return; + const timer = setTimeout(() => { + highlightRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" }); + }, 50); + const clear = setTimeout(() => setHighlightId(null), 2000); + return () => { clearTimeout(timer); clearTimeout(clear); }; + }, [highlightId]); + async function handleStatus(id: number, status: BookingStatus) { if (status === "confirmed" && onConfirm) { onConfirm(id); @@ -41,7 +53,13 @@ export function GenericBookingsList({ } const prev = items.find((b) => b.id === id); const prevStatus = prev?.status; - onItemsChange((list) => list.map((b) => b.id === id ? { ...b, status } : b)); + // Move changed item to front so it appears first in its status group after sort + onItemsChange((list) => { + const item = list.find((b) => b.id === id); + if (!item) return list; + return [{ ...item, status }, ...list.filter((b) => b.id !== id)]; + }); + setHighlightId(id); try { const res = await adminFetch(endpoint, { method: "PUT", @@ -85,8 +103,10 @@ export function GenericBookingsList({ } function renderItem(item: T, isArchived: boolean) { + const isHighlighted = highlightId === item.id; return ( - +
+
{item.name} @@ -104,6 +124,7 @@ export function GenericBookingsList({
handleNotes(item.id, notes)} /> +
); } diff --git a/src/app/admin/bookings/page.tsx b/src/app/admin/bookings/page.tsx index b8b9455..5ddc380 100644 --- a/src/app/admin/bookings/page.tsx +++ b/src/app/admin/bookings/page.tsx @@ -799,7 +799,7 @@ function BookingsPageInner() { .then((r) => r.json()) .then((data: { total: number }) => { if (lastTotalRef.current !== null && data.total !== lastTotalRef.current) { - setRefreshKey((k) => k + 1); + refreshDashboard(); } lastTotalRef.current = data.total; }) @@ -921,8 +921,8 @@ function BookingsPageInner() {
{/* Tab content */} -
- {tab === "reminders" && } +
+ {tab === "reminders" && } {tab === "classes" && } {tab === "master-classes" && } {tab === "open-day" && } @@ -933,7 +933,7 @@ function BookingsPageInner() { setAddOpen(false)} - onAdded={() => setRefreshKey((k) => k + 1)} + onAdded={() => { setRefreshKey((k) => k + 1); refreshDashboard(); }} />
); diff --git a/src/app/admin/bookings/types.ts b/src/app/admin/bookings/types.ts index 37210bb..85ef10d 100644 --- a/src/app/admin/bookings/types.ts +++ b/src/app/admin/bookings/types.ts @@ -36,7 +36,10 @@ export function countStatuses(items: { status: string }[]): Record(items: T[]): T[] { const order: Record = { new: 0, contacted: 1, confirmed: 2, declined: 3 }; - return [...items].sort((a, b) => (order[a.status] ?? 0) - (order[b.status] ?? 0)); + const UNKNOWN_STATUS_ORDER = 99; + return [...items].sort((a, b) => + (order[a.status] ?? UNKNOWN_STATUS_ORDER) - (order[b.status] ?? UNKNOWN_STATUS_ORDER) + ); } export interface BookingGroup { diff --git a/src/app/admin/master-classes/page.tsx b/src/app/admin/master-classes/page.tsx index 1f7f68f..7e8535f 100644 --- a/src/app/admin/master-classes/page.tsx +++ b/src/app/admin/master-classes/page.tsx @@ -35,6 +35,7 @@ function PriceField({ label, value, onChange, placeholder }: { label: string; va interface MasterClassesData { title: string; successMessage?: string; + waitingListText?: string; items: MasterClassItem[]; } diff --git a/src/app/api/master-class-register/route.ts b/src/app/api/master-class-register/route.ts index 90d87e3..6a3de0e 100644 --- a/src/app/api/master-class-register/route.ts +++ b/src/app/api/master-class-register/route.ts @@ -38,7 +38,8 @@ export async function POST(request: Request) { let isWaiting = false; if (mcItem?.maxParticipants && mcItem.maxParticipants > 0) { const currentRegs = getMcRegistrations(cleanTitle); - isWaiting = currentRegs.length >= mcItem.maxParticipants; + const confirmedCount = currentRegs.filter((r) => r.status === "confirmed").length; + isWaiting = confirmedCount >= mcItem.maxParticipants; } const id = addMcRegistration( @@ -46,7 +47,8 @@ export async function POST(request: Request) { cleanName, sanitizeHandle(instagram) ?? "", sanitizeHandle(telegram), - cleanPhone + cleanPhone, + isWaiting ? "Лист ожидания" : undefined ); return NextResponse.json({ ok: true, id, isWaiting }); diff --git a/src/app/api/open-day-register/route.ts b/src/app/api/open-day-register/route.ts index 6d63e0c..325ff88 100644 --- a/src/app/api/open-day-register/route.ts +++ b/src/app/api/open-day-register/route.ts @@ -4,6 +4,7 @@ import { getPersonOpenDayBookings, getOpenDayEvent, getOpenDayClassById, + getConfirmedOpenDayBookingCount, } from "@/lib/db"; import { checkRateLimit, getClientIp } from "@/lib/rateLimit"; import { sanitizeName, sanitizePhone, sanitizeHandle } from "@/lib/validation"; @@ -35,17 +36,18 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 }); } - // Check if class is full (event-level max) — if so, booking goes to waiting list - const cls = getOpenDayClassById(classId); + // Check if class is full (event-level max, confirmed only) — if so, booking goes to waiting list const event = getOpenDayEvent(eventId); const maxP = event?.maxParticipants ?? 0; - const isWaiting = maxP > 0 && cls ? cls.bookingCount >= maxP : false; + const confirmedCount = maxP > 0 ? getConfirmedOpenDayBookingCount(classId) : 0; + const isWaiting = maxP > 0 && confirmedCount >= maxP; const id = addOpenDayBooking(classId, eventId, { name: cleanName, phone: cleanPhone, instagram: sanitizeHandle(instagram), telegram: sanitizeHandle(telegram), + notes: isWaiting ? "Лист ожидания" : undefined, }); // Return total bookings for this person (for discount calculation) diff --git a/src/lib/db.ts b/src/lib/db.ts index 14d4320..f209c9d 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -593,15 +593,16 @@ export function addMcRegistration( name: string, instagram: string, telegram?: string, - phone?: string + phone?: string, + notes?: string ): number { const db = getDb(); const result = db .prepare( - `INSERT INTO mc_registrations (master_class_title, name, instagram, telegram, phone) - VALUES (?, ?, ?, ?, ?)` + `INSERT INTO mc_registrations (master_class_title, name, instagram, telegram, phone, notes) + VALUES (?, ?, ?, ?, ?, ?)` ) - .run(masterClassTitle, name, instagram, telegram || null, phone || null); + .run(masterClassTitle, name, instagram, telegram || null, phone || null, notes || null); return result.lastInsertRowid as number; } @@ -1103,6 +1104,14 @@ function mapClassRow(r: OpenDayClassRow): OpenDayClass { }; } +export function getConfirmedOpenDayBookingCount(classId: number): number { + const db = getDb(); + const row = db.prepare( + "SELECT COUNT(*) as cnt FROM open_day_bookings WHERE class_id = ? AND status = 'confirmed'" + ).get(classId) as { cnt: number }; + return row.cnt; +} + export function setOpenDayBookingStatus(id: number, status: string): void { const db = getDb(); db.prepare("UPDATE open_day_bookings SET status = ?, notified_confirm = 1 WHERE id = ?").run(status, id); @@ -1297,15 +1306,15 @@ export function deleteOpenDayClass(id: number): void { export function addOpenDayBooking( classId: number, eventId: number, - data: { name: string; phone: string; instagram?: string; telegram?: string } + data: { name: string; phone: string; instagram?: string; telegram?: string; notes?: string } ): number { const db = getDb(); const result = db .prepare( - `INSERT INTO open_day_bookings (class_id, event_id, name, phone, instagram, telegram) - VALUES (?, ?, ?, ?, ?, ?)` + `INSERT INTO open_day_bookings (class_id, event_id, name, phone, instagram, telegram, notes) + VALUES (?, ?, ?, ?, ?, ?, ?)` ) - .run(classId, eventId, data.name, data.phone, data.instagram || null, data.telegram || null); + .run(classId, eventId, data.name, data.phone, data.instagram || null, data.telegram || null, data.notes || null); return result.lastInsertRowid as number; }