feat: MC admin — collapsible cards, filters, photo preview, validation, archive

- Collapsible cards with title + hall in label, archive badge
- Archived MCs sorted to bottom, dimmed with "Архив" badge
- Cards have hover + focus-within gold border highlight
- Date validation: error text for missing dates and invalid time ranges
- Search by title/trainer + filter by date (upcoming/past) and hall
- Photo preview with hover overlay (like trainer page)
- ArrayEditor: hiddenItems, getItemBadge props, focus-within styles
This commit is contained in:
2026-03-26 00:43:09 +03:00
parent 6c485872b0
commit 64e923460f
2 changed files with 386 additions and 180 deletions

View File

@@ -13,6 +13,8 @@ interface ArrayEditorProps<T> {
addLabel?: string; addLabel?: string;
collapsible?: boolean; collapsible?: boolean;
getItemTitle?: (item: T, index: number) => string; getItemTitle?: (item: T, index: number) => string;
getItemBadge?: (item: T, index: number) => React.ReactNode;
hiddenItems?: Set<number>;
} }
export function ArrayEditor<T>({ export function ArrayEditor<T>({
@@ -24,6 +26,8 @@ export function ArrayEditor<T>({
addLabel = "Добавить", addLabel = "Добавить",
collapsible = false, collapsible = false,
getItemTitle, getItemTitle,
getItemBadge,
hiddenItems,
}: ArrayEditorProps<T>) { }: ArrayEditorProps<T>) {
const [dragIndex, setDragIndex] = useState<number | null>(null); const [dragIndex, setDragIndex] = useState<number | null>(null);
const [insertAt, setInsertAt] = useState<number | null>(null); const [insertAt, setInsertAt] = useState<number | null>(null);
@@ -146,14 +150,15 @@ export function ArrayEditor<T>({
if (dragIndex === null || insertAt === null) { if (dragIndex === null || insertAt === null) {
return items.map((item, i) => { return items.map((item, i) => {
const isCollapsed = collapsible && collapsed.has(i) && newItemIndex !== i; const isCollapsed = collapsible && collapsed.has(i) && newItemIndex !== i;
const isHidden = hiddenItems?.has(i) ?? false;
const title = getItemTitle?.(item, i) || `#${i + 1}`; const title = getItemTitle?.(item, i) || `#${i + 1}`;
return ( return (
<div <div
key={i} key={i}
ref={(el) => { itemRefs.current[i] = el; }} ref={(el) => { itemRefs.current[i] = el; }}
className={`rounded-lg border bg-neutral-900/50 mb-3 hover:border-white/25 hover:bg-neutral-800/50 transition-all ${ className={`rounded-lg border bg-neutral-900/50 mb-3 hover:border-white/25 hover:bg-neutral-800/50 focus-within:border-gold/50 focus-within:bg-neutral-800 transition-all ${
newItemIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-white/10" newItemIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-white/10"
}`} } ${isHidden ? "hidden" : ""}`}
> >
<div className={`flex items-center justify-between gap-2 p-4 ${isCollapsed ? "" : "pb-0 mb-3"}`}> <div className={`flex items-center justify-between gap-2 p-4 ${isCollapsed ? "" : "pb-0 mb-3"}`}>
<div className="flex items-center gap-2 flex-1 min-w-0"> <div className="flex items-center gap-2 flex-1 min-w-0">
@@ -170,6 +175,7 @@ export function ArrayEditor<T>({
className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group" className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group"
> >
<span className="text-sm font-medium text-neutral-300 truncate group-hover:text-white transition-colors">{title}</span> <span className="text-sm font-medium text-neutral-300 truncate group-hover:text-white transition-colors">{title}</span>
{getItemBadge?.(item, i)}
<ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} /> <ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} />
</button> </button>
)} )}
@@ -231,7 +237,7 @@ export function ArrayEditor<T>({
<div <div
key={i} key={i}
ref={(el) => { itemRefs.current[i] = el; }} ref={(el) => { itemRefs.current[i] = el; }}
className="rounded-lg border border-white/10 bg-neutral-900/50 p-4 mb-3 hover:border-white/25 hover:bg-neutral-800/50 transition-colors" className="rounded-lg border border-white/10 bg-neutral-900/50 p-4 mb-3 hover:border-white/25 hover:bg-neutral-800/50 focus-within:border-gold/50 focus-within:bg-neutral-800 transition-colors"
> >
<div className="flex items-start justify-between gap-2 mb-3"> <div className="flex items-start justify-between gap-2 mb-3">
<div <div

View File

@@ -1,13 +1,45 @@
"use client"; "use client";
import { useState, useRef, useEffect, useMemo } from "react"; import { useState, useEffect } from "react";
import Image from "next/image";
import { SectionEditor } from "../_components/SectionEditor"; import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField, ParticipantLimits, AutocompleteMulti } from "../_components/FormField"; import { InputField, TextareaField, ParticipantLimits, AutocompleteMulti } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor"; import { ArrayEditor } from "../_components/ArrayEditor";
import { Plus, X, Upload, Loader2, ImageIcon, AlertCircle, Check } from "lucide-react"; import { Plus, X, Upload, Loader2, AlertCircle, Check, Search } from "lucide-react";
import { adminFetch } from "@/lib/csrf"; import { adminFetch } from "@/lib/csrf";
import type { MasterClassItem, MasterClassSlot } from "@/types/content"; import type { MasterClassItem, MasterClassSlot } from "@/types/content";
// --- Helpers ---
function isItemArchived(item: MasterClassItem): boolean {
const slots = item.slots ?? [];
if (slots.length === 0) return false;
const today = new Date().toISOString().slice(0, 10);
return slots.every((s) => s.date && s.date < today);
}
function itemMatchesSearch(item: MasterClassItem, query: string): boolean {
if (!query) return true;
const q = query.toLowerCase();
return (
(item.title || "").toLowerCase().includes(q) ||
(item.trainer || "").toLowerCase().includes(q)
);
}
function itemMatchesDateFilter(item: MasterClassItem, filter: "all" | "upcoming" | "past"): boolean {
if (filter === "all") return true;
const archived = isItemArchived(item);
return filter === "past" ? archived : !archived;
}
function itemMatchesLocation(item: MasterClassItem, locationFilter: string): boolean {
if (!locationFilter) return true;
return (item.location || "") === locationFilter;
}
// --- Price Field ---
function PriceField({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) { function PriceField({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) {
const raw = value.replace(/\s*BYN\s*$/i, "").trim(); const raw = value.replace(/\s*BYN\s*$/i, "").trim();
return ( return (
@@ -37,7 +69,6 @@ interface MasterClassesData {
items: MasterClassItem[]; items: MasterClassItem[];
} }
// --- Location Select --- // --- Location Select ---
function LocationSelect({ function LocationSelect({
value, value,
@@ -92,6 +123,13 @@ function calcDurationText(startTime: string, endTime: string): string {
return `${m} мин`; return `${m} мин`;
} }
function hasTimeError(startTime: string, endTime: string): boolean {
if (!startTime || !endTime) return false;
const [sh, sm] = startTime.split(":").map(Number);
const [eh, em] = endTime.split(":").map(Number);
return (eh * 60 + em) <= (sh * 60 + sm);
}
function SlotsField({ function SlotsField({
slots, slots,
onChange, onChange,
@@ -123,41 +161,54 @@ function SlotsField({
<div className="space-y-2"> <div className="space-y-2">
{slots.map((slot, i) => { {slots.map((slot, i) => {
const dur = calcDurationText(slot.startTime, slot.endTime); const dur = calcDurationText(slot.startTime, slot.endTime);
const timeError = hasTimeError(slot.startTime, slot.endTime);
return ( return (
<div key={i} className="flex items-center gap-2 flex-wrap"> <div key={i}>
<input <div className="flex items-center gap-2 flex-wrap">
type="date" <input
value={slot.date} type="date"
onChange={(e) => updateSlot(i, { date: e.target.value })} value={slot.date}
className={`w-[140px] rounded-lg border bg-neutral-800 px-3 py-2 text-sm text-white outline-none transition-colors [color-scheme:dark] ${ onChange={(e) => updateSlot(i, { date: e.target.value })}
!slot.date ? "border-red-500/50" : "border-white/10 focus:border-gold" className={`w-[140px] rounded-lg border bg-neutral-800 px-3 py-2 text-sm text-white outline-none transition-colors [color-scheme:dark] ${
}`} !slot.date ? "border-red-500/50" : "border-white/10 focus:border-gold"
/> }`}
<input />
type="time" <input
value={slot.startTime} type="time"
onChange={(e) => updateSlot(i, { startTime: e.target.value })} value={slot.startTime}
className="w-[100px] rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white outline-none focus:border-gold transition-colors [color-scheme:dark]" onChange={(e) => updateSlot(i, { startTime: e.target.value })}
/> className={`w-[100px] rounded-lg border bg-neutral-800 px-3 py-2 text-sm text-white outline-none transition-colors [color-scheme:dark] ${
<span className="text-neutral-500 text-xs"></span> timeError ? "border-red-500/50" : "border-white/10 focus:border-gold"
<input }`}
type="time" />
value={slot.endTime} <span className="text-neutral-500 text-xs">&ndash;</span>
onChange={(e) => updateSlot(i, { endTime: e.target.value })} <input
className="w-[100px] rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white outline-none focus:border-gold transition-colors [color-scheme:dark]" type="time"
/> value={slot.endTime}
{dur && ( onChange={(e) => updateSlot(i, { endTime: e.target.value })}
<span className="text-[11px] text-neutral-500 bg-neutral-800/50 rounded-full px-2 py-0.5"> className={`w-[100px] rounded-lg border bg-neutral-800 px-3 py-2 text-sm text-white outline-none transition-colors [color-scheme:dark] ${
{dur} timeError ? "border-red-500/50" : "border-white/10 focus:border-gold"
</span> }`}
/>
{dur && (
<span className="text-[11px] text-neutral-500 bg-neutral-800/50 rounded-full px-2 py-0.5">
{dur}
</span>
)}
<button
type="button"
onClick={() => removeSlot(i)}
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
>
<X size={14} />
</button>
</div>
{!slot.date && (
<p className="mt-0.5 ml-1 text-[11px] text-red-400">Укажите дату</p>
)}
{timeError && (
<p className="mt-0.5 ml-1 text-[11px] text-red-400">Время окончания должно быть позже начала</p>
)} )}
<button
type="button"
onClick={() => removeSlot(i)}
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
>
<X size={14} />
</button>
</div> </div>
); );
})} })}
@@ -174,8 +225,8 @@ function SlotsField({
); );
} }
// --- Image Upload --- // --- Photo Preview (like trainer page) ---
function ImageUploadField({ function PhotoPreview({
value, value,
onChange, onChange,
}: { }: {
@@ -183,7 +234,6 @@ function ImageUploadField({
onChange: (path: string) => void; onChange: (path: string) => void;
}) { }) {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) { async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -208,54 +258,48 @@ function ImageUploadField({
return ( return (
<div> <div>
<label className="block text-sm text-neutral-400 mb-1.5"> <label className="block text-sm text-neutral-400 mb-1.5">Изображение</label>
Изображение
</label>
{value ? ( {value ? (
<div className="flex items-center gap-2"> <div className="relative">
<div className="flex items-center gap-1.5 rounded-lg bg-neutral-700/50 px-3 py-2 text-sm text-neutral-300"> <label className="relative block w-full aspect-[16/9] overflow-hidden rounded-xl border border-white/10 cursor-pointer group">
<ImageIcon size={14} className="text-gold" /> <Image
<span className="max-w-[200px] truncate"> src={value}
{value.split("/").pop()} alt="Превью"
</span> fill
</div> className="object-cover"
sizes="(max-width: 768px) 100vw, 500px"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1">
{uploading ? (
<Loader2 size={20} className="animate-spin text-white" />
) : (
<>
<Upload size={20} className="text-white" />
<span className="text-[11px] text-white/80">Изменить</span>
</>
)}
</div>
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
</label>
<button <button
type="button" type="button"
onClick={() => onChange("")} onClick={() => onChange("")}
className="rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors" className="absolute top-2 right-2 rounded-lg bg-black/60 p-1.5 text-neutral-400 hover:text-red-400 transition-colors"
> >
<X size={14} /> <X size={14} />
</button> </button>
<label className="flex cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-sm text-neutral-400 hover:text-white hover:border-white/25 transition-colors">
{uploading ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Upload size={14} />
)}
Заменить
<input
type="file"
accept="image/*"
onChange={handleUpload}
className="hidden"
/>
</label>
</div> </div>
) : ( ) : (
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-3 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors"> <label className="flex cursor-pointer items-center justify-center gap-2 w-full aspect-[16/9] rounded-xl border-2 border-dashed border-white/20 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors">
{uploading ? ( {uploading ? (
<Loader2 size={16} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />
) : ( ) : (
<Upload size={16} /> <>
<Upload size={20} />
<span>Загрузить изображение</span>
</>
)} )}
{uploading ? "Загрузка..." : "Загрузить изображение"} <input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
<input
ref={inputRef}
type="file"
accept="image/*"
onChange={handleUpload}
className="hidden"
/>
</label> </label>
)} )}
</div> </div>
@@ -339,11 +383,113 @@ function ValidationHint({ fields }: { fields: Record<string, string> }) {
); );
} }
// --- Filter bar ---
type DateFilter = "all" | "upcoming" | "past";
const DATE_FILTER_LABELS: Record<DateFilter, string> = {
all: "Все",
upcoming: "Предстоящие",
past: "Прошедшие",
};
function FilterBar({
search,
onSearchChange,
dateFilter,
onDateFilterChange,
locationFilter,
onLocationFilterChange,
locations,
totalCount,
visibleCount,
}: {
search: string;
onSearchChange: (v: string) => void;
dateFilter: DateFilter;
onDateFilterChange: (v: DateFilter) => void;
locationFilter: string;
onLocationFilterChange: (v: string) => void;
locations: { name: string; address: string }[];
totalCount: number;
visibleCount: number;
}) {
return (
<div className="space-y-3 mb-4">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500" />
<input
type="text"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Поиск по названию или тренеру..."
className="w-full rounded-lg border border-white/10 bg-neutral-800 pl-10 pr-4 py-2.5 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"
/>
{search && (
<button
type="button"
onClick={() => onSearchChange("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-white transition-colors"
>
<X size={14} />
</button>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex gap-1">
{(Object.keys(DATE_FILTER_LABELS) as DateFilter[]).map((key) => (
<button
key={key}
type="button"
onClick={() => onDateFilterChange(key)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
dateFilter === key
? "bg-gold/20 text-gold border border-gold/40"
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:border-white/25 hover:text-white"
}`}
>
{DATE_FILTER_LABELS[key]}
</button>
))}
</div>
{locations.length > 0 && (
<>
<span className="text-neutral-600 text-xs">|</span>
<div className="flex gap-1">
{locations.map((loc) => (
<button
key={loc.name}
type="button"
onClick={() => onLocationFilterChange(locationFilter === loc.name ? "" : loc.name)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
locationFilter === loc.name
? "bg-gold/20 text-gold border border-gold/40"
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:border-white/25 hover:text-white"
}`}
>
{loc.name}
</button>
))}
</div>
</>
)}
{visibleCount < totalCount && (
<span className="text-xs text-neutral-500 ml-auto">
{visibleCount} из {totalCount}
</span>
)}
</div>
</div>
);
}
// --- Main page --- // --- Main page ---
export default function MasterClassesEditorPage() { export default function MasterClassesEditorPage() {
const [trainers, setTrainers] = useState<string[]>([]); const [trainers, setTrainers] = useState<string[]>([]);
const [styles, setStyles] = useState<string[]>([]); const [styles, setStyles] = useState<string[]>([]);
const [locations, setLocations] = useState<{ name: string; address: string }[]>([]); const [locations, setLocations] = useState<{ name: string; address: string }[]>([]);
const [search, setSearch] = useState("");
const [dateFilter, setDateFilter] = useState<DateFilter>("all");
const [locationFilter, setLocationFilter] = useState("");
useEffect(() => { useEffect(() => {
// Fetch trainers from team // Fetch trainers from team
@@ -376,119 +522,173 @@ export default function MasterClassesEditorPage() {
sectionKey="masterClasses" sectionKey="masterClasses"
title="Мастер-классы" title="Мастер-классы"
> >
{(data, update) => ( {(data, update) => {
<> // Sort: active first, archived at bottom
<InputField const displayItems = [...data.items].sort((a, b) => {
label="Заголовок секции" const aArch = isItemArchived(a);
value={data.title} const bArch = isItemArchived(b);
onChange={(v) => update({ ...data, title: v })} if (aArch === bArch) return 0;
/> return aArch ? 1 : -1;
});
<ArrayEditor const hiddenItems = new Set<number>();
label="Мастер-классы" displayItems.forEach((item, i) => {
items={data.items} if (
onChange={(items) => update({ ...data, items })} !itemMatchesSearch(item, search) ||
renderItem={(item, _i, updateItem) => ( !itemMatchesDateFilter(item, dateFilter) ||
<div className="space-y-3"> !itemMatchesLocation(item, locationFilter)
<ValidationHint ) {
fields={{ hiddenItems.add(i);
Название: item.title, }
Тренер: item.trainer, });
Стиль: item.style,
Стоимость: item.cost,
"Даты и время": (item.slots ?? []).length > 0 ? "ok" : "",
}}
/>
<InputField const visibleCount = data.items.length - hiddenItems.size;
label="Название"
value={item.title}
onChange={(v) => updateItem({ ...item, title: v })}
placeholder="Мастер-класс от Анны Тарыбы"
/>
<ImageUploadField return (
value={item.image} <>
onChange={(v) => updateItem({ ...item, image: v })} <InputField
/> label="Заголовок секции"
value={data.title}
onChange={(v) => update({ ...data, title: v })}
/>
<div className="grid gap-3 sm:grid-cols-2"> <FilterBar
<AutocompleteMulti search={search}
label="Тренер" onSearchChange={setSearch}
value={item.trainer} dateFilter={dateFilter}
onChange={(v) => updateItem({ ...item, trainer: v })} onDateFilterChange={setDateFilter}
options={trainers} locationFilter={locationFilter}
placeholder="Добавить тренера..." onLocationFilterChange={setLocationFilter}
/> locations={locations}
<AutocompleteMulti totalCount={data.items.length}
label="Стиль" visibleCount={visibleCount}
value={item.style} />
onChange={(v) => updateItem({ ...item, style: v })}
options={styles}
placeholder="Добавить стиль..."
/>
</div>
<PriceField <ArrayEditor
label="Стоимость" label="Мастер-классы"
value={item.cost} items={displayItems}
onChange={(v) => updateItem({ ...item, cost: v })} onChange={(items) => update({ ...data, items })}
placeholder="40" collapsible
/> hiddenItems={hiddenItems}
getItemTitle={(item) => {
const base = item.location
? `${item.title || "Без названия"} · ${item.location}`
: item.title || "Без названия";
return base;
}}
getItemBadge={(item) =>
isItemArchived(item) ? (
<span className="shrink-0 rounded-full bg-neutral-700/50 px-2 py-0.5 text-[10px] font-medium text-neutral-500">
Архив
</span>
) : null
}
renderItem={(item, _i, updateItem) => {
const archived = isItemArchived(item);
return (
<div className={`space-y-3 ${archived ? "opacity-50" : ""}`}>
{locations.length > 0 && ( <ValidationHint
<LocationSelect fields={{
value={item.location || ""} Название: item.title,
onChange={(v) => Тренер: item.trainer,
updateItem({ ...item, location: v || undefined }) Стиль: item.style,
} Стоимость: item.cost,
locations={locations} "Даты и время": (item.slots ?? []).length > 0 ? "ok" : "",
/> }}
)} />
<SlotsField <InputField
slots={item.slots ?? []} label="Название"
onChange={(slots) => updateItem({ ...item, slots })} value={item.title}
/> onChange={(v) => updateItem({ ...item, title: v })}
placeholder="Мастер-класс от Анны Тарыбы"
/>
<TextareaField <PhotoPreview
label="Описание" value={item.image}
value={item.description || ""} onChange={(v) => updateItem({ ...item, image: v })}
onChange={(v) => />
updateItem({ ...item, description: v || undefined })
}
placeholder="Описание мастер-класса, трек, стиль..."
rows={3}
/>
<InstagramLinkField <div className="grid gap-3 sm:grid-cols-2">
value={item.instagramUrl || ""} <AutocompleteMulti
onChange={(v) => label="Тренер"
updateItem({ ...item, instagramUrl: v || undefined }) value={item.trainer}
} onChange={(v) => updateItem({ ...item, trainer: v })}
/> options={trainers}
placeholder="Добавить тренера..."
/>
<AutocompleteMulti
label="Стиль"
value={item.style}
onChange={(v) => updateItem({ ...item, style: v })}
options={styles}
placeholder="Добавить стиль..."
/>
</div>
<ParticipantLimits <PriceField
min={item.minParticipants ?? 0} label="Стоимость"
max={item.maxParticipants ?? 0} value={item.cost}
onMinChange={(v) => updateItem({ ...item, minParticipants: v })} onChange={(v) => updateItem({ ...item, cost: v })}
onMaxChange={(v) => updateItem({ ...item, maxParticipants: v })} placeholder="40"
/> />
</div> {locations.length > 0 && (
)} <LocationSelect
createItem={() => ({ value={item.location || ""}
title: "", onChange={(v) =>
image: "", updateItem({ ...item, location: v || undefined })
slots: [], }
trainer: "", locations={locations}
cost: "", />
style: "", )}
})}
addLabel="Добавить мастер-класс" <SlotsField
/> slots={item.slots ?? []}
</> onChange={(slots) => updateItem({ ...item, slots })}
)} />
<TextareaField
label="Описание"
value={item.description || ""}
onChange={(v) =>
updateItem({ ...item, description: v || undefined })
}
placeholder="Описание мастер-класса, трек, стиль..."
rows={3}
/>
<InstagramLinkField
value={item.instagramUrl || ""}
onChange={(v) =>
updateItem({ ...item, instagramUrl: v || undefined })
}
/>
<ParticipantLimits
min={item.minParticipants ?? 0}
max={item.maxParticipants ?? 0}
onMinChange={(v) => updateItem({ ...item, minParticipants: v })}
onMaxChange={(v) => updateItem({ ...item, maxParticipants: v })}
/>
</div>
);
}}
createItem={() => ({
title: "",
image: "",
slots: [],
trainer: "",
cost: "",
style: "",
})}
addLabel="Добавить мастер-класс"
/>
</>
);
}}
</SectionEditor> </SectionEditor>
); );
} }