Files
blackheart-website/src/components/sections/MasterClasses.tsx
T
diana.dolgolyova 0e626451e7 feat: comprehensive light theme support across entire site
- CSS foundation: theme-aware scrollbars, section glows, glass cards with
  gold shadows, stronger animated borders and glow effects for light mode
- Hero: consistent dark-video treatment for both themes, brighter gold
  gradient text, glowing CTA button
- Gradient text: auto-switch to warm gold tones on light backgrounds via
  html:not(.dark) selector
- Team profile: inverted ambient photo bg with white overlay for light,
  dark text/borders, gold-dark labels for contrast
- All sections: text-neutral-500→600 upgrades for WCAG AA contrast,
  gold shadow accents on cards (About, Pricing, FAQ, DayCard, News)
- Admin: replaced hardcoded #c9a96e with theme tokens, fixed select
  options, array editor borders, booking badges contrast
- Header: white text on transparent hero, dark text after scroll
- UI components: BackToTop, FloatingHearts, ShowcaseLayout tabs,
  SignupModal, NewsModal, GroupCard adapted for light backgrounds
- Updated CLAUDE.md to reflect dual theme support
2026-04-10 21:30:56 +03:00

476 lines
18 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, useEffect } from "react";
import Image from "next/image";
import { Calendar, Clock, User, MapPin, Instagram, X } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import { SignupModal } from "@/components/ui/SignupModal";
import { useFocusTrap } from "@/hooks/useFocusTrap";
import type { SiteContent, MasterClassItem, MasterClassSlot, ScheduleLocation } from "@/types";
import { formatMarkup } from "@/lib/markup";
interface MasterClassesProps {
data: SiteContent["masterClasses"];
regCounts?: Record<string, number>;
popups?: SiteContent["popups"];
locations?: ScheduleLocation[];
}
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 ?? [];
// No dates = "coming soon", still show it
if (slots.length === 0) return true;
// Has dates with actual values
const validSlots = slots.filter(s => s.date);
if (validSlots.length === 0) return true;
const earliestSlot = validSlots.reduce((min, s) => s.date < min.date ? s : min, validSlots[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 formatFirstDate(slots: MasterClassSlot[]): string {
const validSlots = (slots ?? []).filter(s => s.date);
if (validSlots.length === 0) return "Скоро";
const first = validSlots.sort((a, b) => a.date.localeCompare(b.date))[0];
const d = parseDate(first.date);
const day = d.getDate();
const month = MONTHS_RU[d.getMonth()];
const weekday = WEEKDAYS_RU[d.getDay()];
const time = first.startTime ? `, ${first.startTime}` : "";
return `${day} ${month} (${weekday})${time}`;
}
function MasterClassDetail({
item,
locations,
onClose,
onSignup,
}: {
item: MasterClassItem;
locations?: ScheduleLocation[];
onClose: () => void;
onSignup: () => void;
}) {
const slots = item.slots ?? [];
const duration = slots[0] ? calcDuration(slots[0]) : "";
const locAddress = locations?.find(l => l.name === item.location)?.address;
const focusTrapRef = useFocusTrap<HTMLDivElement>(true);
useEffect(() => {
document.body.style.overflow = "hidden";
function handleKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
window.addEventListener("keydown", handleKey);
return () => { document.body.style.overflow = ""; window.removeEventListener("keydown", handleKey); };
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm" onClick={onClose}>
<div
ref={focusTrapRef}
role="dialog"
aria-modal="true"
aria-label={item.title}
className="relative w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl border border-neutral-200 bg-white dark:border-white/10 dark:bg-neutral-950 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Content */}
<div className="p-6 space-y-4">
{/* Top row: tags + price + close aligned */}
<div className="flex items-center gap-2">
<span className="rounded-full border border-gold/40 bg-gold/10 px-3 py-0.5 text-xs font-semibold uppercase tracking-wider text-gold">
{item.style}
</span>
{duration && (
<span className="flex items-center gap-1 text-xs text-neutral-500 dark:text-white/50">
<Clock size={11} />
{duration}
</span>
)}
<span className="flex-1" />
{item.cost && (
<span className="text-lg font-bold text-gold">
{item.cost}{!item.cost.includes("BYN") ? " BYN" : ""}
</span>
)}
<button
onClick={onClose}
aria-label="Закрыть"
className="h-11 w-11 flex items-center justify-center rounded-full text-neutral-400 hover:text-neutral-900 hover:bg-neutral-100 dark:text-white/50 dark:hover:text-white dark:hover:bg-white/10 transition-colors shrink-0 -mr-2"
>
<X size={18} />
</button>
</div>
{/* Title */}
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white">{item.title}</h2>
{/* Trainer */}
<button
onClick={() => window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: item.trainer.split(" · ")[0] }))}
className="flex items-center gap-2 text-sm text-neutral-700 hover:text-gold dark:text-white/80 transition-colors"
>
<User size={14} />
{item.trainer}
</button>
{/* Description */}
{item.description && (
<div className="text-sm leading-relaxed text-neutral-600 dark:text-white/60">
{formatMarkup(item.description)}
</div>
)}
{/* All dates */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-neutral-400 dark:text-white/40 uppercase tracking-wider">Даты</h3>
{slots.length === 0 ? (
<p className="text-sm text-gold">Скоро дата уточняется</p>
) : (
<div className="space-y-1.5">
{slots.filter(s => s.date).sort((a, b) => a.date.localeCompare(b.date)).map((slot, i) => {
const d = parseDate(slot.date);
return (
<div key={i} className="flex items-center gap-3 text-sm">
<Calendar size={13} className="shrink-0 text-gold/60" />
<span className="text-neutral-700 dark:text-white/80">
{d.getDate()} {MONTHS_RU[d.getMonth()]} ({WEEKDAYS_RU[d.getDay()]})
</span>
{slot.startTime && (
<span className="text-neutral-500 dark:text-white/50">
{slot.startTime}{slot.endTime}
</span>
)}
</div>
);
})}
</div>
)}
</div>
{/* Location */}
{item.location && (
<div className="flex items-center gap-2 text-sm text-neutral-500 dark:text-white/60">
<MapPin size={13} className="text-gold/60" />
<span>{item.location}{locAddress ? ` · ${locAddress}` : ""}</span>
</div>
)}
{/* Instagram */}
{item.instagramUrl && (
<a
href={item.instagramUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm text-neutral-400 hover:text-gold dark:text-white/50 transition-colors"
>
<Instagram size={14} />
Подробнее в Instagram
</a>
)}
{/* Signup button */}
<button
onClick={(e) => { e.stopPropagation(); onSignup(); }}
className="w-full rounded-xl bg-gold py-3.5 text-sm font-bold uppercase tracking-wide text-black hover:bg-gold-light transition-colors"
>
Записаться
</button>
</div>
</div>
</div>
);
}
function MasterClassCard({
item,
currentRegs,
onSignup,
onDetail,
locations,
}: {
item: MasterClassItem;
currentRegs: number;
onSignup: () => void;
onDetail: () => void;
locations?: ScheduleLocation[];
}) {
const slots = item.slots ?? [];
const duration = slots[0] ? calcDuration(slots[0]) : "";
const firstDate = formatFirstDate(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 cursor-pointer"
onClick={onDetail}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onDetail(); } }}
role="button"
tabIndex={0}
>
{/* 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"
style={{
objectPosition: `${item.imageFocalX ?? 50}% ${item.imageFocalY ?? 50}%`,
transform: item.imageZoom && item.imageZoom > 1 ? `scale(${item.imageZoom})` : undefined,
}}
/>
{/* No overlay — text shadow handles readability */}
</>
) : (
<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 m-3 sm:m-4 flex flex-col rounded-2xl bg-black/60 backdrop-blur-md border border-white/10 p-4 sm:p-5">
{/* 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-xs 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-xs 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 */}
<button
onClick={(e) => { e.stopPropagation(); window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: item.trainer.split(" · ")[0] })); }}
className="mt-2 flex items-center gap-2 text-sm text-white/90 hover:text-gold transition-colors cursor-pointer"
>
<User size={13} className="shrink-0" />
<span>{item.trainer}</span>
</button>
{/* 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/90 mb-4">
<div className="flex items-center gap-2">
<Calendar size={13} className="shrink-0 text-gold/70" />
<span>{firstDate}{slots.length > 1 && <span className="text-white/50"> +{slots.length - 1}</span>}</span>
</div>
{item.location && (
<div className="flex items-center gap-2">
<MapPin size={13} className="shrink-0 text-gold/70" />
<span>
{item.location}
{(() => {
const loc = locations?.find(l => l.name === item.location);
return loc?.address ? ` · ${loc.address}` : "";
})()}
</span>
</div>
)}
</div>
{/* Price + Actions */}
<div className="flex items-center gap-3">
<button
onClick={onSignup}
aria-label={`Записаться на ${item.title}`}
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>
</div>
{/* Price tag — top right corner */}
{item.cost && (
<div className="absolute top-3 right-3 z-10">
<span className="inline-block rounded-full bg-gold/90 px-3 py-1 text-sm font-bold text-black shadow-lg">
{item.cost}{!item.cost.includes("BYN") ? " BYN" : ""}
</span>
</div>
)}
</div>
);
}
export function MasterClasses({ data, regCounts = {}, popups, locations }: MasterClassesProps) {
if (!data?.items?.length) return null;
const [signupTitle, setSignupTitle] = useState<string | null>(null);
const [detailItem, setDetailItem] = useState<MasterClassItem | null>(null);
const upcoming = useMemo(() => {
return data.items
.filter((item) => item.title && isUpcoming(item))
.sort((a, b) => {
const aFirst = parseDate((a.slots ?? [])[0]?.date ?? "9999");
const bFirst = parseDate((b.slots ?? [])[0]?.date ?? "9999");
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, idx) => (
<MasterClassCard
key={`${item.title}-${idx}`}
item={item}
currentRegs={regCounts[item.title] ?? 0}
onSignup={() => setSignupTitle(item.title)}
onDetail={() => setDetailItem(item)}
locations={locations}
/>
))}
</div>
)}
</Reveal>
</div>
{/* Detail popup */}
{detailItem && (
<MasterClassDetail
item={detailItem}
locations={locations}
onClose={() => setDetailItem(null)}
onSignup={() => { setDetailItem(null); setSignupTitle(detailItem.title); }}
/>
)}
<SignupModal
open={signupTitle !== null}
onClose={() => setSignupTitle(null)}
subtitle={signupTitle ?? ""}
endpoint="/api/master-class-register"
extraBody={{ masterClassTitle: signupTitle }}
successMessage={popups?.successMessage}
waitingMessage={popups?.waitingListText}
errorMessage={popups?.errorMessage}
instagramHint={popups?.instagramHint}
/>
</section>
);
}