diff --git a/src/app/admin/_components/ImageCropField.tsx b/src/app/admin/_components/ImageCropField.tsx index b8d595a..1fa8618 100644 --- a/src/app/admin/_components/ImageCropField.tsx +++ b/src/app/admin/_components/ImageCropField.tsx @@ -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} (перетащите · Ctrl+колёсико для масштаба) {image ? ( -
+
void; -}) { - const [uploading, setUploading] = useState(false); - - async function handleUpload(e: React.ChangeEvent) { - 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 ( -
- - {value ? ( -
- - -
- ) : ( - - )} -
- ); -} +// PhotoPreview replaced by shared ImageCropField // --- Instagram Link Field --- function InstagramLinkField({ @@ -581,51 +502,76 @@ export default function MasterClassesEditorPage() { placeholder="Мастер-класс от Анны Тарыбы" /> - updateItem({ ...item, image: v })} - /> - -
- updateItem({ ...item, trainer: v })} - options={trainers} - placeholder="Добавить тренера..." - /> - updateItem({ ...item, style: v })} - options={styles} - placeholder="Добавить стиль..." - /> + {/* Photo + key fields side by side */} +
+
+ updateItem({ ...item, image: d.image, imageFocalX: d.focalX, imageFocalY: d.focalY, imageZoom: d.zoom })} + /> +
+
+
+ updateItem({ ...item, trainer: v })} + options={trainers} + placeholder="Добавить тренера..." + /> + updateItem({ ...item, style: v })} + options={styles} + placeholder="Добавить стиль..." + /> +
+
+ updateItem({ ...item, cost: v })} + placeholder="40" + /> + {locations.length > 0 && ( + + updateItem({ ...item, location: v || undefined }) + } + locations={locations} + /> + )} +
+ + updateItem({ ...item, instagramUrl: v || undefined }) + } + /> + updateItem({ ...item, minParticipants: v })} + onMaxChange={(v) => updateItem({ ...item, maxParticipants: v })} + /> +
- updateItem({ ...item, cost: v })} - placeholder="40" - /> - - {locations.length > 0 && ( - - updateItem({ ...item, location: v || undefined }) - } - locations={locations} - /> - )} - updateItem({ ...item, slots })} /> - @@ -634,21 +580,6 @@ export default function MasterClassesEditorPage() { placeholder="Описание мастер-класса, трек, стиль..." rows={3} /> - - - updateItem({ ...item, instagramUrl: v || undefined }) - } - /> - - updateItem({ ...item, minParticipants: v })} - onMaxChange={(v) => updateItem({ ...item, maxParticipants: v })} - /> -
); }} diff --git a/src/app/admin/pricing/page.tsx b/src/app/admin/pricing/page.tsx index 18e06fb..5233943 100644 --- a/src/app/admin/pricing/page.tsx +++ b/src/app/admin/pricing/page.tsx @@ -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; diff --git a/src/app/page.tsx b/src/app/page.tsx index 55e1f6b..44c2641 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -49,7 +49,7 @@ export default function HomePage() { {openDayData && content?.popups && } {content?.schedule && } {content?.pricing && } - {content?.masterClasses && } + {content?.masterClasses && } {content?.news && } {content?.faq && } {content?.contact && } diff --git a/src/components/sections/MasterClasses.tsx b/src/components/sections/MasterClasses.tsx index 6d8110f..b44c449 100644 --- a/src/components/sections/MasterClasses.tsx +++ b/src/components/sections/MasterClasses.tsx @@ -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; 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 ( +
+
e.stopPropagation()} + > + {/* Content */} +
+ {/* Top row: tags + price + close aligned */} +
+ + {item.style} + + {duration && ( + + + {duration} + + )} + + {item.cost && ( + + {item.cost}{!item.cost.includes("BYN") ? " BYN" : ""} + + )} + +
+ + {/* Title */} +

{item.title}

+ + {/* Trainer */} + + + {/* Description */} + {item.description && ( +
+ {formatMarkup(item.description)} +
+ )} + + {/* All dates */} +
+

Даты

+ {slots.length === 0 ? ( +

Скоро — дата уточняется

+ ) : ( +
+ {slots.filter(s => s.date).sort((a, b) => a.date.localeCompare(b.date)).map((slot, i) => { + const d = parseDate(slot.date); + return ( +
+ + + {d.getDate()} {MONTHS_RU[d.getMonth()]} ({WEEKDAYS_RU[d.getDay()]}) + + {slot.startTime && ( + + {slot.startTime}–{slot.endTime} + + )} +
+ ); + })} +
+ )} +
+ + {/* Location */} + {item.location && ( +
+ + {item.location}{locAddress ? ` · ${locAddress}` : ""} +
+ )} + + {/* Instagram */} + {item.instagramUrl && ( + + + Подробнее в Instagram + + )} + + {/* Signup button */} + +
+
+
+ ); +} + 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 ( -
+
{/* Full-bleed image or placeholder */}
{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, + }} /> -
+ {/* No overlay — text shadow handles readability */} ) : (
@@ -124,7 +289,7 @@ function MasterClassCard({
{/* Content overlay at bottom */} -
+
{/* Tags row */}
@@ -144,44 +309,37 @@ function MasterClassCard({ {/* Trainer */} -
+
+ {/* Divider */}
{/* Date + Location */} -
+
- {slotsDisplay} + {firstDate}{slots.length > 1 && +{slots.length - 1}}
{item.location && (
- {item.location} + + {item.location} + {(() => { + const loc = locations?.find(l => l.name === item.location); + return loc?.address ? ` · ${loc.address}` : ""; + })()} +
)}
- {/* Spots info */} - {(maxP > 0 || (item.minParticipants && item.minParticipants > 0)) && ( -
- {maxP > 0 && ( - - {currentRegs}/{maxP} мест - - )} - {item.minParticipants && item.minParticipants > 0 && currentRegs < item.minParticipants && ( - - мин. {item.minParticipants} для проведения - - )} -
- )} - {/* Price + Actions */}
+ )}
); } -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(null); + const [detailItem, setDetailItem] = useState(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} /> ))}
@@ -275,6 +440,16 @@ export function MasterClasses({ data, regCounts = {}, popups }: MasterClassesPro
+ {/* Detail popup */} + {detailItem && ( + setDetailItem(null)} + onSignup={() => { setDetailItem(null); setSignupTitle(detailItem.title); }} + /> + )} + setSignupTitle(null)} diff --git a/src/types/content.ts b/src/types/content.ts index 8b68adc..e34014f 100644 --- a/src/types/content.ts +++ b/src/types/content.ts @@ -72,6 +72,9 @@ export interface MasterClassSlot { export interface MasterClassItem { title: string; image: string; + imageFocalX?: number; + imageFocalY?: number; + imageZoom?: number; slots: MasterClassSlot[]; trainer: string; cost: string; @@ -136,6 +139,7 @@ export interface SiteContent { subtitle: string; items: PricingItem[]; rentalTitle: string; + rentalSubtitle?: string; rentalItems: PricingItem[]; rules: string[]; showContactHint?: boolean;