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