- New admin page for shared popup texts (success, waiting list, error, Instagram hint) - Removed popup fields from MC and Open Day admin editors - All SignupModals now read from centralized popups config - Stored as "popups" section in DB with fallback defaults
495 lines
16 KiB
TypeScript
495 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useRef, useEffect, useMemo } from "react";
|
||
import { SectionEditor } from "../_components/SectionEditor";
|
||
import { InputField, TextareaField, ParticipantLimits, AutocompleteMulti } from "../_components/FormField";
|
||
import { ArrayEditor } from "../_components/ArrayEditor";
|
||
import { Plus, X, Upload, Loader2, ImageIcon, AlertCircle, Check } from "lucide-react";
|
||
import { adminFetch } from "@/lib/csrf";
|
||
import type { MasterClassItem, MasterClassSlot } from "@/types/content";
|
||
|
||
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();
|
||
return (
|
||
<div>
|
||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||
<div className="flex rounded-lg border border-white/10 bg-neutral-800 focus-within:border-gold transition-colors">
|
||
<input
|
||
type="text"
|
||
value={raw}
|
||
onChange={(e) => {
|
||
const v = e.target.value;
|
||
onChange(v ? `${v} BYN` : "");
|
||
}}
|
||
placeholder={placeholder ?? "0"}
|
||
className="flex-1 bg-transparent px-4 py-2.5 text-white placeholder-neutral-500 outline-none min-w-0"
|
||
/>
|
||
<span className="flex items-center pr-4 text-sm font-medium text-gold select-none">
|
||
BYN
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface MasterClassesData {
|
||
title: string;
|
||
items: MasterClassItem[];
|
||
}
|
||
|
||
|
||
// --- Location Select ---
|
||
function LocationSelect({
|
||
value,
|
||
onChange,
|
||
locations,
|
||
}: {
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
locations: { name: string; address: string }[];
|
||
}) {
|
||
return (
|
||
<div>
|
||
<label className="block text-sm text-neutral-400 mb-1.5">Локация</label>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{locations.map((loc) => {
|
||
const active = value === loc.name;
|
||
return (
|
||
<button
|
||
key={loc.name}
|
||
type="button"
|
||
onClick={() => onChange(active ? "" : loc.name)}
|
||
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||
active
|
||
? "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"
|
||
}`}
|
||
>
|
||
{active && <Check size={10} className="inline mr-1" />}
|
||
{loc.name}
|
||
<span className="text-neutral-500 ml-1 text-[10px]">
|
||
{loc.address.replace(/^г\.\s*\S+,\s*/, "")}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// --- Date List ---
|
||
function calcDurationText(startTime: string, endTime: string): string {
|
||
if (!startTime || !endTime) return "";
|
||
const [sh, sm] = startTime.split(":").map(Number);
|
||
const [eh, em] = endTime.split(":").map(Number);
|
||
const mins = (eh * 60 + em) - (sh * 60 + sm);
|
||
if (mins <= 0) return "";
|
||
const h = Math.floor(mins / 60);
|
||
const m = mins % 60;
|
||
if (h > 0 && m > 0) return `${h} ч ${m} мин`;
|
||
if (h > 0) return h === 1 ? "1 час" : h < 5 ? `${h} часа` : `${h} часов`;
|
||
return `${m} мин`;
|
||
}
|
||
|
||
function SlotsField({
|
||
slots,
|
||
onChange,
|
||
}: {
|
||
slots: MasterClassSlot[];
|
||
onChange: (slots: MasterClassSlot[]) => void;
|
||
}) {
|
||
function addSlot() {
|
||
// Copy time from last slot for convenience
|
||
const last = slots[slots.length - 1];
|
||
onChange([...slots, {
|
||
date: "",
|
||
startTime: last?.startTime ?? "",
|
||
endTime: last?.endTime ?? "",
|
||
}]);
|
||
}
|
||
|
||
function updateSlot(index: number, patch: Partial<MasterClassSlot>) {
|
||
onChange(slots.map((s, i) => (i === index ? { ...s, ...patch } : s)));
|
||
}
|
||
|
||
function removeSlot(index: number) {
|
||
onChange(slots.filter((_, i) => i !== index));
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<label className="block text-sm text-neutral-400 mb-1.5">Даты и время</label>
|
||
<div className="space-y-2">
|
||
{slots.map((slot, i) => {
|
||
const dur = calcDurationText(slot.startTime, slot.endTime);
|
||
return (
|
||
<div key={i} className="flex items-center gap-2 flex-wrap">
|
||
<input
|
||
type="date"
|
||
value={slot.date}
|
||
onChange={(e) => updateSlot(i, { date: e.target.value })}
|
||
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"
|
||
value={slot.startTime}
|
||
onChange={(e) => updateSlot(i, { startTime: e.target.value })}
|
||
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]"
|
||
/>
|
||
<span className="text-neutral-500 text-xs">–</span>
|
||
<input
|
||
type="time"
|
||
value={slot.endTime}
|
||
onChange={(e) => updateSlot(i, { endTime: e.target.value })}
|
||
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]"
|
||
/>
|
||
{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>
|
||
);
|
||
})}
|
||
<button
|
||
type="button"
|
||
onClick={addSlot}
|
||
className="flex items-center gap-2 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-3 py-1.5 text-xs text-neutral-500 hover:text-gold hover:border-gold/30 transition-colors"
|
||
>
|
||
<Plus size={12} />
|
||
Добавить дату
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// --- Image Upload ---
|
||
function ImageUploadField({
|
||
value,
|
||
onChange,
|
||
}: {
|
||
value: string;
|
||
onChange: (path: string) => void;
|
||
}) {
|
||
const [uploading, setUploading] = useState(false);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
setUploading(true);
|
||
const formData = new FormData();
|
||
formData.append("file", file);
|
||
formData.append("folder", "master-classes");
|
||
try {
|
||
const res = await adminFetch("/api/admin/upload", {
|
||
method: "POST",
|
||
body: formData,
|
||
});
|
||
const result = await res.json();
|
||
if (result.path) onChange(result.path);
|
||
} catch {
|
||
/* upload failed */
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<label className="block text-sm text-neutral-400 mb-1.5">
|
||
Изображение
|
||
</label>
|
||
{value ? (
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex items-center gap-1.5 rounded-lg bg-neutral-700/50 px-3 py-2 text-sm text-neutral-300">
|
||
<ImageIcon size={14} className="text-gold" />
|
||
<span className="max-w-[200px] truncate">
|
||
{value.split("/").pop()}
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => onChange("")}
|
||
className="rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
|
||
>
|
||
<X size={14} />
|
||
</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>
|
||
) : (
|
||
<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">
|
||
{uploading ? (
|
||
<Loader2 size={16} className="animate-spin" />
|
||
) : (
|
||
<Upload size={16} />
|
||
)}
|
||
{uploading ? "Загрузка..." : "Загрузить изображение"}
|
||
<input
|
||
ref={inputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={handleUpload}
|
||
className="hidden"
|
||
/>
|
||
</label>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// --- Instagram Link Field ---
|
||
function InstagramLinkField({
|
||
value,
|
||
onChange,
|
||
}: {
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
}) {
|
||
const error = getInstagramError(value);
|
||
|
||
return (
|
||
<div>
|
||
<label className="block text-sm text-neutral-400 mb-1.5">
|
||
Ссылка на Instagram
|
||
</label>
|
||
<div className="relative">
|
||
<input
|
||
type="url"
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
placeholder="https://instagram.com/p/... или /reel/..."
|
||
className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none transition-colors ${
|
||
error ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||
}`}
|
||
/>
|
||
{value && !error && (
|
||
<Check
|
||
size={14}
|
||
className="absolute right-3 top-1/2 -translate-y-1/2 text-emerald-400"
|
||
/>
|
||
)}
|
||
{error && (
|
||
<AlertCircle
|
||
size={14}
|
||
className="absolute right-3 top-1/2 -translate-y-1/2 text-red-400"
|
||
/>
|
||
)}
|
||
</div>
|
||
{error && (
|
||
<p className="mt-1 text-[11px] text-red-400">{error}</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function getInstagramError(url: string): string | null {
|
||
if (!url) return null;
|
||
try {
|
||
const parsed = new URL(url);
|
||
const host = parsed.hostname.replace("www.", "");
|
||
if (host !== "instagram.com" && host !== "instagr.am") {
|
||
return "Ссылка должна вести на instagram.com";
|
||
}
|
||
const validPaths = ["/p/", "/reel/", "/tv/", "/stories/"];
|
||
if (!validPaths.some((p) => parsed.pathname.includes(p))) {
|
||
return "Ожидается ссылка на пост, рилс или сторис (/p/, /reel/, /tv/)";
|
||
}
|
||
return null;
|
||
} catch {
|
||
return "Некорректная ссылка";
|
||
}
|
||
}
|
||
|
||
// --- Validation badge ---
|
||
function ValidationHint({ fields }: { fields: Record<string, string> }) {
|
||
const missing = Object.entries(fields).filter(([, v]) => !(v ?? "").trim());
|
||
if (missing.length === 0) return null;
|
||
return (
|
||
<div className="flex items-start gap-1.5 rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-xs text-red-400">
|
||
<AlertCircle size={12} className="shrink-0 mt-0.5" />
|
||
<span>
|
||
Не заполнено: {missing.map(([k]) => k).join(", ")}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// --- Main page ---
|
||
export default function MasterClassesEditorPage() {
|
||
const [trainers, setTrainers] = useState<string[]>([]);
|
||
const [styles, setStyles] = useState<string[]>([]);
|
||
const [locations, setLocations] = useState<{ name: string; address: string }[]>([]);
|
||
|
||
useEffect(() => {
|
||
// Fetch trainers from team
|
||
adminFetch("/api/admin/team")
|
||
.then((r) => r.json())
|
||
.then((members: { name: string }[]) => {
|
||
setTrainers(members.map((m) => m.name));
|
||
})
|
||
.catch(() => {});
|
||
|
||
// Fetch styles from classes section
|
||
adminFetch("/api/admin/sections/classes")
|
||
.then((r) => r.json())
|
||
.then((data: { items: { name: string }[] }) => {
|
||
setStyles(data.items.map((c) => c.name));
|
||
})
|
||
.catch(() => {});
|
||
|
||
// Fetch locations from schedule section
|
||
adminFetch("/api/admin/sections/schedule")
|
||
.then((r) => r.json())
|
||
.then((data: { locations: { name: string; address: string }[] }) => {
|
||
setLocations(data.locations);
|
||
})
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
return (
|
||
<SectionEditor<MasterClassesData>
|
||
sectionKey="masterClasses"
|
||
title="Мастер-классы"
|
||
>
|
||
{(data, update) => (
|
||
<>
|
||
<InputField
|
||
label="Заголовок секции"
|
||
value={data.title}
|
||
onChange={(v) => update({ ...data, title: v })}
|
||
/>
|
||
|
||
<ArrayEditor
|
||
label="Мастер-классы"
|
||
items={data.items}
|
||
onChange={(items) => update({ ...data, items })}
|
||
renderItem={(item, _i, updateItem) => (
|
||
<div className="space-y-3">
|
||
<ValidationHint
|
||
fields={{
|
||
Название: item.title,
|
||
Тренер: item.trainer,
|
||
Стиль: item.style,
|
||
Стоимость: item.cost,
|
||
"Даты и время": (item.slots ?? []).length > 0 ? "ok" : "",
|
||
}}
|
||
/>
|
||
|
||
<InputField
|
||
label="Название"
|
||
value={item.title}
|
||
onChange={(v) => updateItem({ ...item, title: v })}
|
||
placeholder="Мастер-класс от Анны Тарыбы"
|
||
/>
|
||
|
||
<ImageUploadField
|
||
value={item.image}
|
||
onChange={(v) => updateItem({ ...item, image: v })}
|
||
/>
|
||
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
<AutocompleteMulti
|
||
label="Тренер"
|
||
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>
|
||
|
||
<PriceField
|
||
label="Стоимость"
|
||
value={item.cost}
|
||
onChange={(v) => updateItem({ ...item, cost: v })}
|
||
placeholder="40"
|
||
/>
|
||
|
||
{locations.length > 0 && (
|
||
<LocationSelect
|
||
value={item.location || ""}
|
||
onChange={(v) =>
|
||
updateItem({ ...item, location: v || undefined })
|
||
}
|
||
locations={locations}
|
||
/>
|
||
)}
|
||
|
||
<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>
|
||
);
|
||
}
|