feat: schedule group view — trainer photos, today badge, click-to-bio

This commit is contained in:
2026-03-26 12:06:46 +03:00
parent f65a6ed811
commit c9cfe63837
4 changed files with 154 additions and 88 deletions

View File

@@ -44,7 +44,7 @@ export default function HomePage() {
<Team data={content.team} schedule={content.schedule.locations} /> <Team data={content.team} schedule={content.schedule.locations} />
<Classes data={content.classes} /> <Classes data={content.classes} />
<MasterClasses data={content.masterClasses} regCounts={mcRegCounts} popups={content.popups} /> <MasterClasses data={content.masterClasses} regCounts={mcRegCounts} popups={content.popups} />
<Schedule data={content.schedule} classItems={content.classes.items} /> <Schedule data={content.schedule} classItems={content.classes.items} teamMembers={content.team.members} />
<Pricing data={content.pricing} /> <Pricing data={content.pricing} />
<News data={content.news} /> <News data={content.news} />
<FAQ data={content.faq} /> <FAQ data={content.faq} />

View File

@@ -79,9 +79,10 @@ function scheduleReducer(state: ScheduleState, action: ScheduleAction): Schedule
interface ScheduleProps { interface ScheduleProps {
data: SiteContent["schedule"]; data: SiteContent["schedule"];
classItems?: { name: string; color?: string }[]; classItems?: { name: string; color?: string }[];
teamMembers?: { name: string; image: string }[];
} }
export function Schedule({ data: schedule, classItems }: ScheduleProps) { export function Schedule({ data: schedule, classItems, teamMembers }: ScheduleProps) {
const [state, dispatch] = useReducer(scheduleReducer, initialState); const [state, dispatch] = useReducer(scheduleReducer, initialState);
const { locationMode, viewMode, filterTrainer, filterType, filterStatus, filterTime, filterDaySet, bookingGroup } = state; const { locationMode, viewMode, filterTrainer, filterType, filterStatus, filterTime, filterDaySet, bookingGroup } = state;
@@ -109,6 +110,16 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
const typeDots = useMemo(() => buildTypeDots(classItems), [classItems]); const typeDots = useMemo(() => buildTypeDots(classItems), [classItems]);
const trainerPhotos = useMemo(() => {
const map: Record<string, string> = {};
if (teamMembers) {
for (const m of teamMembers) {
if (m.image) map[m.name] = m.image;
}
}
return map;
}, [teamMembers]);
// Build days: either from one location or merged from all // Build days: either from one location or merged from all
const activeDays: ScheduleDayMerged[] = useMemo(() => { const activeDays: ScheduleDayMerged[] = useMemo(() => {
if (locationMode !== "all") { if (locationMode !== "all") {
@@ -395,6 +406,7 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
setFilterTrainer={setFilterTrainerFromCard} setFilterTrainer={setFilterTrainerFromCard}
showLocation={isAllMode} showLocation={isAllMode}
onBook={(v) => dispatch({ type: "SET_BOOKING", value: v })} onBook={(v) => dispatch({ type: "SET_BOOKING", value: v })}
trainerPhotos={trainerPhotos}
/> />
</Reveal> </Reveal>
)} )}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect, useCallback } from "react";
import { SectionHeading } from "@/components/ui/SectionHeading"; import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal"; import { Reveal } from "@/components/ui/Reveal";
import { TeamCarousel } from "@/components/sections/team/TeamCarousel"; import { TeamCarousel } from "@/components/sections/team/TeamCarousel";
@@ -17,6 +17,27 @@ export function Team({ data: team, schedule }: TeamProps) {
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const [showProfile, setShowProfile] = useState(false); const [showProfile, setShowProfile] = useState(false);
const openTrainerByName = useCallback((name: string) => {
const idx = team.members.findIndex((m) => m.name === name);
if (idx >= 0) {
setActiveIndex(idx);
setShowProfile(true);
setTimeout(() => {
const el = document.getElementById("team");
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
}, 50);
}
}, [team.members]);
useEffect(() => {
function handler(e: Event) {
const name = (e as CustomEvent<string>).detail;
if (name) openTrainerByName(name);
}
window.addEventListener("openTrainerProfile", handler);
return () => window.removeEventListener("openTrainerProfile", handler);
}, [openTrainerByName]);
return ( return (
<section <section
id="team" id="team"

View File

@@ -1,6 +1,8 @@
"use client"; "use client";
import { User, MapPin } from "lucide-react"; import { useMemo } from "react";
import Image from "next/image";
import { User, MapPin, Calendar } from "lucide-react";
import { shortAddress } from "./constants"; import { shortAddress } from "./constants";
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants"; import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
@@ -123,8 +125,11 @@ interface GroupViewProps {
setFilterTrainer: (trainer: string | null) => void; setFilterTrainer: (trainer: string | null) => void;
showLocation?: boolean; showLocation?: boolean;
onBook?: (groupInfo: string) => void; onBook?: (groupInfo: string) => void;
trainerPhotos?: Record<string, string>;
} }
const WEEKDAY_NAMES = ["Воскресенье", "Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота"];
export function GroupView({ export function GroupView({
typeDots, typeDots,
filteredDays, filteredDays,
@@ -134,10 +139,13 @@ export function GroupView({
setFilterTrainer, setFilterTrainer,
showLocation, showLocation,
onBook, onBook,
trainerPhotos = {},
}: GroupViewProps) { }: GroupViewProps) {
const groups = buildGroups(filteredDays); const groups = buildGroups(filteredDays);
const byTrainer = groupByTrainer(groups); const byTrainer = groupByTrainer(groups);
const todayName = useMemo(() => WEEKDAY_NAMES[new Date().getDay()], []);
if (groups.length === 0) { if (groups.length === 0) {
return ( return (
<div className="py-12 text-center text-sm text-neutral-400 dark:text-white/30"> <div className="py-12 text-center text-sm text-neutral-400 dark:text-white/30">
@@ -147,119 +155,144 @@ export function GroupView({
} }
return ( return (
<div className="mt-8 space-y-3 px-4 sm:px-6 lg:px-8 xl:px-6 max-w-4xl mx-auto"> <div className="mt-8 space-y-4 px-4 sm:px-6 lg:px-8 xl:px-6 max-w-4xl mx-auto">
{Array.from(byTrainer.entries()).map(([trainer, trainerGroups]) => { {Array.from(byTrainer.entries()).map(([trainer, trainerGroups]) => {
const byType = groupByType(trainerGroups); const byType = groupByType(trainerGroups);
const totalGroups = trainerGroups.length; const isActive = filterTrainer === trainer;
return ( return (
<div <div key={trainer} className="space-y-2">
key={trainer}
className="rounded-xl border border-neutral-200 bg-white overflow-hidden dark:border-white/[0.06] dark:bg-[#0a0a0a]"
>
{/* Trainer header */} {/* Trainer header */}
<div className="flex items-center gap-2.5">
{/* Photo — clicks to open trainer bio */}
<button <button
onClick={() => setFilterTrainer(filterTrainer === trainer ? null : trainer)} onClick={() => {
className={`flex items-center gap-2 w-full px-4 py-2.5 text-left transition-colors cursor-pointer ${ window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: trainer }));
filterTrainer === trainer }}
? "bg-gold/10 dark:bg-gold/5" className={`relative flex items-center justify-center h-9 w-9 rounded-full overflow-hidden transition-all cursor-pointer ${
: "bg-neutral-50 dark:bg-white/[0.02]" isActive ? "ring-2 ring-gold/50" : "ring-1 ring-white/10 hover:ring-gold/30"
}`} }`}
title={`Подробнее о ${trainer}`}
> >
<User size={14} className={filterTrainer === trainer ? "text-gold" : "text-neutral-400 dark:text-white/40"} /> {trainerPhotos[trainer] ? (
<span className={`text-sm font-semibold ${ <Image
filterTrainer === trainer ? "text-gold" : "text-neutral-800 dark:text-white/80" src={trainerPhotos[trainer]}
alt={trainer}
fill
className="object-cover"
sizes="36px"
/>
) : (
<div className={`flex items-center justify-center h-full w-full ${isActive ? "bg-gold/20" : "bg-white/[0.06]"}`}>
<User size={14} className={isActive ? "text-gold" : "text-white/40"} />
</div>
)}
</button>
{/* Name — clicks to filter */}
<button
onClick={() => setFilterTrainer(isActive ? null : trainer)}
className="cursor-pointer group"
>
<span className={`text-base font-semibold transition-colors ${
isActive ? "text-gold" : "text-white/90 group-hover:text-white"
}`}> }`}>
{trainer} {trainer}
</span> </span>
<span className="ml-auto text-[10px] text-neutral-400 dark:text-white/25">
{totalGroups === 1 ? "1 группа" : `${totalGroups} групп${totalGroups < 5 ? "ы" : ""}`}
</span>
</button> </button>
</div>
{/* Type → Groups */} {/* Groups */}
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]"> <div className="space-y-2 pl-2">
{byType.map(({ type, groups: typeGroups }) => { {byType.map(({ type, groups: typeGroups }) => {
const dotColor = typeDots[type] ?? "bg-white/30"; const dotColor = typeDots[type] ?? "bg-white/30";
return ( return typeGroups.map((group, gi) => {
<div key={type} className="px-4 py-2.5">
{/* Class type row */}
<button
onClick={() => setFilterType(filterType === type ? null : type)}
className="flex items-center gap-1.5 cursor-pointer"
>
<span className={`h-2 w-2 shrink-0 rounded-full ${dotColor}`} />
<span className="text-sm font-medium text-neutral-800 dark:text-white/80">
{type}
</span>
</button>
{/* Group rows under this type */}
<div className="mt-1.5 space-y-1 pl-3.5">
{typeGroups.map((group, gi) => {
const merged = mergeSlotsByDay(group.slots); const merged = mergeSlotsByDay(group.slots);
const hasToday = group.slots.some(s => s.day === todayName);
return ( return (
<div <div
key={gi} key={`${type}-${gi}`}
className="flex items-center gap-2 flex-wrap" className={`rounded-xl border transition-all ${
hasToday
? "border-gold/20 bg-gold/[0.03] hover:border-gold/30 hover:bg-gold/[0.05]"
: "border-white/[0.06] bg-white/[0.02] hover:border-white/[0.12] hover:bg-white/[0.04]"
}`}
> >
{/* Datetimes */} <div className="flex items-start gap-3 p-3 sm:p-4">
<div className="flex items-center gap-0.5 flex-wrap"> {/* Left: type dot + info */}
{merged.map((m, i) => ( <div className="flex-1 min-w-0 space-y-2">
<span key={i} className="inline-flex items-center gap-1 text-xs"> {/* Type name */}
{i > 0 && <span className="text-neutral-300 dark:text-white/15 mx-0.5">·</span>} <div className="flex items-center gap-2 flex-wrap">
<span className="rounded bg-gold/10 px-1.5 py-0.5 text-[10px] font-bold text-gold-dark dark:text-gold"> <button
{m.days.join(", ")} onClick={() => setFilterType(filterType === type ? null : type)}
</span> className="flex items-center gap-2 cursor-pointer"
<span className="font-medium tabular-nums text-neutral-500 dark:text-white/45"> >
{m.times.join(", ")} <span className={`h-2.5 w-2.5 shrink-0 rounded-full ${dotColor}`} />
</span> <span className="text-sm font-semibold text-white/90">{type}</span>
</span> </button>
))}
</div>
{/* Badges */}
{group.level && ( {group.level && (
<span className="rounded-full bg-rose-500/15 border border-rose-500/25 px-2 py-px text-[10px] font-semibold text-rose-600 dark:text-rose-400"> <span className="rounded-full bg-rose-500/15 border border-rose-500/25 px-2 py-px text-[10px] font-semibold text-rose-400">
{group.level} {group.level}
</span> </span>
)} )}
{hasToday && (
<span className="inline-flex items-center gap-1 rounded-full bg-gold/15 border border-gold/25 px-2 py-px text-[10px] font-semibold text-gold">
<Calendar size={9} />
Сегодня
</span>
)}
</div>
{/* Schedule rows */}
<div className="space-y-1">
{merged.map((m, i) => (
<div key={i} className="flex items-center gap-2">
<span className="rounded-md bg-gold/10 px-2 py-0.5 text-[11px] font-bold text-gold min-w-[52px] text-center">
{m.days.join(", ")}
</span>
<span className="text-sm font-medium tabular-nums text-white/60">
{m.times.join(", ")}
</span>
</div>
))}
</div>
{/* Bottom badges */}
<div className="flex items-center gap-1.5 flex-wrap">
{group.hasSlots && ( {group.hasSlots && (
<span className="rounded-full bg-emerald-500/15 border border-emerald-500/25 px-2 py-px text-[10px] font-semibold text-emerald-600 dark:text-emerald-400"> <span className="rounded-full bg-emerald-500/15 border border-emerald-500/25 px-2.5 py-0.5 text-[10px] font-semibold text-emerald-400">
есть места есть места
</span> </span>
)} )}
{group.recruiting && ( {group.recruiting && (
<span className="rounded-full bg-sky-500/15 border border-sky-500/25 px-2 py-px text-[10px] font-semibold text-sky-600 dark:text-sky-400"> <span className="rounded-full bg-sky-500/15 border border-sky-500/25 px-2.5 py-0.5 text-[10px] font-semibold text-sky-400">
набор набор
</span> </span>
)} )}
{/* Location */}
{showLocation && group.location && ( {showLocation && group.location && (
<span className="flex items-center gap-1 rounded-full bg-white/5 border border-white/10 px-2 py-px text-[10px] font-medium text-neutral-300 dark:text-white/50"> <span className="flex items-center gap-1 rounded-full bg-white/[0.04] border border-white/[0.08] px-2.5 py-0.5 text-[10px] font-medium text-white/45">
<MapPin size={9} /> <MapPin size={9} />
{shortAddress(group.locationAddress || group.location)} {shortAddress(group.locationAddress || group.location)}
</span> </span>
)} )}
</div>
</div>
{/* Book button */} {/* Right: book button */}
{onBook && ( {onBook && (
<button <button
onClick={() => onBook(`${group.type}, ${group.trainer}, ${group.slots.map(s => s.dayShort).join("/")} ${group.slots[0]?.time ?? ""}`)} onClick={() => onBook(`${group.type}, ${group.trainer}, ${group.slots.map(s => s.dayShort).join("/")} ${group.slots[0]?.time ?? ""}`)}
className="ml-auto rounded-lg bg-gold/10 border border-gold/20 px-3 py-1 text-[11px] font-semibold text-gold hover:bg-gold/20 transition-colors cursor-pointer shrink-0" className="shrink-0 self-center rounded-xl bg-gold/10 border border-gold/25 px-4 py-2 text-xs font-semibold text-gold hover:bg-gold/20 hover:border-gold/40 transition-all cursor-pointer"
> >
Записаться Записаться
</button> </button>
)} )}
</div> </div>
);
})}
</div>
</div> </div>
); );
});
})} })}
</div> </div>
</div> </div>