Files
blackheart-website/src/components/sections/OpenDay.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

292 lines
11 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 { useTrainerPhotos } from "@/hooks/useTrainerPhotos";
import Image from "next/image";
import { Calendar, Sparkles, User, MapPin } 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, ScheduleLocation } from "@/types";
import { formatMarkup } from "@/lib/markup";
interface OpenDayProps {
data: {
event: OpenDayEvent;
classes: OpenDayClass[];
};
popups?: SiteContent["popups"];
teamMembers?: { name: string; image: string }[];
locations?: ScheduleLocation[];
}
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, locations }: OpenDayProps) {
const { event, classes } = data;
const [signup, setSignup] = useState<{ classId: number; label: string } | null>(null);
const trainerPhotos = useTrainerPhotos(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);
// Map hall name → address from schedule locations
const hallAddress = useMemo(() => {
const map: Record<string, string> = {};
if (locations) {
for (const loc of locations) {
if (loc.name && loc.address) map[loc.name] = loc.address;
}
}
return map;
}, [locations]);
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} />
<time dateTime={event.date}>{formatDateRu(event.date)}</time>
</div>
</div>
</Reveal>
{/* Pricing info */}
<Reveal>
<div className="mt-6 text-center space-y-1">
<p className="text-lg font-semibold text-neutral-900 dark:text-white">
{event.pricePerClass} BYN <span className="text-neutral-500 dark: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>
<div className="mt-4 text-center text-sm text-neutral-500 dark:text-neutral-400 max-w-2xl mx-auto">
{formatMarkup(event.description)}
</div>
</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">
<div className="text-center mb-4">
<h3 className="text-base font-semibold text-neutral-900 dark:text-white">{halls[0]}</h3>
{hallAddress[halls[0]] && (
<p className="text-sm text-gold/70 mt-0.5 flex items-center justify-center gap-1.5">
<MapPin size={13} />
{hallAddress[halls[0]]}
</p>
)}
</div>
{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>
<div className="text-center mb-4 rounded-lg bg-neutral-50 border border-neutral-200 py-3 px-4 dark:bg-white/[0.03] dark:border-white/[0.06]">
<h3 className="text-base font-semibold text-neutral-900 dark:text-white">{hall}</h3>
{hallAddress[hall] && (
<p className="text-sm text-gold/70 mt-0.5 flex items-center justify-center gap-1.5">
<MapPin size={13} />
{hallAddress[hall]}
</p>
)}
</div>
<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-neutral-200 bg-neutral-50 p-3 sm:p-4 opacity-50 dark:border-white/[0.06] dark:bg-white/[0.02]">
<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-200 px-2 py-0.5 text-xs font-bold text-neutral-500 dark:bg-neutral-800">
<time dateTime={`${cls.startTime}-${cls.endTime}`}>{cls.startTime}{cls.endTime}</time>
</span>
<p className="text-sm text-neutral-500"><del>{cls.trainer} · {cls.style}</del></p>
</div>
<span className="text-xs text-neutral-500 bg-neutral-200 rounded-full px-2.5 py-0.5 font-medium dark:bg-neutral-800">
Отменено
</span>
</div>
</div>
);
}
const isFull = maxParticipants > 0 && cls.bookingCount >= maxParticipants;
return (
<div className={`rounded-xl border transition-all ${
isFull
? "border-neutral-200 bg-neutral-50/50 dark:border-white/[0.04] dark:bg-white/[0.01]"
: "border-neutral-200 bg-white hover:border-neutral-300 hover:bg-neutral-50 dark:border-white/[0.06] dark:bg-white/[0.02] dark:hover:border-white/[0.12] dark: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 }));
}}
aria-label={`Профиль тренера: ${cls.trainer}`}
className="relative flex items-center justify-center h-11 w-11 rounded-full overflow-hidden shrink-0 ring-1 ring-neutral-200 hover:ring-gold/30 transition-all cursor-pointer mt-0.5 dark:ring-white/10"
title={`Подробнее о ${cls.trainer}`}
>
{trainerPhoto ? (
<Image src={trainerPhoto} alt={cls.trainer} fill className="object-cover" sizes="44px" />
) : (
<div className="flex items-center justify-center h-full w-full bg-neutral-100 dark:bg-white/[0.06]">
<User size={16} className="text-neutral-400 dark: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-neutral-900 hover:text-gold transition-colors cursor-pointer dark:text-white/90"
>
{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-xs font-bold text-gold min-w-[80px] text-center">
<time dateTime={`${cls.startTime}-${cls.endTime}`}>{cls.startTime}{cls.endTime}</time>
</span>
<span className="text-sm font-medium text-neutral-500 dark: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-xs font-semibold ${
isFull
? "bg-amber-500/15 border border-amber-500/25 text-amber-500 dark:text-amber-400"
: "bg-neutral-100 border border-neutral-200 text-neutral-600 dark:bg-white/[0.04] dark:border-white/[0.08] dark: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.5 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 dark:bg-gold/5 dark:border-gold/15"
}`}
>
{isFull ? "Лист ожидания" : "Записаться"}
</button>
</div>
</div>
);
}