Files
blackheart-website/src/components/sections/OpenDay.tsx

269 lines
9.4 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, useMemo } from "react";
import Image from "next/image";
import { Calendar, Sparkles, User } 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";
import type { SiteContent } from "@/types";
interface OpenDayProps {
data: {
event: OpenDayEvent;
classes: OpenDayClass[];
};
popups?: SiteContent["popups"];
teamMembers?: { name: string; image: string }[];
}
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, popups, teamMembers }: OpenDayProps) {
const { event, classes } = data;
const [signup, setSignup] = useState<{ classId: number; label: string } | null>(null);
const trainerPhotos = useMemo(() => {
const map: Record<string, string> = {};
if (teamMembers) {
for (const m of teamMembers) {
if (m.image) map[m.name] = m.image;
}
}
return map;
}, [teamMembers]);
// 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="section-glow relative py-10 sm:py-14">
<div className="section-divider absolute top-0 left-0 right-0" />
<div className="mx-auto max-w-6xl px-4">
<Reveal>
<SectionHeading centered>{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>
{event.discountPrice > 0 && event.discountThreshold > 0 && (
<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}
maxParticipants={event.maxParticipants}
onSignup={setSignup}
trainerPhoto={trainerPhotos[cls.trainer]}
/>
))}
</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}
trainerPhoto={trainerPhotos[cls.trainer]}
/>
))}
</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 }}
successMessage={popups?.successMessage}
waitingMessage={popups?.waitingListText}
errorMessage={popups?.errorMessage}
instagramHint={popups?.instagramHint}
/>
)}
</section>
);
}
function ClassCard({
cls,
maxParticipants = 0,
onSignup,
trainerPhoto,
}: {
cls: OpenDayClass;
maxParticipants?: number;
onSignup: (info: { classId: number; label: string }) => void;
trainerPhoto?: string;
}) {
const label = `${cls.style} · ${cls.trainer} · ${cls.startTime}${cls.endTime}`;
if (cls.cancelled) {
return (
<div className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-3 sm:p-4 opacity-50">
<div className="flex items-center justify-between gap-3">
<div className="flex-1 min-w-0 space-y-1">
<span className="rounded-md bg-neutral-800 px-2 py-0.5 text-[11px] font-bold text-neutral-500">
{cls.startTime}{cls.endTime}
</span>
<p className="text-sm text-neutral-500 line-through">{cls.trainer} · {cls.style}</p>
</div>
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2.5 py-0.5 font-medium">
Отменено
</span>
</div>
</div>
);
}
const isFull = maxParticipants > 0 && cls.bookingCount >= maxParticipants;
return (
<div className={`rounded-xl border transition-all ${
isFull
? "border-white/[0.04] bg-white/[0.01]"
: "border-white/[0.06] bg-white/[0.02] hover:border-white/[0.12] hover:bg-white/[0.04]"
}`}>
<div className="flex items-start gap-3 p-3 sm:p-4">
{/* Trainer photo */}
<button
onClick={() => {
window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: cls.trainer }));
}}
className="relative flex items-center justify-center h-10 w-10 rounded-full overflow-hidden shrink-0 ring-1 ring-white/10 hover:ring-gold/30 transition-all cursor-pointer mt-0.5"
title={`Подробнее о ${cls.trainer}`}
>
{trainerPhoto ? (
<Image src={trainerPhoto} alt={cls.trainer} fill className="object-cover" sizes="40px" />
) : (
<div className="flex items-center justify-center h-full w-full bg-white/[0.06]">
<User size={16} className="text-white/40" />
</div>
)}
</button>
<div className="flex-1 min-w-0 space-y-2">
{/* Trainer name — clickable to bio */}
<button
onClick={() => {
window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: cls.trainer }));
}}
className="text-sm font-semibold text-white/90 hover:text-gold transition-colors cursor-pointer"
>
{cls.trainer}
</button>
{/* Time + style */}
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="rounded-md bg-gold/10 px-2 py-0.5 text-[11px] font-bold text-gold min-w-[80px] text-center">
{cls.startTime}{cls.endTime}
</span>
<span className="text-sm font-medium text-white/60">{cls.style}</span>
</div>
</div>
{/* Badges */}
<div className="flex items-center gap-1.5 flex-wrap">
{maxParticipants > 0 && (
<span className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold ${
isFull
? "bg-amber-500/15 border border-amber-500/25 text-amber-400"
: "bg-white/[0.04] border border-white/[0.08] text-white/45"
}`}>
{cls.bookingCount}/{maxParticipants} мест
</span>
)}
</div>
</div>
{/* Book button */}
<button
onClick={() => onSignup({ classId: cls.id, label })}
className={`shrink-0 self-center rounded-xl px-4 py-2 text-xs font-semibold transition-all cursor-pointer ${
isFull
? "bg-amber-500/10 border border-amber-500/25 text-amber-400 hover:bg-amber-500/20 hover:border-amber-500/40"
: "bg-gold/10 border border-gold/25 text-gold hover:bg-gold/20 hover:border-gold/40"
}`}
>
{isFull ? "Лист ожидания" : "Записаться"}
</button>
</div>
</div>
);
}