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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user