feat: MC detail popup, image crop editor, empty dates support

Master Classes:
- Detail popup on card click with description, all dates, location+address
- Card shows only first date (or "Скоро" if no dates)
- Trainer name clickable to open bio
- Text backdrop panel on cards for readability
- No photo overlay darkening
- Fix crash when MC has no/empty slots
- Price "BYN" no longer duplicated
- Admin: ImageCropField replaces PhotoPreview (with focal/zoom)
- Admin: RichTextarea for description
- Admin: photo+fields side-by-side layout, fixed photo width

Pricing:
- Added rentalSubtitle field for rental tab info
This commit is contained in:
2026-04-02 15:00:44 +03:00
parent 2d13b82507
commit 2c6bee9eb1
6 changed files with 293 additions and 179 deletions
+4 -1
View File
@@ -17,6 +17,8 @@ interface ImageCropFieldProps extends ImageCropData {
onChange: (data: ImageCropData) => void;
/** Aspect ratio CSS class for the preview. Default: "aspect-[16/9]" */
aspect?: string;
/** Max width CSS class for the preview container. Default: "max-w-3xl" */
maxWidth?: string;
label?: string;
}
@@ -28,6 +30,7 @@ export function ImageCropField({
folder,
onChange,
aspect = "aspect-[16/9]",
maxWidth = "max-w-3xl",
label = "Фото",
}: ImageCropFieldProps) {
const [uploading, setUploading] = useState(false);
@@ -101,7 +104,7 @@ export function ImageCropField({
{label} <span className="text-neutral-600">(перетащите · Ctrl+колёсико для масштаба)</span>
</label>
{image ? (
<div className="max-w-3xl space-y-2">
<div className={`${maxWidth} space-y-2`}>
<div
ref={containerRef}
className={`relative ${aspect} overflow-hidden rounded-lg border border-white/10 cursor-grab active:cursor-grabbing select-none`}
+67 -136
View File
@@ -1,12 +1,12 @@
"use client";
import { useState, useEffect } from "react";
import Image from "next/image";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField, ParticipantLimits, AutocompleteMulti } from "../_components/FormField";
import { InputField, TextareaField, RichTextarea, ParticipantLimits, AutocompleteMulti } from "../_components/FormField";
import { ImageCropField } from "../_components/ImageCropField";
import { ArrayEditor } from "../_components/ArrayEditor";
import { PriceField } from "../_components/PriceField";
import { Plus, X, Upload, Loader2, AlertCircle, Check, Search } from "lucide-react";
import { Plus, X, Loader2, AlertCircle, Check, Search } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import type { MasterClassItem, MasterClassSlot } from "@/types/content";
@@ -200,86 +200,7 @@ function SlotsField({
);
}
// --- Photo Preview (like trainer page) ---
function PhotoPreview({
value,
onChange,
}: {
value: string;
onChange: (path: string) => void;
}) {
const [uploading, setUploading] = useState(false);
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="relative">
<label className="relative block w-full aspect-[16/9] overflow-hidden rounded-xl border border-white/10 cursor-pointer group">
<Image
src={value}
alt="Превью"
fill
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
type="button"
onClick={() => onChange("")}
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} />
</button>
</div>
) : (
<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 ? (
<Loader2 size={20} className="animate-spin" />
) : (
<>
<Upload size={20} />
<span>Загрузить изображение</span>
</>
)}
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
</label>
)}
</div>
);
}
// PhotoPreview replaced by shared ImageCropField
// --- Instagram Link Field ---
function InstagramLinkField({
@@ -581,51 +502,76 @@ export default function MasterClassesEditorPage() {
placeholder="Мастер-класс от Анны Тарыбы"
/>
<PhotoPreview
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="Добавить стиль..."
/>
{/* Photo + key fields side by side */}
<div className="flex gap-5 items-center">
<div className="w-[220px] shrink-0">
<ImageCropField
image={item.image || ""}
focalX={item.imageFocalX ?? 50}
focalY={item.imageFocalY ?? 50}
zoom={item.imageZoom ?? 1}
folder="master-classes"
label="Фото"
aspect="aspect-[2/3]"
maxWidth="max-w-[220px]"
onChange={(d) => updateItem({ ...item, image: d.image, imageFocalX: d.focalX, imageFocalY: d.focalY, imageZoom: d.zoom })}
/>
</div>
<div className="flex-1 space-y-3 min-w-0">
<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>
<div className="grid gap-3 sm:grid-cols-2">
<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}
/>
)}
</div>
<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>
</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
<RichTextarea
label="Описание"
value={item.description || ""}
onChange={(v) =>
@@ -634,21 +580,6 @@ export default function MasterClassesEditorPage() {
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>
);
}}
+1
View File
@@ -21,6 +21,7 @@ interface PricingData {
subtitle: string;
items: PricingItem[];
rentalTitle: string;
rentalSubtitle?: string;
rentalItems: { name: string; price: string; note?: string }[];
rules: string[];
showContactHint?: boolean;
+1 -1
View File
@@ -49,7 +49,7 @@ export default function HomePage() {
{openDayData && content?.popups && <OpenDay data={openDayData} popups={content.popups} teamMembers={content.team?.members ?? []} locations={content.schedule?.locations} />}
{content?.schedule && <Schedule data={content.schedule} scheduleConfig={content.scheduleConfig} classItems={content.classes?.items ?? []} teamMembers={content.team?.members ?? []} />}
{content?.pricing && <Pricing data={content.pricing} />}
{content?.masterClasses && <MasterClasses data={content.masterClasses} regCounts={mcRegCounts} popups={content.popups} />}
{content?.masterClasses && <MasterClasses data={content.masterClasses} regCounts={mcRegCounts} popups={content.popups} locations={content.schedule?.locations} />}
{content?.news && <News data={content.news} />}
{content?.faq && <FAQ data={content.faq} />}
{content?.contact && <Contact data={content.contact} />}