Files
blackheart-website/src/components/sections/MasterClasses.tsx
diana.dolgolyova 18c11d0611 fix: MC series uses earliest slot date for registration cutoff
Multi-session master classes are a series — once the first session
passes, the group has started and registration closes. Changed all
MC date logic from "latest slot" / "any future slot" to "earliest slot":

- DashboardSummary: upcoming = earliest slot >= today
- McRegistrationsTab: archive = earliest slot < today
- AddBookingModal: only show MCs where earliest slot >= today
- Public MasterClasses: isUpcoming checks earliest slot
2026-03-24 17:35:31 +03:00

260 lines
9.2 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, Clock, User, MapPin, Instagram } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import { SignupModal } from "@/components/ui/SignupModal";
import type { SiteContent, MasterClassItem, MasterClassSlot } from "@/types";
interface MasterClassesProps {
data: SiteContent["masterClasses"];
}
const MONTHS_RU = [
"января", "февраля", "марта", "апреля", "мая", "июня",
"июля", "августа", "сентября", "октября", "ноября", "декабря",
];
const WEEKDAYS_RU = [
"воскресенье", "понедельник", "вторник", "среда",
"четверг", "пятница", "суббота",
];
function parseDate(iso: string) {
return new Date(iso + "T00:00:00");
}
function formatSlots(slots: MasterClassSlot[]): string {
if (slots.length === 0) return "";
const sorted = [...slots].sort(
(a, b) => parseDate(a.date).getTime() - parseDate(b.date).getTime()
);
const dates = sorted.map((s) => parseDate(s.date)).filter((d) => !isNaN(d.getTime()));
if (dates.length === 0) return "";
const timePart = sorted[0].startTime
? `, ${sorted[0].startTime}${sorted[0].endTime}`
: "";
if (dates.length === 1) {
const d = dates[0];
return `${d.getDate()} ${MONTHS_RU[d.getMonth()]} (${WEEKDAYS_RU[d.getDay()]})${timePart}`;
}
const sameMonth = dates.every((d) => d.getMonth() === dates[0].getMonth());
const sameWeekday = dates.every((d) => d.getDay() === dates[0].getDay());
if (sameMonth) {
const days = dates.map((d) => d.getDate()).join(" и ");
const weekdayHint = sameWeekday ? ` (${WEEKDAYS_RU[dates[0].getDay()]})` : "";
return `${days} ${MONTHS_RU[dates[0].getMonth()]}${weekdayHint}${timePart}`;
}
const parts = dates.map((d) => `${d.getDate()} ${MONTHS_RU[d.getMonth()]}`);
return parts.join(", ") + timePart;
}
function calcDuration(slot: MasterClassSlot): string {
if (!slot.startTime || !slot.endTime) return "";
const [sh, sm] = slot.startTime.split(":").map(Number);
const [eh, em] = slot.endTime.split(":").map(Number);
const mins = (eh * 60 + em) - (sh * 60 + sm);
if (mins <= 0) return "";
const h = Math.floor(mins / 60);
const m = mins % 60;
if (h > 0 && m > 0) return `${h} ч ${m} мин`;
if (h > 0) return h === 1 ? "1 час" : h < 5 ? `${h} часа` : `${h} часов`;
return `${m} мин`;
}
function isUpcoming(item: MasterClassItem): boolean {
const now = new Date();
const slots = item.slots ?? [];
if (slots.length === 0) return false;
// Series MC: check earliest slot — if first session passed, group already started
const earliestSlot = slots.reduce((min, s) => s.date < min.date ? s : min, slots[0]);
const d = parseDate(earliestSlot.date);
if (earliestSlot.startTime) {
const [h, m] = earliestSlot.startTime.split(":").map(Number);
d.setHours(h, m, 0, 0);
} else {
d.setHours(23, 59, 59, 999);
}
return d > now;
}
function MasterClassCard({
item,
onSignup,
}: {
item: MasterClassItem;
onSignup: () => void;
}) {
const duration = item.slots[0] ? calcDuration(item.slots[0]) : "";
const slotsDisplay = formatSlots(item.slots);
return (
<div className="group relative flex w-full max-w-sm flex-col overflow-hidden rounded-2xl bg-black">
{/* Full-bleed image or placeholder */}
<div className="relative aspect-[3/4] sm:aspect-[2/3] w-full overflow-hidden">
{item.image ? (
<>
<Image
src={item.image}
alt={item.title}
fill
loading="lazy"
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
className="object-cover transition-transform duration-700 group-hover:scale-110"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/20 to-transparent opacity-80 transition-opacity duration-500 group-hover:opacity-90" />
</>
) : (
<div className="absolute inset-0 bg-gradient-to-b from-neutral-800 to-black" />
)}
</div>
{/* Content overlay at bottom */}
<div className="absolute inset-x-0 bottom-0 flex flex-col p-5 sm:p-6">
{/* Tags row */}
<div className="flex flex-wrap items-center gap-2 mb-3">
<span className="inline-flex items-center gap-1 rounded-full border border-gold/40 bg-black/40 px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-gold backdrop-blur-md">
{item.style}
</span>
{duration && (
<span className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2.5 py-0.5 text-[11px] text-white/60 backdrop-blur-md">
<Clock size={10} />
{duration}
</span>
)}
</div>
{/* Title */}
<h3 className="text-xl sm:text-2xl font-bold text-white leading-tight tracking-tight">
{item.title}
</h3>
{/* Trainer */}
<div className="mt-2 flex items-center gap-2 text-sm text-white/50">
<User size={13} className="shrink-0" />
<span>{item.trainer}</span>
</div>
{/* Divider */}
<div className="mt-4 mb-4 h-px bg-gradient-to-r from-gold/40 via-gold/20 to-transparent" />
{/* Date + Location */}
<div className="flex flex-col gap-1.5 text-sm text-white/60 mb-4">
<div className="flex items-center gap-2">
<Calendar size={13} className="shrink-0 text-gold/70" />
<span>{slotsDisplay}</span>
</div>
{item.location && (
<div className="flex items-center gap-2">
<MapPin size={13} className="shrink-0 text-gold/70" />
<span>{item.location}</span>
</div>
)}
</div>
{/* Price + Actions */}
<div className="flex items-center gap-3">
<button
onClick={onSignup}
className="flex-1 rounded-xl bg-gold py-3 text-sm font-bold text-black uppercase tracking-wide transition-all hover:bg-gold-light hover:shadow-lg hover:shadow-gold/25 cursor-pointer"
>
Записаться
</button>
{item.instagramUrl && (
<button
onClick={() =>
window.open(item.instagramUrl, "_blank", "noopener,noreferrer")
}
aria-label={`Instagram ${item.trainer}`}
className="flex h-[46px] w-[46px] items-center justify-center rounded-xl border border-white/10 text-white/40 transition-all hover:border-gold/30 hover:text-gold cursor-pointer"
>
<Instagram size={18} />
</button>
)}
</div>
{/* Price floating tag */}
<div className="absolute top-0 right-0 -translate-y-full mr-5 sm:mr-6 mb-2">
<span className="inline-block rounded-full bg-white/10 px-3 py-1 text-sm font-bold text-white backdrop-blur-md">
{item.cost}
</span>
</div>
</div>
</div>
);
}
export function MasterClasses({ data }: MasterClassesProps) {
const [signupTitle, setSignupTitle] = useState<string | null>(null);
const upcoming = useMemo(() => {
return data.items
.filter(isUpcoming)
.sort((a, b) => {
const aFirst = parseDate(a.slots[0]?.date ?? "");
const bFirst = parseDate(b.slots[0]?.date ?? "");
return aFirst.getTime() - bFirst.getTime();
});
}, [data.items]);
return (
<section
id="master-classes"
className="section-glow relative section-padding overflow-hidden"
>
<div className="section-divider absolute top-0 left-0 right-0" />
<div className="section-container">
<Reveal>
<SectionHeading centered>{data.title}</SectionHeading>
</Reveal>
<Reveal>
{upcoming.length === 0 ? (
<div className="mt-10 py-12 text-center">
<p className="text-sm text-neutral-500 dark:text-white/40">
Следите за анонсами мастер-классов в нашем{" "}
<a
href="https://instagram.com/blackheartdancehouse/"
target="_blank"
rel="noopener noreferrer"
className="text-gold hover:text-gold-light underline underline-offset-2 transition-colors"
>
Instagram
</a>
</p>
</div>
) : (
<div className="mx-auto mt-10 flex max-w-5xl flex-wrap justify-center gap-5">
{upcoming.map((item) => (
<MasterClassCard
key={item.title}
item={item}
onSignup={() => setSignupTitle(item.title)}
/>
))}
</div>
)}
</Reveal>
</div>
<SignupModal
open={signupTitle !== null}
onClose={() => setSignupTitle(null)}
subtitle={signupTitle ?? ""}
endpoint="/api/master-class-register"
extraBody={{ masterClassTitle: signupTitle }}
successMessage={data.successMessage}
/>
</section>
);
}