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:
@@ -17,6 +17,8 @@ interface ImageCropFieldProps extends ImageCropData {
|
|||||||
onChange: (data: ImageCropData) => void;
|
onChange: (data: ImageCropData) => void;
|
||||||
/** Aspect ratio CSS class for the preview. Default: "aspect-[16/9]" */
|
/** Aspect ratio CSS class for the preview. Default: "aspect-[16/9]" */
|
||||||
aspect?: string;
|
aspect?: string;
|
||||||
|
/** Max width CSS class for the preview container. Default: "max-w-3xl" */
|
||||||
|
maxWidth?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +30,7 @@ export function ImageCropField({
|
|||||||
folder,
|
folder,
|
||||||
onChange,
|
onChange,
|
||||||
aspect = "aspect-[16/9]",
|
aspect = "aspect-[16/9]",
|
||||||
|
maxWidth = "max-w-3xl",
|
||||||
label = "Фото",
|
label = "Фото",
|
||||||
}: ImageCropFieldProps) {
|
}: ImageCropFieldProps) {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
@@ -101,7 +104,7 @@ export function ImageCropField({
|
|||||||
{label} <span className="text-neutral-600">(перетащите · Ctrl+колёсико для масштаба)</span>
|
{label} <span className="text-neutral-600">(перетащите · Ctrl+колёсико для масштаба)</span>
|
||||||
</label>
|
</label>
|
||||||
{image ? (
|
{image ? (
|
||||||
<div className="max-w-3xl space-y-2">
|
<div className={`${maxWidth} space-y-2`}>
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={`relative ${aspect} overflow-hidden rounded-lg border border-white/10 cursor-grab active:cursor-grabbing select-none`}
|
className={`relative ${aspect} overflow-hidden rounded-lg border border-white/10 cursor-grab active:cursor-grabbing select-none`}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } 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, RichTextarea, ParticipantLimits, AutocompleteMulti } from "../_components/FormField";
|
||||||
|
import { ImageCropField } from "../_components/ImageCropField";
|
||||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||||
import { PriceField } from "../_components/PriceField";
|
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 { adminFetch } from "@/lib/csrf";
|
||||||
import type { MasterClassItem, MasterClassSlot } from "@/types/content";
|
import type { MasterClassItem, MasterClassSlot } from "@/types/content";
|
||||||
|
|
||||||
@@ -200,86 +200,7 @@ function SlotsField({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Photo Preview (like trainer page) ---
|
// PhotoPreview replaced by shared ImageCropField
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Instagram Link Field ---
|
// --- Instagram Link Field ---
|
||||||
function InstagramLinkField({
|
function InstagramLinkField({
|
||||||
@@ -581,51 +502,76 @@ export default function MasterClassesEditorPage() {
|
|||||||
placeholder="Мастер-класс от Анны Тарыбы"
|
placeholder="Мастер-класс от Анны Тарыбы"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PhotoPreview
|
{/* Photo + key fields side by side */}
|
||||||
value={item.image}
|
<div className="flex gap-5 items-center">
|
||||||
onChange={(v) => updateItem({ ...item, image: v })}
|
<div className="w-[220px] shrink-0">
|
||||||
/>
|
<ImageCropField
|
||||||
|
image={item.image || ""}
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
focalX={item.imageFocalX ?? 50}
|
||||||
<AutocompleteMulti
|
focalY={item.imageFocalY ?? 50}
|
||||||
label="Тренер"
|
zoom={item.imageZoom ?? 1}
|
||||||
value={item.trainer}
|
folder="master-classes"
|
||||||
onChange={(v) => updateItem({ ...item, trainer: v })}
|
label="Фото"
|
||||||
options={trainers}
|
aspect="aspect-[2/3]"
|
||||||
placeholder="Добавить тренера..."
|
maxWidth="max-w-[220px]"
|
||||||
/>
|
onChange={(d) => updateItem({ ...item, image: d.image, imageFocalX: d.focalX, imageFocalY: d.focalY, imageZoom: d.zoom })}
|
||||||
<AutocompleteMulti
|
/>
|
||||||
label="Стиль"
|
</div>
|
||||||
value={item.style}
|
<div className="flex-1 space-y-3 min-w-0">
|
||||||
onChange={(v) => updateItem({ ...item, style: v })}
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
options={styles}
|
<AutocompleteMulti
|
||||||
placeholder="Добавить стиль..."
|
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>
|
</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
|
<SlotsField
|
||||||
slots={item.slots ?? []}
|
slots={item.slots ?? []}
|
||||||
onChange={(slots) => updateItem({ ...item, slots })}
|
onChange={(slots) => updateItem({ ...item, slots })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextareaField
|
<RichTextarea
|
||||||
label="Описание"
|
label="Описание"
|
||||||
value={item.description || ""}
|
value={item.description || ""}
|
||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
@@ -634,21 +580,6 @@ export default function MasterClassesEditorPage() {
|
|||||||
placeholder="Описание мастер-класса, трек, стиль..."
|
placeholder="Описание мастер-класса, трек, стиль..."
|
||||||
rows={3}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface PricingData {
|
|||||||
subtitle: string;
|
subtitle: string;
|
||||||
items: PricingItem[];
|
items: PricingItem[];
|
||||||
rentalTitle: string;
|
rentalTitle: string;
|
||||||
|
rentalSubtitle?: string;
|
||||||
rentalItems: { name: string; price: string; note?: string }[];
|
rentalItems: { name: string; price: string; note?: string }[];
|
||||||
rules: string[];
|
rules: string[];
|
||||||
showContactHint?: boolean;
|
showContactHint?: boolean;
|
||||||
|
|||||||
+1
-1
@@ -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} />}
|
{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?.schedule && <Schedule data={content.schedule} scheduleConfig={content.scheduleConfig} classItems={content.classes?.items ?? []} teamMembers={content.team?.members ?? []} />}
|
||||||
{content?.pricing && <Pricing data={content.pricing} />}
|
{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?.news && <News data={content.news} />}
|
||||||
{content?.faq && <FAQ data={content.faq} />}
|
{content?.faq && <FAQ data={content.faq} />}
|
||||||
{content?.contact && <Contact data={content.contact} />}
|
{content?.contact && <Contact data={content.contact} />}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useEffect } from "react";
|
||||||
import Image from "next/image";
|
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 { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
import { Reveal } from "@/components/ui/Reveal";
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
import { SignupModal } from "@/components/ui/SignupModal";
|
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 {
|
interface MasterClassesProps {
|
||||||
data: SiteContent["masterClasses"];
|
data: SiteContent["masterClasses"];
|
||||||
regCounts?: Record<string, number>;
|
regCounts?: Record<string, number>;
|
||||||
popups?: SiteContent["popups"];
|
popups?: SiteContent["popups"];
|
||||||
|
locations?: ScheduleLocation[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const MONTHS_RU = [
|
const MONTHS_RU = [
|
||||||
@@ -75,9 +77,12 @@ function calcDuration(slot: MasterClassSlot): string {
|
|||||||
function isUpcoming(item: MasterClassItem): boolean {
|
function isUpcoming(item: MasterClassItem): boolean {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const slots = item.slots ?? [];
|
const slots = item.slots ?? [];
|
||||||
if (slots.length === 0) return false;
|
// No dates = "coming soon", still show it
|
||||||
// Series MC: check earliest slot — if first session passed, group already started
|
if (slots.length === 0) return true;
|
||||||
const earliestSlot = slots.reduce((min, s) => s.date < min.date ? s : min, slots[0]);
|
// 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);
|
const d = parseDate(earliestSlot.date);
|
||||||
if (earliestSlot.startTime) {
|
if (earliestSlot.startTime) {
|
||||||
const [h, m] = earliestSlot.startTime.split(":").map(Number);
|
const [h, m] = earliestSlot.startTime.split(":").map(Number);
|
||||||
@@ -88,22 +93,178 @@ function isUpcoming(item: MasterClassItem): boolean {
|
|||||||
return d > now;
|
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({
|
function MasterClassCard({
|
||||||
item,
|
item,
|
||||||
currentRegs,
|
currentRegs,
|
||||||
onSignup,
|
onSignup,
|
||||||
|
onDetail,
|
||||||
|
locations,
|
||||||
}: {
|
}: {
|
||||||
item: MasterClassItem;
|
item: MasterClassItem;
|
||||||
currentRegs: number;
|
currentRegs: number;
|
||||||
onSignup: () => void;
|
onSignup: () => void;
|
||||||
|
onDetail: () => void;
|
||||||
|
locations?: ScheduleLocation[];
|
||||||
}) {
|
}) {
|
||||||
const duration = item.slots[0] ? calcDuration(item.slots[0]) : "";
|
const slots = item.slots ?? [];
|
||||||
const slotsDisplay = formatSlots(item.slots);
|
const duration = slots[0] ? calcDuration(slots[0]) : "";
|
||||||
|
const firstDate = formatFirstDate(slots);
|
||||||
const maxP = item.maxParticipants ?? 0;
|
const maxP = item.maxParticipants ?? 0;
|
||||||
const isFull = maxP > 0 && currentRegs >= maxP;
|
const isFull = maxP > 0 && currentRegs >= maxP;
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Full-bleed image or placeholder */}
|
||||||
<div className="relative aspect-[3/4] sm:aspect-[2/3] w-full overflow-hidden">
|
<div className="relative aspect-[3/4] sm:aspect-[2/3] w-full overflow-hidden">
|
||||||
{item.image ? (
|
{item.image ? (
|
||||||
@@ -115,8 +276,12 @@ function MasterClassCard({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||||
className="object-cover transition-transform duration-700 group-hover:scale-110"
|
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" />
|
<div className="absolute inset-0 bg-gradient-to-b from-neutral-800 to-black" />
|
||||||
@@ -124,7 +289,7 @@ function MasterClassCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content overlay at bottom */}
|
{/* 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 */}
|
{/* Tags row */}
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
<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">
|
<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>
|
</h3>
|
||||||
|
|
||||||
{/* Trainer */}
|
{/* 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" />
|
<User size={13} className="shrink-0" />
|
||||||
<span>{item.trainer}</span>
|
<span>{item.trainer}</span>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="mt-4 mb-4 h-px bg-gradient-to-r from-gold/40 via-gold/20 to-transparent" />
|
<div className="mt-4 mb-4 h-px bg-gradient-to-r from-gold/40 via-gold/20 to-transparent" />
|
||||||
|
|
||||||
{/* Date + Location */}
|
{/* 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">
|
<div className="flex items-center gap-2">
|
||||||
<Calendar size={13} className="shrink-0 text-gold/70" />
|
<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>
|
</div>
|
||||||
{item.location && (
|
{item.location && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<MapPin size={13} className="shrink-0 text-gold/70" />
|
<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>
|
||||||
)}
|
)}
|
||||||
</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 */}
|
{/* Price + Actions */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
@@ -208,27 +366,32 @@ function MasterClassCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Price floating tag */}
|
</div>
|
||||||
<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">
|
{/* Price tag — top right corner */}
|
||||||
{item.cost}
|
{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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MasterClasses({ data, regCounts = {}, popups }: MasterClassesProps) {
|
export function MasterClasses({ data, regCounts = {}, popups, locations }: MasterClassesProps) {
|
||||||
if (!data?.items?.length) return null;
|
if (!data?.items?.length) return null;
|
||||||
const [signupTitle, setSignupTitle] = useState<string | null>(null);
|
const [signupTitle, setSignupTitle] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [detailItem, setDetailItem] = useState<MasterClassItem | null>(null);
|
||||||
|
|
||||||
const upcoming = useMemo(() => {
|
const upcoming = useMemo(() => {
|
||||||
return data.items
|
return data.items
|
||||||
.filter(isUpcoming)
|
.filter((item) => item.title && isUpcoming(item))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const aFirst = parseDate(a.slots[0]?.date ?? "");
|
const aFirst = parseDate((a.slots ?? [])[0]?.date ?? "9999");
|
||||||
const bFirst = parseDate(b.slots[0]?.date ?? "");
|
const bFirst = parseDate((b.slots ?? [])[0]?.date ?? "9999");
|
||||||
return aFirst.getTime() - bFirst.getTime();
|
return aFirst.getTime() - bFirst.getTime();
|
||||||
});
|
});
|
||||||
}, [data.items]);
|
}, [data.items]);
|
||||||
@@ -268,6 +431,8 @@ export function MasterClasses({ data, regCounts = {}, popups }: MasterClassesPro
|
|||||||
item={item}
|
item={item}
|
||||||
currentRegs={regCounts[item.title] ?? 0}
|
currentRegs={regCounts[item.title] ?? 0}
|
||||||
onSignup={() => setSignupTitle(item.title)}
|
onSignup={() => setSignupTitle(item.title)}
|
||||||
|
onDetail={() => setDetailItem(item)}
|
||||||
|
locations={locations}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -275,6 +440,16 @@ export function MasterClasses({ data, regCounts = {}, popups }: MasterClassesPro
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Detail popup */}
|
||||||
|
{detailItem && (
|
||||||
|
<MasterClassDetail
|
||||||
|
item={detailItem}
|
||||||
|
locations={locations}
|
||||||
|
onClose={() => setDetailItem(null)}
|
||||||
|
onSignup={() => { setDetailItem(null); setSignupTitle(detailItem.title); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<SignupModal
|
<SignupModal
|
||||||
open={signupTitle !== null}
|
open={signupTitle !== null}
|
||||||
onClose={() => setSignupTitle(null)}
|
onClose={() => setSignupTitle(null)}
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ export interface MasterClassSlot {
|
|||||||
export interface MasterClassItem {
|
export interface MasterClassItem {
|
||||||
title: string;
|
title: string;
|
||||||
image: string;
|
image: string;
|
||||||
|
imageFocalX?: number;
|
||||||
|
imageFocalY?: number;
|
||||||
|
imageZoom?: number;
|
||||||
slots: MasterClassSlot[];
|
slots: MasterClassSlot[];
|
||||||
trainer: string;
|
trainer: string;
|
||||||
cost: string;
|
cost: string;
|
||||||
@@ -136,6 +139,7 @@ export interface SiteContent {
|
|||||||
subtitle: string;
|
subtitle: string;
|
||||||
items: PricingItem[];
|
items: PricingItem[];
|
||||||
rentalTitle: string;
|
rentalTitle: string;
|
||||||
|
rentalSubtitle?: string;
|
||||||
rentalItems: PricingItem[];
|
rentalItems: PricingItem[];
|
||||||
rules: string[];
|
rules: string[];
|
||||||
showContactHint?: boolean;
|
showContactHint?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user