feat: min/max participants — shared ParticipantLimits component
- New ParticipantLimits component in FormField.tsx (reusable) - Used in both Open Day settings and MC editor — identical layout - Open Day: event-level min/max (DB migration 15) - MC: per-event min/max (JSON fields) - Public: waiting list when full, spots counter, amber success modal
This commit is contained in:
@@ -10,6 +10,7 @@ import type { SiteContent, MasterClassItem, MasterClassSlot } from "@/types";
|
||||
|
||||
interface MasterClassesProps {
|
||||
data: SiteContent["masterClasses"];
|
||||
regCounts?: Record<string, number>;
|
||||
}
|
||||
|
||||
const MONTHS_RU = [
|
||||
@@ -88,13 +89,17 @@ function isUpcoming(item: MasterClassItem): boolean {
|
||||
|
||||
function MasterClassCard({
|
||||
item,
|
||||
currentRegs,
|
||||
onSignup,
|
||||
}: {
|
||||
item: MasterClassItem;
|
||||
currentRegs: number;
|
||||
onSignup: () => void;
|
||||
}) {
|
||||
const duration = item.slots[0] ? calcDuration(item.slots[0]) : "";
|
||||
const slotsDisplay = formatSlots(item.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">
|
||||
@@ -160,13 +165,33 @@ function MasterClassCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Spots info */}
|
||||
{(maxP > 0 || (item.minParticipants && item.minParticipants > 0)) && (
|
||||
<div className="mb-3 flex items-center gap-3 text-[11px]">
|
||||
{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
|
||||
onClick={onSignup}
|
||||
className="flex-1 rounded-xl bg-gold py-3 text-sm font-bold text-black uppercase tracking-wide transition-all hover:bg-gold-light hover:shadow-lg hover:shadow-gold/25 cursor-pointer"
|
||||
className={`flex-1 rounded-xl py-3 text-sm font-bold uppercase tracking-wide transition-all cursor-pointer ${
|
||||
isFull
|
||||
? "bg-amber-500/15 text-amber-400 hover:bg-amber-500/25"
|
||||
: "bg-gold text-black hover:bg-gold-light hover:shadow-lg hover:shadow-gold/25"
|
||||
}`}
|
||||
>
|
||||
Записаться
|
||||
{isFull ? "Лист ожидания" : "Записаться"}
|
||||
</button>
|
||||
{item.instagramUrl && (
|
||||
<button
|
||||
@@ -192,7 +217,7 @@ function MasterClassCard({
|
||||
);
|
||||
}
|
||||
|
||||
export function MasterClasses({ data }: MasterClassesProps) {
|
||||
export function MasterClasses({ data, regCounts = {} }: MasterClassesProps) {
|
||||
const [signupTitle, setSignupTitle] = useState<string | null>(null);
|
||||
|
||||
const upcoming = useMemo(() => {
|
||||
@@ -238,6 +263,7 @@ export function MasterClasses({ data }: MasterClassesProps) {
|
||||
<MasterClassCard
|
||||
key={item.title}
|
||||
item={item}
|
||||
currentRegs={regCounts[item.title] ?? 0}
|
||||
onSignup={() => setSignupTitle(item.title)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -93,6 +93,7 @@ export function OpenDay({ data }: OpenDayProps) {
|
||||
<ClassCard
|
||||
key={cls.id}
|
||||
cls={cls}
|
||||
maxParticipants={event.maxParticipants}
|
||||
onSignup={setSignup}
|
||||
/>
|
||||
))}
|
||||
@@ -137,9 +138,11 @@ export function OpenDay({ data }: OpenDayProps) {
|
||||
|
||||
function ClassCard({
|
||||
cls,
|
||||
maxParticipants = 0,
|
||||
onSignup,
|
||||
}: {
|
||||
cls: OpenDayClass;
|
||||
maxParticipants?: number;
|
||||
onSignup: (info: { classId: number; label: string }) => void;
|
||||
}) {
|
||||
const label = `${cls.style} · ${cls.trainer} · ${cls.startTime}–${cls.endTime}`;
|
||||
@@ -161,8 +164,10 @@ function ClassCard({
|
||||
);
|
||||
}
|
||||
|
||||
const isFull = maxParticipants > 0 && cls.bookingCount >= maxParticipants;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-neutral-900 p-4 transition-all hover:border-gold/20">
|
||||
<div className={`rounded-xl border p-4 transition-all ${isFull ? "border-white/5 bg-neutral-900/50" : "border-white/10 bg-neutral-900 hover:border-gold/20"}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs text-gold font-medium">{cls.startTime}–{cls.endTime}</span>
|
||||
@@ -171,12 +176,21 @@ function ClassCard({
|
||||
<Users size={10} />
|
||||
{cls.trainer}
|
||||
</p>
|
||||
{maxParticipants > 0 && (
|
||||
<p className={`text-[10px] mt-1 ${isFull ? "text-amber-400" : "text-neutral-500"}`}>
|
||||
{cls.bookingCount}/{maxParticipants} мест
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onSignup({ classId: cls.id, label })}
|
||||
className="shrink-0 rounded-full bg-gold/10 border border-gold/20 px-4 py-2 text-xs font-medium text-gold hover:bg-gold/20 transition-colors cursor-pointer"
|
||||
className={`shrink-0 rounded-full px-4 py-2 text-xs font-medium transition-colors cursor-pointer ${
|
||||
isFull
|
||||
? "bg-amber-500/10 border border-amber-500/20 text-amber-400 hover:bg-amber-500/20"
|
||||
: "bg-gold/10 border border-gold/20 text-gold hover:bg-gold/20"
|
||||
}`}
|
||||
>
|
||||
Записаться
|
||||
{isFull ? "Лист ожидания" : "Записаться"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user