Files
blackheart-website/src/app/admin/master-classes/page.tsx
diana.dolgolyova 6c485872b0 feat: centralize popup texts in new admin tab /admin/popups
- 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
2026-03-25 23:48:06 +03:00

495 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}