0e626451e7
- 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
476 lines
18 KiB
TypeScript
476 lines
18 KiB
TypeScript
"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>
|
||
);
|
||
}
|