feat: schedule group view — trainer photos, today badge, click-to-bio
This commit is contained in:
@@ -44,7 +44,7 @@ export default function HomePage() {
|
||||
<Team data={content.team} schedule={content.schedule.locations} />
|
||||
<Classes data={content.classes} />
|
||||
<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} />
|
||||
<News data={content.news} />
|
||||
<FAQ data={content.faq} />
|
||||
|
||||
@@ -79,9 +79,10 @@ function scheduleReducer(state: ScheduleState, action: ScheduleAction): Schedule
|
||||
interface ScheduleProps {
|
||||
data: SiteContent["schedule"];
|
||||
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 { 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 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
|
||||
const activeDays: ScheduleDayMerged[] = useMemo(() => {
|
||||
if (locationMode !== "all") {
|
||||
@@ -395,6 +406,7 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
||||
setFilterTrainer={setFilterTrainerFromCard}
|
||||
showLocation={isAllMode}
|
||||
onBook={(v) => dispatch({ type: "SET_BOOKING", value: v })}
|
||||
trainerPhotos={trainerPhotos}
|
||||
/>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
import { Reveal } from "@/components/ui/Reveal";
|
||||
import { TeamCarousel } from "@/components/sections/team/TeamCarousel";
|
||||
@@ -17,6 +17,27 @@ export function Team({ data: team, schedule }: TeamProps) {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
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 (
|
||||
<section
|
||||
id="team"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"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 type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
|
||||
|
||||
@@ -123,8 +125,11 @@ interface GroupViewProps {
|
||||
setFilterTrainer: (trainer: string | null) => void;
|
||||
showLocation?: boolean;
|
||||
onBook?: (groupInfo: string) => void;
|
||||
trainerPhotos?: Record<string, string>;
|
||||
}
|
||||
|
||||
const WEEKDAY_NAMES = ["Воскресенье", "Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота"];
|
||||
|
||||
export function GroupView({
|
||||
typeDots,
|
||||
filteredDays,
|
||||
@@ -134,10 +139,13 @@ export function GroupView({
|
||||
setFilterTrainer,
|
||||
showLocation,
|
||||
onBook,
|
||||
trainerPhotos = {},
|
||||
}: GroupViewProps) {
|
||||
const groups = buildGroups(filteredDays);
|
||||
const byTrainer = groupByTrainer(groups);
|
||||
|
||||
const todayName = useMemo(() => WEEKDAY_NAMES[new Date().getDay()], []);
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div className="py-12 text-center text-sm text-neutral-400 dark:text-white/30">
|
||||
@@ -147,119 +155,144 @@ export function GroupView({
|
||||
}
|
||||
|
||||
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]) => {
|
||||
const byType = groupByType(trainerGroups);
|
||||
const totalGroups = trainerGroups.length;
|
||||
const isActive = filterTrainer === trainer;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={trainer}
|
||||
className="rounded-xl border border-neutral-200 bg-white overflow-hidden dark:border-white/[0.06] dark:bg-[#0a0a0a]"
|
||||
>
|
||||
<div key={trainer} className="space-y-2">
|
||||
{/* Trainer header */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
{/* Photo — clicks to open trainer bio */}
|
||||
<button
|
||||
onClick={() => setFilterTrainer(filterTrainer === trainer ? null : trainer)}
|
||||
className={`flex items-center gap-2 w-full px-4 py-2.5 text-left transition-colors cursor-pointer ${
|
||||
filterTrainer === trainer
|
||||
? "bg-gold/10 dark:bg-gold/5"
|
||||
: "bg-neutral-50 dark:bg-white/[0.02]"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: trainer }));
|
||||
}}
|
||||
className={`relative flex items-center justify-center h-9 w-9 rounded-full overflow-hidden transition-all cursor-pointer ${
|
||||
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"} />
|
||||
<span className={`text-sm font-semibold ${
|
||||
filterTrainer === trainer ? "text-gold" : "text-neutral-800 dark:text-white/80"
|
||||
{trainerPhotos[trainer] ? (
|
||||
<Image
|
||||
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}
|
||||
</span>
|
||||
<span className="ml-auto text-[10px] text-neutral-400 dark:text-white/25">
|
||||
{totalGroups === 1 ? "1 группа" : `${totalGroups} групп${totalGroups < 5 ? "ы" : ""}`}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Type → Groups */}
|
||||
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
|
||||
{/* Groups */}
|
||||
<div className="space-y-2 pl-2">
|
||||
{byType.map(({ type, groups: typeGroups }) => {
|
||||
const dotColor = typeDots[type] ?? "bg-white/30";
|
||||
|
||||
return (
|
||||
<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) => {
|
||||
return typeGroups.map((group, gi) => {
|
||||
const merged = mergeSlotsByDay(group.slots);
|
||||
|
||||
const hasToday = group.slots.some(s => s.day === todayName);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={gi}
|
||||
className="flex items-center gap-2 flex-wrap"
|
||||
key={`${type}-${gi}`}
|
||||
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-center gap-0.5 flex-wrap">
|
||||
{merged.map((m, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 text-xs">
|
||||
{i > 0 && <span className="text-neutral-300 dark:text-white/15 mx-0.5">·</span>}
|
||||
<span className="rounded bg-gold/10 px-1.5 py-0.5 text-[10px] font-bold text-gold-dark dark:text-gold">
|
||||
{m.days.join(", ")}
|
||||
</span>
|
||||
<span className="font-medium tabular-nums text-neutral-500 dark:text-white/45">
|
||||
{m.times.join(", ")}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex items-start gap-3 p-3 sm:p-4">
|
||||
{/* Left: type dot + info */}
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
{/* Type name */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setFilterType(filterType === type ? null : type)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${dotColor}`} />
|
||||
<span className="text-sm font-semibold text-white/90">{type}</span>
|
||||
</button>
|
||||
{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}
|
||||
</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 && (
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 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} />
|
||||
{shortAddress(group.locationAddress || group.location)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Book button */}
|
||||
{/* Right: book button */}
|
||||
{onBook && (
|
||||
<button
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user