Files
hope-events/frontend/src/app/dashboard/supervisor/events/page.tsx
T
joshuaandClaude Sonnet 5 8e6cb542d9 Full site redesign, help system, and dashboard stats fixes
Multi-phase visual facelift (design tokens, dashboards, sidebar/navbar
shell, per-page help guides, and a layout/content pass across every
remaining page) plus backend fixes to the dashboard KPI stats:

- Admin/Supervisor dashboard KPIs (revenue, donations, registrations,
  tickets sold) now use a rolling trailing-month window (today back one
  calendar month, e.g. 9 May - 8 June if today is 8 June) instead of
  calendar month-to-date, which under-counted for most of the month.
  The comparison window shifts the same way, so like is still compared
  with like.
- Reports deep-links from those stat tiles now match the same window
  (range=trailing_month, replacing range=this_month).
- Design tokens (brand-* Tailwind scale + shadcn CSS variables), a
  site-wide contextual help button, fixed dashboard sidebar/navbar,
  Admin/Supervisor/Staff/User dashboard rebuilds backed by a new
  GET /api/stats/overview endpoint, a dedicated Contact page, Site
  Settings restyle with WhatsApp config folded in, and an Account
  activity feed backed by a new SecurityEvent model.
- Every remaining page (home, events, registration flow, auth, legal,
  payment results, and every Admin/Supervisor/Staff/User tool page)
  restyled onto the same design tokens, several with real layout
  upgrades (home hero, events list/detail, donate page, auth pages).
- 20+ new dedicated help guides so the whole site has page-specific
  help content instead of falling back to a generic guide.
- Assorted fixes surfaced along the way: donation-leg double-counting
  in payment stats, donations not counting toward revenue, refund
  netting in per-method report breakdowns, and donation
  over-allocation after a refund.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 15:00:10 +02:00

1512 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, resolveToApiOrigin } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Calendar } from "lucide-react";
// ─── helpers ────────────────────────────────────────────────────────────────
function toLocalDT(input: string | Date | null | undefined): string {
if (!input) return "";
const d = new Date(input);
if (isNaN(d.getTime())) return "";
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return `${y}-${m}-${day}T${hh}:${mm}`;
}
const DT_MIN = "2000-01-01T00:00";
const DT_MAX = "2099-12-31T23:59";
function DTInput({ label, value, onChange, hint, required }: { label: string; value: string; onChange: (v: string) => void; hint?: string; required?: boolean }) {
return (
<div>
<label className="block text-xs text-gray-600 mb-1">{label} {required && <span className="text-red-500">*</span>}</label>
<input type="datetime-local" min={DT_MIN} max={DT_MAX} required={required}
className="w-full border rounded px-3 py-2 text-sm"
value={value} onChange={e => onChange(e.target.value)} />
{hint && <p className="text-[10px] text-gray-400 mt-0.5">{hint}</p>}
</div>
);
}
// ─── types ───────────────────────────────────────────────────────────────────
type FormFieldType = "yes_no" | "text" | "date" | "numeric" | "statement" | "paragraph";
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string | null; options?: any }[] };
type Tier = { id?: string; deadline: string; price: string; stockLimit: string; order?: number };
type Variant = {
id?: string;
name: string;
price: string; // empty → use option base price
stockLimit: string;
earlyBirdTiers: Tier[];
};
type OptionDraft = {
id?: string;
name: string;
price: string;
isMainTicket: boolean;
stockLimit: string;
earlyBirdTiers: Tier[]; // option-level (used only when no variants)
variants: Variant[];
};
type SectionDraft = {
name: string;
optionIndices: number[];
};
// ─── sub-components ─────────────────────────────────────────────────────────
function UploadImageButton({ onUploaded, label = "Upload image" }: { onUploaded: (url: string) => void; label?: string }) {
const { token } = useAuth();
const [uploading, setUploading] = useState(false);
const ref = useRef<HTMLInputElement | null>(null);
return (
<div className="inline-flex items-center">
<input ref={ref} type="file" accept="image/*" className="hidden" onChange={async e => {
const file = e.target.files?.[0];
if (!file || !token) return;
setUploading(true);
try {
const fd = new FormData();
fd.append("image", file);
const res: any = await apiFetch("/api/uploads/event-image", { method: "POST", body: fd, authToken: token });
if (res?.url || res?.path) onUploaded(res.url || res.path);
} finally { setUploading(false); if (ref.current) ref.current.value = ""; }
}} />
<button type="button" onClick={() => ref.current?.click()} disabled={uploading}
className="px-3 py-1.5 text-sm rounded border border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 disabled:opacity-50">
{uploading ? "Uploading…" : label}
</button>
</div>
);
}
function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: EventFormDef) => void }) {
const fields = value.fields || [];
const add = (type: FormFieldType) => onChange({ ...value, fields: [...fields, { type, label: "", isRequired: false, helpText: "" }] });
const upd = (idx: number, patch: any) => { const c = fields.slice(); c[idx] = { ...c[idx], ...patch }; onChange({ ...value, fields: c }); };
const rem = (idx: number) => { const c = fields.slice(); c.splice(idx, 1); onChange({ ...value, fields: c }); };
const mov = (idx: number, dir: -1 | 1) => {
const c = fields.slice(); const t = idx + dir;
if (t < 0 || t >= c.length) return;
[c[idx], c[t]] = [c[t], c[idx]]; onChange({ ...value, fields: c });
};
const typeLabel = (t: FormFieldType) => ({ text: "Text", numeric: "Number", date: "Date", yes_no: "Yes/No", statement: "Statement", paragraph: "Heading" }[t] || t);
return (
<div className="border rounded-lg p-3 bg-gray-50">
<div className="flex items-center justify-between mb-3">
<div className="text-sm font-semibold">Registration Form Fields</div>
<label className="text-xs flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" checked={!!value.isRequired} onChange={e => onChange({ ...value, isRequired: e.target.checked })} />
Required before tickets generate
</label>
</div>
{fields.length === 0
? <p className="text-xs text-gray-500 italic mb-3">No fields yet.</p>
: (
<ul className="space-y-2 mb-3">
{fields.map((f, idx) => (
<li key={idx} className="bg-white border rounded p-2">
<div className="flex items-center gap-1 mb-1.5">
<span className="text-[10px] px-1.5 py-0.5 rounded bg-brand-50 text-brand-700 font-medium">{typeLabel(f.type)}</span>
<span className="text-xs text-gray-400">#{idx + 1}</span>
<div className="flex-1" />
<button type="button" className="text-gray-400 hover:text-gray-700 px-1" onClick={() => mov(idx, -1)} disabled={idx === 0}></button>
<button type="button" className="text-gray-400 hover:text-gray-700 px-1" onClick={() => mov(idx, 1)} disabled={idx === fields.length - 1}></button>
<button type="button" className="text-xs px-2 py-0.5 rounded bg-red-50 text-red-700 border border-red-200" onClick={() => rem(idx)}>Remove</button>
</div>
<div className="flex flex-wrap gap-2 mb-1.5">
<select className="border rounded px-2 py-1 text-xs" value={f.type} onChange={e => upd(idx, { type: e.target.value })}>
<option value="text">Text</option><option value="numeric">Number</option><option value="date">Date</option>
<option value="yes_no">Yes/No</option><option value="statement">Statement</option><option value="paragraph">Heading+para</option>
</select>
{f.type !== "statement" && f.type !== "paragraph" && (
<label className="text-xs flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={!!f.isRequired} onChange={e => upd(idx, { isRequired: e.target.checked })} /> Required
</label>
)}
</div>
<input className="border rounded px-2 py-1 text-sm w-full mb-1" placeholder={f.type === "paragraph" ? "Heading text" : "Label"} value={f.label} onChange={e => upd(idx, { label: e.target.value })} />
{f.type === "paragraph"
? <textarea className="border rounded px-2 py-1 text-xs w-full" rows={2} placeholder="Body text" value={f.helpText || ""} onChange={e => upd(idx, { helpText: e.target.value })} />
: f.type !== "statement"
? <input className="border rounded px-2 py-1 text-xs w-full" placeholder="Help text (optional)" value={f.helpText || ""} onChange={e => upd(idx, { helpText: e.target.value })} />
: null}
</li>
))}
</ul>
)}
<div className="flex gap-2 flex-wrap">
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={() => add("text")}>+ Question</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700" onClick={() => add("statement")}>+ Statement</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700" onClick={() => add("paragraph")}>+ Heading</button>
</div>
</div>
);
}
// ─── Items & Pricing substep editors ────────────────────────────────────────
/** Substep 0 — bare option rows */
function OptionsEditor({ options, onChange, required }: { options: OptionDraft[]; onChange: (o: OptionDraft[]) => void; required?: boolean }) {
const upd = (i: number, patch: Partial<OptionDraft>) => { const c = options.slice(); c[i] = { ...c[i], ...patch }; onChange(c); };
const rem = (i: number) => { const c = options.slice(); c.splice(i, 1); onChange(c); };
const add = () => {
const hasMain = options.some(o => o.isMainTicket);
onChange([...options, { name: "", price: "", isMainTicket: !hasMain, stockLimit: "0", earlyBirdTiers: [], variants: [] }]);
};
return (
<div className="space-y-2">
{options.map((opt, i) => {
const priceMissing = required && opt.price.trim() === "";
return (
<div key={i} className="border rounded-lg p-3 bg-white space-y-2">
<div className="flex flex-wrap items-end gap-2">
<div className="flex-1 min-w-36">
<label className="block text-[10px] text-gray-500 mb-0.5">Option name</label>
<input className="border rounded px-2 py-1.5 text-sm w-full" placeholder="e.g. Ticket, Hoodie, Meal"
value={opt.name} onChange={e => upd(i, { name: e.target.value })} />
</div>
<div>
<label className="block text-[10px] text-gray-500 mb-0.5">Base price (R) {required && <span className="text-red-500">*</span>}</label>
<input type="number" step="1" min="0" required={required}
className={`border rounded px-2 py-1.5 text-sm w-28 ${priceMissing ? "border-red-300 bg-red-50" : ""}`}
value={opt.price} onChange={e => upd(i, { price: e.target.value })} />
</div>
<div>
<label className="block text-[10px] text-gray-500 mb-0.5">Stock (0=)</label>
<input type="number" step="1" min="0" className="border rounded px-2 py-1.5 text-sm w-24"
value={opt.stockLimit} onChange={e => upd(i, { stockLimit: e.target.value })} />
</div>
<div className="flex items-center gap-3 pb-1">
<label className="text-xs flex items-center gap-1 cursor-pointer whitespace-nowrap">
<input type="checkbox" checked={opt.isMainTicket} onChange={e => upd(i, { isMainTicket: e.target.checked })} /> Main ticket
</label>
{options.length > 1 && (
<button type="button" className="text-xs px-2 py-1 rounded bg-red-50 text-red-700 border border-red-200 hover:bg-red-100 whitespace-nowrap" onClick={() => rem(i)}>Remove</button>
)}
</div>
</div>
{priceMissing && <p className="text-[10px] text-red-500">Price is required enter 0 for a free option.</p>}
</div>
);
})}
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={add}>+ Add option</button>
</div>
);
}
/** Substep 1 — variants per option */
function VariantsEditor({ options, onChange }: { options: OptionDraft[]; onChange: (o: OptionDraft[]) => void }) {
const updVariants = (optIdx: number, variants: Variant[]) => {
const c = options.slice(); c[optIdx] = { ...c[optIdx], variants }; onChange(c);
};
if (options.length === 0) return <p className="text-sm text-gray-500">Add options first.</p>;
return (
<div className="space-y-4">
{options.map((opt, optIdx) => {
const variants = opt.variants;
const addVariant = () => updVariants(optIdx, [...variants, { name: "", price: "", stockLimit: "0", earlyBirdTiers: [] }]);
const updVariant = (vi: number, patch: Partial<Variant>) => {
const c = variants.slice(); c[vi] = { ...c[vi], ...patch }; updVariants(optIdx, c);
};
const remVariant = (vi: number) => {
const c = variants.slice(); c.splice(vi, 1); updVariants(optIdx, c);
};
return (
<div key={optIdx} className="border rounded-lg overflow-hidden">
<div className="px-3 py-2 bg-gray-50 border-b text-sm font-medium text-gray-700">{opt.name || <span className="italic text-gray-400">Unnamed option</span>}</div>
<div className="p-3 space-y-2">
{variants.length === 0
? <p className="text-xs text-gray-400 italic">No variants uses base price (R{opt.price || "0"}).</p>
: variants.map((v, vi) => (
<div key={vi} className="flex flex-wrap items-end gap-2 bg-white border rounded p-2">
<div className="flex-1 min-w-28">
<label className="block text-[10px] text-gray-500 mb-0.5">Variant name</label>
<input className="border rounded px-2 py-1 text-xs w-full" placeholder="e.g. Adult, Child, Large"
value={v.name} onChange={e => updVariant(vi, { name: e.target.value })} />
</div>
<div>
<label className="block text-[10px] text-gray-500 mb-0.5">Price (blank=base)</label>
<input type="number" step="1" min="0" className="border rounded px-2 py-1 text-xs w-24" placeholder="(optional)"
value={v.price} onChange={e => updVariant(vi, { price: e.target.value })} />
</div>
<div>
<label className="block text-[10px] text-gray-500 mb-0.5">Stock (0=)</label>
<input type="number" step="1" min="0" className="border rounded px-2 py-1 text-xs w-20"
value={v.stockLimit} onChange={e => updVariant(vi, { stockLimit: e.target.value })} />
</div>
<button type="button" className="text-xs px-2 py-1 rounded bg-red-50 text-red-700 border border-red-200 hover:bg-red-100 self-end" onClick={() => remVariant(vi)}></button>
</div>
))}
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={addVariant}>+ Add variant</button>
</div>
</div>
);
})}
</div>
);
}
/** Single tier row editor */
function TierRow({ tier, onChange, onRemove }: { tier: Tier; onChange: (t: Tier) => void; onRemove: () => void }) {
return (
<div className="flex flex-wrap items-end gap-2 bg-white border rounded p-2">
<div>
<div className="text-[10px] text-gray-500 mb-0.5">Deadline</div>
<input type="datetime-local" min={DT_MIN} max={DT_MAX} className="border rounded px-2 py-1 text-xs"
value={tier.deadline} onChange={e => onChange({ ...tier, deadline: e.target.value })} />
</div>
<div>
<div className="text-[10px] text-gray-500 mb-0.5">Price (R)</div>
<input type="number" step="1" min="0" className="border rounded px-2 py-1 text-xs w-24"
value={tier.price} onChange={e => onChange({ ...tier, price: e.target.value })} />
</div>
<div>
<div className="text-[10px] text-gray-500 mb-0.5">Stock (0=)</div>
<input type="number" step="1" min="0" className="border rounded px-2 py-1 text-xs w-20"
value={tier.stockLimit} onChange={e => onChange({ ...tier, stockLimit: e.target.value })} />
</div>
<button type="button" className="text-xs px-2 py-1 rounded bg-red-50 text-red-700 border border-red-200 self-end" onClick={onRemove}></button>
</div>
);
}
/** Substep 2 — early birds per variant (or per option when no variants) */
function EarlyBirdsEditor({ options, onChange }: { options: OptionDraft[]; onChange: (o: OptionDraft[]) => void }) {
if (options.length === 0) return <p className="text-sm text-gray-500">Add options first.</p>;
return (
<div className="space-y-4">
{options.map((opt, optIdx) => {
const hasVariants = opt.variants.length > 0;
if (!hasVariants) {
// Option-level tiers
const updTiers = (tiers: Tier[]) => {
const c = options.slice(); c[optIdx] = { ...c[optIdx], earlyBirdTiers: tiers }; onChange(c);
};
const addTier = () => updTiers([...opt.earlyBirdTiers, { deadline: "", price: "", stockLimit: "0" }]);
return (
<div key={optIdx} className="border rounded-lg overflow-hidden">
<div className="px-3 py-2 bg-gray-50 border-b text-sm font-medium text-gray-700">
{opt.name || <span className="italic text-gray-400">Unnamed option</span>}
<span className="ml-2 text-[10px] text-gray-400">base R{opt.price || "0"}</span>
</div>
<div className="p-3 space-y-2">
{opt.earlyBirdTiers.length === 0 && <p className="text-xs text-gray-400 italic">No early-bird tiers.</p>}
{opt.earlyBirdTiers.map((t, ti) => (
<TierRow key={ti} tier={t}
onChange={updated => { const c = opt.earlyBirdTiers.slice(); c[ti] = updated; updTiers(c); }}
onRemove={() => { const c = opt.earlyBirdTiers.slice(); c.splice(ti, 1); updTiers(c); }} />
))}
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={addTier}>+ Add tier</button>
</div>
</div>
);
}
// Per-variant tiers
return (
<div key={optIdx} className="border rounded-lg overflow-hidden">
<div className="px-3 py-2 bg-gray-50 border-b text-sm font-medium text-gray-700">
{opt.name || <span className="italic text-gray-400">Unnamed option</span>}
</div>
<div className="divide-y">
{opt.variants.map((v, vi) => {
const basePrice = v.price !== "" ? v.price : opt.price;
const updVTiers = (tiers: Tier[]) => {
const co = options.slice();
const cv = co[optIdx].variants.slice();
cv[vi] = { ...cv[vi], earlyBirdTiers: tiers };
co[optIdx] = { ...co[optIdx], variants: cv };
onChange(co);
};
const addTier = () => updVTiers([...v.earlyBirdTiers, { deadline: "", price: "", stockLimit: "0" }]);
return (
<div key={vi} className="p-3 space-y-2">
<div className="text-xs font-medium text-gray-600">
{v.name || <span className="italic text-gray-400">Unnamed variant</span>}
<span className="ml-2 text-gray-400">base R{basePrice || "0"}</span>
</div>
{v.earlyBirdTiers.length === 0 && <p className="text-[10px] text-gray-400 italic">No tiers for this variant.</p>}
{v.earlyBirdTiers.map((t, ti) => (
<TierRow key={ti} tier={t}
onChange={updated => { const c = v.earlyBirdTiers.slice(); c[ti] = updated; updVTiers(c); }}
onRemove={() => { const c = v.earlyBirdTiers.slice(); c.splice(ti, 1); updVTiers(c); }} />
))}
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={addTier}>+ Add tier</button>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
}
// ─── Sections editors ─────────────────────────────────────────────────────────
/** Create-mode: draft sections using option indices */
function SectionsDraftEditor({ sections, options, onChange }: {
sections: SectionDraft[];
options: OptionDraft[];
onChange: (s: SectionDraft[]) => void;
}) {
const [newName, setNewName] = useState("");
const [newIndices, setNewIndices] = useState<number[]>([]);
const create = () => {
if (!newName.trim()) return;
onChange([...sections, { name: newName.trim(), optionIndices: newIndices }]);
setNewName(""); setNewIndices([]);
};
const del = (i: number) => { const c = sections.slice(); c.splice(i, 1); onChange(c); };
return (
<div className="space-y-3">
{sections.length === 0 && <p className="text-xs text-gray-500">No sections yet.</p>}
{sections.map((sec, i) => (
<div key={i} className="border rounded p-3 bg-white flex items-start justify-between gap-3">
<div>
<div className="text-sm font-medium">{sec.name}</div>
<div className="text-xs text-gray-500 mt-0.5">
{sec.optionIndices.length === 0
? "All options"
: sec.optionIndices.map(idx => options[idx]?.name || `Option ${idx + 1}`).join(", ")}
</div>
</div>
<button type="button" className="text-xs px-2 py-1 rounded bg-red-50 text-red-700 border border-red-200 shrink-0" onClick={() => del(i)}>Delete</button>
</div>
))}
<div className="border rounded p-3 bg-gray-50">
<div className="text-xs font-medium mb-2">Create section</div>
<input className="border rounded px-2 py-1 text-sm w-full mb-2" placeholder="Section name"
value={newName} onChange={e => setNewName(e.target.value)} />
{options.length > 0 && (
<>
<div className="text-xs text-gray-600 mb-1">Options in this section:</div>
<div className="flex flex-wrap gap-2 mb-2">
{options.map((opt, idx) => (
<label key={idx} className="text-xs flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={newIndices.includes(idx)}
onChange={e => setNewIndices(e.target.checked ? [...newIndices, idx] : newIndices.filter(x => x !== idx))} />
{opt.name || `Option ${idx + 1}`}
</label>
))}
</div>
</>
)}
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={create}>Create section</button>
</div>
</div>
);
}
/** Edit-mode: live sections manager (DB-backed) */
function SectionsManager({ eventId }: { eventId: string }) {
const { token } = useAuth();
const [sections, setSections] = useState<any[]>([]);
const [eventOptions, setEventOptions] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [newName, setNewName] = useState("");
const [newOptionIds, setNewOptionIds] = useState<string[]>([]);
const load = async () => {
setLoading(true);
try {
const [secList, evFull] = await Promise.all([
apiFetch<any[]>("/api/sections", { authToken: token || undefined }),
apiFetch<any>(`/api/events/${eventId}`)
]);
setSections((secList || []).filter(s => s.eventId === eventId));
setEventOptions(evFull?.eventOptions || []);
} catch {} finally { setLoading(false); }
};
useEffect(() => { if (eventId) load(); }, [eventId]);
const create = async () => {
if (!newName.trim() || !token) return;
await apiFetch("/api/sections", { method: "POST", authToken: token, body: { eventId, name: newName.trim(), allowedOptionIds: newOptionIds } });
setNewName(""); setNewOptionIds([]); load();
};
const del = async (id: string) => {
if (!token) return;
await apiFetch(`/api/sections/${id}`, { method: "DELETE", authToken: token });
load();
};
const update = async (id: string, name: string, optionIds: string[]) => {
if (!token) return;
await apiFetch(`/api/sections/${id}`, { method: "PUT", authToken: token, body: { name, allowedOptionIds: optionIds } });
load();
};
if (loading) return <div className="text-xs text-gray-500">Loading sections</div>;
return (
<div className="space-y-3">
{sections.length === 0 && <p className="text-xs text-gray-500">No sections yet.</p>}
{sections.map(sec => (
<SectionRow key={sec.id} sec={sec} options={eventOptions} onDelete={() => del(sec.id)} onUpdate={update} />
))}
<div className="border rounded p-3 bg-gray-50">
<div className="text-xs font-medium mb-2">Create section</div>
<input className="border rounded px-2 py-1 text-sm w-full mb-2" placeholder="Section name" value={newName} onChange={e => setNewName(e.target.value)} />
<div className="text-xs text-gray-600 mb-1">Options in this section:</div>
<div className="flex flex-wrap gap-2 mb-2">
{eventOptions.map(opt => (
<label key={opt.id} className="text-xs flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={newOptionIds.includes(opt.id)}
onChange={e => setNewOptionIds(e.target.checked ? [...newOptionIds, opt.id] : newOptionIds.filter(id => id !== opt.id))} />
{opt.name}
</label>
))}
</div>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={create}>Create section</button>
</div>
</div>
);
}
function SectionRow({ sec, options, onDelete, onUpdate }: any) {
const [name, setName] = useState(sec.name || "");
const [optIds, setOptIds] = useState<string[]>((sec.allowedOptions || []).map((o: any) => o.eventOptionId));
return (
<div className="border rounded p-3 bg-white">
<input className="border rounded px-2 py-1 text-sm w-full mb-2" value={name} onChange={e => setName(e.target.value)} />
<div className="flex flex-wrap gap-2 mb-2">
{options.map((opt: any) => (
<label key={opt.id} className="text-xs flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={optIds.includes(opt.id)}
onChange={e => setOptIds(e.target.checked ? [...optIds, opt.id] : optIds.filter(id => id !== opt.id))} />
{opt.name}
</label>
))}
</div>
<div className="flex gap-2">
<button type="button" className="text-xs px-2 py-1 rounded bg-brand-600 text-white" onClick={() => onUpdate(sec.id, name, optIds)}>Save</button>
<button type="button" className="text-xs px-2 py-1 rounded bg-red-50 text-red-700 border border-red-200" onClick={onDelete}>Delete</button>
</div>
</div>
);
}
// ─── Attachments ─────────────────────────────────────────────────────────────
function AttachmentsManager({ eventId }: { eventId: string }) {
const { token } = useAuth();
const [items, setItems] = useState<any[]>([]);
const [uploading, setUploading] = useState(false);
const ref = useRef<HTMLInputElement | null>(null);
const load = async () => {
const res = await apiFetch<any[]>(`/api/events/${eventId}/attachments`);
setItems(Array.isArray(res) ? res : []);
};
useEffect(() => { load(); }, [eventId]);
const upload: React.ChangeEventHandler<HTMLInputElement> = async e => {
const file = e.target.files?.[0];
if (!file || !token) return;
setUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
await apiFetch(`/api/events/${eventId}/attachments`, { method: "POST", body: fd, authToken: token });
await load();
} finally { setUploading(false); if (ref.current) ref.current.value = ""; }
};
const del = async (id: string) => {
if (!token || !confirm("Delete this attachment?")) return;
await apiFetch(`/api/events/${eventId}/attachments/${id}`, { method: "DELETE", authToken: token });
await load();
};
return (
<div>
<div className="flex items-center justify-between mb-2">
<div className="text-sm font-medium">Attachments</div>
<div>
<input ref={ref} type="file" className="hidden" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.zip" onChange={upload} />
<button type="button" onClick={() => ref.current?.click()} disabled={uploading} className="text-xs px-2 py-1 rounded border bg-white hover:bg-gray-50">
{uploading ? "Uploading…" : "Upload file"}
</button>
</div>
</div>
{items.length === 0
? <p className="text-xs text-gray-500">No attachments.</p>
: (
<ul className="space-y-1">
{items.map(it => (
<li key={it.id} className="flex items-center justify-between text-xs">
<a href={it.url} target="_blank" rel="noreferrer" className="text-brand-600 hover:underline truncate max-w-xs">{it.originalName}</a>
<button type="button" className="text-red-600 hover:underline ml-2" onClick={() => del(it.id)}>Delete</button>
</li>
))}
</ul>
)}
</div>
);
}
// ─── Notification recipients ───────────────────────────────────────────────
type NotifyUser = { id: string; name: string; email: string };
/** Tracks an anchor element's viewport rect while `active`, updating on any scroll
* (capture-phase, so it catches scrolling inside the modal's clipped containers too)
* or resize. Used to position the search-results dropdown outside the modal's overflow clip. */
function useAnchorRect(anchorRef: React.RefObject<HTMLElement | null>, active: boolean) {
const [rect, setRect] = useState<{ top: number; left: number; width: number; bottom: number } | null>(null);
useLayoutEffect(() => {
if (!active) { setRect(null); return; }
const update = () => {
const el = anchorRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setRect({ top: r.top, left: r.left, width: r.width, bottom: r.bottom });
};
update();
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [anchorRef, active]);
return rect;
}
/** Presentational picker — pure controlled component, no API persistence of its own.
* Used directly for create-mode staging, and wrapped by NotifyRecipientsManager for edit mode. */
function NotifyRecipientsPicker({ selected, onChange, disabled }: { selected: NotifyUser[]; onChange: (next: NotifyUser[]) => void; disabled?: boolean }) {
const { token } = useAuth();
const [search, setSearch] = useState("");
const [results, setResults] = useState<NotifyUser[]>([]);
const [searching, setSearching] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null);
const visibleResults = results.filter(u => !selected.some(s => s.id === u.id));
const dropdownOpen = visibleResults.length > 0;
const rect = useAnchorRect(anchorRef, dropdownOpen);
useEffect(() => {
if (!search.trim()) { setResults([]); return; }
let cancelled = false;
setSearching(true);
const t = setTimeout(async () => {
try {
const res = await apiFetch<any>(`/api/users?search=${encodeURIComponent(search.trim())}&limit=10`, { authToken: token || undefined });
if (!cancelled) setResults(Array.isArray(res?.data) ? res.data : []);
} catch {
if (!cancelled) setResults([]);
} finally {
if (!cancelled) setSearching(false);
}
}, 300);
return () => { cancelled = true; clearTimeout(t); };
}, [search, token]);
const add = (u: NotifyUser) => {
if (selected.some(s => s.id === u.id)) return;
onChange([...selected, u]);
setSearch(""); setResults([]);
};
const remove = (id: string) => onChange(selected.filter(s => s.id !== id));
return (
<div className="space-y-3">
{selected.length === 0 ? (
<p className="text-xs text-gray-400 italic">No recipients selected falling back to the event creator.</p>
) : (
<ul className="space-y-1">
{selected.map(u => (
<li key={u.id} className="flex items-center justify-between text-sm bg-white border rounded px-3 py-1.5">
<span>{u.name} <span className="text-xs text-gray-400">{u.email}</span></span>
<button type="button" className="text-red-600 hover:underline text-xs" onClick={() => remove(u.id)} disabled={disabled}>Remove</button>
</li>
))}
</ul>
)}
<div ref={anchorRef}>
<input
className="border rounded px-3 py-2 text-sm w-full"
placeholder="Search staff by name or email…"
value={search}
onChange={e => setSearch(e.target.value)}
disabled={disabled}
/>
{searching && <div className="text-xs text-gray-400 mt-1">Searching</div>}
</div>
{dropdownOpen && rect && typeof document !== "undefined" && createPortal(
<ul
style={{
position: "fixed",
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
maxHeight: Math.max(160, Math.min(360, window.innerHeight - rect.bottom - 16)),
zIndex: 9999,
}}
className="border rounded bg-white shadow-lg overflow-y-auto"
>
{visibleResults.map(u => (
<li key={u.id} className="px-3 py-1.5 text-sm hover:bg-brand-50 cursor-pointer" onClick={() => add(u)}>
{u.name} <span className="text-xs text-gray-400">{u.email}</span>
</li>
))}
</ul>,
document.body
)}
</div>
);
}
/** Edit-mode manager — persists each change via PUT. Uses `initialRecipients` (already
* loaded by the parent's GET /api/events/:id call) when available so opening this step
* is instant; only falls back to its own (lightweight) GET when that wasn't provided. */
function NotifyRecipientsManager({ eventId, creator, initialRecipients }: { eventId: string; creator?: NotifyUser | null; initialRecipients?: NotifyUser[] | null }) {
const { token } = useAuth();
const hasInitial = Array.isArray(initialRecipients);
const [selected, setSelected] = useState<NotifyUser[]>(initialRecipients || []);
const [loading, setLoading] = useState(!hasInitial);
const [saving, setSaving] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
useEffect(() => {
if (hasInitial) return; // already have the data — skip the network round trip entirely
let cancelled = false;
(async () => {
setLoading(true);
try {
const res = await apiFetch<any>(`/api/events/${eventId}/notify-recipients`, { authToken: token || undefined });
if (!cancelled) setSelected(Array.isArray(res) ? res : []);
} catch (e: any) {
if (!cancelled) setError(e?.message || "Failed to load notification recipients");
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventId]);
const persist = async (next: NotifyUser[]) => {
const prev = selected;
setSelected(next);
setSaving(true);
setError(null);
try {
await apiFetch(`/api/events/${eventId}/notify-recipients`, {
method: "PUT", authToken: token || undefined,
body: { userIds: next.map(u => u.id) },
});
} catch (e: any) {
setSelected(prev);
setError(e?.message || "Failed to save notification recipients");
} finally {
setSaving(false);
}
};
if (loading) return <div className="text-xs text-gray-500">Loading</div>;
return (
<div className="space-y-3">
<p className="text-xs text-gray-500">
Choose who receives new registration, payment, and daily summary emails for this event.
Leave empty to send them to the event creator{creator ? <> (<strong>{creator.name}</strong>, {creator.email})</> : null} instead.
</p>
{error && <div className="p-2 border rounded bg-red-50 text-red-700 text-xs">{error}</div>}
<NotifyRecipientsPicker selected={selected} onChange={persist} disabled={saving} />
</div>
);
}
// ─── steps ───────────────────────────────────────────────────────────────────
const STEPS = ["Basic Details", "Items & Pricing", "Sections", "Form", "Visibility", "Notifications", "Attachments"] as const;
type StepIdx = 0 | 1 | 2 | 3 | 4 | 5 | 6;
const PRICING_SUBSTEPS = ["Options", "Variants", "Early Birds"] as const;
type PricingSubstep = 0 | 1 | 2;
interface EventDraft {
title: string; description: string; startDate: string; endDate: string;
registrationDeadline: string; goLiveAt: string; price: string; picture: string;
redirectUrl: string; isActive: boolean; isHidden: boolean; requiresAuth: boolean;
}
const blankDraft = (): EventDraft => ({
title: "", description: "", startDate: "", endDate: "",
registrationDeadline: "", goLiveAt: "", price: "", picture: "",
redirectUrl: "", isActive: true, isHidden: false, requiresAuth: true,
});
const blankOptions = (): OptionDraft[] => [
{ name: "Ticket", price: "", isMainTicket: true, stockLimit: "0", earlyBirdTiers: [], variants: [] }
];
// ─── EventModal ───────────────────────────────────────────────────────────────
interface EventModalProps {
mode: "create" | "edit";
event?: any;
onClose: () => void;
onSuccess: () => void;
}
function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const { token, user } = useAuth();
const isAdmin = user?.role === "admin";
const [step, setStep] = useState<StepIdx>(0);
const [pricingSubstep, setPricingSubstep] = useState<PricingSubstep>(0);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// ── event draft ──
const [draft, setDraft] = useState<EventDraft>(() => ev ? {
title: ev.title || "", description: ev.description || "",
startDate: toLocalDT(ev.startDate), endDate: toLocalDT(ev.endDate),
registrationDeadline: toLocalDT(ev.registrationDeadline), goLiveAt: toLocalDT(ev.goLiveAt),
price: String(ev.price ?? ""), picture: ev.picture || "", redirectUrl: ev.redirectUrl || "",
isActive: ev.isActive !== false, isHidden: !!ev.isHidden, requiresAuth: ev.requiresAuth !== false,
} : blankDraft());
// ── options (with per-variant tiers) ──
const [options, setOptions] = useState<OptionDraft[]>(() => {
if (!ev?.eventOptions?.length) return blankOptions();
return ev.eventOptions.map((o: any) => {
const allTiers: any[] = o.earlyBirdTiers || [];
return {
id: o.id,
name: o.name,
price: String(o.price ?? ""),
isMainTicket: !!o.isMainTicket,
stockLimit: String(o.stockLimit ?? "0"),
// Option-level tiers only (no variantId)
earlyBirdTiers: allTiers
.filter((t: any) => !t.variantId)
.map((t: any) => ({ id: t.id, deadline: toLocalDT(t.deadline), price: String(t.price ?? ""), stockLimit: String(t.stockLimit ?? "0") })),
variants: (o.variants || []).map((v: any) => ({
id: v.id, name: v.name,
price: v.price !== null && v.price !== undefined ? String(v.price) : "",
stockLimit: String(v.stockLimit ?? "0"),
// Variant-level tiers — filter by variantId
earlyBirdTiers: allTiers
.filter((t: any) => t.variantId === v.id)
.map((t: any) => ({ id: t.id, deadline: toLocalDT(t.deadline), price: String(t.price ?? ""), stockLimit: String(t.stockLimit ?? "0") })),
})),
};
});
});
// ── sections (staged for create mode) ──
const [sectionDrafts, setSectionDrafts] = useState<SectionDraft[]>([]);
// ── attachments (staged for create mode; uploaded after event is created) ──
const [stagedFiles, setStagedFiles] = useState<File[]>([]);
// ── notify recipients (staged for create mode; saved after event is created) ──
const [notifyRecipientDrafts, setNotifyRecipientDrafts] = useState<NotifyUser[]>([]);
// ── form ──
const [formDef, setFormDef] = useState<EventFormDef>({ isRequired: false, fields: [] });
const formLoadedRef = useRef(false);
useEffect(() => {
if (mode !== "edit" || !ev?.id || formLoadedRef.current) return;
apiFetch<any>(`/api/events/${ev.id}`).then(full => {
const f = full?.form;
if (f) setFormDef({ isRequired: !!f.isRequired, fields: (f.fields || []).map((x: any) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, helpText: x.helpText || "" })) });
}).catch(() => {}).finally(() => { formLoadedRef.current = true; });
}, [mode, ev?.id]);
const upd = (patch: Partial<EventDraft>) => setDraft(d => ({ ...d, ...patch }));
// ── auto-fill first option's price from the event base price (create mode only, until the
// user directly edits that option's price — then it becomes independent) ──
const [mainPriceTouched, setMainPriceTouched] = useState(false);
useEffect(() => {
if (mode !== "create" || mainPriceTouched) return;
setOptions(prev => {
if (prev.length === 0 || prev[0].price === draft.price) return prev;
const next = prev.slice();
next[0] = { ...next[0], price: draft.price };
return next;
});
}, [draft.price, mode, mainPriceTouched]);
const handleOptionsChange = (next: OptionDraft[]) => {
if (mode === "create" && next[0] && options[0] && next[0].price !== options[0].price) {
setMainPriceTouched(true);
}
setOptions(next);
};
const isPriceInvalid = (p: string) => p.trim() === "" || isNaN(parseFloat(p)) || parseFloat(p) < 0;
// Closed (cashed-up) events reject every mutating edit endpoint server-side — show that
// upfront and disable Save rather than letting staff fill out the whole form only to have
// it rejected on submit.
const isClosed = mode === "edit" && ev?.cashupStatus === "closed";
// ── save helpers ──
const saveOptions = async (eventId: string) => {
if (mode === "edit") {
for (const opt of options) {
// Build flat tier array: option-level + all variant tiers
const allTiers: any[] = [
...opt.earlyBirdTiers
.filter(t => t.deadline && t.price !== "")
.map((t, i) => ({ id: t.id, deadline: new Date(t.deadline).toISOString(), price: parseFloat(t.price), stockLimit: parseInt(t.stockLimit || "0"), order: i, variantId: null })),
];
const variantsPayload = opt.variants.map((v, vi) => ({
id: v.id, name: v.name,
price: v.price !== "" ? parseFloat(v.price) : null,
stockLimit: parseInt(v.stockLimit || "0"), order: vi,
}));
if (opt.id) {
// Update option + variants first to get/confirm variant IDs
const updated: any = await apiFetch(`/api/events/options/${opt.id}`, {
method: "PUT", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), variants: variantsPayload }
});
// Build variant tiers with confirmed IDs from response
const serverVariants: any[] = updated?.variants || [];
for (let vi = 0; vi < opt.variants.length; vi++) {
const v = opt.variants[vi];
const serverVariant = serverVariants[vi] || serverVariants.find((sv: any) => sv.name === v.name);
if (!serverVariant) continue;
for (let ti = 0; ti < v.earlyBirdTiers.length; ti++) {
const t = v.earlyBirdTiers[ti];
if (!t.deadline || t.price === "") continue;
allTiers.push({ deadline: new Date(t.deadline).toISOString(), price: parseFloat(t.price), stockLimit: parseInt(t.stockLimit || "0"), order: ti, variantId: serverVariant.id });
}
}
if (allTiers.length > 0 || opt.earlyBirdTiers.length > 0 || opt.variants.some(v => v.earlyBirdTiers.length > 0)) {
await apiFetch(`/api/events/options/${opt.id}`, {
method: "PUT", authToken: token || undefined,
body: { earlyBirdTiers: allTiers }
});
}
// Handle form save (edit only)
await apiFetch(`/api/events/${eventId}`, {
method: "PUT", authToken: token || undefined,
body: { form: { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null })) } }
});
} else {
// New option added during edit
const created: any = await apiFetch(`/api/events/${eventId}/options`, {
method: "POST", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0") }
});
if (created?.id) {
const updated: any = await apiFetch(`/api/events/options/${created.id}`, {
method: "PUT", authToken: token || undefined,
body: { variants: variantsPayload }
});
const serverVariants: any[] = updated?.variants || [];
for (let vi = 0; vi < opt.variants.length; vi++) {
const v = opt.variants[vi];
const sv = serverVariants[vi] || serverVariants.find((x: any) => x.name === v.name);
if (!sv) continue;
for (let ti = 0; ti < v.earlyBirdTiers.length; ti++) {
const t = v.earlyBirdTiers[ti];
if (!t.deadline || t.price === "") continue;
allTiers.push({ deadline: new Date(t.deadline).toISOString(), price: parseFloat(t.price), stockLimit: parseInt(t.stockLimit || "0"), order: ti, variantId: sv.id });
}
}
if (allTiers.length > 0) {
await apiFetch(`/api/events/options/${created.id}`, {
method: "PUT", authToken: token || undefined, body: { earlyBirdTiers: allTiers }
});
}
}
}
}
} else {
// Create mode — backend auto-creates a "Main Ticket" option; replace/create from draft
let createdEvent: any = null;
try { createdEvent = await apiFetch(`/api/events/${eventId}`); } catch {}
const existingMain = (createdEvent?.eventOptions || []).find((o: any) => !!o.isMainTicket);
const uiMainIdx = options.findIndex(o => o.isMainTicket);
for (let i = 0; i < options.length; i++) {
const opt = options[i];
const variantsPayload = opt.variants.map((v, vi) => ({
name: v.name, price: v.price !== "" ? parseFloat(v.price) : null,
stockLimit: parseInt(v.stockLimit || "0"), order: vi,
}));
let optId: string;
if (i === uiMainIdx && existingMain) {
// Update the auto-created main ticket
await apiFetch(`/api/events/options/${existingMain.id}`, {
method: "PUT", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: true, stockLimit: parseInt(opt.stockLimit || "0") }
});
optId = existingMain.id;
} else {
const created: any = await apiFetch(`/api/events/${eventId}/options`, {
method: "POST", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0") }
});
optId = created?.id;
}
if (!optId) continue;
// Save variants, then build tiers with resolved IDs
const allTiers: any[] = opt.earlyBirdTiers
.filter(t => t.deadline && t.price !== "")
.map((t, ti) => ({ deadline: new Date(t.deadline).toISOString(), price: parseFloat(t.price), stockLimit: parseInt(t.stockLimit || "0"), order: ti, variantId: null }));
if (variantsPayload.length > 0 || opt.variants.some(v => v.earlyBirdTiers.length > 0)) {
const updated: any = await apiFetch(`/api/events/options/${optId}`, {
method: "PUT", authToken: token || undefined, body: { variants: variantsPayload }
});
const serverVariants: any[] = updated?.variants || [];
for (let vi = 0; vi < opt.variants.length; vi++) {
const v = opt.variants[vi];
const sv = serverVariants[vi] || serverVariants.find((x: any) => x.name === v.name);
if (!sv) continue;
for (let ti = 0; ti < v.earlyBirdTiers.length; ti++) {
const t = v.earlyBirdTiers[ti];
if (!t.deadline || t.price === "") continue;
allTiers.push({ deadline: new Date(t.deadline).toISOString(), price: parseFloat(t.price), stockLimit: parseInt(t.stockLimit || "0"), order: ti, variantId: sv.id });
}
}
}
if (allTiers.length > 0) {
await apiFetch(`/api/events/options/${optId}`, {
method: "PUT", authToken: token || undefined, body: { earlyBirdTiers: allTiers }
});
}
}
}
};
const saveStagedSections = async (eventId: string, createdOptions: any[]) => {
for (const sec of sectionDrafts) {
if (!sec.name.trim()) continue;
const optionIds = sec.optionIndices
.map(idx => createdOptions[idx]?.id)
.filter(Boolean);
try {
await apiFetch("/api/sections", {
method: "POST", authToken: token || undefined,
body: { eventId, name: sec.name.trim(), allowedOptionIds: optionIds }
});
} catch {}
}
};
const handleSave = async () => {
if (!draft.title.trim()) { setError("Title is required"); setStep(0); return; }
if (!draft.startDate || !draft.endDate) { setError("Start and end dates are required"); setStep(0); return; }
if (mode === "create") {
if (isPriceInvalid(draft.price)) { setError("Base price is required (enter 0 for a free event)"); setStep(0); return; }
if (options.some(o => isPriceInvalid(o.price))) { setError("Every option needs a price (enter 0 for a free option)"); setStep(1); setPricingSubstep(0); return; }
}
setSaving(true); setError(null);
try {
const body: any = {
title: draft.title.trim(), description: draft.description || undefined,
startDate: new Date(draft.startDate).toISOString(), endDate: new Date(draft.endDate).toISOString(),
registrationDeadline: draft.registrationDeadline ? new Date(draft.registrationDeadline).toISOString() : undefined,
goLiveAt: draft.goLiveAt ? new Date(draft.goLiveAt).toISOString() : undefined,
price: draft.price ? parseFloat(draft.price) : 0,
picture: draft.picture || undefined, isHidden: draft.isHidden, requiresAuth: draft.requiresAuth,
redirectUrl: draft.redirectUrl?.trim().replace(/\s+/g, "-") || undefined,
};
if (mode === "edit") {
body.isActive = draft.isActive;
await apiFetch(`/api/events/${ev.id}`, { method: "PUT", authToken: token || undefined, body });
await saveOptions(ev.id);
// Form is saved inside saveOptions for edit mode; avoid double call
} else {
if (formDef.isRequired || formDef.fields.length > 0) {
body.form = { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null })) };
}
const created: any = await apiFetch("/api/events", { method: "POST", authToken: token || undefined, body });
if (created?.id) {
await saveOptions(created.id);
// Fetch event to get final option IDs for sections
const fullEvent: any = await apiFetch(`/api/events/${created.id}`).catch(() => null);
const createdOptions = fullEvent?.eventOptions || [];
if (sectionDrafts.length > 0) await saveStagedSections(created.id, createdOptions);
// Upload staged attachments
for (const file of stagedFiles) {
const fd = new FormData();
fd.append("file", file);
await apiFetch(`/api/events/${created.id}/attachments`, { method: "POST", body: fd, authToken: token || undefined });
}
// Save staged notify recipients
if (notifyRecipientDrafts.length > 0) {
await apiFetch(`/api/events/${created.id}/notify-recipients`, {
method: "PUT", authToken: token || undefined,
body: { userIds: notifyRecipientDrafts.map(u => u.id) },
}).catch(() => {});
}
}
}
onSuccess(); onClose();
} catch (e: any) {
setError(e?.message || "Failed to save event");
} finally {
setSaving(false);
}
};
// ── footer navigation ──
const handleBack = () => {
setError(null);
if (step === 1 && pricingSubstep > 0) {
setPricingSubstep((pricingSubstep - 1) as PricingSubstep);
} else {
setStep((step - 1) as StepIdx);
if (step - 1 === 1) setPricingSubstep(2); // entering from right → land on last substep
}
};
const handleNext = () => {
setError(null);
if (step === 1 && pricingSubstep < 2) {
setPricingSubstep((pricingSubstep + 1) as PricingSubstep);
} else {
setStep((step + 1) as StepIdx);
if (step + 1 === 1) setPricingSubstep(0); // entering from left → land on first substep
}
};
const isFirstStep = step === 0;
const isLastStep = step === STEPS.length - 1;
const isOnLastSubstep = step !== 1 || pricingSubstep === 2;
// Basic Details step requires title, start/end dates, and a base price before moving on
// (mirrors handleSave's own checks). Base price is only compulsory when creating a new event.
const basicDetailsIncomplete = step === 0 && (!draft.title.trim() || !draft.startDate || !draft.endDate || (mode === "create" && isPriceInvalid(draft.price)));
// Items & Pricing: every option needs a valid price before leaving the step (checked across
// all pricing substeps so switching to Variants/Early Birds can't be used to skip the gate).
const optionsIncomplete = mode === "create" && step === 1 && options.some(o => isPriceInvalid(o.price));
const nextDisabled = basicDetailsIncomplete || optionsIncomplete;
// ── render ──
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-auto py-6 px-4">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-2xl">
{/* Header */}
<div className="flex items-center justify-between px-5 pt-5 pb-3 border-b">
<h2 className="text-lg font-semibold">{mode === "create" ? "New Event" : "Edit Event"}</h2>
<button type="button" className="text-gray-400 hover:text-gray-700 text-xl leading-none" onClick={onClose}>×</button>
</div>
{/* Step bar */}
<div className="flex items-center gap-1 px-5 py-3 border-b overflow-x-auto">
{STEPS.map((s, i) => {
const clickable = mode === "edit";
return (
<button key={i} type="button"
onClick={clickable ? () => { setStep(i as StepIdx); if (i === 1) setPricingSubstep(0); } : undefined}
className={`px-3 py-1.5 text-xs rounded border whitespace-nowrap transition-colors ${
step === i ? "bg-brand-600 text-white border-brand-600"
: clickable ? "bg-white text-gray-700 border-gray-200 hover:bg-gray-50 cursor-pointer"
: "bg-white text-gray-400 border-gray-100 cursor-default"
}`}
>{s}</button>
);
})}
</div>
{/* Pricing substep bar */}
{step === 1 && (
<div className="flex items-center gap-1 px-5 py-2 border-b bg-gray-50 overflow-x-auto">
{PRICING_SUBSTEPS.map((s, i) => (
<button key={i} type="button"
onClick={() => setPricingSubstep(i as PricingSubstep)}
className={`px-3 py-1 text-xs rounded border whitespace-nowrap transition-colors ${
pricingSubstep === i ? "bg-brand-100 text-brand-700 border-brand-300 font-medium" : "bg-white text-gray-600 border-gray-200 hover:bg-gray-50"
}`}
>{s}</button>
))}
</div>
)}
<div className="p-5 space-y-4 max-h-[65vh] overflow-y-auto">
{isClosed && (
<div className="p-3 border rounded bg-rose-50 text-rose-800 text-sm">
This event is closed it can't be edited until it's reopened from the cashup page (admin only).
</div>
)}
{error && <div className="p-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
{/* ── Step 0: Basic Details ── */}
{step === 0 && (
<div className="space-y-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Title <span className="text-red-500">*</span></label>
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.title} onChange={e => upd({ title: e.target.value })} placeholder="Event title" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Description</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={3} value={draft.description} onChange={e => upd({ description: e.target.value })} />
</div>
<div className="grid sm:grid-cols-2 gap-3">
<DTInput label="Start" required value={draft.startDate} onChange={v => upd({ startDate: v })} />
<DTInput label="End" required value={draft.endDate} onChange={v => upd({ endDate: v })} />
<DTInput label="Registration Deadline (optional)" value={draft.registrationDeadline} onChange={v => upd({ registrationDeadline: v })} />
<DTInput label="Go Live At (optional)" value={draft.goLiveAt} onChange={v => upd({ goLiveAt: v })} hint="Leave blank to show immediately" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Base Price (R) {mode === "create" && <span className="text-red-500">*</span>}</label>
<input type="number" step="1" min="0" required={mode === "create"} className="w-full border rounded px-3 py-2 text-sm" value={draft.price} onChange={e => upd({ price: e.target.value })} placeholder="0" />
{mode === "create" && <p className="text-[10px] text-gray-400 mt-0.5">Auto-fills the first ticket option below enter 0 for a free event.</p>}
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Picture</label>
{draft.picture && <img src={resolveToApiOrigin(draft.picture) || undefined} alt="" className="h-20 w-20 object-cover rounded border mb-2" />}
<div className="flex items-center gap-2">
<input className="flex-1 border rounded px-3 py-2 text-sm" placeholder="/uploads/... or https://" value={draft.picture} onChange={e => upd({ picture: e.target.value })} />
<UploadImageButton onUploaded={url => upd({ picture: url })} label="Upload" />
</div>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">URL Alias (optional)</label>
<input className="w-full border rounded px-3 py-2 text-sm" placeholder="e.g. camp-2025, movie-night" value={draft.redirectUrl} onChange={e => upd({ redirectUrl: e.target.value })} />
</div>
</div>
)}
{/* ── Step 1: Items & Pricing (3 substeps) ── */}
{step === 1 && pricingSubstep === 0 && (
<div>
<p className="text-xs text-gray-500 mb-3">Define your ticket types or purchasable items. Variants and early-bird pricing are configured in the next two steps.</p>
<OptionsEditor options={options} onChange={handleOptionsChange} required={mode === "create"} />
</div>
)}
{step === 1 && pricingSubstep === 1 && (
<div>
<p className="text-xs text-gray-500 mb-3">Add variants per option (e.g. Adult / Child, or t-shirt sizes). Leave blank if an option has only one price.</p>
<VariantsEditor options={options} onChange={setOptions} />
</div>
)}
{step === 1 && pricingSubstep === 2 && (
<div>
<p className="text-xs text-gray-500 mb-3">Set early-bird prices per variant (if variants exist) or per option. The cheapest applicable tier is used at registration time.</p>
<EarlyBirdsEditor options={options} onChange={setOptions} />
</div>
)}
{/* ── Step 2: Sections ── */}
{step === 2 && (
<div>
<p className="text-xs text-gray-500 mb-3">Group ticket options into named sections shown on the registration page (e.g. "Tickets", "Add-ons", "Accommodation").</p>
{mode === "edit" && ev?.id
? <SectionsManager eventId={ev.id} />
: <SectionsDraftEditor sections={sectionDrafts} options={options} onChange={setSectionDrafts} />
}
</div>
)}
{/* ── Step 3: Form ── */}
{step === 3 && (
<div>
<p className="text-xs text-gray-500 mb-3">Collect extra information from registrants (e.g. dietary requirements, t-shirt size, emergency contact).</p>
<FormBuilder value={formDef} onChange={setFormDef} />
</div>
)}
{/* ── Step 4: Visibility ── */}
{step === 4 && (
<div className="space-y-3">
<div className="border rounded-lg p-4 bg-gray-50 space-y-3">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Visibility & Access</div>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input type="checkbox" className="mt-0.5" checked={draft.isHidden} onChange={e => upd({ isHidden: e.target.checked })} />
<span><strong>Hidden</strong> not shown in the public event listing; still accessible via direct link</span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input type="checkbox" className="mt-0.5" checked={!draft.requiresAuth} onChange={e => upd({ requiresAuth: !e.target.checked })} />
<span><strong>Allow guest registration</strong> attendees can register without creating an account</span>
</label>
{mode === "edit" && (
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input type="checkbox" className="mt-0.5" checked={draft.isActive} onChange={e => upd({ isActive: e.target.checked })} />
<span><strong>Active</strong> when unchecked, the event is hidden everywhere including staff views</span>
</label>
)}
</div>
</div>
)}
{/* ── Step 5: Notifications ── */}
{step === 5 && (
<div>
{mode === "edit" && ev?.id ? (
<NotifyRecipientsManager eventId={ev.id} creator={ev.createdBy || null} initialRecipients={ev.notifyRecipients} />
) : (
<div className="space-y-3">
<p className="text-xs text-gray-500">
Choose who receives new registration, payment, and daily summary emails for this event.
Leave empty to send them to you (the event creator) instead.
</p>
<NotifyRecipientsPicker selected={notifyRecipientDrafts} onChange={setNotifyRecipientDrafts} />
</div>
)}
</div>
)}
{/* ── Step 6: Attachments ── */}
{step === 6 && (
<div>
<p className="text-xs text-gray-500 mb-3">Upload documents for attendees to download (e.g. waiver forms, info sheets, maps, schedules).</p>
{mode === "edit" && ev?.id
? <AttachmentsManager eventId={ev.id} />
: (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Files to attach</span>
<label className="cursor-pointer text-xs px-2 py-1 rounded border bg-white hover:bg-gray-50">
<input
type="file"
className="hidden"
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.zip"
onChange={e => {
const file = e.target.files?.[0];
if (file) setStagedFiles(prev => [...prev, file]);
e.target.value = "";
}}
/>
Add file
</label>
</div>
{stagedFiles.length === 0
? <p className="text-xs text-gray-400">No files added. Files will be uploaded when the event is created.</p>
: (
<ul className="space-y-1">
{stagedFiles.map((f, i) => (
<li key={i} className="flex items-center justify-between text-xs">
<span className="truncate max-w-xs text-gray-700">{f.name}</span>
<button
type="button"
className="text-red-600 hover:underline ml-2"
onClick={() => setStagedFiles(prev => prev.filter((_, j) => j !== i))}
>Remove</button>
</li>
))}
</ul>
)
}
</div>
)
}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-5 py-3 border-t bg-gray-50 rounded-b-xl">
<button type="button" disabled={isFirstStep} onClick={handleBack}
className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50 disabled:opacity-40"> Back</button>
<div className="flex items-center gap-2">
{mode === "create" ? (
isLastStep && isOnLastSubstep ? (
<button type="button" disabled={saving} onClick={handleSave}
className="px-5 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">
{saving ? "Creating…" : "Create Event"}
</button>
) : (
<button type="button" disabled={nextDisabled} onClick={handleNext}
title={basicDetailsIncomplete ? "Fill in title, start, end date, and base price to continue" : optionsIncomplete ? "Every option needs a price before continuing" : undefined}
className="px-4 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 font-medium disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-brand-600">Next </button>
)
) : (
<>
{!(isLastStep && isOnLastSubstep) && (
<button type="button" onClick={handleNext}
className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50">Next </button>
)}
<button type="button" disabled={saving || isClosed} onClick={handleSave}
title={isClosed ? "This event is closed — reopen it first to make changes" : undefined}
className="px-5 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">
{saving ? "Saving…" : "Save Changes"}
</button>
</>
)}
</div>
</div>
</div>
</div>
);
}
// ─── event card ───────────────────────────────────────────────────────────────
function EventCard({ ev, onEdit }: { ev: any; onEdit: () => void }) {
const isPast = ev.endDate && new Date(ev.endDate) < new Date();
const isInactive = ev.isActive === false;
const isClosed = ev.cashupStatus === "closed";
return (
<li className="border rounded-lg p-3 bg-white hover:bg-brand-50/40 cursor-pointer transition-colors flex items-start justify-between gap-3" onClick={onEdit}>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm">{ev.title}</span>
{isInactive && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">Inactive</span>}
{isPast && !isInactive && <span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-50 text-amber-700">Past</span>}
{ev.isHidden && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">Hidden</span>}
{isClosed && <span className="text-[10px] px-1.5 py-0.5 rounded bg-rose-50 text-rose-700">Closed</span>}
</div>
<div className="text-xs text-gray-500 mt-0.5">
{ev.startDate ? new Date(ev.startDate).toLocaleDateString() : ""}{ev.endDate ? ` ${new Date(ev.endDate).toLocaleDateString()}` : ""}
{ev.price != null ? <span className="ml-2">R{Number(ev.price).toFixed(0)}</span> : ""}
</div>
</div>
<span className="text-xs text-brand-600 shrink-0 mt-0.5">Edit </span>
</li>
);
}
// ─── main page ────────────────────────────────────────────────────────────────
export default function ManageEventsPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
const canView = useMemo(() => {
const role = user?.role;
return role === "admin" || role === "supervisor";
}, [user]);
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
const [events, setEvents] = useState<any[]>([]);
const [loadingEvents, setLoadingEvents] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const loadEvents = async () => {
if (!token) return;
setLoadingEvents(true);
try {
const evs = await apiFetch<any[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
setEvents(Array.isArray(evs) ? evs.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()) : []);
} catch (e: any) {
setError(e?.message || "Failed to load events");
} finally {
setLoadingEvents(false);
}
};
useEffect(() => { loadEvents(); }, [user, token]);
const [modalMode, setModalMode] = useState<"create" | "edit" | null>(null);
const [editingEvent, setEditingEvent] = useState<any | null>(null);
const [includePast, setIncludePast] = useState(false);
const [showInactive, setShowInactive] = useState(false);
const [showClosed, setShowClosed] = useState(false);
const [search, setSearch] = useState("");
const isAdmin = user?.role === "admin";
const now = Date.now();
const filtered = useMemo(() => events.filter(ev => {
if (!showInactive && ev.isActive === false) return false;
if (!showClosed && ev.cashupStatus === "closed") return false;
if (!includePast && ev.endDate && new Date(ev.endDate).getTime() < now) return false;
if (search && !ev.title?.toLowerCase().includes(search.toLowerCase())) return false;
return true;
}), [events, includePast, showInactive, showClosed, search, now]);
const openCreate = () => { setEditingEvent(null); setModalMode("create"); };
const openEdit = async (ev: any) => {
try {
const full = await apiFetch<any>(`/api/events/${ev.id}`, { authToken: token || undefined });
setEditingEvent(full);
} catch { setEditingEvent(ev); }
setModalMode("edit");
};
return (
<div className="max-w-4xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-6 flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Calendar className="w-5 h-5 text-brand-600" />
</div>
<div>
<h1 className="text-2xl font-semibold text-gray-900">Events</h1>
<p className="text-sm text-gray-500 mt-0.5">Manage and create events</p>
</div>
</div>
<div className="flex items-center gap-2">
<button type="button" className="px-4 py-2 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 shadow-sm font-medium" onClick={openCreate}>+ Add Event</button>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={() => router.push("/dashboard")}>Back</button>
</div>
</div>
{!canView && <div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">You need supervisor or admin access.</div>}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
<div className="flex flex-wrap items-center gap-3 mb-4 text-sm">
<input className="border rounded px-3 py-1.5 text-sm w-48" placeholder="Search by title…" value={search} onChange={e => setSearch(e.target.value)} />
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={includePast} onChange={e => setIncludePast(e.target.checked)} /> Include past
</label>
<label className={`flex items-center gap-2 cursor-pointer ${!isAdmin ? "opacity-50 cursor-not-allowed" : ""}`}>
<input type="checkbox" disabled={!isAdmin} checked={showInactive && isAdmin} onChange={e => setShowInactive(e.target.checked)} /> Show inactive
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={showClosed} onChange={e => setShowClosed(e.target.checked)} /> Show closed
</label>
{loadingEvents && <span className="text-xs text-gray-500">Loading</span>}
</div>
<div className="border rounded-xl bg-white shadow-sm p-4">
{filtered.length === 0
? <p className="text-sm text-gray-500">{events.length === 0 ? 'No events yet. Click "+ Add Event" to create one.' : "No events match the current filters."}</p>
: <ul className="space-y-2">{filtered.map(ev => <EventCard key={ev.id} ev={ev} onEdit={() => openEdit(ev)} />)}</ul>
}
</div>
{modalMode && (
<EventModal mode={modalMode} event={editingEvent}
onClose={() => { setModalMode(null); setEditingEvent(null); }}
onSuccess={loadEvents} />
)}
</div>
);
}