Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
"use client";
|
||||
|
||||
import React, { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
// Format a Date (or date-like input) to the value expected by <input type="datetime-local">
|
||||
// This returns local time (browser timezone) as YYYY-MM-DDTHH:mm
|
||||
function toLocalDateTimeInputValue(input: string | number | Date | null | undefined): string {
|
||||
if (input == null) 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}`;
|
||||
}
|
||||
|
||||
function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers: { deadline: string; price: number; order?: number }[]) => void }) {
|
||||
const [rows, setRows] = React.useState<{ id?: string; deadline: string; price: string; order?: number }[]>([]);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const tiers = Array.isArray(option?.earlyBirdTiers) ? option.earlyBirdTiers : [];
|
||||
const normalized = tiers
|
||||
.slice()
|
||||
.sort((a: any, b: any) => new Date(a.deadline).getTime() - new Date(b.deadline).getTime() || (a.order || 0) - (b.order || 0))
|
||||
.map((t: any, i: number) => ({ id: t.id, deadline: toLocalDateTimeInputValue(t.deadline), price: String(t.price ?? ''), order: typeof t.order === 'number' ? t.order : i }));
|
||||
setRows(normalized);
|
||||
}, [option?.id, option?.earlyBirdTiers]);
|
||||
|
||||
const addRow = () => {
|
||||
setRows((r) => [...r, { deadline: '', price: '', order: (r.length || 0) }]);
|
||||
};
|
||||
const removeRow = (idx: number) => {
|
||||
const copy = rows.slice();
|
||||
copy.splice(idx, 1);
|
||||
setRows(copy);
|
||||
};
|
||||
const updateRow = (idx: number, patch: Partial<{ deadline: string; price: string; order?: number }>) => {
|
||||
const copy = rows.slice();
|
||||
copy[idx] = { ...copy[idx], ...patch } as any;
|
||||
setRows(copy);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const tiers = rows
|
||||
.filter((r) => !!r.deadline && String(r.price).trim() !== '')
|
||||
.map((r, i) => ({ deadline: new Date(r.deadline).toISOString(), price: parseFloat(r.price), order: typeof r.order === 'number' ? r.order : i }))
|
||||
.filter((t) => t.price >= 0 && !isNaN(new Date(t.deadline).getTime()));
|
||||
onSave(tiers);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3 border-t pt-3">
|
||||
<button type="button" className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={() => setOpen(!open)}>
|
||||
{open ? 'Hide early-bird tiers' : 'Manage early-bird tiers'}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="mt-2 bg-gray-50 border rounded p-2">
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-xs text-gray-600 mb-2">No tiers yet. Add deadlines and prices for early-bird discounts.</div>
|
||||
) : (
|
||||
<ul className="space-y-2 mb-2">
|
||||
{rows.map((row, idx) => (
|
||||
<li key={idx} className="bg-white border rounded p-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input className="border rounded px-2 py-1 text-xs" type="datetime-local" value={row.deadline} onChange={(e) => updateRow(idx, { deadline: e.target.value })} />
|
||||
<input className="border rounded px-2 py-1 text-xs w-24" type="number" step="0.01" value={row.price} onChange={(e) => updateRow(idx, { price: e.target.value })} placeholder="Price" />
|
||||
<input className="border rounded px-2 py-1 text-xs w-16" type="number" step="1" value={typeof row.order === 'number' ? String(row.order) : ''} onChange={(e) => updateRow(idx, { order: parseInt(e.target.value || '0', 10) })} placeholder="#" />
|
||||
<button type="button" className="text-xs px-2 py-1 rounded bg-red-600 text-white hover:bg-red-700" onClick={() => removeRow(idx)}>Remove</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" className="text-xs px-2 py-1 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={addRow}>Add tier</button>
|
||||
<button type="button" className="text-xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-60" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save tiers'}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventOptionsContent() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
|
||||
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 [selectedEventId, setSelectedEventId] = useState<string>("");
|
||||
const [options, setOptions] = useState<any[]>([]);
|
||||
const [loadingEv, setLoadingEv] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
|
||||
const loadEvents = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setLoadingEv(true);
|
||||
const evs = await apiFetch<any[]>("/api/events/all", { authToken: token });
|
||||
setEvents(evs || []);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingEv(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { loadEvents(); }, [token]);
|
||||
|
||||
// Preselect event from query (?eventId=)
|
||||
useEffect(() => {
|
||||
const q = search?.get("eventId");
|
||||
if (!q) return;
|
||||
// if events already loaded, ensure it exists then set; otherwise, set directly and let options hook handle
|
||||
setSelectedEventId(q);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
const ev = events.find(e => e.id === selectedEventId);
|
||||
if (ev) {
|
||||
setOptions(ev.options || ev.eventOptions || []);
|
||||
} else {
|
||||
setOptions([]);
|
||||
}
|
||||
}, [selectedEventId, events]);
|
||||
|
||||
const [newOpt, setNewOpt] = useState({ name: "", price: "", isMainTicket: false });
|
||||
|
||||
const createOption = async () => {
|
||||
if (!token || !selectedEventId) return;
|
||||
setError(null); setInfo(null);
|
||||
if (!newOpt.name) { setError("Option name is required"); return; }
|
||||
const priceNum = parseFloat(newOpt.price || "0");
|
||||
try {
|
||||
await apiFetch(`/api/events/${encodeURIComponent(selectedEventId)}/options`, {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: { name: newOpt.name, price: priceNum || 0, isMainTicket: !!newOpt.isMainTicket }
|
||||
});
|
||||
setNewOpt({ name: "", price: "", isMainTicket: false });
|
||||
setInfo("Option created");
|
||||
await loadEvents();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to create option");
|
||||
}
|
||||
};
|
||||
|
||||
const updateOption = async (opt: any, patch: any) => {
|
||||
if (!token) return;
|
||||
setError(null); setInfo(null);
|
||||
try {
|
||||
await apiFetch(`/api/events/options/${encodeURIComponent(opt.id)}`, {
|
||||
method: "PUT",
|
||||
authToken: token,
|
||||
body: patch
|
||||
});
|
||||
setInfo("Option updated");
|
||||
await loadEvents();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to update option");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteOption = async (opt: any) => {
|
||||
if (!token) return;
|
||||
setError(null); setInfo(null);
|
||||
try {
|
||||
await apiFetch(`/api/events/options/${encodeURIComponent(opt.id)}`, {
|
||||
method: "DELETE",
|
||||
authToken: token,
|
||||
});
|
||||
setInfo("Option deleted");
|
||||
await loadEvents();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to delete option");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto w-full p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-semibold">Event options</h1>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard/supervisor/events')}>Back</button>
|
||||
</div>
|
||||
|
||||
{!canView && (
|
||||
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
|
||||
You need supervisor or admin access to use this page.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
|
||||
{info && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{info}</div>}
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
|
||||
<div className="text-lg font-semibold mb-3">Select event</div>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={selectedEventId} onChange={e => setSelectedEventId(e.target.value)}>
|
||||
<option value="">Select an event…</option>
|
||||
{events.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedEventId && (
|
||||
<div className="mt-3 text-xs text-gray-600">
|
||||
{(() => {
|
||||
const ev = events.find(e => e.id === selectedEventId);
|
||||
if (!ev) return null;
|
||||
return <>
|
||||
<div>{new Date(ev.startDate).toLocaleString()} - {new Date(ev.endDate).toLocaleString()}</div>
|
||||
</>;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedEventId && (
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="text-lg font-semibold mb-3">Options</div>
|
||||
{options.length === 0 ? (
|
||||
<div className="text-sm text-gray-500">No options yet.</div>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{options.map(opt => (
|
||||
<li key={opt.id} className="border rounded p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-sm">{opt.name}</div>
|
||||
<div className="text-xs text-gray-500">Price: R {(opt.price || 0).toFixed(2)} {opt.isMainTicket ? "• Main ticket" : ""}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input className="w-36 border rounded px-2 py-1 text-sm" defaultValue={opt.name} onBlur={e => { const v = e.target.value.trim(); if (v && v !== opt.name) updateOption(opt, { name: v }); }} />
|
||||
<input className="w-28 border rounded px-2 py-1 text-sm" type="number" step="0.01" defaultValue={(opt.price || 0)} onBlur={e => { const v = parseFloat(e.target.value || '0'); if (!isNaN(v) && v !== opt.price) updateOption(opt, { price: v }); }} />
|
||||
<label className="text-xs flex items-center gap-1"><input type="checkbox" defaultChecked={!!opt.isMainTicket} onChange={e => updateOption(opt, { isMainTicket: e.target.checked })} /> Main</label>
|
||||
{user?.role === 'admin' && (
|
||||
<button onClick={() => deleteOption(opt)} className="text-xs px-2 py-1 rounded bg-red-600 text-white hover:bg-red-700">
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Early Bird Tiers Editor */}
|
||||
<EarlyBirdTiersEditor
|
||||
option={opt}
|
||||
onSave={(tiers) => updateOption(opt, { earlyBirdTiers: tiers })}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="text-lg font-semibold mb-3">Create new option</div>
|
||||
<div className="grid gap-2">
|
||||
<input className="border rounded px-3 py-2 text-sm" placeholder="Name" value={newOpt.name} onChange={e => setNewOpt({ ...newOpt, name: e.target.value })} />
|
||||
<input className="border rounded px-3 py-2 text-sm" placeholder="Price" type="number" step="0.01" value={newOpt.price} onChange={e => setNewOpt({ ...newOpt, price: e.target.value })} />
|
||||
<label className="text-sm flex items-center gap-2"><input type="checkbox" checked={newOpt.isMainTicket} onChange={e => setNewOpt({ ...newOpt, isMainTicket: e.target.checked })} /> Main ticket</label>
|
||||
<button onClick={createOption} className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm">Create option</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EventOptionsPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-6">Loading...</div>}>
|
||||
<EventOptionsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user