Files
blackheart-website/src/components/ui/BookingModal.tsx
diana.dolgolyova b94ee69033 feat: add booking management, Open Day, unified signup modal
- MC registrations: notification toggles (confirm/remind) with urgency
- Group bookings: save to DB from BookingModal, admin CRUD at /admin/bookings
- Open Day: full event system with schedule grid (halls × time), per-class
  booking, discount pricing (30 BYN / 20 BYN from 3+), auto-cancel threshold
- Unified SignupModal replaces 3 separate forms — consistent fields
  (name, phone, instagram, telegram), Instagram DM fallback on network error
- Centralized /admin/bookings page with 3 tabs (classes, MC, Open Day),
  collapsible sections, notification toggles, filter chips
- Unread booking badge on sidebar + dashboard widget with per-type breakdown
- Pricing: contact hint (Instagram/Telegram/phone) on price & rental tabs,
  admin toggle to show/hide
- DB migrations 5-7: group_bookings table, open_day tables, unified fields

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 12:58:04 +03:00

215 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { X, Instagram, Send, CheckCircle, Phone } from "lucide-react";
import { BRAND } from "@/lib/constants";
interface BookingModalProps {
open: boolean;
onClose: () => void;
groupInfo?: string;
contact?: { instagram: string; phone: string };
}
const DEFAULT_CONTACT = {
instagram: BRAND.instagram,
phone: "+375 29 389-70-01",
};
export function BookingModal({ open, onClose, groupInfo, contact: contactProp }: BookingModalProps) {
const contact = contactProp ?? DEFAULT_CONTACT;
const [name, setName] = useState("");
const [phone, setPhone] = useState("+375 ");
// Format phone: +375 (XX) XXX-XX-XX
function handlePhoneChange(raw: string) {
// Strip everything except digits
let digits = raw.replace(/\D/g, "");
// Ensure starts with 375
if (!digits.startsWith("375")) {
digits = "375" + digits.replace(/^375?/, "");
}
// Limit to 12 digits (375 + 9 digits)
digits = digits.slice(0, 12);
// Format
let formatted = "+375";
const rest = digits.slice(3);
if (rest.length > 0) formatted += " (" + rest.slice(0, 2);
if (rest.length >= 2) formatted += ") ";
if (rest.length > 2) formatted += rest.slice(2, 5);
if (rest.length > 5) formatted += "-" + rest.slice(5, 7);
if (rest.length > 7) formatted += "-" + rest.slice(7, 9);
setPhone(formatted);
}
const [submitted, setSubmitted] = useState(false);
// Close on Escape
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
// Lock body scroll
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [open]);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
// Save booking to DB (fire-and-forget)
fetch("/api/group-booking", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, phone, groupInfo }),
}).catch(() => {});
// Build Instagram DM message with pre-filled text
const groupText = groupInfo ? ` (${groupInfo})` : "";
const message = `Здравствуйте! Меня зовут ${name}, хочу записаться на занятие${groupText}. Мой телефон: ${phone}`;
const instagramUrl = `https://ig.me/m/blackheartdancehouse?text=${encodeURIComponent(message)}`;
window.open(instagramUrl, "_blank");
setSubmitted(true);
},
[name, phone, groupInfo, contact]
);
const handleClose = useCallback(() => {
onClose();
// Reset after animation
setTimeout(() => {
setName("");
setPhone("+375 ");
setSubmitted(false);
}, 300);
}, [onClose]);
if (!open) return null;
return createPortal(
<div
className="modal-overlay fixed inset-0 z-50 flex items-center justify-center p-4"
onClick={handleClose}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
{/* Modal */}
<div
className="modal-content relative w-full max-w-md rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-6 sm:p-8 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Close button */}
<button
onClick={handleClose}
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full text-neutral-500 transition-colors hover:bg-white/[0.06] hover:text-white cursor-pointer"
>
<X size={18} />
</button>
{submitted ? (
/* Success state */
<div className="py-4 text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/10">
<CheckCircle size={28} className="text-emerald-500" />
</div>
<h3 className="text-lg font-bold text-white">Отлично!</h3>
<p className="mt-2 text-sm text-neutral-400">
Сообщение отправлено в Instagram. Мы свяжемся с вами в ближайшее время!
</p>
<button
onClick={handleClose}
className="mt-6 rounded-full bg-gold px-6 py-2.5 text-sm font-semibold text-black transition-all hover:bg-gold-light cursor-pointer"
>
Закрыть
</button>
</div>
) : (
<>
{/* Header */}
<div className="mb-6">
<h3 className="text-xl font-bold text-white">Записаться</h3>
<p className="mt-1 text-sm text-neutral-400">
Оставьте данные и мы свяжемся с вами, или напишите нам напрямую
</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ваше имя"
required
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
/>
</div>
<div>
<input
type="tel"
value={phone}
onChange={(e) => handlePhoneChange(e.target.value)}
placeholder="+375 (__) ___-__-__"
required
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
/>
</div>
<button
type="submit"
className="flex w-full items-center justify-center gap-2 rounded-xl bg-gold py-3 text-sm font-semibold text-black transition-all hover:bg-gold-light hover:shadow-lg hover:shadow-gold/20 cursor-pointer"
>
<Send size={15} />
Отправить в Instagram
</button>
</form>
{/* Divider */}
<div className="my-5 flex items-center gap-3">
<span className="h-px flex-1 bg-white/[0.06]" />
<span className="text-xs text-neutral-500">или напрямую</span>
<span className="h-px flex-1 bg-white/[0.06]" />
</div>
{/* Direct links */}
<div className="flex gap-2">
<a
href={contact.instagram}
target="_blank"
rel="noopener noreferrer"
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-white/[0.08] bg-white/[0.03] py-3 text-sm font-medium text-neutral-300 transition-all hover:border-gold/30 hover:text-gold-light cursor-pointer"
>
<Instagram size={16} />
Instagram
</a>
<a
href={`tel:${contact.phone.replace(/\s/g, "")}`}
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-white/[0.08] bg-white/[0.03] py-3 text-sm font-medium text-neutral-300 transition-all hover:border-gold/30 hover:text-gold-light cursor-pointer"
>
<Phone size={16} />
Позвонить
</a>
</div>
</>
)}
</div>
</div>,
document.body
);
}