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

287 lines
10 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"];
regCounts?: Record<string, number>;
}
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,
currentRegs,
onSignup,
}: {
item: MasterClassItem;
currentRegs: number;
onSignup: () => void;
}) {
const duration = item.slots[0] ? calcDuration(item.slots[0]) : "";
const slotsDisplay = formatSlots(item.slots);
const maxP = item.maxParticipants ?? 0;
const isFull = maxP > 0 && currentRegs >= maxP;
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>
{/* Spots info */}
{(maxP > 0 || (item.minParticipants && item.minParticipants > 0)) && (
<div className="mb-3 flex items-center gap-3 text-[11px]">
{maxP > 0 && (
<span className={isFull ? "text-amber-400" : "text-white/40"}>
{currentRegs}/{maxP} мест
</span>
)}
{item.minParticipants && item.minParticipants > 0 && currentRegs < item.minParticipants && (
<span className="text-red-400/70">
мин. {item.minParticipants} для проведения
</span>
)}
</div>
)}
{/* Price + Actions */}
<div className="flex items-center gap-3">
<button
onClick={onSignup}
className={`flex-1 rounded-xl py-3 text-sm font-bold uppercase tracking-wide transition-all cursor-pointer ${
isFull
? "bg-amber-500/15 text-amber-400 hover:bg-amber-500/25"
: "bg-gold text-black hover:bg-gold-light hover:shadow-lg hover:shadow-gold/25"
}`}
>
{isFull ? "Лист ожидания" : "Записаться"}
</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, regCounts = {} }: 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}
currentRegs={regCounts[item.title] ?? 0}
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}
waitingMessage={data.waitingListText}
/>
</section>
);
}