feat: unique color picker for class types in schedule editor
- Add clickable color picker in schedule legend (16 distinct colors) - Two-pass smart assignment: explicit colors first, then unused palette slots - Hide already-used colors from the picker (both explicit and fallback) - Colors saved to classes section and flow to public site schedule dots - Expanded palette: rose, orange, amber, yellow, lime, emerald, teal, cyan, sky, blue, indigo, violet, purple, fuchsia, pink, red Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ interface ClassesData {
|
|||||||
icon: string;
|
icon: string;
|
||||||
detailedDescription?: string;
|
detailedDescription?: string;
|
||||||
images?: string[];
|
images?: string[];
|
||||||
|
color?: string;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
import { SectionEditor } from "../_components/SectionEditor";
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
import { InputField, SelectField, TimeRangeField, ToggleField } from "../_components/FormField";
|
import { InputField, SelectField, TimeRangeField, ToggleField } from "../_components/FormField";
|
||||||
import { Plus, X, Trash2 } from "lucide-react";
|
import { Plus, X, Trash2 } from "lucide-react";
|
||||||
@@ -31,23 +31,75 @@ const LEVELS = [
|
|||||||
{ value: "Продвинутый", label: "Продвинутый" },
|
{ value: "Продвинутый", label: "Продвинутый" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const COLOR_PALETTE = [
|
const COLOR_SWATCHES: { value: string; bg: string }[] = [
|
||||||
"bg-rose-500/80 border-rose-400",
|
{ value: "rose", bg: "bg-rose-500" },
|
||||||
"bg-violet-500/80 border-violet-400",
|
{ value: "orange", bg: "bg-orange-500" },
|
||||||
"bg-amber-500/80 border-amber-400",
|
{ value: "amber", bg: "bg-amber-500" },
|
||||||
"bg-emerald-500/80 border-emerald-400",
|
{ value: "yellow", bg: "bg-yellow-400" },
|
||||||
"bg-pink-500/80 border-pink-400",
|
{ value: "lime", bg: "bg-lime-500" },
|
||||||
"bg-sky-500/80 border-sky-400",
|
{ value: "emerald", bg: "bg-emerald-500" },
|
||||||
"bg-indigo-500/80 border-indigo-400",
|
{ value: "teal", bg: "bg-teal-500" },
|
||||||
"bg-orange-500/80 border-orange-400",
|
{ value: "cyan", bg: "bg-cyan-500" },
|
||||||
"bg-teal-500/80 border-teal-400",
|
{ value: "sky", bg: "bg-sky-500" },
|
||||||
"bg-fuchsia-500/80 border-fuchsia-400",
|
{ value: "blue", bg: "bg-blue-500" },
|
||||||
|
{ value: "indigo", bg: "bg-indigo-500" },
|
||||||
|
{ value: "violet", bg: "bg-violet-500" },
|
||||||
|
{ value: "purple", bg: "bg-purple-500" },
|
||||||
|
{ value: "fuchsia", bg: "bg-fuchsia-500" },
|
||||||
|
{ value: "pink", bg: "bg-pink-500" },
|
||||||
|
{ value: "red", bg: "bg-red-500" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function getTypeColor(type: string, classTypes: string[]): string {
|
const COLOR_MAP: Record<string, string> = {
|
||||||
const idx = classTypes.indexOf(type);
|
rose: "bg-rose-500/80 border-rose-400",
|
||||||
if (idx >= 0) return COLOR_PALETTE[idx % COLOR_PALETTE.length];
|
orange: "bg-orange-500/80 border-orange-400",
|
||||||
return "bg-neutral-600/80 border-neutral-500";
|
amber: "bg-amber-500/80 border-amber-400",
|
||||||
|
yellow: "bg-yellow-400/80 border-yellow-300",
|
||||||
|
lime: "bg-lime-500/80 border-lime-400",
|
||||||
|
emerald: "bg-emerald-500/80 border-emerald-400",
|
||||||
|
teal: "bg-teal-500/80 border-teal-400",
|
||||||
|
cyan: "bg-cyan-500/80 border-cyan-400",
|
||||||
|
sky: "bg-sky-500/80 border-sky-400",
|
||||||
|
blue: "bg-blue-500/80 border-blue-400",
|
||||||
|
indigo: "bg-indigo-500/80 border-indigo-400",
|
||||||
|
violet: "bg-violet-500/80 border-violet-400",
|
||||||
|
purple: "bg-purple-500/80 border-purple-400",
|
||||||
|
fuchsia: "bg-fuchsia-500/80 border-fuchsia-400",
|
||||||
|
pink: "bg-pink-500/80 border-pink-400",
|
||||||
|
red: "bg-red-500/80 border-red-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_PALETTE = COLOR_SWATCHES.map((c) => COLOR_MAP[c.value]);
|
||||||
|
|
||||||
|
function buildColorAssignments(classTypes: string[], classColors: Record<string, string>): Record<string, string> {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
const usedPalette = new Set<number>();
|
||||||
|
|
||||||
|
// First pass: assign explicit colors
|
||||||
|
for (const type of classTypes) {
|
||||||
|
const c = classColors[type];
|
||||||
|
if (c && COLOR_MAP[c]) {
|
||||||
|
result[type] = COLOR_MAP[c];
|
||||||
|
const idx = COLOR_SWATCHES.findIndex((s) => s.value === c);
|
||||||
|
if (idx >= 0) usedPalette.add(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: assign remaining types to unused palette slots
|
||||||
|
let nextSlot = 0;
|
||||||
|
for (const type of classTypes) {
|
||||||
|
if (result[type]) continue;
|
||||||
|
while (usedPalette.has(nextSlot) && nextSlot < FALLBACK_PALETTE.length) nextSlot++;
|
||||||
|
result[type] = FALLBACK_PALETTE[nextSlot % FALLBACK_PALETTE.length];
|
||||||
|
usedPalette.add(nextSlot);
|
||||||
|
nextSlot++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeColor(type: string, assignments: Record<string, string>): string {
|
||||||
|
return assignments[type] ?? "bg-neutral-600/80 border-neutral-500";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calendar config
|
// Calendar config
|
||||||
@@ -127,7 +179,7 @@ function ClassBlock({
|
|||||||
index,
|
index,
|
||||||
isOverlapping,
|
isOverlapping,
|
||||||
isDragging,
|
isDragging,
|
||||||
classTypes,
|
colorAssignments,
|
||||||
onClick,
|
onClick,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
}: {
|
}: {
|
||||||
@@ -135,7 +187,7 @@ function ClassBlock({
|
|||||||
index: number;
|
index: number;
|
||||||
isOverlapping: boolean;
|
isOverlapping: boolean;
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
classTypes: string[];
|
colorAssignments: Record<string, string>;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
onDragStart: (e: React.MouseEvent) => void;
|
onDragStart: (e: React.MouseEvent) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -147,7 +199,7 @@ function ClassBlock({
|
|||||||
|
|
||||||
const top = minutesToY(startMin);
|
const top = minutesToY(startMin);
|
||||||
const height = Math.max(((endMin - startMin) / 60) * HOUR_HEIGHT, 20);
|
const height = Math.max(((endMin - startMin) / 60) * HOUR_HEIGHT, 20);
|
||||||
const colors = getTypeColor(cls.type, classTypes);
|
const colors = getTypeColor(cls.type, colorAssignments);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -295,12 +347,16 @@ function CalendarGrid({
|
|||||||
trainers,
|
trainers,
|
||||||
addresses,
|
addresses,
|
||||||
classTypes,
|
classTypes,
|
||||||
|
classColors,
|
||||||
|
onColorChange,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
location: ScheduleLocation;
|
location: ScheduleLocation;
|
||||||
trainers: string[];
|
trainers: string[];
|
||||||
addresses: string[];
|
addresses: string[];
|
||||||
classTypes: string[];
|
classTypes: string[];
|
||||||
|
classColors: Record<string, string>;
|
||||||
|
onColorChange: (typeName: string, color: string) => void;
|
||||||
onChange: (loc: ScheduleLocation) => void;
|
onChange: (loc: ScheduleLocation) => void;
|
||||||
}) {
|
}) {
|
||||||
const [editingClass, setEditingClass] = useState<{
|
const [editingClass, setEditingClass] = useState<{
|
||||||
@@ -312,6 +368,15 @@ function CalendarGrid({
|
|||||||
cls: ScheduleClass;
|
cls: ScheduleClass;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
// Compute color assignments (explicit + smart fallback)
|
||||||
|
const colorAssignments = useMemo(
|
||||||
|
() => buildColorAssignments(classTypes, classColors),
|
||||||
|
[classTypes, classColors]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Color picker popup
|
||||||
|
const [colorPicker, setColorPicker] = useState<string | null>(null);
|
||||||
|
|
||||||
// Hover highlight state
|
// Hover highlight state
|
||||||
const [hover, setHover] = useState<{ dayIndex: number; startMin: number } | null>(null);
|
const [hover, setHover] = useState<{ dayIndex: number; startMin: number } | null>(null);
|
||||||
|
|
||||||
@@ -534,7 +599,7 @@ function CalendarGrid({
|
|||||||
const dragPreview = drag?.moved ? (() => {
|
const dragPreview = drag?.moved ? (() => {
|
||||||
const sourceDay = sortedDays[drag.sourceDayIndex];
|
const sourceDay = sortedDays[drag.sourceDayIndex];
|
||||||
const cls = sourceDay.classes[drag.classIndex];
|
const cls = sourceDay.classes[drag.classIndex];
|
||||||
const colors = getTypeColor(cls.type, classTypes);
|
const colors = getTypeColor(cls.type, colorAssignments);
|
||||||
const top = minutesToY(drag.previewStartMin);
|
const top = minutesToY(drag.previewStartMin);
|
||||||
const height = (drag.durationMin / 60) * HOUR_HEIGHT;
|
const height = (drag.durationMin / 60) * HOUR_HEIGHT;
|
||||||
const newStart = formatMinutes(drag.previewStartMin);
|
const newStart = formatMinutes(drag.previewStartMin);
|
||||||
@@ -560,15 +625,56 @@ function CalendarGrid({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend with color picker */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{classTypes.map((type) => {
|
{classTypes.map((type) => {
|
||||||
const colors = getTypeColor(type, classTypes);
|
const colors = getTypeColor(type, colorAssignments);
|
||||||
const bgClass = colors.split(" ")[0] || "bg-neutral-600/80";
|
const bgClass = colors.split(" ")[0] || "bg-neutral-600/80";
|
||||||
|
const isOpen = colorPicker === type;
|
||||||
return (
|
return (
|
||||||
<div key={type} className="flex items-center gap-1.5 text-xs text-neutral-300">
|
<div key={type} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColorPicker(isOpen ? null : type)}
|
||||||
|
className={`flex items-center gap-1.5 text-xs rounded-md px-2 py-1 transition-colors ${
|
||||||
|
isOpen
|
||||||
|
? "bg-white/10 text-white"
|
||||||
|
: "text-neutral-300 hover:bg-white/5 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div className={`h-3 w-3 rounded-sm ${bgClass}`} />
|
<div className={`h-3 w-3 rounded-sm ${bgClass}`} />
|
||||||
{type}
|
{type}
|
||||||
|
</button>
|
||||||
|
{isOpen && (() => {
|
||||||
|
// Build used set from resolved assignments (includes both explicit & fallback)
|
||||||
|
const usedColors = new Set(
|
||||||
|
Object.entries(colorAssignments)
|
||||||
|
.filter(([t]) => t !== type)
|
||||||
|
.map(([, cls]) => COLOR_SWATCHES.find((s) => COLOR_MAP[s.value] === cls)?.value)
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="absolute top-full left-0 mt-1 z-50 rounded-lg border border-white/10 bg-neutral-800 p-2 shadow-xl">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{COLOR_SWATCHES.filter((c) => !usedColors.has(c.value)).map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onColorChange(type, c.value);
|
||||||
|
setColorPicker(null);
|
||||||
|
}}
|
||||||
|
className={`h-6 w-6 rounded-full ${c.bg} transition-all ${
|
||||||
|
classColors[type] === c.value
|
||||||
|
? "ring-2 ring-white ring-offset-1 ring-offset-neutral-800 scale-110"
|
||||||
|
: "opacity-60 hover:opacity-100"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -686,7 +792,7 @@ function CalendarGrid({
|
|||||||
drag.classIndex === ci &&
|
drag.classIndex === ci &&
|
||||||
drag.moved
|
drag.moved
|
||||||
}
|
}
|
||||||
classTypes={classTypes}
|
colorAssignments={colorAssignments}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (justDraggedRef.current) return;
|
if (justDraggedRef.current) return;
|
||||||
setEditingClass({ dayIndex: di, classIndex: ci });
|
setEditingClass({ dayIndex: di, classIndex: ci });
|
||||||
@@ -762,6 +868,8 @@ export default function ScheduleEditorPage() {
|
|||||||
const [trainers, setTrainers] = useState<string[]>([]);
|
const [trainers, setTrainers] = useState<string[]>([]);
|
||||||
const [addresses, setAddresses] = useState<string[]>([]);
|
const [addresses, setAddresses] = useState<string[]>([]);
|
||||||
const [classTypes, setClassTypes] = useState<string[]>([]);
|
const [classTypes, setClassTypes] = useState<string[]>([]);
|
||||||
|
const [classColors, setClassColors] = useState<Record<string, string>>({});
|
||||||
|
const classesDataRef = useRef<{ title: string; items: { name: string; color?: string; [k: string]: unknown }[] } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/admin/team")
|
fetch("/api/admin/team")
|
||||||
@@ -780,12 +888,36 @@ export default function ScheduleEditorPage() {
|
|||||||
|
|
||||||
fetch("/api/admin/sections/classes")
|
fetch("/api/admin/sections/classes")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((classes: { items?: { name: string }[] }) => {
|
.then((classes: { title: string; items?: { name: string; color?: string }[] }) => {
|
||||||
setClassTypes((classes.items ?? []).map((c) => c.name));
|
const items = classes.items ?? [];
|
||||||
|
classesDataRef.current = { title: classes.title, items };
|
||||||
|
setClassTypes(items.map((c) => c.name));
|
||||||
|
const colors: Record<string, string> = {};
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.color) colors[item.name] = item.color;
|
||||||
|
}
|
||||||
|
setClassColors(colors);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleColorChange = useCallback((typeName: string, color: string) => {
|
||||||
|
setClassColors((prev) => ({ ...prev, [typeName]: color }));
|
||||||
|
|
||||||
|
// Save to classes section
|
||||||
|
const data = classesDataRef.current;
|
||||||
|
if (!data) return;
|
||||||
|
const updatedItems = data.items.map((item) =>
|
||||||
|
item.name === typeName ? { ...item, color } : item
|
||||||
|
);
|
||||||
|
classesDataRef.current = { ...data, items: updatedItems };
|
||||||
|
fetch("/api/admin/sections/classes", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ...data, items: updatedItems }),
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionEditor<ScheduleData> sectionKey="schedule" title="Расписание">
|
<SectionEditor<ScheduleData> sectionKey="schedule" title="Расписание">
|
||||||
{(data, update) => {
|
{(data, update) => {
|
||||||
@@ -866,6 +998,8 @@ export default function ScheduleEditorPage() {
|
|||||||
trainers={trainers}
|
trainers={trainers}
|
||||||
addresses={addresses}
|
addresses={addresses}
|
||||||
classTypes={classTypes}
|
classTypes={classTypes}
|
||||||
|
classColors={classColors}
|
||||||
|
onColorChange={handleColorChange}
|
||||||
onChange={updateLocation}
|
onChange={updateLocation}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default function HomePage() {
|
|||||||
/>
|
/>
|
||||||
<Team data={content.team} />
|
<Team data={content.team} />
|
||||||
<Classes data={content.classes} />
|
<Classes data={content.classes} />
|
||||||
<Schedule data={content.schedule} />
|
<Schedule data={content.schedule} classItems={content.classes.items} />
|
||||||
<Pricing data={content.pricing} />
|
<Pricing data={content.pricing} />
|
||||||
<FAQ data={content.faq} />
|
<FAQ data={content.faq} />
|
||||||
<Contact data={content.contact} />
|
<Contact data={content.contact} />
|
||||||
|
|||||||
@@ -7,14 +7,16 @@ import { Reveal } from "@/components/ui/Reveal";
|
|||||||
import { DayCard } from "./schedule/DayCard";
|
import { DayCard } from "./schedule/DayCard";
|
||||||
import { ScheduleFilters } from "./schedule/ScheduleFilters";
|
import { ScheduleFilters } from "./schedule/ScheduleFilters";
|
||||||
import { MobileSchedule } from "./schedule/MobileSchedule";
|
import { MobileSchedule } from "./schedule/MobileSchedule";
|
||||||
|
import { buildTypeDots } from "./schedule/constants";
|
||||||
import type { StatusFilter } from "./schedule/constants";
|
import type { StatusFilter } from "./schedule/constants";
|
||||||
import type { SiteContent } from "@/types/content";
|
import type { SiteContent } from "@/types/content";
|
||||||
|
|
||||||
interface ScheduleProps {
|
interface ScheduleProps {
|
||||||
data: SiteContent["schedule"];
|
data: SiteContent["schedule"];
|
||||||
|
classItems?: { name: string; color?: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Schedule({ data: schedule }: ScheduleProps) {
|
export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
||||||
const [locationIndex, setLocationIndex] = useState(0);
|
const [locationIndex, setLocationIndex] = useState(0);
|
||||||
const [filterTrainer, setFilterTrainer] = useState<string | null>(null);
|
const [filterTrainer, setFilterTrainer] = useState<string | null>(null);
|
||||||
const [filterType, setFilterType] = useState<string | null>(null);
|
const [filterType, setFilterType] = useState<string | null>(null);
|
||||||
@@ -22,6 +24,8 @@ export function Schedule({ data: schedule }: ScheduleProps) {
|
|||||||
const [showTrainers, setShowTrainers] = useState(false);
|
const [showTrainers, setShowTrainers] = useState(false);
|
||||||
const location = schedule.locations[locationIndex];
|
const location = schedule.locations[locationIndex];
|
||||||
|
|
||||||
|
const typeDots = useMemo(() => buildTypeDots(classItems), [classItems]);
|
||||||
|
|
||||||
const { trainers, types, hasAnySlots, hasAnyRecruiting } = useMemo(() => {
|
const { trainers, types, hasAnySlots, hasAnyRecruiting } = useMemo(() => {
|
||||||
const trainerSet = new Set<string>();
|
const trainerSet = new Set<string>();
|
||||||
const typeSet = new Set<string>();
|
const typeSet = new Set<string>();
|
||||||
@@ -108,6 +112,7 @@ export function Schedule({ data: schedule }: ScheduleProps) {
|
|||||||
{/* Compact filters — desktop only */}
|
{/* Compact filters — desktop only */}
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<ScheduleFilters
|
<ScheduleFilters
|
||||||
|
typeDots={typeDots}
|
||||||
types={types}
|
types={types}
|
||||||
trainers={trainers}
|
trainers={trainers}
|
||||||
hasAnySlots={hasAnySlots}
|
hasAnySlots={hasAnySlots}
|
||||||
@@ -129,6 +134,7 @@ export function Schedule({ data: schedule }: ScheduleProps) {
|
|||||||
{/* Mobile: compact agenda list with tap-to-filter */}
|
{/* Mobile: compact agenda list with tap-to-filter */}
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<MobileSchedule
|
<MobileSchedule
|
||||||
|
typeDots={typeDots}
|
||||||
filteredDays={filteredDays}
|
filteredDays={filteredDays}
|
||||||
filterType={filterType}
|
filterType={filterType}
|
||||||
setFilterType={setFilterType}
|
setFilterType={setFilterType}
|
||||||
@@ -151,7 +157,7 @@ export function Schedule({ data: schedule }: ScheduleProps) {
|
|||||||
key={day.day}
|
key={day.day}
|
||||||
className={filteredDays.length === 1 ? "w-full max-w-[340px]" : ""}
|
className={filteredDays.length === 1 ? "w-full max-w-[340px]" : ""}
|
||||||
>
|
>
|
||||||
<DayCard day={day} />
|
<DayCard day={day} typeDots={typeDots} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Clock, User } from "lucide-react";
|
import { Clock, User } from "lucide-react";
|
||||||
import type { ScheduleDay } from "@/types/content";
|
import type { ScheduleDay } from "@/types/content";
|
||||||
import { TYPE_DOT } from "./constants";
|
|
||||||
|
|
||||||
export function DayCard({ day }: { day: ScheduleDay }) {
|
export function DayCard({ day, typeDots }: { day: ScheduleDay; typeDots: Record<string, string> }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a] overflow-hidden">
|
<div className="rounded-2xl border border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a] overflow-hidden">
|
||||||
{/* Day header */}
|
{/* Day header */}
|
||||||
@@ -43,7 +42,7 @@ export function DayCard({ day }: { day: ScheduleDay }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`h-2 w-2 shrink-0 rounded-full ${TYPE_DOT[cls.type] ?? "bg-white/30"}`} />
|
<span className={`h-2 w-2 shrink-0 rounded-full ${typeDots[cls.type] ?? "bg-white/30"}`} />
|
||||||
<span className="text-xs text-neutral-500 dark:text-white/40">{cls.type}</span>
|
<span className="text-xs text-neutral-500 dark:text-white/40">{cls.type}</span>
|
||||||
</div>
|
</div>
|
||||||
{cls.level && (
|
{cls.level && (
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import { User, X } from "lucide-react";
|
import { User, X } from "lucide-react";
|
||||||
import type { ScheduleDay } from "@/types/content";
|
import type { ScheduleDay } from "@/types/content";
|
||||||
import { TYPE_DOT } from "./constants";
|
|
||||||
|
|
||||||
interface MobileScheduleProps {
|
interface MobileScheduleProps {
|
||||||
|
typeDots: Record<string, string>;
|
||||||
filteredDays: ScheduleDay[];
|
filteredDays: ScheduleDay[];
|
||||||
filterType: string | null;
|
filterType: string | null;
|
||||||
setFilterType: (type: string | null) => void;
|
setFilterType: (type: string | null) => void;
|
||||||
@@ -15,6 +15,7 @@ interface MobileScheduleProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MobileSchedule({
|
export function MobileSchedule({
|
||||||
|
typeDots,
|
||||||
filteredDays,
|
filteredDays,
|
||||||
filterType,
|
filterType,
|
||||||
setFilterType,
|
setFilterType,
|
||||||
@@ -37,7 +38,7 @@ export function MobileSchedule({
|
|||||||
)}
|
)}
|
||||||
{filterType && (
|
{filterType && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className={`h-1.5 w-1.5 rounded-full ${TYPE_DOT[filterType] ?? "bg-white/30"}`} />
|
<span className={`h-1.5 w-1.5 rounded-full ${typeDots[filterType] ?? "bg-white/30"}`} />
|
||||||
{filterType}
|
{filterType}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -107,7 +108,7 @@ export function MobileSchedule({
|
|||||||
onClick={() => setFilterType(filterType === cls.type ? null : cls.type)}
|
onClick={() => setFilterType(filterType === cls.type ? null : cls.type)}
|
||||||
className={`mt-0.5 flex items-center gap-1.5 active:opacity-60 ${filterType === cls.type ? "opacity-100" : ""}`}
|
className={`mt-0.5 flex items-center gap-1.5 active:opacity-60 ${filterType === cls.type ? "opacity-100" : ""}`}
|
||||||
>
|
>
|
||||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${TYPE_DOT[cls.type] ?? "bg-white/30"}`} />
|
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${typeDots[cls.type] ?? "bg-white/30"}`} />
|
||||||
<span className={`text-[11px] ${filterType === cls.type ? "text-gold underline underline-offset-2" : "text-neutral-400 dark:text-white/30"}`}>{cls.type}</span>
|
<span className={`text-[11px] ${filterType === cls.type ? "text-gold underline underline-offset-2" : "text-neutral-400 dark:text-white/30"}`}>{cls.type}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { User, X, ChevronDown } from "lucide-react";
|
import { User, X, ChevronDown } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
TYPE_DOT,
|
|
||||||
pillBase,
|
pillBase,
|
||||||
pillActive,
|
pillActive,
|
||||||
pillInactive,
|
pillInactive,
|
||||||
@@ -10,6 +9,7 @@ import {
|
|||||||
} from "./constants";
|
} from "./constants";
|
||||||
|
|
||||||
interface ScheduleFiltersProps {
|
interface ScheduleFiltersProps {
|
||||||
|
typeDots: Record<string, string>;
|
||||||
types: string[];
|
types: string[];
|
||||||
trainers: string[];
|
trainers: string[];
|
||||||
hasAnySlots: boolean;
|
hasAnySlots: boolean;
|
||||||
@@ -27,6 +27,7 @@ interface ScheduleFiltersProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ScheduleFilters({
|
export function ScheduleFilters({
|
||||||
|
typeDots,
|
||||||
types,
|
types,
|
||||||
trainers,
|
trainers,
|
||||||
hasAnySlots,
|
hasAnySlots,
|
||||||
@@ -52,7 +53,7 @@ export function ScheduleFilters({
|
|||||||
onClick={() => setFilterType(filterType === type ? null : type)}
|
onClick={() => setFilterType(filterType === type ? null : type)}
|
||||||
className={`${pillBase} ${filterType === type ? pillActive : pillInactive}`}
|
className={`${pillBase} ${filterType === type ? pillActive : pillInactive}`}
|
||||||
>
|
>
|
||||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${TYPE_DOT[type] ?? "bg-white/30"}`} />
|
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${typeDots[type] ?? "bg-white/30"}`} />
|
||||||
{type}
|
{type}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,10 +1,67 @@
|
|||||||
export const TYPE_DOT: Record<string, string> = {
|
/** Hardcoded fallback — overridden by admin-chosen colors when available */
|
||||||
|
export const TYPE_DOT_FALLBACK: Record<string, string> = {
|
||||||
"Exotic Pole Dance": "bg-gold",
|
"Exotic Pole Dance": "bg-gold",
|
||||||
"Pole Dance": "bg-rose-500",
|
"Pole Dance": "bg-rose-500",
|
||||||
"Body Plastic": "bg-purple-500",
|
"Body Plastic": "bg-purple-500",
|
||||||
"Трюковые комбинации с пилоном": "bg-amber-500",
|
"Трюковые комбинации с пилоном": "bg-amber-500",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const COLOR_KEY_TO_DOT: Record<string, string> = {
|
||||||
|
rose: "bg-rose-500",
|
||||||
|
orange: "bg-orange-500",
|
||||||
|
amber: "bg-amber-500",
|
||||||
|
yellow: "bg-yellow-400",
|
||||||
|
lime: "bg-lime-500",
|
||||||
|
emerald: "bg-emerald-500",
|
||||||
|
teal: "bg-teal-500",
|
||||||
|
cyan: "bg-cyan-500",
|
||||||
|
sky: "bg-sky-500",
|
||||||
|
blue: "bg-blue-500",
|
||||||
|
indigo: "bg-indigo-500",
|
||||||
|
violet: "bg-violet-500",
|
||||||
|
purple: "bg-purple-500",
|
||||||
|
fuchsia: "bg-fuchsia-500",
|
||||||
|
pink: "bg-pink-500",
|
||||||
|
red: "bg-red-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_DOTS = [
|
||||||
|
"bg-rose-500", "bg-orange-500", "bg-amber-500", "bg-yellow-400",
|
||||||
|
"bg-lime-500", "bg-emerald-500", "bg-teal-500", "bg-cyan-500",
|
||||||
|
"bg-sky-500", "bg-blue-500", "bg-indigo-500", "bg-violet-500",
|
||||||
|
"bg-purple-500", "bg-fuchsia-500", "bg-pink-500", "bg-red-500",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Build a type→dot map from class items with optional color field */
|
||||||
|
export function buildTypeDots(
|
||||||
|
classItems?: { name: string; color?: string }[]
|
||||||
|
): Record<string, string> {
|
||||||
|
if (!classItems?.length) return TYPE_DOT_FALLBACK;
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
const usedSlots = new Set<number>();
|
||||||
|
|
||||||
|
// First pass: explicit colors
|
||||||
|
classItems.forEach((item) => {
|
||||||
|
if (item.color && COLOR_KEY_TO_DOT[item.color]) {
|
||||||
|
map[item.name] = COLOR_KEY_TO_DOT[item.color];
|
||||||
|
const idx = FALLBACK_DOTS.indexOf(COLOR_KEY_TO_DOT[item.color]);
|
||||||
|
if (idx >= 0) usedSlots.add(idx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second pass: assign remaining to unused slots
|
||||||
|
let nextSlot = 0;
|
||||||
|
classItems.forEach((item) => {
|
||||||
|
if (map[item.name]) return;
|
||||||
|
while (usedSlots.has(nextSlot) && nextSlot < FALLBACK_DOTS.length) nextSlot++;
|
||||||
|
map[item.name] = FALLBACK_DOTS[nextSlot % FALLBACK_DOTS.length];
|
||||||
|
usedSlots.add(nextSlot);
|
||||||
|
nextSlot++;
|
||||||
|
});
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
export type StatusFilter = "all" | "hasSlots" | "recruiting";
|
export type StatusFilter = "all" | "hasSlots" | "recruiting";
|
||||||
|
|
||||||
export const pillBase =
|
export const pillBase =
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface ClassItem {
|
|||||||
icon: string;
|
icon: string;
|
||||||
detailedDescription?: string;
|
detailedDescription?: string;
|
||||||
images?: string[];
|
images?: string[];
|
||||||
|
color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamMember {
|
export interface TeamMember {
|
||||||
|
|||||||
Reference in New Issue
Block a user