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>
1512 lines
76 KiB
TypeScript
1512 lines
76 KiB
TypeScript
"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>
|
||
);
|
||
} |