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
+216 -41
View File
@@ -1,17 +1,19 @@
"use client";
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect } from "react";
import Image from "next/image";
import { Calendar, Clock, User, MapPin, Instagram } from "lucide-react";
import { Calendar, Clock, User, MapPin, Instagram, X } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import { SignupModal } from "@/components/ui/SignupModal";
import type { SiteContent, MasterClassItem, MasterClassSlot } from "@/types";
import type { SiteContent, MasterClassItem, MasterClassSlot, ScheduleLocation } from "@/types";
import { formatMarkup } from "@/lib/markup";
interface MasterClassesProps {
data: SiteContent["masterClasses"];
regCounts?: Record<string, number>;
popups?: SiteContent["popups"];
locations?: ScheduleLocation[];
}
const MONTHS_RU = [
@@ -75,9 +77,12 @@ function calcDuration(slot: MasterClassSlot): string {
function isUpcoming(item: MasterClassItem): boolean {
const now = new Date();
const slots = item.slots ?? [];
if (slots.length === 0) return false;
// Series MC: check earliest slot — if first session passed, group already started
const earliestSlot = slots.reduce((min, s) => s.date < min.date ? s : min, slots[0]);
// No dates = "coming soon", still show it
if (slots.length === 0) return true;
// Has dates with actual values
const validSlots = slots.filter(s => s.date);
if (validSlots.length === 0) return true;
const earliestSlot = validSlots.reduce((min, s) => s.date < min.date ? s : min, validSlots[0]);
const d = parseDate(earliestSlot.date);
if (earliestSlot.startTime) {
const [h, m] = earliestSlot.startTime.split(":").map(Number);
@@ -88,22 +93,178 @@ function isUpcoming(item: MasterClassItem): boolean {
return d > now;
}
function formatFirstDate(slots: MasterClassSlot[]): string {
const validSlots = (slots ?? []).filter(s => s.date);
if (validSlots.length === 0) return "Скоро";
const first = validSlots.sort((a, b) => a.date.localeCompare(b.date))[0];
const d = parseDate(first.date);
const day = d.getDate();
const month = MONTHS_RU[d.getMonth()];
const weekday = WEEKDAYS_RU[d.getDay()];
const time = first.startTime ? `, ${first.startTime}` : "";
return `${day} ${month} (${weekday})${time}`;
}
function MasterClassDetail({
item,
locations,
onClose,
onSignup,
}: {
item: MasterClassItem;
locations?: ScheduleLocation[];
onClose: () => void;
onSignup: () => void;
}) {
const slots = item.slots ?? [];
const duration = slots[0] ? calcDuration(slots[0]) : "";
const locAddress = locations?.find(l => l.name === item.location)?.address;
useEffect(() => {
document.body.style.overflow = "hidden";
function handleKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
window.addEventListener("keydown", handleKey);
return () => { document.body.style.overflow = ""; window.removeEventListener("keydown", handleKey); };
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm" onClick={onClose}>
<div
role="dialog"
aria-modal="true"
aria-label={item.title}
className="relative w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl border border-white/10 bg-neutral-950 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Content */}
<div className="p-6 space-y-4">
{/* Top row: tags + price + close aligned */}
<div className="flex items-center gap-2">
<span className="rounded-full border border-gold/40 bg-gold/10 px-3 py-0.5 text-xs font-semibold uppercase tracking-wider text-gold">
{item.style}
</span>
{duration && (
<span className="flex items-center gap-1 text-xs text-white/50">
<Clock size={11} />
{duration}
</span>
)}
<span className="flex-1" />
{item.cost && (
<span className="text-lg font-bold text-gold">
{item.cost}{!item.cost.includes("BYN") ? " BYN" : ""}
</span>
)}
<button
onClick={onClose}
aria-label="Закрыть"
className="h-9 w-9 flex items-center justify-center rounded-full text-white/50 hover:text-white hover:bg-white/10 transition-colors shrink-0 -mr-2"
>
<X size={18} />
</button>
</div>
{/* Title */}
<h2 className="text-2xl font-bold text-white">{item.title}</h2>
{/* Trainer */}
<button
onClick={() => window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: item.trainer.split(" · ")[0] }))}
className="flex items-center gap-2 text-sm text-white/80 hover:text-gold transition-colors"
>
<User size={14} />
{item.trainer}
</button>
{/* Description */}
{item.description && (
<div className="text-sm leading-relaxed text-white/60">
{formatMarkup(item.description)}
</div>
)}
{/* All dates */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-white/40 uppercase tracking-wider">Даты</h3>
{slots.length === 0 ? (
<p className="text-sm text-gold">Скоро дата уточняется</p>
) : (
<div className="space-y-1.5">
{slots.filter(s => s.date).sort((a, b) => a.date.localeCompare(b.date)).map((slot, i) => {
const d = parseDate(slot.date);
return (
<div key={i} className="flex items-center gap-3 text-sm">
<Calendar size={13} className="shrink-0 text-gold/60" />
<span className="text-white/80">
{d.getDate()} {MONTHS_RU[d.getMonth()]} ({WEEKDAYS_RU[d.getDay()]})
</span>
{slot.startTime && (
<span className="text-white/50">
{slot.startTime}{slot.endTime}
</span>
)}
</div>
);
})}
</div>
)}
</div>
{/* Location */}
{item.location && (
<div className="flex items-center gap-2 text-sm text-white/60">
<MapPin size={13} className="text-gold/60" />
<span>{item.location}{locAddress ? ` · ${locAddress}` : ""}</span>
</div>
)}
{/* Instagram */}
{item.instagramUrl && (
<a
href={item.instagramUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-gold transition-colors"
>
<Instagram size={14} />
Подробнее в Instagram
</a>
)}
{/* Signup button */}
<button
onClick={(e) => { e.stopPropagation(); onSignup(); }}
className="w-full rounded-xl bg-gold py-3.5 text-sm font-bold uppercase tracking-wide text-black hover:bg-gold-light transition-colors"
>
Записаться
</button>
</div>
</div>
</div>
);
}
function MasterClassCard({
item,
currentRegs,
onSignup,
onDetail,
locations,
}: {
item: MasterClassItem;
currentRegs: number;
onSignup: () => void;
onDetail: () => void;
locations?: ScheduleLocation[];
}) {
const duration = item.slots[0] ? calcDuration(item.slots[0]) : "";
const slotsDisplay = formatSlots(item.slots);
const slots = item.slots ?? [];
const duration = slots[0] ? calcDuration(slots[0]) : "";
const firstDate = formatFirstDate(slots);
const maxP = item.maxParticipants ?? 0;
const isFull = maxP > 0 && currentRegs >= maxP;
return (
<div className="group relative flex w-full max-w-sm flex-col overflow-hidden rounded-2xl bg-black">
<div className="group relative flex w-full max-w-sm flex-col overflow-hidden rounded-2xl bg-black cursor-pointer" onClick={onDetail}>
{/* Full-bleed image or placeholder */}
<div className="relative aspect-[3/4] sm:aspect-[2/3] w-full overflow-hidden">
{item.image ? (
@@ -115,8 +276,12 @@ function MasterClassCard({
loading="lazy"
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
className="object-cover transition-transform duration-700 group-hover:scale-110"
style={{
objectPosition: `${item.imageFocalX ?? 50}% ${item.imageFocalY ?? 50}%`,
transform: item.imageZoom && item.imageZoom > 1 ? `scale(${item.imageZoom})` : undefined,
}}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/20 to-transparent opacity-80 transition-opacity duration-500 group-hover:opacity-90" />
{/* No overlay — text shadow handles readability */}
</>
) : (
<div className="absolute inset-0 bg-gradient-to-b from-neutral-800 to-black" />
@@ -124,7 +289,7 @@ function MasterClassCard({
</div>
{/* Content overlay at bottom */}
<div className="absolute inset-x-0 bottom-0 flex flex-col p-5 sm:p-6">
<div className="absolute inset-x-0 bottom-0 m-3 sm:m-4 flex flex-col rounded-2xl bg-black/60 backdrop-blur-md border border-white/10 p-4 sm:p-5">
{/* Tags row */}
<div className="flex flex-wrap items-center gap-2 mb-3">
<span className="inline-flex items-center gap-1 rounded-full border border-gold/40 bg-black/40 px-2.5 py-0.5 text-xs font-semibold uppercase tracking-wider text-gold backdrop-blur-md">
@@ -144,44 +309,37 @@ function MasterClassCard({
</h3>
{/* Trainer */}
<div className="mt-2 flex items-center gap-2 text-sm text-white/50">
<button
onClick={(e) => { e.stopPropagation(); window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: item.trainer.split(" · ")[0] })); }}
className="mt-2 flex items-center gap-2 text-sm text-white/90 hover:text-gold transition-colors cursor-pointer"
>
<User size={13} className="shrink-0" />
<span>{item.trainer}</span>
</div>
</button>
{/* Divider */}
<div className="mt-4 mb-4 h-px bg-gradient-to-r from-gold/40 via-gold/20 to-transparent" />
{/* Date + Location */}
<div className="flex flex-col gap-1.5 text-sm text-white/60 mb-4">
<div className="flex flex-col gap-1.5 text-sm text-white/90 mb-4">
<div className="flex items-center gap-2">
<Calendar size={13} className="shrink-0 text-gold/70" />
<span>{slotsDisplay}</span>
<span>{firstDate}{slots.length > 1 && <span className="text-white/50"> +{slots.length - 1}</span>}</span>
</div>
{item.location && (
<div className="flex items-center gap-2">
<MapPin size={13} className="shrink-0 text-gold/70" />
<span>{item.location}</span>
<span>
{item.location}
{(() => {
const loc = locations?.find(l => l.name === item.location);
return loc?.address ? ` · ${loc.address}` : "";
})()}
</span>
</div>
)}
</div>
{/* Spots info */}
{(maxP > 0 || (item.minParticipants && item.minParticipants > 0)) && (
<div className="mb-3 flex items-center gap-3 text-xs">
{maxP > 0 && (
<span className={isFull ? "text-amber-400" : "text-white/40"}>
{currentRegs}/{maxP} мест
</span>
)}
{item.minParticipants && item.minParticipants > 0 && currentRegs < item.minParticipants && (
<span className="text-red-400/70">
мин. {item.minParticipants} для проведения
</span>
)}
</div>
)}
{/* Price + Actions */}
<div className="flex items-center gap-3">
<button
@@ -208,27 +366,32 @@ function MasterClassCard({
)}
</div>
{/* Price floating tag */}
<div className="absolute top-0 right-0 -translate-y-full mr-5 sm:mr-6 mb-2">
<span className="inline-block rounded-full bg-white/10 px-3 py-1 text-sm font-bold text-white backdrop-blur-md">
{item.cost}
</div>
{/* Price tag — top right corner */}
{item.cost && (
<div className="absolute top-3 right-3 z-10">
<span className="inline-block rounded-full bg-gold/90 px-3 py-1 text-sm font-bold text-black shadow-lg">
{item.cost}{!item.cost.includes("BYN") ? " BYN" : ""}
</span>
</div>
</div>
)}
</div>
);
}
export function MasterClasses({ data, regCounts = {}, popups }: MasterClassesProps) {
export function MasterClasses({ data, regCounts = {}, popups, locations }: MasterClassesProps) {
if (!data?.items?.length) return null;
const [signupTitle, setSignupTitle] = useState<string | null>(null);
const [detailItem, setDetailItem] = useState<MasterClassItem | null>(null);
const upcoming = useMemo(() => {
return data.items
.filter(isUpcoming)
.filter((item) => item.title && isUpcoming(item))
.sort((a, b) => {
const aFirst = parseDate(a.slots[0]?.date ?? "");
const bFirst = parseDate(b.slots[0]?.date ?? "");
const aFirst = parseDate((a.slots ?? [])[0]?.date ?? "9999");
const bFirst = parseDate((b.slots ?? [])[0]?.date ?? "9999");
return aFirst.getTime() - bFirst.getTime();
});
}, [data.items]);
@@ -268,6 +431,8 @@ export function MasterClasses({ data, regCounts = {}, popups }: MasterClassesPro
item={item}
currentRegs={regCounts[item.title] ?? 0}
onSignup={() => setSignupTitle(item.title)}
onDetail={() => setDetailItem(item)}
locations={locations}
/>
))}
</div>
@@ -275,6 +440,16 @@ export function MasterClasses({ data, regCounts = {}, popups }: MasterClassesPro
</Reveal>
</div>
{/* Detail popup */}
{detailItem && (
<MasterClassDetail
item={detailItem}
locations={locations}
onClose={() => setDetailItem(null)}
onSignup={() => { setDetailItem(null); setSignupTitle(detailItem.title); }}
/>
)}
<SignupModal
open={signupTitle !== null}
onClose={() => setSignupTitle(null)}