- 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
176 lines
7.4 KiB
TypeScript
176 lines
7.4 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import { Loader2, Trash2, Phone, Instagram, Send, X } from "lucide-react";
|
||
import { type BookingStatus, type BookingFilter, BOOKING_STATUSES } from "./types";
|
||
|
||
export function LoadingSpinner() {
|
||
return (
|
||
<div className="flex items-center gap-2 py-8 text-neutral-500 justify-center">
|
||
<Loader2 size={16} className="animate-spin" />
|
||
Загрузка...
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function EmptyState({ total }: { total: number }) {
|
||
return (
|
||
<p className="text-sm text-neutral-500 py-8 text-center">
|
||
{total === 0 ? "Пока нет записей" : "Нет записей по фильтру"}
|
||
</p>
|
||
);
|
||
}
|
||
|
||
// --- #1: Delete with confirmation ---
|
||
|
||
export function DeleteBtn({ onClick, name }: { onClick: () => void; name?: string }) {
|
||
const [confirming, setConfirming] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!confirming) return;
|
||
function onKey(e: KeyboardEvent) { if (e.key === "Escape") setConfirming(false); }
|
||
document.addEventListener("keydown", onKey);
|
||
return () => document.removeEventListener("keydown", onKey);
|
||
}, [confirming]);
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => setConfirming(true)}
|
||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
|
||
title="Удалить"
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
{confirming && createPortal(
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setConfirming(false)}>
|
||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||
<div className="relative w-full max-w-xs rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-5 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||
<button onClick={() => setConfirming(false)} className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-white/[0.06] hover:text-white">
|
||
<X size={16} />
|
||
</button>
|
||
<h3 className="text-sm font-bold text-white">Удалить запись?</h3>
|
||
{name && <p className="mt-1 text-xs text-neutral-400">{name}</p>}
|
||
<p className="mt-2 text-xs text-neutral-500">Это действие нельзя отменить.</p>
|
||
<div className="mt-4 flex gap-2">
|
||
<button
|
||
onClick={() => setConfirming(false)}
|
||
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 py-2 text-xs font-medium text-neutral-300 hover:bg-neutral-700 transition-colors"
|
||
>
|
||
Отмена
|
||
</button>
|
||
<button
|
||
onClick={() => { setConfirming(false); onClick(); }}
|
||
className="flex-1 rounded-lg bg-red-600 py-2 text-xs font-medium text-white hover:bg-red-500 transition-colors"
|
||
>
|
||
Удалить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
export function ContactLinks({ phone, instagram, telegram }: { phone?: string; instagram?: string; telegram?: string }) {
|
||
return (
|
||
<>
|
||
{phone && (
|
||
<a href={`tel:${phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
|
||
<Phone size={10} />{phone}
|
||
</a>
|
||
)}
|
||
{instagram && (
|
||
<a href={`https://ig.me/m/${instagram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs">
|
||
<Instagram size={10} />{instagram}
|
||
</a>
|
||
)}
|
||
{telegram && (
|
||
<a href={`https://t.me/${telegram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs">
|
||
<Send size={10} />{telegram}
|
||
</a>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
export function FilterTabs({ filter, counts, total, onFilter }: {
|
||
filter: BookingFilter;
|
||
counts: Record<string, number>;
|
||
total: number;
|
||
onFilter: (f: BookingFilter) => void;
|
||
}) {
|
||
return (
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<button
|
||
onClick={() => onFilter("all")}
|
||
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||
filter === "all" ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
|
||
}`}
|
||
>
|
||
Все <span className="text-neutral-500 ml-1">{total}</span>
|
||
</button>
|
||
{BOOKING_STATUSES.map((s) => (
|
||
<button
|
||
key={s.key}
|
||
onClick={() => onFilter(s.key)}
|
||
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||
filter === s.key ? `${s.bg} ${s.color} border ${s.border}` : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
|
||
}`}
|
||
>
|
||
{s.label}
|
||
{counts[s.key] > 0 && <span className="ml-1.5">{counts[s.key]}</span>}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function StatusBadge({ status }: { status: BookingStatus }) {
|
||
const conf = BOOKING_STATUSES.find((s) => s.key === status) || BOOKING_STATUSES[0];
|
||
return (
|
||
<span className={`text-[10px] font-medium ${conf.bg} ${conf.color} border ${conf.border} rounded-full px-2.5 py-0.5`}>
|
||
{conf.label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export function StatusActions({ status, onStatus }: { status: BookingStatus; onStatus: (s: BookingStatus) => void }) {
|
||
const actionBtn = (label: string, onClick: () => void, cls: string) => (
|
||
<button onClick={onClick} className={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium transition-all ${cls}`}>
|
||
{label}
|
||
</button>
|
||
);
|
||
return (
|
||
<div className="flex gap-1 ml-auto">
|
||
{status === "new" && actionBtn("Связались →", () => onStatus("contacted"), "bg-blue-500/10 text-blue-400 border border-blue-500/30 hover:bg-blue-500/20")}
|
||
{status === "contacted" && (
|
||
<>
|
||
{actionBtn("Подтвердить", () => onStatus("confirmed"), "bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/20")}
|
||
{actionBtn("Отказ", () => onStatus("declined"), "bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20")}
|
||
</>
|
||
)}
|
||
{(status === "confirmed" || status === "declined") && actionBtn("Вернуть", () => onStatus("contacted"), "bg-neutral-800/50 text-neutral-500 border border-transparent hover:border-white/10 hover:text-neutral-300")}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function BookingCard({ status, highlight, children }: { status: BookingStatus; highlight?: boolean; children: React.ReactNode }) {
|
||
return (
|
||
<div
|
||
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 hover:opacity-70 hover:border-red-500/30"
|
||
: 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] hover:border-gold/40 hover:bg-gold/[0.06]"
|
||
: "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}
|
||
</div>
|
||
);
|
||
}
|