feat: booking UX improvements — waiting list, card focus, sort order
- 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
This commit is contained in:
@@ -158,6 +158,15 @@ src/
|
|||||||
- Migrations run automatically on server start via `runMigrations()` and are tracked in the `_migrations` table
|
- 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
|
- 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
|
## Git
|
||||||
- Remote: Gitea at `git.dolgolyov-family.by`
|
- Remote: Gitea at `git.dolgolyov-family.by`
|
||||||
- User: diana.dolgolyova
|
- User: diana.dolgolyova
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`rounded-lg border p-3 transition-colors ${
|
className={`rounded-lg border p-3 transition-all duration-200 cursor-default ${
|
||||||
status === "declined" ? "border-red-500/15 bg-red-500/[0.02] opacity-50"
|
status === "declined" ? "border-red-500/15 bg-red-500/[0.02] opacity-50 hover:opacity-70 hover:border-red-500/30"
|
||||||
: status === "confirmed" ? "border-emerald-500/15 bg-emerald-500/[0.02]"
|
: status === "confirmed" ? "border-emerald-500/15 bg-emerald-500/[0.02] hover:border-emerald-500/30 hover:bg-emerald-500/[0.05]"
|
||||||
: status === "new" ? "border-gold/20 bg-gold/[0.03]"
|
: status === "new" ? "border-gold/20 bg-gold/[0.03] hover:border-gold/40 hover:bg-gold/[0.06]"
|
||||||
: "border-white/10 bg-neutral-800/30"
|
: "border-white/10 bg-neutral-800/30 hover:border-white/20 hover:bg-neutral-800/50"
|
||||||
}`}
|
}${highlight ? " ring-2 ring-gold/40 animate-[pulse_1s_ease-in-out_1]" : ""}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
|
||||||
import { ChevronDown, ChevronRight, Archive } from "lucide-react";
|
import { ChevronDown, ChevronRight, Archive } from "lucide-react";
|
||||||
import { adminFetch } from "@/lib/csrf";
|
import { adminFetch } from "@/lib/csrf";
|
||||||
import { type BookingStatus, type BookingFilter, type BaseBooking, type BookingGroup, sortByStatus } from "./types";
|
import { type BookingStatus, type BookingFilter, type BaseBooking, type BookingGroup, sortByStatus } from "./types";
|
||||||
@@ -32,8 +32,20 @@ export function GenericBookingsList<T extends BaseBooking>({
|
|||||||
}: GenericBookingsListProps<T>) {
|
}: GenericBookingsListProps<T>) {
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||||
|
const [highlightId, setHighlightId] = useState<number | null>(null);
|
||||||
|
const highlightRef = useRef<HTMLDivElement>(null);
|
||||||
const { showError } = useToast();
|
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) {
|
async function handleStatus(id: number, status: BookingStatus) {
|
||||||
if (status === "confirmed" && onConfirm) {
|
if (status === "confirmed" && onConfirm) {
|
||||||
onConfirm(id);
|
onConfirm(id);
|
||||||
@@ -41,7 +53,13 @@ export function GenericBookingsList<T extends BaseBooking>({
|
|||||||
}
|
}
|
||||||
const prev = items.find((b) => b.id === id);
|
const prev = items.find((b) => b.id === id);
|
||||||
const prevStatus = prev?.status;
|
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 {
|
try {
|
||||||
const res = await adminFetch(endpoint, {
|
const res = await adminFetch(endpoint, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -85,8 +103,10 @@ export function GenericBookingsList<T extends BaseBooking>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderItem(item: T, isArchived: boolean) {
|
function renderItem(item: T, isArchived: boolean) {
|
||||||
|
const isHighlighted = highlightId === item.id;
|
||||||
return (
|
return (
|
||||||
<BookingCard key={item.id} status={item.status}>
|
<div key={item.id} ref={isHighlighted ? highlightRef : undefined}>
|
||||||
|
<BookingCard status={item.status} highlight={isHighlighted}>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0">
|
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0">
|
||||||
<span className="font-medium text-white truncate max-w-[200px]">{item.name}</span>
|
<span className="font-medium text-white truncate max-w-[200px]">{item.name}</span>
|
||||||
@@ -104,6 +124,7 @@ export function GenericBookingsList<T extends BaseBooking>({
|
|||||||
</div>
|
</div>
|
||||||
<InlineNotes value={item.notes || ""} onSave={(notes) => handleNotes(item.id, notes)} />
|
<InlineNotes value={item.notes || ""} onSave={(notes) => handleNotes(item.id, notes)} />
|
||||||
</BookingCard>
|
</BookingCard>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -799,7 +799,7 @@ function BookingsPageInner() {
|
|||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data: { total: number }) => {
|
.then((data: { total: number }) => {
|
||||||
if (lastTotalRef.current !== null && data.total !== lastTotalRef.current) {
|
if (lastTotalRef.current !== null && data.total !== lastTotalRef.current) {
|
||||||
setRefreshKey((k) => k + 1);
|
refreshDashboard();
|
||||||
}
|
}
|
||||||
lastTotalRef.current = data.total;
|
lastTotalRef.current = data.total;
|
||||||
})
|
})
|
||||||
@@ -921,8 +921,8 @@ function BookingsPageInner() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab content */}
|
{/* Tab content */}
|
||||||
<div className="mt-4" key={`tab-${refreshKey}`}>
|
<div className="mt-4">
|
||||||
{tab === "reminders" && <RemindersTab />}
|
{tab === "reminders" && <RemindersTab key={refreshKey} />}
|
||||||
{tab === "classes" && <GroupBookingsTab filter={statusFilter} onDataChange={refreshDashboard} />}
|
{tab === "classes" && <GroupBookingsTab filter={statusFilter} onDataChange={refreshDashboard} />}
|
||||||
{tab === "master-classes" && <McRegistrationsTab filter={statusFilter} onDataChange={refreshDashboard} />}
|
{tab === "master-classes" && <McRegistrationsTab filter={statusFilter} onDataChange={refreshDashboard} />}
|
||||||
{tab === "open-day" && <OpenDayBookingsTab filter={statusFilter} onDataChange={refreshDashboard} />}
|
{tab === "open-day" && <OpenDayBookingsTab filter={statusFilter} onDataChange={refreshDashboard} />}
|
||||||
@@ -933,7 +933,7 @@ function BookingsPageInner() {
|
|||||||
<AddBookingModal
|
<AddBookingModal
|
||||||
open={addOpen}
|
open={addOpen}
|
||||||
onClose={() => setAddOpen(false)}
|
onClose={() => setAddOpen(false)}
|
||||||
onAdded={() => setRefreshKey((k) => k + 1)}
|
onAdded={() => { setRefreshKey((k) => k + 1); refreshDashboard(); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ export function countStatuses(items: { status: string }[]): Record<string, numbe
|
|||||||
|
|
||||||
export function sortByStatus<T extends { status: string }>(items: T[]): T[] {
|
export function sortByStatus<T extends { status: string }>(items: T[]): T[] {
|
||||||
const order: Record<string, number> = { new: 0, contacted: 1, confirmed: 2, declined: 3 };
|
const order: Record<string, number> = { 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<T extends BaseBooking> {
|
export interface BookingGroup<T extends BaseBooking> {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ function PriceField({ label, value, onChange, placeholder }: { label: string; va
|
|||||||
interface MasterClassesData {
|
interface MasterClassesData {
|
||||||
title: string;
|
title: string;
|
||||||
successMessage?: string;
|
successMessage?: string;
|
||||||
|
waitingListText?: string;
|
||||||
items: MasterClassItem[];
|
items: MasterClassItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ export async function POST(request: Request) {
|
|||||||
let isWaiting = false;
|
let isWaiting = false;
|
||||||
if (mcItem?.maxParticipants && mcItem.maxParticipants > 0) {
|
if (mcItem?.maxParticipants && mcItem.maxParticipants > 0) {
|
||||||
const currentRegs = getMcRegistrations(cleanTitle);
|
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(
|
const id = addMcRegistration(
|
||||||
@@ -46,7 +47,8 @@ export async function POST(request: Request) {
|
|||||||
cleanName,
|
cleanName,
|
||||||
sanitizeHandle(instagram) ?? "",
|
sanitizeHandle(instagram) ?? "",
|
||||||
sanitizeHandle(telegram),
|
sanitizeHandle(telegram),
|
||||||
cleanPhone
|
cleanPhone,
|
||||||
|
isWaiting ? "Лист ожидания" : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, id, isWaiting });
|
return NextResponse.json({ ok: true, id, isWaiting });
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
getPersonOpenDayBookings,
|
getPersonOpenDayBookings,
|
||||||
getOpenDayEvent,
|
getOpenDayEvent,
|
||||||
getOpenDayClassById,
|
getOpenDayClassById,
|
||||||
|
getConfirmedOpenDayBookingCount,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
||||||
import { sanitizeName, sanitizePhone, sanitizeHandle } from "@/lib/validation";
|
import { sanitizeName, sanitizePhone, sanitizeHandle } from "@/lib/validation";
|
||||||
@@ -35,17 +36,18 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
|
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if class is full (event-level max) — if so, booking goes to waiting list
|
// Check if class is full (event-level max, confirmed only) — if so, booking goes to waiting list
|
||||||
const cls = getOpenDayClassById(classId);
|
|
||||||
const event = getOpenDayEvent(eventId);
|
const event = getOpenDayEvent(eventId);
|
||||||
const maxP = event?.maxParticipants ?? 0;
|
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, {
|
const id = addOpenDayBooking(classId, eventId, {
|
||||||
name: cleanName,
|
name: cleanName,
|
||||||
phone: cleanPhone,
|
phone: cleanPhone,
|
||||||
instagram: sanitizeHandle(instagram),
|
instagram: sanitizeHandle(instagram),
|
||||||
telegram: sanitizeHandle(telegram),
|
telegram: sanitizeHandle(telegram),
|
||||||
|
notes: isWaiting ? "Лист ожидания" : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return total bookings for this person (for discount calculation)
|
// Return total bookings for this person (for discount calculation)
|
||||||
|
|||||||
@@ -593,15 +593,16 @@ export function addMcRegistration(
|
|||||||
name: string,
|
name: string,
|
||||||
instagram: string,
|
instagram: string,
|
||||||
telegram?: string,
|
telegram?: string,
|
||||||
phone?: string
|
phone?: string,
|
||||||
|
notes?: string
|
||||||
): number {
|
): number {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const result = db
|
const result = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO mc_registrations (master_class_title, name, instagram, telegram, phone)
|
`INSERT INTO mc_registrations (master_class_title, name, instagram, telegram, phone, notes)
|
||||||
VALUES (?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?)`
|
||||||
)
|
)
|
||||||
.run(masterClassTitle, name, instagram, telegram || null, phone || null);
|
.run(masterClassTitle, name, instagram, telegram || null, phone || null, notes || null);
|
||||||
return result.lastInsertRowid as number;
|
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 {
|
export function setOpenDayBookingStatus(id: number, status: string): void {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
db.prepare("UPDATE open_day_bookings SET status = ?, notified_confirm = 1 WHERE id = ?").run(status, id);
|
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(
|
export function addOpenDayBooking(
|
||||||
classId: number,
|
classId: number,
|
||||||
eventId: number,
|
eventId: number,
|
||||||
data: { name: string; phone: string; instagram?: string; telegram?: string }
|
data: { name: string; phone: string; instagram?: string; telegram?: string; notes?: string }
|
||||||
): number {
|
): number {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const result = db
|
const result = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO open_day_bookings (class_id, event_id, name, phone, instagram, telegram)
|
`INSERT INTO open_day_bookings (class_id, event_id, name, phone, instagram, telegram, notes)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`
|
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;
|
return result.lastInsertRowid as number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user