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>
This commit is contained in:
@@ -6,7 +6,7 @@ import { useState, useEffect } from "react";
|
||||
import { BRAND, NAV_LINKS } from "@/lib/constants";
|
||||
import { UI_CONFIG } from "@/lib/config";
|
||||
import { HeroLogo } from "@/components/ui/HeroLogo";
|
||||
import { BookingModal } from "@/components/ui/BookingModal";
|
||||
import { SignupModal } from "@/components/ui/SignupModal";
|
||||
|
||||
export function Header() {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
@@ -182,7 +182,7 @@ export function Header() {
|
||||
Записаться
|
||||
</button>
|
||||
|
||||
<BookingModal open={bookingOpen} onClose={() => setBookingOpen(false)} />
|
||||
<SignupModal open={bookingOpen} onClose={() => setBookingOpen(false)} endpoint="/api/group-booking" />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import Image from "next/image";
|
||||
import { Calendar, Clock, User, MapPin, Instagram } from "lucide-react";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
import { Reveal } from "@/components/ui/Reveal";
|
||||
import { MasterClassSignupModal } from "@/components/ui/MasterClassSignupModal";
|
||||
import { SignupModal } from "@/components/ui/SignupModal";
|
||||
import type { SiteContent, MasterClassItem, MasterClassSlot } from "@/types";
|
||||
|
||||
interface MasterClassesProps {
|
||||
@@ -236,10 +236,12 @@ export function MasterClasses({ data }: MasterClassesProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MasterClassSignupModal
|
||||
<SignupModal
|
||||
open={signupTitle !== null}
|
||||
onClose={() => setSignupTitle(null)}
|
||||
masterClassTitle={signupTitle ?? ""}
|
||||
subtitle={signupTitle ?? ""}
|
||||
endpoint="/api/master-class-register"
|
||||
extraBody={{ masterClassTitle: signupTitle }}
|
||||
successMessage={data.successMessage}
|
||||
/>
|
||||
</section>
|
||||
|
||||
184
src/components/sections/OpenDay.tsx
Normal file
184
src/components/sections/OpenDay.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { Calendar, Users, Sparkles } from "lucide-react";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
import { Reveal } from "@/components/ui/Reveal";
|
||||
import { SignupModal } from "@/components/ui/SignupModal";
|
||||
import type { OpenDayEvent, OpenDayClass } from "@/lib/openDay";
|
||||
|
||||
interface OpenDayProps {
|
||||
data: {
|
||||
event: OpenDayEvent;
|
||||
classes: OpenDayClass[];
|
||||
};
|
||||
}
|
||||
|
||||
function formatDateRu(dateStr: string): string {
|
||||
const d = new Date(dateStr + "T12:00:00");
|
||||
return d.toLocaleDateString("ru-RU", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
});
|
||||
}
|
||||
|
||||
export function OpenDay({ data }: OpenDayProps) {
|
||||
const { event, classes } = data;
|
||||
const [signup, setSignup] = useState<{ classId: number; label: string } | null>(null);
|
||||
|
||||
// Group classes by hall
|
||||
const hallGroups = useMemo(() => {
|
||||
const groups: Record<string, OpenDayClass[]> = {};
|
||||
for (const cls of classes) {
|
||||
if (!groups[cls.hall]) groups[cls.hall] = [];
|
||||
groups[cls.hall].push(cls);
|
||||
}
|
||||
// Sort each hall's classes by time
|
||||
for (const hall in groups) {
|
||||
groups[hall].sort((a, b) => a.startTime.localeCompare(b.startTime));
|
||||
}
|
||||
return groups;
|
||||
}, [classes]);
|
||||
|
||||
const halls = Object.keys(hallGroups);
|
||||
|
||||
if (classes.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section id="open-day" className="py-10 sm:py-14">
|
||||
<div className="mx-auto max-w-6xl px-4">
|
||||
<Reveal>
|
||||
<SectionHeading>{event.title}</SectionHeading>
|
||||
</Reveal>
|
||||
|
||||
<Reveal>
|
||||
<div className="mt-4 text-center">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-gold/10 border border-gold/20 px-5 py-2.5 text-sm font-medium text-gold">
|
||||
<Calendar size={16} />
|
||||
{formatDateRu(event.date)}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Pricing info */}
|
||||
<Reveal>
|
||||
<div className="mt-6 text-center space-y-1">
|
||||
<p className="text-lg font-semibold text-white">
|
||||
{event.pricePerClass} BYN <span className="text-neutral-400 font-normal text-sm">за занятие</span>
|
||||
</p>
|
||||
<p className="text-sm text-gold">
|
||||
<Sparkles size={12} className="inline mr-1" />
|
||||
От {event.discountThreshold} занятий — {event.discountPrice} BYN за каждое!
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{event.description && (
|
||||
<Reveal>
|
||||
<p className="mt-4 text-center text-sm text-neutral-400 max-w-2xl mx-auto">
|
||||
{event.description}
|
||||
</p>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
{/* Schedule Grid */}
|
||||
<div className="mt-8">
|
||||
{halls.length === 1 ? (
|
||||
// Single hall — simple list
|
||||
<Reveal>
|
||||
<div className="max-w-lg mx-auto space-y-3">
|
||||
<h3 className="text-sm font-medium text-neutral-400 text-center">{halls[0]}</h3>
|
||||
{hallGroups[halls[0]].map((cls) => (
|
||||
<ClassCard
|
||||
key={cls.id}
|
||||
cls={cls}
|
||||
onSignup={setSignup}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
) : (
|
||||
// Multiple halls — columns
|
||||
<div className={`grid gap-6 ${halls.length === 2 ? "sm:grid-cols-2" : "sm:grid-cols-2 lg:grid-cols-3"}`}>
|
||||
{halls.map((hall) => (
|
||||
<Reveal key={hall}>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-neutral-400 mb-3 text-center">{hall}</h3>
|
||||
<div className="space-y-3">
|
||||
{hallGroups[hall].map((cls) => (
|
||||
<ClassCard
|
||||
key={cls.id}
|
||||
cls={cls}
|
||||
onSignup={setSignup}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{signup && (
|
||||
<SignupModal
|
||||
open
|
||||
onClose={() => setSignup(null)}
|
||||
subtitle={signup.label}
|
||||
endpoint="/api/open-day-register"
|
||||
extraBody={{ classId: signup.classId, eventId: event.id }}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassCard({
|
||||
cls,
|
||||
onSignup,
|
||||
}: {
|
||||
cls: OpenDayClass;
|
||||
onSignup: (info: { classId: number; label: string }) => void;
|
||||
}) {
|
||||
const label = `${cls.style} · ${cls.trainer} · ${cls.startTime}–${cls.endTime}`;
|
||||
|
||||
if (cls.cancelled) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/5 bg-neutral-900/30 p-4 opacity-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-xs text-neutral-500">{cls.startTime}–{cls.endTime}</span>
|
||||
<p className="text-sm text-neutral-500 line-through">{cls.style}</p>
|
||||
<p className="text-xs text-neutral-600">{cls.trainer}</p>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">
|
||||
Отменено
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-neutral-900 p-4 transition-all hover:border-gold/20">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs text-gold font-medium">{cls.startTime}–{cls.endTime}</span>
|
||||
<p className="text-sm font-medium text-white mt-0.5">{cls.style}</p>
|
||||
<p className="text-xs text-neutral-400 flex items-center gap-1 mt-0.5">
|
||||
<Users size={10} />
|
||||
{cls.trainer}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onSignup({ classId: cls.id, label })}
|
||||
className="shrink-0 rounded-full bg-gold/10 border border-gold/20 px-4 py-2 text-xs font-medium text-gold hover:bg-gold/20 transition-colors cursor-pointer"
|
||||
>
|
||||
Записаться
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CreditCard, Building2, ScrollText, Crown, Sparkles } from "lucide-react";
|
||||
import { CreditCard, Building2, ScrollText, Crown, Sparkles, Instagram, Send, Phone } from "lucide-react";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
import { Reveal } from "@/components/ui/Reveal";
|
||||
import { BookingModal } from "@/components/ui/BookingModal";
|
||||
import { BRAND } from "@/lib/constants";
|
||||
import type { SiteContent } from "@/types/content";
|
||||
|
||||
type Tab = "prices" | "rental" | "rules";
|
||||
@@ -13,9 +13,42 @@ interface PricingProps {
|
||||
data: SiteContent["pricing"];
|
||||
}
|
||||
|
||||
function ContactHint() {
|
||||
return (
|
||||
<div className="mt-5 flex flex-wrap items-center justify-center gap-3 text-xs text-neutral-500">
|
||||
<span>Для записи и бронирования:</span>
|
||||
<a
|
||||
href={BRAND.instagram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-white/[0.06] px-3 py-1.5 text-pink-400 hover:text-pink-300 hover:border-pink-400/30 transition-colors"
|
||||
>
|
||||
<Instagram size={12} />
|
||||
Instagram
|
||||
</a>
|
||||
<a
|
||||
href="https://t.me/blackheartdancehouse"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-white/[0.06] px-3 py-1.5 text-blue-400 hover:text-blue-300 hover:border-blue-400/30 transition-colors"
|
||||
>
|
||||
<Send size={12} />
|
||||
Telegram
|
||||
</a>
|
||||
<a
|
||||
href="tel:+375293897001"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-white/[0.06] px-3 py-1.5 text-emerald-400 hover:text-emerald-300 hover:border-emerald-400/30 transition-colors"
|
||||
>
|
||||
<Phone size={12} />
|
||||
Позвонить
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Pricing({ data: pricing }: PricingProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("prices");
|
||||
const [bookingOpen, setBookingOpen] = useState(false);
|
||||
const showHint = pricing.showContactHint !== false; // default true
|
||||
|
||||
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: "prices", label: "Абонементы", icon: <CreditCard size={16} /> },
|
||||
@@ -68,13 +101,12 @@ export function Pricing({ data: pricing }: PricingProps) {
|
||||
{regularItems.map((item, i) => {
|
||||
const isPopular = item.popular ?? false;
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => setBookingOpen(true)}
|
||||
className={`group relative cursor-pointer rounded-2xl border p-5 transition-all duration-300 text-left ${
|
||||
className={`group relative rounded-2xl border p-5 transition-all duration-300 ${
|
||||
isPopular
|
||||
? "border-gold/40 bg-gradient-to-br from-gold/10 via-transparent to-gold/5 dark:from-gold/[0.07] dark:to-gold/[0.02] shadow-lg shadow-gold/10 hover:shadow-xl hover:shadow-gold/20"
|
||||
: "border-neutral-200 bg-white hover:border-neutral-300 dark:border-white/[0.06] dark:bg-[#0a0a0a] dark:hover:border-white/[0.12]"
|
||||
? "border-gold/40 bg-gradient-to-br from-gold/10 via-transparent to-gold/5 dark:from-gold/[0.07] dark:to-gold/[0.02] shadow-lg shadow-gold/10"
|
||||
: "border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a]"
|
||||
}`}
|
||||
>
|
||||
{/* Popular badge */}
|
||||
@@ -105,14 +137,14 @@ export function Pricing({ data: pricing }: PricingProps) {
|
||||
{item.price}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Featured — big card */}
|
||||
{featuredItem && (
|
||||
<button onClick={() => setBookingOpen(true)} className="mt-6 w-full cursor-pointer text-left team-card-glitter rounded-2xl border border-gold/30 bg-gradient-to-r from-gold/10 via-gold/5 to-gold/10 dark:from-gold/[0.06] dark:via-transparent dark:to-gold/[0.06] p-6 sm:p-8 transition-shadow duration-300 hover:shadow-xl hover:shadow-gold/20">
|
||||
<div className="mt-6 w-full team-card-glitter rounded-2xl border border-gold/30 bg-gradient-to-r from-gold/10 via-gold/5 to-gold/10 dark:from-gold/[0.06] dark:via-transparent dark:to-gold/[0.06] p-6 sm:p-8">
|
||||
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
|
||||
<div className="text-center sm:text-left">
|
||||
<div className="flex items-center justify-center gap-2 sm:justify-start">
|
||||
@@ -131,8 +163,10 @@ export function Pricing({ data: pricing }: PricingProps) {
|
||||
{featuredItem.price}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showHint && <ContactHint />}
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
@@ -142,10 +176,9 @@ export function Pricing({ data: pricing }: PricingProps) {
|
||||
<Reveal>
|
||||
<div className="mx-auto mt-10 max-w-2xl space-y-3">
|
||||
{pricing.rentalItems.map((item, i) => (
|
||||
<button
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => setBookingOpen(true)}
|
||||
className="w-full cursor-pointer text-left flex items-center justify-between gap-4 rounded-2xl border border-neutral-200 bg-white px-6 py-5 transition-colors hover:border-neutral-300 dark:border-white/[0.06] dark:bg-[#0a0a0a] dark:hover:border-white/[0.12]"
|
||||
className="flex items-center justify-between gap-4 rounded-2xl border border-neutral-200 bg-white px-6 py-5 dark:border-white/[0.06] dark:bg-[#0a0a0a]"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900 dark:text-white">
|
||||
@@ -160,8 +193,10 @@ export function Pricing({ data: pricing }: PricingProps) {
|
||||
<span className="shrink-0 font-display text-xl font-bold text-gold-dark dark:text-gold-light">
|
||||
{item.price}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{showHint && <ContactHint />}
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
@@ -187,8 +222,6 @@ export function Pricing({ data: pricing }: PricingProps) {
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BookingModal open={bookingOpen} onClose={() => setBookingOpen(false)} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { BookingModal } from "@/components/ui/BookingModal";
|
||||
import { SignupModal } from "@/components/ui/SignupModal";
|
||||
import { CalendarDays, Users, LayoutGrid } from "lucide-react";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
import { Reveal } from "@/components/ui/Reveal";
|
||||
@@ -335,10 +335,12 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
||||
/>
|
||||
</Reveal>
|
||||
)}
|
||||
<BookingModal
|
||||
<SignupModal
|
||||
open={bookingGroup !== null}
|
||||
onClose={() => setBookingGroup(null)}
|
||||
groupInfo={bookingGroup ?? undefined}
|
||||
subtitle={bookingGroup ?? undefined}
|
||||
endpoint="/api/group-booking"
|
||||
extraBody={{ groupInfo: bookingGroup }}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import Image from "next/image";
|
||||
import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Award, Scale, Clock, MapPin } from "lucide-react";
|
||||
import type { TeamMember, RichListItem, VictoryItem, ScheduleLocation } from "@/types/content";
|
||||
import { BookingModal } from "@/components/ui/BookingModal";
|
||||
import { SignupModal } from "@/components/ui/SignupModal";
|
||||
|
||||
interface TeamProfileProps {
|
||||
member: TeamMember;
|
||||
@@ -329,10 +329,12 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BookingModal
|
||||
<SignupModal
|
||||
open={bookingGroup !== null}
|
||||
onClose={() => setBookingGroup(null)}
|
||||
groupInfo={bookingGroup ?? undefined}
|
||||
subtitle={bookingGroup ?? undefined}
|
||||
endpoint="/api/group-booking"
|
||||
extraBody={{ groupInfo: bookingGroup }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -71,6 +71,12 @@ export function BookingModal({ open, onClose, groupInfo, contact: contactProp }:
|
||||
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}`;
|
||||
|
||||
210
src/components/ui/OpenDaySignupModal.tsx
Normal file
210
src/components/ui/OpenDaySignupModal.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, CheckCircle, Send, Phone as PhoneIcon } from "lucide-react";
|
||||
|
||||
interface OpenDaySignupModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
classId: number;
|
||||
eventId: number;
|
||||
classLabel: string;
|
||||
}
|
||||
|
||||
export function OpenDaySignupModal({ open, onClose, classId, eventId, classLabel }: OpenDaySignupModalProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [phone, setPhone] = useState("+375 ");
|
||||
const [instagram, setInstagram] = useState("");
|
||||
const [telegram, setTelegram] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [result, setResult] = useState<{ totalBookings: number; pricePerClass: number } | null>(null);
|
||||
|
||||
function handlePhoneChange(raw: string) {
|
||||
let digits = raw.replace(/\D/g, "");
|
||||
if (!digits.startsWith("375")) {
|
||||
digits = "375" + digits.replace(/^375?/, "");
|
||||
}
|
||||
digits = digits.slice(0, 12);
|
||||
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);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) document.body.style.overflow = "hidden";
|
||||
else document.body.style.overflow = "";
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSubmitting(true);
|
||||
|
||||
const cleanPhone = phone.replace(/\D/g, "");
|
||||
if (cleanPhone.length < 12) {
|
||||
setError("Введите корректный номер телефона");
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/open-day-register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
classId,
|
||||
eventId,
|
||||
name: name.trim(),
|
||||
phone: cleanPhone,
|
||||
instagram: instagram.trim() ? `@${instagram.trim()}` : undefined,
|
||||
telegram: telegram.trim() ? `@${telegram.trim()}` : undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Ошибка при записи");
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
setResult({ totalBookings: data.totalBookings, pricePerClass: data.pricePerClass });
|
||||
} catch {
|
||||
setError("Ошибка сети");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [classId, eventId, name, phone, instagram, telegram]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose();
|
||||
setTimeout(() => {
|
||||
setName("");
|
||||
setPhone("+375 ");
|
||||
setInstagram("");
|
||||
setTelegram("");
|
||||
setError("");
|
||||
setResult(null);
|
||||
}, 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}>
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||
<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()}
|
||||
>
|
||||
<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>
|
||||
|
||||
{result ? (
|
||||
<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">{classLabel}</p>
|
||||
<p className="mt-3 text-sm text-white">
|
||||
Вы записаны на <span className="text-gold font-semibold">{result.totalBookings}</span> занятий.
|
||||
<br />
|
||||
Стоимость: <span className="text-gold font-semibold">{result.pricePerClass} BYN</span> за занятие
|
||||
</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>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xl font-bold text-white">Записаться</h3>
|
||||
<p className="mt-1 text-sm text-neutral-400">{classLabel}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<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 className="relative">
|
||||
<PhoneIcon size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500" />
|
||||
<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] pl-9 pr-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 className="grid grid-cols-2 gap-2">
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500 text-xs">@</span>
|
||||
<input
|
||||
type="text"
|
||||
value={instagram}
|
||||
onChange={(e) => setInstagram(e.target.value.replace(/^@/, ""))}
|
||||
placeholder="Instagram"
|
||||
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-7 pr-3 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500 text-xs">@</span>
|
||||
<input
|
||||
type="text"
|
||||
value={telegram}
|
||||
onChange={(e) => setTelegram(e.target.value.replace(/^@/, ""))}
|
||||
placeholder="Telegram"
|
||||
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-7 pr-3 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
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 disabled:opacity-50"
|
||||
>
|
||||
<Send size={15} />
|
||||
{submitting ? "Записываем..." : "Записаться"}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
264
src/components/ui/SignupModal.tsx
Normal file
264
src/components/ui/SignupModal.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, CheckCircle, Send, Phone as PhoneIcon, Instagram } from "lucide-react";
|
||||
import { BRAND } from "@/lib/constants";
|
||||
|
||||
interface SignupModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
/** API endpoint to POST to */
|
||||
endpoint: string;
|
||||
/** Extra fields merged into the POST body (e.g. masterClassTitle, classId, eventId, groupInfo) */
|
||||
extraBody?: Record<string, unknown>;
|
||||
/** Custom success message */
|
||||
successMessage?: string;
|
||||
/** Callback with API response data on success */
|
||||
onSuccess?: (data: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function SignupModal({
|
||||
open,
|
||||
onClose,
|
||||
title = "Записаться",
|
||||
subtitle,
|
||||
endpoint,
|
||||
extraBody,
|
||||
successMessage,
|
||||
onSuccess,
|
||||
}: SignupModalProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [phone, setPhone] = useState("+375 ");
|
||||
const [instagram, setInstagram] = useState("");
|
||||
const [telegram, setTelegram] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [successData, setSuccessData] = useState<Record<string, unknown> | null>(null);
|
||||
|
||||
function handlePhoneChange(raw: string) {
|
||||
let digits = raw.replace(/\D/g, "");
|
||||
if (!digits.startsWith("375")) {
|
||||
digits = "375" + digits.replace(/^375?/, "");
|
||||
}
|
||||
digits = digits.slice(0, 12);
|
||||
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);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) document.body.style.overflow = "hidden";
|
||||
else document.body.style.overflow = "";
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
const cleanPhone = phone.replace(/\D/g, "");
|
||||
if (cleanPhone.length < 12) {
|
||||
setError("Введите корректный номер телефона");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
name: name.trim(),
|
||||
phone: cleanPhone,
|
||||
...extraBody,
|
||||
};
|
||||
if (instagram.trim()) body.instagram = `@${instagram.trim()}`;
|
||||
if (telegram.trim()) body.telegram = `@${telegram.trim()}`;
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Ошибка при записи");
|
||||
return;
|
||||
}
|
||||
setSuccess(true);
|
||||
setSuccessData(data);
|
||||
onSuccess?.(data);
|
||||
} catch {
|
||||
setError("network");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [name, phone, instagram, telegram, endpoint, extraBody, onSuccess]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose();
|
||||
setTimeout(() => {
|
||||
setName("");
|
||||
setPhone("+375 ");
|
||||
setInstagram("");
|
||||
setTelegram("");
|
||||
setError("");
|
||||
setSuccess(false);
|
||||
setSuccessData(null);
|
||||
}, 300);
|
||||
}, [onClose]);
|
||||
|
||||
function openInstagramDM() {
|
||||
const text = `Здравствуйте! Меня зовут ${name}. Хочу записаться${subtitle ? ` (${subtitle})` : ""}. Мой телефон: ${phone}`;
|
||||
window.open(`https://ig.me/m/blackheartdancehouse?text=${encodeURIComponent(text)}`, "_blank");
|
||||
handleClose();
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="modal-overlay fixed inset-0 z-50 flex items-center justify-center p-4" onClick={handleClose}>
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||
<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()}
|
||||
>
|
||||
<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>
|
||||
|
||||
{success ? (
|
||||
<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">
|
||||
{successMessage || "Вы записаны!"}
|
||||
</h3>
|
||||
{subtitle && <p className="mt-1 text-sm text-neutral-400">{subtitle}</p>}
|
||||
{successData?.totalBookings !== undefined && (
|
||||
<p className="mt-3 text-sm text-white">
|
||||
Вы записаны на <span className="text-gold font-semibold">{String(successData.totalBookings)}</span> занятий.
|
||||
<br />
|
||||
Стоимость: <span className="text-gold font-semibold">{String(successData.pricePerClass)} BYN</span> за занятие
|
||||
</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>
|
||||
) : error === "network" ? (
|
||||
/* Network error — fallback to Instagram DM */
|
||||
<div className="py-4 text-center">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-amber-500/10">
|
||||
<Instagram size={28} className="text-amber-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white">Что-то пошло не так</h3>
|
||||
<p className="mt-2 text-sm text-neutral-400">
|
||||
Не удалось отправить заявку. Свяжитесь с нами через Instagram — мы запишем вас!
|
||||
</p>
|
||||
<button
|
||||
onClick={openInstagramDM}
|
||||
className="mt-5 flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-purple-600 to-pink-500 py-3 text-sm font-semibold text-white transition-all hover:opacity-90 cursor-pointer"
|
||||
>
|
||||
<Instagram size={16} />
|
||||
Написать в Instagram
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setError("")}
|
||||
className="mt-2 text-xs text-neutral-500 hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
Попробовать снова
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xl font-bold text-white">{title}</h3>
|
||||
{subtitle && <p className="mt-1 text-sm text-neutral-400">{subtitle}</p>}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<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 className="relative">
|
||||
<PhoneIcon size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500" />
|
||||
<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] pl-9 pr-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 className="grid grid-cols-2 gap-2">
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500 text-xs">@</span>
|
||||
<input
|
||||
type="text"
|
||||
value={instagram}
|
||||
onChange={(e) => setInstagram(e.target.value.replace(/^@/, ""))}
|
||||
placeholder="Instagram"
|
||||
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-7 pr-3 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500 text-xs">@</span>
|
||||
<input
|
||||
type="text"
|
||||
value={telegram}
|
||||
onChange={(e) => setTelegram(e.target.value.replace(/^@/, ""))}
|
||||
placeholder="Telegram"
|
||||
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-7 pr-3 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && error !== "network" && (
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
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 disabled:opacity-50"
|
||||
>
|
||||
<Send size={15} />
|
||||
{submitting ? "Записываем..." : "Записаться"}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user